@gemboss/ui 0.12.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -242,9 +242,9 @@ import "@gemboss/ui/editor.css"; // only on the screens that show an editor
242
242
 
243
243
  Not here, on purpose: the body modes that hide the admin's own topbar and rail while an editor is open
244
244
  (`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.
245
+ 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.
248
248
 
249
249
  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
250
 
@@ -257,6 +257,140 @@ Changes since the verbatim move, in 0.10.1 (05/10/2026, step 1b, the only ones t
257
257
  - `gemboss-flyout-in` is defined (fade + 4px drop, as the page picker), so `.gemboss-editor-menu` animates; `none`
258
258
  under `prefers-reduced-motion`.
259
259
 
260
+ ## Editor frame (0.14.0): `EditorFrame`, `EditorTopbar`
261
+
262
+ The frame every editor is drawn in: the admin's theme, page and section editors render these components since 0.14.0
263
+ (measured on a production build before and after: the same DOM and the same computed style, see CHANGELOG), and
264
+ GemFactory's Builder is next. FRAME ONLY: the chat, the Configure dock, the canvas, the page picker, the menus and Publish
265
+ belong to the editor and arrive as slots; the package holds no data logic and no state.
266
+
267
+ ```tsx
268
+ import "@gemboss/ui/editor.css";
269
+ import { DeviceToggle, EditorBack, EditorChatToggle, EditorFrame, EditorPaneTabs, EditorSaveState, EditorTopbar } from "@gemboss/ui/editor";
270
+
271
+ <EditorFrame
272
+ top={<EditorTopbar variant="theme" back={<EditorBack as={Link} href="/" />} title="Builder"
273
+ chatToggle={<EditorChatToggle collapsed={collapsed} onToggle={toggle} />}
274
+ left={<EditorSaveState state={saveState} />}
275
+ center={<DeviceToggle device={device} onChange={setDevice} />}
276
+ right={<>{tools}{moreMenu}{publish}</>}
277
+ status={message ? { state: "error", message } : null} />}
278
+ split={{ width, min: 300, max, collapsed, onPointerDown, onKeyDown }}
279
+ left={<div className="gemboss-ec">…</div>}
280
+ preview={<Canvas />}
281
+ right={presetsOpen ? <Presets /> : null}
282
+ mobilePane={pane}
283
+ >
284
+ <EditorPaneTabs value={pane} onChange={setPane} />
285
+ {drawersAndPopups}
286
+ </EditorFrame>
287
+ ```
288
+
289
+ | Component | Draws | Notes |
290
+ |---|---|---|
291
+ | `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. |
292
+ | `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. |
293
+ | `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. |
294
+ | `EditorBack` | `.gemboss-editor-back` ← | A `<button type="button">`, or `as={Link}` / `as="a"` with `href`. Named "Back" for a screen reader (`label`). |
295
+ | `EditorChatToggle` | `.gemboss-editor-chat-toggle` | Shows / hides the left column; filled while open, pressed when collapsed. |
296
+ | `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. |
298
+
299
+ One module per piece behind one entry, so a bundler gives each editor only what it draws (measured: one module for all
300
+ of it cost the page and section editors about 1 KB of code they never ran). Not in `@gemboss/ui` itself: like
301
+ `editor.css`, only a screen with an editor pays for it.
302
+
303
+ The geometry comes from `editor.css`. What the admin got from its own sheet (the left column sized by `--gemboss-chat-w`,
304
+ stacked above the preview at 1000px and below, the preview taking the rest) is in `editor.css` as ZERO-specificity
305
+ `:where(…)` rules since 0.14.0: anything more specific wins (the admin's own rules do, so its look did not move), and a
306
+ page that has only the package still lays out.
307
+
308
+ ### Plain HTML: `editor-plain.js`
309
+
310
+ Write the markup the components render (copy it from `catalog/editor.html`, where it is rendered from the components and
311
+ runs), load the behaviour AFTER `kit-plain.js`, and call `gembossUI.initEditorFrame()`:
312
+
313
+ ```html
314
+ <link rel="stylesheet" href="…/@gemboss/ui/gemboss-ui.css"><link rel="stylesheet" href="…/@gemboss/ui/src/editor.css">
315
+ …
316
+ <script src="…/@gemboss/ui/src/kit-plain.js"></script>
317
+ <script src="…/@gemboss/ui/src/editor-plain.js"></script>
318
+ <script>
319
+ gembossUI.initEditorFrame(); // every .gemboss-editor, or initEditorFrame(root)
320
+ addEventListener("gemboss:editor-device", (e) => setCanvas(e.detail.device));
321
+ </script>
322
+ ```
323
+
324
+ | Wires | Markup | Event on the editor (bubbles) |
325
+ |---|---|---|
326
+ | 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 }` |
327
+ | Hide / show the left column | `.gemboss-editor-chat-toggle` | `gemboss:editor-chat` `{ collapsed }` |
328
+ | Device | `.gemboss-pv-devices .gemboss-pv-device` (`data-device`, or the `title`) | `gemboss:editor-device` `{ device }` |
329
+ | Phone pane | `.gemboss-editor-mobile-tab` (`data-pane`, or Chat first / Preview second) | `gemboss:editor-pane` `{ pane }` |
330
+ | ⋯ / 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 }` |
331
+
332
+ Without `kit-plain.js` it logs what is missing and defines nothing (`kit-plain.js` owns `gembossUI`). Its own file for
333
+ the same reason as `editor.css`: a page with no editor does not load it.
334
+
335
+
336
+ ## Settings panel (0.17.0): `PanelTabs`, `PanelHeader`, `InspectorGroup`, `Field` and the controls
337
+
338
+ Step 3 of the editor kit. The pieces every editor's settings panel is made of: the admin's Configure column, its element
339
+ inspector and its schema form render these since 0.17.0 (measured on a production build before and after: the same DOM
340
+ and the same computed style, see CHANGELOG). Same entry as the frame (`@gemboss/ui/editor`), look from `editor.css`.
341
+ Controls take a value and give one back; nothing in the package knows what is edited, what schema a value came from or
342
+ where it is saved.
343
+
344
+ ```tsx
345
+ import "@gemboss/ui/editor.css";
346
+ import { ColorControl, EdgeBox, Field, ImageControl, InspectorGroup, PanelAction, PanelHeader, PanelTabs, RangeControl, Seg } from "@gemboss/ui/editor";
347
+
348
+ <PanelHeader title="Hero" subtitle="hero-banner" actions={<PanelAction label="Duplicate section" onClick={dup}>⧉</PanelAction>} />
349
+ <PanelTabs variant="inspector" value={tab} onChange={setTab} tabs={[{ id: "content", label: "Content" }, { id: "design", label: "Design" }]} />
350
+ <InspectorGroup title="Layout" note="all screens">
351
+ <Field label="Width"><Seg label="Width" value={width} onChange={setWidth} options={[{ value: "full", label: "Full" }, { value: "contained", label: "Contained" }]} /></Field>
352
+ <Field label="Background" htmlFor="bg"><ColorControl id="bg" label="Background" value={bg} onChange={setBg} /></Field>
353
+ <Field label="Opacity" htmlFor="op" labelValue={`${op}%`}><RangeControl id="op" min={0} max={100} value={op} onChange={setOp} /></Field>
354
+ <Field label="Padding"><EdgeBox label="Padding" values={pad} onChange={(side, raw) => setPad({ ...pad, [side]: raw })} onCommit={save} /></Field>
355
+ <Field label="Image" htmlFor="img"><ImageControl id="img" value={url} onChange={setUrl} actions={<ChipButton onClick={openLibrary}>Library</ChipButton>} /></Field>
356
+ </InspectorGroup>
357
+ ```
358
+
359
+ | Component | Draws | Behaviour |
360
+ |---|---|---|
361
+ | `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). |
362
+ | `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. |
363
+ | `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`. |
364
+ | `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`. |
365
+ | `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. |
366
+ | `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`). |
367
+ | `RangeControl` | `input[type=range].gemboss-ei-range` | Every change, as a number (drag, arrows, Home / End). Show the value with `Field`'s `labelValue`. |
368
+ | `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. |
369
+ | `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. |
370
+ | `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). |
371
+ | `swatchState` | | The one rule for what `<input type="color">` can show. Also `@gemboss/ui/editor/swatch` (plain TypeScript, for node). |
372
+
373
+ Two modules behind the entry (`panel`, `controls`), both `"use client"`. Kept in the admin, on purpose: the schema
374
+ (`FieldSpec`), value shapes and clamping (FieldRenderer), the theme job's media library and image generation, the
375
+ per-breakpoint spacing model. Since 0.20.0 (#3846) the schema's On / Off toggles and the inspector's Visibility (Show /
376
+ Hide) rows are `Seg` too, so they say `aria-pressed` like every other segmented row.
377
+
378
+ ### Plain HTML: `initEditorPanel()`
379
+
380
+ The same markup (copy it from `catalog/editor.html`), then `gembossUI.initEditorPanel()` (editor-plain.js, after
381
+ `kit-plain.js`; the whole document, or `initEditorPanel(element)`). By delegation, each with an event on the element
382
+ named (it bubbles):
383
+
384
+ | Wires | Markup | Event |
385
+ |---|---|---|
386
+ | 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 |
387
+ | Fold / unfold a group | `.gemboss-ei-gh` | `gemboss:editor-group` `{ open }` on the header |
388
+ | Press a Seg option | `.gemboss-ei-seg button` (`data-value`, or the text) | `gemboss:editor-seg` `{ value }` on the group |
389
+ | 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 }` |
390
+ | 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 |
391
+ | Edges: Enter blurs; a box that loses focus sends all four | `.gemboss-ei-edgebox` | `gemboss:editor-edges` `{ t, r, b, l }` |
392
+ | 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
+
260
394
  ## Plain-HTML behaviour: `kit-plain.js` (0.3.0)
261
395
 
262
396
  Surfaces without React (GemFactory, gemcare, gempress, partner-radar) render the kit's markup on the server and need
@@ -461,6 +595,7 @@ both looks like the admin and passes Gems/CLAUDE.md §9.1.
461
595
  |---|---|---|
462
596
  | `ui-btn` (every variant), `ui-btn--icon` | 28px, padding 0 12, 12px/550 (`--ui-btn-h`) | 44px |
463
597
  | `ui-seg` group, `.is-text` label | 28px, 12px/550 | 44px |
598
+ | `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
599
  | `ui-tabs` | 28px, padding 0 12, 12px/550 selected or not, 8px apart | 44px |
465
600
  | `ui-page-head h1` | 20px / 24px / 700 (Polaris headingLg) | same |
466
601
  | `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 +847,23 @@ import { Avatar, Dialog, Leaderboard, Podium, SearchDialog, ThemeToggle, themeSc
712
847
  (after the name, e.g. a level) and `id` (a `#me` anchor). `title` may be left out on a page whose own
713
848
  `<h1>` names the board; pass `label` then. Screen-reader text uses `.gb-sr-only`, styled in portal.css.
714
849
 
850
+ ## Reactions (0.21.0): one emoji set and one chip for the fleet
851
+
852
+ 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
853
+ team chat built them first (`public/assets/inbox.js`); this is that, moved here so GemFeedback's requests and comments,
854
+ and GemCare's chat, draw and accept the same thing.
855
+
856
+ | Piece | What |
857
+ |---|---|
858
+ | `@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 }` |
859
+ | `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 |
860
+ | `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 |
861
+ | `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`) |
862
+
863
+ Every target is 44px (a control, CLAUDE.md §9.1); the pill you see is 26px, transparent padding carries the rest, so a
864
+ message with reactions does not grow by a full line. A plain-HTML surface writes the same classes (markup at the top of
865
+ `reactions.css`, and the catalog's "Reactions" section).
866
+
715
867
  ## Report pages (0.4.0)
716
868
 
717
869
  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
@@ -731,6 +883,141 @@ surface computes every number and ratio; the kit only draws. Markup for each is
731
883
  - `ui-grid--2` / `--3`: card grid that drops to one column; `ui-section-head`: an `h2` + note between page head and
732
884
  card titles.
733
885
 
886
+ ## Collections (0.13.0): tables, lists, grids, boards
887
+
888
+ Chris settled these on 05/10/2026 after an inventory of 136 hand-made collections on 11 surfaces (86 tables scrolling
889
+ sideways, 7 of them at 1280; 0 sticky first columns; 12 kinds of empty state; checkboxes that selected nothing). Load
890
+ `@gemboss/ui/collections.css` beside `gemboss-ui.css` (its own entry, like `editor.css`: the export had ~1.4 KB of room
891
+ left under its 90 KB, and the admin, which draws tables with Polaris, never loads it). Plain-HTML surfaces also load
892
+ `kit-plain.js` (row links, selection, client sort). Every pattern is in the catalog with its states, at 1280 and 390.
893
+
894
+ ### Which pattern
895
+
896
+ ```
897
+ Is the picture what people look at to choose? → 5 Card grid
898
+ Do items move through states, and do people move them? → 6 Board
899
+ Is the frame narrower than 480 (a rail, a popup, a drawer)? → 4 Compact list (3 if it needs media)
900
+ Are there 3+ attributes to compare across items?
901
+ ├─ each row is an entity to open / select in bulk? → 2 Index table
902
+ └─ read-only figures → 1 Data table
903
+ One line per item? → 4 Compact list
904
+ Otherwise → 3 Resource list
905
+ ```
906
+
907
+ More than 50 items: paginate (`ui-pager`, position in the URL; a feed or a board column: `ui-more`). Never cut a list
908
+ silently: a `slice` prints "+N more" or "See all N". A table renders at most 100 rows at a time.
909
+
910
+ | # | Pattern | React | HTML | Polaris |
911
+ |---|---|---|---|---|
912
+ | 1 | Data table | `DataTable` | `ui-table-wrap > table.ui-table(.ui-table--sticky-first, --compact)`; `tfoot.ui-table__totals`, `.ui-table__footer` | DataTable |
913
+ | 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 |
914
+ | 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 |
915
+ | 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 |
916
+ | 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 |
917
+ | 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 |
918
+ | | 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 |
919
+
920
+ `./collections` is a plain module (a server component renders it, render functions and all); `IndexTable` and
921
+ `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;
922
+ the callbacks (`onSort`, `onSelectionChange`, a bulk action's `onAction`) need a client parent. Neither module reaches
923
+ the admin's first load (`kit-plain-bundles.test.ts` checks the shell import carries none of their code).
924
+
925
+ ### The rules, and what checks them
926
+
927
+ - **The whole row opens the item; the title stays a real link** (Cmd-click, a screen reader). A click on a checkbox, a
928
+ button, the ⋯ menu, a select or a second link does only that; so does a click that ends a text selection. A row with
929
+ two or more in-place editors is not linked (only its title is). HTML: `tr[data-href]` + kit-plain.js; React:
930
+ `IndexTable` rows with `href`; a resource list's title link covers its row (no script).
931
+ - **One visible button per row at most**, the rest in the ⋯ menu (`ui-menu`). **Never shown only on hover.**
932
+ - **An open ⋯ menu is never cut by its frame (0.18.0).** It floats in the browser's top layer (`popover="manual"`,
933
+ `position: fixed` against its button): below the button, above it when the room below is short and the room above is
934
+ larger, 8px from the viewport's edges, scrolling inside when taller than the room. It closes when a scroll moves its
935
+ button (the page, or a frame around the menu) or the window resizes; a scroll inside the list or elsewhere (a log
936
+ following its output) does not count. Tab order, Escape (focus back on ⋯) and "a click in the
937
+ menu is not a click on the row" are unchanged: the list is still the details' child. React `Menu` does it itself;
938
+ plain HTML gets it from kit-plain.js (`initMenuLayer()`, runs by itself). No popover API (Safari < 17) or no script:
939
+ the absolute list, as before. GemFactory Members (#197) is the case: at 820 / 900 / 1280 the last rows' menus were
940
+ half hidden inside the table's scrolling frame.
941
+ - **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
942
+ it; the aside and the actions share ONE line under the body (aside left, actions right, from the body's edge); the
943
+ media spans both lines; a row without media gives the body the whole width; a body that is only its title keeps
944
+ them on its line. The row's direct children are the four slots (a checkbox goes in `__media`). So a long
945
+ subtitle and a body of many meta lines both get shorter than in 0.13.0 (`collections.test.ts` checks the geometry
946
+ at 320 / 390 / 768 for both, with the 0.13.0 rule and the `flex: 1 1 0` patch as negative controls).
947
+ - **A compact list wraps when its labels carry data (0.15.0).** `CompactList wrap` (`ui-list--wrap`): the label and an
948
+ activity's detail wrap instead of ending in "…", because the cut text's `title` is mouse-only. A side rail of short
949
+ labels keeps one line. `CompactItem.body` (activity only) is the event's own text under them (`.ui-list__content`,
950
+ body size, never cut; paragraphs as `<p>`); `labelText` puts the full label in `title`.
951
+ - **Below 768 of its container an index table becomes a list** (title line; the other cells on one caption line,
952
+ `data-label` prefixes, `data-condensed="hide"` drops; checkbox and actions kept; rows ≥ 44); sort moves to the chips
953
+ of `ui-index__sortbar`. **A data table never folds:** it scrolls in its frame with `--sticky-first`.
954
+ - **A data table reads from the top of its rows (0.16.0)**, as Polaris DataTable (`verticalAlign` "top"): one-line
955
+ 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
956
+ text lines up (`calc((1lh - 24px) / 2)`). The index table keeps `middle`. The row header (`th.ui-table__rowhead`)
957
+ wraps like any cell (Polaris `firstColumn`) with Polaris's first-column width as a floor (9.0625rem; the frame
958
+ scrolls rather than squeezing it to a word a line); `truncate` or `ui-clamp-2` when it must stay short. A column's
959
+ `minWidth` (DataTable) is the floor of a column whose cells wrap, and replaces the row header's (`0` for a row
960
+ header that is always short: a date, an id). A link alone on its
961
+ line is 24px in the row header and in a list inside a cell (`li > a`), as `td > a` already was.
962
+ - **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
963
+ picture (a moodboard, a product shot, a social post). No other ratio: any other value draws 16:10.
964
+ - **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
965
+ the HTML behind nested `<details>` (content-radar ideas reaches 2,000). With `hasMore` the column shows the cards the
966
+ server sent (`boardShown(page)`: 12, then 24 more a page, Chris 05/10/2026) and ends in ONE "Show 24 more":
967
+ `moreHref` is this page with the column one page further (`?col=ideas&page=2`, cumulative, so no script, Back and a
968
+ sent link all work; `moreHref` alone means there is more, `hasMore: false` is the last page). On a plain-HTML page
969
+ (`fetchMore`, i.e. `a[data-gb-more]`) kit-plain.js fetches it, appends the cards whose `data-id` are not on the board
970
+ yet, takes the next link and the count, puts the URL in the address bar, says "24 more cards in <column>" and focuses
971
+ 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
972
+ `onLoadMore(columnId)` + `loading`. Cards need ids: `items: [{ id, card, name? }]` instead of `cards`.
973
+ - **Moving cards between columns is optional (0.18.0)** (`Board onMove(cardId, toColumn, toIndex)`; plain HTML
974
+ `.ui-board[data-gb-move]` + `li[data-id][tabindex="0"][draggable="true"]`, event `gb:move` { id, from, to, index },
975
+ cancelable: not cancelled, kit-plain.js moves the `li` and the counts). Drag with a mouse, or Tab to a card, Space,
976
+ arrows (↑ ↓ in the column, ← → to the next one), Space or Enter to drop, Escape to put it back, each step said in the
977
+ board's live region. While held, the card has `data-gb-lifted="key"` or `"drag"` (a surface may change its layout for a
978
+ keyboard hold, never inside dragstart: Chrome cancels the drag), the target column `data-gb-over`, the landing line
979
+ `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
980
+ `onMove` the board stays read-only (no tab stops, no drag). A phone cannot drag: keep a way that is not dragging (a
981
+ select, a ⋯ "Move to").
982
+ - **At 1280 a table fits its page frame.** Fix in this order: a `canvas` page if the content really is wide; move
983
+ low-priority columns to the detail page; fold attributes into the `ui-sub` line.
984
+ - **No zebra, no uppercase header, no card per row, no grid that changes its column count per viewport** (auto-fill
985
+ with one of 200 / 240 / 320).
986
+ - **A checkbox column only with bulk actions.** The bulk bar covers the header (the table does not move), carries its
987
+ own select-all, Shift+click takes a range, Escape clears. HTML: `input[data-gb-select]` / `[data-gb-select-all]`,
988
+ `gembossUI.selection(root)`, event `gb:selection`.
989
+ - **A resource list selects the same way (0.16.0):** `SelectableResourceList` (`items`, `bulkActions`, optional
990
+ controlled `selected` + `onSelectionChange`, `selectedLabel`, `linkAs`). The checkbox is first in the media slot
991
+ (`label.ui-list__check`: 24, 44 under a finger, pulled into the row's padding so a 64px row stays 64). One
992
+ difference from the table: the bar is the list's header from the start ("0 selected", actions disabled), because a
993
+ list has no header to cover and a bar that appears on the first check pushes the rows under the finger (talent-radar
994
+ /pool measured 74px at 1280, 194px at 390). The whole row still opens the item. HTML: the same `data-gb-select`
995
+ hooks in a `.ui-resource` (kit-plain.js enables the action buttons from 1 instead of showing the bar; an action with
996
+ its own rule sets its button on `gb:selection`, which fires after). collections-gate reports a list with selection
997
+ boxes and no bar (`select-without-bulk`). An open ⋯ menu or dialog takes Escape first; a closed one kept in a row
998
+ does not.
999
+ - **A sort control is 44 × 44 under a finger (0.18.0)**: the header button grows to 44 tall under `pointer: coarse`
1000
+ and to 44 wide under `pointer: coarse` or at 768 and below (a short label like "ID" was 44 × 30 on an iPad); the
1001
+ chips below 768 have the same floor. Under a mouse the header keeps its Polaris size.
1002
+ - **Sort lives in the URL** (`th[aria-sort] > a.ui-table__sort`); in React the sort links (header and the chips below
1003
+ 768) are `linkAs`, the same element as the row titles (0.16.0), so a Next `Link` sorts without reloading the page;
1004
+ `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
1005
+ (`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.
1006
+ - **States** (Gems/CLAUDE.md 9.2): empty (one main action) · no match (filters and header stay, "Clear filters") ·
1007
+ loading (a skeleton in the pattern's shape: a table keeps its real header, rows keep their height) · reloading
1008
+ (`aria-busy`: the old rows stay, dimmed) · error (a banner in the frame + Retry, "as of HH:MM" with old data; never an
1009
+ empty list) · selected · sort · filter (`ui-toolbar` + `ui-chip` + `ui-coll-count`) · pagination · long content
1010
+ (`ui-trunc`, `ui-clamp-2`, the full text in `title`) · hover · focus-visible (a ring on the row) · reduced motion.
1011
+
1012
+ Checks: `packages/ui/src/collections.test.ts` (behaviour in Chromium, both the HTML and the React table), the density gate
1013
+ (`polaris-reference.json` roles `dataRow`, `indexHead`, `indexCell`, `indexRow`, `bulkBar`, `resourceItem`,
1014
+ `resourceItemTwoLine`, `resourceTitle`, `compactRow`, `skeletonBar`, `thumbnail`) and `packages/ui/scripts/collections-gate.mjs`,
1015
+ which reports the ruled-out combinations on any page (each rule has a page built to break it in the test):
1016
+
1017
+ ```bash
1018
+ node packages/ui/scripts/collections-gate.mjs --url https://partner-radar.ops.gemboss.ai/companies --width 1280
1019
+ ```
1020
+
734
1021
  ## Long lists: pages or "load more" (0.7.3)
735
1022
 
736
1023
  *Recommendation, not a rule: Claude, 04/10/2026, after Chris asked it to decide for GemFactory.*
@@ -799,10 +1086,14 @@ something the base cannot express, add a token or a prop here, and every surface
799
1086
 
800
1087
  | Path | What |
801
1088
  |---|---|
802
- | `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`) |
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`) |
803
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). |
804
1091
  | `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. |
805
1092
  | `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
+ | `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". |
1095
+ | `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`. |
1096
+ | `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`. |
806
1097
  | `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. |
807
1098
  | `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. |
808
1099
  | `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). |
@@ -814,12 +1105,36 @@ something the base cannot express, add a token or a prop here, and every surface
814
1105
  change `packages/ui/src/shell.css` (since 29/09/2026: before that it lived in the admin's stylesheet and was extracted).
815
1106
  The admin and the next published version both carry it.
816
1107
 
1108
+ ## Minified on publish, weighed per file (0.19.0)
1109
+
1110
+ The published tarball carries every stylesheet (`gemboss-ui.css`, `src/*.css`) and the two plain scripts
1111
+ (`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
1112
+ generated header (the gemboss commit; GemFactory's `/api/health` reads it). Next and Vite minify what they bundle anyway;
1113
+ the surfaces that serve the files as they come out of `node_modules` (GemFactory, taskdesk, gemcare, gempress) did not.
1114
+ 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.
1115
+
1116
+ `npm publish` cannot minify in place (`exports` points at `src/`, which the admin compiles from), so the release goes
1117
+ through a staged copy: `packages/ui/scripts/stage.mjs` copies exactly what npm would pack into `.pack/`, minifying with esbuild
1118
+ (`legalComments: none`, no target: rewrites that compute the same style only). `npm pack` / `npm publish` run straight
1119
+ in `packages/ui` are refused by `prepack`.
1120
+
1121
+ The weight gate, `packages/ui/scripts/weight-gate.mjs`, reads the staged copy: gzip (level 6) of each minified file against its
1122
+ ceiling in `weight-budget.json` (measured + 512 bytes). Red when a file is over its ceiling, more than 1 KB under it
1123
+ (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
1124
+ a ceiling. CI runs `node packages/ui/scripts/weight-gate.mjs --ci`: build, stage into a temp dir, gate, then the
1125
+ negative controls (+5 KB of real rules in `ui.css` red; +20 KB of comment green; the unminified sources red; one
1126
+ unminified `kit-plain.js` red; a stylesheet with no ceiling red; a ceiling for a file not shipped red; a stale ceiling
1127
+ red; a `/*` inside a string is not a comment, green). Ceilings go down by themselves; raising one is not how a PR
1128
+ gets green (CLAUDE.md §9.4: shared weight only shrinks): it takes the reason in the PR and Chris's OK, and a new
1129
+ component goes in its own entry. `packages/ui/scripts/pack-lib.mjs` holds what the three scripts share (pack list, minifier, staging).
1130
+
817
1131
  ## Release
818
1132
 
819
1133
  ```bash
820
1134
  cd packages/ui
821
- npm version patch # or minor / major
822
- npm publish # prepack builds gemboss-ui.css first
1135
+ npm version minor # or patch / major
1136
+ npm run stage # builds gemboss-ui.css + catalogs, minifies into .pack/, runs the weight gate
1137
+ npm publish .pack --auth-type=web # publish the STAGED copy (npm pack .pack for a local tarball)
823
1138
  ```
824
1139
 
825
1140
  Publish from a worktree at the MERGED commit on `main`, not from a branch, so the registry and