@gemboss/ui 0.7.1 → 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 +88 -6
- package/catalog/index.html +71 -34
- package/gemboss-ui.css +79 -31
- package/package.json +3 -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/portal.css +14 -62
- package/src/portal.tsx +24 -12
- package/src/theme-dark.css +65 -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: 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).
|
|
@@ -4299,9 +4328,9 @@ button.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-off
|
|
|
4299
4328
|
|
|
4300
4329
|
/* ═══ @gemboss/ui src/portal.css ═══ */
|
|
4301
4330
|
/* ═══ Portal pieces — @gemboss/ui 0.7.0 (04/10/2026) ════════════════════════════════════════════
|
|
4302
|
-
TopbarNav, Avatar, Leaderboard, Podium (portal.tsx), SearchDialog (dialog.tsx), ThemeToggle
|
|
4303
|
-
|
|
4304
|
-
|
|
4331
|
+
TopbarNav, Avatar, Leaderboard, Podium (portal.tsx), SearchDialog (dialog.tsx), ThemeToggle (theme.tsx).
|
|
4332
|
+
Component rules only, no rule on :root, html or body (a test holds that): a surface with its own skin
|
|
4333
|
+
can take this file with gemboss-tokens.css alone. The dark palette is theme-dark.css. */
|
|
4305
4334
|
|
|
4306
4335
|
/* ── TopbarNav: section tabs in the topbar, after the brand (Featurebase's "Feedback · Roadmap"). The
|
|
4307
4336
|
active tab is underlined on the topbar's bottom edge. 44px targets under a finger, 36 under a mouse
|
|
@@ -4347,6 +4376,11 @@ button.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-off
|
|
|
4347
4376
|
}
|
|
4348
4377
|
@media (prefers-reduced-motion: reduce) { .gb-topnav-chevron { transition: none; } }
|
|
4349
4378
|
|
|
4379
|
+
/* Text for screen readers only ("Rank 1:", ", you"). Its own rule, not ui.css's .ui-sr-only: a surface that
|
|
4380
|
+
takes portal.css without the rest of the kit (GemCommunity) would otherwise print it on the page. */
|
|
4381
|
+
.gb-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0);
|
|
4382
|
+
white-space: nowrap; border: 0; }
|
|
4383
|
+
|
|
4350
4384
|
/* ── Avatar: picture or initials on one of eight tones picked from the name. */
|
|
4351
4385
|
.gb-avatar { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 50%; overflow: hidden;
|
|
4352
4386
|
font-weight: 700; line-height: 1; letter-spacing: .01em; user-select: none; }
|
|
@@ -4383,6 +4417,9 @@ a.gb-leaderboard-link:focus-visible { outline: 2px solid var(--gemboss-accent);
|
|
|
4383
4417
|
.gb-leaderboard-name { flex: 1 1 auto; min-width: 0; font-size: 13px; font-weight: 560; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
4384
4418
|
.gb-leaderboard-value { flex: 0 0 auto; font-size: 13px; font-weight: 650; color: var(--gemboss-text-muted); font-variant-numeric: tabular-nums; }
|
|
4385
4419
|
.gb-leaderboard-row.is-first .gb-leaderboard-value { color: #8a5a00; font-weight: 750; }
|
|
4420
|
+
/* The viewer's own row and podium slot: the selected tint, never a colour of its own. */
|
|
4421
|
+
.gb-leaderboard-row.is-current > .gb-leaderboard-link { background: var(--gemboss-active-bg); }
|
|
4422
|
+
.gb-leaderboard-badge { flex: 0 1 auto; min-width: 0; overflow: hidden; }
|
|
4386
4423
|
.gb-leaderboard-all { justify-self: center; display: inline-flex; align-items: center; min-height: 24px; font-size: 12px; font-weight: 600;
|
|
4387
4424
|
color: var(--gemboss-text-muted); text-decoration: none; }
|
|
4388
4425
|
.gb-leaderboard-all:hover { color: var(--gemboss-text-strong); text-decoration: underline; }
|
|
@@ -4393,6 +4430,8 @@ a.gb-leaderboard-link:focus-visible { outline: 2px solid var(--gemboss-accent);
|
|
|
4393
4430
|
background: var(--gemboss-surface); color: var(--gemboss-text); text-decoration: none; text-align: center; }
|
|
4394
4431
|
a.gb-podium-link:hover { background: var(--gemboss-hover); }
|
|
4395
4432
|
.gb-podium-slot { min-width: 0; }
|
|
4433
|
+
/* A podium with fewer than three people keeps its place: second and third stay empty columns. */
|
|
4434
|
+
.gb-podium-slot.is-empty { visibility: hidden; }
|
|
4396
4435
|
.gb-podium-slot--1 .gb-podium-link { padding-top: 22px; padding-bottom: 20px; border-color: color-mix(in srgb, #d4a017 45%, var(--gemboss-divider)); }
|
|
4397
4436
|
.gb-podium-slot--2 .gb-podium-link { padding-bottom: 12px; }
|
|
4398
4437
|
.gb-podium-slot--3 .gb-podium-link { padding-bottom: 8px; }
|
|
@@ -4401,6 +4440,7 @@ a.gb-podium-link:hover { background: var(--gemboss-hover); }
|
|
|
4401
4440
|
.gb-podium-value { font-size: 15px; font-weight: 750; font-variant-numeric: tabular-nums; }
|
|
4402
4441
|
.gb-podium-slot--1 .gb-podium-value { color: #8a5a00; }
|
|
4403
4442
|
.gb-podium-unit { font-size: 11px; font-weight: 500; color: var(--gemboss-text-muted); }
|
|
4443
|
+
.gb-podium-slot.is-current .gb-podium-link { border-color: var(--gemboss-text-strong); background: var(--gemboss-active-bg); }
|
|
4404
4444
|
|
|
4405
4445
|
/* ── SearchDialog: the topbar's search button, and the popup (640 wide, near the top, like Featurebase's). */
|
|
4406
4446
|
.gb-search-trigger, .gb-theme-toggle { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; flex: 0 0 auto;
|
|
@@ -4427,7 +4467,15 @@ dialog.ui-modal.gb-search { width: min(640px, 100%); margin-top: 8vh; max-height
|
|
|
4427
4467
|
font-size: 12px; color: var(--ui-text-muted); }
|
|
4428
4468
|
@media (max-width: 760px) { dialog.ui-modal.gb-search { margin-top: 12px; } .gb-search-foot { display: none; } }
|
|
4429
4469
|
|
|
4430
|
-
/*
|
|
4470
|
+
/* ═══ @gemboss/ui src/theme-dark.css ═══ */
|
|
4471
|
+
/* ═══ Dark palette — @gemboss/ui 0.7.2 ═══════════════════════════════════════════════════════════════
|
|
4472
|
+
Split out of portal.css (04/10/2026). `ThemeToggle` / `themeScript` set data-theme on <html>, and this
|
|
4473
|
+
file turns every --gemboss-* colour dark under it. It lives apart so that a surface which takes only
|
|
4474
|
+
the leaderboard (gemboss-tokens.css + portal.css) does not inherit it: GemCommunity sets
|
|
4475
|
+
data-theme="dark" for its own sign-in pages and remembers it, and the rule
|
|
4476
|
+
`:root[data-theme="dark"] body` here outranks its body rule, so a member who once picked dark there
|
|
4477
|
+
would get a dark page behind light cards. gemboss-ui.css still carries this file. */
|
|
4478
|
+
/* Dark palette. `ThemeToggle` / `themeScript` set data-theme on <html>; a surface without them stays
|
|
4431
4479
|
light. Only colours change: the components above and every kit class read these tokens. */
|
|
4432
4480
|
:root[data-theme="dark"] {
|
|
4433
4481
|
color-scheme: dark;
|
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",
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
"./assistant": "./src/assistant/index.ts",
|
|
27
27
|
"./assistant/dock": "./src/assistant/dock.ts",
|
|
28
28
|
"./assistant.css": "./src/assistant.css",
|
|
29
|
+
"./portal.css": "./src/portal.css",
|
|
30
|
+
"./theme-dark.css": "./src/theme-dark.css",
|
|
29
31
|
"./icon.svg": "./src/icon.svg"
|
|
30
32
|
},
|
|
31
33
|
"files": [
|
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" }), []);
|