@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
@@ -48,6 +48,7 @@ const ALLOWED_ROLES = new Set([
48
48
  "code",
49
49
  "graph_export",
50
50
  "raw_payload",
51
+ "priming_outcome",
51
52
  ]);
52
53
  const ALLOWED_MEDIA_TYPES = new Set([
53
54
  "application/json",
@@ -1,6 +1,8 @@
1
1
  import { resolveActiveSiteBrand } from "../../../lib/site-brand-tenant";
2
2
  import { notFound, redirect } from "next/navigation";
3
3
  import { toPublicNewsroomPath } from "../../../lib/newsroom-base-path";
4
+ import { contentItemBoardPathSegment } from "../../../lib/newsroom-board";
5
+ import { NewsroomBoardView } from "../../../components/newsroom-board-view";
4
6
  import { NewsDeskPage } from "../../../components/news-desk-page";
5
7
  import type { NewsDeskTab } from "../../../components/topic-steering-workspace";
6
8
 
@@ -42,9 +44,15 @@ const NEWS_DESK_ROUTE_SECTIONS = new Set<NewsDeskTab>([
42
44
 
43
45
  export default async function NewsDeskSectionPage({ params, searchParams }: NewsDeskSectionPageProps) {
44
46
  const { section } = await params;
47
+ if (section === "work") redirect(toPublicNewsroomPath(`/newsroom/${contentItemBoardPathSegment()}`));
45
48
  if (section === "users") redirect(toPublicNewsroomPath("/newsroom/administration/users"));
46
49
  if (section === "doctrine") redirect(toPublicNewsroomPath("/newsroom/administration/policies"));
47
50
  if (section === "desks" && resolveActiveSiteBrand().newsroomLayout !== "organizer") redirect(toPublicNewsroomPath("/newsroom/topics"));
51
+ if (section === contentItemBoardPathSegment() && resolveActiveSiteBrand().newsroomLayout === "organizer") return <NewsroomBoardView subject="contentItem" />;
48
52
  if (!NEWS_DESK_ROUTE_SECTIONS.has(section as NewsDeskTab)) notFound();
53
+ if (section === "assignments" && resolveActiveSiteBrand().newsroomLayout === "organizer") {
54
+ const resolved = await searchParams;
55
+ if (!resolved?.assignment && resolved?.demo !== "1") return <NewsroomBoardView subject="assignment" />;
56
+ }
49
57
  return <NewsDeskPage section={section} searchParams={searchParams} />;
50
58
  }
@@ -1,3 +1,8 @@
1
+ import { redirect } from "next/navigation";
2
+ import { contentItemBoardPathSegment } from "../../../lib/newsroom-board";
3
+ import { toPublicNewsroomPath } from "../../../lib/newsroom-base-path";
4
+ import { NewsroomBoardView } from "../../../components/newsroom-board-view";
5
+ import { resolveActiveSiteBrand } from "../../../lib/site-brand-tenant";
1
6
  import { NewsroomArticlesView } from "../../../components/newsroom-articles-view";
2
7
 
3
8
  export const dynamic = "force-dynamic";
@@ -9,5 +14,10 @@ type NewsroomArticlesPageProps = {
9
14
  export default async function NewsroomArticlesPage({ searchParams }: NewsroomArticlesPageProps) {
10
15
  const resolved = await searchParams;
11
16
  const demoValue = Array.isArray(resolved?.demo) ? resolved?.demo[0] : resolved?.demo;
17
+ if (resolveActiveSiteBrand().newsroomLayout === "organizer" && demoValue !== "1") {
18
+ const boardPathSegment = contentItemBoardPathSegment();
19
+ if (boardPathSegment !== "articles") redirect(toPublicNewsroomPath(`/newsroom/${boardPathSegment}`));
20
+ return <NewsroomBoardView subject="contentItem" />;
21
+ }
12
22
  return <NewsroomArticlesView demo={demoValue === "1"} />;
13
23
  }
@@ -3,6 +3,7 @@
3
3
  import { Hub } from "aws-amplify/utils";
4
4
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
5
5
  import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
6
+ import type { CategorySteeringDashboard } from "../lib/category-repository";
6
7
  import { ensureDevSandboxEditorSession } from "./dev-sandbox-editor-sign-in";
7
8
  import {
8
9
  loadEditorAssignmentsData,
@@ -46,7 +47,12 @@ const NewsDeskClientContext = createContext<NewsDeskClientContextValue | null>(n
46
47
  export function NewsDeskClientProvider({
47
48
  children,
48
49
  devSandboxEditorAuth = null,
49
- }: Readonly<{ children: React.ReactNode; devSandboxEditorAuth?: DevSandboxEditorAuth | null }>) {
50
+ localPreviewDashboard = null,
51
+ }: Readonly<{
52
+ children: React.ReactNode;
53
+ devSandboxEditorAuth?: DevSandboxEditorAuth | null;
54
+ localPreviewDashboard?: CategorySteeringDashboard | null;
55
+ }>) {
50
56
  const [shell, setShell] = useState<NewsDeskShellState>(() => createInitialNewsDeskShellState());
51
57
  const [searchTransition, setSearchTransition] = useState<NewsDeskSearchTransition | null>(null);
52
58
  const shellRef = useRef(shell);
@@ -58,6 +64,15 @@ export function NewsDeskClientProvider({
58
64
 
59
65
  const refreshDashboard = useCallback(async () => {
60
66
  const sequence = ++bootstrapSequenceRef.current;
67
+ if (localPreviewDashboard) {
68
+ setShell((current) => resolveDashboardReady(
69
+ current,
70
+ localPreviewDashboard,
71
+ { status: "signedIn", label: "Local preview editor", email: null },
72
+ new Date().toISOString(),
73
+ ));
74
+ return;
75
+ }
61
76
  setShell((current) => beginAccessCheck(current));
62
77
 
63
78
  try {
@@ -101,7 +116,7 @@ export function NewsDeskClientProvider({
101
116
  error instanceof Error ? error.message : "Could not load Newsroom data.",
102
117
  ));
103
118
  }
104
- }, [devSandboxEditorAuth]);
119
+ }, [devSandboxEditorAuth, localPreviewDashboard]);
105
120
 
106
121
  const refreshAssignments = useCallback(async () => {
107
122
  try {
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
4
+ import { createLocalPreviewAssignments, createLocalPreviewContentItemVersions, createLocalPreviewReferences } from "../lib/local-preview-seed";
3
5
  import { generateClient } from "aws-amplify/data";
4
6
  import { testEditorOverrideAllowed } from "../lib/test-editor-override";
5
7
  import { downloadData, getUrl } from "aws-amplify/storage";
@@ -39,7 +41,7 @@ import type {
39
41
  UserDirectoryEntry,
40
42
  } from "../lib/category-repository";
41
43
  import { relationTypeKey } from "../lib/semantic-graph";
42
- import { contentItemStageKeys, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
44
+ import { legacyStageKeysToLoad, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
43
45
  import { getContentItemStageDefinitions } from "../lib/site-brand";
44
46
  import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
45
47
  import { configureAmplifyClient } from "./amplify-client-provider";
@@ -1863,6 +1865,7 @@ export function normalizeNewsroomReferencePageOrder(value?: string | null): News
1863
1865
  }
1864
1866
 
1865
1867
  export async function loadNewsroomReferencePage(options: NewsroomReferencePageOptions = {}): Promise<NewsroomRecordPage<ReferenceRecord>> {
1868
+ if (isLocalPreviewEnabled()) return { items: createLocalPreviewReferences(), nextToken: null, hasMore: false };
1866
1869
  configureAmplifyClient();
1867
1870
  const order = normalizeNewsroomReferencePageOrder(options.order);
1868
1871
  if (order === "imported") {
@@ -2146,6 +2149,7 @@ export async function loadEditorOverviewEditionData(): Promise<{
2146
2149
  }
2147
2150
 
2148
2151
  export async function loadNewsroomAssignmentPage(options: NewsroomAssignmentPageOptions = {}): Promise<NewsroomRecordPage<AssignmentRecord>> {
2152
+ if (isLocalPreviewEnabled()) return { items: createLocalPreviewAssignments(), nextToken: null, hasMore: false };
2149
2153
  return loadNewsroomFeedPage<AssignmentRecord>({
2150
2154
  query: NEWSROOM_ASSIGNMENT_FEED_QUERY,
2151
2155
  field: "listAssignmentsByNewsroomFeedAndCreatedAt",
@@ -2168,13 +2172,23 @@ const CONTENT_ITEMS_BY_STAGE_QUERY = `
2168
2172
  }
2169
2173
  `;
2170
2174
 
2171
- export async function loadNewsroomContentItemVersions(): Promise<ContentItemVersionRecord[]> {
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
+ ];
2183
+ if (isLocalPreviewEnabled()) {
2184
+ return createLocalPreviewContentItemVersions().filter(version => stagesToLoad.includes(version.stage ?? ""));
2185
+ }
2172
2186
  configureAmplifyClient();
2173
2187
  const client = generateClient<Schema>() as unknown as {
2174
2188
  graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<ContentItemVersionRecord>>;
2175
2189
  };
2176
2190
  const field = "listItemsByStageAndUpdatedAt";
2177
- const versionsPerStage = await Promise.all(contentItemStageKeys(getContentItemStageDefinitions()).map(async (stage) => {
2191
+ const versionsPerStage = await Promise.all(stagesToLoad.map(async (stage) => {
2178
2192
  const versions: ContentItemVersionRecord[] = [];
2179
2193
  let nextToken: string | null = null;
2180
2194
  do {
@@ -3826,3 +3840,72 @@ export async function requestCyclotronReviewRate(
3826
3840
  content: JSON.stringify(request),
3827
3841
  });
3828
3842
  }
3843
+
3844
+ const LIST_PRIMING_REQUEST_PAYLOADS_QUERY = `
3845
+ query ListKnowledgeRawPayloadsByOwnerAndKind($ownerId: ID!, $payloadKind: ModelStringKeyConditionInput, $limit: Int, $nextToken: String) {
3846
+ listKnowledgeRawPayloadsByOwnerAndKind(ownerId: $ownerId, payloadKind: $payloadKind, limit: $limit, nextToken: $nextToken) {
3847
+ items { id }
3848
+ nextToken
3849
+ }
3850
+ }
3851
+ `;
3852
+
3853
+ /** Ask the next decide-relevance sweep to prime the running cyclotron with one pasted URL. */
3854
+ export async function requestCyclotronPriming(
3855
+ cyclotronId: string,
3856
+ request: import("../lib/relevance-status").PrimingRequest,
3857
+ ): Promise<void> {
3858
+ const { primingRequestPayloadId, PRIMING_REQUEST_KIND, safeId } = await import("../lib/relevance-status");
3859
+ const id = primingRequestPayloadId(cyclotronId, request.requestId);
3860
+ await runGraphql(CREATE_KNOWLEDGE_RAW_PAYLOAD_MUTATION, {
3861
+ input: {
3862
+ id,
3863
+ ownerType: "cyclotron",
3864
+ ownerId: safeId(cyclotronId),
3865
+ payloadKind: PRIMING_REQUEST_KIND,
3866
+ createdAt: request.requestedAt,
3867
+ updatedAt: request.requestedAt,
3868
+ },
3869
+ });
3870
+ await uploadModelPayloadForOwner({
3871
+ ownerKind: "knowledgeRawPayload",
3872
+ ownerId: id,
3873
+ ownerLineageId: id,
3874
+ role: "raw_payload",
3875
+ sortKey: "priming-request",
3876
+ filename: "priming-request.json",
3877
+ mediaType: "application/json",
3878
+ content: JSON.stringify(request),
3879
+ });
3880
+ }
3881
+
3882
+ /** The saved priming requests of a cyclotron, newest first, each with the sweep's outcome once there is one. */
3883
+ export async function loadCyclotronPrimingRequests(
3884
+ cyclotronId: string,
3885
+ ): Promise<import("../lib/relevance-status").PrimingRequestView[]> {
3886
+ const { PRIMING_OUTCOME_ROLE, PRIMING_REQUEST_KIND, isPrimingOutcome, safeId } = await import("../lib/relevance-status");
3887
+ const ids: string[] = [];
3888
+ let nextToken: string | null = null;
3889
+ do {
3890
+ const page: { listKnowledgeRawPayloadsByOwnerAndKind?: { items?: Array<{ id: string } | null>; nextToken?: string | null } | null } =
3891
+ await runGraphql(LIST_PRIMING_REQUEST_PAYLOADS_QUERY, {
3892
+ ownerId: safeId(cyclotronId), payloadKind: { eq: PRIMING_REQUEST_KIND }, limit: 100, nextToken,
3893
+ });
3894
+ const connection = page.listKnowledgeRawPayloadsByOwnerAndKind;
3895
+ ids.push(...(connection?.items ?? []).filter((item): item is { id: string } => Boolean(item)).map((item) => item.id));
3896
+ nextToken = connection?.nextToken ?? null;
3897
+ } while (nextToken);
3898
+ const views = await Promise.all(ids.map(async (id) => {
3899
+ const payloads = await loadModelPayloadsForOwner("knowledgeRawPayload", id, ["raw_payload", PRIMING_OUTCOME_ROLE]);
3900
+ const request = payloads.find((payload) => payload.attachment.role === "raw_payload")?.json;
3901
+ const outcome = payloads.find((payload) => payload.attachment.role === PRIMING_OUTCOME_ROLE)?.json;
3902
+ if (!request || typeof request !== "object") return null;
3903
+ return {
3904
+ request: request as import("../lib/relevance-status").PrimingRequest,
3905
+ outcome: isPrimingOutcome(outcome) ? outcome : null,
3906
+ };
3907
+ }));
3908
+ return views
3909
+ .filter((view): view is import("../lib/relevance-status").PrimingRequestView => view !== null)
3910
+ .sort((left, right) => right.request.requestedAt.localeCompare(left.request.requestedAt));
3911
+ }
@@ -1139,10 +1139,10 @@ function FrontStoryBlock({
1139
1139
  <div className="jump-line">
1140
1140
  {block.jumpTargetPage ? (
1141
1141
  disableLinks ? (
1142
- <span>{block.jumpLabel ?? `SEE MORE ON A${block.jumpTargetPage}`}</span>
1142
+ <span>{block.jumpLabel}</span>
1143
1143
  ) : (
1144
1144
  <Link href={getPageHref(block.jumpTargetPage, editionBasePath)} onClick={(event) => handleJumpClick(event, block.jumpTargetPage ?? 1, scrollToPage)}>
1145
- {block.jumpLabel ?? `SEE MORE ON A${block.jumpTargetPage}`}
1145
+ {block.jumpLabel}
1146
1146
  </Link>
1147
1147
  )
1148
1148
  ) : (
@@ -1178,10 +1178,10 @@ function FrontStoryBlock({
1178
1178
  <div className="jump-line">
1179
1179
  {block.jumpTargetPage ? (
1180
1180
  disableLinks ? (
1181
- <span>{block.jumpLabel ?? `SEE MORE ON A${block.jumpTargetPage}`}</span>
1181
+ <span>{block.jumpLabel}</span>
1182
1182
  ) : (
1183
1183
  <Link href={getPageHref(block.jumpTargetPage, editionBasePath)} onClick={(event) => handleJumpClick(event, block.jumpTargetPage ?? 1, scrollToPage)}>
1184
- {block.jumpLabel ?? `SEE MORE ON A${block.jumpTargetPage}`}
1184
+ {block.jumpLabel}
1185
1185
  </Link>
1186
1186
  )
1187
1187
  ) : (
@@ -2,12 +2,25 @@
2
2
 
3
3
  import { useEffect, useMemo, useRef, useState } from "react";
4
4
  import Link from "next/link";
5
- import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter } 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
- import { canReadNewsroomBoard, contentItemBoardType, newsroomBoardConfig, projectNewsroomBoard, type NativeBoardIssue } from "../lib/newsroom-board";
8
+ import { PlusIcon } from "lucide-react";
9
+ import { buttonVariants } from "./ui/button";
10
+ import { cn } from "../lib/utils";
11
+ import { newsroomHref } from "../lib/newsroom-base-path";
12
+ import {
13
+ ASSIGNMENT_BOARD_TYPE,
14
+ assignmentBoardConfig,
15
+ canReadNewsroomBoard,
16
+ contentItemBoardConfig,
17
+ contentItemBoardType,
18
+ projectAssignmentBoard,
19
+ projectContentItemBoard,
20
+ type NativeBoardIssue,
21
+ } from "../lib/newsroom-board";
9
22
  import type { AssignmentRecord } from "../lib/category-repository";
10
- import { getContentItemName, SITE_BRAND } from "../lib/site-brand";
23
+ import { getContentItemName, getContentItemStageDefinitions, SITE_BRAND } from "../lib/site-brand";
11
24
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
12
25
  import { loadNewsroomAssignmentPage, loadNewsroomContentItemVersions, loadNewsroomPublishedContentItemVersions } from "./news-desk-taxonomy-client";
13
26
  import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
@@ -17,6 +30,7 @@ import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
17
30
  import { readLocalReaderSettings, subscribeReaderSettingsChanges } from "./reader-settings";
18
31
 
19
32
  const STATIC_MOTION = { mode: "static" } as const;
33
+ const BOARD_REFRESH_INTERVAL_MILLISECONDS = 30_000;
20
34
 
21
35
  async function loadEveryNewsroomAssignment() {
22
36
  const assignments: AssignmentRecord[] = [];
@@ -29,20 +43,22 @@ async function loadEveryNewsroomAssignment() {
29
43
  return assignments;
30
44
  }
31
45
 
32
- /** Read-only shared board over authenticated CMS records; existing workspaces own every mutation. */
33
- export function NewsroomBoardView() {
46
+ export type NewsroomBoardSubject = "contentItem" | "assignment";
47
+
48
+ /** Read-only board of one record kind over authenticated CMS records; existing workspaces own every mutation. */
49
+ export function NewsroomBoardView({ subject }: { subject: NewsroomBoardSubject }) {
34
50
  const session = useOptionalNewsDeskClient();
35
51
  const allowed = canReadNewsroomBoard(session?.shell.phase);
36
52
  const theme = useResolvedPapyrusTheme();
37
53
  const [lowMotion, setLowMotion] = useState(false);
38
54
  const [issues, setIssues] = useState<NativeBoardIssue[]>([]);
39
- const [filter, setFilter] = useState(contentItemBoardType());
40
55
  const [view, setView] = useState<"board" | "list">("board");
41
- const [revision, setRevision] = useState(0);
42
56
  const [selected, setSelected] = useState<string | null>(null);
43
57
  const [loading, setLoading] = useState(false);
44
58
  const [error, setError] = useState<string | null>(null);
45
59
  const generation = useRef(0);
60
+ const openedCollapsedUntilOpenedStagesRef = useRef<string[]>([]);
61
+ const refreshIssuesRef = useRef<((initial: boolean) => void) | null>(null);
46
62
  useEffect(() => {
47
63
  setLowMotion(readLocalReaderSettings().motion === "low");
48
64
  return subscribeReaderSettingsChanges(settings => setLowMotion(settings.motion === "low"));
@@ -51,35 +67,60 @@ export function NewsroomBoardView() {
51
67
  const request = ++generation.current;
52
68
  setIssues([]); setSelected(null); setError(null);
53
69
  if (!allowed) { setLoading(false); return; }
54
- setLoading(true);
55
- Promise.all([
56
- Promise.all([loadNewsroomContentItemVersions(), loadNewsroomPublishedContentItemVersions()]).then(([workingVersions, publishedVersions]) => [...workingVersions, ...publishedVersions]),
57
- loadEveryNewsroomAssignment(),
58
- ]).then(([contentItemVersions, assignments]) => {
59
- if (generation.current !== request) return;
60
- setIssues(projectNewsroomBoard({ contentItemVersions, assignments }));
61
- }).catch((reason: unknown) => {
62
- if (generation.current === request) setError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
63
- }).finally(() => { if (generation.current === request) { setLoading(false); } });
64
- return () => { generation.current = request + 1; };
65
- }, [allowed, revision]);
66
- const config = useMemo(() => newsroomBoardConfig(issues), [issues]);
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);
76
+ 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);
82
+ }).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); });
85
+ };
86
+ refreshIssuesRef.current = refresh;
87
+ refresh(true);
88
+ const refreshWhenVisible = () => { if (document.visibilityState === "visible") refresh(false); };
89
+ const interval = window.setInterval(refreshWhenVisible, BOARD_REFRESH_INTERVAL_MILLISECONDS);
90
+ document.addEventListener("visibilitychange", refreshWhenVisible);
91
+ return () => { generation.current = request + 1; window.clearInterval(interval); document.removeEventListener("visibilitychange", refreshWhenVisible); };
92
+ }, [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]);
99
+ useEffect(() => {
100
+ openedCollapsedUntilOpenedStagesRef.current = openedCollapsedUntilOpenedStageKeys ? openedCollapsedUntilOpenedStageKeys.split(",") : [];
101
+ if (openedCollapsedUntilOpenedStageKeys) refreshIssuesRef.current?.(false);
102
+ }, [openedCollapsedUntilOpenedStageKeys]);
103
+ const pageTitle = subject === "contentItem" ? getContentItemName().plural : "Assignments";
67
104
  const issue = issues.find(item => item.id === selected);
68
105
  if (!allowed) return <NewsDeskAccessGate shell={session?.shell ?? null} />;
69
- return <NewsroomOpsShell activeTab="work" appTitle={SITE_BRAND.appTitle} backLabel={SITE_BRAND.backToHomeLabel} pageTitle="Board">
106
+ return <NewsroomOpsShell activeTab={subject === "contentItem" ? "articles" : "assignments"} appTitle={SITE_BRAND.appTitle} backLabel={SITE_BRAND.backToHomeLabel} pageTitle={pageTitle}
107
+ 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" />
112
+ {`New ${getContentItemName().singular.toLowerCase()}`}
113
+ </Link>
114
+ )}
115
+ </>}>
70
116
  <div className="organizer-native-board papyrus-newsprint-board" data-color-mode={theme}>
71
- <div className="organizer-board-toolbar">
72
- <IssueTypeFilter config={config} value={filter} onChange={value => { setFilter(value); setSelected(null); }} name="Record type" />
73
- <div role="group" aria-label="Record presentation"><button type="button" aria-pressed={view === "board"} onClick={() => setView("board")}>Board</button><button type="button" aria-pressed={view === "list"} onClick={() => setView("list")}>List</button><button type="button" disabled={loading} onClick={() => setRevision(current => current + 1)}>Refresh records</button></div>
74
- </div>
75
- <p className="organizer-board-note">{`Each ${getContentItemName().singular.toLowerCase()} appears once, in the column for its stage. Open a record to use its existing editor or workflow controls.`}</p>
76
117
  {error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
77
118
  <p role="status" className="organizer-board-note">{loading ? "Loading records…" : `${issues.length} records loaded.`}</p>
78
119
  <div className="organizer-board-body" data-detail-open={Boolean(issue)}>
79
- <IssueCollection issues={issues} config={config} query={{ type: issueTypeFromFilter(filter) }} view={view}
120
+ <IssueCollection issues={issues} config={config} query={{ type: boardType }} view={view}
80
121
  motion={lowMotion ? STATIC_MOTION : undefined}
81
- columnStateKey={`papyrus.cms.columns.v1.${SITE_BRAND.id}`} initialScroll="start"
82
- selectedIssueId={selected} detailOpen={Boolean(issue)} onSelectIssue={item => setSelected(item.id)} ariaLabel="Newsroom records"
122
+ collapsedColumns={columnState.collapsedColumns} onToggleCollapse={columnState.toggleColumn} initialScroll="start"
123
+ selectedIssueId={selected} detailOpen={Boolean(issue)} onSelectIssue={item => setSelected(item.id)} ariaLabel={pageTitle}
83
124
  emptyMessage={loading ? "Loading records…" : error ? "Records could not be loaded." : "No records in this selection."} />
84
125
  {issue && <IssueDetailPanel issue={issue} onClose={() => setSelected(null)} fields={[{ label: "CMS record ID", value: issue.nativeId }, { label: "Status", value: issue.nativeStatus }]}>
85
126
  <Link href={getNewsroomNavHref(issue.href)}>Open {issue.type}</Link>
@@ -1,11 +1,18 @@
1
1
  import { NewsDeskClientProvider } from "./news-desk-client-provider";
2
+ import { createDemoCategorySteeringDashboard } from "../lib/category-repository";
2
3
  import { resolveDevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
4
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
5
+ import { getBrandNewsroomSectionsConfigPath, resolveActiveSiteBrand } from "../lib/site-brand-tenant";
3
6
 
4
7
  export function NewsroomClientShell({ children }: Readonly<{ children: React.ReactNode }>) {
5
8
  const devSandboxEditorAuth = resolveDevSandboxEditorAuth();
9
+ const activeBrand = resolveActiveSiteBrand();
10
+ const localPreviewDashboard = isLocalPreviewEnabled()
11
+ ? createDemoCategorySteeringDashboard(activeBrand.id, getBrandNewsroomSectionsConfigPath(activeBrand))
12
+ : null;
6
13
 
7
14
  return (
8
- <NewsDeskClientProvider devSandboxEditorAuth={devSandboxEditorAuth}>
15
+ <NewsDeskClientProvider devSandboxEditorAuth={devSandboxEditorAuth} localPreviewDashboard={localPreviewDashboard}>
9
16
  {children}
10
17
  </NewsDeskClientProvider>
11
18
  );
@@ -15,13 +15,13 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGro
15
15
  import type { NewsroomOpsShellProps } from "./newsroom-ops-shell";
16
16
  import "./newsroom-organizer.css";
17
17
 
18
- const ICONS = { overview: LayoutDashboard, board: ClipboardList, references: BookOpen, messages: MessagesSquare };
18
+ const ICONS = { overview: LayoutDashboard, articles: ClipboardList, references: BookOpen, messages: MessagesSquare };
19
19
 
20
20
  /** Navigation is a presentation layer; existing route and server authorization still own access. */
21
21
  function Navigation({ activePath, demo, admin, surface, onNavigate }: { activePath: string; demo: boolean; admin: boolean; surface: string; onNavigate?: () => void }) {
22
22
  const id = useId();
23
23
  const all = ORGANIZER_NAV_GROUPS.flatMap(group => group.items);
24
- const active = all.filter(item => activePath === item.path || (item.path !== "/newsroom" && activePath.startsWith(item.path + "/"))).sort((a, b) => b.path.length - a.path.length)[0]?.key;
24
+ const active = all.filter(item => [item.path, ...(item.alsoActiveOnPaths ?? [])].some(candidate => activePath === candidate || (candidate !== "/newsroom" && activePath.startsWith(candidate + "/")))).sort((a, b) => b.path.length - a.path.length)[0]?.key;
25
25
  return <nav className="organizer-destinations" aria-label={surface === "sidebar" ? "Newsroom" : "All workspaces"}>
26
26
  {ORGANIZER_NAV_GROUPS.map((group, index) => <section key={group.title} aria-labelledby={`${id}-${index}`}>
27
27
  <h3 id={`${id}-${index}`}>{group.title}</h3>
@@ -99,18 +99,23 @@ export function NewsroomOrganizerShell({ activeTab, appTitle, backHref = "/", ba
99
99
  return () => { document.removeEventListener("focusin", remember); media.removeEventListener("change", change); cancelAnimationFrame(frame); };
100
100
  }, []);
101
101
  const activePath = browserPathToInternalNewsroomPath(pathname ?? "/newsroom");
102
+ const sidebarToggleLabel = collapsed ? "Expand sidebar" : "Collapse sidebar";
103
+ const sidebarToggle = <button type="button" className="organizer-toggle" title={sidebarToggleLabel}
104
+ aria-label={sidebarToggleLabel} aria-expanded={!collapsed} aria-controls="organizer-sidebar" onClick={() => setCollapsed(value => !value)}>
105
+ {collapsed ? <PanelLeftOpen size={16} aria-hidden="true" /> : <PanelLeftClose size={16} aria-hidden="true" />}
106
+ </button>;
102
107
  return <div className="newsroom-organizer" data-news-desk data-newsroom-ops-shell="true" data-newsroom-chrome="organizer" data-sidebar-collapsed={collapsed || !showNavigation ? "true" : "false"}>
103
108
  {showNavigation && <aside className="organizer-sidebar" hidden={collapsed} id="organizer-sidebar">
104
- <Link className="organizer-brand" href={getNewsroomNavHref("/newsroom", demo)}>{appTitle}<small>The newsroom</small></Link>
109
+ <div className="organizer-sidebar-head">
110
+ <Link className="organizer-brand" href={getNewsroomNavHref("/newsroom", demo)}>{appTitle}<small>The newsroom</small></Link>
111
+ {sidebarToggle}
112
+ </div>
105
113
  <Navigation activePath={activePath} demo={demo} admin={admin} surface="sidebar" />
106
114
  <Account placement="sidebar" />
107
115
  </aside>}
108
116
  <div className="organizer-workspace">
109
117
  <header className="organizer-topbar" data-newsroom-ops-topbar>
110
- {showNavigation && <button type="button" className="organizer-toggle" title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
111
- aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"} aria-expanded={!collapsed} aria-controls="organizer-sidebar" onClick={() => setCollapsed(value => !value)}>
112
- {collapsed ? <PanelLeftOpen size={16} aria-hidden="true" /> : <PanelLeftClose size={16} aria-hidden="true" />}<span>{collapsed ? "Expand sidebar" : "Collapse sidebar"}</span>
113
- </button>}
118
+ {showNavigation && collapsed && sidebarToggle}
114
119
  <Link className="organizer-masthead" href={backHref} title={backLabel}>{appTitle}</Link>
115
120
  <h1 data-newsroom-ops-title>{pageTitle}</h1><div className="organizer-header-actions">{headerActions}</div><Account placement="header" />
116
121
  </header>
@@ -118,7 +123,7 @@ export function NewsroomOrganizerShell({ activeTab, appTitle, backHref = "/", ba
118
123
  </div>
119
124
  {showNavigation && <nav className="organizer-bottom-nav" aria-label="Primary newsroom navigation"><ul>
120
125
  {ORGANIZER_PRIMARY_NAV.map(item => { const Icon = ICONS[item.key as keyof typeof ICONS]; return <li key={item.key}><Link
121
- href={getNewsroomNavHref(item.path, demo)} title={item.description} aria-label={item.label} aria-current={activeTab === item.key || activeTab === "work" && item.key === "board" ? "page" : undefined}>
126
+ href={getNewsroomNavHref(item.path, demo)} title={item.description} aria-label={item.label} aria-current={activeTab === item.key ? "page" : undefined}>
122
127
  <Icon size={18} aria-hidden="true" /><span>{item.label}</span></Link></li>; })}
123
128
  <li><Sheet open={menuOpen} onOpenChange={setMenuOpen}><SheetTrigger className="organizer-more" aria-label="More"><Menu size={18} aria-hidden="true" /><span>More</span></SheetTrigger>
124
129
  <SheetContent side="bottom" className="organizer-menu"><SheetHeader><SheetTitle>Newsroom</SheetTitle><SheetDescription>Workspaces and administration</SheetDescription></SheetHeader>
@@ -23,7 +23,8 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
23
23
  .organizer-topbar{height:57px;min-height:57px;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
- .organizer-toggle{display:flex;align-items:center;gap:8px;min-height:36px;padding:8px 12px;border:1px solid var(--org-line);font:400 11px Newsroom UI,system-ui;flex-shrink:0}
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}
27
+ .organizer-sidebar-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}.organizer-sidebar-head .organizer-brand{flex:1;min-width:0}
27
28
  .organizer-header-actions{display:flex;align-items:center;gap:8px;min-width:0;max-width:45%;overflow-x:auto}
28
29
  .organizer-main{min-width:0;min-height:0;overflow:auto;overscroll-behavior:contain;flex:1;padding:16px 0}
29
30
  .organizer-main>[data-category-steering]{min-height:0}.organizer-demo-notice{font-size:11px;color:var(--org-muted);margin-bottom:12px}
@@ -36,10 +37,9 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
36
37
  .organizer-bottom-nav{display:none}
37
38
  .organizer-main:has(.organizer-native-board){display:flex;overflow:hidden}
38
39
  .organizer-native-board{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;gap:12px}
39
- .organizer-board-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0;flex-wrap:wrap}
40
- .organizer-board-toolbar [role=group]{display:flex;gap:8px;flex-wrap:wrap}
41
- .organizer-board-toolbar button{min-height:44px;padding:8px 12px;border:1px solid var(--org-line)}
42
- .organizer-board-toolbar button[aria-pressed=true]{background:var(--org-ink);color:var(--org-paper)}
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
43
  .organizer-board-note{font-size:11px;line-height:1.5;margin:0;color:var(--org-muted)}
44
44
  .organizer-board-body{position:relative;display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-width:0;min-height:0;gap:16px}
45
45
  .organizer-board-body[data-detail-open=true]{grid-template-columns:minmax(0,1fr) minmax(280px,360px)}
@@ -49,7 +49,9 @@ import { normalizeReferenceCurationStatus } from "../lib/reference-policy";
49
49
  import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
50
50
  import { ReferenceSourcePreview } from "./reference-source-preview";
51
51
  import { ReferenceRelevanceReview } from "./reference-relevance-review";
52
+ import { ReferenceCyclotronPriming, type PrimingFormInput } from "./reference-cyclotron-priming";
52
53
  import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
54
+ import type { PrimingRequestView } from "../lib/relevance-status";
53
55
  import type { CyclotronStatus } from "cyclotron/cyclotron-status";
54
56
  import type { RelevanceCuration, RelevanceDecisionView } from "../lib/relevance-decisions";
55
57
  import {
@@ -62,6 +64,12 @@ type NewsroomReferencesViewProps = {
62
64
  priming?: PrimingPanelProps;
63
65
  /** The relevance cyclotron's status snapshot, shown above the list. */
64
66
  cyclotronStatus?: CyclotronStatus | null;
67
+ /** Present for editors who may prime the running relevance cyclotron with a pasted URL. */
68
+ cyclotronPriming?: {
69
+ labels: readonly string[];
70
+ requests: PrimingRequestView[];
71
+ onRequest: (input: PrimingFormInput) => Promise<void> | void;
72
+ };
65
73
  /** Present for editors who may ask for a manual review rate. */
66
74
  onRequestReviewRate?: (choice: ReviewRateChoice) => Promise<void> | void;
67
75
  demo?: boolean;
@@ -399,6 +407,7 @@ function ReferenceDetailPanel({
399
407
  export function NewsroomReferencesView({
400
408
  priming,
401
409
  cyclotronStatus,
410
+ cyclotronPriming,
402
411
  decisions,
403
412
  onRequestReviewRate,
404
413
  demo = false,
@@ -516,6 +525,15 @@ export function NewsroomReferencesView({
516
525
  </div>
517
526
  <ReferenceCurationControls disabled={disabled} />
518
527
  {priming && !disabled ? <ReferencePriming {...priming} /> : null}
528
+ {cyclotronPriming && !disabled ? (
529
+ <div className="shrink-0">
530
+ <ReferenceCyclotronPriming
531
+ labels={cyclotronPriming.labels}
532
+ onRequestPriming={cyclotronPriming.onRequest}
533
+ requests={cyclotronPriming.requests}
534
+ />
535
+ </div>
536
+ ) : null}
519
537
  {cyclotronStatus ? (
520
538
  <div className="shrink-0">
521
539
  <ReferenceCyclotronStatus onRequestRate={disabled ? undefined : onRequestReviewRate} status={cyclotronStatus} />