@gemboss/ui 0.7.2 → 0.8.1
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 +127 -6
- package/catalog/index.html +234 -6
- package/gemboss-ui.css +82 -27
- package/package.json +1 -1
- package/src/app-shell.css +26 -0
- package/src/assistant/dock.ts +19 -18
- package/src/assistant/host.tsx +40 -5
- package/src/assistant/provider.tsx +43 -19
- package/src/index.ts +3 -0
- package/src/kit-plain.css +31 -12
- package/src/page.tsx +35 -0
- package/src/ui.css +23 -13
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: 22b5d0daf43ab644a6a9bffef08352a93cc5ca1c
|
|
5
|
+
* sources sha256: 7d836f2fc37705cee220b28af995f1e1006b2cb1a41e0ac162c29fa9276f70d3
|
|
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);
|
|
@@ -2144,6 +2154,32 @@ body.gemboss-assistant-unread .gnav-item.gnav-assistant::after {
|
|
|
2144
2154
|
/* A phone has no ⌘ key, and the box needs every pixel the bar has left. */
|
|
2145
2155
|
@media (max-width: 460px) { .gemboss-topbar-search-kbd { display: none; } }
|
|
2146
2156
|
|
|
2157
|
+
/* ── Page width (0.8.1) ──────────────────────────────────────────────────────────────────────────────
|
|
2158
|
+
* Chris 04/10/2026: the rail on the left, and on the right a content column with ONE max width across every
|
|
2159
|
+
* service, not edge to edge. The admin's tiers (gemboss-shell.css, `.gemboss-w-*` around a Polaris Page), same
|
|
2160
|
+
* numbers; `page-width.test.tsx` fails if the two disagree:
|
|
2161
|
+
* .ui-page → --ui-content-max (900px, ui.css): lists, tables, card grids, dashboards
|
|
2162
|
+
* .ui-page--feed → 720px: read top to bottom (activity, logs, timelines)
|
|
2163
|
+
* .ui-page--form → 662px: one column of fields (Polaris narrowWidth)
|
|
2164
|
+
* .ui-page--canvas → 1440px: content measured to be wider than a reading column (admin: the funnel path)
|
|
2165
|
+
* .ui-page--full → no cap: a board or an editor that is the screen. Say why where you use it.
|
|
2166
|
+
* A cap only exists where it is SMALLER than the room it sits in. Right of the 240px rail with main's 24px padding,
|
|
2167
|
+
* 900 bites from 1188px up (1280 → 992 of room); 1440 bites only from ~1730px. Below that the column is simply the
|
|
2168
|
+
* room, so the tier never causes a horizontal scroll.
|
|
2169
|
+
* Here, not in ui.css: ui.css is exported verbatim into the admin under a 90 KB budget (89.8 KB at 0.7.3), and the
|
|
2170
|
+
* admin draws its tiers on Polaris' Page instead. Fixed px, not %: a % cap grows the line length with the monitor. */
|
|
2171
|
+
.ui-page {
|
|
2172
|
+
box-sizing: border-box;
|
|
2173
|
+
width: 100%;
|
|
2174
|
+
min-width: 0;
|
|
2175
|
+
max-width: var(--ui-content-max, 900px);
|
|
2176
|
+
margin-inline: auto;
|
|
2177
|
+
}
|
|
2178
|
+
.ui-page--feed { max-width: 720px; }
|
|
2179
|
+
.ui-page--form { max-width: 662px; }
|
|
2180
|
+
.ui-page--canvas { max-width: 1440px; }
|
|
2181
|
+
.ui-page--full { max-width: none; }
|
|
2182
|
+
|
|
2147
2183
|
/* ═══ @gemboss/ui src/auth.css ═══ */
|
|
2148
2184
|
/* The GemBoss sign-in screen — @gemboss/ui (28/09/2026).
|
|
2149
2185
|
*
|
|
@@ -3376,23 +3412,24 @@ body.gemboss-assistant-unread .gnav-item.gnav-assistant::after {
|
|
|
3376
3412
|
Link: `<a class="ui-link" href>` for a link inside text. Iris (--gemboss-accent-strong, 6.2:1 on white), underlined
|
|
3377
3413
|
so it is not told by colour alone (WCAG 1.4.1); visited looks the same (these are tools, not articles). Markdown or
|
|
3378
3414
|
other generated HTML you cannot add classes to: wrap it in `.ui-prose` and its bare `<a>` get the same look.
|
|
3415
|
+
`.ui-prose` links sit at :where() specificity (0.7.3), so any component's own link rule wins inside it.
|
|
3379
3416
|
|
|
3380
3417
|
Media card: `<article class="ui-card ui-card--media"><a|button class="ui-card__media"><img alt=""></…>
|
|
3381
3418
|
<div class="ui-card__body">…</div><div class="ui-card__foot">…</div></article>`. The picture is 16:10 and
|
|
3382
3419
|
cropped to fill; the card has no padding of its own, the body and foot do. */
|
|
3383
3420
|
.ui-link,
|
|
3384
|
-
.ui-prose a:not([class]) {
|
|
3421
|
+
.ui-prose :where(a:not([class])) {
|
|
3385
3422
|
color: var(--gemboss-accent-strong, #3a44de);
|
|
3386
3423
|
text-decoration: underline;
|
|
3387
3424
|
text-decoration-thickness: from-font;
|
|
3388
3425
|
text-underline-offset: 2px;
|
|
3389
3426
|
}
|
|
3390
3427
|
.ui-link:visited,
|
|
3391
|
-
.ui-prose a:not([class]):visited { color: var(--gemboss-accent-strong, #3a44de); }
|
|
3428
|
+
.ui-prose :where(a:not([class])):visited { color: var(--gemboss-accent-strong, #3a44de); }
|
|
3392
3429
|
.ui-link:hover,
|
|
3393
|
-
.ui-prose a:not([class]):hover { color: var(--gemboss-accent, #5661f5); text-decoration-thickness: 2px; }
|
|
3430
|
+
.ui-prose :where(a:not([class])):hover { color: var(--gemboss-accent, #5661f5); text-decoration-thickness: 2px; }
|
|
3394
3431
|
.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; }
|
|
3432
|
+
.ui-prose :where(a:not([class])):focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: 2px; border-radius: 2px; }
|
|
3396
3433
|
|
|
3397
3434
|
.ui-card--media { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
|
|
3398
3435
|
.ui-card__media {
|
|
@@ -3411,13 +3448,16 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
|
|
|
3411
3448
|
}
|
|
3412
3449
|
|
|
3413
3450
|
/* 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
|
-
}
|
|
3451
|
+
Their padding moved to ui.css in 0.7.3 (the admin loads that file, not this one). kit-plain.js's openModal writes
|
|
3452
|
+
exactly this shape. What stays here is what only the plain-HTML popups use:
|
|
3453
|
+
|
|
3454
|
+
A head that carries more than a title (a large popup's subtitle and its own actions, GemFactory's detail popup):
|
|
3455
|
+
`<div class="ui-modal__head"><div class="ui-modal__title">Mega menu<span class="ui-modal__subtitle">chrome/header</span>
|
|
3456
|
+
</div><div class="ui-modal__actions"><button class="ui-btn">Copy</button></div><button class="ui-modal__close">…` */
|
|
3457
|
+
.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; }
|
|
3458
|
+
.ui-modal__actions { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-2); }
|
|
3459
|
+
/* 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). */
|
|
3460
|
+
:is(.ui-modal__head, .ui-modal__foot) .ui-btn--icon { width: 28px; padding: 0; }
|
|
3421
3461
|
/* A popup taller than the screen: the body scrolls, the head and foot stay, so the actions are always reachable
|
|
3422
3462
|
(320×568 with a long form put Huỷ/Xoá below the fold, unreachable under the locked page; review #3633). */
|
|
3423
3463
|
.ui-overlay > .ui-modal { display: flex; flex-direction: column; max-height: calc(100dvh - 40px); }
|
|
@@ -3689,7 +3729,7 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
|
|
|
3689
3729
|
<div class="ui-modal__foot">…</div></dialog>` */
|
|
3690
3730
|
dialog.ui-modal { padding: 0; max-width: calc(100% - 40px); max-height: calc(100dvh - 40px); color: var(--ui-text); }
|
|
3691
3731
|
dialog.ui-modal[open] { display: flex; flex-direction: column; }
|
|
3692
|
-
dialog.ui-modal::backdrop { background: rgba(0, 0, 0, 0.
|
|
3732
|
+
dialog.ui-modal::backdrop { background: rgba(0, 0, 0, 0.5); } /* the overlay's own scrim (0.7.3: Polaris' .5, no blur) */
|
|
3693
3733
|
|
|
3694
3734
|
/* ── 0.5.1: what the first surfaces on 0.5.0 still drew themselves (GemFactory, content-radar, 03/10/2026) ─────────── */
|
|
3695
3735
|
|
|
@@ -3751,6 +3791,21 @@ button.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-off
|
|
|
3751
3791
|
repaint its edge grey the moment it is clicked (measured on gemwatcher Radar). */
|
|
3752
3792
|
.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
3793
|
|
|
3794
|
+
/* 0.7.3: numbered pager + ui-more, for the plain surfaces (the admin uses Polaris Pagination). Markup and rationale:
|
|
3795
|
+
README "Long lists". Here, not in ui.css: ui.css is exported verbatim into the admin, under a 90 KB budget. */
|
|
3796
|
+
.ui-pager > a, .ui-pager > [aria-disabled="true"] { min-width: 32px; justify-content: center; box-sizing: border-box; gap: 4px; }
|
|
3797
|
+
.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; }
|
|
3798
|
+
.ui-pager > a[aria-current="page"]:hover { background: var(--ui-text); }
|
|
3799
|
+
.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; }
|
|
3800
|
+
.ui-pager > [aria-disabled="true"]:hover { background: none; }
|
|
3801
|
+
.ui-pager__gap { min-width: 16px; text-align: center; color: var(--ui-text-muted); }
|
|
3802
|
+
.ui-pager__range { margin-left: auto; color: var(--ui-text-muted); font-size: var(--ui-fs-body); font-variant-numeric: tabular-nums; }
|
|
3803
|
+
.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; }
|
|
3804
|
+
.ui-more[hidden] { display: none; }
|
|
3805
|
+
@media (pointer: coarse), (max-width: 47.9975em) { .ui-pager > a, .ui-pager > [aria-disabled="true"] { min-width: 44px; min-height: 44px; } }
|
|
3806
|
+
/* Phone: 11 targets of 44px wrapped into 3-4 rows at 320 (measured). Keep Previous · current · Next; the range wraps under. */
|
|
3807
|
+
@media (max-width: 35.9975em) { .ui-pager > a:not([rel]):not([aria-current]):not([aria-disabled]), .ui-pager__gap { display: none; } }
|
|
3808
|
+
|
|
3754
3809
|
/* ═══ @gemboss/ui src/chat.css ═══ */
|
|
3755
3810
|
/*
|
|
3756
3811
|
* 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.1",
|
|
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/app-shell.css
CHANGED
|
@@ -139,3 +139,29 @@
|
|
|
139
139
|
@media (pointer: coarse), (max-width: 47.9975em) { .gemboss-topbar-search { height: 44px; } }
|
|
140
140
|
/* A phone has no ⌘ key, and the box needs every pixel the bar has left. */
|
|
141
141
|
@media (max-width: 460px) { .gemboss-topbar-search-kbd { display: none; } }
|
|
142
|
+
|
|
143
|
+
/* ── Page width (0.8.1) ──────────────────────────────────────────────────────────────────────────────
|
|
144
|
+
* Chris 04/10/2026: the rail on the left, and on the right a content column with ONE max width across every
|
|
145
|
+
* service, not edge to edge. The admin's tiers (gemboss-shell.css, `.gemboss-w-*` around a Polaris Page), same
|
|
146
|
+
* numbers; `page-width.test.tsx` fails if the two disagree:
|
|
147
|
+
* .ui-page → --ui-content-max (900px, ui.css): lists, tables, card grids, dashboards
|
|
148
|
+
* .ui-page--feed → 720px: read top to bottom (activity, logs, timelines)
|
|
149
|
+
* .ui-page--form → 662px: one column of fields (Polaris narrowWidth)
|
|
150
|
+
* .ui-page--canvas → 1440px: content measured to be wider than a reading column (admin: the funnel path)
|
|
151
|
+
* .ui-page--full → no cap: a board or an editor that is the screen. Say why where you use it.
|
|
152
|
+
* A cap only exists where it is SMALLER than the room it sits in. Right of the 240px rail with main's 24px padding,
|
|
153
|
+
* 900 bites from 1188px up (1280 → 992 of room); 1440 bites only from ~1730px. Below that the column is simply the
|
|
154
|
+
* room, so the tier never causes a horizontal scroll.
|
|
155
|
+
* Here, not in ui.css: ui.css is exported verbatim into the admin under a 90 KB budget (89.8 KB at 0.7.3), and the
|
|
156
|
+
* admin draws its tiers on Polaris' Page instead. Fixed px, not %: a % cap grows the line length with the monitor. */
|
|
157
|
+
.ui-page {
|
|
158
|
+
box-sizing: border-box;
|
|
159
|
+
width: 100%;
|
|
160
|
+
min-width: 0;
|
|
161
|
+
max-width: var(--ui-content-max, 900px);
|
|
162
|
+
margin-inline: auto;
|
|
163
|
+
}
|
|
164
|
+
.ui-page--feed { max-width: 720px; }
|
|
165
|
+
.ui-page--form { max-width: 662px; }
|
|
166
|
+
.ui-page--canvas { max-width: 1440px; }
|
|
167
|
+
.ui-page--full { max-width: none; }
|
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/index.ts
CHANGED
|
@@ -2,6 +2,9 @@ export {
|
|
|
2
2
|
AppShell, MobileMenuButton, MobileNavBackdrop, Rail, RailBottom, RailCount, RailGroup, RailHeading,
|
|
3
3
|
RailItem, RailScroll, RailSection, RailSep, RailSub, RailSubItem, Shell, ShellMain, Topbar,
|
|
4
4
|
} from "./shell";
|
|
5
|
+
// Page width (0.8.1): its own plain module, so nothing the admin imports from shell.tsx carries it (first-load budget).
|
|
6
|
+
export { PageContainer, pageWidthClass } from "./page";
|
|
7
|
+
export type { PageWidth } from "./page";
|
|
5
8
|
export { GemMark } from "./GemMark";
|
|
6
9
|
export { AuthError, AuthField, AuthFoot, AuthForm, AuthHelp, AuthLayout, PasswordField, AuthSubmit } from "./auth";
|
|
7
10
|
export { BrandLockup } from "./brand";
|
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; } }
|