@anthusai/papyrus 5.43.0-next.4 → 5.43.0-next.41

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 (118) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/category-action/handler.ts +16 -8
  6. package/amplify/functions/content-actions/handler.py +8 -0
  7. package/amplify/functions/content-actions/requirements.txt +2 -0
  8. package/amplify/functions/content-actions/resource.js +1 -1
  9. package/amplify/functions/content-actions/resource.ts +1 -1
  10. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  11. package/amplify/functions/monitoring-view/schema.js +16 -0
  12. package/amplify/functions/monitoring-view/schema.ts +16 -0
  13. package/amplify/functions/procedure-action/handler.ts +2 -10
  14. package/amplify/functions/proposal-actions/proposals.py +20 -3
  15. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  16. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  17. package/app/api/newsroom/source-frameable/route.ts +12 -0
  18. package/app/api/newsroom/source-icon/route.ts +20 -0
  19. package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
  20. package/app/newsroom/[section]/page.tsx +1 -0
  21. package/bin/papyrus-app.mjs +12 -1
  22. package/bin/papyrus-infra.mjs +21 -1
  23. package/components/auto-accepted-badge.tsx +21 -0
  24. package/components/conversation-context.tsx +52 -0
  25. package/components/conversation-provider.tsx +20 -0
  26. package/components/conversation-thread.tsx +174 -0
  27. package/components/decision-control.tsx +306 -0
  28. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  29. package/components/markdown-context.tsx +412 -0
  30. package/components/monitoring-overview-view.tsx +143 -21
  31. package/components/monitoring-panel.tsx +64 -4
  32. package/components/needs-me-bodies.tsx +302 -0
  33. package/components/needs-me-detail.tsx +24 -0
  34. package/components/needs-me-feed.tsx +594 -0
  35. package/components/needs-me-reference-detail.tsx +79 -0
  36. package/components/needs-me-shared.tsx +48 -0
  37. package/components/news-desk-client-provider.tsx +24 -3
  38. package/components/news-desk-page.tsx +2 -1
  39. package/components/news-desk-taxonomy-client.ts +253 -31
  40. package/components/newsroom-access-gate.tsx +13 -20
  41. package/components/newsroom-board-skeleton.tsx +20 -0
  42. package/components/newsroom-board-view.tsx +2 -6
  43. package/components/newsroom-client-shell.tsx +2 -1
  44. package/components/newsroom-ops-overview.tsx +88 -73
  45. package/components/newsroom-ops-shell.tsx +8 -28
  46. package/components/newsroom-organizer.css +127 -3
  47. package/components/newsroom-references-view.tsx +334 -158
  48. package/components/notification-cards.tsx +370 -0
  49. package/components/notification-center.tsx +376 -213
  50. package/components/papyrus-console-shell.tsx +2 -1
  51. package/components/proposals-panel.tsx +21 -24
  52. package/components/proposals-view.tsx +4 -2
  53. package/components/reader-auth-control.tsx +3 -1
  54. package/components/reference-capture-client.ts +138 -0
  55. package/components/reference-capture-panel.tsx +268 -0
  56. package/components/reference-conversation.tsx +43 -0
  57. package/components/reference-feed.tsx +514 -0
  58. package/components/reference-review-card.tsx +62 -197
  59. package/components/reference-source-preview.tsx +27 -5
  60. package/components/reference-stories.tsx +34 -0
  61. package/components/references-page-loading.tsx +61 -0
  62. package/components/references-prefetch.ts +52 -0
  63. package/components/story-reporting-trail-client.ts +45 -8
  64. package/components/story-reporting-trail.tsx +24 -13
  65. package/components/topic-steering-workspace.tsx +50 -439
  66. package/components/ui/auto-grow-textarea.tsx +18 -0
  67. package/components/use-conversation.ts +84 -0
  68. package/components/use-media-query.ts +16 -0
  69. package/components/use-reference-preview-attachments.ts +101 -0
  70. package/components/use-reference-ratings.ts +189 -0
  71. package/infra/amplify-app-shell.js +18 -2
  72. package/infra/amplify-app-shell.ts +18 -2
  73. package/infra/chat-stack.js +222 -0
  74. package/infra/chat-stack.ts +250 -0
  75. package/infra/chat.test.js +142 -0
  76. package/infra/chat.test.ts +151 -0
  77. package/infra/index.js +1 -0
  78. package/infra/site-config.js +58 -1
  79. package/infra/site-config.ts +78 -1
  80. package/lib/category-repository.ts +6 -1
  81. package/lib/content-actions-client.ts +20 -0
  82. package/lib/conversation.ts +154 -0
  83. package/lib/local-preview-feed-fixture.ts +63 -0
  84. package/lib/local-preview-seed.ts +24 -2
  85. package/lib/local-preview-synthetic-rejects.ts +41 -0
  86. package/lib/monitoring-client.ts +29 -2
  87. package/lib/monitoring-overview.ts +52 -1
  88. package/lib/monitoring-paging.ts +58 -0
  89. package/lib/needs-me-items.ts +289 -0
  90. package/lib/needs-me-ranking.ts +315 -0
  91. package/lib/newsroom-all-pages.ts +21 -0
  92. package/lib/newsroom-chat-identity.ts +2 -1
  93. package/lib/newsroom-index-filters.ts +18 -0
  94. package/lib/newsroom-nav.ts +1 -1
  95. package/lib/newsroom-organizer-nav.ts +2 -1
  96. package/lib/newsroom-references.ts +25 -1
  97. package/lib/notification-feed.ts +212 -0
  98. package/lib/proposals-client.ts +29 -0
  99. package/lib/reader-subject.ts +3 -2
  100. package/lib/reference-capture.ts +294 -0
  101. package/lib/reference-feed-cache.ts +170 -0
  102. package/lib/reference-feed-ranking.ts +236 -0
  103. package/lib/reference-policy.ts +90 -0
  104. package/lib/reference-preview.ts +112 -0
  105. package/lib/relevance-decisions.ts +6 -2
  106. package/lib/review-client.ts +71 -5
  107. package/lib/review-drafts.ts +277 -0
  108. package/lib/review-fixture.ts +56 -2
  109. package/lib/review-requests.ts +89 -24
  110. package/lib/site-brand.ts +2 -0
  111. package/lib/source-frameable.ts +77 -0
  112. package/lib/source-icon.ts +104 -0
  113. package/lib/staff-groups.ts +23 -0
  114. package/lib/staging-gate.ts +2 -2
  115. package/lib/story-reporting-trail.ts +33 -2
  116. package/package.json +45 -2
  117. package/routes.manifest.json +23 -1
  118. package/components/newsroom-overview-skeleton.tsx +0 -25
@@ -0,0 +1,174 @@
1
+ "use client";
2
+
3
+ import { Loader2Icon, MessageSquarePlusIcon, ReplyIcon, TriangleAlertIcon, CornerDownRightIcon } from "lucide-react";
4
+ import { useMemo, useState } from "react";
5
+ import { buildConversationTree, conversationAge, conversationAuthorName, type ConversationMessage } from "../lib/conversation";
6
+ import { cn } from "../lib/utils";
7
+ import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
8
+ import type { ConversationState } from "./use-conversation";
9
+
10
+ /** The decision control's register: caption labels, md corners, a tone and never an outline. */
11
+ const LABEL = "font-[family-name:var(--font-sans)] text-[10px] font-extrabold tracking-[0.07em] uppercase";
12
+ const PILL = "inline-flex min-h-9 items-center justify-center gap-1.5 rounded-[var(--feed-radius-md,16px)] px-3.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring disabled:opacity-[var(--feed-quiet,0.6)]";
13
+ const PILL_PRIMARY = cn(PILL, "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]");
14
+ const PILL_QUIET = cn(PILL, "text-muted-foreground hover:bg-[var(--feed-control)]/70 hover:text-foreground");
15
+
16
+ /**
17
+ * A box for writing a Message. The text clears the moment you post, so the Message shows at once; if the save fails the text comes
18
+ * back (unless you have typed something new) with the reason, and nothing you wrote is lost.
19
+ */
20
+ function Composer({ autoFocus = false, buttonLabel, disabledReason, icon, label, onCancel, onSubmit, placeholder }: {
21
+ autoFocus?: boolean;
22
+ buttonLabel: string;
23
+ disabledReason?: string | null;
24
+ icon: "post" | "reply";
25
+ label: string;
26
+ onCancel?: () => void;
27
+ onSubmit: (body: string) => Promise<void>;
28
+ placeholder: string;
29
+ }) {
30
+ const [draft, setDraft] = useState("");
31
+ const [error, setError] = useState<string | null>(null);
32
+ const Icon = icon === "post" ? MessageSquarePlusIcon : ReplyIcon;
33
+ const canPost = !disabledReason && draft.trim().length > 0;
34
+
35
+ async function submit() {
36
+ const text = draft.trim();
37
+ if (!text || disabledReason) return;
38
+ setDraft("");
39
+ setError(null);
40
+ try {
41
+ await onSubmit(text);
42
+ onCancel?.();
43
+ } catch (failure) {
44
+ setDraft((current) => current || text);
45
+ setError(failure instanceof Error ? failure.message : "Your message could not be saved.");
46
+ }
47
+ }
48
+
49
+ return (
50
+ <div className="space-y-2" data-conversation-composer={icon}>
51
+ <AutoGrowTextarea
52
+ aria-label={label}
53
+ autoFocus={autoFocus}
54
+ className="block min-h-16 w-full rounded-[var(--feed-radius-md,16px)] border-0 bg-[var(--feed-control)] px-3 py-2 text-sm font-normal leading-5 outline-none placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring"
55
+ disabled={Boolean(disabledReason)}
56
+ onChange={(event) => setDraft(event.target.value)}
57
+ onKeyDown={(event) => { if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); void submit(); } }}
58
+ placeholder={placeholder}
59
+ value={draft}
60
+ />
61
+ {error ? (
62
+ <p className="flex items-start gap-1.5 text-sm text-destructive" data-conversation-error role="alert">
63
+ <TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
64
+ <span>Not posted: {error} Your text is kept here.</span>
65
+ </p>
66
+ ) : null}
67
+ {disabledReason ? <p className="text-sm text-muted-foreground">{disabledReason}</p> : null}
68
+ <div className="flex flex-wrap items-center gap-2">
69
+ <button className={PILL_PRIMARY} data-conversation-post={icon} disabled={!canPost} onClick={() => void submit()} type="button">
70
+ <Icon aria-hidden="true" className="size-4" />
71
+ {buttonLabel}
72
+ </button>
73
+ {onCancel ? <button className={PILL_QUIET} onClick={onCancel} type="button">Cancel</button> : null}
74
+ </div>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ function MessageBody({ answers, message, now, onReply, replying, tone }: {
80
+ answers?: string | null;
81
+ message: ConversationMessage;
82
+ now: number;
83
+ onReply: () => void;
84
+ replying: boolean;
85
+ tone: "root" | "reply";
86
+ }) {
87
+ return (
88
+ <div className={cn("space-y-1.5 rounded-[var(--feed-radius-md,16px)] p-3", tone === "root" ? "bg-[var(--feed-control)]" : "bg-[var(--feed-footer)]")} data-conversation-message={message.id} data-conversation-pending={message.pending || undefined}>
89
+ <p className="flex flex-wrap items-center gap-x-2 text-sm">
90
+ <span className="font-semibold" data-conversation-author>{conversationAuthorName(message.authorLabel)}</span>
91
+ <span className="text-xs text-muted-foreground" data-conversation-age>
92
+ {message.pending ? (
93
+ <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3 animate-spin" />Sending</span>
94
+ ) : conversationAge(message.createdAt, now)}
95
+ </span>
96
+ </p>
97
+ {answers ? (
98
+ <p className="flex items-center gap-1 text-xs text-muted-foreground" data-conversation-answers>
99
+ <CornerDownRightIcon aria-hidden="true" className="size-3" />
100
+ In reply to {answers}
101
+ </p>
102
+ ) : null}
103
+ <p className="whitespace-pre-wrap break-words text-sm leading-5" data-conversation-content>{message.content}</p>
104
+ {!message.pending ? (
105
+ <button aria-expanded={replying} className={cn(PILL_QUIET, "-ml-3")} data-conversation-reply={message.id} onClick={onReply} type="button">
106
+ <ReplyIcon aria-hidden="true" className="size-4" />
107
+ Reply
108
+ </button>
109
+ ) : null}
110
+ </div>
111
+ );
112
+ }
113
+
114
+ /**
115
+ * The Messages of one conversation, replies nested one level under their top-level Message, and a box to add another. It knows
116
+ * nothing about where the Messages come from: the caller loads them and saves a post.
117
+ */
118
+ export function ConversationThread({ disabledReason = null, error = null, heading = "Messages", messages, now, onPost, onRetry, state = "ready" }: {
119
+ disabledReason?: string | null;
120
+ error?: string | null;
121
+ heading?: string;
122
+ messages: ReadonlyArray<ConversationMessage>;
123
+ now: number;
124
+ onPost: (body: string, parentMessageId: string | null) => Promise<void>;
125
+ onRetry?: () => void;
126
+ state?: ConversationState;
127
+ }) {
128
+ const tree = useMemo(() => buildConversationTree(messages), [messages]);
129
+ const [replyingTo, setReplyingTo] = useState<string | null>(null);
130
+ const authorOf = (id: string) => conversationAuthorName(messages.find((message) => message.id === id)?.authorLabel);
131
+ const toggleReply = (id: string) => setReplyingTo((current) => (current === id ? null : id));
132
+
133
+ return (
134
+ <section aria-label={heading} className="space-y-3" data-conversation-thread>
135
+ <h3 className={cn(LABEL, "text-muted-foreground")}>{heading}</h3>
136
+ {state === "loading" ? <p className="flex items-center gap-1.5 text-sm text-muted-foreground" data-conversation-loading><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Loading messages</p> : null}
137
+ {state === "error" ? (
138
+ <p className="flex flex-wrap items-center gap-2 text-sm text-destructive" role="alert">
139
+ <TriangleAlertIcon aria-hidden="true" className="size-4" />
140
+ Messages could not be loaded. {error}
141
+ {onRetry ? <button className={PILL_QUIET} onClick={onRetry} type="button">Try again</button> : null}
142
+ </p>
143
+ ) : null}
144
+ {state === "ready" && !tree.length ? <p className="text-sm text-muted-foreground" data-conversation-empty>No messages yet. Write down what this made you think.</p> : null}
145
+ <ul className="space-y-3">
146
+ {tree.map((root) => (
147
+ <li className="space-y-2" key={root.message.id}>
148
+ <MessageBody message={root.message} now={now} onReply={() => toggleReply(root.message.id)} replying={replyingTo === root.message.id} tone="root" />
149
+ {root.replies.length || replyingTo === root.message.id ? (
150
+ <ul className="ml-4 space-y-2" data-conversation-replies>
151
+ {root.replies.map((reply) => (
152
+ <li key={reply.message.id}>
153
+ <MessageBody answers={reply.answers} message={reply.message} now={now} onReply={() => toggleReply(reply.message.id)} replying={replyingTo === reply.message.id} tone="reply" />
154
+ {replyingTo === reply.message.id ? (
155
+ <div className="mt-2">
156
+ <Composer autoFocus buttonLabel="Reply" disabledReason={disabledReason} icon="reply" label={`Reply to ${authorOf(reply.message.id)}`} onCancel={() => setReplyingTo(null)} onSubmit={(body) => onPost(body, reply.message.id)} placeholder={`Reply to ${authorOf(reply.message.id)}…`} />
157
+ </div>
158
+ ) : null}
159
+ </li>
160
+ ))}
161
+ {replyingTo === root.message.id ? (
162
+ <li>
163
+ <Composer autoFocus buttonLabel="Reply" disabledReason={disabledReason} icon="reply" label={`Reply to ${authorOf(root.message.id)}`} onCancel={() => setReplyingTo(null)} onSubmit={(body) => onPost(body, root.message.id)} placeholder={`Reply to ${authorOf(root.message.id)}…`} />
164
+ </li>
165
+ ) : null}
166
+ </ul>
167
+ ) : null}
168
+ </li>
169
+ ))}
170
+ </ul>
171
+ <Composer buttonLabel="Post" disabledReason={disabledReason} icon="post" label="Add a message" onSubmit={(body) => onPost(body, null)} placeholder="Add a message…" />
172
+ </section>
173
+ );
174
+ }
@@ -0,0 +1,306 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useState } from "react";
4
+ import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
5
+ import { UNRECORDED_CONFIDENCE } from "../lib/reference-feed-ranking";
6
+ import { cn } from "../lib/utils";
7
+ import type { CardPrediction, ReviewChoice } from "./reference-review-card";
8
+
9
+ /**
10
+ * One classification, in two stages: a model predicts it, a person decides it.
11
+ *
12
+ * PREDICTED ACCEPT
13
+ * [=========== 96% ==========| ] the prediction's own confidence, in its own bar
14
+ * [ ✓ Accept PREDICTED ] [ ✕ Reject ] one pair of segments; the predicted one is pre-filled, in its own tone
15
+ * REASON · OPTIONAL
16
+ * [ Why? In your own words… ] written, always in view, full width
17
+ * ✓ Recorded · confirms the prediction one status line for the whole block
18
+ *
19
+ * The prediction and the answer share one row of segments rather than a row each: they are not two
20
+ * voters, they are one decision before and after a person touched it. Once decided, a small marker
21
+ * stays on the segment the model picked — on the losing one if it was overturned — so the comparison
22
+ * survives the answer without a second row. The bar belongs to the model, so it never moves when the
23
+ * person answers; only its tone changes.
24
+ *
25
+ * The verdict never tints the person's own choice: a decided segment is always `--primary`, and
26
+ * `--feed-verdict-*` colours the marker and the bar. An overturned Reject painted red reads as "your
27
+ * answer is wrong" rather than "the model's was".
28
+ *
29
+ * No engine or product name appears in the copy: the component knows about predictions, not about
30
+ * what made them (AGENTS.md: a deployment publishes about any subject with any classifier behind it).
31
+ *
32
+ * Every tone, corner and opacity is a feed token from newsroom-organizer.css; the values after the
33
+ * comma are the last resort for a surface that declares none.
34
+ */
35
+
36
+ export type RatingFeedback = { phase: "idle" | "saving" | "saved" | "error"; message?: string };
37
+
38
+ /** P(accept) from a prediction: its confidence is in the side it chose; an unrecorded confidence is a mild lean. */
39
+ export function predictionAcceptProbability(prediction: Extract<CardPrediction, { state: "ready" }>): number {
40
+ const confidence = prediction.confidence ?? UNRECORDED_CONFIDENCE;
41
+ return prediction.choice === "accept" ? confidence : 1 - confidence;
42
+ }
43
+
44
+ /** How far, in percent of the whole bar, the fill reaches from the centre: |P(accept) - 0.5| * 2, over the two halves. */
45
+ export function confidenceFillPercent(probability: number): number {
46
+ return Math.min(100, Math.abs(probability - 0.5) * 100 * 2);
47
+ }
48
+
49
+ const WORD: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
50
+ const LABEL = "font-[family-name:var(--font-sans)] text-[10px] font-extrabold tracking-[0.07em] uppercase";
51
+ /** A fill shorter than this cannot hold its own percentage, so the number sits beside it. */
52
+ const NARROW_FILL = 14;
53
+
54
+ export function DecisionControl({
55
+ answer,
56
+ decided = null,
57
+ disabled = false,
58
+ disabledReason = null,
59
+ feedback = { phase: "idle" },
60
+ needsReason = false,
61
+ note = null,
62
+ onChoose,
63
+ onReasonChange,
64
+ onReasonCommit,
65
+ prediction,
66
+ reason = "",
67
+ reasonRequiredFor = null,
68
+ showPrediction = true,
69
+ showReason = true,
70
+ size = "compact",
71
+ statusOverlay = false,
72
+ surface = "footer",
73
+ }: {
74
+ /** The side shown as chosen. It may be an answer that is still saving. */
75
+ answer: ReviewChoice | null;
76
+ /** The saved answer, which the Confirmed / Overturned verdict compares against. */
77
+ decided?: ReviewChoice | null;
78
+ disabled?: boolean;
79
+ disabledReason?: string | null;
80
+ feedback?: RatingFeedback;
81
+ /** The answer is staged and waiting for a written reason before it can be recorded. */
82
+ needsReason?: boolean;
83
+ /** A short line shown in the status row beside the saved tick. It never adds a row. */
84
+ note?: string | null;
85
+ onChoose: (choice: ReviewChoice) => void;
86
+ onReasonChange?: (text: string) => void;
87
+ /** Save the reason on its own (on blur, or from the status row). */
88
+ onReasonCommit?: () => void;
89
+ prediction: CardPrediction;
90
+ /** The written reason, owned by the caller. */
91
+ reason?: string;
92
+ /** The side that cannot be recorded without a written reason. */
93
+ reasonRequiredFor?: ReviewChoice | null;
94
+ /** False for an item with nothing to predict: the bar and the caption are left out. */
95
+ showPrediction?: boolean;
96
+ /** False where the surrounding card owns the reason field. */
97
+ showReason?: boolean;
98
+ size?: "compact" | "large";
99
+ /** Lay the status line over the bottom padding of the nearest positioned ancestor, so it adds no height. */
100
+ statusOverlay?: boolean;
101
+ /** The tone the control sits on, so the bar's centre notch matches it. */
102
+ surface?: "footer" | "card";
103
+ }) {
104
+ const fieldId = useId();
105
+ const ready = prediction.state === "ready" ? prediction : null;
106
+ const verdict = ready && decided ? (ready.choice === decided ? "match" : "miss") : null;
107
+ const saving = feedback.phase === "saving";
108
+ const [reasonDirty, setReasonDirty] = useState(false);
109
+ useEffect(() => { if (feedback.phase === "saved") setReasonDirty(false); }, [feedback.phase]);
110
+
111
+ const probability = ready ? predictionAcceptProbability(ready) : 0.5;
112
+ const half = confidenceFillPercent(probability) / 2; // percent of the full bar, one side of the centre
113
+ const leansAccept = probability >= 0.5;
114
+ const confidenceText = ready ? (ready.confidence !== null ? `${Math.round(ready.confidence * 100)}%` : "unrecorded") : null;
115
+ const narrow = half < NARROW_FILL;
116
+ const cell = size === "large" ? "min-h-14 text-base" : "min-h-12 text-sm";
117
+ const field = size === "large" ? "min-h-16" : "min-h-9";
118
+
119
+ const caption = ready
120
+ ? `Predicted ${WORD[ready.choice]}`
121
+ : prediction.state === "loading" ? "Reading the prediction…"
122
+ : prediction.state === "error" ? "Prediction unavailable" : "No prediction";
123
+
124
+ /* What the chosen segment says about itself while the answer is in flight or held back. A segment
125
+ never claims to be unrecorded while it is being recorded. */
126
+ const stagedWord = saving ? (decided ? "Updating…" : "Sending…") : needsReason ? "Needs a reason" : null;
127
+
128
+ return (
129
+ <div className="@container/decision grid gap-y-2" data-decision-control data-rating-grid>
130
+ {showPrediction ? (
131
+ <div className="grid gap-y-0.5" data-prediction>
132
+ <span className={cn(LABEL, "text-muted-foreground")} data-prediction-caption>{caption}</span>
133
+ <div
134
+ aria-label={ready ? `Prediction: ${WORD[ready.choice]}, ${confidenceText} confident` : caption}
135
+ className="relative h-4 w-full overflow-hidden rounded-full bg-[var(--feed-control)]"
136
+ data-confidence-bar
137
+ data-confidence-direction={ready ? (leansAccept ? "accept" : "reject") : "none"}
138
+ data-prediction-state={ready ? undefined : prediction.state}
139
+ role="img"
140
+ >
141
+ {ready ? (
142
+ <span
143
+ className={cn(
144
+ "absolute inset-y-0 flex items-center rounded-full",
145
+ leansAccept ? "justify-end" : "justify-start",
146
+ verdict === "match" ? "bg-[var(--feed-verdict-match-fill,#059669)]"
147
+ : verdict === "miss" ? "bg-[var(--feed-verdict-miss-fill,#dc2626)]"
148
+ : "bg-[var(--foreground-muted)]",
149
+ )}
150
+ data-confidence-fill
151
+ data-fill-percent={Math.round(half * 2)}
152
+ style={{ left: leansAccept ? `${50 - half}%` : "50%", width: `${half}%` }}
153
+ >
154
+ {/* The confidence is printed in the bar and nowhere else, so no button has to carry a number. */}
155
+ {narrow ? null : <small className={cn(LABEL, "px-1.5 text-[var(--feed-card)] whitespace-nowrap")} data-confidence-value>{confidenceText}</small>}
156
+ </span>
157
+ ) : null}
158
+ {ready && narrow ? (
159
+ <small
160
+ className={cn(LABEL, "absolute top-0.5 text-muted-foreground whitespace-nowrap")}
161
+ data-confidence-value
162
+ style={leansAccept ? { left: `${50 - half}%`, transform: "translateX(-100%)" } : { left: `${50 + half}%` }}
163
+ >
164
+ {confidenceText}
165
+ </small>
166
+ ) : null}
167
+ <span aria-hidden="true" className={cn("absolute inset-y-0 left-1/2 w-1 -translate-x-1/2", surface === "footer" ? "bg-[var(--feed-footer)]" : "bg-[var(--feed-card)]")} />
168
+ </div>
169
+ </div>
170
+ ) : null}
171
+
172
+ <div aria-label="Classification" className="grid grid-cols-2 gap-x-1.5" data-review-row="you" role="group">
173
+ {(["accept", "reject"] as const).map((choice) => {
174
+ const on = answer === choice;
175
+ const predicted = ready?.choice === choice;
176
+ const proposed = predicted && !answer;
177
+ const Icon = choice === "accept" ? CheckIcon : XIcon;
178
+ const sub = proposed ? "Predicted"
179
+ : on ? (stagedWord ?? (verdict === "miss" ? "Overturned" : verdict === "match" ? "Confirmed" : decided === choice ? "Recorded" : null))
180
+ : null;
181
+ return (
182
+ <button
183
+ aria-disabled={disabled ? true : undefined}
184
+ aria-pressed={on}
185
+ className={cn(
186
+ "relative inline-flex min-w-0 flex-col items-center justify-center gap-px rounded-[var(--feed-radius-md,16px)] px-2 font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring @max-[17rem]/decision:px-1",
187
+ cell,
188
+ // Three degrees of finish, in tone alone: the feed register has no borders to spend
189
+ // (newsroom-organizer.css strips them), so a chosen answer is the solid fill, the
190
+ // predicted side is the control tone, and an untouched side is quieter still.
191
+ on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]"
192
+ : proposed ? "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70"
193
+ : "bg-[var(--feed-control)]/45 text-muted-foreground hover:bg-[var(--feed-control)]/70",
194
+ disabled && !on && "opacity-[var(--feed-quiet,0.6)]",
195
+ )}
196
+ data-predicted={predicted || undefined}
197
+ data-review-choice={choice}
198
+ data-state={on ? (saving || needsReason ? "staged" : "decided") : proposed ? "predicted" : "idle"}
199
+ key={choice}
200
+ onClick={() => { if (!disabled) onChoose(choice); }}
201
+ type="button"
202
+ >
203
+ {/* The model's opinion survives the answer, on the segment it picked. */}
204
+ {predicted && answer ? (
205
+ <span
206
+ className={cn(
207
+ "absolute top-1.5 right-2 opacity-55",
208
+ verdict === "match" && "text-[var(--feed-verdict-match-fill,#059669)] opacity-100",
209
+ verdict === "miss" && "text-[var(--feed-verdict-miss-fill,#dc2626)] opacity-100",
210
+ )}
211
+ data-prediction-mark
212
+ data-verdict={verdict ?? undefined}
213
+ title="this is what was predicted"
214
+ >
215
+ <svg aria-hidden="true" className="size-3" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
216
+ <circle cx="12" cy="12" r="8" />
217
+ <circle cx="12" cy="12" fill="currentColor" r="2.6" stroke="none" />
218
+ </svg>
219
+ </span>
220
+ ) : null}
221
+ <span className="flex items-center gap-1.5">
222
+ <Icon aria-hidden="true" className={cn("size-4", !on && "opacity-[var(--feed-quiet,0.6)]")} />
223
+ {WORD[choice]}
224
+ </span>
225
+ {sub ? <span className={cn(LABEL, "opacity-75")} data-choice-state>{sub}</span> : null}
226
+ {verdict && predicted ? <span className="sr-only">{verdict === "match" ? ", this is what was predicted" : ", the prediction was the other way"}</span> : null}
227
+ </button>
228
+ );
229
+ })}
230
+ </div>
231
+
232
+ {showReason ? (
233
+ <div className="grid gap-y-1.5" data-review-reason>
234
+ <label className={cn(LABEL, "text-muted-foreground")} htmlFor={fieldId}>
235
+ Reason{reasonRequiredFor ? ` · required to ${WORD[reasonRequiredFor].toLowerCase()}` : " · optional"}
236
+ </label>
237
+ {/* Written reasons only. A row of canned answers collects a label the model mostly has already;
238
+ what trains the next prediction is a sentence. */}
239
+ <textarea
240
+ className={cn(
241
+ "w-full rounded-[var(--feed-radius-md,16px)] border-0 bg-[var(--feed-control)] px-3 py-2 text-sm leading-5 text-foreground outline-none transition-[min-height] placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring",
242
+ field,
243
+ )}
244
+ disabled={disabled}
245
+ id={fieldId}
246
+ onBlur={() => { if (!disabled) onReasonCommit?.(); }}
247
+ onChange={(event) => { setReasonDirty(true); onReasonChange?.(event.target.value); }}
248
+ placeholder="Why? In your own words — this is what the next prediction learns from."
249
+ value={reason}
250
+ />
251
+ </div>
252
+ ) : null}
253
+
254
+ <div
255
+ className={cn(
256
+ "flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground",
257
+ // The overlay strip is 16px tall: a question with a button, or an error, needs a row of its own.
258
+ statusOverlay && !needsReason && feedback.phase !== "error" ? "absolute inset-x-4 bottom-0 h-4 overflow-hidden leading-4" : "min-h-5",
259
+ )}
260
+ data-feed-saved
261
+ role="status"
262
+ >
263
+ {note ? <span data-feed-reason>{note}</span> : null}
264
+ {needsReason ? (
265
+ <>
266
+ <span className="font-semibold text-foreground">A reason is needed to record {WORD[answer ?? "reject"]}</span>
267
+ <button
268
+ className="rounded-[var(--feed-radius-md,16px)] bg-[var(--primary)] px-2.5 py-0.5 font-semibold text-[var(--primary-foreground)] outline-none focus-visible:ring-[3px] focus-visible:ring-ring disabled:opacity-[var(--feed-quiet,0.6)]"
269
+ disabled={!reason.trim()}
270
+ data-review-record
271
+ onClick={() => { if (answer) onChoose(answer); }}
272
+ type="button"
273
+ >
274
+ Record
275
+ </button>
276
+ </>
277
+ ) : null}
278
+ {saving ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Recording…</span> : null}
279
+ {feedback.phase === "saved" && !needsReason ? (
280
+ <span className="inline-flex items-center gap-1 text-foreground" data-review-saved>
281
+ <CheckIcon aria-hidden="true" className="size-3.5" />
282
+ Recorded
283
+ {verdict ? <span className="text-muted-foreground">· {verdict === "match" ? "confirms" : "overturns"} the prediction</span> : null}
284
+ {showReason ? (
285
+ reasonDirty ? <span className="text-muted-foreground">· reason not saved yet</span>
286
+ : reason.trim() ? <span className="text-muted-foreground">· reason saved</span>
287
+ : <span className="text-muted-foreground">· no reason given</span>
288
+ ) : null}
289
+ </span>
290
+ ) : null}
291
+ {feedback.phase === "saved" && reasonDirty && showReason ? (
292
+ <button
293
+ className="rounded-[var(--feed-radius-md,16px)] bg-[var(--primary)] px-2.5 py-0.5 font-semibold text-[var(--primary-foreground)] outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
294
+ data-review-save-reason
295
+ onClick={() => onReasonCommit?.()}
296
+ type="button"
297
+ >
298
+ Save reason
299
+ </button>
300
+ ) : null}
301
+ </div>
302
+ {disabledReason ? <p className="text-xs text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
303
+ {feedback.phase === "error" ? <p className="text-xs text-destructive" role="alert">{feedback.message}</p> : null}
304
+ </div>
305
+ );
306
+ }
@@ -2,9 +2,9 @@
2
2
 
3
3
  import { fetchAuthSession, signIn, signOut } from "aws-amplify/auth";
4
4
  import { configureAmplifyClient } from "./amplify-client-provider";
5
+ import { hasEditorRights } from "../lib/staff-groups";
5
6
  import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
6
7
 
7
- const EDITOR_GROUPS = new Set(["editor", "admin"]);
8
8
 
9
9
  function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>): string[] {
10
10
  const accessPayload = session.tokens?.accessToken?.payload ?? {};
@@ -29,7 +29,7 @@ function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>
29
29
 
30
30
  function sessionHasEditorAccess(session: Awaited<ReturnType<typeof fetchAuthSession>>): boolean {
31
31
  if (!session.tokens?.accessToken) return false;
32
- return readSessionGroups(session).some((group) => EDITOR_GROUPS.has(group));
32
+ return hasEditorRights(readSessionGroups(session));
33
33
  }
34
34
 
35
35
  export async function ensureDevSandboxEditorSession(