@anthusai/papyrus 1.5.0 → 1.6.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.
@@ -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);
@@ -41,7 +41,7 @@ import type {
41
41
  UserDirectoryEntry,
42
42
  } from "../lib/category-repository";
43
43
  import { relationTypeKey } from "../lib/semantic-graph";
44
- import { contentItemStageKeys, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
44
+ import { legacyStageKeysToLoad, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
45
45
  import { getContentItemStageDefinitions } from "../lib/site-brand";
46
46
  import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
47
47
  import { configureAmplifyClient } from "./amplify-client-provider";
@@ -2172,31 +2172,45 @@ const CONTENT_ITEMS_BY_STAGE_QUERY = `
2172
2172
  }
2173
2173
  `;
2174
2174
 
2175
- export async function loadNewsroomContentItemVersions(): Promise<ContentItemVersionRecord[]> {
2176
- if (isLocalPreviewEnabled()) return createLocalPreviewContentItemVersions();
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> {
2188
+ if (isLocalPreviewEnabled()) {
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
+ };
2196
+ }
2177
2197
  configureAmplifyClient();
2178
2198
  const client = generateClient<Schema>() as unknown as {
2179
2199
  graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<ContentItemVersionRecord>>;
2180
2200
  };
2181
2201
  const field = "listItemsByStageAndUpdatedAt";
2182
- const versionsPerStage = await Promise.all(contentItemStageKeys(getContentItemStageDefinitions()).map(async (stage) => {
2183
- const versions: ContentItemVersionRecord[] = [];
2184
- let nextToken: string | null = null;
2185
- do {
2186
- const response: GraphQLConnectionResponse<ContentItemVersionRecord> = await client.graphql({
2187
- query: CONTENT_ITEMS_BY_STAGE_QUERY,
2188
- variables: { stage, sortDirection: "DESC", limit: NEWSROOM_PAGE_LIMIT, nextToken },
2189
- authMode: USER_POOL_AUTH_MODE,
2190
- });
2191
- assertNoGraphQLErrors(response.errors);
2192
- const connection = response.data?.[field];
2193
- if (!connection) throw new Error(`Missing GraphQL connection payload for ${field}.`);
2194
- versions.push(...((connection.items ?? []).filter(Boolean) as ContentItemVersionRecord[]));
2195
- nextToken = connection.nextToken ?? null;
2196
- } while (nextToken);
2197
- return versions;
2198
- }));
2199
- 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
+ };
2200
2214
  }
2201
2215
 
2202
2216
  const PUBLISHED_ITEMS_QUERY = `
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect, useMemo, useRef, useState } from "react";
4
4
  import Link from "next/link";
5
- import { IssueCollection, IssueDetailPanel } from "@kanbus/ui";
5
+ import { IssueCollection, IssueDetailPanel, 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";
@@ -20,9 +20,11 @@ import {
20
20
  type NativeBoardIssue,
21
21
  } from "../lib/newsroom-board";
22
22
  import type { AssignmentRecord } from "../lib/category-repository";
23
- import { getContentItemName, SITE_BRAND } from "../lib/site-brand";
23
+ import { getContentItemName, getContentItemStageDefinitions, SITE_BRAND } from "../lib/site-brand";
24
24
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
25
- import { loadNewsroomAssignmentPage, loadNewsroomContentItemVersions, loadNewsroomPublishedContentItemVersions } from "./news-desk-taxonomy-client";
25
+ import { loadNewsroomAssignmentPage, loadNewsroomPublishedContentItemVersions } from "./news-desk-taxonomy-client";
26
+ import { usePagedContentItemVersions } from "./use-paged-content-item-versions";
27
+ import type { ContentItemVersionRecord } from "../lib/content-item-lifecycle";
26
28
  import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
27
29
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
28
30
  import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
@@ -31,6 +33,7 @@ import { readLocalReaderSettings, subscribeReaderSettingsChanges } from "./reade
31
33
 
32
34
  const STATIC_MOTION = { mode: "static" } as const;
33
35
  const BOARD_REFRESH_INTERVAL_MILLISECONDS = 30_000;
36
+ const COLUMN_LOAD_MORE_DISTANCE_PIXELS = 240;
34
37
 
35
38
  async function loadEveryNewsroomAssignment() {
36
39
  const assignments: AssignmentRecord[] = [];
@@ -51,35 +54,44 @@ export function NewsroomBoardView({ subject }: { subject: NewsroomBoardSubject }
51
54
  const allowed = canReadNewsroomBoard(session?.shell.phase);
52
55
  const theme = useResolvedPapyrusTheme();
53
56
  const [lowMotion, setLowMotion] = useState(false);
54
- const [issues, setIssues] = useState<NativeBoardIssue[]>([]);
57
+ const [assignmentBoardIssues, setAssignmentBoardIssues] = useState<NativeBoardIssue[]>([]);
58
+ const [publishedVersions, setPublishedVersions] = useState<ContentItemVersionRecord[]>([]);
59
+ const [assignments, setAssignments] = useState<AssignmentRecord[]>([]);
55
60
  const [view, setView] = useState<"board" | "list">("board");
56
61
  const [selected, setSelected] = useState<string | null>(null);
57
- const [loading, setLoading] = useState(false);
58
- const [error, setError] = useState<string | null>(null);
62
+ const [supportingRecordsLoading, setSupportingRecordsLoading] = useState(false);
63
+ const [supportingRecordsError, setSupportingRecordsError] = useState<string | null>(null);
59
64
  const generation = useRef(0);
65
+ const boardBodyRef = useRef<HTMLDivElement | null>(null);
66
+ const boardType = subject === "contentItem" ? contentItemBoardType() : ASSIGNMENT_BOARD_TYPE;
67
+ const columnStateConfig = useMemo(() => subject === "contentItem" ? contentItemBoardConfig([]) : assignmentBoardConfig(assignmentBoardIssues), [assignmentBoardIssues, subject]);
68
+ const columnState = useBoardColumnState({ config: columnStateConfig, storageKey: `papyrus.cms.columns.v1.${SITE_BRAND.id}.${subject}` });
69
+ const openedCollapsedUntilOpenedStages = useMemo(() => subject !== "contentItem" ? [] : getContentItemStageDefinitions()
70
+ .filter(definition => definition.collapsedUntilOpened && !columnState.collapsedColumns.has(`${boardType}/${definition.key}`))
71
+ .map(definition => definition.key), [boardType, columnState.collapsedColumns, subject]);
72
+ const pagedContentItems = usePagedContentItemVersions({ enabled: allowed && subject === "contentItem", openedCollapsedUntilOpenedStages });
60
73
  useEffect(() => {
61
74
  setLowMotion(readLocalReaderSettings().motion === "low");
62
75
  return subscribeReaderSettingsChanges(settings => setLowMotion(settings.motion === "low"));
63
76
  }, []);
64
77
  useEffect(() => {
65
78
  const request = ++generation.current;
66
- setIssues([]); setSelected(null); setError(null);
67
- if (!allowed) { setLoading(false); return; }
68
- const loadIssues = () => subject === "contentItem"
69
- ? Promise.all([
70
- Promise.all([loadNewsroomContentItemVersions(), loadNewsroomPublishedContentItemVersions()]).then(([workingVersions, publishedVersions]) => [...workingVersions, ...publishedVersions]),
71
- loadEveryNewsroomAssignment(),
72
- ]).then(([contentItemVersions, assignments]) => projectContentItemBoard({ contentItemVersions, assignments }))
73
- : loadEveryNewsroomAssignment().then(projectAssignmentBoard);
79
+ setAssignmentBoardIssues([]); setPublishedVersions([]); setAssignments([]); setSelected(null); setSupportingRecordsError(null);
80
+ if (!allowed) { setSupportingRecordsLoading(false); return; }
81
+ const loadSupportingRecords = () => subject === "contentItem"
82
+ ? Promise.all([loadNewsroomPublishedContentItemVersions(), loadEveryNewsroomAssignment()]).then(([published, everyAssignment]) => {
83
+ if (generation.current === request) { setPublishedVersions(published); setAssignments(everyAssignment); }
84
+ })
85
+ : loadEveryNewsroomAssignment().then(everyAssignment => {
86
+ if (generation.current === request) setAssignmentBoardIssues(projectAssignmentBoard(everyAssignment));
87
+ });
74
88
  const refresh = (initial: boolean) => {
75
- if (initial) setLoading(true);
76
- loadIssues().then(loadedIssues => {
77
- if (generation.current !== request) return;
78
- setIssues(loadedIssues);
79
- setError(null);
89
+ if (initial) setSupportingRecordsLoading(true);
90
+ loadSupportingRecords().then(() => {
91
+ if (generation.current === request) setSupportingRecordsError(null);
80
92
  }).catch((reason: unknown) => {
81
- if (generation.current === request && initial) setError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
82
- }).finally(() => { if (generation.current === request && initial) setLoading(false); });
93
+ if (generation.current === request && initial) setSupportingRecordsError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
94
+ }).finally(() => { if (generation.current === request && initial) setSupportingRecordsLoading(false); });
83
95
  };
84
96
  refresh(true);
85
97
  const refreshWhenVisible = () => { if (document.visibilityState === "visible") refresh(false); };
@@ -87,8 +99,26 @@ export function NewsroomBoardView({ subject }: { subject: NewsroomBoardSubject }
87
99
  document.addEventListener("visibilitychange", refreshWhenVisible);
88
100
  return () => { generation.current = request + 1; window.clearInterval(interval); document.removeEventListener("visibilitychange", refreshWhenVisible); };
89
101
  }, [allowed, subject]);
102
+ const issues = useMemo(() => subject === "contentItem"
103
+ ? projectContentItemBoard({ contentItemVersions: [...pagedContentItems.versions, ...publishedVersions], assignments })
104
+ : assignmentBoardIssues, [assignmentBoardIssues, assignments, pagedContentItems.versions, publishedVersions, subject]);
105
+ const loading = supportingRecordsLoading || (subject === "contentItem" && pagedContentItems.loading);
106
+ const error = supportingRecordsError ?? (subject === "contentItem" ? pagedContentItems.error : null);
90
107
  const config = useMemo(() => subject === "contentItem" ? contentItemBoardConfig(issues) : assignmentBoardConfig(issues), [issues, subject]);
91
- const boardType = subject === "contentItem" ? contentItemBoardType() : ASSIGNMENT_BOARD_TYPE;
108
+ const columnStageFromElement = (element: Element | null) => element?.closest("[data-column-key]")?.getAttribute("data-column-key")?.slice(boardType.length + 1) ?? null;
109
+ const loadMoreWhenColumnIsNearItsEnd = (columnScroll: HTMLElement) => {
110
+ const distanceToEnd = columnScroll.scrollHeight - columnScroll.scrollTop - columnScroll.clientHeight;
111
+ const stage = columnStageFromElement(columnScroll);
112
+ if (stage && distanceToEnd < COLUMN_LOAD_MORE_DISTANCE_PIXELS) pagedContentItems.loadMoreForStage(stage);
113
+ };
114
+ const handleBoardScroll = (event: React.UIEvent<HTMLDivElement>) => {
115
+ const target = event.target as HTMLElement;
116
+ if (subject === "contentItem" && target.classList?.contains("kb-column-scroll")) loadMoreWhenColumnIsNearItsEnd(target);
117
+ };
118
+ useEffect(() => {
119
+ if (subject !== "contentItem") return;
120
+ boardBodyRef.current?.querySelectorAll<HTMLElement>(".kb-column-scroll").forEach(loadMoreWhenColumnIsNearItsEnd);
121
+ });
92
122
  const pageTitle = subject === "contentItem" ? getContentItemName().plural : "Assignments";
93
123
  const issue = issues.find(item => item.id === selected);
94
124
  if (!allowed) return <NewsDeskAccessGate shell={session?.shell ?? null} />;
@@ -105,10 +135,10 @@ export function NewsroomBoardView({ subject }: { subject: NewsroomBoardSubject }
105
135
  <div className="organizer-native-board papyrus-newsprint-board" data-color-mode={theme}>
106
136
  {error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
107
137
  <p role="status" className="organizer-board-note">{loading ? "Loading records…" : `${issues.length} records loaded.`}</p>
108
- <div className="organizer-board-body" data-detail-open={Boolean(issue)}>
138
+ <div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={boardBodyRef} onScrollCapture={handleBoardScroll}>
109
139
  <IssueCollection issues={issues} config={config} query={{ type: boardType }} view={view}
110
140
  motion={lowMotion ? STATIC_MOTION : undefined}
111
- columnStateKey={`papyrus.cms.columns.v1.${SITE_BRAND.id}.${subject}`} initialScroll="start"
141
+ collapsedColumns={columnState.collapsedColumns} onToggleCollapse={columnState.toggleColumn} initialScroll="start"
112
142
  selectedIssueId={selected} detailOpen={Boolean(issue)} onSelectIssue={item => setSelected(item.id)} ariaLabel={pageTitle}
113
143
  emptyMessage={loading ? "Loading records…" : error ? "Records could not be loaded." : "No records in this selection."} />
114
144
  {issue && <IssueDetailPanel issue={issue} onClose={() => setSelected(null)} fields={[{ label: "CMS record ID", value: issue.nativeId }, { label: "Status", value: issue.nativeStatus }]}>
@@ -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,
@@ -7,24 +7,51 @@
7
7
  export type ContentItemStageDefinition = {
8
8
  key: string;
9
9
  label: string;
10
+ /** Closed or rarely visited stage: its board column starts collapsed and its cards load only when it is opened. */
11
+ collapsedUntilOpened?: boolean;
10
12
  };
11
13
 
14
+ export const PUBLISHED_STAGE_KEY = "published";
15
+
16
+ /** Stage keys written before the story stages were simplified, read as the stage that replaced them. */
17
+ export const LEGACY_CONTENT_ITEM_STAGE_ALIASES: Readonly<Record<string, string>> = {
18
+ research: "reporting",
19
+ report: "reporting",
20
+ editor_select: "reporting",
21
+ };
22
+
23
+ /** Stages for work that has not become a story yet; they appear on the assignments board only. */
24
+ export const ASSIGNMENT_PHASE_STAGE_KEYS: readonly string[] = ["idea", "assignment"];
25
+
12
26
  export const DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS: readonly ContentItemStageDefinition[] = [
13
- { key: "idea", label: "Idea" },
14
- { key: "assignment", label: "Assignment" },
15
- { key: "research", label: "Research" },
16
- { key: "report", label: "Report" },
17
- { key: "editor_select", label: "Editor select" },
27
+ { key: "reporting", label: "Reporting" },
18
28
  { key: "copywriting", label: "Copywriting" },
19
29
  { key: "illustration", label: "Illustration" },
20
30
  { key: "typesetting", label: "Typesetting" },
21
31
  { key: "published", label: "Published" },
32
+ { key: "held", label: "Held" },
33
+ { key: "spiked", label: "Spiked", collapsedUntilOpened: true },
22
34
  ];
23
35
 
24
36
  export function contentItemStageKeys(stageDefinitions: readonly ContentItemStageDefinition[]): string[] {
25
37
  return stageDefinitions.map(definition => definition.key);
26
38
  }
27
39
 
40
+ export function canonicalContentItemStage(
41
+ stage: string,
42
+ stageDefinitions: readonly ContentItemStageDefinition[] = DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS,
43
+ ): string {
44
+ const isDefined = stageDefinitions.some(definition => definition.key === stage);
45
+ return isDefined ? stage : LEGACY_CONTENT_ITEM_STAGE_ALIASES[stage] ?? stage;
46
+ }
47
+
48
+ export function legacyStageKeysToLoad(stageDefinitions: readonly ContentItemStageDefinition[]): string[] {
49
+ const definedKeys = contentItemStageKeys(stageDefinitions);
50
+ return Object.entries(LEGACY_CONTENT_ITEM_STAGE_ALIASES)
51
+ .filter(([legacyKey, replacementKey]) => !definedKeys.includes(legacyKey) && definedKeys.includes(replacementKey))
52
+ .map(([legacyKey]) => legacyKey);
53
+ }
54
+
28
55
  export function contentItemStageLabel(
29
56
  stage: string,
30
57
  stageDefinitions: readonly ContentItemStageDefinition[] = DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS,
@@ -6,15 +6,13 @@ const SEED_TIMESTAMP_BASE = Date.parse("2026-10-01T12:00:00.000Z");
6
6
  const ONE_HOUR_IN_MILLISECONDS = 60 * 60 * 1000;
7
7
 
8
8
  const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION: readonly (readonly string[])[] = [
9
- ["Readers ask what the newsroom should chase next", "A tip from the field: harbor dredging schedule"],
10
- ["City council budget vote draws a crowd", "Interview request: regional transit director"],
11
- ["Mapping every permit filed since January", "What the county audit left out"],
12
9
  ["Three sources confirm the zoning reversal", "Reporting packet: school board redistricting"],
13
- ["Editor shortlist: the week's lead candidates", "Editor shortlist: the weekend long read"],
14
10
  ["Draft in progress: the last ferry crossing", "Draft in progress: neighborhood bakery profile"],
15
11
  ["Illustration brief: flooded streets, aerial view", "Illustration brief: the market at dawn"],
16
12
  ["Layout pass: front page lead and rail", "Layout pass: the Saturday supplement"],
17
13
  ["Published: harbor safety review", "Published: the lighthouse keepers' archive"],
14
+ ["Held for the winter issue: the lighthouse keepers' archive", "Evergreen: how the harbor dredging works"],
15
+ ["Spiked: the unconfirmed council rumor", "Spiked: the recycled press release"],
18
16
  ];
19
17
 
20
18
  function isoTimestampHoursBeforeBase(hours: number): string {
@@ -1,6 +1,9 @@
1
1
  import type { KanbanConfig, KanbanIssue } from "@kanbus/ui";
2
2
  import type { AssignmentRecord } from "./category-repository";
3
3
  import {
4
+ ASSIGNMENT_PHASE_STAGE_KEYS,
5
+ PUBLISHED_STAGE_KEY,
6
+ canonicalContentItemStage,
4
7
  contentItemStageKeys,
5
8
  contentItemStageLabel,
6
9
  latestVersionPerContentItem,
@@ -42,8 +45,12 @@ export function projectContentItemBoard(records: {
42
45
  }
43
46
  }
44
47
  const contentItemType = contentItemBoardType();
45
- return latestVersionPerContentItem(records.contentItemVersions).map(version => {
46
- const stage = version.stage ?? "idea";
48
+ const stageDefinitions = getContentItemStageDefinitions();
49
+ const stageKeys = contentItemStageKeys(stageDefinitions);
50
+ const contentItemVersionsOnBoard = latestVersionPerContentItem(records.contentItemVersions)
51
+ .map(version => ({ version, stage: canonicalContentItemStage(version.stage ?? "idea", stageDefinitions) }))
52
+ .filter(({ stage }) => stageKeys.includes(stage) || !ASSIGNMENT_PHASE_STAGE_KEYS.includes(stage));
53
+ return contentItemVersionsOnBoard.map(({ version, stage }) => {
47
54
  const workCount = assignmentCountByContentItem.get(version.lineageId) ?? 0;
48
55
  return {
49
56
  id: `contentItem:${version.lineageId}`, nativeId: version.lineageId, kind: "contentItem" as const, type: contentItemType,
@@ -70,9 +77,10 @@ export function contentItemColumnOrder(
70
77
  stagesPresentOnBoard: readonly string[],
71
78
  stageKeys: readonly string[] = contentItemStageKeys(getContentItemStageDefinitions()),
72
79
  ): string[] {
73
- const lastStage = stageKeys[stageKeys.length - 1];
74
80
  const unrecognizedStages = [...new Set(stagesPresentOnBoard)].filter(stage => !stageKeys.includes(stage));
75
- return [...stageKeys.filter(stage => stage !== lastStage), ...unrecognizedStages, lastStage];
81
+ const stageBeforeWhichUnrecognizedStagesSit = stageKeys.includes(PUBLISHED_STAGE_KEY) ? PUBLISHED_STAGE_KEY : stageKeys[stageKeys.length - 1];
82
+ const insertionIndex = stageKeys.indexOf(stageBeforeWhichUnrecognizedStagesSit);
83
+ return [...stageKeys.slice(0, insertionIndex), ...unrecognizedStages, ...stageKeys.slice(insertionIndex)];
76
84
  }
77
85
 
78
86
  export function assignmentColumnOrder(statusesPresentOnBoard: readonly string[]): string[] {
@@ -81,7 +89,7 @@ export function assignmentColumnOrder(statusesPresentOnBoard: readonly string[])
81
89
  return [...ASSIGNMENT_STATUSES_BEFORE_OTHERS, ...otherStatuses, ...ASSIGNMENT_STATUSES_AFTER_OTHERS];
82
90
  }
83
91
 
84
- function boardConfigForType(type: string, columns: readonly { key: string; name: string }[]): KanbanConfig {
92
+ function boardConfigForType(type: string, columns: readonly { key: string; name: string; collapsed?: boolean }[]): KanbanConfig {
85
93
  const statuses = columns.map(column => ({ ...column, category: "native" }));
86
94
  return {
87
95
  hierarchy: [], types: [type], statuses,
@@ -90,11 +98,15 @@ function boardConfigForType(type: string, columns: readonly { key: string; name:
90
98
  };
91
99
  }
92
100
 
93
- /** Content items read left to right through the lifecycle with the final stage last. */
101
+ /** Content items read left to right through the lifecycle; stages marked collapsed-until-opened start collapsed. */
94
102
  export function contentItemBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
95
103
  const contentItemType = contentItemBoardType();
96
104
  return boardConfigForType(contentItemType, contentItemColumnOrder(issues.map(issue => issue.nativeStatus))
97
- .map(stage => ({ key: `${contentItemType}/${stage}`, name: contentItemStageLabel(stage, getContentItemStageDefinitions()) })));
105
+ .map(stage => ({
106
+ key: `${contentItemType}/${stage}`,
107
+ name: contentItemStageLabel(stage, getContentItemStageDefinitions()),
108
+ collapsed: getContentItemStageDefinitions().some(definition => definition.key === stage && definition.collapsedUntilOpened),
109
+ })));
98
110
  }
99
111
 
100
112
  export function assignmentBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
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",
@@ -350,7 +351,7 @@
350
351
  "optional": true
351
352
  }
352
353
  },
353
- "version": "1.5.0",
354
+ "version": "1.6.0",
354
355
  "dependencies": {
355
356
  "@aws-amplify/adapter-nextjs": "^1.7.3",
356
357
  "@aws-crypto/sha256-js": "5.2.0",
@@ -376,7 +377,9 @@
376
377
  "clsx": "^2.1.1",
377
378
  "cmdk": "^1.1.1",
378
379
  "cyclotron": "https://github.com/AnthusAI/Cyclotron/releases/download/ui-v0.2.2/cyclotron-0.2.2.tgz",
380
+ "d3": "^7.9.0",
379
381
  "esbuild": "^0.28.2",
382
+ "framer-motion": "^14.3.0",
380
383
  "gray-matter": "^4.0.3",
381
384
  "gsap": "^3.15.0",
382
385
  "lucide-react": "^1.16.0",
@@ -385,6 +388,7 @@
385
388
  "shadcn": "^4.8.0",
386
389
  "tailwind-merge": "^3.6.0",
387
390
  "tailwindcss": "^4.3.0",
391
+ "three": "^0.186.1",
388
392
  "tsx": "^4.23.15",
389
393
  "tw-animate-css": "^1.4.0",
390
394
  "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.5.0",
2
+ "version": "1.6.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",