npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@gemboss/ui

v0.35.2

Published

GemBoss design system for every surface in the fleet: the admin shell (topbar + rail + drawer) as React components, and the admin stylesheet exported as one CSS file.

Readme

@gemboss/ui

The GemBoss design system for every surface in the fleet: the admin's stylesheet as one CSS file, and the admin shell (topbar + 240px rail + mobile drawer) as React components. The GemBoss admin renders its own rail with these components, so what you get is what the admin is.

Use it

npm install @gemboss/ui
// The core, once, in the root layout, FIRST and in this order (0.23.0; "Load only what you use" below):
import "@gemboss/ui/core.css";
import "@gemboss/ui/components.css";
import "@gemboss/ui/shell.css";
import { AppShell, RailItem, RailGroup, RailHeading, BrandLockup } from "@gemboss/ui";

<AppShell
  brand={<BrandLockup name="GemWatcher" href="#/" />}
  center={<SearchBox />}
  rail={<>
    <RailHeading>Market</RailHeading>
    <RailGroup active={view === "pulse"}>
      <RailItem href="#/pulse" icon={<PulseIcon />} label="Pulse" active={view === "pulse"} />
    </RailGroup>
  </>}
  mainAs="div"   // your pages render their own <main>
>
  {page}
</AppShell>
  • The topbar brand is BrandLockup (the admin's: gem 20.8×26, name 13.3px/700, 40px box), never a bare GemMark. GemMark is the app TILE (sign-in screen, favicon); at 28px in a topbar it reads as a second app icon (this example taught that until 0.5.0, and gemwatcher shipped it).
  • Wrap each item in RailGroup: the selected pill only exists inside .gnav-group.
  • RailItem renders <a>; pass as={Link} (Next) or as="button". Every other prop is forwarded.
  • No router, no data. The package only emits the markup the CSS was written for.

Surfaces with no React (gemcare, gempress) use the CSS alone and write the same markup by hand: nav.icon-nav > .gnav-scroll > .gnav-group > a.gnav-item > span.gnav-ico + span.gnav-label. packages/ui/src/shell.tsx is the reference.

Load only what you use: the core and the entries (0.23.0)

Every surface used to load gemboss-ui.css whole and use 14–21% of it (measured 06/10/2026 on twelve surfaces). Since 0.23.0 the CSS comes in parts (packages/ui/scripts/parts.mjs is the list): a CORE every surface loads once, and ENTRIES that come with the component that draws them. A React component imports its own entry (auth.tsx imports auth.css), so a Next or Vite surface downloads an entry only on the routes that render it, and a component it never imports takes its CSS out of the build with it (sideEffects). gzip of the minified file, as published:

| Part | Import (Node / bundler) | File in the tarball | What | gzip | Who loads it | |---|---|---|---|---:|---| | core | @gemboss/ui/core.css | parts/core.css | the box-sizing reset + the tokens | 1.0 KB | every surface, first | | components | @gemboss/ui/components.css | parts/components.css | the kit (ui.css), the field look, the plain-HTML primitives (kit-plain.css: menu, chips, report pieces, popup body) | 8.2 KB | every surface, second | | shell | @gemboss/ui/shell.css | parts/shell.css | rail, topbar, main, mobile drawer, the thread rail, topbar slots, base text, page widths (app-shell.css) | 5.5 KB | every surface, third | | 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 | | assistant | @gemboss/ui/assistant.css | packages/ui/src/assistant.css | the dock and the launcher | 2.2 KB | AssistantHost, the launcher import it | | 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 | | 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 | | collections | @gemboss/ui/collections.css | packages/ui/src/collections.css | tables, lists, grids, boards | 4.6 KB | IndexTable, ResourceList, Board… import it | | editor | @gemboss/ui/editor.css | packages/ui/src/editor.css | the editors | 7.9 KB | the @gemboss/ui/editor components import it | | reactions | @gemboss/ui/reactions.css | packages/ui/src/reactions.css | the emoji reactions and their picker | 0.8 KB | Reactions / ReactionPicker import it | | 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 | | 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 | | 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 | | minichart | @gemboss/ui/minichart.css | packages/ui/src/minichart.css | Sparkline, ShareBar (0.30.0) | 0.5 KB | those components import it | | setup-guide | @gemboss/ui/setup-guide.css | packages/ui/src/setup-guide.css | SetupGuide (0.30.0) | 0.9 KB | SetupGuide imports it | | 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) |

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).

Next.js / Vite. The core's three imports go at the top of the root layout, above any component import, in the order above. Entries need nothing: the components bring them. A page that writes chat markup itself imports @gemboss/ui/chat.css. Drop gemboss-ui.css IN THE SAME BUMP: kept beside the parts, the entries a component brings load a second time, after your own CSS, and a second copy of a rule you override wins again.

// app/layout.tsx
import "@gemboss/ui/core.css";
import "@gemboss/ui/components.css";
import "@gemboss/ui/shell.css";
import "./globals.css";            // your own CSS after the core

Add "@gemboss/ui" to experimental.optimizePackageImports in next.config (the admin does, 0.23.0): a SERVER component that imports from the index otherwise collects the CSS of every module the index re-exports (the admin's /login, which imports AuthHelp in its server page, picked up portal.css and collections.css under webpack until it did).

Which bundler drops what (measured 06/10/2026): Next 16 (Turbopack) leaves out the entry of a component a route does not render. content-radar on 0.23.0 with the core: /v2 38.5 → 32.3 KB gzip of CSS, /login 31.2 → 25.4 (auth.css only there, portal.css nowhere). esbuild keeps the CSS of every module the index reaches even when it drops their JS, so through @gemboss/ui it ships every entry the index re-exports; and esbuild with no CSS loader now stops at a component's import "./x.css": give it loader: { ".css": "empty" } (you link the CSS yourself, as GemFactory's gbui.mjs and this package's tests do) or "css".

Plain HTML (a server that serves the files out of node_modules): link the core, then only the entries the page draws.

<link rel="stylesheet" href="/ui/core.css">        <!-- require.resolve("@gemboss/ui/core.css") -->
<link rel="stylesheet" href="/ui/components.css">  <!-- require.resolve("@gemboss/ui/components.css") -->
<link rel="stylesheet" href="/ui/shell.css">       <!-- require.resolve("@gemboss/ui/shell.css") -->
<link rel="stylesheet" href="/ui/auth.css">        <!-- the sign-in page only -->
<link rel="stylesheet" href="/ui/collections.css"> <!-- list pages only -->

Order. The core loads FIRST, and its three parts in that order: the top lines of the root layout, or the first three <link>s. shell.css restyles the field look at phone width, and entries restyle the kit with BEM modifiers at the kit's own weight (.ui-empty--no-match after .ui-empty): both are written to follow what they modify, which keeps every rule at the weight your own CSS can override. After the core, the entries may land in any order: packages/ui/scripts/cascade-check.mjs loads the catalog with the entries (collections.css and editor.css included) after the core, in order and reversed, and holds every element's computed style to the bundle's at 1280 and 390 (parts-cascade.test.ts, CI; negative control: two entries that only agree by order). An entry loaded BEFORE the core is not supported: measured, .ui-empty--no-match loses its 32px padding to .ui-empty. Two kit-plain.css rules that overrode shell.css by coming after it (the menu row's flex, the thread rail's 44px buttons at phone width) moved to the end of app-shell.css, same weight, so they still follow it.

A new component gets its own entry: a file in src/, listed in ENTRIES in packages/ui/scripts/parts.mjs, exported, imported by the component that draws it, with a ceiling in weight-budget.json. It never goes into the core or into gemboss-ui.css: packages/ui/scripts/parts-gate.mjs (CI) holds both frozen, fails on a stylesheet nobody classified, and on a component that draws an entry's class without importing it.

The admin is unchanged: it has Polaris and its own order, so it imports the sources one by one (gemboss-tokens.css, ui.css, and shell-source.css from its gemboss-shell.css; @gemboss/ui/shell.css is the built part now). Its editor, assistant and sign-in modules already imported the same entry files the components now import, so nothing moves.

Page width (0.8.1)

Chris, 04/10/2026: the rail on the left, and on the right a content column with the same max width on every service, not edge to edge. The tiers are the admin's (gemboss-shell.css, .gemboss-w-* around a Polaris Page), same numbers; page-width.test.tsx fails if the admin and the package disagree.

| Tier | Class | Max width | For | Admin | |---|---|---|---|---| | default | ui-page | --ui-content-max (900px) | lists, tables, card grids, dashboards, detail pages | bare Page, .gemboss-w-gallery, .gemboss-w-dashboard | | feed | ui-page ui-page--feed | 720px | read top to bottom: activity, logs, timelines, a thread | .gemboss-w-feed | | form | ui-page ui-page--form | 662px | one column of fields: settings, a create form | .gemboss-w-form (Polaris narrowWidth) | | canvas | ui-page ui-page--canvas | 1440px | content measured to be wider than a column of text: a horizontal path, a wide comparison table | .gemboss-w-canvas (funnel path, 864px needed) | | full | ui-page ui-page--full | none | a board or an editor that IS the screen. Write the reason next to it. | the editor (fixed, its own frame) |

React: one PageContainer directly inside AppShell caps every screen. Pass the CURRENT route's tier; a page cannot widen a cap its parent set, so a screen that needs canvas or full gets it here, not from a second wrapper of its own.

<AppShell rail={rail}>
  <PageContainer width={route === "settings" ? "form" : route === "board" ? "full" : "default"}>{page}</PageContainer>
</AppShell>

as="section" changes the tag. pageWidthClass("form") returns the class string for a server template. PageContainer lives in its own plain module (packages/ui/src/page.tsx), not in shell.tsx, and AppShell has no width prop: the admin imports the shell pieces from shell.tsx, a client module its bundler keeps whole, and the admin's first-load shell budget had no room for even this much JS (240.0 / 240 KB, 04/10/2026).

Plain HTML (GemFactory, gemcare, gempress): one wrapper as the first child of main.

<main class="gemboss-platform-main">
  <div class="ui-page ui-page--form">…</div>
</main>

Delete the surface's own cap when it moves onto a tier (maxWidth: 1160 on each gemwatcher screen, a max-width on a .container): two caps on one column means the narrower one wins and nobody can tell which.

A cap only exists where it is SMALLER than the room it sits in. Right of the 240px rail, inside main's 24px padding, the room is viewport − 288: 992 at 1280, 1152 at 1440, 1632 at 1920. So 900 bites at all three, and 1440 (canvas) bites only from about 1730px; below that the column is just the room. Before you quote a width, check cap < viewport − rail − padding at the width people actually use (the admin shipped two caps, 1040 then 998, that were wider than the room at 1277 and did nothing there, 10/09/2026). The column is never wider than the room, so no tier makes a page scroll sideways; at ≤760px there is no rail and the column is viewport − 32.

The page must reset body { margin: 0 } itself: gemboss-ui.css does not (the admin gets it from Polaris).

The sign-in screen

import "@gemboss/ui/gemboss-ui.css";   // includes auth.css
import { AuthLayout, AuthForm, PasswordField, AuthError, AuthSubmit, AuthFoot } from "@gemboss/ui";

<AuthLayout brand="GemWatcher" tagline="GemBoss · internal tool"
            panel={{ eyebrow: "GemWatcher", heading: "What competitors changed.", body: "…" }}>
  <AuthForm onSubmit={submit}>
    <PasswordField id="pw" label="Password" value={pw} onChange={(e) => setPw(e.target.value)} />
    <AuthError>{error}</AuthError>
    <AuthSubmit disabled={busy}>{busy ? "Signing in…" : "Sign in"}</AuthSubmit>
  </AuthForm>
  <AuthFoot>Team access only.</AuthFoot>
</AuthLayout>

The admin's /login renders exactly this (plus its own Google button). The screen sheds the tagline, then the mark, then the foot lines as the visible height shrinks, follows the OS into dark, and tracks the on-screen keyboard. Re-theme it with tokens on .gb-auth, e.g. a light iris panel: --gb-panel-bg, --gb-panel-ink, --gb-panel-muted, --gb-panel-eyebrow, --gb-panel-facet-o.

The panel's ground: backdrop (0.30.0). "facet" (default, unchanged): the mark's facet lines, oversized, at 5%, over iris washes on ink. "grid": gemform's sign-in ground (Chris 08/10/2026: "cái background bên dưới logo cũng đẹp"), a 34px grid of iris hairlines at 10% that fades out from the panel's top-left corner, on an ink gradient. "none": the ground alone. Re-tune the grid with --gb-panel-grid-bg, --gb-panel-grid-line, --gb-panel-grid-size, --gb-panel-grid-fade on .gb-auth. Like the panel itself it is drawn only at 900 wide AND 520 tall, and it is dark in both themes. A page that writes the markup puts gb-auth-panel--grid (or --none) beside gb-auth-panel. The grid brings its OWN ground (--gb-panel-grid-bg, an ink gradient), not --gb-panel-bg: a surface that re-themed the panel light (with dark --gb-panel-ink) sets --gb-panel-grid-bg too, or its words land dark on ink. The grid's rules live in auth.css (+0.2 KB gzip on the sign-in entry and the bundle): an entry of its own would need a second component to import it.

<AuthLayout brand="gemform" tagline="GemBoss Ops · internal tool" backdrop="grid"
            panel={{ eyebrow: "gemform · GemBoss Ops", heading: "Every Gem registration, in one place." }}>…</AuthLayout>

The assistant frame

import "@gemboss/ui/gemboss-ui.css";            // includes assistant.css
import { AssistantProvider, AssistantHost, useAssistant } from "@gemboss/ui/assistant";

// once, around the whole app
<AssistantProvider options={{ storagePrefix: "gemwatcher.assistant",
  isFullLink: (u) => u.hash === "#/assistant", isOnFullPage: () => location.hash === "#/assistant" }}>
  <AppShell …>{screens}</AppShell>
  {/* once, outside every route, so switching screens never unmounts a running answer */}
  <AssistantHost title="Trợ lý" labels={VI} onExpand={openFull} onShrink={backToPopup} onNewChat={newChat}>
    <YourChat onBusyChange={useAssistant().setBusy} />
  </AssistantHost>
</AssistantProvider>

Five shapes, one mounted subtree: full screen (next to the rail), a popup that floats over every screen and survives navigation, a full-height side panel, a status bar, and hidden-but-still-running. A live run is never thrown off screen (assistant/dock.ts holds that rule). The popup resizes from its top edge and slides along the bottom, by pointer or keyboard, and remembers both under storagePrefix.

The side panel is the DEFAULT shape since 0.8.0 (Chris, 04/10/2026): a browser that never chose opens the assistant there, and allowSide defaults to true — a surface whose layout cannot make room passes allowSide={false} and gets the popup. The popup header has "Pin to the side", and the panel carries "Float as a popup" back; the choice is kept under <prefix>.shape (the older floatShape key recorded the old default for everyone, so it is not read). The rail link reopens in that shape unless the person last worked full screen. A popup opens as tall as the window allows, 12px under the topbar (it was min(480, viewport − 176)); a height the person dragged to is kept, and dragging it back to the top returns to "as tall as the window allows".

Upgrading to 0.8.0: a host that never passed allowSide now offers the panel. Its page must make room — render inside AppShell (.gemboss-platform-main takes the margin) or pad by var(--gb-assistant-side-w) while <prefix>-side is on <body> — or pass allowSide={false}. A full-screen "shrink" handler should call reopen() (back to the working shape), not openDock(), which records "popup" as the person's choice. It stands under the topbar on the right, and the page moves over for it instead of being covered: the provider puts <prefix>-side on <body> and the width in --gb-assistant-side-w (SIDE_WIDTH_VAR), and .gemboss-platform-main takes that as margin-right. A surface with its own layout reads the same variable. Its left edge is a .ui-resizer (drag, or ←/→, Shift for 64px, Home/End), clamped to 380–720px and never leaving the page less than 480px; the width is remembered. Panel and page only fit side by side from 1100px (rail 240

  • 380 + 480). Between 761 and 1099 the panel covers the content area and is not offered — the host withdraws allowSide there (SIDE_FITS_QUERY), so even the default opens the popup; on a phone it takes the screen. The status bar, a busy ×, leaving full screen mid-run and full screen's own way back ("Back to the side panel") all return to whichever shape the person last worked in (floatShape, remembered across reloads under <prefix>.shape), so a pinned panel never pops back as a popup. The host registers allowSide with the provider, and every way into the panel checks it, including a "side" remembered in storage: a host without it gets the popup.

The rail's way in is a RailItem with className="gnav-assistant", linking to your full-screen address (the admin puts it in RailBottom, above Inbox). That class draws the iris-tinted pill (0.6.8; it was a mix of --gemboss-primary, grey once primary went near-black, so it read as a second selected row), and the working / answer-waiting dot the provider switches on through <body> classes. Leave bodyClassPrefix at its default so those rules apply. If the popup is what the person last used, a plain click on that link opens the popup instead of navigating.

The frame does not bring the chat itself (children) or any data. The list of past conversations is a separate piece you put inside it:

import { AssistantWorkspaceLayout, SessionRail, OpenChatTabs } from "@gemboss/ui/assistant";

<AssistantWorkspaceLayout dock={presentation === "dock" || presentation === "side"} rail={
  <SessionRail sessions={sessions} activeId={active} busyIds={running}
    collapsed={collapsed} onToggleCollapsed={toggle} onNewChat={newChat}
    onOpen={open} onRename={rename} onTogglePin={pin} onDelete={remove} labels={VI} />
}>
  {openChats.length > 1 && presentation === "dock" ? <OpenChatTabs chats={openChats} … /> : null}
  {chat}
</AssistantWorkspaceLayout>

SessionRail owns the looking: search (diacritic-insensitive), Pinned / Today / Yesterday / 7 days / 30 days / Older, inline rename, the row menu and its two-step delete. Your callbacks own the doing. Its styles are the gemboss-assistant-* rules in packages/ui/src/shell.css (the shared shell, which the admin @imports too), and they reach you through gemboss-ui.css together with gemboss-field.css. The admin passes a second tab ("Talk to a person", gemcare) through secondTab and its button through fullActions; a surface without those leaves them out.

The header's ⋯ menu (0.31.0). The header shows four controls: + (onNewChat), full screen, ⋯ and ×. ⋯ holds what is used once in a while: your rows (menuItems, as AssistantMenuItems), "Clear history" when you pass onClearHistory (greyed out with canClearHistory={false}; confirm inside your handler), then pin / float and minimise. Full screen's chrome shows ⋯ too when you pass either. Do not draw a Clear button of your own in the body.

<AssistantHost title="Radar Assistant" onNewChat={reset} onExpand={expand} onShrink={shrink}
  onClearHistory={clearHistory} canClearHistory={messages.length > 0}
  menuItems={<AssistantMenuItem onClick={exportChat}>Export chat</AssistantMenuItem>}>

The engine picker (0.25.0): EnginePicker, BridgeNotice

Which engine answers the assistant, and the bridge on the user's machine, without spending the chat's space on it (Chris 07/10/2026: content-radar's assistant spent close to half its frame on an ENGINE / MODEL / EFFORT row, an "outdated bridge" block and the install command). The admin's shape: a chip in the composer row opens a popup.

import { EnginePicker, BridgeNotice, useEnginePicker } from "@gemboss/ui/assistant"; // brings assistant-engine.css

const picker = useEnginePicker();          // open + tab, shared with the notice
<EnginePicker {...picker.props}
  engines={[{ id: "claude", label: "Claude Code", note: "Your Claude subscription" }, { id: "server", label: "Server" }]}
  engine={engine} onEngineChange={setEngine}
  models={[{ id: "", label: "Default" }, { id: "opus", label: "Opus" }]} model={model} onModelChange={setModel}
  efforts={[{ id: "", label: "Default" }, { id: "high", label: "High" }]} effort={effort} onEffortChange={setEffort}
  bridge={{ state: "outdated", version: "8", latest: "10", autostart: true, detail: "Codex cannot see the radar tools." }}
  setup={{ command: installCommand, pairingCode, note: "The panel reconnects in a few seconds." }}
  statusActions={<button className="ui-btn" onClick={recheck}>Check again</button>} />

// Only when a turn FAILED because of the bridge, one line in the stream:
<BridgeNotice onOpenSetup={() => picker.openTab("setup")}>The bridge on your machine is not responding.</BridgeNotice>
  • Chip: engine name, the model when it is not the default (id: ""), a dot for bridge.state: green running, amber outdated, red offline / unpaired, hollow checking. Its accessible name says the state in words.
  • Popup: a non-modal dialog in the top layer (placed like the kit's menus, placeFloating), so the assistant's frame cannot cut it. Tabs: Engine always; Setup when setup is given; Status when bridge is given. ←/→/Home/End move between tabs; ↑/↓ move between engines (one Tab stop). Escape with focus in the panel, or a model / effort pick from the buttons, closes it and gives focus back to the chip; an outside click closes. An engine pick and a pick from a select (more than six models, or grouped ones) keep it open. Closing goes back to the Engine tab. 44px targets under a coarse pointer and at phone width.
  • Not in the chat: no standing warning, no command in the conversation. The amber dot on the chip and on the Setup tab is the warning. BridgeNotice is for a turn that actually failed, once.
  • For the admin (0.29.0): EngineChoice.mark ("claude", "codex") is shown on the chip in place of the name when the composer is too narrow (ChatComposer density icon); EngineChoice.models on every engine + onPick(engine, model) lists each engine with its own models at once, and one click picks both and closes (the admin's Polaris popup); problem is a red line on the Engine tab ("Wait for the current answer to finish"); disabled disables the chip while a switch is in flight.
  • The picker itself knows no bridge: the surface hands in its state, the command and the callbacks. Since 0.32.0 the surface usually gets all three from useBridge + bridgePickerProps (next section). EnginePanel is the same tabs without the chip (catalog, a settings page).
  • Closing from inside (0.32.0): a button in the panel that acts and then shuts it (Stop bridge, Remove, before a confirm opens) calls picker.close(): it closes and puts focus on the chip at once, so the confirm records the chip as where to return. picker.props.onOpenChange(false) (an outside click) closes without moving focus; the button that had it is gone with the panel, so focus would fall to <body>. picker.focusChip() / picker.chipRef for the rest; EnginePicker chipRef takes your own ref.

Tests: packages/ui/src/assistant/engine.test.tsx (markup, styles, its own entry), packages/ui/src/assistant/engine-browser.test.tsx (Chromium: top layer through a clipping frame, keys, focus, copy, Open setup, 44px; with negative controls).

Escape (0.27.0). An Escape that closes the panel is marked handled (preventDefault) also when focus is outside it (the composer): a surface whose own Escape hides the assistant and checks defaultPrevented leaves that key alone, and the next Escape is its own again. Before, the key went on unmarked and React re-renders between the picker's listener and the surface's, so each surface needed a window-capture marker to tell the two apart (gemlabs #922, gemfeedback #66). With 0.27.0 the surface's !e.defaultPrevented is enough.

Plain HTML: gembossUI.enginePicker() (0.27.0)

engine-plain.js, after kit-plain.js, for a page with no React (gemcare). The same markup and classes as the component (asserted: same class set for the same options), so assistant-engine.css draws it, and the same behaviour (top layer, tabs and keys, focus back to the chip, closing goes back to Engine, Copy, 44px under a coarse pointer, Escape marked):

<link rel="stylesheet" href="/ui/assistant-engine.css">   <!-- require.resolve("@gemboss/ui/assistant-engine.css") -->
<script src="/ui/kit-plain.js"></script><script src="/ui/engine-plain.js"></script>
const picker = gembossUI.enginePicker(document.querySelector(".gemboss-ec-toolbar-lead"), {
  engines: [{ id: "server", label: "GemCare AI, paid" },
            { id: "claude", label: "Claude Code", badge: "your plan", badgeTone: "success", note: "Your own subscription." }],
  engine: "claude", onEngineChange: (id) => …,
  models, model, onModelChange, efforts, effort, onEffortChange, effortDisabled, notes, modelControl,
  bridge: { state: "outdated", version: "8", latest: "10", autostart: true, detail: "…" }, showBridgeDot: true,
  setup: { command, pairingCode, steps, note }, statusActions: [checkAgainButton],
  onOpenChange, onTabChange, locked, end, labels,
});
picker.update({ bridge: { state: "running" } });   // a poll: bridge / setup / labels merge one level; patched in place
picker.openTab("setup"); picker.close(); picker.isOpen(); picker.destroy();
log.append(gembossUI.bridgeNotice({ text: "The bridge on your machine is not responding.", onOpenSetup: () => picker.openTab("setup") }));

Strings are text; steps, notes, detail, statusActions and modelControl may also be a Node, an array of Nodes or a DocumentFragment: placed once and left where they are, so a field in them keeps focus, caret and events across redraws. A redraw PATCHES the panel in place (the same elements, only what changed, as React does): focus on a tab, an engine row or the model select stays, and a clamped panel keeps its scroll; behaviour is delegated from the panel (data-*). A pick from the engine list, the model or the effort updates the picker at once and calls back (no callback: that row is disabled, as in the component); update() is how the page says anything else changed. An Escape it does not own is left alone: an IME composition, or one in a modal opened from the panel; and focus entering such a modal closes the panel, which would otherwise sit above it in the top layer (the component does the same since 0.27.0). Tests: packages/ui/src/engine-plain.test.ts (Chromium: the component's class set on all three tabs, top layer through a clipping frame with its negative control, no popover API, keys, Escape, Copy, redraws that keep focus / scroll / a caller's field without a blur or change, merge rules, 44px).

The bridge (0.32.0): useBridge, createBridgeClient, bridgePickerProps

The bridge is the small server a teammate installs on their machine so the assistant can run Claude Code / Codex on their own subscription; the page talks to it at http://127.0.0.1:<port>, and the surface's server never sees that call. content-radar, partner-radar and search-radar carried the same ~300 lines for it each (find it behind Chrome's Local Network Access permission, pair, a data token, a turn and its stream, polls, "it went away", the Setup / Status tabs), and a review fixed one copy at a time. Once, here:

import { bridgePickerProps, bridgeControlNote, createBridgeClient, postTurn, readTurnEvents, useBridge } from "@gemboss/ui/assistant";

// Module level (one per page): its data token is cached on it.
const bridgeClient = createBridgeClient({ port: 8789, codeKey: "pr-bridge-code", app: "partner-radar" });

const picker = useEnginePicker();
const b = useBridge(bridgeClient, { open, wantsLocal: engine !== "api", pickerOpen: picker.open,
  // An engine that cannot run here (not on the server, and not "your machine only": the bridge may come) → the API one.
  onDetected: (eng) => setEngine((cur) => (eng[cur] && !eng[cur].installed && !eng[cur].localOnly && eng.api?.installed ? "api" : cur)) });
// b.bridge (absent | unpaired | paired | needs-permission | asking | blocked, + lost / off / wrong), b.status (the
// server's engines), b.engines (merged: a paired bridge's engines replace "runs on your machine only"), b.pairCode.

async function send(text) {
  const viaBridge = engine !== "api" && b.paired;
  const body = { message: text, engine, sessionId, page };
  const id = turns.start();
  try {
    const res = viaBridge ? await bridgeClient.turn(body, { signal, version: b.version }) : await postTurn("/api/assistant", body, { signal });
    await readTurnEvents(res, (ev) => { if (ev.type === "text") turns.write(id, ev.delta); /* … */ }, { atBridge: viaBridge });
    turns.finish(id);
  } catch (e) {
    if (turns.fail(id, e).bridge) b.lost();       // gone / 401 → BridgeNotice + probe again; anything else is the turn's
  }
}

async function onControl(kind) {                  // Stop / Remove on Status
  picker.close();                                  // closes, focus on the chip (the confirm returns there)
  if (kind === "remove" && !(await confirm(…))) return;
  try { turns.note(bridgeControlNote(kind, await b.control(kind)), { bridge: true }); }
  catch (e) { turns.note(`Couldn't ${kind} the bridge: ${e.message}`); }
}

<EnginePicker {...picker.props} engines={…} engine={engine} onEngineChange={setEngine}
  {...bridgePickerProps(b, { latest: BRIDGE_LATEST, busy, controls: engine !== "api", onControl,
    installNote: "… A bridge installed for content-radar does not count: this one uses another port." })}
  showBridgeDot={engine !== "api"} />
  • createBridgeClient (no React): probe(code) (401 = another code → unpaired; any failure → absent; never throws), detect({ fromClick }) (reads the permission first: denied → blocked; not asked → needs-permission and no call at all; from a click it waits 60 s, the dialog's time), ensureCode() (8 characters, no 0/O/1/I, kept in codeKey), installCommand(code), dataToken() (POST tokenPath, reused until under an hour is left; its error is the turn's, never the bridge's), turn(body, { signal, version }) (adds the code and the token; tokenSince keeps the header off an older bridge whose CORS refuses it; throws marked atBridge, with the status), control("stop" | "remove").
  • readTurnEvents(res, onEvent, { atBridge }): the turn's data: {…} events, whole across chunks, : ping skipped. A read that throws is the bridge's only for the bridge's stream; the user's Stop stays a Stop. A stream that ends WITHOUT done throws (BRIDGE_CLOSED_EARLY, the bridge's; SERVER_CLOSED_EARLY, the turn's): half an answer is not a finished one. Any early exit cancels the reader, so the bridge sees the client go. postTurn(url, body) is the same POST to the surface's own route (errors unmarked, with status).
  • useBridge: probes only while open, the server's engines and the bridge together (statusUrl, default /api/assistant); every 3 s while wantsLocal and nothing is found (a teammate is installing); again on focus, on return to the tab and on a permission change, also once paired (a bridge that stops is noticed); never over a state the user is in the middle of (asking, a wrong code), and never over a user action that happened while it was out (an epoch: a /status that left before Stop does not bring "running" back); one background probe at a time. lost() after a failed turn, pair(code), allowAccess() from the Allow click, control(kind) (no answer → lost; 401 → Not connected with the Pair form; another HTTP error leaves the state), recheck(). On the dev machine (local, default: the page is on localhost) it probes nothing; b.local switches after mount, so the server's markup and the first render agree. The client is the first render's for the hook's life (make it at module level: its data token is cached on it).
  • bridgePickerProps(b, { latest, … }) → bridge, setup, statusActions for EnginePicker: the dot and Status rows, Setup per case (Allow / unblock / the Pair form + command / install / update), Check again, Stop and Remove (controls). The words are BRIDGE_SETUP_EN (labels overrides); outdatedDetail and installNote are the surface's.
  • What stays the surface's: which engines exist and what they cost, the request body, what each event does to its messages, its confirm dialog.

Tests: bridge.test.tsx (the client, the stream and the picker's props, with fetch and the permission faked), bridge-browser.test.tsx (Chromium, a fake bridge on a test port answered by the router: open, poll, lost, pair, permission, Stop; each with its negative control).

The chat body (components since 0.29.0)

Chris 08/10/2026: "đồng bộ hết giao diện của AI assistant… mọi service có panel này đồng bộ style + code dùng gemboss ui". Since 0.29.0 the package DRAWS the chat, not only its CSS: the surface hands in messages and actions and keeps its own streaming, bridge and sessions. Each piece is the better of the two surfaces that had it (inventory 08/10/2026, Gems/_local/fleet/assistant-sync-2026-10/):

| Piece | Component | Taken from | Why that one | |---|---|---|---| | Composer | ChatComposer | admin (shape, density, one action slot) + partner-radar (a DIV, IME guard) | one bordered shell, send/stop the same 44px circle, grows to 84px, says less as it narrows (measured); a form broke the engine popup's own form; an IME's Enter must not send | | Markdown | ChatMarkdown | admin | tables, folded code, safe links, trusted pictures, no raw HTML; the radars fed an HTML string to dangerouslySetInnerHTML | | Steps | ChatSteps | admin, with partner-radar's words | rows print and stop (one heartbeat moves, not a spinner per row); a step says what it did in words + its target, a trace keeps the raw call folded | | Working | ChatPending | admin | ✳, a verb that changes, the seconds: the wait reads alive | | Starters | ChatStarters | partner-radar | they wrap (radius 12): a long question as a nowrap pill is cut in a 400px panel; a fill-in example | | Under an answer | ChatMeta | partner-radar | Copy · engine · seconds · cost | | Errors | ChatError, BridgeNotice | partner-radar | a fix the user can take (sentence, command, Copy, the original folded); a bridge failure is one line | | Engine | EnginePicker | partner-radar (Setup / Status tabs) + admin (mark, models per engine) | see "The engine picker" | | Frame, sessions | AssistantHost, SessionRail | (already the package's) | |

import { AssistantChat, ChatAttach, EnginePicker, useChatTurns, useEnginePicker } from "@gemboss/ui/assistant"; // brings its CSS

const turns = useChatTurns();                       // messages with ids; a turn is written BY ID
const picker = useEnginePicker();
async function send(text: string) {
  turns.say(text);
  const id = turns.start();
  try {
    for await (const ev of stream(text)) {
      if (ev.type === "text") turns.write(id, ev.text);
      if (ev.type === "tool") turns.step(id, { id: ev.id, label: TOOL_LABEL[ev.name], target: ev.target, state: ev.state });
    }
    turns.finish(id, { meta: ["Claude Code", "4.2s"] });
  } catch (e) {
    if (turns.fail(id, e).bridge) probeTheBridgeAgain();   // mark the bridge's own call with atBridge(e, status)
  }
}

<AssistantHost title="Partner Assistant" …>
  <AssistantWorkspaceLayout dock={presentation !== "full"}>
    <AssistantChat messages={turns.messages} busy={busy} onSend={send} onStop={stop}
      title="Partner Assistant"                       // shown at full screen only (the frame's header says it elsewhere)
      intro={<p className="gemboss-md-p">Describe a meeting…</p>}
      starters={[{ label: "Had dinner with Amit…", hint: "Example, click to fill in", fill: true }]}
      moreStarters={SUGGESTIONS} moreStartersTitle="Or ask about partners in the radar:"
      engine={<EnginePicker {...picker.props} … />} tools={<ChatAttach accept=".pdf,.docx" onFiles={upload} />}
      onOpenSetup={() => picker.openTab("setup")} renderExtra={(m) => m.proposals?.map(…)} onNavigate={router.push} />
  </AssistantWorkspaceLayout>
</AssistantHost>
  • messages (ChatMessageData): role user / assistant / note; text (markdown for an answer, markdown: false for plain); pending (the heartbeat until text, then the cursor); steps; error + fix: { text, command }, or bridge: true (one BridgeNotice line whose action calls onOpenSetup); warn; stopped; meta (after Copy); a note's tone warn / down + action makes it a BridgeNotice ("This bridge is outdated · Open setup").
  • Failed turns (bridgeTurnError, used by turns.fail): only a failure AT the bridge is the bridge's: its call threw (it is gone, the machine slept) or it answered 401 (this browser's pairing code is not its code). Its 400 / 503 and a failure on the way to the server (a data token, an expired session) are the turn's. The surface marks the bridge's own call (throw atBridge(err, res.status)); it never guesses from "this turn used the bridge" (content-radar #937).
  • Failed turns, timeouts: give the bridge's call its deadline with AbortSignal.timeout(ms) (a TimeoutError, the bridge's); an AbortController you abort yourself throws an AbortError, which reads as the user's Stop.
  • Following the end: the log follows while the reader is at the end, and stops at their first move up (wheel, finger, ArrowUp / PageUp / Home), not 24px later. A send, or another conversation shown, brings it back (ChatThread pinKey; AssistantChat does both). Since 0.32.0 so does a NEW user message from anywhere: a page's "Ask about this partner", the 📎 upload, a surface's own starter (no requestAnimationFrame(() => thread.scrollTop = …) in the surface any more). A note or an answer arriving while the reader is up leaves them where they are.
  • Links (0.32.0): linkPolicy={(href, label, target) => …} decides for EVERY link in an answer (written, bare, a picture from another host), asked with the href as written and target, what the kit would link (linkTarget, normalised) or null when it refuses it: true / undefined = the kit's link (in-app through onNavigate, https in a new tab, words when target is null); false / null = its words; any other node is drawn as given (a router link, a customer:12 button). Drawing your own link for an ordinary URL: build it from target.href, never the raw href. The radars' "this path is text" is linkPolicy={(h) => !keepAsText(h)}. ownLink + renderLink still work; linkPolicy wins when both are given. React only: chat-plain.js keeps the pair (it sits at its weight ceiling, and gemcare, its one user, draws only its own scheme).
  • Full screen: AssistantChat measures how far the frame's floated buttons (fullActions, Back) reach into it and gives its heading (or, without one, its log) that much room (useFullChromeRoom, for your own column); a fullActions button with no icon keeps its words on a phone. Measured in the child because only the child knows where its column is (at 1920 the 820px column does not reach the buttons at all).
  • Loading: the chat components import chat.css and chat-composer.css, which the old gemboss-ui.css bundle also carries. A surface still linking the bundle gets them twice, the second copy after its own CSS: move to the parts first (see "Load only what you use").
  • Composer density: full "Claude Code · Opus" → compact "Claude Code" → icon the engine's MARK (EngineChoice.mark) → tight. An engine with no mark keeps its (cut) name.
  • Markdown (0.29.2): a bare https://… in the prose is a link (GitHub's reading: the sentence's trailing . , : ; ! ? _ ~ ' " and an unpartnered ) / ] stay text; <https://…> too), through the same linkTarget as [x](…), so only http(s) with a host. It ends at a space, *, CJK text or full-width punctuation ("访问https://x.test。然后" links only the URL), and one glued to a word (foohttps://…) is not a link. Inside **bold** a URL stays plain text. #### to ###### are headings at the lowest step (h5.gemboss-md-h3); a heading line with no words is dropped.
  • Pictures on the page's own origin (0.29.2): imageHosts={[{ path: "/api/img/" }]} (no host) draws ![cover](/api/img/r2/a.png) as written, a root-relative path checked after the browser's normalisation: ../, %2e%2e, \ that leave path, an encoded / or \, a double encoding (%25), a ; (..;/), //host and /\host are refused (the alt stays as text). path must be a directory below the root (/x/, not / nor /x), or the entry is ignored. No need to rewrite the answer into absolute URLs first.
  • A form in the engine popup (0.29.2): the send circle is .gemboss-ec-toolbar > button[type=submit], the toolbar's own child. Before, .gemboss-ec-input button[type=submit] also painted a submit button in the popup (it lives in the leading slot in the DOM): a surface's Pair form can keep type="submit".
  • A surface that needs its own arrangement uses the pieces directly (ChatThread, ChatMessage, ChatBubble, ChatMarkdown, ChatSteps, ChatPending, ChatStarters, ChatMeta, ChatError, ChatNote, ChatComposer, ChatAttach) inside ChatLabelsProvider for its words. Every word is in CHAT_EN (English chrome, Chris 03/10/2026).

CSS: chat.css (the body; also inside gemboss-ui.css, and the admin @imports it), chat-composer.css (toolbar, density, Stop) and assistant-chat.css (0.29.0: the column, wrapping starters, the meta line, errors, notes, the paperclip, the room for full screen's buttons). The chat components import all three; a page that writes the markup itself links them.

| Piece | Classes | |---|---| | Column | .gb-chat › (.gemboss-ec-head.gb-chat-head) + .gemboss-ec-scroll[role=log] + .gemboss-ec-input | | Messages | .gemboss-ec-msg.user / .gemboss-ec-msg.assistant › .gemboss-ec-bubble | | Markdown inside a bubble | .gemboss-md-p / -h -h1..3 / -list / -quote / -rule / -code / -codeblock / -table(wrap) / -img(link), links .gemboss-ec-md-link | | Steps | ul.gemboss-narr › li.gemboss-narr-row.ok|.running|.failed|.thinking|.trace|.steer › .gemboss-narr-dot + .gemboss-narr-text (+ .gb-chat-target) | | Working / streaming | .gemboss-ec-pending (-star, -verb, -meta, .gb-chat-sr), .gemboss-ec-cursor after the streamed text | | Suggestions | .gemboss-ec-starters › button.gemboss-ec-starter.gb-chat-starter (› .gb-chat-starter-hint); a group title .gb-chat-label | | Under an answer, errors, notes | .gb-chat-meta; .ui-notice.ui-notice--danger.gb-chat-error › .gb-chat-cmd + details.gb-chat-original; .gb-chat-note; .gb-engine-notice | | Composer | div.gemboss-ec-input[data-density] › textarea + div.gemboss-ec-toolbar › div.gemboss-ec-toolbar-lead (chips) + div.gemboss-ec-toolbar-tools (button.gemboss-ec-attach-btn) + button[type=submit] (a DIRECT child: the send circle) or button.gemboss-ec-stop |

Tests: packages/ui/src/assistant/chat.test.tsx (markup, parser, every class styled), chat-browser.test.tsx (Chromium: Enter / IME, Stop in send's place, growing box, density with the mark, following the end, the turn store by id, failed turns, full screen's buttons, 44px; each with its negative control).

Plain HTML: gembossUI.chat (0.29.0)

chat-plain.js, after kit-plain.js (and engine-plain.js when the page has the picker), for a page with no React (gemcare's inbox and Reports assistants). The same markup as the components, element by element, and the same markdown (chat-plain.test.ts renders both from the same data and compares the trees). render() redraws from the data each time; a message whose look did not change keeps its element, and a changed one keeps the folds the reader opened and its heartbeat's clock:

<link rel="stylesheet" href="/ui/chat.css"><link rel="stylesheet" href="/ui/chat-composer.css">
<link rel="stylesheet" href="/ui/assistant-chat.css"><link rel="stylesheet" href="/ui/assistant-engine.css">
<script src="/ui/kit-plain.js"></script><script src="/ui/engine-plain.js"></script><script src="/ui/chat-plain.js"></script>
const log = gembossUI.chat.thread(document.getElementById("ai-thread"));          // role=log, follows the end
gembossUI.chat.render(log.el, messages, { intro, starters, onStarter, onOpenSetup: () => picker.openTab("setup"),
  ownLink: (h) => /^(customer|conversation):\d+$/.test(h), renderLink: (href, label) => aiLinkButton(href, label) });
const box = gembossUI.chat.composer(host, { placeholder: "Ask about this customer", maxLength: 1000, leading: pickerHost,
  thread: log, onSend: (text) => ask(text), onStop: () => controller.abort() });   // thread: back to the end on a send
box.setBusy(true); log.setBusy(true);
const { error, bridge } = gembossUI.chat.turnError(err);                         // the same rule as bridgeTurnError

Editor CSS (0.9.x)

The admin's three editors (theme, page, section) are drawn with editor.css, moved verbatim out of the admin's gemboss-shell.css on 04/10/2026: the same file the admin loads, so a surface that writes the same markup gets the same editor. It is a SEPARATE entry, not part of gemboss-ui.css: a surface with no editor does not download it.

import "@gemboss/ui/gemboss-ui.css";
import "@gemboss/ui/editor.css";   // only on the screens that show an editor
<link rel="stylesheet" href="…/gemboss-ui.css"><link rel="stylesheet" href="…/editor.css">

| Piece | Classes | |---|---| | Frame | .gemboss-editor (fixed, full screen; --embedded fills its container) › header.gemboss-editor-topbar + .gemboss-editor-body › .gemboss-ec (left column, width --gemboss-chat-w) + .gemboss-editor-resizer.ui-resizer + .gemboss-editor-preview-pane. --gemboss-editor-inset (5px) is the gap and the outer padding. Toast .gemboss-editor-toast, status line .gemboss-editor-statusbar, phone pane switch .gemboss-editor-mobile-tabs › .gemboss-editor-mobile-tab | | Top bar | .gemboss-editor-topbar (--theme, --guest) › .gemboss-editor-tb-left (.gemboss-editor-back, .gemboss-editor-tb-name) · .gemboss-editor-tb-center · .gemboss-editor-tb-right (.gemboss-editor-tb-tools, .gemboss-editor-tb-ghost, .gemboss-editor-tb-undo, .gemboss-editor-chat-toggle, .gemboss-editor-savestate). Publish split button .gemboss-editor-tb-publish / .gemboss-editor-publish (-main, -caret). Overflow .gemboss-editor-overflow (-trigger) › .gemboss-editor-menu › .gemboss-editor-menu-item (-label, -val, -check, -ext), .gemboss-editor-menu-sep | | Page picker | .gemboss-pagepicker › -trigger (-trigger-icon, -trigger-label, -caret) + -menu › -search (-search-icon, -search-input), -list › -item (-item-icon, -item-label, -check, -drill, -thumb), -back, -empty, -note | | 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 | | 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 | | 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 | | 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 | | 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) | | 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 | | 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 | | Try door bar | .gemboss-editor-topbar--guest › .gemboss-guest-brand (.gemboss-guest-mark), -note, -keep, -signin |

Not here, on purpose: the body modes that hide the admin's own topbar and rail while an editor is open (gemboss-editor-fullscreen, -just-exited, -layer-open) and admin-only extensions such as the product studio's resizer. The frame and its top bar are React components since 0.14.0 ("Editor frame" below), with their behaviour for plain HTML in editor-plain.js; the panel and its controls are components since 0.17.0 ("Settings panel" below, step 3); the layers, the element outline and the drawer since 0.22.0 ("Layers and drawer" below, step 4). Step 5 moves GemFactory's Builder onto them.

Changes since the verbatim move, in 0.10.1 (05/10/2026, step 1b, the only ones that change what the admin shows; CHANGELOG):

  • Tab row on the space scale: padding: var(--ui-space-2) var(--ui-space-3) on the row, var(--ui-space-2) on each tab (were 8px 10px and 7px; the temporary --gemboss-ec-tabs-pad-x / --gemboss-ec-tab-pad are gone). Under a mouse a tab is 2px taller; under touch the 44px floor decides.
  • No iris for selection: Layers' selected row and the page editor's "Editing:" chip use --gemboss-active-bg / --gemboss-active-fg / --gemboss-divider; the try door's Keep button is --gemboss-primary (hover --gemboss-primary-dark) like Publish. Iris (--gemboss-accent) stays for focus and links.
  • gemboss-flyout-in is defined (fade + 4px drop, as the page picker), so .gemboss-editor-menu animates; none under prefers-reduced-motion.

Editor frame (0.14.0): EditorFrame, EditorTopbar

The frame every editor is drawn in: the admin's theme, page and section editors render these components since 0.14.0 (measured on a production build before and after: the same DOM and the same computed style, see CHANGELOG), and GemFactory's Builder is next. FRAME ONLY: the chat, the Configure dock, the canvas, the page picker, the menus and Publish belong to the editor and arrive as slots; the package holds no data logic and no state.

import "@gemboss/ui/editor.css";
import { DeviceToggle, EditorBack, EditorChatToggle, EditorFrame, EditorPaneTabs, EditorSaveState, EditorTopbar } from "@gemboss/ui/editor";

<EditorFrame
  top={<EditorTopbar variant="theme" back={<EditorBack as={Link} href="/" />} title="Builder"
         chatToggle={<EditorChatToggle collapsed={collapsed} onToggle={toggle} />}
         left={<EditorSaveState state={saveState} />}
         center={<DeviceToggle device={device} onChange={setDevice} />}
         right={<>{tools}{moreMenu}{publish}</>}
         status={message ? { state: "error", message } : null} />}
  split={{ width, min: 300, max, collapsed, onPointerDown, onKeyDown }}
  left={<div className="gemboss-ec">…</div>}
  preview={<Canvas />}
  right={presetsOpen ? <Presets /> : null}
  mobilePane={pane}
>
  <EditorPaneTabs value={pane} onChange={setPane} />
  {drawersAndPopups}
</EditorFrame>

| Component | Draws | Notes | |---|---|---| | EditorFrame | div.gemboss-editor › top · .gemboss-editor-body (left · seam · .gemboss-editor-preview-pane) · children | split writes --gemboss-chat-w (0 while collapsed, plus .is-chat-collapsed) and draws the seam (role=separator, aria-valuenow/min/max, tabIndex 0); the drag and the keys are the caller's (onPointerDown, onKeyDown). right is the right column: .gemboss-editor-dock, docked over the preview's right edge, 320px (max 90%), first in the pane. mobilePane is data-mobile-pane on the body. embedded fills a container instead of the viewport. Refs and extra classes for body and pane. | | EditorPaneTabs | .gemboss-editor-mobile-tabs › two [role=tab] | The phone's Chat / Preview switch (shown at 760px and below). Put it first in the frame's children. labels renames them. | | 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. | | EditorBack | .gemboss-editor-back ← | A <button type="button">, or as={Link} / as="a" with href. Named "Back" for a screen reader (label). | | EditorChatToggle | .gemboss-editor-chat-toggle | Shows / hides the left column; filled while open, pressed when collapsed. | | 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. | | 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). |

One module per piece behind one entry, so a bundler gives each editor only what it draws (measured: one module for all of it cost the page and section editors about 1 KB of code they never ran). Not in @gemboss/ui itself: like editor.css, only a screen with an editor pays for it.

The geometry comes from editor.css. What the admin got from its own sheet (the left column sized by --gemboss-chat-w, stacked above the preview at 1000px and below, the preview taking the rest) is in editor.css as ZERO-specificity :where(…) rules since 0.14.0: anything more specific wins (the admin's own rules do, so its look did not move), and a page that has only the package still lays out.

Plain HTML: editor-plain.js

Write the markup the components render (copy it from catalog/editor.html, where it is rendered from the components and runs), load the behaviour AFTER kit-plain.js, and call gembossUI.initEditorFrame():

<link rel="stylesheet" href="…/@gemboss/ui/gemboss-ui.css"><link rel="stylesheet" href="…/@gemboss/ui/src/editor.css">
…
<script src="…/@gemboss/ui/src/kit-plain.js"></script>
<script src="…/@gemboss/ui/src/editor-plain.js"></script>
<script>
  gembossUI.initEditorFrame();                       // every .gemboss-editor, or initEditorFrame(root)
  addEventListener("gemboss:editor-device", (e) => setCanvas(e.detail.device));
</script>

| Wires | Markup | Event on the editor (bubbles) | |---|---|---| | The seam: pointer drag (main button of the primary pointer: mouse, touch, pen), ←/→ 16px (Shift 64), Home / End; clamped to the BOX, min(aria-valuemax, body width − 360 − 5), never below aria-valuemin; the CHOSEN width is kept apart, so a box that narrows clamps it and widening gives it back, and reopening the column clamps to the box as it is then; ui-resizing on <body> while dragging; remembered when the body has data-gb-storage="<key>" | .gemboss-editor-resizer (the frame's) | gemboss:editor-split { width } | | Hide / show the left column | .gemboss-editor-chat-toggle | gemboss:editor-chat { collapsed } | | Device | .gemboss-pv-devices .gemboss-pv-device (data-device, or the title) | gemboss:editor-device { device } | | Phone pane | .gemboss-editor-mobile-tab (data-pane, or Chat first / Preview second) | gemboss:editor-pane { pane } | | ⋯ / Publish ▾ menus | a trigger with aria-haspopup="menu", then a .gemboss-editor-menu[hidden] right after it: opens 6px under the trigger, right edges aligned, at least 8px inside the window; one at a time; a press outside, an item, the window losing focus (a press in the canvas iframe) and a resize close or re-place it; Escape closes the menu only (capture phase, like initMenus; a popup behind it stays open; not while an IME composes) | gemboss:editor-menu { open, menu } |

Without kit-plain.js it logs what is missing and defines nothing (kit-plain.js owns gembossUI). Its own file for the same reason as editor.css: a page with no editor does not load it.

Settings panel (0.17.0): PanelTabs, PanelHeader, InspectorGroup, Field and the controls

Step 3 of the editor kit. The pieces every editor's settings panel is made of: the admin's Configure column, its element inspector and its schema form render these since 0.17.0 (measured on a production build before and after: the same DOM and the same computed style, see CHANGELOG). Same entry as the frame (@gemboss/ui/editor), look from editor.css. Controls take a value and give one back; nothing in the package knows what is edited, what schema a value came from or where it is saved.

import "@gemboss/ui/editor.css";
import { ColorControl, EdgeBox, Field, ImageControl, InspectorGroup, PanelAction, PanelHeader, PanelTabs, RangeControl, Seg } from "@gemboss/ui/editor";

<PanelHeader title="Hero" subtitle="hero-banner" actions={<PanelAction label="Duplicate section" onClick={dup}>⧉</PanelAction>} />
<PanelTabs variant="inspector" value={tab} onChange={setTab} tabs={[{ id: "content", label: "Content" }, { id: "design", label: "Design" }]} />
<InspectorGroup title="Layout" note="all screens">
  <Field label="Width"><Seg label="Width" value={width} onChange={setWidth} options={[{ value: "full", label: "Full" }, { value: "contained", label: "Contained" }]} /></Field>
  <Field label="Background" htmlFor="bg"><ColorControl id="bg" label="Background" value={bg} onChange={setBg} /></Field>
  <Field label="Opacity" htmlFor="op" labelValue={`${op}%`}><RangeControl id="op" min={0} max={100} value={op} onChange={setOp} /></Field>
  <Field label="Padding"><EdgeBox label="Padding" values={pad} onChange={(side, raw) => setPad({ ...pad, [side]: raw })} onCommit={save} /></Field>
  <Field label="Image" htmlFor="img"><ImageControl id="img" value={url} onChange={setUrl} actions={<ChipButton onClick={openLibrary}>Library</ChipButton>} /></Field>
</InspectorGroup>

| Component | Draws | Behaviour | |---|---|---| | 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). | | 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. | | 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. | | 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. | | 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. | | 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). | | RangeControl | input[type=range].gemboss-ei-range | Every change, as a number (drag, arrows, Home / End). Show the value with Field's labelValue. | | EdgeBox | .gemboss-ei-edgebox › four .gemboss-ei-edge (box + T / R / B / L) | onChange(side, raw) per keystroke, onCommit() on blur; Enter blurs. Boxes named " top / right / bottom / left". narrow (default) adds gemboss-field--narrow; placeholders, ids per side. What an empty edge means is the caller's. | | ImageControl, ChipButton | .gemboss-ei-img › 52px thumbnail (— when empty) · URL box · .gemboss-ei-img-actions (actions, then Clear while there is a value) | Commits on blur / Enter only when the box changed; Clear commits "". The box's text can be the caller's (draft + onDraftChange) to put a picked URL in it at once. The thumbnail's url() is quoted (cssUrl, 0.20.1), so a URL with ")" or a space still shows. | | cssUrl | | url("…") for any URL: quoted, " and \ escaped, a control character as a CSS hex escape. Plain TypeScript (also @gemboss/ui/editor/css-url), so a server component can call it; gembossUI.cssUrl in editor-plain.js writes the same. Use it for your own image cells (the admin's library grid does). | | swatchState | | The one rule for what <input type="color"> can show. Also @gemboss/ui/editor/swatch (plain TypeScript, for node). |

Two modules behind the entry (panel, controls), both "use client". Kept in the admin, on purpose: the schema (FieldSpec), value shapes and clamping (FieldRender