@anthusai/papyrus 1.3.0 → 1.4.0-next.2

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 (37) hide show
  1. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  2. package/app/newsroom/[section]/page.tsx +8 -0
  3. package/app/newsroom/articles/page.tsx +10 -0
  4. package/components/news-desk-client-provider.tsx +17 -2
  5. package/components/news-desk-taxonomy-client.ts +86 -3
  6. package/components/newspaper.tsx +4 -4
  7. package/components/newsroom-board-view.tsx +70 -29
  8. package/components/newsroom-client-shell.tsx +8 -1
  9. package/components/newsroom-organizer-shell.tsx +13 -8
  10. package/components/newsroom-organizer.css +5 -5
  11. package/components/newsroom-references-view.tsx +18 -0
  12. package/components/reference-cyclotron-priming.tsx +85 -0
  13. package/components/topic-steering-workspace.tsx +35 -1
  14. package/lib/article-flow.ts +95 -0
  15. package/lib/article-frame-solver.ts +519 -0
  16. package/lib/chrome-text.ts +504 -0
  17. package/lib/content-item-lifecycle.ts +32 -5
  18. package/lib/front-mosaic.ts +1132 -0
  19. package/lib/furniture.ts +385 -0
  20. package/lib/labels.ts +37 -0
  21. package/lib/layout-config.ts +237 -0
  22. package/lib/layout-plan.ts +37 -9
  23. package/lib/layout-scenarios.ts +21 -11
  24. package/lib/layout-types.ts +457 -0
  25. package/lib/local-preview-seed.ts +95 -0
  26. package/lib/local-preview.ts +3 -0
  27. package/lib/newspaper-layout.ts +17 -3410
  28. package/lib/newsroom-board.ts +70 -47
  29. package/lib/newsroom-organizer-nav.ts +5 -5
  30. package/lib/pretext-layout.ts +22 -0
  31. package/lib/relevance-status.ts +73 -0
  32. package/lib/site-brand.ts +8 -1
  33. package/lib/text-columns.ts +251 -0
  34. package/package.json +13 -2
  35. package/renderers/pretext/presentation-shell.tsx +1 -1
  36. package/routes.manifest.json +1 -9
  37. package/app/newsroom/work/page.tsx +0 -10
@@ -1,11 +1,15 @@
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,
7
10
  type ContentItemVersionRecord,
8
11
  } from "./content-item-lifecycle";
12
+ import { assignmentStatusLabel } from "./newsroom-assignments";
9
13
  import { getContentItemName, getContentItemStageDefinitions } from "./site-brand";
10
14
 
11
15
  export type NativeBoardKind = "contentItem" | "assignment";
@@ -16,21 +20,22 @@ export interface NativeBoardIssue extends KanbanIssue {
16
20
  href: string;
17
21
  }
18
22
 
23
+ export function contentItemBoardPathSegment(): string {
24
+ return getContentItemName().plural.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "articles";
25
+ }
26
+
19
27
  export function contentItemBoardType(): string {
20
28
  return getContentItemName().singular.toLowerCase();
21
29
  }
22
30
 
23
- function boardTypeForKind(kind: NativeBoardKind): string {
24
- return kind === "contentItem" ? contentItemBoardType() : kind;
25
- }
31
+ export const ASSIGNMENT_BOARD_TYPE = "assignment";
32
+
33
+ const ASSIGNMENT_STATUSES_BEFORE_OTHERS = ["open", "claimed"];
34
+ const ASSIGNMENT_STATUSES_AFTER_OTHERS = ["completed", "canceled"];
26
35
 
27
- /**
28
- * One card per content item, in the column for its lifecycle stage. Assignments that are work on a content item
29
- * are counted on that item's card rather than shown as separate cards. Other assignments keep their own identity and
30
- * native status. References are not shown on the board.
31
- */
32
- export function projectNewsroomBoard(records: {
33
- contentItemVersions?: readonly ContentItemVersionRecord[];
36
+ /** One card per content item, in the column for its lifecycle stage; its assignments are counted on the card. */
37
+ export function projectContentItemBoard(records: {
38
+ contentItemVersions: readonly ContentItemVersionRecord[];
34
39
  assignments?: readonly AssignmentRecord[];
35
40
  }): NativeBoardIssue[] {
36
41
  const assignmentCountByContentItem = new Map<string, number>();
@@ -40,57 +45,75 @@ export function projectNewsroomBoard(records: {
40
45
  }
41
46
  }
42
47
  const contentItemType = contentItemBoardType();
43
- return [
44
- ...latestVersionPerContentItem(records.contentItemVersions ?? []).map(version => {
45
- const stage = version.stage ?? "idea";
46
- const workCount = assignmentCountByContentItem.get(version.lineageId) ?? 0;
47
- return {
48
- id: `contentItem:${version.lineageId}`, nativeId: version.lineageId, kind: "contentItem" as const, type: contentItemType,
49
- title: version.headline || version.title || version.slug, nativeStatus: stage, status: `${contentItemType}/${stage}`,
50
- priority: 2, description: workCount ? `${workCount} assignment${workCount === 1 ? "" : "s"} on this ${contentItemType}.` : undefined,
51
- updated_at: version.updatedAt ?? undefined, href: `/newsroom/articles/${encodeURIComponent(version.id)}`,
52
- };
53
- }),
54
- ...(records.assignments ?? []).filter(row => !row.itemLineageId).map(row => ({
55
- id: `assignment:${row.id}`, nativeId: row.id, kind: "assignment" as const, type: "assignment",
56
- title: row.title, nativeStatus: row.status, status: `assignment/${row.status}`,
57
- priority: row.priority ?? 2, assignee: row.assigneeId || undefined,
58
- description: row.summary || row.brief || undefined,
59
- created_at: row.createdAt, updated_at: row.updatedAt,
60
- href: `/newsroom/assignments/${encodeURIComponent(row.id)}`,
61
- })),
62
- ];
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 }) => {
54
+ const workCount = assignmentCountByContentItem.get(version.lineageId) ?? 0;
55
+ return {
56
+ id: `contentItem:${version.lineageId}`, nativeId: version.lineageId, kind: "contentItem" as const, type: contentItemType,
57
+ title: version.headline || version.title || version.slug, nativeStatus: stage, status: `${contentItemType}/${stage}`,
58
+ priority: 2, description: workCount ? `${workCount} assignment${workCount === 1 ? "" : "s"} on this ${contentItemType}.` : undefined,
59
+ updated_at: version.updatedAt ?? undefined, href: `/newsroom/articles/${encodeURIComponent(version.id)}`,
60
+ };
61
+ });
62
+ }
63
+
64
+ /** One card per assignment, in the column for its status. */
65
+ export function projectAssignmentBoard(assignments: readonly AssignmentRecord[]): NativeBoardIssue[] {
66
+ return assignments.map(row => ({
67
+ id: `assignment:${row.id}`, nativeId: row.id, kind: "assignment" as const, type: ASSIGNMENT_BOARD_TYPE,
68
+ title: row.title, nativeStatus: row.status, status: `${ASSIGNMENT_BOARD_TYPE}/${row.status}`,
69
+ priority: row.priority ?? 2, assignee: row.assigneeId || undefined,
70
+ description: row.summary || row.brief || undefined,
71
+ created_at: row.createdAt, updated_at: row.updatedAt,
72
+ href: `/newsroom/assignments/${encodeURIComponent(row.id)}`,
73
+ }));
63
74
  }
64
75
 
65
76
  export function contentItemColumnOrder(
66
77
  stagesPresentOnBoard: readonly string[],
67
78
  stageKeys: readonly string[] = contentItemStageKeys(getContentItemStageDefinitions()),
68
79
  ): string[] {
69
- const lastStage = stageKeys[stageKeys.length - 1];
70
80
  const unrecognizedStages = [...new Set(stagesPresentOnBoard)].filter(stage => !stageKeys.includes(stage));
71
- 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)];
72
84
  }
73
85
 
74
- /** Content items read left to right through the lifecycle with the final stage last; other record kinds follow it. */
75
- export function newsroomBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
76
- const contentItemType = contentItemBoardType();
77
- const kinds: NativeBoardKind[] = ["contentItem", "assignment"];
78
- const contentItemStatuses = contentItemColumnOrder(issues.filter(issue => issue.kind === "contentItem").map(issue => issue.nativeStatus))
79
- .map(stage => ({ key: `${contentItemType}/${stage}`, name: contentItemStageLabel(stage, getContentItemStageDefinitions()), category: "native" }));
80
- const otherStatuses = [...new Set(issues.filter(issue => issue.kind !== "contentItem").map(issue => issue.status))]
81
- .map(key => ({ key, name: key.replace("/", " · "), category: "native" }));
82
- const statuses = [...contentItemStatuses, ...otherStatuses];
86
+ export function assignmentColumnOrder(statusesPresentOnBoard: readonly string[]): string[] {
87
+ const knownStatuses = [...ASSIGNMENT_STATUSES_BEFORE_OTHERS, ...ASSIGNMENT_STATUSES_AFTER_OTHERS];
88
+ const otherStatuses = [...new Set(statusesPresentOnBoard)].filter(status => !knownStatuses.includes(status));
89
+ return [...ASSIGNMENT_STATUSES_BEFORE_OTHERS, ...otherStatuses, ...ASSIGNMENT_STATUSES_AFTER_OTHERS];
90
+ }
91
+
92
+ function boardConfigForType(type: string, columns: readonly { key: string; name: string; collapsed?: boolean }[]): KanbanConfig {
93
+ const statuses = columns.map(column => ({ ...column, category: "native" }));
83
94
  return {
84
- hierarchy: [], types: kinds.map(boardTypeForKind),
85
- statuses,
95
+ hierarchy: [], types: [type], statuses,
86
96
  categories: [{ name: "native" }], priorities: {}, type_colors: {},
87
- workflows: Object.fromEntries(kinds.map(kind => {
88
- const type = boardTypeForKind(kind);
89
- return [type, Object.fromEntries(statuses.filter(status => status.key.startsWith(`${type}/`)).map(status => [status.key, []]))];
90
- })),
97
+ workflows: { [type]: Object.fromEntries(statuses.map(status => [status.key, []])) },
91
98
  };
92
99
  }
93
100
 
101
+ /** Content items read left to right through the lifecycle; stages marked collapsed-until-opened start collapsed. */
102
+ export function contentItemBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
103
+ const contentItemType = contentItemBoardType();
104
+ return boardConfigForType(contentItemType, contentItemColumnOrder(issues.map(issue => issue.nativeStatus))
105
+ .map(stage => ({
106
+ key: `${contentItemType}/${stage}`,
107
+ name: contentItemStageLabel(stage, getContentItemStageDefinitions()),
108
+ collapsed: getContentItemStageDefinitions().some(definition => definition.key === stage && definition.collapsedUntilOpened),
109
+ })));
110
+ }
111
+
112
+ export function assignmentBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
113
+ return boardConfigForType(ASSIGNMENT_BOARD_TYPE, assignmentColumnOrder(issues.map(issue => issue.nativeStatus))
114
+ .map(status => ({ key: `${ASSIGNMENT_BOARD_TYPE}/${status}`, name: assignmentStatusLabel(status) })));
115
+ }
116
+
94
117
  export function canReadNewsroomBoard(phase: string | undefined): boolean {
95
118
  return phase === "ready";
96
119
  }
@@ -1,16 +1,16 @@
1
+ import { contentItemBoardPathSegment } from "./newsroom-board";
1
2
  import { getContentItemName } from "./site-brand";
2
3
 
3
4
  const contentItemPluralLowercase = getContentItemName().plural.toLowerCase();
4
5
  export type OrganizerDestination = {
5
6
  key: string; label: string; description: string; path: string;
6
- access?: "admin"; primary?: boolean; unavailable?: string;
7
+ access?: "admin"; primary?: boolean; unavailable?: string; alsoActiveOnPaths?: readonly string[];
7
8
  };
8
9
  export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
9
10
  { title: "Editorial work", items: [
10
11
  { key: "overview", label: "Overview", description: "See the desk and choose where to begin.", path: "/newsroom", primary: true },
11
- { key: "board", label: "Board", description: `Follow ${contentItemPluralLowercase}, sources and assignments across their stages.`, path: "/newsroom/work", primary: true },
12
- { key: "articles", label: getContentItemName().plural, description: "Write, revise and prepare copy for publication.", path: "/newsroom/articles" },
13
- { key: "assignments", label: "Assignments", description: "Claim work and follow research and reporting progress.", path: "/newsroom/assignments" },
12
+ { key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
13
+ { key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
14
14
  { key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
15
15
  { key: "insights", label: "Insights", description: "Discuss findings and research threads.", path: "/newsroom/insights" },
16
16
  ] },
@@ -30,7 +30,7 @@ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly Org
30
30
  ] },
31
31
  ];
32
32
  const all = ORGANIZER_NAV_GROUPS.flatMap(group => group.items);
33
- export const ORGANIZER_PRIMARY_NAV = ["overview", "board", "references", "messages"].map(key => all.find(item => item.key === key)!);
33
+ export const ORGANIZER_PRIMARY_NAV = ["overview", "articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
34
34
 
35
35
  export const ORGANIZER_SETTINGS_DESTINATION = {
36
36
  label: "Settings", path: "/newsroom/settings", description: "Theme, motion and interface preferences.",
@@ -9,6 +9,27 @@ import {
9
9
 
10
10
  export { layoutNextLine, prepareWithSegments, type LayoutCursor, type PreparedTextWithSegments };
11
11
 
12
+ const preparedTextCache = new Map<string, PreparedTextWithSegments>();
13
+ let preparedTextCacheMissCount = 0;
14
+
15
+ export function getPreparedTextCacheMissCount(): number {
16
+ return preparedTextCacheMissCount;
17
+ }
18
+
19
+ export function prepareWithSegmentsCached(
20
+ text: string,
21
+ font: string,
22
+ options?: { whiteSpace?: "normal" | "pre-wrap" },
23
+ ): PreparedTextWithSegments {
24
+ const key = `${options?.whiteSpace ?? "normal"}\u0000${font}\u0000${text}`;
25
+ const cached = preparedTextCache.get(key);
26
+ if (cached) return cached;
27
+ preparedTextCacheMissCount += 1;
28
+ const prepared = prepareWithSegments(text, font, options);
29
+ preparedTextCache.set(key, prepared);
30
+ return prepared;
31
+ }
32
+
12
33
  export type TextLine = {
13
34
  text: string;
14
35
  width: number;
@@ -184,5 +205,6 @@ function clamp(value: number, min: number, max: number): number {
184
205
  }
185
206
 
186
207
  export function clearPretextMeasurementCache(): void {
208
+ preparedTextCache.clear();
187
209
  clearCache();
188
210
  }
@@ -61,3 +61,76 @@ export const DEMO_CYCLOTRON_STATUS: CyclotronStatus = {
61
61
  lastChange: null,
62
62
  pending: { decisionsAwaitingReview: 1, staleSince: null },
63
63
  };
64
+
65
+ /**
66
+ * What the Newsroom assumes until a decision names the cyclotron: the defaults
67
+ * of the steering config's relevanceCyclotron block (src/papyrus_content/relevance_cyclotron.py).
68
+ */
69
+ export const DEFAULT_RELEVANCE_CYCLOTRON = { id: "papyrus-relevance", labels: ["include", "exclude"] } as const;
70
+
71
+ /** The payload kind of a priming request; the sweep lists the cyclotron's payloads and filters on it. */
72
+ export const PRIMING_REQUEST_KIND = "cyclotron-priming-request";
73
+
74
+ /** The attachment role the sweep writes a request's outcome under. */
75
+ export const PRIMING_OUTCOME_ROLE = "priming_outcome";
76
+
77
+ /** The KnowledgeRawPayload an editor writes to ask the next sweep to prime the cyclotron with one URL. */
78
+ export function primingRequestPayloadId(cyclotronId: string, requestId: string): string {
79
+ return `knowledge-raw-payload-cyclotron-priming-request-${safeId(cyclotronId)}-${safeId(requestId)}`;
80
+ }
81
+
82
+ export type PrimingRequest = {
83
+ requestId: string;
84
+ url: string;
85
+ label: string;
86
+ explanation: string;
87
+ requestedBy: string;
88
+ requestedAt: string;
89
+ };
90
+
91
+ export type PrimingOutcome =
92
+ | { state: "applied"; version: number; at: string; itemId?: string }
93
+ | { state: "failed"; message: string; at: string };
94
+
95
+ /** A saved request with the sweep's outcome, once there is one. */
96
+ export type PrimingRequestView = { request: PrimingRequest; outcome: PrimingOutcome | null };
97
+
98
+ export function buildPrimingRequest(
99
+ input: { url: string; label: string; explanation?: string },
100
+ requestedBy: string,
101
+ labels: readonly string[],
102
+ now: Date = new Date(),
103
+ requestId: string = `${now.getTime().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
104
+ ): PrimingRequest {
105
+ let parsed: URL | null = null;
106
+ try {
107
+ parsed = new URL(input.url.trim());
108
+ } catch {
109
+ parsed = null;
110
+ }
111
+ if (!parsed || parsed.protocol !== "https:") throw new Error("Paste an https URL.");
112
+ if (!labels.includes(input.label)) throw new Error(`Choose a label: ${labels.join(" or ")}.`);
113
+ return {
114
+ requestId,
115
+ url: parsed.toString(),
116
+ label: input.label,
117
+ explanation: (input.explanation ?? "").trim(),
118
+ requestedBy,
119
+ requestedAt: now.toISOString(),
120
+ };
121
+ }
122
+
123
+ export function isPrimingOutcome(value: unknown): value is PrimingOutcome {
124
+ if (!value || typeof value !== "object") return false;
125
+ const outcome = value as Record<string, unknown>;
126
+ return (outcome.state === "applied" && typeof outcome.version === "number")
127
+ || (outcome.state === "failed" && typeof outcome.message === "string");
128
+ }
129
+
130
+ /** In words: waiting for the next sweep, applied as version N, or failed with the reason. */
131
+ export function primingStateText(view: PrimingRequestView): string {
132
+ if (!view.outcome) return "Waiting for the next sweep";
133
+ return view.outcome.state === "applied"
134
+ ? `Applied as version ${view.outcome.version}`
135
+ : `Failed: ${view.outcome.message}`;
136
+ }
package/lib/site-brand.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { isLocalPreviewEnabled } from "./local-preview";
1
2
  import type { ComponentType } from "react";
2
3
  import type { EditionPresentationFormat } from "./content-types";
3
4
  import type { Article, ArticleVideoAsset } from "./articles";
@@ -184,11 +185,17 @@ const REFERENCE_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
184
185
  * The Papyrus reference brand plus whatever the publication registered in its
185
186
  * own `papyrus.config.ts` (resolved through the `papyrus-site` alias).
186
187
  */
187
- const SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
188
+ const REGISTERED_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
188
189
  ...REFERENCE_SITE_BRANDS,
189
190
  ...Object.fromEntries((papyrusSite.brands ?? []).map((brand) => [brand.id, brand])),
190
191
  };
191
192
 
193
+ const SITE_BRANDS: Record<SiteBrandId, SiteBrand> = isLocalPreviewEnabled()
194
+ ? Object.fromEntries(
195
+ Object.entries(REGISTERED_SITE_BRANDS).map(([id, brand]) => [id, { ...brand, newsroomLayout: "organizer" as const }]),
196
+ )
197
+ : REGISTERED_SITE_BRANDS;
198
+
192
199
  export function normalizeSiteBrandId(value: string | undefined | null): SiteBrandId | null {
193
200
  if (!value) return null;
194
201
  const trimmed = value.trim();
@@ -0,0 +1,251 @@
1
+ import { type ArticlePublicationItem, getPublicationItemText } from "./publication-items";
2
+ import {
3
+ type LayoutCursor,
4
+ layoutTextLines,
5
+ type PreparedTextWithSegments,
6
+ prepareWithSegmentsCached,
7
+ type TextLine,
8
+ type TextObstacle,
9
+ } from "./pretext-layout";
10
+ import { reserveRhythmRows, type VerticalRhythm } from "./blog-rhythm";
11
+ import {
12
+ type FurnitureSufficiencyReport,
13
+ type LayoutConfig,
14
+ type SolvedChromeBox,
15
+ type SolvedFurniture,
16
+ } from "./layout-types";
17
+ import { SERIF_TEXT_FONT, clamp, getSpanWidth } from "./layout-config";
18
+ import { rectsOverlap } from "./furniture";
19
+
20
+ export function layoutTextColumns({
21
+ item,
22
+ cursor,
23
+ columnCount,
24
+ textHeight,
25
+ maximumTextHeightByColumn = [],
26
+ localConfig,
27
+ furniture,
28
+ chromeBoxes = [],
29
+ furnitureObstacleOffsetY = 0,
30
+ furnitureObstacleMarginBottom = 0,
31
+ minimumLineStartYByColumn = [],
32
+ }: {
33
+ item: ArticlePublicationItem;
34
+ cursor: LayoutCursor;
35
+ columnCount: number;
36
+ textHeight: number;
37
+ maximumTextHeightByColumn?: number[];
38
+ localConfig: LayoutConfig;
39
+ furniture: SolvedFurniture[];
40
+ chromeBoxes?: SolvedChromeBox[];
41
+ furnitureObstacleOffsetY?: number;
42
+ furnitureObstacleMarginBottom?: number;
43
+ minimumLineStartYByColumn?: number[];
44
+ }): { columns: TextLine[][]; cursor: LayoutCursor; hasMore: boolean } {
45
+ const columns: TextLine[][] = [];
46
+ const preparedText = getPrepared(item, localConfig.bodyFont);
47
+ const columnWidth = getSpanWidth(localConfig, 1);
48
+ let current = { ...cursor };
49
+ let hasMore = true;
50
+
51
+ for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
52
+ const result = layoutTextLines({
53
+ prepared: preparedText,
54
+ cursor: current,
55
+ maxHeight: maximumTextHeightByColumn[columnIndex] ?? textHeight,
56
+ maxWidth: columnWidth,
57
+ lineHeight: localConfig.lineHeight,
58
+ linePaintHeight: localConfig.linePaintHeight,
59
+ fontSize: localConfig.bodyFontSize,
60
+ fontFamily: SERIF_TEXT_FONT,
61
+ obstacles: getColumnTextObstacles(
62
+ furniture,
63
+ columnIndex,
64
+ chromeBoxes,
65
+ furnitureObstacleOffsetY,
66
+ furnitureObstacleMarginBottom,
67
+ minimumLineStartYByColumn[columnIndex] ?? 0,
68
+ ),
69
+ });
70
+ columns.push(result.lines);
71
+ current = result.cursor;
72
+ hasMore = result.hasMore;
73
+ if (!hasMore) {
74
+ while (columns.length < columnCount) columns.push([]);
75
+ break;
76
+ }
77
+ }
78
+
79
+ return { columns, cursor: current, hasMore };
80
+ }
81
+
82
+ export function getPrepared(article: ArticlePublicationItem, font: string): PreparedTextWithSegments {
83
+ return prepareWithSegmentsCached(getPublicationItemText(article), font, { whiteSpace: "pre-wrap" });
84
+ }
85
+
86
+ export function getLineLimitHeight(maxLines: number, lineHeight: number, linePaintHeight: number, rhythm: VerticalRhythm): number {
87
+ if (maxLines <= 0) return 0;
88
+ return reserveRhythmRows((maxLines - 1) * lineHeight + linePaintHeight, rhythm);
89
+ }
90
+
91
+ function getColumnTextObstacles(
92
+ furniture: SolvedFurniture[],
93
+ columnIndex: number,
94
+ chromeBoxes: SolvedChromeBox[] = [],
95
+ furnitureOffsetY = 0,
96
+ furnitureMarginBottom = 0,
97
+ minimumLineStartY = 0,
98
+ ): TextObstacle[] {
99
+ const topObstacle: TextObstacle[] = minimumLineStartY > 0
100
+ ? [{ x: 0, y: 0, width: Number.POSITIVE_INFINITY, height: minimumLineStartY }]
101
+ : [];
102
+ const furnitureObstacles = furniture
103
+ .filter((item) => item.wrapsText && columnIndex >= item.columnStart && columnIndex < item.columnStart + item.columnSpan)
104
+ .map((item) => ({
105
+ x: 0,
106
+ y: item.y + furnitureOffsetY,
107
+ width: Number.POSITIVE_INFINITY,
108
+ height: item.height + getFurnitureObstacleMarginBottom(item, furnitureMarginBottom),
109
+ }));
110
+ const imageClearanceObstacles = furniture
111
+ .filter((item) => (
112
+ item.kind === "image" &&
113
+ item.wrapsText &&
114
+ furnitureMarginBottom > 0 &&
115
+ columnIndex >= item.columnStart &&
116
+ columnIndex < item.columnStart + item.columnSpan
117
+ ))
118
+ .map((item) => ({
119
+ x: 0,
120
+ y: item.y + furnitureOffsetY + item.height,
121
+ width: Number.POSITIVE_INFINITY,
122
+ height: furnitureMarginBottom,
123
+ }));
124
+ const chromeObstacles = chromeBoxes
125
+ .filter((box) => columnIndex >= box.columnStart && columnIndex < box.columnStart + box.columnSpan)
126
+ .map((box) => ({
127
+ x: 0,
128
+ y: box.y,
129
+ width: Number.POSITIVE_INFINITY,
130
+ height: box.height,
131
+ }));
132
+ return [...topObstacle, ...furnitureObstacles, ...imageClearanceObstacles, ...chromeObstacles];
133
+ }
134
+
135
+ function getFurnitureObstacleMarginBottom(item: SolvedFurniture, marginBottom: number): number {
136
+ return item.kind === "image" || item.kind === "pullQuote" ? marginBottom : 0;
137
+ }
138
+
139
+ export function getColumnWhitespace(columns: TextLine[][], textHeight: number, furniture: SolvedFurniture[]): number {
140
+ return columns.reduce(
141
+ (total, column, columnIndex) =>
142
+ total + Math.max(0, textHeight - getLinesHeight(column) - getColumnObstacleHeight(furniture, columnIndex, textHeight)),
143
+ 0,
144
+ );
145
+ }
146
+
147
+ export function getFurnitureSufficiencyReport({
148
+ columns,
149
+ textHeight,
150
+ furniture,
151
+ hasMore,
152
+ config,
153
+ }: {
154
+ columns: TextLine[][];
155
+ textHeight: number;
156
+ furniture: SolvedFurniture[];
157
+ hasMore: boolean;
158
+ config: LayoutConfig;
159
+ }): FurnitureSufficiencyReport {
160
+ const visibleRows = columns.reduce((total, column) => total + column.length, 0);
161
+ const furnitureRows = furniture.reduce(
162
+ (total, item) => total + getFurnitureRowBurden(item, config.rhythm.rowHeight),
163
+ 0,
164
+ );
165
+ const accepted = (reason?: string): FurnitureSufficiencyReport => ({
166
+ accepted: !reason,
167
+ reason,
168
+ visibleRows,
169
+ furnitureRows,
170
+ });
171
+
172
+ if (furniture.length === 0 || furnitureRows === 0) return accepted();
173
+ const overlapReason = getFurnitureOverlapReason(furniture);
174
+ if (overlapReason) return accepted(overlapReason);
175
+ const minimumReadableRows = columns.length * (furniture.some((item) => item.kind === "pullQuote") ? 6 : 3);
176
+ if (visibleRows * 4 < furnitureRows) {
177
+ return accepted("visible-copy-far-below-furniture-burden");
178
+ }
179
+ if (visibleRows < furnitureRows && visibleRows < minimumReadableRows) {
180
+ return accepted("visible-copy-below-furniture-burden");
181
+ }
182
+
183
+ const finalTextColumnIndex = columns.reduce((lastIndex, column, columnIndex) => (
184
+ column.length > 0 ? columnIndex : lastIndex
185
+ ), -1);
186
+ const exhausted = !hasMore;
187
+ for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) {
188
+ const visibleColumnRows = columns[columnIndex].length;
189
+ const obstacleRows = Math.ceil(getColumnObstacleHeight(furniture, columnIndex, textHeight) / config.rhythm.rowHeight);
190
+ const availableRows = Math.floor(textHeight / config.rhythm.rowHeight) - obstacleRows;
191
+ const hasSubstantialFurniture = obstacleRows >= 6;
192
+ const isAfterExhaustedText = exhausted && columnIndex > finalTextColumnIndex;
193
+ if (!hasSubstantialFurniture && !isAfterExhaustedText && availableRows >= 3 && visibleColumnRows < 3) {
194
+ return accepted("open-column-below-minimum-copy");
195
+ }
196
+ if (!hasSubstantialFurniture && visibleColumnRows === 1 && columnIndex < finalTextColumnIndex) {
197
+ return accepted("non-final-column-single-copy-row");
198
+ }
199
+ }
200
+
201
+ return accepted();
202
+ }
203
+
204
+ function getFurnitureRowBurden(item: SolvedFurniture, rowHeight: number): number {
205
+ if (item.kind !== "image" && item.kind !== "pullQuote") return 0;
206
+ return Math.ceil(item.height / rowHeight) * item.columnSpan;
207
+ }
208
+
209
+ function getFurnitureOverlapReason(furniture: SolvedFurniture[]): string | undefined {
210
+ for (let firstIndex = 0; firstIndex < furniture.length; firstIndex += 1) {
211
+ for (let secondIndex = firstIndex + 1; secondIndex < furniture.length; secondIndex += 1) {
212
+ if (rectsOverlap(furniture[firstIndex], furniture[secondIndex])) return "furniture-overlap";
213
+ }
214
+ }
215
+ return undefined;
216
+ }
217
+
218
+ function getColumnObstacleHeight(furniture: SolvedFurniture[], columnIndex: number, textHeight: number): number {
219
+ const intervals = furniture
220
+ .filter((item) => item.wrapsText && columnIndex >= item.columnStart && columnIndex < item.columnStart + item.columnSpan)
221
+ .map((item) => ({
222
+ start: clamp(item.y, 0, textHeight),
223
+ end: clamp(item.y + item.height, 0, textHeight),
224
+ }))
225
+ .filter((interval) => interval.end > interval.start)
226
+ .sort((a, b) => a.start - b.start);
227
+
228
+ const merged: Array<{ start: number; end: number }> = [];
229
+ for (const interval of intervals) {
230
+ const previous = merged[merged.length - 1];
231
+ if (previous && interval.start <= previous.end) previous.end = Math.max(previous.end, interval.end);
232
+ else merged.push({ ...interval });
233
+ }
234
+ return merged.reduce((total, interval) => total + interval.end - interval.start, 0);
235
+ }
236
+
237
+ export function getDeadColumnCount(columns: TextLine[][], textHeight: number, furniture: SolvedFurniture[]): number {
238
+ return columns.reduce((count, column, columnIndex) => {
239
+ if (column.length > 0) return count;
240
+ const obstacleHeight = getColumnObstacleHeight(furniture, columnIndex, textHeight);
241
+ return obstacleHeight >= Math.min(96, textHeight * 0.18) ? count : count + 1;
242
+ }, 0);
243
+ }
244
+
245
+ export function getLinesHeight(lines: TextLine[]): number {
246
+ return lines.length === 0 ? 0 : Math.max(...lines.map((line) => line.y + line.paintHeight));
247
+ }
248
+
249
+ export function getFurnitureBottom(furniture: SolvedFurniture[]): number {
250
+ return furniture.reduce((bottom, item) => Math.max(bottom, item.y + item.height), 0);
251
+ }
package/package.json CHANGED
@@ -105,6 +105,7 @@
105
105
  "./components/reader-settings": "./components/reader-settings.ts",
106
106
  "./components/reader-theme-control": "./components/reader-theme-control.tsx",
107
107
  "./components/reference-curation-controls": "./components/reference-curation-controls.tsx",
108
+ "./components/reference-cyclotron-priming": "./components/reference-cyclotron-priming.tsx",
108
109
  "./components/reference-cyclotron-status": "./components/reference-cyclotron-status.tsx",
109
110
  "./components/reference-priming": "./components/reference-priming.tsx",
110
111
  "./components/reference-relevance-review": "./components/reference-relevance-review.tsx",
@@ -142,6 +143,8 @@
142
143
  "./lib/approved-priming-proof-reports": "./lib/approved-priming-proof-reports.ts",
143
144
  "./lib/archive-data": "./lib/archive-data.ts",
144
145
  "./lib/archive-types": "./lib/archive-types.ts",
146
+ "./lib/article-flow": "./lib/article-flow.ts",
147
+ "./lib/article-frame-solver": "./lib/article-frame-solver.ts",
145
148
  "./lib/articles": "./lib/articles.ts",
146
149
  "./lib/blog-feature-solver": "./lib/blog-feature-solver.ts",
147
150
  "./lib/blog-rhythm": "./lib/blog-rhythm.ts",
@@ -150,6 +153,7 @@
150
153
  "./lib/cached-content-repository": "./lib/cached-content-repository.ts",
151
154
  "./lib/category-dashboard": "./lib/category-dashboard.ts",
152
155
  "./lib/category-repository": "./lib/category-repository.ts",
156
+ "./lib/chrome-text": "./lib/chrome-text.ts",
153
157
  "./lib/console-chat-client": "./lib/console-chat-client.ts",
154
158
  "./lib/console-web-context": "./lib/console-web-context.ts",
155
159
  "./lib/content-actions-client": "./lib/content-actions-client.ts",
@@ -168,10 +172,17 @@
168
172
  "./lib/edition-sections": "./lib/edition-sections.ts",
169
173
  "./lib/empty-edition-layout-plan": "./lib/empty-edition-layout-plan.ts",
170
174
  "./lib/excerpts": "./lib/excerpts.ts",
175
+ "./lib/front-mosaic": "./lib/front-mosaic.ts",
176
+ "./lib/furniture": "./lib/furniture.ts",
171
177
  "./lib/graphql-content-repository": "./lib/graphql-content-repository.ts",
172
178
  "./lib/image-url": "./lib/image-url.ts",
179
+ "./lib/labels": "./lib/labels.ts",
180
+ "./lib/layout-config": "./lib/layout-config.ts",
173
181
  "./lib/layout-plan": "./lib/layout-plan.ts",
174
182
  "./lib/layout-scenarios": "./lib/layout-scenarios.ts",
183
+ "./lib/layout-types": "./lib/layout-types.ts",
184
+ "./lib/local-preview-seed": "./lib/local-preview-seed.ts",
185
+ "./lib/local-preview": "./lib/local-preview.ts",
175
186
  "./lib/markus-body": "./lib/markus-body.ts",
176
187
  "./lib/markus-ir": "./lib/markus-ir.ts",
177
188
  "./lib/markus-projection": "./lib/markus-projection.ts",
@@ -245,6 +256,7 @@
245
256
  "./lib/staging-gated-path": "./lib/staging-gated-path.ts",
246
257
  "./lib/staging-preview-object": "./lib/staging-preview-object.ts",
247
258
  "./lib/test-editor-override": "./lib/test-editor-override.ts",
259
+ "./lib/text-columns": "./lib/text-columns.ts",
248
260
  "./lib/themed-image": "./lib/themed-image.ts",
249
261
  "./lib/utils": "./lib/utils.ts",
250
262
  "./lib/video-loading": "./lib/video-loading.ts",
@@ -289,7 +301,6 @@
289
301
  "./app/newsroom/page": "./app/newsroom/page.tsx",
290
302
  "./app/newsroom/sections/[sectionId]/page": "./app/newsroom/sections/[sectionId]/page.tsx",
291
303
  "./app/newsroom/settings/page": "./app/newsroom/settings/page.tsx",
292
- "./app/newsroom/work/page": "./app/newsroom/work/page.tsx",
293
304
  "./app/page": "./app/page.tsx",
294
305
  "./app/robots": "./app/robots.ts",
295
306
  "./app/settings/page": "./app/settings/page.tsx"
@@ -339,7 +350,7 @@
339
350
  "optional": true
340
351
  }
341
352
  },
342
- "version": "1.3.0",
353
+ "version": "1.4.0-next.2",
343
354
  "dependencies": {
344
355
  "@aws-amplify/adapter-nextjs": "^1.7.3",
345
356
  "@aws-crypto/sha256-js": "5.2.0",
@@ -288,7 +288,7 @@ function PresentationItem({
288
288
  const lines = useMemo(() => {
289
289
  if (!maxWidth || !text.trim()) return [];
290
290
  return layoutAllTextLines({
291
- prepared: prepareWithSegments(text, `${textStyle.fontSize}px ${textStyle.fontFamily}`),
291
+ prepared: prepareWithSegments(text, `${textStyle.fontSize}px ${textStyle.fontFamily}`, { whiteSpace: "pre-wrap" }),
292
292
  maxWidth,
293
293
  ...textStyle,
294
294
  });