cortena-ui 1.12.0 → 1.14.0

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +232 -6
  3. package/dist/a2ui/views.js +1 -1
  4. package/dist/components/accordion.js +1 -1
  5. package/dist/components/actor.d.ts +103 -0
  6. package/dist/components/actor.js +175 -0
  7. package/dist/components/actor.js.map +1 -0
  8. package/dist/components/admin-permissions/role-assignment.js +1 -1
  9. package/dist/components/agent-chat-popup.js +1 -1
  10. package/dist/components/agent-chat.js +1 -1
  11. package/dist/components/app-shell.d.ts +125 -2
  12. package/dist/components/app-shell.js +471 -89
  13. package/dist/components/app-shell.js.map +1 -1
  14. package/dist/components/avatar.js +1 -1
  15. package/dist/components/badge.d.ts +1 -1
  16. package/dist/components/breadcrumb.js +1 -1
  17. package/dist/components/button.d.ts +1 -1
  18. package/dist/components/calendar.js +1 -1
  19. package/dist/components/checkbox.js +1 -1
  20. package/dist/components/chip.d.ts +1 -1
  21. package/dist/components/combobox.js +1 -1
  22. package/dist/components/command.js +1 -1
  23. package/dist/components/data-table/data-table.js +1 -1
  24. package/dist/components/data-table/index.d.ts +2 -1
  25. package/dist/components/data-table/parts.js +1 -1
  26. package/dist/components/data-table/system-columns.d.ts +46 -0
  27. package/dist/components/data-table/system-columns.js +47 -2
  28. package/dist/components/data-table/system-columns.js.map +1 -1
  29. package/dist/components/date-field.js +1 -1
  30. package/dist/components/date-picker.js +1 -1
  31. package/dist/components/dropdown-menu.js +22 -22
  32. package/dist/components/dropdown-menu.js.map +1 -1
  33. package/dist/components/dropzone.js +1 -1
  34. package/dist/components/error-banner.js +1 -1
  35. package/dist/components/help-panel.js +1 -1
  36. package/dist/components/markdown.js +1 -1
  37. package/dist/components/rich-text-editor/toolbar.js +1 -1
  38. package/dist/components/select.js +1 -1
  39. package/dist/components/sortable-list.js +1 -1
  40. package/dist/components/toast.js +1 -1
  41. package/dist/core.d.ts +3 -2
  42. package/dist/core.js +5 -4
  43. package/dist/data-table.d.ts +2 -1
  44. package/dist/data-table.js +2 -1
  45. package/dist/index.d.ts +4 -2
  46. package/dist/index.js +6 -4
  47. package/package.json +1 -1
  48. package/src/components/actor.tsx +297 -0
  49. package/src/components/app-shell.tsx +716 -38
  50. package/src/components/data-table/index.tsx +2 -0
  51. package/src/components/data-table/system-columns.tsx +73 -0
  52. package/src/entries/core.ts +4 -2
package/CHANGELOG.md CHANGED
@@ -3,6 +3,127 @@
3
3
  Notable changes per release. Versions before 1.6.0 are recorded in the git log
4
4
  and in `../../CONSUMING.md`; this file starts where the changelog does.
5
5
 
6
+ ## 1.14.0
7
+
8
+ ### Added
9
+
10
+ - **`Actor` — one component for "who did this"** (DESIGN-120). The protocol
11
+ records the acting user and `via` on every write (§20, audit rule P-26) and
12
+ expects a trail to read "Name (Agent)". Seven extensions were each about to
13
+ format that themselves: Tasks prints the stored string raw as
14
+ `Amit (claude-code)`, Assure keeps a rich JSON actor and shows none of it.
15
+ `Actor` takes the resolved actor — `{ userId, displayName, via, avatarUrl,
16
+ at, surface }` — and **has no string input**: there is no `label`, `text` or
17
+ `children` prop, because a consumer that can pass a preformatted label is
18
+ how the inconsistency arrived. The rules it encodes: a direct action is the
19
+ name alone; an agent action is the name and a fixed ` (Agent)` in the
20
+ caption/muted style, never the agent's technical id or session; `system` is
21
+ `System`; a user with no stored display name is `Someone`, never a raw id.
22
+ Three sizes — `inline` for a sentence, `cell` for a list, `head` for a feed
23
+ row or comment with avatar and time — and a hover showing surface and time
24
+ wherever either is given. Times are `en-GB` by default and hand-written, the
25
+ same way `DateField` formats dates, so a trail does not read differently on
26
+ every machine; `locale` overrides it. `title` and `dangerouslySetInnerHTML`
27
+ are omitted from the props alongside `children`. The hover is not the only
28
+ way to reach what it says: the same text is in the DOM visually hidden, in
29
+ document order after the name, so a screen reader reads it and a `gridcell`
30
+ or link around the actor takes it into its own name. The avatar's initials
31
+ and the visible timestamp are `aria-hidden`, so neither is announced twice.
32
+ - **`actorColumn()`** in `cortena-ui/data-table`, so a list needs no custom
33
+ cell. It is an accessor column over the label rather than a display column,
34
+ so the value the table sorts, filters, searches and exports is the string
35
+ the cell paints. The header defaults to `By`.
36
+ - **`actorLabel`, `actorName`, `actorTime`** and the three wordings
37
+ (`ACTOR_AGENT_SUFFIX`, `ACTOR_SYSTEM_NAME`, `ACTOR_UNKNOWN_NAME`) are
38
+ exported for the places a React node cannot go — an `aria-label`, a CSV
39
+ cell, a `title`. `Actor` and its types come from `cortena-ui/core` and the
40
+ root barrel; `actorColumn` from `cortena-ui/data-table`.
41
+
42
+ ## 1.13.0
43
+
44
+ ### Added
45
+
46
+ - **`AppShell` has a navigation contract** (DESIGN-88). `nav` takes groups of
47
+ items — id, label, lucide icon or registered mark, href, badge, `exact` —
48
+ with an optional section label per group and a `footer` for the one thing in
49
+ a sidebar that is not navigation. Active state comes from `activeId`,
50
+ `isActive(href, item)` or `pathname`, in that order; cortena-ui still has no
51
+ router dependency, and `onNavigate` is where react-router takes over without
52
+ the `href` stopping being real. Below 1024px the sidebar becomes a menu
53
+ button and a drawer. `nav={null}` renders no sidebar, which is a legal state.
54
+ The five extensions in the shared-shell mockup would otherwise have shipped
55
+ five sidebars: five widths, five active treatments, five answers to badges
56
+ and groups.
57
+ - **`headerSlot`**, between the extension name and the avatar, for a live
58
+ status pill, the org the screen is scoped to, or one secondary action —
59
+ and for nothing else. README, "The header slot and the avatar menu", has the
60
+ list of what may not go there.
61
+ - **`menuItems`**, inserted between Settings and Log out in the avatar menu.
62
+ Log out stays last and stays behind its separator.
63
+ - **`bottomInset`**, and `data-bottom-bar` detection when it is not given. The
64
+ theme/help cluster and anything in `agentSlot` lift clear of a mobile bottom
65
+ tab bar instead of covering two of its tabs. The shell publishes
66
+ `--ds-shell-bottom-inset` and `--ds-shell-header-height` on its own element
67
+ and on `<html>`, so a pop-up that portals out of the shell can read them.
68
+ - **`HELP_PANEL_ID` and `NAV_DRAWER_ID`** are exported from `cortena-ui/core`
69
+ and the root barrel — the ids the shell gives the help region and the mobile
70
+ drawer, for a consumer that wires its own control's `aria-controls` to one
71
+ of them. They were exported from the module and reachable from neither entry.
72
+
73
+ ### Changed
74
+
75
+ - **The help panel starts below the header.** It was `fixed top-6 right-6`,
76
+ which put it over the 64px header and over the avatar trigger it sits next
77
+ to. It now starts 12px below the header and still stops 12px above the
78
+ cluster (DESIGN-88).
79
+ - **`BottomRightCluster` sets its own `bottom`.** It is now
80
+ `calc(24px + var(--ds-shell-bottom-inset, 0px))` as an inline style rather
81
+ than the `bottom-6` class, because the value is `calc()` over a variable the
82
+ consumer may not have set. **Migration:** an existing
83
+ `className="bottom-22 lg:bottom-6"` on the cluster no longer wins — an
84
+ inline style beats a utility class — and is now wrong twice over, since it
85
+ never moved the agent pop-up in `agentSlot`. Delete the override and give
86
+ the bottom bar `data-bottom-bar`, or pass `bottomInset`; both lift the
87
+ cluster and the pop-up together (DESIGN-88).
88
+ - **The mobile drawer is a real modal.** It was `role="dialog"
89
+ aria-modal="true"` with no focus trap: focus stayed on the menu button,
90
+ Tab walked straight out into the shell under the scrim, and the page
91
+ scrolled behind it. Focus now starts on its close button, Tab and Shift+Tab
92
+ cycle inside it, the rest of the shell is `inert` while it is open, body
93
+ scroll is locked and restored, and focus returns to the menu button
94
+ (DESIGN-88 review).
95
+ - **A modified click belongs to the browser.** `onNavigate` no longer fires —
96
+ and the drawer no longer closes — for a middle-click or a
97
+ cmd/ctrl/shift/alt-click, so "open in a new tab" works without every
98
+ consumer repeating the same guard before its `preventDefault()`. A router's
99
+ `onNavigate` written to the old contract keeps working; its own guard is
100
+ now redundant rather than required (DESIGN-88 review).
101
+ - **`--ds-shell-header-height` is measured**, not restated. The constant is
102
+ declared once and the header takes it as its height; what lands on `<html>`
103
+ and on the shell element is the height the browser laid out.
104
+
105
+ ### Fixed
106
+
107
+ - **More than one shell on a page no longer fights over `<html>`.** Every
108
+ shell wrote `--ds-shell-bottom-inset` and `--ds-shell-header-height` there
109
+ and every unmount removed them, so the values were whichever shell committed
110
+ last and the first to leave took them from the rest — visible in the token
111
+ guide, which mounts three. The first shell to mount owns the pair; the last
112
+ to unmount clears it (DESIGN-88 review).
113
+ - **`aria-controls` no longer dangles.** The menu button and the help button
114
+ named an id that was on the page only while the drawer, or the panel, was
115
+ mounted; the attribute is now set only while it is (DESIGN-88 review).
116
+ - **The shell's ResizeObservers survive a re-render.** The cluster's and the
117
+ bottom bar's effects listed `agentSlot`, `helpOpen` and `children` as
118
+ dependencies — fresh element objects every render — so both observers were
119
+ torn down and rebuilt on every render of the page underneath. They are keyed
120
+ on the DOM node now (DESIGN-88 review).
121
+ - **`BrandMark` no longer logs three React errors per page.**
122
+ `safeRootAttributes` spread a mark's root attributes into JSX in their SVG
123
+ spelling — `stroke-width`, `stroke-linecap`, `stroke-linejoin` — and React
124
+ 19 warned about each on every screen of every extension. They are camelCased
125
+ on the way in; the values still reach the DOM unchanged (DESIGN-88).
126
+
6
127
  ## 1.12.0
7
128
 
8
129
  ### Changed
package/README.md CHANGED
@@ -124,7 +124,7 @@ regression; `test/chart-bundle.test.tsx` says which line caused it.
124
124
  - **Tested in a real browser.** Tests assert computed pixels against the
125
125
  token the browser resolved. A dangling `var()` paints transparent, which the
126
126
  tests catch and jsdom cannot. Most tests pin a theme and run once; see
127
- "Which tests run in both themes".
127
+ "Which tests run where".
128
128
  - **Every component appears in the guide.** `guide/sections.tsx` is the visual
129
129
  check in light, dark and system-dark; add an entry with every variant and
130
130
  size when adding a component.
@@ -136,7 +136,10 @@ how-to-create-a-cortena-extension, audit rule P-10): the registered brand mark
136
136
  and the extension name top left, the avatar menu — the only settings entry
137
137
  point — top right, and one fixed bottom-right cluster holding the theme toggle
138
138
  then the help button. `BrandMark`, `BottomRightCluster`, `ThemeToggle`,
139
- `HelpButton` and `documentTitle` are exported alongside it.
139
+ `HelpButton` and `documentTitle` are exported alongside it, and so are
140
+ `HELP_PANEL_ID` and `NAV_DRAWER_ID` — the ids the shell gives the help region
141
+ and the mobile drawer, for a consumer wiring its own `aria-controls` to one of
142
+ them.
140
143
 
141
144
  The mark comes from `cortena-design/marks` by `extension.id`, which is the same
142
145
  file the Apps tile, the MCP app card, the agent pop-up pill and the favicon
@@ -147,6 +150,195 @@ favicon".
147
150
  `helpPanel` is a slot until `HelpPanel` lands (EXTBP-24); `help.source` is the
148
151
  functional document the panel will read.
149
152
 
153
+ ### Navigation
154
+
155
+ `nav` is the left sidebar, and it is the shell's so that five extensions do not
156
+ invent five of them (DESIGN-88):
157
+
158
+ ```tsx
159
+ <AppShell
160
+ extension={{ id: "tasks", name: "Tasks" }}
161
+ user={user}
162
+ nav={{
163
+ pathname, // from the router; see below
164
+ groups: [
165
+ { items: [{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard, href: "/", exact: true }] },
166
+ {
167
+ label: "Projects", // a labelled section
168
+ items: [
169
+ { id: "all", label: "All Projects", icon: FolderKanban, href: "/projects", exact: true },
170
+ { id: "alerts", label: "Alerts", icon: Bell, href: "/alerts", badge: 9 },
171
+ ],
172
+ },
173
+ ],
174
+ footer: <StorageMeter />, // the one thing that is not navigation
175
+ }}
176
+ />
177
+ ```
178
+
179
+ - **`icon`** is a lucide component or the id of a mark registered in
180
+ `cortena-design/marks`.
181
+ - **`badge`** is a count or a short string, painted from tokens. `0` and `""`
182
+ draw nothing — an empty chip is worse than no chip.
183
+ - **Active state** comes from one of three, in this order: `activeId` (the
184
+ item's id), `isActive(href, item)` (a callback), or `pathname` (compared to
185
+ each `href` as a prefix, or exactly for an item marked `exact`). Give none
186
+ and nothing is active: cortena-ui has no router dependency, and reading
187
+ `window.location` here would be right on first paint and stale after every
188
+ navigation.
189
+ - **react-router** passes `pathname: useLocation().pathname` and keeps
190
+ navigation client-side with `onNavigate`, which is called before the browser
191
+ follows the real `href`:
192
+
193
+ ```tsx
194
+ onNavigate: (item, event) => {
195
+ event.preventDefault();
196
+ navigate(item.href);
197
+ },
198
+ ```
199
+
200
+ The `href` stays real either way, so middle-click, "open in new tab" and
201
+ copy-link all still work: the shell does not call `onNavigate` at all for a
202
+ middle-click or a cmd/ctrl/shift/alt-click, so that guard does not belong in
203
+ every consumer.
204
+ - **`href` is compared as a plain path.** A value carrying a query or a hash
205
+ (`/reports?tab=open`, `/docs#intro`) does not prefix-match the pathname and
206
+ will never light up. Give the item its base path and resolve `activeId`
207
+ yourself.
208
+ - **Below 1024px** the sidebar becomes a menu button in the header and a
209
+ drawer, closed by Escape, by the scrim, or by following an item. It is a
210
+ real modal: focus moves to its close button, Tab cycles inside it, the rest
211
+ of the shell is `inert` while it is open, the page under it does not scroll,
212
+ and focus returns to the menu button on close.
213
+ - **`nav={null}`** renders no sidebar and no menu button. That is a legal
214
+ state, and the right one for an extension whose whole navigation lives
215
+ inside one page.
216
+
217
+ ### The header slot and the avatar menu
218
+
219
+ `headerSlot` sits between the extension name and the avatar; `menuItems` are
220
+ inserted between Settings and Log out. Both exist because the top bar was
221
+ closed and three extensions had something real to put in it — and both stay
222
+ narrow on purpose (§10):
223
+
224
+ | May go in `headerSlot` | May not |
225
+ | --- | --- |
226
+ | Live status about what is on screen — Assure's "Claude executing" pill | Navigation of any kind. That is `nav`, or it is in the page |
227
+ | The org, workspace or tenant the screen is scoped to — Tasks' "Cortena Labs" | The theme control. It is in the bottom-right cluster, once |
228
+ | **One** secondary action, text or icon, that belongs to the whole extension rather than to the page — Report an issue | Help. It is the help button, and its panel |
229
+ | | Settings, or anything that opens settings. The avatar menu is the only entry point |
230
+ | | A page-level action. Those are `PageHeader`'s `actions` |
231
+
232
+ `menuItems` takes destinations that are the user's rather than the screen's —
233
+ Assure's Permissions, a Report-issue dialog. Log out stays last and stays
234
+ behind its separator: an extension item cannot push itself under the
235
+ destructive action.
236
+
237
+ ### The bottom inset, and the two variables the shell publishes
238
+
239
+ `AppShell` sets `--ds-shell-header-height` and `--ds-shell-bottom-inset`, on
240
+ its own element and on `<html>`, so anything that portals out of the shell can
241
+ still tell where the chrome is. `<html>` has one pair of them and a page may
242
+ hold more than one shell — the token guide holds three — so the first shell to
243
+ mount owns the pair and the last to unmount clears it; every shell publishes
244
+ its own values on its own element regardless.
245
+
246
+ An extension with a bottom tab bar gives that bar `data-bottom-bar` and the
247
+ shell measures it — the measurement is 0 while the bar is hidden, so a
248
+ `lg:hidden` tab bar lifts the cluster on a phone and nowhere else. `bottomInset`
249
+ (a number of pixels, or any CSS length) sets the same distance by hand. Both
250
+ move the theme/help cluster **and** whatever `agentSlot` renders, which is what
251
+ the old per-extension `className="bottom-22 lg:bottom-6"` workaround could not
252
+ do.
253
+
254
+ ## Attribution: who did this
255
+
256
+ `Actor` is the only thing in the fleet that turns a recorded actor into words
257
+ (DESIGN-120, protocol §20 / audit rule P-26). Every extension stores the same
258
+ two columns on every write — the acting user and `via` — and every screen that
259
+ shows a trail has to answer the same question: did a person do this, or did
260
+ their agent? Seven extensions answering it themselves is seven answers, and
261
+ the one that prints the stored string raw shows `Amit (claude-code)`, which
262
+ names a CLI and says nothing about whether a human was in the loop.
263
+
264
+ ```tsx
265
+ <p>
266
+ <Actor userId={row.actorUserId} displayName={row.actorName} via={row.actorVia} /> changed the status
267
+ </p>
268
+ ```
269
+
270
+ **The input is the actor, never a label.** There is no `label`, `text` or
271
+ `children` prop and there will not be one: a consumer that can pass a
272
+ preformatted string eventually passes a differently formatted one, which is
273
+ exactly how the inconsistency arrived. The rules live in the component:
274
+
275
+ | `via` | `displayName` | reads as |
276
+ | --- | --- | --- |
277
+ | `user` | "Amit Sharma" | Amit Sharma |
278
+ | `user` | missing or blank | Someone |
279
+ | `agent` | "Amit Sharma" | Amit Sharma **(Agent)** |
280
+ | `agent` | missing or blank | Someone **(Agent)** |
281
+ | `system` | anything | System |
282
+
283
+ The suffix is fixed and caption-sized in `--ds-muted-foreground`. It is never
284
+ the agent's technical id or session — which agent ran is an audit question and
285
+ `actorAgentId` answers it; on screen the only thing that changes the reader's
286
+ next action is *that* an agent acted. An unknown or deleted user is `Someone`
287
+ and never the id: the server-side `actorLabel` in `packages/shared` falls back
288
+ to the id because a log line has no other anchor, and a screen does.
289
+
290
+ Three sizes. `inline` is a run of text in a sentence. `cell` is avatar-less
291
+ and one line, and truncates on its own inline-block box, inside the `td` that
292
+ truncates around it. `head` is avatar, name and time, for a feed row or a
293
+ comment header — `avatar={false}` keeps the stacked shape without the picture,
294
+ for a comment thread that has already shown it once. `at` and `surface` show on
295
+ hover at every size.
296
+
297
+ Times format as `14 Sep 2026, 19:32`: `en-GB` by default and hand-written, the
298
+ same way `DateField` does it, so one date is not spelled two ways on two
299
+ screens and a trail does not read differently on every machine. The zone stays
300
+ local, and `locale` overrides the format.
301
+
302
+ `title` and `dangerouslySetInnerHTML` are omitted from the props alongside
303
+ `children`, for the same reason: every route by which a caller could put its
304
+ own words on screen is closed.
305
+
306
+ The hover is not the only way to reach what it says. The same text is also in
307
+ the DOM, visually hidden (`sr-only`), in document order right after the name,
308
+ so a screen reader reads it where it is relevant and any ancestor whose role
309
+ takes its name from its contents — the `gridcell` a DataTable cell sits in, a
310
+ link, a button — includes it in that name. The trigger stays a plain `span`:
311
+ no tab stop, because making every actor in a 200-row grid focusable would
312
+ wreck keyboard navigation of it, and no `aria-label`, because a `role=generic`
313
+ element's label is dropped rather than announced. The avatar's initials and
314
+ `head`'s visible timestamp are `aria-hidden`, so neither is read twice.
315
+
316
+ A list uses the column helper rather than a custom cell:
317
+
318
+ ```tsx
319
+ const helper = createColumnHelper<AuditRow>();
320
+ const columns = helper.columns([
321
+ helper.accessor("action", { header: "Action", meta: { fill: true } }),
322
+ actorColumn(helper, {
323
+ actor: (row) => ({
324
+ userId: row.actorUserId,
325
+ displayName: row.actorName,
326
+ via: row.actorVia,
327
+ at: row.at,
328
+ surface: row.surface,
329
+ }),
330
+ }),
331
+ ]);
332
+ ```
333
+
334
+ It is an accessor column over `actorLabel`, not a display column, so the value
335
+ the table sorts, filters, searches and exports is the same string the cell
336
+ paints — a table sorted by **By** lands where the reader expects, and a CSV of
337
+ it reads like the screen. The header defaults to `By`.
338
+
339
+ `actorLabel`, `actorName` and `actorTime` are exported for the places a React
340
+ node cannot go: an `aria-label`, a CSV cell, a `title`.
341
+
150
342
  ## The agent pop-up
151
343
 
152
344
  `AgentChatPopup` is the extension's own Cortena Agent, in the corner of every
@@ -697,11 +889,12 @@ action is shaped like A2UI's client-to-server `action`, and the catalogue is a
697
889
  schema-per-component registry. Swapping the engine later is mechanical. Nothing
698
890
  from `@a2ui/*` is installed, so `vitest.config.ts` has no entry for it.
699
891
 
700
- ## Which tests run in both themes
892
+ ## Which tests run where
701
893
 
702
- `vitest.config.ts` has two projects. **`light`** runs every file once in a
703
- Chromium whose OS colour scheme is light; **`dark`** runs a second time, in a
704
- dark Chromium, only the files whose name ends `.theme.test.tsx`. Tag a test with
894
+ `vitest.config.ts` has four projects. **`light-1`, `light-2` and `light-3`**
895
+ between them run every file once, in a Chromium whose OS colour scheme is
896
+ light; **`dark`** runs a second time, in a dark Chromium, only the files whose
897
+ name ends `.theme.test.tsx`. Tag a test with
705
898
  that suffix when its result depends on the browser's own colour scheme — that
706
899
  is, when it asserts a theme-dependent value while no `data-theme` attribute is
707
900
  set, the `@media (prefers-color-scheme: dark)` branch of `tokens.css`. Nothing
@@ -713,6 +906,38 @@ test in a file needs the tag, move that test to a sibling `*.theme.test.tsx`
713
906
  rather than paying for the whole file twice — six such files exist today, and
714
907
  `pnpm test:dark` runs exactly them.
715
908
 
909
+ The three `light` projects are a wall-clock split, not a behaviour split
910
+ (DESIGN-86). Files run one at a time inside a project — parallel files made
911
+ overlay tests time out and let one file's pointer position leak into the next —
912
+ so 72 light files in one project meant 72 browser-page setups end to end, and
913
+ that, not the test work, was the suite's wall time. Vitest runs *projects* side
914
+ by side, so splitting the light files three ways makes the wall time the slowest
915
+ shard rather than the sum. Nothing about isolation changes: each shard is still
916
+ serial inside itself, and three light shards plus `dark` is four Chromium
917
+ contexts at once, the same kind of concurrency the suite has always had and as
918
+ many as an 8 GB laptop takes without swapping.
919
+
920
+ Measured on the machine this was written on: the full suite went from
921
+ **78.3 s** to **54.3 s** wall for the same 78 file runs and 812 tests, while
922
+ CPU time rose from 73 s to 95 s. It is a third off, not the two-thirds the file
923
+ counts suggest, and the gap is the fixed cost each project pays for itself — a
924
+ Vite server, the `optimizeDeps` pre-bundle above, and a browser launch, worth
925
+ roughly ten seconds before the first file runs — plus what four of those
926
+ contending for 8 GB costs the ones already running. Splitting further buys less
927
+ each time and costs memory sooner; measure before raising `LIGHT_SHARDS`.
928
+
929
+ A file's shard is `hash(path) % 3`, an FNV-1a of its path — deliberately not an
930
+ alphabetical split. Alphabetically, adding one file shifts every file after it
931
+ across a boundary, so shard membership churns on every new test; hashing the
932
+ path leaves every existing file where it was and puts only the new file
933
+ somewhere, and a file moves only when it is renamed. The spread stays close to
934
+ even on its own (21/27/24 over today's 72 files). You never choose a shard, and
935
+ nothing you write needs to know which one it is in: `pnpm test` selects changed
936
+ files across all of them, and a shard with nothing changed in it simply runs
937
+ nothing. If the shards ever drift badly out of balance, raise `LIGHT_SHARDS` in
938
+ `vitest.config.ts` — but keep the light shards plus `dark` at four contexts or
939
+ fewer unless the machine has more memory.
940
+
716
941
  ## Commands
717
942
 
718
943
  ```bash
@@ -743,6 +968,7 @@ src/styles/index.css what consumers import after the tokens: animation
743
968
  utilities and the Base UI data-attribute variants
744
969
  scripts/bundle-probe.mjs builds a consumer app per entry and weighs it
745
970
  test/ browser tests; setup.css is the consumer recipe verbatim
971
+ split by path hash across three concurrent light projects
746
972
  *.theme.test.tsx also run in a dark Chromium
747
973
  *.test.mjs are the Node-side bundle budgets
748
974
  guide/ Vite app: ?theme=light|dark|system frames, or all three
@@ -23,8 +23,8 @@ import { DateField, formatDateISO, parseDateISO } from "../components/date-field
23
23
  import { DataTable } from "../components/data-table/data-table.js";
24
24
  import { Chart } from "../components/chart/chart.js";
25
25
  import { Markdown } from "../components/markdown.js";
26
- import { AlertTriangle, CheckCircle2, Info, OctagonAlert, Terminal } from "lucide-react";
27
26
  import { jsx, jsxs } from "react/jsx-runtime";
27
+ import { AlertTriangle, CheckCircle2, Info, OctagonAlert, Terminal } from "lucide-react";
28
28
  import * as React from "react";
29
29
  //#region src/a2ui/views.tsx
30
30
  /**
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
  "use client";
3
3
  import { cn } from "../lib/cn.js";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
4
5
  import { Accordion } from "@base-ui/react/accordion";
5
6
  import { ChevronDown } from "lucide-react";
6
- import { jsx, jsxs } from "react/jsx-runtime";
7
7
  //#region src/components/accordion.tsx
8
8
  /**
9
9
  * Accordion.
@@ -0,0 +1,103 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ //#region src/components/actor.d.ts
4
+ /**
5
+ * Who acted, on one screen, in one component (DESIGN-120, protocol §20 /
6
+ * audit rule P-26).
7
+ *
8
+ * Every extension records the same two things on every write — the acting
9
+ * user and `via` — and every screen that shows a trail has to answer the same
10
+ * question: did a person do this, or did their agent? Seven extensions
11
+ * formatting that themselves is seven answers, and the one that prints the
12
+ * stored string raw shows `Amit (claude-code)`, which tells an operator the
13
+ * name of a CLI and not whether a human was in the loop.
14
+ *
15
+ * So the input is the resolved actor, never a label. There is deliberately no
16
+ * `label`, `text` or `children` prop: a consumer that could pass a string
17
+ * would eventually pass a differently formatted one, which is exactly how the
18
+ * inconsistency arrived. The rules below live here and nowhere else.
19
+ */
20
+ /**
21
+ * How the principal acted. `user` and `agent` are the protocol's two values
22
+ * (packages/shared `ActorVia`); `system` is the display-only third for a write
23
+ * the platform made on nobody's behalf — a retention sweep, a migration.
24
+ */
25
+ export type ActorVia = "user" | "agent" | "system";
26
+ /**
27
+ * inline — text only, sits inside a sentence: "Amit (Agent) changed the status".
28
+ * cell — a DataTable cell: avatar-less, one line, truncates.
29
+ * head — avatar, name and time, for a feed row or a comment header.
30
+ */
31
+ export type ActorSize = "inline" | "cell" | "head";
32
+ /**
33
+ * The suffix, fixed, in one place, so no screen can invent its own. It is
34
+ * never the agent's technical id or session: which agent ran is an audit
35
+ * question, and the audit table has `actorAgentId` for it. On screen the only
36
+ * thing that changes the reader's next action is *that* an agent acted.
37
+ */
38
+ export declare const ACTOR_AGENT_SUFFIX = "(Agent)";
39
+ /** What `via: "system"` reads as, whatever name came with the row. */
40
+ export declare const ACTOR_SYSTEM_NAME = "System";
41
+ /**
42
+ * An unknown or deleted user. Never the id: `usr_9f2c…` in a feed reads as
43
+ * noise to everyone who is not debugging, and the ids are in the audit table
44
+ * for the one person who is. The server-side `actorLabel` falls back to the
45
+ * id because a log line has no other anchor; a screen does.
46
+ */
47
+ export declare const ACTOR_UNKNOWN_NAME = "Someone";
48
+ /** What the shared actor resolver produces and what the list APIs return. */
49
+ export interface ActorValue {
50
+ /** The acting principal. Null for a system write, or a row that lost its user. */
51
+ userId: string | null;
52
+ /** The stored display name. Absent or empty means unknown or deleted. */
53
+ displayName?: string | null;
54
+ via: ActorVia;
55
+ avatarUrl?: string;
56
+ /** When it happened. Shown in `head` and in the hover for every size. */
57
+ at?: string | Date;
58
+ /** Where it happened — "Web", "MCP", "Tasks › Sprint 4". Hover only. */
59
+ surface?: string;
60
+ }
61
+ /** The name alone, before the suffix. */
62
+ export declare function actorName(actor: Pick<ActorValue, "displayName" | "via">): string;
63
+ /**
64
+ * The full label as a string: `"Amit"`, `"Amit (Agent)"`, `"System"`,
65
+ * `"Someone (Agent)"`.
66
+ *
67
+ * Exported for the places a React node cannot go — an `aria-label`, a CSV
68
+ * export, a `title` — and used by `actorColumn` as the column's sort and
69
+ * filter value, so a table sorts by what the reader sees.
70
+ */
71
+ export declare function actorLabel(actor: Pick<ActorValue, "displayName" | "via">): string;
72
+ /**
73
+ * `5 Jan 2026, 14:32` for en-GB; other locales via `Intl.DateTimeFormat`.
74
+ *
75
+ * `en-GB` by default and hand-written, exactly as `date-field.tsx` does it,
76
+ * for the same two reasons. The machine's own locale would render the same
77
+ * trail differently on every machine — the drift this component exists to
78
+ * remove — and `Intl`'s `dateStyle: "medium"` says "14 Sept 2026" where
79
+ * `DateField` says "14 Sep 2026", so two screens in one product would spell
80
+ * one date two ways. The zone stays local: what time it was *for the reader*
81
+ * is the question a feed answers.
82
+ */
83
+ export declare function actorTime(at: string | Date, locale?: string): string | undefined;
84
+ export interface ActorProps extends ActorValue, Omit<React.ComponentProps<"span">, "children" | "color" | "title" | "dangerouslySetInnerHTML"> {
85
+ /** @default "inline" */
86
+ size?: ActorSize;
87
+ /** Override the per-size default: off for `inline` and `cell`, on for `head`. */
88
+ avatar?: boolean;
89
+ /** Formatting locale for `at`. @default "en-GB" */
90
+ locale?: string;
91
+ }
92
+ /**
93
+ * Actor — "Amit", "Amit (Agent)", "System", "Someone".
94
+ *
95
+ * @example
96
+ * <p>
97
+ * <Actor userId={r.actorUserId} displayName={r.actorName} via={r.actorVia} /> changed the status
98
+ * </p>
99
+ */
100
+ declare function Actor({ userId, displayName, via, avatarUrl, at, surface, size, avatar, locale, className, ...props }: ActorProps): React.JSX.Element;
101
+ //#endregion
102
+ export { Actor };
103
+ //# sourceMappingURL=actor.d.ts.map