@gemboss/ui 0.25.0 → 0.32.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.
package/README.md CHANGED
@@ -56,7 +56,7 @@ its CSS out of the build with it (`sideEffects`). gzip of the minified file, as
56
56
  | core | `@gemboss/ui/core.css` | `parts/core.css` | the box-sizing reset + the tokens | 1.0 KB | every surface, first |
57
57
  | components | `@gemboss/ui/components.css` | `parts/components.css` | the kit (`ui.css`), the field look, the plain-HTML primitives (`kit-plain.css`: menu, chips, report pieces, popup body) | 8.2 KB | every surface, second |
58
58
  | shell | `@gemboss/ui/shell.css` | `parts/shell.css` | rail, topbar, main, mobile drawer, the thread rail, topbar slots, base text, page widths (`app-shell.css`) | 5.5 KB | every surface, third |
59
- | auth | `@gemboss/ui/auth.css` | `packages/ui/src/auth.css` | the sign-in screen | 2.6 KB | `AuthLayout` & co. import it |
59
+ | auth | `@gemboss/ui/auth.css` | `packages/ui/src/auth.css` | the sign-in screen (+ the grid backdrop, 0.30.0) | 2.8 KB | `AuthLayout` & co. import it |
60
60
  | assistant | `@gemboss/ui/assistant.css` | `packages/ui/src/assistant.css` | the dock and the launcher | 2.2 KB | `AssistantHost`, the launcher import it |
61
61
  | portal | `@gemboss/ui/portal.css` | `packages/ui/src/portal.css` | `TopbarNav`, `SearchDialog`, `Avatar`, `Leaderboard`, `Podium`, the `ThemeToggle` button | 2.1 KB | those components import it |
62
62
  | theme-dark | `@gemboss/ui/theme-dark.css` | `packages/ui/src/theme-dark.css` | the dark palette | 0.6 KB | `ThemeToggle` and `themeScript` (`theme-script.ts`, without portal.css) import it |
@@ -64,8 +64,11 @@ its CSS out of the build with it (`sideEffects`). gzip of the minified file, as
64
64
  | editor | `@gemboss/ui/editor.css` | `packages/ui/src/editor.css` | the editors | 7.9 KB | the `@gemboss/ui/editor` components import it |
65
65
  | reactions | `@gemboss/ui/reactions.css` | `packages/ui/src/reactions.css` | the emoji reactions and their picker | 0.8 KB | `Reactions` / `ReactionPicker` import it |
66
66
  | assistant-engine | `@gemboss/ui/assistant-engine.css` | `packages/ui/src/assistant-engine.css` | the engine picker (chip + Engine/Setup/Status popup) and the bridge notice | 1.9 KB | `EnginePicker` / `EnginePanel` / `BridgeNotice` import it |
67
- | chat | `@gemboss/ui/chat.css` + `chat-composer.css` | `packages/ui/src/chat.css`, `packages/ui/src/chat-composer.css` | the chat body, the composer | 3.0 + 0.9 KB | the surface (no package component draws it) |
68
- | bundle | `@gemboss/ui/gemboss-ui.css` | `gemboss-ui.css` | core + components + shell + auth + assistant + chat + portal + theme-dark | 21.7 KB | compatibility only (see CHANGELOG 0.23.0) |
67
+ | chat | `@gemboss/ui/chat.css` + `chat-composer.css` | `packages/ui/src/chat.css`, `packages/ui/src/chat-composer.css` | the chat body, the composer | 3.0 + 0.9 KB | the chat components (0.29.0); a page writing the markup links them |
68
+ | assistant-chat | `@gemboss/ui/assistant-chat.css` | `packages/ui/src/assistant-chat.css` | the chat pieces the package draws (0.29.0): column, starters that wrap, meta, errors, notes, paperclip | 0.9 KB | `AssistantChat` and its pieces import it |
69
+ | minichart | `@gemboss/ui/minichart.css` | `packages/ui/src/minichart.css` | `Sparkline`, `ShareBar` (0.30.0) | 0.5 KB | those components import it |
70
+ | setup-guide | `@gemboss/ui/setup-guide.css` | `packages/ui/src/setup-guide.css` | `SetupGuide` (0.30.0) | 0.9 KB | `SetupGuide` imports it |
71
+ | bundle | `@gemboss/ui/gemboss-ui.css` | `gemboss-ui.css` | core + components + shell + auth + assistant + chat + portal + theme-dark | 21.9 KB | compatibility only (see CHANGELOG 0.23.0) |
69
72
 
70
73
  The core alone is 14.7 KB against 21.7 for the bundle (each part opens with a short header naming the gemboss commit, kept through minify, so a surface serving the parts can still say which commit its CSS is); a list page adds collections (4.6), a sign-in page auth (2.6).
71
74
 
@@ -196,6 +199,21 @@ then the mark, then the foot lines as the visible height shrinks, follows the OS
196
199
  tracks the on-screen keyboard. Re-theme it with tokens on `.gb-auth`, e.g. a light iris panel:
197
200
  `--gb-panel-bg`, `--gb-panel-ink`, `--gb-panel-muted`, `--gb-panel-eyebrow`, `--gb-panel-facet-o`.
198
201
 
202
+ **The panel's ground: `backdrop` (0.30.0).** `"facet"` (default, unchanged): the mark's facet lines, oversized, at 5%, over
203
+ iris washes on ink. `"grid"`: gemform's sign-in ground (Chris 08/10/2026: "cái background bên dưới logo cũng đẹp"), a 34px
204
+ grid of iris hairlines at 10% that fades out from the panel's top-left corner, on an ink gradient. `"none"`: the ground
205
+ alone. Re-tune the grid with `--gb-panel-grid-bg`, `--gb-panel-grid-line`, `--gb-panel-grid-size`, `--gb-panel-grid-fade`
206
+ on `.gb-auth`. Like the panel itself it is drawn only at 900 wide AND 520 tall, and it is dark in both themes. A page
207
+ that writes the markup puts `gb-auth-panel--grid` (or `--none`) beside `gb-auth-panel`. The grid brings its OWN ground
208
+ (`--gb-panel-grid-bg`, an ink gradient), not `--gb-panel-bg`: a surface that re-themed the panel light (with dark
209
+ `--gb-panel-ink`) sets `--gb-panel-grid-bg` too, or its words land dark on ink. The grid's rules live in auth.css
210
+ (+0.2 KB gzip on the sign-in entry and the bundle): an entry of its own would need a second component to import it.
211
+
212
+ ```tsx
213
+ <AuthLayout brand="gemform" tagline="GemBoss Ops · internal tool" backdrop="grid"
214
+ panel={{ eyebrow: "gemform · GemBoss Ops", heading: "Every Gem registration, in one place." }}>…</AuthLayout>
215
+ ```
216
+
199
217
  ## The assistant frame
200
218
 
201
219
  ```tsx
@@ -272,6 +290,17 @@ Its styles are the `gemboss-assistant-*` rules in `packages/ui/src/shell.css` (t
272
290
  @imports too), and they reach you through `gemboss-ui.css` together with `gemboss-field.css`. The admin passes a second tab ("Talk to a person", gemcare) through `secondTab` and its button
273
291
  through `fullActions`; a surface without those leaves them out.
274
292
 
293
+ **The header's ⋯ menu (0.31.0).** The header shows four controls: + (`onNewChat`), full screen, ⋯ and ×. ⋯ holds
294
+ what is used once in a while: your rows (`menuItems`, as `AssistantMenuItem`s), "Clear history" when you pass
295
+ `onClearHistory` (greyed out with `canClearHistory={false}`; confirm inside your handler), then pin / float and
296
+ minimise. Full screen's chrome shows ⋯ too when you pass either. Do not draw a Clear button of your own in the body.
297
+
298
+ ```tsx
299
+ <AssistantHost title="Radar Assistant" onNewChat={reset} onExpand={expand} onShrink={shrink}
300
+ onClearHistory={clearHistory} canClearHistory={messages.length > 0}
301
+ menuItems={<AssistantMenuItem onClick={exportChat}>Export chat</AssistantMenuItem>}>
302
+ ```
303
+
275
304
  ## The engine picker (0.25.0): `EnginePicker`, `BridgeNotice`
276
305
 
277
306
  Which engine answers the assistant, and the bridge on the user's machine, without spending the chat's space on it
@@ -304,35 +333,285 @@ const picker = useEnginePicker(); // open + tab, shared with the notice
304
333
  select (more than six models, or grouped ones) keep it open. Closing goes back to the Engine tab. 44px targets under a coarse pointer and at phone width.
305
334
  - **Not in the chat**: no standing warning, no command in the conversation. The amber dot on the chip and on the Setup
306
335
  tab is the warning. `BridgeNotice` is for a turn that actually failed, once.
307
- - The package knows no bridge: the surface polls its own, computes `outdated`, builds the command, and owns the
308
- behaviour of every callback. `EnginePanel` is the same tabs without the chip (catalog, a settings page).
336
+ - **For the admin (0.29.0)**: `EngineChoice.mark` ("claude", "codex") is shown on the chip in place of the name when the
337
+ composer is too narrow (`ChatComposer` density icon); `EngineChoice.models` on every engine + `onPick(engine, model)`
338
+ lists each engine with its own models at once, and one click picks both and closes (the admin's Polaris popup);
339
+ `problem` is a red line on the Engine tab ("Wait for the current answer to finish"); `disabled` disables the chip while
340
+ a switch is in flight.
341
+ - The picker itself knows no bridge: the surface hands in its state, the command and the callbacks. Since 0.32.0 the
342
+ surface usually gets all three from `useBridge` + `bridgePickerProps` (next section). `EnginePanel` is the same tabs
343
+ without the chip (catalog, a settings page).
344
+ - **Closing from inside (0.32.0)**: a button in the panel that acts and then shuts it (Stop bridge, Remove, before a
345
+ confirm opens) calls `picker.close()`: it closes and puts focus on the chip at once, so the confirm records the chip as
346
+ where to return. `picker.props.onOpenChange(false)` (an outside click) closes without moving focus; the button that had
347
+ it is gone with the panel, so focus would fall to `<body>`. `picker.focusChip()` / `picker.chipRef` for the rest;
348
+ `EnginePicker chipRef` takes your own ref.
309
349
 
310
350
  Tests: `packages/ui/src/assistant/engine.test.tsx` (markup, styles, its own entry), `packages/ui/src/assistant/engine-browser.test.tsx`
311
351
  (Chromium: top layer through a clipping frame, keys, focus, copy, Open setup, 44px; with negative controls).
312
352
 
313
- ## The chat body
353
+ **Escape (0.27.0).** An Escape that closes the panel is marked handled (`preventDefault`) also when focus is outside it
354
+ (the composer): a surface whose own Escape hides the assistant and checks `defaultPrevented` leaves that key alone, and the
355
+ next Escape is its own again. Before, the key went on unmarked and React re-renders between the picker's listener and the
356
+ surface's, so each surface needed a window-capture marker to tell the two apart (gemlabs #922, gemfeedback #66). With
357
+ 0.27.0 the surface's `!e.defaultPrevented` is enough.
358
+
359
+ ### Plain HTML: `gembossUI.enginePicker()` (0.27.0)
360
+
361
+ `engine-plain.js`, after `kit-plain.js`, for a page with no React (gemcare). The same markup and classes as the component
362
+ (asserted: same class set for the same options), so `assistant-engine.css` draws it, and the same behaviour (top layer,
363
+ tabs and keys, focus back to the chip, closing goes back to Engine, Copy, 44px under a coarse pointer, Escape marked):
364
+
365
+ ```html
366
+ <link rel="stylesheet" href="/ui/assistant-engine.css"> <!-- require.resolve("@gemboss/ui/assistant-engine.css") -->
367
+ <script src="/ui/kit-plain.js"></script><script src="/ui/engine-plain.js"></script>
368
+ ```
369
+
370
+ ```js
371
+ const picker = gembossUI.enginePicker(document.querySelector(".gemboss-ec-toolbar-lead"), {
372
+ engines: [{ id: "server", label: "GemCare AI, paid" },
373
+ { id: "claude", label: "Claude Code", badge: "your plan", badgeTone: "success", note: "Your own subscription." }],
374
+ engine: "claude", onEngineChange: (id) => …,
375
+ models, model, onModelChange, efforts, effort, onEffortChange, effortDisabled, notes, modelControl,
376
+ bridge: { state: "outdated", version: "8", latest: "10", autostart: true, detail: "…" }, showBridgeDot: true,
377
+ setup: { command, pairingCode, steps, note }, statusActions: [checkAgainButton],
378
+ onOpenChange, onTabChange, locked, end, labels,
379
+ });
380
+ picker.update({ bridge: { state: "running" } }); // a poll: bridge / setup / labels merge one level; patched in place
381
+ picker.openTab("setup"); picker.close(); picker.isOpen(); picker.destroy();
382
+ log.append(gembossUI.bridgeNotice({ text: "The bridge on your machine is not responding.", onOpenSetup: () => picker.openTab("setup") }));
383
+ ```
384
+
385
+ Strings are text; `steps`, `notes`, `detail`, `statusActions` and `modelControl` may also be a Node, an array of Nodes or a
386
+ DocumentFragment: placed once and left where they are, so a field in them keeps focus, caret and events across redraws.
387
+ A redraw PATCHES the panel in place (the same elements, only what changed, as React does): focus on a tab, an engine row
388
+ or the model select stays, and a clamped panel keeps its scroll; behaviour is delegated from the panel (`data-*`).
389
+ A pick from the engine list, the model or the effort updates the picker at once and calls back (no callback: that row is
390
+ disabled, as in the component); `update()` is how the page says anything else changed. An Escape it does not own is left
391
+ alone: an IME composition, or one in a modal opened from the panel; and focus entering such a modal closes the panel,
392
+ which would otherwise sit above it in the top layer (the component does the same since 0.27.0). Tests:
393
+ `packages/ui/src/engine-plain.test.ts` (Chromium: the component's class set on all three tabs, top layer through a
394
+ clipping frame with its negative control, no popover API, keys, Escape, Copy, redraws that keep focus / scroll / a
395
+ caller's field without a blur or change, merge rules, 44px).
396
+
397
+ ## The bridge (0.32.0): `useBridge`, `createBridgeClient`, `bridgePickerProps`
398
+
399
+ The bridge is the small server a teammate installs on their machine so the assistant can run Claude Code / Codex on their
400
+ own subscription; the page talks to it at `http://127.0.0.1:<port>`, and the surface's server never sees that call.
401
+ content-radar, partner-radar and search-radar carried the same ~300 lines for it each (find it behind Chrome's Local Network
402
+ Access permission, pair, a data token, a turn and its stream, polls, "it went away", the Setup / Status tabs), and a
403
+ review fixed one copy at a time. Once, here:
404
+
405
+ ```tsx
406
+ import { bridgePickerProps, bridgeControlNote, createBridgeClient, postTurn, readTurnEvents, useBridge } from "@gemboss/ui/assistant";
407
+
408
+ // Module level (one per page): its data token is cached on it.
409
+ const bridgeClient = createBridgeClient({ port: 8789, codeKey: "pr-bridge-code", app: "partner-radar" });
410
+
411
+ const picker = useEnginePicker();
412
+ const b = useBridge(bridgeClient, { open, wantsLocal: engine !== "api", pickerOpen: picker.open,
413
+ // An engine that cannot run here (not on the server, and not "your machine only": the bridge may come) → the API one.
414
+ onDetected: (eng) => setEngine((cur) => (eng[cur] && !eng[cur].installed && !eng[cur].localOnly && eng.api?.installed ? "api" : cur)) });
415
+ // b.bridge (absent | unpaired | paired | needs-permission | asking | blocked, + lost / off / wrong), b.status (the
416
+ // server's engines), b.engines (merged: a paired bridge's engines replace "runs on your machine only"), b.pairCode.
417
+
418
+ async function send(text) {
419
+ const viaBridge = engine !== "api" && b.paired;
420
+ const body = { message: text, engine, sessionId, page };
421
+ const id = turns.start();
422
+ try {
423
+ const res = viaBridge ? await bridgeClient.turn(body, { signal, version: b.version }) : await postTurn("/api/assistant", body, { signal });
424
+ await readTurnEvents(res, (ev) => { if (ev.type === "text") turns.write(id, ev.delta); /* … */ }, { atBridge: viaBridge });
425
+ turns.finish(id);
426
+ } catch (e) {
427
+ if (turns.fail(id, e).bridge) b.lost(); // gone / 401 → BridgeNotice + probe again; anything else is the turn's
428
+ }
429
+ }
430
+
431
+ async function onControl(kind) { // Stop / Remove on Status
432
+ picker.close(); // closes, focus on the chip (the confirm returns there)
433
+ if (kind === "remove" && !(await confirm(…))) return;
434
+ try { turns.note(bridgeControlNote(kind, await b.control(kind)), { bridge: true }); }
435
+ catch (e) { turns.note(`Couldn't ${kind} the bridge: ${e.message}`); }
436
+ }
437
+
438
+ <EnginePicker {...picker.props} engines={…} engine={engine} onEngineChange={setEngine}
439
+ {...bridgePickerProps(b, { latest: BRIDGE_LATEST, busy, controls: engine !== "api", onControl,
440
+ installNote: "… A bridge installed for content-radar does not count: this one uses another port." })}
441
+ showBridgeDot={engine !== "api"} />
442
+ ```
443
+
444
+ - **`createBridgeClient`** (no React): `probe(code)` (401 = another code → `unpaired`; any failure → `absent`; never
445
+ throws), `detect({ fromClick })` (reads the permission first: `denied` → `blocked`; not asked → `needs-permission`
446
+ and no call at all; from a click it waits 60 s, the dialog's time), `ensureCode()` (8 characters, no 0/O/1/I, kept in
447
+ `codeKey`), `installCommand(code)`, `dataToken()` (POST `tokenPath`, reused until under an hour is left; its error is the
448
+ turn's, never the bridge's), `turn(body, { signal, version })` (adds the code and the token; `tokenSince` keeps the
449
+ header off an older bridge whose CORS refuses it; throws marked `atBridge`, with the status), `control("stop" | "remove")`.
450
+ - **`readTurnEvents(res, onEvent, { atBridge })`**: the turn's `data: {…}` events, whole across chunks, `: ping` skipped. A
451
+ read that throws is the bridge's only for the bridge's stream; the user's Stop stays a Stop. A stream that ends WITHOUT
452
+ `done` throws (`BRIDGE_CLOSED_EARLY`, the bridge's; `SERVER_CLOSED_EARLY`, the turn's): half an answer is not a finished
453
+ one. Any early exit cancels the reader, so the bridge sees the client go. `postTurn(url, body)` is the same POST to the
454
+ surface's own route (errors unmarked, with `status`).
455
+ - **`useBridge`**: probes only while `open`, the server's engines and the bridge together (`statusUrl`, default
456
+ `/api/assistant`); every 3 s while `wantsLocal` and nothing is found (a teammate is installing); again on focus, on
457
+ return to the tab and on a permission change, also once paired (a bridge that stops is noticed); never over a state the
458
+ user is in the middle of (`asking`, a wrong code), and never over a user action that happened while it was out (an
459
+ epoch: a `/status` that left before Stop does not bring "running" back); one background probe at a time. `lost()` after
460
+ a failed turn, `pair(code)`, `allowAccess()` from the Allow click, `control(kind)` (no answer → lost; 401 → Not connected
461
+ with the Pair form; another HTTP error leaves the state), `recheck()`. On the dev machine (`local`, default: the page is
462
+ on localhost) it probes nothing; `b.local` switches after mount, so the server's markup and the first render agree. The
463
+ client is the first render's for the hook's life (make it at module level: its data token is cached on it).
464
+ - **`bridgePickerProps(b, { latest, … })`** → `bridge`, `setup`, `statusActions` for `EnginePicker`: the dot and Status
465
+ rows, Setup per case (Allow / unblock / the Pair form + command / install / update), Check again, Stop and Remove
466
+ (`controls`). The words are `BRIDGE_SETUP_EN` (`labels` overrides); `outdatedDetail` and `installNote` are the surface's.
467
+ - What stays the surface's: which engines exist and what they cost, the request body, what each event does to its
468
+ messages, its confirm dialog.
469
+
470
+ Tests: `bridge.test.tsx` (the client, the stream and the picker's props, with fetch and the permission faked),
471
+ `bridge-browser.test.tsx` (Chromium, a fake bridge on a test port answered by the router: open, poll, lost, pair,
472
+ permission, Stop; each with its negative control).
473
+
474
+ ## The chat body (components since 0.29.0)
475
+
476
+ Chris 08/10/2026: "đồng bộ hết giao diện của AI assistant… mọi service có panel này đồng bộ style + code dùng gemboss ui".
477
+ Since 0.29.0 the package DRAWS the chat, not only its CSS: the surface hands in messages and actions and keeps its own
478
+ streaming, bridge and sessions. Each piece is the better of the two surfaces that had it (inventory 08/10/2026,
479
+ `Gems/_local/fleet/assistant-sync-2026-10/`):
480
+
481
+ | Piece | Component | Taken from | Why that one |
482
+ |---|---|---|---|
483
+ | Composer | `ChatComposer` | admin (shape, density, one action slot) + partner-radar (a DIV, IME guard) | one bordered shell, send/stop the same 44px circle, grows to 84px, says less as it narrows (measured); a form broke the engine popup's own form; an IME's Enter must not send |
484
+ | Markdown | `ChatMarkdown` | admin | tables, folded code, safe links, trusted pictures, no raw HTML; the radars fed an HTML string to `dangerouslySetInnerHTML` |
485
+ | Steps | `ChatSteps` | admin, with partner-radar's words | rows print and stop (one heartbeat moves, not a spinner per row); a step says what it did in words + its target, a trace keeps the raw call folded |
486
+ | Working | `ChatPending` | admin | ✳, a verb that changes, the seconds: the wait reads alive |
487
+ | Starters | `ChatStarters` | partner-radar | they wrap (radius 12): a long question as a nowrap pill is cut in a 400px panel; a fill-in example |
488
+ | Under an answer | `ChatMeta` | partner-radar | Copy · engine · seconds · cost |
489
+ | Errors | `ChatError`, `BridgeNotice` | partner-radar | a fix the user can take (sentence, command, Copy, the original folded); a bridge failure is one line |
490
+ | Engine | `EnginePicker` | partner-radar (Setup / Status tabs) + admin (mark, models per engine) | see "The engine picker" |
491
+ | Frame, sessions | `AssistantHost`, `SessionRail` | (already the package's) | |
492
+
493
+ ```tsx
494
+ import { AssistantChat, ChatAttach, EnginePicker, useChatTurns, useEnginePicker } from "@gemboss/ui/assistant"; // brings its CSS
495
+
496
+ const turns = useChatTurns(); // messages with ids; a turn is written BY ID
497
+ const picker = useEnginePicker();
498
+ async function send(text: string) {
499
+ turns.say(text);
500
+ const id = turns.start();
501
+ try {
502
+ for await (const ev of stream(text)) {
503
+ if (ev.type === "text") turns.write(id, ev.text);
504
+ if (ev.type === "tool") turns.step(id, { id: ev.id, label: TOOL_LABEL[ev.name], target: ev.target, state: ev.state });
505
+ }
506
+ turns.finish(id, { meta: ["Claude Code", "4.2s"] });
507
+ } catch (e) {
508
+ if (turns.fail(id, e).bridge) probeTheBridgeAgain(); // mark the bridge's own call with atBridge(e, status)
509
+ }
510
+ }
511
+
512
+ <AssistantHost title="Partner Assistant" …>
513
+ <AssistantWorkspaceLayout dock={presentation !== "full"}>
514
+ <AssistantChat messages={turns.messages} busy={busy} onSend={send} onStop={stop}
515
+ title="Partner Assistant" // shown at full screen only (the frame's header says it elsewhere)
516
+ intro={<p className="gemboss-md-p">Describe a meeting…</p>}
517
+ starters={[{ label: "Had dinner with Amit…", hint: "Example, click to fill in", fill: true }]}
518
+ moreStarters={SUGGESTIONS} moreStartersTitle="Or ask about partners in the radar:"
519
+ engine={<EnginePicker {...picker.props} … />} tools={<ChatAttach accept=".pdf,.docx" onFiles={upload} />}
520
+ onOpenSetup={() => picker.openTab("setup")} renderExtra={(m) => m.proposals?.map(…)} onNavigate={router.push} />
521
+ </AssistantWorkspaceLayout>
522
+ </AssistantHost>
523
+ ```
314
524
 
315
- What an answer looks like, on any surface that puts a chat somewhere: `chat.css` (inside `gemboss-ui.css`;
316
- the admin `@import`s it, so it is the same file the admin's own chat is drawn with).
525
+ - **`messages`** (`ChatMessageData`): `role` user / assistant / note; `text` (markdown for an answer, `markdown: false` for
526
+ plain); `pending` (the heartbeat until text, then the cursor); `steps`; `error` + `fix: { text, command }`, or `bridge:
527
+ true` (one BridgeNotice line whose action calls `onOpenSetup`); `warn`; `stopped`; `meta` (after Copy); a note's `tone`
528
+ warn / down + `action` makes it a BridgeNotice ("This bridge is outdated · Open setup").
529
+ - **Failed turns** (`bridgeTurnError`, used by `turns.fail`): only a failure AT the bridge is the bridge's: its call threw
530
+ (it is gone, the machine slept) or it answered 401 (this browser's pairing code is not its code). Its 400 / 503 and a
531
+ failure on the way to the server (a data token, an expired session) are the turn's. The surface marks the bridge's own
532
+ call (`throw atBridge(err, res.status)`); it never guesses from "this turn used the bridge" (content-radar #937).
533
+ - **Failed turns, timeouts**: give the bridge's call its deadline with `AbortSignal.timeout(ms)` (a `TimeoutError`, the
534
+ bridge's); an `AbortController` you abort yourself throws an `AbortError`, which reads as the user's Stop.
535
+ - **Following the end**: the log follows while the reader is at the end, and stops at their first move up (wheel, finger,
536
+ ArrowUp / PageUp / Home), not 24px later. A send, or another conversation shown, brings it back (`ChatThread pinKey`;
537
+ `AssistantChat` does both). Since 0.32.0 so does a NEW user message from anywhere: a page's "Ask about this partner", the
538
+ 📎 upload, a surface's own starter (no `requestAnimationFrame(() => thread.scrollTop = …)` in the surface any more). A
539
+ note or an answer arriving while the reader is up leaves them where they are.
540
+ - **Links** (0.32.0): `linkPolicy={(href, label, target) => …}` decides for EVERY link in an answer (written, bare, a
541
+ picture from another host), asked with the href as written and `target`, what the kit would link (`linkTarget`,
542
+ normalised) or null when it refuses it: `true` / undefined = the kit's link (in-app through `onNavigate`, https in a new
543
+ tab, words when `target` is null); `false` / null = its words; any other node is drawn as given (a router link, a
544
+ `customer:12` button). Drawing your own link for an ordinary URL: build it from `target.href`, never the raw `href`. The radars' "this path is text" is `linkPolicy={(h) => !keepAsText(h)}`.
545
+ `ownLink` + `renderLink` still work; `linkPolicy` wins when both are given. React only: `chat-plain.js` keeps the pair
546
+ (it sits at its weight ceiling, and gemcare, its one user, draws only its own scheme).
547
+ - **Full screen**: `AssistantChat` measures how far the frame's floated buttons (`fullActions`, Back) reach into it and
548
+ gives its heading (or, without one, its log) that much room (`useFullChromeRoom`, for your own column); a `fullActions`
549
+ button with no icon keeps its words on a phone. Measured in the child because only the child knows where its column is
550
+ (at 1920 the 820px column does not reach the buttons at all).
551
+ - **Loading**: the chat components import chat.css and chat-composer.css, which the old `gemboss-ui.css` bundle also
552
+ carries. A surface still linking the bundle gets them twice, the second copy after its own CSS: move to the parts
553
+ first (see "Load only what you use").
554
+ - **Composer density**: `full` "Claude Code · Opus" → `compact` "Claude Code" → `icon` the engine's MARK (`EngineChoice.mark`)
555
+ → `tight`. An engine with no mark keeps its (cut) name.
556
+ - **Markdown** (0.29.2): a bare `https://…` in the prose is a link (GitHub's reading: the sentence's trailing `. , : ; ! ?
557
+ _ ~ ' "` and an unpartnered `)` / `]` stay text; `<https://…>` too), through the same `linkTarget` as `[x](…)`, so only
558
+ http(s) with a host. It ends at a space, `*`, CJK text or full-width punctuation ("访问https://x.test。然后" links only
559
+ the URL), and one glued to a word (`foohttps://…`) is not a link. Inside `**bold**` a URL stays plain text. `####` to
560
+ `######` are headings at the lowest step (`h5.gemboss-md-h3`); a heading line with no words is dropped.
561
+ - **Pictures on the page's own origin** (0.29.2): `imageHosts={[{ path: "/api/img/" }]}` (no `host`) draws
562
+ `![cover](/api/img/r2/a.png)` as written, a root-relative path checked after the browser's normalisation: `../`,
563
+ `%2e%2e`, `\` that leave `path`, an encoded `/` or `\`, a double encoding (`%25`), a `;` (`..;/`), `//host` and `/\host`
564
+ are refused (the alt stays as text). `path` must be a directory below the root (`/x/`, not `/` nor `/x`), or the entry
565
+ is ignored. No need to rewrite the answer into absolute URLs first.
566
+ - **A form in the engine popup** (0.29.2): the send circle is `.gemboss-ec-toolbar > button[type=submit]`, the toolbar's
567
+ own child. Before, `.gemboss-ec-input button[type=submit]` also painted a submit button in the popup (it lives in the
568
+ leading slot in the DOM): a surface's Pair form can keep `type="submit"`.
569
+ - A surface that needs its own arrangement uses the pieces directly (`ChatThread`, `ChatMessage`, `ChatBubble`,
570
+ `ChatMarkdown`, `ChatSteps`, `ChatPending`, `ChatStarters`, `ChatMeta`, `ChatError`, `ChatNote`, `ChatComposer`,
571
+ `ChatAttach`) inside `ChatLabelsProvider` for its words. Every word is in `CHAT_EN` (English chrome, Chris 03/10/2026).
572
+
573
+ CSS: `chat.css` (the body; also inside `gemboss-ui.css`, and the admin `@import`s it), `chat-composer.css` (toolbar, density,
574
+ Stop) and `assistant-chat.css` (0.29.0: the column, wrapping starters, the meta line, errors, notes, the paperclip, the
575
+ room for full screen's buttons). The chat components import all three; a page that writes the markup itself links them.
317
576
 
318
577
  | Piece | Classes |
319
578
  |---|---|
320
- | Header | `.gemboss-ec-head` › `.gemboss-ec-title` + `.gemboss-ec-sub` |
321
- | Message list | `.gemboss-ec-scroll` (thin visible scrollbar) |
579
+ | Column | `.gb-chat` › (`.gemboss-ec-head.gb-chat-head`) + `.gemboss-ec-scroll[role=log]` + `.gemboss-ec-input` |
322
580
  | Messages | `.gemboss-ec-msg.user` / `.gemboss-ec-msg.assistant` › `.gemboss-ec-bubble` |
323
581
  | Markdown inside a bubble | `.gemboss-md-p / -h -h1..3 / -list / -quote / -rule / -code / -codeblock / -table(wrap) / -img(link)`, links `.gemboss-ec-md-link` |
324
- | Steps the assistant took | `ul.gemboss-narr` › `li.gemboss-narr-row.ok|.running|.failed|.thinking|.trace|.steer` › `.gemboss-narr-dot` + `.gemboss-narr-text` (running: `.gemboss-narr-spin`) |
325
- | Working / streaming | `.gemboss-ec-pending` (`-star`, `-verb`, `-meta`), `.gemboss-ec-cursor` after the streamed text |
326
- | Suggestions, choices | `.gemboss-ec-starters` › `button.gemboss-ec-starter`; `.gemboss-ec-chips` › `button.gemboss-ec-chip` |
327
- | Composer | `form.gemboss-ec-input` › `textarea` + `div.gemboss-ec-toolbar` › `div.gemboss-ec-toolbar-lead` (chips; leave it EMPTY for none) + `button[type=submit]` (44px; disabled = grey). An empty lead puts the box and the button on ONE row. Stop while streaming: `button.gemboss-ec-stop` in place of submit |
328
-
329
- The toolbar row, the one-row layout, the engine picker's layout and Stop are in a second sheet,
330
- `chat-composer.css` (also inside `gemboss-ui.css`): the admin imports it from its chat components rather than its
331
- layout, so screens without a chat never load it. The catalog's "Thân chat" section is the markup to copy. NOT here, on purpose: the panel's own geometry
332
- (`.gemboss-ec` width; the editor columns are in `editor.css` since 0.9.0), attachments, action cards, the engine picker and work mode — those
333
- are still the admin's. The one exception is the chat panel's touch / mouse target blocks: one rule per pointer
334
- kind names every control of the panel, admin-only ones included (they match nothing elsewhere). The behaviour (streaming, Stop, Enter to send, IME, scroll-to-bottom) belongs to
335
- the surface.
582
+ | Steps | `ul.gemboss-narr` › `li.gemboss-narr-row.ok|.running|.failed|.thinking|.trace|.steer` › `.gemboss-narr-dot` + `.gemboss-narr-text` (+ `.gb-chat-target`) |
583
+ | Working / streaming | `.gemboss-ec-pending` (`-star`, `-verb`, `-meta`, `.gb-chat-sr`), `.gemboss-ec-cursor` after the streamed text |
584
+ | Suggestions | `.gemboss-ec-starters` › `button.gemboss-ec-starter.gb-chat-starter` (› `.gb-chat-starter-hint`); a group title `.gb-chat-label` |
585
+ | Under an answer, errors, notes | `.gb-chat-meta`; `.ui-notice.ui-notice--danger.gb-chat-error` › `.gb-chat-cmd` + `details.gb-chat-original`; `.gb-chat-note`; `.gb-engine-notice` |
586
+ | Composer | `div.gemboss-ec-input[data-density]` › `textarea` + `div.gemboss-ec-toolbar` › `div.gemboss-ec-toolbar-lead` (chips) + `div.gemboss-ec-toolbar-tools` (`button.gemboss-ec-attach-btn`) + `button[type=submit]` (a DIRECT child: the send circle) or `button.gemboss-ec-stop` |
587
+
588
+ Tests: `packages/ui/src/assistant/chat.test.tsx` (markup, parser, every class styled), `chat-browser.test.tsx` (Chromium:
589
+ Enter / IME, Stop in send's place, growing box, density with the mark, following the end, the turn store by id, failed
590
+ turns, full screen's buttons, 44px; each with its negative control).
591
+
592
+ ### Plain HTML: `gembossUI.chat` (0.29.0)
593
+
594
+ `chat-plain.js`, after `kit-plain.js` (and `engine-plain.js` when the page has the picker), for a page with no React
595
+ (gemcare's inbox and Reports assistants). The same markup as the components, element by element, and the same markdown
596
+ (`chat-plain.test.ts` renders both from the same data and compares the trees). `render()` redraws from the data each
597
+ time; a message whose look did not change keeps its element, and a changed one keeps the folds the reader opened and its
598
+ heartbeat's clock:
599
+
600
+ ```html
601
+ <link rel="stylesheet" href="/ui/chat.css"><link rel="stylesheet" href="/ui/chat-composer.css">
602
+ <link rel="stylesheet" href="/ui/assistant-chat.css"><link rel="stylesheet" href="/ui/assistant-engine.css">
603
+ <script src="/ui/kit-plain.js"></script><script src="/ui/engine-plain.js"></script><script src="/ui/chat-plain.js"></script>
604
+ ```
605
+
606
+ ```js
607
+ const log = gembossUI.chat.thread(document.getElementById("ai-thread")); // role=log, follows the end
608
+ gembossUI.chat.render(log.el, messages, { intro, starters, onStarter, onOpenSetup: () => picker.openTab("setup"),
609
+ ownLink: (h) => /^(customer|conversation):\d+$/.test(h), renderLink: (href, label) => aiLinkButton(href, label) });
610
+ const box = gembossUI.chat.composer(host, { placeholder: "Ask about this customer", maxLength: 1000, leading: pickerHost,
611
+ thread: log, onSend: (text) => ask(text), onStop: () => controller.abort() }); // thread: back to the end on a send
612
+ box.setBusy(true); log.setBusy(true);
613
+ const { error, bridge } = gembossUI.chat.turnError(err); // the same rule as bridgeTurnError
614
+ ```
336
615
 
337
616
  ## Editor CSS (0.9.x)
338
617
 
@@ -418,7 +697,7 @@ import { DeviceToggle, EditorBack, EditorChatToggle, EditorFrame, EditorPaneTabs
418
697
  | `EditorBack` | `.gemboss-editor-back` ← | A `<button type="button">`, or `as={Link}` / `as="a"` with `href`. Named "Back" for a screen reader (`label`). |
419
698
  | `EditorChatToggle` | `.gemboss-editor-chat-toggle` | Shows / hides the left column; filled while open, pressed when collapsed. |
420
699
  | `EditorSaveState` | `.gemboss-editor-savestate` (`.is-saving` with the spinner, `.is-saved`) | `idle` draws nothing, or `idleText` dimmed (the section editor's "Autosaves"). Texts and titles are props. |
421
- | `DeviceToggle`, `MonitorGlyph`, `PhoneGlyph` | `.gemboss-pv-devices` › `.gemboss-pv-device` | Desktop / tablet / mobile. Moved from the admin unchanged. |
700
+ | `DeviceToggle`, `MonitorGlyph`, `TabletGlyph`, `PhoneGlyph` | `.gemboss-pv-devices` › `.gemboss-pv-device` | Desktop / tablet / mobile. Moved from the admin unchanged; `TabletGlyph` exported 0.26.0 (it was inline in the toggle). |
422
701
 
423
702
  One module per piece behind one entry, so a bundler gives each editor only what it draws (measured: one module for all
424
703
  of it cost the page and section editors about 1 KB of code they never ran). Not in `@gemboss/ui` itself: like
@@ -993,7 +1272,12 @@ import { Avatar, Dialog, Leaderboard, Podium, SearchDialog, ThemeToggle, themeSc
993
1272
  - `SearchDialog`: the topbar search button opening a 640px popup (Featurebase's, the admin's ⌘K).
994
1273
  `initial` before typing, `search(q)` while typing (150 ms debounce, latest answer wins); ↑ ↓ Enter
995
1274
  Esc; ⌘K / Ctrl+K and "/" open it. Results are links; without JavaScript the button goes to
996
- `fallbackHref`.
1275
+ `fallbackHref`. 0.31.0: `trigger="field"` draws the door as the topbar's search box (TopbarSearch's look, `hint`
1276
+ at its right edge) for a surface whose topbar centre is the search; `external: true` on a result opens it in a new
1277
+ tab (↗) and closes the popup; a `search` that throws shows "Search failed: …"; `onNavigate(href)` takes results inside
1278
+ the surface without a full reload (pass `router.push`); `minChars` asks for more characters before searching. Escape
1279
+ closes it in one press and is marked handled (`defaultPrevented`); a surface listening for Escape in the CAPTURE phase
1280
+ runs before it, so check `document.querySelector("dialog[open]")` there.
997
1281
  - `Dialog`: the kit's native `<dialog class="ui-modal">` (see kit-plain.css) driven by `open`; a
998
1282
  backdrop click closes it, focus returns to the opener.
999
1283
  - `ThemeToggle` + `themeScript`: `data-theme="dark|light"` on <html>, remembered in localStorage
@@ -1033,6 +1317,28 @@ Every target is 44px (a control, CLAUDE.md §9.1); the pill you see is 26px, tra
1033
1317
  message with reactions does not grow by a full line. A plain-HTML surface writes the same classes (markup at the top of
1034
1318
  `reactions.css`, and the catalog's "Reactions" section).
1035
1319
 
1320
+
1321
+ ## Action card (0.28.0): an assistant proposes, a person applies
1322
+
1323
+ Chris 08/10/2026: the Feedback Assistant lists likely duplicates and merges them, and carries out status, owner, label
1324
+ and date changes on request, "follow theo gemboss ui". GemFeedback built it first (`components/MergeCard.tsx`,
1325
+ `components/ActionCard.tsx` there); this is its look, moved here so every assistant in the fleet asks the same way.
1326
+
1327
+ The contract every surface keeps: **the model never changes anything**. It proposes (a link it writes, a tool result);
1328
+ the card shows what would change item by item, what to know first, and how far it reaches; only the primary button
1329
+ applies; afterwards the outcome replaces the body, with Undo when the surface can put things back.
1330
+
1331
+ | Piece | What it draws |
1332
+ | --- | --- |
1333
+ | `ActionCard` | `title`, `aside` (a link beside it), `loading` (spinner line), `warnings` (warning banner, "Before you apply"), `summary` + `actions` (the foot), `outcome` (`{tone: "success" \| "info", children, undo?: {onUndo, label, busy}}`, replaces body, warnings and actions), `error` |
1334
+ | `ActionCardSection` | a labelled list ("Keeping", "Merging into it"), or unlabelled |
1335
+ | `ActionCardItem` | `name` (+ `href`, drawn with `renderLink` for a router link: a list link, 24px), `control` (a checkbox before the name, in a 44px label on touch), `meta`, `excerpt` (3 lines), `footer` (a secondary action), `kept` (accent edge), `muted` |
1336
+ | `ActionChange` | "Idea → Planned"; "Already Planned" when equal; `problem` ("Merged into #7", "Changed since") instead |
1337
+ | `action-card.css` | its own entry, loaded by the component; a plain-HTML surface links `@gemboss/ui/action-card.css` and writes `ui-action-card`, `__head`, `__title`, `__aside`, `__section`, `__label`, `__list`, `__item` (`--kept`, `--muted`), `__name`, `__pick`, `__meta`, `__excerpt`, `__item-foot`, `__change`, `__same`, `__warn`, `__foot`, `__summary`, `__actions` |
1338
+
1339
+ Presentational: no fetching and no state of its own. The surface loads the preview, owns the handlers, and decides
1340
+ what Undo puts back (GemFeedback: the merge record, or the values an action replaced, guarded by `if=` so Undo never
1341
+ overwrites a later edit). Catalog section "Thẻ hành động".
1036
1342
  ## Report pages (0.4.0)
1037
1343
 
1038
1344
  Plain CSS in `kit-plain.css` for a numbers page written by hand (first user: gemnews `/admin`, 03/10/2026; that page moved to GemCommunity the same day, so gemnews no longer takes the package). The
@@ -1052,6 +1358,46 @@ surface computes every number and ratio; the kit only draws. Markup for each is
1052
1358
  - `ui-grid--2` / `--3`: card grid that drops to one column; `ui-section-head`: an `h2` + note between page head and
1053
1359
  card titles.
1054
1360
 
1361
+ ## Mini charts (0.30.0): `Sparkline`, `ShareBar`
1362
+
1363
+ Two charts small enough for a card or a table cell, each its own entry (`minichart.css`, imported by the component).
1364
+ Before this, six surfaces drew a sparkline by hand (admin intelligence, analytics and operator; talent-radar; search-radar;
1365
+ gemwatcher; gemform) with three different baselines.
1366
+
1367
+ ```tsx
1368
+ import { Sparkline, ShareBar } from "@gemboss/ui";
1369
+ <Sparkline points={[3, 5, null, 4, 8]} label="Registrations, last 5 days" />
1370
+ <ShareBar label="Status" parts={[{ label: "New", value: 25, tone: "info" }, { label: "Spam", value: 2, tone: "critical" }]} />
1371
+ ```
1372
+
1373
+ - `Sparkline`: oldest point first; `null` is a gap (the line breaks, a lone point is a dot); counts start at 0 unless
1374
+ `min` / `max` fix the scale; `invert` for a rank. Muted line, the newest point one iris dot; `tone` `series` /
1375
+ `success` / `critical`. With `label` it is an image with that name, without it decoration (`aria-hidden`).
1376
+ `sparklinePaths(points, opts)` is the geometry alone, for a page that writes the SVG itself.
1377
+ - `ShareBar`: one 6px bar split by share; parts at 0 take no room; tones are the badge tones (`info`, `success`,
1378
+ `warning`, `critical`, `neutral`) or `series-1..5` (the default, in order). The bar is one image whose name lists every
1379
+ part, so a screen reader hears the numbers the colours stand for. Each segment grows by its SHARE (value / total), so
1380
+ fractions fill the track. The share is an inline `flex-grow`: a plain-HTML page under a CSP that forbids inline style
1381
+ (gemcare) sizes the segments from its own stylesheet or script.
1382
+
1383
+ ## Setup guide (0.30.0): `SetupGuide`
1384
+
1385
+ The admin's "Set up your store" card (Shopify's Setup guide) as a component, its own entry (`setup-guide.css`): a title
1386
+ with the count, a bar, collapsed steps of which the first not done is open. Native `<details>`: the head folds the guide,
1387
+ the steps share one `name` so the browser keeps one open, and a page without React writing the same markup (catalog
1388
+ "Hướng dẫn thiết lập") behaves the same with no script. The row reveals; what changes something is a button in the
1389
+ step's `body`; a step with no body is a plain row (no toggle that opens nothing). The open step is decided ONCE, as in
1390
+ the admin: a state change while someone reads a step never shuts it. `state: "unknown"` (could not be read) is counted
1391
+ apart, never as progress. The bar is a `<progress>` (no inline style). `rememberKey` keeps a folded guide folded across
1392
+ visits (localStorage); `group` names the steps' `name` when two guides render in separate React roots. No steps: no guide.
1393
+
1394
+ ```tsx
1395
+ <SetupGuide title="Telegram alerts" steps={[
1396
+ { id: "bot", title: "Connect a bot", state: bot ? "done" : "todo", body: <ConnectBot /> },
1397
+ { id: "chat", title: "Choose where alerts go", state: chats.length ? "done" : "todo", body: <Chats /> },
1398
+ ]} />
1399
+ ```
1400
+
1055
1401
  ## Collections (0.13.0): tables, lists, grids, boards
1056
1402
 
1057
1403
  Chris settled these on 05/10/2026 after an inventory of 136 hand-made collections on 11 surfaces (86 tables scrolling
@@ -8,7 +8,7 @@
8
8
  .cat-code summary{padding:10px 16px;font-size:13px;cursor:pointer;color:var(--gemboss-text-muted);border-top:1px solid var(--gemboss-divider);min-height:24px}
9
9
  .cat-code pre,.cat-api{margin:0;padding:12px 16px;overflow:auto;background:#0f1012;color:#e8e8e6;font-size:12px;line-height:1.5;border-radius:0}
10
10
  .cat-api{border-radius:12px;margin:0 0 16px}</style></head><body><div class="cat">
11
- <h1>@gemboss/ui 0.25.0 · Editor</h1>
11
+ <h1>@gemboss/ui 0.32.0 · Editor</h1>
12
12
  <p>Khung của mọi editor: <code>EditorFrame</code> (cột trái · khe kéo · preview · cột phải dock trên mép phải preview) và <code>EditorTopbar</code> (← · tiêu đề · cụm giữa · cụm phải · dòng trạng thái). Là đúng khung của theme / page / section editor trong admin. Gói chỉ vẽ KHUNG; chat, Configure, canvas, page picker, menu, Publish là của editor, đi vào bằng slot. Trang HTML thuần nạp <code>editor.css</code> + <code>editor-plain.js</code> rồi gọi <code>gembossUI.initEditorFrame()</code>: kéo khe (chuột, chạm, phím mũi tên / Home / End), đổi thiết bị, mở ⋯ và Publish ▾, ẩn cột trái, Chat | Preview trên điện thoại. <a href="index.html">← Catalog</a></p>
13
13
  <h2>React</h2><pre class="cat-api"><code>import &quot;@gemboss/ui/editor.css&quot;;
14
14
  import { EditorFrame, EditorTopbar, EditorBack, EditorChatToggle, EditorPaneTabs, EditorSaveState, DeviceToggle } from &quot;@gemboss/ui/editor&quot;;