@anthusai/papyrus 5.43.0-next.38 → 5.43.0-next.39
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.
- package/components/decision-control.tsx +306 -0
- package/components/needs-me-bodies.tsx +4 -3
- package/components/needs-me-feed.tsx +8 -14
- package/components/newsroom-organizer.css +24 -0
- package/components/notification-cards.tsx +5 -4
- package/components/reference-feed.tsx +7 -16
- package/components/reference-review-card.tsx +8 -134
- package/components/use-reference-ratings.ts +1 -1
- package/package.json +2 -2
- package/routes.manifest.json +1 -1
- package/components/review-rating-grid.tsx +0 -190
|
@@ -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
|
+
}
|
|
@@ -13,7 +13,7 @@ import type { NeedsMeEntry } from "../lib/needs-me-items";
|
|
|
13
13
|
import { answerWords, formatWhen, nextText, steeringText, subjectHref, subjectLabel } from "../lib/review-requests";
|
|
14
14
|
import { DecidedLine } from "./notification-cards";
|
|
15
15
|
import { SourceMark } from "./reference-feed";
|
|
16
|
-
import {
|
|
16
|
+
import { DecisionControl } from "./decision-control";
|
|
17
17
|
import type { RatingController } from "./use-reference-ratings";
|
|
18
18
|
import { FlagFooter, decidedChoiceOf, recordedAnswer } from "./needs-me-shared";
|
|
19
19
|
|
|
@@ -111,7 +111,7 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
|
|
|
111
111
|
{item.decisionState === "acknowledged"
|
|
112
112
|
? <p className="flex items-center gap-1.5 text-sm font-medium" data-flag-acknowledged><CircleCheckIcon aria-hidden="true" className="size-4" />Acknowledged</p>
|
|
113
113
|
: <DecidedLine choice={decided ?? "accept"} notThisKind={recorded.notThisKind} />}
|
|
114
|
-
{flag ? null : <
|
|
114
|
+
{flag ? null : <DecisionControl answer={decided} disabled feedback={{ phase: "idle" }} onChoose={() => undefined} prediction={{ state: "none" }} showPrediction={false} showReason={false} size="large" surface="footer" />}
|
|
115
115
|
{lockedNote ? <p className="text-xs text-muted-foreground" data-review-disabled-reason>{lockedNote}</p> : null}
|
|
116
116
|
</>
|
|
117
117
|
) : (
|
|
@@ -134,7 +134,7 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
|
|
|
134
134
|
{flag ? (
|
|
135
135
|
<FlagFooter busy={busy} disabledReason={disabledReason} done={false} error={rating.feedback.phase === "error" ? rating.feedback.message ?? null : null} onAcknowledge={() => ratings.choose(item.id, "accept")} />
|
|
136
136
|
) : (
|
|
137
|
-
<
|
|
137
|
+
<DecisionControl
|
|
138
138
|
answer={rating.pending}
|
|
139
139
|
disabled={Boolean(disabledReason)}
|
|
140
140
|
disabledReason={disabledReason}
|
|
@@ -142,6 +142,7 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
|
|
|
142
142
|
onChoose={(choice) => { if (!disabledReason) ratings.choose(item.id, choice); }}
|
|
143
143
|
prediction={{ state: "none" }}
|
|
144
144
|
showPrediction={false}
|
|
145
|
+
showReason={false}
|
|
145
146
|
size="large"
|
|
146
147
|
surface="footer"
|
|
147
148
|
/>
|
|
@@ -32,7 +32,7 @@ import { NotificationSkeleton, RecentDecisions } from "./notification-cards";
|
|
|
32
32
|
import { FEED_GRID, FEED_SURFACE_PROPS, FeedImage, PILL, REASON_LABELS, SELECTED_RING, SourceMark } from "./reference-feed";
|
|
33
33
|
import { predictionFromDecision, ReviewQueueBar, useQueueGestures, type CardPrediction, type ReviewChoice } from "./reference-review-card";
|
|
34
34
|
import type { CorrectCaptureAction } from "./reference-capture-client";
|
|
35
|
-
import {
|
|
35
|
+
import { DecisionControl } from "./decision-control";
|
|
36
36
|
import { useMediaQuery } from "./use-media-query";
|
|
37
37
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
38
38
|
import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
|
|
@@ -169,30 +169,24 @@ export function NeedsMeCard({ item, now, onOpen, predictionLoading, ratings, sel
|
|
|
169
169
|
{flag ? (
|
|
170
170
|
<FlagFooter busy={rating.feedback.phase === "saving"} disabledReason={disabledReason} done={item.decisionState === "acknowledged"} error={rating.feedback.phase === "error" ? rating.feedback.message ?? null : null} onAcknowledge={() => ratings.choose(item.id, "accept")} />
|
|
171
171
|
) : (
|
|
172
|
-
<
|
|
172
|
+
<DecisionControl
|
|
173
173
|
answer={chosen}
|
|
174
174
|
decided={decided}
|
|
175
175
|
disabled={Boolean(disabledReason) || locked}
|
|
176
176
|
disabledReason={disabledReason}
|
|
177
177
|
feedback={feedback}
|
|
178
|
+
needsReason={refLike && rating.askReason}
|
|
178
179
|
note={reasonLabel ? `Reason: ${reasonLabel}` : locked ? whyLine ?? "Decided here." : null}
|
|
179
180
|
onChoose={(choice) => { if (!disabledReason && !locked) ratings.choose(item.id, choice); }}
|
|
181
|
+
onReasonChange={(text) => ratings.setNote(item.id, text)}
|
|
182
|
+
onReasonCommit={() => { if (!disabledReason && !locked) ratings.commitNote(item.id); }}
|
|
180
183
|
prediction={cardPrediction(item, predictionLoading)}
|
|
184
|
+
reason={rating.note ?? ""}
|
|
185
|
+
reasonRequiredFor={refLike ? "reject" : null}
|
|
181
186
|
showPrediction={refLike}
|
|
187
|
+
showReason={refLike}
|
|
182
188
|
/>
|
|
183
189
|
)}
|
|
184
|
-
{refLike && rating.askReason ? (
|
|
185
|
-
// Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
|
|
186
|
-
<div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
|
|
187
|
-
<p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
|
|
188
|
-
<div className="flex flex-wrap gap-1.5">
|
|
189
|
-
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
190
|
-
<button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => ratings.setReason(item.id, code)} type="button">{REASON_LABELS[code]}</button>
|
|
191
|
-
))}
|
|
192
|
-
<button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => ratings.cancelReason(item.id)} type="button">Cancel</button>
|
|
193
|
-
</div>
|
|
194
|
-
</div>
|
|
195
|
-
) : null}
|
|
196
190
|
</div>
|
|
197
191
|
</article>
|
|
198
192
|
);
|
|
@@ -401,11 +401,29 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
401
401
|
--feed-radius-lg: 10px;
|
|
402
402
|
--feed-radius-md: 8px;
|
|
403
403
|
--feed-radius-pill: 8px;
|
|
404
|
+
/* Verdict tones for the rating grid. Hue is never the only signal: the cell also carries a CircleCheck or
|
|
405
|
+
CircleX and the word Match or Miss. Named here, in the feed layer, because the component must not hold
|
|
406
|
+
literal palette values and because Tailwind's dark: variant (.dark *) never fires in this app. */
|
|
407
|
+
--feed-verdict-match-tint: #05966933;
|
|
408
|
+
--feed-verdict-match-ink: #064e3b;
|
|
409
|
+
--feed-verdict-match-fill: #059669;
|
|
410
|
+
--feed-verdict-miss-tint: #dc262633;
|
|
411
|
+
--feed-verdict-miss-ink: #7f1d1d;
|
|
412
|
+
--feed-verdict-miss-fill: #dc2626;
|
|
413
|
+
/* The quiet step for an unpredicted cell and a switched-off button. */
|
|
414
|
+
--feed-quiet: 0.6;
|
|
404
415
|
}
|
|
405
416
|
[data-kanbus-theme][data-color-mode="dark"] [data-feed-surface], [data-kanbus-theme][data-color-mode="dark"][data-feed-surface] {
|
|
406
417
|
--feed-card: color-mix(in oklab, var(--kb-theme-paper) 94%, var(--kb-theme-ink));
|
|
407
418
|
--feed-footer: color-mix(in oklab, var(--kb-theme-paper) 90%, var(--kb-theme-ink));
|
|
408
419
|
--feed-control: color-mix(in oklab, var(--kb-theme-paper) 80%, var(--kb-theme-ink));
|
|
420
|
+
/* Dark verdicts lift toward the tint rather than down into it, so the ink reads on a dark card. */
|
|
421
|
+
--feed-verdict-match-tint: #34d39933;
|
|
422
|
+
--feed-verdict-match-ink: #d1fae5;
|
|
423
|
+
--feed-verdict-match-fill: #34d399;
|
|
424
|
+
--feed-verdict-miss-tint: #f8717133;
|
|
425
|
+
--feed-verdict-miss-ink: #fee2e2;
|
|
426
|
+
--feed-verdict-miss-fill: #f87171;
|
|
409
427
|
}
|
|
410
428
|
|
|
411
429
|
/* Borderless detail pane, Add panel and mobile sheet ([data-feed-flat]): no border, rule or box anywhere inside. Regions are told apart
|
|
@@ -426,6 +444,12 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
426
444
|
}
|
|
427
445
|
[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
446
|
[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; }
|
|
447
|
+
/* The decision control keeps one corner across its segments and its reason field, and its predicted segment
|
|
448
|
+
keeps its tones apart: the flat rules above give every unpressed button the same control fill, which would
|
|
449
|
+
leave the predicted side and an untouched side looking identical. */
|
|
450
|
+
[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; }
|
|
451
|
+
[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; }
|
|
452
|
+
|
|
429
453
|
/* The rating grid keeps its own rounded rectangles in the borderless panes (not the pill shape the flat rules give other buttons). */
|
|
430
454
|
[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
455
|
|
|
@@ -9,7 +9,7 @@ import { cannotMoveReason, type DraftChoice } from "../lib/review-drafts";
|
|
|
9
9
|
import { answerWords, formatWhen, modeLabel, nextText, steeringText, subjectHref, subjectLabel, type ReviewRequest } from "../lib/review-requests";
|
|
10
10
|
import type { ReferencePreview } from "../lib/reference-preview";
|
|
11
11
|
import { FEED_SURFACE_PROPS, FeedImage, PILL, SourceMark } from "./reference-feed";
|
|
12
|
-
import {
|
|
12
|
+
import { DecisionControl } from "./decision-control";
|
|
13
13
|
|
|
14
14
|
/** What the editor typed with an answer. */
|
|
15
15
|
export type DecisionExtras = { why: string; notThisKind: boolean };
|
|
@@ -109,9 +109,9 @@ function DecisionFooter({ busy, decision, disabledReason, error, kind, lockedNot
|
|
|
109
109
|
{locked ? (
|
|
110
110
|
<>
|
|
111
111
|
<DecidedLine choice={decision.choice} notThisKind={decision.notThisKind} why={decision.why} />
|
|
112
|
-
<
|
|
112
|
+
<DecisionControl
|
|
113
113
|
answer={decision.choice} disabled disabledReason={null} feedback={{ phase: "saved" }} onChoose={() => undefined}
|
|
114
|
-
prediction={{ state: "none" }} showPrediction={false} surface="footer"
|
|
114
|
+
prediction={{ state: "none" }} showPrediction={false} showReason={false} surface="footer"
|
|
115
115
|
/>
|
|
116
116
|
<p className="text-xs text-muted-foreground" data-review-disabled-reason>{lockedNote}</p>
|
|
117
117
|
</>
|
|
@@ -132,7 +132,7 @@ function DecisionFooter({ busy, decision, disabledReason, error, kind, lockedNot
|
|
|
132
132
|
</button>
|
|
133
133
|
<span className="text-xs text-muted-foreground">Optional. Counts if you reject.</span>
|
|
134
134
|
</div>
|
|
135
|
-
<
|
|
135
|
+
<DecisionControl
|
|
136
136
|
answer={busy ? pending : null}
|
|
137
137
|
disabled={Boolean(disabledReason)}
|
|
138
138
|
disabledReason={disabledReason}
|
|
@@ -140,6 +140,7 @@ function DecisionFooter({ busy, decision, disabledReason, error, kind, lockedNot
|
|
|
140
140
|
onChoose={choose}
|
|
141
141
|
prediction={{ state: "none" }}
|
|
142
142
|
showPrediction={false}
|
|
143
|
+
showReason={false}
|
|
143
144
|
surface="footer"
|
|
144
145
|
/>
|
|
145
146
|
</>
|
|
@@ -20,7 +20,7 @@ import { referenceDisplayTitle } from "../lib/newsroom-references";
|
|
|
20
20
|
import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
21
21
|
import { cn } from "../lib/utils";
|
|
22
22
|
import { predictionFromDecision, type ReviewChoice } from "./reference-review-card";
|
|
23
|
-
import {
|
|
23
|
+
import { DecisionControl } from "./decision-control";
|
|
24
24
|
import { CaptureBadge } from "./reference-capture-panel";
|
|
25
25
|
import { AutoAcceptedBadge } from "./auto-accepted-badge";
|
|
26
26
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
@@ -312,31 +312,22 @@ export function FeedCard({
|
|
|
312
312
|
</button>
|
|
313
313
|
|
|
314
314
|
<div className="relative bg-[var(--feed-footer)] px-4 pb-4 pt-3" data-feed-actions>
|
|
315
|
-
<
|
|
315
|
+
<DecisionControl
|
|
316
316
|
answer={chosen}
|
|
317
317
|
decided={answer}
|
|
318
318
|
disabled={Boolean(disabledReason)}
|
|
319
319
|
disabledReason={disabledReason}
|
|
320
320
|
feedback={feedback}
|
|
321
|
+
needsReason={rating.askReason}
|
|
321
322
|
note={reasonLabel ? `Reason: ${reasonLabel}` : null}
|
|
322
323
|
onChoose={(choice) => { if (!disabledReason) store.choose(item.lineageId, choice); }}
|
|
324
|
+
onReasonChange={(text) => store.setNote(item.lineageId, text)}
|
|
325
|
+
onReasonCommit={() => { if (!disabledReason) store.commitNote(item.lineageId); }}
|
|
323
326
|
prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
|
|
327
|
+
reason={rating.note ?? ""}
|
|
328
|
+
reasonRequiredFor="reject"
|
|
324
329
|
statusOverlay
|
|
325
330
|
/>
|
|
326
|
-
{rating.askReason ? (
|
|
327
|
-
// Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
|
|
328
|
-
<div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
|
|
329
|
-
<p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
|
|
330
|
-
<div className="flex flex-wrap gap-1.5">
|
|
331
|
-
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
332
|
-
<button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => store.setReason(item.lineageId, code)} type="button">
|
|
333
|
-
{REASON_LABELS[code]}
|
|
334
|
-
</button>
|
|
335
|
-
))}
|
|
336
|
-
<button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => store.cancelReason(item.lineageId)} type="button">Cancel</button>
|
|
337
|
-
</div>
|
|
338
|
-
</div>
|
|
339
|
-
) : null}
|
|
340
331
|
</div>
|
|
341
332
|
</article>
|
|
342
333
|
);
|
|
@@ -2,20 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
ArchiveIcon,
|
|
5
|
-
CheckIcon,
|
|
6
5
|
ChevronLeftIcon,
|
|
7
6
|
ChevronRightIcon,
|
|
8
|
-
CircleCheckIcon,
|
|
9
|
-
CircleXIcon,
|
|
10
7
|
ExternalLinkIcon,
|
|
11
8
|
} from "lucide-react";
|
|
12
9
|
import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
|
|
13
10
|
import { Button } from "./ui/button";
|
|
14
|
-
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
15
11
|
import { cn } from "../lib/utils";
|
|
16
|
-
import {
|
|
12
|
+
import { DecisionControl } from "./decision-control";
|
|
17
13
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
18
|
-
import {
|
|
14
|
+
import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
19
15
|
|
|
20
16
|
export type ReviewChoice = "accept" | "reject";
|
|
21
17
|
|
|
@@ -31,96 +27,6 @@ export type ReviewCardDecision = {
|
|
|
31
27
|
reasonCode: ReferenceRejectionReasonCode | null;
|
|
32
28
|
};
|
|
33
29
|
|
|
34
|
-
const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
|
|
35
|
-
out_of_scope: "Out of scope",
|
|
36
|
-
policy_exclusion: "Policy exclusion",
|
|
37
|
-
duplicate: "Duplicate",
|
|
38
|
-
low_quality: "Low quality",
|
|
39
|
-
unavailable: "Unavailable",
|
|
40
|
-
provenance: "Provenance",
|
|
41
|
-
other: "Other",
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const CHOICE_LABEL: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
|
|
45
|
-
|
|
46
|
-
function percent(confidence: number | null): string | null {
|
|
47
|
-
return confidence === null ? null : `${Math.round(confidence * 100)}%`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The Cyclotron's read-only row: its Accept or Reject highlighted with its confidence, and a check or cross once a person has answered.
|
|
52
|
-
* Shared by the review card and by every list that shows a suggestion before a decision.
|
|
53
|
-
*/
|
|
54
|
-
export function CyclotronRow({ decided = null, prediction }: { decided?: ReviewChoice | null; prediction: CardPrediction }) {
|
|
55
|
-
const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
|
|
56
|
-
return (
|
|
57
|
-
<div className="space-y-2" data-review-row="cyclotron">
|
|
58
|
-
<div className="flex items-center gap-3">
|
|
59
|
-
<span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
|
|
60
|
-
{prediction.state === "ready" ? (
|
|
61
|
-
<div className="flex flex-1 gap-2">
|
|
62
|
-
{(["accept", "reject"] as const).map((choice) => {
|
|
63
|
-
const predicted = prediction.state === "ready" && prediction.choice === choice;
|
|
64
|
-
const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
|
|
65
|
-
return (
|
|
66
|
-
<span
|
|
67
|
-
className={cn(
|
|
68
|
-
"inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
|
|
69
|
-
predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
|
|
70
|
-
verdict === "match" && "border-green-700 bg-green-100 text-green-950",
|
|
71
|
-
verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
|
|
72
|
-
)}
|
|
73
|
-
data-cyclotron-choice={choice}
|
|
74
|
-
data-predicted={predicted || undefined}
|
|
75
|
-
data-verdict={verdict ?? undefined}
|
|
76
|
-
key={choice}
|
|
77
|
-
>
|
|
78
|
-
{verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
79
|
-
{verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
|
|
80
|
-
{CHOICE_LABEL[choice]}
|
|
81
|
-
{predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
|
|
82
|
-
{verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
|
|
83
|
-
</span>
|
|
84
|
-
);
|
|
85
|
-
})}
|
|
86
|
-
</div>
|
|
87
|
-
) : (
|
|
88
|
-
<p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
|
|
89
|
-
{prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
90
|
-
</p>
|
|
91
|
-
)}
|
|
92
|
-
</div>
|
|
93
|
-
</div>
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** The two answers, large enough to tap: Accept and Reject, the chosen one filled and checked (never colour alone). */
|
|
98
|
-
export function ChoiceButtons({ chosen, disabled = false, onChoose }: { chosen: ReviewChoice | null; disabled?: boolean; onChoose: (choice: ReviewChoice) => void }) {
|
|
99
|
-
return (
|
|
100
|
-
<div className="flex flex-1 gap-2">
|
|
101
|
-
{(["accept", "reject"] as const).map((choice) => {
|
|
102
|
-
const on = chosen === choice;
|
|
103
|
-
return (
|
|
104
|
-
<Button
|
|
105
|
-
aria-disabled={disabled ? true : undefined}
|
|
106
|
-
aria-pressed={on}
|
|
107
|
-
className="min-h-14 flex-1 text-base"
|
|
108
|
-
data-review-choice={choice}
|
|
109
|
-
key={choice}
|
|
110
|
-
onClick={() => onChoose(choice)}
|
|
111
|
-
type="button"
|
|
112
|
-
variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
|
|
113
|
-
>
|
|
114
|
-
{on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
|
|
115
|
-
{CHOICE_LABEL[choice]}
|
|
116
|
-
</Button>
|
|
117
|
-
);
|
|
118
|
-
})}
|
|
119
|
-
</div>
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** A reference's relevance decision in the card's own vocabulary. */
|
|
124
30
|
export function predictionFromDecision(decision: { label: string; positiveLabel: string | null; confidence: number | null }): CardPrediction {
|
|
125
31
|
return { state: "ready", choice: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", confidence: decision.confidence };
|
|
126
32
|
}
|
|
@@ -181,10 +87,8 @@ export function ReviewCard({
|
|
|
181
87
|
const store = ratings ?? local;
|
|
182
88
|
const item = store.get(itemId);
|
|
183
89
|
const note = item.note ?? initialNote;
|
|
184
|
-
const reason = item.reasonCode !== undefined ? item.reasonCode : initialReason;
|
|
185
90
|
const chosen = item.pending ?? decided;
|
|
186
91
|
const choose = (choice: ReviewChoice) => { if (!disabledReason) store.choose(itemId, choice); };
|
|
187
|
-
const chooseReason = (code: ReferenceRejectionReasonCode) => { if (!disabledReason) store.setReason(itemId, code); };
|
|
188
92
|
const commitNote = () => { if (!disabledReason) store.commitNote(itemId); };
|
|
189
93
|
|
|
190
94
|
// Desktop shortcuts: A accepts, R rejects. Never while typing.
|
|
@@ -222,53 +126,23 @@ export function ReviewCard({
|
|
|
222
126
|
) : null}
|
|
223
127
|
</header>
|
|
224
128
|
|
|
225
|
-
<
|
|
129
|
+
<DecisionControl
|
|
226
130
|
answer={chosen}
|
|
227
131
|
decided={decided}
|
|
228
132
|
disabled={Boolean(disabledReason)}
|
|
229
133
|
disabledReason={disabledReason}
|
|
230
134
|
feedback={item.feedback}
|
|
135
|
+
needsReason={item.askReason}
|
|
231
136
|
onChoose={choose}
|
|
137
|
+
onReasonChange={(text) => store.setNote(itemId, text)}
|
|
138
|
+
onReasonCommit={commitNote}
|
|
232
139
|
prediction={prediction}
|
|
140
|
+
reason={note}
|
|
141
|
+
reasonRequiredFor="reject"
|
|
233
142
|
size="large"
|
|
234
143
|
surface="card"
|
|
235
144
|
/>
|
|
236
145
|
|
|
237
|
-
<label className="block text-sm font-medium">
|
|
238
|
-
Why? (optional)
|
|
239
|
-
<AutoGrowTextarea
|
|
240
|
-
aria-label="Why? (optional)"
|
|
241
|
-
className="mt-1 block min-h-20 w-full rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-control)] p-3 text-base font-normal outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
242
|
-
onBlur={commitNote}
|
|
243
|
-
onChange={(event) => store.setNote(itemId, event.target.value)}
|
|
244
|
-
placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
|
|
245
|
-
value={note}
|
|
246
|
-
/>
|
|
247
|
-
</label>
|
|
248
|
-
|
|
249
|
-
{chosen === "reject" ? (
|
|
250
|
-
<div className="space-y-2" data-review-reasons>
|
|
251
|
-
<p className="text-sm font-medium">Reason for rejecting</p>
|
|
252
|
-
<div className="flex flex-wrap gap-2">
|
|
253
|
-
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
254
|
-
<button
|
|
255
|
-
aria-disabled={disabledReason ? true : undefined}
|
|
256
|
-
aria-pressed={reason === code}
|
|
257
|
-
className={cn("inline-flex min-h-10 items-center gap-1.5 rounded-[var(--feed-radius-pill,8px)] px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", reason === code ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]")}
|
|
258
|
-
data-review-reason={code}
|
|
259
|
-
key={code}
|
|
260
|
-
onClick={() => chooseReason(code)}
|
|
261
|
-
type="button"
|
|
262
|
-
>
|
|
263
|
-
{reason === code ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
264
|
-
{REASON_LABELS[code]}
|
|
265
|
-
</button>
|
|
266
|
-
))}
|
|
267
|
-
</div>
|
|
268
|
-
{!reason && !note.trim() ? <p className="text-sm text-muted-foreground">Choose a reason, or say why above, to save your Reject.</p> : null}
|
|
269
|
-
</div>
|
|
270
|
-
) : null}
|
|
271
|
-
|
|
272
146
|
{children}
|
|
273
147
|
|
|
274
148
|
{onArchive ? (
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
5
5
|
import type { ReviewChoice } from "./reference-review-card";
|
|
6
|
-
import type { RatingFeedback } from "./
|
|
6
|
+
import type { RatingFeedback } from "./decision-control";
|
|
7
7
|
|
|
8
8
|
/** How long after the last keystroke a typed reason is saved on its own. */
|
|
9
9
|
export const NOTE_AUTOSAVE_MS = 1200;
|
package/package.json
CHANGED
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"./components/conversation-provider": "./components/conversation-provider.tsx",
|
|
85
85
|
"./components/conversation-thread": "./components/conversation-thread.tsx",
|
|
86
86
|
"./components/curation-delegation-client": "./components/curation-delegation-client.ts",
|
|
87
|
+
"./components/decision-control": "./components/decision-control.tsx",
|
|
87
88
|
"./components/dev-sandbox-editor-sign-in": "./components/dev-sandbox-editor-sign-in.tsx",
|
|
88
89
|
"./components/editorial-checks-admin-panel": "./components/editorial-checks-admin-panel.tsx",
|
|
89
90
|
"./components/editorial-finding-card": "./components/editorial-finding-card.tsx",
|
|
@@ -164,7 +165,6 @@
|
|
|
164
165
|
"./components/reference-stories": "./components/reference-stories.tsx",
|
|
165
166
|
"./components/references-page-loading": "./components/references-page-loading.tsx",
|
|
166
167
|
"./components/references-prefetch": "./components/references-prefetch.ts",
|
|
167
|
-
"./components/review-rating-grid": "./components/review-rating-grid.tsx",
|
|
168
168
|
"./components/settings-page": "./components/settings-page.tsx",
|
|
169
169
|
"./components/signup-entries-panel": "./components/signup-entries-panel.tsx",
|
|
170
170
|
"./components/staging-banner": "./components/staging-banner.tsx",
|
|
@@ -510,7 +510,7 @@
|
|
|
510
510
|
"optional": true
|
|
511
511
|
}
|
|
512
512
|
},
|
|
513
|
-
"version": "5.43.0-next.
|
|
513
|
+
"version": "5.43.0-next.39",
|
|
514
514
|
"dependencies": {
|
|
515
515
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
516
516
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED
|
@@ -1,190 +0,0 @@
|
|
|
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
|
-
* The grid sizes itself to its own width (a container query), not the window's: on a narrow card the label column shrinks to its
|
|
17
|
-
* word, the padding tightens, and the confidence wraps under "Accept" instead of spilling out of its cell.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
export type RatingFeedback = { phase: "idle" | "saving" | "saved" | "error"; message?: string };
|
|
21
|
-
|
|
22
|
-
/** P(accept) from a prediction: its confidence is in the side it chose; an unrecorded confidence is a mild lean. */
|
|
23
|
-
export function predictionAcceptProbability(prediction: Extract<CardPrediction, { state: "ready" }>): number {
|
|
24
|
-
const confidence = prediction.confidence ?? UNRECORDED_CONFIDENCE;
|
|
25
|
-
return prediction.choice === "accept" ? confidence : 1 - confidence;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** How far, in percent of the whole bar, the fill reaches from the centre: |P(accept) - 0.5| * 2, over the two halves. */
|
|
29
|
-
export function confidenceFillPercent(probability: number): number {
|
|
30
|
-
return Math.min(100, Math.abs(probability - 0.5) * 2 * 100);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const WORD: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
|
|
34
|
-
|
|
35
|
-
const TONE = {
|
|
36
|
-
match: "bg-emerald-600/20 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-100",
|
|
37
|
-
miss: "bg-red-600/20 text-red-900 dark:bg-red-400/20 dark:text-red-100",
|
|
38
|
-
} as const;
|
|
39
|
-
const FILL = { match: "bg-emerald-600 dark:bg-emerald-400", miss: "bg-red-600 dark:bg-red-400", neutral: "bg-[var(--primary)]" } as const;
|
|
40
|
-
|
|
41
|
-
export function RatingGrid({
|
|
42
|
-
answer,
|
|
43
|
-
decided = answer,
|
|
44
|
-
disabled = false,
|
|
45
|
-
disabledReason = null,
|
|
46
|
-
feedback = { phase: "idle" },
|
|
47
|
-
note = null,
|
|
48
|
-
onChoose,
|
|
49
|
-
prediction,
|
|
50
|
-
showPrediction = true,
|
|
51
|
-
size = "compact",
|
|
52
|
-
statusOverlay = false,
|
|
53
|
-
surface = "footer",
|
|
54
|
-
}: {
|
|
55
|
-
/** The side the buttons show as chosen (it may be a pending choice that is still saving). */
|
|
56
|
-
answer: ReviewChoice | null;
|
|
57
|
-
/** The saved human answer, which the Match / Miss verdict compares against. */
|
|
58
|
-
decided?: ReviewChoice | null;
|
|
59
|
-
disabled?: boolean;
|
|
60
|
-
disabledReason?: string | null;
|
|
61
|
-
feedback?: RatingFeedback;
|
|
62
|
-
/** A short line shown in the status row beside the Saved tick (for example the reason). It never adds a row. */
|
|
63
|
-
note?: string | null;
|
|
64
|
-
onChoose: (choice: ReviewChoice) => void;
|
|
65
|
-
prediction: CardPrediction;
|
|
66
|
-
/** False for a card with nothing to predict (an AI draft): the Cyclotron row and the "You" label are left out and only the two buttons remain. */
|
|
67
|
-
showPrediction?: boolean;
|
|
68
|
-
size?: "compact" | "large";
|
|
69
|
-
/** Lay the Saved / note line over the bottom padding of the nearest positioned ancestor, so it adds no height under the buttons. */
|
|
70
|
-
statusOverlay?: boolean;
|
|
71
|
-
/** The tone the grid sits on, so the bar's centre notch matches it. */
|
|
72
|
-
surface?: "footer" | "card";
|
|
73
|
-
}) {
|
|
74
|
-
const ready = prediction.state === "ready" ? prediction : null;
|
|
75
|
-
const verdict = ready && decided ? (ready.choice === decided ? "match" : "miss") : null;
|
|
76
|
-
const probability = ready ? predictionAcceptProbability(ready) : 0.5;
|
|
77
|
-
const half = confidenceFillPercent(probability) / 2; // percent of the full bar width, on one side of the centre
|
|
78
|
-
const leansAccept = probability >= 0.5;
|
|
79
|
-
const confidenceText = ready && ready.confidence !== null ? `${Math.round(ready.confidence * 100)}%` : null;
|
|
80
|
-
const cell = size === "large" ? "min-h-14 text-base" : "min-h-12 text-sm";
|
|
81
|
-
const label = size === "large" ? "text-sm" : "text-xs";
|
|
82
|
-
|
|
83
|
-
return (
|
|
84
|
-
<div
|
|
85
|
-
className={cn(
|
|
86
|
-
"@container/rating grid gap-x-2 gap-y-2",
|
|
87
|
-
showPrediction ? "grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)] @max-[17rem]/rating:gap-x-1.5" : "grid-cols-2",
|
|
88
|
-
)}
|
|
89
|
-
data-rating-grid
|
|
90
|
-
>
|
|
91
|
-
{showPrediction ? <div className="col-span-3 grid grid-cols-subgrid items-stretch gap-y-2" data-review-row="cyclotron">
|
|
92
|
-
<span className={cn("self-center font-semibold", label)}>Cyclotron</span>
|
|
93
|
-
{(["accept", "reject"] as const).map((choice) => {
|
|
94
|
-
const predicted = ready?.choice === choice;
|
|
95
|
-
const Icon = predicted ? (verdict === "match" ? CircleCheckIcon : verdict === "miss" ? CircleXIcon : choice === "accept" ? CheckIcon : XIcon) : null;
|
|
96
|
-
return (
|
|
97
|
-
<span
|
|
98
|
-
className={cn(
|
|
99
|
-
"flex min-w-0 flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center @max-[17rem]/rating:px-1",
|
|
100
|
-
cell,
|
|
101
|
-
!predicted && "bg-[var(--feed-control)]/45 text-muted-foreground",
|
|
102
|
-
predicted && !verdict && "bg-[var(--feed-control)] font-semibold text-[var(--primary)]",
|
|
103
|
-
predicted && verdict && cn("font-semibold", TONE[verdict]),
|
|
104
|
-
)}
|
|
105
|
-
data-cyclotron-choice={choice}
|
|
106
|
-
data-predicted={predicted || undefined}
|
|
107
|
-
data-verdict={predicted && verdict ? verdict : undefined}
|
|
108
|
-
key={choice}
|
|
109
|
-
>
|
|
110
|
-
{/* Wraps rather than overflows: when the cell is narrow, the confidence moves onto its own line. */}
|
|
111
|
-
<span className="flex max-w-full flex-wrap items-center justify-center gap-x-1.5 leading-tight">
|
|
112
|
-
<span className="inline-flex items-center gap-1">
|
|
113
|
-
{Icon ? <Icon aria-hidden="true" className="size-4 shrink-0" /> : null}
|
|
114
|
-
{WORD[choice]}
|
|
115
|
-
</span>
|
|
116
|
-
{/* The confidence and the verdict share one line, so a vote never makes the cell (or the card) taller. */}
|
|
117
|
-
{predicted && (confidenceText || verdict) ? (
|
|
118
|
-
<small className="inline-flex items-center gap-1 whitespace-nowrap text-xs">
|
|
119
|
-
{confidenceText ? <span className="font-normal opacity-80" data-cyclotron-confidence>{confidenceText}</span> : null}
|
|
120
|
-
{confidenceText && verdict ? <span aria-hidden="true" className="opacity-60">·</span> : null}
|
|
121
|
-
{verdict ? <span className="font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</span> : null}
|
|
122
|
-
</small>
|
|
123
|
-
) : null}
|
|
124
|
-
</span>
|
|
125
|
-
{predicted && verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
|
|
126
|
-
</span>
|
|
127
|
-
);
|
|
128
|
-
})}
|
|
129
|
-
<div className="col-span-2 col-start-2 flex flex-col justify-center" data-confidence>
|
|
130
|
-
<div
|
|
131
|
-
aria-label={ready ? `Cyclotron confidence: leans ${leansAccept ? "Accept" : "Reject"}, ${Math.round(confidenceFillPercent(probability))} percent of the way` : "No confidence"}
|
|
132
|
-
className="relative h-2.5 w-full overflow-hidden rounded-full bg-[var(--feed-control)]"
|
|
133
|
-
data-confidence-bar
|
|
134
|
-
data-confidence-direction={ready ? (leansAccept ? "accept" : "reject") : "none"}
|
|
135
|
-
role="img"
|
|
136
|
-
>
|
|
137
|
-
{ready ? (
|
|
138
|
-
<span
|
|
139
|
-
className={cn("absolute inset-y-0 rounded-full", FILL[verdict ?? "neutral"])}
|
|
140
|
-
data-confidence-fill
|
|
141
|
-
data-fill-percent={Math.round(half * 2)}
|
|
142
|
-
style={{ left: leansAccept ? `${50 - half}%` : "50%", width: `${half}%` }}
|
|
143
|
-
/>
|
|
144
|
-
) : null}
|
|
145
|
-
<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)]")} />
|
|
146
|
-
</div>
|
|
147
|
-
{/* Screen-reader status only: a visible line under the bar would push it off the middle between the Cyclotron and You rows. */}
|
|
148
|
-
<small className="sr-only" data-cyclotron-state={prediction.state === "ready" ? undefined : prediction.state}>
|
|
149
|
-
{prediction.state === "ready" ? "" : prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
150
|
-
</small>
|
|
151
|
-
</div>
|
|
152
|
-
</div> : null}
|
|
153
|
-
|
|
154
|
-
<div className={cn("grid grid-cols-subgrid items-stretch", showPrediction ? "col-span-3" : "col-span-2")} data-review-row="you">
|
|
155
|
-
{showPrediction ? <span className={cn("self-center font-semibold", label)}>You</span> : null}
|
|
156
|
-
{(["accept", "reject"] as const).map((choice) => {
|
|
157
|
-
const on = answer === choice;
|
|
158
|
-
const Icon = choice === "accept" ? CheckIcon : XIcon;
|
|
159
|
-
return (
|
|
160
|
-
<button
|
|
161
|
-
aria-disabled={disabled ? true : undefined}
|
|
162
|
-
aria-pressed={on}
|
|
163
|
-
className={cn(
|
|
164
|
-
"inline-flex min-w-0 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 @max-[17rem]/rating:gap-1 @max-[17rem]/rating:px-1",
|
|
165
|
-
cell,
|
|
166
|
-
on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70",
|
|
167
|
-
disabled && !on && "opacity-60",
|
|
168
|
-
)}
|
|
169
|
-
data-review-choice={choice}
|
|
170
|
-
key={choice}
|
|
171
|
-
onClick={() => onChoose(choice)}
|
|
172
|
-
type="button"
|
|
173
|
-
>
|
|
174
|
-
<Icon aria-hidden="true" className={cn("size-4", !on && "opacity-60")} />
|
|
175
|
-
{WORD[choice]}
|
|
176
|
-
</button>
|
|
177
|
-
);
|
|
178
|
-
})}
|
|
179
|
-
</div>
|
|
180
|
-
|
|
181
|
-
<div className={cn("flex flex-wrap items-center gap-x-3 text-xs text-muted-foreground", statusOverlay ? "absolute inset-x-4 bottom-0 h-4 overflow-hidden leading-4" : cn("min-h-5", showPrediction ? "col-span-3" : "col-span-2"))} data-feed-saved role="status">
|
|
182
|
-
{note ? <span data-feed-reason>{note}</span> : null}
|
|
183
|
-
{feedback.phase === "saving" ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</span> : null}
|
|
184
|
-
{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}
|
|
185
|
-
</div>
|
|
186
|
-
{disabledReason ? <p className={cn("text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-review-disabled-reason>{disabledReason}</p> : null}
|
|
187
|
-
{feedback.phase === "error" ? <p className={cn("text-xs text-destructive", showPrediction ? "col-span-3" : "col-span-2")} role="alert">{feedback.message}</p> : null}
|
|
188
|
-
</div>
|
|
189
|
-
);
|
|
190
|
-
}
|