@anthusai/papyrus 5.50.0 → 5.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,3 +4,4 @@ anthus-markus==0.7.0
4
4
  Pillow>=11.3,<12
5
5
  awscrt>=0.23,<1
6
6
  humanus>=0.33,<0.34
7
+ pypdf>=6.12.1,<7
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { pythonLambdaCode } from "../shared/python-bundle.js";
8
8
  const dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const projectRoot = path.resolve(dirname, "../../..");
10
- export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1", "humanus>=0.33,<0.34"];
10
+ export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1", "humanus>=0.33,<0.34", "pypdf>=6.12.1,<7"];
11
11
  function installCheckoutRequirements(outputDir) {
12
12
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
13
13
  execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
@@ -10,7 +10,7 @@ import { pythonLambdaCode, type SiteBundleFile } from "../shared/python-bundle";
10
10
  const dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const projectRoot = path.resolve(dirname, "../../..");
12
12
 
13
- export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1", "humanus>=0.33,<0.34"];
13
+ export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1", "humanus>=0.33,<0.34", "pypdf>=6.12.1,<7"];
14
14
 
15
15
  function installCheckoutRequirements(outputDir: string): void {
16
16
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
@@ -0,0 +1,20 @@
1
+ import { fetchSourceIcon } from "../../../../lib/source-icon";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ /** GET ?host=<source host> -> the source's own icon, or 404 so the newsroom shows its letter mark. Cached for a week. */
7
+ export async function GET(request: Request) {
8
+ const host = (new URL(request.url).searchParams.get("host") ?? "").toLowerCase();
9
+ const icon = host ? await fetchSourceIcon(host) : null;
10
+ if (!icon) return new Response(null, { status: 404, headers: { "cache-control": "public, max-age=86400" } });
11
+ return new Response(icon.body, {
12
+ headers: {
13
+ "cache-control": "public, max-age=604800",
14
+ "content-type": icon.contentType,
15
+ // An SVG icon is shown only through <img>, but never let it run as a document on our origin.
16
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
17
+ "x-content-type-options": "nosniff",
18
+ },
19
+ });
20
+ }
@@ -41,6 +41,8 @@ export default async function NewsDeskNestedSectionPage({ params, searchParams }
41
41
  if (section === "users") redirect(toPublicNewsroomPath("/newsroom/administration/users"));
42
42
  if (section === "doctrine") redirect(toPublicNewsroomPath("/newsroom/administration/policies"));
43
43
  if (section === "administration" && selection[0] === "analysis") redirect(toPublicNewsroomPath("/newsroom/assignments"));
44
+ // A Notification Center deep link (`/needs-me/<item id>`): the console home, which opens that item from the address.
45
+ if (section === "needs-me") return <NewsDeskPage searchParams={searchParams} />;
44
46
  if (!NEWS_DESK_ROUTE_SECTIONS.has(section as NewsDeskTab)) notFound();
45
47
  return <NewsDeskPage section={section} selectionPath={selection} searchParams={searchParams} />;
46
48
  }
@@ -43,6 +43,7 @@ const NEWS_DESK_ROUTE_SECTIONS = new Set<NewsDeskTab>([
43
43
  export default async function NewsDeskSectionPage({ params, searchParams }: NewsDeskSectionPageProps) {
44
44
  const { section } = await params;
45
45
  if (section === "work") redirect(toPublicNewsroomPath(`/newsroom/${contentItemBoardPathSegment()}`));
46
+ if (section === "needs-me") redirect(toPublicNewsroomPath("/newsroom"));
46
47
  if (section === "users") redirect(toPublicNewsroomPath("/newsroom/administration/users"));
47
48
  if (section === "doctrine") redirect(toPublicNewsroomPath("/newsroom/administration/policies"));
48
49
  if (section === contentItemBoardPathSegment()) return <NewsroomBoardView subject="contentItem" />;
@@ -0,0 +1,412 @@
1
+ import { useMemo, type ReactNode } from "react";
2
+
3
+ /** Renders Markdown as React elements (headings, lists, tables, code, links), never as HTML, so stored text cannot inject markup.
4
+ * Links open in a new tab and only http(s) and mailto destinations become links. */
5
+ export function MarkdownContext({ className, text }: { className?: string; text: string }) {
6
+ const blocks = useMemo(() => parseMarkdownBlocks(normalizeMarkdownForRendering(text)), [text]);
7
+ return (
8
+ <div className={className ? `news-desk-markdown-context ${className}` : "news-desk-markdown-context"}>
9
+ {blocks.map((block, index) => renderMarkdownBlock(block, index))}
10
+ </div>
11
+ );
12
+ }
13
+
14
+ type MarkdownBlock =
15
+ | { type: "code"; text: string }
16
+ | { type: "heading"; depth: number; text: string }
17
+ | { type: "list"; items: string[]; ordered: boolean }
18
+ | { type: "table"; headers: string[]; rows: string[][] }
19
+ | { type: "paragraph"; text: string };
20
+
21
+ function normalizeMarkdownForRendering(text: string): string {
22
+ const trimmed = text.trim();
23
+ const fencedMarkdown = /^```(?:markdown|md|mdx)?\s*\n([\s\S]*?)\n```$/i.exec(trimmed);
24
+ const unwrapped = fencedMarkdown ? (fencedMarkdown[1] ?? "") : text;
25
+ return normalizeMultilineMarkdownLinks(unwrapped);
26
+ }
27
+
28
+ function parseMarkdownBlocks(text: string): MarkdownBlock[] {
29
+ const blocks: MarkdownBlock[] = [];
30
+ const lines = text.split(/\r?\n/);
31
+ let paragraph: string[] = [];
32
+ let list: string[] = [];
33
+ let listOrdered = false;
34
+ let code: string[] = [];
35
+ let inCode = false;
36
+ const flushParagraph = () => {
37
+ if (paragraph.length) {
38
+ blocks.push({ type: "paragraph", text: paragraph.join(" ").trim() });
39
+ paragraph = [];
40
+ }
41
+ };
42
+ const flushList = () => {
43
+ if (list.length) {
44
+ blocks.push({ type: "list", items: list, ordered: listOrdered });
45
+ list = [];
46
+ listOrdered = false;
47
+ }
48
+ };
49
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
50
+ const line = lines[lineIndex];
51
+ if (line.trim().startsWith("```")) {
52
+ if (inCode) {
53
+ blocks.push({ type: "code", text: code.join("\n") });
54
+ code = [];
55
+ inCode = false;
56
+ } else {
57
+ flushParagraph();
58
+ flushList();
59
+ inCode = true;
60
+ }
61
+ continue;
62
+ }
63
+ if (inCode) {
64
+ code.push(line);
65
+ continue;
66
+ }
67
+ const trimmed = line.trim();
68
+ if (!trimmed) {
69
+ flushParagraph();
70
+ flushList();
71
+ continue;
72
+ }
73
+ if (
74
+ lineIndex + 1 < lines.length
75
+ && looksLikeMarkdownTableHeaderLine(line)
76
+ && isMarkdownTableDividerLine(lines[lineIndex + 1] ?? "")
77
+ ) {
78
+ flushParagraph();
79
+ flushList();
80
+ const headerCells = splitMarkdownTableCells(line);
81
+ const dividerCells = splitMarkdownTableCells(lines[lineIndex + 1] ?? "");
82
+ const columnCount = Math.max(headerCells.length, dividerCells.length, 1);
83
+ const headers = normalizeMarkdownTableCells(headerCells, columnCount);
84
+ const rows: string[][] = [];
85
+ lineIndex += 2;
86
+ while (lineIndex < lines.length) {
87
+ const rowLine = lines[lineIndex] ?? "";
88
+ if (!looksLikeMarkdownTableRowLine(rowLine)) {
89
+ lineIndex -= 1;
90
+ break;
91
+ }
92
+ rows.push(normalizeMarkdownTableCells(splitMarkdownTableCells(rowLine), columnCount));
93
+ lineIndex += 1;
94
+ }
95
+ if (lineIndex >= lines.length) lineIndex -= 1;
96
+ blocks.push({ type: "table", headers, rows });
97
+ continue;
98
+ }
99
+ const heading = /^(#{1,4})\s+(.+)$/.exec(trimmed);
100
+ if (heading) {
101
+ flushParagraph();
102
+ flushList();
103
+ blocks.push({ type: "heading", depth: heading[1].length, text: heading[2].trim() });
104
+ continue;
105
+ }
106
+ const bullet = /^(?:([-*+])\s+|(\d+)[.)]\s+)(.+)$/.exec(trimmed);
107
+ if (bullet) {
108
+ flushParagraph();
109
+ const ordered = Boolean(bullet[2]);
110
+ if (list.length && listOrdered !== ordered) flushList();
111
+ listOrdered = ordered;
112
+ list.push((bullet[3] ?? "").trim());
113
+ continue;
114
+ }
115
+ flushList();
116
+ paragraph.push(trimmed);
117
+ }
118
+ flushParagraph();
119
+ flushList();
120
+ if (code.length) blocks.push({ type: "code", text: code.join("\n") });
121
+ return blocks;
122
+ }
123
+
124
+ function renderMarkdownBlock(block: MarkdownBlock, index: number): ReactNode {
125
+ const key = `${block.type}-${index}`;
126
+ if (block.type === "code") return <pre className="news-desk-analysis-command" key={key}>{block.text}</pre>;
127
+ if (block.type === "list") {
128
+ const ListTag = block.ordered ? "ol" : "ul";
129
+ return (
130
+ <ListTag key={key}>
131
+ {block.items.map((item, itemIndex) => <li key={`${key}-${itemIndex}`}>{renderInlineMarkdown(item)}</li>)}
132
+ </ListTag>
133
+ );
134
+ }
135
+ if (block.type === "heading") {
136
+ if (block.depth <= 1) return <h3 key={key}>{block.text}</h3>;
137
+ if (block.depth === 2) return <h4 key={key}>{block.text}</h4>;
138
+ return <h5 key={key}>{block.text}</h5>;
139
+ }
140
+ if (block.type === "table") {
141
+ return (
142
+ <table key={key}>
143
+ <thead>
144
+ <tr>
145
+ {block.headers.map((header, headerIndex) => (
146
+ <th key={`${key}-head-${headerIndex}`} scope="col">{renderInlineMarkdown(header)}</th>
147
+ ))}
148
+ </tr>
149
+ </thead>
150
+ {block.rows.length ? (
151
+ <tbody>
152
+ {block.rows.map((row, rowIndex) => (
153
+ <tr key={`${key}-row-${rowIndex}`}>
154
+ {row.map((cell, cellIndex) => (
155
+ <td key={`${key}-row-${rowIndex}-cell-${cellIndex}`}>{renderInlineMarkdown(cell)}</td>
156
+ ))}
157
+ </tr>
158
+ ))}
159
+ </tbody>
160
+ ) : null}
161
+ </table>
162
+ );
163
+ }
164
+ return <p key={key}>{renderInlineMarkdown(block.text)}</p>;
165
+ }
166
+
167
+ function renderInlineMarkdown(text: string): ReactNode[] {
168
+ const nodes: ReactNode[] = [];
169
+ let index = 0;
170
+ let plain = "";
171
+ const flushPlain = () => {
172
+ if (!plain) return;
173
+ nodes.push(plain);
174
+ plain = "";
175
+ };
176
+
177
+ while (index < text.length) {
178
+ const linkLike = parseMarkdownLinkLikeAt(text, index);
179
+ if (linkLike) {
180
+ flushPlain();
181
+ if (!linkLike.image) {
182
+ const href = normalizeInlineMarkdownHref(linkLike.hrefRaw);
183
+ const label = collapseMarkdownLinkLabel(linkLike.label);
184
+ if (href) {
185
+ nodes.push(
186
+ <a href={href} key={`${index}-link`} rel="noopener noreferrer" target="_blank">
187
+ {renderInlineMarkdown(label)}
188
+ </a>,
189
+ );
190
+ } else {
191
+ nodes.push(linkLike.raw);
192
+ }
193
+ }
194
+ index += linkLike.length;
195
+ continue;
196
+ }
197
+
198
+ if (text.startsWith("**", index) || text.startsWith("__", index)) {
199
+ const marker = text.slice(index, index + 2);
200
+ const end = text.indexOf(marker, index + 2);
201
+ if (end > index + 2) {
202
+ flushPlain();
203
+ nodes.push(<strong key={`${index}-strong`}>{renderInlineMarkdown(text.slice(index + 2, end))}</strong>);
204
+ index = end + 2;
205
+ continue;
206
+ }
207
+ }
208
+
209
+ if (text.startsWith("~~", index)) {
210
+ const end = text.indexOf("~~", index + 2);
211
+ if (end > index + 2) {
212
+ flushPlain();
213
+ nodes.push(<del key={`${index}-strike`}>{renderInlineMarkdown(text.slice(index + 2, end))}</del>);
214
+ index = end + 2;
215
+ continue;
216
+ }
217
+ }
218
+
219
+ if (text[index] === "*" || text[index] === "_") {
220
+ const marker = text[index];
221
+ const end = text.indexOf(marker, index + 1);
222
+ if (end > index + 1) {
223
+ flushPlain();
224
+ nodes.push(<em key={`${index}-em`}>{renderInlineMarkdown(text.slice(index + 1, end))}</em>);
225
+ index = end + 1;
226
+ continue;
227
+ }
228
+ }
229
+
230
+ if (text[index] === "`") {
231
+ const end = text.indexOf("`", index + 1);
232
+ if (end > index + 1) {
233
+ flushPlain();
234
+ nodes.push(<code key={`${index}-code`}>{text.slice(index + 1, end)}</code>);
235
+ index = end + 1;
236
+ continue;
237
+ }
238
+ }
239
+
240
+ const autolinkMatch = /^(<https?:\/\/[^>\s]+>|https?:\/\/[^\s<]+)/.exec(text.slice(index));
241
+ if (autolinkMatch) {
242
+ flushPlain();
243
+ const token = autolinkMatch[1];
244
+ const href = normalizeInlineMarkdownHref(token);
245
+ if (href) {
246
+ nodes.push(
247
+ <a href={href} key={`${index}-autolink`} rel="noopener noreferrer" target="_blank">
248
+ {href}
249
+ </a>,
250
+ );
251
+ } else {
252
+ nodes.push(token);
253
+ }
254
+ index += token.length;
255
+ continue;
256
+ }
257
+
258
+ plain += text[index];
259
+ index += 1;
260
+ }
261
+
262
+ flushPlain();
263
+ return nodes;
264
+ }
265
+
266
+ function normalizeInlineMarkdownHref(rawHref: string): string | null {
267
+ const href = extractMarkdownHrefDestination(rawHref);
268
+ if (!href) return null;
269
+ if (/^(https?:\/\/|mailto:)/i.test(href)) return href;
270
+ return null;
271
+ }
272
+
273
+ function normalizeMultilineMarkdownLinks(text: string): string {
274
+ let normalized = "";
275
+ let index = 0;
276
+ while (index < text.length) {
277
+ const linkLike = parseMarkdownLinkLikeAt(text, index);
278
+ if (!linkLike || linkLike.image || !linkLike.raw.includes("\n")) {
279
+ normalized += text[index];
280
+ index += 1;
281
+ continue;
282
+ }
283
+ const href = extractMarkdownHrefDestination(linkLike.hrefRaw);
284
+ const label = collapseMarkdownLinkLabel(linkLike.label);
285
+ if (!href || !label) {
286
+ normalized += linkLike.raw;
287
+ index += linkLike.length;
288
+ continue;
289
+ }
290
+ normalized += `[${label}](${href})`;
291
+ index += linkLike.length;
292
+ }
293
+ return normalized;
294
+ }
295
+
296
+ function collapseMarkdownLinkLabel(label: string): string {
297
+ const withoutImages = label.replace(/!\[[^\]]*\]\((?:[^)(]|\([^)(]*\))*\)/g, " ");
298
+ return withoutImages
299
+ .replace(/\r?\n+/g, " ")
300
+ .replace(/(^|\s)#{1,6}\s+/g, "$1")
301
+ .replace(/\s+/g, " ")
302
+ .trim();
303
+ }
304
+
305
+ function extractMarkdownHrefDestination(rawHref: string): string {
306
+ const trimmed = rawHref.trim();
307
+ if (!trimmed) return "";
308
+ if (trimmed.startsWith("<")) {
309
+ const closing = trimmed.indexOf(">");
310
+ if (closing > 1) return trimmed.slice(1, closing).trim();
311
+ }
312
+ const match = /^(\S+)/.exec(trimmed);
313
+ return match?.[1]?.trim() ?? "";
314
+ }
315
+
316
+ function parseMarkdownLinkLikeAt(
317
+ source: string,
318
+ startIndex: number,
319
+ ): { hrefRaw: string; image: boolean; label: string; length: number; raw: string } | null {
320
+ const image = source.startsWith("![", startIndex);
321
+ const link = source[startIndex] === "[";
322
+ if (!image && !link) return null;
323
+ const labelStart = startIndex + (image ? 2 : 1);
324
+ const labelEnd = findMatchingBracketIndex(source, labelStart, "[", "]");
325
+ if (labelEnd < 0) return null;
326
+ let next = labelEnd + 1;
327
+ while (next < source.length && /\s/.test(source[next])) next += 1;
328
+ if (source[next] !== "(") return null;
329
+ const hrefStart = next + 1;
330
+ const hrefEnd = findMatchingBracketIndex(source, hrefStart, "(", ")");
331
+ if (hrefEnd < 0) return null;
332
+ const raw = source.slice(startIndex, hrefEnd + 1);
333
+ return {
334
+ image,
335
+ label: source.slice(labelStart, labelEnd),
336
+ hrefRaw: source.slice(hrefStart, hrefEnd),
337
+ length: raw.length,
338
+ raw,
339
+ };
340
+ }
341
+
342
+ function findMatchingBracketIndex(source: string, contentStart: number, open: string, close: string): number {
343
+ let depth = 1;
344
+ for (let index = contentStart; index < source.length; index += 1) {
345
+ const ch = source[index];
346
+ if (ch === "\\") {
347
+ index += 1;
348
+ continue;
349
+ }
350
+ if (ch === open) {
351
+ depth += 1;
352
+ continue;
353
+ }
354
+ if (ch !== close) continue;
355
+ depth -= 1;
356
+ if (depth === 0) return index;
357
+ }
358
+ return -1;
359
+ }
360
+
361
+ function looksLikeMarkdownTableHeaderLine(line: string): boolean {
362
+ return /\|/.test(line.trim());
363
+ }
364
+
365
+ function looksLikeMarkdownTableRowLine(line: string): boolean {
366
+ const trimmed = line.trim();
367
+ if (!trimmed) return false;
368
+ if (!/\|/.test(trimmed)) return false;
369
+ if (isMarkdownTableDividerLine(trimmed)) return false;
370
+ return true;
371
+ }
372
+
373
+ function isMarkdownTableDividerLine(line: string): boolean {
374
+ const cells = splitMarkdownTableCells(line);
375
+ if (!cells.length) return false;
376
+ return cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
377
+ }
378
+
379
+ function splitMarkdownTableCells(line: string): string[] {
380
+ const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
381
+ if (!trimmed) return [];
382
+ const cells: string[] = [];
383
+ let current = "";
384
+ let escaping = false;
385
+ for (let index = 0; index < trimmed.length; index += 1) {
386
+ const ch = trimmed[index];
387
+ if (escaping) {
388
+ current += ch;
389
+ escaping = false;
390
+ continue;
391
+ }
392
+ if (ch === "\\") {
393
+ escaping = true;
394
+ current += ch;
395
+ continue;
396
+ }
397
+ if (ch === "|") {
398
+ cells.push(current.trim());
399
+ current = "";
400
+ continue;
401
+ }
402
+ current += ch;
403
+ }
404
+ cells.push(current.trim());
405
+ return cells;
406
+ }
407
+
408
+ function normalizeMarkdownTableCells(cells: string[], columnCount: number): string[] {
409
+ const normalized = cells.slice(0, columnCount);
410
+ while (normalized.length < columnCount) normalized.push("");
411
+ return normalized;
412
+ }
@@ -5,6 +5,7 @@ import {
5
5
  Loader2Icon, LockIcon, MailIcon, NewspaperIcon, SlidersHorizontalIcon, Undo2Icon, type LucideIcon,
6
6
  } from "lucide-react";
7
7
  import Link from "next/link";
8
+ import { usePathname } from "next/navigation";
8
9
  import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
9
10
  import { ScrollArea } from "./ui/scroll-area";
10
11
  import { Sheet, SheetContent, SheetHeader, SheetTitle } from "./ui/sheet";
@@ -20,13 +21,15 @@ import { cannotMoveReason } from "../lib/review-drafts";
20
21
  import type { CyclotronEntry } from "../lib/review-requests";
21
22
  import { referenceReasonCode, REFERENCE_REJECTION_REASON_CODES, normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
22
23
  import { formatFeedAge } from "../lib/reference-preview";
24
+ import { buildNeedsMeItemPath, parseNeedsMeItemIdFromNewsroomPathname } from "../lib/newsroom-index-filters";
25
+ import { getNewsroomNavHref } from "../lib/newsroom-nav";
23
26
  import type { FeedFreshness } from "../lib/reference-feed-ranking";
24
27
  import type { RecentDecision } from "../lib/notification-feed";
25
28
  import { getKanbusTheme, SITE_BRAND } from "../lib/site-brand";
26
29
  import { NeedsMeDetail, type DecisionExtras } from "./needs-me-detail";
27
30
  import { FlagFooter, decidedChoiceOf, isReferenceLike, recordedAnswer } from "./needs-me-shared";
28
31
  import { NotificationSkeleton, RecentDecisions } from "./notification-cards";
29
- import { FEED_GRID, FEED_SURFACE_PROPS, FeedImage, PILL, REASON_LABELS, SourceMark } from "./reference-feed";
32
+ import { FEED_GRID, FEED_SURFACE_PROPS, FeedImage, PILL, REASON_LABELS, SELECTED_RING, SourceMark } from "./reference-feed";
30
33
  import { predictionFromDecision, ReviewQueueBar, useQueueGestures, type CardPrediction, type ReviewChoice } from "./reference-review-card";
31
34
  import type { CorrectCaptureAction } from "./reference-capture-client";
32
35
  import { RatingGrid } from "./review-rating-grid";
@@ -118,18 +121,18 @@ export function NeedsMeCard({ item, now, onOpen, predictionLoading, ratings, sel
118
121
  aria-labelledby={titleId}
119
122
  className={cn(
120
123
  "relative flex flex-col overflow-hidden rounded-[20px] text-card-foreground transition-[background-color,box-shadow] duration-200",
121
- selected ? "bg-[var(--feed-control)] shadow-xl" : "bg-[var(--feed-card)]",
124
+ selected ? "bg-[var(--feed-control)]" : "bg-[var(--feed-card)]",
122
125
  )}
123
126
  data-feed-card={item.id}
124
127
  data-feed-rated={decided ?? undefined}
125
128
  data-feed-selected={selected || undefined}
129
+ style={selected ? SELECTED_RING : undefined}
126
130
  data-needs-me-card={item.type}
127
131
  data-needs-me-state={item.decisionState}
128
132
  data-newsroom-card-id={item.id}
129
133
  data-review-draft={draftId}
130
134
  data-review-request={requestId}
131
135
  >
132
- {selected ? <span aria-hidden="true" className="absolute inset-y-0 left-0 z-10 w-1.5 bg-[var(--primary)]" data-feed-selected-bar /> : null}
133
136
  <button
134
137
  aria-label={`Open: ${item.title}`}
135
138
  className="flex flex-1 flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
@@ -301,7 +304,10 @@ export function NeedsMeView({
301
304
  const themeAttributes = { "data-kanbus-theme": getKanbusTheme(SITE_BRAND), "data-color-mode": themeMode };
302
305
  const isMobile = useMediaQuery("(max-width: 767px)");
303
306
  const [filter, setFilter] = useState<NeedsMeFilter>(initialFilter);
304
- const [selectedId, setSelectedId] = useState("");
307
+ // The open item lives in the address (`/needs-me/<id>`), so it can be linked, bookmarked and reopened with Back.
308
+ const pathname = usePathname() ?? (typeof window === "undefined" ? null : window.location.pathname);
309
+ const routeId = useMemo(() => parseNeedsMeItemIdFromNewsroomPathname(pathname) ?? "", [pathname]);
310
+ const [selectedId, setSelectedId] = useState(routeId);
305
311
  const [caughtUp, setCaughtUp] = useState(false);
306
312
  const [sheetOpen, setSheetOpen] = useState(false);
307
313
  const [recipeOpen, setRecipeOpen] = useState(false);
@@ -357,21 +363,37 @@ export function NeedsMeView({
357
363
  const previousId = caughtUp ? queue[queue.length - 1] ?? null : selectedIndex > 0 ? queue[selectedIndex - 1] : null;
358
364
  const nextId = selectedIndex >= 0 && selectedIndex < queue.length - 1 ? queue[selectedIndex + 1] : null;
359
365
 
366
+ const showInAddress = (id: string, mode: "push" | "replace") => {
367
+ if (demo || typeof window === "undefined") return;
368
+ const next = id ? buildNeedsMeItemPath(id) : getNewsroomNavHref("/newsroom");
369
+ if (`${window.location.pathname}${window.location.search}` === next) return;
370
+ if (mode === "push") window.history.pushState(null, "", next);
371
+ else window.history.replaceState(null, "", next);
372
+ };
360
373
  const select = (item: NeedsMeEntry) => {
361
374
  setCaughtUp(false);
362
375
  setSelectedId(item.id);
363
376
  if (isMobile) setSheetOpen(true);
377
+ showInAddress(item.id, "push");
364
378
  onOpened?.(item);
365
379
  };
366
380
  const goNext = () => {
367
- if (nextId) { setCaughtUp(false); setSelectedId(nextId); setScrollTarget({ id: nextId, nonce: Date.now() }); const next = byId.get(nextId); if (next) onOpened?.(next); }
368
- else if (!caughtUp) { setCaughtUp(true); setSelectedId(""); }
381
+ if (nextId) { setCaughtUp(false); setSelectedId(nextId); showInAddress(nextId, "replace"); setScrollTarget({ id: nextId, nonce: Date.now() }); const next = byId.get(nextId); if (next) onOpened?.(next); }
382
+ else if (!caughtUp) { setCaughtUp(true); setSelectedId(""); showInAddress("", "replace"); }
369
383
  };
370
384
  const goPrevious = () => {
371
- if (previousId) { setCaughtUp(false); setSelectedId(previousId); setScrollTarget({ id: previousId, nonce: Date.now() }); }
385
+ if (previousId) { setCaughtUp(false); setSelectedId(previousId); showInAddress(previousId, "replace"); setScrollTarget({ id: previousId, nonce: Date.now() }); }
372
386
  };
373
387
  const gestures = useQueueGestures({ onNext: goNext, onPrevious: goPrevious });
374
- const closeDetail = () => { setCaughtUp(false); setSheetOpen(false); setSelectedId(""); };
388
+ const closeDetail = () => { setCaughtUp(false); setSheetOpen(false); setSelectedId(""); showInAddress("", "replace"); };
389
+
390
+ // Back and Forward (and a link opened in place) follow the address.
391
+ useEffect(() => {
392
+ setSelectedId(routeId);
393
+ if (routeId) { setCaughtUp(false); if (isMobile) setSheetOpen(true); }
394
+ }, [routeId, isMobile]);
395
+ // A linked item that is not waiting any more (decided, or not yours) says so once everything is read, instead of an empty pane.
396
+ const linkedGone = Boolean(routeId) && settled && !byId.has(routeId);
375
397
 
376
398
  // Previous / Next scrolls the feed to the same card, smoothly. Selecting a card by clicking it never scrolls.
377
399
  useEffect(() => {
@@ -404,14 +426,15 @@ export function NeedsMeView({
404
426
  ) : null;
405
427
 
406
428
  return (
407
- <div className="flex flex-col gap-5 md:h-full md:flex-row md:items-stretch" data-detail-reserved="true" data-notification-center {...FEED_SURFACE_PROPS}>
408
- <div className="@container min-w-0 flex-1 space-y-6 pb-6 md:min-h-0 md:overflow-y-auto md:pr-1" data-needs-me-main data-needs-me-scroll>
429
+ <div className="flex flex-col gap-5 md:h-full md:flex-row md:items-stretch" data-notification-center {...FEED_SURFACE_PROPS}>
430
+ <div className="@container min-w-0 flex-1 space-y-6 pb-6 md:min-h-0 md:overflow-y-auto px-1.5 pt-1.5 md:pr-2.5" data-needs-me-main data-needs-me-scroll>
409
431
  <header className="space-y-4">
410
432
  <div className="space-y-1.5">
411
433
  <h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl" data-notification-title>What needs me?</h1>
412
434
  <p className="text-lg text-muted-foreground" data-notification-summary role="status">{waitingTextFor(counts)}</p>
413
435
  </div>
414
436
  <ShapingStrip stats={shaping} />
437
+ {linkedGone ? <Note data-needs-me-link-gone role="status">The item in this link is not waiting for you any more: it was decided, or it is not yours to decide.</Note> : null}
415
438
  <div className="flex flex-wrap items-center justify-between gap-2">
416
439
  <div aria-label="Show" className="inline-flex max-w-full overflow-x-auto rounded-full bg-[var(--feed-card)] p-1" data-notification-filters role="group">
417
440
  {NEEDS_ME_FILTERS.map((key) => {
@@ -542,9 +565,10 @@ export function NeedsMeView({
542
565
  {below}
543
566
  </div>
544
567
 
568
+ {/* The pane appears once something is selected (or when the queue is done); until then the feed has the whole width. */}
545
569
  <aside
546
570
  aria-label="Detail"
547
- className="hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:flex md:h-full md:w-[min(100%,24rem)] md:pb-16 lg:w-[min(100%,28rem)]"
571
+ className={cn("hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:h-full", (selected || caughtUp) && "md:flex", " md:w-[min(100%,24rem)] md:pb-16 lg:w-[min(100%,28rem)]")}
548
572
  data-feed-flat=""
549
573
  data-needs-me-detail-shell
550
574
  data-detail-open={selected ? "true" : "false"}
@@ -552,9 +576,7 @@ export function NeedsMeView({
552
576
  <div className="flex shrink-0 items-center justify-between gap-2 px-4 pb-1 pt-3"><span className="text-sm font-medium text-foreground">Detail</span></div>
553
577
  <div className="flex min-h-0 flex-1 flex-col" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}>
554
578
  <ScrollArea className="min-h-0 flex-1">
555
- {isMobile ? null : selected ? <div key={selected.id}>{detailBody(selected)}</div> : caughtUp ? caughtUpPanel : (
556
- <p className="p-4 text-sm text-muted-foreground" data-needs-me-detail-empty>Select an item to read it here and decide.</p>
557
- )}
579
+ {isMobile ? null : selected ? <div key={selected.id}>{detailBody(selected)}</div> : caughtUp ? caughtUpPanel : null}
558
580
  </ScrollArea>
559
581
  </div>
560
582
  {queueBar}
@@ -1,6 +1,5 @@
1
1
  "use client";
2
2
 
3
- import { newsroomHref } from "../lib/newsroom-base-path";
4
3
  import Link from "next/link";
5
4
  import { SITE_BRAND } from "../lib/site-brand";
6
5
  import type { NewsDeskShellState } from "../lib/news-desk-session";
@@ -23,7 +22,7 @@ export function NewsDeskAccessGate({ initialTab, shell, showSectionTabs = false
23
22
  backHref="/"
24
23
  backLabel={SITE_BRAND.backToHomeLabel}
25
24
  headerActions={(
26
- <Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
25
+ <Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "organizer-header-settings")} href="/settings">Settings</Link>
27
26
  )}
28
27
  pageTitle={referencesPage ? "References" : showSectionTabs ? "Notification Center" : "Newsroom"}
29
28
  showNavigation={showSectionTabs || referencesPage}
@@ -39,7 +38,8 @@ export function NewsDeskAccessGate({ initialTab, shell, showSectionTabs = false
39
38
  <p className="m-0 text-sm text-foreground">{formatAccessActionDetail(shell)}</p>
40
39
  {shell?.phase === "signedOut" || shell?.phase === "error" ? (
41
40
  <div className="news-desk-access-panel__auth">
42
- <ReaderAuthControl postAuthPath={newsroomHref()} showIdentity />
41
+ {/* No fixed return path: sign-in comes back to the page that asked, so a deep link survives it. */}
42
+ <ReaderAuthControl showIdentity />
43
43
  </div>
44
44
  ) : null}
45
45
  </div>