@gemboss/ui 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/kit-plain.css CHANGED
@@ -61,23 +61,24 @@
61
61
  Link: `<a class="ui-link" href>` for a link inside text. Iris (--gemboss-accent-strong, 6.2:1 on white), underlined
62
62
  so it is not told by colour alone (WCAG 1.4.1); visited looks the same (these are tools, not articles). Markdown or
63
63
  other generated HTML you cannot add classes to: wrap it in `.ui-prose` and its bare `<a>` get the same look.
64
+ `.ui-prose` links sit at :where() specificity (0.7.3), so any component's own link rule wins inside it.
64
65
 
65
66
  Media card: `<article class="ui-card ui-card--media"><a|button class="ui-card__media"><img alt=""></…>
66
67
  <div class="ui-card__body">…</div><div class="ui-card__foot">…</div></article>`. The picture is 16:10 and
67
68
  cropped to fill; the card has no padding of its own, the body and foot do. */
68
69
  .ui-link,
69
- .ui-prose a:not([class]) {
70
+ .ui-prose :where(a:not([class])) {
70
71
  color: var(--gemboss-accent-strong, #3a44de);
71
72
  text-decoration: underline;
72
73
  text-decoration-thickness: from-font;
73
74
  text-underline-offset: 2px;
74
75
  }
75
76
  .ui-link:visited,
76
- .ui-prose a:not([class]):visited { color: var(--gemboss-accent-strong, #3a44de); }
77
+ .ui-prose :where(a:not([class])):visited { color: var(--gemboss-accent-strong, #3a44de); }
77
78
  .ui-link:hover,
78
- .ui-prose a:not([class]):hover { color: var(--gemboss-accent, #5661f5); text-decoration-thickness: 2px; }
79
+ .ui-prose :where(a:not([class])):hover { color: var(--gemboss-accent, #5661f5); text-decoration-thickness: 2px; }
79
80
  .ui-link:focus-visible,
80
- .ui-prose a:not([class]):focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: 2px; border-radius: 2px; }
81
+ .ui-prose :where(a:not([class])):focus-visible { outline: 2px solid var(--ui-focus, #5661f5); outline-offset: 2px; border-radius: 2px; }
81
82
 
82
83
  .ui-card--media { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
83
84
  .ui-card__media {
@@ -96,13 +97,16 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
96
97
  }
97
98
 
98
99
  /* Popup body and foot: `<div class="ui-modal__body">…</div><div class="ui-modal__foot"><button class="ui-btn">…</button></div>`.
99
- ui.css never styled these, and the admin does not load this file: its Modal.tsx children carry their own padding
100
- (or none). Every non-React surface padded them by hand (GemFactory: 16/20 and a hairline above the actions). kit-plain.js's openModal writes exactly this shape. */
101
- .ui-modal__body { padding: 16px 20px; }
102
- .ui-modal__foot {
103
- display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;
104
- padding: 12px 20px; border-top: 1px solid var(--ui-border);
105
- }
100
+ Their padding moved to ui.css in 0.7.3 (the admin loads that file, not this one). kit-plain.js's openModal writes
101
+ exactly this shape. What stays here is what only the plain-HTML popups use:
102
+
103
+ A head that carries more than a title (a large popup's subtitle and its own actions, GemFactory's detail popup):
104
+ `<div class="ui-modal__head"><div class="ui-modal__title">Mega menu<span class="ui-modal__subtitle">chrome/header</span>
105
+ </div><div class="ui-modal__actions"><button class="ui-btn">Copy</button></div><button class="ui-modal__close">…` */
106
+ .ui-modal__subtitle { display: block; margin-top: 2px; font-size: var(--ui-fs-caption); line-height: 16px; font-weight: 450; color: var(--ui-text-muted); overflow-wrap: anywhere; }
107
+ .ui-modal__actions { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-2); }
108
+ /* An icon button in the popup's head or foot is square at the popup's 28 (ui.css gives those buttons 0/12 padding). */
109
+ :is(.ui-modal__head, .ui-modal__foot) .ui-btn--icon { width: 28px; padding: 0; }
106
110
  /* A popup taller than the screen: the body scrolls, the head and foot stay, so the actions are always reachable
107
111
  (320×568 with a long form put Huỷ/Xoá below the fold, unreachable under the locked page; review #3633). */
108
112
  .ui-overlay > .ui-modal { display: flex; flex-direction: column; max-height: calc(100dvh - 40px); }
@@ -374,7 +378,7 @@ button.ui-card__media, a.ui-card__media { cursor: pointer; }
374
378
  <div class="ui-modal__foot">…</div></dialog>` */
375
379
  dialog.ui-modal { padding: 0; max-width: calc(100% - 40px); max-height: calc(100dvh - 40px); color: var(--ui-text); }
376
380
  dialog.ui-modal[open] { display: flex; flex-direction: column; }
377
- dialog.ui-modal::backdrop { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px); }
381
+ dialog.ui-modal::backdrop { background: rgba(0, 0, 0, 0.5); } /* the overlay's own scrim (0.7.3: Polaris' .5, no blur) */
378
382
 
379
383
  /* ── 0.5.1: what the first surfaces on 0.5.0 still drew themselves (GemFactory, content-radar, 03/10/2026) ─────────── */
380
384
 
@@ -435,3 +439,18 @@ button.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-off
435
439
  /* Class doubled: the chosen card is usually the one under the pointer, and `button.ui-metric:hover` (0,2,1) would
436
440
  repaint its edge grey the moment it is clicked (measured on gemwatcher Radar). */
437
441
  .ui-metric.ui-metric:is([aria-pressed="true"], [aria-current="page"]) { border-color: var(--gemboss-accent); box-shadow: inset 0 0 0 1px var(--gemboss-accent); }
442
+
443
+ /* 0.7.3: numbered pager + ui-more, for the plain surfaces (the admin uses Polaris Pagination). Markup and rationale:
444
+ README "Long lists". Here, not in ui.css: ui.css is exported verbatim into the admin, under a 90 KB budget. */
445
+ .ui-pager > a, .ui-pager > [aria-disabled="true"] { min-width: 32px; justify-content: center; box-sizing: border-box; gap: 4px; }
446
+ .ui-pager > a[aria-current="page"] { background: var(--ui-text); border-color: var(--ui-text); color: var(--ui-surface); font-size: var(--ui-fs-body); font-weight: 600; }
447
+ .ui-pager > a[aria-current="page"]:hover { background: var(--ui-text); }
448
+ .ui-pager > [aria-disabled="true"] { display: inline-flex; align-items: center; min-height: 32px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-md); color: var(--ui-text-muted); font-size: var(--ui-fs-body); opacity: .55; cursor: default; }
449
+ .ui-pager > [aria-disabled="true"]:hover { background: none; }
450
+ .ui-pager__gap { min-width: 16px; text-align: center; color: var(--ui-text-muted); }
451
+ .ui-pager__range { margin-left: auto; color: var(--ui-text-muted); font-size: var(--ui-fs-body); font-variant-numeric: tabular-nums; }
452
+ .ui-more { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 12px; padding: 16px 0; color: var(--ui-text-muted); font-size: var(--ui-fs-body); text-align: center; }
453
+ .ui-more[hidden] { display: none; }
454
+ @media (pointer: coarse), (max-width: 47.9975em) { .ui-pager > a, .ui-pager > [aria-disabled="true"] { min-width: 44px; min-height: 44px; } }
455
+ /* Phone: 11 targets of 44px wrapped into 3-4 rows at 320 (measured). Keep Previous · current · Next; the range wraps under. */
456
+ @media (max-width: 35.9975em) { .ui-pager > a:not([rel]):not([aria-current]):not([aria-disabled]), .ui-pager__gap { display: none; } }
package/src/portal.css CHANGED
@@ -1,7 +1,7 @@
1
1
  /* ═══ Portal pieces — @gemboss/ui 0.7.0 (04/10/2026) ════════════════════════════════════════════
2
- TopbarNav, Avatar, Leaderboard, Podium (portal.tsx), SearchDialog (dialog.tsx), ThemeToggle and the
3
- dark palette (theme.tsx). Tokens only: every colour comes from --gemboss-* / --ui-*, so the dark
4
- palette at the end reaches these too. */
2
+ TopbarNav, Avatar, Leaderboard, Podium (portal.tsx), SearchDialog (dialog.tsx), ThemeToggle (theme.tsx).
3
+ Component rules only, no rule on :root, html or body (a test holds that): a surface with its own skin
4
+ can take this file with gemboss-tokens.css alone. The dark palette is theme-dark.css. */
5
5
 
6
6
  /* ── TopbarNav: section tabs in the topbar, after the brand (Featurebase's "Feedback · Roadmap"). The
7
7
  active tab is underlined on the topbar's bottom edge. 44px targets under a finger, 36 under a mouse
@@ -47,6 +47,11 @@
47
47
  }
48
48
  @media (prefers-reduced-motion: reduce) { .gb-topnav-chevron { transition: none; } }
49
49
 
50
+ /* Text for screen readers only ("Rank 1:", ", you"). Its own rule, not ui.css's .ui-sr-only: a surface that
51
+ takes portal.css without the rest of the kit (GemCommunity) would otherwise print it on the page. */
52
+ .gb-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0);
53
+ white-space: nowrap; border: 0; }
54
+
50
55
  /* ── Avatar: picture or initials on one of eight tones picked from the name. */
51
56
  .gb-avatar { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 50%; overflow: hidden;
52
57
  font-weight: 700; line-height: 1; letter-spacing: .01em; user-select: none; }
@@ -83,6 +88,9 @@ a.gb-leaderboard-link:focus-visible { outline: 2px solid var(--gemboss-accent);
83
88
  .gb-leaderboard-name { flex: 1 1 auto; min-width: 0; font-size: 13px; font-weight: 560; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
84
89
  .gb-leaderboard-value { flex: 0 0 auto; font-size: 13px; font-weight: 650; color: var(--gemboss-text-muted); font-variant-numeric: tabular-nums; }
85
90
  .gb-leaderboard-row.is-first .gb-leaderboard-value { color: #8a5a00; font-weight: 750; }
91
+ /* The viewer's own row and podium slot: the selected tint, never a colour of its own. */
92
+ .gb-leaderboard-row.is-current > .gb-leaderboard-link { background: var(--gemboss-active-bg); }
93
+ .gb-leaderboard-badge { flex: 0 1 auto; min-width: 0; overflow: hidden; }
86
94
  .gb-leaderboard-all { justify-self: center; display: inline-flex; align-items: center; min-height: 24px; font-size: 12px; font-weight: 600;
87
95
  color: var(--gemboss-text-muted); text-decoration: none; }
88
96
  .gb-leaderboard-all:hover { color: var(--gemboss-text-strong); text-decoration: underline; }
@@ -93,6 +101,8 @@ a.gb-leaderboard-link:focus-visible { outline: 2px solid var(--gemboss-accent);
93
101
  background: var(--gemboss-surface); color: var(--gemboss-text); text-decoration: none; text-align: center; }
94
102
  a.gb-podium-link:hover { background: var(--gemboss-hover); }
95
103
  .gb-podium-slot { min-width: 0; }
104
+ /* A podium with fewer than three people keeps its place: second and third stay empty columns. */
105
+ .gb-podium-slot.is-empty { visibility: hidden; }
96
106
  .gb-podium-slot--1 .gb-podium-link { padding-top: 22px; padding-bottom: 20px; border-color: color-mix(in srgb, #d4a017 45%, var(--gemboss-divider)); }
97
107
  .gb-podium-slot--2 .gb-podium-link { padding-bottom: 12px; }
98
108
  .gb-podium-slot--3 .gb-podium-link { padding-bottom: 8px; }
@@ -101,6 +111,7 @@ a.gb-podium-link:hover { background: var(--gemboss-hover); }
101
111
  .gb-podium-value { font-size: 15px; font-weight: 750; font-variant-numeric: tabular-nums; }
102
112
  .gb-podium-slot--1 .gb-podium-value { color: #8a5a00; }
103
113
  .gb-podium-unit { font-size: 11px; font-weight: 500; color: var(--gemboss-text-muted); }
114
+ .gb-podium-slot.is-current .gb-podium-link { border-color: var(--gemboss-text-strong); background: var(--gemboss-active-bg); }
104
115
 
105
116
  /* ── SearchDialog: the topbar's search button, and the popup (640 wide, near the top, like Featurebase's). */
106
117
  .gb-search-trigger, .gb-theme-toggle { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; flex: 0 0 auto;
@@ -126,62 +137,3 @@ dialog.ui-modal.gb-search { width: min(640px, 100%); margin-top: 8vh; max-height
126
137
  .gb-search-foot { display: flex; gap: 16px; justify-content: flex-end; padding: 8px 14px; border-top: 1px solid var(--ui-border);
127
138
  font-size: 12px; color: var(--ui-text-muted); }
128
139
  @media (max-width: 760px) { dialog.ui-modal.gb-search { margin-top: 12px; } .gb-search-foot { display: none; } }
129
-
130
- /* ── Dark palette. `ThemeToggle` / `themeScript` set data-theme on <html>; a surface without them stays
131
- light. Only colours change: the components above and every kit class read these tokens. */
132
- :root[data-theme="dark"] {
133
- color-scheme: dark;
134
- --gemboss-primary: #f2f3f5;
135
- --gemboss-primary-dark: #ffffff;
136
- --gemboss-primary-soft: #23242a;
137
- --gemboss-on-primary: #0d0d12;
138
- --gemboss-accent: #7c84ff;
139
- --gemboss-accent-strong: #a3a9ff;
140
- --gemboss-accent-soft: #1f2140;
141
- --gemboss-text-strong: #f2f3f5;
142
- --gemboss-text: #e3e4e8;
143
- --gemboss-text-muted: #9a9ca6;
144
- --gemboss-surface: #16171d;
145
- --gemboss-canvas: #0d0d12;
146
- --gemboss-app-bg: #0d0d12;
147
- --gemboss-rail-bg: #0d0d12;
148
- --gemboss-panel-bg: #16171d;
149
- --gemboss-topbar-bg: rgba(13, 13, 18, 0.96);
150
- --gemboss-nav-bg: #0d0d12;
151
- --gemboss-nav-hover: rgba(255, 255, 255, 0.06);
152
- --gemboss-divider: #2a2b33;
153
- --gemboss-divider-soft: #22232a;
154
- --gemboss-hover: #22232b;
155
- --gemboss-active-bg: #262833;
156
- --gemboss-active-fg: #f2f3f5;
157
- --gemboss-field-bg: #121318;
158
- --gemboss-field-bg-hover: #17181e;
159
- --gemboss-field-ink: #e3e4e8;
160
- --gemboss-field-placeholder: #8a8c96;
161
- --gemboss-field-line: #3a3c46;
162
- --gemboss-field-line-hover: #50525e;
163
- --gemboss-field-line-active: #c9cbd3;
164
- --gemboss-chip-ink: #d6d8de;
165
- --gemboss-chip-line: #3a3c46;
166
- --gemboss-chip-line-hover: #50525e;
167
- --gemboss-chip-bg-hover: #22232b;
168
- --gemboss-success: #34c38f;
169
- --gemboss-success-strong: #5fd6a8;
170
- --gemboss-success-soft: #13291f;
171
- --gemboss-success-border: #22533e;
172
- --gemboss-warning: #e0b341;
173
- --gemboss-warning-soft: #2b2412;
174
- --gemboss-warning-border: #5a4a1d;
175
- --gemboss-danger: #ff6b5a;
176
- --gemboss-danger-strong: #ff8a7c;
177
- --gemboss-danger-soft: #2e1714;
178
- --gemboss-danger-border: #5c2a24;
179
- --ui-accent-fg: #0d0d12;
180
- --ui-warning-fg: #e8c063;
181
- --gb-brand-ink: #f2f3f5;
182
- }
183
- /* Avatar initials in dark: the tone's light end, so the text clears 4.5:1 on its tinted disc. */
184
- :root[data-theme="dark"] .gb-avatar[data-tone] { background: color-mix(in srgb, var(--gb-tone) 30%, #16171d); color: color-mix(in srgb, var(--gb-tone) 35%, #ffffff); }
185
- :root[data-theme="dark"] body { background: var(--gemboss-app-bg); color: var(--gemboss-text); }
186
- :root[data-theme="dark"] .gb-leaderboard-row.is-first .gb-leaderboard-value,
187
- :root[data-theme="dark"] .gb-podium-slot--1 .gb-podium-value { color: #e0b341; }
package/src/portal.tsx CHANGED
@@ -80,7 +80,15 @@ export function Avatar({ name, src, size = 24, className }: { name: string; src?
80
80
  );
81
81
  }
82
82
 
83
- export type LeaderRow = { key: string | number; name: string; value: number; avatar?: string | null; href?: string };
83
+ export type LeaderRow = {
84
+ key: string | number; name: string; value: number; avatar?: string | null; href?: string;
85
+ /** Shown after the name: a level, a role (GemCommunity's "Lv2 · Builder"). */
86
+ badge?: React.ReactNode;
87
+ /** The viewer's own row: tinted, and announced. */
88
+ current?: boolean;
89
+ /** The row's element id, for a "#me" link that scrolls to it. */
90
+ id?: string;
91
+ };
84
92
 
85
93
  const MEDALS = ["🥇", "🥈", "🥉"];
86
94
  const PODIUM_SLOT = ["gb-podium-slot--1", "gb-podium-slot--2", "gb-podium-slot--3"];
@@ -90,40 +98,44 @@ const PODIUM_SLOT = ["gb-podium-slot--1", "gb-podium-slot--2", "gb-podium-slot--
90
98
  * screen readers ("163 votes") and can be shown with `showUnit`. Empty → nothing (a "top" list of
91
99
  * zeros looks dead; GemCommunity hides it the same way).
92
100
  */
93
- export function Leaderboard({ title, caption, rows, unit, showUnit = false, viewAll, as: L = "a", className, headingLevel = 2, id }: {
94
- title: React.ReactNode; caption?: React.ReactNode; rows: LeaderRow[]; unit?: string; showUnit?: boolean;
101
+ export function Leaderboard({ title, label, caption, rows, unit, showUnit = false, viewAll, as: L = "a", className, headingLevel = 2, id, currentLabel = "you" }: {
102
+ /** The visible heading. Leave it out on a page whose own <h1> already names the board; give `label`. */
103
+ title?: React.ReactNode; label?: string; caption?: React.ReactNode; rows: LeaderRow[]; unit?: string; showUnit?: boolean;
95
104
  viewAll?: { href: string; label: React.ReactNode }; as?: LinkLike; className?: string; headingLevel?: 2 | 3;
96
105
  /** The heading's id. Pass one when two leaderboards share a page or the title is not plain text. */
97
106
  id?: string;
107
+ /** Read by screen readers after the viewer's own name ("Minh, you"). "" when the name already says it on screen. */
108
+ currentLabel?: string;
98
109
  }) {
99
110
  if (!rows.length) return null;
100
111
  const H = `h${headingLevel}` as "h2" | "h3";
101
112
  // From the title's letters (any script) and a hash of it, so "Most helpful" and a Vietnamese or
102
113
  // Chinese title each get their own; two boards with the same title need `id`.
103
- const text = typeof title === "string" ? title : "leaderboard";
114
+ const text = typeof title === "string" ? title : label ?? "leaderboard";
104
115
  let h = 0;
105
116
  for (const ch of text) h = (h * 31 + ch.codePointAt(0)!) >>> 0;
106
117
  const headingId = id ?? `gb-lb-${text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "")}-${h.toString(36)}`;
107
118
  return (
108
- <section className={cx("gb-leaderboard", className)} aria-labelledby={headingId}>
109
- <H className="gb-leaderboard-title" id={headingId}>{title}</H>
119
+ <section className={cx("gb-leaderboard", className)} aria-labelledby={title ? headingId : undefined} aria-label={title ? undefined : label}>
120
+ {title ? <H className="gb-leaderboard-title" id={headingId}>{title}</H> : null}
110
121
  {caption ? <p className="gb-leaderboard-caption">{caption}</p> : null}
111
122
  <ol className="gb-leaderboard-list">
112
123
  {rows.map((r, i) => {
113
124
  const inner = (
114
125
  <>
115
126
  <span className={cx("gb-leaderboard-rank", !MEDALS[i] && "is-number")}>
116
- <span aria-hidden="true">{MEDALS[i] ?? i + 1}</span><span className="ui-sr-only">Rank {i + 1}: </span>
127
+ <span aria-hidden="true">{MEDALS[i] ?? i + 1}</span><span className="gb-sr-only">Rank {i + 1}: </span>
117
128
  </span>
118
129
  <Avatar name={r.name} src={r.avatar} />
119
- <span className="gb-leaderboard-name">{r.name}</span>
130
+ <span className="gb-leaderboard-name">{r.name}{r.current && currentLabel ? <span className="gb-sr-only">, {currentLabel}</span> : null}</span>
131
+ {r.badge ? <span className="gb-leaderboard-badge">{r.badge}</span> : null}
120
132
  <span className="gb-leaderboard-value">
121
- {r.value}{unit ? (showUnit ? <> {unit}</> : <span className="ui-sr-only"> {unit}</span>) : null}
133
+ {r.value}{unit ? (showUnit ? <> {unit}</> : <span className="gb-sr-only"> {unit}</span>) : null}
122
134
  </span>
123
135
  </>
124
136
  );
125
137
  return (
126
- <li key={r.key} className={cx("gb-leaderboard-row", i === 0 && "is-first")}>
138
+ <li key={r.key} id={r.id} className={cx("gb-leaderboard-row", i === 0 && "is-first", r.current && "is-current")}>
127
139
  {r.href ? <L href={r.href} className="gb-leaderboard-link">{inner}</L> : <div className="gb-leaderboard-link">{inner}</div>}
128
140
  </li>
129
141
  );
@@ -144,14 +156,14 @@ export function Podium({ rows, unit, as: L = "a", className }: { rows: LeaderRow
144
156
  if (!r) return <li key={idx} className="gb-podium-slot is-empty" aria-hidden="true" />;
145
157
  const body = (
146
158
  <>
147
- <span className="gb-podium-medal"><span aria-hidden="true">{MEDALS[idx]}</span><span className="ui-sr-only">Rank {idx + 1}: </span></span>
159
+ <span className="gb-podium-medal"><span aria-hidden="true">{MEDALS[idx]}</span><span className="gb-sr-only">Rank {idx + 1}: </span></span>
148
160
  <Avatar name={r.name} src={r.avatar} size={56} />
149
161
  <span className="gb-podium-name">{r.name}</span>
150
162
  <span className="gb-podium-value">{r.value}{unit ? <span className="gb-podium-unit"> {unit}</span> : null}</span>
151
163
  </>
152
164
  );
153
165
  return (
154
- <li key={r.key} className={cx("gb-podium-slot", PODIUM_SLOT[idx])}>
166
+ <li key={r.key} className={cx("gb-podium-slot", PODIUM_SLOT[idx], r.current && "is-current")}>
155
167
  {r.href ? <L href={r.href} className="gb-podium-link">{body}</L> : <div className="gb-podium-link">{body}</div>}
156
168
  </li>
157
169
  );
@@ -0,0 +1,65 @@
1
+ /* ═══ Dark palette — @gemboss/ui 0.7.2 ═══════════════════════════════════════════════════════════════
2
+ Split out of portal.css (04/10/2026). `ThemeToggle` / `themeScript` set data-theme on <html>, and this
3
+ file turns every --gemboss-* colour dark under it. It lives apart so that a surface which takes only
4
+ the leaderboard (gemboss-tokens.css + portal.css) does not inherit it: GemCommunity sets
5
+ data-theme="dark" for its own sign-in pages and remembers it, and the rule
6
+ `:root[data-theme="dark"] body` here outranks its body rule, so a member who once picked dark there
7
+ would get a dark page behind light cards. gemboss-ui.css still carries this file. */
8
+ /* Dark palette. `ThemeToggle` / `themeScript` set data-theme on <html>; a surface without them stays
9
+ light. Only colours change: the components above and every kit class read these tokens. */
10
+ :root[data-theme="dark"] {
11
+ color-scheme: dark;
12
+ --gemboss-primary: #f2f3f5;
13
+ --gemboss-primary-dark: #ffffff;
14
+ --gemboss-primary-soft: #23242a;
15
+ --gemboss-on-primary: #0d0d12;
16
+ --gemboss-accent: #7c84ff;
17
+ --gemboss-accent-strong: #a3a9ff;
18
+ --gemboss-accent-soft: #1f2140;
19
+ --gemboss-text-strong: #f2f3f5;
20
+ --gemboss-text: #e3e4e8;
21
+ --gemboss-text-muted: #9a9ca6;
22
+ --gemboss-surface: #16171d;
23
+ --gemboss-canvas: #0d0d12;
24
+ --gemboss-app-bg: #0d0d12;
25
+ --gemboss-rail-bg: #0d0d12;
26
+ --gemboss-panel-bg: #16171d;
27
+ --gemboss-topbar-bg: rgba(13, 13, 18, 0.96);
28
+ --gemboss-nav-bg: #0d0d12;
29
+ --gemboss-nav-hover: rgba(255, 255, 255, 0.06);
30
+ --gemboss-divider: #2a2b33;
31
+ --gemboss-divider-soft: #22232a;
32
+ --gemboss-hover: #22232b;
33
+ --gemboss-active-bg: #262833;
34
+ --gemboss-active-fg: #f2f3f5;
35
+ --gemboss-field-bg: #121318;
36
+ --gemboss-field-bg-hover: #17181e;
37
+ --gemboss-field-ink: #e3e4e8;
38
+ --gemboss-field-placeholder: #8a8c96;
39
+ --gemboss-field-line: #3a3c46;
40
+ --gemboss-field-line-hover: #50525e;
41
+ --gemboss-field-line-active: #c9cbd3;
42
+ --gemboss-chip-ink: #d6d8de;
43
+ --gemboss-chip-line: #3a3c46;
44
+ --gemboss-chip-line-hover: #50525e;
45
+ --gemboss-chip-bg-hover: #22232b;
46
+ --gemboss-success: #34c38f;
47
+ --gemboss-success-strong: #5fd6a8;
48
+ --gemboss-success-soft: #13291f;
49
+ --gemboss-success-border: #22533e;
50
+ --gemboss-warning: #e0b341;
51
+ --gemboss-warning-soft: #2b2412;
52
+ --gemboss-warning-border: #5a4a1d;
53
+ --gemboss-danger: #ff6b5a;
54
+ --gemboss-danger-strong: #ff8a7c;
55
+ --gemboss-danger-soft: #2e1714;
56
+ --gemboss-danger-border: #5c2a24;
57
+ --ui-accent-fg: #0d0d12;
58
+ --ui-warning-fg: #e8c063;
59
+ --gb-brand-ink: #f2f3f5;
60
+ }
61
+ /* Avatar initials in dark: the tone's light end, so the text clears 4.5:1 on its tinted disc. */
62
+ :root[data-theme="dark"] .gb-avatar[data-tone] { background: color-mix(in srgb, var(--gb-tone) 30%, #16171d); color: color-mix(in srgb, var(--gb-tone) 35%, #ffffff); }
63
+ :root[data-theme="dark"] body { background: var(--gemboss-app-bg); color: var(--gemboss-text); }
64
+ :root[data-theme="dark"] .gb-leaderboard-row.is-first .gb-leaderboard-value,
65
+ :root[data-theme="dark"] .gb-podium-slot--1 .gb-podium-value { color: #e0b341; }
package/src/ui.css CHANGED
@@ -274,10 +274,11 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
274
274
 
275
275
  /* ── Modal ────────────────────────────────────────────────────────────────────
276
276
  Rendered INLINE (not portaled): a portal escapes the .gemboss-admin-shell token
277
- scope and would render light. Kept in-tree so --ui-* resolves to the surface. */
277
+ scope and would render light. Kept in-tree so --ui-* resolves to the surface.
278
+ 0.7.3: Polaris Modal's density (#1099, README "Popup density"). Popup only: --ui-control-h stays 32. */
278
279
  .ui-overlay {
279
280
  position: fixed; inset: 0; z-index: 500;
280
- background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px);
281
+ background: rgba(0, 0, 0, 0.5);
281
282
  display: grid; place-items: center; padding: 20px;
282
283
  }
283
284
  .ui-modal {
@@ -287,30 +288,39 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
287
288
  }
288
289
  /* Head = title + the one close button every modal used to draw for itself. */
289
290
  .ui-modal__head {
290
- display: flex; align-items: flex-start; gap: 16px;
291
- padding: 16px 20px; border-bottom: 1px solid var(--ui-border);
292
- font-size: 15px; font-weight: 620; color: var(--ui-text);
291
+ display: flex; align-items: center; gap: 12px;
292
+ padding: 12px 16px; border-bottom: 1px solid var(--ui-border);
293
+ font-size: 14px; line-height: 20px; font-weight: 650; color: var(--ui-text);
293
294
  }
294
295
  .ui-modal__title { min-width: 0; flex: 1 1 auto; }
295
296
  .ui-modal__close {
296
297
  flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
297
- width: 44px; height: 44px; margin: -8px -8px -8px 0; /* a 44px target that does not grow the head */
298
+ width: 28px; height: 28px; padding: 0;
298
299
  border: 0; border-radius: var(--ui-radius-md, 8px); background: transparent;
299
300
  color: var(--ui-text-muted); cursor: pointer;
300
301
  }
301
302
  .ui-modal__close svg { width: 20px; height: 20px; }
302
303
  .ui-modal__close:hover { background: var(--ui-surface-2); color: var(--ui-text); }
303
304
  .ui-modal__close:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: -2px; }
305
+ /* Here, not in kit-plain.css: the admin loads only this file (0.7.3). */
306
+ .ui-modal__body { padding: 16px; }
307
+ .ui-modal__foot {
308
+ display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;
309
+ padding: 12px 16px; border-top: 1px solid var(--ui-border);
310
+ }
311
+ :is(.ui-modal__head, .ui-modal__foot) .ui-btn { height: 28px; padding: 0 12px; font-size: var(--ui-fs-caption); }
312
+ /* 44px under a thumb or at phone width; -8px keeps the head 53. */
313
+ @media (pointer: coarse), (max-width: 47.9975em) {
314
+ .ui-modal__close { width: 44px; height: 44px; margin: -8px -8px -8px 0; }
315
+ :is(.ui-modal__head, .ui-modal__foot) .ui-btn { min-height: 44px; }
316
+ }
304
317
 
305
- /* `size="lg"` — the screen-shaped dialog. Only the HOOK lives here: this sheet is imported by the
306
- root layout, so every rule in it is paid on every screen, and the geometry of a dialog with one
307
- consumer belongs in that consumer's chunk (see SettingsDialog.module.css). */
318
+ /* `size="lg"`: only the HOOK here (this sheet is paid on every screen); its geometry is in
319
+ SettingsDialog.module.css, the one consumer's chunk. */
308
320
  .ui-modal--lg { position: relative; }
309
321
 
310
- /* The kit's multi-line field joins the ONE field family (01/10/2026, Chris: "form control đồng bộ"):
311
- same input-border token, hover and focus ring as `gemboss-field` and `.ui-select`. Before, it drew a
312
- lighter --ui-border box at 14px with no focus ring, so on GemFactory's compose form a textarea sat
313
- beside selects of a darker border and text fields of a third look (measured on prod). */
322
+ /* The kit's multi-line field is the ONE field family (01/10/2026, Chris: "form control đồng bộ"): same
323
+ border token, hover and focus ring as `gemboss-field` / `.ui-select` (it drew a third look on GemFactory). */
314
324
  .ui-textarea {
315
325
  width: 100%; min-height: 96px; resize: vertical; box-sizing: border-box;
316
326
  background: var(--p-color-input-bg-surface, var(--gemboss-field-bg));