@startsimpli/ui 0.4.139 → 0.4.141

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.139",
3
+ "version": "0.4.141",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -49,9 +49,9 @@
49
49
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
50
50
  "react": "^18.0.0 || ^19.0.0",
51
51
  "react-dom": "^18.0.0 || ^19.0.0",
52
- "@startsimpli/api": "^0.5.66",
52
+ "@startsimpli/i18n": "^0.1.3",
53
53
  "@startsimpli/auth": "^0.4.38",
54
- "@startsimpli/i18n": "^0.1.3"
54
+ "@startsimpli/api": "^0.5.66"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@startsimpli/api": {
@@ -126,8 +126,8 @@
126
126
  "tailwindcss": "^3.4.19",
127
127
  "tsup": "^8.5.1",
128
128
  "typescript": "^6.0.3",
129
- "@startsimpli/realtime": "0.0.2",
130
- "@startsimpli/i18n": "0.1.3"
129
+ "@startsimpli/i18n": "0.1.3",
130
+ "@startsimpli/realtime": "0.0.2"
131
131
  },
132
132
  "keywords": [
133
133
  "ui",
@@ -197,24 +197,29 @@ function TimelineEntry({
197
197
  {activity.description && (
198
198
  <p className="mt-1 text-sm text-muted-foreground">{activity.description}</p>
199
199
  )}
200
- {activity.detail && (
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']}`}>
209
- {activity.detail}
210
- </div>
211
- )}
212
200
  </div>
213
201
  <div className="ml-4 whitespace-nowrap text-xs text-muted-foreground">
214
202
  {formatTime(activity.occurredAt)}
215
203
  </div>
216
204
  </div>
217
205
 
206
+ {activity.detail && (
207
+ // A DIV, NOT A P. `detail` is typed ReactNode and the record history
208
+ // panel passes a field LIST into it — and a <ul> or a <div> inside a
209
+ // <p> is invalid HTML, which React resolves by reparenting during
210
+ // hydration. That produces a hydration mismatch on every row, and
211
+ // neither tsc nor jsdom reports it: only a real browser does
212
+ // (bd startsim-g4rwo).
213
+ //
214
+ // BELOW the title/timestamp row, not inside its left column: there it
215
+ // shared the line with the timestamp and lost ~100px of a 366px
216
+ // review rail to it, which is what left a field's before/after a
217
+ // column of single characters (bd startsim-jkkn7.12).
218
+ <div className={`-mt-1 mb-2 break-words text-xs ${TONE_TEXT[activity.detailTone ?? 'muted']}`}>
219
+ {activity.detail}
220
+ </div>
221
+ )}
222
+
218
223
  <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
219
224
  <ActorIdentity
220
225
  actor={activity.actor}
@@ -113,8 +113,17 @@ export function actorHandle(actor?: ActorIdentityValue): string {
113
113
  * panel instead, and only when some loaded row is
114
114
  * actually unknown.
115
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.
116
+ * The truth is never *lost* in the quiet cases. Whenever a kind is passed, the
117
+ * WRAPPER carries {@link actorKindTitle} as its `title`, on every row and not
118
+ * only on the marker, so hover and the accessible description report it for an
119
+ * unknown or claimed-person row too (bd startsim-jkkn7.15). It is a title and
120
+ * NOT sr-only text on purpose: this chip sits inside sentences
121
+ * ("<actor> changed this record while you were editing"), and a hidden
122
+ * sentence spliced into the middle of one reads worse than no sentence.
123
+ *
124
+ * `person`/`inferred` is unreachable today — the server's inference ladder
125
+ * floors at `unknown` and a believed claim always records `claim` — so its
126
+ * branch below is dead code kept on purpose, as the guard it is.
118
127
  */
119
128
  function kindMarker(
120
129
  kind: ActorKind | undefined,
@@ -185,12 +194,16 @@ export function ActorIdentity({
185
194
  if (!label && !marker) return null;
186
195
  const initials = showInitials && label ? actorInitials(actor) : '';
187
196
  const handle = actorHandle(actor);
197
+ const kindTitle = kind ? actorKindTitle(kind, kindSource) : undefined;
188
198
 
189
199
  return (
190
200
  <span
191
201
  data-testid="actor-identity"
192
202
  data-actor-kind={kind ?? undefined}
193
203
  data-actor-kind-source={kindSource ?? undefined}
204
+ // Only when a kind was passed: every surface that predates the actor axis
205
+ // must render exactly as it did (see the `kind` prop).
206
+ title={kindTitle}
194
207
  className={className}
195
208
  >
196
209
  {initials && <span className={initialsClassName}>{initials}</span>}
@@ -53,25 +53,32 @@ export interface TimeSeriesChartProps {
53
53
  /**
54
54
  * Series colours, in order, from the theme contract (theme/contract.css) — so
55
55
  * they follow the app's brand and flip with `.dark` like every other surface.
56
- * `--success` carries the same fallback channels as the Tailwind preset, for
57
- * apps that predate the token.
56
+ *
57
+ * Categorical only: a series' colour says which series it is, nothing more.
58
+ * `--success` and `--destructive` are deliberately absent — they mean good/bad
59
+ * (gain/loss), and a series that happened to land on one would assert that.
60
+ * `--secondary` and `--accent` are surface tones, too faint to read as a
61
+ * stroke. That leaves three tokens, so distinguishability comes mostly from
62
+ * the dash cycle below.
58
63
  */
59
64
  export const SERIES_COLORS = [
60
65
  'hsl(var(--primary))',
61
66
  'hsl(var(--muted-foreground))',
62
- 'hsl(var(--success, 142.1 76.2% 36.3%))',
63
- 'hsl(var(--destructive))',
64
67
  'hsl(var(--foreground))',
65
68
  ] as const
66
69
 
67
- /** After the palette runs out, series repeat its colours with a dash. */
68
- const DASHES = [undefined, '6 4', '2 3'] as const
70
+ /**
71
+ * Each pass through the palette gets the next dash: solid, dashed, dotted,
72
+ * dash-dot. 3 colours x 4 dashes = 12 series before a style repeats. The
73
+ * patterns fit the 16px legend swatch so each one shows its full rhythm.
74
+ */
75
+ export const SERIES_DASHES = [undefined, '6 4', '2 3', '8 3 2 3'] as const
69
76
 
70
77
  export function seriesStroke(index: number): { color: string; dash: string | undefined } {
71
78
  const n = SERIES_COLORS.length
72
79
  return {
73
80
  color: SERIES_COLORS[index % n],
74
- dash: DASHES[Math.floor(index / n) % DASHES.length],
81
+ dash: SERIES_DASHES[Math.floor(index / n) % SERIES_DASHES.length],
75
82
  }
76
83
  }
77
84
 
@@ -1,5 +1,5 @@
1
1
  import { render, screen } from '@testing-library/react'
2
- import { TimeSeriesChart, SERIES_COLORS, seriesStroke } from '../TimeSeriesChart'
2
+ import { TimeSeriesChart, SERIES_COLORS, SERIES_DASHES, seriesStroke } from '../TimeSeriesChart'
3
3
  import type { TimeSeries } from '../lib/series'
4
4
 
5
5
  // jsdom lays nothing out: recharts' ResponsiveContainer renders no chart at a
@@ -74,6 +74,19 @@ describe('TimeSeriesChart', () => {
74
74
  expect(new Set(strokes)).toEqual(new Set([SERIES_COLORS[0], SERIES_COLORS[1]]))
75
75
  })
76
76
 
77
+ it('dashes the series that repeat a colour, on the line and in the legend', () => {
78
+ const four = [0, 1, 2, 3].map((k) => ({ ...holding, name: `S${k}` }))
79
+ const { container } = render(<TimeSeriesChart series={four} unit="$" />)
80
+ const lines = linePaths(container)
81
+ expect(lines).toHaveLength(4)
82
+ expect(lines[3].getAttribute('stroke')).toBe(lines[0].getAttribute('stroke'))
83
+ expect(lines[3].getAttribute('stroke-dasharray')).toBe('6 4')
84
+ expect(lines[0].getAttribute('stroke-dasharray')).toBeNull()
85
+
86
+ const swatches = Array.from(container.querySelectorAll('ul svg line'))
87
+ expect(swatches.map((l) => l.getAttribute('stroke-dasharray'))).toEqual([null, null, null, '6 4'])
88
+ })
89
+
77
90
  it('shows a legend for two or more series and none for one', () => {
78
91
  const { rerender } = render(<TimeSeriesChart series={[strategy, holding]} unit="$" />)
79
92
  expect(screen.getByText('Strategy')).toBeInTheDocument()
@@ -132,10 +145,42 @@ describe('TimeSeriesChart', () => {
132
145
  })
133
146
 
134
147
  describe('seriesStroke', () => {
135
- it('cycles the token palette and dashes the repeats', () => {
148
+ it('uses only non-semantic tokens, in order', () => {
149
+ expect([...SERIES_COLORS]).toEqual([
150
+ 'hsl(var(--primary))',
151
+ 'hsl(var(--muted-foreground))',
152
+ 'hsl(var(--foreground))',
153
+ ])
154
+ })
155
+
156
+ it('never colours a series as gain or loss', () => {
157
+ // --success / --destructive mean good / bad; a categorical series must not
158
+ // inherit that meaning by landing on one.
159
+ const styles = Array.from({ length: 40 }, (_, i) => seriesStroke(i).color)
160
+ for (const color of styles) {
161
+ expect(color).not.toMatch(/--(success|destructive)\b/)
162
+ }
163
+ })
164
+
165
+ it('cycles the token palette and steps the dash on each pass', () => {
136
166
  const n = SERIES_COLORS.length
137
167
  expect(seriesStroke(0)).toEqual({ color: SERIES_COLORS[0], dash: undefined })
168
+ expect(seriesStroke(n - 1)).toEqual({ color: SERIES_COLORS[n - 1], dash: undefined })
138
169
  expect(seriesStroke(n)).toEqual({ color: SERIES_COLORS[0], dash: '6 4' })
139
170
  expect(seriesStroke(n + 1).color).toBe(SERIES_COLORS[1])
171
+ expect(seriesStroke(2 * n).dash).toBe('2 3')
172
+ expect(seriesStroke(3 * n).dash).toBe('8 3 2 3')
173
+ })
174
+
175
+ it('keeps every series distinct until colours x dashes run out', () => {
176
+ const distinct = SERIES_COLORS.length * SERIES_DASHES.length
177
+ expect(distinct).toBe(12)
178
+ const keys = Array.from({ length: distinct }, (_, i) => {
179
+ const { color, dash } = seriesStroke(i)
180
+ return `${color}|${dash ?? 'solid'}`
181
+ })
182
+ expect(new Set(keys).size).toBe(distinct)
183
+ // Then the cycle starts over.
184
+ expect(seriesStroke(distinct)).toEqual(seriesStroke(0))
140
185
  })
141
186
  })
@@ -1,6 +1,6 @@
1
1
  // Charts live behind their own subpath (`@startsimpli/ui/charts`) so recharts
2
2
  // is only bundled by apps that draw one.
3
- export { TimeSeriesChart, SERIES_COLORS, seriesStroke } from './TimeSeriesChart'
3
+ export { TimeSeriesChart, SERIES_COLORS, SERIES_DASHES, seriesStroke } from './TimeSeriesChart'
4
4
  export type { TimeSeriesChartProps, TimeSeriesReferenceLine } from './TimeSeriesChart'
5
5
  export type {
6
6
  TimeSeries,
@@ -40,14 +40,19 @@
40
40
  * because this route serves every historical value a record ever held, so it
41
41
  * is a richer leak than the row read — and because a `{}` rendered silently
42
42
  * reads as data loss.
43
- * 3. THAT THE TRAIL IS NOT THE WHOLE STORY. Four write paths record NO revision
44
- * today — `annotate`, `move-scope`, `DELETE` and the ingestion worker
45
- * (bd startsim-jkkn7.6). The version sequence is still provably gapless
46
- * (a write that records nothing allocates no number), so there is no hole to
47
- * see — which is exactly why it has to be SAID. A reader must not conclude
48
- * from a quiet trail that no machine touched this record. The panel
49
- * therefore never claims completeness, and its empty state says "no edits
50
- * recorded" and not "never changed".
43
+ * 3. WHEN HISTORY IS DECLARED OFF FOR THIS KIND OF RECORD. Every write path
44
+ * records a revision now — `annotate`, `move-scope`, `DELETE` and the
45
+ * ingestion worker were the last four, closed by bd startsim-jkkn7.6 — so the
46
+ * old standing caveat that "some writes are not recorded yet" described a gap
47
+ * that no longer exists, and was removed (bd startsim-jkkn7.11). What CAN
48
+ * still leave a trail empty is a DECLARATION: `EntityTypeDef.config.history
49
+ * = {"enabled": false}` on the type (or the reserved `"*"` row). That is a
50
+ * decision somebody made, not a silent door, so it gets different words —
51
+ * "history is not recorded for this kind of record" — and it is said only
52
+ * when the host passes `historyEnabled={false}`. The panel cannot see the
53
+ * setting itself: it only holds a revisions client, and the revisions
54
+ * envelope does not carry the resolved policy. Otherwise the empty state
55
+ * says "no edits recorded" and nothing more — and never "never changed".
51
56
  */
52
57
  import { ReactNode, useMemo } from 'react';
53
58
  import { useInfiniteQuery } from '@tanstack/react-query';
@@ -140,9 +145,19 @@ export interface RecordHistoryPanelProps {
140
145
  errorTitle?: string;
141
146
  notFoundTitle?: string;
142
147
  notFoundMessage?: string;
143
- /** The standing caveat that the trail has known silent write paths. Pass
144
- * `null` only if the surrounding page already says it — not to make the
145
- * panel look more complete than it is. */
148
+ /** Whether this record's TYPE keeps a history, as resolved from
149
+ * `EntityTypeDef.config.history` (the type's own declaration, else the
150
+ * reserved `"*"` row, else on). `false` makes the panel say that history is
151
+ * not recorded for this kind of record, beside any rows written before it
152
+ * was switched off. Omitted or `true` says nothing — the panel never
153
+ * invents a caveat it cannot back. bd startsim-jkkn7.11. */
154
+ historyEnabled?: boolean;
155
+ /** The sentence shown when {@link historyEnabled} is `false`. */
156
+ declaredOffNote?: ReactNode;
157
+ /** An extra caveat under the trail, for a host that knows something about
158
+ * this record's coverage the panel cannot. None by default: since bd
159
+ * startsim-jkkn7.6 every write path records, and a standing "some edits may
160
+ * be missing" would teach readers to discount a complete trail. */
146
161
  incompleteNote?: ReactNode;
147
162
  classNames?: RecordHistoryPanelClassNames;
148
163
  }
@@ -166,11 +181,14 @@ export function RecordHistoryPanel({
166
181
  notFoundTitle = 'History is not available to you',
167
182
  notFoundMessage =
168
183
  'This record, or your access to it, has changed. Reopen the record to see its history.',
169
- incompleteNote =
170
- 'Some writes are not recorded yet — bulk imports, scope moves, annotations and deletions leave no entry — so a quiet history is not proof that nothing else touched this record.',
184
+ historyEnabled,
185
+ declaredOffNote =
186
+ 'History is not recorded for this kind of record — it has been switched off for this type, so new edits do not appear here.',
187
+ incompleteNote = null,
171
188
  classNames = {},
172
189
  }: RecordHistoryPanelProps) {
173
190
  const cx = { ...RECORD_HISTORY_PANEL_DEFAULTS, ...classNames };
191
+ const declaredOff = historyEnabled === false;
174
192
 
175
193
  const query = useInfiniteQuery<RevisionPage>({
176
194
  queryKey: [...queryKey, { pageSize, windowMs }],
@@ -214,6 +232,13 @@ export function RecordHistoryPanel({
214
232
  () => sittings.some((s) => s.actorKind === 'unknown'),
215
233
  [sittings],
216
234
  );
235
+ // A claimed person is undecorated exactly like an unknown row, so when both
236
+ // are loaded "the rest" would sweep in the one row whose kind WAS recorded
237
+ // (bd startsim-jkkn7.15). Each row's own title/sr-only text says which.
238
+ const anyClaimedPerson = useMemo(
239
+ () => sittings.some((s) => s.actorKind === 'person' && s.actorKindSource === 'claim'),
240
+ [sittings],
241
+ );
217
242
 
218
243
  // Sentence 2 — the server's own roll-up over this page's rows, unioned
219
244
  // across the pages loaded so far.
@@ -245,9 +270,18 @@ export function RecordHistoryPanel({
245
270
  {anyUnknownKind && (
246
271
  <p className={cx.notice} data-testid="record-history-unknown-kind">
247
272
  Entries marked <strong className="font-medium text-foreground">machine</strong> were
248
- made by an automation. For the rest, whether a person or a machine made the edit was
249
- not recorded — it is not available for edits made before this was switched on, or by
250
- anyone who has not signed in since.
273
+ made by an automation.{' '}
274
+ {anyClaimedPerson
275
+ ? 'Some unmarked entries were recorded as made by a person; for the others, whether a person or a machine made the edit was not recorded'
276
+ : 'For the rest, whether a person or a machine made the edit was not recorded'}{' '}
277
+ — it is not available for edits made before this was switched on, or by anyone who has
278
+ not signed in since.{anyClaimedPerson && ' Hover a name to see which.'}
279
+ </p>
280
+ )}
281
+
282
+ {declaredOff && !query.isError && (
283
+ <p className={cx.notice} data-testid="record-history-declared-off">
284
+ {declaredOffNote}
251
285
  </p>
252
286
  )}
253
287
 
@@ -277,7 +311,9 @@ export function RecordHistoryPanel({
277
311
  hasMore={!!query.hasNextPage}
278
312
  onLoadMore={loadMore}
279
313
  emptyTitle={emptyTitle}
280
- emptyDescription={emptyDescription}
314
+ // Declared off: "edits will appear here" would be untrue, and the
315
+ // notice above already says why the trail is empty.
316
+ emptyDescription={declaredOff ? '' : emptyDescription}
281
317
  activityIcons={DEFAULT_ICONS}
282
318
  defaultIcon={Pencil}
283
319
  loadMoreLabel={query.isFetchingNextPage ? 'Loading…' : 'Load more'}
@@ -292,7 +328,7 @@ export function RecordHistoryPanel({
292
328
  </p>
293
329
  )}
294
330
 
295
- {/* Sentence 3 — always, when there is anything to qualify. */}
331
+ {/* A host-supplied caveat only; none by default (see sentence 3). */}
296
332
  {incompleteNote && !query.isError && (
297
333
  <p className={cx.countLine} data-testid="record-history-incomplete">
298
334
  {incompleteNote}
@@ -57,7 +57,7 @@ const CLAMP = 'max-h-40 overflow-y-auto whitespace-pre-wrap break-words';
57
57
  * screen-reader text — never as an empty box that looks like "" was stored. */
58
58
  function Side({ label, has, value }: { label: string; has: boolean; value: unknown }) {
59
59
  return (
60
- <div className="min-w-0 flex-1">
60
+ <div className="min-w-0">
61
61
  <div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
62
62
  {label}
63
63
  </div>
@@ -106,7 +106,7 @@ function FieldBody({ field, change }: { field: string; change: FoldedFieldChange
106
106
  }
107
107
 
108
108
  return (
109
- <div className="space-y-2">
109
+ <div className="@container space-y-2">
110
110
  {kind === 'truncated' && (
111
111
  <p className="flex items-start gap-2 text-xs text-muted-foreground">
112
112
  <Scissors className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
@@ -118,7 +118,13 @@ function FieldBody({ field, change }: { field: string; change: FoldedFieldChange
118
118
  </span>
119
119
  </p>
120
120
  )}
121
- <div className="flex flex-wrap gap-3 sm:flex-nowrap">
121
+ {/* SIZED ON THE CONTAINER, NOT THE VIEWPORT (bd startsim-jkkn7.12). This
122
+ panel is a composer meant to be embedded, and its common home is a
123
+ ~370px review rail on a wide screen — where a viewport `sm:` forced the
124
+ sides next to each other and each got a few dozen pixels. Stacked by
125
+ default; side by side only once the field body itself is 24rem wide.
126
+ A grid, not `flex-1` + wrap: a zero flex-basis never wraps. */}
127
+ <div className="grid gap-3 @sm:grid-cols-2">
122
128
  <Side label="Before" has={change.hasBefore} value={change.before} />
123
129
  <Side label="After" has={change.hasAfter} value={change.after} />
124
130
  </div>
@@ -217,19 +217,83 @@ describe('RecordHistoryPanel — attempts, truncation and completeness', () => {
217
217
  ).toBeInTheDocument()
218
218
  })
219
219
 
220
- it('NEVER claims the trail is complete', async () => {
221
- mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
222
- const note = await screen.findByTestId('record-history-incomplete')
223
- expect(note).toHaveTextContent(/not recorded yet/i)
224
- })
225
-
226
220
  it('an empty history says "no edits recorded", not "never changed"', async () => {
227
221
  mount(<RecordHistoryPanel client={clientFor(page([]))} />)
228
222
  expect(await screen.findByText('No edits recorded for this record')).toBeInTheDocument()
229
223
  expect(screen.queryByText(/never been changed|never changed/i)).not.toBeInTheDocument()
230
- // The caveat still stands on an empty panel — a quiet trail is the exact
231
- // case a reader would otherwise over-read.
232
- expect(screen.getByTestId('record-history-incomplete')).toBeInTheDocument()
224
+ })
225
+ })
226
+
227
+ describe('RecordHistoryPanel — what the trail does and does not cover (bd startsim-jkkn7.11/.12)', () => {
228
+ // bd startsim-jkkn7.6 closed all four silent write paths (annotate,
229
+ // move-scope, DELETE, the ingestion worker). A caveat that the trail misses
230
+ // them now describes a gap that does not exist, and teaches a reader to
231
+ // discount the one surface whose job is to be trusted.
232
+ it('no longer claims bulk imports, scope moves, annotations or deletions go unrecorded', async () => {
233
+ mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
234
+ await screen.findByText('jurga@ogmc.example')
235
+ const panel = screen.getByTestId('record-history-panel')
236
+ expect(panel.textContent).not.toMatch(/bulk imports|scope moves|not recorded yet/i)
237
+ expect(screen.queryByTestId('record-history-incomplete')).not.toBeInTheDocument()
238
+ })
239
+
240
+ it('an empty history says "No edits recorded" and nothing more when history is on or unknown', async () => {
241
+ mount(<RecordHistoryPanel client={clientFor(page([]))} />)
242
+ await screen.findByText('No edits recorded for this record')
243
+ expect(screen.queryByTestId('record-history-incomplete')).not.toBeInTheDocument()
244
+ expect(screen.queryByTestId('record-history-declared-off')).not.toBeInTheDocument()
245
+ })
246
+
247
+ it('says history is not recorded for this kind of record when the type declares it off', async () => {
248
+ mount(<RecordHistoryPanel client={clientFor(page([]))} historyEnabled={false} />)
249
+ const note = await screen.findByTestId('record-history-declared-off')
250
+ expect(note).toHaveTextContent(/history is not recorded for this kind of record/i)
251
+ // A declaration, not a defect — it must not read like missing data.
252
+ expect(note.textContent).not.toMatch(/may be missing|error|fail|broken/i)
253
+ // And the empty state must not suggest edits will appear.
254
+ expect(screen.queryByText(/will appear here/i)).not.toBeInTheDocument()
255
+ })
256
+
257
+ it('keeps the declared-off disclosure next to rows recorded before it was switched off', async () => {
258
+ mount(
259
+ <RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} historyEnabled={false} />,
260
+ )
261
+ expect(await screen.findByText('jurga@ogmc.example')).toBeInTheDocument()
262
+ expect(screen.getByTestId('record-history-declared-off')).toHaveTextContent(
263
+ /history is not recorded for this kind of record/i,
264
+ )
265
+ })
266
+
267
+ it('says nothing about a declaration when the type keeps history', async () => {
268
+ mount(<RecordHistoryPanel client={clientFor(page([]))} historyEnabled />)
269
+ await screen.findByText('No edits recorded for this record')
270
+ expect(screen.queryByTestId('record-history-declared-off')).not.toBeInTheDocument()
271
+ })
272
+ })
273
+
274
+ describe('RevisionFields — the before/after split sizes on its container (bd startsim-jkkn7.12)', () => {
275
+ it('never decides the split from the VIEWPORT', async () => {
276
+ mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
277
+ fireEvent.click(await screen.findByRole('button', { name: /blog/ }))
278
+ const before = screen.getByText('Before')
279
+ const split = before.parentElement!.parentElement!
280
+ // `sm:`/`md:`/`lg:` are viewport media queries: on a wide screen they force
281
+ // the two sides next to each other even inside a 370px rail.
282
+ expect(split.className).not.toMatch(/(^|\s)(sm|md|lg|xl):/)
283
+ // Stacked by default, side by side only when the CONTAINER has room.
284
+ expect(split.className).toMatch(/@sm:/)
285
+ expect(split.closest('.\\@container')).not.toBeNull()
286
+ })
287
+
288
+ it('gives the field list the full card width, not a column beside the timestamp', async () => {
289
+ // Measured in a 366px rail: the detail sat in the header's left column and
290
+ // shared the line with "2 minutes ago", losing ~100px it never needed.
291
+ mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
292
+ const fields = await screen.findByTestId('revision-fields')
293
+ const row = fields.closest('[data-activity-id]')!
294
+ const time = within(row as HTMLElement).getByText(/ago|\d{1,2}:\d{2}/)
295
+ // The timestamp's row must not contain the field list.
296
+ expect(time.parentElement!.contains(fields)).toBe(false)
233
297
  })
234
298
  })
235
299
 
@@ -308,5 +372,89 @@ describe('ActorIdentity — the kind props are additive', () => {
308
372
  render(<ActorIdentity actor={{ email: 'a@b.com' }} kind="unknown" />)
309
373
  const el = screen.getByTestId('actor-identity')
310
374
  expect(el).toHaveAttribute('data-actor-kind', 'unknown')
375
+ expect(el).toHaveAttribute(
376
+ 'title',
377
+ 'Whether a person or a machine made this change was not recorded.',
378
+ )
379
+ })
380
+ })
381
+
382
+ describe('ActorIdentity — every row with a kind can say it (bd startsim-jkkn7.15)', () => {
383
+ it('titles a claimed person, the other undecorated case', () => {
384
+ render(<ActorIdentity actor={{ email: 'malin@ogmc.example' }} kind="person" kindSource="claim" />)
385
+ expect(screen.getByTestId('actor-identity')).toHaveAttribute(
386
+ 'title',
387
+ 'A person made this change (declared by the sign-in service).',
388
+ )
389
+ })
390
+
391
+ it('titles a marked machine row on the wrapper too', () => {
392
+ render(<ActorIdentity actor={{ email: 'svc@x' }} kind="machine" kindSource="claim" />)
393
+ expect(screen.getByTestId('actor-identity')).toHaveAttribute(
394
+ 'title',
395
+ 'A machine made this change (declared by the sign-in service).',
396
+ )
397
+ })
398
+
399
+ it('keeps the kind out of the text, because the chip sits inside sentences', () => {
400
+ render(<ActorIdentity actor={{ email: 'a@b.com' }} kind="unknown" showInitials={false} />)
401
+ expect(screen.getByTestId('actor-identity')).toHaveTextContent(/^a@b\.com$/)
402
+ })
403
+
404
+ it('adds no title when no kind is passed (older surfaces stay byte-identical)', () => {
405
+ render(<ActorIdentity actor={{ email: 'a@b.com' }} />)
406
+ expect(screen.getByTestId('actor-identity')).not.toHaveAttribute('title')
407
+ })
408
+
409
+ it('titles every row in the panel, including unknown and person/claim', async () => {
410
+ mount(
411
+ <RecordHistoryPanel
412
+ windowMs={0}
413
+ client={clientFor(
414
+ page([
415
+ rev({ version: 4, actor_label: 'u4@x', actor_sub: 'u4', actor_kind: 'unknown', actor_kind_source: 'inferred' }),
416
+ rev({ version: 3, actor_label: 'malin@x', actor_sub: 'u3', created_at: at(-60_000) }),
417
+ rev({ version: 2, actor_label: 'svc:a', actor_sub: 'svc:a', created_at: at(-120_000), actor_kind: 'machine', actor_kind_source: 'inferred' }),
418
+ rev({ version: 1, actor_label: 'svc:b', actor_sub: 'svc:b', created_at: at(-180_000), actor_kind: 'machine', actor_kind_source: 'claim' }),
419
+ ]),
420
+ )}
421
+ />,
422
+ )
423
+ await screen.findByText('malin@x')
424
+ const rows = screen.getAllByTestId('actor-identity')
425
+ expect(rows).toHaveLength(4)
426
+ for (const row of rows) expect(row.getAttribute('title')).toBeTruthy()
427
+ })
428
+ })
429
+
430
+ describe('RecordHistoryPanel — the unrecorded-kind sentence is scoped (bd startsim-jkkn7.15)', () => {
431
+ it('does not sweep a recorded person into "the rest"', async () => {
432
+ mount(
433
+ <RecordHistoryPanel
434
+ windowMs={0}
435
+ client={clientFor(
436
+ page([
437
+ rev({ version: 2, actor_label: 'u2@x', actor_sub: 'u2', actor_kind: 'unknown', actor_kind_source: 'inferred' }),
438
+ rev({ version: 1, actor_label: 'malin@x', actor_sub: 'u1', created_at: at(-60_000) }),
439
+ ]),
440
+ )}
441
+ />,
442
+ )
443
+ const note = await screen.findByTestId('record-history-unknown-kind')
444
+ expect(note.textContent).not.toMatch(/for the rest/i)
445
+ // It says that SOME unmarked entries were recorded as a person, and where
446
+ // to find out which.
447
+ expect(note.textContent).toMatch(/some unmarked entries/i)
448
+ expect(note.textContent).toMatch(/person/i)
449
+ })
450
+
451
+ it('keeps the plain wording when no loaded row is a recorded person', async () => {
452
+ mount(
453
+ <RecordHistoryPanel
454
+ client={clientFor(page([rev({ version: 1, actor_kind: 'unknown', actor_kind_source: 'inferred' })]))}
455
+ />,
456
+ )
457
+ const note = await screen.findByTestId('record-history-unknown-kind')
458
+ expect(note.textContent).toMatch(/for the rest/i)
311
459
  })
312
460
  })
@@ -10,7 +10,7 @@ export interface RecordHistoryPanelClassNames {
10
10
  description?: string;
11
11
  body?: string;
12
12
  /** The panel-level sentences: the unrecorded-kind explanation, the redaction
13
- * notice and the "some write paths are not recorded" caveat. */
13
+ * notice and the declared-off disclosure. */
14
14
  notice?: string;
15
15
  footer?: string;
16
16
  countLine?: string;