@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/README.md +45 -3
- package/catalog/index.html +421 -5
- package/gemboss-ui.css +212 -17
- package/package.json +1 -1
- package/src/account.tsx +4 -6
- package/src/app-shell.css +4 -1
- package/src/assistant.css +7 -4
- package/src/chat.css +2 -2
- package/src/dialog.tsx +195 -0
- package/src/icons.tsx +2 -0
- package/src/index.ts +6 -1
- package/src/initials.ts +14 -0
- package/src/kit-plain.js +19 -6
- package/src/portal.css +187 -0
- package/src/portal.tsx +161 -0
- package/src/shell.css +13 -11
- package/src/theme.tsx +42 -0
- package/src/ui.css +4 -4
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
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
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
|
-
|
|
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:
|
|
410
|
-
LEAF is the destination, the hub quiets to
|
|
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":
|
|
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.
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
background:
|
|
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-
|
|
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-
|
|
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); }
|