@startsimpli/ui 0.4.53 → 0.4.54
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/package.json +3 -3
- package/src/catalog/catalog.ts +18 -0
- package/src/components/index.ts +5 -0
- package/src/components/review/ReviewNotes.tsx +130 -0
- package/src/components/review/ReviewScorecard.tsx +226 -0
- package/src/components/review/__tests__/review.test.tsx +114 -0
- package/src/components/review/index.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.54",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
42
42
|
"react": "^18.0.0 || ^19.0.0",
|
|
43
43
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
44
|
-
"@startsimpli/
|
|
45
|
-
"@startsimpli/
|
|
44
|
+
"@startsimpli/auth": "0.4.29",
|
|
45
|
+
"@startsimpli/api": "0.5.44"
|
|
46
46
|
},
|
|
47
47
|
"peerDependenciesMeta": {
|
|
48
48
|
"@startsimpli/api": {
|
package/src/catalog/catalog.ts
CHANGED
|
@@ -190,6 +190,24 @@ export const UI_CATALOG: CatalogEntry[] = [
|
|
|
190
190
|
dataNeeds: 'A content type carrying a judge verdict + auto-checks (and the editable content fields to recheck).',
|
|
191
191
|
source: { export: 'ValidationChecklist', from: '@startsimpli/ui' },
|
|
192
192
|
},
|
|
193
|
+
{
|
|
194
|
+
id: 'review-scorecard',
|
|
195
|
+
category: 'component',
|
|
196
|
+
title: 'Review scorecard',
|
|
197
|
+
description:
|
|
198
|
+
"A controlled scorecard for a human reviewer's structured judgement of an AI-generated draft: an overall verdict segmented control (Approve / Revise / Reject, toned green/amber/red), one 1–5 score + note row per guardrail dimension (defaults to political-sensitivity, source-reliability, recency, tone-and-voice, accuracy — overridable), and an overall note. Bring-your-own-data + onChange (the app owns persistence); the captured verdict + scores drive a downstream AI revise loop. Pair it with the validation-checklist (machine review) and review-notes (section critique).",
|
|
199
|
+
dataNeeds: 'A content type carrying a reviewer verdict + per-dimension scores/notes.',
|
|
200
|
+
source: { export: 'ReviewScorecard', from: '@startsimpli/ui' },
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: 'review-notes',
|
|
204
|
+
category: 'component',
|
|
205
|
+
title: 'Review notes',
|
|
206
|
+
description:
|
|
207
|
+
"A lightweight critique thread for an AI-generated draft, used per content section or overall: a list of reviewer notes (author, relative time, resolve action, resolved styling) plus a composer. Purely presentational + bring-your-own-data (onAdd / onResolve callbacks; no fetching). Pair it with the review-scorecard to capture section-level feedback that feeds the AI revise loop.",
|
|
208
|
+
dataNeeds: 'A review-note / comment type (body + optional author, timestamp, section, resolved flag).',
|
|
209
|
+
source: { export: 'ReviewNotes', from: '@startsimpli/ui' },
|
|
210
|
+
},
|
|
193
211
|
{
|
|
194
212
|
id: 'chat',
|
|
195
213
|
category: 'component',
|
package/src/components/index.ts
CHANGED
|
@@ -173,3 +173,8 @@ export * from './record-detail'
|
|
|
173
173
|
// runContentChecks recompute util. Light (React + lucide only), so it ships on
|
|
174
174
|
// the main barrel rather than a subpath.
|
|
175
175
|
export * from './validation'
|
|
176
|
+
|
|
177
|
+
// Reviewer feedback (768w.16.10.4) — ReviewScorecard (verdict + per-guardrail
|
|
178
|
+
// scorecard) + ReviewNotes (critique thread). Controlled + presentational; light
|
|
179
|
+
// (React + lucide only), so they ride the main barrel like health / validation.
|
|
180
|
+
export * from './review'
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { MessageSquare, Check } from 'lucide-react';
|
|
3
|
+
import { cn } from '../../lib/utils';
|
|
4
|
+
import { formatRelativeTime } from '../../utils';
|
|
5
|
+
|
|
6
|
+
/** One critique note in a review thread (used per content section or overall). */
|
|
7
|
+
export interface ReviewNote {
|
|
8
|
+
id: string;
|
|
9
|
+
body: string;
|
|
10
|
+
author?: string;
|
|
11
|
+
/** When the note was recorded — rendered as a relative time if present. */
|
|
12
|
+
at?: string | Date;
|
|
13
|
+
resolved?: boolean;
|
|
14
|
+
/** Optional section/anchor the note is about. */
|
|
15
|
+
section?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ReviewNotesProps {
|
|
19
|
+
/** The notes to render. */
|
|
20
|
+
notes: ReviewNote[];
|
|
21
|
+
/** Provide to show a composer. Called with the new note body. */
|
|
22
|
+
onAdd?: (body: string) => void;
|
|
23
|
+
/** Provide to show a Resolve action on unresolved notes. */
|
|
24
|
+
onResolve?: (id: string) => void;
|
|
25
|
+
emptyMessage?: string;
|
|
26
|
+
title?: string;
|
|
27
|
+
/** Hide the composer + resolve actions (view-only). */
|
|
28
|
+
readOnly?: boolean;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A lightweight note/critique thread (768w.16.10.4) — the section-level or overall
|
|
34
|
+
* companion to ReviewScorecard. Purely presentational + bring-your-own-data: it
|
|
35
|
+
* lists notes (author, relative time, resolved styling), exposes a Resolve action
|
|
36
|
+
* and a composer when the callbacks are provided, and never fetches or persists.
|
|
37
|
+
*/
|
|
38
|
+
export function ReviewNotes({
|
|
39
|
+
notes,
|
|
40
|
+
onAdd,
|
|
41
|
+
onResolve,
|
|
42
|
+
emptyMessage = 'No notes yet.',
|
|
43
|
+
title = 'Notes',
|
|
44
|
+
readOnly,
|
|
45
|
+
className,
|
|
46
|
+
}: ReviewNotesProps) {
|
|
47
|
+
const [draft, setDraft] = React.useState('');
|
|
48
|
+
const canCompose = !readOnly && !!onAdd;
|
|
49
|
+
|
|
50
|
+
const submit = () => {
|
|
51
|
+
const body = draft.trim();
|
|
52
|
+
if (!body) return;
|
|
53
|
+
onAdd?.(body);
|
|
54
|
+
setDraft('');
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className={cn('rounded-xl border bg-card text-card-foreground shadow p-5 space-y-4', className)}>
|
|
59
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
60
|
+
<MessageSquare className="w-4 h-4 text-gray-400 flex-shrink-0" aria-hidden="true" />
|
|
61
|
+
<h3 className="text-sm font-semibold text-gray-900 truncate">{title}</h3>
|
|
62
|
+
{notes.length > 0 && <span className="text-xs text-gray-400">{notes.length}</span>}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{notes.length === 0 ? (
|
|
66
|
+
<p className="text-sm text-gray-500 py-2 text-center">{emptyMessage}</p>
|
|
67
|
+
) : (
|
|
68
|
+
<ul className="space-y-2">
|
|
69
|
+
{notes.map((note) => (
|
|
70
|
+
<li
|
|
71
|
+
key={note.id}
|
|
72
|
+
className={cn('rounded-lg border p-3 text-sm space-y-1', note.resolved && 'opacity-60')}
|
|
73
|
+
>
|
|
74
|
+
<div className="flex items-center justify-between gap-3">
|
|
75
|
+
<div className="flex items-center gap-2 min-w-0 text-xs text-gray-500 flex-wrap">
|
|
76
|
+
{note.author && <span className="font-medium text-gray-700">{note.author}</span>}
|
|
77
|
+
{note.section && (
|
|
78
|
+
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600">{note.section}</span>
|
|
79
|
+
)}
|
|
80
|
+
{note.at && <span>{formatRelativeTime(note.at)}</span>}
|
|
81
|
+
</div>
|
|
82
|
+
{note.resolved ? (
|
|
83
|
+
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-600 flex-shrink-0">
|
|
84
|
+
<Check className="w-3.5 h-3.5" aria-hidden="true" />
|
|
85
|
+
Resolved
|
|
86
|
+
</span>
|
|
87
|
+
) : (
|
|
88
|
+
onResolve &&
|
|
89
|
+
!readOnly && (
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={() => onResolve(note.id)}
|
|
93
|
+
className="text-xs font-medium text-gray-500 hover:text-gray-900 flex-shrink-0"
|
|
94
|
+
>
|
|
95
|
+
Resolve
|
|
96
|
+
</button>
|
|
97
|
+
)
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
<p className={cn('text-gray-900 break-words', note.resolved && 'line-through text-gray-500')}>
|
|
101
|
+
{note.body}
|
|
102
|
+
</p>
|
|
103
|
+
</li>
|
|
104
|
+
))}
|
|
105
|
+
</ul>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{canCompose && (
|
|
109
|
+
<div className="space-y-2 border-t pt-3">
|
|
110
|
+
<textarea
|
|
111
|
+
value={draft}
|
|
112
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
113
|
+
rows={2}
|
|
114
|
+
placeholder="Add a note…"
|
|
115
|
+
aria-label="Add a note"
|
|
116
|
+
className="w-full rounded-md border px-2 py-1 text-sm"
|
|
117
|
+
/>
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
disabled={draft.trim().length === 0}
|
|
121
|
+
onClick={submit}
|
|
122
|
+
className="rounded-md bg-gray-900 px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40"
|
|
123
|
+
>
|
|
124
|
+
Add note
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ClipboardCheck } from 'lucide-react';
|
|
3
|
+
import { cn } from '../../lib/utils';
|
|
4
|
+
|
|
5
|
+
/** One guardrail/dimension the reviewer scores (key drives storage, label is shown). */
|
|
6
|
+
export interface ReviewDimension {
|
|
7
|
+
key: string;
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A reviewer's structured judgement of an AI-generated draft: an overall verdict
|
|
13
|
+
* plus per-guardrail scores/notes and a free-text overall note. Drives the
|
|
14
|
+
* downstream AI revise loop.
|
|
15
|
+
*/
|
|
16
|
+
export interface ReviewScore {
|
|
17
|
+
/** Overall verdict — the primary signal for the revise loop. */
|
|
18
|
+
verdict?: 'approve' | 'revise' | 'reject';
|
|
19
|
+
/** Per-dimension score (1–5) + optional note, keyed by dimension key. */
|
|
20
|
+
dimensions?: Record<string, { score?: number; note?: string }>;
|
|
21
|
+
/** Free-text overall note. */
|
|
22
|
+
overallNote?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The default guardrail dimensions, mirroring a content-guardrail judge. Callers
|
|
27
|
+
* can override with any dimension set — the scorecard renders whatever it's given,
|
|
28
|
+
* so it stays generic (this is one sensible instance, not the design driver).
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_REVIEW_DIMENSIONS: ReviewDimension[] = [
|
|
31
|
+
{ key: 'political-sensitivity', label: 'Political sensitivity' },
|
|
32
|
+
{ key: 'source-reliability', label: 'Source reliability' },
|
|
33
|
+
{ key: 'recency', label: 'Recency' },
|
|
34
|
+
{ key: 'tone-and-voice', label: 'Tone and voice' },
|
|
35
|
+
{ key: 'accuracy', label: 'Accuracy' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
type Verdict = NonNullable<ReviewScore['verdict']>;
|
|
39
|
+
|
|
40
|
+
/** Redundant (color + label) tone per verdict — never rely on color alone. */
|
|
41
|
+
const VERDICT_TONE: Record<Verdict, { label: string; bg: string; text: string }> = {
|
|
42
|
+
approve: { label: 'Approve', bg: 'bg-emerald-600', text: 'text-white' },
|
|
43
|
+
revise: { label: 'Revise', bg: 'bg-amber-500', text: 'text-white' },
|
|
44
|
+
reject: { label: 'Reject', bg: 'bg-red-600', text: 'text-white' },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const VERDICTS: Verdict[] = ['approve', 'revise', 'reject'];
|
|
48
|
+
const SCORE_VALUES = [1, 2, 3, 4, 5];
|
|
49
|
+
|
|
50
|
+
export interface ReviewScorecardProps {
|
|
51
|
+
/** Controlled value — the reviewer's current judgement. */
|
|
52
|
+
value: ReviewScore;
|
|
53
|
+
/** Called with the next value on every change (immutable). */
|
|
54
|
+
onChange: (next: ReviewScore) => void;
|
|
55
|
+
/** The dimensions to score. Defaults to DEFAULT_REVIEW_DIMENSIONS. */
|
|
56
|
+
dimensions?: ReviewDimension[];
|
|
57
|
+
/** Render values as read-only (no inputs). */
|
|
58
|
+
readOnly?: boolean;
|
|
59
|
+
title?: string;
|
|
60
|
+
className?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A 1–5 score picker (clickable dots), read-only when the scorecard is. */
|
|
64
|
+
function ScorePicker({
|
|
65
|
+
label,
|
|
66
|
+
value,
|
|
67
|
+
onChange,
|
|
68
|
+
readOnly,
|
|
69
|
+
}: {
|
|
70
|
+
label: string;
|
|
71
|
+
value?: number;
|
|
72
|
+
onChange: (score: number) => void;
|
|
73
|
+
readOnly?: boolean;
|
|
74
|
+
}) {
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex items-center gap-1" role="group" aria-label={`${label} score`}>
|
|
77
|
+
{SCORE_VALUES.map((n) => {
|
|
78
|
+
const filled = value != null && n <= value;
|
|
79
|
+
if (readOnly) {
|
|
80
|
+
return (
|
|
81
|
+
<span
|
|
82
|
+
key={n}
|
|
83
|
+
className={cn('inline-block w-3.5 h-3.5 rounded-full', filled ? 'bg-gray-900' : 'bg-gray-200')}
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return (
|
|
89
|
+
<button
|
|
90
|
+
key={n}
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={() => onChange(n)}
|
|
93
|
+
aria-label={`${label} score ${n}`}
|
|
94
|
+
aria-pressed={value === n}
|
|
95
|
+
className={cn(
|
|
96
|
+
'w-4 h-4 rounded-full transition-colors',
|
|
97
|
+
filled ? 'bg-gray-900' : 'bg-gray-200 hover:bg-gray-300',
|
|
98
|
+
)}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
})}
|
|
102
|
+
<span className="ml-1.5 text-xs text-gray-500 tabular-nums">{value != null ? `${value}/5` : '—'}</span>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A controlled scorecard for a reviewer's structured judgement of an AI-generated
|
|
109
|
+
* draft (768w.16.10.4). Presentational + bring-your-own-data: it renders a verdict
|
|
110
|
+
* segmented control, one scored row per guardrail dimension, and an overall note —
|
|
111
|
+
* emitting the next ReviewScore (immutably) on every change. It never fetches or
|
|
112
|
+
* persists; the app owns the data and the downstream revise loop.
|
|
113
|
+
*/
|
|
114
|
+
export function ReviewScorecard({
|
|
115
|
+
value,
|
|
116
|
+
onChange,
|
|
117
|
+
dimensions = DEFAULT_REVIEW_DIMENSIONS,
|
|
118
|
+
readOnly,
|
|
119
|
+
title = 'Review',
|
|
120
|
+
className,
|
|
121
|
+
}: ReviewScorecardProps) {
|
|
122
|
+
const setDimension = (key: string, patch: { score?: number; note?: string }) => {
|
|
123
|
+
const prev = value.dimensions?.[key] ?? {};
|
|
124
|
+
onChange({
|
|
125
|
+
...value,
|
|
126
|
+
dimensions: { ...value.dimensions, [key]: { ...prev, ...patch } },
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className={cn('rounded-xl border bg-card text-card-foreground shadow p-5 space-y-4', className)}>
|
|
132
|
+
{/* Header + verdict */}
|
|
133
|
+
<div className="flex items-center justify-between gap-3">
|
|
134
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
135
|
+
<ClipboardCheck className="w-4 h-4 text-gray-400 flex-shrink-0" aria-hidden="true" />
|
|
136
|
+
<h3 className="text-sm font-semibold text-gray-900 truncate">{title}</h3>
|
|
137
|
+
</div>
|
|
138
|
+
{readOnly ? (
|
|
139
|
+
value.verdict ? (
|
|
140
|
+
<span
|
|
141
|
+
className={cn(
|
|
142
|
+
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium flex-shrink-0',
|
|
143
|
+
VERDICT_TONE[value.verdict].bg,
|
|
144
|
+
VERDICT_TONE[value.verdict].text,
|
|
145
|
+
)}
|
|
146
|
+
>
|
|
147
|
+
{VERDICT_TONE[value.verdict].label}
|
|
148
|
+
</span>
|
|
149
|
+
) : (
|
|
150
|
+
<span className="text-xs text-gray-500 flex-shrink-0">No verdict</span>
|
|
151
|
+
)
|
|
152
|
+
) : (
|
|
153
|
+
<div className="inline-flex rounded-lg border bg-gray-50 p-0.5 flex-shrink-0" role="group" aria-label="Verdict">
|
|
154
|
+
{VERDICTS.map((v) => {
|
|
155
|
+
const tone = VERDICT_TONE[v];
|
|
156
|
+
const selected = value.verdict === v;
|
|
157
|
+
return (
|
|
158
|
+
<button
|
|
159
|
+
key={v}
|
|
160
|
+
type="button"
|
|
161
|
+
onClick={() => onChange({ ...value, verdict: v })}
|
|
162
|
+
aria-pressed={selected}
|
|
163
|
+
className={cn(
|
|
164
|
+
'px-3 py-1 text-xs font-medium rounded-md transition-colors',
|
|
165
|
+
selected ? cn(tone.bg, tone.text) : 'text-gray-600 hover:text-gray-900',
|
|
166
|
+
)}
|
|
167
|
+
>
|
|
168
|
+
{tone.label}
|
|
169
|
+
</button>
|
|
170
|
+
);
|
|
171
|
+
})}
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{/* Per-dimension rows */}
|
|
177
|
+
<div className="space-y-3">
|
|
178
|
+
{dimensions.map((dim) => {
|
|
179
|
+
const dimVal = value.dimensions?.[dim.key] ?? {};
|
|
180
|
+
return (
|
|
181
|
+
<div key={dim.key} className="space-y-1.5">
|
|
182
|
+
<div className="flex items-center justify-between gap-3">
|
|
183
|
+
<span className="text-sm text-gray-900">{dim.label}</span>
|
|
184
|
+
<ScorePicker
|
|
185
|
+
label={dim.label}
|
|
186
|
+
value={dimVal.score}
|
|
187
|
+
onChange={(score) => setDimension(dim.key, { score })}
|
|
188
|
+
readOnly={readOnly}
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
{readOnly ? (
|
|
192
|
+
dimVal.note ? <p className="text-xs text-gray-500 break-words">{dimVal.note}</p> : null
|
|
193
|
+
) : (
|
|
194
|
+
<input
|
|
195
|
+
type="text"
|
|
196
|
+
value={dimVal.note ?? ''}
|
|
197
|
+
onChange={(e) => setDimension(dim.key, { note: e.target.value })}
|
|
198
|
+
aria-label={`${dim.label} note`}
|
|
199
|
+
placeholder="Add a note…"
|
|
200
|
+
className="w-full rounded-md border px-2 py-1 text-xs"
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
})}
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{/* Overall note */}
|
|
209
|
+
<div className="border-t pt-3 space-y-1.5">
|
|
210
|
+
<span className="text-xs font-medium text-gray-600">Overall note</span>
|
|
211
|
+
{readOnly ? (
|
|
212
|
+
<p className="text-sm text-gray-700 break-words">{value.overallNote || <span className="text-gray-400">—</span>}</p>
|
|
213
|
+
) : (
|
|
214
|
+
<textarea
|
|
215
|
+
value={value.overallNote ?? ''}
|
|
216
|
+
onChange={(e) => onChange({ ...value, overallNote: e.target.value })}
|
|
217
|
+
aria-label="Overall note"
|
|
218
|
+
rows={2}
|
|
219
|
+
placeholder="Summarise your feedback…"
|
|
220
|
+
className="w-full rounded-md border px-2 py-1 text-sm"
|
|
221
|
+
/>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
|
+
import { ReviewScorecard, ReviewNotes, DEFAULT_REVIEW_DIMENSIONS } from '../index';
|
|
4
|
+
import type { ReviewScore, ReviewNote } from '../index';
|
|
5
|
+
|
|
6
|
+
describe('ReviewScorecard', () => {
|
|
7
|
+
it('exposes the default guardrail dimensions', () => {
|
|
8
|
+
expect(DEFAULT_REVIEW_DIMENSIONS.map((d) => d.key)).toEqual([
|
|
9
|
+
'political-sensitivity',
|
|
10
|
+
'source-reliability',
|
|
11
|
+
'recency',
|
|
12
|
+
'tone-and-voice',
|
|
13
|
+
'accuracy',
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('emits the next value when a verdict is chosen', () => {
|
|
18
|
+
const onChange = jest.fn();
|
|
19
|
+
render(<ReviewScorecard value={{}} onChange={onChange} />);
|
|
20
|
+
fireEvent.click(screen.getByRole('button', { name: 'Revise' }));
|
|
21
|
+
expect(onChange).toHaveBeenCalledWith({ verdict: 'revise' });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('emits the next value (immutably) when a dimension score is clicked', () => {
|
|
25
|
+
const onChange = jest.fn();
|
|
26
|
+
const value: ReviewScore = { verdict: 'approve' };
|
|
27
|
+
render(<ReviewScorecard value={value} onChange={onChange} />);
|
|
28
|
+
fireEvent.click(screen.getByLabelText('Accuracy score 4'));
|
|
29
|
+
expect(onChange).toHaveBeenCalledWith({ verdict: 'approve', dimensions: { accuracy: { score: 4 } } });
|
|
30
|
+
// original value not mutated
|
|
31
|
+
expect(value).toEqual({ verdict: 'approve' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('merges a dimension note without dropping an existing score', () => {
|
|
35
|
+
const onChange = jest.fn();
|
|
36
|
+
render(
|
|
37
|
+
<ReviewScorecard value={{ dimensions: { accuracy: { score: 5 } } }} onChange={onChange} />,
|
|
38
|
+
);
|
|
39
|
+
fireEvent.change(screen.getByLabelText('Accuracy note'), { target: { value: 'Well cited' } });
|
|
40
|
+
expect(onChange).toHaveBeenCalledWith({ dimensions: { accuracy: { score: 5, note: 'Well cited' } } });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('emits the overall note', () => {
|
|
44
|
+
const onChange = jest.fn();
|
|
45
|
+
render(<ReviewScorecard value={{}} onChange={onChange} />);
|
|
46
|
+
fireEvent.change(screen.getByLabelText('Overall note'), { target: { value: 'Looks solid.' } });
|
|
47
|
+
expect(onChange).toHaveBeenCalledWith({ overallNote: 'Looks solid.' });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('renders read-only values with no inputs', () => {
|
|
51
|
+
render(
|
|
52
|
+
<ReviewScorecard
|
|
53
|
+
readOnly
|
|
54
|
+
onChange={jest.fn()}
|
|
55
|
+
dimensions={[{ key: 'accuracy', label: 'Accuracy' }]}
|
|
56
|
+
value={{ verdict: 'approve', dimensions: { accuracy: { score: 5, note: 'Solid sourcing' } }, overallNote: 'Ship it' }}
|
|
57
|
+
/>,
|
|
58
|
+
);
|
|
59
|
+
expect(screen.getByText('Approve')).toBeInTheDocument();
|
|
60
|
+
expect(screen.getByText('Solid sourcing')).toBeInTheDocument();
|
|
61
|
+
expect(screen.getByText('Ship it')).toBeInTheDocument();
|
|
62
|
+
// no interactive inputs in read-only mode
|
|
63
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
64
|
+
expect(screen.queryByLabelText('Accuracy score 5')).not.toBeInTheDocument();
|
|
65
|
+
expect(screen.queryByRole('button', { name: 'Revise' })).not.toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('ReviewNotes', () => {
|
|
70
|
+
const notes: ReviewNote[] = [
|
|
71
|
+
{ id: 'n1', body: 'Tighten the intro.', author: 'Alex', at: new Date().toISOString() },
|
|
72
|
+
{ id: 'n2', body: 'Fixed the citation.', author: 'Sam', resolved: true },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
it('shows the empty message when there are no notes', () => {
|
|
76
|
+
render(<ReviewNotes notes={[]} emptyMessage="Nothing flagged." />);
|
|
77
|
+
expect(screen.getByText('Nothing flagged.')).toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('renders notes with author and resolved styling', () => {
|
|
81
|
+
render(<ReviewNotes notes={notes} />);
|
|
82
|
+
expect(screen.getByText('Tighten the intro.')).toBeInTheDocument();
|
|
83
|
+
expect(screen.getByText('Alex')).toBeInTheDocument();
|
|
84
|
+
expect(screen.getByText('Resolved')).toBeInTheDocument();
|
|
85
|
+
expect(screen.getByText('Today')).toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('adds a note via the composer', () => {
|
|
89
|
+
const onAdd = jest.fn();
|
|
90
|
+
render(<ReviewNotes notes={[]} onAdd={onAdd} />);
|
|
91
|
+
fireEvent.change(screen.getByLabelText('Add a note'), { target: { value: ' Needs a stronger hook ' } });
|
|
92
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add note' }));
|
|
93
|
+
expect(onAdd).toHaveBeenCalledWith('Needs a stronger hook');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('disables the add button until there is text', () => {
|
|
97
|
+
render(<ReviewNotes notes={[]} onAdd={jest.fn()} />);
|
|
98
|
+
expect(screen.getByRole('button', { name: 'Add note' })).toBeDisabled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('resolves an unresolved note', () => {
|
|
102
|
+
const onResolve = jest.fn();
|
|
103
|
+
render(<ReviewNotes notes={notes} onResolve={onResolve} />);
|
|
104
|
+
// only the unresolved note (n1) exposes a Resolve action
|
|
105
|
+
fireEvent.click(screen.getByRole('button', { name: 'Resolve' }));
|
|
106
|
+
expect(onResolve).toHaveBeenCalledWith('n1');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('hides the composer and resolve actions in read-only mode', () => {
|
|
110
|
+
render(<ReviewNotes notes={notes} onAdd={jest.fn()} onResolve={jest.fn()} readOnly />);
|
|
111
|
+
expect(screen.queryByRole('button', { name: 'Add note' })).not.toBeInTheDocument();
|
|
112
|
+
expect(screen.queryByRole('button', { name: 'Resolve' })).not.toBeInTheDocument();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reviewer feedback surfaces (768w.16.10.4) — presentational, bring-your-own-data
|
|
3
|
+
* controls for recording a human reviewer's structured judgement of an AI draft:
|
|
4
|
+
* an overall verdict + per-guardrail scorecard (ReviewScorecard) and section/overall
|
|
5
|
+
* critique threads (ReviewNotes). Controlled (value + onChange / callbacks); they
|
|
6
|
+
* never fetch or persist. The captured feedback drives a downstream AI revise loop.
|
|
7
|
+
* Light (React + lucide only), so they ride the main components barrel like the
|
|
8
|
+
* health / validation blocks — no dedicated subpath.
|
|
9
|
+
*/
|
|
10
|
+
export { ReviewScorecard, DEFAULT_REVIEW_DIMENSIONS } from './ReviewScorecard';
|
|
11
|
+
export type { ReviewScorecardProps, ReviewScore, ReviewDimension } from './ReviewScorecard';
|
|
12
|
+
|
|
13
|
+
export { ReviewNotes } from './ReviewNotes';
|
|
14
|
+
export type { ReviewNotesProps, ReviewNote } from './ReviewNotes';
|