@anthusai/papyrus 1.4.0-next.2 → 1.4.0-next.3

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.
@@ -126,9 +126,9 @@ function bundleFromPip(outputDir, options) {
126
126
  }
127
127
  export function bundleFromCheckout(outputDir, options) {
128
128
  fs.copyFileSync(path.join(packageRoot, "amplify/functions", options.functionDir, "handler.py"), path.join(outputDir, "handler.py"));
129
- for (const module of options.modules) {
130
- fs.mkdirSync(path.join(outputDir, module), { recursive: true });
131
- fs.cpSync(path.join(packageRoot, "src", module), path.join(outputDir, module), { recursive: true });
129
+ for (const moduleName of options.modules) {
130
+ fs.mkdirSync(path.join(outputDir, moduleName), { recursive: true });
131
+ fs.cpSync(path.join(packageRoot, "src", moduleName), path.join(outputDir, moduleName), { recursive: true });
132
132
  }
133
133
  options.checkoutExtras?.(outputDir);
134
134
  copySiteFiles(outputDir, options.siteFiles);
@@ -164,9 +164,9 @@ export function bundleFromCheckout(outputDir: string, options: PythonBundleOptio
164
164
  path.join(packageRoot, "amplify/functions", options.functionDir, "handler.py"),
165
165
  path.join(outputDir, "handler.py"),
166
166
  );
167
- for (const module of options.modules) {
168
- fs.mkdirSync(path.join(outputDir, module), { recursive: true });
169
- fs.cpSync(path.join(packageRoot, "src", module), path.join(outputDir, module), { recursive: true });
167
+ for (const moduleName of options.modules) {
168
+ fs.mkdirSync(path.join(outputDir, moduleName), { recursive: true });
169
+ fs.cpSync(path.join(packageRoot, "src", moduleName), path.join(outputDir, moduleName), { recursive: true });
170
170
  }
171
171
  options.checkoutExtras?.(outputDir);
172
172
  copySiteFiles(outputDir, options.siteFiles);
@@ -1,4 +1,6 @@
1
1
  import { notFound } from "next/navigation";
2
+ import { resolveActiveSiteBrand } from "../../../../lib/site-brand-tenant";
3
+ import { NewsroomBoardView } from "../../../../components/newsroom-board-view";
2
4
  import { NewsDeskPage } from "../../../../components/news-desk-page";
3
5
 
4
6
  export const dynamic = "force-dynamic";
@@ -13,5 +15,6 @@ export default async function NewsroomDeepSectionPage({ params, searchParams }:
13
15
  const normalizedSectionId = decodeURIComponent(sectionId ?? "").trim();
14
16
  if (!normalizedSectionId) notFound();
15
17
 
18
+ if (resolveActiveSiteBrand().newsroomLayout === "organizer") return <NewsroomBoardView subject="desk" deskId={normalizedSectionId} />;
16
19
  return <NewsDeskPage sectionPageId={normalizedSectionId} searchParams={searchParams} />;
17
20
  }
@@ -2172,40 +2172,45 @@ const CONTENT_ITEMS_BY_STAGE_QUERY = `
2172
2172
  }
2173
2173
  `;
2174
2174
 
2175
- export async function loadNewsroomContentItemVersions(options: { includeCollapsedUntilOpenedStages?: readonly string[] } = {}): Promise<ContentItemVersionRecord[]> {
2176
- const stageDefinitions = getContentItemStageDefinitions();
2177
- const stagesToLoad = [
2178
- ...stageDefinitions
2179
- .filter(definition => !definition.collapsedUntilOpened || options.includeCollapsedUntilOpenedStages?.includes(definition.key))
2180
- .map(definition => definition.key),
2181
- ...legacyStageKeysToLoad(stageDefinitions),
2182
- ];
2175
+ export const CONTENT_ITEM_COLUMN_PAGE_LIMIT = 25;
2176
+
2177
+ export type ContentItemStagePage = {
2178
+ versions: ContentItemVersionRecord[];
2179
+ nextToken: string | null;
2180
+ };
2181
+
2182
+ /** One newest-updated-first page of the content items in one stage, so a board column can load as it is scrolled. */
2183
+ export async function loadNewsroomContentItemStagePage(
2184
+ stage: string,
2185
+ nextToken: string | null = null,
2186
+ limit: number = CONTENT_ITEM_COLUMN_PAGE_LIMIT,
2187
+ ): Promise<ContentItemStagePage> {
2183
2188
  if (isLocalPreviewEnabled()) {
2184
- return createLocalPreviewContentItemVersions().filter(version => stagesToLoad.includes(version.stage ?? ""));
2189
+ const versionsInStage = createLocalPreviewContentItemVersions().filter(version => version.stage === stage);
2190
+ const offset = nextToken ? Number(nextToken) : 0;
2191
+ const followingOffset = offset + limit;
2192
+ return {
2193
+ versions: versionsInStage.slice(offset, followingOffset),
2194
+ nextToken: followingOffset < versionsInStage.length ? String(followingOffset) : null,
2195
+ };
2185
2196
  }
2186
2197
  configureAmplifyClient();
2187
2198
  const client = generateClient<Schema>() as unknown as {
2188
2199
  graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<ContentItemVersionRecord>>;
2189
2200
  };
2190
2201
  const field = "listItemsByStageAndUpdatedAt";
2191
- const versionsPerStage = await Promise.all(stagesToLoad.map(async (stage) => {
2192
- const versions: ContentItemVersionRecord[] = [];
2193
- let nextToken: string | null = null;
2194
- do {
2195
- const response: GraphQLConnectionResponse<ContentItemVersionRecord> = await client.graphql({
2196
- query: CONTENT_ITEMS_BY_STAGE_QUERY,
2197
- variables: { stage, sortDirection: "DESC", limit: NEWSROOM_PAGE_LIMIT, nextToken },
2198
- authMode: USER_POOL_AUTH_MODE,
2199
- });
2200
- assertNoGraphQLErrors(response.errors);
2201
- const connection = response.data?.[field];
2202
- if (!connection) throw new Error(`Missing GraphQL connection payload for ${field}.`);
2203
- versions.push(...((connection.items ?? []).filter(Boolean) as ContentItemVersionRecord[]));
2204
- nextToken = connection.nextToken ?? null;
2205
- } while (nextToken);
2206
- return versions;
2207
- }));
2208
- return versionsPerStage.flat();
2202
+ const response: GraphQLConnectionResponse<ContentItemVersionRecord> = await client.graphql({
2203
+ query: CONTENT_ITEMS_BY_STAGE_QUERY,
2204
+ variables: { stage, sortDirection: "DESC", limit, nextToken },
2205
+ authMode: USER_POOL_AUTH_MODE,
2206
+ });
2207
+ assertNoGraphQLErrors(response.errors);
2208
+ const connection = response.data?.[field];
2209
+ if (!connection) throw new Error(`Missing GraphQL connection payload for ${field}.`);
2210
+ return {
2211
+ versions: (connection.items ?? []).filter(Boolean) as ContentItemVersionRecord[],
2212
+ nextToken: connection.nextToken ?? null,
2213
+ };
2209
2214
  }
2210
2215
 
2211
2216
  const PUBLISHED_ITEMS_QUERY = `
@@ -2231,6 +2236,7 @@ type PublishedItemBoardRow = {
2231
2236
  };
2232
2237
 
2233
2238
  export async function loadNewsroomPublishedContentItemVersions(): Promise<ContentItemVersionRecord[]> {
2239
+ if (isLocalPreviewEnabled()) return [];
2234
2240
  configureAmplifyClient();
2235
2241
  const client = generateClient<Schema>() as unknown as {
2236
2242
  graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<PublishedItemBoardRow>>;
@@ -2,12 +2,10 @@
2
2
 
3
3
  import { useEffect, useMemo, useRef, useState } from "react";
4
4
  import Link from "next/link";
5
- import { IssueCollection, IssueDetailPanel, useBoardColumnState } from "@kanbus/ui";
5
+ import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter, useBoardColumnState } from "@kanbus/ui";
6
6
  import "@kanbus/ui/styles/kanban.css";
7
7
  import "./newsroom-newsprint-board.css";
8
8
  import { PlusIcon } from "lucide-react";
9
- import { buttonVariants } from "./ui/button";
10
- import { cn } from "../lib/utils";
11
9
  import { newsroomHref } from "../lib/newsroom-base-path";
12
10
  import {
13
11
  ASSIGNMENT_BOARD_TYPE,
@@ -15,14 +13,20 @@ import {
15
13
  canReadNewsroomBoard,
16
14
  contentItemBoardConfig,
17
15
  contentItemBoardType,
16
+ deskBoardConfig,
17
+ deskContainsAssignment,
18
+ deskContainsContentItem,
18
19
  projectAssignmentBoard,
19
20
  projectContentItemBoard,
20
21
  type NativeBoardIssue,
21
22
  } from "../lib/newsroom-board";
22
- import type { AssignmentRecord } from "../lib/category-repository";
23
+ import type { AssignmentRecord, NewsroomSectionRecord } from "../lib/category-repository";
24
+ import { displayNewsroomSectionShortTitle } from "../lib/newsroom-sections";
23
25
  import { getContentItemName, getContentItemStageDefinitions, SITE_BRAND } from "../lib/site-brand";
24
26
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
25
- import { loadNewsroomAssignmentPage, loadNewsroomContentItemVersions, loadNewsroomPublishedContentItemVersions } from "./news-desk-taxonomy-client";
27
+ import { loadNewsroomAssignmentPage, loadNewsroomPublishedContentItemVersions } from "./news-desk-taxonomy-client";
28
+ import { usePagedContentItemVersions } from "./use-paged-content-item-versions";
29
+ import type { ContentItemVersionRecord } from "../lib/content-item-lifecycle";
26
30
  import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
27
31
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
28
32
  import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
@@ -31,6 +35,7 @@ import { readLocalReaderSettings, subscribeReaderSettingsChanges } from "./reade
31
35
 
32
36
  const STATIC_MOTION = { mode: "static" } as const;
33
37
  const BOARD_REFRESH_INTERVAL_MILLISECONDS = 30_000;
38
+ const COLUMN_LOAD_MORE_DISTANCE_PIXELS = 240;
34
39
 
35
40
  async function loadEveryNewsroomAssignment() {
36
41
  const assignments: AssignmentRecord[] = [];
@@ -43,81 +48,112 @@ async function loadEveryNewsroomAssignment() {
43
48
  return assignments;
44
49
  }
45
50
 
46
- export type NewsroomBoardSubject = "contentItem" | "assignment";
51
+ export type NewsroomBoardSubject = "contentItem" | "assignment" | "desk";
47
52
 
48
53
  /** Read-only board of one record kind over authenticated CMS records; existing workspaces own every mutation. */
49
- export function NewsroomBoardView({ subject }: { subject: NewsroomBoardSubject }) {
54
+ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardSubject; deskId?: string }) {
50
55
  const session = useOptionalNewsDeskClient();
51
56
  const allowed = canReadNewsroomBoard(session?.shell.phase);
52
57
  const theme = useResolvedPapyrusTheme();
53
58
  const [lowMotion, setLowMotion] = useState(false);
54
- const [issues, setIssues] = useState<NativeBoardIssue[]>([]);
59
+ const [assignmentBoardIssues, setAssignmentBoardIssues] = useState<NativeBoardIssue[]>([]);
60
+ const [publishedVersions, setPublishedVersions] = useState<ContentItemVersionRecord[]>([]);
61
+ const [assignments, setAssignments] = useState<AssignmentRecord[]>([]);
55
62
  const [view, setView] = useState<"board" | "list">("board");
56
63
  const [selected, setSelected] = useState<string | null>(null);
57
- const [loading, setLoading] = useState(false);
58
- const [error, setError] = useState<string | null>(null);
64
+ const [supportingRecordsLoading, setSupportingRecordsLoading] = useState(false);
65
+ const [supportingRecordsError, setSupportingRecordsError] = useState<string | null>(null);
59
66
  const generation = useRef(0);
60
- const openedCollapsedUntilOpenedStagesRef = useRef<string[]>([]);
61
- const refreshIssuesRef = useRef<((initial: boolean) => void) | null>(null);
67
+ const boardBodyRef = useRef<HTMLDivElement | null>(null);
68
+ const [typeFilter, setTypeFilter] = useState<string | null>("all");
69
+ const showsContentItems = subject !== "assignment";
70
+ const contentItemColumnKeyPrefix = `${contentItemBoardType()}/`;
71
+ const boardType = subject === "assignment" ? ASSIGNMENT_BOARD_TYPE : contentItemBoardType();
72
+ const deskAssignmentIssues = useMemo(() => subject === "desk" && deskId
73
+ ? projectAssignmentBoard(assignments.filter(assignment => deskContainsAssignment(assignment, deskId))) : [], [assignments, deskId, subject]);
74
+ const columnStateConfig = useMemo(() => subject === "contentItem" ? contentItemBoardConfig([])
75
+ : subject === "desk" ? deskBoardConfig(deskAssignmentIssues)
76
+ : assignmentBoardConfig(assignmentBoardIssues), [assignmentBoardIssues, deskAssignmentIssues, subject]);
77
+ const columnState = useBoardColumnState({ config: columnStateConfig, storageKey: `papyrus.cms.columns.v1.${SITE_BRAND.id}.${subject}` });
78
+ const openedCollapsedUntilOpenedStages = useMemo(() => !showsContentItems ? [] : getContentItemStageDefinitions()
79
+ .filter(definition => definition.collapsedUntilOpened && !columnState.collapsedColumns.has(`${contentItemColumnKeyPrefix}${definition.key}`))
80
+ .map(definition => definition.key), [contentItemColumnKeyPrefix, columnState.collapsedColumns, showsContentItems]);
81
+ const pagedContentItems = usePagedContentItemVersions({ enabled: allowed && showsContentItems, openedCollapsedUntilOpenedStages });
62
82
  useEffect(() => {
63
83
  setLowMotion(readLocalReaderSettings().motion === "low");
64
84
  return subscribeReaderSettingsChanges(settings => setLowMotion(settings.motion === "low"));
65
85
  }, []);
66
86
  useEffect(() => {
67
87
  const request = ++generation.current;
68
- setIssues([]); setSelected(null); setError(null);
69
- if (!allowed) { setLoading(false); return; }
70
- const loadIssues = () => subject === "contentItem"
71
- ? Promise.all([
72
- Promise.all([loadNewsroomContentItemVersions({ includeCollapsedUntilOpenedStages: openedCollapsedUntilOpenedStagesRef.current }), loadNewsroomPublishedContentItemVersions()]).then(([workingVersions, publishedVersions]) => [...workingVersions, ...publishedVersions]),
73
- loadEveryNewsroomAssignment(),
74
- ]).then(([contentItemVersions, assignments]) => projectContentItemBoard({ contentItemVersions, assignments }))
75
- : loadEveryNewsroomAssignment().then(projectAssignmentBoard);
88
+ setAssignmentBoardIssues([]); setPublishedVersions([]); setAssignments([]); setSelected(null); setSupportingRecordsError(null);
89
+ if (!allowed) { setSupportingRecordsLoading(false); return; }
90
+ const loadSupportingRecords = () => subject !== "assignment"
91
+ ? Promise.all([loadNewsroomPublishedContentItemVersions(), loadEveryNewsroomAssignment()]).then(([published, everyAssignment]) => {
92
+ if (generation.current === request) { setPublishedVersions(published); setAssignments(everyAssignment); }
93
+ })
94
+ : loadEveryNewsroomAssignment().then(everyAssignment => {
95
+ if (generation.current === request) setAssignmentBoardIssues(projectAssignmentBoard(everyAssignment));
96
+ });
76
97
  const refresh = (initial: boolean) => {
77
- if (initial) setLoading(true);
78
- loadIssues().then(loadedIssues => {
79
- if (generation.current !== request) return;
80
- setIssues(loadedIssues);
81
- setError(null);
98
+ if (initial) setSupportingRecordsLoading(true);
99
+ loadSupportingRecords().then(() => {
100
+ if (generation.current === request) setSupportingRecordsError(null);
82
101
  }).catch((reason: unknown) => {
83
- if (generation.current === request && initial) setError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
84
- }).finally(() => { if (generation.current === request && initial) setLoading(false); });
102
+ if (generation.current === request && initial) setSupportingRecordsError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
103
+ }).finally(() => { if (generation.current === request && initial) setSupportingRecordsLoading(false); });
85
104
  };
86
- refreshIssuesRef.current = refresh;
87
105
  refresh(true);
88
106
  const refreshWhenVisible = () => { if (document.visibilityState === "visible") refresh(false); };
89
107
  const interval = window.setInterval(refreshWhenVisible, BOARD_REFRESH_INTERVAL_MILLISECONDS);
90
108
  document.addEventListener("visibilitychange", refreshWhenVisible);
91
109
  return () => { generation.current = request + 1; window.clearInterval(interval); document.removeEventListener("visibilitychange", refreshWhenVisible); };
92
110
  }, [allowed, subject]);
93
- const config = useMemo(() => subject === "contentItem" ? contentItemBoardConfig(issues) : assignmentBoardConfig(issues), [issues, subject]);
94
- const boardType = subject === "contentItem" ? contentItemBoardType() : ASSIGNMENT_BOARD_TYPE;
95
- const columnState = useBoardColumnState({ config, storageKey: `papyrus.cms.columns.v1.${SITE_BRAND.id}.${subject}` });
96
- const openedCollapsedUntilOpenedStageKeys = useMemo(() => subject !== "contentItem" ? "" : getContentItemStageDefinitions()
97
- .filter(definition => definition.collapsedUntilOpened && !columnState.collapsedColumns.has(`${boardType}/${definition.key}`))
98
- .map(definition => definition.key).join(","), [boardType, columnState.collapsedColumns, subject]);
111
+ const issues = useMemo(() => {
112
+ if (subject === "assignment") return assignmentBoardIssues;
113
+ if (subject === "contentItem") return projectContentItemBoard({ contentItemVersions: [...pagedContentItems.versions, ...publishedVersions], assignments });
114
+ const versionsOnDesk = [...pagedContentItems.versions, ...publishedVersions].filter(version => deskId && deskContainsContentItem(version, deskId));
115
+ return [...projectContentItemBoard({ contentItemVersions: versionsOnDesk, assignments: assignments.filter(assignment => deskId && deskContainsAssignment(assignment, deskId)) }), ...deskAssignmentIssues];
116
+ }, [assignmentBoardIssues, assignments, deskAssignmentIssues, deskId, pagedContentItems.versions, publishedVersions, subject]);
117
+ const loading = supportingRecordsLoading || (showsContentItems && pagedContentItems.loading);
118
+ const error = supportingRecordsError ?? (showsContentItems ? pagedContentItems.error : null);
119
+ const config = useMemo(() => subject === "contentItem" ? contentItemBoardConfig(issues) : subject === "desk" ? deskBoardConfig(issues) : assignmentBoardConfig(issues), [issues, subject]);
120
+ const columnStageFromElement = (element: Element | null) => {
121
+ const columnKey = element?.closest("[data-column-key]")?.getAttribute("data-column-key");
122
+ return columnKey?.startsWith(contentItemColumnKeyPrefix) ? columnKey.slice(contentItemColumnKeyPrefix.length) : null;
123
+ };
124
+ const loadMoreWhenColumnIsNearItsEnd = (columnScroll: HTMLElement) => {
125
+ const distanceToEnd = columnScroll.scrollHeight - columnScroll.scrollTop - columnScroll.clientHeight;
126
+ const stage = columnStageFromElement(columnScroll);
127
+ if (stage && distanceToEnd < COLUMN_LOAD_MORE_DISTANCE_PIXELS) pagedContentItems.loadMoreForStage(stage);
128
+ };
129
+ const handleBoardScroll = (event: React.UIEvent<HTMLDivElement>) => {
130
+ const target = event.target as HTMLElement;
131
+ if (showsContentItems && target.classList?.contains("kb-column-scroll")) loadMoreWhenColumnIsNearItsEnd(target);
132
+ };
99
133
  useEffect(() => {
100
- openedCollapsedUntilOpenedStagesRef.current = openedCollapsedUntilOpenedStageKeys ? openedCollapsedUntilOpenedStageKeys.split(",") : [];
101
- if (openedCollapsedUntilOpenedStageKeys) refreshIssuesRef.current?.(false);
102
- }, [openedCollapsedUntilOpenedStageKeys]);
103
- const pageTitle = subject === "contentItem" ? getContentItemName().plural : "Assignments";
134
+ if (!showsContentItems) return;
135
+ boardBodyRef.current?.querySelectorAll<HTMLElement>(".kb-column-scroll").forEach(loadMoreWhenColumnIsNearItsEnd);
136
+ });
137
+ const deskTitle = session?.shell.dashboard?.newsroomSections?.find(section => section.id === deskId)?.title ?? (deskId ? displayNewsroomSectionShortTitle({ id: deskId } as NewsroomSectionRecord) : "Desk");
138
+ const pageTitle = subject === "contentItem" ? getContentItemName().plural : subject === "desk" ? deskTitle : "Assignments";
104
139
  const issue = issues.find(item => item.id === selected);
105
140
  if (!allowed) return <NewsDeskAccessGate shell={session?.shell ?? null} />;
106
- return <NewsroomOpsShell activeTab={subject === "contentItem" ? "articles" : "assignments"} appTitle={SITE_BRAND.appTitle} backLabel={SITE_BRAND.backToHomeLabel} pageTitle={pageTitle}
141
+ return <NewsroomOpsShell activeTab={subject === "contentItem" ? "articles" : subject === "desk" ? "desks" : "assignments"} appTitle={SITE_BRAND.appTitle} backLabel={SITE_BRAND.backToHomeLabel} pageTitle={pageTitle}
107
142
  headerActions={<>
108
- <div role="group" aria-label="Record presentation" className="organizer-board-view-toggle"><button type="button" aria-pressed={view === "board"} onClick={() => setView("board")}>Board</button><button type="button" aria-pressed={view === "list"} onClick={() => setView("list")}>List</button></div>
109
- {subject === "contentItem" && (
110
- <Link className={cn(buttonVariants({ size: "sm" }))} data-newsroom-article-new href={getNewsroomNavHref(newsroomHref("articles", "new"))}>
111
- <PlusIcon aria-hidden="true" />
143
+ <span role="status" className="organizer-board-note">{loading ? "Loading records…" : `${issues.length} records loaded.`}</span>
144
+ <div role="group" aria-label="Record presentation" className="organizer-board-view-toggle"><button type="button" className="organizer-header-button" aria-pressed={view === "board"} onClick={() => setView("board")}>Board</button><button type="button" className="organizer-header-button" aria-pressed={view === "list"} onClick={() => setView("list")}>List</button></div>
145
+ {showsContentItems && (
146
+ <Link className="organizer-header-button" data-newsroom-article-new href={getNewsroomNavHref(newsroomHref("articles", "new"))}>
147
+ <PlusIcon size={14} aria-hidden="true" />
112
148
  {`New ${getContentItemName().singular.toLowerCase()}`}
113
149
  </Link>
114
150
  )}
115
151
  </>}>
116
152
  <div className="organizer-native-board papyrus-newsprint-board" data-color-mode={theme}>
117
153
  {error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
118
- <p role="status" className="organizer-board-note">{loading ? "Loading records…" : `${issues.length} records loaded.`}</p>
119
- <div className="organizer-board-body" data-detail-open={Boolean(issue)}>
120
- <IssueCollection issues={issues} config={config} query={{ type: boardType }} view={view}
154
+ {subject === "desk" && <IssueTypeFilter config={config} value={typeFilter} onChange={setTypeFilter} name="Record type" className="organizer-board-type-filter" />}
155
+ <div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={boardBodyRef} onScrollCapture={handleBoardScroll}>
156
+ <IssueCollection issues={issues} config={config} query={{ type: subject === "desk" ? issueTypeFromFilter(typeFilter) : boardType }} view={view}
121
157
  motion={lowMotion ? STATIC_MOTION : undefined}
122
158
  collapsedColumns={columnState.collapsedColumns} onToggleCollapse={columnState.toggleColumn} initialScroll="start"
123
159
  selectedIssueId={selected} detailOpen={Boolean(issue)} onSelectIssue={item => setSelected(item.id)} ariaLabel={pageTitle}
@@ -20,7 +20,7 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
20
20
  .organizer-destinations a[aria-current=page]{border-bottom:2px solid var(--org-ink);color:var(--org-ink)}
21
21
  .newsroom-organizer :is(a,button):focus-visible,.organizer-menu :is(a,button):focus-visible{outline:2px solid currentColor;outline-offset:-3px}
22
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)}
23
+ .organizer-topbar{height:48px;min-height:48px;display:flex;align-items:center;gap:16px;border-bottom:1px solid var(--org-ink)}
24
24
  .organizer-masthead{font:900 28px Newsroom Display,Georgia,serif;white-space:nowrap;max-width:30vw;overflow:hidden;text-overflow:ellipsis}
25
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
26
  .organizer-toggle{display:flex;align-items:center;justify-content:center;width:36px;min-height:36px;padding:0;border:1px solid var(--org-line);flex-shrink:0}
@@ -36,11 +36,12 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
36
36
  .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}
37
37
  .organizer-bottom-nav{display:none}
38
38
  .organizer-main:has(.organizer-native-board){display:flex;overflow:hidden}
39
- .organizer-native-board{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;gap:12px}
39
+ .organizer-native-board{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;gap:8px}
40
40
  .organizer-board-view-toggle{display:flex;gap:4px;flex-shrink:0}
41
- .organizer-board-view-toggle button{min-height:36px;padding:4px 12px;border:1px solid var(--org-line)}
42
- .organizer-board-view-toggle 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)}
41
+ .organizer-header-button{display:inline-flex;align-items:center;gap:6px;min-height:36px;padding:4px 12px;border:1px solid var(--org-line);font:inherit;color:inherit;background:transparent;text-decoration:none;white-space:nowrap;cursor:pointer}
42
+ .organizer-header-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);white-space:nowrap}
44
+ @media(min-width:901px){.newsroom-organizer[data-sidebar-collapsed=false] .organizer-masthead{display:none}}
44
45
  .organizer-board-body{position:relative;display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-width:0;min-height:0;gap:16px}
45
46
  .organizer-board-body[data-detail-open=true]{grid-template-columns:minmax(0,1fr) minmax(280px,360px)}
46
47
  .organizer-board-body>.kb-issue-collection{height:100%;min-height:0;overflow:auto}
@@ -50,6 +51,7 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
50
51
  .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
52
  .organizer-menu .organizer-destinations a{border:1px solid var(--org-line);padding:12px;color:var(--org-ink)}
52
53
  @media(max-width:900px){
54
+ .organizer-board-note{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
53
55
  .newsroom-organizer,.newsroom-organizer[data-sidebar-collapsed=true]{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr) auto}
54
56
  .organizer-sidebar,.organizer-toggle{display:none}.organizer-workspace{padding:0 16px}.organizer-topbar{gap:8px;height:55px;min-height:55px}
55
57
  .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%}
@@ -13297,7 +13297,7 @@ function buildUiAnalysisReindexAssignmentPlan({
13297
13297
  createdBy: actorLabel,
13298
13298
  createdAt: now,
13299
13299
  updatedAt: now,
13300
- newsroomFeedKey: "assignments",
13300
+ newsroomFeedKey: "operations",
13301
13301
  metadata,
13302
13302
  };
13303
13303
  const event: AssignmentEventRecord = {
@@ -0,0 +1,151 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import {
5
+ canonicalContentItemStage,
6
+ legacyStageKeysToLoad,
7
+ type ContentItemVersionRecord,
8
+ } from "../lib/content-item-lifecycle";
9
+ import { getContentItemStageDefinitions } from "../lib/site-brand";
10
+ import { loadNewsroomContentItemStagePage } from "./news-desk-taxonomy-client";
11
+
12
+ const REFRESH_INTERVAL_MILLISECONDS = 30_000;
13
+
14
+ type StageBucket = {
15
+ versions: ContentItemVersionRecord[];
16
+ nextToken: string | null;
17
+ pagesLoaded: number;
18
+ };
19
+
20
+ export type PagedContentItemVersions = {
21
+ versions: ContentItemVersionRecord[];
22
+ loading: boolean;
23
+ error: string | null;
24
+ stageHasMore: (canonicalStage: string) => boolean;
25
+ loadMoreForStage: (canonicalStage: string) => void;
26
+ };
27
+
28
+ function mergeFreshFirstPage(existing: StageBucket | undefined, fresh: StageBucket): StageBucket {
29
+ if (!existing || existing.pagesLoaded <= 1) return fresh;
30
+ const freshIds = new Set(fresh.versions.map(version => version.id));
31
+ return {
32
+ versions: [...fresh.versions, ...existing.versions.filter(version => !freshIds.has(version.id))],
33
+ nextToken: existing.nextToken,
34
+ pagesLoaded: existing.pagesLoaded,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Newest-updated content items per stage, one page per stage to start, more pages on demand.
40
+ * The periodic refresh re-fetches only first pages and keeps whatever later pages were already loaded.
41
+ */
42
+ export function usePagedContentItemVersions(options: {
43
+ enabled: boolean;
44
+ openedCollapsedUntilOpenedStages: readonly string[];
45
+ }): PagedContentItemVersions {
46
+ const { enabled, openedCollapsedUntilOpenedStages } = options;
47
+ const [buckets, setBuckets] = useState<Record<string, StageBucket>>({});
48
+ const [loading, setLoading] = useState(false);
49
+ const [error, setError] = useState<string | null>(null);
50
+ const bucketsRef = useRef(buckets);
51
+ const stageKeysBeingExtendedRef = useRef(new Set<string>());
52
+ const generationRef = useRef(0);
53
+
54
+ const openedStagesSignature = openedCollapsedUntilOpenedStages.join(",");
55
+ const stageKeysToLoad = useMemo(() => {
56
+ const stageDefinitions = getContentItemStageDefinitions();
57
+ return [
58
+ ...stageDefinitions
59
+ .filter(definition => !definition.collapsedUntilOpened || openedStagesSignature.split(",").includes(definition.key))
60
+ .map(definition => definition.key),
61
+ ...legacyStageKeysToLoad(stageDefinitions),
62
+ ];
63
+ }, [openedStagesSignature]);
64
+ const stageKeysToLoadRef = useRef(stageKeysToLoad);
65
+ stageKeysToLoadRef.current = stageKeysToLoad;
66
+
67
+ const storeBuckets = useCallback((update: (previous: Record<string, StageBucket>) => Record<string, StageBucket>) => {
68
+ const next = update(bucketsRef.current);
69
+ bucketsRef.current = next;
70
+ setBuckets(next);
71
+ }, []);
72
+
73
+ const refreshFirstPages = useCallback(async (request: number, isInitial: boolean, stageKeys: readonly string[]) => {
74
+ if (isInitial) setLoading(true);
75
+ try {
76
+ const freshBuckets = await Promise.all(stageKeys.map(async stageKey => {
77
+ const page = await loadNewsroomContentItemStagePage(stageKey);
78
+ return [stageKey, { versions: page.versions, nextToken: page.nextToken, pagesLoaded: 1 }] as const;
79
+ }));
80
+ if (generationRef.current !== request) return;
81
+ storeBuckets(previous => {
82
+ const next = { ...previous };
83
+ for (const [stageKey, fresh] of freshBuckets) next[stageKey] = mergeFreshFirstPage(previous[stageKey], fresh);
84
+ return next;
85
+ });
86
+ setError(null);
87
+ } catch (reason: unknown) {
88
+ if (generationRef.current === request && isInitial) setError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
89
+ } finally {
90
+ if (generationRef.current === request && isInitial) setLoading(false);
91
+ }
92
+ }, [storeBuckets]);
93
+
94
+ useEffect(() => {
95
+ const request = ++generationRef.current;
96
+ bucketsRef.current = {};
97
+ setBuckets({});
98
+ setError(null);
99
+ if (!enabled) { setLoading(false); return; }
100
+ void refreshFirstPages(request, true, stageKeysToLoadRef.current);
101
+ const refreshWhenVisible = () => {
102
+ if (document.visibilityState === "visible") void refreshFirstPages(request, false, stageKeysToLoadRef.current);
103
+ };
104
+ const interval = window.setInterval(refreshWhenVisible, REFRESH_INTERVAL_MILLISECONDS);
105
+ document.addEventListener("visibilitychange", refreshWhenVisible);
106
+ return () => {
107
+ generationRef.current = request + 1;
108
+ window.clearInterval(interval);
109
+ document.removeEventListener("visibilitychange", refreshWhenVisible);
110
+ };
111
+ }, [enabled, refreshFirstPages]);
112
+
113
+ useEffect(() => {
114
+ if (!enabled) return;
115
+ const stageKeysNotLoadedYet = stageKeysToLoad.filter(stageKey => !(stageKey in bucketsRef.current));
116
+ if (stageKeysNotLoadedYet.length) void refreshFirstPages(generationRef.current, false, stageKeysNotLoadedYet);
117
+ }, [enabled, refreshFirstPages, stageKeysToLoad]);
118
+
119
+ const stageKeysInCanonicalStage = useCallback((canonicalStage: string) => Object.keys(bucketsRef.current)
120
+ .filter(stageKey => canonicalContentItemStage(stageKey, getContentItemStageDefinitions()) === canonicalStage), []);
121
+
122
+ const loadMoreForStage = useCallback((canonicalStage: string) => {
123
+ const request = generationRef.current;
124
+ for (const stageKey of stageKeysInCanonicalStage(canonicalStage)) {
125
+ const bucket = bucketsRef.current[stageKey];
126
+ if (!bucket?.nextToken || stageKeysBeingExtendedRef.current.has(stageKey)) continue;
127
+ stageKeysBeingExtendedRef.current.add(stageKey);
128
+ loadNewsroomContentItemStagePage(stageKey, bucket.nextToken).then(page => {
129
+ if (generationRef.current !== request) return;
130
+ storeBuckets(previous => {
131
+ const current = previous[stageKey] ?? bucket;
132
+ const knownIds = new Set(current.versions.map(version => version.id));
133
+ return {
134
+ ...previous,
135
+ [stageKey]: {
136
+ versions: [...current.versions, ...page.versions.filter(version => !knownIds.has(version.id))],
137
+ nextToken: page.nextToken,
138
+ pagesLoaded: current.pagesLoaded + 1,
139
+ },
140
+ };
141
+ });
142
+ }).catch(() => undefined).finally(() => { stageKeysBeingExtendedRef.current.delete(stageKey); });
143
+ }
144
+ }, [stageKeysInCanonicalStage, storeBuckets]);
145
+
146
+ const stageHasMore = useCallback((canonicalStage: string) => stageKeysInCanonicalStage(canonicalStage)
147
+ .some(stageKey => Boolean(bucketsRef.current[stageKey]?.nextToken)), [stageKeysInCanonicalStage]);
148
+
149
+ const versions = useMemo(() => Object.values(buckets).flatMap(bucket => bucket.versions), [buckets]);
150
+ return { versions, loading, error, stageHasMore, loadMoreForStage };
151
+ }
@@ -12,7 +12,7 @@ import {
12
12
  } from "./blog-rhythm";
13
13
  import { layoutAllTextLines, layoutTextLines, prepareWithSegments, type TextLine, type TextObstacle } from "./pretext-layout";
14
14
 
15
- export type FeaturedLayoutMode = "obstacle" | "float" | "stacked";
15
+ export type FeaturedLayoutMode = "float" | "stacked";
16
16
 
17
17
  export type SolvedFeaturedItem = {
18
18
  mode: FeaturedLayoutMode;
@@ -92,19 +92,6 @@ export function solveFeaturedItem(input: SolveFeaturedItemInput): SolvedFeatured
92
92
  });
93
93
  const aspectRatio = getImageAspectRatio(input.imageAsset);
94
94
 
95
- if (mode === "obstacle") {
96
- return solveObstacleFeaturedItem({
97
- prepared,
98
- containerWidth: input.containerWidth,
99
- viewportWidth: input.viewportWidth,
100
- rhythm,
101
- textStyle: input.textStyle,
102
- aspectRatio,
103
- gap,
104
- imageAsset: input.imageAsset,
105
- });
106
- }
107
-
108
95
  if (mode === "float") {
109
96
  return solveFloatFeaturedItem({
110
97
  prepared,
@@ -151,96 +138,6 @@ type PreparedSolveInput = {
151
138
  imageAsset: ArticleImageAsset;
152
139
  };
153
140
 
154
- function solveObstacleFeaturedItem(input: PreparedSolveInput): SolvedFeaturedItem {
155
- const maxImageWidth = getFloatImageWidth(input);
156
- let best: {
157
- score: number;
158
- textLines: TextLine[];
159
- imageHeight: number;
160
- imageWidth: number;
161
- captionHeight: number;
162
- mediaHeight: number;
163
- copyWidth: number;
164
- textFrameHeight: number;
165
- hasMore: boolean;
166
- } | null = null;
167
-
168
- for (const imageWidth of getObstacleImageWidths(maxImageWidth, input.rhythm)) {
169
- const copyWidth = Math.max(
170
- getFloatCopyMinimum(input.viewportWidth, input.rhythm),
171
- input.containerWidth - imageWidth - input.gap,
172
- );
173
- const imageHeight = snapImageHeight(imageWidth / input.aspectRatio, input.rhythm, input.imageAsset.layout);
174
- const captionHeight = getFeatureCaptionHeight(input.imageAsset, imageWidth, input.rhythm);
175
- const mediaHeight = imageHeight + captionHeight;
176
- const obstacle: TextObstacle = {
177
- x: Math.max(0, copyWidth - imageWidth),
178
- y: 0,
179
- width: imageWidth,
180
- height: mediaHeight,
181
- };
182
-
183
- let candidateHeight = mediaHeight;
184
- let result = layoutTextLines({
185
- prepared: input.prepared,
186
- cursor: { segmentIndex: 0, graphemeIndex: 0 },
187
- maxHeight: candidateHeight,
188
- maxWidth: copyWidth,
189
- lineHeight: input.textStyle.lineHeight,
190
- linePaintHeight: input.textStyle.linePaintHeight,
191
- fontSize: input.textStyle.fontSize,
192
- fontFamily: input.textStyle.fontFamily,
193
- obstacles: [obstacle],
194
- });
195
-
196
- const textBottom = getMeasuredTextHeight(result.lines);
197
- if (textBottom > 0 && textBottom < candidateHeight) {
198
- candidateHeight = reserveRhythmRows(textBottom, input.rhythm);
199
- obstacle.height = candidateHeight;
200
- result = layoutTextLines({
201
- prepared: input.prepared,
202
- cursor: { segmentIndex: 0, graphemeIndex: 0 },
203
- maxHeight: candidateHeight,
204
- maxWidth: copyWidth,
205
- lineHeight: input.textStyle.lineHeight,
206
- linePaintHeight: input.textStyle.linePaintHeight,
207
- fontSize: input.textStyle.fontSize,
208
- fontFamily: input.textStyle.fontFamily,
209
- obstacles: [obstacle],
210
- });
211
- }
212
-
213
- const whitespace = Math.max(0, candidateHeight - getMeasuredTextHeight(result.lines));
214
- const score = 50_000 - whitespace * 1.4 - Math.abs(candidateHeight - mediaHeight) * 0.35 - Math.abs(maxImageWidth - imageWidth) * 0.2;
215
- const candidate = {
216
- score,
217
- textLines: result.lines,
218
- imageHeight: Math.min(imageHeight, candidateHeight),
219
- imageWidth,
220
- captionHeight,
221
- mediaHeight: candidateHeight,
222
- copyWidth,
223
- textFrameHeight: reserveRhythmRows(getMeasuredTextHeight(result.lines), input.rhythm),
224
- hasMore: result.hasMore,
225
- };
226
- if (!best || candidate.score > best.score) best = candidate;
227
- }
228
-
229
- const fallbackImageHeight = input.rhythm.rowHeight * 6;
230
- return {
231
- mode: "obstacle",
232
- textLines: best?.textLines ?? [],
233
- imageHeight: best?.imageHeight ?? fallbackImageHeight,
234
- imageWidth: best?.imageWidth ?? maxImageWidth,
235
- captionHeight: best?.captionHeight ?? 0,
236
- mediaHeight: best?.mediaHeight ?? fallbackImageHeight,
237
- copyWidth: best?.copyWidth ?? input.containerWidth,
238
- textFrameHeight: best?.textFrameHeight ?? 0,
239
- hasMore: best?.hasMore ?? false,
240
- gap: input.gap,
241
- };
242
- }
243
-
244
141
  function solveFloatFeaturedItem(input: PreparedSolveInput): SolvedFeaturedItem {
245
142
  const geometry = computeFloatGeometry({
246
143
  containerWidth: input.containerWidth,
@@ -417,11 +314,6 @@ function getFloatCopyMinimum(viewportWidth: number, rhythm: VerticalRhythm): num
417
314
  return rhythmLengthRows(viewportWidth <= 600 ? FLOAT_COPY_MIN_PHONE_ROWS : FLOAT_COPY_MIN_DESKTOP_ROWS, rhythm);
418
315
  }
419
316
 
420
- function getObstacleImageWidths(maximum: number, rhythm: VerticalRhythm): number[] {
421
- const candidateRows = [0, 1, 2].map((offset) => Math.max(rhythm.rowHeight * 5, maximum - (offset * rhythm.rowHeight)));
422
- return Array.from(new Set(candidateRows));
423
- }
424
-
425
317
  function snapImageHeight(
426
318
  naturalHeight: number,
427
319
  rhythm: VerticalRhythm,
@@ -114,6 +114,31 @@ export function assignmentBoardConfig(issues: readonly NativeBoardIssue[]): Kanb
114
114
  .map(status => ({ key: `${ASSIGNMENT_BOARD_TYPE}/${status}`, name: assignmentStatusLabel(status) })));
115
115
  }
116
116
 
117
+ function matchesDesk(candidate: string | null | undefined, deskId: string): boolean {
118
+ return Boolean(candidate) && candidate!.trim().toLowerCase() === deskId.trim().toLowerCase();
119
+ }
120
+
121
+ export function deskContainsContentItem(version: ContentItemVersionRecord, deskId: string): boolean {
122
+ return matchesDesk(version.section, deskId);
123
+ }
124
+
125
+ export function deskContainsAssignment(assignment: AssignmentRecord, deskId: string): boolean {
126
+ return matchesDesk(assignment.sectionId, deskId) || matchesDesk(assignment.sectionKey, deskId);
127
+ }
128
+
129
+ /** A desk board shows stories and assignments side by side; each keeps its own columns and the type filter narrows to one. */
130
+ export function deskBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
131
+ const storiesConfig = contentItemBoardConfig(issues.filter(issue => issue.kind === "contentItem"));
132
+ const assignmentsConfig = assignmentBoardConfig(issues.filter(issue => issue.kind === "assignment"));
133
+ return {
134
+ ...storiesConfig,
135
+ types: [...(storiesConfig.types ?? []), ...(assignmentsConfig.types ?? [])],
136
+ statuses: [...storiesConfig.statuses, ...assignmentsConfig.statuses],
137
+ categories: storiesConfig.categories,
138
+ workflows: { ...storiesConfig.workflows, ...assignmentsConfig.workflows },
139
+ };
140
+ }
141
+
117
142
  export function canReadNewsroomBoard(phase: string | undefined): boolean {
118
143
  return phase === "ready";
119
144
  }
@@ -0,0 +1,64 @@
1
+ export interface ProspectusPublishingAccountChoice {
2
+ accountId: string;
3
+ name?: string;
4
+ role: string;
5
+ }
6
+
7
+ export type ProspectusAccountState =
8
+ | { status: "unavailable" }
9
+ | { status: "signedOut"; signInUrl: string }
10
+ | {
11
+ status: "signedIn";
12
+ publishingAccounts: ProspectusPublishingAccountChoice[];
13
+ selectedAccountId: string | null;
14
+ cyclotronLabAccess: boolean;
15
+ };
16
+
17
+ type FetchImplementation = (input: string, init?: RequestInit) => Promise<Response>;
18
+
19
+ const SESSION_PATH = "/api/auth/prospectus/session";
20
+ const SELECTION_PATH = "/api/auth/prospectus/selection";
21
+ const SIGN_IN_PATH = "/api/auth/prospectus/sign-in";
22
+
23
+ export function prospectusSignInUrl(returnPath: string): string {
24
+ return `${SIGN_IN_PATH}?returnTo=${encodeURIComponent(returnPath)}`;
25
+ }
26
+
27
+ export async function loadProspectusAccountState(
28
+ returnPath: string,
29
+ fetchImplementation: FetchImplementation = fetch,
30
+ ): Promise<ProspectusAccountState> {
31
+ let response: Response;
32
+ try {
33
+ response = await fetchImplementation(SESSION_PATH, { credentials: "same-origin", cache: "no-store" });
34
+ } catch {
35
+ return { status: "unavailable" };
36
+ }
37
+ if (!response.ok) return { status: "unavailable" };
38
+ const body = await response.json().catch(() => null) as
39
+ | { signedIn?: boolean; publishingAccounts?: ProspectusPublishingAccountChoice[]; selectedAccountId?: string | null; cyclotronLabAccess?: boolean }
40
+ | null;
41
+ if (!body) return { status: "unavailable" };
42
+ if (!body.signedIn) return { status: "signedOut", signInUrl: prospectusSignInUrl(returnPath) };
43
+ return {
44
+ status: "signedIn",
45
+ publishingAccounts: Array.isArray(body.publishingAccounts) ? body.publishingAccounts : [],
46
+ selectedAccountId: body.selectedAccountId ?? null,
47
+ cyclotronLabAccess: body.cyclotronLabAccess === true,
48
+ };
49
+ }
50
+
51
+ export async function selectProspectusAccount(
52
+ accountId: string,
53
+ fetchImplementation: FetchImplementation = fetch,
54
+ ): Promise<{ selectedAccountId: string; role: string } | { error: string }> {
55
+ const response = await fetchImplementation(SELECTION_PATH, {
56
+ method: "POST",
57
+ credentials: "same-origin",
58
+ headers: { "content-type": "application/json" },
59
+ body: JSON.stringify({ accountId }),
60
+ });
61
+ const body = await response.json().catch(() => null) as { selectedAccountId?: string; role?: string; message?: string } | null;
62
+ if (!response.ok || !body?.selectedAccountId) return { error: body?.message ?? "That account could not be selected." };
63
+ return { selectedAccountId: body.selectedAccountId, role: body.role ?? "" };
64
+ }
package/lib/site-stack.ts CHANGED
@@ -149,7 +149,7 @@ export const PILOBOL_US_THEME_PACK_TOKENS: ThemePackTokens = {
149
149
  };
150
150
 
151
151
  /**
152
- * Anthus palette, read off the live Gatsby site at `AnthusAI/Anth.us`
152
+ * Anthus palette, read off the live Gatsby site at `AnthusAI/Anth.us-legacy-Gatsby`
153
153
  * (`src/styles/variables.scss` `:root`). Colors only — Shadcn owns typography.
154
154
  * Mapping from that file's names to the pack's slots:
155
155
  *
package/package.json CHANGED
@@ -135,6 +135,7 @@
135
135
  "./components/use-blog-header-obstacles": "./components/use-blog-header-obstacles.ts",
136
136
  "./components/use-brand-fonts-ready": "./components/use-brand-fonts-ready.ts",
137
137
  "./components/use-near-viewport": "./components/use-near-viewport.ts",
138
+ "./components/use-paged-content-item-versions": "./components/use-paged-content-item-versions.ts",
138
139
  "./components/use-resolved-papyrus-theme": "./components/use-resolved-papyrus-theme.ts",
139
140
  "./components/use-rhythm-overlay": "./components/use-rhythm-overlay.ts",
140
141
  "./lib/amplify-outputs-guard": "./lib/amplify-outputs-guard.ts",
@@ -219,6 +220,7 @@
219
220
  "./lib/presentation-footer": "./lib/presentation-footer.ts",
220
221
  "./lib/pretext-layout": "./lib/pretext-layout.ts",
221
222
  "./lib/priming-proof-report": "./lib/priming-proof-report.ts",
223
+ "./lib/prospectus-account-client": "./lib/prospectus-account-client.ts",
222
224
  "./lib/prospectus-publication-access": "./lib/prospectus-publication-access.ts",
223
225
  "./lib/prospectus-session": "./lib/prospectus-session.ts",
224
226
  "./lib/prospectus-sign-in-routes": "./lib/prospectus-sign-in-routes.ts",
@@ -350,7 +352,7 @@
350
352
  "optional": true
351
353
  }
352
354
  },
353
- "version": "1.4.0-next.2",
355
+ "version": "1.4.0-next.3",
354
356
  "dependencies": {
355
357
  "@aws-amplify/adapter-nextjs": "^1.7.3",
356
358
  "@aws-crypto/sha256-js": "5.2.0",
@@ -376,7 +378,9 @@
376
378
  "clsx": "^2.1.1",
377
379
  "cmdk": "^1.1.1",
378
380
  "cyclotron": "https://github.com/AnthusAI/Cyclotron/releases/download/ui-v0.2.2/cyclotron-0.2.2.tgz",
381
+ "d3": "^7.9.0",
379
382
  "esbuild": "^0.28.2",
383
+ "framer-motion": "^14.3.0",
380
384
  "gray-matter": "^4.0.3",
381
385
  "gsap": "^3.15.0",
382
386
  "lucide-react": "^1.16.0",
@@ -385,6 +389,7 @@
385
389
  "shadcn": "^4.8.0",
386
390
  "tailwind-merge": "^3.6.0",
387
391
  "tailwindcss": "^4.3.0",
392
+ "three": "^0.186.1",
388
393
  "tsx": "^4.23.15",
389
394
  "tw-animate-css": "^1.4.0",
390
395
  "use-stick-to-bottom": "^1.1.4",
@@ -13,6 +13,7 @@ import { PresentationFooter } from "../../components/presentation-footer";
13
13
  import { PresentationHeader } from "../../components/presentation-header";
14
14
  import { useBlogHeaderObstacles } from "../../components/use-blog-header-obstacles";
15
15
  import { useRhythmOverlay } from "../../components/use-rhythm-overlay";
16
+ import { useMeasuredWidth } from "./presentation-shared";
16
17
  import { BLOG_RHYTHM, getFeaturedLayoutStyle, getRhythmShellStyle } from "./rhythm-shell-style";
17
18
 
18
19
  const BlogPageBackground = SITE_BRAND.components?.BlogPageBackground ?? GenericBlogPageBackground;
@@ -135,24 +136,6 @@ export function RhythmArticlePage({
135
136
  );
136
137
  }
137
138
 
138
- function useMeasuredWidth(ref: RefObject<HTMLElement | null>): number {
139
- const [width, setWidth] = useState(0);
140
- useEffect(() => {
141
- const node = ref.current;
142
- if (!node) return;
143
- const update = () => setWidth(Math.max(1, Math.floor(node.getBoundingClientRect().width)));
144
- update();
145
- const observer = new ResizeObserver(update);
146
- observer.observe(node);
147
- window.addEventListener("resize", update);
148
- return () => {
149
- observer.disconnect();
150
- window.removeEventListener("resize", update);
151
- };
152
- }, [ref]);
153
- return width;
154
- }
155
-
156
139
  function useViewportWidth(): number {
157
140
  const [width, setWidth] = useState(0);
158
141
  useEffect(() => {
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.4.0-next.2",
2
+ "version": "1.4.0-next.3",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",