@anthusai/papyrus 1.0.0-next.9 → 1.1.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.
Files changed (222) hide show
  1. package/amplify/auth/prospectus-google-client.js +66 -0
  2. package/amplify/auth/prospectus-google-client.ts +71 -0
  3. package/amplify/auth/redirect-urls.js +54 -0
  4. package/amplify/auth/redirect-urls.ts +66 -0
  5. package/amplify/auth/resource.js +23 -5
  6. package/amplify/auth/resource.ts +31 -5
  7. package/amplify/data/resource.js +42 -80
  8. package/amplify/data/resource.ts +42 -80
  9. package/amplify/data/schema.js +45 -62
  10. package/amplify/data/schema.ts +47 -62
  11. package/amplify/functions/category-action/handler.ts +5 -3
  12. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  13. package/amplify/functions/console-chat-responder/resource.js +0 -7
  14. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  15. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  16. package/amplify/functions/content-actions/handler.py +143 -1
  17. package/amplify/functions/content-actions/requirements.txt +2 -1
  18. package/amplify/functions/content-actions/resource.js +6 -1
  19. package/amplify/functions/content-actions/resource.ts +9 -2
  20. package/amplify/functions/email-submission-processor/handler.py +3 -1
  21. package/amplify/functions/renditions/handler.py +120 -0
  22. package/amplify/functions/renditions/requirements.txt +4 -0
  23. package/amplify/functions/ses-inbound-receive/handler.ts +32 -4
  24. package/amplify/functions/shared/email-submission.ts +109 -1
  25. package/amplify/functions/shared/python-bundle.js +42 -2
  26. package/amplify/functions/shared/python-bundle.ts +47 -2
  27. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  28. package/amplify/inbound-email/ses-intake.js +86 -0
  29. package/amplify/inbound-email/ses-intake.ts +110 -0
  30. package/amplify/plugins/registry.js +19 -0
  31. package/amplify/plugins/registry.ts +23 -0
  32. package/amplify/plugins/signup/function/handler.py +200 -0
  33. package/amplify/plugins/signup/function/resource.js +27 -0
  34. package/amplify/plugins/signup/function/resource.ts +35 -0
  35. package/amplify/plugins/signup/papyrus.plugin.js +59 -0
  36. package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
  37. package/amplify/plugins/signup/schema.js +40 -0
  38. package/amplify/plugins/signup/schema.ts +41 -0
  39. package/amplify/plugins/signup/waf.js +48 -0
  40. package/amplify/plugins/signup/waf.ts +51 -0
  41. package/amplify/plugins/types.js +1 -0
  42. package/amplify/plugins/types.ts +39 -0
  43. package/amplify/plugins/wire.js +24 -0
  44. package/amplify/plugins/wire.ts +26 -0
  45. package/amplify/publication/default-publication.js +18 -0
  46. package/amplify/publication/default-publication.ts +20 -0
  47. package/amplify/publication/invalidation-alarm.js +26 -0
  48. package/amplify/publication/invalidation-alarm.ts +30 -0
  49. package/amplify/publication/redirect-and-index.js +37 -0
  50. package/amplify/publication/renditions-function.js +74 -0
  51. package/amplify/publication/renditions-function.ts +112 -0
  52. package/amplify/publication/renditions-settings.js +51 -0
  53. package/amplify/publication/renditions-settings.ts +58 -0
  54. package/amplify/publication/reproject-on-deploy.js +73 -0
  55. package/amplify/publication/reproject-on-deploy.ts +78 -0
  56. package/amplify/publication/s3-projection-settings.js +67 -0
  57. package/amplify/publication/s3-projection-settings.ts +101 -0
  58. package/amplify/publication/s3-projection.js +151 -0
  59. package/amplify/publication/s3-projection.ts +201 -0
  60. package/amplify/resolver-waves.js +26 -0
  61. package/amplify/resolver-waves.ts +34 -0
  62. package/amplify/revalidate-invalidation-settings.js +30 -0
  63. package/amplify/revalidate-invalidation-settings.ts +35 -0
  64. package/amplify/revalidate-secret-policy.js +13 -0
  65. package/amplify/revalidate-secret-policy.ts +19 -0
  66. package/amplify/site-backend-config.js +65 -1
  67. package/amplify/site-backend-config.ts +153 -2
  68. package/amplify/site-backend-names.js +103 -0
  69. package/amplify/site-backend-names.ts +166 -0
  70. package/amplify/site-backend.js +150 -176
  71. package/amplify/site-backend.ts +189 -201
  72. package/amplify/storage-backups/construct.js +31 -0
  73. package/amplify/storage-backups/construct.ts +47 -0
  74. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
  75. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  76. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
  77. package/app/[year]/[month]/[day]/page.tsx +1 -1
  78. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
  79. package/app/api/archive/editions/route.ts +1 -1
  80. package/app/api/auth/prospectus/callback/route.ts +9 -0
  81. package/app/api/auth/prospectus/selection/route.ts +9 -0
  82. package/app/api/auth/prospectus/session/route.ts +9 -0
  83. package/app/api/auth/prospectus/sign-in/route.ts +9 -0
  84. package/app/api/auth/prospectus/sign-out/route.ts +9 -0
  85. package/app/api/cyclotron/priming-proof/route.ts +11 -0
  86. package/app/api/media/[...path]/route.ts +5 -16
  87. package/app/api/revalidate/route.ts +15 -7
  88. package/app/archive/page.tsx +1 -1
  89. package/app/articles/[slug]/page.tsx +7 -5
  90. package/app/blog-rhythm.css +278 -0
  91. package/app/cyclotron/page.tsx +13 -0
  92. package/app/globals.css +2 -0
  93. package/app/layout.tsx +16 -0
  94. package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
  95. package/app/newsroom/[section]/page.tsx +6 -3
  96. package/app/newsroom/layout.tsx +4 -9
  97. package/app/newsroom/settings/page.tsx +6 -0
  98. package/app/newsroom/work/page.tsx +10 -0
  99. package/app/page.tsx +22 -8
  100. package/app/robots.ts +2 -5
  101. package/app/tailwind.css +1 -6
  102. package/components/amplify-client-provider.tsx +5 -22
  103. package/components/archive-shell.tsx +2 -22
  104. package/components/article-video.tsx +152 -5
  105. package/components/blog-page-background.tsx +23 -6
  106. package/components/news-desk-page.tsx +4 -15
  107. package/components/news-desk-taxonomy-client.ts +90 -3
  108. package/components/newspaper.tsx +6 -2
  109. package/components/newsroom-access-gate.tsx +2 -1
  110. package/components/newsroom-board-view.tsx +99 -0
  111. package/components/newsroom-client-shell.tsx +12 -0
  112. package/components/newsroom-curation-provider.tsx +17 -0
  113. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  114. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  115. package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
  116. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  117. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  118. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
  119. package/components/newsroom-newsprint-board.css +48 -0
  120. package/components/newsroom-ops-overview.tsx +28 -11
  121. package/components/newsroom-ops-shell.tsx +7 -1
  122. package/components/newsroom-organizer-shell.tsx +130 -0
  123. package/components/newsroom-organizer.css +63 -0
  124. package/components/newsroom-references-view.tsx +92 -8
  125. package/components/papyrus-console-shell.tsx +4 -2
  126. package/components/pictogram-figure.tsx +9 -2
  127. package/components/presentation-header.tsx +135 -0
  128. package/components/presentation-rhythm-hrule.tsx +17 -0
  129. package/components/priming-proof-page.tsx +94 -0
  130. package/components/publication-cyclotron-panel.tsx +57 -0
  131. package/components/reader-auth-control.tsx +8 -5
  132. package/components/reference-curation-controls.tsx +77 -0
  133. package/components/reference-cyclotron-status.tsx +109 -0
  134. package/components/reference-priming.tsx +18 -0
  135. package/components/reference-relevance-review.tsx +75 -0
  136. package/components/settings-page.tsx +2 -1
  137. package/components/signup-entries-panel.tsx +94 -0
  138. package/components/topic-steering-workspace.tsx +78 -8
  139. package/components/use-blog-header-obstacles.ts +81 -0
  140. package/components/use-brand-fonts-ready.ts +28 -0
  141. package/components/use-near-viewport.ts +30 -0
  142. package/components/use-rhythm-overlay.ts +29 -0
  143. package/infra/amplify-app-shell.js +166 -27
  144. package/infra/amplify-app-shell.ts +180 -31
  145. package/infra/build-specs.js +23 -7
  146. package/infra/build-specs.ts +27 -10
  147. package/infra/site-config.js +248 -36
  148. package/infra/site-config.ts +313 -51
  149. package/lib/amplify-outputs-path.ts +5 -1
  150. package/lib/amplify-server-runtime.ts +36 -1
  151. package/lib/approved-priming-proof-reports.ts +6 -0
  152. package/lib/blog-feature-solver.ts +459 -0
  153. package/lib/blog-rhythm.ts +134 -0
  154. package/lib/brand-fonts-watch.ts +43 -0
  155. package/lib/brand-fonts.ts +112 -0
  156. package/lib/cached-content-repository.ts +12 -0
  157. package/lib/category-repository.ts +43 -93
  158. package/lib/content-item-lifecycle.ts +55 -0
  159. package/lib/content-repository.ts +3 -0
  160. package/lib/content-types.ts +4 -0
  161. package/lib/curation-client.ts +61 -0
  162. package/lib/edition-routes.ts +31 -29
  163. package/lib/graphql-content-repository.ts +56 -22
  164. package/lib/media-route-response.ts +45 -0
  165. package/lib/newspaper-layout.ts +3 -2
  166. package/lib/newsroom-base-path.ts +12 -3
  167. package/lib/newsroom-board.ts +99 -0
  168. package/lib/newsroom-category-drilldown.ts +3 -2
  169. package/lib/newsroom-forum-routes.ts +6 -4
  170. package/lib/newsroom-index-filters.ts +14 -10
  171. package/lib/newsroom-nav.ts +3 -1
  172. package/lib/newsroom-organizer-nav.ts +37 -0
  173. package/lib/newsroom-section-routing.ts +20 -0
  174. package/lib/oauth-redirect-priority.ts +40 -0
  175. package/lib/papyrus-web-locations.ts +7 -6
  176. package/lib/plugin-settings.ts +11 -0
  177. package/lib/presentation-footer.ts +3 -2
  178. package/lib/pretext-layout.ts +12 -3
  179. package/lib/priming-proof-report.ts +90 -0
  180. package/lib/prospectus-publication-access.ts +53 -0
  181. package/lib/prospectus-session.ts +196 -0
  182. package/lib/prospectus-sign-in-routes.ts +284 -0
  183. package/lib/prospectus-site-identity.ts +303 -0
  184. package/lib/publication-cyclotron.ts +74 -0
  185. package/lib/rate-limit-retry.ts +55 -0
  186. package/lib/reader-base-path.ts +24 -0
  187. package/lib/reader-cache-lifetime.ts +5 -0
  188. package/lib/reader-static-params.ts +85 -4
  189. package/lib/reader-storage-url.ts +9 -12
  190. package/lib/reference-policy.ts +5 -0
  191. package/lib/relevance-decisions.ts +103 -0
  192. package/lib/relevance-status.ts +63 -0
  193. package/lib/renderer.ts +7 -0
  194. package/lib/revalidate-prewarm.ts +53 -0
  195. package/lib/revalidate-secret.ts +59 -0
  196. package/lib/robots-policy.ts +14 -0
  197. package/lib/root-route-redirect.ts +16 -0
  198. package/lib/semantic-graph.ts +7 -0
  199. package/lib/shared-credentials-provider.ts +61 -0
  200. package/lib/signup-csv.ts +24 -0
  201. package/lib/site-brand.ts +59 -0
  202. package/lib/staging-gated-path.ts +23 -0
  203. package/lib/staging-preview-object.ts +21 -5
  204. package/lib/test-editor-override.ts +4 -0
  205. package/lib/video-loading.ts +22 -0
  206. package/lib/video-script.ts +14 -0
  207. package/middleware.ts +11 -19
  208. package/package.json +73 -8
  209. package/renderers/pretext/article-page.tsx +39 -3
  210. package/renderers/pretext/presentation-shared.tsx +113 -0
  211. package/renderers/pretext/presentation-shell.tsx +15 -102
  212. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  213. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  214. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  215. package/routes.manifest.json +123 -12
  216. package/src/sync.mjs +30 -6
  217. package/src/with-papyrus.d.mts +9 -0
  218. package/src/with-papyrus.mjs +48 -3
  219. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  220. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  221. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
  222. package/components/ai-elements/message.tsx +0 -360
@@ -0,0 +1,130 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useState } from "react";
4
+ import Link from "next/link";
5
+ import { usePathname } from "next/navigation";
6
+ import { BookOpen, ClipboardList, LayoutDashboard, Menu, MessagesSquare, PanelLeftClose, PanelLeftOpen, Settings, UserRound, LogIn, LogOut } from "lucide-react";
7
+ import { ORGANIZER_NAV_GROUPS, ORGANIZER_PRIMARY_NAV, ORGANIZER_SETTINGS_DESTINATION } from "../lib/newsroom-organizer-nav";
8
+ import { browserPathToInternalNewsroomPath } from "../lib/newsroom-base-path";
9
+ import { getNewsroomNavHref } from "../lib/newsroom-nav";
10
+ import { SITE_BRAND } from "../lib/site-brand";
11
+ import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
12
+ import { ReaderAuthControl } from "./reader-auth-control";
13
+ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetTrigger } from "./ui/sheet";
14
+ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator } from "./ui/dropdown-menu";
15
+ import type { NewsroomOpsShellProps } from "./newsroom-ops-shell";
16
+ import "./newsroom-organizer.css";
17
+
18
+ const ICONS = { overview: LayoutDashboard, board: ClipboardList, references: BookOpen, messages: MessagesSquare };
19
+
20
+ /** Navigation is a presentation layer; existing route and server authorization still own access. */
21
+ function Navigation({ activePath, demo, admin, surface, onNavigate }: { activePath: string; demo: boolean; admin: boolean; surface: string; onNavigate?: () => void }) {
22
+ const id = useId();
23
+ const all = ORGANIZER_NAV_GROUPS.flatMap(group => group.items);
24
+ const active = all.filter(item => activePath === item.path || (item.path !== "/newsroom" && activePath.startsWith(item.path + "/"))).sort((a, b) => b.path.length - a.path.length)[0]?.key;
25
+ return <nav className="organizer-destinations" aria-label={surface === "sidebar" ? "Newsroom" : "All workspaces"}>
26
+ {ORGANIZER_NAV_GROUPS.map((group, index) => <section key={group.title} aria-labelledby={`${id}-${index}`}>
27
+ <h3 id={`${id}-${index}`}>{group.title}</h3>
28
+ {group.items.filter(item => item.access !== "admin" || admin).map(item => <Link key={item.key}
29
+ href={getNewsroomNavHref(item.path, demo)} onClick={onNavigate} aria-label={item.label}
30
+ aria-current={active === item.key ? "page" : undefined} aria-describedby={`${id}-${item.key}`}
31
+ data-news-desk-tab={item.key}>
32
+ <strong>{item.label}</strong><small id={`${id}-${item.key}`}>{item.description}</small>
33
+ </Link>)}
34
+ </section>)}
35
+ </nav>;
36
+ }
37
+
38
+ function Account({ placement }: { placement: "sidebar" | "header" }) {
39
+ const session = useOptionalNewsDeskClient();
40
+ const auth = session?.shell.auth ?? { status: "loading" as const, label: "Checking sign-in" };
41
+ const [open, setOpen] = useState(false);
42
+ useEffect(() => {
43
+ const media = matchMedia("(max-width: 900px)");
44
+ const close = () => setOpen(false);
45
+ media.addEventListener("change", close);
46
+ return () => media.removeEventListener("change", close);
47
+ }, []);
48
+ return <DropdownMenu open={open} onOpenChange={setOpen}>
49
+ <DropdownMenuTrigger className={`organizer-account organizer-account-${placement}`} aria-label="Account menu" title="Account menu">
50
+ <UserRound size={18} aria-hidden="true" /><span><strong>{auth.label}</strong><small>{auth.status === "signedIn" ? "Account & preferences" : auth.status === "signedOut" ? "Not signed in" : "Checking session"}</small></span>
51
+ </DropdownMenuTrigger>
52
+ <DropdownMenuContent className="organizer-account-popup" side={placement === "sidebar" ? "top" : "bottom"} align="end">
53
+ <DropdownMenuGroup>
54
+ <DropdownMenuLabel>{auth.label}</DropdownMenuLabel><DropdownMenuSeparator />
55
+ <DropdownMenuItem render={<Link href={getNewsroomNavHref(ORGANIZER_SETTINGS_DESTINATION.path)} />}><Settings aria-hidden="true" />{ORGANIZER_SETTINGS_DESTINATION.label}</DropdownMenuItem>
56
+ <ReaderAuthControl authState={auth} renderAction={({ disabled, onClick, signedIn }) => <DropdownMenuItem disabled={disabled} closeOnClick={false} onClick={onClick}>
57
+ {signedIn ? <LogOut aria-hidden="true" /> : <LogIn aria-hidden="true" />}{signedIn ? "Sign out" : "Sign in"}
58
+ </DropdownMenuItem>} />
59
+ </DropdownMenuGroup>
60
+ </DropdownMenuContent>
61
+ </DropdownMenu>;
62
+ }
63
+
64
+ export function NewsroomOrganizerShell({ activeTab, appTitle, backHref = "/", backLabel, children, demo = false, headerActions, pageTitle, showNavigation = true }: NewsroomOpsShellProps) {
65
+ const pathname = usePathname();
66
+ const session = useOptionalNewsDeskClient();
67
+ const admin = demo || session?.shell.dashboard?.canManageUsers === true;
68
+ const [menuOpen, setMenuOpen] = useState(false);
69
+ const [collapsed, setCollapsed] = useState(false);
70
+ const [loadedPreference, setLoadedPreference] = useState(false);
71
+ const preferenceKey = `papyrus.cms.sidebar.v1.${SITE_BRAND.id}`;
72
+ useEffect(() => {
73
+ try { setCollapsed(localStorage.getItem(preferenceKey) === "true"); } catch { /* Optional browser preference. */ }
74
+ setLoadedPreference(true);
75
+ }, [preferenceKey]);
76
+ useEffect(() => { if (loadedPreference) try { localStorage.setItem(preferenceKey, String(collapsed)); } catch { /* Controls still work without storage. */ } }, [collapsed, loadedPreference, preferenceKey]);
77
+ useEffect(() => { setMenuOpen(false); }, [pathname]);
78
+ useEffect(() => {
79
+ const media = matchMedia("(max-width: 900px)");
80
+ let previousFocus: Element | null = null;
81
+ let frame = 0;
82
+ const remember = () => { if (document.activeElement !== document.body) previousFocus = document.activeElement; };
83
+ const change = () => {
84
+ const active = document.activeElement === document.body ? previousFocus : document.activeElement;
85
+ if (!(active instanceof HTMLElement)) return;
86
+ if (!media.matches) setMenuOpen(false);
87
+ const selector = active.closest(".organizer-account, .organizer-account-popup") ? ".organizer-account"
88
+ : media.matches && active.closest(".organizer-sidebar, .organizer-toggle") ? ".organizer-more"
89
+ : !media.matches && active.closest(".organizer-bottom-nav, .organizer-menu") ? ".organizer-toggle" : null;
90
+ if (!selector) return;
91
+ cancelAnimationFrame(frame);
92
+ frame = requestAnimationFrame(() => {
93
+ if (document.activeElement === active || document.activeElement === document.body || !(document.activeElement as HTMLElement)?.getClientRects().length)
94
+ Array.from(document.querySelectorAll<HTMLElement>(selector)).find(element => element.getClientRects().length)?.focus();
95
+ });
96
+ };
97
+ document.addEventListener("focusin", remember);
98
+ media.addEventListener("change", change);
99
+ return () => { document.removeEventListener("focusin", remember); media.removeEventListener("change", change); cancelAnimationFrame(frame); };
100
+ }, []);
101
+ const activePath = browserPathToInternalNewsroomPath(pathname ?? "/newsroom");
102
+ return <div className="newsroom-organizer" data-news-desk data-newsroom-ops-shell="true" data-newsroom-chrome="organizer" data-sidebar-collapsed={collapsed || !showNavigation ? "true" : "false"}>
103
+ {showNavigation && <aside className="organizer-sidebar" hidden={collapsed} id="organizer-sidebar">
104
+ <Link className="organizer-brand" href={getNewsroomNavHref("/newsroom", demo)}>{appTitle}<small>The newsroom</small></Link>
105
+ <Navigation activePath={activePath} demo={demo} admin={admin} surface="sidebar" />
106
+ <Account placement="sidebar" />
107
+ </aside>}
108
+ <div className="organizer-workspace">
109
+ <header className="organizer-topbar" data-newsroom-ops-topbar>
110
+ {showNavigation && <button type="button" className="organizer-toggle" title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
111
+ aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"} aria-expanded={!collapsed} aria-controls="organizer-sidebar" onClick={() => setCollapsed(value => !value)}>
112
+ {collapsed ? <PanelLeftOpen size={16} aria-hidden="true" /> : <PanelLeftClose size={16} aria-hidden="true" />}<span>{collapsed ? "Expand sidebar" : "Collapse sidebar"}</span>
113
+ </button>}
114
+ <Link className="organizer-masthead" href={backHref} title={backLabel}>{appTitle}</Link>
115
+ <h1 data-newsroom-ops-title>{pageTitle}</h1><div className="organizer-header-actions">{headerActions}</div><Account placement="header" />
116
+ </header>
117
+ <main className="organizer-main">{demo && <p className="organizer-demo-notice">Demo workspace · synthetic examples</p>}{children}</main>
118
+ </div>
119
+ {showNavigation && <nav className="organizer-bottom-nav" aria-label="Primary newsroom navigation"><ul>
120
+ {ORGANIZER_PRIMARY_NAV.map(item => { const Icon = ICONS[item.key as keyof typeof ICONS]; return <li key={item.key}><Link
121
+ href={getNewsroomNavHref(item.path, demo)} title={item.description} aria-label={item.label} aria-current={activeTab === item.key || activeTab === "work" && item.key === "board" ? "page" : undefined}>
122
+ <Icon size={18} aria-hidden="true" /><span>{item.label}</span></Link></li>; })}
123
+ <li><Sheet open={menuOpen} onOpenChange={setMenuOpen}><SheetTrigger className="organizer-more" aria-label="More"><Menu size={18} aria-hidden="true" /><span>More</span></SheetTrigger>
124
+ <SheetContent side="bottom" className="organizer-menu"><SheetHeader><SheetTitle>Newsroom</SheetTitle><SheetDescription>Workspaces and administration</SheetDescription></SheetHeader>
125
+ <Navigation activePath={activePath} demo={demo} admin={admin} surface="menu" onNavigate={() => setMenuOpen(false)} />
126
+ </SheetContent>
127
+ </Sheet></li>
128
+ </ul></nav>}
129
+ </div>;
130
+ }
@@ -0,0 +1,63 @@
1
+ @font-face{font-family:Newsroom Display;src:url('./newsroom-fonts/playfair-display-700.ttf') format('truetype');font-weight:700;font-display:swap}
2
+ @font-face{font-family:Newsroom Display;src:url('./newsroom-fonts/playfair-display-900.ttf') format('truetype');font-weight:900;font-display:swap}
3
+ @font-face{font-family:Newsroom UI;src:url('./newsroom-fonts/plus-jakarta-sans-400.ttf') format('truetype');font-weight:400;font-display:swap}
4
+ @font-face{font-family:Newsroom UI;src:url('./newsroom-fonts/plus-jakarta-sans-600.ttf') format('truetype');font-weight:600;font-display:swap}
5
+ .newsroom-organizer,.organizer-menu,.organizer-account-popup{--org-paper:var(--background);--org-ink:var(--foreground);--org-line:var(--border);--org-muted:var(--muted-foreground);font-family:Newsroom UI,system-ui,sans-serif;color:var(--org-ink);background:var(--org-paper)}
6
+ .newsroom-organizer{display:grid;grid-template-columns:264px minmax(0,1fr);height:100dvh;max-height:100dvh;overflow:clip;font-size:14px}
7
+ .newsroom-organizer[data-sidebar-collapsed=true]{grid-template-columns:minmax(0,1fr)}
8
+ body:has(.newsroom-organizer){display:flex;flex-direction:column;height:100dvh;overflow:hidden}
9
+ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
10
+ .papyrus-console-page:has(.newsroom-organizer){display:flex;flex:1;min-height:0;min-width:0;overflow:hidden}
11
+ .papyrus-console-page .newsroom-organizer{height:100%;max-height:100%;flex:1}
12
+ .newsroom-organizer h1,.newsroom-organizer h2,.newsroom-organizer h3{font-family:Newsroom Display,Georgia,serif}
13
+ .organizer-sidebar{min-height:0;display:flex;flex-direction:column;border-right:1px solid var(--org-line);padding:16px;gap:16px;overflow:hidden}
14
+ .organizer-sidebar[hidden]{display:none}.organizer-brand{font:900 40px/1.1 Newsroom Display,Georgia,serif;padding-bottom:20px;border-bottom:3px double var(--org-ink)}
15
+ .organizer-brand small{display:block;font:400 10px Newsroom UI,system-ui;letter-spacing:.3em;text-transform:uppercase;margin-top:12px}
16
+ .organizer-destinations{min-height:0;overflow:auto;overscroll-behavior:contain;flex:1;padding-right:1px}
17
+ .organizer-destinations section+section{margin-top:24px}.organizer-destinations h3{font:600 10px Newsroom UI,system-ui;letter-spacing:.07em;text-transform:uppercase;color:var(--org-muted);margin:16px 0 8px}
18
+ .organizer-destinations a{display:flex;flex-direction:column;align-items:start;gap:6px;padding:14px 8px;border-bottom:1px solid var(--org-line);color:var(--org-muted);text-decoration:none;min-height:44px}
19
+ .organizer-destinations a strong{font:700 16px Newsroom Display,Georgia,serif}.organizer-destinations a small{font:400 11px/1.5 Newsroom UI,system-ui;white-space:normal;overflow-wrap:anywhere}
20
+ .organizer-destinations a[aria-current=page]{border-bottom:2px solid var(--org-ink);color:var(--org-ink)}
21
+ .newsroom-organizer :is(a,button):focus-visible,.organizer-menu :is(a,button):focus-visible{outline:2px solid currentColor;outline-offset:-3px}
22
+ .organizer-workspace{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:hidden;padding:0 16px}
23
+ .organizer-topbar{height:57px;min-height:57px;display:flex;align-items:center;gap:16px;border-bottom:1px solid var(--org-ink)}
24
+ .organizer-masthead{font:900 28px Newsroom Display,Georgia,serif;white-space:nowrap;max-width:30vw;overflow:hidden;text-overflow:ellipsis}
25
+ .organizer-topbar h1{font:400 12px Newsroom UI,system-ui;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0}
26
+ .organizer-toggle{display:flex;align-items:center;gap:8px;min-height:36px;padding:8px 12px;border:1px solid var(--org-line);font:400 11px Newsroom UI,system-ui;flex-shrink:0}
27
+ .organizer-header-actions{display:flex;align-items:center;gap:8px;min-width:0;max-width:45%;overflow-x:auto}
28
+ .organizer-main{min-width:0;min-height:0;overflow:auto;overscroll-behavior:contain;flex:1;padding:16px 0}
29
+ .organizer-main>[data-category-steering]{min-height:0}.organizer-demo-notice{font-size:11px;color:var(--org-muted);margin-bottom:12px}
30
+ .organizer-account{display:flex;align-items:center;gap:10px;min-height:44px;flex-shrink:0;border:1px solid var(--org-line);padding:8px;text-align:left;border-radius:3px}
31
+ .organizer-account span{min-width:0}.organizer-account strong{display:block;font:600 11px Newsroom UI,system-ui;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.organizer-account small{display:block;font-size:9px;color:var(--org-muted);margin-top:2px}
32
+ .organizer-account-header{display:none;padding:8px;min-width:36px;min-height:36px}.organizer-account-header span{display:none}
33
+ .newsroom-organizer[data-sidebar-collapsed=true] .organizer-account-header{display:flex}
34
+ .organizer-account-popup{min-width:250px;max-width:calc(100vw - 32px);border:1px solid var(--org-line);padding:6px;border-radius:4px}
35
+ .organizer-account-popup [role=menuitem]{min-height:40px;font-size:12px}.organizer-account-popup .reader-auth{display:contents}.organizer-account-popup .reader-auth__error{display:block;white-space:normal;font-size:11px;padding:8px}
36
+ .organizer-bottom-nav{display:none}
37
+ .organizer-main:has(.organizer-native-board){display:flex;overflow:hidden}
38
+ .organizer-native-board{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;gap:12px}
39
+ .organizer-board-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0;flex-wrap:wrap}
40
+ .organizer-board-toolbar [role=group],.organizer-board-pages{display:flex;gap:8px;flex-wrap:wrap}
41
+ .organizer-board-toolbar button,.organizer-board-pages button{min-height:44px;padding:8px 12px;border:1px solid var(--org-line)}
42
+ .organizer-board-toolbar button[aria-pressed=true]{background:var(--org-ink);color:var(--org-paper)}
43
+ .organizer-board-note{font-size:11px;line-height:1.5;margin:0;color:var(--org-muted)}
44
+ .organizer-board-body{position:relative;display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-width:0;min-height:0;gap:16px}
45
+ .organizer-board-body[data-detail-open=true]{grid-template-columns:minmax(0,1fr) minmax(280px,360px)}
46
+ .organizer-board-body>.kb-issue-collection{height:100%;min-height:0;overflow:auto}
47
+ .organizer-board-body>.kb-issue-detail{overflow:auto;min-height:0}
48
+ .organizer-board-body .kb-issue-detail a{display:block;min-height:44px;text-decoration:underline;padding:12px 0}
49
+ .organizer-menu{height:min(85dvh,760px);max-height:85dvh;display:flex;flex-direction:column;padding:16px;border-radius:24px 24px 0 0;gap:20px}
50
+ .organizer-menu .organizer-destinations{overflow-y:auto}.organizer-menu .organizer-destinations section{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.organizer-menu .organizer-destinations h3{grid-column:1/-1;font-size:12px;text-transform:none;margin:0 0 4px}
51
+ .organizer-menu .organizer-destinations a{border:1px solid var(--org-line);padding:12px;color:var(--org-ink)}
52
+ @media(max-width:900px){
53
+ .newsroom-organizer,.newsroom-organizer[data-sidebar-collapsed=true]{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr) auto}
54
+ .organizer-sidebar,.organizer-toggle{display:none}.organizer-workspace{padding:0 16px}.organizer-topbar{gap:8px;height:55px;min-height:55px}
55
+ .organizer-masthead{font-size:24px;max-width:36vw}.organizer-topbar h1{font-size:10px}.organizer-account-header{display:flex}.organizer-header-actions{gap:4px;max-width:35%}
56
+ .organizer-bottom-nav{display:block;border-top:1px solid var(--org-ink);background:var(--org-paper);padding:4px 8px max(4px,env(safe-area-inset-bottom))}
57
+ .organizer-bottom-nav ul{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:4px;list-style:none;margin:0;padding:0}
58
+ .organizer-bottom-nav :is(a,button){display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;min-height:54px;width:100%;font:400 10px Newsroom UI,system-ui;padding:6px 0;text-decoration:none;border-radius:8px}
59
+ .organizer-bottom-nav a[aria-current=page]{background:var(--muted);font-weight:600}
60
+ .organizer-board-body[data-detail-open=true]{grid-template-columns:minmax(0,1fr)}
61
+ .organizer-board-body>.kb-issue-detail{position:absolute;inset:0;z-index:2;overflow:auto}
62
+ }
63
+ @media(prefers-reduced-motion:reduce){.newsroom-organizer,.newsroom-organizer *,.organizer-menu,.organizer-menu *,.organizer-account-popup,.organizer-account-popup *{scroll-behavior:auto;animation:none!important;transition:none!important}}
@@ -1,5 +1,9 @@
1
1
  "use client";
2
2
 
3
+ import type { PrimingPanelProps } from "cyclotron/components/priming-panel";
4
+ import { ReferencePriming } from "./reference-priming";
5
+ import { ReferenceCurationControls } from "./reference-curation-controls";
6
+
3
7
  import {
4
8
  ArchiveIcon,
5
9
  ChevronLeftIcon,
@@ -44,21 +48,38 @@ import {
44
48
  import { normalizeReferenceCurationStatus } from "../lib/reference-policy";
45
49
  import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
46
50
  import { ReferenceSourcePreview } from "./reference-source-preview";
51
+ import { ReferenceRelevanceReview } from "./reference-relevance-review";
52
+ import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
53
+ import type { CyclotronStatus } from "cyclotron/cyclotron-status";
54
+ import type { RelevanceCuration, RelevanceDecisionView } from "../lib/relevance-decisions";
47
55
  import {
48
56
  loadReferenceAttachmentsForLineageId,
49
57
  loadStoragePathUrl,
50
58
  } from "./news-desk-taxonomy-client";
51
59
 
52
60
  type NewsroomReferencesViewProps = {
61
+ /** Present only when the host has configured optional example priming. */
62
+ priming?: PrimingPanelProps;
63
+ /** The relevance cyclotron's status snapshot, shown above the list. */
64
+ cyclotronStatus?: CyclotronStatus | null;
65
+ /** Present for editors who may ask for a manual review rate. */
66
+ onRequestReviewRate?: (choice: ReviewRateChoice) => Promise<void> | void;
53
67
  demo?: boolean;
68
+ /** The relevance cyclotron's current decisions, keyed by reference lineage. */
69
+ decisions?: Map<string, RelevanceDecisionView>;
54
70
  disabled?: boolean;
71
+ onRelevanceReview?: (reference: ReferenceRecord, curation: RelevanceCuration) => void;
55
72
  initialReferenceLineageId?: string | null;
56
73
  onReview: (reference: ReferenceRecord, action: ReferenceCurationAction) => void;
57
74
  referenceAttachments?: ReferenceAttachmentRecord[];
58
75
  references: ReferenceRecord[];
59
76
  };
60
77
 
61
- const STATUS_FILTERS: Array<{ key: ReferenceStatusFilter; label: string }> = [
78
+ type ReferenceListFilter = ReferenceStatusFilter | "needs_review";
79
+
80
+ const NEEDS_REVIEW_FILTER: { key: ReferenceListFilter; label: string } = { key: "needs_review", label: "Needs review" };
81
+
82
+ const STATUS_FILTERS: Array<{ key: ReferenceListFilter; label: string }> = [
62
83
  { key: "all", label: "All" },
63
84
  { key: "pending", label: "Pending" },
64
85
  { key: "accepted", label: "Accepted" },
@@ -183,16 +204,30 @@ function useReferencePreviewAttachments(
183
204
  return previewAttachments;
184
205
  }
185
206
 
207
+ function decisionSummary(decision: RelevanceDecisionView): string {
208
+ const sure = decision.confidence == null ? "confidence unknown" : `${Math.round(decision.confidence * 100)}% sure`;
209
+ return `${decision.label} · ${sure}${decision.version != null ? ` · version ${decision.version}` : ""}`;
210
+ }
211
+
212
+ function needsReview(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): boolean {
213
+ return Boolean(decision?.reviewRecommended)
214
+ && normalizeReferenceCurationStatus(reference.curationStatus) === "pending";
215
+ }
216
+
186
217
  function ReferenceDetailPanel({
218
+ decision,
187
219
  demo,
188
220
  disabled,
221
+ onRelevanceReview,
189
222
  onReview,
190
223
  previewAttachments,
191
224
  reference,
192
225
  onClose,
193
226
  }: {
227
+ decision?: RelevanceDecisionView;
194
228
  demo?: boolean;
195
229
  disabled?: boolean;
230
+ onRelevanceReview?: (curation: RelevanceCuration) => void;
196
231
  onReview: (action: ReferenceCurationAction) => void;
197
232
  previewAttachments: ReferenceAttachmentRecord[];
198
233
  reference: ReferenceRecord;
@@ -224,11 +259,25 @@ function ReferenceDetailPanel({
224
259
  ) : null}
225
260
  </div>
226
261
 
262
+ {decision && status === "pending" && onRelevanceReview ? (
263
+ <ReferenceRelevanceReview
264
+ decision={decision}
265
+ disabled={disabled}
266
+ onCurate={onRelevanceReview}
267
+ referenceLineageId={lineageId}
268
+ />
269
+ ) : decision ? (
270
+ <p className="text-sm text-muted-foreground" data-reference-relevance-decision={lineageId}>
271
+ Cyclotron decision: {decisionSummary(decision)}
272
+ </p>
273
+ ) : null}
274
+
227
275
  <div
228
276
  className="flex flex-wrap items-center gap-2"
229
277
  data-news-desk-reference-curation-cluster
230
278
  data-reference-curation-status={status}
231
279
  >
280
+ {decision && status === "pending" && onRelevanceReview ? null : <>
232
281
  <Button
233
282
  aria-label="Accept reference"
234
283
  aria-pressed={status === "accepted"}
@@ -255,6 +304,7 @@ function ReferenceDetailPanel({
255
304
  <ThumbsDownIcon className="size-4" />
256
305
  Reject
257
306
  </Button>
307
+ </>}
258
308
  <Button
259
309
  aria-label="Archive reference"
260
310
  disabled={archiveDisabled}
@@ -347,8 +397,13 @@ function ReferenceDetailPanel({
347
397
  }
348
398
 
349
399
  export function NewsroomReferencesView({
400
+ priming,
401
+ cyclotronStatus,
402
+ decisions,
403
+ onRequestReviewRate,
350
404
  demo = false,
351
405
  disabled = false,
406
+ onRelevanceReview,
352
407
  initialReferenceLineageId = null,
353
408
  onReview,
354
409
  referenceAttachments = [],
@@ -365,13 +420,20 @@ export function NewsroomReferencesView({
365
420
  const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
366
421
  const statusCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
367
422
 
368
- const [statusFilter, setStatusFilter] = useState<ReferenceStatusFilter>("all");
423
+ const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>("all");
424
+ const needsReviewCount = useMemo(
425
+ () => canonicalReferences.filter((reference) => needsReview(reference, decisions?.get(referenceLineageId(reference)))).length,
426
+ [canonicalReferences, decisions],
427
+ );
428
+ const filters = decisions?.size ? [NEEDS_REVIEW_FILTER, ...STATUS_FILTERS] : STATUS_FILTERS;
369
429
  const [selectedLineageId, setSelectedLineageId] = useState(routeLineageId);
370
430
  const [mobileDetailOpen, setMobileDetailOpen] = useState(Boolean(routeLineageId) && isMobileDetail);
371
431
 
372
432
  const filteredReferences = useMemo(
373
- () => filterReferencesByStatus(canonicalReferences, statusFilter),
374
- [canonicalReferences, statusFilter],
433
+ () => statusFilter === "needs_review"
434
+ ? canonicalReferences.filter((reference) => needsReview(reference, decisions?.get(referenceLineageId(reference))))
435
+ : filterReferencesByStatus(canonicalReferences, statusFilter),
436
+ [canonicalReferences, decisions, statusFilter],
375
437
  );
376
438
 
377
439
  const selectedReference = useMemo(
@@ -390,7 +452,7 @@ export function NewsroomReferencesView({
390
452
  ? filteredReferences[selectedIndex + 1]
391
453
  : null;
392
454
 
393
- const syncStatusUrl = useCallback((nextStatus: ReferenceStatusFilter) => {
455
+ const syncStatusUrl = useCallback((nextStatus: ReferenceListFilter) => {
394
456
  if (demo) return;
395
457
  syncBrowserNewsroomIndexUrl("references", {
396
458
  status: nextStatus === "all" ? "" : nextStatus,
@@ -452,22 +514,31 @@ export function NewsroomReferencesView({
452
514
  Review source intake before it becomes accepted evidence.
453
515
  </p>
454
516
  </div>
517
+ <ReferenceCurationControls disabled={disabled} />
518
+ {priming && !disabled ? <ReferencePriming {...priming} /> : null}
519
+ {cyclotronStatus ? (
520
+ <div className="shrink-0">
521
+ <ReferenceCyclotronStatus onRequestRate={disabled ? undefined : onRequestReviewRate} status={cyclotronStatus} />
522
+ </div>
523
+ ) : null}
455
524
 
456
525
  <Tabs
457
526
  className="shrink-0"
458
527
  defaultValue="all"
459
528
  onValueChange={(value) => {
460
- const next = value as ReferenceStatusFilter;
529
+ const next = value as ReferenceListFilter;
461
530
  setStatusFilter(next);
462
531
  syncStatusUrl(next);
463
532
  }}
464
533
  value={statusFilter}
465
534
  >
466
535
  <TabsList className="h-auto w-full flex-wrap justify-start gap-1 bg-muted/40 p-1">
467
- {STATUS_FILTERS.map((filter) => {
536
+ {filters.map((filter) => {
468
537
  const count = filter.key === "all"
469
538
  ? canonicalReferences.length
470
- : statusCounts[filter.key] ?? 0;
539
+ : filter.key === "needs_review"
540
+ ? needsReviewCount
541
+ : statusCounts[filter.key] ?? 0;
471
542
  return (
472
543
  <TabsTrigger className="text-xs sm:text-sm" key={filter.key} value={filter.key}>
473
544
  {filter.label}
@@ -484,6 +555,7 @@ export function NewsroomReferencesView({
484
555
  const lineageId = referenceLineageId(reference);
485
556
  const status = normalizeReferenceCurationStatus(reference.curationStatus);
486
557
  const active = selectedLineageId === lineageId;
558
+ const decision = decisions?.get(lineageId);
487
559
  return (
488
560
  <button
489
561
  aria-current={active ? "true" : undefined}
@@ -504,6 +576,14 @@ export function NewsroomReferencesView({
504
576
  <Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
505
577
  </div>
506
578
  <p className="mt-2 text-xs text-muted-foreground">{formatReferenceListDate(reference)}</p>
579
+ {decision ? (
580
+ <p className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground" data-reference-relevance-row={lineageId}>
581
+ <span>Decision: {decisionSummary(decision)}</span>
582
+ {needsReview(reference, decision) ? (
583
+ <Badge variant="outline"><span aria-hidden="true">●</span> Needs review</Badge>
584
+ ) : null}
585
+ </p>
586
+ ) : null}
507
587
  </button>
508
588
  );
509
589
  }) : (
@@ -552,8 +632,10 @@ export function NewsroomReferencesView({
552
632
  <ScrollArea className="min-h-0 flex-1">
553
633
  {selectedReference ? (
554
634
  <ReferenceDetailPanel
635
+ decision={decisions?.get(referenceLineageId(selectedReference))}
555
636
  demo={demo}
556
637
  disabled={disabled}
638
+ onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
557
639
  onReview={runReview}
558
640
  previewAttachments={previewAttachments}
559
641
  reference={selectedReference}
@@ -581,9 +663,11 @@ export function NewsroomReferencesView({
581
663
  {selectedReference ? (
582
664
  <ScrollArea className="max-h-[75dvh]">
583
665
  <ReferenceDetailPanel
666
+ decision={decisions?.get(referenceLineageId(selectedReference))}
584
667
  demo={demo}
585
668
  disabled={disabled}
586
669
  onClose={closeDetail}
670
+ onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
587
671
  onReview={runReview}
588
672
  previewAttachments={previewAttachments}
589
673
  reference={selectedReference}
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { browserPathToInternalNewsroomPath } from "../lib/newsroom-base-path";
3
4
  import type { BotAvatarState } from "anthus-vultus";
4
5
  import dynamic from "next/dynamic";
5
6
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
@@ -152,7 +153,8 @@ export function PapyrusConsoleShell({ children }: PapyrusConsoleShellProps) {
152
153
  const [session, setSession] = useState<ReaderSessionSnapshot | null>(null);
153
154
  const [open, setOpen] = useState(false);
154
155
  const [openingReferenceChat, setOpeningReferenceChat] = useState(false);
155
- const isNewsroomPath = pathname === "/newsroom" || pathname.startsWith("/newsroom/");
156
+ const internalPathname = browserPathToInternalNewsroomPath(pathname);
157
+ const isNewsroomPath = internalPathname === "/newsroom" || internalPathname.startsWith("/newsroom/");
156
158
  const canUseConsole = Boolean(session?.hasSession && session.groups.some((group) => group === "editor" || group === "admin"));
157
159
  const shouldOfferConsole = isNewsroomPath && canUseConsole;
158
160
 
@@ -433,7 +435,7 @@ function ConsolePanel({ actorEmail, actorLabel, onClose }: { actorEmail: string
433
435
  const handledNavigationMessageIdsRef = useRef<Set<string>>(new Set());
434
436
 
435
437
  const currentWebPath = useMemo(() => {
436
- const path = pathname ?? "/newsroom";
438
+ const path = browserPathToInternalNewsroomPath(pathname ?? "/newsroom");
437
439
  const query = searchParams.toString();
438
440
  return query ? `${path}?${query}` : path;
439
441
  }, [pathname, searchParams]);
@@ -12,6 +12,9 @@ export type PictogramFigureProps = {
12
12
  credit: string;
13
13
  figureClassName: string;
14
14
  height: number;
15
+ /** Rhythm blog layout: solved frame size in CSS pixels; wins over `height`/`width` when set. */
16
+ frameHeight?: number;
17
+ frameWidth?: number;
15
18
  layout?: ArticleImageLayout;
16
19
  priority?: boolean;
17
20
  sizes: string;
@@ -34,6 +37,8 @@ export function PictogramFigure({
34
37
  caption,
35
38
  credit,
36
39
  figureClassName,
40
+ frameHeight,
41
+ frameWidth,
37
42
  height,
38
43
  priority = false,
39
44
  sizes,
@@ -41,6 +46,8 @@ export function PictogramFigure({
41
46
  themeVariants,
42
47
  width,
43
48
  }: PictogramFigureProps) {
49
+ const imageHeight = frameHeight ?? height;
50
+ const imageWidth = frameWidth ?? width;
44
51
  const resolvedTheme = useResolvedPapyrusTheme();
45
52
  const themedImageSrc = resolveThemedImageSrc(src, themeVariants, resolvedTheme);
46
53
 
@@ -53,8 +60,8 @@ export function PictogramFigure({
53
60
  <Image
54
61
  src={themedImageSrc}
55
62
  alt={alt}
56
- width={width}
57
- height={height}
63
+ width={imageWidth}
64
+ height={imageHeight}
58
65
  sizes={sizes}
59
66
  priority={priority}
60
67
  unoptimized={shouldBypassImageOptimization(themedImageSrc)}
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { getEditionSectionPath } from "../lib/edition-routes";
5
+ import { SITE_BRAND } from "../lib/site-brand";
6
+
7
+ export type PresentationHeaderSection = {
8
+ key: string;
9
+ label: string;
10
+ };
11
+
12
+ export type PresentationHeaderProps = {
13
+ editionDate?: string;
14
+ title?: string;
15
+ description?: string;
16
+ sections?: PresentationHeaderSection[];
17
+ editionBasePath?: string;
18
+ };
19
+
20
+ export function PresentationHeader({
21
+ editionDate,
22
+ title: contentTitle,
23
+ description,
24
+ sections,
25
+ editionBasePath,
26
+ }: PresentationHeaderProps) {
27
+ const title = SITE_BRAND.mastheadSource === "brand" ? SITE_BRAND.mastheadTitle : contentTitle;
28
+ const subtitle = SITE_BRAND.mastheadSource === "brand" ? SITE_BRAND.mastheadSubtitle : description;
29
+ const eyebrow = SITE_BRAND.mastheadEyebrow ?? null;
30
+ const tagline = SITE_BRAND.mastheadTagline ?? null;
31
+ const taglineLines = SITE_BRAND.mastheadTaglineLines ?? null;
32
+ const displayDate = editionDate
33
+ ? SITE_BRAND.mastheadDateFormat === "formatted"
34
+ ? formatMastheadDate(editionDate)
35
+ : editionDate
36
+ : null;
37
+
38
+ const eyebrowParts = eyebrow ? splitEyebrow(eyebrow) : null;
39
+
40
+ return (
41
+ <>
42
+ <header className="presentation-header">
43
+ <div className="presentation-header__copy-stack">
44
+ {eyebrowParts ? (
45
+ <span className="presentation-header__eyebrow">
46
+ <span className="presentation-header__eyebrow-strong">{eyebrowParts.lead}</span>
47
+ {eyebrowParts.rest ? (
48
+ <span className="presentation-header__eyebrow-muted">{eyebrowParts.rest}</span>
49
+ ) : null}
50
+ </span>
51
+ ) : null}
52
+ <h1>
53
+ {SITE_BRAND.mastheadWordSplit && title
54
+ ? title.split(/\s+/).map((word) => (
55
+ <span className="presentation-header__word" key={word}>
56
+ <span className="presentation-header__word-text">{word}</span>
57
+ </span>
58
+ ))
59
+ : title}
60
+ </h1>
61
+ <div className="presentation-header__meta">
62
+ {subtitle && !eyebrow ? <span className="presentation-header__subtitle">{subtitle}</span> : null}
63
+ {displayDate ? <p className="presentation-header__date">{displayDate}</p> : null}
64
+ </div>
65
+ </div>
66
+ {tagline ? (
67
+ <span className="presentation-header__tagline">
68
+ {taglineLines && taglineLines.length > 0
69
+ ? taglineLines.map((line, index) => (
70
+ <span className="presentation-header__tagline-line" key={index}>
71
+ <span className="presentation-header__tagline-emphasis">{line.emphasis}</span>
72
+ <span className="presentation-header__tagline-tail">{line.tail}</span>
73
+ {index < taglineLines.length - 1 ? " " : null}
74
+ </span>
75
+ ))
76
+ : tagline}
77
+ </span>
78
+ ) : null}
79
+ </header>
80
+ {sections && sections.length > 0 ? (
81
+ <nav className="presentation-section-nav" aria-label="Edition sections">
82
+ {sections.map((section) => (
83
+ <Link
84
+ href={getSectionHref(section, { editionBasePath, editionDate })}
85
+ key={section.key}
86
+ >
87
+ {section.label}
88
+ </Link>
89
+ ))}
90
+ </nav>
91
+ ) : null}
92
+ </>
93
+ );
94
+ }
95
+
96
+ function getSectionHref(
97
+ section: PresentationHeaderSection,
98
+ options: { editionBasePath?: string; editionDate?: string },
99
+ ): string {
100
+ if (SITE_BRAND.sectionLinkStrategy === "anchor") {
101
+ const anchor = `#${getSectionAnchorId(section.key)}`;
102
+ return options.editionBasePath ? `${options.editionBasePath}${anchor}` : anchor;
103
+ }
104
+ if (options.editionBasePath) {
105
+ return `${options.editionBasePath}/section/${encodeURIComponent(section.key)}`;
106
+ }
107
+ if (options.editionDate) {
108
+ const datedPath = getEditionSectionPath(options.editionDate, section.key);
109
+ return datedPath.startsWith("//") ? `#${getSectionAnchorId(section.key)}` : datedPath;
110
+ }
111
+ return `#${getSectionAnchorId(section.key)}`;
112
+ }
113
+
114
+ export function getSectionAnchorId(sectionKey: string): string {
115
+ return `section-${sectionKey}`;
116
+ }
117
+
118
+ function splitEyebrow(text: string): { lead: string; rest: string } {
119
+ const trimmed = text.trim();
120
+ const firstSpace = trimmed.indexOf(" ");
121
+ if (firstSpace === -1) return { lead: trimmed, rest: "" };
122
+ return { lead: trimmed.slice(0, firstSpace), rest: trimmed.slice(firstSpace + 1) };
123
+ }
124
+
125
+ export function formatMastheadDate(value: string): string {
126
+ const normalized = value?.trim();
127
+ if (!normalized) return value;
128
+ const date = new Date(`${normalized}T00:00:00`);
129
+ if (Number.isNaN(date.getTime())) return value;
130
+ return new Intl.DateTimeFormat("en-US", {
131
+ month: "long",
132
+ day: "numeric",
133
+ year: "numeric",
134
+ }).format(date);
135
+ }
@@ -0,0 +1,17 @@
1
+ type PresentationRhythmHRuleProps = {
2
+ /** Hide in wide 2-column secondary pairs (items 1+2 side-by-side). */
3
+ hideInSecondaryPair?: boolean;
4
+ };
5
+
6
+ export function PresentationRhythmHRule({ hideInSecondaryPair = false }: PresentationRhythmHRuleProps) {
7
+ return (
8
+ <div
9
+ aria-hidden="true"
10
+ className="presentation-rhythm-hrule"
11
+ data-hide-in-secondary-pair={hideInSecondaryPair ? "true" : undefined}
12
+ role="presentation"
13
+ >
14
+ <div className="presentation-rhythm-hrule__line" />
15
+ </div>
16
+ );
17
+ }