@gemboss/ui 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,17 +85,27 @@ A live run is never thrown off screen (`assistant/dock.ts` holds that rule). The
85
85
  its top edge and slides along the bottom, by pointer or keyboard, and remembers both under
86
86
  `storagePrefix`.
87
87
 
88
- The side panel is opt-in: `<AssistantHost allowSide>` puts a "Pin to the side" button in the popup
89
- header, and the panel carries "Float as a popup" back. It stands under the topbar on the right, and
88
+ The side panel is the DEFAULT shape since 0.8.0 (Chris, 04/10/2026): a browser that never chose opens the assistant
89
+ there, and `allowSide` defaults to true — a surface whose layout cannot make room passes `allowSide={false}` and gets
90
+ the popup. The popup header has "Pin to the side", and the panel carries "Float as a popup" back; the choice is kept
91
+ under `<prefix>.shape` (the older `floatShape` key recorded the old default for everyone, so it is not read). The rail
92
+ link reopens in that shape unless the person last worked full screen. A popup opens as tall as the window allows,
93
+ 12px under the topbar (it was min(480, viewport − 176)); a height the person dragged to is kept, and dragging it back
94
+ to the top returns to "as tall as the window allows".
95
+
96
+ **Upgrading to 0.8.0:** a host that never passed `allowSide` now offers the panel. Its page must make room — render
97
+ inside `AppShell` (`.gemboss-platform-main` takes the margin) or pad by `var(--gb-assistant-side-w)` while
98
+ `<prefix>-side` is on `<body>` — or pass `allowSide={false}`. A full-screen "shrink" handler should call `reopen()`
99
+ (back to the working shape), not `openDock()`, which records "popup" as the person's choice. It stands under the topbar on the right, and
90
100
  the page moves over for it instead of being covered: the provider puts `<prefix>-side` on `<body>` and
91
101
  the width in `--gb-assistant-side-w` (`SIDE_WIDTH_VAR`), and `.gemboss-platform-main` takes that as
92
102
  `margin-right`. A surface with its own layout reads the same variable. Its left edge is a
93
103
  `.ui-resizer` (drag, or ←/→, Shift for 64px, Home/End), clamped to 380–720px and never leaving the page
94
104
  less than 480px; the width is remembered. Panel and page only fit side by side from 1100px (rail 240
95
- + 380 + 480). Between 761 and 1099 the panel covers the content area and is not offered; on a phone
96
- it takes the screen. The status bar, a busy ×, leaving full screen mid-run and full
105
+ + 380 + 480). Between 761 and 1099 the panel covers the content area and is not offered — the host
106
+ 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
97
107
  screen's own way back ("Back to the side panel") all return to whichever shape the person last
98
- worked in (`floatShape`, remembered across reloads), so a pinned panel never pops back as a popup.
108
+ worked in (`floatShape`, remembered across reloads under `<prefix>.shape`), so a pinned panel never pops back as a popup.
99
109
  The host registers `allowSide` with the provider, and every way into the panel checks it, including
100
110
  a "side" remembered in storage: a host without it gets the popup.
101
111
 
@@ -201,6 +211,7 @@ surfaces on the package, the same gaps were filled six ways. What 0.5.0 settles,
201
211
  |---|---|---|
202
212
  | Control height: `gemboss-field`, `ui-select`, `ui-btn`, `ui-seg` | **32px** (Polaris TextField / Select), `--ui-control-h` | **44px**, `ui-btn--sm` included |
203
213
  | `ui-btn--sm` | 28px | 44px |
214
+ | `ui-btn` inside a popup's head or foot (0.7.3) | 28px, 12px | 44px |
204
215
  | Focus ring, every control | 2px iris `--ui-focus` (= `--gemboss-accent`, the admin's `--p-color-border-focus`) | same |
205
216
  | Body text (`html`, `body`, at zero specificity) | 13px / 20px, weight 450, `--gemboss-text`, antialiased (Polaris bodyMd) | same |
206
217
  | Kit text (tabs, table, notice, pager, empty, page-head lead) | `--ui-fs-body`, 13px | 16px in fields (iOS zoom) |
@@ -248,6 +259,42 @@ If your surface still sets any of these itself (a `body { font-size }`, a `min-h
248
259
  cascade over the package's zero-specificity base. `packages/ui/src/kit-look.test.ts` measures the table above in
249
260
  Chromium on the built CSS.
250
261
 
262
+ ## Popup density (0.7.3)
263
+
264
+ The kit popup (`.ui-overlay > .ui-modal`, `<dialog class="ui-modal">`, `openModal`, `Dialog`) is drawn at Polaris
265
+ Modal's density, the popup the admin opens. Chris, 04/10/2026, on GemFactory's detail popup: the admin's buttons are
266
+ smaller and save room (GemFeedback #1099). Measured at 1280 with a mouse:
267
+
268
+ | | Polaris Modal (admin) | 0.7.1 / 0.7.3 | 0.7.3 |
269
+ |---|---|---|---|
270
+ | × | 28×28 | 44×44 at every pointer | **28×28**; 44×44 under a thumb or ≤ 767px wide (head stays 53) |
271
+ | Head | 53px, padding 12/16 | 61px, padding 16/20 | **53px**, padding 12/16, `align-items: center` |
272
+ | Title | 14px / 650 | 15px / 620 | **14px / 650** |
273
+ | `ui-btn` in `__head` / `__foot` | 28 tall, 12px, padding 0/12 | 32, 13px, 0/16 | **28, 12px, 0/12**; 44 under a thumb or ≤ 767px |
274
+ | Body / foot padding | 16 / 16 | 16/20 / 12/20 | **16 / 12/16** |
275
+ | Scrim | rgba(0,0,0,.5), no blur | .6, blur 2px | **.5, no blur** (`::backdrop` too) |
276
+
277
+ Popup only: `--ui-control-h` stays 32, and a button outside a popup is still 32 tall.
278
+
279
+ `.ui-modal__body` and `.ui-modal__foot` moved from `kit-plain.css` to `ui.css`. The admin loads `ui.css` and not
280
+ `kit-plain.css`, so before 0.7.3 its kit popups drew a body with no padding and two foot buttons touching
281
+ (AutopilotConfirmModal). The scrolling layout of a tall popup stays in `kit-plain.css`.
282
+
283
+ A large popup can keep its subtitle and its own actions in the kit head instead of drawing a second bar
284
+ (`kit-plain.css`):
285
+
286
+ ```html
287
+ <div class="ui-modal__head">
288
+ <div class="ui-modal__title">Mega menu<span class="ui-modal__subtitle">chrome/header</span></div>
289
+ <div class="ui-modal__actions"><button class="ui-btn">Copy</button><button class="ui-btn ui-btn--icon" aria-label="More">…</button></div>
290
+ <button type="button" class="ui-modal__close" aria-label="Close">…</button>
291
+ </div>
292
+ ```
293
+
294
+ `packages/ui/src/modal-density.test.ts` measures the table in Chromium at 1280 (mouse), 390 and under a finger, on
295
+ `gemboss-ui.css` and on the admin's sheets alone. `UI_MODAL_ROOT=<a 0.7.1 tree>` runs it against the old CSS, where
296
+ it is red.
297
+
251
298
  ## Resizable panels
252
299
 
253
300
  `ui-resizer` is the seam a person drags between two panels; `ui-resizing` on `<body>` is the drag in
@@ -428,6 +475,31 @@ surface computes every number and ratio; the kit only draws. Markup for each is
428
475
  - `ui-grid--2` / `--3`: card grid that drops to one column; `ui-section-head`: an `h2` + note between page head and
429
476
  card titles.
430
477
 
478
+ ## Long lists: pages or "load more" (0.7.3)
479
+
480
+ *Recommendation, not a rule: Claude, 04/10/2026, after Chris asked it to decide for GemFactory.*
481
+
482
+ | List | Pattern | Why |
483
+ |---|---|---|
484
+ | A work list: items with an #ID, filters, a review queue, links people send each other (GemFactory board) | **Pages**, `ui-pager` with numbers, position in the URL (`?p=3`) | Back returns to the same place, a link opens the same page, the total is visible. Infinite scroll loses all three. |
485
+ | One column of a Kanban (GemFactory), or a feed read top to bottom | **Load more**, `ui-more` | Nobody jumps to page 9 of one column or of a feed. |
486
+
487
+ `ui-pager`: `<a rel="prev">` · page links · `<span class="ui-pager__gap" aria-hidden="true">…</span>` · the current page as
488
+ `<a aria-current="page">` (filled) · `<a rel="next">` · `<span class="ui-pager__range">201–250 of 655</span>` (pushed right).
489
+ On the first or last page the edge is `<a role="link" aria-disabled="true">` with no `href`: a screen reader still reads a
490
+ link and says it is unavailable (a `<span aria-disabled>` has no role, so it reads as plain text). It is muted and does
491
+ not react to hover. Page links are at least 32x32, 44x44 under a finger. Arrow and word are two flex items, hence the 4px
492
+ gap. Under 576px only Previous · the current page · Next stay and the range wraps under them (eleven 44px targets
493
+ took three to four rows at 320). The old form `<span aria-current="page">2 / 9</span>` keeps its muted look. The surface computes which numbers to
494
+ show (first, last, current ± 1, a gap only where it hides two or more pages: GemFactory `pageWindow`).
495
+
496
+ `ui-more`: one centred row. Idle: a `ui-btn` ("Show 24 more"). Loading: `<span class="ui-spinner" aria-hidden="true">` +
497
+ `<span role="status">Loading…</span>`. Error: `<span role="alert">Couldn't load.</span>` + "Try again". End: a line. As an
498
+ IntersectionObserver sentinel it has height from its padding; zero the padding and it has none.
499
+
500
+ `.ui-prose` links are at `:where()` specificity, so a component's own link rule (`ui-pager`, `ui-seg`, `ui-tabs`) wins
501
+ inside a page-wide `.ui-prose` (GemFactory wraps every page in it; its page numbers were drawn as blue underlined text).
502
+
431
503
  ## The catalog: every token, class and component, with its markup
432
504
 
433
505
  `npm run catalog` builds `catalog/index.html` (also shipped in the npm tarball, and built by `prepack`):
@@ -27,7 +27,7 @@ body{margin:0;background:var(--gemboss-app-bg,#f1f1f1);color:var(--gemboss-text)
27
27
  @media (max-width:760px){.cat{grid-template-columns:1fr;padding:16px}.cat-nav{position:static;flex-direction:row;flex-wrap:wrap}}
28
28
  </style></head><body>
29
29
  <div class="cat"><nav class="cat-nav" aria-label="Mục"><a href="#tokens">Token</a><a href="#buttons">Nút</a><a href="#fields">Ô nhập</a><a href="#choice">Tab, nhóm nút, thẻ</a><a href="#surfaces">Thẻ, số liệu, bảng, trạng thái trống</a><a href="#report">Báo cáo</a><a href="#resizer">Thanh kéo giữa hai cột</a><a href="#modal">Hộp thoại, toast, menu (JS thuần)</a><a href="#shell">Khung app</a><a href="#portal">Cổng công khai: tab trên topbar, tìm kiếm, sáng / tối, người dẫn đầu</a><a href="#auth">Đăng nhập</a><a href="#chat">Thân chat</a><a href="#assistant">Trợ lý: cột phiên</a><a href="#icons">Icon</a></nav>
30
- <main><header class="cat-head"><h1>@gemboss/ui 0.7.2</h1><p>Ngôn ngữ thiết kế chung của các công cụ GemBoss. Markup dưới mỗi ví dụ là đúng thứ package render ra; bề mặt không dùng React (HTML tĩnh, Python) chép markup này và nạp <code>gemboss-ui.css</code>. Dựng từ gemboss <code>e66ca212d292</code>.</p></header>
30
+ <main><header class="cat-head"><h1>@gemboss/ui 0.8.0</h1><p>Ngôn ngữ thiết kế chung của các công cụ GemBoss. Markup dưới mỗi ví dụ là đúng thứ package render ra; bề mặt không dùng React (HTML tĩnh, Python) chép markup này và nạp <code>gemboss-ui.css</code>. Dựng từ gemboss <code>680adcf8cd14</code>.</p></header>
31
31
  <section id="tokens"><h2>Token</h2><p>Đổi giao diện một bề mặt bằng cách đặt lại token, không viết đè class. Tên và giá trị đọc thẳng từ gemboss-ui.css.</p><div class="cat-swatches"><div class="cat-swatch"><i style="background:var(--gemboss-primary)"></i><code>--gemboss-primary</code><span>#1b1c1e</span></div><div class="cat-swatch"><i style="background:var(--gemboss-primary-dark)"></i><code>--gemboss-primary-dark</code><span>#000000</span></div><div class="cat-swatch"><i style="background:var(--gemboss-primary-soft)"></i><code>--gemboss-primary-soft</code><span>#f2f3f5</span></div><div class="cat-swatch"><i style="background:var(--gemboss-accent)"></i><code>--gemboss-accent</code><span>#5661f5</span></div><div class="cat-swatch"><i style="background:var(--gemboss-accent-strong)"></i><code>--gemboss-accent-strong</code><span>#3a44de</span></div><div class="cat-swatch"><i style="background:var(--gemboss-accent-soft)"></i><code>--gemboss-accent-soft</code><span>#eef0fe</span></div><div class="cat-swatch"><i style="background:var(--gemboss-text-strong)"></i><code>--gemboss-text-strong</code><span>#1b1c1e</span></div><div class="cat-swatch"><i style="background:var(--gemboss-text)"></i><code>--gemboss-text</code><span>#1b1c1e</span></div><div class="cat-swatch"><i style="background:var(--gemboss-text-muted)"></i><code>--gemboss-text-muted</code><span>#686b71</span></div><div class="cat-swatch"><i style="background:var(--gemboss-surface)"></i><code>--gemboss-surface</code><span>#ffffff</span></div><div class="cat-swatch"><i style="background:var(--gemboss-canvas)"></i><code>--gemboss-canvas</code><span>#ffffff</span></div><div class="cat-swatch"><i style="background:var(--gemboss-preview-canvas)"></i><code>--gemboss-preview-canvas</code><span>#eceef1</span></div><div class="cat-swatch"><i style="background:var(--gemboss-rail-bg)"></i><code>--gemboss-rail-bg</code><span>#ffffff</span></div><div class="cat-swatch"><i style="background:var(--gemboss-panel-bg)"></i><code>--gemboss-panel-bg</code><span>#ffffff</span></div><div class="cat-swatch"><i style="background:var(--gemboss-divider)"></i><code>--gemboss-divider</code><span>#e5e7ea</span></div><div class="cat-swatch"><i style="background:var(--gemboss-divider-soft)"></i><code>--gemboss-divider-soft</code><span>#edeff2</span></div><div class="cat-swatch"><i style="background:var(--gemboss-hover)"></i><code>--gemboss-hover</code><span>#eceef2</span></div><div class="cat-swatch"><i style="background:var(--gemboss-active-bg)"></i><code>--gemboss-active-bg</code><span>#f0f1f4</span></div><div class="cat-swatch"><i style="background:var(--gemboss-active-fg)"></i><code>--gemboss-active-fg</code><span>#1b1c1e</span></div><div class="cat-swatch"><i style="background:var(--gemboss-danger)"></i><code>--gemboss-danger</code><span>#d72c0d</span></div><div class="cat-swatch"><i style="background:var(--gemboss-danger-strong)"></i><code>--gemboss-danger-strong</code><span>#b42318</span></div><div class="cat-swatch"><i style="background:var(--gemboss-danger-soft)"></i><code>--gemboss-danger-soft</code><span>#fff0e9</span></div><div class="cat-swatch"><i style="background:var(--gemboss-danger-border)"></i><code>--gemboss-danger-border</code><span>#f5c9bc</span></div><div class="cat-swatch"><i style="background:var(--gemboss-success)"></i><code>--gemboss-success</code><span>#007f5f</span></div><div class="cat-swatch"><i style="background:var(--gemboss-success-strong)"></i><code>--gemboss-success-strong</code><span>#0a6a4c</span></div><div class="cat-swatch"><i style="background:var(--gemboss-success-soft)"></i><code>--gemboss-success-soft</code><span>#e3f1df</span></div><div class="cat-swatch"><i style="background:var(--gemboss-success-border)"></i><code>--gemboss-success-border</code><span>#b7dfcd</span></div><div class="cat-swatch"><i style="background:var(--gemboss-warning)"></i><code>--gemboss-warning</code><span>#9a6700</span></div><div class="cat-swatch"><i style="background:var(--gemboss-warning-soft)"></i><code>--gemboss-warning-soft</code><span>#fff8e0</span></div><div class="cat-swatch"><i style="background:var(--gemboss-warning-border)"></i><code>--gemboss-warning-border</code><span>#f5d569</span></div><div class="cat-swatch"><i style="background:var(--gemboss-dark-bg)"></i><code>--gemboss-dark-bg</code><span>#0a0a0c</span></div><div class="cat-swatch"><i style="background:var(--gemboss-dark-rail)"></i><code>--gemboss-dark-rail</code><span>#0e0e11</span></div><div class="cat-swatch"><i style="background:var(--gemboss-dark-surface)"></i><code>--gemboss-dark-surface</code><span>#16161a</span></div><div class="cat-swatch"><i style="background:var(--gemboss-dark-surface-2)"></i><code>--gemboss-dark-surface-2</code><span>#1e1e24</span></div><div class="cat-swatch"><i style="background:var(--gemboss-dark-divider)"></i><code>--gemboss-dark-divider</code><span>#2a2a31</span></div><div class="cat-swatch"><i style="background:var(--gemboss-dark-text)"></i><code>--gemboss-dark-text</code><span>#ededf0</span></div><div class="cat-swatch"><i style="background:var(--gemboss-dark-muted)"></i><code>--gemboss-dark-muted</code><span>#9a9aa4</span></div><div class="cat-swatch"><i style="background:var(--gemboss-field-bg)"></i><code>--gemboss-field-bg</code><span>#fdfdfd</span></div><div class="cat-swatch"><i style="background:var(--gemboss-field-bg-hover)"></i><code>--gemboss-field-bg-hover</code><span>#fafafa</span></div><div class="cat-swatch"><i style="background:var(--gemboss-field-ink)"></i><code>--gemboss-field-ink</code><span>#303030</span></div><div class="cat-swatch"><i style="background:var(--gemboss-field-placeholder)"></i><code>--gemboss-field-placeholder</code><span>#616161</span></div><div class="cat-swatch"><i style="background:var(--gemboss-field-line)"></i><code>--gemboss-field-line</code><span>#8a8a8a</span></div><div class="cat-swatch"><i style="background:var(--gemboss-field-line-hover)"></i><code>--gemboss-field-line-hover</code><span>#616161</span></div><div class="cat-swatch"><i style="background:var(--gemboss-field-line-active)"></i><code>--gemboss-field-line-active</code><span>#1a1a1a</span></div><div class="cat-swatch"><i style="background:var(--gemboss-chip-ink)"></i><code>--gemboss-chip-ink</code><span>#303633</span></div><div class="cat-swatch"><i style="background:var(--gemboss-chip-line)"></i><code>--gemboss-chip-line</code><span>#cfd6ce</span></div><div class="cat-swatch"><i style="background:var(--gemboss-chip-line-hover)"></i><code>--gemboss-chip-line-hover</code><span>#aab4ad</span></div><div class="cat-swatch"><i style="background:var(--gemboss-chip-bg-hover)"></i><code>--gemboss-chip-bg-hover</code><span>#f6f7f5</span></div><div class="cat-swatch"><i style="background:var(--gemboss-on-primary)"></i><code>--gemboss-on-primary</code><span>#ffffff</span></div><div class="cat-swatch"><i style="background:var(--gemboss-app-bg)"></i><code>--gemboss-app-bg</code><span>#ffffff</span></div></div><table class="ui-table cat-scale"><thead><tr><th>Token</th><th>Giá trị</th><th>Mẫu</th></tr></thead><tbody><tr><td><code>--gemboss-fs-micro</code></td><td>11px</td><td style="font-size:var(--gemboss-fs-micro)">Ngôn ngữ thiết kế chung</td></tr><tr><td><code>--gemboss-fs-caption</code></td><td>12px</td><td style="font-size:var(--gemboss-fs-caption)">Ngôn ngữ thiết kế chung</td></tr><tr><td><code>--gemboss-fs-body</code></td><td>14px</td><td style="font-size:var(--gemboss-fs-body)">Ngôn ngữ thiết kế chung</td></tr><tr><td><code>--gemboss-fs-body-lg</code></td><td>16px</td><td style="font-size:var(--gemboss-fs-body-lg)">Ngôn ngữ thiết kế chung</td></tr><tr><td><code>--gemboss-fs-h3</code></td><td>16px</td><td style="font-size:var(--gemboss-fs-h3)">Ngôn ngữ thiết kế chung</td></tr><tr><td><code>--gemboss-fs-h2</code></td><td>20px</td><td style="font-size:var(--gemboss-fs-h2)">Ngôn ngữ thiết kế chung</td></tr><tr><td><code>--gemboss-fs-h1</code></td><td>24px</td><td style="font-size:var(--gemboss-fs-h1)">Ngôn ngữ thiết kế chung</td></tr><tr><td><code>--gemboss-fs-display</code></td><td>30px</td><td style="font-size:var(--gemboss-fs-display)">Ngôn ngữ thiết kế chung</td></tr><tr><td><code>--gemboss-radius-xs</code></td><td>4px</td><td><i class="cat-radius" style="border-radius:var(--gemboss-radius-xs)"></i></td></tr><tr><td><code>--gemboss-radius-sm</code></td><td>6px</td><td><i class="cat-radius" style="border-radius:var(--gemboss-radius-sm)"></i></td></tr><tr><td><code>--gemboss-radius-md</code></td><td>8px</td><td><i class="cat-radius" style="border-radius:var(--gemboss-radius-md)"></i></td></tr><tr><td><code>--gemboss-radius-lg</code></td><td>12px</td><td><i class="cat-radius" style="border-radius:var(--gemboss-radius-lg)"></i></td></tr><tr><td><code>--gemboss-radius-xl</code></td><td>16px</td><td><i class="cat-radius" style="border-radius:var(--gemboss-radius-xl)"></i></td></tr><tr><td><code>--gemboss-radius-2xl</code></td><td>20px</td><td><i class="cat-radius" style="border-radius:var(--gemboss-radius-2xl)"></i></td></tr><tr><td><code>--gemboss-radius-full</code></td><td>999px</td><td><i class="cat-radius" style="border-radius:var(--gemboss-radius-full)"></i></td></tr><tr><td><code>--gemboss-shadow-resting</code></td><td class="cat-small">0 1px 2px rgba(31, 30, 29, 0.04), 0 2px 6px rgba(31, 30, 29, 0.06)</td><td><i class="cat-radius" style="box-shadow:var(--gemboss-shadow-resting);border:0"></i></td></tr><tr><td><code>--gemboss-shadow-raised</code></td><td class="cat-small">0 4px 16px rgba(31, 30, 29, 0.10)</td><td><i class="cat-radius" style="box-shadow:var(--gemboss-shadow-raised);border:0"></i></td></tr><tr><td><code>--gemboss-shadow-overlay</code></td><td class="cat-small">0 16px 48px rgba(31, 30, 29, 0.18)</td><td><i class="cat-radius" style="box-shadow:var(--gemboss-shadow-overlay);border:0"></i></td></tr></tbody></table></section>
32
32
  <section id="buttons"><h2>Nút</h2><p>Một lớp <code>ui-btn</code>, ba biến thể, tông đỏ, một cỡ nhỏ. Cao 32px dưới chuột (bằng ô nhập và ô chọn), 44px dưới ngón tay và ở khổ điện thoại, cỡ nhỏ cũng vậy. Dùng được trên <code>&lt;button&gt;</code> lẫn <code>&lt;a&gt;</code> (căn giữa sẵn).</p><figure class="cat-demo"><figcaption>Mặc định · chính · ghost · nhỏ · tắt</figcaption><div class="cat-live"><div class="cat-row"><button type="button" class="ui-btn">Lưu nháp</button><button type="button" class="ui-btn ui-btn--primary">Xuất bản</button><button type="button" class="ui-btn ui-btn--ghost">Huỷ</button><button type="button" class="ui-btn ui-btn--sm">Nhỏ</button><button type="button" class="ui-btn ui-btn--primary" disabled>Đang lưu…</button><a class="ui-btn" href="#buttons">Link dạng nút</a></div></div><details class="cat-code"><summary>Markup</summary><pre><code>&lt;div class=&quot;cat-row&quot;&gt;
33
33
  &lt;button type=&quot;button&quot; class=&quot;ui-btn&quot;&gt;Lưu nháp&lt;/button&gt;
@@ -248,7 +248,44 @@ body{margin:0;background:var(--gemboss-app-bg,#f1f1f1);color:var(--gemboss-text)
248
248
  &lt;span aria-current=&quot;page&quot;&gt;2 / 9&lt;/span&gt;
249
249
  &lt;a href=&quot;#&quot; rel=&quot;next&quot;&gt;Sau &lt;span aria-hidden=&quot;true&quot;&gt;›&lt;/span&gt;
250
250
  &lt;/a&gt;
251
- &lt;/nav&gt;</code></pre></details></figure><figure class="cat-demo"><figcaption>Link (<code>ui-link</code>) · HTML sinh ra (<code>ui-prose</code>: mọi <code>&lt;a&gt;</code> không class bên trong)</figcaption><div class="cat-live"><p>Xem <a class="ui-link" href="#">báo cáo đối thủ</a> trước khi gửi.</p><div class="ui-prose"><p>Đoạn markdown: nguồn ở <a href="#">trang tích hợp của Replo</a>.</p></div></div><details class="cat-code"><summary>Markup</summary><pre><code>&lt;p&gt;Xem &lt;a class=&quot;ui-link&quot; href=&quot;#&quot;&gt;báo cáo đối thủ&lt;/a&gt; trước khi gửi.&lt;/p&gt;
251
+ &lt;/nav&gt;</code></pre></details></figure><figure class="cat-demo"><figcaption>Phân trang có số trang (<code>ui-pager</code> + <code>__gap</code> · <code>__range</code>, mép <code>&lt;a role=link aria-disabled&gt;</code>): danh sách làm việc, vị trí nằm trên URL</figcaption><div class="cat-live"><nav class="ui-pager" aria-label="Pages"><a href="#" rel="prev"><span aria-hidden="true">‹</span> Previous</a><a href="#">1</a><span class="ui-pager__gap" aria-hidden="true">…</span><a href="#">4</a><a href="#" aria-current="page">5</a><a href="#">6</a><span class="ui-pager__gap" aria-hidden="true">…</span><a href="#">14</a><a href="#" rel="next">Next <span aria-hidden="true">›</span></a><span class="ui-pager__range">201–250 of 655</span></nav><nav class="ui-pager" aria-label="Pages"><a role="link" aria-disabled="true"><span aria-hidden="true">‹</span> Previous</a><a href="#" aria-current="page">1</a><a href="#">2</a><a href="#">3</a><a href="#" rel="next">Next <span aria-hidden="true">›</span></a><span class="ui-pager__range">1–50 of 131</span></nav></div><details class="cat-code"><summary>Markup</summary><pre><code>&lt;nav class=&quot;ui-pager&quot; aria-label=&quot;Pages&quot;&gt;
252
+ &lt;a href=&quot;#&quot; rel=&quot;prev&quot;&gt;
253
+ &lt;span aria-hidden=&quot;true&quot;&gt;‹&lt;/span&gt; Previous&lt;/a&gt;
254
+ &lt;a href=&quot;#&quot;&gt;1&lt;/a&gt;
255
+ &lt;span class=&quot;ui-pager__gap&quot; aria-hidden=&quot;true&quot;&gt;…&lt;/span&gt;
256
+ &lt;a href=&quot;#&quot;&gt;4&lt;/a&gt;
257
+ &lt;a href=&quot;#&quot; aria-current=&quot;page&quot;&gt;5&lt;/a&gt;
258
+ &lt;a href=&quot;#&quot;&gt;6&lt;/a&gt;
259
+ &lt;span class=&quot;ui-pager__gap&quot; aria-hidden=&quot;true&quot;&gt;…&lt;/span&gt;
260
+ &lt;a href=&quot;#&quot;&gt;14&lt;/a&gt;
261
+ &lt;a href=&quot;#&quot; rel=&quot;next&quot;&gt;Next &lt;span aria-hidden=&quot;true&quot;&gt;›&lt;/span&gt;
262
+ &lt;/a&gt;
263
+ &lt;span class=&quot;ui-pager__range&quot;&gt;201–250 of 655&lt;/span&gt;
264
+ &lt;/nav&gt;
265
+ &lt;nav class=&quot;ui-pager&quot; aria-label=&quot;Pages&quot;&gt;
266
+ &lt;a role=&quot;link&quot; aria-disabled=&quot;true&quot;&gt;
267
+ &lt;span aria-hidden=&quot;true&quot;&gt;‹&lt;/span&gt; Previous&lt;/a&gt;
268
+ &lt;a href=&quot;#&quot; aria-current=&quot;page&quot;&gt;1&lt;/a&gt;
269
+ &lt;a href=&quot;#&quot;&gt;2&lt;/a&gt;
270
+ &lt;a href=&quot;#&quot;&gt;3&lt;/a&gt;
271
+ &lt;a href=&quot;#&quot; rel=&quot;next&quot;&gt;Next &lt;span aria-hidden=&quot;true&quot;&gt;›&lt;/span&gt;
272
+ &lt;/a&gt;
273
+ &lt;span class=&quot;ui-pager__range&quot;&gt;1–50 of 131&lt;/span&gt;
274
+ &lt;/nav&gt;</code></pre></details></figure><figure class="cat-demo"><figcaption>Tải thêm (<code>ui-more</code>): feed, cột Kanban. Bốn trạng thái: chờ · đang tải · lỗi · hết</figcaption><div class="cat-live"><div class="ui-more"><button type="button" class="ui-btn">Load more</button></div><div class="ui-more"><span class="ui-spinner" aria-hidden="true"></span><span role="status">Loading…</span></div><div class="ui-more"><span role="alert">Couldn't load.</span><button type="button" class="ui-btn">Try again</button></div><div class="ui-more"><span>That's everything</span></div></div><details class="cat-code"><summary>Markup</summary><pre><code>&lt;div class=&quot;ui-more&quot;&gt;
275
+ &lt;button type=&quot;button&quot; class=&quot;ui-btn&quot;&gt;Load more&lt;/button&gt;
276
+ &lt;/div&gt;
277
+ &lt;div class=&quot;ui-more&quot;&gt;
278
+ &lt;span class=&quot;ui-spinner&quot; aria-hidden=&quot;true&quot;&gt;
279
+ &lt;/span&gt;
280
+ &lt;span role=&quot;status&quot;&gt;Loading…&lt;/span&gt;
281
+ &lt;/div&gt;
282
+ &lt;div class=&quot;ui-more&quot;&gt;
283
+ &lt;span role=&quot;alert&quot;&gt;Couldn't load.&lt;/span&gt;
284
+ &lt;button type=&quot;button&quot; class=&quot;ui-btn&quot;&gt;Try again&lt;/button&gt;
285
+ &lt;/div&gt;
286
+ &lt;div class=&quot;ui-more&quot;&gt;
287
+ &lt;span&gt;That's everything&lt;/span&gt;
288
+ &lt;/div&gt;</code></pre></details></figure><figure class="cat-demo"><figcaption>Link (<code>ui-link</code>) · HTML sinh ra (<code>ui-prose</code>: mọi <code>&lt;a&gt;</code> không class bên trong)</figcaption><div class="cat-live"><p>Xem <a class="ui-link" href="#">báo cáo đối thủ</a> trước khi gửi.</p><div class="ui-prose"><p>Đoạn markdown: nguồn ở <a href="#">trang tích hợp của Replo</a>.</p></div></div><details class="cat-code"><summary>Markup</summary><pre><code>&lt;p&gt;Xem &lt;a class=&quot;ui-link&quot; href=&quot;#&quot;&gt;báo cáo đối thủ&lt;/a&gt; trước khi gửi.&lt;/p&gt;
252
289
  &lt;div class=&quot;ui-prose&quot;&gt;
253
290
  &lt;p&gt;Đoạn markdown: nguồn ở &lt;a href=&quot;#&quot;&gt;trang tích hợp của Replo&lt;/a&gt;.&lt;/p&gt;
254
291
  &lt;/div&gt;</code></pre></details></figure><figure class="cat-demo"><figcaption>Thẻ có ảnh (<code>ui-card--media</code>: <code>__media</code> 16:10 · <code>__body</code> · <code>__foot</code>)</figcaption><div class="cat-live"><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(min(240px,100%),1fr));gap:12px"><article class="ui-card ui-card--media"><button type="button" class="ui-card__media" aria-label="Xem lớn"><img alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 10'%3E%3Crect width='16' height='10' fill='%23e9e7f7'/%3E%3Crect x='2' y='2' width='7' height='1.2' rx='.4' fill='%235661f5'/%3E%3Crect x='2' y='4.2' width='10' height='.8' rx='.3' fill='%23b9bdf0'/%3E%3C/svg%3E"></button><div class="ui-card__body"><h3 class="ui-card__title" style="margin:0">#392 Thẻ sản phẩm so sánh</h3><span class="ui-tag">comparison-table</span></div><div class="ui-card__foot"><span>Minh Anh · v5</span><span>2 giờ trước</span></div></article></div></div><details class="cat-code"><summary>Markup</summary><pre><code>&lt;div style=&quot;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(240px,100%),1fr));gap:12px&quot;&gt;
package/gemboss-ui.css CHANGED
@@ -1,8 +1,8 @@
1
1
  /*
2
2
  * GemBoss admin design system — GENERATED — do not edit; regenerate from gemboss:
3
3
  * node apps/admin/scripts/export-ui-css.mjs --out <path> (npm run export:ui-css)
4
- * gemboss commit: e66ca212d292e091348613340ba69cff77c159fc
5
- * sources sha256: 518302cf7e7a5abd3c1c2157f2b0993b9578c9e030d25ee90cd5ef36b898eec2
4
+ * gemboss commit: 680adcf8cd1434698a6e1facafcd52d0a642ce96
5
+ * sources sha256: 2d75fe13397758ceeb8de58a7418337dafd3cb1aa573e5c4c9f3438ba8099347
6
6
  * contents: 1 allow-listed rule from apps/admin/src/app/globals.css + packages/ui/src/gemboss-tokens.css
7
7
  * (verbatim) + packages/ui/src/ui.css (verbatim) + packages/ui/src/gemboss-field.css (verbatim)
8
8
  * + 181 rules from packages/ui/src/shell.css (the shared shell)
@@ -457,10 +457,11 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
457
457
 
458
458
  /* ── Modal ────────────────────────────────────────────────────────────────────
459
459
  Rendered INLINE (not portaled): a portal escapes the .gemboss-admin-shell token
460
- scope and would render light. Kept in-tree so --ui-* resolves to the surface. */
460
+ scope and would render light. Kept in-tree so --ui-* resolves to the surface.
461
+ 0.7.3: Polaris Modal's density (#1099, README "Popup density"). Popup only: --ui-control-h stays 32. */
461
462
  .ui-overlay {
462
463
  position: fixed; inset: 0; z-index: 500;
463
- background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px);
464
+ background: rgba(0, 0, 0, 0.5);
464
465
  display: grid; place-items: center; padding: 20px;
465
466
  }
466
467
  .ui-modal {
@@ -470,30 +471,39 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
470
471
  }
471
472
  /* Head = title + the one close button every modal used to draw for itself. */
472
473
  .ui-modal__head {
473
- display: flex; align-items: flex-start; gap: 16px;
474
- padding: 16px 20px; border-bottom: 1px solid var(--ui-border);
475
- font-size: 15px; font-weight: 620; color: var(--ui-text);
474
+ display: flex; align-items: center; gap: 12px;
475
+ padding: 12px 16px; border-bottom: 1px solid var(--ui-border);
476
+ font-size: 14px; line-height: 20px; font-weight: 650; color: var(--ui-text);
476
477
  }
477
478
  .ui-modal__title { min-width: 0; flex: 1 1 auto; }
478
479
  .ui-modal__close {
479
480
  flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
480
- width: 44px; height: 44px; margin: -8px -8px -8px 0; /* a 44px target that does not grow the head */
481
+ width: 28px; height: 28px; padding: 0;
481
482
  border: 0; border-radius: var(--ui-radius-md, 8px); background: transparent;
482
483
  color: var(--ui-text-muted); cursor: pointer;
483
484
  }
484
485
  .ui-modal__close svg { width: 20px; height: 20px; }
485
486
  .ui-modal__close:hover { background: var(--ui-surface-2); color: var(--ui-text); }
486
487
  .ui-modal__close:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: -2px; }
488
+ /* Here, not in kit-plain.css: the admin loads only this file (0.7.3). */
489
+ .ui-modal__body { padding: 16px; }
490
+ .ui-modal__foot {
491
+ display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;
492
+ padding: 12px 16px; border-top: 1px solid var(--ui-border);
493
+ }
494
+ :is(.ui-modal__head, .ui-modal__foot) .ui-btn { height: 28px; padding: 0 12px; font-size: var(--ui-fs-caption); }
495
+ /* 44px under a thumb or at phone width; -8px keeps the head 53. */
496
+ @media (pointer: coarse), (max-width: 47.9975em) {
497
+ .ui-modal__close { width: 44px; height: 44px; margin: -8px -8px -8px 0; }
498
+ :is(.ui-modal__head, .ui-modal__foot) .ui-btn { min-height: 44px; }
499
+ }
487
500
 
488
- /* `size="lg"` — the screen-shaped dialog. Only the HOOK lives here: this sheet is imported by the
489
- root layout, so every rule in it is paid on every screen, and the geometry of a dialog with one
490
- consumer belongs in that consumer's chunk (see SettingsDialog.module.css). */
501
+ /* `size="lg"`: only the HOOK here (this sheet is paid on every screen); its geometry is in
502
+ SettingsDialog.module.css, the one consumer's chunk. */
491
503
  .ui-modal--lg { position: relative; }
492
504
 
493
- /* The kit's multi-line field joins the ONE field family (01/10/2026, Chris: "form control đồng bộ"):
494
- same input-border token, hover and focus ring as `gemboss-field` and `.ui-select`. Before, it drew a
495
- lighter --ui-border box at 14px with no focus ring, so on GemFactory's compose form a textarea sat
496
- beside selects of a darker border and text fields of a third look (measured on prod). */
505
+ /* The kit's multi-line field is the ONE field family (01/10/2026, Chris: "form control đồng bộ"): same
506
+ border token, hover and focus ring as `gemboss-field` / `.ui-select` (it drew a third look on GemFactory). */
497
507
  .ui-textarea {
498
508
  width: 100%; min-height: 96px; resize: vertical; box-sizing: border-box;
499
509
  background: var(--gemboss-field-bg);
@@ -3376,23 +3386,24 @@ body.gemboss-assistant-unread .gnav-item.gnav-assistant::after {
3376
3386
  Link: `<a class="ui-link" href>` for a link inside text. Iris (--gemboss-accent-strong, 6.2:1 on white), underlined
3377
3387
  so it is not told by colour alone (WCAG 1.4.1); visited looks the same (these are tools, not articles). Markdown or
3378
3388
  other generated HTML you cannot add classes to: wrap it in `.ui-prose` and its bare `<a>` get the same look.
3389
+ `.ui-prose` links sit at :where() specificity (0.7.3), so any component's own link rule wins inside it.
3379
3390
 
3380
3391
  Media card: `<article class="ui-card ui-card--media"><a|button class="ui-card__media"><img alt=""></…>
3381
3392
  <div class="ui-card__body">…</div><div class="ui-card__foot">…</div></article>`. The picture is 16:10 and
3382
3393
  cropped to fill; the card has no padding of its own, the body and foot do. */
3383
3394
  .ui-link,
3384
- .ui-prose a:not([class]) {
3395
+ .ui-prose :where(a:not([class])) {
3385
3396
  color: var(--gemboss-accent-strong, #3a44de);
3386
3397
  text-decoration: underline;
3387
3398
  text-decoration-thickness: from-font;
3388
3399
  text-underline-offset: 2px;
3389
3400
  }
3390
3401
  .ui-link:visited,
3391
- .ui-prose a:not([class]):visited { color: var(--gemboss-accent-strong, #3a44de); }
3402
+ .ui-prose :where(a:not([class])):visited { color: var(--gemboss-accent-strong, #3a44de); }
3392
3403
  .ui-link:hover,
3393
- .ui-prose a:not([class]):hover { color: var(--gemboss-accent, #5661f5); text-decoration-thickness: 2px; }
3404
+ .ui-prose :where(a:not([class])):hover { color: var(--gemboss-accent, #5661f5); text-decoration-thickness: 2px; }
3394
3405
  .ui-link:focus-visible,
3395
- .ui-prose a:not([class]):focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: 2px; border-radius: 2px; }
3406
+ .ui-prose :where(a:not([class])):focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: 2px; border-radius: 2px; }
3396
3407
 
3397
3408
  .ui-card--media { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
3398
3409
  .ui-card__media {
@@ -3411,13 +3422,16 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
3411
3422
  }
3412
3423
 
3413
3424
  /* Popup body and foot: `<div class="ui-modal__body">…</div><div class="ui-modal__foot"><button class="ui-btn">…</button></div>`.
3414
- ui.css never styled these, and the admin does not load this file: its Modal.tsx children carry their own padding
3415
- (or none). Every non-React surface padded them by hand (GemFactory: 16/20 and a hairline above the actions). kit-plain.js's openModal writes exactly this shape. */
3416
- .ui-modal__body { padding: 16px 20px; }
3417
- .ui-modal__foot {
3418
- display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;
3419
- padding: 12px 20px; border-top: 1px solid var(--ui-border);
3420
- }
3425
+ Their padding moved to ui.css in 0.7.3 (the admin loads that file, not this one). kit-plain.js's openModal writes
3426
+ exactly this shape. What stays here is what only the plain-HTML popups use:
3427
+
3428
+ A head that carries more than a title (a large popup's subtitle and its own actions, GemFactory's detail popup):
3429
+ `<div class="ui-modal__head"><div class="ui-modal__title">Mega menu<span class="ui-modal__subtitle">chrome/header</span>
3430
+ </div><div class="ui-modal__actions"><button class="ui-btn">Copy</button></div><button class="ui-modal__close">…` */
3431
+ .ui-modal__subtitle { display: block; margin-top: 2px; font-size: var(--ui-fs-caption); line-height: 16px; font-weight: 450; color: var(--ui-text-muted); overflow-wrap: anywhere; }
3432
+ .ui-modal__actions { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-2); }
3433
+ /* An icon button in the popup's head or foot is square at the popup's 28 (ui.css gives those buttons 0/12 padding). */
3434
+ :is(.ui-modal__head, .ui-modal__foot) .ui-btn--icon { width: 28px; padding: 0; }
3421
3435
  /* A popup taller than the screen: the body scrolls, the head and foot stay, so the actions are always reachable
3422
3436
  (320×568 with a long form put Huỷ/Xoá below the fold, unreachable under the locked page; review #3633). */
3423
3437
  .ui-overlay > .ui-modal { display: flex; flex-direction: column; max-height: calc(100dvh - 40px); }
@@ -3689,7 +3703,7 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
3689
3703
  <div class="ui-modal__foot">…</div></dialog>` */
3690
3704
  dialog.ui-modal { padding: 0; max-width: calc(100% - 40px); max-height: calc(100dvh - 40px); color: var(--ui-text); }
3691
3705
  dialog.ui-modal[open] { display: flex; flex-direction: column; }
3692
- dialog.ui-modal::backdrop { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px); }
3706
+ dialog.ui-modal::backdrop { background: rgba(0, 0, 0, 0.5); } /* the overlay's own scrim (0.7.3: Polaris' .5, no blur) */
3693
3707
 
3694
3708
  /* ── 0.5.1: what the first surfaces on 0.5.0 still drew themselves (GemFactory, content-radar, 03/10/2026) ─────────── */
3695
3709
 
@@ -3751,6 +3765,21 @@ button.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-off
3751
3765
  repaint its edge grey the moment it is clicked (measured on gemwatcher Radar). */
3752
3766
  .ui-metric.ui-metric:is([aria-pressed="true"], [aria-current="page"]) { border-color: var(--gemboss-accent); box-shadow: inset 0 0 0 1px var(--gemboss-accent); }
3753
3767
 
3768
+ /* 0.7.3: numbered pager + ui-more, for the plain surfaces (the admin uses Polaris Pagination). Markup and rationale:
3769
+ README "Long lists". Here, not in ui.css: ui.css is exported verbatim into the admin, under a 90 KB budget. */
3770
+ .ui-pager > a, .ui-pager > [aria-disabled="true"] { min-width: 32px; justify-content: center; box-sizing: border-box; gap: 4px; }
3771
+ .ui-pager > a[aria-current="page"] { background: var(--ui-text); border-color: var(--ui-text); color: var(--ui-surface); font-size: var(--ui-fs-body); font-weight: 600; }
3772
+ .ui-pager > a[aria-current="page"]:hover { background: var(--ui-text); }
3773
+ .ui-pager > [aria-disabled="true"] { display: inline-flex; align-items: center; min-height: 32px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-md); color: var(--ui-text-muted); font-size: var(--ui-fs-body); opacity: .55; cursor: default; }
3774
+ .ui-pager > [aria-disabled="true"]:hover { background: none; }
3775
+ .ui-pager__gap { min-width: 16px; text-align: center; color: var(--ui-text-muted); }
3776
+ .ui-pager__range { margin-left: auto; color: var(--ui-text-muted); font-size: var(--ui-fs-body); font-variant-numeric: tabular-nums; }
3777
+ .ui-more { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 12px; padding: 16px 0; color: var(--ui-text-muted); font-size: var(--ui-fs-body); text-align: center; }
3778
+ .ui-more[hidden] { display: none; }
3779
+ @media (pointer: coarse), (max-width: 47.9975em) { .ui-pager > a, .ui-pager > [aria-disabled="true"] { min-width: 44px; min-height: 44px; } }
3780
+ /* Phone: 11 targets of 44px wrapped into 3-4 rows at 320 (measured). Keep Previous · current · Next; the range wraps under. */
3781
+ @media (max-width: 35.9975em) { .ui-pager > a:not([rel]):not([aria-current]):not([aria-disabled]), .ui-pager__gap { display: none; } }
3782
+
3754
3783
  /* ═══ @gemboss/ui src/chat.css ═══ */
3755
3784
  /*
3756
3785
  * The chat BODY — what an answer looks like, wherever a GemBoss surface puts a chat (01/10/2026).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemboss/ui",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "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.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -132,25 +132,18 @@ export function clampDockHeight(height: number, viewport: number): number {
132
132
  }
133
133
 
134
134
  /**
135
- * How far below the topbar a popup that has just OPENED starts (03/10/2026).
135
+ * The height a popup opens at: what the seller dragged it to, else AS TALL AS THE WINDOW ALLOWS — the same 12px under
136
+ * the topbar a drag can reach (`clampDockHeight`).
136
137
  *
137
- * The 12px ceiling above is where a popup the seller DRAGS can reach. It is not where one should open.
138
- * Chris, 03/10: "default height cao hơn sát vào topbar". Measured on his browser (window 620px tall):
139
- * gemwatcher had a dragged 599 saved, every reopen clamped it to 552, and the popup opened 12px under
140
- * the topbar; a flat 480 default also leaves only 84px on a 620 window and ~0 on a 560 one.
138
+ * 04/10/2026, Chris chose "gần hết màn" over min(480, viewport − 176) (03/10's 120px opening gap). Measured on
139
+ * gemwatcher at 1440×900 before: the popup was 480 tall, the engine card took ~190px of its 431px body and the
140
+ * conversation got ~90px. `null` means "no drag saved" and is resolved against the CURRENT window every time — the
141
+ * provider keeps it as null rather than freezing the mount-time ceiling (review #3744: a window made taller after load
142
+ * kept the old, shorter popup).
141
143
  */
142
- export const DOCK_OPEN_GAP = 120;
143
-
144
- /** The tallest a popup OPENS at in this window: a quarter-ish band of page stays visible above it. */
145
- export function dockOpenCeiling(viewport: number): number {
146
- return Math.max(DOCK_MIN_HEIGHT, Math.round(viewport) - DOCK_TOPBAR_HEIGHT - DOCK_OPEN_GAP);
147
- }
148
-
149
- /** The height a popup opens at: what the seller asked for (or the default), never closer to the topbar
150
- * than DOCK_OPEN_GAP. Dragging in-session can still go up to the 12px ceiling of `clampDockHeight`. */
151
144
  export function openingDockHeight(requested: number | null, viewport: number): number {
152
- const want = requested !== null && Number.isFinite(requested) && requested > 0 ? requested : DOCK_DEFAULT_HEIGHT;
153
- return clampDockHeight(Math.min(want, dockOpenCeiling(viewport)), viewport);
145
+ const want = requested !== null && Number.isFinite(requested) && requested > 0 ? requested : Infinity;
146
+ return clampDockHeight(want, viewport);
154
147
  }
155
148
 
156
149
  export const initialAssistantDockState: AssistantDockState = {
@@ -162,7 +155,10 @@ export const initialAssistantDockState: AssistantDockState = {
162
155
  dockOffsetRight: DOCK_DEFAULT_OFFSET_RIGHT,
163
156
  openNonce: 0,
164
157
  sideWidth: SIDE_DEFAULT_WIDTH,
165
- floatShape: "dock",
158
+ // The side panel is where every surface opens by default (Chris, 04/10/2026: "Dính cạnh phải"): it takes the full
159
+ // height and the page moves over instead of being covered. A host that does not offer it still gets the popup
160
+ // (`backTo`), and a seller who floats it as a popup is remembered as such.
161
+ floatShape: "side",
166
162
  sideAllowed: false,
167
163
  };
168
164
 
@@ -204,7 +200,12 @@ export function assistantDockReducer(state: AssistantDockState, event: Assistant
204
200
  case "side-allowed":
205
201
  if (event.allowed === state.sideAllowed) return state;
206
202
  // Withdrawn while the panel is up: it becomes the popup rather than a shape the host no longer draws for.
207
- return { ...state, sideAllowed: event.allowed, presentation: !event.allowed && state.presentation === "side" ? "dock" : state.presentation };
203
+ if (!event.allowed) return { ...state, sideAllowed: false, presentation: state.presentation === "side" ? "dock" : state.presentation };
204
+ // Allowed (again): a popup that is only there because the panel was not allowed a moment ago — the working shape
205
+ // is still "side" — goes back to the panel. Two ways that happens (review #3744): the admin mounts its host only
206
+ // after the first open, so that first open arrived before the host said yes; and a window that dipped below
207
+ // 1100px (DevTools docked, a snap) and came back. A popup the person CHOSE has floatShape "dock" and stays.
208
+ return { ...state, sideAllowed: true, presentation: state.presentation === "dock" && state.floatShape === "side" ? "side" : state.presentation };
208
209
 
209
210
  case "restore-float":
210
211
  return { ...state, floatShape: event.shape };
@@ -12,7 +12,7 @@
12
12
  * assistant is unmounted by the next menu click, and unmounting aborts the run. Geometry only changes
13
13
  * class names; the subtree is never re-parented (that would remount the chat and abort the run too).
14
14
  */
15
- import React, { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
15
+ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
16
16
 
17
17
  import { DOCK_MIN_HEIGHT, SIDE_MIN_WIDTH, clampDockHeight, clampSideWidth } from "./dock";
18
18
  import { sideAvailable, useAssistant } from "./provider";
@@ -62,9 +62,33 @@ export interface AssistantSecondTab {
62
62
  content: ReactNode | null;
63
63
  }
64
64
 
65
+ /** Where the side panel stands BESIDE the page (assistant.css). Below it the panel would cover the page — the whole
66
+ * content area at 761–1099px, the whole screen on a phone — so the default there stays the popup (review #3744: on a
67
+ * phone a fresh open became a full-screen sheet over the very screen the question is about). */
68
+ export const SIDE_FITS_QUERY = "(min-width: 1100px)";
69
+
70
+ /** Before paint in the browser, a plain effect on the server (where a layout effect only warns). */
71
+ const useBeforePaint = typeof window === "undefined" ? useEffect : useLayoutEffect;
72
+
73
+ function useSideFits(): boolean {
74
+ const [fits, setFits] = useState(() => typeof window === "undefined" || !window.matchMedia
75
+ ? true : window.matchMedia(SIDE_FITS_QUERY).matches);
76
+ useEffect(() => {
77
+ if (typeof window === "undefined" || !window.matchMedia) return;
78
+ const mq = window.matchMedia(SIDE_FITS_QUERY);
79
+ const on = () => setFits(mq.matches);
80
+ on();
81
+ // Safari < 14 has only the old listener API on MediaQueryList.
82
+ if (mq.addEventListener) mq.addEventListener("change", on);
83
+ else mq.addListener(on);
84
+ return () => { if (mq.removeEventListener) mq.removeEventListener("change", on); else mq.removeListener(on); };
85
+ }, []);
86
+ return fits;
87
+ }
88
+
65
89
  export function AssistantHost({
66
90
  title, labels, onNewChat, onExpand, onShrink, fullActions, secondTab, view = "primary", onViewChange,
67
- miniHint, allowSide = false, children,
91
+ miniHint, allowSide = true, children,
68
92
  }: {
69
93
  /** "Gem assistant" in the admin. Names the region, the popup header and the status bar. */
70
94
  title: string;
@@ -86,7 +110,8 @@ export function AssistantHost({
86
110
  /**
87
111
  * Offer the full-height SIDE panel (01/10/2026): a "Pin to the side" button in the popup, "Float as a popup" in the
88
112
  * panel. The page makes room for it — `.gemboss-platform-main` pads by `--gb-assistant-side-w`; a surface with its
89
- * own layout reads the same variable. Off by default, so a surface that has not laid out for it never meets it.
113
+ * own layout reads the same variable. ON by default since 04/10/2026 (Chris: the side panel is every surface's default
114
+ * shape); a surface whose layout cannot make room passes `allowSide={false}` and gets the popup instead.
90
115
  */
91
116
  allowSide?: boolean;
92
117
  children?: ReactNode;
@@ -192,8 +217,16 @@ export function AssistantHost({
192
217
  }, []);
193
218
 
194
219
  // The provider gates every way into the side panel on this, so a host that never offers it never draws it.
220
+ // …and neither does a window where the panel cannot stand beside the page (04/10/2026). Below 1100 it covers the
221
+ // content area (or the screen) and the popup hides its pin (assistant.css); with the side panel now the DEFAULT shape,
222
+ // a first open at 768 went straight into that covering panel (measured on gemwatcher: 528px wide over the whole
223
+ // page). There the person gets the popup; the reducer puts the panel back when the window is wide again.
195
224
  const { setSideAllowed } = dock;
196
- useEffect(() => { setSideAllowed(allowSide); }, [allowSide, setSideAllowed]);
225
+ const sideFits = useSideFits();
226
+ // Before paint: the admin mounts this host only after the first open, so that open was decided while the panel was
227
+ // not yet allowed; registering after paint drew the stand-in popup for a frame before the reducer swapped in the
228
+ // panel (review #3744).
229
+ useBeforePaint(() => { setSideAllowed(allowSide && sideFits); }, [allowSide, sideFits, setSideAllowed]);
197
230
 
198
231
  // ── Width drag (side only) ───────────────────────────────────────────────
199
232
  // The panel's LEFT edge is a `.ui-resizer` (the fleet's one seam, @gemboss/ui ui.css): dragging it left widens the
@@ -371,7 +404,9 @@ export function AssistantHost({
371
404
  <span className="gb-dock-sr">{L.newChat}</span>
372
405
  </button>
373
406
  ) : null}
374
- {isDock && allowSide ? (
407
+ {/* On `dock.sideAllowed`, not the prop: it already folds in the window width (SIDE_FITS_QUERY), so the pin
408
+ never shows where pressing it would only drop the working shape to "dock" (review #3744). */}
409
+ {isDock && dock.sideAllowed ? (
375
410
  <button type="button" className="gb-dock-icon gb-dock-action gb-dock-pinside" title={L.pinSide} onClick={dock.openSide}>
376
411
  {/* A window with its right third filled: the panel it will become. */}
377
412
  <svg viewBox="0 0 20 20" aria-hidden>
@@ -16,9 +16,9 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useR
16
16
  import {
17
17
  assistantDockReducer,
18
18
  initialAssistantDockState,
19
- DOCK_DEFAULT_HEIGHT,
20
19
  DOCK_DEFAULT_OFFSET_RIGHT,
21
20
  openingDockHeight,
21
+ type AssistantFloatShape,
22
22
  SIDE_DEFAULT_WIDTH,
23
23
  type AssistantDockState,
24
24
  } from "./dock";
@@ -33,7 +33,9 @@ export interface AssistantOptions {
33
33
 
34
34
  /** Storage keys. `lastMode` is the last shape the seller actually used: "full", "dock" or "side" — nothing
35
35
  * else is worth reopening as. Module-level so the callbacks below stay identity-stable. */
36
- const storageKey = (prefix: string, k: "dockHeight" | "dockOffsetRight" | "lastMode" | "sideWidth" | "floatShape") => `${prefix}.${k}`;
36
+ // `shape`, not the old `floatShape` key (04/10/2026): that one was written on every mount with the then-default "dock",
37
+ // so it records what every browser was GIVEN, not what anyone chose — reading it would keep the old default forever.
38
+ const storageKey = (prefix: string, k: "dockHeight" | "dockOffsetRight" | "lastMode" | "sideWidth" | "shape") => `${prefix}.${k}`;
37
39
 
38
40
  /**
39
41
  * The width of the content area the side panel sits in: the window minus the rail. Measured from `main`'s LEFT
@@ -117,11 +119,10 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
117
119
  useEffect(() => {
118
120
  try {
119
121
  const stored = window.localStorage.getItem(storageKey(opts.current.storagePrefix, "dockHeight"));
120
- // Opens no closer to the topbar than DOCK_OPEN_GAP, even when a past drag saved the ceiling
121
- // (dock.ts, 03/10/2026). The stored number is left as it was: a drag rewrites it.
122
- const height = openingDockHeight(stored === null || stored === "" ? null : Number(stored), window.innerHeight);
123
- requestedHeight.current = height;
124
- dispatch({ type: "resize", height, viewport: window.innerHeight });
122
+ // No drag saved: null, resolved against the CURRENT window on every resize (dock.ts `openingDockHeight`).
123
+ const raw = stored === null || stored === "" ? NaN : Number(stored);
124
+ requestedHeight.current = Number.isFinite(raw) && raw > 0 ? raw : null;
125
+ dispatch({ type: "resize", height: openingDockHeight(requestedHeight.current, window.innerHeight), viewport: window.innerHeight });
125
126
  // `Number(null)` is 0, not NaN — so reading a key that was never written used to slide the
126
127
  // popup flush against the window edge on every fresh browser. Measured: inline `right: 0px`.
127
128
  const storedOffset = window.localStorage.getItem(storageKey(opts.current.storagePrefix, "dockOffsetRight"));
@@ -133,7 +134,10 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
133
134
  dispatch({ type: "slide", offsetRight: rawOffset, ...offsetBounds() });
134
135
  }
135
136
  // The working shape outlives a reload: `lastMode` cannot stand in for it, since opening full screen overwrites it.
136
- if (window.localStorage.getItem(storageKey(opts.current.storagePrefix, "floatShape")) === "side") dispatch({ type: "restore-float", shape: "side" });
137
+ const shape = window.localStorage.getItem(storageKey(opts.current.storagePrefix, "shape"));
138
+ if (shape === "side" || shape === "dock") dispatch({ type: "restore-float", shape });
139
+ // The pre-0.8.0 key: never read again (it holds the old default, not a choice) — drop it so storage has one answer.
140
+ window.localStorage.removeItem(`${opts.current.storagePrefix}.floatShape`);
137
141
  const storedSide = Number(window.localStorage.getItem(storageKey(opts.current.storagePrefix, "sideWidth")));
138
142
  if (Number.isFinite(storedSide) && storedSide > 0) {
139
143
  requestedSide.current = storedSide;
@@ -149,7 +153,7 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
149
153
  * window baked the smaller number in and growing it back never recovered. Measured 2026-09-12:
150
154
  * 800 → window 600 → 512 → window 1200 → still 512. Same trap the persisted value already avoids.
151
155
  */
152
- const requestedHeight = useRef(DOCK_DEFAULT_HEIGHT);
156
+ const requestedHeight = useRef<number | null>(null);
153
157
  /** Same reasoning as `requestedHeight`: persist what the seller ASKED for, clamp only to draw. */
154
158
  const requestedOffset = useRef(DOCK_DEFAULT_OFFSET_RIGHT);
155
159
  /** …and the same for the side panel's width: a width chosen on a 27" screen comes back there. */
@@ -175,7 +179,7 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
175
179
  // got taller again must give the seller their height back.
176
180
  useEffect(() => {
177
181
  const onResize = () => {
178
- dispatch({ type: "resize", height: requestedHeight.current, viewport: window.innerHeight });
182
+ dispatch({ type: "resize", height: openingDockHeight(requestedHeight.current, window.innerHeight), viewport: window.innerHeight });
179
183
  // …and sideways too. `requestedOffset` was being written and never read, so a window that got
180
184
  // narrower left the popup where a wider one had allowed it: on the rail, or past the edge.
181
185
  dispatch({ type: "slide", offsetRight: requestedOffset.current, ...offsetBounds() });
@@ -187,11 +191,18 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
187
191
 
188
192
  const setDockHeight = useCallback((height: number) => {
189
193
  const viewport = typeof window === "undefined" ? 900 : window.innerHeight;
190
- requestedHeight.current = Math.round(height);
194
+ // Dragged (or Home) to the ceiling = "as tall as the window allows": back to AUTO, not this window's number — a
195
+ // saved ceiling kept the popup at 632 after a 700px window was maximised (review #3744).
196
+ const auto = Math.round(height) >= openingDockHeight(null, viewport);
197
+ requestedHeight.current = auto ? null : Math.round(height);
191
198
  dispatch({ type: "resize", height, viewport });
192
199
  // The RAW request, not the clamped result: dragging to the ceiling in a short window used to
193
200
  // save that ceiling, so the popup came back permanently smaller on a tall screen.
194
- try { window.localStorage.setItem(storageKey(opts.current.storagePrefix, "dockHeight"), String(Math.round(height))); } catch { /* best-effort */ }
201
+ try {
202
+ const k = storageKey(opts.current.storagePrefix, "dockHeight");
203
+ if (auto) window.localStorage.removeItem(k);
204
+ else window.localStorage.setItem(k, String(Math.round(height)));
205
+ } catch { /* best-effort */ }
195
206
  }, []);
196
207
 
197
208
  const setSideWidth = useCallback((width: number) => {
@@ -216,9 +227,15 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
216
227
  * Only `full`, `dock` and `side` are recorded. `mini` and `hidden` are ways of putting something away, not
217
228
  * ways of working in it; reopening into a collapsed bar would answer a click with almost nothing.
218
229
  */
219
- useEffect(() => {
220
- try { window.localStorage.setItem(storageKey(opts.current.storagePrefix, "floatShape"), state.floatShape); } catch { /* best-effort */ }
221
- }, [state.floatShape]);
230
+ // The working shape is written ONLY when the person picks it (`openDock` / `openSide` below — the popup's pin and the
231
+ // panel's "Float as a popup"). An effect on `floatShape` wrote the initial default on every mount, i.e. what each
232
+ // browser was GIVEN (the flaw that retired the old `floatShape` key), and under StrictMode's double effect run that
233
+ // write landed between two restores and turned a saved "dock" back into "side" on every reload (review #3744).
234
+ const sideAllowedNow = useRef(false);
235
+ sideAllowedNow.current = state.sideAllowed;
236
+ const saveShape = useCallback((shape: AssistantFloatShape) => {
237
+ try { window.localStorage.setItem(storageKey(opts.current.storagePrefix, "shape"), shape); } catch { /* best-effort */ }
238
+ }, []);
222
239
 
223
240
  useEffect(() => {
224
241
  if (state.presentation !== "full" && state.presentation !== "dock" && state.presentation !== "side") return;
@@ -244,10 +261,16 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
244
261
  if (opts.current.isOnFullPage()) return;
245
262
  let last = "";
246
263
  try { last = window.localStorage.getItem(storageKey(opts.current.storagePrefix, "lastMode")) ?? ""; } catch { /* private mode */ }
247
- if (last !== "dock" && last !== "side") return;
264
+ // The RAIL's Assistant item (`.gnav-assistant`): only someone who last worked FULL screen follows it. Everyone
265
+ // else — including a browser that never opened it — gets the working shape back (`floatShape`, the side panel by
266
+ // default since 04/10/2026), not the `lastMode` "dock" the old default wrote for everybody.
267
+ // Any OTHER link to the full chat ("Open in chat" in a page's content) keeps the old rule: it navigates unless
268
+ // the person last worked in the popup or the panel (review #3744: it was swallowed for every fresh browser).
269
+ const rail = !!anchor.closest(".gnav-assistant");
270
+ if (rail ? last === "full" : last !== "dock" && last !== "side") return;
248
271
  e.preventDefault();
249
272
  e.stopPropagation();
250
- dispatch({ type: last });
273
+ dispatch({ type: "reopen" });
251
274
  // The shell closes its mobile nav drawer when the ROUTE changes. This click deliberately does
252
275
  // not change the route, so on a phone the drawer stayed open on top of the popup it had just
253
276
  // opened — measured at 390px, `.gnav-scroll` sitting over the composer. Say it out loud
@@ -295,8 +318,9 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
295
318
  // fire a spurious "the run ended" on every state change.
296
319
  const enterFull = useCallback(() => dispatch({ type: "enter-full" }), []);
297
320
  const leaveFull = useCallback(() => dispatch({ type: "leave-full" }), []);
298
- const openDock = useCallback(() => dispatch({ type: "dock" }), []);
299
- const openSide = useCallback(() => dispatch({ type: "side" }), []);
321
+ const openDock = useCallback(() => { dispatch({ type: "dock" }); saveShape("dock"); }, [saveShape]);
322
+ // Not offered here (narrow window, host without the panel): the reducer gives the popup and nothing is recorded.
323
+ const openSide = useCallback(() => { dispatch({ type: "side" }); if (sideAllowedNow.current) saveShape("side"); }, [saveShape]);
300
324
  const reopen = useCallback(() => dispatch({ type: "reopen" }), []);
301
325
  const setSideAllowed = useCallback((allowed: boolean) => dispatch({ type: "side-allowed", allowed }), []);
302
326
  const minimize = useCallback(() => dispatch({ type: "minimize" }), []);
package/src/kit-plain.css CHANGED
@@ -61,23 +61,24 @@
61
61
  Link: `<a class="ui-link" href>` for a link inside text. Iris (--gemboss-accent-strong, 6.2:1 on white), underlined
62
62
  so it is not told by colour alone (WCAG 1.4.1); visited looks the same (these are tools, not articles). Markdown or
63
63
  other generated HTML you cannot add classes to: wrap it in `.ui-prose` and its bare `<a>` get the same look.
64
+ `.ui-prose` links sit at :where() specificity (0.7.3), so any component's own link rule wins inside it.
64
65
 
65
66
  Media card: `<article class="ui-card ui-card--media"><a|button class="ui-card__media"><img alt=""></…>
66
67
  <div class="ui-card__body">…</div><div class="ui-card__foot">…</div></article>`. The picture is 16:10 and
67
68
  cropped to fill; the card has no padding of its own, the body and foot do. */
68
69
  .ui-link,
69
- .ui-prose a:not([class]) {
70
+ .ui-prose :where(a:not([class])) {
70
71
  color: var(--gemboss-accent-strong, #3a44de);
71
72
  text-decoration: underline;
72
73
  text-decoration-thickness: from-font;
73
74
  text-underline-offset: 2px;
74
75
  }
75
76
  .ui-link:visited,
76
- .ui-prose a:not([class]):visited { color: var(--gemboss-accent-strong, #3a44de); }
77
+ .ui-prose :where(a:not([class])):visited { color: var(--gemboss-accent-strong, #3a44de); }
77
78
  .ui-link:hover,
78
- .ui-prose a:not([class]):hover { color: var(--gemboss-accent, #5661f5); text-decoration-thickness: 2px; }
79
+ .ui-prose :where(a:not([class])):hover { color: var(--gemboss-accent, #5661f5); text-decoration-thickness: 2px; }
79
80
  .ui-link:focus-visible,
80
- .ui-prose a:not([class]):focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: 2px; border-radius: 2px; }
81
+ .ui-prose :where(a:not([class])):focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: 2px; border-radius: 2px; }
81
82
 
82
83
  .ui-card--media { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
83
84
  .ui-card__media {
@@ -96,13 +97,16 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
96
97
  }
97
98
 
98
99
  /* Popup body and foot: `<div class="ui-modal__body">…</div><div class="ui-modal__foot"><button class="ui-btn">…</button></div>`.
99
- ui.css never styled these, and the admin does not load this file: its Modal.tsx children carry their own padding
100
- (or none). Every non-React surface padded them by hand (GemFactory: 16/20 and a hairline above the actions). kit-plain.js's openModal writes exactly this shape. */
101
- .ui-modal__body { padding: 16px 20px; }
102
- .ui-modal__foot {
103
- display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;
104
- padding: 12px 20px; border-top: 1px solid var(--ui-border);
105
- }
100
+ Their padding moved to ui.css in 0.7.3 (the admin loads that file, not this one). kit-plain.js's openModal writes
101
+ exactly this shape. What stays here is what only the plain-HTML popups use:
102
+
103
+ A head that carries more than a title (a large popup's subtitle and its own actions, GemFactory's detail popup):
104
+ `<div class="ui-modal__head"><div class="ui-modal__title">Mega menu<span class="ui-modal__subtitle">chrome/header</span>
105
+ </div><div class="ui-modal__actions"><button class="ui-btn">Copy</button></div><button class="ui-modal__close">…` */
106
+ .ui-modal__subtitle { display: block; margin-top: 2px; font-size: var(--ui-fs-caption); line-height: 16px; font-weight: 450; color: var(--ui-text-muted); overflow-wrap: anywhere; }
107
+ .ui-modal__actions { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-2); }
108
+ /* An icon button in the popup's head or foot is square at the popup's 28 (ui.css gives those buttons 0/12 padding). */
109
+ :is(.ui-modal__head, .ui-modal__foot) .ui-btn--icon { width: 28px; padding: 0; }
106
110
  /* A popup taller than the screen: the body scrolls, the head and foot stay, so the actions are always reachable
107
111
  (320×568 with a long form put Huỷ/Xoá below the fold, unreachable under the locked page; review #3633). */
108
112
  .ui-overlay > .ui-modal { display: flex; flex-direction: column; max-height: calc(100dvh - 40px); }
@@ -374,7 +378,7 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
374
378
  <div class="ui-modal__foot">…</div></dialog>` */
375
379
  dialog.ui-modal { padding: 0; max-width: calc(100% - 40px); max-height: calc(100dvh - 40px); color: var(--ui-text); }
376
380
  dialog.ui-modal[open] { display: flex; flex-direction: column; }
377
- dialog.ui-modal::backdrop { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px); }
381
+ dialog.ui-modal::backdrop { background: rgba(0, 0, 0, 0.5); } /* the overlay's own scrim (0.7.3: Polaris' .5, no blur) */
378
382
 
379
383
  /* ── 0.5.1: what the first surfaces on 0.5.0 still drew themselves (GemFactory, content-radar, 03/10/2026) ─────────── */
380
384
 
@@ -435,3 +439,18 @@ button.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-off
435
439
  /* Class doubled: the chosen card is usually the one under the pointer, and `button.ui-metric:hover` (0,2,1) would
436
440
  repaint its edge grey the moment it is clicked (measured on gemwatcher Radar). */
437
441
  .ui-metric.ui-metric:is([aria-pressed="true"], [aria-current="page"]) { border-color: var(--gemboss-accent); box-shadow: inset 0 0 0 1px var(--gemboss-accent); }
442
+
443
+ /* 0.7.3: numbered pager + ui-more, for the plain surfaces (the admin uses Polaris Pagination). Markup and rationale:
444
+ README "Long lists". Here, not in ui.css: ui.css is exported verbatim into the admin, under a 90 KB budget. */
445
+ .ui-pager > a, .ui-pager > [aria-disabled="true"] { min-width: 32px; justify-content: center; box-sizing: border-box; gap: 4px; }
446
+ .ui-pager > a[aria-current="page"] { background: var(--ui-text); border-color: var(--ui-text); color: var(--ui-surface); font-size: var(--ui-fs-body); font-weight: 600; }
447
+ .ui-pager > a[aria-current="page"]:hover { background: var(--ui-text); }
448
+ .ui-pager > [aria-disabled="true"] { display: inline-flex; align-items: center; min-height: 32px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-md); color: var(--ui-text-muted); font-size: var(--ui-fs-body); opacity: .55; cursor: default; }
449
+ .ui-pager > [aria-disabled="true"]:hover { background: none; }
450
+ .ui-pager__gap { min-width: 16px; text-align: center; color: var(--ui-text-muted); }
451
+ .ui-pager__range { margin-left: auto; color: var(--ui-text-muted); font-size: var(--ui-fs-body); font-variant-numeric: tabular-nums; }
452
+ .ui-more { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 12px; padding: 16px 0; color: var(--ui-text-muted); font-size: var(--ui-fs-body); text-align: center; }
453
+ .ui-more[hidden] { display: none; }
454
+ @media (pointer: coarse), (max-width: 47.9975em) { .ui-pager > a, .ui-pager > [aria-disabled="true"] { min-width: 44px; min-height: 44px; } }
455
+ /* Phone: 11 targets of 44px wrapped into 3-4 rows at 320 (measured). Keep Previous · current · Next; the range wraps under. */
456
+ @media (max-width: 35.9975em) { .ui-pager > a:not([rel]):not([aria-current]):not([aria-disabled]), .ui-pager__gap { display: none; } }
package/src/ui.css CHANGED
@@ -274,10 +274,11 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
274
274
 
275
275
  /* ── Modal ────────────────────────────────────────────────────────────────────
276
276
  Rendered INLINE (not portaled): a portal escapes the .gemboss-admin-shell token
277
- scope and would render light. Kept in-tree so --ui-* resolves to the surface. */
277
+ scope and would render light. Kept in-tree so --ui-* resolves to the surface.
278
+ 0.7.3: Polaris Modal's density (#1099, README "Popup density"). Popup only: --ui-control-h stays 32. */
278
279
  .ui-overlay {
279
280
  position: fixed; inset: 0; z-index: 500;
280
- background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px);
281
+ background: rgba(0, 0, 0, 0.5);
281
282
  display: grid; place-items: center; padding: 20px;
282
283
  }
283
284
  .ui-modal {
@@ -287,30 +288,39 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
287
288
  }
288
289
  /* Head = title + the one close button every modal used to draw for itself. */
289
290
  .ui-modal__head {
290
- display: flex; align-items: flex-start; gap: 16px;
291
- padding: 16px 20px; border-bottom: 1px solid var(--ui-border);
292
- font-size: 15px; font-weight: 620; color: var(--ui-text);
291
+ display: flex; align-items: center; gap: 12px;
292
+ padding: 12px 16px; border-bottom: 1px solid var(--ui-border);
293
+ font-size: 14px; line-height: 20px; font-weight: 650; color: var(--ui-text);
293
294
  }
294
295
  .ui-modal__title { min-width: 0; flex: 1 1 auto; }
295
296
  .ui-modal__close {
296
297
  flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
297
- width: 44px; height: 44px; margin: -8px -8px -8px 0; /* a 44px target that does not grow the head */
298
+ width: 28px; height: 28px; padding: 0;
298
299
  border: 0; border-radius: var(--ui-radius-md, 8px); background: transparent;
299
300
  color: var(--ui-text-muted); cursor: pointer;
300
301
  }
301
302
  .ui-modal__close svg { width: 20px; height: 20px; }
302
303
  .ui-modal__close:hover { background: var(--ui-surface-2); color: var(--ui-text); }
303
304
  .ui-modal__close:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: -2px; }
305
+ /* Here, not in kit-plain.css: the admin loads only this file (0.7.3). */
306
+ .ui-modal__body { padding: 16px; }
307
+ .ui-modal__foot {
308
+ display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;
309
+ padding: 12px 16px; border-top: 1px solid var(--ui-border);
310
+ }
311
+ :is(.ui-modal__head, .ui-modal__foot) .ui-btn { height: 28px; padding: 0 12px; font-size: var(--ui-fs-caption); }
312
+ /* 44px under a thumb or at phone width; -8px keeps the head 53. */
313
+ @media (pointer: coarse), (max-width: 47.9975em) {
314
+ .ui-modal__close { width: 44px; height: 44px; margin: -8px -8px -8px 0; }
315
+ :is(.ui-modal__head, .ui-modal__foot) .ui-btn { min-height: 44px; }
316
+ }
304
317
 
305
- /* `size="lg"` — the screen-shaped dialog. Only the HOOK lives here: this sheet is imported by the
306
- root layout, so every rule in it is paid on every screen, and the geometry of a dialog with one
307
- consumer belongs in that consumer's chunk (see SettingsDialog.module.css). */
318
+ /* `size="lg"`: only the HOOK here (this sheet is paid on every screen); its geometry is in
319
+ SettingsDialog.module.css, the one consumer's chunk. */
308
320
  .ui-modal--lg { position: relative; }
309
321
 
310
- /* The kit's multi-line field joins the ONE field family (01/10/2026, Chris: "form control đồng bộ"):
311
- same input-border token, hover and focus ring as `gemboss-field` and `.ui-select`. Before, it drew a
312
- lighter --ui-border box at 14px with no focus ring, so on GemFactory's compose form a textarea sat
313
- beside selects of a darker border and text fields of a third look (measured on prod). */
322
+ /* The kit's multi-line field is the ONE field family (01/10/2026, Chris: "form control đồng bộ"): same
323
+ border token, hover and focus ring as `gemboss-field` / `.ui-select` (it drew a third look on GemFactory). */
314
324
  .ui-textarea {
315
325
  width: 100%; min-height: 96px; resize: vertical; box-sizing: border-box;
316
326
  background: var(--p-color-input-bg-surface, var(--gemboss-field-bg));