@gemboss/ui 0.4.2 → 0.6.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/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@gemboss/ui",
3
- "version": "0.4.2",
3
+ "version": "0.6.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",
7
7
  "sideEffects": [
8
- "*.css"
8
+ "*.css",
9
+ "./src/kit-plain.js"
9
10
  ],
10
11
  "main": "./src/index.ts",
11
12
  "types": "./src/index.ts",
@@ -24,13 +25,15 @@
24
25
  "./kit-plain.js": "./src/kit-plain.js",
25
26
  "./assistant": "./src/assistant/index.ts",
26
27
  "./assistant/dock": "./src/assistant/dock.ts",
27
- "./assistant.css": "./src/assistant.css"
28
+ "./assistant.css": "./src/assistant.css",
29
+ "./icon.svg": "./src/icon.svg"
28
30
  },
29
31
  "files": [
30
32
  "src/**/*.ts",
31
33
  "src/**/*.tsx",
32
34
  "src/*.css",
33
35
  "src/kit-plain.js",
36
+ "src/icon.svg",
34
37
  "!src/**/*.test.*",
35
38
  "gemboss-ui.css",
36
39
  "catalog/index.html",
package/src/app-shell.css CHANGED
@@ -13,6 +13,19 @@
13
13
  * these elements wins, wherever it sits in the cascade. Re-theme with the token, not a body rule.
14
14
  * The admin does not load this file, so its own rendering is untouched. */
15
15
  :where(html, body, button) { font-family: var(--gemboss-font-sans); }
16
+ /* …and the rest of Polaris' body text (02/10/2026): bodyMd, 13px on a 20px line, weight 450, the text colour,
17
+ * antialiased. With the face alone, every surface picked its own size — measured on the sign-in pages that day:
18
+ * 14, 15 and 16px bodies, one in Lora serif — so every line outside a kit class read differently from the admin.
19
+ * Same (0,0,0) as above: a surface that sets its own body text still wins, and should delete that rule.
20
+ * BODY only, never `html`: the root size is what `rem` means, and gemwatcher alone has ~200 rem lengths that a 13px
21
+ * root would shrink by a fifth (review #3665). Polaris does the same: html stays at the browser's 100%. */
22
+ :where(body) {
23
+ font-size: 13px; line-height: 20px; font-weight: 450; color: var(--gemboss-text);
24
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
25
+ }
26
+ /* Headings get their own line (Polaris heading line-heights are ~1.25-1.4): inheriting the body's 20px made a wrapped 22px
27
+ * title overlap itself (gempress, 0.5.1). */
28
+ :where(h1, h2, h3) { letter-spacing: -0.01em; line-height: 1.3; }
16
29
 
17
30
  .gemboss-topbar-start { display: flex; align-items: center; gap: 8px; min-width: 0; }
18
31
  .gemboss-topbar-center { display: flex; justify-content: center; min-width: 0; }
@@ -22,6 +35,10 @@
22
35
  .gemboss-topbar-center { flex: 1 1 auto; justify-content: flex-start; }
23
36
  /* No actions: the empty slot would still take one flex `gap` (8px) off the search at 320px. */
24
37
  .gemboss-topbar-end:empty { display: none; }
38
+ /* Only the search gives. With `min-width: 0` on all three slots, flex shrank them all: the actions slot gave way and its
39
+ * buttons (justify flex-end) spilled LEFT over the search (two 44px targets, assistant + account, overlapped
40
+ * partner-radar's search by 15px at 320 and 360), and the start slot squeezed the gem mark to 16px. */
41
+ .gemboss-topbar-start, .gemboss-topbar-end { flex: 0 0 auto; }
25
42
  }
26
43
 
27
44
  .gnav-heading {
@@ -62,7 +79,11 @@
62
79
  overflow: hidden; text-overflow: ellipsis; min-width: 0;
63
80
  }
64
81
  /* The admin shows only its mark below 460px; so does this. */
65
- @media (max-width: 460px) { .gb-brand-name { display: none; } }
82
+ @media (max-width: 460px) {
83
+ .gb-brand-name { display: none; }
84
+ /* The mark alone is ~21px wide: a 44px target, centred (visual-gate tap_target_link on content-radar at 320, 0.5.1). */
85
+ .gb-brand { min-width: 44px; height: 44px; justify-content: center; }
86
+ }
66
87
 
67
88
  /* ── AccountMenu (./account.tsx): its rules live in shell.css since 29/09/2026, because the admin
68
89
  * renders AccountMenu too now and loads shell.css, not this file. */
package/src/assistant.css CHANGED
@@ -495,38 +495,61 @@
495
495
  .gb-dock-side .gb-dock-sidegrip, .gb-dock-pinside { display: none; }
496
496
  }
497
497
 
498
- /* ── AssistantLauncher (02/10/2026): the floating button that opens the assistant, bottom-right ──────────────────────
499
- Top-right is the account corner (AccountMenu). Below the dock (290/320) and the topbar (300), above page content; the
500
- phone drawer (1000) covers it. 44px floor at every width; text + shortcut on wide screens, the icon alone on phones
501
- (the label stays the accessible name). Clears the iPhone home indicator with safe-area insets. */
498
+ /* ── AssistantLauncher: the button that opens the assistant. NOT floating (03/10/2026) ────────────────────────────────
499
+ 0.4.2 floated it bottom-right (z 280). visual-gate on partner-radar prod, 03/10: `content_covered` red at 5 of 6 widths
500
+ on every page measured (/, /apps, /sources): a fixed button always sits on whatever text scrolls under it, and at
501
+ scroll 0 that is a table column. GemCommunity had dropped its floating buttons for the same reason (#239). Chris chose
502
+ "bỏ nút nổi". It now lives where nothing scrolls under it:
503
+ · `--rail` wide screens: the last row of the rail (`railBottom`), a full-width primary button. Hidden ≤760px,
504
+ where the rail is an off-canvas drawer nobody sees until they open it.
505
+ · `--topbar` phones (≤760px): a 44px icon button in the topbar's `actions`, left of the account chip. Hidden above.
506
+ The top-right corner stays the ACCOUNT. A surface renders both; exactly one is displayed at any width. */
502
507
  .gb-assistant-launcher {
503
- position: fixed;
504
- right: calc(20px + env(safe-area-inset-right, 0px));
505
- bottom: calc(20px + env(safe-area-inset-bottom, 0px));
506
- z-index: 280;
507
508
  display: inline-flex;
508
509
  align-items: center;
509
510
  gap: 8px;
510
- min-height: 44px;
511
- padding: 0 16px;
512
511
  border: 0;
513
- border-radius: 999px;
514
- background: var(--gemboss-primary, #1b1c1e);
515
- color: var(--gemboss-on-primary, #fff);
516
- font: 600 14px/1 var(--gemboss-font-sans, system-ui, sans-serif);
517
- box-shadow: var(--gemboss-shadow-raised, 0 6px 18px rgba(0, 0, 0, 0.18));
518
512
  cursor: pointer;
513
+ font-family: var(--gemboss-font-sans, system-ui, sans-serif);
519
514
  }
520
- .gb-assistant-launcher:hover { background: var(--gemboss-primary-dark, #000); }
521
- .gb-assistant-launcher:focus-visible { outline: 2px solid var(--gemboss-accent, #5661f5); outline-offset: 2px; }
522
515
  .gb-assistant-launcher svg { flex: 0 0 auto; }
516
+ .gb-assistant-launcher:focus-visible { outline: 2px solid var(--gemboss-accent, #5661f5); outline-offset: 2px; }
517
+ .gb-assistant-launcher--rail {
518
+ width: 100%;
519
+ min-height: 44px; /* a control, not a list link: §9.1 asks 44 (visual-gate red at 768 with 36) */
520
+ margin: 8px 0 4px;
521
+ padding: 0 12px;
522
+ border-radius: var(--gemboss-radius-md, 8px);
523
+ background: var(--gemboss-primary, #1b1c1e);
524
+ color: var(--gemboss-on-primary, #fff);
525
+ font-size: 13px;
526
+ font-weight: 600;
527
+ line-height: 1;
528
+ text-align: left;
529
+ }
530
+ .gb-assistant-launcher--rail:hover { background: var(--gemboss-primary-dark, #000); }
531
+ .gb-assistant-launcher--rail .gb-assistant-launcher-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
523
532
  .gb-assistant-launcher-kbd {
524
533
  font: inherit; font-size: 11px; opacity: 0.7;
525
534
  border: 1px solid currentColor; border-radius: 5px; padding: 1px 5px;
526
535
  }
536
+ .gb-assistant-launcher--topbar {
537
+ display: none;
538
+ position: relative; /* holds the clipped label: an absolute child of a static button escapes to the nearest positioned box */
539
+ justify-content: center;
540
+ flex: 0 0 auto;
541
+ width: 44px;
542
+ height: 44px;
543
+ padding: 0;
544
+ border-radius: var(--gemboss-radius-md, 8px);
545
+ background: transparent;
546
+ color: var(--gemboss-text, #1b1c1e);
547
+ }
548
+ .gb-assistant-launcher--topbar:hover, .gb-assistant-launcher--topbar:active { background: var(--gemboss-hover, #eceef2); }
549
+ .gb-assistant-launcher--topbar .gb-assistant-launcher-label, .gb-assistant-launcher--topbar .gb-assistant-launcher-kbd {
550
+ position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
551
+ }
527
552
  @media (max-width: 760px) {
528
- .gb-assistant-launcher { min-width: 52px; min-height: 52px; padding: 0; justify-content: center; }
529
- .gb-assistant-launcher-label, .gb-assistant-launcher-kbd {
530
- position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
531
- }
553
+ .gb-assistant-launcher--rail { display: none; }
554
+ .gb-assistant-launcher--topbar { display: inline-flex; }
532
555
  }
package/src/auth.tsx CHANGED
@@ -173,6 +173,9 @@ export function PasswordField({ id, label = "Password", toggleLabels = { show: "
173
173
  // ("Hide password, pressed" while the password sits in plain text).
174
174
  aria-label={name}
175
175
  title={name}
176
+ // Both names for kit-plain.js `initPasswordFields()` on a page with no React (static render).
177
+ data-label-show={toggleLabels.show}
178
+ data-label-hide={toggleLabels.hide}
176
179
  className="gb-auth-pw-toggle"
177
180
  >
178
181
  <EyeIcon off={shown} />
@@ -107,4 +107,4 @@
107
107
  .gemboss-ec-stop svg { width: 12px; height: 12px; fill: currentColor; }
108
108
  .gemboss-ec-stop:disabled { opacity: 0.6; cursor: default; }
109
109
  .gemboss-ec-stop:hover { filter: brightness(1.08); }
110
- .gemboss-ec-stop:focus-visible { outline: 2px solid var(--gemboss-focus, #005bd3); outline-offset: 2px; }
110
+ .gemboss-ec-stop:focus-visible { outline: 2px solid var(--gemboss-focus, var(--ui-focus, #5661f5)); outline-offset: 2px; }
package/src/chat.css CHANGED
@@ -355,7 +355,7 @@
355
355
  * chat can sit in a 380px sidebar — and capped in height so five thumbnails read as a shortlist, not
356
356
  * five screens. The link around it is the tap target: keyboard focus has to be visible on it. */
357
357
  .gemboss-md-imglink { display: inline-block; max-width: 100%; margin: 4px 0 8px; border-radius: 8px; line-height: 0; }
358
- .gemboss-md-imglink:focus-visible { outline: 2px solid var(--gemboss-focus, #005bd3); outline-offset: 2px; }
358
+ .gemboss-md-imglink:focus-visible { outline: 2px solid var(--gemboss-focus, var(--ui-focus, #5661f5)); outline-offset: 2px; }
359
359
  .gemboss-md-img {
360
360
  display: block;
361
361
  max-width: 100%;
@@ -50,7 +50,7 @@ input[type="search"]:is(.gemboss-field, .gemboss-field__bare) { -webkit-appearan
50
50
  .gemboss-field:hover:not(:disabled),
51
51
  .gemboss-control:hover:not(:disabled) { border-color: var(--p-color-input-border-hover, var(--gemboss-field-line-hover)); background-color: var(--p-color-input-bg-surface-hover, var(--gemboss-field-bg-hover)); }
52
52
  .gemboss-field:focus-visible,
53
- .gemboss-control:focus-visible { outline: 2px solid var(--p-color-border-focus, #005bd3); outline-offset: 1px; border-color: var(--p-color-input-border-active, var(--gemboss-field-line-active)); }
53
+ .gemboss-control:focus-visible { outline: 2px solid var(--p-color-border-focus, var(--ui-focus, #5661f5)); outline-offset: 1px; border-color: var(--p-color-input-border-active, var(--gemboss-field-line-active)); }
54
54
  .gemboss-field:disabled,
55
55
  .gemboss-control:disabled { opacity: 0.5; cursor: not-allowed; }
56
56
  /* Polaris' own breakpoint for the same switch: under it a field reads 16px, so iOS does not zoom. */
@@ -79,7 +79,7 @@ textarea.gemboss-field { height: auto; padding: 6px 12px; resize: vertical; }
79
79
  rows, the two places a field is made of parts. A media query does not outrank anything; source
80
80
  order decided it, and the fix is to say it again after. (/code-review, 23/09/2026.) */
81
81
  @media (max-width: 47.9975em) { .gemboss-field--group { min-height: 44px; } }
82
- .gemboss-field--group:focus-within { outline: 2px solid var(--p-color-border-focus, #005bd3); outline-offset: 1px; border-color: var(--p-color-input-border-active, var(--gemboss-field-line-active)); }
82
+ .gemboss-field--group:focus-within { outline: 2px solid var(--p-color-border-focus, var(--ui-focus, #5661f5)); outline-offset: 1px; border-color: var(--p-color-input-border-active, var(--gemboss-field-line-active)); }
83
83
  /* The colour swatch in a group: FLUSH to the group's left edge and full height — it covers the group's
84
84
  1px border top and bottom and its 12px left padding, so it is the whole 44px target the panel's rule
85
85
  asks for, rounded by the group's own corner (overflow: hidden), and never clipped. A 44px swatch
@@ -88,7 +88,7 @@ textarea.gemboss-field { height: auto; padding: 6px 12px; resize: vertical; }
88
88
  /* ↑ the right edge is what keeps a WHITE swatch visible: without it the block melted into the field (measured). */
89
89
  .gemboss-field--group input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
90
90
  .gemboss-field--group input[type="color"]::-webkit-color-swatch { border: 0; border-radius: 0; }
91
- .gemboss-field__bare { flex: 1; min-width: 0; align-self: stretch; padding: 0 12px 0 0; border: 0; border-radius: 0; outline: none; background: transparent; color: inherit; font: inherit; }
91
+ .gemboss-field__bare { flex: 1; min-width: 0; align-self: stretch; margin-block: -1px; /* over the group's 1px border: the input is the 44px target, not 42 (0.5.1) */ padding: 0 12px 0 0; border: 0; border-radius: 0; outline: none; background: transparent; color: inherit; font: inherit; }
92
92
  /* …and the placeholder, which "draws nothing" does not cover: the surfaces that used to declare
93
93
  their own (the /admin search rows, the settings and palette rows) stopped when they stopped
94
94
  owning the box, and without this every bare field would fall back to the browser's grey. */
@@ -79,7 +79,7 @@
79
79
  and reading as "the font keeps shrinking". New rule: pick a step here, never a
80
80
  raw px. Floor is 12px (--fs-caption); 11px (--fs-micro) is reserved for bold
81
81
  micro-badges/count-pills ONLY — nothing below that. Body default is 14px, one
82
- step up from Polaris bodySm(13) which the app had been leaning on for prose. */
82
+ step up from Polaris bodyMd (13px; bodySm is 12) which the app had been leaning on for prose. The kit (ui.css) reads --ui-fs-body, 13px, because it sits beside Polaris components. */
83
83
  --gemboss-fs-micro: 11px; /* bold count-pills / status chips only */
84
84
  --gemboss-fs-caption: 12px; /* metadata, timestamps, secondary labels */
85
85
  --gemboss-fs-body: 14px; /* default body / reading text */
package/src/icon.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100"><rect width="100" height="100" rx="24" fill="#0B0B0C"/><g transform="translate(18,10) scale(0.66)"><polygon points="26,14 74,14 66,50 34,50" fill="#5661F5"/><path d="M26,14 74,14 96,50 50,110 4,50Z" fill="none" stroke="#fff" stroke-width="9" stroke-linejoin="round"/><path d="M4,50H96M26,14 34,50M74,14 66,50M34,50 50,110M66,50 50,110" fill="none" stroke="#fff" stroke-width="5.6" stroke-linejoin="round"/></g></svg>
package/src/kit-plain.css CHANGED
@@ -77,7 +77,7 @@
77
77
  .ui-link:hover,
78
78
  .ui-prose a:not([class]):hover { color: var(--gemboss-accent, #5661f5); text-decoration-thickness: 2px; }
79
79
  .ui-link:focus-visible,
80
- .ui-prose a:not([class]):focus-visible { outline: 2px solid var(--ui-focus, #005bd3); outline-offset: 2px; border-radius: 2px; }
80
+ .ui-prose a:not([class]):focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: 2px; border-radius: 2px; }
81
81
 
82
82
  .ui-card--media { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
83
83
  .ui-card__media {
@@ -86,7 +86,7 @@
86
86
  }
87
87
  .ui-card__media > img { display: block; width: 100%; height: 100%; object-fit: cover; }
88
88
  button.ui-card__media, a.ui-card__media { cursor: pointer; }
89
- .ui-card__media:focus-visible { outline: 2px solid var(--ui-focus, #005bd3); outline-offset: -2px; }
89
+ .ui-card__media:focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: -2px; }
90
90
  /* justify-items: start: a tag or a button in the body keeps its own width instead of stretching across the card. */
91
91
  .ui-card__body { display: grid; justify-items: start; gap: var(--ui-space-2, 8px); padding: var(--ui-space-3, 12px) var(--ui-space-4, 16px); min-width: 0; }
92
92
  .ui-card__foot {
@@ -96,8 +96,8 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
96
96
  }
97
97
 
98
98
  /* Popup body and foot: `<div class="ui-modal__body">…</div><div class="ui-modal__foot"><button class="ui-btn">…</button></div>`.
99
- The admin's Modal.tsx pads its own children, so ui.css never styled these; every non-React surface padded them by
100
- hand (GemFactory: 16/20 and a hairline above the actions). kit-plain.js's openModal writes exactly this shape. */
99
+ ui.css never styled these, and the admin does not load this file: its Modal.tsx children carry their own padding
100
+ (or none). Every non-React surface padded them by hand (GemFactory: 16/20 and a hairline above the actions). kit-plain.js's openModal writes exactly this shape. */
101
101
  .ui-modal__body { padding: 16px 20px; }
102
102
  .ui-modal__foot {
103
103
  display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;
@@ -214,3 +214,222 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
214
214
  /* Tab / nhóm nút là ĐIỀU KHIỂN: ≥ 44px dưới ngón tay (§9.1). Khối coarse của ui.css chỉ bắt `pointer: coarse`; ở khổ điện
215
215
  thoại trình duyệt máy tính vẫn báo `fine`, nên nâng theo khổ như ui-btn--icon ở trên. */
216
216
  @media (pointer: coarse), (max-width: 47.9975em) { .ui-seg :is(button, a) { height: 44px; min-height: 44px; } }
217
+
218
+ /* ── Polaris parity: the primitives every surface was drawing for itself (02/10/2026) ──────────────────────
219
+ An audit of the six surfaces on the package (gemwatcher, GemFactory, gempress, gemcare, partner-radar,
220
+ content-radar) found the same gaps re-filled six ways: no destructive button (four surfaces fell back to the
221
+ browser's `confirm()`), one-tone badges, banners as bare colour blocks, hand-made radio / switch / tooltip /
222
+ spinner, and help and error text under a field in five sizes. The admin has all of them from Polaris; this is
223
+ the same shape in plain CSS, with Polaris' measurements and GemBoss' tokens (the danger / success / warning
224
+ set the kit's tags and notices already use). The admin does not load this file. */
225
+
226
+ /* Destructive button: `ui-btn ui-btn--critical` (red text on the default button) and
227
+ `ui-btn ui-btn--primary ui-btn--critical` (red fill), Polaris' `tone="critical"` in both variants. Red text is
228
+ --gemboss-danger-strong: 7.3:1 on white, 5.7:1 on the hover ground (shell.css says why not --gemboss-danger). */
229
+ .ui-btn--critical { color: var(--gemboss-danger-strong); }
230
+ .ui-btn--critical:hover { background: var(--gemboss-danger-soft); color: var(--gemboss-danger-strong); }
231
+ .ui-btn--primary.ui-btn--critical { background: var(--gemboss-danger); border-color: var(--gemboss-danger); color: var(--gemboss-on-primary); }
232
+ .ui-btn--primary.ui-btn--critical:hover { background: var(--gemboss-danger-strong); border-color: var(--gemboss-danger-strong); color: var(--gemboss-on-primary); }
233
+ .ui-btn--critical:focus-visible { outline-color: var(--gemboss-danger-strong); }
234
+
235
+ /* Under a thumb, and at phone WIDTH (a desktop browser at 320px reports pointer: fine), EVERY kit button is a 44px
236
+ control, the compact one included: §9.1 has no density exception, and GemFactory, gempress and gemcare each
237
+ restated this floor for `--sm` themselves. ui.css's coarse block leaves `--sm` alone for the admin's own
238
+ settings page; this file is not loaded there. */
239
+ @media (pointer: coarse), (max-width: 47.9975em) {
240
+ .ui-btn, .ui-btn.ui-btn--sm { min-height: 44px; }
241
+ .ui-select.ui-select { min-height: 44px; }
242
+ /* A target is an AREA: the compact icon button (28 wide) and an icon segment (30) grow sideways as well. */
243
+ .ui-btn--icon.ui-btn--sm, .ui-seg :is(button, a):not(.is-text) { min-width: 44px; }
244
+ }
245
+
246
+ /* Badge tones: `<span class="ui-badge ui-badge--success">Active</span>`. Polaris' tones on GemBoss' soft grounds (Polaris' `attention` is `--warning` here: the fleet has one amber).
247
+ `ui-badge--dot` puts a status dot before the label (filled = done, the tone's colour). */
248
+ .ui-badge--success { background: var(--gemboss-success-soft); color: var(--gemboss-success-strong); }
249
+ .ui-badge--warning { background: var(--gemboss-warning-soft); color: var(--gemboss-warning); }
250
+ .ui-badge--critical { background: var(--gemboss-danger-soft); color: var(--gemboss-danger-strong); }
251
+ .ui-badge--info { background: var(--gemboss-accent-soft); color: var(--gemboss-accent-strong); }
252
+ .ui-badge--dot::before { content: ""; flex: none; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
253
+
254
+ /* Banner: Polaris' in-page Banner. A tone strip with an icon and a title, a body, optional actions, optional
255
+ dismiss. `ui-notice` stays for a one-line note; a banner is for something the person must read or act on.
256
+ <div class="ui-banner ui-banner--warning" role="status">
257
+ <div class="ui-banner__head"><svg class="ui-banner__icon" …/><h3 class="ui-banner__title">Slack token expired</h3>
258
+ <button type="button" class="ui-banner__close" aria-label="Dismiss">…</button></div>
259
+ <div class="ui-banner__body"><p>Monday's digest will not send.</p>
260
+ <div class="ui-banner__actions"><a class="ui-btn" href>Reconnect</a></div></div></div>
261
+ No tone = neutral. role="status" / "alert" only when it appears after the page loaded. */
262
+ .ui-banner {
263
+ --ui-banner-bg: var(--ui-surface-2); --ui-banner-fg: var(--ui-text);
264
+ border-radius: var(--ui-radius-lg); overflow: hidden; background: var(--ui-surface); color: var(--ui-text);
265
+ box-shadow: 0 0 0 1px var(--ui-border), 0 3px 1px -1px rgba(26, 26, 26, 0.07);
266
+ font-size: var(--ui-fs-body); line-height: 20px;
267
+ }
268
+ .ui-banner--success { --ui-banner-bg: var(--gemboss-success-soft); --ui-banner-fg: var(--gemboss-success-strong); }
269
+ .ui-banner--warning { --ui-banner-bg: var(--gemboss-warning-soft); --ui-banner-fg: var(--gemboss-warning); }
270
+ .ui-banner--critical { --ui-banner-bg: var(--gemboss-danger-soft); --ui-banner-fg: var(--gemboss-danger-strong); }
271
+ .ui-banner--info { --ui-banner-bg: var(--gemboss-accent-soft); --ui-banner-fg: var(--gemboss-accent-strong); }
272
+ .ui-banner__head { display: flex; align-items: center; gap: var(--ui-space-2); min-height: 32px; padding: 6px var(--ui-space-3); background: var(--ui-banner-bg); color: var(--ui-banner-fg); }
273
+ .ui-banner__icon { flex: none; width: 20px; height: 20px; fill: currentColor; }
274
+ .ui-banner__title { flex: 1 1 auto; min-width: 0; margin: 0; font-size: var(--ui-fs-body); font-weight: 650; line-height: 20px; }
275
+ .ui-banner__close {
276
+ flex: none; display: inline-grid; place-items: center; width: 32px; height: 32px; margin: -6px -8px -6px 0;
277
+ border: 0; border-radius: var(--ui-radius-md); background: transparent; color: inherit; cursor: pointer;
278
+ }
279
+ .ui-banner__close:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
280
+ .ui-banner__close:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: -2px; }
281
+ .ui-banner__close svg { width: 16px; height: 16px; }
282
+ .ui-banner__body { padding: var(--ui-space-3) var(--ui-space-4); }
283
+ .ui-banner__body > :first-child { margin-top: 0; }
284
+ .ui-banner__body > :last-child { margin-bottom: 0; }
285
+ .ui-banner__actions { display: flex; flex-wrap: wrap; gap: var(--ui-space-2); margin-top: var(--ui-space-3); }
286
+ @media (pointer: coarse), (max-width: 47.9975em) { .ui-banner__close { width: 44px; height: 44px; margin: -12px -12px -12px 0; } }
287
+
288
+ /* Help and error text under a field, Polaris' Labelled help text and InlineError:
289
+ <label class="ui-label" for="x">Domain</label><input id="x" class="gemboss-field gemboss-field--tall"
290
+ aria-describedby="x-help x-err" aria-invalid="true"><p class="ui-help" id="x-help">Without https://</p>
291
+ <p class="ui-error" id="x-err">This store is already tracked.</p>
292
+ aria-invalid paints the field in the critical tone; the message says what to do, not only what is wrong. */
293
+ .ui-help { margin: var(--ui-space-1) 0 0; font-size: var(--ui-fs-body); line-height: 20px; color: var(--ui-text-muted); }
294
+ .ui-error:empty { display: none; } /* a placeholder kept for aria-describedby draws no icon */
295
+ .ui-error { display: flex; gap: 6px; margin: var(--ui-space-1) 0 0; font-size: var(--ui-fs-body); line-height: 20px; color: var(--gemboss-danger-strong); }
296
+ .ui-error::before {
297
+ content: ""; flex: none; width: 16px; height: 16px; margin-top: 2px; background: currentColor;
298
+ -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path d='M10 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.75 4h1.5v5.5h-1.5V6Zm.75 9a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z'/></svg>") center / contain no-repeat;
299
+ mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path d='M10 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.75 4h1.5v5.5h-1.5V6Zm.75 9a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z'/></svg>") center / contain no-repeat;
300
+ }
301
+ .gemboss-field[aria-invalid="true"], .gemboss-field--group:has([aria-invalid="true"]),
302
+ .ui-select[aria-invalid="true"], .ui-textarea[aria-invalid="true"] {
303
+ border-color: var(--gemboss-danger-strong); background-color: var(--gemboss-danger-soft);
304
+ }
305
+
306
+ /* Radio: the checkbox's row with `type="radio"` — `<fieldset class="ui-choices"><legend class="ui-label">Plan</legend>
307
+ <label class="ui-check"><input type="radio" name="plan" checked> Basic</label>…</fieldset>`. */
308
+ .ui-choices { display: grid; gap: 0; margin: 0; padding: 0; border: 0; min-width: 0; }
309
+ .ui-choices > legend { padding: 0; }
310
+
311
+ /* Switch: a checkbox that reads as on / off, for a setting that applies at once (no Save button).
312
+ `<label class="ui-check ui-switch"><input type="checkbox" role="switch" checked> Send Monday digest</label>`.
313
+ Track 32×20, thumb 16; the row stays the checkbox's 44px target. */
314
+ .ui-switch > input[type="checkbox"] {
315
+ appearance: none; -webkit-appearance: none; position: relative; width: 32px; height: 20px; margin: 0;
316
+ border-radius: var(--gemboss-radius-full); background: var(--gemboss-field-line); /* 3.5:1 on white, WCAG 1.4.11 */ transition: background-color var(--gemboss-transition-fast) ease;
317
+ }
318
+ .ui-switch > input[type="checkbox"]::before {
319
+ content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%;
320
+ background: var(--gemboss-surface); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); transition: transform var(--gemboss-transition-fast) ease;
321
+ }
322
+ .ui-switch > input[type="checkbox"]:checked { background: var(--ui-accent); }
323
+ .ui-switch > input[type="checkbox"]:checked::before { transform: translateX(12px); }
324
+ @media (prefers-reduced-motion: reduce) { .ui-switch > input[type="checkbox"], .ui-switch > input[type="checkbox"]::before { transition: none; } }
325
+
326
+ /* Tooltip: a SHORT label for an icon-only control, shown on hover and keyboard focus. Not for anything the person
327
+ needs to act on (a touch screen never hovers).
328
+ <span class="ui-tooltip"><button class="ui-btn ui-btn--icon" aria-describedby="t1" aria-label="Archive">…</button>
329
+ <span class="ui-tooltip__bubble" role="tooltip" id="t1">Archive</span></span> `--end` aligns it right. */
330
+ .ui-tooltip { position: relative; display: inline-flex; }
331
+ .ui-tooltip__bubble {
332
+ position: absolute; z-index: 30; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%);
333
+ width: max-content; max-width: 240px; padding: 4px 8px; border-radius: var(--ui-radius-md);
334
+ background: var(--ui-surface); color: var(--ui-text); font-size: var(--ui-fs-body); line-height: 20px; font-weight: 450;
335
+ box-shadow: 0 8px 16px -4px rgba(26, 26, 26, 0.22), 0 0 0 1px var(--ui-border);
336
+ opacity: 0; visibility: hidden; pointer-events: none; transition: opacity var(--gemboss-transition-fast) ease;
337
+ }
338
+ .ui-tooltip--end .ui-tooltip__bubble { left: auto; right: 0; transform: none; }
339
+ .ui-tooltip:hover > .ui-tooltip__bubble, .ui-tooltip:focus-within > .ui-tooltip__bubble { opacity: 1; visibility: visible; }
340
+
341
+ /* Spinner: `<span class="ui-spinner" role="status"><span class="ui-sr-only">Loading…</span></span>`, 20px
342
+ (Polaris small); `--lg` is 44px. Reduced motion turns it slower, never off: it is the only sign of waiting. */
343
+ .ui-spinner {
344
+ display: inline-block; flex: none; width: 20px; height: 20px; box-sizing: border-box; border-radius: 50%;
345
+ border: 2px solid color-mix(in srgb, var(--ui-text) 18%, transparent); border-top-color: var(--ui-text);
346
+ animation: ui-spin 0.7s linear infinite; vertical-align: middle;
347
+ }
348
+ .ui-spinner--lg { width: 44px; height: 44px; border-width: 4px; }
349
+ @keyframes ui-spin { to { transform: rotate(360deg); } }
350
+ @media (prefers-reduced-motion: reduce) { .ui-spinner { animation-duration: 2s; } }
351
+
352
+ /* Progress: `<progress class="ui-progress" value="37" max="100" aria-label="Import">37%</progress>`, 8px, the
353
+ primary fill (Polaris' small ProgressBar). Tones: `--success`, `--critical`. */
354
+ .ui-progress { appearance: none; -webkit-appearance: none; display: block; width: 100%; height: 8px; border: 0; border-radius: var(--gemboss-radius-full); overflow: hidden; background: var(--gemboss-divider); color: var(--ui-accent); }
355
+ .ui-progress::-webkit-progress-bar { background: var(--gemboss-divider); border-radius: inherit; }
356
+ .ui-progress::-webkit-progress-value { background: currentColor; border-radius: inherit; }
357
+ .ui-progress::-moz-progress-bar { background: currentColor; border-radius: inherit; }
358
+ .ui-progress--success { color: var(--gemboss-success); }
359
+ .ui-progress--critical { color: var(--gemboss-danger); }
360
+
361
+ /* Wider popup: `openModal({ className: "ui-modal--wide" })`, Polaris' default Modal (620px). The kit's own 460 is
362
+ Polaris' small step plus one; a form with two columns or a table needs this one. */
363
+ .ui-modal--wide { width: min(620px, 100%); }
364
+
365
+ /* Native popup: `<dialog class="ui-modal">` opened with `showModal()`, for React (and other) surfaces that do not load
366
+ kit-plain.js. The browser does what openModal does by hand: Escape closes it, the page behind is inert, it sits in
367
+ the top layer above the shell's topbar. Same head / body / foot markup; `::backdrop` is the kit's overlay. gemcare
368
+ and content-radar already used `<dialog>`, each with its own width, padding and backdrop (02/10/2026: four shapes,
369
+ 420 to 760 wide). Close on a backdrop click yourself: a click whose target IS the dialog landed outside the panel.
370
+ `<dialog class="ui-modal" aria-labelledby="t"><div class="ui-modal__head"><div class="ui-modal__title" id="t">…</div>
371
+ <button type="button" class="ui-modal__close" aria-label="Close">…</button></div><div class="ui-modal__body">…</div>
372
+ <div class="ui-modal__foot">…</div></dialog>` */
373
+ dialog.ui-modal { padding: 0; max-width: calc(100% - 40px); max-height: calc(100dvh - 40px); color: var(--ui-text); }
374
+ dialog.ui-modal[open] { display: flex; flex-direction: column; }
375
+ dialog.ui-modal::backdrop { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px); }
376
+
377
+ /* ── 0.5.1: what the first surfaces on 0.5.0 still drew themselves (GemFactory, content-radar, 03/10/2026) ─────────── */
378
+
379
+ /* At phone width a short compact button ("Bỏ") was 44 tall but 41 wide: a target is an area. */
380
+ @media (pointer: coarse), (max-width: 47.9975em) { .ui-btn.ui-btn--sm { min-width: 44px; } }
381
+
382
+ /* A long label in a badge ("Đã dùng 1 lần + 4 bài cùng nguồn · 2 Hot News") wraps instead of running out of its card. */
383
+ .ui-badge--wrap { white-space: normal; overflow-wrap: anywhere; line-height: 16px; }
384
+
385
+ /* Page head with actions on the right, wrapping under the title when narrow:
386
+ `<div class="ui-page-head ui-page-head--actions"><div><h1>…</h1><p>…</p></div><div class="ui-page-head__actions">
387
+ <button class="ui-btn ui-btn--primary">…</button></div></div>`. */
388
+ .ui-page-head--actions { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 12px 16px; }
389
+ .ui-page-head--actions > :first-child { min-width: 0; }
390
+ .ui-page-head__actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-2); }
391
+
392
+ /* Chips that TOGGLE (a filter applied in place, no URL): `<div class="ui-chips"><button type="button" class="ui-tag
393
+ ui-tag--chip" aria-pressed="true">CRO</button>…`. Same box as the link chips (ui.css); pressed = the link's current. */
394
+ .ui-chips > button.ui-tag--chip {
395
+ min-height: 28px; padding: 4px 12px; border-radius: var(--ui-radius-md, 8px); cursor: pointer;
396
+ font-family: inherit; font-size: var(--gemboss-fs-caption, 12px); color: var(--ui-text);
397
+ }
398
+ .ui-chips > button.ui-tag--chip:hover { background: var(--ui-surface-2); border-color: var(--ui-text-muted); }
399
+ .ui-chips > button.ui-tag--chip:focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: 1px; }
400
+ .ui-chips > button.ui-tag--chip[aria-pressed="true"] { background: var(--ui-accent-soft); border-color: var(--ui-accent); color: var(--ui-accent-strong); font-weight: 600; }
401
+ @media (pointer: coarse), (max-width: 47.9975em) { .ui-chips > button.ui-tag--chip { min-height: 44px; } }
402
+
403
+ /* Chat at phone WIDTH (gemwatcher's assistant, measured 03/10/2026): the composer read 14px (iOS zooms the page on tap
404
+ below 16) and "new chat" was 35px wide under a fine pointer. shell.css raises the latter under `pointer: coarse`; a
405
+ desktop window at 390px reports fine. Here and not in chat.css / shell.css: the admin's first-load budget. */
406
+ @media (max-width: 47.9975em) {
407
+ /* Class doubled: chat.css is appended AFTER this file in gemboss-ui.css, so a same-weight rule there would win. */
408
+ .gemboss-ec-input.gemboss-ec-input textarea { font-size: 16px; }
409
+ .gemboss-assistant-collapse, .gemboss-assistant-newchat { min-height: 44px; min-width: 44px; }
410
+ }
411
+
412
+ /* Segmented buttons that say their state the accessible way: `aria-pressed="true"` looks like `.active` (ui-tabs reads
413
+ aria-selected already; gemcare had to set both on every group). */
414
+ .ui-seg button[aria-pressed="true"] { background: var(--ui-accent-soft); color: var(--ui-accent-strong); }
415
+
416
+ /* Read-only field: visibly not editable (the admin's field has no such state; gempress kept its own `.field[readonly]`).
417
+ The attribute doubled = (0,3,0), so it also holds over the field's own hover rule, which comes earlier. */
418
+ :is(.gemboss-field, .ui-textarea)[readonly][readonly] { background: var(--ui-surface-2); border-color: var(--ui-border); cursor: default; }
419
+
420
+ /* A popup whose body and foot sit inside one <form>: the form takes no box, so `.ui-modal > .ui-modal__body` (the
421
+ scrolling body) and the pinned foot still lay out as the popup's own children (gemcare's escalate form, 0.5.1). */
422
+ .ui-modal > form { display: contents; }
423
+ .ui-modal > form > .ui-modal__head, .ui-modal > form > .ui-modal__foot { flex: none; }
424
+ .ui-modal > form > .ui-modal__body { flex: 1 1 auto; min-height: 0; overflow: auto; }
425
+
426
+ /* A metric that FILTERS the view below it, as a toggle: `<button type="button" class="ui-metric" aria-pressed="true">`
427
+ (gemwatcher's Radar cards: as links to the same hash, Cmd-click opened an unfiltered tab, Space did nothing and the
428
+ chosen card was told by colour alone). A metric link that is the current view: `a.ui-metric[aria-current="page"]`.
429
+ Selected = iris edge, the shell's "this one" colour. */
430
+ button.ui-metric { font: inherit; text-align: left; color: inherit; cursor: pointer; }
431
+ button.ui-metric:hover { border-color: var(--ui-text-muted); }
432
+ button.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: 1px; }
433
+ /* Class doubled: the chosen card is usually the one under the pointer, and `button.ui-metric:hover` (0,2,1) would
434
+ repaint its edge grey the moment it is clicked (measured on gemwatcher Radar). */
435
+ .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); }