@gemboss/ui 0.12.0 → 0.21.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.
@@ -1,112 +1 @@
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
- /* WebKit draws `input[type=search]` as `-webkit-appearance: searchfield`: its own padding and rounding, so in Safari
34
- (macOS and iOS, the platform the 16px rule exists for) a search box did not match the fields beside it. Same box for
35
- every text type; the clear button stays. (GemFactory #98 review, 01/10/2026.) */
36
- input[type="search"]:is(.gemboss-field, .gemboss-field__bare) { -webkit-appearance: none; appearance: none; }
37
- .gemboss-field::placeholder { color: var(--p-color-text-secondary, var(--gemboss-field-placeholder)); }
38
- /* A NARROW field — the inspector's T/R/B/L boxes and its number spinners are 40-68px wide, and the
39
- shared 12px side padding left 38px of content box for a three-digit value (measured in Chromium:
40
- clientWidth 38, scrollWidth 58 for "120"). Same box, tighter lane. */
41
- /* A field that is a 44px target at EVERY width, not only on a phone. Some surfaces asked for that in
42
- their own inline style (a popup's `minHeight: 44`), and that intent is theirs to keep — it moves here
43
- so the look still comes from one place. */
44
- .gemboss-field--tall { min-height: 44px; }
45
- .gemboss-field--narrow { padding: 0 6px; appearance: textfield; -moz-appearance: textfield; }
46
- /* …and no spinner: the arrows eat ~11px of a 40px box, so "120" was still cut with the tighter
47
- padding (measured: clientWidth 38, scrollWidth 49). The value is typed here, not stepped. */
48
- .gemboss-field--narrow::-webkit-outer-spin-button,
49
- .gemboss-field--narrow::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
50
- .gemboss-field:hover:not(:disabled),
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
- .gemboss-field:focus-visible,
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
- .gemboss-field:disabled,
55
- .gemboss-control:disabled { opacity: 0.5; cursor: not-allowed; }
56
- /* Polaris' own breakpoint for the same switch: under it a field reads 16px, so iOS does not zoom. */
57
- /* On a PHONE-WIDTH screen a field is 16px (iOS does not zoom) and 44px tall (§9.1) — by WIDTH, not by
58
- pointer. A desktop browser resized to 320px still reports `pointer: fine`, and the page-revision
59
- gate measures the publication select's height at 320px: dropping that panel's own `min-height: 44px`
60
- in favour of the shared class left it 32px there, and the gate went red (CI of #3201). */
61
- /* 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
62
- 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
63
- control đọc ra MỘT họ: nút Refresh của compare view ngồi giữa hai select, và ở 13px giữa hai
64
- ô 16px thì nó là một cỡ chữ thứ hai trong cùng một hàng (/code-review, 23/09/2026). */
65
- @media (max-width: 47.9975em) { .gemboss-field, .gemboss-control { font-size: 16px; min-height: 44px; } }
66
- /* A multi-line field: Polaris' multiline TextField — the same box, its height from `rows`. */
67
- textarea.gemboss-field { height: auto; padding: 6px 12px; resize: vertical; }
68
- /* A field made of parts — a fixed prefix ("/pages/") or a colour swatch beside the text. The WRAPPER
69
- wears `gemboss-field gemboss-field--group` and is the box; the text inside wears `gemboss-field__bare`
70
- and draws nothing of its own, taking the box's font size (13px, 16px on a phone) by inheritance. */
71
- /* The GROUP grows to whatever it holds: `gemboss-field` fixes a 32px height, and a panel that asks
72
- for 44px targets (the section content panel does, and `page-saved-continuation-fixture` measures
73
- EVERY control there) puts a 44px input inside this box. Fixing the group at 32 and shrinking the
74
- input to fit clipped it and broke that gate; the box follows its content instead. */
75
- .gemboss-field--group { display: flex; align-items: center; gap: 6px; height: auto; min-height: 32px; padding-right: 0; overflow: hidden; }
76
- /* The phone floor again, for the GROUP. `min-height: 32px` above is the same specificity as the
77
- `.gemboss-field` rule in the media query and comes LATER in the file, so it won the cascade at
78
- phone width and quietly took the group back down to 32px — the URL-handle and background-colour
79
- rows, the two places a field is made of parts. A media query does not outrank anything; source
80
- order decided it, and the fix is to say it again after. (/code-review, 23/09/2026.) */
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, var(--ui-focus, #5661f5)); outline-offset: 1px; border-color: var(--p-color-input-border-active, var(--gemboss-field-line-active)); }
83
- /* The colour swatch in a group: FLUSH to the group's left edge and full height — it covers the group's
84
- 1px border top and bottom and its 12px left padding, so it is the whole 44px target the panel's rule
85
- asks for, rounded by the group's own corner (overflow: hidden), and never clipped. A 44px swatch
86
- INSIDE the 42px the border leaves was cut by a pixel top and bottom (/code-review #3201). */
87
- .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; }
88
- /* ↑ the right edge is what keeps a WHITE swatch visible: without it the block melted into the field (measured). */
89
- .gemboss-field--group input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
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; 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
- /* …and the placeholder, which "draws nothing" does not cover: the surfaces that used to declare
93
- their own (the /admin search rows, the settings and palette rows) stopped when they stopped
94
- owning the box, and without this every bare field would fall back to the browser's grey. */
95
- .gemboss-field__bare::placeholder { color: var(--p-color-text-secondary, var(--gemboss-field-placeholder)); }
96
-
97
- /* Under a thumb the field is 44px, the same height this app gives a Polaris field there. It sits in
98
- THIS file, not in the shell's pointer:coarse block, so /login and /try — which load only this
99
- sheet — get it too. */
100
- @media (pointer: coarse) { .gemboss-field, .gemboss-control { height: auto; min-height: 44px; } }
101
- /* A control is pressable; a field is not. That is the whole difference. */
102
- .gemboss-control { cursor: pointer; }
103
- /* An ICON control — the repeater's ✕ — is square and narrow beside its row. It was pinned to
104
- `width: 32` in an inline style, which no media query can reach, so under a thumb it was a
105
- 32x44 target: tall enough, too narrow (§9.1 asks 44 in BOTH dimensions for a control).
106
- The width lives here now, and grows with the pointer. (/code-review, 23/09/2026.) */
107
- /* Hai class trên cùng phần tử, có chủ ý: `.gemboss-ei-inp` đặt `width: 100%` ở cùng trọng số và
108
- nằm SAU trong thứ tự nạp (shell @import file này ở đầu), nên một selector đơn thua nó — đo
109
- đượ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
110
- vào CSS thì phải mang theo trọng số của chính nó. */
111
- .gemboss-control.gemboss-control--icon { width: 32px; flex: 0 0 auto; padding: 0; }
112
- @media (pointer: coarse) { .gemboss-control.gemboss-control--icon { width: 44px; min-width: 44px; } }
1
+ .gemboss-field,.gemboss-control{box-sizing:border-box;height:32px;min-width:0;padding:0 12px;font:inherit;font-size:var(--p-font-size-325, 13px);line-height:20px;color:var(--p-color-text, var(--gemboss-field-ink));background-color:var(--p-color-input-bg-surface, var(--gemboss-field-bg));border:1px solid var(--p-color-input-border, var(--gemboss-field-line));border-radius:var(--p-border-radius-200, 8px)}input[type=search]:is(.gemboss-field,.gemboss-field__bare){-webkit-appearance:none;appearance:none}.gemboss-field::placeholder{color:var(--p-color-text-secondary, var(--gemboss-field-placeholder))}.gemboss-field--tall{min-height:44px}.gemboss-field--narrow{padding:0 6px;appearance:textfield;-moz-appearance:textfield}.gemboss-field--narrow::-webkit-outer-spin-button,.gemboss-field--narrow::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}.gemboss-field:hover:not(:disabled),.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))}.gemboss-field:focus-visible,.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))}.gemboss-field:disabled,.gemboss-control:disabled{opacity:.5;cursor:not-allowed}@media (max-width: 47.9975em){.gemboss-field,.gemboss-control{font-size:16px;min-height:44px}}textarea.gemboss-field{height:auto;padding:6px 12px;resize:vertical}.gemboss-field--group{display:flex;align-items:center;gap:6px;height:auto;min-height:32px;padding-right:0;overflow:hidden}@media (max-width: 47.9975em){.gemboss-field--group{min-height:44px}}.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))}.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}.gemboss-field--group input[type=color]::-webkit-color-swatch-wrapper{padding:0}.gemboss-field--group input[type=color]::-webkit-color-swatch{border:0;border-radius:0}.gemboss-field__bare{flex:1;min-width:0;align-self:stretch;margin-block:-1px;padding:0 12px 0 0;border:0;border-radius:0;outline:none;background:transparent;color:inherit;font:inherit}.gemboss-field__bare::placeholder{color:var(--p-color-text-secondary, var(--gemboss-field-placeholder))}@media (pointer: coarse){.gemboss-field,.gemboss-control{height:auto;min-height:44px}}.gemboss-control{cursor:pointer}.gemboss-control.gemboss-control--icon{width:32px;flex:0 0 auto;padding:0}@media (pointer: coarse){.gemboss-control.gemboss-control--icon{width:44px;min-width:44px}}
@@ -1,164 +1 @@
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 bodyMd (13px; bodySm is 12) which the app had been leaning on for prose. The kit (ui.css, kit-plain.css) reads --ui-fs-body, 13px, because it sits beside Polaris components; since 0.12.0 it reads none of body/h1-h3 here (README "Admin density"). */
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
- }
1
+ :root{--gemboss-primary: #1b1c1e;--gemboss-primary-dark: #000000;--gemboss-primary-soft: #f2f3f5;--gemboss-accent: #5661f5;--gemboss-accent-strong: #3a44de;--gemboss-accent-soft: #eef0fe;--gemboss-text-strong: #1b1c1e;--gemboss-text: #1b1c1e;--gemboss-text-muted: #686b71;--gemboss-surface: #ffffff;--gemboss-canvas: #ffffff;--gemboss-preview-canvas: #eceef1;--gemboss-rail-bg: #ffffff;--gemboss-panel-bg: #ffffff;--gemboss-divider: #e5e7ea;--gemboss-invite-h: 0px;--gemboss-divider-soft: #edeff2;--gemboss-hover: #eceef2;--gemboss-active-bg: #f0f1f4;--gemboss-active-fg: #1b1c1e;--gemboss-danger: #d72c0d;--gemboss-danger-strong: #b42318;--gemboss-danger-soft: #fff0e9;--gemboss-danger-border: #f5c9bc;--gemboss-success: #007f5f;--gemboss-success-strong: #0a6a4c;--gemboss-success-soft: #e3f1df;--gemboss-success-border: #b7dfcd;--gemboss-warning: #9a6700;--gemboss-warning-soft: #fff8e0;--gemboss-warning-border: #f5d569;--gemboss-radius-xs: 4px;--gemboss-radius-sm: 6px;--gemboss-radius-md: 8px;--gemboss-radius-lg: 12px;--gemboss-radius-xl: 16px;--gemboss-radius-2xl: 20px;--gemboss-radius-full: 999px;--gemboss-shadow-resting: 0 1px 2px rgba(31, 30, 29, .04), 0 2px 6px rgba(31, 30, 29, .06);--gemboss-shadow-raised: 0 4px 16px rgba(31, 30, 29, .1);--gemboss-shadow-overlay: 0 16px 48px rgba(31, 30, 29, .18);--gemboss-transition-fast: .12s;--gemboss-transition-base: .15s;--gemboss-fs-micro: 11px;--gemboss-fs-caption: 12px;--gemboss-fs-body: 14px;--gemboss-fs-body-lg: 16px;--gemboss-fs-h3: 16px;--gemboss-fs-h2: 20px;--gemboss-fs-h1: 24px;--gemboss-fs-display: 30px;--gemboss-dark-bg: #0a0a0c;--gemboss-dark-rail: #0e0e11;--gemboss-dark-surface: #16161a;--gemboss-dark-surface-2: #1e1e24;--gemboss-dark-divider: #2a2a31;--gemboss-dark-text: #ededf0;--gemboss-dark-muted: #9a9aa4;--gemboss-serif: var(--p-font-family-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);--gemboss-font-sans: var(--p-font-family-sans, "Inter", -apple-system, BlinkMacSystemFont, "San Francisco", "Segoe UI", Roboto, "Helvetica Neue", sans-serif);--gemboss-field-bg: #fdfdfd;--gemboss-field-bg-hover: #fafafa;--gemboss-field-ink: #303030;--gemboss-field-placeholder: #616161;--gemboss-field-line: #8a8a8a;--gemboss-field-line-hover: #616161;--gemboss-field-line-active: #1a1a1a;--gemboss-chip-ink: #303633;--gemboss-chip-line: #cfd6ce;--gemboss-chip-line-hover: #aab4ad;--gemboss-chip-bg-hover: #f6f7f5;--gemboss-on-primary: #ffffff;--gemboss-topbar-bg: rgba(255, 255, 255, .98);--gemboss-nav-bg: var(--gemboss-surface);--gemboss-nav-hover: rgba(20, 19, 18, .045)}:root{--gemboss-primary: #1b1c1e;--gemboss-primary-dark: #000000;--gemboss-primary-soft: #f2f3f5;--gemboss-accent: #5661f5;--gemboss-accent-strong: #3a44de;--gemboss-accent-soft: #eef0fe;--gemboss-active-bg: #f0f1f4;--gemboss-active-fg: #1b1c1e;--gemboss-app-bg: #ffffff}
package/src/icons.tsx CHANGED
@@ -17,6 +17,8 @@
17
17
  import React from "react";
18
18
 
19
19
  const P = {
20
+ // A face with a plus (add a reaction): the open arc leaves room for the plus at the top right.
21
+ reaction: <><path d="M16.5 10a6.5 6.5 0 1 1-6.5-6.5" /><path d="M7.5 11.8s.9 1.2 2.5 1.2 2.5-1.2 2.5-1.2" /><path d="M7.8 8.2h.01M12.2 8.2h.01" /><path d="M15 2.5v4M13 4.5h4" /></>,
20
22
  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
23
  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
24
  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" /></>,
@@ -0,0 +1,179 @@
1
+ "use client";
2
+ /**
3
+ * IndexTable (0.13.0): a list of entities people open, compare and act on in bulk. Polaris IndexTable's shape on the
4
+ * classes of `@gemboss/ui/collections.css` (README "Collections").
5
+ *
6
+ * - The WHOLE row opens the item (Chris 05/10/2026): a click anywhere that is not a control follows the row's title link
7
+ * (`a.ui-table__title`), which stays a real link, so Cmd-click, middle-click and a screen reader all work. A click on
8
+ * a checkbox, a button, a menu, a select or another link does what that control does and nothing else; so does a
9
+ * click that ends a text selection. A row with two or more in-place editing controls should not be linked (`href`
10
+ * off; link only its title by putting the link in `title` yourself).
11
+ * - Selection only with bulk actions: a checkbox column with nothing to do with the selection is inert (Artifacts and
12
+ * GemCRM shipped exactly that). The bulk bar takes the header's place, carries its own select-all box (the folded list
13
+ * has no header), and Escape clears the selection.
14
+ * - Below 768 of ITS container the table folds into a list (collections.css): the title line, then the other cells on
15
+ * one caption line (`label` prefixes a value; `condensed: "hide"` leaves a cell out), checkbox and actions kept.
16
+ *
17
+ * Every prop that describes the table is plain data (rows of rendered cells, `sortHref` strings), so a server
18
+ * component can render it; the callbacks (`onSort`, `onSelectionChange`, bulk `onAction`) need a client parent.
19
+ * Its own module, "use client": nothing the admin imports pulls it in (first-load budget).
20
+ */
21
+ import React, { useEffect, useRef } from "react";
22
+ import { ariaSort, SortHeader, CollectionSkeleton, type CollectionColumn, type CollectionSort, type SortDirection, type SortLinkProps } from "./collections";
23
+ import { BulkBar, useSelection, type BulkAction } from "./selection";
24
+ export type { BulkAction } from "./selection";
25
+ export { ESCAPE_OWNERS } from "./selection";
26
+
27
+ const cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(" ");
28
+
29
+ export type IndexTableRow = {
30
+ id: string;
31
+ /** The row's main text. With `href` the table makes it the row's link; without, pass your own `<a className="ui-table__title">` or leave the row unlinked. */
32
+ title: React.ReactNode;
33
+ /** The whole title for a screen tip when the ellipsis cuts it. */
34
+ titleText?: string;
35
+ href?: string;
36
+ /** "_blank" for a row that opens somewhere else (the row follows the same link). */
37
+ target?: string;
38
+ /** One line under the title (`ui-sub`). */
39
+ sub?: React.ReactNode;
40
+ /** Avatar 24/32 or a `ui-thumb` (40), left of the title. One size for the whole table. */
41
+ media?: React.ReactNode;
42
+ /** The other columns, by column key. */
43
+ cells: Record<string, React.ReactNode>;
44
+ /** One button and/or a ⋯ menu, at the end of the row. Never more than one visible button. */
45
+ actions?: React.ReactNode;
46
+ };
47
+
48
+ export type IndexTableProps = {
49
+ /** The first column is the primary one (title, sub, media); the rest map to `row.cells[key]`. */
50
+ columns: CollectionColumn[];
51
+ rows: IndexTableRow[];
52
+ /** Names the table for a screen reader. */
53
+ label: string;
54
+ sort?: CollectionSort | null;
55
+ onSort?: (key: string, direction: SortDirection) => void;
56
+ /** Turns the checkbox column on. Only together with `bulkActions`. */
57
+ bulkActions?: BulkAction[];
58
+ /** Controlled selection; leave both out and the table keeps it. */
59
+ selected?: string[];
60
+ onSelectionChange?: (ids: string[]) => void;
61
+ /** "12 selected": `(n) => …`. */
62
+ selectedLabel?: (n: number) => React.ReactNode;
63
+ selectAllLabel?: string;
64
+ /** The link element for titles AND sort links (header and chips, 0.16.0): a Next `Link` from a client module, so
65
+ * opening a row and sorting both stay in the app instead of reloading the page. Default `<a>`. */
66
+ linkAs?: React.ElementType;
67
+ /** Extra props for the sort links only, e.g. `{ scroll: false }` for a Next `Link`: a sort is the same page in another
68
+ * order, so it should not jump to the top the way opening a row does (0.16.0). */
69
+ sortLinkProps?: SortLinkProps;
70
+ /** Below 768 the header is gone: these chips sort instead. Default: one per sortable column. */
71
+ sortLabel?: React.ReactNode;
72
+ state?: "ready" | "loading" | "busy" | "empty" | "no-match" | "error";
73
+ /** For empty / no-match / error: a `CollectionState`. The header stays above it. */
74
+ stateSlot?: React.ReactNode;
75
+ loadingRows?: number;
76
+ /** Whether the loading skeleton has an actions column (default: any row has actions). */
77
+ actionsColumn?: boolean;
78
+ className?: string;
79
+ };
80
+
81
+ /** Anything in a row that is a control of its own: a click on it is not a click on the row. */
82
+ export const ROW_CONTROLS = 'a[href], button, input, select, textarea, label, summary, details, dialog, [role="dialog"], [role="menu"], [role="button"], [role="menuitem"], [role="checkbox"], [contenteditable="true"], [contenteditable=""], [data-row-ignore], .ui-table__check';
83
+
84
+ /** The click on a row, minus controls and text selection; follows the row's title link. Shared with kit-plain.js in spirit (same rules). */
85
+ export function followRow(e: React.MouseEvent<HTMLElement>): void {
86
+ const row = e.currentTarget;
87
+ const t = e.target as Element;
88
+ if (e.defaultPrevented || e.button > 1) return;
89
+ // A portal (a popover rendered elsewhere) still bubbles React events through the row: not a click on the row.
90
+ if (!(t instanceof Node) || !row.contains(t)) return;
91
+ const hit = t.closest ? t.closest(ROW_CONTROLS) : null;
92
+ if (hit && row.contains(hit)) return;
93
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
94
+ if (sel && !sel.isCollapsed && sel.anchorNode && row.contains(sel.anchorNode)) return;
95
+ const a = row.querySelector<HTMLAnchorElement>("a.ui-table__title[href]");
96
+ if (!a) return;
97
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { window.open(a.href, "_blank", "noopener"); return; }
98
+ a.click();
99
+ }
100
+
101
+ export function IndexTable({ columns, rows, label, sort, onSort, bulkActions, selected: selectedProp, onSelectionChange, selectedLabel = (n) => `${n} selected`,
102
+ selectAllLabel = "Select all", linkAs: Link = "a", sortLinkProps, sortLabel = "Sort", state = "ready", stateSlot, loadingRows = 5, actionsColumn, className }: IndexTableProps) {
103
+ const selectable = !!(bulkActions && bulkActions.length);
104
+ const ids = rows.map((r) => r.id);
105
+ // Own or controlled ids, Shift range, select-all, Escape (./selection: the same rules as SelectableResourceList).
106
+ const { rootRef, selSet, chosen, all, some, toggle, toggleAll } = useSelection<HTMLDivElement>(ids, selectedProp, onSelectionChange, selectable);
107
+ const hasActions = rows.some((r) => r.actions);
108
+ const headBox = useRef<HTMLInputElement>(null);
109
+ useEffect(() => { if (headBox.current) headBox.current.indeterminate = some; }, [some]);
110
+
111
+ if (state === "loading") return <CollectionSkeleton pattern="index" columns={columns} rows={loadingRows} label={label} check={selectable} actions={actionsColumn ?? hasActions} />;
112
+ const showRows = state === "ready" || state === "busy";
113
+ const [primary, ...rest] = columns;
114
+ const sortable = columns.filter((c) => c.sortHref || (c.sortable && onSort));
115
+
116
+ return (
117
+ <div className={cx("ui-index", className)} ref={rootRef} aria-busy={state === "busy" ? true : undefined}>
118
+ {sortable.length && showRows ? (
119
+ <div className="ui-index__sortbar" role="group" aria-label={typeof sortLabel === "string" ? sortLabel : "Sort"}>
120
+ <span aria-hidden="true">{sortLabel}</span>
121
+ {sortable.map((c) => {
122
+ const on = sort?.key === c.key;
123
+ const arrow = on ? (sort!.direction === "ascending" ? " ↑" : " ↓") : "";
124
+ return c.sortHref
125
+ ? <Link {...sortLinkProps} key={c.key} className="ui-chip" href={c.sortHref} aria-current={on ? "true" : undefined}>{c.header}{arrow}</Link>
126
+ : <button key={c.key} type="button" className="ui-chip" aria-pressed={on} onClick={() => onSort!(c.key, on && sort!.direction === "ascending" ? "descending" : "ascending")}>{c.header}{arrow}</button>;
127
+ })}
128
+ </div>
129
+ ) : null}
130
+ {selectable ? <BulkBar actions={bulkActions!} chosen={chosen} all={all} some={some} onToggleAll={toggleAll} selectedLabel={selectedLabel} selectAllLabel={selectAllLabel} idle="hide" /> : null}
131
+ <div className="ui-table-wrap">
132
+ <table className="ui-table ui-table--index">
133
+ <caption className="ui-sr-only">{label}</caption>
134
+ <thead>
135
+ <tr>
136
+ {selectable ? <th className="ui-table__check" scope="col"><label className="ui-table__checkbox"><input ref={headBox} type="checkbox" checked={all} aria-label={selectAllLabel} onChange={(e) => toggleAll(e.target.checked)} disabled={!showRows || !rows.length} /></label></th> : null}
137
+ <th className="ui-table__primary" scope="col" aria-sort={ariaSort(primary, sort)}><SortHeader col={primary} sort={sort} onSort={onSort} linkAs={Link} linkProps={sortLinkProps} /></th>
138
+ {rest.map((c) => <th key={c.key} scope="col" className={c.numeric ? "is-num" : undefined} aria-sort={ariaSort(c, sort)}><SortHeader col={c} sort={sort} onSort={onSort} linkAs={Link} linkProps={sortLinkProps} /></th>)}
139
+ {hasActions ? <th className="ui-table__actions" scope="col"><span className="ui-sr-only">Actions</span></th> : null}
140
+ </tr>
141
+ </thead>
142
+ {showRows ? (
143
+ <tbody>
144
+ {rows.map((r, i) => {
145
+ const isSel = selSet.has(r.id);
146
+ const title = r.href
147
+ ? <Link className="ui-table__title" href={r.href} target={r.target} rel={r.target === "_blank" ? "noopener" : undefined} title={r.titleText}>{r.title}</Link>
148
+ : r.title;
149
+ return (
150
+ <tr key={r.id} className={cx(r.href && "is-linked", isSel && "is-selected")} aria-selected={selectable ? isSel : undefined} onClick={r.href ? followRow : undefined} onAuxClick={r.href ? followRow : undefined}>
151
+ {selectable ? (
152
+ <td className="ui-table__check" onClick={(e) => { if (e.target === e.currentTarget) (e.currentTarget.querySelector("input") as HTMLInputElement | null)?.click(); }}>
153
+ <label className="ui-table__checkbox">
154
+ <input type="checkbox" checked={isSel} aria-label={`Select ${r.titleText ?? (typeof r.title === "string" ? r.title : r.id)}`}
155
+ onChange={(e) => toggle(i, e.target.checked, (e.nativeEvent as MouseEvent).shiftKey)} />
156
+ </label>
157
+ </td>
158
+ ) : null}
159
+ <td className="ui-table__primary" style={primary.truncate != null ? ({ ["--ui-trunc" as string]: typeof primary.truncate === "number" ? `${primary.truncate}px` : primary.truncate } as React.CSSProperties) : undefined}>
160
+ {r.media ? <div className="ui-table__primary-inner">{r.media}<div className="ui-table__primary-text">{title}{r.sub ? <span className="ui-sub">{r.sub}</span> : null}</div></div>
161
+ : <>{title}{r.sub ? <span className="ui-sub">{r.sub}</span> : null}</>}
162
+ </td>
163
+ {rest.map((c) => (
164
+ <td key={c.key} className={c.numeric ? "is-num" : undefined} data-label={c.label} data-condensed={c.condensed === "hide" ? "hide" : undefined}>
165
+ {c.truncate != null ? <span className="ui-trunc" style={{ ["--ui-trunc" as string]: typeof c.truncate === "number" ? `${c.truncate}px` : c.truncate } as React.CSSProperties}>{r.cells[c.key]}</span> : r.cells[c.key]}
166
+ </td>
167
+ ))}
168
+ {hasActions ? <td className="ui-table__actions">{r.actions ? <span className="ui-row-actions">{r.actions}</span> : null}</td> : null}
169
+ </tr>
170
+ );
171
+ })}
172
+ </tbody>
173
+ ) : null}
174
+ </table>
175
+ {!showRows ? stateSlot : null}
176
+ </div>
177
+ </div>
178
+ );
179
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,23 @@ export { PageContainer, pageWidthClass } from "./page";
7
7
  export type { PageWidth } from "./page";
8
8
  // The loading boundary every route group under @gemboss/ui/next needs (0.10.0). Plain module, like ./page.
9
9
  export { RouteCover, RouteHolding } from "./route-holding";
10
+ // Collections (0.13.0): tables, lists, grids, boards. Their own modules, like ./page: nothing the admin imports pulls them in
11
+ // (it draws tables with Polaris). ./collections is plain (a server component renders it); ./index-table and
12
+ // ./resource-list-select are "use client".
13
+ export {
14
+ BOARD_MOVE_HELP, Board, boardShown, CardGrid, CollectionSkeleton, CollectionState, CompactList, DataTable, MediaCard, ResourceItem, ResourceList, SortHeader, Truncate, ariaSort,
15
+ } from "./collections";
16
+ export type {
17
+ BoardCard, BoardColumn, BoardProps, CardGridMin, CollectionColumn, CollectionRow, CollectionSort, CollectionStateProps, CompactItem, CompactListProps, DataTableProps, MediaCardProps,
18
+ ResourceItemProps, ResourceListProps, SkeletonPattern, SortDirection, SortLinkProps,
19
+ } from "./collections";
20
+ export { IndexTable, followRow, ROW_CONTROLS } from "./index-table";
21
+ export type { BulkAction, IndexTableProps, IndexTableRow } from "./index-table";
22
+ // 0.16.0: a resource list with selection + bulk actions. A client module of its own, like ./index-table.
23
+ export { SelectableResourceList } from "./resource-list-select";
24
+ export type { SelectableResourceItem, SelectableResourceListProps } from "./resource-list-select";
25
+ // The one switch (0.20.0): every "pick one" control in the admin, and the editor kit's Seg. Its own module (plain, no state).
26
+ export { Segmented, IconSegmented, type SegmentedOption } from "./segmented";
10
27
  export { GemMark } from "./GemMark";
11
28
  export { AuthError, AuthField, AuthFoot, AuthForm, AuthHelp, AuthLayout, PasswordField, AuthSubmit } from "./auth";
12
29
  export { BrandLockup } from "./brand";
@@ -20,3 +37,5 @@ export { AssistantLauncher } from "./launcher";
20
37
  export { Avatar, avatarTone, Leaderboard, Podium, TopbarNav, type AvatarSize, type LeaderRow, type TopbarNavItem } from "./portal";
21
38
  export { Dialog, SearchDialog, type SearchItem, type SearchSection } from "./dialog";
22
39
  export { ThemeToggle, THEME_KEY, themeScript } from "./theme";
40
+ export { Reactions, ReactionPicker, type ReactionsProps } from "./reactions";
41
+ export { isReactionEmoji, QUICK_REACTIONS, REACTION_EMOJI, REACTION_GROUPS, reactionLabel, reactionWords, searchReactions, toggleReaction, type Reaction } from "./reactions-data";