@anthusai/papyrus 5.57.0 → 5.59.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.
Files changed (68) hide show
  1. package/amplify/data/message-actions.js +68 -0
  2. package/amplify/data/message-actions.ts +70 -0
  3. package/amplify/data/resolvers/delete-own-message.js +30 -0
  4. package/amplify/data/resolvers/edit-message.js +33 -0
  5. package/amplify/data/resolvers/moderate-message.js +32 -0
  6. package/amplify/data/resolvers/post-message.js +68 -0
  7. package/amplify/data/resolvers/rename-thread.js +28 -0
  8. package/amplify/data/resource.js +28 -3
  9. package/amplify/data/resource.ts +28 -3
  10. package/amplify/data/schema.js +34 -5
  11. package/amplify/data/schema.ts +34 -5
  12. package/amplify/functions/category-action/handler.ts +51 -125
  13. package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
  14. package/components/conversation-context.tsx +29 -2
  15. package/components/conversation-provider.tsx +5 -1
  16. package/components/conversation-thread.tsx +154 -25
  17. package/components/conversations-memory-source.ts +83 -0
  18. package/components/conversations-network-source.ts +52 -0
  19. package/components/conversations-page.tsx +14 -0
  20. package/components/conversations-source.ts +36 -0
  21. package/components/conversations-view.tsx +680 -0
  22. package/components/decision-control.tsx +307 -0
  23. package/components/list-detail-layout.tsx +44 -0
  24. package/components/needs-me-bodies.tsx +18 -17
  25. package/components/needs-me-feed.tsx +16 -32
  26. package/components/news-desk-taxonomy-client.ts +216 -62
  27. package/components/newsroom-article-editor.tsx +10 -4
  28. package/components/newsroom-organizer.css +34 -1
  29. package/components/newsroom-references-view.tsx +41 -100
  30. package/components/notification-cards.tsx +9 -8
  31. package/components/notification-center.tsx +5 -7
  32. package/components/proposals-view.tsx +4 -4
  33. package/components/reference-conversation.tsx +5 -0
  34. package/components/reference-feed.tsx +129 -127
  35. package/components/reference-review-card.tsx +9 -135
  36. package/components/references-page-loading.tsx +12 -6
  37. package/components/story-reporting-trail-client.ts +4 -3
  38. package/components/story-reporting-trail.tsx +8 -0
  39. package/components/topic-steering-workspace.tsx +44 -786
  40. package/components/use-conversation.ts +36 -2
  41. package/components/use-feed-preferences.ts +33 -0
  42. package/components/use-reference-ratings.ts +11 -7
  43. package/lib/category-repository.ts +14 -38
  44. package/lib/console-chat-client.ts +8 -178
  45. package/lib/conversation-feed.ts +96 -0
  46. package/lib/conversation.ts +73 -2
  47. package/lib/cyclotron-suggestions.ts +18 -36
  48. package/lib/local-preview-seed.ts +16 -27
  49. package/lib/needs-me-items.ts +4 -4
  50. package/lib/needs-me-ranking.ts +9 -22
  51. package/lib/newsroom-board.ts +10 -1
  52. package/lib/newsroom-demo-dashboard.ts +0 -7
  53. package/lib/newsroom-organizer-nav.ts +2 -2
  54. package/lib/post-message.ts +54 -0
  55. package/lib/proposals-admin.ts +2 -2
  56. package/lib/reference-feed-keys.ts +69 -0
  57. package/lib/reference-feed-ranking.ts +77 -160
  58. package/lib/reference-policy.ts +83 -11
  59. package/lib/reference-review-details.ts +21 -57
  60. package/lib/relevance-decisions.ts +6 -2
  61. package/lib/review-drafts.ts +2 -2
  62. package/lib/review-fixture.ts +1 -1
  63. package/lib/review-requests.ts +4 -4
  64. package/lib/semantic-graph.ts +1 -1
  65. package/lib/story-reporting-trail.ts +28 -2
  66. package/package.json +12 -2
  67. package/routes.manifest.json +1 -1
  68. package/components/review-rating-grid.tsx +0 -190
@@ -49,8 +49,11 @@ import type {
49
49
  import { relationTypeKey, semanticStateKey, semanticVersionKey } from "../lib/semantic-graph";
50
50
  import {
51
51
  CONVERSATION_FEED_KEY,
52
+ GENERAL_CONVERSATION_KIND,
52
53
  REFERENCE_CONVERSATION_KIND,
53
54
  conversationAnchorKey,
55
+ conversationPreviewLine,
56
+ threadMetadataWithLastAuthor,
54
57
  conversationMessageId,
55
58
  conversationThreadId,
56
59
  conversationThreadKind,
@@ -58,12 +61,15 @@ import {
58
61
  findOrCreateThread,
59
62
  type ConversationAnchor,
60
63
  type ConversationMessage,
64
+ type ConversationViewer,
61
65
  } from "../lib/conversation";
66
+ import { postMessageArguments, postMessageMutation } from "../lib/post-message";
62
67
  import { legacyStageKeysToLoad, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
63
68
  import { getContentItemStageDefinitions } from "../lib/site-brand";
64
69
  import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
65
70
  import { configureAmplifyClient } from "./amplify-client-provider";
66
71
  import { isUnauthenticatedError, loadReaderSessionSnapshot, type ReaderAuthSnapshot } from "./reader-auth-state";
72
+ import { resolveReaderSettings } from "./reader-settings";
67
73
 
68
74
  const USER_POOL_AUTH_MODE = "userPool";
69
75
  const USER_POOL_LIST_LIMIT = 500;
@@ -77,7 +83,7 @@ const REFERENCE_STATUS_KEYS = ["pending", "accepted", "rejected", "archived"] as
77
83
  const NEWSROOM_MESSAGE_FEED_QUERY = `
78
84
  query ListMessagesByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelMessageFilterInput) {
79
85
  listMessagesByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
80
- items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
86
+ items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
81
87
  nextToken
82
88
  }
83
89
  }
@@ -136,7 +142,7 @@ const CREATE_SEMANTIC_RELATION_MUTATION = `
136
142
  const LIST_MESSAGES_BY_THREAD_AND_SEQUENCE_QUERY = `
137
143
  query ListMessagesByThreadAndSequence($threadId: ID!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
138
144
  listMessagesByThreadAndSequence(threadId: $threadId, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
139
- items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
145
+ items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
140
146
  nextToken
141
147
  }
142
148
  }
@@ -145,7 +151,7 @@ const LIST_MESSAGES_BY_THREAD_AND_SEQUENCE_QUERY = `
145
151
  const LIST_MESSAGES_BY_KIND_AND_CREATED_AT_QUERY = `
146
152
  query ListMessagesByKindAndCreatedAt($messageKind: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
147
153
  listMessagesByKindAndCreatedAt(messageKind: $messageKind, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
148
- items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
154
+ items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
149
155
  nextToken
150
156
  }
151
157
  }
@@ -154,7 +160,7 @@ const LIST_MESSAGES_BY_KIND_AND_CREATED_AT_QUERY = `
154
160
  const GET_MESSAGE_QUERY = `
155
161
  query GetMessage($id: ID!) {
156
162
  getMessage(id: $id) {
157
- id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey
163
+ id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey
158
164
  }
159
165
  }
160
166
  `;
@@ -219,40 +225,7 @@ const UPDATE_MESSAGE_THREAD_MUTATION = `
219
225
  }
220
226
  `;
221
227
 
222
- const CREATE_MESSAGE_MUTATION = `
223
- mutation CreateMessage($input: CreateMessageInput!) {
224
- createMessage(input: $input) {
225
- id
226
- messageKind
227
- messageDomain
228
- status
229
- summary
230
- source
231
- importRunId
232
- authorSub
233
- authorUserProfileId
234
- authorLabel
235
- threadId
236
- parentMessageId
237
- sequenceNumber
238
- role
239
- messageType
240
- content
241
- semanticLayer
242
- searchVisibility
243
- responseTarget
244
- responseStatus
245
- responseOwner
246
- responseStartedAt
247
- responseCompletedAt
248
- responseError
249
- metadata
250
- createdAt
251
- updatedAt
252
- newsroomFeedKey
253
- }
254
- }
255
- `;
228
+ const POST_MESSAGE_SELECTION = "id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey";
256
229
 
257
230
  const UPDATE_MESSAGE_MUTATION = `
258
231
  mutation UpdateMessage($input: UpdateMessageInput!) {
@@ -267,6 +240,9 @@ const UPDATE_MESSAGE_MUTATION = `
267
240
  authorSub
268
241
  authorUserProfileId
269
242
  authorLabel
243
+ editedAt
244
+ deletedAt
245
+ deletedBySub
270
246
  threadId
271
247
  parentMessageId
272
248
  sequenceNumber
@@ -301,7 +277,7 @@ const NEWSROOM_ASSIGNMENT_FEED_QUERY = `
301
277
  const NEWSROOM_REFERENCE_FEED_QUERY = `
302
278
  query ListReferencesByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
303
279
  listReferencesByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
304
- items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
280
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey discoveredByMonitorId updatedAt }
305
281
  nextToken
306
282
  }
307
283
  }
@@ -310,7 +286,7 @@ const NEWSROOM_REFERENCE_FEED_QUERY = `
310
286
  const NEWSROOM_REFERENCE_IMPORTED_FEED_QUERY = `
311
287
  query ListReferencesByNewsroomFeedAndImportedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
312
288
  listReferencesByNewsroomFeedAndImportedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
313
- items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
289
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey discoveredByMonitorId updatedAt }
314
290
  nextToken
315
291
  }
316
292
  }
@@ -319,7 +295,7 @@ const NEWSROOM_REFERENCE_IMPORTED_FEED_QUERY = `
319
295
  const LIST_REFERENCES_BY_CURATION_STATUS_AND_UPDATED_AT_QUERY = `
320
296
  query ListReferencesByCurationStatusAndUpdatedAt($curationStatus: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
321
297
  listReferencesByCurationStatusAndUpdatedAt(curationStatus: $curationStatus, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
322
- items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
298
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey discoveredByMonitorId updatedAt }
323
299
  nextToken
324
300
  }
325
301
  }
@@ -328,7 +304,7 @@ const LIST_REFERENCES_BY_CURATION_STATUS_AND_UPDATED_AT_QUERY = `
328
304
  const LIST_REFERENCES_BY_REVIEWED_FEED_AND_UPDATED_AT_QUERY = `
329
305
  query ListReferencesByReviewedFeedAndUpdatedAt($reviewedFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
330
306
  listReferencesByReviewedFeedAndUpdatedAt(reviewedFeedKey: $reviewedFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
331
- items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
307
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey discoveredByMonitorId updatedAt }
332
308
  nextToken
333
309
  }
334
310
  }
@@ -337,7 +313,7 @@ const LIST_REFERENCES_BY_REVIEWED_FEED_AND_UPDATED_AT_QUERY = `
337
313
  const LIST_REFERENCES_FALLBACK_QUERY = `
338
314
  query ListReferencesFallback($limit: Int, $nextToken: String) {
339
315
  listReferences(limit: $limit, nextToken: $nextToken) {
340
- items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
316
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey discoveredByMonitorId updatedAt }
341
317
  nextToken
342
318
  }
343
319
  }
@@ -470,7 +446,7 @@ const MODEL_ATTACHMENTS_BY_OWNER_QUERY = `
470
446
 
471
447
  /** Matches Reference model fields in amplify/data/resource.ts (no metadata on Reference). */
472
448
  const REFERENCE_RECORD_GRAPHQL_FIELDS =
473
- "id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt";
449
+ "id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey discoveredByMonitorId updatedAt";
474
450
 
475
451
  const LIST_REFERENCES_BY_LINEAGE_AND_VERSION_QUERY = `
476
452
  query ListReferencesByLineageAndVersion($lineageId: ID!, $limit: Int, $nextToken: String) {
@@ -1422,7 +1398,7 @@ export async function createSectionForumThreadRecord(input: {
1422
1398
  * Appends one Message to a thread: the next sequence number, the Message, then the thread's count and last Message. The Message id
1423
1399
  * derives from the sequence number, so a create that loses a race for the number collides and retries with the next one.
1424
1400
  */
1425
- async function appendMessageToThread(thread: MessageThreadRecord, build: (sequenceNumber: number, now: string) => MessageRecord): Promise<{ thread: MessageThreadRecord; message: MessageRecord }> {
1401
+ async function appendMessageToThread(thread: MessageThreadRecord, build: (sequenceNumber: number, now: string) => MessageRecord, threadFields: { summary?: string; metadata?: string } = {}): Promise<{ thread: MessageThreadRecord; message: MessageRecord }> {
1426
1402
  const threadId = thread.id;
1427
1403
  let now = new Date().toISOString();
1428
1404
  let built: MessageRecord | null = null;
@@ -1430,11 +1406,15 @@ async function appendMessageToThread(thread: MessageThreadRecord, build: (sequen
1430
1406
  list: () => listMessagesByThreadId(threadId, 1000),
1431
1407
  create: (sequenceNumber) => {
1432
1408
  now = new Date().toISOString();
1433
- built = build(sequenceNumber, now);
1434
- return runGraphql<{ createMessage?: MessageRecord | null }>(CREATE_MESSAGE_MUTATION, { input: built });
1409
+ // `createdAt` is part of the composite sort key of messagesByResponseTargetStatusCreatedAt, so AppSync refuses a Message that
1410
+ // sets it without `responseStatus`. A person's message waits on no responder: COMPLETED, as the Python message contract writes.
1411
+ built = { responseStatus: "COMPLETED", ...build(sequenceNumber, now) };
1412
+ // The backend stamps the author from the signed-in identity (postMessage); nothing here names one.
1413
+ const args = postMessageArguments(built as unknown as Record<string, unknown>);
1414
+ return runGraphql<{ postMessage?: MessageRecord | null }>(postMessageMutation(args, POST_MESSAGE_SELECTION), args);
1435
1415
  },
1436
1416
  });
1437
- const createdMessage = normalizeMessageRecord(created.createMessage ?? built!);
1417
+ const createdMessage = normalizeMessageRecord(created.postMessage ?? built!);
1438
1418
  const updated = await runGraphql<{ updateMessageThread?: MessageThreadRecord | null }>(
1439
1419
  UPDATE_MESSAGE_THREAD_MUTATION,
1440
1420
  {
@@ -1444,11 +1424,12 @@ async function appendMessageToThread(thread: MessageThreadRecord, build: (sequen
1444
1424
  lastMessageId: createdMessage.id,
1445
1425
  lastMessageAt: createdMessage.createdAt,
1446
1426
  updatedAt: now,
1427
+ ...threadFields,
1447
1428
  },
1448
1429
  },
1449
1430
  );
1450
1431
  return {
1451
- thread: normalizeThreadRecord(updated.updateMessageThread ?? { ...thread, updatedAt: now }),
1432
+ thread: normalizeThreadRecord(updated.updateMessageThread ?? { ...thread, ...threadFields, updatedAt: now }),
1452
1433
  message: createdMessage,
1453
1434
  };
1454
1435
  }
@@ -1490,7 +1471,7 @@ export async function appendForumThreadMessageRecord(input: {
1490
1471
  }));
1491
1472
  }
1492
1473
 
1493
- type ConversationAuthor = { sub: string | null; label: string };
1474
+ type ConversationAuthor = { sub: string | null; label: string; userProfileId: string | null; isAdmin: boolean };
1494
1475
 
1495
1476
  async function currentConversationAuthor(): Promise<ConversationAuthor> {
1496
1477
  const snapshot = await loadReaderSessionSnapshot();
@@ -1502,7 +1483,50 @@ async function currentConversationAuthor(): Promise<ConversationAuthor> {
1502
1483
  } catch {
1503
1484
  sub = null;
1504
1485
  }
1505
- return { sub, label: snapshot.auth.status === "signedIn" ? snapshot.auth.label : "editor" };
1486
+ const profile = await currentUserProfile();
1487
+ // The author's name travels on the message (other editors cannot read profiles): their display name, else the session's label.
1488
+ const sessionLabel = snapshot.auth.status === "signedIn" ? snapshot.auth.label : "editor";
1489
+ return { sub, label: profile.displayName || sessionLabel, userProfileId: profile.id, isAdmin: snapshot.groups.some((group) => group.trim().toLowerCase() === "admin") };
1490
+ }
1491
+
1492
+ let userProfileLookup: Promise<{ id: string | null; displayName: string | null }> | null = null;
1493
+ /** The signed-in person's profile id and display name (their own profile is readable by them), read once per page load. A failed read is not kept, and does not stop a post. */
1494
+ function currentUserProfile(): Promise<{ id: string | null; displayName: string | null }> {
1495
+ userProfileLookup ??= (async () => {
1496
+ const resolution = await resolveReaderSettings();
1497
+ const id = resolution.userProfileId ?? null;
1498
+ if (!id) return { id: null, displayName: null };
1499
+ const names = await loadUserProfileNames([id]);
1500
+ return { id, displayName: names.get(id) ?? null };
1501
+ })().catch(() => { userProfileLookup = null; return { id: null, displayName: null }; });
1502
+ return userProfileLookup;
1503
+ }
1504
+
1505
+ /** Who is looking at a conversation: their sub, and whether they are an admin. Decided from the session, never from a Message. */
1506
+ export async function loadConversationViewer(): Promise<ConversationViewer> {
1507
+ const author = await currentConversationAuthor();
1508
+ return { sub: author.sub, isAdmin: author.isAdmin };
1509
+ }
1510
+
1511
+ /** Display names by profile id, in one request. A profile this person may not read is left out and its Messages keep the saved label. */
1512
+ export async function loadUserProfileNames(profileIds: string[]): Promise<Map<string, string>> {
1513
+ const ids = [...new Set(profileIds.filter(Boolean))];
1514
+ const names = new Map<string, string>();
1515
+ if (!ids.length) return names;
1516
+ const query = `query UserProfileNames { ${ids.map((id, index) => `p${index}: getUserProfile(id: ${JSON.stringify(id)}) { id displayName }`).join(" ")} }`;
1517
+ configureAmplifyClient();
1518
+ const client = generateClient<Schema>() as unknown as { graphql: (options: Record<string, unknown>) => Promise<{ data?: Record<string, { id?: string; displayName?: string | null } | null> | null }> };
1519
+ let data: Record<string, { id?: string; displayName?: string | null } | null> | null | undefined;
1520
+ try {
1521
+ data = (await client.graphql({ query, authMode: USER_POOL_AUTH_MODE })).data;
1522
+ } catch (failure) {
1523
+ // Some of the profiles are refused: the rest of the answer is still good.
1524
+ data = (failure as { data?: typeof data } | null)?.data;
1525
+ }
1526
+ for (const profile of Object.values(data ?? {})) {
1527
+ if (profile?.id && profile.displayName?.trim()) names.set(profile.id, profile.displayName.trim());
1528
+ }
1529
+ return names;
1506
1530
  }
1507
1531
 
1508
1532
  async function findConversationThread(anchorKey: string): Promise<MessageThreadRecord | null> {
@@ -1536,6 +1560,7 @@ export async function ensureConversationThread(anchor: ConversationAnchor, autho
1536
1560
  primaryAnchorLineageId: lineageId,
1537
1561
  primaryAnchorKey: anchorKey,
1538
1562
  createdBySub: author?.sub ?? null,
1563
+ createdByUserProfileId: author?.userProfileId ?? null,
1539
1564
  createdByLabel: author?.label ?? "editor",
1540
1565
  messageCount: 0,
1541
1566
  lastMessageId: null,
@@ -1556,9 +1581,14 @@ function toConversationMessage(record: MessageRecord): ConversationMessage {
1556
1581
  id: record.id,
1557
1582
  parentMessageId: record.parentMessageId ?? null,
1558
1583
  authorLabel: record.authorLabel ?? "",
1559
- content: record.content ?? record.body ?? "",
1584
+ authorSub: record.authorSub ?? null,
1585
+ authorUserProfileId: record.authorUserProfileId ?? null,
1586
+ content: record.status === "deleted" ? "" : record.content ?? record.body ?? "",
1560
1587
  createdAt: record.createdAt,
1561
1588
  sequenceNumber: Number(record.sequenceNumber ?? 0),
1589
+ editedAt: record.editedAt ?? null,
1590
+ deleted: record.status === "deleted",
1591
+ deletedAt: record.deletedAt ?? null,
1562
1592
  };
1563
1593
  }
1564
1594
 
@@ -1569,7 +1599,7 @@ export async function loadConversationMessages(anchor: ConversationAnchor): Prom
1569
1599
  const thread = await findConversationThread(anchorKey);
1570
1600
  if (!thread) return [];
1571
1601
  const records = await listMessagesByThreadId(thread.id, 500);
1572
- return records.filter((record) => record.status !== "deleted").map(toConversationMessage);
1602
+ return records.map(toConversationMessage);
1573
1603
  }
1574
1604
 
1575
1605
  /** How many Messages each Reference has, from the Reference conversation threads alone (no query per card). Keyed by lineage id. */
@@ -1591,11 +1621,13 @@ export async function appendConversationMessage(input: {
1591
1621
  anchor: ConversationAnchor;
1592
1622
  body: string;
1593
1623
  parentMessageId?: string | null;
1624
+ /** The conversation to write into, when the caller already holds it (the Messages page). Without it the anchor finds or creates it. */
1625
+ thread?: MessageThreadRecord;
1594
1626
  }): Promise<{ message: ConversationMessage; thread: MessageThreadRecord; relationError: string | null }> {
1595
1627
  const content = String(input.body || "").trim();
1596
1628
  if (!content) throw new Error("Write something to post.");
1597
1629
  const author = await currentConversationAuthor();
1598
- const thread = await ensureConversationThread(input.anchor, author);
1630
+ const thread = input.thread ?? await ensureConversationThread(input.anchor, author);
1599
1631
  const result = await appendMessageToThread(thread, (sequenceNumber, now) => ({
1600
1632
  id: conversationMessageId(thread.id, sequenceNumber),
1601
1633
  messageKind: "comment",
@@ -1603,7 +1635,6 @@ export async function appendConversationMessage(input: {
1603
1635
  status: "active",
1604
1636
  summary: content.slice(0, 120),
1605
1637
  source: "newsroom.conversation",
1606
- authorSub: author.sub,
1607
1638
  authorLabel: author.label,
1608
1639
  threadId: thread.id,
1609
1640
  parentMessageId: input.parentMessageId?.trim() || null,
@@ -1615,7 +1646,7 @@ export async function appendConversationMessage(input: {
1615
1646
  createdAt: now,
1616
1647
  updatedAt: now,
1617
1648
  newsroomFeedKey: CONVERSATION_FEED_KEY,
1618
- }));
1649
+ }), { summary: conversationPreviewLine(content), metadata: threadMetadataWithLastAuthor(thread.metadata, author.label) });
1619
1650
  let relationError: string | null = null;
1620
1651
  if (input.anchor.kind !== "none") {
1621
1652
  try {
@@ -1627,6 +1658,124 @@ export async function appendConversationMessage(input: {
1627
1658
  return { message: toConversationMessage(result.message), thread: result.thread, relationError };
1628
1659
  }
1629
1660
 
1661
+ /** One page of conversation threads of a kind, newest activity first. */
1662
+ export async function loadConversationThreadPage(threadKind: string, nextToken: string | null, limit = 20): Promise<{ items: MessageThreadRecord[]; nextToken: string | null }> {
1663
+ type ThreadConnectionResponse = { listMessageThreadsByKindAndUpdatedAt?: { items?: Array<MessageThreadRecord | null> | null; nextToken?: string | null } | null };
1664
+ const response = await runGraphql<ThreadConnectionResponse>(LIST_MESSAGE_THREADS_BY_KIND_AND_UPDATED_AT_QUERY, { threadKind, sortDirection: "DESC", limit, nextToken });
1665
+ const connection = response.listMessageThreadsByKindAndUpdatedAt;
1666
+ const items = ((connection?.items ?? []).filter(Boolean) as MessageThreadRecord[]).map(normalizeThreadRecord).filter((thread) => thread.status !== "deleted");
1667
+ return { items, nextToken: connection?.nextToken ?? null };
1668
+ }
1669
+
1670
+ /** Every Message in a conversation thread, oldest first. */
1671
+ export async function loadConversationThreadMessages(threadId: string): Promise<ConversationMessage[]> {
1672
+ const records = await listMessagesByThreadId(threadId, 500);
1673
+ return records.map(toConversationMessage);
1674
+ }
1675
+
1676
+ /** Starts a conversation about nothing but itself: its own thread, and its first Message. */
1677
+ export async function startUnattachedConversation(title: string, body: string): Promise<{ message: ConversationMessage; thread: MessageThreadRecord }> {
1678
+ const content = String(body || "").trim();
1679
+ const heading = String(title || "").trim();
1680
+ if (!heading) throw new Error("Give the conversation a title.");
1681
+ if (!content) throw new Error("Write something to post.");
1682
+ const author = await currentConversationAuthor();
1683
+ const now = new Date().toISOString();
1684
+ const id = `message-thread-conversation-new-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1685
+ const thread: MessageThreadRecord = {
1686
+ id,
1687
+ threadKind: GENERAL_CONVERSATION_KIND,
1688
+ status: "active",
1689
+ title: heading.slice(0, 120),
1690
+ createdBySub: author.sub,
1691
+ createdByUserProfileId: author.userProfileId,
1692
+ createdByLabel: author.label,
1693
+ messageCount: 0,
1694
+ lastMessageId: null,
1695
+ lastMessageAt: null,
1696
+ metadata: JSON.stringify({ anchorKind: "none" }),
1697
+ createdAt: now,
1698
+ updatedAt: now,
1699
+ newsroomFeedKey: CONVERSATION_FEED_KEY,
1700
+ };
1701
+ const created = await runGraphql<{ createMessageThread?: MessageThreadRecord | null }>(CREATE_MESSAGE_THREAD_MUTATION, { input: thread });
1702
+ const saved = normalizeThreadRecord(created.createMessageThread ?? thread);
1703
+ const result = await appendConversationMessage({ anchor: { kind: "none" }, body: content, thread: saved });
1704
+ return { message: result.message, thread: result.thread };
1705
+ }
1706
+
1707
+ const GET_MESSAGE_THREAD_QUERY = `
1708
+ query GetMessageThread($id: ID!) {
1709
+ getMessageThread(id: $id) {
1710
+ id threadKind status title summary primaryAnchorKind primaryAnchorId primaryAnchorLineageId primaryAnchorKey createdBySub createdByUserProfileId createdByLabel messageCount lastMessageId lastMessageAt contextDigest activeResponseMessageId responseLockOwner responseLockExpiresAt metadata createdAt updatedAt newsroomFeedKey
1711
+ }
1712
+ }
1713
+ `;
1714
+
1715
+ const MESSAGE_ACTION_SELECTION = "id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey";
1716
+
1717
+ /** The author changes the text of their own Message. The backend (editMessage) checks the stored author against the signed-in user and records when. */
1718
+ export async function editConversationMessage(messageId: string, body: string): Promise<ConversationMessage> {
1719
+ const content = String(body || "").trim();
1720
+ if (!content) throw new Error("A message cannot be empty.");
1721
+ const updated = await runGraphql<{ editMessage?: MessageRecord | null }>(
1722
+ `mutation EditMessage($id: ID!, $content: String!, $summary: String) { editMessage(id: $id, content: $content, summary: $summary) { ${MESSAGE_ACTION_SELECTION} } }`,
1723
+ { id: messageId, content, summary: content.slice(0, 120) },
1724
+ );
1725
+ if (!updated.editMessage) throw new Error("The message was not saved.");
1726
+ return toConversationMessage(normalizeMessageRecord(updated.editMessage));
1727
+ }
1728
+
1729
+ /**
1730
+ * Deletes a Message softly: it stays (so its replies keep their place) marked deleted, with when and by whom. Nothing is removed.
1731
+ * An author deletes their own (deleteOwnMessage); an admin deleting someone else's moderates it (moderateMessage). The backend decides.
1732
+ */
1733
+ export async function deleteConversationMessage(messageId: string, moderate = false): Promise<ConversationMessage> {
1734
+ const field = moderate ? "moderateMessage" : "deleteOwnMessage";
1735
+ const updated = await runGraphql<Record<string, MessageRecord | null | undefined>>(
1736
+ `mutation DeleteMessage($id: ID!) { ${field}(id: $id) { ${MESSAGE_ACTION_SELECTION} } }`,
1737
+ { id: messageId },
1738
+ );
1739
+ const record = updated[field];
1740
+ if (!record) throw new Error("The message was not deleted.");
1741
+ return toConversationMessage(normalizeMessageRecord(record));
1742
+ }
1743
+
1744
+ /** One conversation thread by id (a linked address), or null when there is none or it was removed. */
1745
+ export async function loadConversationThread(threadId: string): Promise<MessageThreadRecord | null> {
1746
+ const response = await runGraphql<{ getMessageThread?: MessageThreadRecord | null }>(GET_MESSAGE_THREAD_QUERY, { id: threadId });
1747
+ const thread = response.getMessageThread ? normalizeThreadRecord(response.getMessageThread) : null;
1748
+ return thread && thread.status !== "deleted" ? thread : null;
1749
+ }
1750
+
1751
+ /** Gives a conversation a new title. Nothing else about the thread changes. */
1752
+ export async function renameConversationThread(thread: MessageThreadRecord, title: string): Promise<MessageThreadRecord> {
1753
+ const next = String(title || "").trim();
1754
+ if (!next) throw new Error("A title cannot be empty.");
1755
+ // renameThread (the backend): only the person who started the conversation, or an admin.
1756
+ const updated = await runGraphql<{ renameThread?: MessageThreadRecord | null }>(
1757
+ `mutation RenameThread($id: ID!, $title: String!) { renameThread(id: $id, title: $title) { id threadKind status title summary primaryAnchorKind primaryAnchorId primaryAnchorLineageId primaryAnchorKey createdBySub createdByUserProfileId createdByLabel messageCount lastMessageId lastMessageAt contextDigest activeResponseMessageId responseLockOwner responseLockExpiresAt metadata createdAt updatedAt newsroomFeedKey } }`,
1758
+ { id: thread.id, title: next.slice(0, 120) },
1759
+ );
1760
+ return normalizeThreadRecord(updated.renameThread ?? { ...thread, title: next.slice(0, 120) });
1761
+ }
1762
+
1763
+ /** The title and editorial stage of each Story, by lineage id. A Story that cannot be read has no entry. */
1764
+ export async function loadStoryHeaders(lineageIds: string[]): Promise<Map<string, { title: string; stage: string | null }>> {
1765
+ const headers = new Map<string, { title: string; stage: string | null }>();
1766
+ await Promise.all([...new Set(lineageIds)].map(async (lineageId) => {
1767
+ try {
1768
+ type Response = { listItemsByLineageAndVersion?: { items?: Array<{ versionNumber?: number | null; headline?: string | null; title?: string | null; stage?: string | null } | null> | null } | null };
1769
+ const response = await runGraphql<Response>(`query StoryHeader($lineageId: ID!) { listItemsByLineageAndVersion(lineageId: $lineageId, limit: 50) { items { versionNumber headline title stage } } }`, { lineageId });
1770
+ const items = (response.listItemsByLineageAndVersion?.items ?? []).filter(Boolean) as Array<{ versionNumber?: number | null; headline?: string | null; title?: string | null; stage?: string | null }>;
1771
+ const newest = items.sort((left, right) => (right.versionNumber ?? 0) - (left.versionNumber ?? 0))[0];
1772
+ const title = newest?.headline || newest?.title;
1773
+ if (title) headers.set(lineageId, { title, stage: newest?.stage ?? null });
1774
+ } catch { /* a Story that cannot be read keeps the title the thread was started with */ }
1775
+ }));
1776
+ return headers;
1777
+ }
1778
+
1630
1779
  async function createCommentRelation(message: MessageRecord, anchor: ConversationAnchor): Promise<void> {
1631
1780
  if (anchor.kind === "none") return;
1632
1781
  const lineageId = anchor.lineageId.trim();
@@ -1843,10 +1992,17 @@ async function runGraphql<T extends Record<string, unknown>>(
1843
1992
  const client = generateClient<Schema>() as unknown as {
1844
1993
  graphql: (options: Record<string, unknown>) => Promise<{ data?: T | null; errors?: unknown[] | null }>;
1845
1994
  };
1995
+ // The Amplify client rejects with the raw `{ data, errors }` response when the API returns errors; turn that into an
1996
+ // Error carrying the API's own words, so a refusal is said in words instead of as an unknown failure.
1846
1997
  const response = await client.graphql({
1847
1998
  query,
1848
1999
  variables,
1849
2000
  authMode: USER_POOL_AUTH_MODE,
2001
+ }).catch((failure: unknown) => {
2002
+ if (failure && typeof failure === "object" && Array.isArray((failure as { errors?: unknown }).errors)) {
2003
+ assertNoGraphQLErrors((failure as { errors: unknown[] }).errors);
2004
+ }
2005
+ throw failure;
1850
2006
  });
1851
2007
  assertNoGraphQLErrors(response.errors);
1852
2008
  return (response.data ?? {}) as T;
@@ -2082,17 +2238,15 @@ export async function loadEditorReferenceReviewDetails(lineageId: string) {
2082
2238
  configureAmplifyClient();
2083
2239
  const testMock = getTestEditorNewsroomMock();
2084
2240
  return loadReferenceReviewDetails(lineageId, {
2085
- relations: async (direction) => {
2241
+ relations: async () => {
2086
2242
  if (testMock?.semanticRelations) return testMock.semanticRelations;
2087
2243
  return loadSemanticRelationsByState({
2088
- field: direction === "outgoing" ? "listSemanticRelationsBySubjectState" : "listSemanticRelationsByObjectState",
2089
- keyName: direction === "outgoing" ? "subjectStateKey" : "objectStateKey",
2244
+ field: "listSemanticRelationsBySubjectState",
2245
+ keyName: "subjectStateKey",
2090
2246
  keyValue: `reference#${lineageId}#current`,
2091
- query: direction === "outgoing" ? LIST_SEMANTIC_RELATIONS_BY_SUBJECT_STATE_QUERY : LIST_SEMANTIC_RELATIONS_BY_OBJECT_STATE_QUERY,
2247
+ query: LIST_SEMANTIC_RELATIONS_BY_SUBJECT_STATE_QUERY,
2092
2248
  });
2093
2249
  },
2094
- message: async (id) => testMock?.messages ? testMock.messages.find((message) => message.id === id) ?? null : getMessageRecordById(id),
2095
- payloads: (id) => loadModelPayloadsForOwner("message", id, ["metadata"]),
2096
2250
  });
2097
2251
  }
2098
2252
 
@@ -513,10 +513,15 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
513
513
  }
514
514
  if (!editor) {
515
515
  return (
516
- <div aria-label="Loading article" className="space-y-5" role="status">
517
- <div className="h-8 w-2/3 rounded bg-muted" />
518
- <div className="grid gap-4 sm:grid-cols-2"><div className="h-12 rounded bg-muted" /><div className="h-12 rounded bg-muted" /></div>
519
- <div className="h-64 rounded-xl border bg-card p-4"><div className="h-4 w-5/6 rounded bg-muted" /><div className="mt-3 h-4 w-3/4 rounded bg-muted" /></div>
516
+ // The same blocks, in the same order and heights, as the loaded editor (toolbar, then fields, tabs and the preview card),
517
+ // so nothing moves when the article arrives. The heights live in newsroom-organizer.css (data-skeleton-*).
518
+ <div aria-label="Loading article" aria-busy="true" className="space-y-4" data-newsroom-article-skeleton role="status">
519
+ <div className="rounded-md bg-muted" data-skeleton-toolbar />
520
+ <div className="grid gap-6">
521
+ <div className="rounded-md bg-muted" data-skeleton-fields />
522
+ <div className="h-10 w-72 max-w-full rounded-md bg-muted" data-skeleton-tabs />
523
+ <div className="rounded-xl bg-muted" data-skeleton-preview />
524
+ </div>
520
525
  </div>
521
526
  );
522
527
  }
@@ -568,6 +573,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
568
573
  <Badge data-newsroom-article-version variant="outline">{`Version ${editor.versionNumber}`}</Badge>
569
574
  ) : null}
570
575
  <Badge
576
+ className="min-w-[8.5rem] justify-center"
571
577
  data-newsroom-article-validity={validation.status}
572
578
  variant={validation.status === "valid" ? "secondary" : invalid ? "outline" : "muted"}
573
579
  >
@@ -54,7 +54,16 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
54
54
  .organizer-board-body{position:relative;display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-width:0;min-height:0;gap:16px}
55
55
  .organizer-board-body[data-detail-open=true]{grid-template-columns:minmax(0,1fr) clamp(320px,34vw,1000px)}
56
56
  .organizer-board-body>.kb-issue-collection{height:100%;min-height:0;overflow:auto}
57
- .organizer-board-body>.kb-issue-detail{overflow:auto;min-height:0}
57
+ /* The detail view is a solid surface from its first frame: the theme's panel token (opaque), with the card token as a fallback if
58
+ a theme has not defined it yet, and never any opacity animation, so the board never shows through it while it loads. */
59
+ .organizer-board-body>.kb-issue-detail{overflow:auto;min-height:0;position:relative;z-index:2;opacity:1;background-color:var(--kb-theme-panel,var(--card,Canvas));isolation:isolate}
60
+ /* One stable shape while an article loads and after: the preview card (and its skeleton) reserve the same height, so the page below it does not move. */
61
+ .newsroom-embedded-article :is([data-newsroom-article-preview],[data-skeleton-preview]){min-height:min(28rem,55dvh)}
62
+ /* The loading skeleton is the loaded editor's size block for block: the toolbar wraps onto more rows as the panel narrows (a container query,
63
+ because the panel's width, not the window's, decides), and the three fields stack when it is narrow. */
64
+ [data-skeleton-toolbar]{height:44px}[data-skeleton-fields]{height:76px}[data-skeleton-preview]{height:min(28rem,55dvh)}
65
+ @container (max-width:719px){[data-skeleton-toolbar]{height:80px}}
66
+ @container (max-width:399px){[data-skeleton-toolbar]{height:116px}[data-skeleton-fields]{height:208px}}
58
67
  .organizer-board-body .kb-issue-detail a:not(.organizer-header-button){display:block;min-height:44px;text-decoration:underline;padding:12px 0}
59
68
  .organizer-menu{height:min(85dvh,760px);max-height:85dvh;display:flex;flex-direction:column;padding:16px;border-radius:24px 24px 0 0;gap:20px}
60
69
  .organizer-menu .organizer-destinations{overflow-y:auto}.organizer-menu .organizer-destinations section{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.organizer-menu .organizer-destinations h3{grid-column:1/-1;font-size:12px;text-transform:none;margin:0 0 4px}
@@ -401,11 +410,29 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
401
410
  --feed-radius-lg: 10px;
402
411
  --feed-radius-md: 8px;
403
412
  --feed-radius-pill: 8px;
413
+ /* Verdict tones for the rating grid. Hue is never the only signal: the cell also carries a CircleCheck or
414
+ CircleX and the word Match or Miss. Named here, in the feed layer, because the component must not hold
415
+ literal palette values and because Tailwind's dark: variant (.dark *) never fires in this app. */
416
+ --feed-verdict-match-tint: #05966933;
417
+ --feed-verdict-match-ink: #064e3b;
418
+ --feed-verdict-match-fill: #059669;
419
+ --feed-verdict-miss-tint: #dc262633;
420
+ --feed-verdict-miss-ink: #7f1d1d;
421
+ --feed-verdict-miss-fill: #dc2626;
422
+ /* The quiet step for an unpredicted cell and a switched-off button. */
423
+ --feed-quiet: 0.6;
404
424
  }
405
425
  [data-kanbus-theme][data-color-mode="dark"] [data-feed-surface], [data-kanbus-theme][data-color-mode="dark"][data-feed-surface] {
406
426
  --feed-card: color-mix(in oklab, var(--kb-theme-paper) 94%, var(--kb-theme-ink));
407
427
  --feed-footer: color-mix(in oklab, var(--kb-theme-paper) 90%, var(--kb-theme-ink));
408
428
  --feed-control: color-mix(in oklab, var(--kb-theme-paper) 80%, var(--kb-theme-ink));
429
+ /* Dark verdicts lift toward the tint rather than down into it, so the ink reads on a dark card. */
430
+ --feed-verdict-match-tint: #34d39933;
431
+ --feed-verdict-match-ink: #d1fae5;
432
+ --feed-verdict-match-fill: #34d399;
433
+ --feed-verdict-miss-tint: #f8717133;
434
+ --feed-verdict-miss-ink: #fee2e2;
435
+ --feed-verdict-miss-fill: #f87171;
409
436
  }
410
437
 
411
438
  /* Borderless detail pane, Add panel and mobile sheet ([data-feed-flat]): no border, rule or box anywhere inside. Regions are told apart
@@ -426,6 +453,12 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
426
453
  }
427
454
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-cyclotron-choice] { border-radius: var(--feed-radius-pill) !important; }
428
455
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-review-queue-bar] { background: var(--feed-footer) !important; }
456
+ /* The decision control keeps one corner across its segments and its reason field, and its predicted segment
457
+ keeps its tones apart: the flat rules above give every unpressed button the same control fill, which would
458
+ leave the predicted side and an untouched side looking identical. */
459
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-decision-control] :is(button, textarea) { border-radius: var(--feed-radius-md) !important; }
460
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-decision-control] [data-review-choice][data-state="idle"] { background: color-mix(in oklab, var(--feed-control) 45%, transparent) !important; }
461
+
429
462
  /* The rating grid keeps its own rounded rectangles in the borderless panes (not the pill shape the flat rules give other buttons). */
430
463
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-rating-grid] :is(button, [data-cyclotron-choice]) { border-radius: var(--feed-radius-md) !important; }
431
464