@gemboss/ui 0.35.7 → 0.39.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/CHANGELOG.md CHANGED
@@ -2,6 +2,296 @@
2
2
 
3
3
  Versions up to 0.9.0 are described in the README, each in the section that introduced it.
4
4
 
5
+ ## 0.39.0 (19/10/2026)
6
+
7
+ **A field of real pages for the sign-in panel, and an entrance for the screen.** Chris, on admin.gemboss.ai/login:
8
+ "animation ko quá lố nhưng thể hiện được những kỹ thuật, có chiều sâu như ở ngoài website". gemboss.ai opens on a
9
+ tilted field of real pages; the sign-in panel can now do the same, quieter.
10
+
11
+ Added
12
+ - **`AuthPageField`** (`auth-field.tsx`, its own entry `auth-field.css`, 1.1 KB gzipped): five columns of page thumbnails
13
+ the surface serves (portrait, 3:4) on a plane tipped back in perspective, drifting 100 to 130 seconds a lap, neighbours
14
+ the other way; a veil of the panel's ink keeps the words readable; a fine pointer leans the plane by at most 3 degrees.
15
+ Each column is dealt at least five pages so a lap never shows a gap. Images are lazy and live inside the panel, so a
16
+ screen without the panel (under 900 wide or 520 tall) never fetches them.
17
+ - **`AuthLayout` `art`**: anything drawn on the panel under its words. Pair it with `backdrop="none"`.
18
+ - **`className="gb-auth--alive"`** (with `AuthPageField` loaded): the mark, the name and the tagline, then each field,
19
+ then the panel's words rise 12px as they fade in, 0.7s each, all done by 1.1s; one light passes over the mark.
20
+
21
+ Nothing changes for a surface that does not opt in. Under `prefers-reduced-motion: reduce` the field is a still picture
22
+ and nothing rises.
23
+
24
+ **And "Continue with GemBoss" for the internal tools** (Chris, 16/10/2026: one sign-in for the fleet, "có cả continue
25
+ với gemboss hoặc nhập mật khẩu"; protocol in gemboss docs/fleet-sso.md).
26
+
27
+ Added
28
+ - **`AuthContinueWithGemBoss`** `{ service, next }`: put it first inside `AuthLayout`, above the form. An outline button
29
+ with the gem (the password form keeps the one dark button), then "or use the password". It links to GemBoss's door
30
+ for this tool; once the tool has set `gb_sso=1` (a browser that came in this way) the page continues by itself,
31
+ except over a reason the tool sent back (`?sso=not_member|invalid|off`, shown under the button) or `?password=1`.
32
+ - **`gemBossSignInUrl(service, next)`**, **`continuesByItself(cookie, search)`**: the same two rules, for a page that
33
+ writes the markup itself.
34
+ - **`auth-sso-plain.js`** (`@gemboss/ui/auth-sso-plain.js`, 1.1 KB gzipped, its own entry: kit-plain.js had no room
35
+ under its ceiling): `gembossUI.initContinueWithGemBoss()` for a sign-in page with no React. The page writes
36
+ `<a class="gb-auth-sso-btn" data-gb-sso="<tool>" data-next="/admin">` inside a `.gb-auth-sso`; the script points it at
37
+ GemBoss, fills in the gem, the label, the reason and "or use the password", and continues by itself under the same rules.
38
+ - auth.css: `.gb-auth-sso-btn`, `.gb-auth-sso-why`, `.gb-auth-sso .gb-auth-or` (auth.css 3,013 B gzip, under its ceiling).
39
+
40
+ **The save pill reads on the light bar.** `EditorSaveState`'s colours were the old dark topbar's: measured on the page editor's
41
+ bar (white) 19/10/2026, "✓ Draft saved" read 1.35:1 and "Saving…" 1:1, white on white. They are on the tokens now:
42
+ "Saving…" in `--gemboss-text-muted` on the bar (5.3:1), "✓ Draft saved" in `--gemboss-success-strong` on
43
+ `--gemboss-success-soft` (5.6:1), the spinner `--gemboss-divider` with a `currentColor` arc. The idle words ("Autosaves", the
44
+ page editor's "Draft saved · <when>") were the text at opacity .55, 3.31:1: they are `is-idle` now, the muted token (5.3:1), and
45
+ the inline `style` is gone from the markup. The theme and section editors' pills change with it.
46
+
47
+ **`EditorSaveState` can be unannounced.** `live` (default `true`, so every editor reads as before): `live={false}` draws the same
48
+ words and title without `role=status` / `aria-live`, for a surface that speaks through a live region of its own, in the page
49
+ from its first render and saying only what changed (the admin's page editor: "Saving…", "Saved just now", "Your last change
50
+ was not saved."). Two live regions for one pill announced it twice, and a region mounted with its first words is not read.
51
+
52
+ **The page and section editors' bar keeps its free space beside its centre.** Above 1000px `.gemboss-pageedit-top` is
53
+ `auto fit-content(100%) auto` with the default `justify-content` (it was `auto auto auto` with `space-between`, which the theme
54
+ editor keeps): the free space goes into the left and right tracks, half each, instead of between the three clusters. The
55
+ centre and the right sit exactly where they were (measured 19/10/2026 at 1024-1920 on the page editor's bar, the Studio's and
56
+ the section editor's). What changes is the left track: it has room past its content, which is where the admin's page editor
57
+ draws its save pill (in its own sheet, adding no width to any track). The page editor's left cluster declares its gap as
58
+ `--gemboss-tb-gap` (12px), so a child can hand it back; a bar with another gap sets the property.
59
+
60
+ **Toggles that are each on or off.** `ToggleBar` (from `@gemboss/ui` and `@gemboss/ui/toggle-bar`, 17/10/2026, Chris: the panel
61
+ was missing pickers; research `_local/gemboss/research/pickers-2026-10-16/pickers.md` §13 slice 8): a row of buttons drawn as
62
+ `.ui-seg`, any number pressed at once (`aria-pressed`), `role="toolbar"` with one Tab stop and ← → Home End along it. `keepFocus`
63
+ leaves the focus where it is on a press, so a text box keeps its selection. The admin's text toolbar (Bold, Italic, Underline,
64
+ Highlight, Accent color, Warning color, Link) draws through it. Not a switch: "pick one of these" stays `Segmented`.
65
+
66
+ Added
67
+ - `ToggleBar` (`toggle-bar.tsx`, a client module of its own: `segmented.tsx` stays free of hooks for the server components that
68
+ import `Segmented`).
69
+
70
+ **A segmented option can be a glyph with a name.** `Seg` options take `name` (17/10/2026): the button's accessible name and
71
+ its hover title, for a label that is an arrow (the admin's gradient direction: ↓ → ↘ ↗, named Down, Right, Down right, Up right).
72
+
73
+ **A choice of shapes is picture tiles.** `ChoiceTiles` (from `@gemboss/ui/editor`, 16/10/2026, Chris: the panel was missing
74
+ pickers; research `_local/gemboss/research/pickers-2026-10-16/pickers.md` §4, pattern P9): each option a tile with its picture
75
+ (`picture`, or a rectangle `shape` of w×h drawn to fit 26px) and its name, 4 to a row, the lit one `aria-pressed`; 56px tall,
76
+ past 44px under a finger. The admin draws an image's ratio with it (Original, 1:1, 4:5, 4:3, 3:2, 16:9, 9:16 + a Custom box).
77
+
78
+ Added
79
+ - `ChoiceTiles` (`editor/tiles.tsx`) and its own part `@gemboss/ui/editor-tiles.css` (parts.mjs, export, weight ceiling 1,000 B).
80
+ - `ChoiceTiles` sizes its tiles to fit (as many to a row as fit at 72px: 4 in a 350px panel, 3 on a phone, 2 wide ones for a choice of two) and a
81
+ name wraps to two lines instead of being cut ("Main image and thumbnails"): the admin's layout tiles (17/10/2026).
82
+
83
+ **A setting that is a list reads as rows.** `RepeaterList` + `useKeyedRows` (from `@gemboss/ui/editor`, 16/10/2026, Chris: the
84
+ panel was missing pickers; research `_local/gemboss/research/pickers-2026-10-16/pickers.md` §9). Each row: a grip to drag, its
85
+ name (folds it), ↑ ↓, duplicate, ✕; more than one row opens folded and a row added or duplicated opens; the count beside the
86
+ list's name; Alt+↑/↓ moves a row and keeps the focus. Rows are kept by key, so a row's typing follows it when it moves.
87
+
88
+ Added
89
+ - `RepeaterList` (`touch`: 44px buttons with a mouse too), `useKeyedRows` (`patch(key, fn)` writes a field by the row's key onto its
90
+ latest version; a refused write, `commit` answering false, puts the stored list back; the stored list is read by content),
91
+ `useRowDrag`, `dropIndex` (`editor/repeater.tsx`, `editor/sortable.tsx`).
92
+ - `@gemboss/ui/editor-repeater.css`: its own part, imported by `RepeaterList`.
93
+
94
+ Changed
95
+ - `SortableLayerList` drags through `useRowDrag`, the same rule as `RepeaterList` (behaviour unchanged; its browser tests pass).
96
+
97
+ Moved
98
+ - `.gemboss-ei-rep*` out of `editor.css` into `editor-repeater.css` (editor.css 8.4 → 8.2 KB gzip).
99
+
100
+ **The settings panel reads like the rest of the admin.** Chris 16/10/2026: the panel's controls looked bigger than the kit's
101
+ and Polaris's. Measured (a countdown's Settings tab, 1280, a mouse): every control was Polaris-sized already (field 32/13,
102
+ chips 26-28/12, buttons 28); the panel was long, 3,363px, because each setting was a label line over its control (70px a
103
+ setting) and 22% of the height was help lines. Research of GemPages, PageFly and Replo:
104
+ `_local/gemboss/research/inspector-density-2026-10-16.md`.
105
+
106
+ Changed, inside an inspector group (`InspectorGroup`) only; a plugin's settings form is unchanged:
107
+ - `Field` takes `row`: label left (2 parts), control right (3 parts), for a one-value setting. GemPages draws a setting in 52px
108
+ this way. The admin sets it on toggles, selects, chip sets of up to three, numbers (the unit beside the box), ranges and
109
+ colours. Value controls (chips + box + slider), longer chip sets, text, dates, spacing, images and lists keep the label
110
+ above: their parts need the panel's width (measured: "None 1 2 3 4" was cut in the control column). A countdown's
111
+ Settings tab went from 3,363px to 2,197px.
112
+ - `Field`'s `hint` with a label is behind a ? beside the label (`button.gemboss-ei-help`, named "About <label>", described by
113
+ the help line): a click, Enter or a tap shows it as the line under the control, again hides it. Outside a group the
114
+ ? is not drawn and the line shows, as before. The line is still one node (`p.gemboss-ei-hint.gemboss-ei-help-text`).
115
+ - Labels are quiet: 13px / 450, secondary (Polaris bodyMd).
116
+ - Group titles (`.gemboss-ei-gh`) are Polaris headingSm everywhere: 13px / 650, sentence case, the text colour. They were
117
+ 10-11px uppercase with letter spacing, the old vault look.
118
+
119
+ Removed
120
+ - `.gemboss-pageedit-modes`: no source in the fleet draws it.
121
+ - `.gemboss-ei-img-grid` / `.gemboss-ei-img-cell`: the image field's inline library grid. The admin's image field opens the
122
+ media picker since gemboss #4474 (16/10/2026) and nothing in the fleet draws the grid any more.
123
+
124
+ ## 0.38.0 (15/10/2026)
125
+
126
+ **A setting you can click as well as type.** `ValueControl` (from `@gemboss/ui/editor`): a number's named values as chips, then
127
+ its box with the unit and, for a bounded range, a slider. Chris 15/10/2026: "có option để vừa bấm được vừa nhập được, lười thì bấm".
128
+ Research of GemPages, PageFly, Replo and Shopify: Shopify's reference themes never show a bare number box for a style; GemPages and
129
+ PageFly both left border width one.
130
+
131
+ Added
132
+ - `ValueControl`: props `presets` (`{ value, label }`, smallest first), `text` (what the box holds), `suffix`, `slider`
133
+ (`{ min, max, step, value }`), `note`, and `onPreset` / `onText` / `onCommit` / `onSlide` / `onKeyDown`. The chips are the kit's
134
+ segmented control (`Seg`): the one whose value the box holds lights, a value of its own lights none. The caller owns the value.
135
+ - `.gemboss-ei-value`, `.gemboss-ei-value-row`, `.gemboss-ei-value-box`: chips on top, then the 72px box with its unit and the
136
+ slider filling the rest of the row.
137
+
138
+ ## 0.37.1 (16/10/2026)
139
+
140
+ **Three things the ServiceSwitcher rollout found on the 16 services.** A sliver of the search's first letter at 320
141
+ (GemMedia), the panel opening 6px over a header of the surface's own (GemNews's admin), and a React hydration warning
142
+ about `<html>` on the Next surfaces that import `kit-plain.js` (talent-radar, post-studio: 1 load in 3).
143
+
144
+ Fixed
145
+ - **The search under its floor is its icon.** In a bar with the switcher, under 360px, the search goes below its 96px
146
+ floor (0.36.0), and its words did not fit: measured in Chromium with the switcher, the assistant and the account, the box
147
+ is 56 / 58 / 78 wide at 300 / 320 / 340 and showed 0 / 2 / 22px of its words (a cut letter). Below 96 wide it is now the
148
+ search icon alone, centred (a container query on the centre slot, `gb-svc-search`, set only in that bar under 360).
149
+ Both topbar searches: `TopbarSearch`'s input covers the box, placeholder unpainted, name still its `aria-label`, the
150
+ icon stepping aside while it is focused or holds words; `SearchDialog trigger="field"`'s words stay the link's name,
151
+ visually hidden. At 359 and up, and in a bar without the switcher, every number is unchanged (30 of 36 measured rows
152
+ identical; the 6 that change are those three widths, both searches).
153
+ - **…and the box is a target again there.** `TopbarSearch`'s input had the 0-2px left of the box at 300 / 320, so a tap at
154
+ the box's centre landed on the icon and focused nothing (typing went nowhere). It now lands in the input.
155
+ - **The panel hangs under the bar that holds the button**, the closest `.gemboss-platform-topbar`, `header` or
156
+ `[role="banner"]` around it (never above the button's own bottom), else under the button. Only the kit's topbar was
157
+ looked for, so on GemNews's admin (its own 56px `<header>`, the plain script) the panel was placed from the 32px button
158
+ and opened at y=50, over the header's last 6px; now 62. The kit's topbar is unchanged: 62 at 1280. The GemBoss admin's
159
+ `/admin` console draws its own 56px `<header class="gemboss-admin-topbar">` too, so it moves the same way (50 → 62,
160
+ read off its CSS, not measured); the seller shell's bar is the kit's topbar, unchanged. The React component and
161
+ `service-switcher-plain.js` share the selector (`BAR`) and the rule, held equal by a test.
162
+ - **`kit-plain.js` writes nothing on `<html>` as it loads.** The `data-gb-menus="float"` flag (kit-plain.css: an open
163
+ list nobody has floated yet stays unpainted, 0.18.0) now goes on in the capture phase of the click that opens a menu,
164
+ which runs before the browser opens the details (Enter and Space on a summary are that click too), and on any opening
165
+ after; it is put back if something took it off. A client module that imports the script can run before React hydrates
166
+ `<html>`, and React then reported the attribute. Measured on a Next 16.2.6 / React 19.2.6 app (`next dev`) with the
167
+ script made to run before hydration (`next/script` `beforeInteractive`, the order an import lands in 1 load in 3 on
168
+ post-studio; imported from a client module alone it ran after hydration 30 of 30 loads there): 0.36.1 warned on 20 of 20 loads ("A tree hydrated but some attributes of the server rendered HTML
169
+ didn't match"), 0.37.1 on 0 of 20; a click on its menu then sets the flag and floats it, 20 of 20. Plain pages keep
170
+ what the flag is for: the first frame after the opening click paints the list hidden, never inside its frame (20 of 20
171
+ by mouse and by Enter; without the flag it is inside its frame 20 of 20). post-studio's `suppressHydrationWarning` on
172
+ `<html>` is not needed for this any more.
173
+
174
+ Added
175
+ - Two services in the catalogue (`GEM_SERVICES`, `ServiceId`, the plain-HTML copy), both live since 15/10/2026: **GemX**
176
+ (`gemx`, https://x.gemboss.ai, "Tests and personalization across every store.", Build the store, after GemForm, icon
177
+ `sliders`, tint amber) and **GemPixel** (`gempixel`, https://pixel.gemboss.ai, "Events every store sends, and whether they
178
+ add up.", Read the market, after GemMarket, icon `activity`, tint blue). The panel now has 18 tiles. Their own admins can
179
+ pass `switcher={{ current: "gemx" }}` / `"gempixel"` once they are on this version.
180
+
181
+ Changed
182
+ - A menu opened by code (`details.open = true`) before any menu has been clicked may show inside its frame for one frame
183
+ before it floats, as every menu did before 0.18.0. A menu served open is floated as the script loads, as before.
184
+ - Weight (gzip of the minified file): `service-switcher.css` 1815 → 2040, `service-switcher-plain.js` 4559 → 4583,
185
+ `kit-plain.js` 8704 → 8742. No ceiling raised.
186
+
187
+ Tests: `service-switcher-browser.test.tsx` (Chromium): both searches at 300 / 320 / 340 in a bar with the switcher (no
188
+ words painted, icon centred within 1px, the centre of the box its target, the name kept, nothing wider than the screen),
189
+ a tap at 320 types into `TopbarSearch`, and 359 / 390 / 320-without-the-switcher unchanged; negative control: 0.36.1's
190
+ sheet paints 2px of the words at 320 and the centre tap lands on the icon. `service-switcher-plain-browser.test.ts`: the
191
+ panel 6px under GemNews's header, plain and React on the same pixel; 62 on the kit's topbar, both; 6px under the button in
192
+ no bar, both; negative control: 0.36.1's selector opens it at the button's bottom + 6, over the header.
193
+ `service-switcher-plain-data.test.ts`: one `BAR` in both files. `collections-board.test.ts`: no `data-gb-menus` until a
194
+ menu opens, the first frame after a mouse / Enter / Space opening never inside the frame (negative control: a kit that
195
+ never sets the flag paints it there); a React development build hydrating the whole document after kit-plain.js ran logs
196
+ no hydration warning and its menu floats with the flag (negative control: the flag set at load, 0.36.1, warns).
197
+
198
+ Also in this release: 0.37.0 (#4425), merged and not published until now (npm has 0.36.1).
199
+
200
+ ## 0.37.0 (15/10/2026)
201
+
202
+ **A spacing box's side can be dragged.** `EdgeBox` takes `onScrub(side, dx, keys)` and `onScrubEnd(side, moved)`: with them,
203
+ each side's letter (T · R · B · L) is a drag row, as Figma's and Webflow's number labels are. The package reports how far the
204
+ pointer moved and which keys are held; the caller turns that into a value and saves once when the drag lets go.
205
+
206
+ Added
207
+ - `.gemboss-ei-edge-scrub`: the letter as a 24px row the box's width (WCAG 2.5.8), `cursor: ew-resize`, hover fill
208
+ `--gemboss-hover`. A mouse or a pen drags; a finger scrolls the panel (`touch-action: pan-y`) and types in the box, which is
209
+ 44px under a finger. A press that moves under 2px is a click (`moved: false`).
210
+ - Without `onScrub` the letter is the plain `<span>` it was: nothing changes for other callers.
211
+
212
+ ## 0.36.1 (15/10/2026)
213
+
214
+ **The package reads only tokens it declares.** It read eight `--gemboss-*` tokens it never declared, each with a
215
+ fallback, so nothing failed: the browser painted the fallback hex. That hex is light-theme only, and in one place it
216
+ was wrong in light too. gemcare's copy of gemboss-ui.css failed its token gate on six of them (15/10/2026).
217
+
218
+ Fixed
219
+ - Editor inspector repeater (`.gemboss-ei-rep-*`): the rows and the add button had a `#2a2a3a` border on the white
220
+ panel, and the move/remove buttons hovered to that same near-black behind `#1b1c1e` glyphs. Now `--gemboss-divider`
221
+ and `--gemboss-hover`, like the rest of the panel.
222
+ - Chat markdown in dark mode outside a bubble: table headers, code blocks, inline code and images kept `#f7f7f7`; quotes
223
+ and h3 kept `#616161`. They now read `--gemboss-primary-soft`, `--gemboss-divider` and `--gemboss-text-muted`, which
224
+ `theme-dark.css` redefines. In light the shift is a hair (header `#f7f7f7` → `#f2f3f5`, border `#e3e3e3` → `#e5e7ea`);
225
+ the code-language label goes `#8a8a8a` → `#686b71`.
226
+ - `.gemboss-ec-stop`: `--gemboss-danger` with the send button's `--gemboss-on-primary` ink. Light is unchanged (white
227
+ on `#d72c0d`, 4.9:1); dark is `#0d0d12` on `#ff6b5a` (6.9:1), where white would be 2.8:1.
228
+ - `.gemboss-page-draft-status button` border: `--gemboss-divider` (was `#ccc`), as kit buttons. Lifted board card
229
+ (`[data-gb-lifted]`): `--gemboss-shadow-raised`.
230
+
231
+ Unchanged on screen
232
+ - Focus rings read `var(--ui-focus, #5661f5)` (the undeclared `--gemboss-focus` in front of it is gone); the trace
233
+ `code` font is the literal mono stack; the assistant thread rail's selected row is the literal `rgba(0,0,0,0.07)`
234
+ the export already wrote for it (`--gemboss-active-bg` is lighter than the row's `--gemboss-hover`, so it would
235
+ read as less selected than a hovered row).
236
+
237
+ Tests: `tokens-declared.test.ts` (every `var(--gemboss-*)` in the package's CSS and JS names a token the package
238
+ declares; only the editor's runtime `--gemboss-pv-*` and `--gemboss-editor-topbar-h` are exempt). On 0.36.0 it fails
239
+ with 30 reads of eight tokens. `chat-plain.test.ts` (0.35.4) removed the bubble rules and expected a dark answer to fall
240
+ under 3:1; with theme tokens underneath it no longer does, so that control now holds it at 4.5:1 or more (6.54 at its
241
+ weakest). The seller's bubble, light and dark, still falls under 3:1 without its rules.
242
+
243
+ ## 0.36.0 (15/10/2026)
244
+
245
+ **ServiceSwitcher: the logo goes home, the button beside it opens every other Gem service.** Chris 15/10/2026: on an
246
+ internal service, a click on the logo goes home, and beside it a dropdown to the other Gem services, ordered and organised,
247
+ working on a phone, a full panel when needed: a grid of each service's logo with a line under it saying what it is for.
248
+ README "ServiceSwitcher".
249
+
250
+ Added
251
+ - `ServiceSwitcher` (client): a nine-dot button after the brand (`aria-haspopup="dialog"`, `aria-expanded`; 32px, 44 under a
252
+ finger or at phone width) opening a native `<dialog class="gb-svc">` through `useModalDialog`. Desktop: under the topbar
253
+ from the brand's left edge, `min(760px, 100vw - 24px)` wide, at most 80% of the height. ≤640px: the whole screen, two
254
+ columns, `env(safe-area-inset-*)`. A search box (name, purpose, group; Enter follows the first match), the groups in
255
+ order, tiles that are links to each home (same tab); the current service's tile is `aria-current="page"` with the accent
256
+ ring and still links home. Arrows by place on screen, Home / End, one tile in the Tab order; it opens on the current tile.
257
+ - `BrandLockup switcher={{ current, services?, labels?, allHref?, search? }}`: the logo link as before, the button after it.
258
+ Without the prop the lockup renders exactly as before.
259
+ - `GEM_SERVICES`, `SERVICE_GROUPS`, `SERVICE_TINTS`, `groupServices()` and the types `GemService`, `ServiceId`,
260
+ `ServiceGroup`, `ServiceGroupId`, `ServiceTint` (src/services.ts, plain data, also `@gemboss/ui/services`): sixteen
261
+ services in six groups, in the order a store is built and run: GemBoss; Build the store (GemFactory, GemMedia, GemForm);
262
+ Read the market (GemWatcher, GemMarket, GemCapture, Search Radar); Content and community (GemPress, Content Radar,
263
+ GemCommunity, GemCommunity News); Customers (GemCare, GemFeedback); Team and partners (Partner Radar, Taskdesk).
264
+ - `SERVICES_HEALTH_URL` (GemFactory's `/services`), the panel's default foot link.
265
+ - `service-switcher.css`: its own entry `./service-switcher.css`, loaded by the component; `gemboss-ui.css` and the core
266
+ unchanged. Nine tint hues as `--gb-svc-*` tokens on `:root`, lighter under `:root[data-theme="dark"]`, always mixed with
267
+ the theme's own surface and text.
268
+ - `Icon name="apps"`: nine dots, drawn as `more` draws its three (the set is 45 icons).
269
+ - Plain HTML: `service-switcher-plain.js` (`@gemboss/ui/service-switcher-plain.js`, its own entry, in `sideEffects`),
270
+ `gembossUI.initServiceSwitcher(options?)` and `gembossUI.GEM_SERVICES`. A placeholder
271
+ `<span data-gb-service-switcher="<service id>" hidden>` right after `.gb-brand` is replaced by the component's button and
272
+ panel, the same DOM (asserted node for node against the React component in Chromium), so `service-switcher.css` draws it
273
+ unchanged, with the same behaviour. Options are the component's props (`labels`, `allHref`, `search`, `services`,
274
+ `className`, `current`) plus `root`. Idempotent; a call in `<head>` draws the rest at `DOMContentLoaded`. Adds to
275
+ `gembossUI` (after `kit-plain.js`, which it never replaces; alone it defines it), closes an open kit menu when the panel
276
+ opens. The catalogue is a literal block held to `services.ts` + `icons.tsx` by `service-switcher-plain-data.test.ts`
277
+ (`UPDATE_PLAIN_CATALOGUE=1` writes it). README "ServiceSwitcher" › "Plain HTML". `kit-plain.js` is unchanged.
278
+ - Catalog section "Chuyển dịch vụ" (the panel open at 1280, in dark, at 390, and a live plain-HTML frame running
279
+ `service-switcher-plain.js`); the shell section's lockup carries the switcher.
280
+ - `packages/ui/weight-budget.json`: ceilings for `packages/ui/src/service-switcher.css` and
281
+ `packages/ui/src/service-switcher-plain.js` (new entries; no existing ceiling raised).
282
+ - Tests: `service-switcher.test.tsx` (catalogue, markup, BrandLockup with and without it, the CSS reaching every class),
283
+ `service-switcher-browser.test.tsx` (Chromium, in `test:ui-browser`), `css-on-demand.test.ts` rows for the switcher,
284
+ BrandLockup and services.ts; `service-switcher-plain-data.test.ts` (Node: the plain catalogue against the TypeScript,
285
+ with its negative control, and the packaging) and `service-switcher-plain-browser.test.ts` (Chromium, in
286
+ `test:ui-browser`: DOM parity with the component, behaviour, phone, beside kit-plain.js, its own contract).
287
+
288
+ Changed
289
+ - `BrandLockup`'s module imports the switcher, so it brings `service-switcher.css` (1.8 KB gzip) and the switcher's code
290
+ wherever a bundler keeps what is imported, also without the prop.
291
+ - The phone topbar with a switcher in it: under 360px the search's floor drops from 96 to 56px (the bar has no other
292
+ room: ☰, mark, button, search, assistant, account). A bar without the switcher is unchanged.
293
+
294
+
5
295
  ## 0.35.7 (15/10/2026)
6
296
 
7
297
  **The rail's scrollbar runs down the rail's edge.** The 12px side gutter was padding on `.icon-nav`, so the scroll box
package/README.md CHANGED
@@ -62,12 +62,15 @@ its CSS out of the build with it (`sideEffects`). gzip of the minified file, as
62
62
  | theme-dark | `@gemboss/ui/theme-dark.css` | `packages/ui/src/theme-dark.css` | the dark palette | 0.6 KB | `ThemeToggle` and `themeScript` (`theme-script.ts`, without portal.css) import it |
63
63
  | collections | `@gemboss/ui/collections.css` | `packages/ui/src/collections.css` | tables, lists, grids, boards | 4.6 KB | `IndexTable`, `ResourceList`, `Board`… import it |
64
64
  | editor | `@gemboss/ui/editor.css` | `packages/ui/src/editor.css` | the editors | 7.9 KB | the `@gemboss/ui/editor` components import it |
65
+ | editor-repeater | `@gemboss/ui/editor-repeater.css` | `packages/ui/src/editor-repeater.css` | `RepeaterList`: a setting that is a list, as rows | 0.9 KB | `RepeaterList` imports it |
66
+ | editor-tiles | `@gemboss/ui/editor-tiles.css` | `packages/ui/src/editor-tiles.css` | `ChoiceTiles`: a closed choice of shapes as picture tiles | 0.5 KB | `ChoiceTiles` imports it |
65
67
  | reactions | `@gemboss/ui/reactions.css` | `packages/ui/src/reactions.css` | the emoji reactions and their picker | 0.8 KB | `Reactions` / `ReactionPicker` import it |
66
68
  | assistant-engine | `@gemboss/ui/assistant-engine.css` | `packages/ui/src/assistant-engine.css` | the engine picker (chip + Engine/Setup/Status popup) and the bridge notice | 1.9 KB | `EnginePicker` / `EnginePanel` / `BridgeNotice` import it |
67
69
  | chat | `@gemboss/ui/chat.css` + `chat-composer.css` | `packages/ui/src/chat.css`, `packages/ui/src/chat-composer.css` | the chat body, the composer | 3.0 + 0.9 KB | the chat components (0.29.0); a page writing the markup links them |
68
70
  | assistant-chat | `@gemboss/ui/assistant-chat.css` | `packages/ui/src/assistant-chat.css` | the chat pieces the package draws (0.29.0): column, starters that wrap, meta, errors, notes, paperclip | 0.9 KB | `AssistantChat` and its pieces import it |
69
71
  | minichart | `@gemboss/ui/minichart.css` | `packages/ui/src/minichart.css` | `Sparkline`, `ShareBar` (0.30.0) | 0.5 KB | those components import it |
70
72
  | 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") |
71
74
  | 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) |
72
75
 
73
76
  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
  | 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` |
640
643
  | 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`) |
641
644
  | 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` |
642
- | 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` |
645
+ | 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` |
643
646
  | Try door bar | `.gemboss-editor-topbar--guest` › `.gemboss-guest-brand` (`.gemboss-guest-mark`), `-note`, `-keep`, `-signin` |
644
647
 
645
648
  Not here, on purpose: the body modes that hide the admin's own topbar and rail while an editor is open
@@ -696,7 +699,7 @@ import { DeviceToggle, EditorBack, EditorChatToggle, EditorFrame, EditorPaneTabs
696
699
  | `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. |
697
700
  | `EditorBack` | `.gemboss-editor-back` ← | A `<button type="button">`, or `as={Link}` / `as="a"` with `href`. Named "Back" for a screen reader (`label`). |
698
701
  | `EditorChatToggle` | `.gemboss-editor-chat-toggle` | Shows / hides the left column; filled while open, pressed when collapsed. |
699
- | `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. |
702
+ | `EditorSaveState` | `.gemboss-editor-savestate` (`.is-idle`, `.is-saving` with the spinner, `.is-saved`) | `idle` draws nothing, or `idleText` as `.is-idle` (muted, 5.3:1 on the bar; the section editor's "Autosaves", the page editor's "Draft saved · <when>"). `.is-saved` is hidden at 760px and below. Texts and titles are props. Saving and saved are a polite live region (`role=status`) unless `live={false}`: for a surface with its own live region, present from its first render, that says only what changed (the admin's page editor). |
700
703
  | `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). |
701
704
 
702
705
  One module per piece behind one entry, so a bundler gives each editor only what it draws (measured: one module for all
@@ -870,7 +873,11 @@ gembossUI.initPasswordFields(); // the sign-in's reveal button; resetPasswordFie
870
873
 
871
874
  In a bundled app (Next, Vite) load it once with a bare `import "@gemboss/ui/kit-plain.js";` in a client module: the
872
875
  package lists the file in `sideEffects`, so the bundler keeps it (before 0.5.0 it dropped it and `gembossUI` was
873
- undefined in production builds). Use `confirm` instead of `window.confirm()`: the browser's box has no GemBoss look, no critical tone, and its
876
+ undefined in production builds). Loading it writes nothing on `<html>` (0.37.1): the `data-gb-menus` flag kit-plain.css
877
+ reads (an open menu's list stays unpainted until the script floats it) goes on in the capture phase of the click that
878
+ opens a menu, before the browser opens it. Up to 0.36.1 the script set it as it loaded, and a client module can run
879
+ before React hydrates `<html>`: React reported an attribute the server did not render (talent-radar, post-studio: 1
880
+ load in 3). `suppressHydrationWarning` on `<html>` is not needed for it any more; drop it unless something else writes there. Use `confirm` instead of `window.confirm()`: the browser's box has no GemBoss look, no critical tone, and its
874
881
  buttons cannot be named. For `initShell`, put `data-label-open` / `data-label-close` on the ☰ button to have its
875
882
  name follow the state; for `initPasswordFields`, `data-label-show` / `data-label-hide` on the reveal button.
876
883
 
@@ -1203,6 +1210,166 @@ import { Icon, SettingsDialog } from "@gemboss/ui";
1203
1210
  `packages/ui/src/settings-dialog-browser.test.tsx` measures it in Chromium; its negative control runs without
1204
1211
  `settings-dialog.css` and finds no 280px rail. Catalog section "Hộp cài đặt".
1205
1212
 
1213
+ ## ServiceSwitcher (0.36.0): the logo goes home, the button beside it opens the other Gem services
1214
+
1215
+ Chris 15/10/2026: on an internal service, a click on the logo goes home (or reloads it), and beside it there should be a
1216
+ way to switch to the other Gem services, "ordered and organised logically", working on a phone, opening when needed as a
1217
+ full panel: a grid of each service's logo with a line under it saying what it is for.
1218
+
1219
+ ```tsx
1220
+ import { AppShell, BrandLockup, ServiceSwitcher } from "@gemboss/ui";
1221
+
1222
+ <AppShell brand={<BrandLockup name="GemFactory" href="/" switcher={{ current: "gemfactory" }} />} … />
1223
+
1224
+ // A surface with a logo of its own (the GemBoss admin draws an image): the button right after it.
1225
+ <><Logo /><ServiceSwitcher current="gemboss" /></>
1226
+ ```
1227
+
1228
+ **When.** On the topbar of an INTERNAL service, beside its logo. Not on a public surface (GemFeedback's board,
1229
+ GemCommunity, a storefront): the panel names internal tools and links to their admin pages.
1230
+
1231
+ | | |
1232
+ | --- | --- |
1233
+ | 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) |
1234
+ | The button | `button.gb-svc-trigger`, the nine-dot `apps` icon (new in the set), `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls` the panel. 32px under a mouse, 4px from the brand; 44 under a finger or at phone width; ≤460px, where the brand is its 44px mark alone, 40 wide and touching it |
1235
+ | The panel | `<dialog class="gb-svc">` through the kit's `useModalDialog` (as `Dialog`, `Sheet`, `SettingsDialog`): Escape (one press, also from the search box), ✕ (the kit popup's `ui-modal__close`), a press that starts on the backdrop; the page behind is inert and holds still; focus goes back to the button. It opens on the current service's tile (not on ✕, not on the search box, which would pull up a phone's keyboard). Each opening starts with every service shown |
1236
+ | Where | desktop: 6px under the bar that holds the button, from the brand's left edge (measured when it opens, kept inside the window), `min(760px, 100vw - 24px)` wide, at most 80% of the height, its body scrolling. The bar (0.37.1) is the closest `.gemboss-platform-topbar`, `header` or `[role="banner"]` around the button (62 at 1280 on the kit's 56px topbar; GemNews's own 56px `<header>` the same, where 0.36.1 measured from the 32px button and opened at 50, over the header's last 6px); with none, 6px under the button. Both implementations, one selector (`service-switcher-plain-data.test.ts`). ≤640px: the whole screen, a head row (title + ✕), two columns, padded by `env(safe-area-inset-*)` |
1237
+ | A tile | an `<a href>` to the service's home, same tab: a 40px mark (a kit `Icon` on a square tinted per service), the name (600), what it is for (muted, two lines at most). The current service's tile carries `aria-current="page"` and the accent ring, and still links to its own home. A followed tile closes the panel (a page back from the back/forward cache does not come back with it open); a new-tab click leaves it |
1238
+ | Search | name, purpose, id and group title; the groups keep their order; "No service matches “…”." when nothing does; Enter follows the first match ("med⏎" is GemMedia); ↓ goes to the first tile |
1239
+ | Keys | arrows move by place on screen (↓ from a tile is the one under it, also across groups), Home / End the first and the last, ↑ from the first row the search box. One tile is in the Tab order at a time |
1240
+ | Tints | `data-tint` on the tile, one of `SERVICE_TINTS` (iris, blue, teal, green, amber, orange, rose, violet, slate). A hue is never painted as it is: the mark is the hue mixed into the theme's surface (14%, 22% in dark), the glyph the hue mixed with the theme's text, so one value reads in light and in `data-theme="dark"` (lighter hues there). Measured in Chromium, both themes: every glyph ≥ 3:1 on its mark, names and purposes ≥ 4.5:1 |
1241
+ | CSS | `service-switcher.css`, its own entry, 1.8 KB gzip, imported by the component. `BrandLockup` imports the component, so a surface that renders `BrandLockup` gets the entry (and the switcher's code) even without the prop: 1.8 KB of CSS, and 5.8 KB gzip of JS for brand.tsx's module graph against 0.5 before (most of it the icon set and the popup hook, which a surface with a rail already has) |
1242
+ | The topbar | `.gemboss-topbar-start` holds ☰, the brand and the button. ≤460 the button is 40 wide and touches the mark, so a full bar (search at its 96px floor, the assistant, the account) still fits at 360. Under 360 nothing else can give: in a bar that holds the switcher, the search's floor drops from 96 to 56 (still a box to tap), rather than the account leaving the screen. 0.37.1: narrower than 96 (a container query on the centre slot, only in that bar under 360) the box is the search icon alone, centred: its words would show a few px (2 at 320). `TopbarSearch`: the input covers the box, its placeholder unpainted, its name its `aria-label`, the icon stepping aside while it is focused or holds words. `SearchDialog trigger="field"`: the words stay the link's name, visually hidden. Measured at 300 / 320 / 340 (56 / 58 / 78 wide); at 359 and up, and in a bar without the switcher, nothing changes |
1243
+
1244
+ The groups, in order, are how a store is built and then run (`SERVICE_GROUPS`, services.ts):
1245
+
1246
+ | Group | Services |
1247
+ | --- | --- |
1248
+ | GemBoss | GemBoss (the seller app and the ops console) |
1249
+ | Build the store | GemFactory, GemMedia, GemForm, GemX |
1250
+ | Read the market | GemWatcher, GemMarket, GemPixel, GemCapture, Search Radar |
1251
+ | Content and community | GemPress, Content Radar, GemCommunity, GemCommunity News |
1252
+ | Customers | GemCare, GemFeedback |
1253
+ | Team and partners | Partner Radar, Taskdesk |
1254
+
1255
+ **Adding a service** (or moving one): one entry in `GEM_SERVICES` (`packages/ui/src/services.ts`), in its group, at its
1256
+ place in that group's story: `{ id, name, url, purpose, group, icon, tint }`. Its id joins `ServiceId`; the purpose is one
1257
+ plain sentence of at most 60 characters; the icon is a name of the set (draw a missing one in icons.tsx, by the set's
1258
+ rules); the tint one of `SERVICE_TINTS`, not the same as the tile before it. `service-switcher.test.tsx` checks all of that,
1259
+ and the homes as listed. Then run `UPDATE_PLAIN_CATALOGUE=1 npx vitest run packages/ui/src/service-switcher-plain-data.test.ts`:
1260
+ it writes the plain script's copy of the list (below), and without it that test is red. Every surface gets it at its next
1261
+ bump. A server reads the list from `@gemboss/ui/services`.
1262
+
1263
+ `packages/ui/src/service-switcher-browser.test.tsx` measures it in Chromium (where it sits at 1280, Escape / ✕ / backdrop,
1264
+ search, keys, a phone at 390 and 320, the topbar at 320 / 360 / 390 / 461, the search under its floor at 300 / 320 / 340
1265
+ for both searches, the tints in both themes, kit-plain.js beside it); its negative controls run without
1266
+ `service-switcher.css` (the browser's centred dialog) and with 0.36.1's sheet (2px of the search's words at 320).
1267
+ `service-switcher-plain-browser.test.ts` measures where the panel hangs, both implementations: the kit's topbar, GemNews's
1268
+ own header, no bar. Catalog section
1269
+ "Chuyển dịch vụ", and the shell section's lockup carries it.
1270
+
1271
+ ### Plain HTML: `gembossUI.initServiceSwitcher()` (0.36.0)
1272
+
1273
+ For a page with no React (GemCapture's ops admin, GemPress's admin, GemCare, Taskdesk): the server writes a placeholder
1274
+ right after its `.gb-brand`, and `service-switcher-plain.js` draws the component's button and panel in its place, the
1275
+ same DOM (classes, ARIA, order), so `service-switcher.css` draws it unchanged, and the same behaviour. Its own file, not
1276
+ `kit-plain.js` (which had 485 bytes gzip left under its ceiling at 0.36.0): 4.5 KB gzip, loaded only where the switcher is.
1277
+ A page with its own header (not the kit's topbar) needs nothing more: the panel hangs under the `header` or
1278
+ `[role="banner"]` around the button (0.37.1).
1279
+
1280
+ ```html
1281
+ <head>
1282
+ <link rel="stylesheet" href="/ui/service-switcher.css"> <!-- require.resolve("@gemboss/ui/service-switcher.css") -->
1283
+ </head>
1284
+ …
1285
+ <div class="gemboss-topbar-start">
1286
+ <a class="gb-brand" href="/admin" aria-label="GemCapture home">…</a>
1287
+ <span data-gb-service-switcher="gemcapture" hidden></span> <!-- the value: this service's id (services.ts) -->
1288
+ </div>
1289
+ …
1290
+ <script src="/ui/kit-plain.js"></script> <!-- @gemboss/ui/kit-plain.js, if the page has it: FIRST -->
1291
+ <script src="/ui/service-switcher-plain.js"></script> <!-- @gemboss/ui/service-switcher-plain.js -->
1292
+ <script>gembossUI.initServiceSwitcher();</script>
1293
+ ```
1294
+
1295
+ ```js
1296
+ gembossUI.initServiceSwitcher({ // every option is optional, and the component's prop of the same name
1297
+ labels: { trigger: "Chuyển dịch vụ", title: "Dịch vụ Gem", close: "Đóng", search: "Tìm dịch vụ",
1298
+ noMatch: "Không có dịch vụ nào khớp “{query}”.", all: "Mọi dịch vụ và tình trạng" },
1299
+ allHref: null, // the foot link; default GemFactory's /services; null draws none
1300
+ search: false, // the search box (default true)
1301
+ services: gembossUI.GEM_SERVICES.filter((s) => s.group !== "team"), // default every service
1302
+ className: "…", // added to the button's class
1303
+ current: "gemcapture", // instead of the placeholder's value
1304
+ root: element, // where to look for placeholders (default the document)
1305
+ }); // → [{ button, dialog, open(), close() }] for what it drew
1306
+ ```
1307
+
1308
+ | | |
1309
+ | --- | --- |
1310
+ | The placeholder | any element with `data-gb-service-switcher="<service id>"`, right after the brand; `hidden`, so it takes no room (nor the topbar's gap) before the script runs or where it never does. It is REPLACED by `button.gb-svc-trigger` + `dialog.gb-svc`: the button has to be the brand's next sibling, as `BrandLockup` draws it, for `.gb-brand + .gb-svc-trigger` (4px from the logo; at ≤460 40 wide and touching it, so the phone bar fits at 360) and for the panel to hang from the brand's left edge. A wrapper around the button would break all three |
1311
+ | Calls | idempotent: a placeholder is drawn once (it is gone after), so a second call draws nothing, and markup added later (`root`) is drawn by the next call. Called while the page still loads (in `<head>`), it draws what is parsed and the rest at `DOMContentLoaded`. Each switcher gets ids of its own |
1312
+ | `gembossUI` | a classic script that ADDS to it: after `kit-plain.js` when the page has it, because `kit-plain.js` defines nothing when `gembossUI` already exists (loaded the other way round it says so in the console). On its own it defines `gembossUI` with `initServiceSwitcher` and `GEM_SERVICES` only. With `kit-plain.js`, opening the panel closes an open kit menu (`closeMenus`: an open account menu would take the first Escape) |
1313
+ | Behaviour | the component's, item for item (table above): `showModal()`, Escape / ✕ / a press that starts on the backdrop, focus back to the button, opens on the current tile, search (Enter follows the first match, ↓ to the tiles), arrows by place on screen, Home / End, ↑ to the search box, one tile in the Tab order, a followed tile closes it. Strings are text |
1314
+ | The list | `GEM_SERVICES` as data inside the script, with the paths of the icons it names, held to `services.ts` + `icons.tsx` character for character by `service-switcher-plain-data.test.ts` (red when either moves without it; `UPDATE_PLAIN_CATALOGUE=1` writes it). A literal copy and a test, as the other plain scripts carry their TypeScript twins: the published file is the source minified, with no build step that could write it. A list of your own (`services`) uses the icons the catalogue names |
1315
+ | Inline | it holds no closing script tag, so a server can inline it (`readFileSync(require.resolve("@gemboss/ui/service-switcher-plain.js"))`), as it does `kit-plain.js` |
1316
+
1317
+ `packages/ui/src/service-switcher-plain-browser.test.ts` (Chromium, in `test:ui-browser`) renders the React component and
1318
+ this script for the same props on the same shell and compares the two DOMs node for node (generated ids aside): closed,
1319
+ open, filtered, nothing matching, after the keys, closed again, and with labels, no foot, no search and a list of its own;
1320
+ its negative control changes one attribute and the comparison sees it. Then the component's own checks on the plain page
1321
+ (1280, Escape / ✕ / backdrop, search, Enter, keys, 390 and 320 full screen, the bar at 320 / 360 / 390, kit-plain.js
1322
+ beside it, idempotence, `<head>`, alone, the wrong order). Catalog section "Chuyển dịch vụ", last frame: the plain page,
1323
+ live.
1324
+
1325
+ ## Repeater rows (16/10/2026): `RepeaterList`, `useKeyedRows`
1326
+
1327
+ A setting that is a list (an accordion's items, a pricing table's plans, choice cards) is the element's content. The admin's
1328
+ Canvas panel and page editor drew it as boxes always open, moved one place at a time with ↑ ↓; PageFly and GemPages draw it as
1329
+ rows (research `_local/gemboss/research/pickers-2026-10-16/pickers.md` §9, P11). `RepeaterList` is that, for both admin
1330
+ surfaces, with its own part (`editor-repeater.css`, the rules that were in `editor.css` plus the new ones).
1331
+
1332
+ ```tsx
1333
+ import { RepeaterList, useKeyedRows } from "@gemboss/ui/editor";
1334
+
1335
+ const list = useKeyedRows(items, (next) => save({ items: next })); // a stable key per row; the whole list written per change
1336
+ <RepeaterList label="Questions" keys={list.keys} title={(i) => list.rows[i].question}
1337
+ body={(i) => { const key = list.keys[i]; return <QuestionFields row={list.rows[i]} onChange={(field, v) => list.patch(key, (row) => ({ ...row, [field]: v }))} />; }}
1338
+ onMove={list.move} onRemove={list.remove} onDuplicate={list.duplicate} onAdd={() => list.add({ question: "", answer: "" })} />
1339
+ ```
1340
+
1341
+ | Part | Markup | Behaviour |
1342
+ |---|---|---|
1343
+ | list | `.gemboss-ei-fld.gemboss-ei-rep` › `label` (+ `.gemboss-ei-rep-count`) · `hint` · `.gemboss-ei-rep-list` · `button.gemboss-ei-rep-add` | the count beside the name; "No items yet." (or `empty`) without rows; `canAdd={false}` turns add and duplicate off |
1344
+ | row | `.gemboss-ei-rep-item[data-key]` (`.is-dragging`, `data-drop=before\|after`) › `.gemboss-ei-rep-head` › `.gemboss-ei-rep-grip` · `button.gemboss-ei-rep-toggle[aria-expanded]` (`.gemboss-ei-rep-title`) · `.gemboss-ei-rep-acts` (↑ ↓ duplicate ✕) | drag the grip (a 2px line where it lands, `useRowDrag`, the Layers rule); the name folds and opens the row; Alt+↑/↓ on it moves the row and keeps the focus; more than one row opens folded, a row added or duplicated later opens |
1345
+ | body | `.gemboss-ei-rep-body[role=group]` | drawn only while the row is open |
1346
+
1347
+ `useKeyedRows(value, commit)` keeps a key per row that moves, duplicates and is removed with it, so a row's open state and its
1348
+ controls' unsaved typing follow it (measured in `editor-repeater-browser.test.tsx`, with the same list keyed by index as the
1349
+ negative control). It shows a written list at once; `commit` may answer (a Promise of true / false) and a refusal puts the
1350
+ stored list back; the stored list is read by content (a new `[]` each render is no change); one of its own writes coming
1351
+ back is not a change; any other stored list (an undo) replaces what is shown. A field writes with `patch(key, fn)`: by the
1352
+ row's key and onto its latest version, so a control that commits late (a colour, a slider) after a move changes the right row
1353
+ and after a remove changes none. `touch` makes the buttons 44px with a mouse too (the page editor's content panel).
1354
+ Drag is pointer only (HTML drag and drop does not start from a touch); ↑ ↓ stay, 44px under a finger (`editor.css`).
1355
+
1356
+ ## Choice tiles (16/10/2026): `ChoiceTiles`
1357
+
1358
+ A closed choice whose options are shapes (an image's ratio, a layout) as picture tiles with their name under them, as GemPages'
1359
+ layout selector draws them: the seller sees the shape before choosing it.
1360
+
1361
+ ```tsx
1362
+ import { ChoiceTiles } from "@gemboss/ui/editor";
1363
+
1364
+ <ChoiceTiles label="Aspect ratio" value={ratio} onChange={save}
1365
+ options={[{ value: "original", label: "Original", picture: <ImageIcon /> }, { value: "1-1", label: "1:1", shape: { w: 1, h: 1 } },
1366
+ { value: "16-9", label: "16:9", shape: { w: 16, h: 9 } }]} />
1367
+ ```
1368
+
1369
+ `.gemboss-ei-tiles[role=group]` › `button.gemboss-ei-tile[aria-pressed][data-value]` › `.gemboss-ei-tile-pic` (the picture, or a
1370
+ rectangle drawn to fit 26px: wide by its width, tall by its height) · `.gemboss-ei-tile-label`. 4 to a row, 56px tall. The caller
1371
+ owns the value; a click calls `onChange`. Its own part (`editor-tiles.css`).
1372
+
1206
1373
  ## Resizable panels
1207
1374
 
1208
1375
  `ui-resizer` is the seam a person drags between two panels; `ui-resizing` on `<body>` is the drag in
@@ -1259,7 +1426,8 @@ const open = dock.presentation !== "hidden";
1259
1426
  - No provider and no state: pass `hidden` while your assistant is open.
1260
1427
  - The shortcut is a hint only: the surface owns the key binding.
1261
1428
  - The phone topbar (≤760px) gives in this order when it is short: the search (to a 96px floor), then the brand name
1262
- (ellipsis). The actions never shrink, so the account chip stays on screen (0.6.1; `topbar.test.tsx`).
1429
+ (ellipsis). The actions never shrink, so the account chip stays on screen (0.6.1; `topbar.test.tsx`). With a
1430
+ `ServiceSwitcher` in the bar (0.36.0), under 360px the search goes below its floor, to 56px: README "ServiceSwitcher".
1263
1431
  - 0.6.0, not a patch: an existing `<AssistantLauncher>` call changes meaning (no longer fixed; the default copy is the
1264
1432
  rail one, hidden on phones). A surface on `^0.5.x` must move to `^0.6.0` in the same PR that places the two copies.
1265
1433
 
@@ -1284,12 +1452,13 @@ import { AppShell, BrandLockup, AccountMenu, AccountMenuItem, Icon, RailItem } f
1284
1452
  ```
1285
1453
 
1286
1454
  - `BrandLockup`: the admin wordmark's gem and proportions (26px tall, 13.3px/700 name) with your
1287
- product's name. The name hides below 460px, as the admin's wordmark does.
1455
+ product's name. The name hides below 460px, as the admin's wordmark does. `switcher={{ current: "gemfactory" }}`
1456
+ (0.36.0) adds the button that opens every other Gem service right after it: README "ServiceSwitcher".
1288
1457
  - `AccountMenu`: the admin's 32px initials chip and popover (same CSS). A `<details>`, so it opens
1289
1458
  without JavaScript on a page that never hydrates; with React it also closes on an outside click and
1290
1459
  on Escape. `AccountMenuItem` is a link (`href`), a POST form (`formAction`), or a button.
1291
1460
  - `TopbarSearch` (0.6.3): see "Topbar search" below.
1292
- - `Icon`: the GemBoss set, 39 icons on a 20px grid with 1.5px strokes, drawn to sit next to Polaris.
1461
+ - `Icon`: the GemBoss set, 45 icons on a 20px grid with 1.5px strokes, drawn to sit next to Polaris.
1293
1462
  `ICON_NAMES` lists them. Internal tools cannot use `@shopify/polaris-icons`: its licence only covers
1294
1463
  applications that integrate with Shopify. Draw a missing one HERE, not in the surface: every rail
1295
1464
  icon must keep `fill="none"` on its inner `<g>`, because `.gnav-ico svg { fill: currentColor }`
@@ -1314,6 +1483,8 @@ The admin's "Search ⌘K" box for every surface, as a real form (works with no J
1314
1483
  full 44 (0.6.6): the box is a form, so the input is what a finger has to hit. ⌘K hint hidden on focus and below 460px.
1315
1484
  - Shortcut on a page without React: `gembossUI.initSearch()` from `kit-plain.js` (⌘K / Ctrl+K and "/" focus it,
1316
1485
  Escape leaves it). "/" is ignored while another field has focus.
1486
+ - In a bar that holds the ServiceSwitcher, under 360px, the box may go below its 96px floor; there it is its icon alone
1487
+ (0.37.1, `service-switcher.css`; README "ServiceSwitcher" › The topbar). `SearchDialog trigger="field"` the same.
1317
1488
 
1318
1489
  ## Public portal pieces (0.7.0)
1319
1490
 
@@ -1690,6 +1861,7 @@ something the base cannot express, add a token or a prop here, and every surface
1690
1861
  | `packages/ui/src/editor/` | 0.14.0, `@gemboss/ui/editor`: `EditorFrame`, `EditorPaneTabs`, `EditorTopbar`, `EditorBack`, `EditorChatToggle`, `EditorSaveState`, `DeviceToggle`. See "Editor frame". 0.17.0: `PanelTabs`, `PanelHeader`, `PanelAction`, `InspectorGroup`, `Field`, `Seg`, `ColorControl`, `RangeControl`, `EdgeBox`, `ImageControl`, `ChipButton`, `swatchState` (`./editor/swatch` too); 0.20.1 `cssUrl`. See "Settings panel". 0.22.0: `LayerList`, `SortableLayerList`, `LayerTree`, `EditorDrawer`. See "Layers and drawer". |
1691
1862
  | `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`. |
1692
1863
  | `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
+ | `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". |
1693
1865
  | `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`. |
1694
1866
  | `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. |
1695
1867
  | `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. |