@gemboss/ui 0.13.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +512 -1
  2. package/README.md +426 -14
  3. package/catalog/editor.html +1027 -0
  4. package/catalog/index.html +1215 -90
  5. package/gemboss-ui.css +3 -4698
  6. package/package.json +24 -5
  7. package/parts/components.css +5 -0
  8. package/parts/core.css +5 -0
  9. package/parts/shell.css +5 -0
  10. package/src/app-shell.css +1 -256
  11. package/src/assistant/engine.tsx +607 -0
  12. package/src/assistant/host.tsx +1 -0
  13. package/src/assistant/index.ts +1 -0
  14. package/src/assistant-engine.css +1 -0
  15. package/src/assistant.css +1 -558
  16. package/src/auth.css +1 -606
  17. package/src/auth.tsx +1 -0
  18. package/src/board-move.ts +194 -0
  19. package/src/chat-composer.css +1 -110
  20. package/src/chat.css +1 -432
  21. package/src/collections.css +1 -350
  22. package/src/collections.tsx +170 -40
  23. package/src/dialog.tsx +3 -135
  24. package/src/editor/controls.tsx +207 -0
  25. package/src/editor/css-url.ts +11 -0
  26. package/src/editor/frame.tsx +243 -0
  27. package/src/editor/index.ts +52 -0
  28. package/src/editor/layers.tsx +136 -0
  29. package/src/editor/panel.tsx +189 -0
  30. package/src/editor/panes.tsx +45 -0
  31. package/src/editor/sortable.tsx +79 -0
  32. package/src/editor/swatch.ts +45 -0
  33. package/src/editor/tree.tsx +61 -0
  34. package/src/editor-layers-plain.js +1 -0
  35. package/src/editor-plain.js +1 -0
  36. package/src/editor.css +1 -1099
  37. package/src/gemboss-field.css +1 -112
  38. package/src/gemboss-tokens.css +1 -164
  39. package/src/icons.tsx +2 -0
  40. package/src/index-table.tsx +20 -56
  41. package/src/index.ts +16 -6
  42. package/src/kit-plain.css +1 -499
  43. package/src/kit-plain.js +1 -575
  44. package/src/launcher.tsx +1 -0
  45. package/src/menu-layer.ts +94 -0
  46. package/src/menu.tsx +28 -2
  47. package/src/portal.css +1 -139
  48. package/src/portal.tsx +1 -0
  49. package/src/reactions-data.ts +59 -0
  50. package/src/reactions.css +1 -0
  51. package/src/reactions.tsx +140 -0
  52. package/src/resource-list-select.tsx +73 -0
  53. package/src/search-dialog.tsx +146 -0
  54. package/src/segmented-click.ts +20 -0
  55. package/src/segmented.tsx +98 -0
  56. package/src/selection.tsx +79 -0
  57. package/src/shell.css +1 -1079
  58. package/src/theme-dark.css +1 -66
  59. package/src/theme-script.ts +10 -0
  60. package/src/theme.tsx +5 -5
  61. package/src/ui.css +1 -610
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,88 @@ 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 | 2.6 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 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) |
69
+
70
+ 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
+
72
+ **Next.js / Vite.** The core's three imports go at the top of the root layout, above any component import, in the
73
+ order above. Entries need nothing: the components bring them. A page that writes chat markup itself imports
74
+ `@gemboss/ui/chat.css`. Drop `gemboss-ui.css` IN THE SAME BUMP: kept beside the parts, the entries a component brings
75
+ load a second time, after your own CSS, and a second copy of a rule you override wins again.
76
+
77
+ ```tsx
78
+ // app/layout.tsx
79
+ import "@gemboss/ui/core.css";
80
+ import "@gemboss/ui/components.css";
81
+ import "@gemboss/ui/shell.css";
82
+ import "./globals.css"; // your own CSS after the core
83
+ ```
84
+
85
+ Add `"@gemboss/ui"` to `experimental.optimizePackageImports` in `next.config` (the admin does, 0.23.0): a SERVER
86
+ component that imports from the index otherwise collects the CSS of every module the index re-exports (the admin's
87
+ `/login`, which imports `AuthHelp` in its server page, picked up portal.css and collections.css under webpack until it did).
88
+
89
+ Which bundler drops what (measured 06/10/2026): Next 16 (Turbopack) leaves out the entry of a component a route does
90
+ 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
91
+ only there, portal.css nowhere). esbuild keeps the CSS of every module the index reaches even when it drops their JS, so
92
+ through `@gemboss/ui` it ships every entry the index re-exports; and esbuild with no CSS loader now stops at a
93
+ component's `import "./x.css"`: give it `loader: { ".css": "empty" }` (you link the CSS yourself, as GemFactory's
94
+ gbui.mjs and this package's tests do) or `"css"`.
95
+
96
+ **Plain HTML** (a server that serves the files out of `node_modules`): link the core, then only the entries the page
97
+ draws.
98
+
99
+ ```html
100
+ <link rel="stylesheet" href="/ui/core.css"> <!-- require.resolve("@gemboss/ui/core.css") -->
101
+ <link rel="stylesheet" href="/ui/components.css"> <!-- require.resolve("@gemboss/ui/components.css") -->
102
+ <link rel="stylesheet" href="/ui/shell.css"> <!-- require.resolve("@gemboss/ui/shell.css") -->
103
+ <link rel="stylesheet" href="/ui/auth.css"> <!-- the sign-in page only -->
104
+ <link rel="stylesheet" href="/ui/collections.css"> <!-- list pages only -->
105
+ ```
106
+
107
+ **Order.** The core loads FIRST, and its three parts in that order: the top lines of the root layout, or the first three
108
+ `<link>`s. `shell.css` restyles the field look at phone width, and entries restyle the kit with BEM modifiers at the
109
+ kit's own weight (`.ui-empty--no-match` after `.ui-empty`): both are written to follow what they modify, which keeps
110
+ every rule at the weight your own CSS can override. After the core, the entries may land in any order:
111
+ `packages/ui/scripts/cascade-check.mjs` loads the catalog with the entries (collections.css and editor.css included) after the core,
112
+ in order and reversed, and holds every element's computed style to the bundle's at 1280 and 390 (`parts-cascade.test.ts`,
113
+ CI; negative control: two entries that only agree by order). An entry loaded BEFORE the core is not supported: measured,
114
+ `.ui-empty--no-match` loses its 32px padding to `.ui-empty`. Two kit-plain.css rules that overrode shell.css by coming
115
+ 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
116
+ weight, so they still follow it.
117
+
118
+ **A new component** gets its own entry: a file in `src/`, listed in `ENTRIES` in `packages/ui/scripts/parts.mjs`, exported,
119
+ imported by the component that draws it, with a ceiling in `weight-budget.json`. It never goes into the core or into
120
+ `gemboss-ui.css`: `packages/ui/scripts/parts-gate.mjs` (CI) holds both frozen, fails on a stylesheet nobody classified, and on a
121
+ component that draws an entry's class without importing it.
122
+
123
+ **The admin** is unchanged: it has Polaris and its own order, so it imports the sources one by one
124
+ (`gemboss-tokens.css`, `ui.css`, and `shell-source.css` from its `gemboss-shell.css`; `@gemboss/ui/shell.css` is the
125
+ built part now). Its editor, assistant and sign-in modules already imported the same entry files the components now
126
+ import, so nothing moves.
127
+
43
128
  ## Page width (0.8.1)
44
129
 
45
130
  Chris, 04/10/2026: the rail on the left, and on the right a content column with the same max width on every
@@ -187,6 +272,44 @@ Its styles are the `gemboss-assistant-*` rules in `packages/ui/src/shell.css` (t
187
272
  @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
273
  through `fullActions`; a surface without those leaves them out.
189
274
 
275
+ ## The engine picker (0.25.0): `EnginePicker`, `BridgeNotice`
276
+
277
+ Which engine answers the assistant, and the bridge on the user's machine, without spending the chat's space on it
278
+ (Chris 07/10/2026: content-radar's assistant spent close to half its frame on an ENGINE / MODEL / EFFORT row, an
279
+ "outdated bridge" block and the install command). The admin's shape: a chip in the composer row opens a popup.
280
+
281
+ ```tsx
282
+ import { EnginePicker, BridgeNotice, useEnginePicker } from "@gemboss/ui/assistant"; // brings assistant-engine.css
283
+
284
+ const picker = useEnginePicker(); // open + tab, shared with the notice
285
+ <EnginePicker {...picker.props}
286
+ engines={[{ id: "claude", label: "Claude Code", note: "Your Claude subscription" }, { id: "server", label: "Server" }]}
287
+ engine={engine} onEngineChange={setEngine}
288
+ models={[{ id: "", label: "Default" }, { id: "opus", label: "Opus" }]} model={model} onModelChange={setModel}
289
+ efforts={[{ id: "", label: "Default" }, { id: "high", label: "High" }]} effort={effort} onEffortChange={setEffort}
290
+ bridge={{ state: "outdated", version: "8", latest: "10", autostart: true, detail: "Codex cannot see the radar tools." }}
291
+ setup={{ command: installCommand, pairingCode, note: "The panel reconnects in a few seconds." }}
292
+ statusActions={<button className="ui-btn" onClick={recheck}>Check again</button>} />
293
+
294
+ // Only when a turn FAILED because of the bridge, one line in the stream:
295
+ <BridgeNotice onOpenSetup={() => picker.openTab("setup")}>The bridge on your machine is not responding.</BridgeNotice>
296
+ ```
297
+
298
+ - **Chip**: engine name, the model when it is not the default (`id: ""`), a dot for `bridge.state`: green `running`,
299
+ amber `outdated`, red `offline` / `unpaired`, hollow `checking`. Its accessible name says the state in words.
300
+ - **Popup**: a non-modal dialog in the top layer (placed like the kit's menus, `placeFloating`), so the assistant's frame
301
+ cannot cut it. Tabs: Engine always; Setup when `setup` is given; Status when `bridge` is given. ←/→/Home/End move
302
+ between tabs; ↑/↓ move between engines (one Tab stop). Escape with focus in the panel, or a model / effort pick from
303
+ the buttons, closes it and gives focus back to the chip; an outside click closes. An engine pick and a pick from a
304
+ 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
+ - **Not in the chat**: no standing warning, no command in the conversation. The amber dot on the chip and on the Setup
306
+ 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).
309
+
310
+ Tests: `packages/ui/src/assistant/engine.test.tsx` (markup, styles, its own entry), `packages/ui/src/assistant/engine-browser.test.tsx`
311
+ (Chromium: top layer through a clipping frame, keys, focus, copy, Open setup, 44px; with negative controls).
312
+
190
313
  ## The chat body
191
314
 
192
315
  What an answer looks like, on any surface that puts a chat somewhere: `chat.css` (inside `gemboss-ui.css`;
@@ -242,9 +365,10 @@ import "@gemboss/ui/editor.css"; // only on the screens that show an editor
242
365
 
243
366
  Not here, on purpose: the body modes that hide the admin's own topbar and rail while an editor is open
244
367
  (`gemboss-editor-fullscreen`, `-just-exited`, `-layer-open`) and admin-only extensions such as the product studio's
245
- resizer. The behaviour (resizing, tabs, menus, the inspector's writes) belongs to the surface for now: the React
246
- components come in steps 2 to 4 (`EditorFrame` + `EditorTopbar`; `PanelTabs`, `PanelHeader`, `InspectorGroup`, `Field`
247
- and the controls; `LayerList`, `EditorDrawer`). Step 5 moves GemFactory's Builder onto them.
368
+ resizer. The frame and its top bar are React components since 0.14.0 ("Editor frame" below), with their behaviour for
369
+ plain HTML in `editor-plain.js`; the panel and its controls are components since 0.17.0 ("Settings panel" below, step 3); the
370
+ layers, the element outline and the drawer since 0.22.0 ("Layers and drawer" below, step 4). Step 5 moves GemFactory's
371
+ Builder onto them.
248
372
 
249
373
  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
374
 
@@ -257,6 +381,185 @@ Changes since the verbatim move, in 0.10.1 (05/10/2026, step 1b, the only ones t
257
381
  - `gemboss-flyout-in` is defined (fade + 4px drop, as the page picker), so `.gemboss-editor-menu` animates; `none`
258
382
  under `prefers-reduced-motion`.
259
383
 
384
+ ## Editor frame (0.14.0): `EditorFrame`, `EditorTopbar`
385
+
386
+ The frame every editor is drawn in: the admin's theme, page and section editors render these components since 0.14.0
387
+ (measured on a production build before and after: the same DOM and the same computed style, see CHANGELOG), and
388
+ GemFactory's Builder is next. FRAME ONLY: the chat, the Configure dock, the canvas, the page picker, the menus and Publish
389
+ belong to the editor and arrive as slots; the package holds no data logic and no state.
390
+
391
+ ```tsx
392
+ import "@gemboss/ui/editor.css";
393
+ import { DeviceToggle, EditorBack, EditorChatToggle, EditorFrame, EditorPaneTabs, EditorSaveState, EditorTopbar } from "@gemboss/ui/editor";
394
+
395
+ <EditorFrame
396
+ top={<EditorTopbar variant="theme" back={<EditorBack as={Link} href="/" />} title="Builder"
397
+ chatToggle={<EditorChatToggle collapsed={collapsed} onToggle={toggle} />}
398
+ left={<EditorSaveState state={saveState} />}
399
+ center={<DeviceToggle device={device} onChange={setDevice} />}
400
+ right={<>{tools}{moreMenu}{publish}</>}
401
+ status={message ? { state: "error", message } : null} />}
402
+ split={{ width, min: 300, max, collapsed, onPointerDown, onKeyDown }}
403
+ left={<div className="gemboss-ec">…</div>}
404
+ preview={<Canvas />}
405
+ right={presetsOpen ? <Presets /> : null}
406
+ mobilePane={pane}
407
+ >
408
+ <EditorPaneTabs value={pane} onChange={setPane} />
409
+ {drawersAndPopups}
410
+ </EditorFrame>
411
+ ```
412
+
413
+ | Component | Draws | Notes |
414
+ |---|---|---|
415
+ | `EditorFrame` | `div.gemboss-editor` › `top` · `.gemboss-editor-body` (`left` · seam · `.gemboss-editor-preview-pane`) · `children` | `split` writes `--gemboss-chat-w` (0 while `collapsed`, plus `.is-chat-collapsed`) and draws the seam (`role=separator`, `aria-valuenow/min/max`, `tabIndex 0`); the drag and the keys are the caller's (`onPointerDown`, `onKeyDown`). `right` is the right column: `.gemboss-editor-dock`, docked over the preview's right edge, 320px (max 90%), first in the pane. `mobilePane` is `data-mobile-pane` on the body. `embedded` fills a container instead of the viewport. Refs and extra classes for body and pane. |
416
+ | `EditorPaneTabs` | `.gemboss-editor-mobile-tabs` › two `[role=tab]` | The phone's Chat / Preview switch (shown at 760px and below). Put it first in the frame's `children`. `labels` renames them. |
417
+ | `EditorTopbar` | `header.gemboss-editor-topbar` › `-tb-left` (`back`, `chatToggle`, `title`, `left`) · `-tb-center` · `-tb-right`; then the status line | `variant`: `theme` (page picker + split Publish), `page` (`.gemboss-pageedit-top`: a title, a type chip, a status), `guest` (the try door). `status` draws `.gemboss-editor-statusbar.<state>` under the bar while its message is not empty. |
418
+ | `EditorBack` | `.gemboss-editor-back` ← | A `<button type="button">`, or `as={Link}` / `as="a"` with `href`. Named "Back" for a screen reader (`label`). |
419
+ | `EditorChatToggle` | `.gemboss-editor-chat-toggle` | Shows / hides the left column; filled while open, pressed when collapsed. |
420
+ | `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. |
422
+
423
+ One module per piece behind one entry, so a bundler gives each editor only what it draws (measured: one module for all
424
+ of it cost the page and section editors about 1 KB of code they never ran). Not in `@gemboss/ui` itself: like
425
+ `editor.css`, only a screen with an editor pays for it.
426
+
427
+ The geometry comes from `editor.css`. What the admin got from its own sheet (the left column sized by `--gemboss-chat-w`,
428
+ stacked above the preview at 1000px and below, the preview taking the rest) is in `editor.css` as ZERO-specificity
429
+ `:where(…)` rules since 0.14.0: anything more specific wins (the admin's own rules do, so its look did not move), and a
430
+ page that has only the package still lays out.
431
+
432
+ ### Plain HTML: `editor-plain.js`
433
+
434
+ Write the markup the components render (copy it from `catalog/editor.html`, where it is rendered from the components and
435
+ runs), load the behaviour AFTER `kit-plain.js`, and call `gembossUI.initEditorFrame()`:
436
+
437
+ ```html
438
+ <link rel="stylesheet" href="…/@gemboss/ui/gemboss-ui.css"><link rel="stylesheet" href="…/@gemboss/ui/src/editor.css">
439
+ …
440
+ <script src="…/@gemboss/ui/src/kit-plain.js"></script>
441
+ <script src="…/@gemboss/ui/src/editor-plain.js"></script>
442
+ <script>
443
+ gembossUI.initEditorFrame(); // every .gemboss-editor, or initEditorFrame(root)
444
+ addEventListener("gemboss:editor-device", (e) => setCanvas(e.detail.device));
445
+ </script>
446
+ ```
447
+
448
+ | Wires | Markup | Event on the editor (bubbles) |
449
+ |---|---|---|
450
+ | The seam: pointer drag (main button of the primary pointer: mouse, touch, pen), ←/→ 16px (Shift 64), Home / End; clamped to the BOX, `min(aria-valuemax, body width − 360 − 5)`, never below `aria-valuemin`; the CHOSEN width is kept apart, so a box that narrows clamps it and widening gives it back, and reopening the column clamps to the box as it is then; `ui-resizing` on `<body>` while dragging; remembered when the body has `data-gb-storage="<key>"` | `.gemboss-editor-resizer` (the frame's) | `gemboss:editor-split` `{ width }` |
451
+ | Hide / show the left column | `.gemboss-editor-chat-toggle` | `gemboss:editor-chat` `{ collapsed }` |
452
+ | Device | `.gemboss-pv-devices .gemboss-pv-device` (`data-device`, or the `title`) | `gemboss:editor-device` `{ device }` |
453
+ | Phone pane | `.gemboss-editor-mobile-tab` (`data-pane`, or Chat first / Preview second) | `gemboss:editor-pane` `{ pane }` |
454
+ | ⋯ / Publish ▾ menus | a trigger with `aria-haspopup="menu"`, then a `.gemboss-editor-menu[hidden]` right after it: opens 6px under the trigger, right edges aligned, at least 8px inside the window; one at a time; a press outside, an item, the window losing focus (a press in the canvas iframe) and a resize close or re-place it; Escape closes the menu only (capture phase, like `initMenus`; a popup behind it stays open; not while an IME composes) | `gemboss:editor-menu` `{ open, menu }` |
455
+
456
+ Without `kit-plain.js` it logs what is missing and defines nothing (`kit-plain.js` owns `gembossUI`). Its own file for
457
+ the same reason as `editor.css`: a page with no editor does not load it.
458
+
459
+
460
+ ## Settings panel (0.17.0): `PanelTabs`, `PanelHeader`, `InspectorGroup`, `Field` and the controls
461
+
462
+ Step 3 of the editor kit. The pieces every editor's settings panel is made of: the admin's Configure column, its element
463
+ inspector and its schema form render these since 0.17.0 (measured on a production build before and after: the same DOM
464
+ and the same computed style, see CHANGELOG). Same entry as the frame (`@gemboss/ui/editor`), look from `editor.css`.
465
+ Controls take a value and give one back; nothing in the package knows what is edited, what schema a value came from or
466
+ where it is saved.
467
+
468
+ ```tsx
469
+ import "@gemboss/ui/editor.css";
470
+ import { ColorControl, EdgeBox, Field, ImageControl, InspectorGroup, PanelAction, PanelHeader, PanelTabs, RangeControl, Seg } from "@gemboss/ui/editor";
471
+
472
+ <PanelHeader title="Hero" subtitle="hero-banner" actions={<PanelAction label="Duplicate section" onClick={dup}>⧉</PanelAction>} />
473
+ <PanelTabs variant="inspector" value={tab} onChange={setTab} tabs={[{ id: "content", label: "Content" }, { id: "design", label: "Design" }]} />
474
+ <InspectorGroup title="Layout" note="all screens">
475
+ <Field label="Width"><Seg label="Width" value={width} onChange={setWidth} options={[{ value: "full", label: "Full" }, { value: "contained", label: "Contained" }]} /></Field>
476
+ <Field label="Background" htmlFor="bg"><ColorControl id="bg" label="Background" value={bg} onChange={setBg} /></Field>
477
+ <Field label="Opacity" htmlFor="op" labelValue={`${op}%`}><RangeControl id="op" min={0} max={100} value={op} onChange={setOp} /></Field>
478
+ <Field label="Padding"><EdgeBox label="Padding" values={pad} onChange={(side, raw) => setPad({ ...pad, [side]: raw })} onCommit={save} /></Field>
479
+ <Field label="Image" htmlFor="img"><ImageControl id="img" value={url} onChange={setUrl} actions={<ChipButton onClick={openLibrary}>Library</ChipButton>} /></Field>
480
+ </InspectorGroup>
481
+ ```
482
+
483
+ | Component | Draws | Behaviour |
484
+ |---|---|---|
485
+ | `PanelTabs` | `variant="column"`: `.gemboss-ec-tabs` › `.gemboss-ec-tab` (Configure · Chat, `icon` before the label). `variant="inspector"`: `.gemboss-ei-tabs` › `.gemboss-ei-tab(.on)` | `role=tablist` / `tab`, `aria-selected`, `data-value` (the tab's id, for plain HTML), `aria-controls` from a tab's `controls`. `roving` (default on): only the selected tab is a Tab stop, ←/→ move the selection and the focus, wrapping. The admin's Configure · Chat row passes `roving={false}` (both tabs Tab stops, no arrows: how it always behaved). |
486
+ | `PanelHeader`, `PanelAction` | `header.gemboss-ei-head` › `.gemboss-ei-title` (+ `small` subtitle) · `.gemboss-ei-act` buttons · `.gemboss-ei-x` ✕ | ✕ only with `onClose` (a docked panel has none); `onDragStart` adds `.gemboss-ei-drag` and takes the mouse-down of a floating panel. |
487
+ | `InspectorGroup` | `.gemboss-ei-grp` › `button.gemboss-ei-gh` (▾ / ▸, 11px / 700 uppercase, `note` as `.gemboss-ei-gh-note`) + the body | `aria-expanded`, `data-open`; closed, CSS hides everything after the header (the body stays mounted). Controlled (`open` + `onOpenChange`, which receives the NEXT state) or not (`defaultOpen`). `collapsible={false}`: the schema form's static group, `section.gemboss-ei-group` › `h4.gemboss-ei-grouphead` + `hint`. |
488
+ | `Field` | `.gemboss-ei-fld` › `label` (13px / 500; `htmlFor`; `labelValue` as `.gemboss-ei-rangeval` at its right edge) · control · `hint` | No label when `label` is undefined / null. `className` (the admin's `togrow`), `style`. |
489
+ | `Seg` | `.gemboss-ei-seg[role=group]` › `button[aria-pressed][data-value]` (`.on`) | A click hands back the option's value; `label` names the group; `style` on the group. |
490
+ | `ColorControl` | `.gemboss-ei-color` › `input[type=color].gemboss-ei-swatch` + text box | The swatch commits on every change; the text box keeps a draft and commits on blur (every blur) or Enter. Anything but a 6-digit hex disables the swatch, with a title saying why (`swatchState`). |
491
+ | `RangeControl` | `input[type=range].gemboss-ei-range` | Every change, as a number (drag, arrows, Home / End). Show the value with `Field`'s `labelValue`. |
492
+ | `EdgeBox` | `.gemboss-ei-edgebox` › four `.gemboss-ei-edge` (box + T / R / B / L) | `onChange(side, raw)` per keystroke, `onCommit()` on blur; Enter blurs. Boxes named "<label> top / right / bottom / left". `narrow` (default) adds `gemboss-field--narrow`; `placeholders`, `ids` per side. What an empty edge means is the caller's. |
493
+ | `ImageControl`, `ChipButton` | `.gemboss-ei-img` › 52px thumbnail (— when empty) · URL box · `.gemboss-ei-img-actions` (`actions`, then Clear while there is a value) | Commits on blur / Enter only when the box changed; Clear commits "". The box's text can be the caller's (`draft` + `onDraftChange`) to put a picked URL in it at once. The thumbnail's `url()` is quoted (`cssUrl`, 0.20.1), so a URL with ")" or a space still shows. |
494
+ | `cssUrl` | | `url("…")` for any URL: quoted, `"` and `\` escaped, a control character as a CSS hex escape. Plain TypeScript (also `@gemboss/ui/editor/css-url`), so a server component can call it; `gembossUI.cssUrl` in editor-plain.js writes the same. Use it for your own image cells (the admin's library grid does). |
495
+ | `swatchState` | | The one rule for what `<input type="color">` can show. Also `@gemboss/ui/editor/swatch` (plain TypeScript, for node). |
496
+
497
+ Two modules behind the entry (`panel`, `controls`), both `"use client"`. Kept in the admin, on purpose: the schema
498
+ (`FieldSpec`), value shapes and clamping (FieldRenderer), the theme job's media library and image generation, the
499
+ per-breakpoint spacing model. Since 0.20.0 (#3846) the schema's On / Off toggles and the inspector's Visibility (Show /
500
+ Hide) rows are `Seg` too, so they say `aria-pressed` like every other segmented row.
501
+
502
+ ### Plain HTML: `initEditorPanel()`
503
+
504
+ The same markup (copy it from `catalog/editor.html`), then `gembossUI.initEditorPanel()` (editor-plain.js, after
505
+ `kit-plain.js`; the whole document, or `initEditorPanel(element)`). By delegation, each with an event on the element
506
+ named (it bubbles):
507
+
508
+ | Wires | Markup | Event |
509
+ |---|---|---|
510
+ | Tabs: select (`aria-selected`, `.on`, the tab's `aria-controls` panel shown and its siblings' hidden); ←/→ when the row is roving (its tabs carry `tabindex`) | `.gemboss-ec-tabs` / `.gemboss-ei-tabs` › `[role=tab]` (`data-value`, or the text) | `gemboss:editor-tab` `{ tab, index }` on the tab |
511
+ | Fold / unfold a group | `.gemboss-ei-gh` | `gemboss:editor-group` `{ open }` on the header |
512
+ | Press a Seg option | `.gemboss-ei-seg button` (`data-value`, or the text) | `gemboss:editor-seg` `{ value }` on the group |
513
+ | Colour: the swatch writes the box; the box commits on blur / Enter and the swatch follows (off for a non-hex) | `.gemboss-ei-color` | `gemboss:editor-color` `{ value }` |
514
+ | Range: the label's `.gemboss-ei-rangeval` follows, with its `data-suffix` | `input.gemboss-ei-range` with an `id` and a `label[for]` | `gemboss:editor-range` `{ value }` on the input |
515
+ | Edges: Enter blurs; a box that loses focus sends all four | `.gemboss-ei-edgebox` | `gemboss:editor-edges` `{ t, r, b, l }` |
516
+ | 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 }` |
517
+
518
+ ## Layers and drawer (0.22.0): `LayerList`, `SortableLayerList`, `LayerTree`, `EditorDrawer`
519
+
520
+ The page editor's Layers, Page settings and Edit content drawers and the element inspector's Elements outline, as
521
+ components (`@gemboss/ui/editor`). The admin draws those four with them since 0.22.0, measured before and after on a
522
+ production build: the same computed style and box, the markup differing only in `data-id` on each row, `type="button"`
523
+ on the buttons the components draw, and `aria-current` on the selected layer (CHANGELOG has the numbers).
524
+
525
+ ```tsx
526
+ import { EditorDrawer, LayerList, LayerTree, SortableLayerList } from "@gemboss/ui/editor";
527
+
528
+ {layersOpen ? (
529
+ <EditorDrawer side="left" label="Page layers" title="Layers" onClose={() => setLayersOpen(false)}>
530
+ <SortableLayerList items={sections.map((s) => ({ id: s.id, title: s.type, subtitle: s.heading, hidden: s.hidden }))}
531
+ selectedId={picked} onSelect={(it) => setPicked(it.id)}
532
+ onMove={(it, to) => moveSection(it.id, to)} onToggleHidden={(it, hidden) => setHidden(it.id, hidden)}
533
+ actions={(it) => <button aria-label="Delete" onClick={() => remove(it.id)}>✕</button>} empty="No sections yet" />
534
+ </EditorDrawer>
535
+ ) : null}
536
+ ```
537
+
538
+ | Component | Markup | Behaviour |
539
+ |---|---|---|
540
+ | `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. |
541
+ | `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. |
542
+ | `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. |
543
+ | `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`). |
544
+
545
+ Drag to reorder is pointer only (HTML drag and drop does not start from touch); a list that must reorder on a phone
546
+ keeps ↑ / ↓ buttons in its `actions`, as the admin's does. Kept as the admin had them: the drawer has no Escape and
547
+ does not move the focus into itself or back; the admin's Layers do not drag and have no eye (they move with ↑ / ↓).
548
+
549
+ ### Plain HTML: `initEditorLayers()`
550
+
551
+ The same markup (copy it from `catalog/editor.html`), then `gembossUI.initEditorLayers()` (`editor-layers-plain.js`, its own file:
552
+ 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:
553
+ `hidden` on the aside and on the backdrop before it, an `id` on the aside.
554
+
555
+ | Wires | Markup | Event |
556
+ |---|---|---|
557
+ | Select a layer (`.is-sel`, `aria-current`) | `.gemboss-layer-main` | `gemboss:editor-layer` `{ id, index }` on the row |
558
+ | Hide / show (`aria-pressed`, `.is-hidden`, the eye's slash) | `.gemboss-layer-eye` | `gemboss:editor-layer-hidden` `{ id, hidden }` |
559
+ | 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 }` |
560
+ | Select an outline node; a second click clears it | `.gemboss-ei-tree .gemboss-ei-tname` | `gemboss:editor-tree` `{ id }` (`null` when cleared) |
561
+ | 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 |
562
+
260
563
  ## Plain-HTML behaviour: `kit-plain.js` (0.3.0)
261
564
 
262
565
  Surfaces without React (GemFactory, gemcare, gempress, partner-radar) render the kit's markup on the server and need
@@ -461,6 +764,7 @@ both looks like the admin and passes Gems/CLAUDE.md §9.1.
461
764
  |---|---|---|
462
765
  | `ui-btn` (every variant), `ui-btn--icon` | 28px, padding 0 12, 12px/550 (`--ui-btn-h`) | 44px |
463
766
  | `ui-seg` group, `.is-text` label | 28px, 12px/550 | 44px |
767
+ | `ui-seg--fill` (0.20.0): the row's width, segments equal; `ui-seg-n` a count inside a segment. EVERY "pick one" switch in the admin is a `ui-seg`, drawn by `Segmented` (`@gemboss/ui/segmented`); the editor kit's `Seg` and `DeviceToggle` too | same | same |
464
768
  | `ui-tabs` | 28px, padding 0 12, 12px/550 selected or not, 8px apart | 44px |
465
769
  | `ui-page-head h1` | 20px / 24px / 700 (Polaris headingLg) | same |
466
770
  | `ui-card`, `ui-table-wrap` | Polaris' shadow-100 under it (`--ui-card-shadow`), one 1px `--ui-border` edge, radius 12, padding 16 | same |
@@ -712,6 +1016,23 @@ import { Avatar, Dialog, Leaderboard, Podium, SearchDialog, ThemeToggle, themeSc
712
1016
  (after the name, e.g. a level) and `id` (a `#me` anchor). `title` may be left out on a page whose own
713
1017
  `<h1>` names the board; pass `label` then. Screen-reader text uses `.gb-sr-only`, styled in portal.css.
714
1018
 
1019
+ ## Reactions (0.21.0): one emoji set and one chip for the fleet
1020
+
1021
+ Chris 06/10/2026: "reaction các thứ comment nên chuẩn hoá một kiểu, bên gemcare cũng có, nên đưa vào gemboss ui". GemCare's
1022
+ team chat built them first (`public/assets/inbox.js`); this is that, moved here so GemFeedback's requests and comments,
1023
+ and GemCare's chat, draw and accept the same thing.
1024
+
1025
+ | Piece | What |
1026
+ |---|---|
1027
+ | `@gemboss/ui/reactions-data` (plain TS, also re-exported from the root) | `REACTION_GROUPS` (GemCare's 137 emoji in 4 groups, each with search words), `REACTION_EMOJI`, `isReactionEmoji` (the server's check: refuse anything else), `searchReactions`, `reactionLabel` (names, two then `+n`), `toggleReaction` (the optimistic guess drawn before the server answers), `QUICK_REACTIONS` (👍 ✅), type `Reaction` = `{ emoji, names, mine }` |
1028
+ | `Reactions` (React, client) | the chips (emoji + NAMES of who put it; yours outlined + filled, `aria-pressed`) and "Add reaction", which opens `ReactionPicker` in flow under the row. `canReact={false}`: chips only; no chips and no right to react: nothing |
1029
+ | `ReactionPicker` | search, "Frequently used" (this browser's last 9, `localStorage`, else the quick pair), the groups; arrows move in the grid, Enter in the search picks the first, Escape closes and returns focus |
1030
+ | `reactions.css` (its own entry, NOT inside `gemboss-ui.css`: link it where reactions are drawn, as `collections.css`) | `ui-reactions`, `ui-reactions__row`, `ui-reaction` (+ `is-mine`), `__emoji`, `__names`, `ui-reaction-add`, `ui-reaction-picker` (`__search`, `__body`, `__head`, `__grid`, `__btn`, `__empty`) |
1031
+
1032
+ Every target is 44px (a control, CLAUDE.md §9.1); the pill you see is 26px, transparent padding carries the rest, so a
1033
+ message with reactions does not grow by a full line. A plain-HTML surface writes the same classes (markup at the top of
1034
+ `reactions.css`, and the catalog's "Reactions" section).
1035
+
715
1036
  ## Report pages (0.4.0)
716
1037
 
717
1038
  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
@@ -759,14 +1080,14 @@ silently: a `slice` prints "+N more" or "See all N". A table renders at most 100
759
1080
  |---|---|---|---|---|
760
1081
  | 1 | Data table | `DataTable` | `ui-table-wrap > table.ui-table(.ui-table--sticky-first, --compact)`; `tfoot.ui-table__totals`, `.ui-table__footer` | DataTable |
761
1082
  | 2 | Index table | `IndexTable` (client) | `div.ui-index > [div.ui-index__sortbar] + [div.ui-bulkbar] + ui-table-wrap > table.ui-table.ui-table--index`; `tr[data-href]`, `td.ui-table__check`, `td.ui-table__primary > a.ui-table__title + span.ui-sub`, `td.ui-table__actions` | IndexTable |
762
- | 3 | Resource list | `ResourceList` + `ResourceItem` | `ul.ui-list > li.ui-list__item > .ui-list__media + .ui-list__body(a.ui-list__title, p.ui-list__sub, p.ui-list__meta, .ui-list__badges) + .ui-list__aside + .ui-list__actions` | ResourceList |
763
- | 4 | Compact list | `CompactList` (`variant="activity"`) | `ul.ui-list.ui-list--compact > li.ui-list__item > a/button/div.ui-list__row`; timeline: `ui-list--activity`, `.ui-list__dot(--tone)`, `.ui-list__detail`, `time` | ActionList |
1083
+ | 3 | Resource list | `ResourceList` + `ResourceItem`; with selection `SelectableResourceList` (client, 0.16.0) | `ul.ui-list > li.ui-list__item > .ui-list__media + .ui-list__body(a.ui-list__title, p.ui-list__sub, p.ui-list__meta, .ui-list__badges) + .ui-list__aside + .ui-list__actions`; selectable: `div.ui-resource > div.ui-bulkbar + ul.ui-list`, `label.ui-list__check > input[data-gb-select]` first in `__media` | ResourceList |
1084
+ | 4 | Compact list | `CompactList` (`variant="activity"`, `wrap`) | `ul.ui-list.ui-list--compact > li.ui-list__item > a/button/div.ui-list__row`; timeline: `ui-list--activity`, `.ui-list__dot(--tone)`, `.ui-list__detail`, `.ui-list__content` (0.15.0), `time`; `ui-list--wrap` (0.15.0) | ActionList |
764
1085
  | 5 | Card grid | `CardGrid min={200 \| 240 \| 320}` + `MediaCard` | `div.ui-cardgrid[data-min] > article.ui-card.ui-card--media > a.ui-card__media + .ui-card__menu + .ui-card__body > a.ui-card__link` | InlineGrid + Card |
765
- | 6 | Board | `Board` | `div.ui-board > section.ui-board__col > header.ui-board__head + ol.ui-board__list + details.ui-board__more` | none |
1086
+ | 6 | Board | `Board` (`onMove`, `hasMore` / `moreHref` / `onLoadMore`: 0.18.0) | `div.ui-board > section.ui-board__col[data-col] > header.ui-board__head + ol.ui-board__list > li[data-id]` + `details.ui-board__more` (client paging) or `div.ui-more.ui-board__next > a[data-gb-more]` (server paging) | none |
766
1087
  | | States | `CollectionState kind="empty \| no-match \| error"`, `CollectionSkeleton pattern=…`, `state="busy"` | `ui-empty(--no-match)`, `ui-coll-error`, `ui-skeleton--table/--list/--compact/--grid/--board`, `aria-busy="true"` | EmptyState, EmptySearchResult |
767
1088
 
768
- `./collections` is a plain module (a server component renders it, render functions and all); `IndexTable` is a client
769
- module. Its props are plain data (rows of rendered cells, `sortHref` strings) so a server page can still render it;
1089
+ `./collections` is a plain module (a server component renders it, render functions and all); `IndexTable` and
1090
+ `SelectableResourceList` (0.16.0) are client modules. Their props are plain data (rows of rendered cells, `sortHref` strings) so a server page can still render them;
770
1091
  the callbacks (`onSort`, `onSelectionChange`, a bulk action's `onAction`) need a client parent. Neither module reaches
771
1092
  the admin's first load (`kit-plain-bundles.test.ts` checks the shell import carries none of their code).
772
1093
 
@@ -777,9 +1098,56 @@ the admin's first load (`kit-plain-bundles.test.ts` checks the shell import carr
777
1098
  two or more in-place editors is not linked (only its title is). HTML: `tr[data-href]` + kit-plain.js; React:
778
1099
  `IndexTable` rows with `href`; a resource list's title link covers its row (no script).
779
1100
  - **One visible button per row at most**, the rest in the ⋯ menu (`ui-menu`). **Never shown only on hover.**
1101
+ - **An open ⋯ menu is never cut by its frame (0.18.0).** It floats in the browser's top layer (`popover="manual"`,
1102
+ `position: fixed` against its button): below the button, above it when the room below is short and the room above is
1103
+ larger, 8px from the viewport's edges, scrolling inside when taller than the room. It closes when a scroll moves its
1104
+ button (the page, or a frame around the menu) or the window resizes; a scroll inside the list or elsewhere (a log
1105
+ following its output) does not count. Tab order, Escape (focus back on ⋯) and "a click in the
1106
+ menu is not a click on the row" are unchanged: the list is still the details' child. React `Menu` does it itself;
1107
+ plain HTML gets it from kit-plain.js (`initMenuLayer()`, runs by itself). No popover API (Safari < 17) or no script:
1108
+ the absolute list, as before. GemFactory Members (#197) is the case: at 820 / 900 / 1280 the last rows' menus were
1109
+ half hidden inside the table's scrolling frame.
1110
+ - **A resource list in a frame of 400 or less (0.15.0)** keeps the body beside the media, with all the width right of
1111
+ it; the aside and the actions share ONE line under the body (aside left, actions right, from the body's edge); the
1112
+ media spans both lines; a row without media gives the body the whole width; a body that is only its title keeps
1113
+ them on its line. The row's direct children are the four slots (a checkbox goes in `__media`). So a long
1114
+ subtitle and a body of many meta lines both get shorter than in 0.13.0 (`collections.test.ts` checks the geometry
1115
+ at 320 / 390 / 768 for both, with the 0.13.0 rule and the `flex: 1 1 0` patch as negative controls).
1116
+ - **A compact list wraps when its labels carry data (0.15.0).** `CompactList wrap` (`ui-list--wrap`): the label and an
1117
+ activity's detail wrap instead of ending in "…", because the cut text's `title` is mouse-only. A side rail of short
1118
+ labels keeps one line. `CompactItem.body` (activity only) is the event's own text under them (`.ui-list__content`,
1119
+ body size, never cut; paragraphs as `<p>`); `labelText` puts the full label in `title`.
780
1120
  - **Below 768 of its container an index table becomes a list** (title line; the other cells on one caption line,
781
1121
  `data-label` prefixes, `data-condensed="hide"` drops; checkbox and actions kept; rows ≥ 44); sort moves to the chips
782
1122
  of `ui-index__sortbar`. **A data table never folds:** it scrolls in its frame with `--sticky-first`.
1123
+ - **A data table reads from the top of its rows (0.16.0)**, as Polaris DataTable (`verticalAlign` "top"): one-line
1124
+ cells sit on the first line of a three-line row, and a 24px cell link is pulled up by half its extra height so its
1125
+ text lines up (`calc((1lh - 24px) / 2)`). The index table keeps `middle`. The row header (`th.ui-table__rowhead`)
1126
+ wraps like any cell (Polaris `firstColumn`) with Polaris's first-column width as a floor (9.0625rem; the frame
1127
+ scrolls rather than squeezing it to a word a line); `truncate` or `ui-clamp-2` when it must stay short. A column's
1128
+ `minWidth` (DataTable) is the floor of a column whose cells wrap, and replaces the row header's (`0` for a row
1129
+ header that is always short: a date, an id). A link alone on its
1130
+ line is 24px in the row header and in a list inside a cell (`li > a`), as `td > a` already was.
1131
+ - **A media card is 16:10, 4:3 or 1:1 (0.18.0)** (`MediaCard ratio`, `data-ratio="4/3" | "1/1"`): 1:1 for a square
1132
+ picture (a moodboard, a product shot, a social post). No other ratio: any other value draws 16:10.
1133
+ - **A board column of more than a few dozen cards pages on the SERVER (0.18.0).** The 0.13.0 board puts every card in
1134
+ the HTML behind nested `<details>` (content-radar ideas reaches 2,000). With `hasMore` the column shows the cards the
1135
+ server sent (`boardShown(page)`: 12, then 24 more a page, Chris 05/10/2026) and ends in ONE "Show 24 more":
1136
+ `moreHref` is this page with the column one page further (`?col=ideas&page=2`, cumulative, so no script, Back and a
1137
+ sent link all work; `moreHref` alone means there is more, `hasMore: false` is the last page). On a plain-HTML page
1138
+ (`fetchMore`, i.e. `a[data-gb-more]`) kit-plain.js fetches it, appends the cards whose `data-id` are not on the board
1139
+ yet, takes the next link and the count, puts the URL in the address bar, says "24 more cards in <column>" and focuses
1140
+ the first new card ("Couldn't load." + Try again on failure); never on a board React owns. React: `linkAs` (a Next `Link` with `moreLinkProps={{ scroll: false }}`) or
1141
+ `onLoadMore(columnId)` + `loading`. Cards need ids: `items: [{ id, card, name? }]` instead of `cards`.
1142
+ - **Moving cards between columns is optional (0.18.0)** (`Board onMove(cardId, toColumn, toIndex)`; plain HTML
1143
+ `.ui-board[data-gb-move]` + `li[data-id][tabindex="0"][draggable="true"]`, event `gb:move` { id, from, to, index },
1144
+ cancelable: not cancelled, kit-plain.js moves the `li` and the counts). Drag with a mouse, or Tab to a card, Space,
1145
+ arrows (↑ ↓ in the column, ← → to the next one), Space or Enter to drop, Escape to put it back, each step said in the
1146
+ board's live region. While held, the card has `data-gb-lifted="key"` or `"drag"` (a surface may change its layout for a
1147
+ keyboard hold, never inside dragstart: Chrome cancels the drag), the target column `data-gb-over`, the landing line
1148
+ `data-gb-drop="before|after"` on a card. Nothing moves in the DOM until the drop, and a drop where it was reports nothing. Without
1149
+ `onMove` the board stays read-only (no tab stops, no drag). A phone cannot drag: keep a way that is not dragging (a
1150
+ select, a ⋯ "Move to").
783
1151
  - **At 1280 a table fits its page frame.** Fix in this order: a `canvas` page if the content really is wide; move
784
1152
  low-priority columns to the detail page; fold attributes into the `ui-sub` line.
785
1153
  - **No zebra, no uppercase header, no card per row, no grid that changes its column count per viewport** (auto-fill
@@ -787,7 +1155,22 @@ the admin's first load (`kit-plain-bundles.test.ts` checks the shell import carr
787
1155
  - **A checkbox column only with bulk actions.** The bulk bar covers the header (the table does not move), carries its
788
1156
  own select-all, Shift+click takes a range, Escape clears. HTML: `input[data-gb-select]` / `[data-gb-select-all]`,
789
1157
  `gembossUI.selection(root)`, event `gb:selection`.
790
- - **Sort lives in the URL** (`th[aria-sort] > a.ui-table__sort`); `button.ui-table__sort` sorts a small table in place
1158
+ - **A resource list selects the same way (0.16.0):** `SelectableResourceList` (`items`, `bulkActions`, optional
1159
+ controlled `selected` + `onSelectionChange`, `selectedLabel`, `linkAs`). The checkbox is first in the media slot
1160
+ (`label.ui-list__check`: 24, 44 under a finger, pulled into the row's padding so a 64px row stays 64). One
1161
+ difference from the table: the bar is the list's header from the start ("0 selected", actions disabled), because a
1162
+ list has no header to cover and a bar that appears on the first check pushes the rows under the finger (talent-radar
1163
+ /pool measured 74px at 1280, 194px at 390). The whole row still opens the item. HTML: the same `data-gb-select`
1164
+ hooks in a `.ui-resource` (kit-plain.js enables the action buttons from 1 instead of showing the bar; an action with
1165
+ its own rule sets its button on `gb:selection`, which fires after). collections-gate reports a list with selection
1166
+ boxes and no bar (`select-without-bulk`). An open ⋯ menu or dialog takes Escape first; a closed one kept in a row
1167
+ does not.
1168
+ - **A sort control is 44 × 44 under a finger (0.18.0)**: the header button grows to 44 tall under `pointer: coarse`
1169
+ and to 44 wide under `pointer: coarse` or at 768 and below (a short label like "ID" was 44 × 30 on an iPad); the
1170
+ chips below 768 have the same floor. Under a mouse the header keeps its Polaris size.
1171
+ - **Sort lives in the URL** (`th[aria-sort] > a.ui-table__sort`); in React the sort links (header and the chips below
1172
+ 768) are `linkAs`, the same element as the row titles (0.16.0), so a Next `Link` sorts without reloading the page;
1173
+ `sortLinkProps={{ scroll: false }}` keeps the scroll position (a sort is the same page in another order); `button.ui-table__sort` sorts a small table in place
791
1174
  (`table[data-gb-sort="client"]`, by `data-sort-value`) or fires `gb:sort` for the page (`data-gb-sort="event"`); a React table owns its buttons and kit-plain.js leaves them alone.
792
1175
  - **States** (Gems/CLAUDE.md 9.2): empty (one main action) · no match (filters and header stay, "Clear filters") ·
793
1176
  loading (a skeleton in the pattern's shape: a table keeps its real header, rows keep their height) · reloading
@@ -872,11 +1255,16 @@ something the base cannot express, add a token or a prop here, and every surface
872
1255
 
873
1256
  | Path | What |
874
1257
  |---|---|
875
- | `gemboss-ui.css` | built at pack time: `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`) |
876
- | `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). |
1258
+ | `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"). |
1259
+ | `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`) |
1260
+ | `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). |
877
1261
  | `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. |
878
1262
  | `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. |
879
1263
  | `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. |
1264
+ | `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". |
1265
+ | `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`. |
1266
+ | `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`. |
1267
+ | `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`. |
880
1268
  | `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. |
881
1269
  | `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. |
882
1270
  | `packages/ui/src/icon.svg` | 0.5.0: the GemBoss app icon, for `<link rel="icon" type="image/svg+xml">`. Must stay byte-identical to `apps/admin/src/app/icon.svg` (`kit-look.test.ts` checks). |
@@ -888,12 +1276,36 @@ something the base cannot express, add a token or a prop here, and every surface
888
1276
  change `packages/ui/src/shell.css` (since 29/09/2026: before that it lived in the admin's stylesheet and was extracted).
889
1277
  The admin and the next published version both carry it.
890
1278
 
1279
+ ## Minified on publish, weighed per file (0.19.0)
1280
+
1281
+ The published tarball carries every stylesheet (`gemboss-ui.css`, `src/*.css`) and the two plain scripts
1282
+ (`packages/ui/src/kit-plain.js`, `packages/ui/src/editor-plain.js`) MINIFIED; the comments stay here in the sources. `gemboss-ui.css` keeps its
1283
+ generated header (the gemboss commit; GemFactory's `/api/health` reads it). Next and Vite minify what they bundle anyway;
1284
+ the surfaces that serve the files as they come out of `node_modules` (GemFactory, taskdesk, gemcare, gempress) did not.
1285
+ Measured on this change: `gemboss-ui.css` 78.1 KB gzip as written, 21.9 KB minified; `kit-plain.js` 17.8 → 8.5 KB.
1286
+
1287
+ `npm publish` cannot minify in place (`exports` points at `src/`, which the admin compiles from), so the release goes
1288
+ through a staged copy: `packages/ui/scripts/stage.mjs` copies exactly what npm would pack into `.pack/`, minifying with esbuild
1289
+ (`legalComments: none`, no target: rewrites that compute the same style only). `npm pack` / `npm publish` run straight
1290
+ in `packages/ui` are refused by `prepack`.
1291
+
1292
+ The weight gate, `packages/ui/scripts/weight-gate.mjs`, reads the staged copy: gzip (level 6) of each minified file against its
1293
+ ceiling in `weight-budget.json` (measured + 512 bytes). Red when a file is over its ceiling, more than 1 KB under it
1294
+ (lower it: a ceiling above the truth constrains nothing), still carries comments or indentation (not minified; measured by esbuild, so a `/*` in a string does not count), or is shipped without
1295
+ a ceiling. CI runs `node packages/ui/scripts/weight-gate.mjs --ci`: build, stage into a temp dir, gate, then the
1296
+ negative controls (+5 KB of real rules in `ui.css` red; +20 KB of comment green; the unminified sources red; one
1297
+ unminified `kit-plain.js` red; a stylesheet with no ceiling red; a ceiling for a file not shipped red; a stale ceiling
1298
+ red; a `/*` inside a string is not a comment, green). Ceilings go down by themselves; raising one is not how a PR
1299
+ gets green (CLAUDE.md §9.4: shared weight only shrinks): it takes the reason in the PR and Chris's OK, and a new
1300
+ component goes in its own entry. `packages/ui/scripts/pack-lib.mjs` holds what the three scripts share (pack list, minifier, staging).
1301
+
891
1302
  ## Release
892
1303
 
893
1304
  ```bash
894
1305
  cd packages/ui
895
- npm version patch # or minor / major
896
- npm publish # prepack builds gemboss-ui.css first
1306
+ npm version minor # or patch / major
1307
+ npm run stage # builds gemboss-ui.css + catalogs, minifies into .pack/, runs the weight gate
1308
+ npm publish .pack --auth-type=web # publish the STAGED copy (npm pack .pack for a local tarball)
897
1309
  ```
898
1310
 
899
1311
  Publish from a worktree at the MERGED commit on `main`, not from a branch, so the registry and