@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/gemboss-ui.css
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* GemBoss admin design system — GENERATED — do not edit; regenerate from gemboss:
|
|
3
3
|
* node apps/admin/scripts/export-ui-css.mjs --out <path> (npm run export:ui-css)
|
|
4
|
-
* gemboss commit:
|
|
5
|
-
* sources sha256:
|
|
4
|
+
* gemboss commit: fef56e0d93ce96cce6e59613fc62f8f68a739cd2
|
|
5
|
+
* sources sha256: 518302cf7e7a5abd3c1c2157f2b0993b9578c9e030d25ee90cd5ef36b898eec2
|
|
6
6
|
* contents: 1 allow-listed rule from apps/admin/src/app/globals.css + packages/ui/src/gemboss-tokens.css
|
|
7
7
|
* (verbatim) + packages/ui/src/ui.css (verbatim) + packages/ui/src/gemboss-field.css (verbatim)
|
|
8
8
|
* + 181 rules from packages/ui/src/shell.css (the shared shell)
|
|
@@ -567,7 +567,7 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
|
|
|
567
567
|
pointer-events: auto;
|
|
568
568
|
display: flex; align-items: center; gap: 12px;
|
|
569
569
|
min-width: 220px; max-width: 100%; padding: 12px 12px 12px 16px;
|
|
570
|
-
background: var(--ui-text-strong, #1a1917); color: #fff; border-radius: 12px;
|
|
570
|
+
background: var(--ui-text-strong, #1a1917); color: var(--gemboss-on-primary, #fff); border-radius: 12px;
|
|
571
571
|
font-size: var(--ui-fs-body, 13px); line-height: 1.4; font-weight: 500;
|
|
572
572
|
box-shadow: var(--gemboss-shadow-overlay, 0 8px 28px rgba(0, 0, 0, 0.24));
|
|
573
573
|
animation: gemboss-toast-in 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
@@ -575,7 +575,7 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
|
|
|
575
575
|
.gemboss-toast.is-error { background: var(--gemboss-danger, #d72c0d); }
|
|
576
576
|
.gemboss-toast-text { flex: 1 1 auto; min-width: 0; }
|
|
577
577
|
.gemboss-toast-action {
|
|
578
|
-
flex: 0 0 auto; border: 0; background: transparent; color: #fff; cursor: pointer;
|
|
578
|
+
flex: 0 0 auto; border: 0; background: transparent; color: var(--gemboss-on-primary, #fff); cursor: pointer;
|
|
579
579
|
padding: 4px 8px; border-radius: 8px; font: inherit; font-weight: 650;
|
|
580
580
|
text-decoration: underline; text-underline-offset: 2px;
|
|
581
581
|
transition: background 0.14s ease;
|
|
@@ -584,12 +584,12 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
|
|
|
584
584
|
.gemboss-toast-dismiss {
|
|
585
585
|
flex: 0 0 auto; display: inline-grid; place-items: center;
|
|
586
586
|
width: 24px; height: 24px; border: 0; border-radius: 8px; cursor: pointer;
|
|
587
|
-
background: rgba(255, 255, 255, 0.12); color: #fff;
|
|
587
|
+
background: rgba(255, 255, 255, 0.12); color: var(--gemboss-on-primary, #fff);
|
|
588
588
|
transition: background 0.14s ease;
|
|
589
589
|
}
|
|
590
590
|
.gemboss-toast-dismiss:hover { background: rgba(255, 255, 255, 0.26); }
|
|
591
591
|
.gemboss-toast-action:focus-visible,
|
|
592
|
-
.gemboss-toast-dismiss:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
|
|
592
|
+
.gemboss-toast-dismiss:focus-visible { outline: 2px solid var(--gemboss-on-primary, #fff); outline-offset: 2px; }
|
|
593
593
|
@keyframes gemboss-toast-in {
|
|
594
594
|
from { opacity: 0; transform: translateY(12px); }
|
|
595
595
|
to { opacity: 1; transform: translateY(0); }
|
|
@@ -3151,7 +3151,7 @@ body.gemboss-assistant-unread .gnav-item.gnav-assistant::after {
|
|
|
3151
3151
|
padding: 0 6px;
|
|
3152
3152
|
border-radius: var(--gemboss-radius-full);
|
|
3153
3153
|
background: var(--gemboss-primary);
|
|
3154
|
-
color: #fff;
|
|
3154
|
+
color: var(--gemboss-on-primary, #fff);
|
|
3155
3155
|
font-size: 11px;
|
|
3156
3156
|
font-weight: 700;
|
|
3157
3157
|
line-height: 18px;
|
|
@@ -3275,14 +3275,17 @@ body.gemboss-assistant-unread .gnav-item.gnav-assistant::after {
|
|
|
3275
3275
|
margin: 8px 0 4px;
|
|
3276
3276
|
padding: 0 12px;
|
|
3277
3277
|
border-radius: var(--gemboss-radius-md, 8px);
|
|
3278
|
-
|
|
3279
|
-
|
|
3278
|
+
/* The SAME iris pill as the admin's rail Assistant (.gnav-item.gnav-assistant, 0.6.8), not a solid near-black button
|
|
3279
|
+
(0.7.1, 04/10/2026): content-radar and partner-radar open their assistant through this launcher, and after 0.6.8 the
|
|
3280
|
+
two drew the one AI front door in two different colours beside the admin. */
|
|
3281
|
+
background: var(--gemboss-accent-soft);
|
|
3282
|
+
color: var(--gemboss-accent-strong);
|
|
3280
3283
|
font-size: 13px;
|
|
3281
3284
|
font-weight: 600;
|
|
3282
3285
|
line-height: 1;
|
|
3283
3286
|
text-align: left;
|
|
3284
3287
|
}
|
|
3285
|
-
.gb-assistant-launcher--rail:hover { background: var(--gemboss-
|
|
3288
|
+
.gb-assistant-launcher--rail:hover { background: color-mix(in srgb, var(--gemboss-accent) 16%, var(--gemboss-surface)); }
|
|
3286
3289
|
.gb-assistant-launcher--rail .gb-assistant-launcher-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
3287
3290
|
.gb-assistant-launcher-kbd {
|
|
3288
3291
|
font: inherit; font-size: 11px; opacity: 0.7;
|
|
@@ -3794,7 +3797,7 @@ button.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-off
|
|
|
3794
3797
|
font-size: var(--gemboss-fs-body); line-height: 1.5; padding: 12px 12px; border-radius: 16px; max-width: min(90%, 68ch);
|
|
3795
3798
|
white-space: pre-wrap; word-break: break-word;
|
|
3796
3799
|
}
|
|
3797
|
-
.gemboss-ec-msg.user .gemboss-ec-bubble { background: var(--gemboss-primary); color: #fff; border-bottom-right-radius: 4px; }
|
|
3800
|
+
.gemboss-ec-msg.user .gemboss-ec-bubble { background: var(--gemboss-primary); color: var(--gemboss-on-primary, #fff); border-bottom-right-radius: 4px; }
|
|
3798
3801
|
.gemboss-ec-msg.assistant .gemboss-ec-bubble { background: var(--gemboss-surface); color: var(--gemboss-text); border: 1px solid var(--gemboss-divider); border-bottom-left-radius: 4px; }
|
|
3799
3802
|
.gemboss-ec-input button[type="submit"] svg { width: 18px; height: 18px; fill: currentColor; }
|
|
3800
3803
|
/* Live "working" heartbeat — the Claude-Code-style running asterisk shown before the first token.
|
|
@@ -3894,7 +3897,7 @@ button.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-off
|
|
|
3894
3897
|
.gemboss-ec-input button[type="submit"] {
|
|
3895
3898
|
flex: 0 0 auto; width: 44px; height: 44px; min-width: 44px; min-height: 44px; border-radius: 50%; border: 0; cursor: pointer;
|
|
3896
3899
|
display: inline-flex; align-items: center; justify-content: center;
|
|
3897
|
-
background: var(--gemboss-primary); color: #fff; font-size: 16px; font-weight: 700;
|
|
3900
|
+
background: var(--gemboss-primary); color: var(--gemboss-on-primary, #fff); font-size: 16px; font-weight: 700;
|
|
3898
3901
|
transition: background var(--gemboss-transition-fast) ease, filter var(--gemboss-transition-fast) ease;
|
|
3899
3902
|
}
|
|
3900
3903
|
.gemboss-ec-input button[type="submit"]:hover:not(:disabled) { background: var(--gemboss-primary-dark); }
|
|
@@ -4293,3 +4296,192 @@ button.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-off
|
|
|
4293
4296
|
.gemboss-ec-stop:disabled { opacity: 0.6; cursor: default; }
|
|
4294
4297
|
.gemboss-ec-stop:hover { filter: brightness(1.08); }
|
|
4295
4298
|
.gemboss-ec-stop:focus-visible { outline: 2px solid var(--gemboss-focus, var(--ui-focus, #5661f5)); outline-offset: 2px; }
|
|
4299
|
+
|
|
4300
|
+
/* ═══ @gemboss/ui src/portal.css ═══ */
|
|
4301
|
+
/* ═══ Portal pieces — @gemboss/ui 0.7.0 (04/10/2026) ════════════════════════════════════════════
|
|
4302
|
+
TopbarNav, Avatar, Leaderboard, Podium (portal.tsx), SearchDialog (dialog.tsx), ThemeToggle and the
|
|
4303
|
+
dark palette (theme.tsx). Tokens only: every colour comes from --gemboss-* / --ui-*, so the dark
|
|
4304
|
+
palette at the end reaches these too. */
|
|
4305
|
+
|
|
4306
|
+
/* ── TopbarNav: section tabs in the topbar, after the brand (Featurebase's "Feedback · Roadmap"). The
|
|
4307
|
+
active tab is underlined on the topbar's bottom edge. 44px targets under a finger, 36 under a mouse
|
|
4308
|
+
(the kit's two-tier rule, as TopbarSearch). A phone scrolls the row sideways rather than wrapping it. */
|
|
4309
|
+
.gb-topnav { display: flex; align-items: stretch; gap: 4px; min-width: 0; height: 100%; margin-left: 12px; padding-left: 12px;
|
|
4310
|
+
border-left: 1px solid var(--gemboss-divider); overflow-x: auto; scrollbar-width: none; }
|
|
4311
|
+
.gb-topnav::-webkit-scrollbar { display: none; }
|
|
4312
|
+
/* Each tab is as tall as the topbar, so its underline lands on the topbar's bottom edge. */
|
|
4313
|
+
.gb-topnav-link { position: relative; display: inline-flex; align-items: center; align-self: stretch; flex: 0 0 auto; min-height: 36px; padding: 0 10px;
|
|
4314
|
+
border-radius: 8px; color: var(--gemboss-text-muted); font-size: 14px; font-weight: 520; text-decoration: none; white-space: nowrap; }
|
|
4315
|
+
.gb-topnav-link:hover { color: var(--gemboss-text-strong); background: var(--gemboss-hover); }
|
|
4316
|
+
.gb-topnav-link:focus-visible { outline: 2px solid var(--gemboss-accent); outline-offset: -2px; }
|
|
4317
|
+
.gb-topnav-link[aria-current="page"] { color: var(--gemboss-text-strong); font-weight: 640; }
|
|
4318
|
+
.gb-topnav-link[aria-current="page"]::after { content: ""; position: absolute; left: 10px; right: 10px; bottom: -1px; height: 2px;
|
|
4319
|
+
border-radius: 2px; background: var(--gemboss-text-strong); }
|
|
4320
|
+
@media (pointer: coarse) { .gb-topnav-link { min-height: 44px; } }
|
|
4321
|
+
/* A topbar that carries tabs has no centre search: brand + tabs take the room, actions sit right. The
|
|
4322
|
+
default columns keep 280–560px for a search box, which cut "Roadmap" to "Ro" and the brand to "G…". */
|
|
4323
|
+
.gemboss-platform-topbar:has(.gb-topnav):has(> .gemboss-topbar-center:empty) { grid-template-columns: minmax(0, 1fr) auto; }
|
|
4324
|
+
.gemboss-platform-topbar:has(.gb-topnav) > .gemboss-topbar-center:empty { display: none; }
|
|
4325
|
+
.gemboss-topbar-start:has(.gb-topnav) { min-width: 0; height: 100%; }
|
|
4326
|
+
.gemboss-topbar-start:has(.gb-topnav) > .gb-brand { flex: 0 0 auto; }
|
|
4327
|
+
@media (max-width: 760px) { .gb-topnav { margin-left: 8px; padding-left: 8px; } .gb-topnav-link { min-height: 44px; padding: 0 8px; }
|
|
4328
|
+
}
|
|
4329
|
+
.gb-topnav-row { display: flex; align-items: stretch; gap: 4px; min-width: 0; height: 100%; }
|
|
4330
|
+
/* The folded form (under 600px): one button naming the open tab, the tabs in a popover under it. */
|
|
4331
|
+
.gb-topnav-fold { display: none; position: relative; align-self: stretch; }
|
|
4332
|
+
.gb-topnav-fold > summary { list-style: none; cursor: pointer; gap: 4px; height: 100%; }
|
|
4333
|
+
.gb-topnav-fold > summary::-webkit-details-marker { display: none; }
|
|
4334
|
+
.gb-topnav-fold-btn[aria-current="page"]::after { display: none; }
|
|
4335
|
+
.gb-topnav-chevron { flex: 0 0 auto; transition: transform .15s ease; }
|
|
4336
|
+
.gb-topnav-fold[open] .gb-topnav-chevron { transform: rotate(180deg); }
|
|
4337
|
+
.gb-topnav-pop { position: fixed; top: 56px; left: 8px; min-width: 200px; padding: 6px; display: grid; gap: 2px; z-index: 60; }
|
|
4338
|
+
.gb-topnav-item { display: flex; align-items: center; min-height: 44px; padding: 0 12px; border-radius: 8px;
|
|
4339
|
+
color: var(--gemboss-text); font-size: 14px; font-weight: 520; text-decoration: none; }
|
|
4340
|
+
.gb-topnav-item:hover { background: var(--gemboss-hover); }
|
|
4341
|
+
.gb-topnav-item:focus-visible { outline: 2px solid var(--gemboss-accent); outline-offset: -2px; }
|
|
4342
|
+
.gb-topnav-item[aria-current="page"] { color: var(--gemboss-active-fg); background: var(--gemboss-active-bg); font-weight: 640; }
|
|
4343
|
+
@media (max-width: 600px) {
|
|
4344
|
+
.gb-topnav { overflow: visible; }
|
|
4345
|
+
.gb-topnav-row { display: none; }
|
|
4346
|
+
.gb-topnav-fold { display: flex; }
|
|
4347
|
+
}
|
|
4348
|
+
@media (prefers-reduced-motion: reduce) { .gb-topnav-chevron { transition: none; } }
|
|
4349
|
+
|
|
4350
|
+
/* ── Avatar: picture or initials on one of eight tones picked from the name. */
|
|
4351
|
+
.gb-avatar { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 50%; overflow: hidden;
|
|
4352
|
+
font-weight: 700; line-height: 1; letter-spacing: .01em; user-select: none; }
|
|
4353
|
+
.gb-avatar--24 { width: 24px; height: 24px; font-size: 9.5px; }
|
|
4354
|
+
.gb-avatar--32 { width: 32px; height: 32px; font-size: 11px; }
|
|
4355
|
+
.gb-avatar--40 { width: 40px; height: 40px; font-size: 13px; }
|
|
4356
|
+
.gb-avatar--56 { width: 56px; height: 56px; font-size: 18px; }
|
|
4357
|
+
.gb-avatar-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
4358
|
+
/* One hue per tone; the disc and the initials are both mixed from it (light: dark initials on a pale
|
|
4359
|
+
disc; dark: light initials on a deep disc). portal.test.tsx checks every tone clears 4.5:1 in both. */
|
|
4360
|
+
.gb-avatar[data-tone] { background: color-mix(in srgb, var(--gb-tone) 16%, #ffffff); color: color-mix(in srgb, var(--gb-tone) 70%, #000000); }
|
|
4361
|
+
.gb-avatar[data-tone="0"] { --gb-tone: #5661f5; }
|
|
4362
|
+
.gb-avatar[data-tone="1"] { --gb-tone: #0a8f6a; }
|
|
4363
|
+
.gb-avatar[data-tone="2"] { --gb-tone: #c26a04; }
|
|
4364
|
+
.gb-avatar[data-tone="3"] { --gb-tone: #db2777; }
|
|
4365
|
+
.gb-avatar[data-tone="4"] { --gb-tone: #0891b2; }
|
|
4366
|
+
.gb-avatar[data-tone="5"] { --gb-tone: #7c3aed; }
|
|
4367
|
+
.gb-avatar[data-tone="6"] { --gb-tone: #dc2626; }
|
|
4368
|
+
.gb-avatar[data-tone="7"] { --gb-tone: #6b7280; }
|
|
4369
|
+
|
|
4370
|
+
/* ── Leaderboard: medals 1–3, then the rank; avatar, name, count; the leader's count in gold. Rows are
|
|
4371
|
+
44px, so neighbours need no gap (§9.1). */
|
|
4372
|
+
.gb-leaderboard { display: grid; gap: 6px; min-width: 0; }
|
|
4373
|
+
.gb-leaderboard-title { margin: 0; display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 650; letter-spacing: .04em;
|
|
4374
|
+
text-transform: uppercase; color: var(--gemboss-text-muted); }
|
|
4375
|
+
.gb-leaderboard-caption { margin: -2px 0 2px; font-size: 12px; color: var(--gemboss-text-muted); }
|
|
4376
|
+
.gb-leaderboard-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
|
|
4377
|
+
.gb-leaderboard-link { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 6px; border-radius: 8px;
|
|
4378
|
+
color: var(--gemboss-text); text-decoration: none; }
|
|
4379
|
+
a.gb-leaderboard-link:hover { background: var(--gemboss-hover); }
|
|
4380
|
+
a.gb-leaderboard-link:focus-visible { outline: 2px solid var(--gemboss-accent); outline-offset: -2px; }
|
|
4381
|
+
.gb-leaderboard-rank { width: 20px; flex: 0 0 auto; text-align: center; font-size: 15px; line-height: 1; }
|
|
4382
|
+
.gb-leaderboard-rank.is-number { font-size: 12px; font-weight: 700; color: var(--gemboss-text-muted); font-variant-numeric: tabular-nums; }
|
|
4383
|
+
.gb-leaderboard-name { flex: 1 1 auto; min-width: 0; font-size: 13px; font-weight: 560; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
4384
|
+
.gb-leaderboard-value { flex: 0 0 auto; font-size: 13px; font-weight: 650; color: var(--gemboss-text-muted); font-variant-numeric: tabular-nums; }
|
|
4385
|
+
.gb-leaderboard-row.is-first .gb-leaderboard-value { color: #8a5a00; font-weight: 750; }
|
|
4386
|
+
.gb-leaderboard-all { justify-self: center; display: inline-flex; align-items: center; min-height: 24px; font-size: 12px; font-weight: 600;
|
|
4387
|
+
color: var(--gemboss-text-muted); text-decoration: none; }
|
|
4388
|
+
.gb-leaderboard-all:hover { color: var(--gemboss-text-strong); text-decoration: underline; }
|
|
4389
|
+
|
|
4390
|
+
/* ── Podium: second · first · third, the first raised. */
|
|
4391
|
+
.gb-podium { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; align-items: end; }
|
|
4392
|
+
.gb-podium-link { display: grid; justify-items: center; gap: 6px; padding: 14px 8px; border: 1px solid var(--gemboss-divider); border-radius: 12px;
|
|
4393
|
+
background: var(--gemboss-surface); color: var(--gemboss-text); text-decoration: none; text-align: center; }
|
|
4394
|
+
a.gb-podium-link:hover { background: var(--gemboss-hover); }
|
|
4395
|
+
.gb-podium-slot { min-width: 0; }
|
|
4396
|
+
.gb-podium-slot--1 .gb-podium-link { padding-top: 22px; padding-bottom: 20px; border-color: color-mix(in srgb, #d4a017 45%, var(--gemboss-divider)); }
|
|
4397
|
+
.gb-podium-slot--2 .gb-podium-link { padding-bottom: 12px; }
|
|
4398
|
+
.gb-podium-slot--3 .gb-podium-link { padding-bottom: 8px; }
|
|
4399
|
+
.gb-podium-medal { font-size: 22px; line-height: 1; }
|
|
4400
|
+
.gb-podium-name { max-width: 100%; font-size: 13px; font-weight: 620; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
4401
|
+
.gb-podium-value { font-size: 15px; font-weight: 750; font-variant-numeric: tabular-nums; }
|
|
4402
|
+
.gb-podium-slot--1 .gb-podium-value { color: #8a5a00; }
|
|
4403
|
+
.gb-podium-unit { font-size: 11px; font-weight: 500; color: var(--gemboss-text-muted); }
|
|
4404
|
+
|
|
4405
|
+
/* ── SearchDialog: the topbar's search button, and the popup (640 wide, near the top, like Featurebase's). */
|
|
4406
|
+
.gb-search-trigger, .gb-theme-toggle { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; flex: 0 0 auto;
|
|
4407
|
+
border: 0; border-radius: 8px; background: transparent; color: var(--gemboss-text-muted); cursor: pointer; }
|
|
4408
|
+
.gb-search-trigger:hover, .gb-theme-toggle:hover { background: var(--gemboss-hover); color: var(--gemboss-text-strong); }
|
|
4409
|
+
.gb-search-trigger:focus-visible, .gb-theme-toggle:focus-visible { outline: 2px solid var(--gemboss-accent); outline-offset: -2px; }
|
|
4410
|
+
@media (pointer: coarse), (max-width: 760px) { .gb-search-trigger, .gb-theme-toggle { width: 44px; height: 44px; } }
|
|
4411
|
+
dialog.ui-modal.gb-search { width: min(640px, 100%); margin-top: 8vh; max-height: min(560px, calc(100dvh - 16vh)); }
|
|
4412
|
+
.gb-search-box { position: relative; display: flex; align-items: center; border-bottom: 1px solid var(--ui-border); }
|
|
4413
|
+
.gb-search-icon { position: absolute; left: 18px; color: var(--ui-text-muted); pointer-events: none; }
|
|
4414
|
+
.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);
|
|
4415
|
+
font: inherit; font-size: 15px; outline: none; }
|
|
4416
|
+
.gb-search-input::-webkit-search-cancel-button { display: none; }
|
|
4417
|
+
.gb-search-results { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 8px; }
|
|
4418
|
+
.gb-search-section + .gb-search-section { margin-top: 8px; }
|
|
4419
|
+
.gb-search-section-title { padding: 6px 10px; font-size: 12px; font-weight: 600; color: var(--ui-text-muted); }
|
|
4420
|
+
.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; }
|
|
4421
|
+
.gb-search-item.is-active { background: var(--ui-surface-2); }
|
|
4422
|
+
.gb-search-item-title { font-size: 14px; font-weight: 520; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
4423
|
+
.gb-search-item-meta { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--ui-text-muted); }
|
|
4424
|
+
.gb-search-empty { margin: 0; padding: 18px 18px 0; font-size: 13px; color: var(--ui-text-muted); }
|
|
4425
|
+
.gb-search-empty:empty { padding: 0; }
|
|
4426
|
+
.gb-search-foot { display: flex; gap: 16px; justify-content: flex-end; padding: 8px 14px; border-top: 1px solid var(--ui-border);
|
|
4427
|
+
font-size: 12px; color: var(--ui-text-muted); }
|
|
4428
|
+
@media (max-width: 760px) { dialog.ui-modal.gb-search { margin-top: 12px; } .gb-search-foot { display: none; } }
|
|
4429
|
+
|
|
4430
|
+
/* ── Dark palette. `ThemeToggle` / `themeScript` set data-theme on <html>; a surface without them stays
|
|
4431
|
+
light. Only colours change: the components above and every kit class read these tokens. */
|
|
4432
|
+
:root[data-theme="dark"] {
|
|
4433
|
+
color-scheme: dark;
|
|
4434
|
+
--gemboss-primary: #f2f3f5;
|
|
4435
|
+
--gemboss-primary-dark: #ffffff;
|
|
4436
|
+
--gemboss-primary-soft: #23242a;
|
|
4437
|
+
--gemboss-on-primary: #0d0d12;
|
|
4438
|
+
--gemboss-accent: #7c84ff;
|
|
4439
|
+
--gemboss-accent-strong: #a3a9ff;
|
|
4440
|
+
--gemboss-accent-soft: #1f2140;
|
|
4441
|
+
--gemboss-text-strong: #f2f3f5;
|
|
4442
|
+
--gemboss-text: #e3e4e8;
|
|
4443
|
+
--gemboss-text-muted: #9a9ca6;
|
|
4444
|
+
--gemboss-surface: #16171d;
|
|
4445
|
+
--gemboss-canvas: #0d0d12;
|
|
4446
|
+
--gemboss-app-bg: #0d0d12;
|
|
4447
|
+
--gemboss-rail-bg: #0d0d12;
|
|
4448
|
+
--gemboss-panel-bg: #16171d;
|
|
4449
|
+
--gemboss-topbar-bg: rgba(13, 13, 18, 0.96);
|
|
4450
|
+
--gemboss-nav-bg: #0d0d12;
|
|
4451
|
+
--gemboss-nav-hover: rgba(255, 255, 255, 0.06);
|
|
4452
|
+
--gemboss-divider: #2a2b33;
|
|
4453
|
+
--gemboss-divider-soft: #22232a;
|
|
4454
|
+
--gemboss-hover: #22232b;
|
|
4455
|
+
--gemboss-active-bg: #262833;
|
|
4456
|
+
--gemboss-active-fg: #f2f3f5;
|
|
4457
|
+
--gemboss-field-bg: #121318;
|
|
4458
|
+
--gemboss-field-bg-hover: #17181e;
|
|
4459
|
+
--gemboss-field-ink: #e3e4e8;
|
|
4460
|
+
--gemboss-field-placeholder: #8a8c96;
|
|
4461
|
+
--gemboss-field-line: #3a3c46;
|
|
4462
|
+
--gemboss-field-line-hover: #50525e;
|
|
4463
|
+
--gemboss-field-line-active: #c9cbd3;
|
|
4464
|
+
--gemboss-chip-ink: #d6d8de;
|
|
4465
|
+
--gemboss-chip-line: #3a3c46;
|
|
4466
|
+
--gemboss-chip-line-hover: #50525e;
|
|
4467
|
+
--gemboss-chip-bg-hover: #22232b;
|
|
4468
|
+
--gemboss-success: #34c38f;
|
|
4469
|
+
--gemboss-success-strong: #5fd6a8;
|
|
4470
|
+
--gemboss-success-soft: #13291f;
|
|
4471
|
+
--gemboss-success-border: #22533e;
|
|
4472
|
+
--gemboss-warning: #e0b341;
|
|
4473
|
+
--gemboss-warning-soft: #2b2412;
|
|
4474
|
+
--gemboss-warning-border: #5a4a1d;
|
|
4475
|
+
--gemboss-danger: #ff6b5a;
|
|
4476
|
+
--gemboss-danger-strong: #ff8a7c;
|
|
4477
|
+
--gemboss-danger-soft: #2e1714;
|
|
4478
|
+
--gemboss-danger-border: #5c2a24;
|
|
4479
|
+
--ui-accent-fg: #0d0d12;
|
|
4480
|
+
--ui-warning-fg: #e8c063;
|
|
4481
|
+
--gb-brand-ink: #f2f3f5;
|
|
4482
|
+
}
|
|
4483
|
+
/* Avatar initials in dark: the tone's light end, so the text clears 4.5:1 on its tinted disc. */
|
|
4484
|
+
: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); }
|
|
4485
|
+
:root[data-theme="dark"] body { background: var(--gemboss-app-bg); color: var(--gemboss-text); }
|
|
4486
|
+
:root[data-theme="dark"] .gb-leaderboard-row.is-first .gb-leaderboard-value,
|
|
4487
|
+
:root[data-theme="dark"] .gb-podium-slot--1 .gb-podium-value { color: #e0b341; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemboss/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "GemBoss design system for every surface in the fleet: the admin shell (topbar + rail + drawer) as React components, and the admin stylesheet exported as one CSS file.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
package/src/account.tsx
CHANGED
|
@@ -14,12 +14,10 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import React, { useEffect, useRef } from "react";
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return letters.toUpperCase();
|
|
22
|
-
}
|
|
17
|
+
import { initialsOf } from "./initials";
|
|
18
|
+
|
|
19
|
+
// Kept exported from here too: it was the public name before initials.ts existed.
|
|
20
|
+
export { initialsOf };
|
|
23
21
|
|
|
24
22
|
export function AccountMenu({ name, email, role, initials, label, children }: {
|
|
25
23
|
name: string;
|
package/src/assistant.css
CHANGED
|
@@ -396,7 +396,7 @@
|
|
|
396
396
|
padding: 0 6px;
|
|
397
397
|
border-radius: var(--gemboss-radius-full);
|
|
398
398
|
background: var(--gemboss-primary);
|
|
399
|
-
color: #fff;
|
|
399
|
+
color: var(--gemboss-on-primary, #fff);
|
|
400
400
|
font-size: 11px;
|
|
401
401
|
font-weight: 700;
|
|
402
402
|
line-height: 18px;
|
|
@@ -520,14 +520,17 @@
|
|
|
520
520
|
margin: 8px 0 4px;
|
|
521
521
|
padding: 0 12px;
|
|
522
522
|
border-radius: var(--gemboss-radius-md, 8px);
|
|
523
|
-
|
|
524
|
-
|
|
523
|
+
/* The SAME iris pill as the admin's rail Assistant (.gnav-item.gnav-assistant, 0.6.8), not a solid near-black button
|
|
524
|
+
(0.7.1, 04/10/2026): content-radar and partner-radar open their assistant through this launcher, and after 0.6.8 the
|
|
525
|
+
two drew the one AI front door in two different colours beside the admin. */
|
|
526
|
+
background: var(--gemboss-accent-soft);
|
|
527
|
+
color: var(--gemboss-accent-strong);
|
|
525
528
|
font-size: 13px;
|
|
526
529
|
font-weight: 600;
|
|
527
530
|
line-height: 1;
|
|
528
531
|
text-align: left;
|
|
529
532
|
}
|
|
530
|
-
.gb-assistant-launcher--rail:hover { background: var(--gemboss-
|
|
533
|
+
.gb-assistant-launcher--rail:hover { background: color-mix(in srgb, var(--gemboss-accent) 16%, var(--gemboss-surface)); }
|
|
531
534
|
.gb-assistant-launcher--rail .gb-assistant-launcher-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
532
535
|
.gb-assistant-launcher-kbd {
|
|
533
536
|
font: inherit; font-size: 11px; opacity: 0.7;
|
package/src/chat.css
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
font-size: var(--gemboss-fs-body); line-height: 1.5; padding: 12px 12px; border-radius: 16px; max-width: min(90%, 68ch);
|
|
44
44
|
white-space: pre-wrap; word-break: break-word;
|
|
45
45
|
}
|
|
46
|
-
.gemboss-ec-msg.user .gemboss-ec-bubble { background: var(--gemboss-primary); color: #fff; border-bottom-right-radius: 4px; }
|
|
46
|
+
.gemboss-ec-msg.user .gemboss-ec-bubble { background: var(--gemboss-primary); color: var(--gemboss-on-primary, #fff); border-bottom-right-radius: 4px; }
|
|
47
47
|
.gemboss-ec-msg.assistant .gemboss-ec-bubble { background: var(--gemboss-surface); color: var(--gemboss-text); border: 1px solid var(--gemboss-divider); border-bottom-left-radius: 4px; }
|
|
48
48
|
.gemboss-ec-input button[type="submit"] svg { width: 18px; height: 18px; fill: currentColor; }
|
|
49
49
|
/* Live "working" heartbeat — the Claude-Code-style running asterisk shown before the first token.
|
|
@@ -143,7 +143,7 @@
|
|
|
143
143
|
.gemboss-ec-input button[type="submit"] {
|
|
144
144
|
flex: 0 0 auto; width: 44px; height: 44px; min-width: 44px; min-height: 44px; border-radius: 50%; border: 0; cursor: pointer;
|
|
145
145
|
display: inline-flex; align-items: center; justify-content: center;
|
|
146
|
-
background: var(--gemboss-primary); color: #fff; font-size: 16px; font-weight: 700;
|
|
146
|
+
background: var(--gemboss-primary); color: var(--gemboss-on-primary, #fff); font-size: 16px; font-weight: 700;
|
|
147
147
|
transition: background var(--gemboss-transition-fast) ease, filter var(--gemboss-transition-fast) ease;
|
|
148
148
|
}
|
|
149
149
|
.gemboss-ec-input button[type="submit"]:hover:not(:disabled) { background: var(--gemboss-primary-dark); }
|
package/src/dialog.tsx
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Popups for React surfaces — @gemboss/ui (04/10/2026).
|
|
5
|
+
*
|
|
6
|
+
* `Dialog`: the kit's native `<dialog class="ui-modal">` (kit-plain.css) driven by `open`. The browser
|
|
7
|
+
* gives Escape, the inert page and the top layer; this adds what it lacks: a click on the backdrop
|
|
8
|
+
* closes, focus goes back to whatever opened it, and `onClose` hears every way it closed. GemFeedback's
|
|
9
|
+
* sign-in and "Create a new post" open like this (Featurebase does both as popups: Chris 04/10/2026,
|
|
10
|
+
* "signup login là bật ra popup đó nhanh và tiện hơn").
|
|
11
|
+
*
|
|
12
|
+
* `SearchDialog`: the Featurebase / admin command search. A topbar button (or ⌘K / Ctrl+K / "/")
|
|
13
|
+
* opens it; what to show before typing comes from `initial`, results while typing from `search`
|
|
14
|
+
* (debounced, the latest answer wins); ↑ ↓ move, Enter opens, Esc closes. Results are links, so a
|
|
15
|
+
* page without JavaScript loses only the popup, never the search itself (`fallbackHref`).
|
|
16
|
+
*/
|
|
17
|
+
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
18
|
+
import { Icon } from "./icons";
|
|
19
|
+
|
|
20
|
+
const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
|
|
21
|
+
|
|
22
|
+
export function Dialog({ open, onClose, title, children, footer, wide = false, closeLabel = "Close", className }: {
|
|
23
|
+
open: boolean;
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
title: React.ReactNode;
|
|
26
|
+
children?: React.ReactNode;
|
|
27
|
+
footer?: React.ReactNode;
|
|
28
|
+
wide?: boolean;
|
|
29
|
+
closeLabel?: string;
|
|
30
|
+
className?: string;
|
|
31
|
+
}) {
|
|
32
|
+
const ref = useRef<HTMLDialogElement>(null);
|
|
33
|
+
const opener = useRef<Element | null>(null);
|
|
34
|
+
const titleId = useId();
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const d = ref.current;
|
|
37
|
+
if (!d) return;
|
|
38
|
+
if (open && !d.open) { opener.current = document.activeElement; d.showModal(); }
|
|
39
|
+
if (!open && d.open) d.close();
|
|
40
|
+
}, [open]);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const d = ref.current;
|
|
43
|
+
if (!d) return undefined;
|
|
44
|
+
const onNativeClose = () => {
|
|
45
|
+
onClose();
|
|
46
|
+
(opener.current as HTMLElement | null)?.focus?.();
|
|
47
|
+
};
|
|
48
|
+
d.addEventListener("close", onNativeClose);
|
|
49
|
+
return () => d.removeEventListener("close", onNativeClose);
|
|
50
|
+
}, [onClose]);
|
|
51
|
+
return (
|
|
52
|
+
<dialog ref={ref} className={cx("ui-modal", wide && "ui-modal--wide", className)} aria-labelledby={titleId}
|
|
53
|
+
// A click whose target IS the dialog landed on the backdrop, outside the panel.
|
|
54
|
+
onClick={(e) => { if (e.target === e.currentTarget) e.currentTarget.close(); }}>
|
|
55
|
+
<div className="ui-modal__head">
|
|
56
|
+
<div className="ui-modal__title" id={titleId}>{title}</div>
|
|
57
|
+
<button type="button" className="ui-modal__close" aria-label={closeLabel} onClick={() => ref.current?.close()}>
|
|
58
|
+
<Icon name="close" size={20} />
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
<div className="ui-modal__body">{children}</div>
|
|
62
|
+
{footer ? <div className="ui-modal__foot">{footer}</div> : null}
|
|
63
|
+
</dialog>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type SearchItem = { href: string; title: string; meta?: React.ReactNode };
|
|
68
|
+
export type SearchSection = { title: string; items: SearchItem[] };
|
|
69
|
+
|
|
70
|
+
// One constant, not a default `= []`: a fresh array each render, read by an effect, re-ran it forever
|
|
71
|
+
// ("Maximum update depth exceeded", review of 0.7.0 in Chromium).
|
|
72
|
+
const NO_SECTIONS: SearchSection[] = [];
|
|
73
|
+
|
|
74
|
+
export function SearchDialog({ search, initial = NO_SECTIONS, placeholder = "Search…", label = "Search", fallbackHref, shortcut = true, renderItem }: {
|
|
75
|
+
/** Results for a query. Called at most every 150 ms; an answer to an older query is dropped. */
|
|
76
|
+
search: (q: string) => Promise<SearchSection[]>;
|
|
77
|
+
/** What the popup shows before anything is typed (Featurebase: "Trending requests", "Latest updates"). */
|
|
78
|
+
initial?: SearchSection[];
|
|
79
|
+
placeholder?: string;
|
|
80
|
+
label?: string;
|
|
81
|
+
/** Where the topbar button goes without JavaScript (a search page), used as given. */
|
|
82
|
+
fallbackHref?: string;
|
|
83
|
+
/** ⌘K / Ctrl+K and "/" open it. */
|
|
84
|
+
shortcut?: boolean;
|
|
85
|
+
renderItem?: (item: SearchItem) => React.ReactNode;
|
|
86
|
+
}) {
|
|
87
|
+
const [open, setOpen] = useState(false);
|
|
88
|
+
const [q, setQ] = useState("");
|
|
89
|
+
const [sections, setSections] = useState<SearchSection[]>(initial);
|
|
90
|
+
const [active, setActive] = useState(0);
|
|
91
|
+
const [busy, setBusy] = useState(false);
|
|
92
|
+
const ref = useRef<HTMLDialogElement>(null);
|
|
93
|
+
const input = useRef<HTMLInputElement>(null);
|
|
94
|
+
const seq = useRef(0);
|
|
95
|
+
const listId = useId();
|
|
96
|
+
const flat = sections.flatMap((s) => s.items);
|
|
97
|
+
// The latest `search` and `initial`, read when needed: a consumer's inline arrow or array (a new one
|
|
98
|
+
// each render) must not restart the debounce or the effect.
|
|
99
|
+
const searchRef = useRef(search);
|
|
100
|
+
const initialRef = useRef(initial);
|
|
101
|
+
searchRef.current = search;
|
|
102
|
+
initialRef.current = initial;
|
|
103
|
+
|
|
104
|
+
const show = useCallback(() => {
|
|
105
|
+
const d = ref.current;
|
|
106
|
+
if (d && !d.open) { d.showModal(); setOpen(true); requestAnimationFrame(() => input.current?.focus()); }
|
|
107
|
+
}, []);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!shortcut) return undefined;
|
|
110
|
+
const onKey = (e: KeyboardEvent) => {
|
|
111
|
+
const t = e.target as HTMLElement | null;
|
|
112
|
+
const typing = !!t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
|
|
113
|
+
// A handler that already took the key (the admin's own ⌘K palette) keeps it.
|
|
114
|
+
if (e.defaultPrevented) return;
|
|
115
|
+
if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || (e.key === "/" && !typing)) { e.preventDefault(); show(); }
|
|
116
|
+
};
|
|
117
|
+
document.addEventListener("keydown", onKey);
|
|
118
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
119
|
+
}, [shortcut, show]);
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!open) return undefined;
|
|
122
|
+
const query = q.trim();
|
|
123
|
+
// Every change takes a new number: an answer still on its way for an older query is dropped,
|
|
124
|
+
// including when the box was just cleared (it used to land under an empty box).
|
|
125
|
+
const mine = ++seq.current;
|
|
126
|
+
if (!query) { setSections(initialRef.current); setActive(0); setBusy(false); return undefined; }
|
|
127
|
+
setBusy(true);
|
|
128
|
+
const t = setTimeout(async () => {
|
|
129
|
+
try {
|
|
130
|
+
const res = await searchRef.current(query);
|
|
131
|
+
if (mine === seq.current) { setSections(res); setActive(0); }
|
|
132
|
+
} catch {
|
|
133
|
+
if (mine === seq.current) setSections(NO_SECTIONS);
|
|
134
|
+
} finally {
|
|
135
|
+
if (mine === seq.current) setBusy(false);
|
|
136
|
+
}
|
|
137
|
+
}, 150);
|
|
138
|
+
return () => { clearTimeout(t); if (mine === seq.current) setBusy(false); };
|
|
139
|
+
}, [q, open]);
|
|
140
|
+
// Before anything is typed, show the latest suggestions the page passed.
|
|
141
|
+
useEffect(() => { if (open && !q.trim()) setSections(initial); }, [initial, open, q]);
|
|
142
|
+
// The highlighted result stays in view as the arrows move it.
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (!open) return;
|
|
145
|
+
document.getElementById(`${listId}-${active}`)?.scrollIntoView({ block: "nearest" });
|
|
146
|
+
}, [active, open, listId]);
|
|
147
|
+
|
|
148
|
+
const go = (item: SearchItem | undefined) => { if (item) window.location.assign(item.href); };
|
|
149
|
+
let n = -1;
|
|
150
|
+
return (
|
|
151
|
+
<>
|
|
152
|
+
<a className="gb-search-trigger" href={fallbackHref ?? "#"} aria-label={label} aria-haspopup="dialog"
|
|
153
|
+
onClick={(e) => { e.preventDefault(); show(); }}>
|
|
154
|
+
<Icon name="search" size={18} />
|
|
155
|
+
</a>
|
|
156
|
+
<dialog ref={ref} className="ui-modal gb-search" aria-label={label}
|
|
157
|
+
onClose={() => { setOpen(false); setQ(""); }}
|
|
158
|
+
onClick={(e) => { if (e.target === e.currentTarget) e.currentTarget.close(); }}>
|
|
159
|
+
<div className="gb-search-box">
|
|
160
|
+
<Icon name="search" size={18} className="gb-search-icon" />
|
|
161
|
+
<input ref={input} className="gb-search-input" type="search" placeholder={placeholder} aria-label={label}
|
|
162
|
+
value={q} onChange={(e) => setQ(e.target.value)} role="combobox" aria-expanded="true" aria-controls={listId}
|
|
163
|
+
aria-activedescendant={flat[active] ? `${listId}-${active}` : undefined} autoComplete="off"
|
|
164
|
+
onKeyDown={(e) => {
|
|
165
|
+
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, flat.length - 1)); }
|
|
166
|
+
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
|
|
167
|
+
// Enter that confirms an IME composition (Vietnamese, Chinese) is not "open".
|
|
168
|
+
else if (e.key === "Enter" && !e.nativeEvent.isComposing && e.keyCode !== 229) { e.preventDefault(); go(flat[active]); }
|
|
169
|
+
}} />
|
|
170
|
+
</div>
|
|
171
|
+
<p className="gb-search-empty" aria-live="polite">
|
|
172
|
+
{sections.every((s) => !s.items.length) && q.trim() ? (busy ? "Searching…" : "Nothing matches.") : ""}
|
|
173
|
+
</p>
|
|
174
|
+
<div className="gb-search-results" id={listId} role="listbox" aria-busy={busy}>
|
|
175
|
+
{sections.filter((s) => s.items.length).map((s) => (
|
|
176
|
+
<div key={s.title} className="gb-search-section" role="group" aria-label={s.title}>
|
|
177
|
+
<div className="gb-search-section-title">{s.title}</div>
|
|
178
|
+
{s.items.map((it) => {
|
|
179
|
+
n += 1;
|
|
180
|
+
const i = n;
|
|
181
|
+
return (
|
|
182
|
+
<a key={`${s.title}-${it.href}`} id={`${listId}-${i}`} href={it.href} role="option" aria-selected={i === active}
|
|
183
|
+
className={cx("gb-search-item", i === active && "is-active")} onMouseMove={() => setActive(i)}>
|
|
184
|
+
{renderItem ? renderItem(it) : (<><span className="gb-search-item-title">{it.title}</span>{it.meta ? <span className="gb-search-item-meta">{it.meta}</span> : null}</>)}
|
|
185
|
+
</a>
|
|
186
|
+
);
|
|
187
|
+
})}
|
|
188
|
+
</div>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
<div className="gb-search-foot" aria-hidden="true"><span>↵ open</span><span>↑↓ move</span><span>esc</span></div>
|
|
192
|
+
</dialog>
|
|
193
|
+
</>
|
|
194
|
+
);
|
|
195
|
+
}
|
package/src/icons.tsx
CHANGED
|
@@ -46,6 +46,8 @@ const P = {
|
|
|
46
46
|
calendar: <><rect x="3" y="4.5" width="14" height="12.5" rx="1.8" /><path d="M3 8.5h14M7 3v3M13 3v3" /></>,
|
|
47
47
|
clock: <><circle cx="10" cy="10" r="7" /><path d="M10 6v4l2.8 1.8" /></>,
|
|
48
48
|
search: <><circle cx="8.8" cy="8.8" r="5.3" /><path d="M12.8 12.8 17 17" /></>,
|
|
49
|
+
moon: <path d="M15.6 12.4A6.2 6.2 0 0 1 7.6 4.4a6.2 6.2 0 1 0 8 8Z" />,
|
|
50
|
+
sun: <><circle cx="10" cy="10" r="3.2" /><path d="M10 2.6v1.8M10 15.6v1.8M2.6 10h1.8M15.6 10h1.8M4.8 4.8l1.3 1.3M13.9 13.9l1.3 1.3M4.8 15.2l1.3-1.3M13.9 6.1l1.3-1.3" /></>,
|
|
49
51
|
link: <><path d="M8.6 11.4a3 3 0 0 0 4.2 0l2.6-2.6a3 3 0 0 0-4.2-4.2l-1 1" /><path d="M11.4 8.6a3 3 0 0 0-4.2 0l-2.6 2.6a3 3 0 0 0 4.2 4.2l1-1" /></>,
|
|
50
52
|
sliders: <><path d="M3 5h14M3 10h14M3 15h14" /><circle cx="7" cy="5" r="1.6" /><circle cx="13" cy="10" r="1.6" /><circle cx="9" cy="15" r="1.6" /></>,
|
|
51
53
|
settings: <><path d="M15.52 9.08 17.15 9.15 17.15 10.85 15.52 10.92 14.56 13.26 15.66 14.45 14.45 15.66 13.26 14.56 10.92 15.52 10.85 17.15 9.15 17.15 9.08 15.52 6.74 14.56 5.55 15.66 4.34 14.45 5.44 13.26 4.48 10.92 2.85 10.85 2.85 9.15 4.48 9.08 5.44 6.74 4.34 5.55 5.55 4.34 6.74 5.44 9.08 4.48 9.15 2.85 10.85 2.85 10.92 4.48 13.26 5.44 14.45 4.34 15.66 5.55 14.56 6.74Z" /><circle cx="10" cy="10" r="2.4" /></>,
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,12 @@ export { GemMark } from "./GemMark";
|
|
|
6
6
|
export { AuthError, AuthField, AuthFoot, AuthForm, AuthHelp, AuthLayout, PasswordField, AuthSubmit } from "./auth";
|
|
7
7
|
export { BrandLockup } from "./brand";
|
|
8
8
|
export { TopbarSearch } from "./search";
|
|
9
|
-
export { AccountMenu, AccountMenuItem
|
|
9
|
+
export { AccountMenu, AccountMenuItem } from "./account";
|
|
10
|
+
// From the plain module, not account.tsx ("use client"): a server component can call it.
|
|
11
|
+
export { initialsOf } from "./initials";
|
|
10
12
|
export { Menu, MenuItem, MenuSeparator } from "./menu";
|
|
11
13
|
export { Icon, ICON_NAMES, type IconName } from "./icons";
|
|
12
14
|
export { AssistantLauncher } from "./launcher";
|
|
15
|
+
export { Avatar, avatarTone, Leaderboard, Podium, TopbarNav, type AvatarSize, type LeaderRow, type TopbarNavItem } from "./portal";
|
|
16
|
+
export { Dialog, SearchDialog, type SearchItem, type SearchSection } from "./dialog";
|
|
17
|
+
export { ThemeToggle, THEME_KEY, themeScript } from "./theme";
|
package/src/initials.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A person's initials ("Minh Anh" → "MA", "Seller" → "SE"). Plain module, not "use client": server
|
|
3
|
+
* components (Avatar, Leaderboard) call it too. GemFeedback hit "Attempted to call initialsOf() from
|
|
4
|
+
* the server" when it lived only in account.tsx, a client module (03/10/2026).
|
|
5
|
+
*/
|
|
6
|
+
export function initialsOf(name: string): string {
|
|
7
|
+
// Words that start with a letter: "Seller 219" is "SE", not "S2" (GemFeedback's pseudonyms, 04/10/2026).
|
|
8
|
+
const all = name.trim().split(/\s+/).filter(Boolean);
|
|
9
|
+
const lettered = all.filter((w) => /^\p{L}/u.test(w));
|
|
10
|
+
const words = lettered.length ? lettered : all;
|
|
11
|
+
if (!words.length) return "?";
|
|
12
|
+
const letters = words.length === 1 ? words[0].slice(0, 2) : words[0][0] + words[words.length - 1][0];
|
|
13
|
+
return letters.toUpperCase();
|
|
14
|
+
}
|