@gemboss/ui 0.6.5 → 0.7.1

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.js CHANGED
@@ -85,7 +85,7 @@
85
85
  if (o.onClose) o.onClose(value);
86
86
  }
87
87
  function onKey(e) {
88
- if (MODALS[MODALS.length - 1] !== m) return;
88
+ if (e === opening || MODALS[MODALS.length - 1] !== m) return;
89
89
  // Escape while an IME is composing (zh, ja, vi telex) cancels the composition, not the dialog.
90
90
  if (e.key === "Escape") { if (e.isComposing || e.keyCode === 229) return; e.preventDefault(); m.close(null); return; }
91
91
  if (e.key !== "Tab") return;
@@ -103,13 +103,26 @@
103
103
  overlay.addEventListener("click", function (e) { if (e.target === overlay && downOnBackdrop) m.close(null); downOnBackdrop = false; });
104
104
  var x = panel.querySelector(".ui-modal__head .ui-modal__close");
105
105
  if (x) x.onclick = function () { m.close(null); };
106
- // On the NEXT task, not now: a popup opened from a keydown handler (content-radar's composer asks "close without
107
- // saving?" on Escape) would otherwise receive that same Escape when it bubbles up to window, and close itself at
108
- // once (0.5.1). A listener added during a dispatch still runs if its target has not been reached yet.
109
- setTimeout(function () { if (MODALS.indexOf(m) >= 0) addEventListener("keydown", onKey); }, 0);
106
+ // Keys belong to the popup from the moment it opens (0.6.4). 0.5.1 attached this listener a task later, and on a busy
107
+ // page the browser runs input before timers: Escape or Tab pressed right after opening was lost, the popup stayed open
108
+ // or Tab left the trap (CI lost "stacked popups" three runs in a row, 03/10/2026). The one key it must NOT take is the
109
+ // keydown that is opening it: a popup opened from a keydown handler (content-radar's composer asks "close without
110
+ // saving?" on Escape) would receive that same Escape when it bubbles up to window — a listener added during a dispatch
111
+ // still runs if its target has not been reached yet — and close at once. `window.event` is that event while it runs.
112
+ var opening = window.event;
113
+ addEventListener("keydown", onKey);
110
114
  if (!MODALS.length) savedOverflow = document.body.style.overflow;
111
115
  MODALS.push(m);
112
- document.body.append(overlay);
116
+ // Inside the top open MODAL <dialog>, not <body> (0.6.9, 03/10/2026). A `<dialog class="ui-modal">` opened with
117
+ // showModal() sits in the top layer and makes the rest of the page inert, so a confirm appended to <body> drew
118
+ // BEHIND it and could not be clicked: GemCommunity's lesson editor asked "Discard changes?" into a dead layer and
119
+ // had to move the overlay by hand. Appended inside that dialog, the overlay rides the top layer with it.
120
+ var host = document.body;
121
+ try {
122
+ var open = document.querySelectorAll("dialog[open]");
123
+ for (var di = open.length - 1; di >= 0; di--) { if (open[di].matches(":modal")) { host = open[di]; break; } }
124
+ } catch (_) { /* a browser without :modal keeps <body> */ }
125
+ host.append(overlay);
113
126
  document.body.style.overflow = "hidden";
114
127
  setTimeout(function () { (panel.querySelector("[autofocus]") || panel).focus(); }, 0);
115
128
  return m;
package/src/portal.css ADDED
@@ -0,0 +1,187 @@
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. */
5
+
6
+ /* ── TopbarNav: section tabs in the topbar, after the brand (Featurebase's "Feedback · Roadmap"). The
7
+ active tab is underlined on the topbar's bottom edge. 44px targets under a finger, 36 under a mouse
8
+ (the kit's two-tier rule, as TopbarSearch). A phone scrolls the row sideways rather than wrapping it. */
9
+ .gb-topnav { display: flex; align-items: stretch; gap: 4px; min-width: 0; height: 100%; margin-left: 12px; padding-left: 12px;
10
+ border-left: 1px solid var(--gemboss-divider); overflow-x: auto; scrollbar-width: none; }
11
+ .gb-topnav::-webkit-scrollbar { display: none; }
12
+ /* Each tab is as tall as the topbar, so its underline lands on the topbar's bottom edge. */
13
+ .gb-topnav-link { position: relative; display: inline-flex; align-items: center; align-self: stretch; flex: 0 0 auto; min-height: 36px; padding: 0 10px;
14
+ border-radius: 8px; color: var(--gemboss-text-muted); font-size: 14px; font-weight: 520; text-decoration: none; white-space: nowrap; }
15
+ .gb-topnav-link:hover { color: var(--gemboss-text-strong); background: var(--gemboss-hover); }
16
+ .gb-topnav-link:focus-visible { outline: 2px solid var(--gemboss-accent); outline-offset: -2px; }
17
+ .gb-topnav-link[aria-current="page"] { color: var(--gemboss-text-strong); font-weight: 640; }
18
+ .gb-topnav-link[aria-current="page"]::after { content: ""; position: absolute; left: 10px; right: 10px; bottom: -1px; height: 2px;
19
+ border-radius: 2px; background: var(--gemboss-text-strong); }
20
+ @media (pointer: coarse) { .gb-topnav-link { min-height: 44px; } }
21
+ /* A topbar that carries tabs has no centre search: brand + tabs take the room, actions sit right. The
22
+ default columns keep 280–560px for a search box, which cut "Roadmap" to "Ro" and the brand to "G…". */
23
+ .gemboss-platform-topbar:has(.gb-topnav):has(> .gemboss-topbar-center:empty) { grid-template-columns: minmax(0, 1fr) auto; }
24
+ .gemboss-platform-topbar:has(.gb-topnav) > .gemboss-topbar-center:empty { display: none; }
25
+ .gemboss-topbar-start:has(.gb-topnav) { min-width: 0; height: 100%; }
26
+ .gemboss-topbar-start:has(.gb-topnav) > .gb-brand { flex: 0 0 auto; }
27
+ @media (max-width: 760px) { .gb-topnav { margin-left: 8px; padding-left: 8px; } .gb-topnav-link { min-height: 44px; padding: 0 8px; }
28
+ }
29
+ .gb-topnav-row { display: flex; align-items: stretch; gap: 4px; min-width: 0; height: 100%; }
30
+ /* The folded form (under 600px): one button naming the open tab, the tabs in a popover under it. */
31
+ .gb-topnav-fold { display: none; position: relative; align-self: stretch; }
32
+ .gb-topnav-fold > summary { list-style: none; cursor: pointer; gap: 4px; height: 100%; }
33
+ .gb-topnav-fold > summary::-webkit-details-marker { display: none; }
34
+ .gb-topnav-fold-btn[aria-current="page"]::after { display: none; }
35
+ .gb-topnav-chevron { flex: 0 0 auto; transition: transform .15s ease; }
36
+ .gb-topnav-fold[open] .gb-topnav-chevron { transform: rotate(180deg); }
37
+ .gb-topnav-pop { position: fixed; top: 56px; left: 8px; min-width: 200px; padding: 6px; display: grid; gap: 2px; z-index: 60; }
38
+ .gb-topnav-item { display: flex; align-items: center; min-height: 44px; padding: 0 12px; border-radius: 8px;
39
+ color: var(--gemboss-text); font-size: 14px; font-weight: 520; text-decoration: none; }
40
+ .gb-topnav-item:hover { background: var(--gemboss-hover); }
41
+ .gb-topnav-item:focus-visible { outline: 2px solid var(--gemboss-accent); outline-offset: -2px; }
42
+ .gb-topnav-item[aria-current="page"] { color: var(--gemboss-active-fg); background: var(--gemboss-active-bg); font-weight: 640; }
43
+ @media (max-width: 600px) {
44
+ .gb-topnav { overflow: visible; }
45
+ .gb-topnav-row { display: none; }
46
+ .gb-topnav-fold { display: flex; }
47
+ }
48
+ @media (prefers-reduced-motion: reduce) { .gb-topnav-chevron { transition: none; } }
49
+
50
+ /* ── Avatar: picture or initials on one of eight tones picked from the name. */
51
+ .gb-avatar { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 50%; overflow: hidden;
52
+ font-weight: 700; line-height: 1; letter-spacing: .01em; user-select: none; }
53
+ .gb-avatar--24 { width: 24px; height: 24px; font-size: 9.5px; }
54
+ .gb-avatar--32 { width: 32px; height: 32px; font-size: 11px; }
55
+ .gb-avatar--40 { width: 40px; height: 40px; font-size: 13px; }
56
+ .gb-avatar--56 { width: 56px; height: 56px; font-size: 18px; }
57
+ .gb-avatar-img { width: 100%; height: 100%; object-fit: cover; display: block; }
58
+ /* One hue per tone; the disc and the initials are both mixed from it (light: dark initials on a pale
59
+ disc; dark: light initials on a deep disc). portal.test.tsx checks every tone clears 4.5:1 in both. */
60
+ .gb-avatar[data-tone] { background: color-mix(in srgb, var(--gb-tone) 16%, #ffffff); color: color-mix(in srgb, var(--gb-tone) 70%, #000000); }
61
+ .gb-avatar[data-tone="0"] { --gb-tone: #5661f5; }
62
+ .gb-avatar[data-tone="1"] { --gb-tone: #0a8f6a; }
63
+ .gb-avatar[data-tone="2"] { --gb-tone: #c26a04; }
64
+ .gb-avatar[data-tone="3"] { --gb-tone: #db2777; }
65
+ .gb-avatar[data-tone="4"] { --gb-tone: #0891b2; }
66
+ .gb-avatar[data-tone="5"] { --gb-tone: #7c3aed; }
67
+ .gb-avatar[data-tone="6"] { --gb-tone: #dc2626; }
68
+ .gb-avatar[data-tone="7"] { --gb-tone: #6b7280; }
69
+
70
+ /* ── Leaderboard: medals 1–3, then the rank; avatar, name, count; the leader's count in gold. Rows are
71
+ 44px, so neighbours need no gap (§9.1). */
72
+ .gb-leaderboard { display: grid; gap: 6px; min-width: 0; }
73
+ .gb-leaderboard-title { margin: 0; display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 650; letter-spacing: .04em;
74
+ text-transform: uppercase; color: var(--gemboss-text-muted); }
75
+ .gb-leaderboard-caption { margin: -2px 0 2px; font-size: 12px; color: var(--gemboss-text-muted); }
76
+ .gb-leaderboard-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
77
+ .gb-leaderboard-link { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 6px; border-radius: 8px;
78
+ color: var(--gemboss-text); text-decoration: none; }
79
+ a.gb-leaderboard-link:hover { background: var(--gemboss-hover); }
80
+ a.gb-leaderboard-link:focus-visible { outline: 2px solid var(--gemboss-accent); outline-offset: -2px; }
81
+ .gb-leaderboard-rank { width: 20px; flex: 0 0 auto; text-align: center; font-size: 15px; line-height: 1; }
82
+ .gb-leaderboard-rank.is-number { font-size: 12px; font-weight: 700; color: var(--gemboss-text-muted); font-variant-numeric: tabular-nums; }
83
+ .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
+ .gb-leaderboard-value { flex: 0 0 auto; font-size: 13px; font-weight: 650; color: var(--gemboss-text-muted); font-variant-numeric: tabular-nums; }
85
+ .gb-leaderboard-row.is-first .gb-leaderboard-value { color: #8a5a00; font-weight: 750; }
86
+ .gb-leaderboard-all { justify-self: center; display: inline-flex; align-items: center; min-height: 24px; font-size: 12px; font-weight: 600;
87
+ color: var(--gemboss-text-muted); text-decoration: none; }
88
+ .gb-leaderboard-all:hover { color: var(--gemboss-text-strong); text-decoration: underline; }
89
+
90
+ /* ── Podium: second · first · third, the first raised. */
91
+ .gb-podium { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; align-items: end; }
92
+ .gb-podium-link { display: grid; justify-items: center; gap: 6px; padding: 14px 8px; border: 1px solid var(--gemboss-divider); border-radius: 12px;
93
+ background: var(--gemboss-surface); color: var(--gemboss-text); text-decoration: none; text-align: center; }
94
+ a.gb-podium-link:hover { background: var(--gemboss-hover); }
95
+ .gb-podium-slot { min-width: 0; }
96
+ .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
+ .gb-podium-slot--2 .gb-podium-link { padding-bottom: 12px; }
98
+ .gb-podium-slot--3 .gb-podium-link { padding-bottom: 8px; }
99
+ .gb-podium-medal { font-size: 22px; line-height: 1; }
100
+ .gb-podium-name { max-width: 100%; font-size: 13px; font-weight: 620; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
101
+ .gb-podium-value { font-size: 15px; font-weight: 750; font-variant-numeric: tabular-nums; }
102
+ .gb-podium-slot--1 .gb-podium-value { color: #8a5a00; }
103
+ .gb-podium-unit { font-size: 11px; font-weight: 500; color: var(--gemboss-text-muted); }
104
+
105
+ /* ── SearchDialog: the topbar's search button, and the popup (640 wide, near the top, like Featurebase's). */
106
+ .gb-search-trigger, .gb-theme-toggle { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; flex: 0 0 auto;
107
+ border: 0; border-radius: 8px; background: transparent; color: var(--gemboss-text-muted); cursor: pointer; }
108
+ .gb-search-trigger:hover, .gb-theme-toggle:hover { background: var(--gemboss-hover); color: var(--gemboss-text-strong); }
109
+ .gb-search-trigger:focus-visible, .gb-theme-toggle:focus-visible { outline: 2px solid var(--gemboss-accent); outline-offset: -2px; }
110
+ @media (pointer: coarse), (max-width: 760px) { .gb-search-trigger, .gb-theme-toggle { width: 44px; height: 44px; } }
111
+ dialog.ui-modal.gb-search { width: min(640px, 100%); margin-top: 8vh; max-height: min(560px, calc(100dvh - 16vh)); }
112
+ .gb-search-box { position: relative; display: flex; align-items: center; border-bottom: 1px solid var(--ui-border); }
113
+ .gb-search-icon { position: absolute; left: 18px; color: var(--ui-text-muted); pointer-events: none; }
114
+ .gb-search-input { flex: 1 1 auto; min-width: 0; height: 52px; padding: 0 18px 0 46px; border: 0; background: transparent; color: var(--ui-text);
115
+ font: inherit; font-size: 15px; outline: none; }
116
+ .gb-search-input::-webkit-search-cancel-button { display: none; }
117
+ .gb-search-results { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 8px; }
118
+ .gb-search-section + .gb-search-section { margin-top: 8px; }
119
+ .gb-search-section-title { padding: 6px 10px; font-size: 12px; font-weight: 600; color: var(--ui-text-muted); }
120
+ .gb-search-item { display: grid; gap: 2px; min-height: 44px; align-content: center; padding: 6px 10px; border-radius: 8px; color: var(--ui-text); text-decoration: none; }
121
+ .gb-search-item.is-active { background: var(--ui-surface-2); }
122
+ .gb-search-item-title { font-size: 14px; font-weight: 520; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
123
+ .gb-search-item-meta { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--ui-text-muted); }
124
+ .gb-search-empty { margin: 0; padding: 18px 18px 0; font-size: 13px; color: var(--ui-text-muted); }
125
+ .gb-search-empty:empty { padding: 0; }
126
+ .gb-search-foot { display: flex; gap: 16px; justify-content: flex-end; padding: 8px 14px; border-top: 1px solid var(--ui-border);
127
+ font-size: 12px; color: var(--ui-text-muted); }
128
+ @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 ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Pieces of a public portal — @gemboss/ui (04/10/2026).
3
+ *
4
+ * GemFeedback (feedback.gemboss.ai) copies the layout of the GemPages Featurebase board, and
5
+ * GemCommunity has drawn the same "top people" list by hand since its sidebar was built (Chris
6
+ * 04/10/2026: "leader board … cần đồng bộ giữa gemcommunity và gemfeedback và chuẩn hoá nó vào gemboss
7
+ * ui … có huy chương"). One owner for each:
8
+ *
9
+ * - `TopbarNav`: the section tabs INSIDE the topbar, beside the brand (Featurebase: "Feedback ·
10
+ * Roadmap · Changelog"), instead of a second row under it. Chris: "đưa feedback roadmap lên menu
11
+ * top bar để tiết kiệm không gian".
12
+ * - `Avatar`: a person's picture, or their initials on a tone picked from their name (the same name
13
+ * always gets the same tone, so a list of people reads as people, not a column of grey dots).
14
+ * - `Leaderboard`: medals for the first three, then the rank, the avatar, the name and the count; the
15
+ * leader's count in gold. GemCommunity's "Top contributors", GemFeedback's "Most helpful".
16
+ * - `Podium`: the first three as a podium (GemCommunity's leaderboard page), second · first · third.
17
+ *
18
+ * Server components: no state, no hooks. Links are `<a>` unless a router link is passed as `as`.
19
+ */
20
+ import React from "react";
21
+ import { initialsOf } from "./initials";
22
+
23
+ type LinkLike = React.ElementType;
24
+ const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
25
+
26
+ export type TopbarNavItem = { href: string; label: React.ReactNode; active?: boolean };
27
+
28
+ /**
29
+ * The section tabs in the topbar's start column, after the brand. The active tab carries aria-current.
30
+ *
31
+ * Under 600px the row does not fit beside the brand and the topbar's actions (measured at 390: room for
32
+ * about 180px, three tabs need 250), and a row that scrolls sideways hides the tabs past the edge with no
33
+ * sign they are there. There the tabs fold into one button naming the open tab, and a list under it, as
34
+ * Featurebase's board does on a phone. A <details>, so it opens without JavaScript; keyed by the active
35
+ * tab, so following a link (a client-side navigation keeps the element) closes it.
36
+ */
37
+ export function TopbarNav({ items, as: L = "a", label = "Sections", className }: {
38
+ items: TopbarNavItem[]; as?: LinkLike; label?: string; className?: string;
39
+ }) {
40
+ const current = items.find((it) => it.active) ?? items[0];
41
+ const links = (cls: string) => items.map((it) => (
42
+ <L key={it.href} href={it.href} className={cls} aria-current={it.active ? "page" : undefined}>{it.label}</L>
43
+ ));
44
+ return (
45
+ <nav className={cx("gb-topnav", className)} aria-label={label}>
46
+ <div className="gb-topnav-row">{links("gb-topnav-link")}</div>
47
+ {current ? (
48
+ <details className="gb-topnav-fold" key={current.href}>
49
+ <summary className="gb-topnav-link gb-topnav-fold-btn" aria-current="page">
50
+ {current.label}
51
+ <svg className="gb-topnav-chevron" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true" focusable="false">
52
+ <path d="M3 4.5 6 7.5 9 4.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
53
+ </svg>
54
+ </summary>
55
+ <div className="gb-topnav-pop gemboss-popover-surface">{links("gb-topnav-item")}</div>
56
+ </details>
57
+ ) : null}
58
+ </nav>
59
+ );
60
+ }
61
+
62
+ /** Eight tones, picked by a hash of the name: stable across pages and renders. */
63
+ export function avatarTone(name: string): number {
64
+ let h = 0;
65
+ for (const ch of name) h = (h * 31 + ch.codePointAt(0)!) >>> 0;
66
+ return h % 8;
67
+ }
68
+
69
+ export type AvatarSize = 24 | 32 | 40 | 56;
70
+ // Whole class names, not `gb-avatar--${size}`: the admin's orphan-class gate reads markup for classes,
71
+ // and an interpolated stem exempts everything under it from that check.
72
+ const AVATAR_SIZE: Record<AvatarSize, string> = { 24: "gb-avatar--24", 32: "gb-avatar--32", 40: "gb-avatar--40", 56: "gb-avatar--56" };
73
+
74
+ /** A person: their picture when there is one, else their initials (the account chip's rule). */
75
+ export function Avatar({ name, src, size = 24, className }: { name: string; src?: string | null; size?: AvatarSize; className?: string }) {
76
+ return (
77
+ <span className={cx("gb-avatar", AVATAR_SIZE[size], className)} data-tone={avatarTone(name)} aria-hidden="true">
78
+ {src ? <img className="gb-avatar-img" src={src} alt="" loading="lazy" width={size} height={size} /> : initialsOf(name)}
79
+ </span>
80
+ );
81
+ }
82
+
83
+ export type LeaderRow = { key: string | number; name: string; value: number; avatar?: string | null; href?: string };
84
+
85
+ const MEDALS = ["🥇", "🥈", "🥉"];
86
+ const PODIUM_SLOT = ["gb-podium-slot--1", "gb-podium-slot--2", "gb-podium-slot--3"];
87
+
88
+ /**
89
+ * A ranked list of people. Rows are links when they carry `href`; `unit` is read after the count by
90
+ * screen readers ("163 votes") and can be shown with `showUnit`. Empty → nothing (a "top" list of
91
+ * zeros looks dead; GemCommunity hides it the same way).
92
+ */
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;
95
+ viewAll?: { href: string; label: React.ReactNode }; as?: LinkLike; className?: string; headingLevel?: 2 | 3;
96
+ /** The heading's id. Pass one when two leaderboards share a page or the title is not plain text. */
97
+ id?: string;
98
+ }) {
99
+ if (!rows.length) return null;
100
+ const H = `h${headingLevel}` as "h2" | "h3";
101
+ // From the title's letters (any script) and a hash of it, so "Most helpful" and a Vietnamese or
102
+ // Chinese title each get their own; two boards with the same title need `id`.
103
+ const text = typeof title === "string" ? title : "leaderboard";
104
+ let h = 0;
105
+ for (const ch of text) h = (h * 31 + ch.codePointAt(0)!) >>> 0;
106
+ const headingId = id ?? `gb-lb-${text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "")}-${h.toString(36)}`;
107
+ return (
108
+ <section className={cx("gb-leaderboard", className)} aria-labelledby={headingId}>
109
+ <H className="gb-leaderboard-title" id={headingId}>{title}</H>
110
+ {caption ? <p className="gb-leaderboard-caption">{caption}</p> : null}
111
+ <ol className="gb-leaderboard-list">
112
+ {rows.map((r, i) => {
113
+ const inner = (
114
+ <>
115
+ <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>
117
+ </span>
118
+ <Avatar name={r.name} src={r.avatar} />
119
+ <span className="gb-leaderboard-name">{r.name}</span>
120
+ <span className="gb-leaderboard-value">
121
+ {r.value}{unit ? (showUnit ? <> {unit}</> : <span className="ui-sr-only"> {unit}</span>) : null}
122
+ </span>
123
+ </>
124
+ );
125
+ return (
126
+ <li key={r.key} className={cx("gb-leaderboard-row", i === 0 && "is-first")}>
127
+ {r.href ? <L href={r.href} className="gb-leaderboard-link">{inner}</L> : <div className="gb-leaderboard-link">{inner}</div>}
128
+ </li>
129
+ );
130
+ })}
131
+ </ol>
132
+ {viewAll ? <L href={viewAll.href} className="gb-leaderboard-all">{viewAll.label}</L> : null}
133
+ </section>
134
+ );
135
+ }
136
+
137
+ /** The first three as a podium, drawn second · first · third. */
138
+ export function Podium({ rows, unit, as: L = "a", className }: { rows: LeaderRow[]; unit?: string; as?: LinkLike; className?: string }) {
139
+ if (!rows.length) return null;
140
+ return (
141
+ <ol className={cx("gb-podium", className)} aria-label="Top three">
142
+ {[1, 0, 2].map((idx) => {
143
+ const r = rows[idx];
144
+ if (!r) return <li key={idx} className="gb-podium-slot is-empty" aria-hidden="true" />;
145
+ const body = (
146
+ <>
147
+ <span className="gb-podium-medal"><span aria-hidden="true">{MEDALS[idx]}</span><span className="ui-sr-only">Rank {idx + 1}: </span></span>
148
+ <Avatar name={r.name} src={r.avatar} size={56} />
149
+ <span className="gb-podium-name">{r.name}</span>
150
+ <span className="gb-podium-value">{r.value}{unit ? <span className="gb-podium-unit"> {unit}</span> : null}</span>
151
+ </>
152
+ );
153
+ return (
154
+ <li key={r.key} className={cx("gb-podium-slot", PODIUM_SLOT[idx])}>
155
+ {r.href ? <L href={r.href} className="gb-podium-link">{body}</L> : <div className="gb-podium-link">{body}</div>}
156
+ </li>
157
+ );
158
+ })}
159
+ </ol>
160
+ );
161
+ }
package/src/shell.css CHANGED
@@ -406,8 +406,8 @@
406
406
  .gnav-item:focus-visible { outline: 2px solid var(--gemboss-accent); outline-offset: -2px; } /* the one iris ring (0.5.1): had none, the browser drew its own */
407
407
 
408
408
  /* neutral gray on white (Polaris-like) */
409
- /* Active hub: a full coral pill when you're ON the hub's own page. But when a deeper
410
- LEAF is the destination, the hub quiets to coral text only — so the leaf below carries
409
+ /* Active hub: the neutral selected pill (--gemboss-active-bg) when you're ON the hub's own page. But when a deeper
410
+ LEAF is the destination, the hub quiets to bold text only — so the leaf below carries
411
411
  the strong "you are here" and the two don't compete. */
412
412
  .gnav-item.active { color: var(--gemboss-active-fg); font-weight: 600; }
413
413
 
@@ -450,7 +450,7 @@
450
450
 
451
451
  .gnav-subitem:hover { background: var(--gemboss-nav-hover); color: var(--gemboss-text); }
452
452
 
453
- /* Active leaf — the strong "you are here": coral pill + a coral accent bar on the tree line. */
453
+ /* Active leaf — the strong "you are here": the neutral selected pill + a near-black bar on the tree line. */
454
454
  .gnav-subitem.active { color: var(--gemboss-active-fg); font-weight: 600; background: var(--gemboss-active-bg); }
455
455
 
456
456
  .gnav-subitem.active:hover { background: var(--gemboss-active-bg); color: var(--gemboss-active-fg); }
@@ -470,19 +470,21 @@
470
470
  /* section separator (Assistant · Settings) */
471
471
  .gnav-sep { height: 1px; background: var(--gemboss-divider); margin: 8px 6px; }
472
472
 
473
- /* Assistant = the AI front door, not just another row. A coral-tinted pill that stands
473
+ /* Assistant = the AI front door, not just another row. An iris-tinted pill that stands
474
474
  apart from the neutral hubs above; deepens on hover; becomes the standard active pill
475
- when it's the current page. */
475
+ when it's the current page. It was a --gemboss-primary mix, which turned grey (#ececed)
476
+ once primary went near-black and read as a second selected row next to #f0f1f4 (0.6.8).
477
+ Contrast on --gemboss-accent-soft: label 6.05:1, icon and the iris focus ring 4.19:1 (16% hover: 5.56 / 3.84). */
476
478
  .gnav-item.gnav-assistant {
477
- /* Label uses the DARKER coral for WCAG AA (4.77:1 on the 8% pill; the lighter
478
- --gemboss-primary was 2.87, failed). Icon + pill keep --gemboss-primary as the accent. */
479
- color: var(--gemboss-primary-dark); font-weight: 600;
480
- background: color-mix(in srgb, var(--gemboss-primary) 8%, #fff);
479
+ color: var(--gemboss-accent-strong); font-weight: 600;
480
+ /* The kit's iris tint token, not a mix with #fff: a dark surface remaps -soft and -strong together, so the label keeps
481
+ its contrast there too (review #3710: a #fff mix under gempress's dark -strong would be 1.8:1). */
482
+ background: var(--gemboss-accent-soft);
481
483
  }
482
484
 
483
- .gnav-item.gnav-assistant .gnav-ico svg { fill: var(--gemboss-primary); }
485
+ .gnav-item.gnav-assistant .gnav-ico svg { fill: var(--gemboss-accent); }
484
486
 
485
- .gnav-item.gnav-assistant:hover { background: color-mix(in srgb, var(--gemboss-primary) 15%, #fff); }
487
+ .gnav-item.gnav-assistant:hover { background: color-mix(in srgb, var(--gemboss-accent) 16%, var(--gemboss-surface)); }
486
488
 
487
489
  .gnav-item.gnav-assistant.active { background: var(--gemboss-active-bg); color: var(--gemboss-active-fg); }
488
490
 
package/src/theme.tsx ADDED
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Light and dark — @gemboss/ui (04/10/2026).
5
+ *
6
+ * Until now the kit had dark colours only on the sign-in screen, following the OS (auth.css). The
7
+ * GemPages board has a sun/moon button in its topbar and remembers the choice (Chris 04/10/2026: "xem
8
+ * kỹ các mode của nó … chuyển chế độ sáng tối"). `ThemeToggle` sets `data-theme="dark|light"` on
9
+ * <html> and stores it; portal.css redefines the `--gemboss-*` colours under `[data-theme="dark"]`, so
10
+ * every kit class (cards, tags, fields, buttons, the topbar) follows without a rule of its own.
11
+ *
12
+ * Opt-in: a surface without the toggle (the admin) never gets the attribute and stays light.
13
+ * `themeScript` goes in <head> (`<script dangerouslySetInnerHTML={{ __html: themeScript }} />`): it applies
14
+ * the stored choice, else the OS's, before the first paint, so a dark page never flashes white.
15
+ */
16
+ import React, { useEffect, useState } from "react";
17
+ import { Icon } from "./icons";
18
+
19
+ export const THEME_KEY = "gemboss-theme";
20
+
21
+ export const themeScript = `(function(){try{var t=localStorage.getItem("${THEME_KEY}");if(t!=="dark"&&t!=="light"){t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}document.documentElement.setAttribute("data-theme",t)}catch(e){}})();`;
22
+
23
+ export function ThemeToggle({ labels = { toDark: "Switch to dark mode", toLight: "Switch to light mode" } }: {
24
+ labels?: { toDark: string; toLight: string };
25
+ }) {
26
+ // Unknown on the server; the script in <head> has already set the attribute by hydration.
27
+ const [dark, setDark] = useState<boolean | null>(null);
28
+ useEffect(() => { setDark(document.documentElement.getAttribute("data-theme") === "dark"); }, []);
29
+ const flip = () => {
30
+ const next = !(document.documentElement.getAttribute("data-theme") === "dark");
31
+ document.documentElement.setAttribute("data-theme", next ? "dark" : "light");
32
+ try { localStorage.setItem(THEME_KEY, next ? "dark" : "light"); } catch { /* private mode: the choice lasts this page */ }
33
+ setDark(next);
34
+ };
35
+ return (
36
+ // The label says what a press does; no aria-pressed as well (two states for one control).
37
+ <button type="button" className="gb-theme-toggle" onClick={flip}
38
+ aria-label={dark ? labels.toLight : labels.toDark} title={dark ? labels.toLight : labels.toDark}>
39
+ <Icon name={dark ? "sun" : "moon"} size={18} />
40
+ </button>
41
+ );
42
+ }
package/src/ui.css CHANGED
@@ -384,7 +384,7 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
384
384
  pointer-events: auto;
385
385
  display: flex; align-items: center; gap: 12px;
386
386
  min-width: 220px; max-width: 100%; padding: 12px 12px 12px 16px;
387
- background: var(--ui-text-strong, #1a1917); color: #fff; border-radius: 12px;
387
+ background: var(--ui-text-strong, #1a1917); color: var(--gemboss-on-primary, #fff); border-radius: 12px;
388
388
  font-size: var(--ui-fs-body, 13px); line-height: 1.4; font-weight: 500;
389
389
  box-shadow: var(--gemboss-shadow-overlay, 0 8px 28px rgba(0, 0, 0, 0.24));
390
390
  animation: gemboss-toast-in 240ms cubic-bezier(0.16, 1, 0.3, 1);
@@ -392,7 +392,7 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
392
392
  .gemboss-toast.is-error { background: var(--gemboss-danger, #d72c0d); }
393
393
  .gemboss-toast-text { flex: 1 1 auto; min-width: 0; }
394
394
  .gemboss-toast-action {
395
- flex: 0 0 auto; border: 0; background: transparent; color: #fff; cursor: pointer;
395
+ flex: 0 0 auto; border: 0; background: transparent; color: var(--gemboss-on-primary, #fff); cursor: pointer;
396
396
  padding: 4px 8px; border-radius: 8px; font: inherit; font-weight: 650;
397
397
  text-decoration: underline; text-underline-offset: 2px;
398
398
  transition: background 0.14s ease;
@@ -401,12 +401,12 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
401
401
  .gemboss-toast-dismiss {
402
402
  flex: 0 0 auto; display: inline-grid; place-items: center;
403
403
  width: 24px; height: 24px; border: 0; border-radius: 8px; cursor: pointer;
404
- background: rgba(255, 255, 255, 0.12); color: #fff;
404
+ background: rgba(255, 255, 255, 0.12); color: var(--gemboss-on-primary, #fff);
405
405
  transition: background 0.14s ease;
406
406
  }
407
407
  .gemboss-toast-dismiss:hover { background: rgba(255, 255, 255, 0.26); }
408
408
  .gemboss-toast-action:focus-visible,
409
- .gemboss-toast-dismiss:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
409
+ .gemboss-toast-dismiss:focus-visible { outline: 2px solid var(--gemboss-on-primary, #fff); outline-offset: 2px; }
410
410
  @keyframes gemboss-toast-in {
411
411
  from { opacity: 0; transform: translateY(12px); }
412
412
  to { opacity: 1; transform: translateY(0); }