@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/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: d82cf2553a585b741231a905e8845db7353c4726
5
- * sources sha256: 67e1eb62e369a8fedf0d081820f8dffd9fd39801df6ca597060851c99cec3b42
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
- background: var(--gemboss-primary, #1b1c1e);
3279
- color: var(--gemboss-on-primary, #fff);
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-primary-dark, #000); }
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.6.9",
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
- export function initialsOf(name: string): string {
18
- const words = name.trim().split(/\s+/).filter(Boolean);
19
- if (!words.length) return "?";
20
- const letters = words.length === 1 ? words[0].slice(0, 2) : words[0][0] + words[words.length - 1][0];
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
- background: var(--gemboss-primary, #1b1c1e);
524
- color: var(--gemboss-on-primary, #fff);
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-primary-dark, #000); }
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, initialsOf } from "./account";
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";
@@ -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
+ }