@gemboss/ui 0.6.9 → 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 +39 -0
- package/catalog/index.html +419 -3
- package/gemboss-ui.css +204 -12
- package/package.json +1 -1
- package/src/account.tsx +4 -6
- 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/portal.css +187 -0
- package/src/portal.tsx +161 -0
- package/src/theme.tsx +42 -0
- package/src/ui.css +4 -4
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/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); }
|