@startsimpli/ui 0.4.61 → 0.4.62

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.61",
3
+ "version": "0.4.62",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -2,30 +2,29 @@
2
2
 
3
3
  /**
4
4
  * Review-first record drawer — the fast triage surface for a status-carrying
5
- * entity type (bd startsim-768w.18.14).
5
+ * entity type (bd startsim-768w.18.14 / .15).
6
6
  *
7
- * Opens READ-OPTIMISED, not as an edit form: the record's title / summary / sources
8
- * up top, then a sticky action bar that ACTS IN ONE CLICK and SAVES IMMEDIATELY —
9
- * cast a verdict (👍/👎/✎), Approve (→ ready) or Reject (→ rejected) which advances
10
- * to the next record without closing, and jot a quick note. ↑/↓ (or j/k) walk the
11
- * queue; a / x / e / g / b are bare-letter shortcuts (guarded while typing). Deep
12
- * field-editing stays available behind "Edit fields" (the app injects its own form
13
- * via renderEditFields), so nothing is lost — it's just no longer the default.
7
+ * Opens READ-OPTIMISED, not as an edit form: the record's title / summary /
8
+ * sources up top, then a sticky bar with ONE decision — Approve / Needs work /
9
+ * Reject. Each decision writes BOTH underlying fields coherently (status = the
10
+ * pipeline gate + team_verdict = the re-rank signal) in a single save, so there's
11
+ * no separate "verdict" row duplicating Approve/Reject. Approve/Reject advance to
12
+ * the next record; the current state shows as a badge that doubles as the rare
13
+ * manual override (mark written, un-reject). ↑/↓ (or j/k) walk the queue; a / w /
14
+ * x / e are bare-letter shortcuts (guarded while typing). Deep field-editing stays
15
+ * behind "Edit fields" (the app injects its form via renderEditFields).
14
16
  *
15
- * Generic + client-injected: the consumer passes a CollectionClient (its authed
16
- * tenant API), the ordered queue (for prev/next), and an optional ReviewConfig /
17
- * render-props. Defaults match the OGMC content schema but everything is a prop, so
18
- * nothing here is tenant-specific.
17
+ * Generic + client-injected: the consumer passes a CollectionClient, the ordered
18
+ * queue (prev/next), and optional ReviewConfig / render-props. Defaults match the
19
+ * OGMC content schema but everything is a prop — nothing here is tenant-specific.
19
20
  */
20
21
  import { useEffect, useMemo, useState, type ReactNode } from 'react';
21
22
  import {
22
23
  ChevronLeft,
23
24
  ChevronRight,
24
- ThumbsUp,
25
- ThumbsDown,
26
- PencilLine,
27
25
  Check,
28
26
  X,
27
+ PencilLine,
29
28
  ExternalLink,
30
29
  ShieldCheck,
31
30
  MessageSquarePlus,
@@ -33,6 +32,13 @@ import {
33
32
 
34
33
  import { Button } from '../ui/button';
35
34
  import { Textarea } from '../ui/textarea';
35
+ import {
36
+ Select,
37
+ SelectContent,
38
+ SelectItem,
39
+ SelectTrigger,
40
+ SelectValue,
41
+ } from '../ui/select';
36
42
  import { notify } from '../toast/use-notify';
37
43
  import { cn } from '../../lib/utils';
38
44
  import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
@@ -40,11 +46,12 @@ import { readData, toCamelKey } from './data';
40
46
  import { parseSources } from './drafts';
41
47
  import {
42
48
  resolveReviewConfig,
49
+ reviewDecisions,
43
50
  shouldIgnoreShortcut,
44
51
  fieldStr,
45
52
  reviewHostOf,
46
53
  type ReviewConfig,
47
- type VerdictOption,
54
+ type ReviewDecision,
48
55
  } from './review';
49
56
 
50
57
  export interface ReviewDrawerProps {
@@ -95,6 +102,7 @@ function ReviewDrawerInner({
95
102
  renderExtra,
96
103
  }: ReviewDrawerProps & { record: EntityRecord }) {
97
104
  const cfg = useMemo(() => resolveReviewConfig(type, config), [type, config]);
105
+ const decisions = useMemo(() => reviewDecisions(cfg), [cfg]);
98
106
  const [mode, setMode] = useState<'review' | 'edit'>('review');
99
107
  // Local optimistic copy so a click reflects instantly (the parent also refetches).
100
108
  const [data, setData] = useState<Record<string, unknown>>(() => ({ ...record.data }));
@@ -139,25 +147,37 @@ function ReviewDrawerInner({
139
147
  }
140
148
  }
141
149
 
142
- function setVerdict(value: string) {
143
- void patch({ [cfg.verdictAttr]: verdict === value ? '' : value }, `verdict:${value}`);
150
+ /** One decision → write status AND verdict together (the coherent pair). */
151
+ function applyDecision(d: ReviewDecision) {
152
+ const changes: Record<string, unknown> = {};
153
+ if (d.status) changes[cfg.statusName] = d.status;
154
+ if (d.verdict) changes[cfg.verdictAttr] = d.verdict;
155
+ if (d.promptNote) setNoteOpen(true);
156
+ void patch(changes, `decision:${d.key}`, d.advance);
144
157
  }
145
- function approve() {
146
- if (cfg.transitions.approve) void patch({ [cfg.statusName]: cfg.transitions.approve }, 'approve', true);
147
- }
148
- function reject() {
149
- if (cfg.transitions.reject) void patch({ [cfg.statusName]: cfg.transitions.reject }, 'reject', true);
158
+
159
+ /** Manual state override from the badge — status only, no verdict, no advance. */
160
+ function overrideStatus(value: string) {
161
+ if (value === status) return;
162
+ void patch({ [cfg.statusName]: value }, `status:${value}`, false);
150
163
  }
164
+
151
165
  async function saveNote() {
152
166
  await patch({ [cfg.noteAttr]: note.trim() }, 'note');
153
167
  setNoteOpen(false);
154
168
  }
155
169
 
156
- // Keyboard: walk the queue + one-key act. Guarded so it never fires while typing.
170
+ // Keyboard: walk the queue + one-key decide. Guarded so it never fires while typing.
157
171
  useEffect(() => {
158
172
  if (mode !== 'review') return;
159
173
  function onKey(e: KeyboardEvent) {
160
174
  if (shouldIgnoreShortcut(e)) return;
175
+ const d = decisions.find((x) => x.shortcut === e.key);
176
+ if (d) {
177
+ e.preventDefault();
178
+ applyDecision(d);
179
+ return;
180
+ }
161
181
  switch (e.key) {
162
182
  case 'j':
163
183
  case 'ArrowDown':
@@ -167,18 +187,16 @@ function ReviewDrawerInner({
167
187
  case 'ArrowUp':
168
188
  if (prev) { e.preventDefault(); onNavigate(prev); }
169
189
  break;
170
- case 'a': e.preventDefault(); approve(); break;
171
- case 'x': e.preventDefault(); reject(); break;
172
190
  case 'e': e.preventDefault(); setMode('edit'); break;
173
- case 'g': e.preventDefault(); setVerdict('good'); break;
174
- case 'b': e.preventDefault(); setVerdict('bad'); break;
175
191
  case 'Escape': e.preventDefault(); onClose(); break;
176
192
  }
177
193
  }
178
194
  window.addEventListener('keydown', onKey);
179
195
  return () => window.removeEventListener('keydown', onKey);
180
196
  // eslint-disable-next-line react-hooks/exhaustive-deps
181
- }, [mode, prev, next, verdict, status, data]);
197
+ }, [mode, prev, next, decisions, data]);
198
+
199
+ const legend = [...decisions.map((d) => `${d.shortcut} ${d.label.toLowerCase()}`), 'e edit', 'j/k move'].join(' · ');
182
200
 
183
201
  return (
184
202
  <div className="fixed inset-0 z-50 flex justify-end" role="dialog" aria-modal="true">
@@ -194,6 +212,21 @@ function ReviewDrawerInner({
194
212
  ) : null}
195
213
  </div>
196
214
  <div className="flex shrink-0 items-center gap-1">
215
+ {/* Current state — and the rare manual override (mark written, un-reject). */}
216
+ {cfg.statusChoices.length > 0 ? (
217
+ <Select value={status || undefined} onValueChange={overrideStatus}>
218
+ <SelectTrigger className="h-7 w-auto gap-1 border-border text-xs capitalize">
219
+ <SelectValue placeholder="State…" />
220
+ </SelectTrigger>
221
+ <SelectContent>
222
+ {cfg.statusChoices.map((s) => (
223
+ <SelectItem key={s} value={s} className="capitalize">
224
+ {s}
225
+ </SelectItem>
226
+ ))}
227
+ </SelectContent>
228
+ </Select>
229
+ ) : null}
197
230
  <button
198
231
  onClick={() => onNavigate(prev!)}
199
232
  disabled={!prev}
@@ -256,11 +289,6 @@ function ReviewDrawerInner({
256
289
  rank {rank}
257
290
  </span>
258
291
  ) : null}
259
- {status ? (
260
- <span className="rounded-full bg-muted px-2 py-0.5 capitalize text-muted-foreground">
261
- {status}
262
- </span>
263
- ) : null}
264
292
  {cfg.metaAttrs.map((a) => {
265
293
  const v = fieldStr(data, a);
266
294
  if (!v) return null;
@@ -280,30 +308,32 @@ function ReviewDrawerInner({
280
308
  </div>
281
309
 
282
310
  <footer className="space-y-3 border-t border-border px-4 py-3">
283
- {/* Verdict */}
284
- <div className="flex flex-wrap items-center gap-2">
285
- <span className="text-xs font-medium text-muted-foreground">Verdict</span>
286
- <div className="flex overflow-hidden rounded-md border border-border">
287
- {cfg.verdicts.map((v) => (
288
- <VerdictButton
289
- key={v.value}
290
- option={v}
291
- active={verdict === v.value}
292
- busy={busy === `verdict:${v.value}`}
293
- onClick={() => setVerdict(v.value)}
311
+ {decisions.length > 0 ? (
312
+ <div className="flex items-stretch gap-2">
313
+ {decisions.map((d) => (
314
+ <DecisionButton
315
+ key={d.key}
316
+ decision={d}
317
+ active={verdict !== '' && d.verdict === verdict}
318
+ busy={busy === `decision:${d.key}`}
319
+ onClick={() => applyDecision(d)}
294
320
  />
295
321
  ))}
296
322
  </div>
323
+ ) : null}
324
+
325
+ <div className="flex items-center gap-2">
297
326
  <button
298
327
  onClick={() => setNoteOpen((o) => !o)}
299
328
  className={cn(
300
- 'ml-auto inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-muted',
329
+ 'inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-muted',
301
330
  note && 'text-primary-700',
302
331
  )}
303
332
  >
304
333
  <MessageSquarePlus className="h-3.5 w-3.5" />
305
- {note ? 'Note ✓' : 'Note'}
334
+ {note ? 'Note ✓' : 'Add note'}
306
335
  </button>
336
+ <span className="ml-auto text-[11px] text-muted-foreground">{legend}</span>
307
337
  </div>
308
338
 
309
339
  {noteOpen ? (
@@ -312,8 +342,9 @@ function ReviewDrawerInner({
312
342
  value={note}
313
343
  onChange={(e) => setNote(e.target.value)}
314
344
  rows={2}
315
- placeholder="Quick note for the AI — e.g. “good topic, angle too broad; needs a fresher source.”"
345
+ placeholder="Why? — e.g. “good topic, angle too broad; needs a fresher source.”"
316
346
  className="text-sm"
347
+ autoFocus
317
348
  />
318
349
  <div className="flex gap-2">
319
350
  <Button size="sm" onClick={saveNote} disabled={busy === 'note'}>
@@ -334,36 +365,6 @@ function ReviewDrawerInner({
334
365
  ) : note ? (
335
366
  <p className="border-l-2 border-border pl-2 text-xs italic text-muted-foreground">{note}</p>
336
367
  ) : null}
337
-
338
- {/* Decide */}
339
- <div className="flex items-center gap-2">
340
- {cfg.transitions.approve ? (
341
- <Button
342
- size="sm"
343
- onClick={approve}
344
- disabled={busy === 'approve'}
345
- className="flex-1 bg-emerald-600 hover:bg-emerald-700"
346
- >
347
- <Check className="mr-1.5 h-4 w-4" />
348
- {busy === 'approve' ? 'Saving…' : `Approve → ${cfg.transitions.approve}`}
349
- </Button>
350
- ) : null}
351
- {cfg.transitions.reject ? (
352
- <Button
353
- size="sm"
354
- variant="outline"
355
- onClick={reject}
356
- disabled={busy === 'reject'}
357
- className="flex-1 border-rose-300 text-rose-700 hover:bg-rose-50"
358
- >
359
- <X className="mr-1.5 h-4 w-4" />
360
- {busy === 'reject' ? 'Saving…' : 'Reject'}
361
- </Button>
362
- ) : null}
363
- </div>
364
- <p className="text-center text-[11px] text-muted-foreground">
365
- j/k move · a approve · x reject · e edit · g/b verdict
366
- </p>
367
368
  </footer>
368
369
  </>
369
370
  )}
@@ -372,44 +373,58 @@ function ReviewDrawerInner({
372
373
  );
373
374
  }
374
375
 
375
- const TONE_ON: Record<VerdictOption['tone'], string> = {
376
- good: 'bg-emerald-600 text-white',
377
- bad: 'bg-rose-600 text-white',
378
- neutral: 'bg-amber-500 text-white',
379
- };
380
- const TONE_ICON: Record<VerdictOption['tone'], typeof ThumbsUp> = {
381
- good: ThumbsUp,
382
- bad: ThumbsDown,
376
+ const DECISION_ICON: Record<ReviewDecision['tone'], typeof Check> = {
377
+ good: Check,
378
+ bad: X,
383
379
  neutral: PencilLine,
384
380
  };
381
+ const DECISION_STYLE: Record<ReviewDecision['tone'], string> = {
382
+ good: 'bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600',
383
+ bad: 'border-rose-300 text-rose-700 hover:bg-rose-50',
384
+ neutral: 'border-amber-300 text-amber-700 hover:bg-amber-50',
385
+ };
385
386
 
386
- function VerdictButton({
387
- option,
387
+ function DecisionButton({
388
+ decision,
388
389
  active,
389
390
  busy,
390
391
  onClick,
391
392
  }: {
392
- option: VerdictOption;
393
+ decision: ReviewDecision;
393
394
  active: boolean;
394
395
  busy: boolean;
395
396
  onClick: () => void;
396
397
  }) {
397
- const Icon = TONE_ICON[option.tone];
398
+ const Icon = DECISION_ICON[decision.tone];
398
399
  return (
399
400
  <button
400
401
  type="button"
401
402
  onClick={onClick}
402
403
  disabled={busy}
404
+ title={`${decision.label} (${decision.shortcut})`}
403
405
  className={cn(
404
- 'flex items-center gap-1 px-2.5 py-1 text-xs font-medium transition disabled:opacity-60',
405
- active ? TONE_ON[option.tone] : 'bg-card text-muted-foreground hover:bg-muted',
406
+ 'flex flex-1 flex-col items-center justify-center gap-0.5 rounded-md border px-3 py-2 text-sm font-medium transition disabled:opacity-60',
407
+ DECISION_STYLE[decision.tone],
408
+ active && 'ring-2 ring-offset-1 ring-current',
406
409
  )}
407
410
  >
408
- <Icon className="h-3.5 w-3.5" /> {option.label}
411
+ <span className="flex items-center gap-1.5">
412
+ <Icon className="h-4 w-4" />
413
+ {busy ? 'Saving…' : decision.label}
414
+ </span>
415
+ <span className={cn('text-[10px] font-normal', decision.tone === 'good' ? 'text-white/80' : 'opacity-70')}>
416
+ {decisionHint(decision)}
417
+ </span>
409
418
  </button>
410
419
  );
411
420
  }
412
421
 
422
+ /** The tiny "→ ready · good" outcome hint under a decision. */
423
+ function decisionHint(d: ReviewDecision): string {
424
+ const parts = [d.status ? `→ ${d.status}` : null, d.verdict].filter(Boolean);
425
+ return parts.join(' · ');
426
+ }
427
+
413
428
  /** Source chips: host + optional tier badge, linked when a url is present. */
414
429
  function ReviewSources({
415
430
  data,
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  deriveReviewTransitions,
3
3
  resolveReviewConfig,
4
+ reviewDecisions,
4
5
  pickStatusAttr,
5
6
  shouldIgnoreShortcut,
6
7
  nextReviewIndex,
@@ -100,6 +101,44 @@ describe('resolveReviewConfig', () => {
100
101
  });
101
102
  });
102
103
 
104
+ describe('reviewDecisions', () => {
105
+ const topic = type([
106
+ enumAttr('status', ['suggested', 'ready', 'rejected', 'written']),
107
+ textAttr('team_verdict'),
108
+ ]);
109
+
110
+ it('maps the three decisions to status + verdict pairs on the OGMC schema', () => {
111
+ const d = reviewDecisions(resolveReviewConfig(topic));
112
+ expect(d.map((x) => x.key)).toEqual(['approve', 'needs_work', 'reject']);
113
+ expect(d.find((x) => x.key === 'approve')).toMatchObject({ status: 'ready', verdict: 'good', advance: true });
114
+ expect(d.find((x) => x.key === 'needs_work')).toMatchObject({ status: 'suggested', verdict: 'edit', advance: false, promptNote: true });
115
+ expect(d.find((x) => x.key === 'reject')).toMatchObject({ status: 'rejected', verdict: 'bad', advance: true });
116
+ });
117
+
118
+ it('drops decisions whose status target does not resolve', () => {
119
+ // two-state enum with no rejection word → no Reject; approve resolves to the successor
120
+ const twoState = type([enumAttr('status', ['open', 'closed']), textAttr('team_verdict')]);
121
+ const keys = reviewDecisions(resolveReviewConfig(twoState)).map((x) => x.key);
122
+ expect(keys).toContain('approve');
123
+ expect(keys).not.toContain('reject');
124
+ });
125
+
126
+ it('uses custom verdict vocab tones', () => {
127
+ const d = reviewDecisions(
128
+ resolveReviewConfig(topic, {
129
+ verdicts: [
130
+ { value: 'yes', label: 'Yes', tone: 'good' },
131
+ { value: 'no', label: 'No', tone: 'bad' },
132
+ ],
133
+ }),
134
+ );
135
+ expect(d.find((x) => x.key === 'approve')?.verdict).toBe('yes');
136
+ expect(d.find((x) => x.key === 'reject')?.verdict).toBe('no');
137
+ // no neutral verdict, but an entry stage exists → Needs work still offered (verdict null)
138
+ expect(d.find((x) => x.key === 'needs_work')).toMatchObject({ verdict: null });
139
+ });
140
+ });
141
+
103
142
  describe('shouldIgnoreShortcut', () => {
104
143
  it('ignores while typing in a form control', () => {
105
144
  expect(shouldIgnoreShortcut({ key: 'a', target: { tagName: 'INPUT' } })).toBe(true);
@@ -11,6 +11,8 @@ export { ReviewDrawer, type ReviewDrawerProps } from './ReviewDrawer';
11
11
  export {
12
12
  resolveReviewConfig,
13
13
  deriveReviewTransitions,
14
+ reviewDecisions,
15
+ type ReviewDecision,
14
16
  pickStatusAttr,
15
17
  shouldIgnoreShortcut,
16
18
  nextReviewIndex,
@@ -215,6 +215,88 @@ export function resolveReviewConfig(
215
215
  };
216
216
  }
217
217
 
218
+ // ---- the single review decision (Approve / Needs work / Reject) ----
219
+ //
220
+ // status and team_verdict are two fields with different consumers (status = the
221
+ // pipeline gate the writer keys off; team_verdict = the signal the re-rank agent
222
+ // turns INTO status). Their outcomes overlap, so the reviewer makes ONE decision
223
+ // and we write BOTH fields coherently — never two competing button rows.
224
+
225
+ export interface ReviewDecision {
226
+ /** Stable id ('approve' | 'needs_work' | 'reject'). */
227
+ key: string;
228
+ label: string;
229
+ /** Status to write (null → leave status untouched). */
230
+ status: string | null;
231
+ /** Verdict to write (null → leave verdict untouched). */
232
+ verdict: string | null;
233
+ tone: VerdictTone;
234
+ /** Auto-advance to the next record after this decision. */
235
+ advance: boolean;
236
+ /** Bare-letter shortcut. */
237
+ shortcut: string;
238
+ /** Open the note box on this decision (a rejection/hold wants a reason). */
239
+ promptNote: boolean;
240
+ }
241
+
242
+ /**
243
+ * The (up to) three review decisions derived from the resolved config:
244
+ * - Approve → the approve status + the "good" verdict, auto-advances
245
+ * - Needs work → back to the entry stage + the "edit"/neutral verdict, holds
246
+ * (so it's revisited) and prompts a note
247
+ * - Reject → the reject status + the "bad" verdict, auto-advances
248
+ * A decision only appears when its status target resolves (or, for Needs work,
249
+ * when there's an entry stage or an edit verdict) — so a two-state or non-workflow
250
+ * enum simply shows fewer buttons instead of guessing.
251
+ */
252
+ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
253
+ const verdictOf = (tone: VerdictTone): string | null =>
254
+ r.verdicts.find((v) => v.tone === tone)?.value ?? null;
255
+ const entry = r.statusChoices[0] ?? null;
256
+ const out: ReviewDecision[] = [];
257
+
258
+ if (r.transitions.approve) {
259
+ out.push({
260
+ key: 'approve',
261
+ label: 'Approve',
262
+ status: r.transitions.approve,
263
+ verdict: verdictOf('good'),
264
+ tone: 'good',
265
+ advance: true,
266
+ shortcut: 'a',
267
+ promptNote: false,
268
+ });
269
+ }
270
+
271
+ const editVerdict = verdictOf('neutral');
272
+ if (entry && (editVerdict || entry !== r.transitions.approve)) {
273
+ out.push({
274
+ key: 'needs_work',
275
+ label: 'Needs work',
276
+ status: entry,
277
+ verdict: editVerdict,
278
+ tone: 'neutral',
279
+ advance: false,
280
+ shortcut: 'w',
281
+ promptNote: true,
282
+ });
283
+ }
284
+
285
+ if (r.transitions.reject) {
286
+ out.push({
287
+ key: 'reject',
288
+ label: 'Reject',
289
+ status: r.transitions.reject,
290
+ verdict: verdictOf('bad'),
291
+ tone: 'bad',
292
+ advance: true,
293
+ shortcut: 'x',
294
+ promptNote: false,
295
+ });
296
+ }
297
+ return out;
298
+ }
299
+
218
300
  // ---- small read helpers used by the component (kept here so they're tested) ----
219
301
 
220
302
  /** String read of an attr off a record's data blob ('' when missing). */