@gemboss/ui 0.39.0 → 0.41.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
@@ -59,6 +59,7 @@ 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 |
@@ -70,7 +71,9 @@ its CSS out of the build with it (`sideEffects`). gzip of the minified file, as
70
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 |
71
72
  | minichart | `@gemboss/ui/minichart.css` | `packages/ui/src/minichart.css` | `Sparkline`, `ShareBar` (0.30.0) | 0.5 KB | those components import it |
72
73
  | setup-guide | `@gemboss/ui/setup-guide.css` | `packages/ui/src/setup-guide.css` | `SetupGuide` (0.30.0) | 0.9 KB | `SetupGuide` imports it |
73
- | 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
+ | reader-dialog | `@gemboss/ui/reader-dialog.css` | `packages/ui/src/reader-dialog.css` | `ReaderDialog`: the popup sized for reading a document (0.41.0) | 0.4 KB | `ReaderDialog` imports it |
75
+ | 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") |
76
+ | 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") |
74
77
  | 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) |
75
78
 
76
79
  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).
@@ -639,7 +642,7 @@ import "@gemboss/ui/editor.css"; // only on the screens that show an editor
639
642
  | 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` |
640
643
  | 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` |
641
644
  | 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` |
642
- | 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` |
645
+ | 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` |
643
646
  | 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`) |
644
647
  | 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` |
645
648
  | 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` |
@@ -766,7 +769,7 @@ import { ColorControl, EdgeBox, Field, ImageControl, InspectorGroup, PanelAction
766
769
  |---|---|---|
767
770
  | `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). |
768
771
  | `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. |
769
- | `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`. |
772
+ | `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`. |
770
773
  | `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`. |
771
774
  | `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. |
772
775
  | `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`). |
@@ -1175,6 +1178,40 @@ The body scrolls under a head that stays, and scrolling past its end does not sc
1175
1178
  contain`). `packages/ui/src/sheet-browser.test.tsx` measures it in Chromium; its negative control runs the same probe
1176
1179
  without `sheet.css` and finds the centred popup. Catalog section "Ngăn trượt cạnh phải".
1177
1180
 
1181
+ ## ReaderDialog (0.41.0): a document read over its list
1182
+
1183
+ Two surfaces drew the same popup with their own CSS the same week, because `Dialog`'s widths (460, `wide` 620) are a
1184
+ form's: GemMedia's admin Docs (gemmedia #76) and partner-radar's Docs (gemlabs #1122) open a long document over the list
1185
+ of documents, nearly the whole window, the whole screen on a phone. Chris 20/10/2026: put it in the kit. `ReaderDialog`
1186
+ is that popup:
1187
+
1188
+ ```tsx
1189
+ import { ReaderDialog } from "@gemboss/ui";
1190
+
1191
+ <ReaderDialog open onClose={close} title={doc.title} subtitle={`v${doc.version} · updated ${doc.updated}`}
1192
+ actions={<><CopyLink slug={doc.slug} /><a className="ui-btn ui-btn--sm" href={doc.src} target="_blank">Open in a new tab</a></>}>
1193
+ <iframe className="ui-reader__frame" src={doc.src} title={doc.title} />
1194
+ </ReaderDialog>
1195
+ ```
1196
+
1197
+ The body is the document's. Give it one child that fills it: `iframe.ui-reader__frame` for a self-contained HTML
1198
+ document, or a scroller of your own with `ui-reader__scroll` (padded 24/32/48, 16/16/40 on a phone; it scrolls, keeps
1199
+ its overscroll, and takes `tabIndex={-1}` and focus so the arrow keys and Space read the document). Keys pressed inside
1200
+ an iframe do not reach the `<dialog>`: a surface that wants Escape there forwards it (`frame.contentWindow` keydown →
1201
+ `dialog.close()`, same-origin). A route that draws it (Next's intercepting route) leaves `open` true and goes back in
1202
+ `onClose`, as with `Sheet`.
1203
+
1204
+ | | |
1205
+ | --- | --- |
1206
+ | Markup | `<dialog class="ui-modal ui-reader">` with the popup's own `ui-modal__head` (title, `__subtitle`, `__actions`, ✕), `__body`, `__foot` |
1207
+ | Behaviour | It is `Dialog` with `ui-reader` on it (one render tree, one hook, `useModalDialog`): Escape, ✕ and a press that started on the backdrop close it, `onClose` hears each once, the page behind is inert and holds still (`html:has(dialog.ui-modal:modal)`), focus goes back to the opener. On open focus goes to the first control, as `Dialog`'s; move it to your document |
1208
+ | Size | `min(1440px, 94vw)` × `min(92dvh, 1200px)`, centred, from 768px up; the whole screen below that, where the actions take their own line under the title and ✕ stays beside it |
1209
+ | CSS | `reader-dialog.css`, its own entry, loaded by the component. Every selector is `dialog.ui-modal.ui-reader…` ((0,2,1)). It lays itself out (no padding, border-box, a column the document fills), so it works on a surface that loads `ui.css` alone. A plain-HTML surface links `@gemboss/ui/reader-dialog.css` beside `gemboss-ui.css` and opens the markup with `showModal()` |
1210
+
1211
+ `packages/ui/src/reader-dialog-browser.test.tsx` measures it in Chromium (size at four windows and three phones, the
1212
+ iframe flush under the head, the scroller scrolling under a head that stays, the phone's head, the page lock, Escape);
1213
+ its negative control runs without `reader-dialog.css` and finds the 460px popup. Catalog section "Hộp đọc tài liệu".
1214
+
1178
1215
  ## SettingsDialog (0.35.0): settings as one popup
1179
1216
 
1180
1217
  The admin's Settings (apps/admin `SettingsDialog`): one popup over the app, the sections in a rail on the left with a
@@ -1228,6 +1265,32 @@ import { AppShell, BrandLockup, ServiceSwitcher } from "@gemboss/ui";
1228
1265
  **When.** On the topbar of an INTERNAL service, beside its logo. Not on a public surface (GemFeedback's board,
1229
1266
  GemCommunity, a storefront): the panel names internal tools and links to their admin pages.
1230
1267
 
1268
+ **The same panel for any list: `Switcher` (0.40.0).** `ServiceSwitcher` is `Switcher` with the Gem services. A public
1269
+ 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à
1270
+ description … như các dịch vụ gemboss") uses `Switcher` with its own items, never the services:
1271
+
1272
+ ```tsx
1273
+ import { Switcher } from "@gemboss/ui";
1274
+
1275
+ <Switcher
1276
+ items={[
1277
+ { id: "gemcommunity", name: "GemCommunity", description: "The GemBoss users' group.", href: "/c/gemcommunity", group: "mine", media: { glyph: "◈" }, tint: "iris" },
1278
+ { 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" },
1279
+ ]}
1280
+ groups={[{ id: "mine", title: "Your groups" }, { id: "discover", title: "Discover" }]}
1281
+ current="gemcommunity"
1282
+ labels={{ trigger: "Switch group", title: "Communities", search: "Find a group" }}
1283
+ foot={<button type="button" className="ui-btn">Create a community</button>}
1284
+ />
1285
+ ```
1286
+
1287
+ | | |
1288
+ | --- | --- |
1289
+ | 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`). |
1290
+ | 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. |
1291
+ | 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. |
1292
+
1293
+
1231
1294
  | | |
1232
1295
  | --- | --- |
1233
1296
  | 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) |
@@ -1540,6 +1603,69 @@ import { Avatar, Dialog, Leaderboard, Podium, SearchDialog, ThemeToggle, themeSc
1540
1603
  (after the name, e.g. a level) and `id` (a `#me` anchor). `title` may be left out on a page whose own
1541
1604
  `<h1>` names the board; pass `label` then. Screen-reader text uses `.gb-sr-only`, styled in portal.css.
1542
1605
 
1606
+ ## The public site layer (0.40.0): `SiteHeader`, `SiteBrand`, links, buttons, footer
1607
+
1608
+ **Which layer.** A PUBLIC page (gemboss.ai, the docs, news, a product's home page before sign-in) uses this layer. An APP
1609
+ surface (the admin, a portal behind sign-in, GemFeedback's board) keeps `Topbar` + `BrandLockup`. Chris 20/10/2026: "có gemboss
1610
+ ui thì có gemboss website ui"; measured the same day, the public pages had drifted on the same detail: the logo name 18px on
1611
+ gemboss.ai and the docs, 16px on news, 13.3px on the community home; the bar 65 / 57 / 69 / 56px.
1612
+
1613
+ **Two lockup sizes, one gem** (Chris 20/10/2026): site 18px (`SiteBrand`, `.gb-brand--site`), app 13.3px (`BrandLockup`). The gem
1614
+ 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
1615
+ is full of controls. `SiteBrand` does not go through `BrandLockup`: brand.tsx brings the ServiceSwitcher, whose panel names
1616
+ internal tools and their admin URLs, and a public page must not carry it.
1617
+
1618
+ The values are gemboss.ai's own (`website/assets/styles.css`): the bar 64px with the content in a 1132px column, nav links
1619
+ 14px/500, the primary button ink (dark: it inverts with `--gemboss-primary`) 40px and 36px in the bar, footer headings 13px/600
1620
+ and links 14px. Every colour is a `--gemboss-*` token, so `theme-dark.css` applies unchanged.
1621
+
1622
+ ```tsx
1623
+ import { SiteBrand, SiteHeader } from "@gemboss/ui"; // brings @gemboss/ui/site.css
1624
+
1625
+ <SiteHeader
1626
+ brand={<SiteBrand name="GemBoss" href="/" />}
1627
+ nav={<><a className="gb-site-link" href="/product" aria-current="page">Product</a><a className="gb-site-link" href="/pricing">Pricing</a></>}
1628
+ 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></>} />
1629
+ ```
1630
+
1631
+ **Plain HTML** needs only the tokens and `site.css`, NOT the core (which resets the page): site.css repeats the lockup's base
1632
+ rules under `gb-brand--site`, so gemboss.ai and the docs can adopt it beside their own stylesheet (`site-browser.test.tsx`
1633
+ "standalone" holds it). Link `gemboss-tokens.css` (or map the `--gemboss-*` tokens the file reads onto your own palette, which
1634
+ also carries your dark mode), then `site.css`, and write the same markup (the catalog's "Trang công khai" section shows it
1635
+ rendered; copy the gem's `<svg>` from there):
1636
+
1637
+ ```html
1638
+ <header class="gb-site-header"><div class="gb-site-header__in">
1639
+ <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>
1640
+ <nav class="gb-site-nav" aria-label="Main"><a class="gb-site-link" href="/pricing">Pricing</a></nav>
1641
+ <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>
1642
+ </div></header>
1643
+
1644
+ <footer class="gb-site-footer"><div class="gb-site-wrap">
1645
+ <div class="gb-site-footer__grid">
1646
+ <div><!-- brand + a line --></div>
1647
+ <div class="gb-site-footer__col"><h4>Product</h4><a href="/funnels">Adaptive funnels</a></div>
1648
+ </div>
1649
+ <div class="gb-site-footer__bottom"><span>© 2026 GemBoss</span><span><a href="/terms">Terms</a></span></div>
1650
+ </div></footer>
1651
+ ```
1652
+
1653
+ | Class | What |
1654
+ |---|---|
1655
+ | `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 |
1656
+ | `gb-brand gb-brand--site` | the lockup at the site size (`SiteBrand` writes it) |
1657
+ | `gb-site-nav`, `gb-site-link` | the bar's links, 14px/500; `aria-current="page"` is 600 |
1658
+ | `gb-site-actions` | the right side of the bar |
1659
+ | `gb-site-btn` + `--primary` / `--secondary` | the site's buttons: 40px, 36px in the bar, 44px under a finger or at ≤768px |
1660
+ | `gb-site-footer`, `__grid`, `__col`, `__bottom`, `gb-site-wrap` | the footer: a brand column + link columns, then one row of small print |
1661
+
1662
+ 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
1663
+ 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
1664
+ too wide with "Sign in" in the bar, hence the ≤380px rule).
1665
+
1666
+ Not in this layer yet: a menu button for the folded nav (each site keeps its own), and the page sections (hero, features, FAQ,
1667
+ pricing, CTA band), which are the next step.
1668
+
1543
1669
  ## Reactions (0.21.0): one emoji set and one chip for the fleet
1544
1670
 
1545
1671
  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
@@ -1558,6 +1684,514 @@ message with reactions does not grow by a full line. A plain-HTML surface writes
1558
1684
  `reactions.css`, and the catalog's "Reactions" section).
1559
1685
 
1560
1686
 
1687
+ ## The team chat layer (20/10/2026): `TeamChat`, the rail, messages, composer, thread and people
1688
+
1689
+ Chris 20/10/2026: bring GemCare's Slack-like channel chat to GemCommunity, and make the two inherit the same UI so the next
1690
+ upgrade happens in one place. GemCare built the chat (its `public/assets/inbox.{html,css,js}`, origin/main 20/10/2026); its
1691
+ look and behaviour moved here: the channel rail with unread and @mention badges, day separators, the "New messages" line,
1692
+ a person's consecutive messages grouped into one turn, the reply quote, "(edited)", the read receipt, reactions, the thread
1693
+ preview under a message, the hover action bar, the thread panel on the right, the round "jump to the latest". New for the
1694
+ community: the PEOPLE panel, and a layout that gives a small screen its space back.
1695
+
1696
+ **What it needs.** `team-chat.css`, its own entry (the React components import it); `Avatar` from `portal.css` and
1697
+ `Reactions` from `reactions.css` (reused, not redrawn: the components import them too); the kit's `ui-btn` and `ui-check`
1698
+ from the core's `components.css`; `theme-dark.css` for the dark palette. Every colour in `team-chat.css` is a `--gemboss-*`
1699
+ token, so `data-theme="dark"` turns it dark unchanged. It holds no rule on `:root`, `html` or `body`.
1700
+
1701
+ | Component | Draws | Takes |
1702
+ |---|---|---|
1703
+ | `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"`) |
1704
+ | `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` |
1705
+ | `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` |
1706
+ | `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` |
1707
+ | `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` |
1708
+ | `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` |
1709
+ | `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` |
1710
+ | `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` |
1711
+
1712
+ Props only: no fetching, no store, no router. The app owns the data and the actions; the components keep only the screen's
1713
+ own state (the reaction picker a message opened, the composer's text when the app does not hold it, focus). Every word they
1714
+ write themselves is in `TEAM_CHAT_LABELS`; pass `labels` (any subset) to translate. `teamChatRows(messages, { meId,
1715
+ isUnread })` (plain TS, `team-chat-data.ts`) says where the day separators and the "New messages" line go and which
1716
+ messages are `grouped`: same author, same day, under 5 minutes apart, not a reply, and not the first new one (GemCare's
1717
+ rules). `chatDayWord` gives "Today" / "Yesterday"; older days the app formats in its own locale.
1718
+
1719
+ **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
1720
+ window minus the shell's rail), with container queries. `TEAM_CHAT_DRAWER_MAX` (1100) and `TEAM_CHAT_PHONE_MAX` (760) are the
1721
+ same numbers for JS.
1722
+
1723
+ | Width of `.gb-tc` | Right panel | Rail / conversation |
1724
+ |---|---|---|
1725
+ | > 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 |
1726
+ | ≤ 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 |
1727
+ | ≤ 760px | a sheet over the whole chat | `data-view="list"`: the rail alone; `"conversation"`: the conversation alone, with a back button in its head |
1728
+
1729
+ While the panel is a drawer, `TeamChat` moves focus into it on open, makes the rail and the conversation `inert` (Tab stays
1730
+ in the drawer), closes it on Escape or a press on the scrim (`onClosePanel`), and gives focus back to what opened it.
1731
+ Escape closes a thread at any width. The message list is a container too: where IT is narrower than 560px (the thread
1732
+ panel, a docked window) the action bar drops its quick reactions and the rows their side padding.
1733
+
1734
+ **Under a finger** (`pointer: coarse`) and at phone width every control is 44px: rail rows, people rows, the quote, the
1735
+ thread preview, the action bar's buttons, the composer's buttons (the jump button 48px); under a mouse the rail rows and
1736
+ the action bar are 32px, as the admin's density. The composer's field is 16px there (iOS zooms the page under it). With
1737
+ no hover (`hover: none`) the action bar is ONE TAP away: a tap focuses the message row (`tabindex="-1"`) and the bar shows.
1738
+ GemCare's bar was always on under a finger, a row of 44px buttons under every message; this gives that height back to
1739
+ the conversation.
1740
+
1741
+ ### Plain HTML: the markup contract
1742
+
1743
+ A surface with no React (GemCare) writes exactly this, links `team-chat.css` beside the core, `portal.css` and
1744
+ `reactions.css`, and does the behaviour itself (below). `team-chat-contract.test.tsx` renders every component with the data
1745
+ of the block named after it and holds the two to the same tree (tags, classes, attributes, text; `<svg>…</svg>` is an
1746
+ icon from the set, `Icon` in React, drawn as in the catalog), so this markup and the components cannot drift. Ids here
1747
+ (`tc-…`) are the app's own; keep the pairs (`aria-labelledby` → `id`, `for` → `id`, `aria-controls` → `id`) pointing at
1748
+ each other.
1749
+
1750
+ The screen. `data-panel` names the panel in the right slot (absent: none); the scrim is drawn only while the panel is
1751
+ OPEN (a thread is open; the people panel is not collapsed).
1752
+
1753
+ <!-- team-chat: screen -->
1754
+ ```html
1755
+ <div class="gb-tc" data-view="conversation" data-panel="thread">
1756
+ <nav class="gb-tc-rail" aria-label="Channels">…</nav>
1757
+ <section class="gb-tc-main" aria-label="Conversation">…</section>
1758
+ <div class="gb-tc-scrim" aria-hidden="true">
1759
+ </div>
1760
+ <aside class="gb-tc-thread" aria-label="Thread">…</aside>
1761
+ </div>
1762
+ ```
1763
+
1764
+ The rail. The active row has `aria-current` (`"page"` on a link, `"true"` on a button); a row with unread messages adds
1765
+ `gb-tc-channel--unread`; its `aria-label` says the counts (a badge cannot be read out). A DM row may carry a presence dot.
1766
+
1767
+ <!-- team-chat: rail -->
1768
+ ```html
1769
+ <nav class="gb-tc-rail" aria-label="Channels">
1770
+ <div class="gb-tc-rail__head">
1771
+ <h2 class="gb-tc-rail__title">Acme team</h2>
1772
+ <div class="gb-tc-rail__actions">
1773
+ <button type="button" class="ui-btn ui-btn--ghost ui-btn--icon" aria-label="New message">
1774
+ <svg>…</svg>
1775
+ </button>
1776
+ </div>
1777
+ </div>
1778
+ <div class="gb-tc-rail__scroll">
1779
+ <div class="gb-tc-rail__section" role="group" aria-labelledby="tc-channels-h">
1780
+ <h3 class="gb-tc-rail__heading" id="tc-channels-h">Channels</h3>
1781
+ <ul class="gb-tc-rail__list">
1782
+ <li>
1783
+ <button type="button" class="gb-tc-channel" aria-current="true" aria-label="general">
1784
+ <span class="gb-tc-channel__glyph" aria-hidden="true">
1785
+ <svg>…</svg>
1786
+ </span>
1787
+ <span class="gb-tc-channel__name">general</span>
1788
+ </button>
1789
+ </li>
1790
+ <li>
1791
+ <button type="button" class="gb-tc-channel gb-tc-channel--unread" aria-label="launch, 2 mentioning you, 5 unread">
1792
+ <span class="gb-tc-channel__glyph" aria-hidden="true">
1793
+ <svg>…</svg>
1794
+ </span>
1795
+ <span class="gb-tc-channel__name">launch</span>
1796
+ <span class="gb-tc-badge gb-tc-badge--mention" aria-hidden="true">@2</span>
1797
+ <span class="gb-tc-badge" aria-hidden="true">5</span>
1798
+ </button>
1799
+ </li>
1800
+ <li>
1801
+ <button type="button" class="gb-tc-channel" aria-label="payroll, private channel">
1802
+ <span class="gb-tc-channel__glyph" aria-hidden="true">
1803
+ <svg>…</svg>
1804
+ </span>
1805
+ <span class="gb-tc-channel__name">payroll</span>
1806
+ </button>
1807
+ </li>
1808
+ </ul>
1809
+ </div>
1810
+ <div class="gb-tc-rail__section" role="group" aria-labelledby="tc-dms-h">
1811
+ <h3 class="gb-tc-rail__heading" id="tc-dms-h">Direct messages</h3>
1812
+ <ul class="gb-tc-rail__list">
1813
+ <li>
1814
+ <a class="gb-tc-channel gb-tc-channel--unread" href="/chat/dm/linh" aria-label="Linh Tran, direct message, 1 unread">
1815
+ <span class="gb-tc-channel__glyph" aria-hidden="true">
1816
+ <svg>…</svg>
1817
+ <span class="gb-tc-presence gb-tc-presence--online">
1818
+ </span>
1819
+ </span>
1820
+ <span class="gb-tc-channel__name">Linh Tran</span>
1821
+ <span class="gb-tc-badge" aria-hidden="true">1</span>
1822
+ </a>
1823
+ </li>
1824
+ </ul>
1825
+ </div>
1826
+ </div>
1827
+ </nav>
1828
+ ```
1829
+
1830
+ The conversation's head. The back button shows only at phone width; the members toggle is `aria-expanded` and points at
1831
+ the people panel.
1832
+
1833
+ <!-- team-chat: header -->
1834
+ ```html
1835
+ <header class="gb-tc-head">
1836
+ <button type="button" class="ui-btn ui-btn--ghost ui-btn--icon gb-tc-head__back" aria-label="Back to channels">
1837
+ <svg>…</svg>
1838
+ </button>
1839
+ <div class="gb-tc-head__text">
1840
+ <h2 class="gb-tc-head__title">
1841
+ <span class="gb-tc-head__glyph" aria-hidden="true">
1842
+ <svg>…</svg>
1843
+ </span>
1844
+ <span class="gb-tc-head__name">general</span>
1845
+ </h2>
1846
+ <p class="gb-tc-head__sub">Everyone on the team is here.</p>
1847
+ </div>
1848
+ <div class="gb-tc-head__actions">
1849
+ <button type="button" class="ui-btn ui-btn--ghost gb-tc-head__members" aria-expanded="false" aria-controls="tc-people" aria-label="Members, 12">
1850
+ <svg>…</svg>
1851
+ <span class="gb-tc-head__count">12</span>
1852
+ </button>
1853
+ </div>
1854
+ </header>
1855
+ ```
1856
+
1857
+ 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`:
1858
+ the time in the avatar's column, shown on hover, and the author for screen readers). The jump button is `hidden` while the
1859
+ reader is at the bottom.
1860
+
1861
+ <!-- team-chat: list -->
1862
+ ```html
1863
+ <div class="gb-tc-body">
1864
+ <div class="gb-tc-log" role="log" aria-live="polite" aria-label="Messages in #general" tabindex="0">
1865
+ <ol class="gb-tc-list">
1866
+ <li class="gb-tc-day">
1867
+ <time class="gb-tc-day__label" datetime="2026-10-20">Today</time>
1868
+ </li>
1869
+ <li class="gb-tc-msg" tabindex="-1">
1870
+ <span class="gb-avatar gb-avatar--32 gb-tc-msg__av" data-tone="4" aria-hidden="true">LT</span>
1871
+ <div class="gb-tc-msg__main">
1872
+ <div class="gb-tc-msg__who">
1873
+ <span class="gb-tc-msg__name">Linh Tran</span>
1874
+ <time class="gb-tc-msg__time" datetime="2026-10-20T09:41:00+07:00">09:41</time>
1875
+ </div>
1876
+ <div class="gb-tc-msg__body">Morning! The launch checklist is in the doc.</div>
1877
+ </div>
1878
+ </li>
1879
+ <li class="gb-tc-new">
1880
+ <span class="gb-tc-new__label" role="separator" aria-label="New messages">New messages</span>
1881
+ </li>
1882
+ <li class="gb-tc-msg" tabindex="-1">
1883
+ <span class="gb-avatar gb-avatar--32 gb-tc-msg__av" data-tone="0" aria-hidden="true">QL</span>
1884
+ <div class="gb-tc-msg__main">
1885
+ <div class="gb-tc-msg__who">
1886
+ <span class="gb-tc-msg__name">Quan Le</span>
1887
+ <time class="gb-tc-msg__time" datetime="2026-10-20T09:44:00+07:00">09:44</time>
1888
+ </div>
1889
+ <div class="gb-tc-msg__body">On it.</div>
1890
+ </div>
1891
+ </li>
1892
+ <li class="gb-tc-msg gb-tc-msg--grouped" tabindex="-1">
1893
+ <time class="gb-tc-msg__hover-time" datetime="2026-10-20T09:45:00+07:00">09:45</time>
1894
+ <div class="gb-tc-msg__main">
1895
+ <span class="gb-tc-sr">Quan Le, 09:45: </span>
1896
+ <div class="gb-tc-msg__body">Pushed the fix for the cart drawer.</div>
1897
+ </div>
1898
+ </li>
1899
+ </ol>
1900
+ </div>
1901
+ <button type="button" class="gb-tc-jump" aria-label="Jump to the latest message, 3 new">
1902
+ <svg>…</svg>
1903
+ <span class="gb-tc-jump__n" aria-hidden="true">3</span>
1904
+ </button>
1905
+ </div>
1906
+ ```
1907
+
1908
+ A message with everything: the quote it answers, "(edited)", a read receipt (the viewer's own messages; a ring, a slice
1909
+ per share, filled and ticked when everyone has read it), reactions (the package's `Reactions`; none, no row), the thread
1910
+ preview, the action bar. Modifiers on the `li`: `gb-tc-msg--mention` (calls the viewer: an amber bar on the left),
1911
+ `--highlight` (jumped to), `--sending` (faded, no bar, `p.gb-tc-msg__status` "Sending…"), `--failed` (the status in the danger
1912
+ colour), `--active` (the reaction picker is open: the bar stays), `--editing` (the app's edit box in `div.gb-tc-msg__editor`
1913
+ in place of the body, no bar). Slots: `div.gb-tc-msg__files` (pictures, files), `div.gb-tc-msg__extra` (link cards), the
1914
+ app's own buttons at the end of `div.gb-tc-acts`. "Add reaction" opens the package's picker (`ui-reaction-picker`) in the
1915
+ flow under the body; the quick pair (👍 ✅) is `gb-tc-act--quick`.
1916
+
1917
+ <!-- team-chat: message -->
1918
+ ```html
1919
+ <li class="gb-tc-msg" data-msg-id="42" tabindex="-1">
1920
+ <span class="gb-avatar gb-avatar--32 gb-tc-msg__av" data-tone="7" aria-hidden="true">MP</span>
1921
+ <div class="gb-tc-msg__main">
1922
+ <button type="button" class="gb-tc-quote">
1923
+ <span class="gb-tc-quote__who">Linh Tran</span>
1924
+ <span class="gb-tc-quote__text">Can someone own the hero copy?</span>
1925
+ </button>
1926
+ <div class="gb-tc-msg__who">
1927
+ <span class="gb-tc-msg__name">Mai Pham</span>
1928
+ <time class="gb-tc-msg__time" datetime="2026-10-20T10:02:00+07:00">10:02</time>
1929
+ </div>
1930
+ <div class="gb-tc-msg__body">Mine, shipping it at noon.<span class="gb-tc-msg__edited">(edited)</span>
1931
+ <span class="gb-tc-receipt gb-tc-receipt--all" role="img" aria-label="Read by everyone (3)" title="Read by everyone (3)">
1932
+ <svg>…</svg>
1933
+ </span>
1934
+ </div>
1935
+ <div class="ui-reactions">
1936
+ <div class="ui-reactions__row">
1937
+ <button type="button" class="ui-reaction" aria-pressed="false" title="Linh, Quan" aria-label="👍, Linh, Quan. Add yours">
1938
+ <span class="ui-reaction__emoji">👍</span>
1939
+ <span class="ui-reaction__names">Linh, Quan</span>
1940
+ </button>
1941
+ <button type="button" class="ui-reaction-add" title="Add reaction" aria-label="Add a reaction to the message from Mai Pham" aria-expanded="false">
1942
+ <svg>…</svg>
1943
+ </button>
1944
+ </div>
1945
+ </div>
1946
+ <button type="button" class="gb-tc-threadprev" aria-label="Open the thread, 3 replies, Last reply 2 hours ago">
1947
+ <span class="gb-tc-threadprev__faces" aria-hidden="true">
1948
+ <span class="gb-avatar gb-avatar--24" data-tone="4" aria-hidden="true">LT</span>
1949
+ <span class="gb-avatar gb-avatar--24" data-tone="0" aria-hidden="true">QL</span>
1950
+ </span>
1951
+ <span class="gb-tc-threadprev__count">3 replies</span>
1952
+ <span class="gb-tc-threadprev__last">Last reply 2 hours ago</span>
1953
+ </button>
1954
+ </div>
1955
+ <div class="gb-tc-acts" role="toolbar" aria-label="Actions for the message from Mai Pham">
1956
+ <button type="button" class="gb-tc-act gb-tc-act--quick" aria-pressed="false" aria-label="Add 👍 on the message from Mai Pham">👍</button>
1957
+ <button type="button" class="gb-tc-act gb-tc-act--quick" aria-pressed="false" aria-label="Add ✅ on the message from Mai Pham">✅</button>
1958
+ <button type="button" class="gb-tc-act" aria-expanded="false" aria-label="More reactions for the message from Mai Pham" title="Add reaction">
1959
+ <svg>…</svg>
1960
+ </button>
1961
+ <button type="button" class="gb-tc-act" aria-label="Reply to the message from Mai Pham" title="Reply">
1962
+ <svg>…</svg>
1963
+ </button>
1964
+ <button type="button" class="gb-tc-act" aria-label="Reply in thread to the message from Mai Pham" title="Reply in thread">
1965
+ <svg>…</svg>
1966
+ </button>
1967
+ </div>
1968
+ </li>
1969
+ ```
1970
+
1971
+ The composer. "Replying to…" sits right above the box; `div.gb-tc-composer__files` and `div.gb-tc-composer__picker` hold
1972
+ the app's files and @mention list; Send is `disabled` while there is nothing to send.
1973
+
1974
+ <!-- team-chat: composer -->
1975
+ ```html
1976
+ <form class="gb-tc-composer">
1977
+ <div class="gb-tc-replying">
1978
+ <span class="gb-tc-replying__text">Replying to Linh Tran: Can someone own the hero copy?</span>
1979
+ <button type="button" class="ui-btn ui-btn--ghost ui-btn--icon" aria-label="Stop replying">
1980
+ <svg>…</svg>
1981
+ </button>
1982
+ </div>
1983
+ <div class="gb-tc-composer__files">…</div>
1984
+ <div class="gb-tc-composer__picker">…</div>
1985
+ <div class="gb-tc-composer__box">
1986
+ <button type="button" class="ui-btn ui-btn--ghost ui-btn--icon gb-tc-composer__attach" aria-label="Attach a file">
1987
+ <svg>…</svg>
1988
+ </button>
1989
+ <label class="gb-tc-sr" for="tc-input">Message</label>
1990
+ <textarea id="tc-input" class="gb-tc-composer__input" rows="1" maxlength="4000" placeholder="Message #general">
1991
+ </textarea>
1992
+ <button type="submit" class="ui-btn ui-btn--primary ui-btn--icon gb-tc-composer__send" aria-label="Send" disabled="">
1993
+ <svg>…</svg>
1994
+ </button>
1995
+ </div>
1996
+ </form>
1997
+ ```
1998
+
1999
+ The thread panel: the root, the "N replies" rule, the replies flat, a composer with "Also send to the channel".
2000
+
2001
+ <!-- team-chat: thread -->
2002
+ ```html
2003
+ <aside id="tc-thread" class="gb-tc-thread" aria-labelledby="tc-thread-title" tabindex="-1">
2004
+ <header class="gb-tc-panel-head">
2005
+ <div class="gb-tc-panel-head__text">
2006
+ <h2 class="gb-tc-panel-head__title" id="tc-thread-title">Thread</h2>
2007
+ <p class="gb-tc-panel-head__sub">#general</p>
2008
+ </div>
2009
+ <button type="button" class="ui-btn ui-btn--ghost ui-btn--icon gb-tc-panel-head__close" aria-label="Close thread">
2010
+ <svg>…</svg>
2011
+ </button>
2012
+ </header>
2013
+ <div class="gb-tc-body">
2014
+ <div class="gb-tc-log" role="log" aria-live="polite" aria-labelledby="tc-thread-title" tabindex="0">
2015
+ <ol class="gb-tc-list">
2016
+ <li class="gb-tc-msg" tabindex="-1">
2017
+ <span class="gb-avatar gb-avatar--32 gb-tc-msg__av" data-tone="4" aria-hidden="true">LT</span>
2018
+ <div class="gb-tc-msg__main">
2019
+ <div class="gb-tc-msg__who">
2020
+ <span class="gb-tc-msg__name">Linh Tran</span>
2021
+ <time class="gb-tc-msg__time" datetime="2026-10-20T09:41:00+07:00">09:41</time>
2022
+ </div>
2023
+ <div class="gb-tc-msg__body">Can someone own the hero copy?</div>
2024
+ </div>
2025
+ </li>
2026
+ <li class="gb-tc-thread__count">
2027
+ <span>1 reply</span>
2028
+ </li>
2029
+ <li class="gb-tc-msg" tabindex="-1">
2030
+ <span class="gb-avatar gb-avatar--32 gb-tc-msg__av" data-tone="7" aria-hidden="true">MP</span>
2031
+ <div class="gb-tc-msg__main">
2032
+ <div class="gb-tc-msg__who">
2033
+ <span class="gb-tc-msg__name">Mai Pham</span>
2034
+ <time class="gb-tc-msg__time" datetime="2026-10-20T10:02:00+07:00">10:02</time>
2035
+ </div>
2036
+ <div class="gb-tc-msg__body">Mine, shipping it at noon.</div>
2037
+ </div>
2038
+ </li>
2039
+ </ol>
2040
+ </div>
2041
+ </div>
2042
+ <form class="gb-tc-composer">
2043
+ <div class="gb-tc-composer__box">
2044
+ <label class="gb-tc-sr" for="tc-thread-input">Reply to thread</label>
2045
+ <textarea id="tc-thread-input" class="gb-tc-composer__input" rows="1" maxlength="4000" placeholder="Reply…">
2046
+ </textarea>
2047
+ <button type="submit" class="ui-btn ui-btn--primary ui-btn--icon gb-tc-composer__send" aria-label="Send" disabled="">
2048
+ <svg>…</svg>
2049
+ </button>
2050
+ </div>
2051
+ <label class="ui-check gb-tc-composer__also">
2052
+ <input type="checkbox"/>
2053
+ <span>Also send to #general</span>
2054
+ </label>
2055
+ </form>
2056
+ </aside>
2057
+ ```
2058
+
2059
+ The people panel. Collapsed: `data-collapsed` on the `aside`, the toggle `aria-expanded="false"` and labelled "Show
2060
+ members", no `gb-tc-panel-head__actions`; the names stay in the accessibility tree (clipped, not removed). A row is a link,
2061
+ 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
2062
+ shell's right column) it fills its column's height.
2063
+
2064
+ <!-- team-chat: people -->
2065
+ ```html
2066
+ <aside id="tc-people" class="gb-tc-people" aria-labelledby="tc-people-title" tabindex="-1">
2067
+ <header class="gb-tc-panel-head">
2068
+ <div class="gb-tc-panel-head__text">
2069
+ <h2 class="gb-tc-panel-head__title" id="tc-people-title">Members</h2>
2070
+ </div>
2071
+ <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">
2072
+ <svg>…</svg>
2073
+ </button>
2074
+ </header>
2075
+ <div class="gb-tc-people__body" id="tc-people-body">
2076
+ <div class="gb-tc-people__section" role="group" aria-labelledby="tc-online-h">
2077
+ <h3 class="gb-tc-people__heading" id="tc-online-h">Online — 2</h3>
2078
+ <ul class="gb-tc-people__list">
2079
+ <li>
2080
+ <button type="button" class="gb-tc-person" title="Linh Tran">
2081
+ <span class="gb-tc-person__av">
2082
+ <span class="gb-avatar gb-avatar--32" data-tone="4" aria-hidden="true">LT</span>
2083
+ <span class="gb-tc-presence gb-tc-presence--online">
2084
+ <span class="gb-tc-sr">Online</span>
2085
+ </span>
2086
+ </span>
2087
+ <span class="gb-tc-person__text">
2088
+ <span class="gb-tc-person__name">Linh Tran</span>
2089
+ <span class="gb-tc-person__sub">Support lead</span>
2090
+ </span>
2091
+ <span class="gb-tc-person__role">Admin</span>
2092
+ </button>
2093
+ </li>
2094
+ <li>
2095
+ <a class="gb-tc-person" href="/u/quan" title="Quan Le">
2096
+ <span class="gb-tc-person__av">
2097
+ <span class="gb-avatar gb-avatar--32" data-tone="0" aria-hidden="true">QL</span>
2098
+ <span class="gb-tc-presence gb-tc-presence--away">
2099
+ <span class="gb-tc-sr">Away</span>
2100
+ </span>
2101
+ </span>
2102
+ <span class="gb-tc-person__text">
2103
+ <span class="gb-tc-person__name">Quan Le</span>
2104
+ <span class="gb-tc-person__sub">In a meeting</span>
2105
+ </span>
2106
+ </a>
2107
+ </li>
2108
+ </ul>
2109
+ </div>
2110
+ <div class="gb-tc-people__section" role="group" aria-labelledby="tc-members-h">
2111
+ <h3 class="gb-tc-people__heading" id="tc-members-h">Members — 1</h3>
2112
+ <ul class="gb-tc-people__list">
2113
+ <li>
2114
+ <div class="gb-tc-person gb-tc-person--offline" title="Bao Ngo">
2115
+ <span class="gb-tc-person__av">
2116
+ <span class="gb-avatar gb-avatar--32" data-tone="6" aria-hidden="true">BN</span>
2117
+ <span class="gb-tc-presence gb-tc-presence--offline">
2118
+ <span class="gb-tc-sr">Offline</span>
2119
+ </span>
2120
+ </span>
2121
+ <span class="gb-tc-person__text">
2122
+ <span class="gb-tc-person__name">Bao Ngo</span>
2123
+ </span>
2124
+ </div>
2125
+ </li>
2126
+ </ul>
2127
+ </div>
2128
+ </div>
2129
+ </aside>
2130
+ ```
2131
+
2132
+ **What a plain-HTML surface does itself** (the React components do it): Enter sends and Shift+Enter is a new line, but not
2133
+ during an IME composition (`isComposing`, keyCode 229); the field grows with its text (`field-sizing: content`; where a
2134
+ browser lacks it, set `height` from `scrollHeight` through CSSOM, which a `style-src 'self'` CSP allows); Escape or the scrim
2135
+ closes a drawer, focus moves into it and back, the rail and the conversation are `inert` meanwhile; the reaction picker's
2136
+ open state (`gb-tc-msg--active`, `aria-expanded` on its button); arrow keys along `div.gb-tc-acts[role=toolbar]`; when to
2137
+ show `.gb-tc-jump` and its count.
2138
+
2139
+ ### Plain HTML: `gembossUI.teamChat` (20/10/2026)
2140
+
2141
+ So a plain page does not write the markup above by hand. `team-chat-plain.js` builds the rows from data and RETURNS
2142
+ elements whose markup is the component's for the same props: tags, classes, every attribute, the text, the icons' SVG.
2143
+ `team-chat-plain-browser.test.ts` draws every piece in every state both ways in Chromium (the contract's data,
2144
+ `team-chat.test.fixtures.tsx`, and the states beyond it) and holds them to one tree, so the markup is written once, in
2145
+ `team-chat.tsx`, and a plain page follows a change to it with a version bump. A classic script, no imports, inlinable:
2146
+ after `kit-plain.js` when the page has it (it adds `gembossUI.teamChat`; loaded first it says so in the console).
2147
+
2148
+ | Builder | Draws what this draws |
2149
+ |---|---|
2150
+ | `message(props)` | `Message`, every prop by the same name; plus `picking` / `reactionsPicking` (a picker drawn open) and `onPickingChange(which, open)` (`"bar"` or `"reactions"`) for a page that redraws its whole list |
2151
+ | `daySeparator(label, dateTime)`, `newMessagesLine(labels)` | `DaySeparator`, `NewMessagesLine` |
2152
+ | `jumpToLatest({ count, visible, onJump, labels })`, `updateJump(button, { count, visible, labels })` | `JumpToLatest`; `updateJump` redraws the same button (a scroll handler keeps its focus) |
2153
+ | `readReceipt({ read, of, label })` | `ReadReceipt` (`null` when `of` ≤ 0) |
2154
+ | `channelSection({ label, id, items })`, `channelItem(props)` | `ChannelSection`, `ChannelItem` (`href`: a link, else a button); `items` are rows or their props |
2155
+ | `peopleSection({ label, count, id, items })`, `personRow(props)` | `PeopleSection`, `PersonRow` (a link, a button with `onSelect`, else a `div`) |
2156
+ | `reactions(props)`, `reactionPicker(props)` | `Reactions` (plus `open` / `onOpenChange`), `ReactionPicker` |
2157
+ | `avatar({ name, src, size, className })`, `icon(name, size)` | `Avatar` (portal.tsx's initials and tone), `Icon` (the icons the builders draw, and `more`) |
2158
+ | `rows(messages, opts)`, `dayKey(at)`, `dayWord(at, now)` | `teamChatRows`, `chatDayKey`, `chatDayWord` |
2159
+ | `LABELS` | `TEAM_CHAT_LABELS`; `labels` (any subset) translates, functions included |
2160
+
2161
+ ```html
2162
+ <link rel="stylesheet" href="/ui/portal.css"><link rel="stylesheet" href="/ui/reactions.css"><link rel="stylesheet" href="/ui/team-chat.css">
2163
+ <script src="/ui/kit-plain.js"></script><script src="/ui/team-chat-plain.js"></script>
2164
+ ```
2165
+
2166
+ ```js
2167
+ const tc = gembossUI.teamChat;
2168
+ list.replaceChildren(...tc.rows(messages, { meId: me.id, isUnread: (m) => m.id > readUpTo }).map((r) =>
2169
+ r.kind === "day" ? tc.daySeparator(dayLabel(r.date), isoDay(r.date))
2170
+ : r.kind === "new" ? tc.newMessagesLine()
2171
+ : tc.message({ author: r.message.authorName, time: hhmm(r.message.at), dateTime: r.message.at, body: r.message.text,
2172
+ grouped: r.grouped, messageId: r.message.id, reactions: r.message.reactions, edited: Boolean(r.message.editedAt),
2173
+ receipt: tc.readReceipt({ read: 2, of: 3, label: "Read by Linh, Quan. Not yet: Bo" }),
2174
+ onReact: (emoji) => react(r.message.id, emoji), onReply: () => startReply(r.message), onOpenThread: (e) => openThread(r.message.id, e.currentTarget),
2175
+ moreActions: myMoreButton(r.message), picking: view.picking === r.message.id,
2176
+ onPickingChange: (which, open) => { if (which === "bar") view.picking = open ? r.message.id : null; } })));
2177
+ ```
2178
+
2179
+ Strings are TEXT (a body `"<img onerror=…>"` shows those characters); a slot that takes a node (`avatar`, `badges`,
2180
+ `attachments`, `extra`, `receipt`, `moreActions`, `editor`, a rich `time` or `body`) takes a DOM node as given. Callbacks are
2181
+ click listeners and get what React's `onClick` gets (the event; `onReact` the emoji). The behaviour is the components': the
2182
+ action bar is a toolbar (arrows, Home / End), "More reactions" opens the package's picker under the body and the reaction
2183
+ row's "Add reaction" one under the row (search, this browser's "Frequently used" under the React picker's own storage key,
2184
+ arrows, Enter picks the first match, Escape closes and gives focus back), a chip's click remembers its emoji. A row drawn
2185
+ with `picking` takes no focus: after a redraw the page puts focus back, as it does for anything it redraws.
2186
+
2187
+ Not built here: the screen's shell (`TeamChat`, `ChannelRail`, `ChatHeader`, `MessageList`, `Composer`, `ThreadPanel`,
2188
+ `PeoplePanel`) is markup a page writes once (the blocks above), with the behaviour listed just above.
2189
+
2190
+ The script carries two kinds of copy, both held by `team-chat-plain-data.test.ts`: the icons' drawing (icons.tsx) and the
2191
+ emoji set (reactions-data.ts) as a generated block (`UPDATE_PLAIN_TEAM_CHAT=1` on that test writes it), and the labels, the
2192
+ avatar's initials and tone, the reaction helpers and `teamChatRows` in ES5, called with the TypeScript's inputs and held to
2193
+ its answers.
2194
+
1561
2195
  ## Action card (0.28.0): an assistant proposes, a person applies
1562
2196
 
1563
2197
  Chris 08/10/2026: the Feedback Assistant lists likely duplicates and merges them, and carries out status, owner, label
@@ -1862,6 +2496,7 @@ something the base cannot express, add a token or a prop here, and every surface
1862
2496
  | `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`. |
1863
2497
  | `packages/ui/src/editor-layers-plain.js` | 0.22.0: `gembossUI.initEditorLayers()`, the layers', the outline's and the drawers' behaviour for plain HTML. Load after `kit-plain.js`. |
1864
2498
  | `packages/ui/src/service-switcher-plain.js` | 0.36.0: `gembossUI.initServiceSwitcher()`, the ServiceSwitcher drawn and driven for plain HTML (`@gemboss/ui/service-switcher-plain.js`). After `kit-plain.js` when the page has it; works alone. See "ServiceSwitcher". |
2499
+ | `packages/ui/src/team-chat-plain.js` | 20/10/2026: `gembossUI.teamChat`, builders that return the team chat's rows (messages, day separators, "New messages", jump, receipt, rail rows, people rows, reactions and the picker) with the components' markup, for plain HTML (`@gemboss/ui/team-chat-plain.js`). After `kit-plain.js` when the page has it; works alone. See "The team chat layer". |
1865
2500
  | `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`. |
1866
2501
  | `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. |
1867
2502
  | `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. |