@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 +77 -5
- package/catalog/index.html +39 -2
- package/gemboss-ui.css +56 -27
- package/package.json +1 -1
- package/src/assistant/dock.ts +19 -18
- package/src/assistant/host.tsx +40 -5
- package/src/assistant/provider.tsx +43 -19
- package/src/kit-plain.css +31 -12
- package/src/ui.css +23 -13
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
|
|
89
|
-
|
|
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
|
|
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`):
|
package/catalog/index.html
CHANGED
|
@@ -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.
|
|
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><button></code> lẫn <code><a></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><div class="cat-row">
|
|
33
33
|
<button type="button" class="ui-btn">Lưu nháp</button>
|
|
@@ -248,7 +248,44 @@ body{margin:0;background:var(--gemboss-app-bg,#f1f1f1);color:var(--gemboss-text)
|
|
|
248
248
|
<span aria-current="page">2 / 9</span>
|
|
249
249
|
<a href="#" rel="next">Sau <span aria-hidden="true">›</span>
|
|
250
250
|
</a>
|
|
251
|
-
</nav></code></pre></details></figure><figure class="cat-demo"><figcaption>
|
|
251
|
+
</nav></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><a role=link aria-disabled></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><nav class="ui-pager" aria-label="Pages">
|
|
252
|
+
<a href="#" rel="prev">
|
|
253
|
+
<span aria-hidden="true">‹</span> Previous</a>
|
|
254
|
+
<a href="#">1</a>
|
|
255
|
+
<span class="ui-pager__gap" aria-hidden="true">…</span>
|
|
256
|
+
<a href="#">4</a>
|
|
257
|
+
<a href="#" aria-current="page">5</a>
|
|
258
|
+
<a href="#">6</a>
|
|
259
|
+
<span class="ui-pager__gap" aria-hidden="true">…</span>
|
|
260
|
+
<a href="#">14</a>
|
|
261
|
+
<a href="#" rel="next">Next <span aria-hidden="true">›</span>
|
|
262
|
+
</a>
|
|
263
|
+
<span class="ui-pager__range">201–250 of 655</span>
|
|
264
|
+
</nav>
|
|
265
|
+
<nav class="ui-pager" aria-label="Pages">
|
|
266
|
+
<a role="link" aria-disabled="true">
|
|
267
|
+
<span aria-hidden="true">‹</span> Previous</a>
|
|
268
|
+
<a href="#" aria-current="page">1</a>
|
|
269
|
+
<a href="#">2</a>
|
|
270
|
+
<a href="#">3</a>
|
|
271
|
+
<a href="#" rel="next">Next <span aria-hidden="true">›</span>
|
|
272
|
+
</a>
|
|
273
|
+
<span class="ui-pager__range">1–50 of 131</span>
|
|
274
|
+
</nav></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><div class="ui-more">
|
|
275
|
+
<button type="button" class="ui-btn">Load more</button>
|
|
276
|
+
</div>
|
|
277
|
+
<div class="ui-more">
|
|
278
|
+
<span class="ui-spinner" aria-hidden="true">
|
|
279
|
+
</span>
|
|
280
|
+
<span role="status">Loading…</span>
|
|
281
|
+
</div>
|
|
282
|
+
<div class="ui-more">
|
|
283
|
+
<span role="alert">Couldn't load.</span>
|
|
284
|
+
<button type="button" class="ui-btn">Try again</button>
|
|
285
|
+
</div>
|
|
286
|
+
<div class="ui-more">
|
|
287
|
+
<span>That's everything</span>
|
|
288
|
+
</div></code></pre></details></figure><figure class="cat-demo"><figcaption>Link (<code>ui-link</code>) · HTML sinh ra (<code>ui-prose</code>: mọi <code><a></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><p>Xem <a class="ui-link" href="#">báo cáo đối thủ</a> trước khi gửi.</p>
|
|
252
289
|
<div class="ui-prose">
|
|
253
290
|
<p>Đoạn markdown: nguồn ở <a href="#">trang tích hợp của Replo</a>.</p>
|
|
254
291
|
</div></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><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(min(240px,100%),1fr));gap:12px">
|
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:
|
|
5
|
-
* sources sha256:
|
|
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.
|
|
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:
|
|
474
|
-
padding: 16px
|
|
475
|
-
font-size:
|
|
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:
|
|
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"
|
|
489
|
-
|
|
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
|
|
494
|
-
|
|
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
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
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.
|
|
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.
|
|
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",
|
package/src/assistant/dock.ts
CHANGED
|
@@ -132,25 +132,18 @@ export function clampDockHeight(height: number, viewport: number): number {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
*
|
|
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
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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 :
|
|
153
|
-
return clampDockHeight(
|
|
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
|
-
|
|
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:
|
|
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 };
|
package/src/assistant/host.tsx
CHANGED
|
@@ -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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
requestedHeight.current
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
291
|
-
padding: 16px
|
|
292
|
-
font-size:
|
|
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:
|
|
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"
|
|
306
|
-
|
|
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
|
|
311
|
-
|
|
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));
|