@anthusai/papyrus 1.13.0 → 1.14.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.
@@ -298,6 +298,7 @@ const schema = a.schema({
298
298
  reasonCode: a.string(),
299
299
  decisionRelationId: a.id(),
300
300
  shareable: a.boolean(),
301
+ sectionId: a.id(),
301
302
  })
302
303
  .returns(a.ref("ReferenceCurationActionResult"))
303
304
  .authorization((allow) => [
@@ -346,6 +347,7 @@ const schema = a.schema({
346
347
  reasonCode: a.string(),
347
348
  decisionRelationId: a.id(),
348
349
  shareable: a.boolean(),
350
+ sectionId: a.id(),
349
351
  })
350
352
  .returns(a.ref("ReferenceCurationActionResult"))
351
353
  .authorization((allow) => [
@@ -324,6 +324,7 @@ const schema = a.schema({
324
324
  reasonCode: a.string(),
325
325
  decisionRelationId: a.id(),
326
326
  shareable: a.boolean(),
327
+ sectionId: a.id(),
327
328
  })
328
329
  .returns(a.ref("ReferenceCurationActionResult"))
329
330
  .authorization((allow) => [
@@ -376,6 +377,7 @@ const schema = a.schema({
376
377
  reasonCode: a.string(),
377
378
  decisionRelationId: a.id(),
378
379
  shareable: a.boolean(),
380
+ sectionId: a.id(),
379
381
  })
380
382
  .returns(a.ref("ReferenceCurationActionResult"))
381
383
  .authorization((allow) => [
@@ -256,6 +256,7 @@ const baseDefinitions = {
256
256
  reasonCode: a.string(),
257
257
  decisionRelationId: a.id(),
258
258
  shareable: a.boolean(),
259
+ sectionId: a.id(),
259
260
  })
260
261
  .returns(a.ref("ReferenceCurationActionResult"))
261
262
  .authorization((allow) => [
@@ -304,6 +305,7 @@ const baseDefinitions = {
304
305
  reasonCode: a.string(),
305
306
  decisionRelationId: a.id(),
306
307
  shareable: a.boolean(),
308
+ sectionId: a.id(),
307
309
  })
308
310
  .returns(a.ref("ReferenceCurationActionResult"))
309
311
  .authorization((allow) => [
@@ -280,6 +280,7 @@ const baseDefinitions = {
280
280
  reasonCode: a.string(),
281
281
  decisionRelationId: a.id(),
282
282
  shareable: a.boolean(),
283
+ sectionId: a.id(),
283
284
  })
284
285
  .returns(a.ref("ReferenceCurationActionResult"))
285
286
  .authorization((allow) => [
@@ -332,6 +333,7 @@ const baseDefinitions = {
332
333
  reasonCode: a.string(),
333
334
  decisionRelationId: a.id(),
334
335
  shareable: a.boolean(),
336
+ sectionId: a.id(),
335
337
  })
336
338
  .returns(a.ref("ReferenceCurationActionResult"))
337
339
  .authorization((allow) => [
@@ -4,7 +4,7 @@ import { Amplify } from "aws-amplify";
4
4
  import { generateClient } from "aws-amplify/data";
5
5
  import type { Schema } from "../../data/resource";
6
6
  import { putJsonModelPayload, putTextModelPayload, readJsonModelPayload } from "../shared/model-payloads";
7
- import { curationActorKind, referenceCurationMetadata } from "../shared/reference-curation-metadata";
7
+ import { curationActorKind, referenceCurationMetadata, validatedCurationSectionId } from "../shared/reference-curation-metadata";
8
8
  import {
9
9
  CURATION_DELEGATION_OWNER_TYPE,
10
10
  CURATION_DELEGATION_PAYLOAD_KIND,
@@ -193,6 +193,11 @@ async function reviewReferenceCuration(
193
193
  const nextStatus = referenceCurationStatusForAction(action);
194
194
  const reasonCode = normalizeReferenceRejectionReasonCode(event.arguments.reasonCode, action === "reject");
195
195
  const reference = await getRequiredRecord(client.models.Reference, referenceId, "Reference");
196
+ const requestedSectionId = normalizeOptionalString(event.arguments.sectionId);
197
+ const sectionRecord = requestedSectionId
198
+ ? (await client.models.NewsroomSection.get({ id: requestedSectionId })).data ?? null
199
+ : null;
200
+ const sectionId = validatedCurationSectionId(requestedSectionId, action, sectionRecord);
196
201
  const now = new Date().toISOString();
197
202
  const actorSub = normalizeOptionalString(event.arguments.actorSub) ?? getIdentitySub(event);
198
203
  const actorLabel = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event);
@@ -259,6 +264,7 @@ async function reviewReferenceCuration(
259
264
  decisionRelationId: normalizeOptionalString(event.arguments.decisionRelationId),
260
265
  shareable: event.arguments.shareable ?? null,
261
266
  actorKind: delegation ? "human" : curationActorKind(event.identity),
267
+ sectionId,
262
268
  delegation,
263
269
  }),
264
270
  { filename: "metadata.json", now },
@@ -463,6 +469,7 @@ async function recordDelegatedCuration(event: Parameters<RecordDelegatedCuration
463
469
  reasonCode: event.arguments.reasonCode,
464
470
  decisionRelationId: event.arguments.decisionRelationId,
465
471
  shareable: event.arguments.shareable,
472
+ sectionId: event.arguments.sectionId,
466
473
  actorSub: delegation.grantorSub,
467
474
  actorLabel: delegation.grantorLabel,
468
475
  },
@@ -13,6 +13,7 @@ export type ReferenceCurationMetadataInput = {
13
13
  decisionRelationId?: string | null
14
14
  shareable?: boolean | null
15
15
  actorKind?: CurationActorKind | null
16
+ sectionId?: string | null
16
17
  delegation?: { delegationId: string; relayedBy: string; instruction: string } | null
17
18
  }
18
19
 
@@ -39,6 +40,7 @@ export function referenceCurationMetadata(input: ReferenceCurationMetadataInput)
39
40
  referenceId: input.referenceId,
40
41
  referenceLineageId: input.referenceLineageId,
41
42
  ...(input.actorKind ? {actorKind: input.actorKind} : {}),
43
+ ...(input.sectionId?.trim() ? {sectionId: input.sectionId.trim()} : {}),
42
44
  ...(input.delegation
43
45
  ? {attestation: "delegated", relayedBy: input.delegation.relayedBy, delegationId: input.delegation.delegationId,
44
46
  instruction: input.delegation.instruction}
@@ -47,3 +49,32 @@ export function referenceCurationMetadata(input: ReferenceCurationMetadataInput)
47
49
  ...(decisionRelationId ? {shareable: input.shareable === true} : {}),
48
50
  }
49
51
  }
52
+
53
+ export type CurationSectionCandidate = {
54
+ id?: string | null
55
+ enabled?: boolean | null
56
+ enabledStatus?: string | null
57
+ } | null | undefined
58
+
59
+ /**
60
+ * A curation may name the newsroom Section the Reference belongs in, but only
61
+ * when it accepts the Reference and the Section exists and is enabled.
62
+ */
63
+ export function validatedCurationSectionId(
64
+ requestedSectionId: string | null | undefined,
65
+ action: string,
66
+ section: CurationSectionCandidate,
67
+ ): string | null {
68
+ const sectionId = requestedSectionId?.trim() || null
69
+ if (!sectionId) return null
70
+ if (action !== "accept") {
71
+ throw new Error("A section can be recorded only when accepting a reference.")
72
+ }
73
+ if (!section || section.id !== sectionId) {
74
+ throw new Error(`Newsroom section ${sectionId} was not found.`)
75
+ }
76
+ if (section.enabled === false || section.enabledStatus === "disabled") {
77
+ throw new Error(`Newsroom section ${sectionId} is disabled.`)
78
+ }
79
+ return sectionId
80
+ }
@@ -3628,6 +3628,18 @@ export async function loadCyclotronShadowAgreement(cyclotronId: string): Promise
3628
3628
  return latestShadowAgreement(payloads.map((payload) => ({ json: payload.json, updatedAt: payload.attachment.updatedAt })));
3629
3629
  }
3630
3630
 
3631
+ /** The site cyclotron's latest per-Section agreement payload, as the Section sweep recorded it. */
3632
+ export async function loadSiteSectionAgreement(cyclotronId: string): Promise<import("../lib/site-section-suggestions").SiteSectionAgreement | null> {
3633
+ const { shadowAgreementPayloadId } = await import("../lib/cyclotron-suggestions");
3634
+ const { parseSiteSectionAgreement } = await import("../lib/site-section-suggestions");
3635
+ const payloads = await loadModelPayloadsForOwner("knowledgeRawPayload", shadowAgreementPayloadId(cyclotronId), ["raw_payload"]);
3636
+ const parsed = payloads
3637
+ .map((payload) => ({ agreement: parseSiteSectionAgreement(payload.json), updatedAt: payload.attachment.updatedAt }))
3638
+ .filter((entry) => entry.agreement !== null)
3639
+ .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
3640
+ return parsed.length ? parsed[0].agreement : null;
3641
+ }
3642
+
3631
3643
  /** Ask the next decide-relevance sweep to set or clear a manual review rate. */
3632
3644
  export async function requestCyclotronReviewRate(
3633
3645
  cyclotronId: string,
@@ -5,8 +5,8 @@ import Link from "next/link";
5
5
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
6
6
  import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter, useBoardColumnState } from "@kanbus/ui";
7
7
  import "@kanbus/ui/styles/kanban.css";
8
+ import "@kanbus/ui/styles/newsprint.css";
8
9
  import "./newsroom-newsprint-board.css";
9
- import { BoardCardExcerpts } from "./board-card-excerpts";
10
10
  import { PlusIcon } from "lucide-react";
11
11
  import { newsroomHref } from "../lib/newsroom-base-path";
12
12
  import {
@@ -81,7 +81,6 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
81
81
  const [supportingRecordsError, setSupportingRecordsError] = useState<string | null>(null);
82
82
  const generation = useRef(0);
83
83
  const boardBodyRef = useRef<HTMLDivElement | null>(null);
84
- const [boardBodyElement, setBoardBodyElement] = useState<HTMLDivElement | null>(null);
85
84
  const [typeFilter, setTypeFilter] = useState<string | null>("all");
86
85
  const showsContentItems = subject !== "assignment";
87
86
  const contentItemColumnKeyPrefix = `${contentItemBoardType()}/`;
@@ -173,11 +172,10 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
173
172
  </Link>
174
173
  )}
175
174
  </>}>
176
- <div className="organizer-native-board papyrus-newsprint-board" data-color-mode={theme}>
175
+ <div className="organizer-native-board papyrus-newsprint-board" data-kanbus-theme="newsprint" data-color-mode={theme}>
177
176
  {error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
178
177
  {subject === "desk" && <IssueTypeFilter config={config} value={typeFilter} onChange={setTypeFilter} name="Record type" className="organizer-board-type-filter" />}
179
- <div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={node => { boardBodyRef.current = node; setBoardBodyElement(node); }} onScrollCapture={handleBoardScroll}>
180
- <BoardCardExcerpts excerpts={issues.flatMap(item => item.kind === "contentItem" && item.excerpt ? [{ issueId: item.id, text: item.excerpt }] : [])} scope={boardBodyElement} />
178
+ <div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={boardBodyRef} onScrollCapture={handleBoardScroll}>
181
179
  <IssueCollection issues={issues} config={config} query={{ type: subject === "desk" ? issueTypeFromFilter(typeFilter) : boardType }} view={view}
182
180
  motion={lowMotion ? STATIC_MOTION : undefined}
183
181
  collapsedColumns={columnState.collapsedColumns} onToggleCollapse={columnState.toggleColumn} initialScroll="start"
@@ -1,56 +1,7 @@
1
1
  .papyrus-newsprint-board {
2
- --background: var(--org-paper);
3
- --card: var(--org-paper);
4
- --frame: var(--org-paper);
5
- --column: var(--org-paper);
6
- --border: var(--org-line);
7
- --text-foreground: var(--org-ink);
8
- --text-muted: var(--org-muted);
9
- --text-selected: var(--org-ink);
10
- --font-body: Newsroom UI, system-ui, sans-serif;
11
- --kb-column-width: 245px;
12
- --kb-column-radius: 0;
13
- --kb-card-radius: 0;
14
- --kb-card-border-width: 0;
15
- --kb-card-shadow: none;
16
- --kb-card-selected-shadow: none;
17
- --kb-board-gap: 18px;
18
- color: var(--org-ink);
19
- background: transparent;
2
+ --kb-newsprint-paper: var(--org-paper);
3
+ --kb-newsprint-ink: var(--org-ink);
4
+ --kb-newsprint-line: var(--org-line);
5
+ --kb-newsprint-display-font: Newsroom Display, Georgia, serif;
6
+ --kb-newsprint-body-font: Newsroom UI, system-ui, sans-serif;
20
7
  }
21
- .papyrus-newsprint-board .kb-grid { height: 100%; min-height: 0; scrollbar-width: thin; }
22
- .papyrus-newsprint-board .kb-column { border-left: 1px solid var(--org-line); padding-left: 10px; }
23
- .papyrus-newsprint-board .kb-column:first-child { border-left: 0; padding-left: 0; }
24
- .papyrus-newsprint-board .kb-column-header {
25
- border-bottom: 2px solid var(--org-ink);
26
- padding: 5px 0;
27
- font: 700 12px Newsroom Display, Georgia, serif;
28
- text-transform: uppercase;
29
- letter-spacing: .6px;
30
- min-height: 32px;
31
- }
32
- .papyrus-newsprint-board .issue-card {
33
- --issue-accent: var(--org-paper) !important;
34
- --issue-accent-muted: var(--muted) !important;
35
- --issue-accent-foreground: var(--org-ink) !important;
36
- --issue-priority-bg: var(--org-paper) !important;
37
- background: transparent !important;
38
- border-radius: 0;
39
- border: 0 !important;
40
- box-shadow: none !important;
41
- }
42
- .papyrus-newsprint-board .issue-card + .issue-card { border-top: 1px solid var(--org-line) !important; }
43
- .papyrus-newsprint-board .issue-accent-bar { border-bottom: 0; background: var(--org-paper) !important; color: var(--org-ink) !important; }
44
- .papyrus-newsprint-board .issue-accent-id { font: 400 9px Newsroom UI, system-ui, sans-serif; letter-spacing: .8px; white-space: nowrap; color: var(--org-ink) !important; }
45
- .papyrus-newsprint-board .issue-accent-priority { border-radius: 0; color: var(--org-muted) !important; background: transparent !important; font-size: 8px; }
46
- .papyrus-newsprint-board .issue-card h3 { font: 700 19px/1.2 Newsroom Display, Georgia, serif !important; letter-spacing: -.2px; }
47
- .papyrus-newsprint-board .issue-card[data-selected=true] { outline: 2px solid var(--org-ink); outline-offset: -2px; }
48
- .papyrus-newsprint-board .issue-card[data-selected=true] h3 { color: var(--org-ink) !important; }
49
- .papyrus-newsprint-board .kb-issue-detail { background: var(--org-paper); border: 1px solid var(--org-line); border-radius: 0; box-shadow: none; }
50
- .papyrus-newsprint-board .kb-issue-detail h2 { font: 700 28px/1.2 Newsroom Display, Georgia, serif; border-bottom: 3px double var(--org-ink); padding-bottom: 12px; }
51
- .papyrus-newsprint-board .issue-card .issue-accent-bar { height: auto !important; margin: 0 !important; width: 100% !important; padding: 0 !important; min-height: 0; }
52
- .papyrus-newsprint-board .issue-card .issue-card-content { padding-top: 2px !important; gap: 4px; }
53
- .papyrus-newsprint-board .issue-card h3 { margin: 0; }
54
- .board-card-excerpt p { margin: 0; font-size: 13px; line-height: 1.4; color: var(--org-ink); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
55
- .board-card-excerpt[data-expanded="true"] p { display: block; -webkit-line-clamp: unset; }
56
- .board-card-excerpt button { background: none; border: 0; padding: 0; font: inherit; font-size: 12px; text-decoration: underline; text-underline-offset: 3px; color: var(--org-ink); cursor: pointer; }
@@ -57,6 +57,11 @@ import {
57
57
  ReferenceSuggestionRowLine,
58
58
  } from "./reference-cyclotron-suggestion";
59
59
  import { ReferenceLiveAgreement } from "./reference-live-agreement";
60
+ import { SectionPicker, SectionSuggestionBlock, type SectionChoice } from "./reference-section-suggestion";
61
+ import type { SectionSuggestion, SiteSectionAgreement } from "../lib/site-section-suggestions";
62
+
63
+ /** What an editor adds to an accept: the Section the reference belongs in, which becomes a training label. */
64
+ export type ReferenceReviewOptions = { sectionId?: string | null };
60
65
  import type { DelegatedCuration, ShadowAgreement } from "../lib/cyclotron-suggestions";
61
66
  import { ReferenceCyclotronPriming, type PrimingFormInput } from "./reference-cyclotron-priming";
62
67
  import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
@@ -91,9 +96,15 @@ type NewsroomReferencesViewProps = {
91
96
  /** The live-agreement payload; the strip is hidden without it. */
92
97
  shadowAgreement?: ShadowAgreement | null;
93
98
  disabled?: boolean;
99
+ /** The enabled newsroom Sections an accepted reference can be placed in. */
100
+ sections?: SectionChoice[];
101
+ /** The site cyclotron's suggested Section per reference lineage. Advisory. */
102
+ sectionSuggestions?: Map<string, SectionSuggestion>;
103
+ /** How often the Section suggestions matched later editor decisions. */
104
+ sectionAgreement?: SiteSectionAgreement | null;
94
105
  onRelevanceReview?: (reference: ReferenceRecord, curation: RelevanceCuration) => void;
95
106
  initialReferenceLineageId?: string | null;
96
- onReview: (reference: ReferenceRecord, action: ReferenceCurationAction) => void;
107
+ onReview: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: ReferenceReviewOptions) => void;
97
108
  referenceAttachments?: ReferenceAttachmentRecord[];
98
109
  references: ReferenceRecord[];
99
110
  };
@@ -243,6 +254,9 @@ function ReferenceDetailPanel({
243
254
  previewAttachments,
244
255
  reference,
245
256
  onClose,
257
+ sectionAgreement = null,
258
+ sectionSuggestion,
259
+ sections = [],
246
260
  }: {
247
261
  countsAsTrainingLabel?: boolean;
248
262
  delegated?: DelegatedCuration;
@@ -250,12 +264,17 @@ function ReferenceDetailPanel({
250
264
  demo?: boolean;
251
265
  disabled?: boolean;
252
266
  onRelevanceReview?: (curation: RelevanceCuration) => void;
253
- onReview: (action: ReferenceCurationAction) => void;
267
+ onReview: (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => void;
268
+ sectionAgreement?: SiteSectionAgreement | null;
269
+ sectionSuggestion?: SectionSuggestion;
270
+ sections?: SectionChoice[];
254
271
  previewAttachments: ReferenceAttachmentRecord[];
255
272
  reference: ReferenceRecord;
256
273
  onClose?: () => void;
257
274
  }) {
258
275
  const lineageId = referenceLineageId(reference);
276
+ const [chosenSectionId, setChosenSectionId] = useState<string | null>(null);
277
+ useEffect(() => { setChosenSectionId(null); }, [lineageId]);
259
278
  const status = normalizeReferenceCurationStatus(reference.curationStatus);
260
279
  const url = referenceUrl(reference);
261
280
  const acceptDisabled = disabled || status === "accepted";
@@ -307,6 +326,20 @@ function ReferenceDetailPanel({
307
326
  ) : null}
308
327
  </div>
309
328
  ) : null}
329
+ {status === "pending" && sectionSuggestion ? (
330
+ <SectionSuggestionBlock agreement={sectionAgreement} referenceLineageId={lineageId} sections={sections} suggestion={sectionSuggestion} />
331
+ ) : null}
332
+ {status === "pending" && sections.length ? (
333
+ <SectionPicker
334
+ agreement={sectionAgreement}
335
+ chosenSectionId={chosenSectionId}
336
+ disabled={disabled}
337
+ onChoose={setChosenSectionId}
338
+ referenceLineageId={lineageId}
339
+ sections={sections}
340
+ suggestedSectionId={sectionSuggestion?.sectionId ?? null}
341
+ />
342
+ ) : null}
310
343
  {delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
311
344
 
312
345
  <div
@@ -319,7 +352,7 @@ function ReferenceDetailPanel({
319
352
  aria-pressed={status === "accepted"}
320
353
  data-news-desk-reference-accept
321
354
  disabled={acceptDisabled}
322
- onClick={() => onReview("accept")}
355
+ onClick={() => onReview("accept", chosenSectionId ? { sectionId: chosenSectionId } : undefined)}
323
356
  size="sm"
324
357
  type="button"
325
358
  variant={status === "accepted" ? "default" : "outline"}
@@ -443,6 +476,9 @@ export function NewsroomReferencesView({
443
476
  demo = false,
444
477
  disabled = false,
445
478
  onRelevanceReview,
479
+ sections = [],
480
+ sectionSuggestions,
481
+ sectionAgreement = null,
446
482
  initialReferenceLineageId = null,
447
483
  onReview,
448
484
  referenceAttachments = [],
@@ -531,9 +567,9 @@ export function NewsroomReferencesView({
531
567
  }
532
568
  };
533
569
 
534
- const runReview = (action: ReferenceCurationAction) => {
570
+ const runReview = (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => {
535
571
  if (!selectedReference) return;
536
- onReview(selectedReference, action);
572
+ onReview(selectedReference, action, options);
537
573
  };
538
574
 
539
575
  const emptyLabel = canonicalReferences.length
@@ -688,6 +724,9 @@ export function NewsroomReferencesView({
688
724
  disabled={disabled}
689
725
  onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
690
726
  onReview={runReview}
727
+ sectionAgreement={sectionAgreement}
728
+ sectionSuggestion={sectionSuggestions?.get(referenceLineageId(selectedReference))}
729
+ sections={sections}
691
730
  previewAttachments={previewAttachments}
692
731
  reference={selectedReference}
693
732
  />
@@ -722,6 +761,9 @@ export function NewsroomReferencesView({
722
761
  onClose={closeDetail}
723
762
  onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
724
763
  onReview={runReview}
764
+ sectionAgreement={sectionAgreement}
765
+ sectionSuggestion={sectionSuggestions?.get(referenceLineageId(selectedReference))}
766
+ sections={sections}
725
767
  previewAttachments={previewAttachments}
726
768
  reference={selectedReference}
727
769
  />
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { formatPercentage } from "../lib/cyclotron-suggestions";
4
+ import { sectionAgreementIsMeasured, type SectionAgreement, type SectionSuggestion, type SiteSectionAgreement } from "../lib/site-section-suggestions";
5
+ import { MINIMUM_COMPARISONS_FOR_AGREEMENT } from "../lib/cyclotron-suggestions";
6
+
7
+ export type SectionChoice = { id: string; title: string };
8
+
9
+ function titleOf(sections: SectionChoice[], sectionId: string): string {
10
+ return sections.find((section) => section.id === sectionId)?.title ?? sectionId;
11
+ }
12
+
13
+ function agreementWords(agreement: SectionAgreement | undefined): string {
14
+ if (!agreement) return "no editor decisions compared yet";
15
+ if (!sectionAgreementIsMeasured(agreement)) {
16
+ return `${agreement.comparisons} of ${MINIMUM_COMPARISONS_FOR_AGREEMENT} editor decisions compared, too few to measure`;
17
+ }
18
+ return `agreement ${formatPercentage(agreement.agreement)}, ${agreement.comparisons} editor decisions compared`;
19
+ }
20
+
21
+ /**
22
+ * The suggested Section for a pending reference, how likely the other Sections
23
+ * are, and how often this Section's suggestions matched editors. Advisory: no
24
+ * Section is chosen until an editor picks one.
25
+ */
26
+ export function SectionSuggestionBlock({
27
+ agreement,
28
+ referenceLineageId,
29
+ sections,
30
+ suggestion,
31
+ }: {
32
+ agreement: SiteSectionAgreement | null;
33
+ referenceLineageId: string;
34
+ sections: SectionChoice[];
35
+ suggestion: SectionSuggestion;
36
+ }) {
37
+ const figures = agreement?.sections.find((entry) => entry.sectionId === suggestion.sectionId);
38
+ const others = suggestion.probabilities.filter((entry) => entry.sectionId !== suggestion.sectionId).slice(0, 3);
39
+ return (
40
+ <section aria-label="Suggested Section" className="space-y-2 border-y border-foreground py-3" data-reference-section-suggestion={referenceLineageId}>
41
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggested Section</h3>
42
+ <p className="text-sm text-foreground">
43
+ The cyclotron suggests <span className="font-semibold">{titleOf(sections, suggestion.sectionId)}</span>
44
+ {suggestion.confidence !== null ? ` (${formatPercentage(suggestion.confidence)})` : ""}.
45
+ </p>
46
+ {others.length ? (
47
+ <p className="text-xs text-foreground" data-reference-section-alternatives>
48
+ Next most likely: {others.map((entry) => `${titleOf(sections, entry.sectionId)} ${formatPercentage(entry.probability)}`).join(", ")}.
49
+ </p>
50
+ ) : null}
51
+ <p className="text-xs text-foreground" data-reference-section-agreement>
52
+ For {titleOf(sections, suggestion.sectionId)}: {agreementWords(figures)}.
53
+ </p>
54
+ <p className="text-xs text-foreground" data-reference-section-notice>
55
+ A suggestion never places a reference. The Section you choose when you accept does.
56
+ </p>
57
+ </section>
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Picks the Section an accepted reference belongs to. The choice is sent with
63
+ * the accept and is what the cyclotron learns from; nothing is preselected.
64
+ */
65
+ export function SectionPicker({
66
+ agreement,
67
+ chosenSectionId,
68
+ disabled,
69
+ onChoose,
70
+ referenceLineageId,
71
+ sections,
72
+ suggestedSectionId,
73
+ }: {
74
+ agreement: SiteSectionAgreement | null;
75
+ chosenSectionId: string | null;
76
+ disabled?: boolean;
77
+ onChoose: (sectionId: string | null) => void;
78
+ referenceLineageId: string;
79
+ sections: SectionChoice[];
80
+ suggestedSectionId: string | null;
81
+ }) {
82
+ return (
83
+ <fieldset className="space-y-1 border-l-2 border-foreground pl-3" data-reference-section-picker={referenceLineageId} disabled={disabled}>
84
+ <legend className="text-xs font-semibold uppercase tracking-wide text-foreground">Section when you accept</legend>
85
+ <label className="flex items-center gap-2 text-sm text-foreground">
86
+ <input checked={chosenSectionId === null} name={`section-${referenceLineageId}`} onChange={() => onChoose(null)} type="radio" />
87
+ <span className={chosenSectionId === null ? "font-semibold" : undefined}>No Section</span>
88
+ </label>
89
+ {sections.map((section) => {
90
+ const figures = agreement?.sections.find((entry) => entry.sectionId === section.id);
91
+ const chosen = chosenSectionId === section.id;
92
+ return (
93
+ <label className="flex flex-wrap items-center gap-x-2 text-sm text-foreground" key={section.id}>
94
+ <input checked={chosen} name={`section-${referenceLineageId}`} onChange={() => onChoose(section.id)} type="radio" value={section.id} />
95
+ <span className={chosen ? "font-semibold underline underline-offset-4" : undefined}>{section.title}</span>
96
+ {section.id === suggestedSectionId ? <span className="text-xs">suggested</span> : null}
97
+ {sectionAgreementIsMeasured(figures) ? <span className="text-xs">agreement {formatPercentage(figures?.agreement ?? null)}</span> : null}
98
+ </label>
99
+ );
100
+ })}
101
+ <p className="text-xs text-foreground">The Section you choose is a training label for the cyclotron.</p>
102
+ </fieldset>
103
+ );
104
+ }
@@ -56,6 +56,7 @@ import {
56
56
  type NewsroomRecordPage,
57
57
  loadCyclotronStatus,
58
58
  loadCyclotronShadowAgreement,
59
+ loadSiteSectionAgreement,
59
60
  requestCyclotronPriming,
60
61
  loadCyclotronPrimingRequests,
61
62
  requestCyclotronReviewRate,
@@ -162,6 +163,8 @@ import { isLocalPreviewEnabled } from "../lib/local-preview";
162
163
  import { currentRelevanceDecisions } from "../lib/relevance-decisions";
163
164
  import { delegatedCurationsByReference, referencesWithHumanLatestCuration, type ShadowAgreement } from "../lib/cyclotron-suggestions";
164
165
  import { createLocalPreviewCyclotronFixtures } from "../lib/local-preview-seed";
166
+ import { createLocalPreviewSectionFixtures } from "../lib/local-preview-section-fixtures";
167
+ import { currentSectionSuggestions, siteCyclotronIdOf, type SiteSectionAgreement } from "../lib/site-section-suggestions";
165
168
  import {
166
169
  buildPrimingRequest,
167
170
  buildReviewRateRequest,
@@ -862,6 +865,35 @@ function NewsDeskDashboard({
862
865
  [dashboard.newsroomSections],
863
866
  );
864
867
  const [newsroomSections, setNewsroomSections] = useState(fallbackNewsroomSections);
868
+ const sectionChoices = useMemo(
869
+ () => newsroomSections
870
+ .filter((section) => section.enabled !== false && section.enabledStatus !== "disabled")
871
+ .sort((left, right) => left.sortOrder - right.sortOrder)
872
+ .map((section) => ({ id: section.id, title: section.title })),
873
+ [newsroomSections],
874
+ );
875
+ const sectionPreviewFixtures = useMemo(
876
+ () => (isLocalPreviewEnabled() ? createLocalPreviewSectionFixtures(references, sectionChoices.map((section) => section.id)) : null),
877
+ [references, sectionChoices],
878
+ );
879
+ const sectionSuggestions = useMemo(
880
+ () => currentSectionSuggestions(sectionPreviewFixtures ? [...semanticRelations, ...sectionPreviewFixtures.relations] : semanticRelations),
881
+ [sectionPreviewFixtures, semanticRelations],
882
+ );
883
+ const siteCyclotronId = useMemo(() => siteCyclotronIdOf(sectionSuggestions), [sectionSuggestions]);
884
+ const [siteSectionAgreement, setSiteSectionAgreement] = useState<SiteSectionAgreement | null>(null);
885
+ useEffect(() => {
886
+ if (sectionPreviewFixtures) {
887
+ setSiteSectionAgreement(sectionPreviewFixtures.agreement);
888
+ return;
889
+ }
890
+ if (!siteCyclotronId || dashboard.isDemo) return;
891
+ let cancelled = false;
892
+ loadSiteSectionAgreement(siteCyclotronId)
893
+ .then((loaded) => { if (!cancelled) setSiteSectionAgreement(loaded); })
894
+ .catch(() => { if (!cancelled) setSiteSectionAgreement(null); });
895
+ return () => { cancelled = true; };
896
+ }, [dashboard.isDemo, sectionPreviewFixtures, siteCyclotronId]);
865
897
  const [procedureDefinitions, setProcedureDefinitions] = useState(dashboard.procedureDefinitions ?? []);
866
898
  const [procedureVersions, setProcedureVersions] = useState(dashboard.procedureVersions ?? []);
867
899
  const [procedureRuns, setProcedureRuns] = useState(dashboard.procedureRuns ?? []);
@@ -1586,6 +1618,7 @@ function NewsDeskDashboard({
1586
1618
  note?: string,
1587
1619
  reasonCode?: ReferenceRejectionReasonCode | null,
1588
1620
  relevance?: { decisionRelationId: string; shareable: boolean },
1621
+ sectionId?: string | null,
1589
1622
  ) {
1590
1623
  const nextStatus = referenceCurationStatusForAction(action);
1591
1624
  setActionState({ id: reference.id, message: `${action} pending`, tone: "pending" });
@@ -1615,6 +1648,7 @@ function NewsDeskDashboard({
1615
1648
  curationStatus: nextStatus,
1616
1649
  reasonCode: action === "reject" ? reasonCode ?? null : null,
1617
1650
  ...(relevance ? { decisionRelationId: relevance.decisionRelationId, shareable: relevance.shareable } : {}),
1651
+ ...(action === "accept" && sectionId ? { sectionId } : {}),
1618
1652
  },
1619
1653
  }, ...current]);
1620
1654
  setSemanticRelations((current) => [{
@@ -1657,6 +1691,7 @@ function NewsDeskDashboard({
1657
1691
  reasonCode: action === "reject" ? reasonCode ?? undefined : undefined,
1658
1692
  decisionRelationId: relevance?.decisionRelationId,
1659
1693
  shareable: relevance ? relevance.shareable : undefined,
1694
+ sectionId: action === "accept" ? sectionId ?? undefined : undefined,
1660
1695
  },
1661
1696
  { authMode: USER_POOL_AUTH_MODE },
1662
1697
  );
@@ -3265,7 +3300,10 @@ function NewsDeskDashboard({
3265
3300
  normalizeReferenceRejectionReasonCode(curation.reasonCode),
3266
3301
  { decisionRelationId: curation.decisionRelationId, shareable: curation.shareable },
3267
3302
  )}
3268
- onReview={(reference, action) => runReferenceCurationAction(reference, action)}
3303
+ sections={sectionChoices}
3304
+ sectionSuggestions={sectionSuggestions}
3305
+ sectionAgreement={siteSectionAgreement}
3306
+ onReview={(reference, action, options) => runReferenceCurationAction(reference, action, undefined, undefined, undefined, options?.sectionId)}
3269
3307
  referenceAttachments={referenceAttachments}
3270
3308
  references={references}
3271
3309
  />
@@ -0,0 +1,72 @@
1
+ import type { ReferenceRecord, SemanticRelationRecord } from "./category-repository";
2
+ import { SHADOW_AGREEMENT_SCHEMA } from "./cyclotron-suggestions";
3
+ import { SECTION_FIT_CLASSIFIER_PREFIX, SECTION_SELECTOR_CLASSIFIER_PREFIX, parseSiteSectionAgreement, type SiteSectionAgreement } from "./site-section-suggestions";
4
+
5
+ const PREVIEW_SITE_CYCLOTRON_ID = "preview-site-cyclotron";
6
+
7
+ /**
8
+ * Local preview only: a suggested Section on the first pending references and
9
+ * a measured agreement for each Section, so the References screen can be
10
+ * reviewed without a sweep.
11
+ */
12
+ export function createLocalPreviewSectionFixtures(
13
+ references: ReferenceRecord[],
14
+ sectionIds: string[],
15
+ ): { relations: SemanticRelationRecord[]; agreement: SiteSectionAgreement | null } {
16
+ if (sectionIds.length < 2) return { relations: [], agreement: null };
17
+ const relations: SemanticRelationRecord[] = references.slice(0, 6).map((reference, index) => {
18
+ const lineageId = reference.lineageId ?? reference.id;
19
+ const top = sectionIds[index % sectionIds.length];
20
+ const runnerUp = sectionIds[(index + 1) % sectionIds.length];
21
+ const confidence = 0.88 - index * 0.07;
22
+ const probabilities: Record<string, number> = { [top]: confidence, [runnerUp]: (1 - confidence) * 0.7 };
23
+ const rest = sectionIds.filter((candidate) => candidate !== top && candidate !== runnerUp);
24
+ rest.forEach((candidate) => { probabilities[candidate] = (1 - confidence) * 0.3 / rest.length; });
25
+ return {
26
+ id: `preview-section-relation-${index}`,
27
+ relationState: "current",
28
+ predicate: "site_decision_is",
29
+ subjectKind: "reference",
30
+ subjectId: reference.id,
31
+ subjectLineageId: lineageId,
32
+ objectKind: "semanticNode",
33
+ objectId: `semantic-node-site-${top}-v1`,
34
+ objectLineageId: `semantic-node-site-${top}`,
35
+ subjectStateKey: `reference#${lineageId}#current`,
36
+ objectStateKey: `semanticNode#semantic-node-site-${top}#current`,
37
+ objectSubjectStateKey: `semanticNode#semantic-node-site-${top}#current#reference`,
38
+ predicateObjectStateKey: `site_decision_is#semanticNode#semantic-node-site-${top}#current`,
39
+ subjectVersionKey: `reference#${reference.id}`,
40
+ objectVersionKey: `semanticNode#semantic-node-site-${top}-v1`,
41
+ confidence,
42
+ classifierId: `${PREVIEW_SITE_CYCLOTRON_ID}/${SECTION_SELECTOR_CLASSIFIER_PREFIX}preview`,
43
+ modelVersion: "2",
44
+ reviewRecommended: false,
45
+ metadata: JSON.stringify({
46
+ decisionId: `preview-site-decision-${index}`,
47
+ cyclotronId: PREVIEW_SITE_CYCLOTRON_ID,
48
+ classifier: `${SECTION_SELECTOR_CLASSIFIER_PREFIX}preview`,
49
+ label: top,
50
+ probabilities,
51
+ version: 2,
52
+ decidedAt: new Date(Date.UTC(2026, 9, 18, 6 - index)).toISOString(),
53
+ }),
54
+ } as SemanticRelationRecord;
55
+ });
56
+ const classifiers: Record<string, unknown> = { [`${SECTION_SELECTOR_CLASSIFIER_PREFIX}preview`]: { comparisons: 14, correct: 10, agreement: 10 / 14 } };
57
+ sectionIds.forEach((sectionId, index) => {
58
+ classifiers[`${SECTION_FIT_CLASSIFIER_PREFIX}${sectionId}`] = index === sectionIds.length - 1
59
+ ? { comparisons: 4, agreement: 0.75, precision: 0.5, recall: 1 }
60
+ : { comparisons: 12 + index, agreement: 0.9 - index * 0.08, precision: 0.85 - index * 0.06, recall: 0.8 };
61
+ });
62
+ return {
63
+ relations,
64
+ agreement: parseSiteSectionAgreement({
65
+ schema: SHADOW_AGREEMENT_SCHEMA,
66
+ computedAt: "2026-10-18T09:00:00.000Z",
67
+ comparisons: 14,
68
+ selectorAccuracy: 10 / 14,
69
+ classifiers,
70
+ }),
71
+ };
72
+ }
@@ -0,0 +1,120 @@
1
+ import type { SemanticRelationRecord } from "./category-repository";
2
+ import { MINIMUM_COMPARISONS_FOR_AGREEMENT, SHADOW_AGREEMENT_SCHEMA } from "./cyclotron-suggestions";
3
+
4
+ export const SITE_DECISION_RELATION = "site_decision_is";
5
+ export const SECTION_SELECTOR_CLASSIFIER_PREFIX = "section-selector-";
6
+ export const SECTION_FIT_CLASSIFIER_PREFIX = "section-fit-";
7
+
8
+ /** The site cyclotron's suggested Section for one reference. Advisory only. */
9
+ export type SectionSuggestion = {
10
+ decisionRelationId: string;
11
+ cyclotronId: string | null;
12
+ sectionId: string;
13
+ confidence: number | null;
14
+ /** Section id to probability, highest first. */
15
+ probabilities: Array<{ sectionId: string; probability: number }>;
16
+ decidedAt: string | null;
17
+ };
18
+
19
+ /** How often the suggestion of one Section matched what editors later decided. */
20
+ export type SectionAgreement = {
21
+ sectionId: string;
22
+ comparisons: number;
23
+ agreement: number | null;
24
+ precision: number | null;
25
+ recall: number | null;
26
+ };
27
+
28
+ export type SiteSectionAgreement = {
29
+ computedAt: string | null;
30
+ selectorAccuracy: number | null;
31
+ selectorComparisons: number;
32
+ sections: SectionAgreement[];
33
+ };
34
+
35
+ function parseMetadata(value: unknown): Record<string, unknown> {
36
+ if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
37
+ if (typeof value !== "string") return {};
38
+ try {
39
+ const parsed = JSON.parse(value);
40
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+
46
+ function numberOrNull(value: unknown): number | null {
47
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
48
+ }
49
+
50
+ /** The part of `<cyclotronId>/<classifier>` after the last slash. */
51
+ export function siteClassifierName(classifierKey: string): string {
52
+ return classifierKey.slice(classifierKey.lastIndexOf("/") + 1);
53
+ }
54
+
55
+ /**
56
+ * Current suggested Sections keyed by reference lineage, from the relations the
57
+ * Newsroom already loads. Only the section-selector classifier names a Section.
58
+ */
59
+ export function currentSectionSuggestions(relations: SemanticRelationRecord[]): Map<string, SectionSuggestion> {
60
+ const suggestions = new Map<string, SectionSuggestion>();
61
+ for (const relation of relations) {
62
+ if (relation.predicate !== SITE_DECISION_RELATION || relation.relationState !== "current") continue;
63
+ if (!siteClassifierName(relation.classifierId ?? "").startsWith(SECTION_SELECTOR_CLASSIFIER_PREFIX)) continue;
64
+ const metadata = parseMetadata(relation.metadata);
65
+ const sectionId = typeof metadata.label === "string" ? metadata.label : "";
66
+ if (!sectionId) continue;
67
+ const rawProbabilities = parseMetadata(metadata.probabilities);
68
+ const probabilities = Object.entries(rawProbabilities)
69
+ .flatMap(([candidate, probability]) => (numberOrNull(probability) === null ? [] : [{ sectionId: candidate, probability: probability as number }]))
70
+ .sort((left, right) => right.probability - left.probability);
71
+ suggestions.set(relation.subjectLineageId, {
72
+ decisionRelationId: relation.id,
73
+ cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
74
+ sectionId,
75
+ confidence: numberOrNull(relation.confidence) ?? probabilities.find((entry) => entry.sectionId === sectionId)?.probability ?? null,
76
+ probabilities,
77
+ decidedAt: typeof metadata.decidedAt === "string" ? metadata.decidedAt : null,
78
+ });
79
+ }
80
+ return suggestions;
81
+ }
82
+
83
+ /** The site cyclotron's id, from any current Section suggestion. */
84
+ export function siteCyclotronIdOf(suggestions: Map<string, SectionSuggestion>): string | null {
85
+ for (const suggestion of suggestions.values()) if (suggestion.cyclotronId) return suggestion.cyclotronId;
86
+ return null;
87
+ }
88
+
89
+ /** The per-Section part of a cyclotron-shadow-agreement/v1 payload the site sweep wrote, or null for any other JSON. */
90
+ export function parseSiteSectionAgreement(json: unknown): SiteSectionAgreement | null {
91
+ if (!json || typeof json !== "object") return null;
92
+ const record = json as Record<string, unknown>;
93
+ if (record.schema !== SHADOW_AGREEMENT_SCHEMA) return null;
94
+ const classifiers = record.classifiers;
95
+ if (!classifiers || typeof classifiers !== "object") return null;
96
+ const sections: SectionAgreement[] = [];
97
+ for (const [classifier, value] of Object.entries(classifiers as Record<string, unknown>)) {
98
+ if (!classifier.startsWith(SECTION_FIT_CLASSIFIER_PREFIX) || !value || typeof value !== "object") continue;
99
+ const metrics = value as Record<string, unknown>;
100
+ sections.push({
101
+ sectionId: classifier.slice(SECTION_FIT_CLASSIFIER_PREFIX.length),
102
+ comparisons: numberOrNull(metrics.comparisons) ?? 0,
103
+ agreement: numberOrNull(metrics.agreement),
104
+ precision: numberOrNull(metrics.precision),
105
+ recall: numberOrNull(metrics.recall),
106
+ });
107
+ }
108
+ if (!sections.length && numberOrNull(record.selectorAccuracy) === null) return null;
109
+ return {
110
+ computedAt: typeof record.computedAt === "string" ? record.computedAt : null,
111
+ selectorAccuracy: numberOrNull(record.selectorAccuracy),
112
+ selectorComparisons: numberOrNull(record.comparisons) ?? 0,
113
+ sections,
114
+ };
115
+ }
116
+
117
+ /** Agreement figures are worth showing only once enough editor decisions followed a suggestion. */
118
+ export function sectionAgreementIsMeasured(agreement: SectionAgreement | undefined): boolean {
119
+ return Boolean(agreement) && (agreement as SectionAgreement).comparisons >= MINIMUM_COMPARISONS_FOR_AGREEMENT && (agreement as SectionAgreement).agreement !== null;
120
+ }
package/package.json CHANGED
@@ -76,7 +76,6 @@
76
76
  "./components/archive-shell": "./components/archive-shell.tsx",
77
77
  "./components/article-video": "./components/article-video.tsx",
78
78
  "./components/blog-page-background": "./components/blog-page-background.tsx",
79
- "./components/board-card-excerpts": "./components/board-card-excerpts.tsx",
80
79
  "./components/curation-delegation-client": "./components/curation-delegation-client.ts",
81
80
  "./components/dev-sandbox-editor-sign-in": "./components/dev-sandbox-editor-sign-in.tsx",
82
81
  "./components/markus-ir-preview": "./components/markus-ir-preview.tsx",
@@ -116,6 +115,7 @@
116
115
  "./components/reference-live-agreement": "./components/reference-live-agreement.tsx",
117
116
  "./components/reference-priming": "./components/reference-priming.tsx",
118
117
  "./components/reference-relevance-review": "./components/reference-relevance-review.tsx",
118
+ "./components/reference-section-suggestion": "./components/reference-section-suggestion.tsx",
119
119
  "./components/reference-source-preview": "./components/reference-source-preview.tsx",
120
120
  "./components/settings-page": "./components/settings-page.tsx",
121
121
  "./components/signup-entries-panel": "./components/signup-entries-panel.tsx",
@@ -197,6 +197,7 @@
197
197
  "./lib/layout-scenarios": "./lib/layout-scenarios.ts",
198
198
  "./lib/layout-types": "./lib/layout-types.ts",
199
199
  "./lib/layout-warnings": "./lib/layout-warnings.ts",
200
+ "./lib/local-preview-section-fixtures": "./lib/local-preview-section-fixtures.ts",
200
201
  "./lib/local-preview-seed": "./lib/local-preview-seed.ts",
201
202
  "./lib/local-preview-tenant": "./lib/local-preview-tenant.ts",
202
203
  "./lib/local-preview": "./lib/local-preview.ts",
@@ -270,6 +271,7 @@
270
271
  "./lib/site-brand": "./lib/site-brand.ts",
271
272
  "./lib/site-env": "./lib/site-env.ts",
272
273
  "./lib/site-renderer": "./lib/site-renderer.ts",
274
+ "./lib/site-section-suggestions": "./lib/site-section-suggestions.ts",
273
275
  "./lib/site-stack": "./lib/site-stack.ts",
274
276
  "./lib/staging-gate": "./lib/staging-gate.ts",
275
277
  "./lib/staging-gated-path": "./lib/staging-gated-path.ts",
@@ -374,7 +376,7 @@
374
376
  "optional": true
375
377
  }
376
378
  },
377
- "version": "1.13.0",
379
+ "version": "1.14.0",
378
380
  "dependencies": {
379
381
  "@aws-amplify/adapter-nextjs": "^1.7.3",
380
382
  "@aws-crypto/sha256-js": "5.2.0",
@@ -387,7 +389,7 @@
387
389
  "@aws-sdk/s3-request-presigner": "^3.1045.0",
388
390
  "@base-ui/react": "^1.5.0",
389
391
  "@chenglou/pretext": "^0.0.7",
390
- "@kanbus/ui": "https://github.com/AnthusAI/Kanbus/releases/download/ui-v0.2.0/kanbus-ui-0.2.0.tgz",
392
+ "@kanbus/ui": "https://github.com/AnthusAI/Kanbus/releases/download/ui-v0.3.0/kanbus-ui-0.3.0.tgz",
391
393
  "@radix-ui/colors": "3.0.0",
392
394
  "@smithy/protocol-http": "5.4.1",
393
395
  "@smithy/signature-v4": "5.7.4",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.13.0",
2
+ "version": "1.14.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -1,61 +0,0 @@
1
- "use client";
2
-
3
- import { useEffect } from "react";
4
-
5
- type Excerpt = { issueId: string; text: string };
6
-
7
- const EXCERPT_TOGGLE_THRESHOLD = 130;
8
-
9
- function buildExcerptElement(text: string): HTMLElement {
10
- const container = document.createElement("div");
11
- container.className = "board-card-excerpt";
12
- container.dataset.boardCardExcerpt = "";
13
- container.dataset.expanded = "false";
14
- const paragraph = document.createElement("p");
15
- paragraph.textContent = text;
16
- container.append(paragraph);
17
- if (text.length > EXCERPT_TOGGLE_THRESHOLD) {
18
- const toggle = document.createElement("button");
19
- toggle.type = "button";
20
- toggle.textContent = "More";
21
- toggle.setAttribute("aria-expanded", "false");
22
- const flip = (event: Event) => {
23
- event.stopPropagation();
24
- const expanded = container.dataset.expanded !== "true";
25
- container.dataset.expanded = String(expanded);
26
- toggle.textContent = expanded ? "Less" : "More";
27
- toggle.setAttribute("aria-expanded", String(expanded));
28
- };
29
- toggle.addEventListener("click", flip);
30
- toggle.addEventListener("keydown", event => event.stopPropagation());
31
- container.append(toggle);
32
- }
33
- return container;
34
- }
35
-
36
- /**
37
- * Adds a short expandable excerpt under each card's headline. The Kanbus card has no excerpt slot,
38
- * so each excerpt is written into the card's content block once the card is on the page.
39
- */
40
- export function BoardCardExcerpts({ excerpts, scope }: { excerpts: readonly Excerpt[]; scope: HTMLElement | null }) {
41
- useEffect(() => {
42
- if (!scope) return;
43
- const textByIssueId = new Map(excerpts.map(({ issueId, text }) => [issueId, text]));
44
- const place = () => {
45
- for (const card of Array.from(scope.querySelectorAll<HTMLElement>(".issue-card"))) {
46
- const text = textByIssueId.get(card.dataset.issueId ?? "");
47
- const content = card.querySelector<HTMLElement>(".issue-card-content");
48
- if (!text || !content) continue;
49
- const existing = content.querySelector<HTMLElement>(":scope > [data-board-card-excerpt]");
50
- if (existing && existing.querySelector("p")?.textContent === text) continue;
51
- existing?.remove();
52
- content.querySelector("h3")?.after(buildExcerptElement(text));
53
- }
54
- };
55
- place();
56
- const observer = new MutationObserver(place);
57
- observer.observe(scope, { childList: true, subtree: true });
58
- return () => observer.disconnect();
59
- }, [excerpts, scope]);
60
- return null;
61
- }