@gemboss/ui 0.36.1 → 0.40.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 (47) hide show
  1. package/CHANGELOG.md +441 -0
  2. package/README.md +617 -14
  3. package/catalog/editor.html +45 -25
  4. package/catalog/index.html +5522 -1448
  5. package/gemboss-ui.css +3 -3
  6. package/package.json +11 -2
  7. package/parts/components.css +2 -2
  8. package/parts/core.css +1 -1
  9. package/parts/shell.css +2 -2
  10. package/src/assistant/chat-markdown.ts +7 -4
  11. package/src/assistant.css +1 -1
  12. package/src/auth-field.css +1 -0
  13. package/src/auth-field.tsx +74 -0
  14. package/src/auth-sso-plain.js +1 -0
  15. package/src/auth.css +1 -1
  16. package/src/auth.tsx +64 -1
  17. package/src/brand-gem.tsx +19 -0
  18. package/src/brand.tsx +4 -7
  19. package/src/chat-plain.js +2 -2
  20. package/src/editor/controls.tsx +148 -11
  21. package/src/editor/frame.tsx +8 -4
  22. package/src/editor/index.ts +8 -4
  23. package/src/editor/panel.tsx +44 -14
  24. package/src/editor/repeater.tsx +322 -0
  25. package/src/editor/sortable.tsx +50 -24
  26. package/src/editor/swatch.ts +20 -0
  27. package/src/editor/tiles.tsx +51 -0
  28. package/src/editor-plain.js +1 -1
  29. package/src/editor-repeater.css +1 -0
  30. package/src/editor-tiles.css +1 -0
  31. package/src/editor.css +1 -1
  32. package/src/gemboss-field.css +1 -1
  33. package/src/icons.tsx +11 -0
  34. package/src/index.ts +14 -2
  35. package/src/kit-plain.js +1 -1
  36. package/src/service-switcher-plain.js +1 -1
  37. package/src/service-switcher.css +1 -1
  38. package/src/service-switcher.tsx +119 -35
  39. package/src/services.ts +4 -2
  40. package/src/shell.css +1 -1
  41. package/src/site.css +1 -0
  42. package/src/site.tsx +50 -0
  43. package/src/team-chat-data.ts +79 -0
  44. package/src/team-chat.css +1 -0
  45. package/src/team-chat.tsx +825 -0
  46. package/src/toggle-bar.tsx +60 -0
  47. package/src/ui.css +1 -1
package/README.md CHANGED
@@ -59,16 +59,20 @@ its CSS out of the build with it (`sideEffects`). gzip of the minified file, as
59
59
  | auth | `@gemboss/ui/auth.css` | `packages/ui/src/auth.css` | the sign-in screen (+ the grid backdrop, 0.30.0) | 2.8 KB | `AuthLayout` & co. import it |
60
60
  | assistant | `@gemboss/ui/assistant.css` | `packages/ui/src/assistant.css` | the dock and the launcher | 2.2 KB | `AssistantHost`, the launcher import it |
61
61
  | portal | `@gemboss/ui/portal.css` | `packages/ui/src/portal.css` | `TopbarNav`, `SearchDialog`, `Avatar`, `Leaderboard`, `Podium`, the `ThemeToggle` button | 2.1 KB | those components import it |
62
+ | site | `@gemboss/ui/site.css` | `packages/ui/src/site.css` | the public site layer (0.40.0): the bar, the lockup at the site size, links, buttons, the footer | 1.0 KB | `SiteHeader` / `SiteBrand` import it; a plain-HTML page links it |
62
63
  | 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
64
  | collections | `@gemboss/ui/collections.css` | `packages/ui/src/collections.css` | tables, lists, grids, boards | 4.6 KB | `IndexTable`, `ResourceList`, `Board`… import it |
64
65
  | editor | `@gemboss/ui/editor.css` | `packages/ui/src/editor.css` | the editors | 7.9 KB | the `@gemboss/ui/editor` components import it |
66
+ | editor-repeater | `@gemboss/ui/editor-repeater.css` | `packages/ui/src/editor-repeater.css` | `RepeaterList`: a setting that is a list, as rows | 0.9 KB | `RepeaterList` imports it |
67
+ | editor-tiles | `@gemboss/ui/editor-tiles.css` | `packages/ui/src/editor-tiles.css` | `ChoiceTiles`: a closed choice of shapes as picture tiles | 0.5 KB | `ChoiceTiles` imports it |
65
68
  | reactions | `@gemboss/ui/reactions.css` | `packages/ui/src/reactions.css` | the emoji reactions and their picker | 0.8 KB | `Reactions` / `ReactionPicker` import it |
66
69
  | 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
70
  | chat | `@gemboss/ui/chat.css` + `chat-composer.css` | `packages/ui/src/chat.css`, `packages/ui/src/chat-composer.css` | the chat body, the composer | 3.0 + 0.9 KB | the chat components (0.29.0); a page writing the markup links them |
68
71
  | assistant-chat | `@gemboss/ui/assistant-chat.css` | `packages/ui/src/assistant-chat.css` | the chat pieces the package draws (0.29.0): column, starters that wrap, meta, errors, notes, paperclip | 0.9 KB | `AssistantChat` and its pieces import it |
69
72
  | minichart | `@gemboss/ui/minichart.css` | `packages/ui/src/minichart.css` | `Sparkline`, `ShareBar` (0.30.0) | 0.5 KB | those components import it |
70
73
  | setup-guide | `@gemboss/ui/setup-guide.css` | `packages/ui/src/setup-guide.css` | `SetupGuide` (0.30.0) | 0.9 KB | `SetupGuide` imports it |
71
- | service-switcher | `@gemboss/ui/service-switcher.css` | `packages/ui/src/service-switcher.css` | `ServiceSwitcher`: the button after the brand and its panel of Gem services (0.36.0) | 1.8 KB | `ServiceSwitcher` imports it, and `BrandLockup` through it (README "ServiceSwitcher") |
74
+ | service-switcher | `@gemboss/ui/service-switcher.css` | `packages/ui/src/service-switcher.css` | `ServiceSwitcher` and `Switcher`: the button after the brand and its panel (0.36.0; any list, illustrated tiles 0.40.0) | 2.2 KB | `ServiceSwitcher` imports it, and `BrandLockup` through it (README "ServiceSwitcher") |
75
+ | team-chat | `@gemboss/ui/team-chat.css` | `packages/ui/src/team-chat.css` | the team chat layer (20/10/2026): rail, messages, composer, thread and people panels, the screen's layout | 3.6 KB | `TeamChat` and its pieces import it (with `Avatar`'s portal.css and `Reactions`' reactions.css); a plain-HTML page links it (README "The team chat layer") |
72
76
  | bundle | `@gemboss/ui/gemboss-ui.css` | `gemboss-ui.css` | core + components + shell + auth + assistant + chat + portal + theme-dark | 21.9 KB | compatibility only (see CHANGELOG 0.23.0) |
73
77
 
74
78
  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).
@@ -637,10 +641,10 @@ import "@gemboss/ui/editor.css"; // only on the screens that show an editor
637
641
  | Preview | `.gemboss-pv` › `.gemboss-pv-stage` › `.gemboss-pv-frame` (`.desktop` / `.tablet` / `.mobile`, `.gemboss-pv-fit` for scale-to-fit) › `iframe.gemboss-pv-iframe`; `.gemboss-pv-spin`; empty state `.gemboss-pv-empty` (`-art`, `-title`, `-sub`, `-cta`). Device toggle `.gemboss-pv-devices › button.gemboss-pv-device` |
638
642
  | Left column | tab row `div.gemboss-ec-tabs[role=tablist] › button[role=tab][aria-selected]` (add `.gemboss-ec-tab` for the 44px pointer floors); Configure pane `.gemboss-ec-dock-body` |
639
643
  | Inspector | `.gemboss-ei` (`--docked` inside the Configure pane) › `.gemboss-ei-head` (`-title`, `-x`, `-savedot`), `.gemboss-ei-tabs › .gemboss-ei-tab`, `.gemboss-ei-body`, `.gemboss-ei-foot`. Groups `.gemboss-ei-group › .gemboss-ei-grouphead` (collapsible) and `.gemboss-ei-gh` / `.gemboss-ei-grp`. Field `.gemboss-ei-fld › label + control + .gemboss-ei-hint` |
640
- | Controls | text / select `.gemboss-field.gemboss-ei-inp` (+ `.gemboss-ei-suffix`, `.gemboss-ei-mono`), segmented `.gemboss-ei-seg`, colour `.gemboss-ei-color` / `.gemboss-ei-swatches › .gemboss-ei-sw` / `.gemboss-ei-swatch`, range `.gemboss-ei-range` + `.gemboss-ei-rangeval`, spacing box `.gemboss-ei-edgebox › .gemboss-ei-edge`, image `.gemboss-ei-img` (`-thumb`, `-ctl`, `-actions`, `-grid`, `-cell`), repeater `.gemboss-ei-rep-item` (`-head`, `-acts`, `-add`), blocks `.gemboss-ei-block` (`-head`, `-x`), add `.gemboss-ei-addrow` / `.gemboss-ei-addchip`, element tree `.gemboss-ei-tree › .gemboss-ei-tnode` (`-tname`, `-tacts`, `-tadd`), notes `.gemboss-ei-note`, `.gemboss-ei-oos` (`-h`), `.gemboss-ei-empty`, drag `.gemboss-ei-drag` / `.gemboss-ei-dragmask` |
644
+ | Controls | text / select `.gemboss-field.gemboss-ei-inp` (+ `.gemboss-ei-mono`), a number with its unit inside `UnitField` (`label.gemboss-field.gemboss-field--group` › `.gemboss-field__bare` + `.gemboss-field__suffix`, 0.40.0; `.gemboss-ei-suffix` is gone), segmented `.gemboss-ei-seg`, colour `.gemboss-ei-color` / `.gemboss-ei-swatches › .gemboss-ei-sw` / `.gemboss-ei-swatch`, range `.gemboss-ei-range` + `.gemboss-ei-rangeval`, spacing box `.gemboss-ei-edgebox › .gemboss-ei-edge`, image `.gemboss-ei-img` (`-thumb`, `-ctl`, `-actions`, `-grid`, `-cell`), repeater `.gemboss-ei-rep-item` (`-head`, `-acts`, `-add`), blocks `.gemboss-ei-block` (`-head`, `-x`), add `.gemboss-ei-addrow` (its buttons are the kit's `.ui-btn` via `ChipButton`, 0.40.0; `.gemboss-ei-addchip` is gone), element tree `.gemboss-ei-tree › .gemboss-ei-tnode` (`-tname`, `-tacts`, `-tadd`), notes `.gemboss-ei-note`, `.gemboss-ei-oos` (`-h`), `.gemboss-ei-empty`, drag `.gemboss-ei-drag` / `.gemboss-ei-dragmask` |
641
645
  | Layers | `.gemboss-layers` › `.gemboss-layer-head` + `.gemboss-layers-list` › `.gemboss-layer` (`.is-sel`) › `.gemboss-layer-main` (`.gemboss-layer-type`) + `.gemboss-layer-acts` (`.gemboss-layer-del`) |
642
646
  | Settings drawer | `.gemboss-settings` › `.gemboss-picker-head` + `.gemboss-settings-body` › `.gemboss-settings-field`, `-grouphead`, `-gen`, `-note`, `-error`, `-handle`; `.gemboss-settings-foot` (`-save`, `-cancel`); backdrop `.gemboss-picker-backdrop`, empty `.gemboss-picker-empty` |
643
- | Page / section editor bar | `.gemboss-pageedit` (`-top`, `-title`, `-kind`, `-status`, `-standing`, `-scope`, `-modes`, `-notice`, `-chip`, `-pv`), `.gemboss-sec-type`, section insert `.gemboss-sec-insert-*`, draft preview `.gemboss-page-draft-stage` / `-status`, `.gemboss-page-section-content`, global styles bundle `.gemboss-bundle-look` |
647
+ | Page / section editor bar | `.gemboss-pageedit` (`-top`, `-title`, `-kind`, `-status`, `-standing`, `-scope`, `-modes`, `-notice`, `-chip`, `-pv`; the left cluster's gap is `--gemboss-tb-gap`, 12px, for a child that hands it back; above 1000px the bar's tracks are `auto fit-content(100%) auto`, so its free space sits beside the centre), `.gemboss-sec-type`, section insert `.gemboss-sec-insert-*`, draft preview `.gemboss-page-draft-stage` / `-status`, `.gemboss-page-section-content`, global styles bundle `.gemboss-bundle-look` |
644
648
  | Try door bar | `.gemboss-editor-topbar--guest` › `.gemboss-guest-brand` (`.gemboss-guest-mark`), `-note`, `-keep`, `-signin` |
645
649
 
646
650
  Not here, on purpose: the body modes that hide the admin's own topbar and rail while an editor is open
@@ -697,7 +701,7 @@ import { DeviceToggle, EditorBack, EditorChatToggle, EditorFrame, EditorPaneTabs
697
701
  | `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. |
698
702
  | `EditorBack` | `.gemboss-editor-back` ← | A `<button type="button">`, or `as={Link}` / `as="a"` with `href`. Named "Back" for a screen reader (`label`). |
699
703
  | `EditorChatToggle` | `.gemboss-editor-chat-toggle` | Shows / hides the left column; filled while open, pressed when collapsed. |
700
- | `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. |
704
+ | `EditorSaveState` | `.gemboss-editor-savestate` (`.is-idle`, `.is-saving` with the spinner, `.is-saved`) | `idle` draws nothing, or `idleText` as `.is-idle` (muted, 5.3:1 on the bar; the section editor's "Autosaves", the page editor's "Draft saved · <when>"). `.is-saved` is hidden at 760px and below. Texts and titles are props. Saving and saved are a polite live region (`role=status`) unless `live={false}`: for a surface with its own live region, present from its first render, that says only what changed (the admin's page editor). |
701
705
  | `DeviceToggle`, `MonitorGlyph`, `TabletGlyph`, `PhoneGlyph` | `.gemboss-pv-devices` › `.gemboss-pv-device` | Desktop / tablet / mobile. Moved from the admin unchanged; `TabletGlyph` exported 0.26.0 (it was inline in the toggle). |
702
706
 
703
707
  One module per piece behind one entry, so a bundler gives each editor only what it draws (measured: one module for all
@@ -764,7 +768,7 @@ import { ColorControl, EdgeBox, Field, ImageControl, InspectorGroup, PanelAction
764
768
  |---|---|---|
765
769
  | `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). |
766
770
  | `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. |
767
- | `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`. |
771
+ | `InspectorGroup` | `.gemboss-ei-grp` › `button.gemboss-ei-gh` (13px / 650, the chevron at its end, `note` as `.gemboss-ei-gh-note`) + the body | `aria-expanded`, `data-open`; closed, CSS hides everything after the header (the body stays mounted; an inline `display` on the body's first element would win over that, so give it a bare wrapper). Controlled (`open` + `onOpenChange`, which receives the NEXT state) or not (`defaultOpen`). `attrs`: `data-*` on the group itself, so a page that finds groups by an attribute needs no wrapper (a wrapper drops the divider between groups). `collapsible={false}`: the schema form's static group, `section.gemboss-ei-group` › `h4.gemboss-ei-grouphead` + `hint`. |
768
772
  | `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`. |
769
773
  | `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. |
770
774
  | `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`). |
@@ -871,7 +875,11 @@ gembossUI.initPasswordFields(); // the sign-in's reveal button; resetPasswordFie
871
875
 
872
876
  In a bundled app (Next, Vite) load it once with a bare `import "@gemboss/ui/kit-plain.js";` in a client module: the
873
877
  package lists the file in `sideEffects`, so the bundler keeps it (before 0.5.0 it dropped it and `gembossUI` was
874
- undefined in production builds). Use `confirm` instead of `window.confirm()`: the browser's box has no GemBoss look, no critical tone, and its
878
+ undefined in production builds). Loading it writes nothing on `<html>` (0.37.1): the `data-gb-menus` flag kit-plain.css
879
+ reads (an open menu's list stays unpainted until the script floats it) goes on in the capture phase of the click that
880
+ opens a menu, before the browser opens it. Up to 0.36.1 the script set it as it loaded, and a client module can run
881
+ before React hydrates `<html>`: React reported an attribute the server did not render (talent-radar, post-studio: 1
882
+ load in 3). `suppressHydrationWarning` on `<html>` is not needed for it any more; drop it unless something else writes there. Use `confirm` instead of `window.confirm()`: the browser's box has no GemBoss look, no critical tone, and its
875
883
  buttons cannot be named. For `initShell`, put `data-label-open` / `data-label-close` on the ☰ button to have its
876
884
  name follow the state; for `initPasswordFields`, `data-label-show` / `data-label-hide` on the reveal button.
877
885
 
@@ -1222,26 +1230,52 @@ import { AppShell, BrandLockup, ServiceSwitcher } from "@gemboss/ui";
1222
1230
  **When.** On the topbar of an INTERNAL service, beside its logo. Not on a public surface (GemFeedback's board,
1223
1231
  GemCommunity, a storefront): the panel names internal tools and links to their admin pages.
1224
1232
 
1233
+ **The same panel for any list: `Switcher` (0.40.0).** `ServiceSwitcher` is `Switcher` with the Gem services. A public
1234
+ surface that has its own list to switch between (GemCommunity's groups: Chris 20/10/2026, "kèm hình ảnh minh hoạ, tên và
1235
+ description … như các dịch vụ gemboss") uses `Switcher` with its own items, never the services:
1236
+
1237
+ ```tsx
1238
+ import { Switcher } from "@gemboss/ui";
1239
+
1240
+ <Switcher
1241
+ items={[
1242
+ { id: "gemcommunity", name: "GemCommunity", description: "The GemBoss users' group.", href: "/c/gemcommunity", group: "mine", media: { glyph: "◈" }, tint: "iris" },
1243
+ { id: "sellers-cn", name: "China Brands Go Global", description: "Chinese brands going global.", href: "/c/sellers-cn/signup", group: "discover", media: { src: "/covers/cn.jpg" }, badge: "Apply" },
1244
+ ]}
1245
+ groups={[{ id: "mine", title: "Your groups" }, { id: "discover", title: "Discover" }]}
1246
+ current="gemcommunity"
1247
+ labels={{ trigger: "Switch group", title: "Communities", search: "Find a group" }}
1248
+ foot={<button type="button" className="ui-btn">Create a community</button>}
1249
+ />
1250
+ ```
1251
+
1252
+ | | |
1253
+ | --- | --- |
1254
+ | Items | `id`, `name`, `href` (same tab), `description` (two lines at most), `group` (a `groups` id; unknown or none: a last group without a heading), `tint` (`SERVICE_TINTS`), `badge` (a chip after the name: "Apply"). The picture: `media: { src }` (an image) or `media: { glyph }` (an emoji or two letters, drawn 44px on a panel of the tint) makes the tile a card; without `media`, the service kind: a 40px mark (`icon` or `glyph`). |
1255
+ | A card | `a.gb-svc__tile.gb-svc__tile--media` > `span.gb-svc__media` (16:9 across the tile, an `<img alt="" loading="lazy">` covering it, or `span.gb-svc__media-glyph`), the name (+ `span.gb-svc__badge`), the line. A group with a card lays its grid out as `gb-svc__grid--media`: 208px columns, two a row on a phone. |
1256
+ | Props | `items`, `groups`, `current`, `labels` (as ServiceSwitcher's), `search`, `allHref` (default none) or `foot` (the app's own foot, actions; wins), `triggerIcon` (default `apps`), `className`. Everything else (the dialog, where it opens, search, keys, the phone sheet) is ServiceSwitcher's, because it is the same code. |
1257
+
1258
+
1225
1259
  | | |
1226
1260
  | --- | --- |
1227
1261
  | Props | `current` (a `ServiceId`, required): the service this page belongs to. `services` (default `GEM_SERVICES`; another list keeps the groups' order). `labels`: `trigger` ("Switch service", the button's name and tooltip), `title` ("Gem services"), `close`, `search` ("Find a service"), `noMatch` ("No service matches “{query}”."), `all`. `allHref`: the foot link, default `SERVICES_HEALTH_URL` (GemFactory's `/services`, every service and its health); `null` draws no foot. `search` (default true). `className` (the button). `BrandLockup switcher={…}` takes the same, less `className`. Every prop is plain data, so a server component can pass it (BrandLockup is not a client module; ServiceSwitcher is) |
1228
1262
  | The button | `button.gb-svc-trigger`, the nine-dot `apps` icon (new in the set), `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls` the panel. 32px under a mouse, 4px from the brand; 44 under a finger or at phone width; ≤460px, where the brand is its 44px mark alone, 40 wide and touching it |
1229
1263
  | The panel | `<dialog class="gb-svc">` through the kit's `useModalDialog` (as `Dialog`, `Sheet`, `SettingsDialog`): Escape (one press, also from the search box), ✕ (the kit popup's `ui-modal__close`), a press that starts on the backdrop; the page behind is inert and holds still; focus goes back to the button. It opens on the current service's tile (not on ✕, not on the search box, which would pull up a phone's keyboard). Each opening starts with every service shown |
1230
- | Where | desktop: under the topbar, from the brand's left edge (measured when it opens, kept inside the window), `min(760px, 100vw - 24px)` wide, at most 80% of the height, its body scrolling. ≤640px: the whole screen, a head row (title + ✕), two columns, padded by `env(safe-area-inset-*)` |
1264
+ | Where | desktop: 6px under the bar that holds the button, from the brand's left edge (measured when it opens, kept inside the window), `min(760px, 100vw - 24px)` wide, at most 80% of the height, its body scrolling. The bar (0.37.1) is the closest `.gemboss-platform-topbar`, `header` or `[role="banner"]` around the button (62 at 1280 on the kit's 56px topbar; GemNews's own 56px `<header>` the same, where 0.36.1 measured from the 32px button and opened at 50, over the header's last 6px); with none, 6px under the button. Both implementations, one selector (`service-switcher-plain-data.test.ts`). ≤640px: the whole screen, a head row (title + ✕), two columns, padded by `env(safe-area-inset-*)` |
1231
1265
  | A tile | an `<a href>` to the service's home, same tab: a 40px mark (a kit `Icon` on a square tinted per service), the name (600), what it is for (muted, two lines at most). The current service's tile carries `aria-current="page"` and the accent ring, and still links to its own home. A followed tile closes the panel (a page back from the back/forward cache does not come back with it open); a new-tab click leaves it |
1232
1266
  | Search | name, purpose, id and group title; the groups keep their order; "No service matches “…”." when nothing does; Enter follows the first match ("med⏎" is GemMedia); ↓ goes to the first tile |
1233
1267
  | Keys | arrows move by place on screen (↓ from a tile is the one under it, also across groups), Home / End the first and the last, ↑ from the first row the search box. One tile is in the Tab order at a time |
1234
1268
  | Tints | `data-tint` on the tile, one of `SERVICE_TINTS` (iris, blue, teal, green, amber, orange, rose, violet, slate). A hue is never painted as it is: the mark is the hue mixed into the theme's surface (14%, 22% in dark), the glyph the hue mixed with the theme's text, so one value reads in light and in `data-theme="dark"` (lighter hues there). Measured in Chromium, both themes: every glyph ≥ 3:1 on its mark, names and purposes ≥ 4.5:1 |
1235
1269
  | CSS | `service-switcher.css`, its own entry, 1.8 KB gzip, imported by the component. `BrandLockup` imports the component, so a surface that renders `BrandLockup` gets the entry (and the switcher's code) even without the prop: 1.8 KB of CSS, and 5.8 KB gzip of JS for brand.tsx's module graph against 0.5 before (most of it the icon set and the popup hook, which a surface with a rail already has) |
1236
- | The topbar | `.gemboss-topbar-start` holds ☰, the brand and the button. ≤460 the button is 40 wide and touches the mark, so a full bar (search at its 96px floor, the assistant, the account) still fits at 360. Under 360 nothing else can give: in a bar that holds the switcher, the search's floor drops from 96 to 56 (still a box to tap), rather than the account leaving the screen |
1270
+ | The topbar | `.gemboss-topbar-start` holds ☰, the brand and the button. ≤460 the button is 40 wide and touches the mark, so a full bar (search at its 96px floor, the assistant, the account) still fits at 360. Under 360 nothing else can give: in a bar that holds the switcher, the search's floor drops from 96 to 56 (still a box to tap), rather than the account leaving the screen. 0.37.1: narrower than 96 (a container query on the centre slot, only in that bar under 360) the box is the search icon alone, centred: its words would show a few px (2 at 320). `TopbarSearch`: the input covers the box, its placeholder unpainted, its name its `aria-label`, the icon stepping aside while it is focused or holds words. `SearchDialog trigger="field"`: the words stay the link's name, visually hidden. Measured at 300 / 320 / 340 (56 / 58 / 78 wide); at 359 and up, and in a bar without the switcher, nothing changes |
1237
1271
 
1238
1272
  The groups, in order, are how a store is built and then run (`SERVICE_GROUPS`, services.ts):
1239
1273
 
1240
1274
  | Group | Services |
1241
1275
  | --- | --- |
1242
1276
  | GemBoss | GemBoss (the seller app and the ops console) |
1243
- | Build the store | GemFactory, GemMedia, GemForm |
1244
- | Read the market | GemWatcher, GemMarket, GemCapture, Search Radar |
1277
+ | Build the store | GemFactory, GemMedia, GemForm, GemX |
1278
+ | Read the market | GemWatcher, GemMarket, GemPixel, GemCapture, Search Radar |
1245
1279
  | Content and community | GemPress, Content Radar, GemCommunity, GemCommunity News |
1246
1280
  | Customers | GemCare, GemFeedback |
1247
1281
  | Team and partners | Partner Radar, Taskdesk |
@@ -1255,8 +1289,11 @@ it writes the plain script's copy of the list (below), and without it that test
1255
1289
  bump. A server reads the list from `@gemboss/ui/services`.
1256
1290
 
1257
1291
  `packages/ui/src/service-switcher-browser.test.tsx` measures it in Chromium (where it sits at 1280, Escape / ✕ / backdrop,
1258
- search, keys, a phone at 390 and 320, the topbar at 320 / 360 / 390 / 461, the tints in both themes, kit-plain.js beside
1259
- it); its negative control runs without `service-switcher.css` and finds the browser's centred dialog. Catalog section
1292
+ search, keys, a phone at 390 and 320, the topbar at 320 / 360 / 390 / 461, the search under its floor at 300 / 320 / 340
1293
+ for both searches, the tints in both themes, kit-plain.js beside it); its negative controls run without
1294
+ `service-switcher.css` (the browser's centred dialog) and with 0.36.1's sheet (2px of the search's words at 320).
1295
+ `service-switcher-plain-browser.test.ts` measures where the panel hangs, both implementations: the kit's topbar, GemNews's
1296
+ own header, no bar. Catalog section
1260
1297
  "Chuyển dịch vụ", and the shell section's lockup carries it.
1261
1298
 
1262
1299
  ### Plain HTML: `gembossUI.initServiceSwitcher()` (0.36.0)
@@ -1264,8 +1301,9 @@ it); its negative control runs without `service-switcher.css` and finds the brow
1264
1301
  For a page with no React (GemCapture's ops admin, GemPress's admin, GemCare, Taskdesk): the server writes a placeholder
1265
1302
  right after its `.gb-brand`, and `service-switcher-plain.js` draws the component's button and panel in its place, the
1266
1303
  same DOM (classes, ARIA, order), so `service-switcher.css` draws it unchanged, and the same behaviour. Its own file, not
1267
- `kit-plain.js` (which has 485 bytes gzip left under its ceiling and stays as it was): 4.5 KB gzip, loaded only where the
1268
- switcher is.
1304
+ `kit-plain.js` (which had 485 bytes gzip left under its ceiling at 0.36.0): 4.5 KB gzip, loaded only where the switcher is.
1305
+ A page with its own header (not the kit's topbar) needs nothing more: the panel hangs under the `header` or
1306
+ `[role="banner"]` around the button (0.37.1).
1269
1307
 
1270
1308
  ```html
1271
1309
  <head>
@@ -1312,6 +1350,54 @@ its negative control changes one attribute and the comparison sees it. Then the
1312
1350
  beside it, idempotence, `<head>`, alone, the wrong order). Catalog section "Chuyển dịch vụ", last frame: the plain page,
1313
1351
  live.
1314
1352
 
1353
+ ## Repeater rows (16/10/2026): `RepeaterList`, `useKeyedRows`
1354
+
1355
+ A setting that is a list (an accordion's items, a pricing table's plans, choice cards) is the element's content. The admin's
1356
+ Canvas panel and page editor drew it as boxes always open, moved one place at a time with ↑ ↓; PageFly and GemPages draw it as
1357
+ rows (research `_local/gemboss/research/pickers-2026-10-16/pickers.md` §9, P11). `RepeaterList` is that, for both admin
1358
+ surfaces, with its own part (`editor-repeater.css`, the rules that were in `editor.css` plus the new ones).
1359
+
1360
+ ```tsx
1361
+ import { RepeaterList, useKeyedRows } from "@gemboss/ui/editor";
1362
+
1363
+ const list = useKeyedRows(items, (next) => save({ items: next })); // a stable key per row; the whole list written per change
1364
+ <RepeaterList label="Questions" keys={list.keys} title={(i) => list.rows[i].question}
1365
+ body={(i) => { const key = list.keys[i]; return <QuestionFields row={list.rows[i]} onChange={(field, v) => list.patch(key, (row) => ({ ...row, [field]: v }))} />; }}
1366
+ onMove={list.move} onRemove={list.remove} onDuplicate={list.duplicate} onAdd={() => list.add({ question: "", answer: "" })} />
1367
+ ```
1368
+
1369
+ | Part | Markup | Behaviour |
1370
+ |---|---|---|
1371
+ | list | `.gemboss-ei-fld.gemboss-ei-rep` › `label` (+ `.gemboss-ei-rep-count`) · `hint` · `.gemboss-ei-rep-list` · `button.gemboss-ei-rep-add` | the count beside the name; "No items yet." (or `empty`) without rows; `canAdd={false}` turns add and duplicate off |
1372
+ | row | `.gemboss-ei-rep-item[data-key]` (`.is-dragging`, `data-drop=before\|after`) › `.gemboss-ei-rep-head` › `.gemboss-ei-rep-grip` · `button.gemboss-ei-rep-toggle[aria-expanded]` (`.gemboss-ei-rep-title`) · `.gemboss-ei-rep-acts` (↑ ↓ duplicate ✕) | drag the grip (a 2px line where it lands, `useRowDrag`, the Layers rule); the name folds and opens the row; Alt+↑/↓ on it moves the row and keeps the focus; more than one row opens folded, a row added or duplicated later opens |
1373
+ | body | `.gemboss-ei-rep-body[role=group]` | drawn only while the row is open |
1374
+
1375
+ `useKeyedRows(value, commit)` keeps a key per row that moves, duplicates and is removed with it, so a row's open state and its
1376
+ controls' unsaved typing follow it (measured in `editor-repeater-browser.test.tsx`, with the same list keyed by index as the
1377
+ negative control). It shows a written list at once; `commit` may answer (a Promise of true / false) and a refusal puts the
1378
+ stored list back; the stored list is read by content (a new `[]` each render is no change); one of its own writes coming
1379
+ back is not a change; any other stored list (an undo) replaces what is shown. A field writes with `patch(key, fn)`: by the
1380
+ row's key and onto its latest version, so a control that commits late (a colour, a slider) after a move changes the right row
1381
+ and after a remove changes none. `touch` makes the buttons 44px with a mouse too (the page editor's content panel).
1382
+ Drag is pointer only (HTML drag and drop does not start from a touch); ↑ ↓ stay, 44px under a finger (`editor.css`).
1383
+
1384
+ ## Choice tiles (16/10/2026): `ChoiceTiles`
1385
+
1386
+ A closed choice whose options are shapes (an image's ratio, a layout) as picture tiles with their name under them, as GemPages'
1387
+ layout selector draws them: the seller sees the shape before choosing it.
1388
+
1389
+ ```tsx
1390
+ import { ChoiceTiles } from "@gemboss/ui/editor";
1391
+
1392
+ <ChoiceTiles label="Aspect ratio" value={ratio} onChange={save}
1393
+ options={[{ value: "original", label: "Original", picture: <ImageIcon /> }, { value: "1-1", label: "1:1", shape: { w: 1, h: 1 } },
1394
+ { value: "16-9", label: "16:9", shape: { w: 16, h: 9 } }]} />
1395
+ ```
1396
+
1397
+ `.gemboss-ei-tiles[role=group]` › `button.gemboss-ei-tile[aria-pressed][data-value]` › `.gemboss-ei-tile-pic` (the picture, or a
1398
+ rectangle drawn to fit 26px: wide by its width, tall by its height) · `.gemboss-ei-tile-label`. 4 to a row, 56px tall. The caller
1399
+ owns the value; a click calls `onChange`. Its own part (`editor-tiles.css`).
1400
+
1315
1401
  ## Resizable panels
1316
1402
 
1317
1403
  `ui-resizer` is the seam a person drags between two panels; `ui-resizing` on `<body>` is the drag in
@@ -1425,6 +1511,8 @@ The admin's "Search ⌘K" box for every surface, as a real form (works with no J
1425
1511
  full 44 (0.6.6): the box is a form, so the input is what a finger has to hit. ⌘K hint hidden on focus and below 460px.
1426
1512
  - Shortcut on a page without React: `gembossUI.initSearch()` from `kit-plain.js` (⌘K / Ctrl+K and "/" focus it,
1427
1513
  Escape leaves it). "/" is ignored while another field has focus.
1514
+ - In a bar that holds the ServiceSwitcher, under 360px, the box may go below its 96px floor; there it is its icon alone
1515
+ (0.37.1, `service-switcher.css`; README "ServiceSwitcher" › The topbar). `SearchDialog trigger="field"` the same.
1428
1516
 
1429
1517
  ## Public portal pieces (0.7.0)
1430
1518
 
@@ -1480,6 +1568,69 @@ import { Avatar, Dialog, Leaderboard, Podium, SearchDialog, ThemeToggle, themeSc
1480
1568
  (after the name, e.g. a level) and `id` (a `#me` anchor). `title` may be left out on a page whose own
1481
1569
  `<h1>` names the board; pass `label` then. Screen-reader text uses `.gb-sr-only`, styled in portal.css.
1482
1570
 
1571
+ ## The public site layer (0.40.0): `SiteHeader`, `SiteBrand`, links, buttons, footer
1572
+
1573
+ **Which layer.** A PUBLIC page (gemboss.ai, the docs, news, a product's home page before sign-in) uses this layer. An APP
1574
+ surface (the admin, a portal behind sign-in, GemFeedback's board) keeps `Topbar` + `BrandLockup`. Chris 20/10/2026: "có gemboss
1575
+ ui thì có gemboss website ui"; measured the same day, the public pages had drifted on the same detail: the logo name 18px on
1576
+ gemboss.ai and the docs, 16px on news, 13.3px on the community home; the bar 65 / 57 / 69 / 56px.
1577
+
1578
+ **Two lockup sizes, one gem** (Chris 20/10/2026): site 18px (`SiteBrand`, `.gb-brand--site`), app 13.3px (`BrandLockup`). The gem
1579
+ is the same 26px-tall drawing (`brand-gem.tsx`). The site name stays on a phone; the app one hides at ≤460px, where an app's bar
1580
+ is full of controls. `SiteBrand` does not go through `BrandLockup`: brand.tsx brings the ServiceSwitcher, whose panel names
1581
+ internal tools and their admin URLs, and a public page must not carry it.
1582
+
1583
+ The values are gemboss.ai's own (`website/assets/styles.css`): the bar 64px with the content in a 1132px column, nav links
1584
+ 14px/500, the primary button ink (dark: it inverts with `--gemboss-primary`) 40px and 36px in the bar, footer headings 13px/600
1585
+ and links 14px. Every colour is a `--gemboss-*` token, so `theme-dark.css` applies unchanged.
1586
+
1587
+ ```tsx
1588
+ import { SiteBrand, SiteHeader } from "@gemboss/ui"; // brings @gemboss/ui/site.css
1589
+
1590
+ <SiteHeader
1591
+ brand={<SiteBrand name="GemBoss" href="/" />}
1592
+ nav={<><a className="gb-site-link" href="/product" aria-current="page">Product</a><a className="gb-site-link" href="/pricing">Pricing</a></>}
1593
+ actions={<><a className="gb-site-link" href="/login">Sign in</a><a className="gb-site-btn gb-site-btn--primary" href="/start">Start free</a></>} />
1594
+ ```
1595
+
1596
+ **Plain HTML** needs only the tokens and `site.css`, NOT the core (which resets the page): site.css repeats the lockup's base
1597
+ rules under `gb-brand--site`, so gemboss.ai and the docs can adopt it beside their own stylesheet (`site-browser.test.tsx`
1598
+ "standalone" holds it). Link `gemboss-tokens.css` (or map the `--gemboss-*` tokens the file reads onto your own palette, which
1599
+ also carries your dark mode), then `site.css`, and write the same markup (the catalog's "Trang công khai" section shows it
1600
+ rendered; copy the gem's `<svg>` from there):
1601
+
1602
+ ```html
1603
+ <header class="gb-site-header"><div class="gb-site-header__in">
1604
+ <a class="gb-brand gb-brand--site" href="/" aria-label="GemBoss home"><svg class="gb-brand-gem" …></svg><span class="gb-brand-name">GemBoss</span></a>
1605
+ <nav class="gb-site-nav" aria-label="Main"><a class="gb-site-link" href="/pricing">Pricing</a></nav>
1606
+ <div class="gb-site-actions"><a class="gb-site-link" href="/login">Sign in</a><a class="gb-site-btn gb-site-btn--primary" href="/start">Start free</a></div>
1607
+ </div></header>
1608
+
1609
+ <footer class="gb-site-footer"><div class="gb-site-wrap">
1610
+ <div class="gb-site-footer__grid">
1611
+ <div><!-- brand + a line --></div>
1612
+ <div class="gb-site-footer__col"><h4>Product</h4><a href="/funnels">Adaptive funnels</a></div>
1613
+ </div>
1614
+ <div class="gb-site-footer__bottom"><span>© 2026 GemBoss</span><span><a href="/terms">Terms</a></span></div>
1615
+ </div></footer>
1616
+ ```
1617
+
1618
+ | Class | What |
1619
+ |---|---|
1620
+ | `gb-site-header`, `gb-site-header__in` | the sticky bar (64px) and its 1132px row; ≤900px the nav folds away, ≤380px the bar's text actions too |
1621
+ | `gb-brand gb-brand--site` | the lockup at the site size (`SiteBrand` writes it) |
1622
+ | `gb-site-nav`, `gb-site-link` | the bar's links, 14px/500; `aria-current="page"` is 600 |
1623
+ | `gb-site-actions` | the right side of the bar |
1624
+ | `gb-site-btn` + `--primary` / `--secondary` | the site's buttons: 40px, 36px in the bar, 44px under a finger or at ≤768px |
1625
+ | `gb-site-footer`, `__grid`, `__col`, `__bottom`, `gb-site-wrap` | the footer: a brand column + link columns, then one row of small print |
1626
+
1627
+ What it holds, measured in Chromium (`site-browser.test.tsx`): the bar 64px; the site name 18px and the app name 13.3px side by
1628
+ side; at 390px the site name shown, every control 44px, the nav folded; no sideways scroll at 1280, 390 or 320 (at 320 it was 15px
1629
+ too wide with "Sign in" in the bar, hence the ≤380px rule).
1630
+
1631
+ Not in this layer yet: a menu button for the folded nav (each site keeps its own), and the page sections (hero, features, FAQ,
1632
+ pricing, CTA band), which are the next step.
1633
+
1483
1634
  ## Reactions (0.21.0): one emoji set and one chip for the fleet
1484
1635
 
1485
1636
  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
@@ -1498,6 +1649,458 @@ message with reactions does not grow by a full line. A plain-HTML surface writes
1498
1649
  `reactions.css`, and the catalog's "Reactions" section).
1499
1650
 
1500
1651
 
1652
+ ## The team chat layer (20/10/2026): `TeamChat`, the rail, messages, composer, thread and people
1653
+
1654
+ Chris 20/10/2026: bring GemCare's Slack-like channel chat to GemCommunity, and make the two inherit the same UI so the next
1655
+ upgrade happens in one place. GemCare built the chat (its `public/assets/inbox.{html,css,js}`, origin/main 20/10/2026); its
1656
+ look and behaviour moved here: the channel rail with unread and @mention badges, day separators, the "New messages" line,
1657
+ a person's consecutive messages grouped into one turn, the reply quote, "(edited)", the read receipt, reactions, the thread
1658
+ preview under a message, the hover action bar, the thread panel on the right, the round "jump to the latest". New for the
1659
+ community: the PEOPLE panel, and a layout that gives a small screen its space back.
1660
+
1661
+ **What it needs.** `team-chat.css`, its own entry (the React components import it); `Avatar` from `portal.css` and
1662
+ `Reactions` from `reactions.css` (reused, not redrawn: the components import them too); the kit's `ui-btn` and `ui-check`
1663
+ from the core's `components.css`; `theme-dark.css` for the dark palette. Every colour in `team-chat.css` is a `--gemboss-*`
1664
+ token, so `data-theme="dark"` turns it dark unchanged. It holds no rule on `:root`, `html` or `body`.
1665
+
1666
+ | Component | Draws | Takes |
1667
+ |---|---|---|
1668
+ | `TeamChat` | the screen: `div.gb-tc[data-view][data-panel]` = rail · `section.gb-tc-main` · scrim · panel | `rail`, `children`, `panel`, `panelKind` (`"thread"` / `"people"`), `panelOpen`, `onClosePanel`, `view` (`"list"` / `"conversation"`) |
1669
+ | `ChannelRail`, `ChannelSection`, `ChannelItem` | `nav.gb-tc-rail`: head (title + actions), groups ("Channels", "Direct messages"), rows with a kind glyph (# · lock · person), `@n` and unread badges | `title`, `actions`; `label`; `name`, `kind`, `unread`, `mentions`, `active` (`aria-current`), `online` (a DM), `href` + `linkAs` or `onOpen` |
1670
+ | `ChatHeader` | `header.gb-tc-head`: back (phone only), glyph + name, topic, actions, the members toggle | `name`, `kind`, `topic`, `onBack`, `members: { count, open, onToggle, controls }`, `actions` |
1671
+ | `MessageList`, `DaySeparator`, `NewMessagesLine`, `JumpToLatest` | `div.gb-tc-body` > `div.gb-tc-log[role=log][aria-live=polite]` > `ol.gb-tc-list`; the sticky day pill; the "New messages" line; the round button with a count | `label`, `before` ("Load earlier"), `after` ("Linh is typing…"), `empty`, `jump`, `logRef`, `onScroll`; `label`, `dateTime`; `count`, `visible`, `onJump` |
1672
+ | `Message`, `ReadReceipt` | `li.gb-tc-msg`: avatar or (in a turn) the time on hover, quote, name + time, body, "(edited)", receipt, files, link cards, `Reactions`, thread preview, the action bar | `author`, `avatarSrc` / `avatar`, `time`, `dateTime`, `body`, `grouped`, `edited`, `mentionsMe`, `highlight`, `sending`, `failed`, `status`, `badges`, `replyTo`, `attachments`, `extra`, `reactions`, `onReact`, `thread`, `receipt`, `onReply`, `onOpenThread`, `moreActions`, `editor`, `messageId`; `read`, `of`, `label` |
1673
+ | `Composer` | `form.gb-tc-composer`: "Replying to…", files, mention picker, one box (paperclip · field · send), "Also send to…", error | `onSend`, `value` / `defaultValue` + `onValueChange`, `placeholder`, `label`, `replyingTo` + `onCancelReply`, `files`, `picker`, `onAttach`, `also`, `error`, `busy`, `disabled`, `canSendEmpty`, `inputRef`, `onKeyDown`, `onPaste` |
1674
+ | `ThreadPanel` | `aside.gb-tc-thread`: head + close, notice (`gb-tc-thread__note`, `--error` in the danger colour), the root message, "N replies", the replies flat, a composer | `onClose`, `root`, `replyCount`, `children`, `composer`, `notice`, `noticeTone` (`"error"`), `title`, `subtitle` |
1675
+ | `PeoplePanel`, `PeopleSection`, `PersonRow` | `aside.gb-tc-people[data-collapsed]`: head + collapse button, sections "Online — 3", rows with avatar + presence dot, name, a line under it, a role chip | `collapsed`, `onToggleCollapse`, `actions`, `title`; `label`, `count`; `name`, `src` / `avatar`, `subtitle`, `role`, `presence` (`online` / `away` / `offline`), `href` + `linkAs` or `onSelect` |
1676
+
1677
+ Props only: no fetching, no store, no router. The app owns the data and the actions; the components keep only the screen's
1678
+ own state (the reaction picker a message opened, the composer's text when the app does not hold it, focus). Every word they
1679
+ write themselves is in `TEAM_CHAT_LABELS`; pass `labels` (any subset) to translate. `teamChatRows(messages, { meId,
1680
+ isUnread })` (plain TS, `team-chat-data.ts`) says where the day separators and the "New messages" line go and which
1681
+ messages are `grouped`: same author, same day, under 5 minutes apart, not a reply, and not the first new one (GemCare's
1682
+ rules). `chatDayWord` gives "Today" / "Yesterday"; older days the app formats in its own locale.
1683
+
1684
+ **The layout.** `.gb-tc` is a CSS grid and a size container: it decides by its OWN width (in an app shell the chat is the
1685
+ window minus the shell's rail), with container queries. `TEAM_CHAT_DRAWER_MAX` (1100) and `TEAM_CHAT_PHONE_MAX` (760) are the
1686
+ same numbers for JS.
1687
+
1688
+ | Width of `.gb-tc` | Right panel | Rail / conversation |
1689
+ |---|---|---|
1690
+ | > 1100px | a third column: a thread 380px (`--gb-tc-thread-w`), the people panel 260px (`--gb-tc-people-w`), 56px collapsed | rail 240px (`--gb-tc-rail-w`) + conversation |
1691
+ | ≤ 1100px | a drawer over the conversation from the right (thread 380px, people 300px), with a scrim; a collapsed people panel is a CLOSED drawer | the conversation keeps its whole width |
1692
+ | ≤ 760px | a sheet over the whole chat | `data-view="list"`: the rail alone; `"conversation"`: the conversation alone, with a back button in its head |
1693
+
1694
+ While the panel is a drawer, `TeamChat` moves focus into it on open, makes the rail and the conversation `inert` (Tab stays
1695
+ in the drawer), closes it on Escape or a press on the scrim (`onClosePanel`), and gives focus back to what opened it.
1696
+ Escape closes a thread at any width. The message list is a container too: where IT is narrower than 560px (the thread
1697
+ panel, a docked window) the action bar drops its quick reactions and the rows their side padding.
1698
+
1699
+ **Under a finger** (`pointer: coarse`) and at phone width every control is 44px: rail rows, people rows, the quote, the
1700
+ thread preview, the action bar's buttons, the composer's buttons (the jump button 48px); under a mouse the rail rows and
1701
+ the action bar are 32px, as the admin's density. The composer's field is 16px there (iOS zooms the page under it). With
1702
+ no hover (`hover: none`) the action bar is ONE TAP away: a tap focuses the message row (`tabindex="-1"`) and the bar shows.
1703
+ GemCare's bar was always on under a finger, a row of 44px buttons under every message; this gives that height back to
1704
+ the conversation.
1705
+
1706
+ ### Plain HTML: the markup contract
1707
+
1708
+ A surface with no React (GemCare) writes exactly this, links `team-chat.css` beside the core, `portal.css` and
1709
+ `reactions.css`, and does the behaviour itself (below). `team-chat-contract.test.tsx` renders every component with the data
1710
+ of the block named after it and holds the two to the same tree (tags, classes, attributes, text; `<svg>…</svg>` is an
1711
+ icon from the set, `Icon` in React, drawn as in the catalog), so this markup and the components cannot drift. Ids here
1712
+ (`tc-…`) are the app's own; keep the pairs (`aria-labelledby` → `id`, `for` → `id`, `aria-controls` → `id`) pointing at
1713
+ each other.
1714
+
1715
+ The screen. `data-panel` names the panel in the right slot (absent: none); the scrim is drawn only while the panel is
1716
+ OPEN (a thread is open; the people panel is not collapsed).
1717
+
1718
+ <!-- team-chat: screen -->
1719
+ ```html
1720
+ <div class="gb-tc" data-view="conversation" data-panel="thread">
1721
+ <nav class="gb-tc-rail" aria-label="Channels">…</nav>
1722
+ <section class="gb-tc-main" aria-label="Conversation">…</section>
1723
+ <div class="gb-tc-scrim" aria-hidden="true">
1724
+ </div>
1725
+ <aside class="gb-tc-thread" aria-label="Thread">…</aside>
1726
+ </div>
1727
+ ```
1728
+
1729
+ The rail. The active row has `aria-current` (`"page"` on a link, `"true"` on a button); a row with unread messages adds
1730
+ `gb-tc-channel--unread`; its `aria-label` says the counts (a badge cannot be read out). A DM row may carry a presence dot.
1731
+
1732
+ <!-- team-chat: rail -->
1733
+ ```html
1734
+ <nav class="gb-tc-rail" aria-label="Channels">
1735
+ <div class="gb-tc-rail__head">
1736
+ <h2 class="gb-tc-rail__title">Acme team</h2>
1737
+ <div class="gb-tc-rail__actions">
1738
+ <button type="button" class="ui-btn ui-btn--ghost ui-btn--icon" aria-label="New message">
1739
+ <svg>…</svg>
1740
+ </button>
1741
+ </div>
1742
+ </div>
1743
+ <div class="gb-tc-rail__scroll">
1744
+ <div class="gb-tc-rail__section" role="group" aria-labelledby="tc-channels-h">
1745
+ <h3 class="gb-tc-rail__heading" id="tc-channels-h">Channels</h3>
1746
+ <ul class="gb-tc-rail__list">
1747
+ <li>
1748
+ <button type="button" class="gb-tc-channel" aria-current="true" aria-label="general">
1749
+ <span class="gb-tc-channel__glyph" aria-hidden="true">
1750
+ <svg>…</svg>
1751
+ </span>
1752
+ <span class="gb-tc-channel__name">general</span>
1753
+ </button>
1754
+ </li>
1755
+ <li>
1756
+ <button type="button" class="gb-tc-channel gb-tc-channel--unread" aria-label="launch, 2 mentioning you, 5 unread">
1757
+ <span class="gb-tc-channel__glyph" aria-hidden="true">
1758
+ <svg>…</svg>
1759
+ </span>
1760
+ <span class="gb-tc-channel__name">launch</span>
1761
+ <span class="gb-tc-badge gb-tc-badge--mention" aria-hidden="true">@2</span>
1762
+ <span class="gb-tc-badge" aria-hidden="true">5</span>
1763
+ </button>
1764
+ </li>
1765
+ <li>
1766
+ <button type="button" class="gb-tc-channel" aria-label="payroll, private channel">
1767
+ <span class="gb-tc-channel__glyph" aria-hidden="true">
1768
+ <svg>…</svg>
1769
+ </span>
1770
+ <span class="gb-tc-channel__name">payroll</span>
1771
+ </button>
1772
+ </li>
1773
+ </ul>
1774
+ </div>
1775
+ <div class="gb-tc-rail__section" role="group" aria-labelledby="tc-dms-h">
1776
+ <h3 class="gb-tc-rail__heading" id="tc-dms-h">Direct messages</h3>
1777
+ <ul class="gb-tc-rail__list">
1778
+ <li>
1779
+ <a class="gb-tc-channel gb-tc-channel--unread" href="/chat/dm/linh" aria-label="Linh Tran, direct message, 1 unread">
1780
+ <span class="gb-tc-channel__glyph" aria-hidden="true">
1781
+ <svg>…</svg>
1782
+ <span class="gb-tc-presence gb-tc-presence--online">
1783
+ </span>
1784
+ </span>
1785
+ <span class="gb-tc-channel__name">Linh Tran</span>
1786
+ <span class="gb-tc-badge" aria-hidden="true">1</span>
1787
+ </a>
1788
+ </li>
1789
+ </ul>
1790
+ </div>
1791
+ </div>
1792
+ </nav>
1793
+ ```
1794
+
1795
+ The conversation's head. The back button shows only at phone width; the members toggle is `aria-expanded` and points at
1796
+ the people panel.
1797
+
1798
+ <!-- team-chat: header -->
1799
+ ```html
1800
+ <header class="gb-tc-head">
1801
+ <button type="button" class="ui-btn ui-btn--ghost ui-btn--icon gb-tc-head__back" aria-label="Back to channels">
1802
+ <svg>…</svg>
1803
+ </button>
1804
+ <div class="gb-tc-head__text">
1805
+ <h2 class="gb-tc-head__title">
1806
+ <span class="gb-tc-head__glyph" aria-hidden="true">
1807
+ <svg>…</svg>
1808
+ </span>
1809
+ <span class="gb-tc-head__name">general</span>
1810
+ </h2>
1811
+ <p class="gb-tc-head__sub">Everyone on the team is here.</p>
1812
+ </div>
1813
+ <div class="gb-tc-head__actions">
1814
+ <button type="button" class="ui-btn ui-btn--ghost gb-tc-head__members" aria-expanded="false" aria-controls="tc-people" aria-label="Members, 12">
1815
+ <svg>…</svg>
1816
+ <span class="gb-tc-head__count">12</span>
1817
+ </button>
1818
+ </div>
1819
+ </header>
1820
+ ```
1821
+
1822
+ The list: a day, a message that starts a turn, the "New messages" line, and a turn of two (the second `gb-tc-msg--grouped`:
1823
+ the time in the avatar's column, shown on hover, and the author for screen readers). The jump button is `hidden` while the
1824
+ reader is at the bottom.
1825
+
1826
+ <!-- team-chat: list -->
1827
+ ```html
1828
+ <div class="gb-tc-body">
1829
+ <div class="gb-tc-log" role="log" aria-live="polite" aria-label="Messages in #general" tabindex="0">
1830
+ <ol class="gb-tc-list">
1831
+ <li class="gb-tc-day">
1832
+ <time class="gb-tc-day__label" datetime="2026-10-20">Today</time>
1833
+ </li>
1834
+ <li class="gb-tc-msg" tabindex="-1">
1835
+ <span class="gb-avatar gb-avatar--32 gb-tc-msg__av" data-tone="4" aria-hidden="true">LT</span>
1836
+ <div class="gb-tc-msg__main">
1837
+ <div class="gb-tc-msg__who">
1838
+ <span class="gb-tc-msg__name">Linh Tran</span>
1839
+ <time class="gb-tc-msg__time" datetime="2026-10-20T09:41:00+07:00">09:41</time>
1840
+ </div>
1841
+ <div class="gb-tc-msg__body">Morning! The launch checklist is in the doc.</div>
1842
+ </div>
1843
+ </li>
1844
+ <li class="gb-tc-new">
1845
+ <span class="gb-tc-new__label" role="separator" aria-label="New messages">New messages</span>
1846
+ </li>
1847
+ <li class="gb-tc-msg" tabindex="-1">
1848
+ <span class="gb-avatar gb-avatar--32 gb-tc-msg__av" data-tone="0" aria-hidden="true">QL</span>
1849
+ <div class="gb-tc-msg__main">
1850
+ <div class="gb-tc-msg__who">
1851
+ <span class="gb-tc-msg__name">Quan Le</span>
1852
+ <time class="gb-tc-msg__time" datetime="2026-10-20T09:44:00+07:00">09:44</time>
1853
+ </div>
1854
+ <div class="gb-tc-msg__body">On it.</div>
1855
+ </div>
1856
+ </li>
1857
+ <li class="gb-tc-msg gb-tc-msg--grouped" tabindex="-1">
1858
+ <time class="gb-tc-msg__hover-time" datetime="2026-10-20T09:45:00+07:00">09:45</time>
1859
+ <div class="gb-tc-msg__main">
1860
+ <span class="gb-tc-sr">Quan Le, 09:45: </span>
1861
+ <div class="gb-tc-msg__body">Pushed the fix for the cart drawer.</div>
1862
+ </div>
1863
+ </li>
1864
+ </ol>
1865
+ </div>
1866
+ <button type="button" class="gb-tc-jump" aria-label="Jump to the latest message, 3 new">
1867
+ <svg>…</svg>
1868
+ <span class="gb-tc-jump__n" aria-hidden="true">3</span>
1869
+ </button>
1870
+ </div>
1871
+ ```
1872
+
1873
+ A message with everything: the quote it answers, "(edited)", a read receipt (the viewer's own messages; a ring, a slice
1874
+ per share, filled and ticked when everyone has read it), reactions (the package's `Reactions`; none, no row), the thread
1875
+ preview, the action bar. Modifiers on the `li`: `gb-tc-msg--mention` (calls the viewer: an amber bar on the left),
1876
+ `--highlight` (jumped to), `--sending` (faded, no bar, `p.gb-tc-msg__status` "Sending…"), `--failed` (the status in the danger
1877
+ colour), `--active` (the reaction picker is open: the bar stays), `--editing` (the app's edit box in `div.gb-tc-msg__editor`
1878
+ in place of the body, no bar). Slots: `div.gb-tc-msg__files` (pictures, files), `div.gb-tc-msg__extra` (link cards), the
1879
+ app's own buttons at the end of `div.gb-tc-acts`. "Add reaction" opens the package's picker (`ui-reaction-picker`) in the
1880
+ flow under the body; the quick pair (👍 ✅) is `gb-tc-act--quick`.
1881
+
1882
+ <!-- team-chat: message -->
1883
+ ```html
1884
+ <li class="gb-tc-msg" data-msg-id="42" tabindex="-1">
1885
+ <span class="gb-avatar gb-avatar--32 gb-tc-msg__av" data-tone="7" aria-hidden="true">MP</span>
1886
+ <div class="gb-tc-msg__main">
1887
+ <button type="button" class="gb-tc-quote">
1888
+ <span class="gb-tc-quote__who">Linh Tran</span>
1889
+ <span class="gb-tc-quote__text">Can someone own the hero copy?</span>
1890
+ </button>
1891
+ <div class="gb-tc-msg__who">
1892
+ <span class="gb-tc-msg__name">Mai Pham</span>
1893
+ <time class="gb-tc-msg__time" datetime="2026-10-20T10:02:00+07:00">10:02</time>
1894
+ </div>
1895
+ <div class="gb-tc-msg__body">Mine, shipping it at noon.<span class="gb-tc-msg__edited">(edited)</span>
1896
+ <span class="gb-tc-receipt gb-tc-receipt--all" role="img" aria-label="Read by everyone (3)" title="Read by everyone (3)">
1897
+ <svg>…</svg>
1898
+ </span>
1899
+ </div>
1900
+ <div class="ui-reactions">
1901
+ <div class="ui-reactions__row">
1902
+ <button type="button" class="ui-reaction" aria-pressed="false" title="Linh, Quan" aria-label="👍, Linh, Quan. Add yours">
1903
+ <span class="ui-reaction__emoji">👍</span>
1904
+ <span class="ui-reaction__names">Linh, Quan</span>
1905
+ </button>
1906
+ <button type="button" class="ui-reaction-add" title="Add reaction" aria-label="Add a reaction to the message from Mai Pham" aria-expanded="false">
1907
+ <svg>…</svg>
1908
+ </button>
1909
+ </div>
1910
+ </div>
1911
+ <button type="button" class="gb-tc-threadprev" aria-label="Open the thread, 3 replies, Last reply 2 hours ago">
1912
+ <span class="gb-tc-threadprev__faces" aria-hidden="true">
1913
+ <span class="gb-avatar gb-avatar--24" data-tone="4" aria-hidden="true">LT</span>
1914
+ <span class="gb-avatar gb-avatar--24" data-tone="0" aria-hidden="true">QL</span>
1915
+ </span>
1916
+ <span class="gb-tc-threadprev__count">3 replies</span>
1917
+ <span class="gb-tc-threadprev__last">Last reply 2 hours ago</span>
1918
+ </button>
1919
+ </div>
1920
+ <div class="gb-tc-acts" role="toolbar" aria-label="Actions for the message from Mai Pham">
1921
+ <button type="button" class="gb-tc-act gb-tc-act--quick" aria-pressed="false" aria-label="Add 👍 on the message from Mai Pham">👍</button>
1922
+ <button type="button" class="gb-tc-act gb-tc-act--quick" aria-pressed="false" aria-label="Add ✅ on the message from Mai Pham">✅</button>
1923
+ <button type="button" class="gb-tc-act" aria-expanded="false" aria-label="More reactions for the message from Mai Pham" title="Add reaction">
1924
+ <svg>…</svg>
1925
+ </button>
1926
+ <button type="button" class="gb-tc-act" aria-label="Reply to the message from Mai Pham" title="Reply">
1927
+ <svg>…</svg>
1928
+ </button>
1929
+ <button type="button" class="gb-tc-act" aria-label="Reply in thread to the message from Mai Pham" title="Reply in thread">
1930
+ <svg>…</svg>
1931
+ </button>
1932
+ </div>
1933
+ </li>
1934
+ ```
1935
+
1936
+ The composer. "Replying to…" sits right above the box; `div.gb-tc-composer__files` and `div.gb-tc-composer__picker` hold
1937
+ the app's files and @mention list; Send is `disabled` while there is nothing to send.
1938
+
1939
+ <!-- team-chat: composer -->
1940
+ ```html
1941
+ <form class="gb-tc-composer">
1942
+ <div class="gb-tc-replying">
1943
+ <span class="gb-tc-replying__text">Replying to Linh Tran: Can someone own the hero copy?</span>
1944
+ <button type="button" class="ui-btn ui-btn--ghost ui-btn--icon" aria-label="Stop replying">
1945
+ <svg>…</svg>
1946
+ </button>
1947
+ </div>
1948
+ <div class="gb-tc-composer__files">…</div>
1949
+ <div class="gb-tc-composer__picker">…</div>
1950
+ <div class="gb-tc-composer__box">
1951
+ <button type="button" class="ui-btn ui-btn--ghost ui-btn--icon gb-tc-composer__attach" aria-label="Attach a file">
1952
+ <svg>…</svg>
1953
+ </button>
1954
+ <label class="gb-tc-sr" for="tc-input">Message</label>
1955
+ <textarea id="tc-input" class="gb-tc-composer__input" rows="1" maxlength="4000" placeholder="Message #general">
1956
+ </textarea>
1957
+ <button type="submit" class="ui-btn ui-btn--primary ui-btn--icon gb-tc-composer__send" aria-label="Send" disabled="">
1958
+ <svg>…</svg>
1959
+ </button>
1960
+ </div>
1961
+ </form>
1962
+ ```
1963
+
1964
+ The thread panel: the root, the "N replies" rule, the replies flat, a composer with "Also send to the channel".
1965
+
1966
+ <!-- team-chat: thread -->
1967
+ ```html
1968
+ <aside id="tc-thread" class="gb-tc-thread" aria-labelledby="tc-thread-title" tabindex="-1">
1969
+ <header class="gb-tc-panel-head">
1970
+ <div class="gb-tc-panel-head__text">
1971
+ <h2 class="gb-tc-panel-head__title" id="tc-thread-title">Thread</h2>
1972
+ <p class="gb-tc-panel-head__sub">#general</p>
1973
+ </div>
1974
+ <button type="button" class="ui-btn ui-btn--ghost ui-btn--icon gb-tc-panel-head__close" aria-label="Close thread">
1975
+ <svg>…</svg>
1976
+ </button>
1977
+ </header>
1978
+ <div class="gb-tc-body">
1979
+ <div class="gb-tc-log" role="log" aria-live="polite" aria-labelledby="tc-thread-title" tabindex="0">
1980
+ <ol class="gb-tc-list">
1981
+ <li class="gb-tc-msg" tabindex="-1">
1982
+ <span class="gb-avatar gb-avatar--32 gb-tc-msg__av" data-tone="4" aria-hidden="true">LT</span>
1983
+ <div class="gb-tc-msg__main">
1984
+ <div class="gb-tc-msg__who">
1985
+ <span class="gb-tc-msg__name">Linh Tran</span>
1986
+ <time class="gb-tc-msg__time" datetime="2026-10-20T09:41:00+07:00">09:41</time>
1987
+ </div>
1988
+ <div class="gb-tc-msg__body">Can someone own the hero copy?</div>
1989
+ </div>
1990
+ </li>
1991
+ <li class="gb-tc-thread__count">
1992
+ <span>1 reply</span>
1993
+ </li>
1994
+ <li class="gb-tc-msg" tabindex="-1">
1995
+ <span class="gb-avatar gb-avatar--32 gb-tc-msg__av" data-tone="7" aria-hidden="true">MP</span>
1996
+ <div class="gb-tc-msg__main">
1997
+ <div class="gb-tc-msg__who">
1998
+ <span class="gb-tc-msg__name">Mai Pham</span>
1999
+ <time class="gb-tc-msg__time" datetime="2026-10-20T10:02:00+07:00">10:02</time>
2000
+ </div>
2001
+ <div class="gb-tc-msg__body">Mine, shipping it at noon.</div>
2002
+ </div>
2003
+ </li>
2004
+ </ol>
2005
+ </div>
2006
+ </div>
2007
+ <form class="gb-tc-composer">
2008
+ <div class="gb-tc-composer__box">
2009
+ <label class="gb-tc-sr" for="tc-thread-input">Reply to thread</label>
2010
+ <textarea id="tc-thread-input" class="gb-tc-composer__input" rows="1" maxlength="4000" placeholder="Reply…">
2011
+ </textarea>
2012
+ <button type="submit" class="ui-btn ui-btn--primary ui-btn--icon gb-tc-composer__send" aria-label="Send" disabled="">
2013
+ <svg>…</svg>
2014
+ </button>
2015
+ </div>
2016
+ <label class="ui-check gb-tc-composer__also">
2017
+ <input type="checkbox"/>
2018
+ <span>Also send to #general</span>
2019
+ </label>
2020
+ </form>
2021
+ </aside>
2022
+ ```
2023
+
2024
+ The people panel. Collapsed: `data-collapsed` on the `aside`, the toggle `aria-expanded="false"` and labelled "Show
2025
+ members", no `gb-tc-panel-head__actions`; the names stay in the accessibility tree (clipped, not removed). A row is a link,
2026
+ a button, or a `div` when it does nothing; `gb-tc-person--offline` fades it. On its own (outside `.gb-tc`, e.g. an app
2027
+ shell's right column) it fills its column's height.
2028
+
2029
+ <!-- team-chat: people -->
2030
+ ```html
2031
+ <aside id="tc-people" class="gb-tc-people" aria-labelledby="tc-people-title" tabindex="-1">
2032
+ <header class="gb-tc-panel-head">
2033
+ <div class="gb-tc-panel-head__text">
2034
+ <h2 class="gb-tc-panel-head__title" id="tc-people-title">Members</h2>
2035
+ </div>
2036
+ <button type="button" class="ui-btn ui-btn--ghost ui-btn--icon gb-tc-people__toggle" aria-expanded="true" aria-controls="tc-people-body" aria-label="Hide members" title="Hide members">
2037
+ <svg>…</svg>
2038
+ </button>
2039
+ </header>
2040
+ <div class="gb-tc-people__body" id="tc-people-body">
2041
+ <div class="gb-tc-people__section" role="group" aria-labelledby="tc-online-h">
2042
+ <h3 class="gb-tc-people__heading" id="tc-online-h">Online — 2</h3>
2043
+ <ul class="gb-tc-people__list">
2044
+ <li>
2045
+ <button type="button" class="gb-tc-person" title="Linh Tran">
2046
+ <span class="gb-tc-person__av">
2047
+ <span class="gb-avatar gb-avatar--32" data-tone="4" aria-hidden="true">LT</span>
2048
+ <span class="gb-tc-presence gb-tc-presence--online">
2049
+ <span class="gb-tc-sr">Online</span>
2050
+ </span>
2051
+ </span>
2052
+ <span class="gb-tc-person__text">
2053
+ <span class="gb-tc-person__name">Linh Tran</span>
2054
+ <span class="gb-tc-person__sub">Support lead</span>
2055
+ </span>
2056
+ <span class="gb-tc-person__role">Admin</span>
2057
+ </button>
2058
+ </li>
2059
+ <li>
2060
+ <a class="gb-tc-person" href="/u/quan" title="Quan Le">
2061
+ <span class="gb-tc-person__av">
2062
+ <span class="gb-avatar gb-avatar--32" data-tone="0" aria-hidden="true">QL</span>
2063
+ <span class="gb-tc-presence gb-tc-presence--away">
2064
+ <span class="gb-tc-sr">Away</span>
2065
+ </span>
2066
+ </span>
2067
+ <span class="gb-tc-person__text">
2068
+ <span class="gb-tc-person__name">Quan Le</span>
2069
+ <span class="gb-tc-person__sub">In a meeting</span>
2070
+ </span>
2071
+ </a>
2072
+ </li>
2073
+ </ul>
2074
+ </div>
2075
+ <div class="gb-tc-people__section" role="group" aria-labelledby="tc-members-h">
2076
+ <h3 class="gb-tc-people__heading" id="tc-members-h">Members — 1</h3>
2077
+ <ul class="gb-tc-people__list">
2078
+ <li>
2079
+ <div class="gb-tc-person gb-tc-person--offline" title="Bao Ngo">
2080
+ <span class="gb-tc-person__av">
2081
+ <span class="gb-avatar gb-avatar--32" data-tone="6" aria-hidden="true">BN</span>
2082
+ <span class="gb-tc-presence gb-tc-presence--offline">
2083
+ <span class="gb-tc-sr">Offline</span>
2084
+ </span>
2085
+ </span>
2086
+ <span class="gb-tc-person__text">
2087
+ <span class="gb-tc-person__name">Bao Ngo</span>
2088
+ </span>
2089
+ </div>
2090
+ </li>
2091
+ </ul>
2092
+ </div>
2093
+ </div>
2094
+ </aside>
2095
+ ```
2096
+
2097
+ **What a plain-HTML surface does itself** (the React components do it): Enter sends and Shift+Enter is a new line, but not
2098
+ during an IME composition (`isComposing`, keyCode 229); the field grows with its text (`field-sizing: content`; where a
2099
+ browser lacks it, set `height` from `scrollHeight` through CSSOM, which a `style-src 'self'` CSP allows); Escape or the scrim
2100
+ closes a drawer, focus moves into it and back, the rail and the conversation are `inert` meanwhile; the reaction picker's
2101
+ open state (`gb-tc-msg--active`, `aria-expanded` on its button); arrow keys along `div.gb-tc-acts[role=toolbar]`; when to
2102
+ show `.gb-tc-jump` and its count.
2103
+
1501
2104
  ## Action card (0.28.0): an assistant proposes, a person applies
1502
2105
 
1503
2106
  Chris 08/10/2026: the Feedback Assistant lists likely duplicates and merges them, and carries out status, owner, label