@startsimpli/ui 0.4.53 → 0.4.55

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.53",
3
+ "version": "0.4.55",
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/api": "0.5.44",
45
- "@startsimpli/auth": "0.4.29"
44
+ "@startsimpli/auth": "0.4.29",
45
+ "@startsimpli/api": "0.5.44"
46
46
  },
47
47
  "peerDependenciesMeta": {
48
48
  "@startsimpli/api": {
@@ -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',
@@ -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';
@@ -451,7 +451,11 @@ export function UnifiedTable<TData>({
451
451
 
452
452
  {/* Filters */}
453
453
  {filtersConfig?.enabled && filtersConfig.config && (
454
- <TableFilters config={filtersConfig.config} filters={filters} />
454
+ <TableFilters
455
+ config={filtersConfig.config}
456
+ filters={filters}
457
+ defaultExpanded={filtersConfig?.defaultExpanded ?? false}
458
+ />
455
459
  )}
456
460
 
457
461
  {/* Bulk Actions Bar */}
@@ -121,6 +121,8 @@ export interface FiltersConfig {
121
121
  onChange: (filters: FilterState) => void
122
122
  presets?: FilterPreset[]
123
123
  showPresets?: boolean // Show preset chips in toolbar (default: true if presets provided)
124
+ // Render the filter panel expanded on first mount instead of collapsed behind the "Filters" toggle (default: false)
125
+ defaultExpanded?: boolean
124
126
  }
125
127
 
126
128
  export interface FilterConfig {