cortena-ui 1.13.0 → 1.15.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 (72) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +293 -0
  3. package/dist/a2ui/views.js +1 -1
  4. package/dist/app.d.ts +6 -0
  5. package/dist/app.js +5 -0
  6. package/dist/components/accordion.js +1 -1
  7. package/dist/components/actor.d.ts +103 -0
  8. package/dist/components/actor.js +175 -0
  9. package/dist/components/actor.js.map +1 -0
  10. package/dist/components/admin-permissions/index.js +11 -0
  11. package/dist/components/admin-permissions/role-assignment.js +1 -1
  12. package/dist/components/agent-chat-popup.js +1 -1
  13. package/dist/components/agent-chat.js +1 -1
  14. package/dist/components/app/agent-mount.d.ts +53 -0
  15. package/dist/components/app/agent-mount.js +40 -0
  16. package/dist/components/app/agent-mount.js.map +1 -0
  17. package/dist/components/app/cortena-app.d.ts +184 -0
  18. package/dist/components/app/cortena-app.js +422 -0
  19. package/dist/components/app/cortena-app.js.map +1 -0
  20. package/dist/components/app/launch.d.ts +128 -0
  21. package/dist/components/app/launch.js +149 -0
  22. package/dist/components/app/launch.js.map +1 -0
  23. package/dist/components/app/settings.d.ts +132 -0
  24. package/dist/components/app/settings.js +424 -0
  25. package/dist/components/app/settings.js.map +1 -0
  26. package/dist/components/app-shell.js +1 -1
  27. package/dist/components/avatar.js +1 -1
  28. package/dist/components/breadcrumb.js +1 -1
  29. package/dist/components/calendar.js +1 -1
  30. package/dist/components/checkbox.js +1 -1
  31. package/dist/components/combobox.js +1 -1
  32. package/dist/components/command.js +1 -1
  33. package/dist/components/data-table/data-table.js +1 -1
  34. package/dist/components/data-table/index.d.ts +2 -1
  35. package/dist/components/data-table/parts.js +1 -1
  36. package/dist/components/data-table/system-columns.d.ts +46 -0
  37. package/dist/components/data-table/system-columns.js +47 -2
  38. package/dist/components/data-table/system-columns.js.map +1 -1
  39. package/dist/components/data-table/use-data-table.js +1 -19
  40. package/dist/components/data-table/use-data-table.js.map +1 -1
  41. package/dist/components/date-field.js +1 -1
  42. package/dist/components/date-picker.js +1 -1
  43. package/dist/components/dropdown-menu.js +1 -1
  44. package/dist/components/dropzone.js +1 -1
  45. package/dist/components/error-banner.js +1 -1
  46. package/dist/components/help-panel.js +1 -1
  47. package/dist/components/markdown.js +1 -1
  48. package/dist/components/rich-text-editor/toolbar.js +1 -1
  49. package/dist/components/select.js +1 -1
  50. package/dist/components/sortable-list.js +1 -1
  51. package/dist/components/toast.js +1 -1
  52. package/dist/core.d.ts +5 -4
  53. package/dist/core.js +4 -3
  54. package/dist/data-table.d.ts +2 -1
  55. package/dist/data-table.js +2 -1
  56. package/dist/index.d.ts +8 -6
  57. package/dist/index.js +5 -3
  58. package/dist/lib/dev.js +44 -0
  59. package/dist/lib/dev.js.map +1 -0
  60. package/package.json +5 -1
  61. package/src/components/actor.tsx +297 -0
  62. package/src/components/app/agent-mount.tsx +103 -0
  63. package/src/components/app/cortena-app.tsx +783 -0
  64. package/src/components/app/launch.ts +227 -0
  65. package/src/components/app/settings.tsx +669 -0
  66. package/src/components/data-table/index.tsx +2 -0
  67. package/src/components/data-table/system-columns.tsx +73 -0
  68. package/src/components/data-table/use-data-table.ts +1 -19
  69. package/src/entries/app.ts +73 -0
  70. package/src/entries/core.ts +2 -0
  71. package/src/index.ts +12 -5
  72. package/src/lib/dev.ts +48 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,145 @@
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.15.0
7
+
8
+ ### Added
9
+
10
+ - **`cortena-ui/app` — `CortenaApp`, the whole frame** (DESIGN-119). Everything
11
+ around an extension's content, mounted once instead of assembled eight ways:
12
+ the launch, `LoginScreen` with the methods the org advertises, the signed-out
13
+ state with a `returnTo`, exactly one session guard, `AppShell`,
14
+ `AgentChatPopup` in `agentSlot`, the tab title, and the standard Profile /
15
+ Settings / Permissions pages. The 2026-09-14 re-audit is what it replaces:
16
+ seven local login pages across eight extensions, three of them with dummy
17
+ users; a guard mounted with a different subset of its behaviour in each; a
18
+ pop-up mounted without the user's token or the agent slug; a Settings item
19
+ greyed out in every one of the six shell swaps.
20
+ - **A `host` role.** `role="host"` leaves the agent slot empty, because
21
+ CortenaWeb *is* the chat and its own guard is the one that renews and pushes
22
+ tokens into framed extensions. Host-only navigation is `nav` and `headerSlot`
23
+ configuration, not a special case inside the composition. cortena-auth's
24
+ hosted pages are the third consumer.
25
+ - **`readCortenaLaunch({ hostOrigins })`**, and with it the rule that a framed
26
+ extension trusts the host's guard **only when the host declares it**. The
27
+ declaration — a `cortenaGuard=host` parameter beside the token, or the first
28
+ bridge message — is origin-checked against an allow-list the app supplies
29
+ from its own configuration, never from the request. Declared: no guard here,
30
+ and renewed tokens arrive through `session.onTokenRenewed`. Absent: a
31
+ countdown from the access token's own `exp`, the standard warning with no
32
+ Continue, then the signed-out state. Not framed: the full `SessionGuard`. One
33
+ of the three, ever — never two countdowns for one session, and never the old
34
+ behaviour of inferring "the host is guarding me" from the absence of a
35
+ refresh token.
36
+ - **The standard pages**, at `/me`, `/settings` and `/settings/permissions`:
37
+ Profile, then Permissions from `AdminPermissions` against the §14.3 admin
38
+ routes when the caller may administer, then the extension's own settings from
39
+ a declared zod schema — or one plain line, "Nothing to configure for <name>
40
+ yet." The avatar menu's Settings item is never disabled again. Exported
41
+ individually as `CortenaSettingsPage`, `CortenaProfilePage` and
42
+ `ProfileSection`, with `matchCortenaStandardRoute` for an app that would
43
+ rather place them under its own router.
44
+ - **`session.hostOrigins`**, and with it the bridge path for the declaration:
45
+ the composition listens for the host's first
46
+ `{ type: "cortena-session", guarded, accessToken }` and takes the declaration
47
+ and any renewal from it. Prefer this over the launch parameter —
48
+ `event.origin` is the browser's and cannot be forged from inside the sending
49
+ page, where the parameter's check falls back to `document.referrer` on a
50
+ browser with no `ancestorOrigins`. The gap is downgrade-only (a forged
51
+ declaration mounts *no* guard, never a second one) and is written down in
52
+ `launch.ts`.
53
+ - **`session.expiresAt`**, for a token whose `exp` cannot be read — an opaque
54
+ token, or one the decoder failed on. Exactly `SessionToken`'s own escape
55
+ hatch, and the way to keep such a session guarded rather than landing on the
56
+ signed-out screen.
57
+ - **A development warning** where a non-framed launch omits `session.refresh`.
58
+ Without it the guard reaches the proactive refresh at about 75% of the access
59
+ token's life, is rejected, and signs the user out — §10.4.1's half-dead
60
+ screen arriving from the inside, with nothing on screen to explain it.
61
+ - **`CortenaApp` is router- and environment-agnostic**: `pathname` in,
62
+ `onNavigate` out, real `href`s, configuration and tokens as props, no browser
63
+ API touched during a first render, and `documentTitle={false}` for a surface
64
+ that does not own the tab. That is what lets CortenaWeb adopt it inside a
65
+ Next.js root layout instead of keeping a second shell.
66
+
67
+ ### Fixed
68
+
69
+ - **A signed-out screen you could not leave.** The state was cleared only where
70
+ a *host* pushed a token, and that path exists only in a host-guarded frame.
71
+ On a standalone launch or an undeclared frame the signed-out screen
72
+ short-circuits the whole render, so a successful sign-in set the token and
73
+ the user stayed on "You were signed out" for ever with nothing on screen to
74
+ explain it. A new token now clears it wherever it comes from — the launch,
75
+ the app's own store, `session.onTokenRenewed`, or the bridge.
76
+ - **A frame with an unreadable token was silently unguarded.** A token with no
77
+ readable `exp` took the countdown branch and then counted nothing: no
78
+ warning, no expiry, no signed-out state — the exact state decision (b)
79
+ removed, arriving through the back door. It is now reported as already over,
80
+ which lands on the signed-out screen with a `returnTo`, and says why in a
81
+ development warning. Warning rather than expiring was considered and
82
+ rejected: a warning's entire content is a number there is none of, and its
83
+ only way out is Log out, which lands on the same screen anyway.
84
+ - **A rejected sign-out** left an unhandled rejection and the user on a shell
85
+ whose session was over. It is caught, and the screen stops pretending either
86
+ way — `SessionGuard` makes the same call.
87
+ - **`settings.load()` is no longer called** for a declaration with no readable
88
+ fields. The card renders one sentence; a GET behind it is a request nothing
89
+ on screen is waiting for.
90
+
91
+ ### Notes
92
+
93
+ - **The frame is light and stays light.** `cortena-ui/app` measures **285.6 kB
94
+ eager** against a 340 kB ceiling — the shell probe's 212.8 kB plus the
95
+ standard pages — with **no heavy package eager at all**. `AdminPermissions`
96
+ (TanStack Table, and exceljs behind `DataTable`'s export) and the agent mount
97
+ (`AgentChat`, react-markdown, the A2UI renderer) are both `React.lazy`, and
98
+ `test/bundle-probe.test.mjs` measures that rather than assuming it.
99
+ - **`@ag-ui/client` is still reachable from `cortena-ui/agent-chat` alone.** The
100
+ transport factory is passed in as `agent.createClient` rather than imported,
101
+ so an extension writes one named import and the composition still owns every
102
+ argument the pop-up is built with.
103
+ - **This package imports no zod.** A settings schema is typed structurally and
104
+ only ever called — `safeParse` to validate, `.shape` to derive fields — so it
105
+ works across zod 3 and 4 and costs the frame nothing.
106
+ - The favicon and the `<title>` no longer need hand-copied files: the generator
107
+ ships as a Vite plugin from `cortena-design/vite` (cortena-design 3.6.0).
108
+
109
+ ## 1.14.0
110
+
111
+ ### Added
112
+
113
+ - **`Actor` — one component for "who did this"** (DESIGN-120). The protocol
114
+ records the acting user and `via` on every write (§20, audit rule P-26) and
115
+ expects a trail to read "Name (Agent)". Seven extensions were each about to
116
+ format that themselves: Tasks prints the stored string raw as
117
+ `Amit (claude-code)`, Assure keeps a rich JSON actor and shows none of it.
118
+ `Actor` takes the resolved actor — `{ userId, displayName, via, avatarUrl,
119
+ at, surface }` — and **has no string input**: there is no `label`, `text` or
120
+ `children` prop, because a consumer that can pass a preformatted label is
121
+ how the inconsistency arrived. The rules it encodes: a direct action is the
122
+ name alone; an agent action is the name and a fixed ` (Agent)` in the
123
+ caption/muted style, never the agent's technical id or session; `system` is
124
+ `System`; a user with no stored display name is `Someone`, never a raw id.
125
+ Three sizes — `inline` for a sentence, `cell` for a list, `head` for a feed
126
+ row or comment with avatar and time — and a hover showing surface and time
127
+ wherever either is given. Times are `en-GB` by default and hand-written, the
128
+ same way `DateField` formats dates, so a trail does not read differently on
129
+ every machine; `locale` overrides it. `title` and `dangerouslySetInnerHTML`
130
+ are omitted from the props alongside `children`. The hover is not the only
131
+ way to reach what it says: the same text is in the DOM visually hidden, in
132
+ document order after the name, so a screen reader reads it and a `gridcell`
133
+ or link around the actor takes it into its own name. The avatar's initials
134
+ and the visible timestamp are `aria-hidden`, so neither is announced twice.
135
+ - **`actorColumn()`** in `cortena-ui/data-table`, so a list needs no custom
136
+ cell. It is an accessor column over the label rather than a display column,
137
+ so the value the table sorts, filters, searches and exports is the string
138
+ the cell paints. The header defaults to `By`.
139
+ - **`actorLabel`, `actorName`, `actorTime`** and the three wordings
140
+ (`ACTOR_AGENT_SUFFIX`, `ACTOR_SYSTEM_NAME`, `ACTOR_UNKNOWN_NAME`) are
141
+ exported for the places a React node cannot go — an `aria-label`, a CSV
142
+ cell, a `title`. `Actor` and its types come from `cortena-ui/core` and the
143
+ root barrel; `actorColumn` from `cortena-ui/data-table`.
144
+
6
145
  ## 1.13.0
7
146
 
8
147
  ### Added
package/README.md CHANGED
@@ -36,6 +36,7 @@ consumer's bundler getting tree-shaking right:
36
36
  | `cortena-ui/a2ui` | the A2UI catalogue and renderer | most of the package, by design |
37
37
  | `cortena-ui/sortable-list` | `SortableList`, `SortableHandle`, `arrayMove` | dnd-kit |
38
38
  | `cortena-ui/agent-chat` | `AgentChatPopup`, `AgentChat`, `createAguiAgentChatClient` | the A2UI catalogue, plus `@ag-ui/client` (rxjs, zod 3, uuid, protobuf) |
39
+ | `cortena-ui/app` | `CortenaApp` — the whole frame, and `readCortenaLaunch` | 285.6 kB eagerly; the standard pages that are heavy arrive in chunks of their own |
39
40
  | `cortena-ui/rich-text-editor` | `RichTextEditor`, `parseMarkdown`, `serializeMarkdown` | TipTap on ProseMirror, 619 kB — the heaviest entry in the package |
40
41
 
41
42
  Each component has exactly one home, so the barrel re-exports every entry
@@ -129,6 +130,210 @@ regression; `test/chart-bundle.test.tsx` says which line caused it.
129
130
  check in light, dark and system-dark; add an entry with every variant and
130
131
  size when adding a component.
131
132
 
133
+ ## The frame
134
+
135
+ `AppShell` owns what renders after sign-in. Everything around it — the sign-in
136
+ screen, the signed-out state, the session guard, the agent pop-up, the tab
137
+ title and the Profile / Settings / Permissions pages — used to be each
138
+ extension's to assemble, and a 2026-09-14 re-audit of eight of them found eight
139
+ answers: seven local login pages, three still with dummy users; a guard mounted
140
+ with a different subset of its behaviour in each; a pop-up mounted without the
141
+ user's token or the agent slug; a Settings item greyed out in every shell.
142
+
143
+ `cortena-ui/app` is the assembly (DESIGN-119):
144
+
145
+ ```tsx
146
+ // main.tsx — synchronously, before createRoot (§7.3)
147
+ import { readCortenaLaunch } from "cortena-ui/app";
148
+ const launch = readCortenaLaunch({ hostOrigins: [config.cortenaWebOrigin] });
149
+
150
+ // App.tsx
151
+ import { CortenaApp } from "cortena-ui/app";
152
+ import { createAguiAgentChatClient } from "cortena-ui/agent-chat";
153
+
154
+ <CortenaApp
155
+ extension={{ id: "tasks", name: "Tasks" }}
156
+ user={user}
157
+ auth={{ publicAuthUrl, orgId, methods, onPassword, onProvider }}
158
+ session={{ launch, getToken, refresh, signOut, onTokenRenewed }}
159
+ nav={{ groups, pathname, onNavigate: (item, e) => { e.preventDefault(); navigate(item.href); } }}
160
+ agent={{ slug: "tasks", gatewayUrl: "/api/agent", createClient: createAguiAgentChatClient, relay, tokensCss }}
161
+ permissions={{ api: adminApi, canAdminister: isAdmin }}
162
+ settings={{ schema, load, save }}
163
+ help={{ source: HELP_DOCUMENT_URL }}
164
+ pathname={pathname}
165
+ onNavigate={(href) => navigate(href)}
166
+ documentTitle={pageTitle}
167
+ >
168
+ <Outlet />
169
+ </CortenaApp>
170
+ ```
171
+
172
+ It does things in this order, and the order is the contract:
173
+
174
+ 1. the launch, from `session.launch`;
175
+ 2. no token → `LoginScreen` with the methods the org advertises;
176
+ 3. the session ended → `LoginScreen` with `signedOut` and a `returnTo`;
177
+ 4. exactly one session guard, at the top, above the shell and the pop-up alike;
178
+ 5. `AppShell`, with Profile / Settings / Log out in the avatar menu;
179
+ 6. `AgentChatPopup` in `agentSlot`, in the `extension` role only;
180
+ 7. the standard pages when `pathname` names one, your `children` otherwise.
181
+
182
+ ### It is a client component, and router-agnostic
183
+
184
+ Navigation is `pathname` in and `onNavigate` out, over real `href`s, exactly as
185
+ `AppShell` already does. Configuration and tokens are props: the composition
186
+ fetches nothing, reads no browser API during a first render, and does not
187
+ assume it owns the document — `documentTitle={false}` turns off even the tab
188
+ title. That is not style. The third consumer is CortenaWeb, in a `host` role,
189
+ inside its own Next.js root layout; without every one of those the fleet keeps
190
+ two shells.
191
+
192
+ `role="host"` leaves the agent slot empty, because the host *is* the chat, and
193
+ its own guard is the one that renews and pushes tokens into framed extensions.
194
+ Host-only navigation — an Apps list, an org switcher, platform admin — is `nav`
195
+ and `headerSlot` configuration, not a special case in here.
196
+
197
+ ### Reading the launch, and who guards the session
198
+
199
+ A framed extension cannot refresh: CortenaWeb holds the refresh token and hands
200
+ down access tokens (`refreshToken=none`). Inferring "the host is guarding me"
201
+ from that absence is trust by assumption — a frame opened by anything at all
202
+ got no warning and no signed-out state, and nothing said so. So:
203
+
204
+ | the launch | what mounts |
205
+ | --- | --- |
206
+ | framed, and the host **declared** it guards the session | no guard here; renewed tokens arrive through `session.onTokenRenewed` |
207
+ | framed, and it did not | a countdown from the access token's own `exp`, the standard warning with no Continue, then the signed-out state with a `returnTo` |
208
+ | not framed | the full `SessionGuard`: idle clock, proactive refresh, cross-tab `BroadcastChannel` |
209
+
210
+ One of the three, ever. Never two countdowns for one session.
211
+
212
+ There are two ways the declaration can arrive, and they are not equally strong.
213
+
214
+ ```tsx
215
+ // The launch parameter: ?token=…&cortenaGuard=host
216
+ const launch = readCortenaLaunch({ hostOrigins: [config.cortenaWebOrigin] });
217
+
218
+ // The first bridge message. Give the same allow-list to the composition and it
219
+ // listens for `{ type: "cortena-session", guarded: true, accessToken? }` itself.
220
+ <CortenaApp session={{ launch, hostOrigins: [config.cortenaWebOrigin], … }} />
221
+ ```
222
+
223
+ Both are checked against an allow-list **your app supplies from its own
224
+ configuration**, never against anything in the request. With no `hostOrigins`
225
+ nothing is ever trusted, which is the safe direction: the frame then runs its
226
+ own countdown rather than trusting a parent it cannot name.
227
+
228
+ **Prefer the bridge.** `readCortenaSessionMessage` checks `event.origin`, which
229
+ the browser sets from the sending document and which cannot be forged from
230
+ inside the page that sent it. The launch parameter is checked against
231
+ `ancestorOrigins`, which is WebKit/Blink only; everywhere else the input is
232
+ `document.referrer`, and an open redirect on the CortenaWeb origin can produce
233
+ one that reads as CortenaWeb's. The failure is downgrade-only — a forged
234
+ declaration mounts *no* guard, it cannot mount a second one or obtain a token —
235
+ but "no guard" is the state decision (b) exists to remove, so pass
236
+ `hostOrigins` and let the host say it on the bridge. `launch.ts` carries the
237
+ whole note.
238
+
239
+ Token renewals arrive the same way: over the bridge where `hostOrigins` is set,
240
+ or through `session.onTokenRenewed` for an app that would rather own the
241
+ listener. Either one, and a token the app itself signs in with, clears the
242
+ signed-out state.
243
+
244
+ Call it in your store's initialiser, synchronously, before `createRoot`. §7.3
245
+ is the single most common embedding failure: a token read in an effect is read
246
+ after a `ProtectedRoute` has already redirected and stripped the query string,
247
+ and the user sees a login page inside CortenaWeb with nothing to explain it.
248
+
249
+ `session.signOut` is optional, and omitting it is meaningful: in a framed launch
250
+ with no host sign-out message to send, the avatar menu's Log out is disabled
251
+ rather than silently doing nothing.
252
+
253
+ ### The standard pages
254
+
255
+ `/me`, `/settings` and `/settings/permissions` are the composition's, reachable
256
+ from the avatar menu, and the Settings item is **never** disabled:
257
+
258
+ - **Profile** — the person's own name, avatar and account link. cortena-auth
259
+ owns the user record, so the page shows it and links to it rather than
260
+ pretending an extension can edit it.
261
+ - **Permissions** — `AdminPermissions` against the §14.3 admin routes, rendered
262
+ only when `permissions.canAdminister`. Absent otherwise, not disabled: "you
263
+ may not" is not a screen. The admin tabs push
264
+ `/settings/permissions/<screen>`, so they are deep-linkable.
265
+
266
+ **`canAdminister` comes from your own permissions client, against the §14.3
267
+ routes — never from an `orgRole` claim on the JWT.** Whether somebody may
268
+ administer *your extension* is your `may()`'s answer over your own matrix
269
+ (§14.2, §14.4); cortena-auth's org role says who owns the organisation, which
270
+ is a different question and one the bootstrap already answered once. Reading
271
+ the claim looks equivalent, hides the screen from an administrator your
272
+ matrix granted, shows it to an org owner your matrix did not, and neither is
273
+ visible until somebody complains. This flag only decides whether the section
274
+ renders; the routes behind it enforce the real thing either way.
275
+ - **The extension's own settings** — whatever it declares, or one plain line:
276
+ "Nothing to configure for <name> yet." An extension that later gains settings
277
+ declares a schema rather than building a page.
278
+
279
+ The routing is your router's. The composition compares `pathname` to the three
280
+ paths and renders its own page instead of `children` when one matches, so it
281
+ works under any router at all — mount it as a layout and give the router a
282
+ route for those paths that renders nothing of its own:
283
+
284
+ ```tsx
285
+ // react-router
286
+ <Route element={<Frame />}>
287
+ <Route path="/me" element={null} />
288
+ <Route path="/settings/*" element={null} />
289
+ …
290
+ </Route>
291
+
292
+ // Next.js: app/me/page.tsx and app/settings/[[...rest]]/page.tsx, each
293
+ // rendering null. The frame is in the layout, and it is "use client".
294
+ ```
295
+
296
+ `matchCortenaStandardRoute(pathname)` is exported for an app that would rather
297
+ place `CortenaSettingsPage` and `CortenaProfilePage` itself: it is the same
298
+ rule, not a second copy of it.
299
+
300
+ ### The settings declaration
301
+
302
+ ```tsx
303
+ const settings = z.object({
304
+ alertThreshold: z.number().describe("Raise an alert when a stream falls this far behind."),
305
+ pauseOnFailure: z.boolean(),
306
+ alertChannel: z.enum(["email", "channels", "none"]),
307
+ });
308
+
309
+ <CortenaApp settings={{ schema: settings, load, save }} … />
310
+ ```
311
+
312
+ The fields are derived from the object's shape, and `safeParse` validates a
313
+ save. This package imports no zod: the schema is typed structurally and only
314
+ ever called, so it works across zod 3 and 4, whose internals differ, and costs
315
+ the frame nothing. Reading the shape is deliberately tolerant — a schema it
316
+ cannot read gives no fields, which draws the "nothing to configure" line rather
317
+ than throwing on a settings page.
318
+
319
+ Pass `fields` where the schema alone does not say enough — a select's options,
320
+ a longer description, a textarea. `load` and `save` are the extension's own
321
+ `/settings` route; the composition never stores anything.
322
+
323
+ ### What it does not do
324
+
325
+ There is **no consent route**. cortena-auth is the authorisation server and
326
+ renders consent; the content comes from the `scopes` section of the extension's
327
+ own `x-cortena` identity block, which cortena-auth reads from the registered
328
+ in-cluster address. An extension ships no consent screen.
329
+
330
+ And `@ag-ui/client` is reachable from `cortena-ui/agent-chat` and nowhere else,
331
+ including here. The transport factory is a prop — `agent.createClient` — rather
332
+ than an import, which is one named import for the extension and leaves the
333
+ composition owning every argument the pop-up is built with: the user's token,
334
+ read fresh on every request, and the agent slug. Those are the two an extension
335
+ shipped without.
336
+
132
337
  ## Shell chrome
133
338
 
134
339
  `AppShell` is the chrome every extension wears (§9 of
@@ -251,6 +456,94 @@ move the theme/help cluster **and** whatever `agentSlot` renders, which is what
251
456
  the old per-extension `className="bottom-22 lg:bottom-6"` workaround could not
252
457
  do.
253
458
 
459
+ ## Attribution: who did this
460
+
461
+ `Actor` is the only thing in the fleet that turns a recorded actor into words
462
+ (DESIGN-120, protocol §20 / audit rule P-26). Every extension stores the same
463
+ two columns on every write — the acting user and `via` — and every screen that
464
+ shows a trail has to answer the same question: did a person do this, or did
465
+ their agent? Seven extensions answering it themselves is seven answers, and
466
+ the one that prints the stored string raw shows `Amit (claude-code)`, which
467
+ names a CLI and says nothing about whether a human was in the loop.
468
+
469
+ ```tsx
470
+ <p>
471
+ <Actor userId={row.actorUserId} displayName={row.actorName} via={row.actorVia} /> changed the status
472
+ </p>
473
+ ```
474
+
475
+ **The input is the actor, never a label.** There is no `label`, `text` or
476
+ `children` prop and there will not be one: a consumer that can pass a
477
+ preformatted string eventually passes a differently formatted one, which is
478
+ exactly how the inconsistency arrived. The rules live in the component:
479
+
480
+ | `via` | `displayName` | reads as |
481
+ | --- | --- | --- |
482
+ | `user` | "Amit Sharma" | Amit Sharma |
483
+ | `user` | missing or blank | Someone |
484
+ | `agent` | "Amit Sharma" | Amit Sharma **(Agent)** |
485
+ | `agent` | missing or blank | Someone **(Agent)** |
486
+ | `system` | anything | System |
487
+
488
+ The suffix is fixed and caption-sized in `--ds-muted-foreground`. It is never
489
+ the agent's technical id or session — which agent ran is an audit question and
490
+ `actorAgentId` answers it; on screen the only thing that changes the reader's
491
+ next action is *that* an agent acted. An unknown or deleted user is `Someone`
492
+ and never the id: the server-side `actorLabel` in `packages/shared` falls back
493
+ to the id because a log line has no other anchor, and a screen does.
494
+
495
+ Three sizes. `inline` is a run of text in a sentence. `cell` is avatar-less
496
+ and one line, and truncates on its own inline-block box, inside the `td` that
497
+ truncates around it. `head` is avatar, name and time, for a feed row or a
498
+ comment header — `avatar={false}` keeps the stacked shape without the picture,
499
+ for a comment thread that has already shown it once. `at` and `surface` show on
500
+ hover at every size.
501
+
502
+ Times format as `14 Sep 2026, 19:32`: `en-GB` by default and hand-written, the
503
+ same way `DateField` does it, so one date is not spelled two ways on two
504
+ screens and a trail does not read differently on every machine. The zone stays
505
+ local, and `locale` overrides the format.
506
+
507
+ `title` and `dangerouslySetInnerHTML` are omitted from the props alongside
508
+ `children`, for the same reason: every route by which a caller could put its
509
+ own words on screen is closed.
510
+
511
+ The hover is not the only way to reach what it says. The same text is also in
512
+ the DOM, visually hidden (`sr-only`), in document order right after the name,
513
+ so a screen reader reads it where it is relevant and any ancestor whose role
514
+ takes its name from its contents — the `gridcell` a DataTable cell sits in, a
515
+ link, a button — includes it in that name. The trigger stays a plain `span`:
516
+ no tab stop, because making every actor in a 200-row grid focusable would
517
+ wreck keyboard navigation of it, and no `aria-label`, because a `role=generic`
518
+ element's label is dropped rather than announced. The avatar's initials and
519
+ `head`'s visible timestamp are `aria-hidden`, so neither is read twice.
520
+
521
+ A list uses the column helper rather than a custom cell:
522
+
523
+ ```tsx
524
+ const helper = createColumnHelper<AuditRow>();
525
+ const columns = helper.columns([
526
+ helper.accessor("action", { header: "Action", meta: { fill: true } }),
527
+ actorColumn(helper, {
528
+ actor: (row) => ({
529
+ userId: row.actorUserId,
530
+ displayName: row.actorName,
531
+ via: row.actorVia,
532
+ at: row.at,
533
+ surface: row.surface,
534
+ }),
535
+ }),
536
+ ]);
537
+ ```
538
+
539
+ It is an accessor column over `actorLabel`, not a display column, so the value
540
+ the table sorts, filters, searches and exports is the same string the cell
541
+ paints — a table sorted by **By** lands where the reader expects, and a CSV of
542
+ it reads like the screen. The header defaults to `By`.
543
+
544
+ `actorLabel`, `actorName` and `actorTime` are exported for the places a React
545
+ node cannot go: an `aria-label`, a CSV cell, a `title`.
546
+
254
547
  ## The agent pop-up
255
548
 
256
549
  `AgentChatPopup` is the extension's own Cortena Agent, in the corner of every
@@ -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
  /**
package/dist/app.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ "use client";
2
+ import { CORTENA_GUARD_HOST, CORTENA_GUARD_PARAM, CORTENA_SESSION_MESSAGE, CortenaLaunch, CortenaSessionMessage, ReadCortenaLaunchOptions, isHostGuardDeclared, readCortenaLaunch, readCortenaSessionMessage } from "./components/app/launch.js";
3
+ import { CortenaAppAgent } from "./components/app/agent-mount.js";
4
+ import { CortenaAppPermissions, CortenaAppUser, CortenaProfilePage, CortenaSettingsControl, CortenaSettingsDeclaration, CortenaSettingsField, CortenaSettingsPage, CortenaSettingsPageProps, CortenaSettingsSchema, ProfileSection, ProfileSectionProps, settingsFields } from "./components/app/settings.js";
5
+ import { CORTENA_STANDARD_ROUTES, CortenaApp, CortenaAppAuth, CortenaAppExtension, CortenaAppProps, CortenaAppRoutes, CortenaSessionAdapter, CortenaStandardRoute, matchCortenaStandardRoute } from "./components/app/cortena-app.js";
6
+ export { CORTENA_GUARD_HOST, CORTENA_GUARD_PARAM, CORTENA_SESSION_MESSAGE, CORTENA_STANDARD_ROUTES, CortenaApp, type CortenaAppAgent, type CortenaAppAuth, type CortenaAppExtension, type CortenaAppPermissions, type CortenaAppProps, type CortenaAppRoutes, type CortenaAppUser, type CortenaLaunch, CortenaProfilePage, type CortenaSessionAdapter, type CortenaSessionMessage, type CortenaSettingsControl, type CortenaSettingsDeclaration, type CortenaSettingsField, CortenaSettingsPage, type CortenaSettingsPageProps, type CortenaSettingsSchema, type CortenaStandardRoute, ProfileSection, type ProfileSectionProps, type ReadCortenaLaunchOptions, isHostGuardDeclared, matchCortenaStandardRoute, readCortenaLaunch, readCortenaSessionMessage, settingsFields };
package/dist/app.js ADDED
@@ -0,0 +1,5 @@
1
+ "use client";
2
+ import { CORTENA_GUARD_HOST, CORTENA_GUARD_PARAM, CORTENA_SESSION_MESSAGE, isHostGuardDeclared, readCortenaLaunch, readCortenaSessionMessage } from "./components/app/launch.js";
3
+ import { CortenaProfilePage, CortenaSettingsPage, ProfileSection, settingsFields } from "./components/app/settings.js";
4
+ import { CORTENA_STANDARD_ROUTES, CortenaApp, matchCortenaStandardRoute } from "./components/app/cortena-app.js";
5
+ export { CORTENA_GUARD_HOST, CORTENA_GUARD_PARAM, CORTENA_SESSION_MESSAGE, CORTENA_STANDARD_ROUTES, CortenaApp, CortenaProfilePage, CortenaSettingsPage, ProfileSection, isHostGuardDeclared, matchCortenaStandardRoute, readCortenaLaunch, readCortenaSessionMessage, settingsFields };
@@ -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