@anthusai/papyrus 5.43.0-next.11 → 5.43.0-next.12

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.
@@ -357,6 +357,8 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
357
357
  .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-[20px]"] { border-radius: 20px !important; }
358
358
  .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-2xl"],
359
359
  .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-2xl"] { border-radius: 16px !important; }
360
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-[16px]"],
361
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-[16px]"] { border-radius: 16px !important; }
360
362
  .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-3xl"],
361
363
  .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-3xl"] { border-radius: 24px !important; }
362
364
 
@@ -391,3 +393,5 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
391
393
  }
392
394
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-cyclotron-choice] { border-radius: 9999px !important; }
393
395
  [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; }
396
+ /* The rating grid keeps its own rounded rectangles in the borderless panes (not the pill shape the flat rules give other buttons). */
397
+ [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: 16px !important; }
@@ -1,14 +1,6 @@
1
1
  "use client";
2
2
 
3
- import {
4
- CheckIcon,
5
- CircleCheckIcon,
6
- CircleXIcon,
7
- InfoIcon,
8
- Loader2Icon,
9
- MinusIcon,
10
- XIcon,
11
- } from "lucide-react";
3
+ import { InfoIcon, Loader2Icon } from "lucide-react";
12
4
  import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
13
5
  import type { ReferenceRecord } from "../lib/category-repository";
14
6
  import { REFERENCE_REJECTION_REASON_CODES, normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
@@ -28,6 +20,7 @@ import { referenceDisplayTitle } from "../lib/newsroom-references";
28
20
  import type { RelevanceDecisionView } from "../lib/relevance-decisions";
29
21
  import { cn } from "../lib/utils";
30
22
  import { predictionFromDecision, type ReviewChoice } from "./reference-review-card";
23
+ import { RatingGrid } from "./review-rating-grid";
31
24
 
32
25
  /**
33
26
  * Flat surfaces, no borders: the page tone, a card tone one step away from it, a footer tone, and a control tone for chips and
@@ -182,46 +175,6 @@ function FeedImage({ host, lead, name, src }: { host: string | null; lead: boole
182
175
  );
183
176
  }
184
177
 
185
- function percent(confidence: number | null): string | null {
186
- return confidence === null ? null : `${Math.round(confidence * 100)}%`;
187
- }
188
-
189
- /** The Cyclotron's read-only answer as a soft pill: Accept or Reject with its confidence, then Match or Miss once a person has answered. */
190
- function PredictionChip({ answer, decision, loading }: { answer: ReviewChoice | null; decision: RelevanceDecisionView | undefined; loading: boolean }) {
191
- const base = "inline-flex min-h-8 items-center gap-1.5 rounded-full px-3 text-xs font-medium";
192
- if (!decision) {
193
- return (
194
- <span className={cn(base, "bg-[var(--feed-control)] text-muted-foreground")} data-cyclotron-state={loading ? "loading" : "none"}>
195
- {loading ? <Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" /> : <MinusIcon aria-hidden="true" className="size-3.5" />}
196
- {loading ? "Predicting…" : "No prediction"}
197
- </span>
198
- );
199
- }
200
- const prediction = predictionFromDecision(decision);
201
- if (prediction.state !== "ready") return null;
202
- const matched = answer ? prediction.choice === answer : null;
203
- const verdict = matched === null ? null : matched ? "match" : "miss";
204
- const Icon = verdict === "match" ? CircleCheckIcon : verdict === "miss" ? CircleXIcon : prediction.choice === "accept" ? CheckIcon : XIcon;
205
- return (
206
- <span
207
- className={cn(
208
- base,
209
- verdict === null && "bg-[var(--feed-control)] text-foreground",
210
- verdict === "match" && "bg-primary/15 text-primary",
211
- verdict === "miss" && "bg-destructive/15 text-destructive",
212
- )}
213
- data-cyclotron-choice={prediction.choice}
214
- data-predicted
215
- data-verdict={verdict ?? undefined}
216
- >
217
- <Icon aria-hidden="true" className="size-4" />
218
- <span>Cyclotron · {prediction.choice === "accept" ? "Accept" : "Reject"}{percent(prediction.confidence) ? ` ${percent(prediction.confidence)}` : ""}</span>
219
- {verdict ? <span data-verdict-word>· {verdict === "match" ? "Match" : "Miss"}</span> : null}
220
- {verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
221
- </span>
222
- );
223
- }
224
-
225
178
  type RateState = { phase: "idle" | "saving" | "saved" | "error" | "reason"; message?: string };
226
179
 
227
180
  function FeedCard({
@@ -324,34 +277,14 @@ function FeedCard({
324
277
  </button>
325
278
 
326
279
  <div className="space-y-2.5 bg-[var(--feed-footer)] p-3" data-feed-actions>
327
- <div className="flex flex-wrap items-center justify-between gap-2">
328
- <PredictionChip answer={answer} decision={decision} loading={predictionLoading} />
329
- <span aria-live="polite" className="inline-flex min-h-5 items-center gap-1 text-xs text-muted-foreground" data-feed-saved role="status">
330
- {rate.phase === "saving" ? <><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</> : null}
331
- {rate.phase === "saved" || (visitRated && rate.phase === "idle" && answer) ? <><CheckIcon aria-hidden="true" className="size-3.5" />Saved</> : null}
332
- </span>
333
- </div>
334
- <div aria-label="Your answer" className="flex items-center gap-2" data-review-row="you" role="group">
335
- <span className="sr-only">You</span>
336
- {(["accept", "reject"] as const).map((choice) => {
337
- const on = answer === choice;
338
- return (
339
- <button
340
- aria-disabled={disabledReason ? true : undefined}
341
- aria-pressed={on}
342
- className={cn(PILL, "min-h-10 flex-1", on ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70", disabledReason && "opacity-60")}
343
- data-review-choice={choice}
344
- key={choice}
345
- onClick={() => choose(choice)}
346
- type="button"
347
- >
348
- {choice === "accept" ? <CheckIcon aria-hidden="true" className={cn("size-4", !on && "opacity-60")} /> : <XIcon aria-hidden="true" className={cn("size-4", !on && "opacity-60")} />}
349
- {choice === "accept" ? "Accept" : "Reject"}
350
- </button>
351
- );
352
- })}
353
- </div>
354
- {disabledReason ? <p className="text-xs text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
280
+ <RatingGrid
281
+ answer={answer}
282
+ disabled={Boolean(disabledReason)}
283
+ disabledReason={disabledReason}
284
+ feedback={rate.phase === "saving" ? { phase: "saving" } : rate.phase === "error" ? { phase: "error", message: rate.message } : rate.phase === "saved" || (visitRated && rate.phase === "idle" && answer) ? { phase: "saved" } : { phase: "idle" }}
285
+ onChoose={choose}
286
+ prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
287
+ />
355
288
  {rate.phase === "reason" ? (
356
289
  <div className="space-y-2" data-review-reasons>
357
290
  <p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
@@ -365,7 +298,6 @@ function FeedCard({
365
298
  </div>
366
299
  </div>
367
300
  ) : null}
368
- {rate.phase === "error" ? <p className="text-xs text-destructive" role="alert">{rate.message}</p> : null}
369
301
  </div>
370
302
  </article>
371
303
  );
@@ -12,6 +12,7 @@ import {
12
12
  import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
13
13
  import { Button } from "./ui/button";
14
14
  import { cn } from "../lib/utils";
15
+ import { RatingGrid } from "./review-rating-grid";
15
16
  import { REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
16
17
 
17
18
  export type ReviewChoice = "accept" | "reject";
@@ -247,21 +248,17 @@ export function ReviewCard({
247
248
  {summary ? <p className="text-sm text-muted-foreground">{summary}</p> : null}
248
249
  </header>
249
250
 
250
- <CyclotronRow decided={decided} prediction={prediction} />
251
-
252
- <div className="space-y-2" data-review-row="you">
253
- <div className="flex items-center gap-3">
254
- <span className="w-20 shrink-0 text-sm font-semibold">You</span>
255
- <ChoiceButtons chosen={chosen} disabled={Boolean(disabledReason)} onChoose={choose} />
256
- </div>
257
- <div className="min-h-5 pl-[5.75rem] text-sm" role="status">
258
- {saving ? "Saving…" : saved ? (
259
- <span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-4" />Saved</span>
260
- ) : null}
261
- </div>
262
- {disabledReason ? <p className="text-sm text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
263
- {error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
264
- </div>
251
+ <RatingGrid
252
+ answer={chosen}
253
+ decided={decided}
254
+ disabled={Boolean(disabledReason)}
255
+ disabledReason={disabledReason}
256
+ feedback={saving ? { phase: "saving" } : error ? { phase: "error", message: error } : saved ? { phase: "saved" } : { phase: "idle" }}
257
+ onChoose={choose}
258
+ prediction={prediction}
259
+ size="large"
260
+ surface="card"
261
+ />
265
262
 
266
263
  <label className="block text-sm font-medium">
267
264
  Why? (optional)
@@ -0,0 +1,164 @@
1
+ "use client";
2
+
3
+ import { CheckIcon, CircleCheckIcon, CircleXIcon, Loader2Icon, XIcon } from "lucide-react";
4
+ import { UNRECORDED_CONFIDENCE } from "../lib/reference-feed-ranking";
5
+ import { cn } from "../lib/utils";
6
+ import type { CardPrediction, ReviewChoice } from "./reference-review-card";
7
+
8
+ /**
9
+ * The rating area of a card: one grid, three columns (label | Accept | Reject), and the two rows share its columns exactly.
10
+ * Cyclotron [ Accept 96% ] [ Reject ] read only; the predicted side is highlighted with its confidence
11
+ * [====== confidence bar, centred at 50% ======]
12
+ * You [ Accept ] [ Reject ] the real buttons, each directly under its matching cell
13
+ * Feedback is never colour alone: before you answer the predicted cell is neutral-accent with a check or cross; after, it turns
14
+ * green with a CircleCheck (Match) or red with a CircleX (Miss), and the confidence bar tints the same way. Your chosen button is
15
+ * filled. Flat tones and rounded shapes only, no borders.
16
+ */
17
+
18
+ export type RatingFeedback = { phase: "idle" | "saving" | "saved" | "error"; message?: string };
19
+
20
+ /** P(accept) from a prediction: its confidence is in the side it chose; an unrecorded confidence is a mild lean. */
21
+ export function predictionAcceptProbability(prediction: Extract<CardPrediction, { state: "ready" }>): number {
22
+ const confidence = prediction.confidence ?? UNRECORDED_CONFIDENCE;
23
+ return prediction.choice === "accept" ? confidence : 1 - confidence;
24
+ }
25
+
26
+ /** How far, in percent of the whole bar, the fill reaches from the centre: |P(accept) - 0.5| * 2, over the two halves. */
27
+ export function confidenceFillPercent(probability: number): number {
28
+ return Math.min(100, Math.abs(probability - 0.5) * 2 * 100);
29
+ }
30
+
31
+ const WORD: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
32
+
33
+ const TONE = {
34
+ match: "bg-emerald-600/20 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-100",
35
+ miss: "bg-red-600/20 text-red-900 dark:bg-red-400/20 dark:text-red-100",
36
+ } as const;
37
+ const FILL = { match: "bg-emerald-600 dark:bg-emerald-400", miss: "bg-red-600 dark:bg-red-400", neutral: "bg-[var(--primary)]" } as const;
38
+
39
+ export function RatingGrid({
40
+ answer,
41
+ decided = answer,
42
+ disabled = false,
43
+ disabledReason = null,
44
+ feedback = { phase: "idle" },
45
+ onChoose,
46
+ prediction,
47
+ size = "compact",
48
+ surface = "footer",
49
+ }: {
50
+ /** The side the buttons show as chosen (it may be a pending choice that is still saving). */
51
+ answer: ReviewChoice | null;
52
+ /** The saved human answer, which the Match / Miss verdict compares against. */
53
+ decided?: ReviewChoice | null;
54
+ disabled?: boolean;
55
+ disabledReason?: string | null;
56
+ feedback?: RatingFeedback;
57
+ onChoose: (choice: ReviewChoice) => void;
58
+ prediction: CardPrediction;
59
+ size?: "compact" | "large";
60
+ /** The tone the grid sits on, so the bar's centre notch matches it. */
61
+ surface?: "footer" | "card";
62
+ }) {
63
+ const ready = prediction.state === "ready" ? prediction : null;
64
+ const verdict = ready && decided ? (ready.choice === decided ? "match" : "miss") : null;
65
+ const probability = ready ? predictionAcceptProbability(ready) : 0.5;
66
+ const half = confidenceFillPercent(probability) / 2; // percent of the full bar width, on one side of the centre
67
+ const leansAccept = probability >= 0.5;
68
+ const confidenceText = ready && ready.confidence !== null ? `${Math.round(ready.confidence * 100)}%` : null;
69
+ const cell = size === "large" ? "min-h-14 text-base" : "min-h-12 text-sm";
70
+ const label = size === "large" ? "text-sm" : "text-xs";
71
+
72
+ return (
73
+ <div className={cn("grid grid-cols-[4.5rem_minmax(0,1fr)_minmax(0,1fr)] gap-x-2 gap-y-2.5", size === "large" && "grid-cols-[5rem_minmax(0,1fr)_minmax(0,1fr)]")} data-rating-grid>
74
+ <div className="col-span-3 grid grid-cols-subgrid items-stretch gap-y-2" data-review-row="cyclotron">
75
+ <span className={cn("self-center font-semibold", label)}>Cyclotron</span>
76
+ {(["accept", "reject"] as const).map((choice) => {
77
+ const predicted = ready?.choice === choice;
78
+ const Icon = predicted ? (verdict === "match" ? CircleCheckIcon : verdict === "miss" ? CircleXIcon : choice === "accept" ? CheckIcon : XIcon) : null;
79
+ return (
80
+ <span
81
+ className={cn(
82
+ "flex flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center",
83
+ cell,
84
+ !predicted && "bg-[var(--feed-control)]/45 text-muted-foreground",
85
+ predicted && !verdict && "bg-[var(--feed-control)] font-semibold text-[var(--primary)]",
86
+ predicted && verdict && cn("font-semibold", TONE[verdict]),
87
+ )}
88
+ data-cyclotron-choice={choice}
89
+ data-predicted={predicted || undefined}
90
+ data-verdict={predicted && verdict ? verdict : undefined}
91
+ key={choice}
92
+ >
93
+ <span className="inline-flex items-center gap-1.5">
94
+ {Icon ? <Icon aria-hidden="true" className="size-4 shrink-0" /> : null}
95
+ {WORD[choice]}
96
+ {predicted && confidenceText ? <small className="text-xs font-normal opacity-80">{confidenceText}</small> : null}
97
+ </span>
98
+ {predicted && verdict ? <small className="text-xs font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</small> : null}
99
+ {predicted && verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
100
+ </span>
101
+ );
102
+ })}
103
+ <div className="col-span-2 col-start-2 flex flex-col gap-1" data-confidence>
104
+ <div
105
+ aria-label={ready ? `Cyclotron confidence: leans ${leansAccept ? "Accept" : "Reject"}, ${Math.round(confidenceFillPercent(probability))} percent of the way` : "No confidence"}
106
+ className="relative h-2.5 w-full overflow-hidden rounded-full bg-[var(--feed-control)]"
107
+ data-confidence-bar
108
+ data-confidence-direction={ready ? (leansAccept ? "accept" : "reject") : "none"}
109
+ role="img"
110
+ >
111
+ {ready ? (
112
+ <span
113
+ className={cn("absolute inset-y-0 rounded-full", FILL[verdict ?? "neutral"])}
114
+ data-confidence-fill
115
+ data-fill-percent={Math.round(half * 2)}
116
+ style={{ left: leansAccept ? `${50 - half}%` : "50%", width: `${half}%` }}
117
+ />
118
+ ) : null}
119
+ <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)]")} />
120
+ </div>
121
+ {prediction.state === "ready" ? null : (
122
+ <small className="text-center text-xs text-muted-foreground" data-cyclotron-state={prediction.state}>
123
+ {prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
124
+ </small>
125
+ )}
126
+ </div>
127
+ </div>
128
+
129
+ <div className="col-span-3 grid grid-cols-subgrid items-stretch" data-review-row="you">
130
+ <span className={cn("self-center font-semibold", label)}>You</span>
131
+ {(["accept", "reject"] as const).map((choice) => {
132
+ const on = answer === choice;
133
+ const Icon = choice === "accept" ? CheckIcon : XIcon;
134
+ return (
135
+ <button
136
+ aria-disabled={disabled ? true : undefined}
137
+ aria-pressed={on}
138
+ className={cn(
139
+ "inline-flex items-center justify-center gap-1.5 rounded-[16px] px-2 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
140
+ cell,
141
+ on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70",
142
+ disabled && "opacity-60",
143
+ )}
144
+ data-review-choice={choice}
145
+ key={choice}
146
+ onClick={() => onChoose(choice)}
147
+ type="button"
148
+ >
149
+ <Icon aria-hidden="true" className={cn("size-4", !on && "opacity-60")} />
150
+ {WORD[choice]}
151
+ </button>
152
+ );
153
+ })}
154
+ </div>
155
+
156
+ <div className="col-span-3 min-h-5 text-xs text-muted-foreground" data-feed-saved role="status">
157
+ {feedback.phase === "saving" ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</span> : null}
158
+ {feedback.phase === "saved" ? <span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-3.5" />Saved</span> : null}
159
+ </div>
160
+ {disabledReason ? <p className="col-span-3 text-xs text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
161
+ {feedback.phase === "error" ? <p className="col-span-3 text-xs text-destructive" role="alert">{feedback.message}</p> : null}
162
+ </div>
163
+ );
164
+ }
package/package.json CHANGED
@@ -149,6 +149,7 @@
149
149
  "./components/reference-source-preview": "./components/reference-source-preview.tsx",
150
150
  "./components/references-page-loading": "./components/references-page-loading.tsx",
151
151
  "./components/references-prefetch": "./components/references-prefetch.ts",
152
+ "./components/review-rating-grid": "./components/review-rating-grid.tsx",
152
153
  "./components/settings-page": "./components/settings-page.tsx",
153
154
  "./components/signup-entries-panel": "./components/signup-entries-panel.tsx",
154
155
  "./components/staging-banner": "./components/staging-banner.tsx",
@@ -479,7 +480,7 @@
479
480
  "optional": true
480
481
  }
481
482
  },
482
- "version": "5.43.0-next.11",
483
+ "version": "5.43.0-next.12",
483
484
  "dependencies": {
484
485
  "@aws-amplify/adapter-nextjs": "^1.7.3",
485
486
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.43.0-next.11",
2
+ "version": "5.43.0-next.12",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",