@gemboss/ui 0.1.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/src/shell.tsx ADDED
@@ -0,0 +1,212 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The GemBoss shell as components: fixed 56px topbar, 240px rail, drawer at ≤760px.
5
+ *
6
+ * Every other surface in the fleet (gempress, gemcare, gemwatcher, content-radar…) used to rebuild
7
+ * this markup by hand from the class names in the exported CSS, and each copy drifted a little: a
8
+ * different hamburger, the selected pill missing because the item was not inside `.gnav-group`, a
9
+ * `<nav>` of buttons that a legacy `nav button {}` rule squashed to 60px. These components ARE the
10
+ * markup the CSS was written for, and the admin renders its own rail through them — so a change to
11
+ * the contract shows up in the admin first, not in a sibling repo weeks later.
12
+ *
13
+ * Styling is not here. It is `gemboss-ui.css` (tokens + kit + the shell extract), generated from the
14
+ * admin's own stylesheets by `apps/admin/scripts/export-ui-css.mjs`; the admin loads those sources
15
+ * directly. These components only emit classes.
16
+ *
17
+ * No router. `RailItem`/`RailSubItem` render an `<a>` unless given `as` — the admin passes
18
+ * `next/link`, a hash-routed SPA passes nothing. Every other prop is forwarded untouched.
19
+ */
20
+ import React, { useEffect, useState } from "react";
21
+
22
+ const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(" ");
23
+
24
+ /** Own props + typed HTML attributes (so `onClick={(e) => …}` gets a MouseEvent, not `any`) + an
25
+ * open index for whatever the `as` component takes (`prefetch`, `data-*`). */
26
+ type Polymorphic<P> = P & { as?: React.ElementType; className?: string }
27
+ & Omit<React.AllHTMLAttributes<HTMLElement>, keyof P | "as" | "className">
28
+ & Record<string, unknown>;
29
+
30
+ /** `div.gemboss-platform-shell`. `mobileNavOpen` is the ≤760px drawer; wider, nothing reads it. */
31
+ export function Shell({ mobileNavOpen, className, children, ...rest }:
32
+ { mobileNavOpen?: boolean; className?: string; children?: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) {
33
+ return <div className={cx("gemboss-platform-shell", mobileNavOpen && "is-mobile-nav-open", className)} {...rest}>{children}</div>;
34
+ }
35
+
36
+ /** `header.gemboss-platform-topbar` — a three-column grid on desktop, one flex row at ≤760px. */
37
+ export function Topbar({ className, children, ...rest }: React.HTMLAttributes<HTMLElement>) {
38
+ return <header className={cx("gemboss-platform-topbar", className)} {...rest}>{children}</header>;
39
+ }
40
+
41
+ /** The ≤760px hamburger. Hidden by the CSS on wider screens. */
42
+ export function MobileMenuButton({ open, onToggle, labels = { open: "Open menu", close: "Close menu" }, ...rest }:
43
+ { open: boolean; onToggle: () => void; labels?: { open: string; close: string } } & React.ButtonHTMLAttributes<HTMLButtonElement>) {
44
+ return (
45
+ <button type="button" className="gemboss-mobile-menu-btn" aria-label={open ? labels.close : labels.open}
46
+ aria-expanded={open} onClick={onToggle} {...rest}>
47
+ <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">
48
+ <path d="M3 5.5h14M3 10h14M3 14.5h14" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
49
+ </svg>
50
+ </button>
51
+ );
52
+ }
53
+
54
+ /** The dimmed layer behind the open drawer; tapping it closes the drawer. */
55
+ export function MobileNavBackdrop({ open, onClose, label = "Close menu" }:
56
+ { open: boolean; onClose: () => void; label?: string }) {
57
+ return (
58
+ <button type="button" className="gemboss-mobile-nav-backdrop" aria-label={label}
59
+ tabIndex={open ? 0 : -1} onClick={onClose} />
60
+ );
61
+ }
62
+
63
+ /** `nav.icon-nav`. */
64
+ export function Rail({ label = "Main navigation", className, children, ...rest }:
65
+ { label?: string } & React.HTMLAttributes<HTMLElement>) {
66
+ return <nav className={cx("icon-nav", className)} aria-label={label} {...rest}>{children}</nav>;
67
+ }
68
+
69
+ /** The part of the rail that scrolls. */
70
+ export function RailScroll({ children }: { children?: React.ReactNode }) {
71
+ return <div className="gnav-scroll">{children}</div>;
72
+ }
73
+
74
+ /** Pinned to the foot of the rail, below the scroll. */
75
+ export function RailBottom({ children }: { children?: React.ReactNode }) {
76
+ return <div className="gnav-bottom">{children}</div>;
77
+ }
78
+
79
+ export function RailSep() {
80
+ return <div className="gnav-sep" />;
81
+ }
82
+
83
+ /**
84
+ * `div.gnav-group`. The selected PILL is defined only as
85
+ * `.gnav-group:not(.has-active-child) > .gnav-item.active` — an item outside a group gets the active
86
+ * text colour and no background. Wrap every top-level item in one (the admin does); set
87
+ * `hasActiveChild` when a sub-item is the real destination so the leaf carries the pill.
88
+ */
89
+ export function RailGroup({ active, hasActiveChild, className, children }:
90
+ { active?: boolean; hasActiveChild?: boolean; className?: string; children?: React.ReactNode }) {
91
+ return <div className={cx("gnav-group", active && "active", hasActiveChild && "has-active-child", className)}>{children}</div>;
92
+ }
93
+
94
+ /**
95
+ * `.gnav-item`: icon + label (+ anything after, e.g. a count). Renders `<a>` by default; pass
96
+ * `as={Link}` for a router link or `as="button"` for an action. `aria-current="page"` follows
97
+ * `active` unless the caller sets it.
98
+ */
99
+ export function RailItem({ as: Comp = "a", icon, label, active, className, children, ...rest }:
100
+ Polymorphic<{ icon?: React.ReactNode; label: React.ReactNode; active?: boolean; children?: React.ReactNode }>) {
101
+ return (
102
+ <Comp className={cx("gnav-item", active && "active", className)}
103
+ aria-current={active ? "page" : undefined} {...rest}>
104
+ <span className="gnav-ico">{icon}</span>
105
+ <span className="gnav-label">{label}</span>
106
+ {children}
107
+ </Comp>
108
+ );
109
+ }
110
+
111
+ /** `ul.gnav-sub` — a hub's children. */
112
+ export function RailSub({ children }: { children?: React.ReactNode }) {
113
+ return <ul className="gnav-sub">{children}</ul>;
114
+ }
115
+
116
+ /** One `li` of `RailSub`. `soon` renders a disabled entry with a "Soon" tag instead of a link. */
117
+ export function RailSubItem({ as: Comp = "a", active, soon, soonLabel = "Soon", className, children, ...rest }:
118
+ Polymorphic<{ active?: boolean; soon?: boolean; soonLabel?: string; children?: React.ReactNode }>) {
119
+ if (soon) return <li><span className="gnav-subitem is-soon">{children}<em>{soonLabel}</em></span></li>;
120
+ return (
121
+ <li>
122
+ <Comp className={cx("gnav-subitem", active && "active", className)}
123
+ aria-current={active ? "page" : undefined} {...rest}>{children}</Comp>
124
+ </li>
125
+ );
126
+ }
127
+
128
+ /** `.gemboss-platform-main`. `as="div"` when the pages render their own `<main>` — two nested
129
+ * `<main>` elements are invalid HTML. */
130
+ export function ShellMain({ as: Comp = "main", className, children, ...rest }:
131
+ Polymorphic<{ children?: React.ReactNode }>) {
132
+ return <Comp className={cx("gemboss-platform-main", className)} {...rest}>{children}</Comp>;
133
+ }
134
+
135
+ /**
136
+ * The whole frame for a surface that has no shell logic of its own (the admin composes the pieces
137
+ * above itself). Owns the drawer: open/close, Escape, and closing when a rail link is followed.
138
+ */
139
+ /** What the assistant provider announces when a rail click opened its popup instead of navigating
140
+ * (`dockedEvent` in ./assistant/provider — the same default). */
141
+ export const ASSISTANT_DOCKED_EVENT = "gemboss:assistant-docked";
142
+
143
+ export function AppShell({ brand, center, actions, rail, railBottom, railLabel, mainAs = "main", menuLabels,
144
+ drawerCloseEvent = ASSISTANT_DOCKED_EVENT, children }: {
145
+ /** Left of the topbar, after the hamburger. */
146
+ brand?: React.ReactNode;
147
+ /** Middle column of the topbar (search). */
148
+ center?: React.ReactNode;
149
+ /** Right column of the topbar. */
150
+ actions?: React.ReactNode;
151
+ /** Contents of the scrolling part of the rail. */
152
+ rail: React.ReactNode;
153
+ railBottom?: React.ReactNode;
154
+ railLabel?: string;
155
+ mainAs?: "main" | "div";
156
+ menuLabels?: { open: string; close: string };
157
+ /** A window event that closes the drawer. The rail's Assistant item can open the popup WITHOUT a
158
+ * navigation (the provider intercepts the click and stops it), so "a followed link closes the
159
+ * drawer" never fires, and on a phone the drawer stayed over the popup it had just opened —
160
+ * measured on gemwatcher at 320 and 390, 28/09/2026. The admin closes its own drawer the same way. */
161
+ drawerCloseEvent?: string;
162
+ children?: React.ReactNode;
163
+ }) {
164
+ const [open, setOpen] = useState(false);
165
+ useEffect(() => {
166
+ if (!open || !drawerCloseEvent) return;
167
+ const close = () => setOpen(false);
168
+ window.addEventListener(drawerCloseEvent, close);
169
+ return () => window.removeEventListener(drawerCloseEvent, close);
170
+ }, [open, drawerCloseEvent]);
171
+ useEffect(() => {
172
+ if (!open) return;
173
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
174
+ window.addEventListener("keydown", onKey);
175
+ return () => window.removeEventListener("keydown", onKey);
176
+ }, [open]);
177
+ // A followed link closes the drawer. Delegated, so the caller's items need no wiring.
178
+ const closeOnFollow = (e: React.MouseEvent) => {
179
+ if ((e.target as Element).closest?.(".gnav-item, .gnav-subitem")) setOpen(false);
180
+ };
181
+
182
+ return (
183
+ <Shell mobileNavOpen={open}>
184
+ <Topbar>
185
+ <div className="gemboss-topbar-start">
186
+ <MobileMenuButton open={open} onToggle={() => setOpen((o) => !o)} labels={menuLabels} />
187
+ {brand}
188
+ </div>
189
+ <div className="gemboss-topbar-center">{center}</div>
190
+ <div className="gemboss-topbar-end">{actions}</div>
191
+ </Topbar>
192
+ <Rail label={railLabel} onClick={closeOnFollow}>
193
+ <RailScroll>{rail}</RailScroll>
194
+ {railBottom ? <RailBottom>{railBottom}</RailBottom> : null}
195
+ </Rail>
196
+ <MobileNavBackdrop open={open} onClose={() => setOpen(false)} label={menuLabels?.close} />
197
+ <ShellMain as={mainAs}>{children}</ShellMain>
198
+ </Shell>
199
+ );
200
+ }
201
+
202
+ /** A small caps label over a run of rail items — how a rail with families of screens tells them
203
+ * apart (gemwatcher: Nhịp / Thị trường / Trang thắng / Hệ). The admin rail does not use one. */
204
+ export function RailHeading({ id, children }: { id?: string; children?: React.ReactNode }) {
205
+ return <div className="gnav-heading" id={id}>{children}</div>;
206
+ }
207
+
208
+ /** A count pill at the end of a `RailItem` (unread, drafts). Caps at 99+. */
209
+ export function RailCount({ value, label }: { value: number; label?: string }) {
210
+ if (!value) return null;
211
+ return <span className="gnav-count" aria-label={label}>{value > 99 ? "99+" : value}</span>;
212
+ }
package/src/ui.css ADDED
@@ -0,0 +1,403 @@
1
+ /* ════════════════════════════════════════════════════════════════════════════
2
+ Tool/App UI kit — the shared primitive layer for the internal operator surfaces
3
+ (admin console, seller app chrome, theme-editor). ONE semantic token spine
4
+ (`--ui-*`) that every primitive reads, plus the primitive CSS itself.
5
+
6
+ Why this exists: buttons/cards/badges were forked into 4–8 hand-rolled class
7
+ families per surface, each re-deriving color/spacing/centering — so the same
8
+ primitive drifted (e.g. a link-button whose text wasn't vertically centered).
9
+ See docs/frontend-shared-system-plan.md.
10
+
11
+ The `--ui-*` layer is SEMANTIC and resolved per surface: components only read
12
+ `--ui-*`; each surface maps those to its own tokens (admin → `--adm-*` below),
13
+ so one <Button> looks native everywhere without hard-coding a palette.
14
+ ════════════════════════════════════════════════════════════════════════════ */
15
+
16
+ :root {
17
+ --ui-surface: #ffffff;
18
+ /* --ui-* is a SEMANTIC layer resolved PER SURFACE — it references each surface's own base tokens
19
+ rather than guessing from the OS. The default (`:root`) maps onto the seller app's warm-ivory
20
+ `--gemboss-*` set; `.gemboss-admin-shell` below remaps onto the dark `--adm-*` set. Literal fallbacks
21
+ keep the kit usable if a surface's base stylesheet isn't loaded. This is the "unify under --ui-*"
22
+ backbone — one token spine, no duplicated palette. */
23
+ --ui-surface: var(--gemboss-surface, #ffffff);
24
+ --ui-surface-2: var(--gemboss-hover, #eceef2);
25
+ --ui-text: var(--gemboss-text, #1b1c1e);
26
+ --ui-text-muted: var(--gemboss-text-muted, #686b71);
27
+ --ui-border: var(--gemboss-divider, #e5e7ea);
28
+ --ui-accent: var(--gemboss-primary, #1b1c1e);
29
+ --ui-accent-hover: var(--gemboss-primary-dark, #000000);
30
+ --ui-accent-fg: #ffffff;
31
+ --ui-accent-soft: var(--gemboss-active-bg, #f0f1f4); /* tinted bg for a selected/active state */
32
+ --ui-accent-strong: var(--gemboss-active-fg, #1b1c1e); /* readable accent text ON --ui-accent-soft */
33
+ --ui-text-strong: var(--gemboss-text-strong, #1b1c1e);
34
+ --ui-focus: var(--gemboss-primary, #1b1c1e);
35
+ --ui-radius-sm: 6px;
36
+ --ui-radius-md: 8px;
37
+ --ui-radius-lg: 12px;
38
+ --ui-space-1: 4px;
39
+ --ui-space-2: 8px;
40
+ --ui-space-3: 12px;
41
+ --ui-space-4: 16px;
42
+ --ui-control-h: 34px;
43
+ --ui-fs-body: 13px;
44
+ --ui-fs-caption: 12px;
45
+ /* Content column width for the seller app shell — ONE source of truth for every wide surface
46
+ right of the 240px sidebar (bare Page, .gemboss-w-dashboard, .gemboss-w-gallery, and the
47
+ navigation skeleton), so they cannot drift apart again. The /admin console is a SEPARATE
48
+ surface and still sets its own 1200px in admin/admin-shell.css; it does not read this token.
49
+ 900px. Polaris' own cap is 998px — 41.375rem primary column + 20rem secondary + 1rem gutter,
50
+ read out of the installed @shopify/polaris stylesheet — and this sits deliberately BELOW it.
51
+
52
+ The reason is a measurement, not a preference about Shopify. The previous value was 1080/1040,
53
+ and moving to Polaris' 998 was the obvious "correct" answer, so that shipped first (#2250). Then
54
+ it turned out to be invisible: 1040 → 998 is 42px total, which is 21px of extra gutter per side.
55
+ Screenshotted side by side at 1440 on the same page seconds apart, the two rows were
56
+ indistinguishable, and Chris — who asked for this — could not see any change at all.
57
+
58
+ A cap only does its job if the breathing room it buys is legible. Measured gutters on
59
+ .Polaris-Page at 1440 beside the 240px sidebar:
60
+
61
+ 1040px → 75 / 86 (before)
62
+ 998px → 96 / 107 Polaris' number; +21px a side, not perceivable
63
+ 960px → 115 / 126
64
+ 900px → 145 / 156 +70px a side, roughly double the old gutter
65
+
66
+ 900 is the first value where the column visibly stops leaning on the rail. The one cost, and it
67
+ was measured rather than guessed: card descriptions gain a line (Cart drawer 2 → 3). The 3-up
68
+ grid holds, and no width from 320 to 1920 overflows.
69
+
70
+ Still fixed px, no percentage, for the reason Polaris uses px: a % cap grows the reading measure
71
+ with the monitor (80% of 2560 is 2048px of line length). Responsiveness belongs to the padding
72
+ and to the secondary column dropping, not to the cap.
73
+
74
+ .gemboss-w-form stays 662px — that IS `.Polaris-Page--narrowWidth`, the primary column alone,
75
+ and a form is the one surface where Polaris' measure is about the input, not the page. */
76
+ --ui-content-max: 900px;
77
+ }
78
+
79
+ /* Admin console bridge: the black chrome defines `--adm-*`; remap `--ui-*` onto them so every
80
+ <Button>/<Select>/<Tag>/<Modal> inside .gemboss-admin-shell adopts the dark palette automatically. */
81
+ .gemboss-admin-shell {
82
+ --ui-surface: var(--adm-surface);
83
+ --ui-surface-2: var(--adm-surface-2);
84
+ --ui-text: var(--adm-text);
85
+ --ui-text-muted: var(--adm-muted);
86
+ --ui-border: var(--adm-divider);
87
+ --ui-accent: var(--adm-accent);
88
+ --ui-accent-hover: var(--gemboss-primary-dark, #000000);
89
+ --ui-accent-fg: #ffffff;
90
+ --ui-accent-soft: var(--adm-accent-soft);
91
+ --ui-accent-strong: var(--adm-accent-fg);
92
+ --ui-text-strong: var(--adm-text-strong);
93
+ --ui-focus: var(--adm-accent);
94
+ }
95
+
96
+ /* ── Button ──────────────────────────────────────────────────────────────────
97
+ inline-flex + center so a <button> and an <a> render identically (anchors ignore
98
+ `height` as inline elements — the root cause of the misaligned link-buttons). */
99
+ .ui-btn {
100
+ display: inline-flex; align-items: center; justify-content: center; gap: var(--ui-space-2);
101
+ height: var(--ui-control-h); padding: 0 var(--ui-space-4); box-sizing: border-box;
102
+ line-height: 1; white-space: nowrap; vertical-align: middle;
103
+ border-radius: var(--ui-radius-md); font-family: inherit; font-size: var(--ui-fs-body); font-weight: 550;
104
+ cursor: pointer; text-decoration: none; user-select: none;
105
+ border: 1px solid var(--ui-border); background: transparent; color: var(--ui-text);
106
+ transition: background .12s ease, border-color .12s ease;
107
+ }
108
+ .ui-btn:hover { background: var(--ui-surface-2); text-decoration: none; }
109
+ .ui-btn:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: 2px; }
110
+ .ui-btn[disabled], .ui-btn[aria-disabled="true"] { opacity: .5; cursor: not-allowed; }
111
+ .ui-btn--primary { background: var(--ui-accent); border-color: var(--ui-accent); color: var(--ui-accent-fg); }
112
+ .ui-btn--primary:hover { background: var(--ui-accent-hover); border-color: var(--ui-accent-hover); }
113
+ .ui-btn--ghost { border-color: transparent; background: transparent; color: var(--ui-text-muted); }
114
+ .ui-btn--ghost:hover { background: var(--ui-surface-2); color: var(--ui-text); }
115
+ .ui-btn--sm { height: 28px; padding: 0 var(--ui-space-3); font-size: var(--ui-fs-caption); }
116
+
117
+ /* ── Card ──────────────────────────────────────────────────────────────────── */
118
+ .ui-card {
119
+ background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius-lg);
120
+ padding: var(--ui-space-4); color: var(--ui-text);
121
+ }
122
+ .ui-card__title { font-size: 14px; font-weight: 600; margin: 0 0 var(--ui-space-2); }
123
+
124
+ /* ── Select ──────────────────────────────────────────────────────────────────
125
+ Native <select> reskinned; custom caret as an inline SVG data-URI (caret color
126
+ uses the muted token via %-encoding fallback).
127
+ Its BOX is the text field's (29/09/2026, Chris): same --gemboss-field-* tokens for ground, line and
128
+ ink, same hover, same focus ring as `.gemboss-field`. Before, a grey --ui-surface-2 select stood next
129
+ to a bordered white field in the same form (gempress editor, admin forms), and `:focus` removed the
130
+ outline and only recoloured the border, so a keyboard user saw almost nothing. Height stays 36px
131
+ (--ui-control-h), because a select sits in toolbars beside 36px `.ui-btn`s. */
132
+ .ui-select {
133
+ height: 36px; padding: 0 32px 0 12px; max-width: 100%;
134
+ background: var(--p-color-input-bg-surface, var(--gemboss-field-bg)) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 20 20'><path fill='%239a9aa4' d='M5 7l5 5 5-5z'/></svg>") no-repeat right 10px center;
135
+ border: 1px solid var(--p-color-input-border, var(--gemboss-field-line)); border-radius: var(--ui-radius-md);
136
+ color: var(--p-color-text, var(--gemboss-field-ink)); font-family: inherit; font-size: var(--ui-fs-body); font-weight: 500;
137
+ appearance: none; -webkit-appearance: none; cursor: pointer;
138
+ }
139
+ .ui-select:hover:not(:disabled) {
140
+ border-color: var(--p-color-input-border-hover, var(--gemboss-field-line-hover));
141
+ background-color: var(--p-color-input-bg-surface-hover, var(--gemboss-field-bg-hover));
142
+ }
143
+ .ui-select:focus { outline: none; }
144
+ .ui-select:focus-visible {
145
+ outline: 2px solid var(--p-color-border-focus, #005bd3); outline-offset: 1px;
146
+ border-color: var(--p-color-input-border-active, var(--gemboss-field-line-active));
147
+ }
148
+ .ui-select:disabled { opacity: .5; cursor: not-allowed; }
149
+ .ui-select--slim { height: 30px; font-size: var(--ui-fs-caption); }
150
+
151
+ /* ── Tag / Chip ──────────────────────────────────────────────────────────────
152
+ Small inline labels. `solid` = filled muted pill (was gemboss-vault-tag); `chip` =
153
+ bordered mono token (was gemboss-vault-chip). `accent` tone for the highlighted state. */
154
+ .ui-tag {
155
+ display: inline-flex; align-items: center; gap: 6px;
156
+ font-size: 10px; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase;
157
+ padding: 1px 6px; border-radius: 6px; background: var(--ui-surface-2); color: var(--ui-text-muted);
158
+ }
159
+ .ui-tag--accent { background: var(--adm-accent-soft, rgba(20,22,26,.05)); color: var(--adm-accent-fg, var(--ui-accent)); }
160
+ .ui-tag--chip {
161
+ text-transform: none; letter-spacing: 0; font-size: 11px; font-weight: 500;
162
+ padding: 2px 8px; background: transparent; border: 1px solid var(--ui-border);
163
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
164
+ }
165
+ .ui-tag--chip.ui-tag--accent { color: var(--adm-accent-fg, var(--ui-accent)); border-color: var(--ui-accent); background: transparent; }
166
+
167
+ /* ── Badge ────────────────────────────────────────────────────────────────────
168
+ Uppercase mono pill for a status/label (was gemboss-admin-storage-badge). */
169
+ .ui-badge {
170
+ display: inline-flex; align-items: center;
171
+ font-size: 11px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase;
172
+ padding: 1px 8px; border-radius: 6px; background: var(--ui-surface-2); color: var(--ui-text-muted);
173
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
174
+ }
175
+
176
+ /* ── Modal ────────────────────────────────────────────────────────────────────
177
+ Rendered INLINE (not portaled): a portal escapes the .gemboss-admin-shell token
178
+ scope and would render light. Kept in-tree so --ui-* resolves to the surface. */
179
+ .ui-overlay {
180
+ position: fixed; inset: 0; z-index: 500;
181
+ background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px);
182
+ display: grid; place-items: center; padding: 20px;
183
+ }
184
+ .ui-modal {
185
+ width: min(460px, 100%);
186
+ background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: 16px;
187
+ box-shadow: var(--gemboss-shadow-overlay, 0 20px 60px rgba(0, 0, 0, 0.4)); overflow: hidden;
188
+ }
189
+ /* Head = title + the one close button every modal used to draw for itself. */
190
+ .ui-modal__head {
191
+ display: flex; align-items: flex-start; gap: 16px;
192
+ padding: 16px 20px; border-bottom: 1px solid var(--ui-border);
193
+ font-size: 15px; font-weight: 620; color: var(--ui-text);
194
+ }
195
+ .ui-modal__title { min-width: 0; flex: 1 1 auto; }
196
+ .ui-modal__close {
197
+ flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
198
+ width: 44px; height: 44px; margin: -8px -8px -8px 0; /* a 44px target that does not grow the head */
199
+ border: 0; border-radius: var(--ui-radius-md, 8px); background: transparent;
200
+ color: var(--ui-text-muted); cursor: pointer;
201
+ }
202
+ .ui-modal__close svg { width: 20px; height: 20px; }
203
+ .ui-modal__close:hover { background: var(--ui-surface-2); color: var(--ui-text); }
204
+ .ui-modal__close:focus-visible { outline: 2px solid var(--ui-accent); outline-offset: -2px; }
205
+
206
+ /* `size="lg"` — the screen-shaped dialog. Only the HOOK lives here: this sheet is imported by the
207
+ root layout, so every rule in it is paid on every screen, and the geometry of a dialog with one
208
+ consumer belongs in that consumer's chunk (see SettingsDialog.module.css). */
209
+ .ui-modal--lg { position: relative; }
210
+
211
+ .ui-textarea {
212
+ width: 100%; min-height: 96px; resize: vertical; box-sizing: border-box;
213
+ background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius-md);
214
+ padding: 12px; color: var(--ui-text); font-size: 14px; font-family: inherit; outline: none;
215
+ }
216
+ .ui-textarea:focus { border-color: var(--ui-accent); }
217
+
218
+ /* ── Segmented control ────────────────────────────────────────────────────────
219
+ Grouped toggle: icon squares (.ui-seg button) or text tabs (.is-text). The
220
+ selected segment uses the accent-soft state. */
221
+ .ui-seg { display: inline-flex; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-md); overflow: hidden; }
222
+ .ui-seg button {
223
+ display: inline-grid; place-items: center; width: 36px; height: 36px;
224
+ border: 0; background: transparent; color: var(--ui-text-muted); cursor: pointer; font-family: inherit;
225
+ }
226
+ .ui-seg button.is-text { width: auto; padding: 0 12px; font-size: var(--ui-fs-body); font-weight: 500; }
227
+ .ui-seg button + button { border-left: 1px solid var(--ui-border); }
228
+ .ui-seg button:hover { background: var(--ui-surface-2); color: var(--ui-text); }
229
+ .ui-seg button.active { background: var(--ui-accent-soft); color: var(--ui-accent-strong); }
230
+ .ui-seg svg { width: 16px; height: 16px; fill: currentColor; }
231
+
232
+ /* ── Stat chip ────────────────────────────────────────────────────────────────
233
+ Count-led pill; clickable ones double as filters (active = accent-soft). */
234
+ .ui-stats { display: flex; flex-wrap: wrap; gap: var(--ui-space-2); margin-bottom: 24px; }
235
+ .ui-stat {
236
+ display: inline-flex; align-items: center; gap: var(--ui-space-2);
237
+ padding: 6px 12px; border-radius: 999px; font-family: inherit;
238
+ background: var(--ui-surface); border: 1px solid var(--ui-border);
239
+ font-size: var(--ui-fs-caption); color: var(--ui-text-muted);
240
+ }
241
+ .ui-stat b { color: var(--ui-text-strong); font-weight: 650; }
242
+ .ui-stat.is-clickable { cursor: pointer; transition: border-color 0.14s ease, background-color 0.14s ease; }
243
+ .ui-stat.is-clickable:hover { border-color: var(--ui-text-muted); background: var(--ui-surface-2); }
244
+ .ui-stat.is-clickable.active { border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--ui-accent-strong); }
245
+ .ui-stat.is-clickable.active b { color: var(--ui-accent-strong); }
246
+
247
+ /* ── Toast / notification bar ─────────────────────────────────────────────────
248
+ App-wide "done / failed" pill for async actions (delete/duplicate/publish…).
249
+ Polaris-style: dark pill, bottom-center, error variant in critical red. Lives
250
+ in <ToastProvider> (components/ui/Toast.tsx), portaled to <body>. */
251
+ .gemboss-toast-viewport {
252
+ position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
253
+ z-index: 2147483000; /* above every overlay/modal/popup */
254
+ display: flex; flex-direction: column; align-items: center; gap: 12px;
255
+ width: max-content; max-width: min(560px, calc(100vw - 32px)); pointer-events: none;
256
+ }
257
+ .gemboss-toast {
258
+ pointer-events: auto;
259
+ display: flex; align-items: center; gap: 12px;
260
+ min-width: 220px; max-width: 100%; padding: 12px 12px 12px 16px;
261
+ background: var(--ui-text-strong, #1a1917); color: #fff; border-radius: 12px;
262
+ font-size: var(--ui-fs-body, 13px); line-height: 1.4; font-weight: 500;
263
+ box-shadow: var(--gemboss-shadow-overlay, 0 8px 28px rgba(0, 0, 0, 0.24));
264
+ animation: gemboss-toast-in 240ms cubic-bezier(0.16, 1, 0.3, 1);
265
+ }
266
+ .gemboss-toast.is-error { background: var(--gemboss-danger, #d72c0d); }
267
+ .gemboss-toast-text { flex: 1 1 auto; min-width: 0; }
268
+ .gemboss-toast-action {
269
+ flex: 0 0 auto; border: 0; background: transparent; color: #fff; cursor: pointer;
270
+ padding: 4px 8px; border-radius: 8px; font: inherit; font-weight: 650;
271
+ text-decoration: underline; text-underline-offset: 2px;
272
+ transition: background 0.14s ease;
273
+ }
274
+ .gemboss-toast-action:hover { background: rgba(255, 255, 255, 0.16); }
275
+ .gemboss-toast-dismiss {
276
+ flex: 0 0 auto; display: inline-grid; place-items: center;
277
+ width: 24px; height: 24px; border: 0; border-radius: 8px; cursor: pointer;
278
+ background: rgba(255, 255, 255, 0.12); color: #fff;
279
+ transition: background 0.14s ease;
280
+ }
281
+ .gemboss-toast-dismiss:hover { background: rgba(255, 255, 255, 0.26); }
282
+ .gemboss-toast-action:focus-visible,
283
+ .gemboss-toast-dismiss:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
284
+ @keyframes gemboss-toast-in {
285
+ from { opacity: 0; transform: translateY(12px); }
286
+ to { opacity: 1; transform: translateY(0); }
287
+ }
288
+ @media (prefers-reduced-motion: reduce) { .gemboss-toast { animation: none; } }
289
+
290
+ /* ── The kit's controls under a thumb ─────────────────────────────────────────────────────────
291
+ * Measured by hit test on /admin/{theme-kernel,media,style-packs} at 390 and 1366 with a coarse
292
+ * pointer: segmented buttons 36x36, selects 30-36 tall, clickable stat tiles 29, the asset row's
293
+ * note button 30x30. §9.1 puts every one of them in the CONTROL tier (44px).
294
+ *
295
+ * HERE rather than in each surface's stylesheet, because this is the shared primitive layer — the
296
+ * same reason the kit exists at all. The admin console and the seller app both read it.
297
+ *
298
+ * `pointer: coarse` only: a mouse keeps the density these surfaces were designed at. Growing a box
299
+ * for every pointer is what #2147 did to a working width.
300
+ */
301
+ @media (pointer: coarse) {
302
+ /* NOT `.ui-textarea`: it already declares `min-height: 96px` earlier in this file, so adding it to
303
+ a 44px group LOWERS it — same specificity, later line. The note dialog's box would have gone from
304
+ 96 to ~58 on every touch device, inside a block whose whole point is bigger targets.
305
+ NOT `.ui-btn--sm` either: `min-height` always beats `height`, so a 44px floor voids the compact
306
+ variant — including four of them on the seller's own /app/settings/store. A density variant is a
307
+ decision, not an oversight; it gets its own measurement pass or none. */
308
+ .ui-btn:not(.ui-btn--sm),
309
+ .ui-stat.is-clickable,
310
+ .ui-seg button { min-height: 44px; }
311
+ /* A clickable stat whose label is one short token ("7d" on /admin/gem-intelligence) measured 40x44
312
+ in the 13/09/2026 sweep: the height floor held, the width did not. */
313
+ .ui-stat.is-clickable { min-width: 44px; justify-content: center; }
314
+ /* `--slim` is a second single class and lands later in this file, so it would win on order over a
315
+ bare `.ui-select`. Doubled here to decide it on specificity instead. */
316
+ .ui-select.ui-select,
317
+ .ui-select--slim.ui-select--slim { min-height: 44px; }
318
+ /* The icon-only segment is 36 WIDE as well — a target is an area. */
319
+ .ui-seg button { min-width: 44px; }
320
+ }
321
+
322
+ /* ── Skeleton ─────────────────────────────────────────────────────────────────
323
+ Vạch xám giữ chỗ cho nội dung chưa về. Ánh sáng chạy qua là gợi ý "đang chờ", không phải trang
324
+ trí — nên nó tắt hẳn với `prefers-reduced-motion`, giữ nguyên vạch tĩnh. */
325
+ .ui-skeleton { display: flex; flex-direction: column; gap: var(--ui-space-2); }
326
+ .ui-skeleton__line {
327
+ display: block; height: 12px; border-radius: 6px;
328
+ background: linear-gradient(90deg, var(--ui-surface-2) 25%, var(--ui-border) 37%, var(--ui-surface-2) 63%);
329
+ background-size: 400% 100%;
330
+ animation: ui-skeleton-sheen 1.4s ease-in-out infinite;
331
+ }
332
+ @keyframes ui-skeleton-sheen { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }
333
+ @media (prefers-reduced-motion: reduce) {
334
+ .ui-skeleton__line { animation: none; background: var(--ui-surface-2); }
335
+ }
336
+ /* Nhãn cho trình đọc màn hình — thứ `<p>Loading…</p>` cũ vốn có và một khối hình thuần sẽ mất. */
337
+ .ui-sr-only {
338
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
339
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
340
+
341
+ }
342
+
343
+ /* ── Tabs · Table · Empty state ───────────────────────────────────────────────
344
+ Plain-CSS primitives for surfaces that are NOT React (gemcare's vanilla dashboard reads this
345
+ file through `npm run export:ui-css`). The React app has Polaris <Tabs>/<DataTable>/<EmptyState>
346
+ for these; nothing in apps/admin uses the classes below, and they restyle no existing component.
347
+ Only `--ui-*` / `--gemboss-*` tokens, so the dark `.gemboss-admin-shell` bridge applies too.
348
+
349
+ Tabs: `<div class="ui-tabs" role="tablist"><button role="tab" aria-selected="true">…` — the
350
+ selected state IS `aria-selected`, so what a screen reader announces and what is painted cannot
351
+ disagree. 44px under EVERY pointer (§9.1 control tier), not only coarse: tabs sit 4px apart, and
352
+ that gap is only acceptable between targets that are already 44px. */
353
+ .ui-tabs {
354
+ display: flex; gap: var(--ui-space-1); border-bottom: 1px solid var(--ui-border);
355
+ overflow-x: auto; scrollbar-width: none;
356
+ }
357
+ .ui-tabs > button {
358
+ position: relative; flex: 0 0 auto; display: inline-flex; align-items: center; gap: var(--ui-space-2);
359
+ min-height: 44px; padding: 0 var(--ui-space-3); border: 0; background: transparent; cursor: pointer;
360
+ border-radius: var(--ui-radius-sm) var(--ui-radius-sm) 0 0;
361
+ font-family: inherit; font-size: var(--gemboss-fs-body); font-weight: 500; white-space: nowrap;
362
+ color: var(--ui-text-muted);
363
+ transition: color var(--gemboss-transition-fast) ease, background-color var(--gemboss-transition-fast) ease;
364
+ }
365
+ .ui-tabs > button:hover { color: var(--ui-text); background: var(--ui-surface-2); }
366
+ .ui-tabs > button:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: -2px; }
367
+ .ui-tabs > button[aria-selected="true"] { color: var(--ui-text-strong); font-weight: 600; }
368
+ .ui-tabs > button[aria-selected="true"]::after {
369
+ content: ""; position: absolute; left: var(--ui-space-3); right: var(--ui-space-3); bottom: 0;
370
+ height: 2px; border-radius: var(--gemboss-radius-full); background: var(--ui-accent);
371
+ }
372
+ .ui-tabs > button:disabled { opacity: .5; cursor: not-allowed; }
373
+
374
+ /* Table: wrap it — `<div class="ui-table-wrap"><table class="ui-table">` — so a wide table scrolls
375
+ inside its card instead of pushing the page sideways at 320px. Numeric cells take `.is-num`:
376
+ right-aligned tabular figures, so a column of numbers lines up digit for digit. */
377
+ .ui-table-wrap {
378
+ overflow-x: auto; max-width: 100%;
379
+ background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius-lg);
380
+ }
381
+ .ui-table { width: 100%; border-collapse: collapse; font-size: var(--gemboss-fs-body); color: var(--ui-text); }
382
+ .ui-table th,
383
+ .ui-table td {
384
+ padding: var(--ui-space-2) var(--ui-space-3); text-align: left; vertical-align: middle;
385
+ border-bottom: 1px solid var(--ui-border);
386
+ }
387
+ .ui-table th {
388
+ font-size: var(--gemboss-fs-caption); font-weight: 600; color: var(--ui-text-muted); white-space: nowrap;
389
+ }
390
+ .ui-table tbody tr:last-child > td { border-bottom: 0; }
391
+ .ui-table tbody tr:hover { background: var(--ui-surface-2); }
392
+ .ui-table .is-num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
393
+
394
+ /* Empty state: `<div class="ui-empty"><p class="ui-empty__title">…</p><p class="ui-empty__body">…</p>
395
+ <button class="ui-btn">…</button></div>`. Says what is missing and what to do next. */
396
+ .ui-empty {
397
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--ui-space-2);
398
+ padding: var(--ui-space-4); padding-block: 40px; text-align: center;
399
+ color: var(--ui-text-muted); font-size: var(--gemboss-fs-body);
400
+ }
401
+ .ui-empty__title { margin: 0; font-size: var(--gemboss-fs-h3); font-weight: 600; color: var(--ui-text-strong); }
402
+ .ui-empty__body { margin: 0; max-width: 48ch; line-height: 1.5; }
403
+ .ui-empty > .ui-btn { margin-top: var(--ui-space-2); }