@gemboss/ui 0.1.1 → 0.2.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 +54 -0
- package/catalog/index.html +142 -11
- package/gemboss-ui.css +257 -19
- package/package.json +4 -2
- package/src/gemboss-field.css +4 -0
- package/src/icons.tsx +1 -0
- package/src/index.ts +1 -0
- package/src/kit-plain.css +55 -0
- package/src/menu.tsx +64 -0
- package/src/shell.css +8 -4
- package/src/ui.css +186 -13
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: be1da9b1254eb62cd68a2e65f026c94b4d7258cf
|
|
5
|
+
* sources sha256: d4f46aabf4df141636ddacc91c119762a521312a30154ca0e6d82412cb99491c
|
|
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
|
* + 180 rules from packages/ui/src/shell.css (the shared shell)
|
|
@@ -214,6 +214,10 @@
|
|
|
214
214
|
--ui-accent-soft: var(--gemboss-active-bg, #f0f1f4); /* tinted bg for a selected/active state */
|
|
215
215
|
--ui-accent-strong: var(--gemboss-active-fg, #1b1c1e); /* readable accent text ON --ui-accent-soft */
|
|
216
216
|
--ui-text-strong: var(--gemboss-text-strong, #1b1c1e);
|
|
217
|
+
/* Chữ theo tông (ui-metric, …). Nền tối của admin ánh xạ lại bên dưới để còn đọc được. */
|
|
218
|
+
--ui-success-fg: var(--gemboss-success);
|
|
219
|
+
--ui-warning-fg: var(--gemboss-primary-dark);
|
|
220
|
+
--ui-danger-fg: var(--gemboss-danger-strong);
|
|
217
221
|
--ui-focus: var(--gemboss-primary, #1b1c1e);
|
|
218
222
|
--ui-radius-sm: 6px;
|
|
219
223
|
--ui-radius-md: 8px;
|
|
@@ -273,6 +277,10 @@
|
|
|
273
277
|
--ui-accent-soft: var(--adm-accent-soft);
|
|
274
278
|
--ui-accent-strong: var(--adm-accent-fg);
|
|
275
279
|
--ui-text-strong: var(--adm-text-strong);
|
|
280
|
+
/* Tông trên nền tối #16161a: pha 70% với trắng ⇒ tương phản 6,2 / 9,5 / 4,8 (đo 01/10/2026, review #3609). */
|
|
281
|
+
--ui-success-fg: color-mix(in srgb, var(--gemboss-success) 70%, #ffffff);
|
|
282
|
+
--ui-warning-fg: color-mix(in srgb, #c99a2e 70%, #ffffff);
|
|
283
|
+
--ui-danger-fg: color-mix(in srgb, var(--gemboss-danger-strong) 70%, #ffffff);
|
|
276
284
|
--ui-focus: var(--adm-accent);
|
|
277
285
|
}
|
|
278
286
|
|
|
@@ -303,6 +311,11 @@
|
|
|
303
311
|
padding: var(--ui-space-4); color: var(--ui-text);
|
|
304
312
|
}
|
|
305
313
|
.ui-card__title { font-size: 14px; font-weight: 600; margin: 0 0 var(--ui-space-2); }
|
|
314
|
+
/* Đầu thẻ có nút: `<div class="ui-card__head"><h2 class="ui-card__title">…</h2><button class="ui-btn">…</button></div>`.
|
|
315
|
+
Tiêu đề trái, hành động phải, xuống dòng khi hẹp (01/10/2026: partner-radar rel-head / agr). */
|
|
316
|
+
.ui-card__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--ui-space-2); margin-bottom: var(--ui-space-2); }
|
|
317
|
+
.ui-card__head > .ui-card__title { margin: 0; }
|
|
318
|
+
.ui-card__head > :last-child:not(.ui-card__title) { display: flex; flex-wrap: wrap; gap: var(--ui-space-2); }
|
|
306
319
|
|
|
307
320
|
/* ── Select ──────────────────────────────────────────────────────────────────
|
|
308
321
|
Native <select> reskinned; custom caret as an inline SVG data-URI (caret color
|
|
@@ -324,7 +337,7 @@
|
|
|
324
337
|
background-color: var(--gemboss-field-bg-hover);
|
|
325
338
|
}
|
|
326
339
|
.ui-select:focus { outline: none; }
|
|
327
|
-
.ui-select:focus-visible {
|
|
340
|
+
.ui-select:focus-visible:not(:disabled) {
|
|
328
341
|
outline: 2px solid #005bd3; outline-offset: 1px;
|
|
329
342
|
border-color: var(--gemboss-field-line-active);
|
|
330
343
|
}
|
|
@@ -347,6 +360,85 @@
|
|
|
347
360
|
}
|
|
348
361
|
.ui-tag--chip.ui-tag--accent { color: var(--adm-accent-fg, var(--ui-accent)); border-color: var(--ui-accent); background: transparent; }
|
|
349
362
|
|
|
363
|
+
/* Danh sách link dạng chip: `<div class="ui-chips"><a class="ui-tag ui-tag--chip" href>…</a>…</div>` (01/10/2026,
|
|
364
|
+
partner-radar /competitors: "đã tích hợp với GemPages"). `.ui-tag--chip` vốn là nhãn TĨNH cao ~19px, dưới mức 24px
|
|
365
|
+
của link trong danh sách (WCAG 2.5.8, §9.1) — visual-gate đo đúng chỗ đó đỏ ở 320 / 390 / 768. Chip là link thì cao
|
|
366
|
+
≥ 28px, chữ thường (không mono), có hover + focus-visible; dưới ngón tay là điều khiển, lên 44px (khối coarse dưới).
|
|
367
|
+
Khe 8px: hai target nhỏ hơn 44px phải cách nhau ≥ 8px. */
|
|
368
|
+
.ui-chips { display: flex; flex-wrap: wrap; gap: var(--ui-space-2, 8px); margin: 0; padding: 0; list-style: none; }
|
|
369
|
+
.ui-chips > a.ui-tag--chip {
|
|
370
|
+
min-height: 28px; padding: 4px 12px; border-radius: var(--ui-radius-md, 8px);
|
|
371
|
+
font-family: inherit; font-size: var(--gemboss-fs-caption, 12px); color: var(--ui-text); text-decoration: none;
|
|
372
|
+
}
|
|
373
|
+
.ui-chips > a.ui-tag--chip:hover { background: var(--ui-surface-2); border-color: var(--ui-text-muted); }
|
|
374
|
+
.ui-chips > a.ui-tag--chip:focus-visible { outline: 2px solid #005bd3; outline-offset: 1px; }
|
|
375
|
+
/* Danh sách LỌC dài (chuyên môn, hạng…) dùng ui-chips thay ui-seg: ui-seg không xuống dòng nên tràn ngang ở 320px.
|
|
376
|
+
Ô đang chọn = `aria-current="page"` (lọc là một URL), cùng tông với ui-seg đang chọn. */
|
|
377
|
+
.ui-chips > a.ui-tag--chip[aria-current="page"] { background: var(--ui-accent-soft); border-color: var(--ui-accent); color: var(--ui-accent-strong); font-weight: 600; }
|
|
378
|
+
|
|
379
|
+
/* ── Tông màu cho tag: `ui-tag--success | --warning | --danger` (token ngữ nghĩa có sẵn). Trạng thái, nguồn,
|
|
380
|
+
kết quả kiểm — thay cho màu viết cứng ở từng surface. */
|
|
381
|
+
.ui-tag--success { background: var(--gemboss-success-soft); color: var(--gemboss-success-strong, var(--gemboss-success)); }
|
|
382
|
+
.ui-tag--warning { background: var(--gemboss-warning-soft); color: var(--gemboss-warning); }
|
|
383
|
+
.ui-tag--danger { background: var(--gemboss-danger-soft); color: var(--gemboss-danger-strong, var(--gemboss-danger)); }
|
|
384
|
+
|
|
385
|
+
/* ── Chữ phụ: `<p class="ui-muted">`, `<span class="ui-muted">` — mô tả, chú thích, dòng meta. Cỡ caption, màu muted.
|
|
386
|
+
Trước đây mỗi surface tự viết `.muted` (partner-radar: 180 chỗ). */
|
|
387
|
+
.ui-muted { color: var(--ui-text-muted); font-size: var(--gemboss-fs-caption); }
|
|
388
|
+
|
|
389
|
+
/* ── Tiêu đề trang: `<div class="ui-page-head"><h1>…</h1><p>…</p></div>`. Đưa lên NGUYÊN VĂN từ admin
|
|
390
|
+
`.gemboss-admin-page-head` (01/10/2026, Chris: một chủ sở hữu; admin dùng bản này). Cố ý không đặt margin cho h1
|
|
391
|
+
và không dùng flex: admin không reset margin tiêu đề, nên đổi ở đây là đổi khoảng cách của 14 trang admin. */
|
|
392
|
+
.ui-page-head { margin-bottom: 20px; }
|
|
393
|
+
.ui-page-head h1 { color: var(--ui-text-strong); font-size: 22px; font-weight: 650; letter-spacing: -0.01em; }
|
|
394
|
+
.ui-page-head p { color: var(--ui-text-muted); font-size: 14px; margin-top: 4px; }
|
|
395
|
+
|
|
396
|
+
/* ── Thanh công cụ: `<div class="ui-toolbar">` — ô tìm giãn, điều khiển bám phải, một hàng gọn, xuống dòng khi hẹp.
|
|
397
|
+
Đưa lên từ admin `.gemboss-admin-toolbar`. Chỉ BỐ CỤC: ô tìm là `gemboss-field gemboss-field--group`. */
|
|
398
|
+
.ui-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 20px; }
|
|
399
|
+
|
|
400
|
+
/* ── Ô số liệu: `<div class="ui-metrics"><div class="ui-metric"><span class="ui-metric__val">12</span>
|
|
401
|
+
<span class="ui-metric__lbl">Nhãn</span></div></div>`; tông `ui-metric--success | --warning | --danger`.
|
|
402
|
+
Đưa lên từ admin `.gemboss-ec-metric` (GemChat). Khác `ui-stat` (viên đếm nhỏ, có thể bấm làm bộ lọc). */
|
|
403
|
+
.ui-metrics { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0; }
|
|
404
|
+
.ui-metric { display: flex; flex-direction: column; gap: 1px; min-width: 78px; padding: 8px 12px; background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: 12px; }
|
|
405
|
+
/* Cả ô là link (`<a class="ui-metric" href>`): vùng chạm là cả ô (~52px), không phải con số 20px bên trong
|
|
406
|
+
(visual-gate đỏ ở partner-radar /ops 01/10/2026 khi link nằm trong __val). */
|
|
407
|
+
a.ui-metric { text-decoration: none; color: inherit; }
|
|
408
|
+
a.ui-metric:hover { border-color: var(--ui-text-muted); }
|
|
409
|
+
a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: 1px; }
|
|
410
|
+
.ui-metric__val { font-size: 17px; font-weight: 700; color: var(--ui-text); font-variant-numeric: tabular-nums; line-height: 1.15; }
|
|
411
|
+
.ui-metric__lbl { font-size: var(--gemboss-fs-caption); font-weight: 500; color: var(--ui-text-muted); }
|
|
412
|
+
.ui-metric--success { border-color: color-mix(in srgb, var(--gemboss-success) 40%, transparent); }
|
|
413
|
+
.ui-metric--success .ui-metric__val { color: var(--ui-success-fg); }
|
|
414
|
+
.ui-metric--warning { border-color: color-mix(in srgb, #c99a2e 40%, transparent); }
|
|
415
|
+
.ui-metric--warning .ui-metric__val { color: var(--ui-warning-fg); }
|
|
416
|
+
.ui-metric--danger { border-color: color-mix(in srgb, var(--gemboss-danger-strong) 40%, transparent); }
|
|
417
|
+
.ui-metric--danger .ui-metric__val { color: var(--ui-danger-fg); }
|
|
418
|
+
|
|
419
|
+
/* ── Cặp nhãn – giá trị: `<dl class="ui-dl"><div><dt>Quốc gia</dt><dd>IL</dd></div>…</dl>` — dữ kiện của một hồ sơ.
|
|
420
|
+
Lưới tự giãn: mỗi ô ≥ 160px, xuống dòng khi hẹp. */
|
|
421
|
+
.ui-dl { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px 16px; margin: 0; }
|
|
422
|
+
.ui-dl > div { min-width: 0; }
|
|
423
|
+
.ui-dl dt { font-size: var(--gemboss-fs-caption); color: var(--ui-text-muted); }
|
|
424
|
+
.ui-dl dd { margin: 4px 0 0; font-weight: 600; color: var(--ui-text); overflow-wrap: anywhere; }
|
|
425
|
+
|
|
426
|
+
/* ── Thông báo: `<div class="ui-notice ui-notice--warning">…</div>`. Lỗi tải, cảnh báo cấu hình, việc cần người
|
|
427
|
+
làm. Mỗi tông mang màu chữ riêng (nền tông luôn sáng, kể cả trong admin tối). Không có tông = ghi chú trung tính.
|
|
428
|
+
role="status"/"alert" CHỈ khi thông báo xuất hiện sau khi trang đã tải (vùng live); có sẵn lúc tải thì không role. */
|
|
429
|
+
.ui-notice { padding: 12px 16px; border-radius: var(--ui-radius-md); border: 1px solid var(--ui-border); background: var(--ui-surface-2); color: var(--ui-text); font-size: var(--gemboss-fs-body); }
|
|
430
|
+
.ui-notice--warning { background: var(--gemboss-warning-soft); border-color: var(--gemboss-warning-border); color: var(--gemboss-warning); }
|
|
431
|
+
.ui-notice--danger { background: var(--gemboss-danger-soft); border-color: var(--gemboss-danger-border); color: var(--gemboss-danger-strong); }
|
|
432
|
+
.ui-notice--success { background: var(--gemboss-success-soft); border-color: var(--gemboss-success-border); color: var(--gemboss-success-strong); }
|
|
433
|
+
|
|
434
|
+
/* ── Phân trang: `<nav class="ui-pager" aria-label="Trang"><a href rel="prev"><span aria-hidden="true">‹</span> Trước</a><span aria-current="page">2 / 9</span>
|
|
435
|
+
<a href rel="next">Sau ›</a></nav>`. Link ≥ 32px (≥ 24px WCAG 2.5.8), 44px dưới ngón tay (khối coarse). */
|
|
436
|
+
.ui-pager { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 16px 0; }
|
|
437
|
+
.ui-pager > a { display: inline-flex; align-items: center; min-height: 32px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-md); color: var(--ui-text); text-decoration: none; font-size: var(--gemboss-fs-body); }
|
|
438
|
+
.ui-pager > a:hover { background: var(--ui-surface-2); }
|
|
439
|
+
.ui-pager > a:focus-visible { outline: 2px solid #005bd3; outline-offset: 1px; }
|
|
440
|
+
.ui-pager > [aria-current="page"] { color: var(--ui-text-muted); font-size: var(--gemboss-fs-caption); }
|
|
441
|
+
|
|
350
442
|
/* ── Badge ────────────────────────────────────────────────────────────────────
|
|
351
443
|
Uppercase mono pill for a status/label (was gemboss-admin-storage-badge). */
|
|
352
444
|
.ui-badge {
|
|
@@ -391,24 +483,50 @@
|
|
|
391
483
|
consumer belongs in that consumer's chunk (see SettingsDialog.module.css). */
|
|
392
484
|
.ui-modal--lg { position: relative; }
|
|
393
485
|
|
|
486
|
+
/* The kit's multi-line field joins the ONE field family (01/10/2026, Chris: "form control đồng bộ"):
|
|
487
|
+
same input-border token, hover and focus ring as `gemboss-field` and `.ui-select`. Before, it drew a
|
|
488
|
+
lighter --ui-border box at 14px with no focus ring, so on GemFactory's compose form a textarea sat
|
|
489
|
+
beside selects of a darker border and text fields of a third look (measured on prod). */
|
|
394
490
|
.ui-textarea {
|
|
395
491
|
width: 100%; min-height: 96px; resize: vertical; box-sizing: border-box;
|
|
396
|
-
background: var(--
|
|
397
|
-
|
|
492
|
+
background: var(--gemboss-field-bg);
|
|
493
|
+
border: 1px solid var(--gemboss-field-line); border-radius: var(--ui-radius-md);
|
|
494
|
+
padding: 12px; color: var(--gemboss-field-ink); font-size: var(--ui-fs-body); line-height: 1.5; font-family: inherit;
|
|
495
|
+
}
|
|
496
|
+
.ui-textarea::placeholder { color: var(--gemboss-field-placeholder); }
|
|
497
|
+
.ui-textarea:hover:not(:disabled) {
|
|
498
|
+
border-color: var(--gemboss-field-line-hover);
|
|
499
|
+
background-color: var(--gemboss-field-bg-hover);
|
|
500
|
+
}
|
|
501
|
+
.ui-textarea:focus { outline: none; }
|
|
502
|
+
.ui-textarea:focus-visible:not(:disabled) {
|
|
503
|
+
outline: 2px solid #005bd3; outline-offset: 1px;
|
|
504
|
+
border-color: var(--gemboss-field-line-active);
|
|
505
|
+
}
|
|
506
|
+
.ui-textarea:disabled { opacity: .5; cursor: not-allowed; }
|
|
507
|
+
/* On a PHONE-WIDTH screen every field of the family reads 16px (iOS zooms the page on tap below that)
|
|
508
|
+
and a select is a 44px target — by WIDTH, as `gemboss-field` does, because a desktop browser at 320px
|
|
509
|
+
still reports pointer: fine. Measured on GemFactory at 390px before this: select 13px, textarea 14px. */
|
|
510
|
+
@media (max-width: 47.9975em) {
|
|
511
|
+
.ui-select.ui-select, .ui-textarea { font-size: 16px; }
|
|
512
|
+
/* Height only for the full select: `--slim` is a density decision (table rows), as the coarse block keeps it. */
|
|
513
|
+
.ui-select:not(.ui-select--slim) { min-height: 44px; }
|
|
398
514
|
}
|
|
399
|
-
.ui-textarea:focus { border-color: var(--ui-accent); }
|
|
400
515
|
|
|
401
516
|
/* ── Segmented control ────────────────────────────────────────────────────────
|
|
402
517
|
Grouped toggle: icon squares (.ui-seg button) or text tabs (.is-text). The
|
|
403
518
|
selected segment uses the accent-soft state. */
|
|
404
519
|
.ui-seg { display: inline-flex; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-md); overflow: hidden; }
|
|
405
|
-
.ui-seg button {
|
|
520
|
+
.ui-seg :is(button, a) {
|
|
406
521
|
display: inline-grid; place-items: center; width: 36px; height: 36px;
|
|
407
522
|
border: 0; background: transparent; color: var(--ui-text-muted); cursor: pointer; font-family: inherit;
|
|
408
523
|
}
|
|
409
|
-
.ui-seg button.is-text { width: auto; padding: 0 12px; font-size: var(--ui-fs-body); font-weight: 500; }
|
|
410
|
-
.ui-seg button + button { border-left: 1px solid var(--ui-border); }
|
|
411
|
-
.ui-seg button:hover { background: var(--ui-surface-2); color: var(--ui-text); }
|
|
524
|
+
.ui-seg :is(button, a).is-text { width: auto; padding: 0 12px; font-size: var(--ui-fs-body); font-weight: 500; }
|
|
525
|
+
.ui-seg :is(button, a) + :is(button, a) { border-left: 1px solid var(--ui-border); }
|
|
526
|
+
.ui-seg :is(button, a):hover { background: var(--ui-surface-2); color: var(--ui-text); }
|
|
527
|
+
/* Lọc / chế độ xem bằng LINK (trang server-render, chọn = đổi URL): `<a aria-current="page">` là ô đang chọn. */
|
|
528
|
+
.ui-seg a { display: inline-flex; align-items: center; justify-content: center; text-decoration: none; color: var(--ui-text-muted); }
|
|
529
|
+
.ui-seg a[aria-current="page"],
|
|
412
530
|
.ui-seg button.active { background: var(--ui-accent-soft); color: var(--ui-accent-strong); }
|
|
413
531
|
.ui-seg svg { width: 16px; height: 16px; fill: currentColor; }
|
|
414
532
|
|
|
@@ -490,7 +608,9 @@
|
|
|
490
608
|
decision, not an oversight; it gets its own measurement pass or none. */
|
|
491
609
|
.ui-btn:not(.ui-btn--sm),
|
|
492
610
|
.ui-stat.is-clickable,
|
|
493
|
-
.ui-
|
|
611
|
+
.ui-chips > a.ui-tag--chip,
|
|
612
|
+
.ui-pager > a,
|
|
613
|
+
.ui-seg :is(button, a) { min-height: 44px; }
|
|
494
614
|
/* A clickable stat whose label is one short token ("7d" on /admin/gem-intelligence) measured 40x44
|
|
495
615
|
in the 13/09/2026 sweep: the height floor held, the width did not. */
|
|
496
616
|
.ui-stat.is-clickable { min-width: 44px; justify-content: center; }
|
|
@@ -499,7 +619,7 @@
|
|
|
499
619
|
.ui-select.ui-select,
|
|
500
620
|
.ui-select--slim.ui-select--slim { min-height: 44px; }
|
|
501
621
|
/* The icon-only segment is 36 WIDE as well — a target is an area. */
|
|
502
|
-
.ui-seg button { min-width: 44px; }
|
|
622
|
+
.ui-seg :is(button, a) { min-width: 44px; }
|
|
503
623
|
}
|
|
504
624
|
|
|
505
625
|
/* ── Skeleton ─────────────────────────────────────────────────────────────────
|
|
@@ -523,6 +643,46 @@
|
|
|
523
643
|
|
|
524
644
|
}
|
|
525
645
|
|
|
646
|
+
/* ── Resizer: the seam you drag between two panels ────────────────────────────
|
|
647
|
+
One look for every split in the fleet: the admin's editors (chat | preview) today; made for gemcare's
|
|
648
|
+
inbox columns too (they adopt it at their next ui:sync). The element is the drag target; its GEOMETRY (a flex item, a grid edge, an absolutely
|
|
649
|
+
placed strip) belongs to the surface. While a drag runs the surface puts `ui-resizing` on <body>
|
|
650
|
+
(and may put `dragging` on the seam itself to keep its grip lit).
|
|
651
|
+
Markup: <div class="ui-resizer" role="separator" aria-orientation="vertical" tabindex="0"
|
|
652
|
+
aria-label="…" aria-valuenow aria-valuemin aria-valuemax>. Pointer events, not mouse events (touch
|
|
653
|
+
is a pointer); arrow keys move it — a focusable separator that ignores the keyboard is INERT. */
|
|
654
|
+
/* `touch-action: none` is what makes a FINGER drag reach this element at all. `preventDefault()` on
|
|
655
|
+
`pointerdown` does not stop panning: the browser hands the gesture to the nearest scroller and sends
|
|
656
|
+
`pointercancel`, so the seam jumps once and stops. */
|
|
657
|
+
.ui-resizer { cursor: col-resize; position: relative; touch-action: none; outline: none; }
|
|
658
|
+
/* The grip pill is invisible at rest (a clean seam) and fades in over the zone, on keyboard focus,
|
|
659
|
+
and while a drag runs. Focus shows the pill rather than a ring: on a 5px seam a ring is a smear. */
|
|
660
|
+
.ui-resizer::before {
|
|
661
|
+
content: ""; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
|
662
|
+
width: 4px; height: 44px; border-radius: var(--gemboss-radius-full);
|
|
663
|
+
background: var(--gemboss-primary);
|
|
664
|
+
opacity: 0;
|
|
665
|
+
transition: opacity 0.14s ease, height 0.14s ease;
|
|
666
|
+
}
|
|
667
|
+
.ui-resizer:hover::before,
|
|
668
|
+
.ui-resizer:focus-visible::before,
|
|
669
|
+
.ui-resizer.dragging::before,
|
|
670
|
+
body.ui-resizing .ui-resizer::before { opacity: 1; height: 52px; }
|
|
671
|
+
/* A FINGER NEEDS MORE THAN 5px, and the line must still look like 5px. WCAG 2.5.8 puts the floor for
|
|
672
|
+
this kind of target at 24px, so the HIT area grows and the visible seam does not — widening the
|
|
673
|
+
element itself would also move the columns. Only where the pointer is coarse. */
|
|
674
|
+
@media (pointer: coarse) {
|
|
675
|
+
.ui-resizer::after {
|
|
676
|
+
content: ""; position: absolute; top: 0; bottom: 0; left: -14px; right: -14px;
|
|
677
|
+
touch-action: none; /* the band must claim the gesture too, or the scroller still wins */
|
|
678
|
+
z-index: 2; /* above the panels either side, transparent */
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
body.ui-resizing, body.ui-resizing * { cursor: col-resize !important; user-select: none !important; }
|
|
682
|
+
/* While dragging, a full-viewport transparent shield sits above everything — iframes included — so the
|
|
683
|
+
pointer stream is never captured by a cross-document frame (the classic "stuck cursor"). */
|
|
684
|
+
body.ui-resizing::after { content: ""; position: fixed; inset: 0; z-index: 9999; cursor: col-resize; }
|
|
685
|
+
|
|
526
686
|
/* ── Tabs · Table · Empty state ───────────────────────────────────────────────
|
|
527
687
|
Plain-CSS primitives for surfaces that are NOT React (gemcare's vanilla dashboard reads this
|
|
528
688
|
file through `npm run export:ui-css`). The React app has Polaris <Tabs>/<DataTable>/<EmptyState>
|
|
@@ -537,7 +697,8 @@
|
|
|
537
697
|
display: flex; gap: var(--ui-space-1); border-bottom: 1px solid var(--ui-border);
|
|
538
698
|
overflow-x: auto; scrollbar-width: none;
|
|
539
699
|
}
|
|
540
|
-
.ui-tabs > button
|
|
700
|
+
.ui-tabs > button,
|
|
701
|
+
.ui-tabs > a {
|
|
541
702
|
position: relative; flex: 0 0 auto; display: inline-flex; align-items: center; gap: var(--ui-space-2);
|
|
542
703
|
min-height: 44px; padding: 0 var(--ui-space-3); border: 0; background: transparent; cursor: pointer;
|
|
543
704
|
border-radius: var(--ui-radius-sm) var(--ui-radius-sm) 0 0;
|
|
@@ -545,9 +706,15 @@
|
|
|
545
706
|
color: var(--ui-text-muted);
|
|
546
707
|
transition: color var(--gemboss-transition-fast) ease, background-color var(--gemboss-transition-fast) ease;
|
|
547
708
|
}
|
|
548
|
-
.ui-tabs > button:hover
|
|
549
|
-
.ui-tabs >
|
|
709
|
+
.ui-tabs > button:hover,
|
|
710
|
+
.ui-tabs > a:hover { color: var(--ui-text); background: var(--ui-surface-2); }
|
|
711
|
+
.ui-tabs > button:focus-visible,
|
|
712
|
+
.ui-tabs > a:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: -2px; }
|
|
713
|
+
.ui-tabs > a { text-decoration: none; }
|
|
714
|
+
/* Tab bằng link (mỗi tab là một URL): `<nav class="ui-tabs"><a aria-current="page">` — cùng hình với tab nút. */
|
|
715
|
+
.ui-tabs > a[aria-current="page"],
|
|
550
716
|
.ui-tabs > button[aria-selected="true"] { color: var(--ui-text-strong); font-weight: 600; }
|
|
717
|
+
.ui-tabs > a[aria-current="page"]::after,
|
|
551
718
|
.ui-tabs > button[aria-selected="true"]::after {
|
|
552
719
|
content: ""; position: absolute; left: var(--ui-space-3); right: var(--ui-space-3); bottom: 0;
|
|
553
720
|
height: 2px; border-radius: var(--gemboss-radius-full); background: var(--ui-accent);
|
|
@@ -573,6 +740,12 @@
|
|
|
573
740
|
.ui-table tbody tr:last-child > td { border-bottom: 0; }
|
|
574
741
|
.ui-table tbody tr:hover { background: var(--ui-surface-2); }
|
|
575
742
|
.ui-table .is-num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
743
|
+
/* Dòng phụ dưới nội dung chính của một ô (lớp, nguồn, ngày…): `<td><a>Loox</a><span class="ui-sub">Cộng sinh</span></td>`.
|
|
744
|
+
Trước đây mỗi surface tự viết `.muted` riêng trong ô bảng (partner-radar, gemwatcher). */
|
|
745
|
+
/* Link nằm thẳng trong ô (tên bản ghi dẫn sang trang chi tiết) là link trong DANH SÁCH: ≥ 24px (WCAG 2.5.8, §9.1).
|
|
746
|
+
Chữ 13px cho hộp 17px; visual-gate đo đỏ ở partner-radar /competitors 01/10/2026. inline-flex giữ nó trên dòng. */
|
|
747
|
+
.ui-table td > a { display: inline-flex; align-items: center; min-height: 24px; }
|
|
748
|
+
.ui-table .ui-sub { display: block; margin-top: 2px; font-size: var(--gemboss-fs-caption); color: var(--ui-text-muted); font-weight: 400; }
|
|
576
749
|
|
|
577
750
|
/* Empty state: `<div class="ui-empty"><p class="ui-empty__title">…</p><p class="ui-empty__body">…</p>
|
|
578
751
|
<button class="ui-btn">…</button></div>`. Says what is missing and what to do next. */
|
|
@@ -618,6 +791,10 @@
|
|
|
618
791
|
border: 1px solid var(--gemboss-field-line);
|
|
619
792
|
border-radius: 8px;
|
|
620
793
|
}
|
|
794
|
+
/* WebKit draws `input[type=search]` as `-webkit-appearance: searchfield`: its own padding and rounding, so in Safari
|
|
795
|
+
(macOS and iOS, the platform the 16px rule exists for) a search box did not match the fields beside it. Same box for
|
|
796
|
+
every text type; the clear button stays. (GemFactory #98 review, 01/10/2026.) */
|
|
797
|
+
input[type="search"]:is(.gemboss-field, .gemboss-field__bare) { -webkit-appearance: none; appearance: none; }
|
|
621
798
|
.gemboss-field::placeholder { color: var(--gemboss-field-placeholder); }
|
|
622
799
|
/* A NARROW field — the inspector's T/R/B/L boxes and its number spinners are 40-68px wide, and the
|
|
623
800
|
shared 12px side padding left 38px of content box for a three-digit value (measured in Chromium:
|
|
@@ -732,7 +909,8 @@ textarea.gemboss-field { height: auto; padding: 6px 12px; resize: vertical; }
|
|
|
732
909
|
z-index: 320;
|
|
733
910
|
overflow: hidden;
|
|
734
911
|
}
|
|
735
|
-
.gemboss-account-menu-item
|
|
912
|
+
.gemboss-account-menu-item,
|
|
913
|
+
.ui-menu__item {
|
|
736
914
|
display: block;
|
|
737
915
|
width: 100%;
|
|
738
916
|
min-height: 44px;
|
|
@@ -747,15 +925,18 @@ textarea.gemboss-field { height: auto; padding: 6px 12px; resize: vertical; }
|
|
|
747
925
|
cursor: pointer;
|
|
748
926
|
transition: background var(--gemboss-transition-fast) ease, color var(--gemboss-transition-fast) ease;
|
|
749
927
|
}
|
|
750
|
-
.gemboss-account-menu-item:hover
|
|
928
|
+
.gemboss-account-menu-item:hover,
|
|
929
|
+
.ui-menu__item:hover {
|
|
751
930
|
background: var(--gemboss-hover);
|
|
752
931
|
}
|
|
753
|
-
.gemboss-account-menu-item:focus-visible
|
|
932
|
+
.gemboss-account-menu-item:focus-visible,
|
|
933
|
+
.ui-menu__item:focus-visible {
|
|
754
934
|
outline: 2px solid var(--gemboss-primary);
|
|
755
935
|
outline-offset: -2px;
|
|
756
936
|
background: var(--gemboss-hover);
|
|
757
937
|
}
|
|
758
|
-
.gemboss-account-menu-item.danger
|
|
938
|
+
.gemboss-account-menu-item.danger,
|
|
939
|
+
.ui-menu__item--danger {
|
|
759
940
|
color: var(--gemboss-danger-strong);
|
|
760
941
|
}
|
|
761
942
|
.gemboss-account-chip {
|
|
@@ -2890,3 +3071,60 @@ body.gemboss-assistant-unread .gnav-item.gnav-assistant::after {
|
|
|
2890
3071
|
border-radius: 0;
|
|
2891
3072
|
}
|
|
2892
3073
|
}
|
|
3074
|
+
|
|
3075
|
+
/* ═══ @gemboss/ui src/kit-plain.css ═══ */
|
|
3076
|
+
/* kit-plain.css — kit primitives that only the NON-React surfaces use (GemFactory, gemcare, gempress write the
|
|
3077
|
+
markup by hand). They ship inside gemboss-ui.css (scripts/build-css.mjs `own`), but the admin does NOT import
|
|
3078
|
+
this file: `ui.css` is loaded by the admin's root layout, so every rule there is paid on every admin screen,
|
|
3079
|
+
and adding these to it pushed the admin shell 0.2 KB over its first-load budget (CI of #3591, 01/10/2026).
|
|
3080
|
+
The admin has Polaris for menus, checkboxes and description lists. */
|
|
3081
|
+
|
|
3082
|
+
/* ── Icon button · Menu · Key-value list ──────────────────────────────────────
|
|
3083
|
+
Added 01/10/2026 for GemFactory's item dialog, which had seven actions in one header row; the
|
|
3084
|
+
secondary five now sit behind one "⋯" button. Plain CSS, same contract as the tabs and tables in ui.css.
|
|
3085
|
+
|
|
3086
|
+
Icon button: `<button class="ui-btn ui-btn--icon" aria-label="…"><svg…></button>` — square, the
|
|
3087
|
+
label is REQUIRED (there is no text for a screen reader to read otherwise).
|
|
3088
|
+
|
|
3089
|
+
Menu: a `<details>` so it opens with no script. `<details class="ui-menu ui-menu--end">
|
|
3090
|
+
<summary class="ui-btn ui-btn--icon" aria-label="More actions">…</summary><div class="ui-menu__list">
|
|
3091
|
+
<button class="ui-menu__item">Copy #12<span class="ui-menu__hint">for Claude Code</span></button>
|
|
3092
|
+
<hr class="ui-menu__sep"><button class="ui-menu__item ui-menu__item--danger">Delete…</button></div>
|
|
3093
|
+
</details>`. `--end` aligns the list to the right edge of its button. Closing on an outside click,
|
|
3094
|
+
on an item, and on Escape is the page's job (a few lines; GemFactory's ui-kit.mjs has them).
|
|
3095
|
+
Items are 44px under EVERY pointer, not only coarse: they touch each other (0px apart), and §9.1
|
|
3096
|
+
only allows a gap under 8px between targets that are already 44px.
|
|
3097
|
+
|
|
3098
|
+
Key-value list: `<dl class="ui-kv"><dt>Owner</dt><dd>Lily</dd>…</dl>` — label column sized to
|
|
3099
|
+
its longest label, value column takes the rest and wraps long keys and URLs. */
|
|
3100
|
+
.ui-btn--icon { flex: none; width: var(--ui-control-h); padding: 0; }
|
|
3101
|
+
.ui-btn--icon.ui-btn--sm { width: 28px; }
|
|
3102
|
+
.ui-btn--icon svg { width: 18px; height: 18px; }
|
|
3103
|
+
/* Menu: the SURFACE is the shell's `gemboss-popover-surface` and the rows are the account menu's row (shell.css
|
|
3104
|
+
declares `.gemboss-account-menu-item, .ui-menu__item` together), so a kit menu and the account menu on one page
|
|
3105
|
+
look the same and a fix to one reaches the other (§9.3). Markup: `<div class="ui-menu__list gemboss-popover-surface">`.
|
|
3106
|
+
Here: only placement, the hint, the separator and the disabled state. */
|
|
3107
|
+
.ui-menu { position: relative; display: inline-block; }
|
|
3108
|
+
.ui-menu > summary { list-style: none; cursor: pointer; }
|
|
3109
|
+
.ui-menu > summary::-webkit-details-marker { display: none; }
|
|
3110
|
+
.ui-menu__list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; display: grid; min-width: 220px; padding: var(--ui-space-1); box-sizing: border-box; }
|
|
3111
|
+
.ui-menu--end .ui-menu__list { left: auto; right: 0; }
|
|
3112
|
+
.ui-menu__item { display: flex; align-items: center; gap: var(--ui-space-2); white-space: nowrap; border-radius: var(--ui-radius-sm); }
|
|
3113
|
+
.ui-menu__item:is([disabled], [aria-disabled="true"]) { opacity: .5; cursor: not-allowed; pointer-events: none; }
|
|
3114
|
+
.ui-menu__hint { margin-left: auto; padding-left: var(--ui-space-4); color: var(--ui-text-muted); font-size: var(--ui-fs-caption); }
|
|
3115
|
+
.ui-menu__sep { height: 1px; margin: var(--ui-space-1) 0; border: 0; background: var(--ui-border); }
|
|
3116
|
+
/* Checkbox: the LABEL is the target; disabled turns both the label and the box to not-allowed. Focus ring in the
|
|
3117
|
+
kit's own token (no --p-* here: this file is appended after the export's Polaris resolver runs). */
|
|
3118
|
+
.ui-check { display: inline-flex; align-items: center; gap: var(--ui-space-2); min-height: 44px; cursor: pointer; font-size: var(--ui-fs-body); color: var(--ui-text); }
|
|
3119
|
+
.ui-check > input { flex: none; width: 18px; height: 18px; margin: 0; accent-color: var(--ui-accent); cursor: inherit; }
|
|
3120
|
+
.ui-check > input:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: 2px; }
|
|
3121
|
+
.ui-check:has(> input:disabled) { opacity: .5; cursor: not-allowed; }
|
|
3122
|
+
.ui-label { display: block; margin: 0 0 var(--ui-space-1); font-size: var(--ui-fs-caption); font-weight: 600; color: var(--ui-text-muted); }
|
|
3123
|
+
.ui-kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--ui-space-2) var(--ui-space-4); margin: 0; font-size: var(--ui-fs-body); }
|
|
3124
|
+
.ui-kv > dt { color: var(--ui-text-muted); }
|
|
3125
|
+
.ui-kv > dd { margin: 0; min-width: 0; overflow-wrap: anywhere; color: var(--ui-text); }
|
|
3126
|
+
|
|
3127
|
+
/* Under a thumb the icon-only button is 44 WIDE as well (the kit's coarse block already raises every .ui-btn to 44 tall). */
|
|
3128
|
+
@media (pointer: coarse) { .ui-btn--icon:not(.ui-btn--sm) { min-width: 44px; } }
|
|
3129
|
+
/* …and at phone WIDTH, as gemboss-field does: a desktop browser at 320px still reports pointer: fine. */
|
|
3130
|
+
@media (max-width: 47.9975em) { .ui-btn--icon:not(.ui-btn--sm) { min-width: 44px; min-height: 44px; } }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemboss/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"./app-shell.css": "./src/app-shell.css",
|
|
19
19
|
"./shell.css": "./src/shell.css",
|
|
20
20
|
"./auth.css": "./src/auth.css",
|
|
21
|
+
"./kit-plain.css": "./src/kit-plain.css",
|
|
21
22
|
"./assistant": "./src/assistant/index.ts",
|
|
22
23
|
"./assistant/dock": "./src/assistant/dock.ts",
|
|
23
24
|
"./assistant.css": "./src/assistant.css"
|
|
@@ -35,7 +36,8 @@
|
|
|
35
36
|
"build:css": "node scripts/build-css.mjs",
|
|
36
37
|
"prepack": "node scripts/build-css.mjs && node scripts/build-catalog.mjs",
|
|
37
38
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
38
|
-
"catalog": "node scripts/build-css.mjs && node scripts/build-catalog.mjs"
|
|
39
|
+
"catalog": "node scripts/build-css.mjs && node scripts/build-catalog.mjs",
|
|
40
|
+
"fleet": "node scripts/fleet.mjs"
|
|
39
41
|
},
|
|
40
42
|
"peerDependencies": {
|
|
41
43
|
"react": ">=18"
|
package/src/gemboss-field.css
CHANGED
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
border: 1px solid var(--p-color-input-border, var(--gemboss-field-line));
|
|
31
31
|
border-radius: var(--p-border-radius-200, 8px);
|
|
32
32
|
}
|
|
33
|
+
/* WebKit draws `input[type=search]` as `-webkit-appearance: searchfield`: its own padding and rounding, so in Safari
|
|
34
|
+
(macOS and iOS, the platform the 16px rule exists for) a search box did not match the fields beside it. Same box for
|
|
35
|
+
every text type; the clear button stays. (GemFactory #98 review, 01/10/2026.) */
|
|
36
|
+
input[type="search"]:is(.gemboss-field, .gemboss-field__bare) { -webkit-appearance: none; appearance: none; }
|
|
33
37
|
.gemboss-field::placeholder { color: var(--p-color-text-secondary, var(--gemboss-field-placeholder)); }
|
|
34
38
|
/* A NARROW field — the inspector's T/R/B/L boxes and its number spinners are 40-68px wide, and the
|
|
35
39
|
shared 12px side padding left 38px of content box for a three-digit value (measured in Chromium:
|
package/src/icons.tsx
CHANGED
|
@@ -54,6 +54,7 @@ const P = {
|
|
|
54
54
|
check: <path d="M4 10.5 8 14.5 16 5.5" />,
|
|
55
55
|
plus: <path d="M10 4v12M4 10h12" />,
|
|
56
56
|
close: <path d="M5 5l10 10M15 5 5 15" />,
|
|
57
|
+
more: <><circle cx="4.5" cy="10" r="1" /><circle cx="10" cy="10" r="1" /><circle cx="15.5" cy="10" r="1" /></>,
|
|
57
58
|
utensils: <><path d="M6 3v5a2 2 0 0 0 4 0V3M8 3v14" /><path d="M15 3c-1.6 1.4-2.5 3.6-2.5 6.5H15V17" /></>,
|
|
58
59
|
signOut: <path d="M8 3.5H4v13h4M12.5 6.5 16 10l-3.5 3.5M16 10H8" />,
|
|
59
60
|
} satisfies Record<string, React.ReactNode>;
|
package/src/index.ts
CHANGED
|
@@ -6,4 +6,5 @@ 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 { AccountMenu, AccountMenuItem, initialsOf } from "./account";
|
|
9
|
+
export { Menu, MenuItem, MenuSeparator } from "./menu";
|
|
9
10
|
export { Icon, ICON_NAMES, type IconName } from "./icons";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* kit-plain.css — kit primitives that only the NON-React surfaces use (GemFactory, gemcare, gempress write the
|
|
2
|
+
markup by hand). They ship inside gemboss-ui.css (scripts/build-css.mjs `own`), but the admin does NOT import
|
|
3
|
+
this file: `ui.css` is loaded by the admin's root layout, so every rule there is paid on every admin screen,
|
|
4
|
+
and adding these to it pushed the admin shell 0.2 KB over its first-load budget (CI of #3591, 01/10/2026).
|
|
5
|
+
The admin has Polaris for menus, checkboxes and description lists. */
|
|
6
|
+
|
|
7
|
+
/* ── Icon button · Menu · Key-value list ──────────────────────────────────────
|
|
8
|
+
Added 01/10/2026 for GemFactory's item dialog, which had seven actions in one header row; the
|
|
9
|
+
secondary five now sit behind one "⋯" button. Plain CSS, same contract as the tabs and tables in ui.css.
|
|
10
|
+
|
|
11
|
+
Icon button: `<button class="ui-btn ui-btn--icon" aria-label="…"><svg…></button>` — square, the
|
|
12
|
+
label is REQUIRED (there is no text for a screen reader to read otherwise).
|
|
13
|
+
|
|
14
|
+
Menu: a `<details>` so it opens with no script. `<details class="ui-menu ui-menu--end">
|
|
15
|
+
<summary class="ui-btn ui-btn--icon" aria-label="More actions">…</summary><div class="ui-menu__list">
|
|
16
|
+
<button class="ui-menu__item">Copy #12<span class="ui-menu__hint">for Claude Code</span></button>
|
|
17
|
+
<hr class="ui-menu__sep"><button class="ui-menu__item ui-menu__item--danger">Delete…</button></div>
|
|
18
|
+
</details>`. `--end` aligns the list to the right edge of its button. Closing on an outside click,
|
|
19
|
+
on an item, and on Escape is the page's job (a few lines; GemFactory's ui-kit.mjs has them).
|
|
20
|
+
Items are 44px under EVERY pointer, not only coarse: they touch each other (0px apart), and §9.1
|
|
21
|
+
only allows a gap under 8px between targets that are already 44px.
|
|
22
|
+
|
|
23
|
+
Key-value list: `<dl class="ui-kv"><dt>Owner</dt><dd>Lily</dd>…</dl>` — label column sized to
|
|
24
|
+
its longest label, value column takes the rest and wraps long keys and URLs. */
|
|
25
|
+
.ui-btn--icon { flex: none; width: var(--ui-control-h); padding: 0; }
|
|
26
|
+
.ui-btn--icon.ui-btn--sm { width: 28px; }
|
|
27
|
+
.ui-btn--icon svg { width: 18px; height: 18px; }
|
|
28
|
+
/* Menu: the SURFACE is the shell's `gemboss-popover-surface` and the rows are the account menu's row (shell.css
|
|
29
|
+
declares `.gemboss-account-menu-item, .ui-menu__item` together), so a kit menu and the account menu on one page
|
|
30
|
+
look the same and a fix to one reaches the other (§9.3). Markup: `<div class="ui-menu__list gemboss-popover-surface">`.
|
|
31
|
+
Here: only placement, the hint, the separator and the disabled state. */
|
|
32
|
+
.ui-menu { position: relative; display: inline-block; }
|
|
33
|
+
.ui-menu > summary { list-style: none; cursor: pointer; }
|
|
34
|
+
.ui-menu > summary::-webkit-details-marker { display: none; }
|
|
35
|
+
.ui-menu__list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; display: grid; min-width: 220px; padding: var(--ui-space-1); box-sizing: border-box; }
|
|
36
|
+
.ui-menu--end .ui-menu__list { left: auto; right: 0; }
|
|
37
|
+
.ui-menu__item { display: flex; align-items: center; gap: var(--ui-space-2); white-space: nowrap; border-radius: var(--ui-radius-sm); }
|
|
38
|
+
.ui-menu__item:is([disabled], [aria-disabled="true"]) { opacity: .5; cursor: not-allowed; pointer-events: none; }
|
|
39
|
+
.ui-menu__hint { margin-left: auto; padding-left: var(--ui-space-4); color: var(--ui-text-muted); font-size: var(--ui-fs-caption); }
|
|
40
|
+
.ui-menu__sep { height: 1px; margin: var(--ui-space-1) 0; border: 0; background: var(--ui-border); }
|
|
41
|
+
/* Checkbox: the LABEL is the target; disabled turns both the label and the box to not-allowed. Focus ring in the
|
|
42
|
+
kit's own token (no --p-* here: this file is appended after the export's Polaris resolver runs). */
|
|
43
|
+
.ui-check { display: inline-flex; align-items: center; gap: var(--ui-space-2); min-height: 44px; cursor: pointer; font-size: var(--ui-fs-body); color: var(--ui-text); }
|
|
44
|
+
.ui-check > input { flex: none; width: 18px; height: 18px; margin: 0; accent-color: var(--ui-accent); cursor: inherit; }
|
|
45
|
+
.ui-check > input:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: 2px; }
|
|
46
|
+
.ui-check:has(> input:disabled) { opacity: .5; cursor: not-allowed; }
|
|
47
|
+
.ui-label { display: block; margin: 0 0 var(--ui-space-1); font-size: var(--ui-fs-caption); font-weight: 600; color: var(--ui-text-muted); }
|
|
48
|
+
.ui-kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--ui-space-2) var(--ui-space-4); margin: 0; font-size: var(--ui-fs-body); }
|
|
49
|
+
.ui-kv > dt { color: var(--ui-text-muted); }
|
|
50
|
+
.ui-kv > dd { margin: 0; min-width: 0; overflow-wrap: anywhere; color: var(--ui-text); }
|
|
51
|
+
|
|
52
|
+
/* Under a thumb the icon-only button is 44 WIDE as well (the kit's coarse block already raises every .ui-btn to 44 tall). */
|
|
53
|
+
@media (pointer: coarse) { .ui-btn--icon:not(.ui-btn--sm) { min-width: 44px; } }
|
|
54
|
+
/* …and at phone WIDTH, as gemboss-field does: a desktop browser at 320px still reports pointer: fine. */
|
|
55
|
+
@media (max-width: 47.9975em) { .ui-btn--icon:not(.ui-btn--sm) { min-width: 44px; min-height: 44px; } }
|
package/src/menu.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// menu.tsx: the kit's action menu ("⋯") — a `<details>` disclosure, so it opens with no JavaScript and a
|
|
4
|
+
// server-rendered surface (GemFactory, gemcare) can print it as plain markup. The surface is the shell's
|
|
5
|
+
// `gemboss-popover-surface` and the rows share the account menu's row (shell.css), so every menu in the
|
|
6
|
+
// fleet looks the same (§9.3). Where React hydrates, the menu closes itself like AccountMenu does: outside
|
|
7
|
+
// click, Escape (focus back on the trigger), and a button row after its handler (review #3591: a React page
|
|
8
|
+
// had no clean way to close it). The catalog renders its example from these components.
|
|
9
|
+
import React, { useEffect, useRef } from "react";
|
|
10
|
+
import { Icon } from "./icons";
|
|
11
|
+
|
|
12
|
+
/** The trigger is an icon button (`label` is its accessible name). `end` aligns the list to the right edge. */
|
|
13
|
+
export function Menu({ label, icon, end, children, className }: {
|
|
14
|
+
label: string;
|
|
15
|
+
icon?: React.ReactNode;
|
|
16
|
+
end?: boolean;
|
|
17
|
+
children?: React.ReactNode;
|
|
18
|
+
className?: string;
|
|
19
|
+
}) {
|
|
20
|
+
const ref = useRef<HTMLDetailsElement>(null);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const el = ref.current;
|
|
23
|
+
if (!el) return undefined;
|
|
24
|
+
const onDoc = (e: MouseEvent) => { if (el.open && !el.contains(e.target as Node)) el.open = false; };
|
|
25
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && el.open) { el.open = false; el.querySelector("summary")?.focus(); } };
|
|
26
|
+
document.addEventListener("click", onDoc);
|
|
27
|
+
document.addEventListener("keydown", onKey);
|
|
28
|
+
return () => { document.removeEventListener("click", onDoc); document.removeEventListener("keydown", onKey); };
|
|
29
|
+
}, []);
|
|
30
|
+
return (
|
|
31
|
+
<details ref={ref} className={["ui-menu", end ? "ui-menu--end" : "", className ?? ""].filter(Boolean).join(" ")}>
|
|
32
|
+
<summary className="ui-btn ui-btn--icon" aria-label={label} title={label}>{icon ?? <Icon name="more" />}</summary>
|
|
33
|
+
<div className="ui-menu__list gemboss-popover-surface">{children}</div>
|
|
34
|
+
</details>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One row: a link (`href`) or a button. `hint` is the quiet text on the right; `danger` for destructive rows.
|
|
39
|
+
* A disabled link is not a link: no href, aria-disabled, out of the tab order (an `<a disabled>` still follows
|
|
40
|
+
* on Enter). A caller's `className` is added to the row's class, not put in its place. */
|
|
41
|
+
export function MenuItem({ href, danger, hint, children, className, disabled, onClick, ...rest }: {
|
|
42
|
+
href?: string;
|
|
43
|
+
danger?: boolean;
|
|
44
|
+
hint?: React.ReactNode;
|
|
45
|
+
children?: React.ReactNode;
|
|
46
|
+
disabled?: boolean;
|
|
47
|
+
onClick?: React.MouseEventHandler<HTMLElement>;
|
|
48
|
+
} & Omit<React.HTMLAttributes<HTMLElement>, "onClick"> & Pick<React.AnchorHTMLAttributes<HTMLAnchorElement>, "target" | "rel">) {
|
|
49
|
+
const cls = ["ui-menu__item", danger ? "ui-menu__item--danger" : "", className ?? ""].filter(Boolean).join(" ");
|
|
50
|
+
const body = <>{children}{hint ? <span className="ui-menu__hint">{hint}</span> : null}</>;
|
|
51
|
+
if (href) {
|
|
52
|
+
if (disabled) return <a className={cls} aria-disabled="true" tabIndex={-1} {...rest}>{body}</a>;
|
|
53
|
+
return <a className={cls} href={href} onClick={onClick} {...rest}>{body}</a>;
|
|
54
|
+
}
|
|
55
|
+
const close: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
|
56
|
+
onClick?.(e);
|
|
57
|
+
e.currentTarget.closest("details")?.removeAttribute("open");
|
|
58
|
+
};
|
|
59
|
+
return <button type="button" className={cls} disabled={disabled} onClick={close} {...rest}>{body}</button>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function MenuSeparator() {
|
|
63
|
+
return <hr className="ui-menu__sep" />;
|
|
64
|
+
}
|
package/src/shell.css
CHANGED
|
@@ -100,20 +100,24 @@
|
|
|
100
100
|
/* Account dropdown menu items (avatar chip) — CSS-driven hover/focus, replaces inline JS handlers. */
|
|
101
101
|
/* min-height 44 (28/09/2026): 12px padding around a 13px line measured 42.84px — under the 44px control
|
|
102
102
|
floor (CLAUDE.md §9.1), in the admin's own menu and in every surface wearing it through @gemboss/ui. */
|
|
103
|
-
.gemboss-account-menu-item
|
|
103
|
+
.gemboss-account-menu-item,
|
|
104
|
+
.ui-menu__item {
|
|
104
105
|
display: block; width: 100%; min-height: 44px; padding: 12px 16px; border: 0; background: transparent;
|
|
105
106
|
font: inherit; font-size: 13px; color: var(--gemboss-text); text-align: left; text-decoration: none;
|
|
106
107
|
cursor: pointer; transition: background var(--gemboss-transition-fast) ease, color var(--gemboss-transition-fast) ease;
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
.gemboss-account-menu-item:hover
|
|
110
|
+
.gemboss-account-menu-item:hover,
|
|
111
|
+
.ui-menu__item:hover { background: var(--gemboss-hover); }
|
|
110
112
|
|
|
111
|
-
.gemboss-account-menu-item:focus-visible
|
|
113
|
+
.gemboss-account-menu-item:focus-visible,
|
|
114
|
+
.ui-menu__item:focus-visible { outline: 2px solid var(--gemboss-primary); outline-offset: -2px; background: var(--gemboss-hover); }
|
|
112
115
|
|
|
113
116
|
/* `--gemboss-danger` var(--gemboss-danger) is 4.25:1 on `--gemboss-hover` #eceef2, and this row takes that hover
|
|
114
117
|
background two lines above — so the one moment the item is under the cursor is the moment its label
|
|
115
118
|
drops below AA. `--gemboss-danger-strong` #b42318 is 5.66:1 there and 7.30:1 on white. */
|
|
116
|
-
.gemboss-account-menu-item.danger
|
|
119
|
+
.gemboss-account-menu-item.danger,
|
|
120
|
+
.ui-menu__item--danger { color: var(--gemboss-danger-strong); }
|
|
117
121
|
|
|
118
122
|
.gemboss-account-chip {
|
|
119
123
|
width: 30px;
|