@gemboss/ui 0.21.0 → 0.30.2

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 (57) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/README.md +433 -24
  3. package/catalog/editor.html +426 -10
  4. package/catalog/index.html +907 -33
  5. package/gemboss-ui.css +3 -3
  6. package/package.json +23 -3
  7. package/parts/components.css +5 -0
  8. package/parts/core.css +5 -0
  9. package/parts/shell.css +5 -0
  10. package/src/action-card.css +1 -0
  11. package/src/action-card.tsx +149 -0
  12. package/src/app-shell.css +1 -1
  13. package/src/assistant/chat-markdown.ts +279 -0
  14. package/src/assistant/chat-turns.ts +120 -0
  15. package/src/assistant/chat.tsx +871 -0
  16. package/src/assistant/engine-mark-art.ts +45 -0
  17. package/src/assistant/engine-mark.tsx +39 -0
  18. package/src/assistant/engine.tsx +697 -0
  19. package/src/assistant/host.tsx +3 -1
  20. package/src/assistant/index.ts +6 -0
  21. package/src/assistant-chat.css +1 -0
  22. package/src/assistant-engine.css +1 -0
  23. package/src/auth.css +1 -1
  24. package/src/auth.tsx +18 -2
  25. package/src/chat-composer.css +1 -1
  26. package/src/chat-plain.js +6 -0
  27. package/src/chat.css +1 -1
  28. package/src/collections.tsx +1 -0
  29. package/src/dialog.tsx +3 -135
  30. package/src/editor/controls.tsx +1 -0
  31. package/src/editor/frame.tsx +12 -4
  32. package/src/editor/index.ts +10 -1
  33. package/src/editor/layers.tsx +136 -0
  34. package/src/editor/panel.tsx +1 -0
  35. package/src/editor/panes.tsx +4 -3
  36. package/src/editor/sortable.tsx +79 -0
  37. package/src/editor/tree.tsx +61 -0
  38. package/src/editor-layers-plain.js +1 -0
  39. package/src/editor.css +1 -1
  40. package/src/engine-plain.js +1 -0
  41. package/src/index-table.tsx +1 -0
  42. package/src/index.ts +8 -3
  43. package/src/kit-plain.css +1 -1
  44. package/src/kit-plain.js +1 -1
  45. package/src/launcher.tsx +1 -0
  46. package/src/menu-layer.ts +9 -2
  47. package/src/minichart.css +1 -0
  48. package/src/minichart.tsx +165 -0
  49. package/src/portal.tsx +1 -0
  50. package/src/reactions.tsx +1 -0
  51. package/src/resource-list-select.tsx +1 -0
  52. package/src/search-dialog.tsx +146 -0
  53. package/src/selection.tsx +1 -0
  54. package/src/setup-guide.css +1 -0
  55. package/src/setup-guide.tsx +116 -0
  56. package/src/theme-script.ts +10 -0
  57. package/src/theme.tsx +5 -5
package/README.md CHANGED
@@ -11,7 +11,10 @@ npm install @gemboss/ui
11
11
  ```
12
12
 
13
13
  ```tsx
14
- import "@gemboss/ui/gemboss-ui.css";
14
+ // The core, once, in the root layout, FIRST and in this order (0.23.0; "Load only what you use" below):
15
+ import "@gemboss/ui/core.css";
16
+ import "@gemboss/ui/components.css";
17
+ import "@gemboss/ui/shell.css";
15
18
  import { AppShell, RailItem, RailGroup, RailHeading, BrandLockup } from "@gemboss/ui";
16
19
 
17
20
  <AppShell
@@ -40,6 +43,91 @@ Surfaces with no React (gemcare, gempress) use the CSS alone and write the same
40
43
  `nav.icon-nav > .gnav-scroll > .gnav-group > a.gnav-item > span.gnav-ico + span.gnav-label`.
41
44
  `packages/ui/src/shell.tsx` is the reference.
42
45
 
46
+ ## Load only what you use: the core and the entries (0.23.0)
47
+
48
+ Every surface used to load `gemboss-ui.css` whole and use 14–21% of it (measured 06/10/2026 on twelve surfaces). Since
49
+ 0.23.0 the CSS comes in parts (`packages/ui/scripts/parts.mjs` is the list): a CORE every surface loads once, and ENTRIES that
50
+ come with the component that draws them. A React component imports its own entry (`auth.tsx` imports `auth.css`), so
51
+ a Next or Vite surface downloads an entry only on the routes that render it, and a component it never imports takes
52
+ its CSS out of the build with it (`sideEffects`). gzip of the minified file, as published:
53
+
54
+ | Part | Import (Node / bundler) | File in the tarball | What | gzip | Who loads it |
55
+ |---|---|---|---|---:|---|
56
+ | core | `@gemboss/ui/core.css` | `parts/core.css` | the box-sizing reset + the tokens | 1.0 KB | every surface, first |
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
+ | 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 (+ the grid backdrop, 0.30.0) | 2.8 KB | `AuthLayout` & co. import it |
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
+ | 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
+ | 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 |
63
+ | collections | `@gemboss/ui/collections.css` | `packages/ui/src/collections.css` | tables, lists, grids, boards | 4.6 KB | `IndexTable`, `ResourceList`, `Board`… import it |
64
+ | editor | `@gemboss/ui/editor.css` | `packages/ui/src/editor.css` | the editors | 7.9 KB | the `@gemboss/ui/editor` components import it |
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
+ | 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 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) |
72
+
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).
74
+
75
+ **Next.js / Vite.** The core's three imports go at the top of the root layout, above any component import, in the
76
+ order above. Entries need nothing: the components bring them. A page that writes chat markup itself imports
77
+ `@gemboss/ui/chat.css`. Drop `gemboss-ui.css` IN THE SAME BUMP: kept beside the parts, the entries a component brings
78
+ load a second time, after your own CSS, and a second copy of a rule you override wins again.
79
+
80
+ ```tsx
81
+ // app/layout.tsx
82
+ import "@gemboss/ui/core.css";
83
+ import "@gemboss/ui/components.css";
84
+ import "@gemboss/ui/shell.css";
85
+ import "./globals.css"; // your own CSS after the core
86
+ ```
87
+
88
+ Add `"@gemboss/ui"` to `experimental.optimizePackageImports` in `next.config` (the admin does, 0.23.0): a SERVER
89
+ component that imports from the index otherwise collects the CSS of every module the index re-exports (the admin's
90
+ `/login`, which imports `AuthHelp` in its server page, picked up portal.css and collections.css under webpack until it did).
91
+
92
+ Which bundler drops what (measured 06/10/2026): Next 16 (Turbopack) leaves out the entry of a component a route does
93
+ not render. content-radar on 0.23.0 with the core: `/v2` 38.5 → 32.3 KB gzip of CSS, `/login` 31.2 → 25.4 (auth.css
94
+ only there, portal.css nowhere). esbuild keeps the CSS of every module the index reaches even when it drops their JS, so
95
+ through `@gemboss/ui` it ships every entry the index re-exports; and esbuild with no CSS loader now stops at a
96
+ component's `import "./x.css"`: give it `loader: { ".css": "empty" }` (you link the CSS yourself, as GemFactory's
97
+ gbui.mjs and this package's tests do) or `"css"`.
98
+
99
+ **Plain HTML** (a server that serves the files out of `node_modules`): link the core, then only the entries the page
100
+ draws.
101
+
102
+ ```html
103
+ <link rel="stylesheet" href="/ui/core.css"> <!-- require.resolve("@gemboss/ui/core.css") -->
104
+ <link rel="stylesheet" href="/ui/components.css"> <!-- require.resolve("@gemboss/ui/components.css") -->
105
+ <link rel="stylesheet" href="/ui/shell.css"> <!-- require.resolve("@gemboss/ui/shell.css") -->
106
+ <link rel="stylesheet" href="/ui/auth.css"> <!-- the sign-in page only -->
107
+ <link rel="stylesheet" href="/ui/collections.css"> <!-- list pages only -->
108
+ ```
109
+
110
+ **Order.** The core loads FIRST, and its three parts in that order: the top lines of the root layout, or the first three
111
+ `<link>`s. `shell.css` restyles the field look at phone width, and entries restyle the kit with BEM modifiers at the
112
+ kit's own weight (`.ui-empty--no-match` after `.ui-empty`): both are written to follow what they modify, which keeps
113
+ every rule at the weight your own CSS can override. After the core, the entries may land in any order:
114
+ `packages/ui/scripts/cascade-check.mjs` loads the catalog with the entries (collections.css and editor.css included) after the core,
115
+ in order and reversed, and holds every element's computed style to the bundle's at 1280 and 390 (`parts-cascade.test.ts`,
116
+ CI; negative control: two entries that only agree by order). An entry loaded BEFORE the core is not supported: measured,
117
+ `.ui-empty--no-match` loses its 32px padding to `.ui-empty`. Two kit-plain.css rules that overrode shell.css by coming
118
+ after it (the menu row's flex, the thread rail's 44px buttons at phone width) moved to the end of app-shell.css, same
119
+ weight, so they still follow it.
120
+
121
+ **A new component** gets its own entry: a file in `src/`, listed in `ENTRIES` in `packages/ui/scripts/parts.mjs`, exported,
122
+ imported by the component that draws it, with a ceiling in `weight-budget.json`. It never goes into the core or into
123
+ `gemboss-ui.css`: `packages/ui/scripts/parts-gate.mjs` (CI) holds both frozen, fails on a stylesheet nobody classified, and on a
124
+ component that draws an entry's class without importing it.
125
+
126
+ **The admin** is unchanged: it has Polaris and its own order, so it imports the sources one by one
127
+ (`gemboss-tokens.css`, `ui.css`, and `shell-source.css` from its `gemboss-shell.css`; `@gemboss/ui/shell.css` is the
128
+ built part now). Its editor, assistant and sign-in modules already imported the same entry files the components now
129
+ import, so nothing moves.
130
+
43
131
  ## Page width (0.8.1)
44
132
 
45
133
  Chris, 04/10/2026: the rail on the left, and on the right a content column with the same max width on every
@@ -111,6 +199,21 @@ then the mark, then the foot lines as the visible height shrinks, follows the OS
111
199
  tracks the on-screen keyboard. Re-theme it with tokens on `.gb-auth`, e.g. a light iris panel:
112
200
  `--gb-panel-bg`, `--gb-panel-ink`, `--gb-panel-muted`, `--gb-panel-eyebrow`, `--gb-panel-facet-o`.
113
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
+
114
217
  ## The assistant frame
115
218
 
116
219
  ```tsx
@@ -187,29 +290,225 @@ Its styles are the `gemboss-assistant-*` rules in `packages/ui/src/shell.css` (t
187
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
188
291
  through `fullActions`; a surface without those leaves them out.
189
292
 
190
- ## The chat body
293
+ ## The engine picker (0.25.0): `EnginePicker`, `BridgeNotice`
191
294
 
192
- What an answer looks like, on any surface that puts a chat somewhere: `chat.css` (inside `gemboss-ui.css`;
193
- the admin `@import`s it, so it is the same file the admin's own chat is drawn with).
295
+ Which engine answers the assistant, and the bridge on the user's machine, without spending the chat's space on it
296
+ (Chris 07/10/2026: content-radar's assistant spent close to half its frame on an ENGINE / MODEL / EFFORT row, an
297
+ "outdated bridge" block and the install command). The admin's shape: a chip in the composer row opens a popup.
298
+
299
+ ```tsx
300
+ import { EnginePicker, BridgeNotice, useEnginePicker } from "@gemboss/ui/assistant"; // brings assistant-engine.css
301
+
302
+ const picker = useEnginePicker(); // open + tab, shared with the notice
303
+ <EnginePicker {...picker.props}
304
+ engines={[{ id: "claude", label: "Claude Code", note: "Your Claude subscription" }, { id: "server", label: "Server" }]}
305
+ engine={engine} onEngineChange={setEngine}
306
+ models={[{ id: "", label: "Default" }, { id: "opus", label: "Opus" }]} model={model} onModelChange={setModel}
307
+ efforts={[{ id: "", label: "Default" }, { id: "high", label: "High" }]} effort={effort} onEffortChange={setEffort}
308
+ bridge={{ state: "outdated", version: "8", latest: "10", autostart: true, detail: "Codex cannot see the radar tools." }}
309
+ setup={{ command: installCommand, pairingCode, note: "The panel reconnects in a few seconds." }}
310
+ statusActions={<button className="ui-btn" onClick={recheck}>Check again</button>} />
311
+
312
+ // Only when a turn FAILED because of the bridge, one line in the stream:
313
+ <BridgeNotice onOpenSetup={() => picker.openTab("setup")}>The bridge on your machine is not responding.</BridgeNotice>
314
+ ```
315
+
316
+ - **Chip**: engine name, the model when it is not the default (`id: ""`), a dot for `bridge.state`: green `running`,
317
+ amber `outdated`, red `offline` / `unpaired`, hollow `checking`. Its accessible name says the state in words.
318
+ - **Popup**: a non-modal dialog in the top layer (placed like the kit's menus, `placeFloating`), so the assistant's frame
319
+ cannot cut it. Tabs: Engine always; Setup when `setup` is given; Status when `bridge` is given. ←/→/Home/End move
320
+ between tabs; ↑/↓ move between engines (one Tab stop). Escape with focus in the panel, or a model / effort pick from
321
+ the buttons, closes it and gives focus back to the chip; an outside click closes. An engine pick and a pick from a
322
+ 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.
323
+ - **Not in the chat**: no standing warning, no command in the conversation. The amber dot on the chip and on the Setup
324
+ tab is the warning. `BridgeNotice` is for a turn that actually failed, once.
325
+ - **For the admin (0.29.0)**: `EngineChoice.mark` ("claude", "codex") is shown on the chip in place of the name when the
326
+ composer is too narrow (`ChatComposer` density icon); `EngineChoice.models` on every engine + `onPick(engine, model)`
327
+ lists each engine with its own models at once, and one click picks both and closes (the admin's Polaris popup);
328
+ `problem` is a red line on the Engine tab ("Wait for the current answer to finish"); `disabled` disables the chip while
329
+ a switch is in flight.
330
+ - The package knows no bridge: the surface polls its own, computes `outdated`, builds the command, and owns the
331
+ behaviour of every callback. `EnginePanel` is the same tabs without the chip (catalog, a settings page).
332
+
333
+ Tests: `packages/ui/src/assistant/engine.test.tsx` (markup, styles, its own entry), `packages/ui/src/assistant/engine-browser.test.tsx`
334
+ (Chromium: top layer through a clipping frame, keys, focus, copy, Open setup, 44px; with negative controls).
335
+
336
+ **Escape (0.27.0).** An Escape that closes the panel is marked handled (`preventDefault`) also when focus is outside it
337
+ (the composer): a surface whose own Escape hides the assistant and checks `defaultPrevented` leaves that key alone, and the
338
+ next Escape is its own again. Before, the key went on unmarked and React re-renders between the picker's listener and the
339
+ surface's, so each surface needed a window-capture marker to tell the two apart (gemlabs #922, gemfeedback #66). With
340
+ 0.27.0 the surface's `!e.defaultPrevented` is enough.
341
+
342
+ ### Plain HTML: `gembossUI.enginePicker()` (0.27.0)
343
+
344
+ `engine-plain.js`, after `kit-plain.js`, for a page with no React (gemcare). The same markup and classes as the component
345
+ (asserted: same class set for the same options), so `assistant-engine.css` draws it, and the same behaviour (top layer,
346
+ tabs and keys, focus back to the chip, closing goes back to Engine, Copy, 44px under a coarse pointer, Escape marked):
347
+
348
+ ```html
349
+ <link rel="stylesheet" href="/ui/assistant-engine.css"> <!-- require.resolve("@gemboss/ui/assistant-engine.css") -->
350
+ <script src="/ui/kit-plain.js"></script><script src="/ui/engine-plain.js"></script>
351
+ ```
352
+
353
+ ```js
354
+ const picker = gembossUI.enginePicker(document.querySelector(".gemboss-ec-toolbar-lead"), {
355
+ engines: [{ id: "server", label: "GemCare AI, paid" },
356
+ { id: "claude", label: "Claude Code", badge: "your plan", badgeTone: "success", note: "Your own subscription." }],
357
+ engine: "claude", onEngineChange: (id) => …,
358
+ models, model, onModelChange, efforts, effort, onEffortChange, effortDisabled, notes, modelControl,
359
+ bridge: { state: "outdated", version: "8", latest: "10", autostart: true, detail: "…" }, showBridgeDot: true,
360
+ setup: { command, pairingCode, steps, note }, statusActions: [checkAgainButton],
361
+ onOpenChange, onTabChange, locked, end, labels,
362
+ });
363
+ picker.update({ bridge: { state: "running" } }); // a poll: bridge / setup / labels merge one level; patched in place
364
+ picker.openTab("setup"); picker.close(); picker.isOpen(); picker.destroy();
365
+ log.append(gembossUI.bridgeNotice({ text: "The bridge on your machine is not responding.", onOpenSetup: () => picker.openTab("setup") }));
366
+ ```
367
+
368
+ Strings are text; `steps`, `notes`, `detail`, `statusActions` and `modelControl` may also be a Node, an array of Nodes or a
369
+ DocumentFragment: placed once and left where they are, so a field in them keeps focus, caret and events across redraws.
370
+ A redraw PATCHES the panel in place (the same elements, only what changed, as React does): focus on a tab, an engine row
371
+ or the model select stays, and a clamped panel keeps its scroll; behaviour is delegated from the panel (`data-*`).
372
+ A pick from the engine list, the model or the effort updates the picker at once and calls back (no callback: that row is
373
+ disabled, as in the component); `update()` is how the page says anything else changed. An Escape it does not own is left
374
+ alone: an IME composition, or one in a modal opened from the panel; and focus entering such a modal closes the panel,
375
+ which would otherwise sit above it in the top layer (the component does the same since 0.27.0). Tests:
376
+ `packages/ui/src/engine-plain.test.ts` (Chromium: the component's class set on all three tabs, top layer through a
377
+ clipping frame with its negative control, no popover API, keys, Escape, Copy, redraws that keep focus / scroll / a
378
+ caller's field without a blur or change, merge rules, 44px).
379
+
380
+ ## The chat body (components since 0.29.0)
381
+
382
+ 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".
383
+ Since 0.29.0 the package DRAWS the chat, not only its CSS: the surface hands in messages and actions and keeps its own
384
+ streaming, bridge and sessions. Each piece is the better of the two surfaces that had it (inventory 08/10/2026,
385
+ `Gems/_local/fleet/assistant-sync-2026-10/`):
386
+
387
+ | Piece | Component | Taken from | Why that one |
388
+ |---|---|---|---|
389
+ | 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 |
390
+ | Markdown | `ChatMarkdown` | admin | tables, folded code, safe links, trusted pictures, no raw HTML; the radars fed an HTML string to `dangerouslySetInnerHTML` |
391
+ | 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 |
392
+ | Working | `ChatPending` | admin | ✳, a verb that changes, the seconds: the wait reads alive |
393
+ | 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 |
394
+ | Under an answer | `ChatMeta` | partner-radar | Copy · engine · seconds · cost |
395
+ | Errors | `ChatError`, `BridgeNotice` | partner-radar | a fix the user can take (sentence, command, Copy, the original folded); a bridge failure is one line |
396
+ | Engine | `EnginePicker` | partner-radar (Setup / Status tabs) + admin (mark, models per engine) | see "The engine picker" |
397
+ | Frame, sessions | `AssistantHost`, `SessionRail` | (already the package's) | |
398
+
399
+ ```tsx
400
+ import { AssistantChat, ChatAttach, EnginePicker, useChatTurns, useEnginePicker } from "@gemboss/ui/assistant"; // brings its CSS
401
+
402
+ const turns = useChatTurns(); // messages with ids; a turn is written BY ID
403
+ const picker = useEnginePicker();
404
+ async function send(text: string) {
405
+ turns.say(text);
406
+ const id = turns.start();
407
+ try {
408
+ for await (const ev of stream(text)) {
409
+ if (ev.type === "text") turns.write(id, ev.text);
410
+ if (ev.type === "tool") turns.step(id, { id: ev.id, label: TOOL_LABEL[ev.name], target: ev.target, state: ev.state });
411
+ }
412
+ turns.finish(id, { meta: ["Claude Code", "4.2s"] });
413
+ } catch (e) {
414
+ if (turns.fail(id, e).bridge) probeTheBridgeAgain(); // mark the bridge's own call with atBridge(e, status)
415
+ }
416
+ }
417
+
418
+ <AssistantHost title="Partner Assistant" …>
419
+ <AssistantWorkspaceLayout dock={presentation !== "full"}>
420
+ <AssistantChat messages={turns.messages} busy={busy} onSend={send} onStop={stop}
421
+ title="Partner Assistant" // shown at full screen only (the frame's header says it elsewhere)
422
+ intro={<p className="gemboss-md-p">Describe a meeting…</p>}
423
+ starters={[{ label: "Had dinner with Amit…", hint: "Example, click to fill in", fill: true }]}
424
+ moreStarters={SUGGESTIONS} moreStartersTitle="Or ask about partners in the radar:"
425
+ engine={<EnginePicker {...picker.props} … />} tools={<ChatAttach accept=".pdf,.docx" onFiles={upload} />}
426
+ onOpenSetup={() => picker.openTab("setup")} renderExtra={(m) => m.proposals?.map(…)} onNavigate={router.push} />
427
+ </AssistantWorkspaceLayout>
428
+ </AssistantHost>
429
+ ```
430
+
431
+ - **`messages`** (`ChatMessageData`): `role` user / assistant / note; `text` (markdown for an answer, `markdown: false` for
432
+ plain); `pending` (the heartbeat until text, then the cursor); `steps`; `error` + `fix: { text, command }`, or `bridge:
433
+ true` (one BridgeNotice line whose action calls `onOpenSetup`); `warn`; `stopped`; `meta` (after Copy); a note's `tone`
434
+ warn / down + `action` makes it a BridgeNotice ("This bridge is outdated · Open setup").
435
+ - **Failed turns** (`bridgeTurnError`, used by `turns.fail`): only a failure AT the bridge is the bridge's: its call threw
436
+ (it is gone, the machine slept) or it answered 401 (this browser's pairing code is not its code). Its 400 / 503 and a
437
+ failure on the way to the server (a data token, an expired session) are the turn's. The surface marks the bridge's own
438
+ call (`throw atBridge(err, res.status)`); it never guesses from "this turn used the bridge" (content-radar #937).
439
+ - **Failed turns, timeouts**: give the bridge's call its deadline with `AbortSignal.timeout(ms)` (a `TimeoutError`, the
440
+ bridge's); an `AbortController` you abort yourself throws an `AbortError`, which reads as the user's Stop.
441
+ - **Following the end**: the log follows while the reader is at the end, and stops at their first move up (wheel, finger,
442
+ ArrowUp / PageUp / Home), not 24px later. A send, or another conversation shown, brings it back (`ChatThread pinKey`;
443
+ `AssistantChat` does both).
444
+ - **Full screen**: `AssistantChat` measures how far the frame's floated buttons (`fullActions`, Back) reach into it and
445
+ gives its heading (or, without one, its log) that much room (`useFullChromeRoom`, for your own column); a `fullActions`
446
+ button with no icon keeps its words on a phone. Measured in the child because only the child knows where its column is
447
+ (at 1920 the 820px column does not reach the buttons at all).
448
+ - **Loading**: the chat components import chat.css and chat-composer.css, which the old `gemboss-ui.css` bundle also
449
+ carries. A surface still linking the bundle gets them twice, the second copy after its own CSS: move to the parts
450
+ first (see "Load only what you use").
451
+ - **Composer density**: `full` "Claude Code · Opus" → `compact` "Claude Code" → `icon` the engine's MARK (`EngineChoice.mark`)
452
+ → `tight`. An engine with no mark keeps its (cut) name.
453
+ - **Markdown** (0.29.2): a bare `https://…` in the prose is a link (GitHub's reading: the sentence's trailing `. , : ; ! ?
454
+ _ ~ ' "` and an unpartnered `)` / `]` stay text; `<https://…>` too), through the same `linkTarget` as `[x](…)`, so only
455
+ http(s) with a host. It ends at a space, `*`, CJK text or full-width punctuation ("访问https://x.test。然后" links only
456
+ the URL), and one glued to a word (`foohttps://…`) is not a link. Inside `**bold**` a URL stays plain text. `####` to
457
+ `######` are headings at the lowest step (`h5.gemboss-md-h3`); a heading line with no words is dropped.
458
+ - **Pictures on the page's own origin** (0.29.2): `imageHosts={[{ path: "/api/img/" }]}` (no `host`) draws
459
+ `![cover](/api/img/r2/a.png)` as written, a root-relative path checked after the browser's normalisation: `../`,
460
+ `%2e%2e`, `\` that leave `path`, an encoded `/` or `\`, a double encoding (`%25`), a `;` (`..;/`), `//host` and `/\host`
461
+ are refused (the alt stays as text). `path` must be a directory below the root (`/x/`, not `/` nor `/x`), or the entry
462
+ is ignored. No need to rewrite the answer into absolute URLs first.
463
+ - **A form in the engine popup** (0.29.2): the send circle is `.gemboss-ec-toolbar > button[type=submit]`, the toolbar's
464
+ own child. Before, `.gemboss-ec-input button[type=submit]` also painted a submit button in the popup (it lives in the
465
+ leading slot in the DOM): a surface's Pair form can keep `type="submit"`.
466
+ - A surface that needs its own arrangement uses the pieces directly (`ChatThread`, `ChatMessage`, `ChatBubble`,
467
+ `ChatMarkdown`, `ChatSteps`, `ChatPending`, `ChatStarters`, `ChatMeta`, `ChatError`, `ChatNote`, `ChatComposer`,
468
+ `ChatAttach`) inside `ChatLabelsProvider` for its words. Every word is in `CHAT_EN` (English chrome, Chris 03/10/2026).
469
+
470
+ CSS: `chat.css` (the body; also inside `gemboss-ui.css`, and the admin `@import`s it), `chat-composer.css` (toolbar, density,
471
+ Stop) and `assistant-chat.css` (0.29.0: the column, wrapping starters, the meta line, errors, notes, the paperclip, the
472
+ room for full screen's buttons). The chat components import all three; a page that writes the markup itself links them.
194
473
 
195
474
  | Piece | Classes |
196
475
  |---|---|
197
- | Header | `.gemboss-ec-head` › `.gemboss-ec-title` + `.gemboss-ec-sub` |
198
- | Message list | `.gemboss-ec-scroll` (thin visible scrollbar) |
476
+ | Column | `.gb-chat` › (`.gemboss-ec-head.gb-chat-head`) + `.gemboss-ec-scroll[role=log]` + `.gemboss-ec-input` |
199
477
  | Messages | `.gemboss-ec-msg.user` / `.gemboss-ec-msg.assistant` › `.gemboss-ec-bubble` |
200
478
  | Markdown inside a bubble | `.gemboss-md-p / -h -h1..3 / -list / -quote / -rule / -code / -codeblock / -table(wrap) / -img(link)`, links `.gemboss-ec-md-link` |
201
- | 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`) |
202
- | Working / streaming | `.gemboss-ec-pending` (`-star`, `-verb`, `-meta`), `.gemboss-ec-cursor` after the streamed text |
203
- | Suggestions, choices | `.gemboss-ec-starters` › `button.gemboss-ec-starter`; `.gemboss-ec-chips` › `button.gemboss-ec-chip` |
204
- | 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 |
205
-
206
- The toolbar row, the one-row layout, the engine picker's layout and Stop are in a second sheet,
207
- `chat-composer.css` (also inside `gemboss-ui.css`): the admin imports it from its chat components rather than its
208
- 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
209
- (`.gemboss-ec` width; the editor columns are in `editor.css` since 0.9.0), attachments, action cards, the engine picker and work mode — those
210
- are still the admin's. The one exception is the chat panel's touch / mouse target blocks: one rule per pointer
211
- 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
212
- the surface.
479
+ | Steps | `ul.gemboss-narr` › `li.gemboss-narr-row.ok|.running|.failed|.thinking|.trace|.steer` › `.gemboss-narr-dot` + `.gemboss-narr-text` (+ `.gb-chat-target`) |
480
+ | Working / streaming | `.gemboss-ec-pending` (`-star`, `-verb`, `-meta`, `.gb-chat-sr`), `.gemboss-ec-cursor` after the streamed text |
481
+ | Suggestions | `.gemboss-ec-starters` › `button.gemboss-ec-starter.gb-chat-starter` (› `.gb-chat-starter-hint`); a group title `.gb-chat-label` |
482
+ | 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` |
483
+ | 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` |
484
+
485
+ Tests: `packages/ui/src/assistant/chat.test.tsx` (markup, parser, every class styled), `chat-browser.test.tsx` (Chromium:
486
+ Enter / IME, Stop in send's place, growing box, density with the mark, following the end, the turn store by id, failed
487
+ turns, full screen's buttons, 44px; each with its negative control).
488
+
489
+ ### Plain HTML: `gembossUI.chat` (0.29.0)
490
+
491
+ `chat-plain.js`, after `kit-plain.js` (and `engine-plain.js` when the page has the picker), for a page with no React
492
+ (gemcare's inbox and Reports assistants). The same markup as the components, element by element, and the same markdown
493
+ (`chat-plain.test.ts` renders both from the same data and compares the trees). `render()` redraws from the data each
494
+ time; a message whose look did not change keeps its element, and a changed one keeps the folds the reader opened and its
495
+ heartbeat's clock:
496
+
497
+ ```html
498
+ <link rel="stylesheet" href="/ui/chat.css"><link rel="stylesheet" href="/ui/chat-composer.css">
499
+ <link rel="stylesheet" href="/ui/assistant-chat.css"><link rel="stylesheet" href="/ui/assistant-engine.css">
500
+ <script src="/ui/kit-plain.js"></script><script src="/ui/engine-plain.js"></script><script src="/ui/chat-plain.js"></script>
501
+ ```
502
+
503
+ ```js
504
+ const log = gembossUI.chat.thread(document.getElementById("ai-thread")); // role=log, follows the end
505
+ gembossUI.chat.render(log.el, messages, { intro, starters, onStarter, onOpenSetup: () => picker.openTab("setup"),
506
+ ownLink: (h) => /^(customer|conversation):\d+$/.test(h), renderLink: (href, label) => aiLinkButton(href, label) });
507
+ const box = gembossUI.chat.composer(host, { placeholder: "Ask about this customer", maxLength: 1000, leading: pickerHost,
508
+ thread: log, onSend: (text) => ask(text), onStop: () => controller.abort() }); // thread: back to the end on a send
509
+ box.setBusy(true); log.setBusy(true);
510
+ const { error, bridge } = gembossUI.chat.turnError(err); // the same rule as bridgeTurnError
511
+ ```
213
512
 
214
513
  ## Editor CSS (0.9.x)
215
514
 
@@ -243,8 +542,9 @@ import "@gemboss/ui/editor.css"; // only on the screens that show an editor
243
542
  Not here, on purpose: the body modes that hide the admin's own topbar and rail while an editor is open
244
543
  (`gemboss-editor-fullscreen`, `-just-exited`, `-layer-open`) and admin-only extensions such as the product studio's
245
544
  resizer. The frame and its top bar are React components since 0.14.0 ("Editor frame" below), with their behaviour for
246
- plain HTML in `editor-plain.js`; the panel and its controls are components since 0.17.0 ("Settings panel" below, step 3); step 4 is `LayerList`,
247
- `EditorDrawer`. Step 5 moves GemFactory's Builder onto them.
545
+ plain HTML in `editor-plain.js`; the panel and its controls are components since 0.17.0 ("Settings panel" below, step 3); the
546
+ layers, the element outline and the drawer since 0.22.0 ("Layers and drawer" below, step 4). Step 5 moves GemFactory's
547
+ Builder onto them.
248
548
 
249
549
  Changes since the verbatim move, in 0.10.1 (05/10/2026, step 1b, the only ones that change what the admin shows; CHANGELOG):
250
550
 
@@ -294,7 +594,7 @@ import { DeviceToggle, EditorBack, EditorChatToggle, EditorFrame, EditorPaneTabs
294
594
  | `EditorBack` | `.gemboss-editor-back` ← | A `<button type="button">`, or `as={Link}` / `as="a"` with `href`. Named "Back" for a screen reader (`label`). |
295
595
  | `EditorChatToggle` | `.gemboss-editor-chat-toggle` | Shows / hides the left column; filled while open, pressed when collapsed. |
296
596
  | `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. |
297
- | `DeviceToggle`, `MonitorGlyph`, `PhoneGlyph` | `.gemboss-pv-devices` › `.gemboss-pv-device` | Desktop / tablet / mobile. Moved from the admin unchanged. |
597
+ | `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). |
298
598
 
299
599
  One module per piece behind one entry, so a bundler gives each editor only what it draws (measured: one module for all
300
600
  of it cost the page and section editors about 1 KB of code they never ran). Not in `@gemboss/ui` itself: like
@@ -391,6 +691,51 @@ named (it bubbles):
391
691
  | Edges: Enter blurs; a box that loses focus sends all four | `.gemboss-ei-edgebox` | `gemboss:editor-edges` `{ t, r, b, l }` |
392
692
  | Image: commits on blur / Enter when changed; the thumbnail follows; a chip with `data-gb-image-clear` empties it | `.gemboss-ei-img` | `gemboss:editor-image` `{ value }` |
393
693
 
694
+ ## Layers and drawer (0.22.0): `LayerList`, `SortableLayerList`, `LayerTree`, `EditorDrawer`
695
+
696
+ The page editor's Layers, Page settings and Edit content drawers and the element inspector's Elements outline, as
697
+ components (`@gemboss/ui/editor`). The admin draws those four with them since 0.22.0, measured before and after on a
698
+ production build: the same computed style and box, the markup differing only in `data-id` on each row, `type="button"`
699
+ on the buttons the components draw, and `aria-current` on the selected layer (CHANGELOG has the numbers).
700
+
701
+ ```tsx
702
+ import { EditorDrawer, LayerList, LayerTree, SortableLayerList } from "@gemboss/ui/editor";
703
+
704
+ {layersOpen ? (
705
+ <EditorDrawer side="left" label="Page layers" title="Layers" onClose={() => setLayersOpen(false)}>
706
+ <SortableLayerList items={sections.map((s) => ({ id: s.id, title: s.type, subtitle: s.heading, hidden: s.hidden }))}
707
+ selectedId={picked} onSelect={(it) => setPicked(it.id)}
708
+ onMove={(it, to) => moveSection(it.id, to)} onToggleHidden={(it, hidden) => setHidden(it.id, hidden)}
709
+ actions={(it) => <button aria-label="Delete" onClick={() => remove(it.id)}>✕</button>} empty="No sections yet" />
710
+ </EditorDrawer>
711
+ ) : null}
712
+ ```
713
+
714
+ | Component | Markup | Behaviour |
715
+ |---|---|---|
716
+ | `LayerList` | `.gemboss-layers-list` › `.gemboss-layer[data-id]` (`.is-sel`) › `button.gemboss-layer-main` (`.gemboss-layer-type` · `.gemboss-layer-head`) · `.gemboss-layer-acts` | A click selects (`onSelect`; the selected button says `aria-current`). Rows are 34px, 50px with a subtitle. `empty` shows in `.gemboss-picker-empty`. `onToggleHidden`: an eye first among the actions (named "Hide <name>", `aria-pressed` = hidden, the row faded). `depth` per item indents it (`indent` px per level, default 14). Stateless. |
717
+ | `SortableLayerList` | the same, each row `draggable` with a `.gemboss-layer-grip` | `onMove(item, to)` (`to` = the index after the move): drag a row (faded while dragged, a 2px line where it lands), or Alt+↑ / Alt+↓ on its button (the focus stays on the moved row, also when `onMove` answers later). The drag is kept by id. Its own module (`"use client"`): a list that does not reorder does not carry it. |
718
+ | `LayerTree` | `ul.gemboss-ei-tree[role=tree]` › `li.gemboss-ei-tnode[role=treeitem][data-id]` (`.on`, level / position / selection) › `button.gemboss-ei-tname` (+ `small`) · `.gemboss-ei-tacts` · `below` | A click on the name calls `onSelect`; toggling it off is the caller's. Actions show on hover, on the selected row and on focus. 27px rows, 14px per level. |
719
+ | `EditorDrawer` | `.gemboss-picker-backdrop` + `aside.gemboss-settings` (`side="right"`, min(380px, 100%)) or `aside.gemboss-layers` (`side="left"`, 300px) › `before` · `.gemboss-picker-head` (title, ✕) · children · `.gemboss-settings-foot` (`footer`) | ✕ and the backdrop call `onClose`. Mounted while open. The body is the caller's: `.gemboss-settings-body` › `label.gemboss-settings-field` (`span`, control, `small`). |
720
+
721
+ Drag to reorder is pointer only (HTML drag and drop does not start from touch); a list that must reorder on a phone
722
+ keeps ↑ / ↓ buttons in its `actions`, as the admin's does. Kept as the admin had them: the drawer has no Escape and
723
+ does not move the focus into itself or back; the admin's Layers do not drag and have no eye (they move with ↑ / ↓).
724
+
725
+ ### Plain HTML: `initEditorLayers()`
726
+
727
+ The same markup (copy it from `catalog/editor.html`), then `gembossUI.initEditorLayers()` (`editor-layers-plain.js`, its own file:
728
+ load it only where layers or drawers are, after `kit-plain.js`; the whole document, or `initEditorLayers(element)`). A drawer stays in the document while closed:
729
+ `hidden` on the aside and on the backdrop before it, an `id` on the aside.
730
+
731
+ | Wires | Markup | Event |
732
+ |---|---|---|
733
+ | Select a layer (`.is-sel`, `aria-current`) | `.gemboss-layer-main` | `gemboss:editor-layer` `{ id, index }` on the row |
734
+ | Hide / show (`aria-pressed`, `.is-hidden`, the eye's slash) | `.gemboss-layer-eye` | `gemboss:editor-layer-hidden` `{ id, hidden }` |
735
+ | Reorder: drag a row, or Alt+↑ / Alt+↓ on its button; the row moves in the document and keeps the focus | `.gemboss-layer[draggable=true]` | `gemboss:editor-layer-move` `{ id, from, to }` |
736
+ | Select an outline node; a second click clears it | `.gemboss-ei-tree .gemboss-ei-tname` | `gemboss:editor-tree` `{ id }` (`null` when cleared) |
737
+ | Open a drawer (closing any other open one) / close it (✕ in its head, `data-gb-drawer-close`, the backdrop) | `[data-gb-drawer="<aside id>"]` | `gemboss:editor-drawer` `{ id, open }` on the aside |
738
+
394
739
  ## Plain-HTML behaviour: `kit-plain.js` (0.3.0)
395
740
 
396
741
  Surfaces without React (GemFactory, gemcare, gempress, partner-radar) render the kit's markup on the server and need
@@ -864,6 +1209,28 @@ Every target is 44px (a control, CLAUDE.md §9.1); the pill you see is 26px, tra
864
1209
  message with reactions does not grow by a full line. A plain-HTML surface writes the same classes (markup at the top of
865
1210
  `reactions.css`, and the catalog's "Reactions" section).
866
1211
 
1212
+
1213
+ ## Action card (0.28.0): an assistant proposes, a person applies
1214
+
1215
+ Chris 08/10/2026: the Feedback Assistant lists likely duplicates and merges them, and carries out status, owner, label
1216
+ and date changes on request, "follow theo gemboss ui". GemFeedback built it first (`components/MergeCard.tsx`,
1217
+ `components/ActionCard.tsx` there); this is its look, moved here so every assistant in the fleet asks the same way.
1218
+
1219
+ The contract every surface keeps: **the model never changes anything**. It proposes (a link it writes, a tool result);
1220
+ the card shows what would change item by item, what to know first, and how far it reaches; only the primary button
1221
+ applies; afterwards the outcome replaces the body, with Undo when the surface can put things back.
1222
+
1223
+ | Piece | What it draws |
1224
+ | --- | --- |
1225
+ | `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` |
1226
+ | `ActionCardSection` | a labelled list ("Keeping", "Merging into it"), or unlabelled |
1227
+ | `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` |
1228
+ | `ActionChange` | "Idea → Planned"; "Already Planned" when equal; `problem` ("Merged into #7", "Changed since") instead |
1229
+ | `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` |
1230
+
1231
+ Presentational: no fetching and no state of its own. The surface loads the preview, owns the handlers, and decides
1232
+ what Undo puts back (GemFeedback: the merge record, or the values an action replaced, guarded by `if=` so Undo never
1233
+ overwrites a later edit). Catalog section "Thẻ hành động".
867
1234
  ## Report pages (0.4.0)
868
1235
 
869
1236
  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
@@ -883,6 +1250,46 @@ surface computes every number and ratio; the kit only draws. Markup for each is
883
1250
  - `ui-grid--2` / `--3`: card grid that drops to one column; `ui-section-head`: an `h2` + note between page head and
884
1251
  card titles.
885
1252
 
1253
+ ## Mini charts (0.30.0): `Sparkline`, `ShareBar`
1254
+
1255
+ Two charts small enough for a card or a table cell, each its own entry (`minichart.css`, imported by the component).
1256
+ Before this, six surfaces drew a sparkline by hand (admin intelligence, analytics and operator; talent-radar; search-radar;
1257
+ gemwatcher; gemform) with three different baselines.
1258
+
1259
+ ```tsx
1260
+ import { Sparkline, ShareBar } from "@gemboss/ui";
1261
+ <Sparkline points={[3, 5, null, 4, 8]} label="Registrations, last 5 days" />
1262
+ <ShareBar label="Status" parts={[{ label: "New", value: 25, tone: "info" }, { label: "Spam", value: 2, tone: "critical" }]} />
1263
+ ```
1264
+
1265
+ - `Sparkline`: oldest point first; `null` is a gap (the line breaks, a lone point is a dot); counts start at 0 unless
1266
+ `min` / `max` fix the scale; `invert` for a rank. Muted line, the newest point one iris dot; `tone` `series` /
1267
+ `success` / `critical`. With `label` it is an image with that name, without it decoration (`aria-hidden`).
1268
+ `sparklinePaths(points, opts)` is the geometry alone, for a page that writes the SVG itself.
1269
+ - `ShareBar`: one 6px bar split by share; parts at 0 take no room; tones are the badge tones (`info`, `success`,
1270
+ `warning`, `critical`, `neutral`) or `series-1..5` (the default, in order). The bar is one image whose name lists every
1271
+ part, so a screen reader hears the numbers the colours stand for. Each segment grows by its SHARE (value / total), so
1272
+ fractions fill the track. The share is an inline `flex-grow`: a plain-HTML page under a CSP that forbids inline style
1273
+ (gemcare) sizes the segments from its own stylesheet or script.
1274
+
1275
+ ## Setup guide (0.30.0): `SetupGuide`
1276
+
1277
+ The admin's "Set up your store" card (Shopify's Setup guide) as a component, its own entry (`setup-guide.css`): a title
1278
+ with the count, a bar, collapsed steps of which the first not done is open. Native `<details>`: the head folds the guide,
1279
+ the steps share one `name` so the browser keeps one open, and a page without React writing the same markup (catalog
1280
+ "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
1281
+ 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
1282
+ the admin: a state change while someone reads a step never shuts it. `state: "unknown"` (could not be read) is counted
1283
+ apart, never as progress. The bar is a `<progress>` (no inline style). `rememberKey` keeps a folded guide folded across
1284
+ visits (localStorage); `group` names the steps' `name` when two guides render in separate React roots. No steps: no guide.
1285
+
1286
+ ```tsx
1287
+ <SetupGuide title="Telegram alerts" steps={[
1288
+ { id: "bot", title: "Connect a bot", state: bot ? "done" : "todo", body: <ConnectBot /> },
1289
+ { id: "chat", title: "Choose where alerts go", state: chats.length ? "done" : "todo", body: <Chats /> },
1290
+ ]} />
1291
+ ```
1292
+
886
1293
  ## Collections (0.13.0): tables, lists, grids, boards
887
1294
 
888
1295
  Chris settled these on 05/10/2026 after an inventory of 136 hand-made collections on 11 surfaces (86 tables scrolling
@@ -1086,13 +1493,15 @@ something the base cannot express, add a token or a prop here, and every surface
1086
1493
 
1087
1494
  | Path | What |
1088
1495
  |---|---|
1089
- | `gemboss-ui.css` | built at stage time and shipped minified (0.19.0): `apps/admin/scripts/export-ui-css.mjs` (tokens + kit + field + `packages/ui/src/shell.css`, with Polaris `--p-*` resolved to their fallbacks) + `packages/ui/src/app-shell.css` + `auth.css` + `assistant.css` + `kit-plain.css` + `chat.css` + `chat-composer.css` (`packages/ui/scripts/build-css.mjs`) |
1090
- | `packages/ui/src/shell.css` | the SHARED SHELL (rail, topbar, main, mobile drawer, the assistant's thread rail, popover surface, account chip). The admin's `gemboss-shell.css` @imports it on line 2; the export refuses a shared-shell rule in the admin's sheet or an admin-only rule here (one owner). |
1496
+ | `parts/core.css`, `parts/components.css`, `parts/shell.css` | 0.23.0: the core, built by `packages/ui/scripts/build-css.mjs` from the same sections as `gemboss-ui.css` (see "Load only what you use"). |
1497
+ | `gemboss-ui.css` | built at stage time and shipped minified (0.19.0); since 0.23.0 the COMPATIBILITY BUNDLE, frozen: `apps/admin/scripts/export-ui-css.mjs` (tokens + kit + field + `packages/ui/src/shell.css`, with Polaris `--p-*` resolved to their fallbacks) + `packages/ui/src/app-shell.css` + `auth.css` + `assistant.css` + `kit-plain.css` + `chat.css` + `chat-composer.css` (`packages/ui/scripts/build-css.mjs`) |
1498
+ | `packages/ui/src/shell.css` | the SHARED SHELL (rail, topbar, main, mobile drawer, the assistant's thread rail, popover surface, account chip). Exported as `@gemboss/ui/shell-source.css` since 0.23.0 (`@gemboss/ui/shell.css` is the built part). The admin's `gemboss-shell.css` @imports it on line 2; the export refuses a shared-shell rule in the admin's sheet or an admin-only rule here (one owner). |
1091
1499
  | `packages/ui/src/kit-plain.css` | primitives for surfaces that render plain HTML, not React (0.2.1): menu, key/value list, icon button; 0.3.0 adds `ui-link` / `ui-prose`, the media card (`ui-card--media`, `__media`, `__body`, `__foot`) and the popup's `ui-modal__body` / `__foot`. Shipped inside `gemboss-ui.css`; the admin does not import it (its first-load CSS budget has ~0.1 KB headroom).; (0.4.0) report pages: delta, large KPI grid, bar list, column chart, legend, funnel, card grid, section head. |
1092
1500
  | `packages/ui/src/kit-plain.js` | 0.3.0: the behaviour those surfaces each wrote by hand: `gembossUI.openModal` (Escape, backdrop, Tab trap, stacking, ask-before-close, focus back), `toast` (text, error tone), `initMenus` (outside click, item, blur, Escape closes the menu before the popup); 0.5.0: `confirm`, `initShell`, `initPasswordFields` / `resetPasswordFields`. A classic script: inline it or `<script src>`; see "Plain-HTML behaviour" above. |
1093
1501
  | `packages/ui/src/collections.css` | 0.13.0: tables, lists, grids, boards and their states (see "Collections"). A separate entry, NOT inside `gemboss-ui.css`; the admin does not load it. |
1094
- | `packages/ui/src/editor/` | 0.14.0, `@gemboss/ui/editor`: `EditorFrame`, `EditorPaneTabs`, `EditorTopbar`, `EditorBack`, `EditorChatToggle`, `EditorSaveState`, `DeviceToggle`. See "Editor frame". 0.17.0: `PanelTabs`, `PanelHeader`, `PanelAction`, `InspectorGroup`, `Field`, `Seg`, `ColorControl`, `RangeControl`, `EdgeBox`, `ImageControl`, `ChipButton`, `swatchState` (`./editor/swatch` too); 0.20.1 `cssUrl`. See "Settings panel". |
1502
+ | `packages/ui/src/editor/` | 0.14.0, `@gemboss/ui/editor`: `EditorFrame`, `EditorPaneTabs`, `EditorTopbar`, `EditorBack`, `EditorChatToggle`, `EditorSaveState`, `DeviceToggle`. See "Editor frame". 0.17.0: `PanelTabs`, `PanelHeader`, `PanelAction`, `InspectorGroup`, `Field`, `Seg`, `ColorControl`, `RangeControl`, `EdgeBox`, `ImageControl`, `ChipButton`, `swatchState` (`./editor/swatch` too); 0.20.1 `cssUrl`. See "Settings panel". 0.22.0: `LayerList`, `SortableLayerList`, `LayerTree`, `EditorDrawer`. See "Layers and drawer". |
1095
1503
  | `packages/ui/src/editor-plain.js` | 0.14.0: `gembossUI.initEditorFrame()`, the frame's behaviour for plain HTML; 0.17.0: `gembossUI.initEditorPanel()`, the settings panel's. Load after `kit-plain.js`. |
1504
+ | `packages/ui/src/editor-layers-plain.js` | 0.22.0: `gembossUI.initEditorLayers()`, the layers', the outline's and the drawers' behaviour for plain HTML. Load after `kit-plain.js`. |
1096
1505
  | `catalog/editor.html` | 0.14.0: the frame rendered from the components and running (`packages/ui/scripts/build-catalog-editor.mjs`), linked from `catalog/index.html`. |
1097
1506
  | `packages/ui/src/editor.css` | 0.9.0: the editors (frame, top bar, preview, Configure dock, inspector and controls, Layers, settings drawer, page picker, menus). A separate entry, NOT inside `gemboss-ui.css`; the admin imports it from the editor components, never from a layout. See "Editor CSS" above. |
1098
1507
  | `packages/ui/src/chat.css`, `chat-composer.css`, `assistant.css`, `auth.css`, `app-shell.css` | the chat body, the composer, the assistant frame, the sign-in screen, the topbar slots + base text + the page width tiers (`ui-page`, 0.8.1). All inside `gemboss-ui.css`; the admin @imports `chat.css` and `chat-composer.css` itself. |