@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/auth.tsx ADDED
@@ -0,0 +1,202 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The GemBoss sign-in screen as components — the markup `auth.css` was written for.
5
+ *
6
+ * Every sign-in in the fleet (gemcommunity, content-radar, gemwatcher, gemcare, partner-radar) was a
7
+ * hand copy of the admin's, and they had drifted: card 380 vs 400, radius 14 vs 12, one with no dark
8
+ * mode. The admin renders its own /login with these now, so there is one of it.
9
+ *
10
+ * What a surface supplies is the FORM's contents — which fields, what submit does, what the errors
11
+ * say. What it does not supply is the screen: the split with the brand panel, the card, the mark,
12
+ * the height ladder that sheds the tagline and the mark on short screens, the keyboard-aware height,
13
+ * the dark theme. Re-theme it by redefining the `--gb-*` tokens on `.gb-auth` (see auth.css), never
14
+ * by restyling its classes.
15
+ */
16
+ import React, { useEffect, useRef, useState } from "react";
17
+
18
+ import { GemMark } from "./GemMark";
19
+
20
+ const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(" ");
21
+
22
+ /**
23
+ * The whole screen. `children` is everything inside the card below the heading: the form, then any
24
+ * lines under it.
25
+ */
26
+ export function AuthLayout({ brand, tagline, mark, panel, className, children }: {
27
+ /** The page heading (h1) in the card. */
28
+ brand: React.ReactNode;
29
+ /** One line under the heading; the first thing shed on a short screen. */
30
+ tagline?: React.ReactNode;
31
+ /** Defaults to the GemBoss gem on the `--gb-mark-bg` tile. */
32
+ mark?: React.ReactNode;
33
+ /** The brand panel beside the card — shown only where there is room (900 wide AND 520 tall). */
34
+ panel?: { eyebrow?: React.ReactNode; heading: React.ReactNode; body?: React.ReactNode };
35
+ className?: string;
36
+ children?: React.ReactNode;
37
+ }) {
38
+ // The height the person can actually SEE. With the on-screen keyboard open, iOS Safari keeps the
39
+ // layout viewport (and so 100dvh and every max-height media query) at full height and simply
40
+ // covers the bottom half — the card measured as "fitting" while Sign in sat under the keyboard.
41
+ // visualViewport is the one number that shrinks. It becomes the shell's height, and every size and
42
+ // shed step in auth.css is a container query on the shell, so they all follow it. Unset (SSR,
43
+ // first paint, no visualViewport) falls back to 100dvh.
44
+ //
45
+ // Two things besides the keyboard move visualViewport (found in review of gemboss #3146):
46
+ // - PINCH-ZOOM shrinks vv.height too (2x zoom halves it). Treated as a keyboard it shed half the
47
+ // card under the user's fingers. Zoomed, the shell goes back to the full layout height.
48
+ // - iOS SCROLLS the visual viewport down (offsetTop) to reveal the focused field. The shell is
49
+ // position:fixed at --vvt, so it overlays exactly what is visible.
50
+ const shellRef = useRef<HTMLElement>(null);
51
+ useEffect(() => {
52
+ const vv = window.visualViewport;
53
+ const shell = shellRef.current;
54
+ if (!vv || !shell) return;
55
+ const sync = () => {
56
+ if (Math.abs(vv.scale - 1) > 0.01) {
57
+ shell.style.removeProperty("--vvh");
58
+ shell.style.removeProperty("--vvt");
59
+ return;
60
+ }
61
+ shell.style.setProperty("--vvh", `${Math.round(vv.height)}px`);
62
+ shell.style.setProperty("--vvt", `${Math.round(vv.offsetTop)}px`);
63
+ };
64
+ sync();
65
+ vv.addEventListener("resize", sync);
66
+ vv.addEventListener("scroll", sync);
67
+ return () => {
68
+ vv.removeEventListener("resize", sync);
69
+ vv.removeEventListener("scroll", sync);
70
+ };
71
+ }, []);
72
+
73
+ return (
74
+ <main className={cx("gb-auth", className)} ref={shellRef}>
75
+ <div className="gb-auth-split">
76
+ <div className="gb-auth-stage">
77
+ <div className="gb-auth-card">
78
+ <header className="gb-auth-header">
79
+ <span className="gb-auth-mark" aria-hidden>
80
+ {mark ?? <GemMark size={56} tile="var(--gb-mark-bg)" />}
81
+ </span>
82
+ <h1>{brand}</h1>
83
+ {tagline ? <p>{tagline}</p> : null}
84
+ </header>
85
+ {children}
86
+ </div>
87
+ </div>
88
+ {/* Left OUT of aria-hidden: the words are an invitation and a screen-reader user should hear
89
+ them. Nothing in it is focusable — it is decoration beside the form, not a second path. */}
90
+ {panel ? (
91
+ <aside className="gb-auth-panel">
92
+ <div className="gb-auth-panel-in">
93
+ {panel.eyebrow ? <p className="gb-auth-eyebrow">{panel.eyebrow}</p> : null}
94
+ <h2 className="gb-auth-panel-h">{panel.heading}</h2>
95
+ {panel.body ? <p className="gb-auth-panel-p">{panel.body}</p> : null}
96
+ </div>
97
+ </aside>
98
+ ) : null}
99
+ </div>
100
+ </main>
101
+ );
102
+ }
103
+
104
+ export function AuthForm({ className, children, ...rest }: React.FormHTMLAttributes<HTMLFormElement>) {
105
+ return <form className={cx("gb-auth-form", className)} {...rest}>{children}</form>;
106
+ }
107
+
108
+ type InputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "className"> & {
109
+ /** The class that draws the field's SHAPE. The admin passes its shared `gemboss-field
110
+ * gemboss-field--tall`; everyone else gets `gb-auth-input`, the same numbers. */
111
+ inputClassName?: string;
112
+ };
113
+
114
+ /**
115
+ * A labelled field. Without `id` the input sits inside its label; with `id` the label points at it
116
+ * (`htmlFor`) from beside it — needed when something else lives in the field's box.
117
+ */
118
+ export function AuthField({ label, id, inputClassName = "gb-auth-input", ...input }: InputProps & { label: React.ReactNode }) {
119
+ if (!id) {
120
+ return (
121
+ <label>
122
+ <span>{label}</span>
123
+ <input className={inputClassName} {...input} />
124
+ </label>
125
+ );
126
+ }
127
+ return (
128
+ <div className="gb-auth-field">
129
+ <label htmlFor={id}>{label}</label>
130
+ <input className={inputClassName} id={id} {...input} />
131
+ </div>
132
+ );
133
+ }
134
+
135
+ /**
136
+ * The eye every password field has. `off` draws the struck-through eye — shown while the password IS
137
+ * visible, because the button says what pressing it will do next.
138
+ */
139
+ function EyeIcon({ off }: { off: boolean }) {
140
+ return (
141
+ <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6"
142
+ strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
143
+ <path d="M1.5 10S4.6 4.5 10 4.5 18.5 10 18.5 10 15.4 15.5 10 15.5 1.5 10 1.5 10Z" />
144
+ <circle cx="10" cy="10" r="2.6" />
145
+ {off && <path d="M3 17 17 3" />}
146
+ </svg>
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Password + reveal toggle inside the field. The input is NOT inside the label: a <button> inside a
152
+ * <label> still fires the label's activation, so every reveal re-focused the input and, on iOS
153
+ * Safari, scrolled the card. `htmlFor` keeps the word "Password" clickable without that.
154
+ */
155
+ export function PasswordField({ id, label = "Password", toggleLabels = { show: "Show password", hide: "Hide password" },
156
+ inputClassName = "gb-auth-input", ...input }: InputProps & {
157
+ id: string;
158
+ label?: React.ReactNode;
159
+ toggleLabels?: { show: string; hide: string };
160
+ }) {
161
+ const [shown, setShown] = useState(false);
162
+ const name = shown ? toggleLabels.hide : toggleLabels.show;
163
+ return (
164
+ <div className="gb-auth-field">
165
+ <label htmlFor={id}>{label}</label>
166
+ <div className="gb-auth-pw">
167
+ <input className={inputClassName} id={id} type={shown ? "text" : "password"} {...input} />
168
+ <button
169
+ type="button"
170
+ onClick={() => setShown((v) => !v)}
171
+ aria-controls={id}
172
+ // NAME ONLY, no aria-pressed: once the name flips, a pressed state says the opposite thing
173
+ // ("Hide password, pressed" while the password sits in plain text).
174
+ aria-label={name}
175
+ title={name}
176
+ className="gb-auth-pw-toggle"
177
+ >
178
+ <EyeIcon off={shown} />
179
+ </button>
180
+ </div>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ export function AuthError({ children }: { children?: React.ReactNode }) {
186
+ if (!children) return null;
187
+ return <div className="gb-auth-error" role="alert">{children}</div>;
188
+ }
189
+
190
+ export function AuthSubmit({ className, children, ...rest }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
191
+ return <button type="submit" className={cx("gb-auth-submit", className)} {...rest}>{children}</button>;
192
+ }
193
+
194
+ /** A line under the form, e.g. "New here? Create an account →". Shed below 360px of height. */
195
+ export function AuthFoot({ children }: { children?: React.ReactNode }) {
196
+ return <p className="gb-auth-foot">{children}</p>;
197
+ }
198
+
199
+ /** The smaller "trouble getting in?" line. Shed below 360px of height. */
200
+ export function AuthHelp({ children }: { children?: React.ReactNode }) {
201
+ return <p className="gb-auth-help">{children}</p>;
202
+ }
package/src/brand.tsx ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The product lockup for the topbar: the GemBoss gem + a product name — @gemboss/ui (28/09/2026).
3
+ *
4
+ * The admin's topbar shows apps/admin/public/gemboss-logo.svg, a line-art gem beside "GemBoss", served
5
+ * 26px tall. Other surfaces could not reuse it (it spells GemBoss, and its ≤460px fallback is a
6
+ * background image at an admin-only path), so each drew its own: GemFactory put the TILED app mark
7
+ * (`GemMark`, white gem on a black square) beside its name, which is the app icon, not the wordmark.
8
+ * This draws the wordmark's own geometry at the wordmark's rendered size, with any name:
9
+ * - gem: the logo file's paths and stroke weights, same 0.31 scale, in a 32×40 box drawn 26px tall;
10
+ * - name: the logo's text at the same scale — 20.5 × 0.65 = 13.3px, 700, -0.13px tracking, #14151a.
11
+ * `BrandLockup name="GemBoss"` is meant to be indistinguishable from the admin's image.
12
+ */
13
+ import React from "react";
14
+
15
+ type AnchorProps = React.AnchorHTMLAttributes<HTMLAnchorElement>;
16
+
17
+ export function BrandLockup({ name, href = "/", label, className, ...rest }:
18
+ { name: string; label?: string } & AnchorProps) {
19
+ return (
20
+ <a className={className ? `gb-brand ${className}` : "gb-brand"} href={href} aria-label={label ?? `${name} home`} {...rest}>
21
+ <svg className="gb-brand-gem" viewBox="0 0 32 40" width="20.8" height="26" aria-hidden="true" focusable="false">
22
+ <g transform="translate(0,0.8) scale(0.31)">
23
+ <polygon points="26,14 74,14 66,50 34,50" fill="#5661F5" fillOpacity="0.92" />
24
+ <path d="M26,14 74,14 96,50 50,110 4,50Z" fill="none" stroke="currentColor" strokeWidth="8.75" strokeLinejoin="round" />
25
+ <path d="M4,50H96M26,14 34,50M74,14 66,50M34,50 50,110M66,50 50,110" fill="none" stroke="currentColor" strokeWidth="5.45" strokeLinejoin="round" />
26
+ </g>
27
+ </svg>
28
+ <span className="gb-brand-name">{name}</span>
29
+ </a>
30
+ );
31
+ }
@@ -0,0 +1,108 @@
1
+ /* THE ONE TEXT FIELD, in a file of its own.
2
+ *
3
+ * It lives apart from gemboss-shell.css because the shell sheet is loaded ONLY by `app/app/layout.tsx`
4
+ * — the /app subtree — while `/login` and `/try` are deliberately lean pages outside it. Adding the
5
+ * class on either changed NOTHING, because neither loads the shell. Measured on prod 23/09/2026 (the
6
+ * build before the change): /login drew its own field family in styled-jsx — 44px, radius 8, 14px,
7
+ * border rgb(201,204,207) — and /try's textarea was the native box, radius 0. An earlier version of
8
+ * this comment said both stayed "21px, unrounded, native border"; that was one field on /try written
9
+ * up as if it described both pages. A few hundred bytes of field rules can go where the shell cannot.
10
+ */
11
+ /* ── ONE text field for every hand-written <input>/<select> in the admin ─────────────────────────
12
+ Measured 22/09/2026 across every /app route: the 21 Polaris fields were one shape (32px, radius 8,
13
+ the input-border token, 13px → 16px on a phone), and the 13 hand-written ones were FIVE shapes —
14
+ heights 33/36/38/42/44, radii 8/9/10, one warm off-token border, and every one of them still 13-14px
15
+ on a phone, which is the size iOS Safari ZOOMS the page for the moment you tap the field. This is the
16
+ Polaris field, written once: a custom field wears `gemboss-field` and nothing else decides its look.
17
+ Its touch height lives in the pointer:coarse block with the Polaris rule it mirrors. */
18
+ /* …and the same box on a PRESSABLE control. Three buttons wore `gemboss-field` itself — the campaign
19
+ compare's "↻ Refresh", which sits in a row between two selects that wear it, and the inspector's ✕
20
+ and + — because what they wanted was this box, lined up with the fields beside them. But a button
21
+ is not a text field, and the class that MEANS "text field" should not be what says so: it makes the
22
+ ratchet count a button as a converted field, and it invites the next person to put it on anything
23
+ box-shaped. One box, two names, declared together so they can never drift apart. */
24
+ .gemboss-field,
25
+ .gemboss-control {
26
+ box-sizing: border-box; height: 32px; min-width: 0; padding: 0 12px;
27
+ font: inherit; font-size: var(--p-font-size-325, 13px); line-height: 20px;
28
+ color: var(--p-color-text, var(--gemboss-field-ink));
29
+ background-color: var(--p-color-input-bg-surface, var(--gemboss-field-bg));
30
+ border: 1px solid var(--p-color-input-border, var(--gemboss-field-line));
31
+ border-radius: var(--p-border-radius-200, 8px);
32
+ }
33
+ .gemboss-field::placeholder { color: var(--p-color-text-secondary, var(--gemboss-field-placeholder)); }
34
+ /* A NARROW field — the inspector's T/R/B/L boxes and its number spinners are 40-68px wide, and the
35
+ shared 12px side padding left 38px of content box for a three-digit value (measured in Chromium:
36
+ clientWidth 38, scrollWidth 58 for "120"). Same box, tighter lane. */
37
+ /* A field that is a 44px target at EVERY width, not only on a phone. Some surfaces asked for that in
38
+ their own inline style (a popup's `minHeight: 44`), and that intent is theirs to keep — it moves here
39
+ so the look still comes from one place. */
40
+ .gemboss-field--tall { min-height: 44px; }
41
+ .gemboss-field--narrow { padding: 0 6px; appearance: textfield; -moz-appearance: textfield; }
42
+ /* …and no spinner: the arrows eat ~11px of a 40px box, so "120" was still cut with the tighter
43
+ padding (measured: clientWidth 38, scrollWidth 49). The value is typed here, not stepped. */
44
+ .gemboss-field--narrow::-webkit-outer-spin-button,
45
+ .gemboss-field--narrow::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
46
+ .gemboss-field:hover:not(:disabled),
47
+ .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)); }
48
+ .gemboss-field:focus-visible,
49
+ .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)); }
50
+ .gemboss-field:disabled,
51
+ .gemboss-control:disabled { opacity: 0.5; cursor: not-allowed; }
52
+ /* Polaris' own breakpoint for the same switch: under it a field reads 16px, so iOS does not zoom. */
53
+ /* On a PHONE-WIDTH screen a field is 16px (iOS does not zoom) and 44px tall (§9.1) — by WIDTH, not by
54
+ pointer. A desktop browser resized to 320px still reports `pointer: fine`, and the page-revision
55
+ gate measures the publication select's height at 320px: dropping that panel's own `min-height: 44px`
56
+ in favour of the shared class left it 32px there, and the gate went red (CI of #3201). */
57
+ /* Cả hai tên nhận CÙNG hai thứ ở khổ điện thoại. Bản đầu chỉ cho `--control` chiều cao, không cho
58
+ cỡ chữ — và 16px ở đây KHÔNG phải để chặn iOS zoom (một cái nút không zoom) mà để một hàng
59
+ control đọc ra MỘT họ: nút Refresh của compare view ngồi giữa hai select, và ở 13px giữa hai
60
+ ô 16px thì nó là một cỡ chữ thứ hai trong cùng một hàng (/code-review, 23/09/2026). */
61
+ @media (max-width: 47.9975em) { .gemboss-field, .gemboss-control { font-size: 16px; min-height: 44px; } }
62
+ /* A multi-line field: Polaris' multiline TextField — the same box, its height from `rows`. */
63
+ textarea.gemboss-field { height: auto; padding: 6px 12px; resize: vertical; }
64
+ /* A field made of parts — a fixed prefix ("/pages/") or a colour swatch beside the text. The WRAPPER
65
+ wears `gemboss-field gemboss-field--group` and is the box; the text inside wears `gemboss-field__bare`
66
+ and draws nothing of its own, taking the box's font size (13px, 16px on a phone) by inheritance. */
67
+ /* The GROUP grows to whatever it holds: `gemboss-field` fixes a 32px height, and a panel that asks
68
+ for 44px targets (the section content panel does, and `page-saved-continuation-fixture` measures
69
+ EVERY control there) puts a 44px input inside this box. Fixing the group at 32 and shrinking the
70
+ input to fit clipped it and broke that gate; the box follows its content instead. */
71
+ .gemboss-field--group { display: flex; align-items: center; gap: 6px; height: auto; min-height: 32px; padding-right: 0; overflow: hidden; }
72
+ /* The phone floor again, for the GROUP. `min-height: 32px` above is the same specificity as the
73
+ `.gemboss-field` rule in the media query and comes LATER in the file, so it won the cascade at
74
+ phone width and quietly took the group back down to 32px — the URL-handle and background-colour
75
+ rows, the two places a field is made of parts. A media query does not outrank anything; source
76
+ order decided it, and the fix is to say it again after. (/code-review, 23/09/2026.) */
77
+ @media (max-width: 47.9975em) { .gemboss-field--group { min-height: 44px; } }
78
+ .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)); }
79
+ /* The colour swatch in a group: FLUSH to the group's left edge and full height — it covers the group's
80
+ 1px border top and bottom and its 12px left padding, so it is the whole 44px target the panel's rule
81
+ asks for, rounded by the group's own corner (overflow: hidden), and never clipped. A 44px swatch
82
+ INSIDE the 42px the border leaves was cut by a pixel top and bottom (/code-review #3201). */
83
+ .gemboss-field--group input[type="color"] { flex: none; align-self: stretch; width: 44px; min-width: 44px; height: auto; min-height: 0; margin: -1px 0 -1px -12px; padding: 0; border: 0; border-right: 1px solid var(--p-color-input-border, var(--gemboss-field-line)); border-radius: 0; background: none; cursor: pointer; }
84
+ /* ↑ the right edge is what keeps a WHITE swatch visible: without it the block melted into the field (measured). */
85
+ .gemboss-field--group input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
86
+ .gemboss-field--group input[type="color"]::-webkit-color-swatch { border: 0; border-radius: 0; }
87
+ .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; }
88
+ /* …and the placeholder, which "draws nothing" does not cover: the surfaces that used to declare
89
+ their own (the /admin search rows, the settings and palette rows) stopped when they stopped
90
+ owning the box, and without this every bare field would fall back to the browser's grey. */
91
+ .gemboss-field__bare::placeholder { color: var(--p-color-text-secondary, var(--gemboss-field-placeholder)); }
92
+
93
+ /* Under a thumb the field is 44px, the same height this app gives a Polaris field there. It sits in
94
+ THIS file, not in the shell's pointer:coarse block, so /login and /try — which load only this
95
+ sheet — get it too. */
96
+ @media (pointer: coarse) { .gemboss-field, .gemboss-control { height: auto; min-height: 44px; } }
97
+ /* A control is pressable; a field is not. That is the whole difference. */
98
+ .gemboss-control { cursor: pointer; }
99
+ /* An ICON control — the repeater's ✕ — is square and narrow beside its row. It was pinned to
100
+ `width: 32` in an inline style, which no media query can reach, so under a thumb it was a
101
+ 32x44 target: tall enough, too narrow (§9.1 asks 44 in BOTH dimensions for a control).
102
+ The width lives here now, and grows with the pointer. (/code-review, 23/09/2026.) */
103
+ /* Hai class trên cùng phần tử, có chủ ý: `.gemboss-ei-inp` đặt `width: 100%` ở cùng trọng số và
104
+ nằm SAU trong thứ tự nạp (shell @import file này ở đầu), nên một selector đơn thua nó — đo
105
+ được: nút ✕ giãn ra 472px. Bề rộng vốn là inline style nên trước đây nó thắng mọi thứ; đưa
106
+ vào CSS thì phải mang theo trọng số của chính nó. */
107
+ .gemboss-control.gemboss-control--icon { width: 32px; flex: 0 0 auto; padding: 0; }
108
+ @media (pointer: coarse) { .gemboss-control.gemboss-control--icon { width: 44px; min-width: 44px; } }
@@ -0,0 +1,164 @@
1
+ /* The design tokens, and ONLY the tokens.
2
+ *
3
+ * Split out of gemboss-shell.css because that file is 203 KB of dashboard component CSS and it was
4
+ * loaded by the ROOT layout — so /try and /login, which render no .gemboss-* component at all,
5
+ * blocked their first paint on it. Tokens are 6 KB of the 203; /login uses var(--gemboss-primary)
6
+ * and the rewritten /try uses the palette, so everyone needs these and almost nobody needs the rest.
7
+ *
8
+ * Order matters and is preserved: the second :root block below redefines --gemboss-primary and
9
+ * friends from the first, exactly as it did inside the shell file. The Polaris --p-* overrides stay
10
+ * in gemboss-shell.css, next to the Polaris stylesheet they override.
11
+ */
12
+ :root {
13
+ --gemboss-primary: #1b1c1e; /* near-black — primary action (buttons / CTA) */
14
+ --gemboss-primary-dark: #000000; /* black hover */
15
+ --gemboss-primary-soft: #f2f3f5; /* neutral tint */
16
+ --gemboss-accent: #5661f5; /* iris — brand accent, điểm nhấn only (links / focus / active) */
17
+ --gemboss-accent-strong: #3a44de; /* iris strong (hover / text) */
18
+ --gemboss-accent-soft: #eef0fe; /* iris tint */
19
+ --gemboss-text-strong: #1b1c1e; /* neutral near-black */
20
+ --gemboss-text: #1b1c1e;
21
+ --gemboss-text-muted: #686b71; /* neutral gray */
22
+ --gemboss-surface: #ffffff;
23
+ --gemboss-canvas: #ffffff; /* white page background (was #f6f7f9 gray) — body + Polaris --p-color-bg */
24
+ --gemboss-preview-canvas: #eceef1; /* neutral sunken backdrop behind a live preview/widget stage */
25
+ --gemboss-rail-bg: #ffffff; /* white sidebar (was #f4f5f7) — separated by border-right divider */
26
+ --gemboss-panel-bg: #ffffff; /* white secondary panels (was #f2f4f6) */
27
+ --gemboss-divider: #e5e7ea; /* neutral hairline border */
28
+
29
+ /* Chiều cao THẬT của lời mời desktop, do DesktopInvite.tsx ghi đè lúc chạy bằng ResizeObserver
30
+ (chip ~44px, thẻ mở ~328px) và GỠ HẲN khi lời mời không hiện. Khai ở đây bằng 0px vì hai lẽ:
31
+ cổng `export-ui-css` đòi mọi token phải được khai (nó bắt được đúng thiếu sót này), và một
32
+ `var()` không có chủ sở hữu là thứ không ai tra ngược được. Trang dùng nó để chừa chỗ ở cuối
33
+ tài liệu — xem `.gemboss-platform-main { padding-bottom: … }` trong gemboss-shell.css. */
34
+ --gemboss-invite-h: 0px;
35
+ --gemboss-divider-soft: #edeff2;
36
+ --gemboss-hover: #eceef2;
37
+ --gemboss-active-bg: #f0f1f4; /* neutral selected (Polaris-style) */
38
+ --gemboss-active-fg: #1b1c1e; /* near-black selected text */
39
+ --gemboss-danger: #d72c0d;
40
+ --gemboss-danger-strong: #b42318;
41
+ --gemboss-danger-soft: #fff0e9;
42
+ --gemboss-danger-border: #f5c9bc;
43
+ --gemboss-success: #007f5f;
44
+ --gemboss-success-strong: #0a6a4c;
45
+ --gemboss-success-soft: #e3f1df;
46
+ --gemboss-success-border: #b7dfcd;
47
+ --gemboss-warning: #9a6700;
48
+ --gemboss-warning-soft: #fff8e0;
49
+ --gemboss-warning-border: #f5d569;
50
+ /* Corner-radius scale — Polaris border-radius steps (2/4/6/8/12/16/20 + pill). The old
51
+ long tail of one-off radii (5/7/9/10/11/14/18px) was consolidated onto these steps
52
+ (round-half-up on ties: 5→6, 7→8, 9→8, 10→12, 11→12, 14→16, 18→20) — the
53
+ check-typography guard now keeps radii on-scale. Prefer a token below; raw px must
54
+ still land on a step. */
55
+ --gemboss-radius-xs: 4px;
56
+ --gemboss-radius-sm: 6px;
57
+ --gemboss-radius-md: 8px;
58
+ --gemboss-radius-lg: 12px;
59
+ --gemboss-radius-xl: 16px;
60
+ --gemboss-radius-2xl: 20px;
61
+ --gemboss-radius-full: 999px;
62
+ /* ── Elevation scale (design-language §G) ────────────────────────────────────
63
+ THREE layers, one shadow each — was ~25 bespoke box-shadows (0 6/8/12/16/28px
64
+ at a dozen opacities + mixed shadow colours) = no elevation system. Warm-neutral
65
+ shadow (rgba 31,30,29) to match the ivory canvas. Use a token, never a hand-rolled
66
+ drop shadow. (Directional edge shadows, coloured glows, focus rings, and insets are
67
+ NOT elevation — they stay literal.) */
68
+ --gemboss-shadow-resting: 0 1px 2px rgba(31, 30, 29, 0.04), 0 2px 6px rgba(31, 30, 29, 0.06); /* cards at rest on canvas */
69
+ --gemboss-shadow-raised: 0 4px 16px rgba(31, 30, 29, 0.10); /* dropdowns · popovers · hovercards */
70
+ --gemboss-shadow-overlay: 0 16px 48px rgba(31, 30, 29, 0.18); /* modals · sheets · large floating panels */
71
+ /* Interaction transition speeds — fast = hover/focus on buttons & toggles, base = the
72
+ general default. One-off animation timings (toast/enter, 240–420ms) stay literal. */
73
+ --gemboss-transition-fast: 0.12s;
74
+ --gemboss-transition-base: 0.15s;
75
+ /* ── Type scale ─────────────────────────────────────────────────────────────
76
+ ONE font-size scale for all custom (non-Polaris-<Text>) chrome. Before this,
77
+ gemboss-shell.css carried ~20 ad-hoc sizes (9px → 40px, incl. fractional 10.5 /
78
+ 11.5 / 12.5 / 13.5) — every component hand-nudged its own, drifting smaller
79
+ and reading as "the font keeps shrinking". New rule: pick a step here, never a
80
+ raw px. Floor is 12px (--fs-caption); 11px (--fs-micro) is reserved for bold
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. */
83
+ --gemboss-fs-micro: 11px; /* bold count-pills / status chips only */
84
+ --gemboss-fs-caption: 12px; /* metadata, timestamps, secondary labels */
85
+ --gemboss-fs-body: 14px; /* default body / reading text */
86
+ --gemboss-fs-body-lg: 16px; /* primary descriptions, empty-state prose */
87
+ --gemboss-fs-h3: 16px; /* card titles */
88
+ --gemboss-fs-h2: 20px; /* zone / section headers */
89
+ --gemboss-fs-h1: 24px; /* page titles */
90
+ --gemboss-fs-display: 30px; /* hero / banner display */
91
+ /* Headings use the same clean sans as Polaris body (Shopify-admin feel) rather
92
+ * than a serif display face — a dashboard reads calmer/more neutral that way.
93
+ * Name kept as `--gemboss-serif` to avoid churning ~10 call sites. */
94
+ /* -- Khung nen TOI, dung chung cho hai be mat toi --------------------------
95
+ `/admin` khai `--adm-*`, `/intel` khai `--in-*`, va bay gia tri trung tinh cua chung trung
96
+ nhau TUNG BYTE duoi hai bo ten khac nhau -- do 10/09/2026:
97
+
98
+ #0a0a0c adm-bg in-bg #2a2a31 adm-divider in-border
99
+ #0e0e11 adm-rail in-bg2 #ededf0 adm-text in-text
100
+ #16161a adm-surface in-panel #9a9aa4 adm-muted in-muted
101
+ #1e1e24 adm-surface-2 in-panel2
102
+
103
+ Do la mot ban sao, khong phai mot bien the: hai console cung mot nen, va lam nen sang them
104
+ mot bac thi phai nho sua o hai cho mang ten khac nhau. Bay gia tri nay song o day; `--adm-*`
105
+ va `--in-*` tro thanh BI DANH tro vao, nen 595 cho dang goi chung khong phai doi mot chu.
106
+
107
+ KHONG gop phan con lai: `--in-panel3/-border2/-faint` la bac rieng intel that su can, va bon
108
+ mau ngu nghia cua no (xanh - ho phach - san ho - tim) la ngon ngu rieng cua be mat do. Gop
109
+ nen, giu diem nhan. */
110
+ --gemboss-dark-bg: #0a0a0c;
111
+ --gemboss-dark-rail: #0e0e11;
112
+ --gemboss-dark-surface: #16161a;
113
+ --gemboss-dark-surface-2: #1e1e24;
114
+ --gemboss-dark-divider: #2a2a31;
115
+ --gemboss-dark-text: #ededf0;
116
+ --gemboss-dark-muted: #9a9aa4;
117
+ --gemboss-serif: var(--p-font-family-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
118
+ /* The BODY font, for surfaces without Polaris. The admin never reads it: its body text comes from
119
+ Polaris's `html, body, button` rule (--p-font-family-sans, @shopify/polaris 13.9.5). The fallback
120
+ below is that stack character for character, and export-ui-css.mjs ships the fallback, so a
121
+ surface on gemboss-ui.css picks the admin's face on the same machine (Inter is not loaded as a web
122
+ font anywhere; it renders where installed). Painted by packages/ui/src/app-shell.css; re-theme by
123
+ redefining the token. Added 28/09/2026: before it, those surfaces rendered in default serif. */
124
+ --gemboss-font-sans: var(--p-font-family-sans, 'Inter', -apple-system, BlinkMacSystemFont, 'San Francisco', 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif);
125
+ /* COMPONENT colours that used to be literals, so a surface with a dark theme re-themes them by
126
+ redefining the token instead of overriding the rule (29/09/2026). Each value is the literal it
127
+ replaced, so the admin paints exactly what it painted before. Measured reason: gempress's dark
128
+ theme had to override `.gemboss-account-chip` (initials #303633 on #16161a all but vanished)
129
+ and keep its own copy of the field and the nav count rather than use the package's.
130
+ - field: the fallbacks of gemboss-field.css. The admin still reads Polaris's --p-* first;
131
+ export-ui-css.mjs resolves --p-* away, so on every other surface these tokens are the look.
132
+ - chip: `.gemboss-account-chip` in apps/admin/src/app/gemboss-shell.css.
133
+ - on-primary: text on a --gemboss-primary fill (the active `.gnav-count` pill). */
134
+ --gemboss-field-bg: #fdfdfd;
135
+ --gemboss-field-bg-hover: #fafafa;
136
+ --gemboss-field-ink: #303030;
137
+ --gemboss-field-placeholder: #616161;
138
+ --gemboss-field-line: #8a8a8a;
139
+ --gemboss-field-line-hover: #616161;
140
+ --gemboss-field-line-active: #1a1a1a;
141
+ --gemboss-chip-ink: #303633;
142
+ --gemboss-chip-line: #cfd6ce;
143
+ --gemboss-chip-line-hover: #aab4ad;
144
+ --gemboss-chip-bg-hover: #f6f7f5;
145
+ --gemboss-on-primary: #ffffff;
146
+ /* The shell's own surfaces (29/09/2026), for the same reason as the slots above: a dark surface
147
+ (gempress) had to override `.gemboss-platform-topbar`, `.icon-nav` and `.gnav-item:hover` by rule.
148
+ Defaults are exactly what the shell painted. */
149
+ --gemboss-topbar-bg: rgba(255, 255, 255, 0.98);
150
+ --gemboss-nav-bg: var(--gemboss-surface);
151
+ --gemboss-nav-hover: rgba(20, 19, 18, 0.045);
152
+ }
153
+
154
+ :root {
155
+ --gemboss-primary: #1b1c1e;
156
+ --gemboss-primary-dark: #000000;
157
+ --gemboss-primary-soft: #f2f3f5;
158
+ --gemboss-accent: #5661f5;
159
+ --gemboss-accent-strong: #3a44de;
160
+ --gemboss-accent-soft: #eef0fe;
161
+ --gemboss-active-bg: #f0f1f4; /* neutral selected (Polaris-style) */
162
+ --gemboss-active-fg: #1b1c1e; /* near-black selected text */
163
+ --gemboss-app-bg: #ffffff; /* clean white main content (was #f6f7f9 gray) — cards keep separation via border/resting-shadow */
164
+ }
package/src/icons.tsx ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The GemBoss icon set — @gemboss/ui (28/09/2026).
3
+ *
4
+ * WHY ITS OWN SET. The admin's rail wears @shopify/polaris-icons, whose licence grants use only for
5
+ * applications that integrate with Shopify. The admin does; GemFactory, gemwatcher, content-radar and
6
+ * the other internal tools do not, so they cannot borrow those icons. Each drew its own instead, and
7
+ * GemFactory's came out as solid black blocks (below). Chris chose one GemBoss set for all of them.
8
+ *
9
+ * DRAWN TO SIT NEXT TO POLARIS: a 20px grid, 1.5px strokes with round caps and joins, a 2.5–3px margin,
10
+ * so a surface on this set reads as the same family as the admin rail.
11
+ *
12
+ * FILL LIVES ON THE <g>, NOT THE <svg>. The rail's CSS (`.gnav-ico svg { fill: currentColor }`, written
13
+ * for Polaris's filled icons) outranks a `fill="none"` attribute on the <svg> element — CSS beats
14
+ * presentation attributes. GemFactory put it there and every outline icon was painted solid. An
15
+ * attribute on a CHILD beats a value inherited from the parent's CSS, so on the <g> it holds.
16
+ */
17
+ import React from "react";
18
+
19
+ const P = {
20
+ home: <><path d="M3.5 9 10 3.5 16.5 9" /><path d="M5 7.8v8.7h10V7.8" /><path d="M8.5 16.5v-4h3v4" /></>,
21
+ dashboard: <><rect x="3" y="3" width="6" height="6" rx="1.3" /><rect x="11" y="3" width="6" height="6" rx="1.3" /><rect x="3" y="11" width="6" height="6" rx="1.3" /><rect x="11" y="11" width="6" height="6" rx="1.3" /></>,
22
+ board: <><rect x="3" y="3.5" width="4" height="13" rx="1.2" /><rect x="8" y="3.5" width="4" height="9" rx="1.2" /><rect x="13" y="3.5" width="4" height="6" rx="1.2" /></>,
23
+ layout: <><rect x="3.5" y="3" width="13" height="3.5" rx="1" /><rect x="3.5" y="8.25" width="13" height="3.5" rx="1" /><rect x="3.5" y="13.5" width="13" height="3.5" rx="1" /></>,
24
+ layers: <><path d="M10 3 17 6.8 10 10.6 3 6.8z" /><path d="M3 10.1l7 3.8 7-3.8" /><path d="M3 13.4l7 3.8 7-3.8" /></>,
25
+ document: <><path d="M5 2.8h6.8l3.2 3.2v11.2H5z" /><path d="M11.8 2.8V6H15M7.5 10h5M7.5 13h5" /></>,
26
+ article: <><rect x="3" y="3.5" width="14" height="13" rx="1.5" /><path d="M6 7h8M6 10h8M6 13h5" /></>,
27
+ page: <><rect x="2.5" y="3.5" width="15" height="13" rx="2" /><path d="M2.5 7.5h15M6 11h8M6 13.5h5" /></>,
28
+ image: <><rect x="3" y="3.5" width="14" height="13" rx="1.8" /><circle cx="7.3" cy="7.8" r="1.3" /><path d="M3.5 15l4.2-4.2 3 3 2.2-2.2 3.6 3.6" /></>,
29
+ folder: <path d="M2.8 5.3c0-.8.6-1.3 1.3-1.3h3.4l1.8 2h6.6c.8 0 1.3.6 1.3 1.3v7.7c0 .8-.6 1.3-1.3 1.3H4.1c-.8 0-1.3-.6-1.3-1.3z" />,
30
+ list: <><path d="M8 5h9M8 10h9M8 15h9" /><circle cx="4" cy="5" r=".9" /><circle cx="4" cy="10" r=".9" /><circle cx="4" cy="15" r=".9" /></>,
31
+ activity: <path d="M2.5 10h3l2-5 3 10 2-5h5" />,
32
+ chart: <><path d="M3 16.5h14" /><rect x="4.5" y="9" width="2.8" height="5.5" rx=".7" /><rect x="8.6" y="5" width="2.8" height="9.5" rx=".7" /><rect x="12.7" y="11" width="2.8" height="3.5" rx=".7" /></>,
33
+ radar: <><circle cx="10" cy="10" r="7" /><circle cx="10" cy="10" r="3.5" /><path d="M10 10l5-5" /></>,
34
+ map: <><rect x="3" y="3" width="14" height="14" rx="2" /><path d="M3 7.7h14M3 12.3h14M7.7 3v14M12.3 3v14" /></>,
35
+ globe: <><circle cx="10" cy="10" r="7" /><path d="M3 10h14M10 3c2 2 2.8 4.3 2.8 7s-.8 5-2.8 7c-2-2-2.8-4.3-2.8-7s.8-5 2.8-7z" /></>,
36
+ megaphone: <><path d="M3 8v4h3l6 4V4L6 8H3z" /><path d="M15 7.5a3.5 3.5 0 0 1 0 5" /></>,
37
+ star: <path d="M10 2.8l2.2 4.5 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5L2.8 8l5-.7z" />,
38
+ eye: <><path d="M1.8 10S4.7 4.8 10 4.8 18.2 10 18.2 10 15.3 15.2 10 15.2 1.8 10 1.8 10z" /><circle cx="10" cy="10" r="2.5" /></>,
39
+ inbox: <><path d="M3 11l2-7h10l2 7v5H3z" /><path d="M3 11h4l1 2h4l1-2h4" /></>,
40
+ bell: <><path d="M5 13.5V9a5 5 0 0 1 10 0v4.5l1.3 1.5H3.7z" /><path d="M8.3 17.2a1.9 1.9 0 0 0 3.4 0" /></>,
41
+ chat: <path d="M3 4.5h14v9H8.5L5 16.5v-3H3z" />,
42
+ money: <><circle cx="10" cy="10" r="7" /><path d="M12 7.6c-.5-.7-1.2-1-2-1-1.1 0-2 .7-2 1.6 0 2.1 4 1.2 4 3.4 0 .9-.9 1.6-2 1.6-.8 0-1.6-.3-2.1-1M10 5v1.6M10 13.2V15" /></>,
43
+ store: <><path d="M3.2 7.5 4.5 3.5h11l1.3 4" /><path d="M3.2 7.5c0 1.3 1 2.2 2.3 2.2s2.2-1 2.2-2.2c0 1.3 1 2.2 2.3 2.2s2.3-1 2.3-2.2c0 1.3.9 2.2 2.2 2.2s2.3-.9 2.3-2.2" /><path d="M4.5 9.5v7h11v-7M8.5 16.5v-3.5h3v3.5" /></>,
44
+ user: <><circle cx="10" cy="7" r="3.2" /><path d="M3.8 16.5c.8-2.8 3.3-4.5 6.2-4.5s5.4 1.7 6.2 4.5" /></>,
45
+ users: <><circle cx="7.5" cy="7.3" r="2.8" /><path d="M2.5 16.3c.6-2.5 2.5-4 5-4s4.4 1.5 5 4" /><path d="M12.3 4.8a2.8 2.8 0 0 1 0 5.2M14.3 12.5c1.6.4 2.7 1.7 3.2 3.8" /></>,
46
+ calendar: <><rect x="3" y="4.5" width="14" height="12.5" rx="1.8" /><path d="M3 8.5h14M7 3v3M13 3v3" /></>,
47
+ clock: <><circle cx="10" cy="10" r="7" /><path d="M10 6v4l2.8 1.8" /></>,
48
+ search: <><circle cx="8.8" cy="8.8" r="5.3" /><path d="M12.8 12.8 17 17" /></>,
49
+ link: <><path d="M8.6 11.4a3 3 0 0 0 4.2 0l2.6-2.6a3 3 0 0 0-4.2-4.2l-1 1" /><path d="M11.4 8.6a3 3 0 0 0-4.2 0l-2.6 2.6a3 3 0 0 0 4.2 4.2l1-1" /></>,
50
+ sliders: <><path d="M3 5h14M3 10h14M3 15h14" /><circle cx="7" cy="5" r="1.6" /><circle cx="13" cy="10" r="1.6" /><circle cx="9" cy="15" r="1.6" /></>,
51
+ settings: <><path d="M15.52 9.08 17.15 9.15 17.15 10.85 15.52 10.92 14.56 13.26 15.66 14.45 14.45 15.66 13.26 14.56 10.92 15.52 10.85 17.15 9.15 17.15 9.08 15.52 6.74 14.56 5.55 15.66 4.34 14.45 5.44 13.26 4.48 10.92 2.85 10.85 2.85 9.15 4.48 9.08 5.44 6.74 4.34 5.55 5.55 4.34 6.74 5.44 9.08 4.48 9.15 2.85 10.85 2.85 10.92 4.48 13.26 5.44 14.45 4.34 15.66 5.55 14.56 6.74Z" /><circle cx="10" cy="10" r="2.4" /></>,
52
+ alert: <><path d="M10 3.2 17.3 16H2.7z" /><path d="M10 8.2v3.6M10 13.9v.1" /></>,
53
+ info: <><circle cx="10" cy="10" r="7" /><path d="M10 9v4.5M10 6.4v.1" /></>,
54
+ check: <path d="M4 10.5 8 14.5 16 5.5" />,
55
+ plus: <path d="M10 4v12M4 10h12" />,
56
+ close: <path d="M5 5l10 10M15 5 5 15" />,
57
+ utensils: <><path d="M6 3v5a2 2 0 0 0 4 0V3M8 3v14" /><path d="M15 3c-1.6 1.4-2.5 3.6-2.5 6.5H15V17" /></>,
58
+ signOut: <path d="M8 3.5H4v13h4M12.5 6.5 16 10l-3.5 3.5M16 10H8" />,
59
+ } satisfies Record<string, React.ReactNode>;
60
+ // `satisfies`, not a `Record<string, …>` annotation: the annotation widened `keyof` to `string`, so a
61
+ // typo (`name="setings"`) type-checked and drew an empty box (review #3474).
62
+
63
+ export type IconName = keyof typeof P;
64
+ export const ICON_NAMES = Object.keys(P) as IconName[];
65
+
66
+ /** One icon from the GemBoss set. 20px by default; colour is `currentColor`. */
67
+ export function Icon({ name, size = 20, title, className }: { name: IconName; size?: number; title?: string; className?: string }) {
68
+ return (
69
+ <svg viewBox="0 0 20 20" width={size} height={size} className={className}
70
+ aria-hidden={title ? undefined : true} role={title ? "img" : undefined} focusable="false">
71
+ {title ? <title>{title}</title> : null}
72
+ <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">{P[name]}</g>
73
+ </svg>
74
+ );
75
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export {
2
+ AppShell, MobileMenuButton, MobileNavBackdrop, Rail, RailBottom, RailCount, RailGroup, RailHeading,
3
+ RailItem, RailScroll, RailSep, RailSub, RailSubItem, Shell, ShellMain, Topbar,
4
+ } from "./shell";
5
+ export { GemMark } from "./GemMark";
6
+ export { AuthError, AuthField, AuthFoot, AuthForm, AuthHelp, AuthLayout, PasswordField, AuthSubmit } from "./auth";
7
+ export { BrandLockup } from "./brand";
8
+ export { AccountMenu, AccountMenuItem, initialsOf } from "./account";
9
+ export { Icon, ICON_NAMES, type IconName } from "./icons";