@startsimpli/ui 0.4.134 → 0.4.136

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.134",
3
+ "version": "0.4.136",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -20,6 +20,7 @@
20
20
  "./blog": "./src/components/blog/index.ts",
21
21
  "./blog/resolve": "./src/components/blog/resolveArticle.ts",
22
22
  "./activity": "./src/components/activity/index.ts",
23
+ "./history": "./src/components/history/index.ts",
23
24
  "./collection": "./src/components/collection/index.ts",
24
25
  "./scope": "./src/components/scope/index.ts",
25
26
  "./facet-tree": "./src/components/facet-tree/index.ts",
@@ -47,8 +48,8 @@
47
48
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
48
49
  "react": "^18.0.0 || ^19.0.0",
49
50
  "react-dom": "^18.0.0 || ^19.0.0",
50
- "@startsimpli/auth": "^0.4.38",
51
51
  "@startsimpli/api": "^0.5.66",
52
+ "@startsimpli/auth": "^0.4.38",
52
53
  "@startsimpli/i18n": "^0.1.3"
53
54
  },
54
55
  "peerDependenciesMeta": {
@@ -123,8 +124,8 @@
123
124
  "tailwindcss": "^3.4.19",
124
125
  "tsup": "^8.5.1",
125
126
  "typescript": "^6.0.3",
126
- "@startsimpli/i18n": "0.1.3",
127
- "@startsimpli/realtime": "0.0.2"
127
+ "@startsimpli/realtime": "0.0.2",
128
+ "@startsimpli/i18n": "0.1.3"
128
129
  },
129
130
  "keywords": [
130
131
  "ui",
@@ -17,7 +17,7 @@ import { formatDistanceToNow } from 'date-fns';
17
17
  import { Clock, User } from 'lucide-react';
18
18
  import { EmptyState } from './states';
19
19
  import { ActorIdentity, actorInitials, actorLabel } from './ActorIdentity';
20
- import type { ActorIdentityValue } from './ActorIdentity';
20
+ import type { ActorIdentityValue, ActorKind, ActorKindSource } from './ActorIdentity';
21
21
 
22
22
  /** Severity of an entry, for the marker. Presentation only — never a gate. */
23
23
  export type ActivityTone = 'ok' | 'warn' | 'error' | 'muted';
@@ -37,6 +37,12 @@ export interface ActivityTimelineItem {
37
37
  // --- event-stream shape -------------------------------------------------
38
38
  /** Identity of the person behind the event (preferred over `createdBy`). */
39
39
  actor?: ActivityTimelineActor;
40
+ /** Whether a person or a MACHINE did it, where the stream records it
41
+ * (bd startsim-g4rwo). Forwarded straight to `ActorIdentity`, so a stream
42
+ * that does not record it renders exactly as before — and so a stream that
43
+ * does cannot show a robot's write as a colleague's. */
44
+ actorKind?: ActorKind;
45
+ actorKindSource?: ActorKindSource;
40
46
  /** Colours the marker; `activityColors[type]` still wins when supplied. */
41
47
  tone?: ActivityTone;
42
48
  /** Muted inline qualifier after the title, e.g. "from provisioning". */
@@ -192,9 +198,16 @@ function TimelineEntry({
192
198
  <p className="mt-1 text-sm text-muted-foreground">{activity.description}</p>
193
199
  )}
194
200
  {activity.detail && (
195
- <p className={`mt-1 break-words text-xs ${TONE_TEXT[activity.detailTone ?? 'muted']}`}>
201
+ // A DIV, NOT A P. `detail` is typed ReactNode and the record
202
+ // history panel passes a field LIST into it — and a <ul> or a
203
+ // <div> inside a <p> is invalid HTML, which React resolves by
204
+ // reparenting during hydration. That produces a hydration
205
+ // mismatch on every row, and neither tsc nor jsdom reports it:
206
+ // only a real browser does (bd startsim-g4rwo). The classes are
207
+ // unchanged, so every existing caller renders identically.
208
+ <div className={`mt-1 break-words text-xs ${TONE_TEXT[activity.detailTone ?? 'muted']}`}>
196
209
  {activity.detail}
197
- </p>
210
+ </div>
198
211
  )}
199
212
  </div>
200
213
  <div className="ml-4 whitespace-nowrap text-xs text-muted-foreground">
@@ -203,7 +216,12 @@ function TimelineEntry({
203
216
  </div>
204
217
 
205
218
  <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
206
- <ActorIdentity actor={activity.actor} showInitials={!markerIsAvatar} />
219
+ <ActorIdentity
220
+ actor={activity.actor}
221
+ showInitials={!markerIsAvatar}
222
+ kind={activity.actorKind}
223
+ kindSource={activity.actorKindSource}
224
+ />
207
225
 
208
226
  {!who && activity.createdBy && (
209
227
  <span className="flex items-center gap-1">
@@ -16,7 +16,33 @@
16
16
  * the address echoed back. Every surface that attributes something to a person
17
17
  * renders this component rather than its own arrangement of the same two
18
18
  * fields (rule #9).
19
+ *
20
+ * AND SINCE bd startsim-g4rwo, ONE WAY TO SAY "A MACHINE DID THIS" — the same
21
+ * component, because the question a reader asks is the same question and the
22
+ * answer has to be comparable. Pass `kind`/`kindSource`; see {@link kindMarker}
23
+ * for which states are decorated and the inversion that decides it. Both props
24
+ * are optional and omitting them renders exactly what this component rendered
25
+ * before they existed, which is what lets the surfaces already using it stay
26
+ * untouched.
27
+ */
28
+ import { Bot } from 'lucide-react';
29
+
30
+ /**
31
+ * WHAT the actor is — and the third value is NOT a defect (bd startsim-g4rwo,
32
+ * claim bd startsim-jkkn7.2).
33
+ *
34
+ * `unknown` means CENTRAL DID NOT SAY. The claim is minted at LOGIN and
35
+ * inherited across refreshes, so for up to the 14-day refresh expiry after
36
+ * rollout a GENUINE HUMAN EDIT carries no kind at all. Note precisely what is
37
+ * missing: the KIND, never the actor — the name and email are still there — so
38
+ * an unknown row can still say who, and only has to stay quiet about what.
19
39
  */
40
+ export type ActorKind = 'person' | 'machine' | 'unknown';
41
+
42
+ /** Whether {@link ActorKind} came from a verified claim or from an inference
43
+ * ladder over naming conventions. `inferred` is a GUESS, and a surface that
44
+ * says so is what makes a later cleanup possible instead of a second guess. */
45
+ export type ActorKindSource = 'claim' | 'inferred';
20
46
 
21
47
  export interface ActorIdentityValue {
22
48
  id?: string;
@@ -54,33 +80,147 @@ export function actorHandle(actor?: ActorIdentityValue): string {
54
80
  return email && email !== actorLabel(actor) ? email : '';
55
81
  }
56
82
 
83
+ /**
84
+ * Whether this kind earns a visible marker, and what it should say.
85
+ *
86
+ * ── THE RULE, AND IT IS AN INVERSION OF THE OBVIOUS DESIGN ──────────────────
87
+ *
88
+ * The obvious design is three badges, one per state. It FAILS the requirement
89
+ * even though it renders all three states, because during the rollout window
90
+ * `unknown` is the COMMON case for real human edits — so a badge on every
91
+ * unknown row makes the whole panel look broken for two weeks on every tenant,
92
+ * and a reader who has been trained that the badges mean trouble stops reading
93
+ * the one badge that matters.
94
+ *
95
+ * So ABSENCE OF A MARKER IS THE CORRECT RENDERING FOR THE CALM CASES, and only
96
+ * the cases that genuinely carry information are decorated:
97
+ *
98
+ * - `machine` → ALWAYS marked, with an icon, and visually distinct
99
+ * from any person. This is the single most valuable
100
+ * thing in the epic: the repeated live failure was a
101
+ * machine overwriting a human, and 41 machine-written
102
+ * notes that could not be cleaned up afterwards
103
+ * "precisely because the data cannot say retrospectively
104
+ * which were human".
105
+ * - `person`/`claim` → NOTHING. A colleague edited the record; that is the
106
+ * unremarkable default and decorating it adds noise to
107
+ * the good case.
108
+ * - `person`/`inferred`→ marked, lightly, because the tenant GUESSED and the
109
+ * guess should be visible to whoever might act on it.
110
+ * - `unknown` → NOTHING on the row. The kind is unrecorded, which is
111
+ * not a fact about this edit worth shouting once per
112
+ * row; `RecordHistoryPanel` says it ONCE for the whole
113
+ * panel instead, and only when some loaded row is
114
+ * actually unknown.
115
+ *
116
+ * The truth is never *lost* in the quiet cases — it goes into the element's
117
+ * accessible title, so hover and assistive tech still report it.
118
+ */
119
+ function kindMarker(
120
+ kind: ActorKind | undefined,
121
+ source: ActorKindSource | undefined,
122
+ ): { label: string; icon: 'bot' | null; inferred: boolean } | null {
123
+ if (kind === 'machine') {
124
+ return { label: 'machine', icon: 'bot', inferred: source === 'inferred' };
125
+ }
126
+ if (kind === 'person' && source === 'inferred') {
127
+ return { label: 'person, inferred', icon: null, inferred: true };
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /** The full truth about the kind, for `title`/screen readers — including in the
133
+ * cases that render no marker, so "quiet" never means "undiscoverable". */
134
+ export function actorKindTitle(
135
+ kind: ActorKind | undefined,
136
+ source: ActorKindSource | undefined,
137
+ ): string {
138
+ if (kind === 'machine') {
139
+ return source === 'claim'
140
+ ? 'A machine made this change (declared by the sign-in service).'
141
+ : 'A machine made this change (inferred from the account, not declared).';
142
+ }
143
+ if (kind === 'person') {
144
+ return source === 'claim'
145
+ ? 'A person made this change (declared by the sign-in service).'
146
+ : 'A person made this change (inferred from the account, not declared).';
147
+ }
148
+ return 'Whether a person or a machine made this change was not recorded.';
149
+ }
150
+
57
151
  export interface ActorIdentityProps {
58
152
  actor?: ActorIdentityValue;
59
153
  /** Show the initials avatar. Off for a compact one-line attribution, or where
60
154
  * the surrounding row already paints the same initials. */
61
155
  showInitials?: boolean;
156
+ /** Person, machine, or not recorded. Omitted on every surface that predates
157
+ * the revision trail, which is why it is optional and why omitting it
158
+ * renders byte-identically to before. */
159
+ kind?: ActorKind;
160
+ /** Whether {@link kind} was claimed or guessed. */
161
+ kindSource?: ActorKindSource;
62
162
  className?: string;
63
163
  initialsClassName?: string;
64
164
  handleClassName?: string;
165
+ kindClassName?: string;
65
166
  }
66
167
 
67
168
  export function ActorIdentity({
68
169
  actor,
69
170
  showInitials = true,
171
+ kind,
172
+ kindSource,
70
173
  className = 'flex flex-wrap items-center gap-x-1.5',
71
174
  initialsClassName = 'flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground',
72
175
  handleClassName = 'text-xs text-muted-foreground/80',
176
+ kindClassName,
73
177
  }: ActorIdentityProps) {
74
178
  const label = actorLabel(actor);
75
- if (!label) return null;
76
- const initials = showInitials ? actorInitials(actor) : '';
179
+ const marker = kindMarker(kind, kindSource);
180
+ // An empty label used to be the signal to render nothing at all, rather than
181
+ // a dangling "by" — and it still is, UNLESS there is a kind worth marking. A
182
+ // service token can carry no email whatsoever, and dropping that row's actor
183
+ // entirely would hide the one fact the actor axis exists to record: that a
184
+ // machine, not a colleague, wrote here.
185
+ if (!label && !marker) return null;
186
+ const initials = showInitials && label ? actorInitials(actor) : '';
77
187
  const handle = actorHandle(actor);
78
188
 
79
189
  return (
80
- <span data-testid="actor-identity" className={className}>
190
+ <span
191
+ data-testid="actor-identity"
192
+ data-actor-kind={kind ?? undefined}
193
+ data-actor-kind-source={kindSource ?? undefined}
194
+ className={className}
195
+ >
81
196
  {initials && <span className={initialsClassName}>{initials}</span>}
82
- <span>{label}</span>
197
+ {label && <span>{label}</span>}
83
198
  {handle && <span className={handleClassName}>{handle}</span>}
199
+ {marker && (
200
+ <span
201
+ data-testid="actor-kind"
202
+ // NOT a warning colour. This is a statement of fact about who typed,
203
+ // and the dark-tenant palette reserves red for failure; a machine
204
+ // edit is routine and must not read as an incident. The icon, not a
205
+ // colour, is what makes it scannable — and `inferred` is drawn with a
206
+ // DASHED border and no fill, so "we guessed" is lighter on the page
207
+ // than "we were told" without needing a second colour.
208
+ className={
209
+ kindClassName ??
210
+ [
211
+ 'inline-flex items-center gap-1 rounded border px-1.5 py-px',
212
+ 'text-[10px] font-medium uppercase tracking-wide',
213
+ marker.inferred
214
+ ? 'border-dashed border-border text-muted-foreground'
215
+ : 'border-border bg-secondary text-secondary-foreground',
216
+ ].join(' ')
217
+ }
218
+ title={actorKindTitle(kind, kindSource)}
219
+ >
220
+ {marker.icon === 'bot' && <Bot className="h-3 w-3" aria-hidden="true" />}
221
+ {marker.label}
222
+ </span>
223
+ )}
84
224
  </span>
85
225
  );
86
226
  }
@@ -5,13 +5,16 @@
5
5
  * startsim-768w.18.15). Renders the (up to) three decisions from the schema as
6
6
  * compact icon buttons ✕ reject · ✓ good · ✎ edit; a click writes status AND
7
7
  * team_verdict together (the coherent pair, same as ReviewDrawer) IN PLACE —
8
- * optimistic, no drawer. The current verdict shows as the active button.
8
+ * optimistic, no drawer.
9
9
  *
10
10
  * The ✎ "edit / needs work" decision opens a small popover with a note box so the
11
11
  * reviewer says HOW it should change — saved to the note attr alongside the
12
12
  * status+verdict move. Meant to live in a table's trailing "Actions" column; it
13
13
  * stops click propagation so acting never triggers the row's drawer-open.
14
14
  *
15
+ * THE ACTIVE BUTTON REPORTS STATUS, NOT THE VERDICT (bd startsim-m7fdm.12) — see
16
+ * `activeDecisionKey` in review.ts for why, and for what that costs.
17
+ *
15
18
  * Generic + client-injected: pass a CollectionClient + the record; defaults match
16
19
  * the OGMC content schema but everything routes through resolveReviewConfig.
17
20
  */
@@ -26,6 +29,7 @@ import { cn } from '../../lib/utils';
26
29
  import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
27
30
  import { writeData } from './data';
28
31
  import {
32
+ activeDecisionKey,
29
33
  applyDecisionToData,
30
34
  fieldStr,
31
35
  resolveReviewConfig,
@@ -69,26 +73,37 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
69
73
  () => [...reviewDecisions(cfg)].sort((a, b) => ORDER.indexOf(a.key) - ORDER.indexOf(b.key)),
70
74
  [cfg],
71
75
  );
72
- // Which decision the record currently reflects — by verdict when the type has
73
- // one (topics), else by status (news: acceptable→approve, rejected→reject). The
74
- // matching button shows active. Optimistically updated on click.
75
- const initialKey = useMemo(() => {
76
- const v = fieldStr(record.data, cfg.verdictAttr);
77
- const s = fieldStr(record.data, cfg.statusName);
78
- return (
79
- decisions.find((d) => (d.verdict ? d.verdict === v : d.status ? d.status === s : false))?.key ??
80
- null
81
- );
82
- }, [record.data, cfg, decisions]);
83
- const [appliedKey, setAppliedKey] = useState<string | null>(initialKey);
76
+ /**
77
+ * Which decision the record's STATE reflects — the button that renders active.
78
+ * Resolved by the shared `activeDecisionKey`, which answers off STATUS wherever
79
+ * the type has a status pipeline, so the AI's `team_verdict` can no longer
80
+ * render as a human's approval (bd startsim-m7fdm.12).
81
+ */
82
+ const storedKey = useMemo(
83
+ () => activeDecisionKey(record.data, cfg, decisions),
84
+ [record.data, cfg, decisions],
85
+ );
86
+ /**
87
+ * Optimistic override, remembered against the exact record snapshot it was made
88
+ * on, so it DROPS the moment a refetch hands this row a new one and the buttons
89
+ * go back to reporting stored state.
90
+ *
91
+ * It used to be `useState(initialKey)` — an initialiser that runs once per
92
+ * mounted component. A table cell's component instance outlives the record in
93
+ * it (a refetch, a re-sort, a page change hands the same cell a different row),
94
+ * and the old state then showed the PREVIOUS row's decision with nothing to
95
+ * correct it. Deriving from the record and overlaying the click is what makes
96
+ * the active state follow the data it claims to describe.
97
+ */
98
+ const [override, setOverride] = useState<{ on: unknown; key: string } | null>(null);
99
+ const appliedKey = override && override.on === record.data ? override.key : storedKey;
84
100
  const [busy, setBusy] = useState<string | null>(null);
85
101
  const [noteOpen, setNoteOpen] = useState(false);
86
102
  const [note, setNote] = useState('');
87
103
 
88
104
  /** Apply a decision (status + verdict), optionally attaching a note. Optimistic. */
89
105
  async function apply(d: ReviewDecision, noteText?: string) {
90
- const prev = appliedKey;
91
- setAppliedKey(d.key); // optimistic
106
+ setOverride({ on: record.data, key: d.key }); // optimistic
92
107
  setBusy(d.key);
93
108
  try {
94
109
  // Declared names re-key the whole outgoing blob, so a decision here cannot
@@ -99,7 +114,7 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
99
114
  onSaved?.();
100
115
  return true;
101
116
  } catch (err) {
102
- setAppliedKey(prev); // roll back
117
+ setOverride(null); // roll back to what is stored
103
118
  notify.error(err instanceof Error ? err.message : 'Could not save.');
104
119
  return false;
105
120
  } finally {
@@ -45,6 +45,7 @@ import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
45
45
  import { declaredBlob, readData, writeData } from './data';
46
46
  import { parseSources } from './drafts';
47
47
  import {
48
+ activeDecisionKey,
48
49
  resolveReviewConfig,
49
50
  reviewDecisions,
50
51
  shouldIgnoreShortcut,
@@ -125,7 +126,19 @@ function ReviewDrawerInner({
125
126
  const next = index >= 0 && index + 1 < records.length ? records[index + 1] : null;
126
127
 
127
128
  const status = fieldStr(data, cfg.statusName);
128
- const verdict = fieldStr(data, cfg.verdictAttr);
129
+ /**
130
+ * Which decision the record's STATE reflects — the ringed button in the sticky
131
+ * bar. Off STATUS, not the verdict, for the reason `activeDecisionKey` sets out
132
+ * (bd startsim-m7fdm.12): the drawer's ring had the same defect as the table's
133
+ * tick, so a reviewer opening one of the twenty topics the re-rank agent had
134
+ * marked `good` saw Approve ringed over a record still sitting at `suggested`.
135
+ *
136
+ * Fed the drawer's own optimistic `data`, so a decision rings instantly; and
137
+ * because it reads status, the manual state override below (`overrideStatus`
138
+ * writes status alone, no verdict) now rings the decision it amounts to
139
+ * instead of ringing nothing.
140
+ */
141
+ const activeKey = useMemo(() => activeDecisionKey(data, cfg, decisions), [data, cfg, decisions]);
129
142
  const title = fieldStr(data, cfg.titleAttr) || record.name || `#${record.id}`;
130
143
  const summary = fieldStr(data, cfg.summaryAttr);
131
144
  const rank = fieldStr(data, cfg.rankAttr);
@@ -339,7 +352,7 @@ function ReviewDrawerInner({
339
352
  <DecisionButton
340
353
  key={d.key}
341
354
  decision={d}
342
- active={verdict !== '' && d.verdict === verdict}
355
+ active={d.key === activeKey}
343
356
  busy={busy === `decision:${d.key}`}
344
357
  onClick={() => applyDecision(d)}
345
358
  />
@@ -1,4 +1,6 @@
1
1
  import {
2
+ activeDecisionKey,
3
+ isTerminalStatus,
2
4
  deriveReviewTransitions,
3
5
  resolveReviewConfig,
4
6
  reviewDecisions,
@@ -10,6 +12,7 @@ import {
10
12
  fieldStr,
11
13
  DEFAULT_VERDICTS,
12
14
  } from '../review';
15
+ import type { ReviewDecision } from '../review';
13
16
  import type { AttributeDef, EntityTypeDef } from '../types';
14
17
 
15
18
  function enumAttr(name: string, choices: string[]): AttributeDef {
@@ -343,3 +346,149 @@ describe('holdAfter — decisions that stay on the record', () => {
343
346
  expect(resolveReviewConfig(type, { holdAfter: ['approve'] }).holdAfter).toEqual(['approve']);
344
347
  });
345
348
  });
349
+
350
+ /**
351
+ * The green tick must report the HUMAN's decision, not the AI's verdict (bd
352
+ * startsim-m7fdm.12).
353
+ *
354
+ * The cases are the live OGMC distribution, measured on 171 topics: suggested/-
355
+ * 100, written/good 31, **suggested/good 20**, rejected/bad 16, written/bad 2,
356
+ * written/- 1, suggested/edit 1, and ZERO at `ready`. The twenty at
357
+ * suggested/good are the defect — the n8n re-rank agent wrote `team_verdict`
358
+ * without touching status, and the table drew a darkened green tick over every
359
+ * one of them.
360
+ */
361
+ describe('activeDecisionKey', () => {
362
+ const topicType = type([
363
+ enumAttr('status', ['suggested', 'ready', 'rejected', 'written']),
364
+ textAttr('team_verdict'),
365
+ textAttr('team_notes'),
366
+ ]);
367
+ const topicCfg = resolveReviewConfig(topicType);
368
+ const topicDecisions = reviewDecisions(topicCfg);
369
+ const key = (data: Record<string, unknown>) => activeDecisionKey(data, topicCfg, topicDecisions);
370
+
371
+ it('a machine-set verdict alone lights NOTHING — the defect, 20 live rows', () => {
372
+ expect(key({ status: 'suggested', team_verdict: 'good' })).toBeNull();
373
+ });
374
+
375
+ it('and nothing lights on the amber one either — the agent writes `edit` too', () => {
376
+ // The same lie one button over: "RESOLVED from edit -> good" is the agent's
377
+ // own prose. Needs work writes status BACK to the entry stage, so it leaves
378
+ // no trace status can tell apart from untouched.
379
+ expect(key({ status: 'suggested', team_verdict: 'edit' })).toBeNull();
380
+ });
381
+
382
+ it('an untouched topic lights nothing — 100 live rows, unchanged', () => {
383
+ expect(key({ status: 'suggested' })).toBeNull();
384
+ });
385
+
386
+ it('approved lights Approve — the state the fix makes reachable', () => {
387
+ expect(key({ status: 'ready' })).toBe('approve');
388
+ // and it does NOT depend on the verdict agreeing, or being present at all.
389
+ expect(key({ status: 'ready', team_verdict: 'bad' })).toBe('approve');
390
+ });
391
+
392
+ it('rejected lights Reject — 16 live rows, unchanged', () => {
393
+ expect(key({ status: 'rejected', team_verdict: 'bad' })).toBe('reject');
394
+ });
395
+
396
+ it('written still lights Approve — forward past approval, 34 live rows', () => {
397
+ // A written topic was approved; rendering it undecided would be the same lie
398
+ // pointing the other way, and with the State column off by default the tick
399
+ // is the only state a row shows.
400
+ expect(key({ status: 'written', team_verdict: 'good' })).toBe('approve');
401
+ expect(key({ status: 'written' })).toBe('approve');
402
+ });
403
+
404
+ it('written/bad lights GREEN where it used to light red — 2 live rows', () => {
405
+ // A DELIBERATE, surprising-direction change. The agent re-marked two
406
+ // already-written topics `bad` ("RE-MARKED (was unset, and it was
407
+ // WRITTEN)"); status says a human approved them and the writer wrote them,
408
+ // and status is the fact the tick reports.
409
+ expect(key({ status: 'written', team_verdict: 'bad' })).toBe('approve');
410
+ });
411
+
412
+ it('is independent of the order the caller holds its decisions in', () => {
413
+ const shuffled = [...topicDecisions].reverse();
414
+ for (const data of [
415
+ { status: 'suggested', team_verdict: 'good' },
416
+ { status: 'ready' },
417
+ { status: 'rejected' },
418
+ { status: 'written' },
419
+ ]) {
420
+ expect(activeDecisionKey(data, topicCfg, shuffled)).toBe(key(data));
421
+ }
422
+ });
423
+
424
+ it('ignores an undeclared status rather than guessing', () => {
425
+ expect(key({ status: 'queued_for_mars' })).toBeNull();
426
+ expect(key({})).toBeNull();
427
+ });
428
+
429
+ /**
430
+ * News Item is the path this must not break: it declares NO team_verdict and
431
+ * passes `verdicts: []`, so it has always resolved by status and must go on
432
+ * doing exactly that. Its enum runs past `acceptable` into outcomes that are
433
+ * NOT consequences of accepting — which is why only TERMINAL stages count
434
+ * forward.
435
+ */
436
+ describe('news_item (no verdict vocabulary) is untouched', () => {
437
+ const newsType = type([
438
+ enumAttr('status', [
439
+ 'new', 'surfaced', 'acceptable', 'cited', 'ignored', 'rejected', 'preview_only',
440
+ ]),
441
+ ]);
442
+ const newsCfg = resolveReviewConfig(newsType, {
443
+ approveStatus: 'acceptable',
444
+ rejectStatus: 'rejected',
445
+ verdicts: [],
446
+ omitNeedsWork: true,
447
+ });
448
+ const newsDecisions = reviewDecisions(newsCfg);
449
+ const nk = (status: string) => activeDecisionKey({ status }, newsCfg, newsDecisions);
450
+
451
+ it('acceptable → Accept, rejected → Reject', () => {
452
+ expect(nk('acceptable')).toBe('approve');
453
+ expect(nk('rejected')).toBe('reject');
454
+ });
455
+
456
+ it('the entry stage and the pre-decision stages light nothing', () => {
457
+ expect(nk('new')).toBeNull();
458
+ expect(nk('surfaced')).toBeNull();
459
+ });
460
+
461
+ it('`ignored` / `preview_only` sit AFTER acceptable and still light nothing', () => {
462
+ // The forward rule is gated on a TERMINAL-reading status for exactly this:
463
+ // being ignored is a different outcome, not a consequence of accepting.
464
+ expect(nk('ignored')).toBeNull();
465
+ expect(nk('preview_only')).toBeNull();
466
+ expect(nk('cited')).toBeNull();
467
+ });
468
+ });
469
+
470
+ it('a type with no status enum at all still resolves by verdict', () => {
471
+ // There, nothing but the buttons themselves writes a verdict, so the verdict
472
+ // IS the decision. `reviewDecisions` yields none without a status pipeline,
473
+ // so this asserts the branch directly.
474
+ const cfg = resolveReviewConfig(type([textAttr('team_verdict')]));
475
+ const decisions: ReviewDecision[] = [
476
+ { key: 'approve', label: 'Approve', status: null, verdict: 'good', tone: 'good', advance: true, shortcut: 'a', promptNote: false },
477
+ { key: 'reject', label: 'Reject', status: null, verdict: 'bad', tone: 'bad', advance: true, shortcut: 'x', promptNote: false },
478
+ ];
479
+ expect(activeDecisionKey({ team_verdict: 'good' }, cfg, decisions)).toBe('approve');
480
+ expect(activeDecisionKey({ team_verdict: 'bad' }, cfg, decisions)).toBe('reject');
481
+ expect(activeDecisionKey({}, cfg, decisions)).toBeNull();
482
+ });
483
+ });
484
+
485
+ describe('isTerminalStatus', () => {
486
+ it('names the end-of-pipeline stages the forward rule keys off', () => {
487
+ for (const s of ['written', 'published', 'sent', 'live', 'closed']) {
488
+ expect(isTerminalStatus(s)).toBe(true);
489
+ }
490
+ for (const s of ['suggested', 'ready', 'rejected', 'ignored', 'preview_only', 'cited']) {
491
+ expect(isTerminalStatus(s)).toBe(false);
492
+ }
493
+ });
494
+ });
@@ -13,6 +13,8 @@ export {
13
13
  resolveReviewConfig,
14
14
  deriveReviewTransitions,
15
15
  reviewDecisions,
16
+ activeDecisionKey,
17
+ isTerminalStatus,
16
18
  decisionLabel,
17
19
  DEFAULT_DECISION_LABELS,
18
20
  applyDecisionToData,