@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/src/ui.css CHANGED
@@ -31,6 +31,10 @@
31
31
  --ui-accent-soft: var(--gemboss-active-bg, #f0f1f4); /* tinted bg for a selected/active state */
32
32
  --ui-accent-strong: var(--gemboss-active-fg, #1b1c1e); /* readable accent text ON --ui-accent-soft */
33
33
  --ui-text-strong: var(--gemboss-text-strong, #1b1c1e);
34
+ /* 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. */
35
+ --ui-success-fg: var(--gemboss-success);
36
+ --ui-warning-fg: var(--gemboss-primary-dark);
37
+ --ui-danger-fg: var(--gemboss-danger-strong);
34
38
  --ui-focus: var(--gemboss-primary, #1b1c1e);
35
39
  --ui-radius-sm: 6px;
36
40
  --ui-radius-md: 8px;
@@ -90,6 +94,10 @@
90
94
  --ui-accent-soft: var(--adm-accent-soft);
91
95
  --ui-accent-strong: var(--adm-accent-fg);
92
96
  --ui-text-strong: var(--adm-text-strong);
97
+ /* 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). */
98
+ --ui-success-fg: color-mix(in srgb, var(--gemboss-success) 70%, #ffffff);
99
+ --ui-warning-fg: color-mix(in srgb, #c99a2e 70%, #ffffff);
100
+ --ui-danger-fg: color-mix(in srgb, var(--gemboss-danger-strong) 70%, #ffffff);
93
101
  --ui-focus: var(--adm-accent);
94
102
  }
95
103
 
@@ -120,6 +128,11 @@
120
128
  padding: var(--ui-space-4); color: var(--ui-text);
121
129
  }
122
130
  .ui-card__title { font-size: 14px; font-weight: 600; margin: 0 0 var(--ui-space-2); }
131
+ /* Đầu thẻ có nút: `<div class="ui-card__head"><h2 class="ui-card__title">…</h2><button class="ui-btn">…</button></div>`.
132
+ Tiêu đề trái, hành động phải, xuống dòng khi hẹp (01/10/2026: partner-radar rel-head / agr). */
133
+ .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); }
134
+ .ui-card__head > .ui-card__title { margin: 0; }
135
+ .ui-card__head > :last-child:not(.ui-card__title) { display: flex; flex-wrap: wrap; gap: var(--ui-space-2); }
123
136
 
124
137
  /* ── Select ──────────────────────────────────────────────────────────────────
125
138
  Native <select> reskinned; custom caret as an inline SVG data-URI (caret color
@@ -141,7 +154,7 @@
141
154
  background-color: var(--p-color-input-bg-surface-hover, var(--gemboss-field-bg-hover));
142
155
  }
143
156
  .ui-select:focus { outline: none; }
144
- .ui-select:focus-visible {
157
+ .ui-select:focus-visible:not(:disabled) {
145
158
  outline: 2px solid var(--p-color-border-focus, #005bd3); outline-offset: 1px;
146
159
  border-color: var(--p-color-input-border-active, var(--gemboss-field-line-active));
147
160
  }
@@ -164,6 +177,85 @@
164
177
  }
165
178
  .ui-tag--chip.ui-tag--accent { color: var(--adm-accent-fg, var(--ui-accent)); border-color: var(--ui-accent); background: transparent; }
166
179
 
180
+ /* Danh sách link dạng chip: `<div class="ui-chips"><a class="ui-tag ui-tag--chip" href>…</a>…</div>` (01/10/2026,
181
+ 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
182
+ 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
183
+ ≥ 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).
184
+ Khe 8px: hai target nhỏ hơn 44px phải cách nhau ≥ 8px. */
185
+ .ui-chips { display: flex; flex-wrap: wrap; gap: var(--ui-space-2, 8px); margin: 0; padding: 0; list-style: none; }
186
+ .ui-chips > a.ui-tag--chip {
187
+ min-height: 28px; padding: 4px 12px; border-radius: var(--ui-radius-md, 8px);
188
+ font-family: inherit; font-size: var(--gemboss-fs-caption, 12px); color: var(--ui-text); text-decoration: none;
189
+ }
190
+ .ui-chips > a.ui-tag--chip:hover { background: var(--ui-surface-2); border-color: var(--ui-text-muted); }
191
+ .ui-chips > a.ui-tag--chip:focus-visible { outline: 2px solid var(--p-color-border-focus, #005bd3); outline-offset: 1px; }
192
+ /* 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.
193
+ Ô đang chọn = `aria-current="page"` (lọc là một URL), cùng tông với ui-seg đang chọn. */
194
+ .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; }
195
+
196
+ /* ── Tông màu cho tag: `ui-tag--success | --warning | --danger` (token ngữ nghĩa có sẵn). Trạng thái, nguồn,
197
+ kết quả kiểm — thay cho màu viết cứng ở từng surface. */
198
+ .ui-tag--success { background: var(--gemboss-success-soft); color: var(--gemboss-success-strong, var(--gemboss-success)); }
199
+ .ui-tag--warning { background: var(--gemboss-warning-soft); color: var(--gemboss-warning); }
200
+ .ui-tag--danger { background: var(--gemboss-danger-soft); color: var(--gemboss-danger-strong, var(--gemboss-danger)); }
201
+
202
+ /* ── Chữ phụ: `<p class="ui-muted">`, `<span class="ui-muted">` — mô tả, chú thích, dòng meta. Cỡ caption, màu muted.
203
+ Trước đây mỗi surface tự viết `.muted` (partner-radar: 180 chỗ). */
204
+ .ui-muted { color: var(--ui-text-muted); font-size: var(--gemboss-fs-caption); }
205
+
206
+ /* ── Tiêu đề trang: `<div class="ui-page-head"><h1>…</h1><p>…</p></div>`. Đưa lên NGUYÊN VĂN từ admin
207
+ `.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
208
+ 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. */
209
+ .ui-page-head { margin-bottom: 20px; }
210
+ .ui-page-head h1 { color: var(--ui-text-strong); font-size: 22px; font-weight: 650; letter-spacing: -0.01em; }
211
+ .ui-page-head p { color: var(--ui-text-muted); font-size: 14px; margin-top: 4px; }
212
+
213
+ /* ── 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.
214
+ Đưa lên từ admin `.gemboss-admin-toolbar`. Chỉ BỐ CỤC: ô tìm là `gemboss-field gemboss-field--group`. */
215
+ .ui-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 20px; }
216
+
217
+ /* ── Ô số liệu: `<div class="ui-metrics"><div class="ui-metric"><span class="ui-metric__val">12</span>
218
+ <span class="ui-metric__lbl">Nhãn</span></div></div>`; tông `ui-metric--success | --warning | --danger`.
219
+ Đư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). */
220
+ .ui-metrics { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0; }
221
+ .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; }
222
+ /* 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
223
+ (visual-gate đỏ ở partner-radar /ops 01/10/2026 khi link nằm trong __val). */
224
+ a.ui-metric { text-decoration: none; color: inherit; }
225
+ a.ui-metric:hover { border-color: var(--ui-text-muted); }
226
+ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: 1px; }
227
+ .ui-metric__val { font-size: 17px; font-weight: 700; color: var(--ui-text); font-variant-numeric: tabular-nums; line-height: 1.15; }
228
+ .ui-metric__lbl { font-size: var(--gemboss-fs-caption); font-weight: 500; color: var(--ui-text-muted); }
229
+ .ui-metric--success { border-color: color-mix(in srgb, var(--gemboss-success) 40%, transparent); }
230
+ .ui-metric--success .ui-metric__val { color: var(--ui-success-fg); }
231
+ .ui-metric--warning { border-color: color-mix(in srgb, #c99a2e 40%, transparent); }
232
+ .ui-metric--warning .ui-metric__val { color: var(--ui-warning-fg); }
233
+ .ui-metric--danger { border-color: color-mix(in srgb, var(--gemboss-danger-strong) 40%, transparent); }
234
+ .ui-metric--danger .ui-metric__val { color: var(--ui-danger-fg); }
235
+
236
+ /* ── 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ơ.
237
+ Lưới tự giãn: mỗi ô ≥ 160px, xuống dòng khi hẹp. */
238
+ .ui-dl { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px 16px; margin: 0; }
239
+ .ui-dl > div { min-width: 0; }
240
+ .ui-dl dt { font-size: var(--gemboss-fs-caption); color: var(--ui-text-muted); }
241
+ .ui-dl dd { margin: 4px 0 0; font-weight: 600; color: var(--ui-text); overflow-wrap: anywhere; }
242
+
243
+ /* ── 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
244
+ 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.
245
+ 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. */
246
+ .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); }
247
+ .ui-notice--warning { background: var(--gemboss-warning-soft); border-color: var(--gemboss-warning-border); color: var(--gemboss-warning); }
248
+ .ui-notice--danger { background: var(--gemboss-danger-soft); border-color: var(--gemboss-danger-border); color: var(--gemboss-danger-strong); }
249
+ .ui-notice--success { background: var(--gemboss-success-soft); border-color: var(--gemboss-success-border); color: var(--gemboss-success-strong); }
250
+
251
+ /* ── 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>
252
+ <a href rel="next">Sau ›</a></nav>`. Link ≥ 32px (≥ 24px WCAG 2.5.8), 44px dưới ngón tay (khối coarse). */
253
+ .ui-pager { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 16px 0; }
254
+ .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); }
255
+ .ui-pager > a:hover { background: var(--ui-surface-2); }
256
+ .ui-pager > a:focus-visible { outline: 2px solid var(--p-color-border-focus, #005bd3); outline-offset: 1px; }
257
+ .ui-pager > [aria-current="page"] { color: var(--ui-text-muted); font-size: var(--gemboss-fs-caption); }
258
+
167
259
  /* ── Badge ────────────────────────────────────────────────────────────────────
168
260
  Uppercase mono pill for a status/label (was gemboss-admin-storage-badge). */
169
261
  .ui-badge {
@@ -208,24 +300,50 @@
208
300
  consumer belongs in that consumer's chunk (see SettingsDialog.module.css). */
209
301
  .ui-modal--lg { position: relative; }
210
302
 
303
+ /* The kit's multi-line field joins the ONE field family (01/10/2026, Chris: "form control đồng bộ"):
304
+ same input-border token, hover and focus ring as `gemboss-field` and `.ui-select`. Before, it drew a
305
+ lighter --ui-border box at 14px with no focus ring, so on GemFactory's compose form a textarea sat
306
+ beside selects of a darker border and text fields of a third look (measured on prod). */
211
307
  .ui-textarea {
212
308
  width: 100%; min-height: 96px; resize: vertical; box-sizing: border-box;
213
- background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius-md);
214
- padding: 12px; color: var(--ui-text); font-size: 14px; font-family: inherit; outline: none;
309
+ background: var(--p-color-input-bg-surface, var(--gemboss-field-bg));
310
+ border: 1px solid var(--p-color-input-border, var(--gemboss-field-line)); border-radius: var(--ui-radius-md);
311
+ padding: 12px; color: var(--p-color-text, var(--gemboss-field-ink)); font-size: var(--ui-fs-body); line-height: 1.5; font-family: inherit;
312
+ }
313
+ .ui-textarea::placeholder { color: var(--p-color-text-secondary, var(--gemboss-field-placeholder)); }
314
+ .ui-textarea:hover:not(:disabled) {
315
+ border-color: var(--p-color-input-border-hover, var(--gemboss-field-line-hover));
316
+ background-color: var(--p-color-input-bg-surface-hover, var(--gemboss-field-bg-hover));
317
+ }
318
+ .ui-textarea:focus { outline: none; }
319
+ .ui-textarea:focus-visible:not(:disabled) {
320
+ outline: 2px solid var(--p-color-border-focus, #005bd3); outline-offset: 1px;
321
+ border-color: var(--p-color-input-border-active, var(--gemboss-field-line-active));
322
+ }
323
+ .ui-textarea:disabled { opacity: .5; cursor: not-allowed; }
324
+ /* On a PHONE-WIDTH screen every field of the family reads 16px (iOS zooms the page on tap below that)
325
+ and a select is a 44px target — by WIDTH, as `gemboss-field` does, because a desktop browser at 320px
326
+ still reports pointer: fine. Measured on GemFactory at 390px before this: select 13px, textarea 14px. */
327
+ @media (max-width: 47.9975em) {
328
+ .ui-select.ui-select, .ui-textarea { font-size: 16px; }
329
+ /* Height only for the full select: `--slim` is a density decision (table rows), as the coarse block keeps it. */
330
+ .ui-select:not(.ui-select--slim) { min-height: 44px; }
215
331
  }
216
- .ui-textarea:focus { border-color: var(--ui-accent); }
217
332
 
218
333
  /* ── Segmented control ────────────────────────────────────────────────────────
219
334
  Grouped toggle: icon squares (.ui-seg button) or text tabs (.is-text). The
220
335
  selected segment uses the accent-soft state. */
221
336
  .ui-seg { display: inline-flex; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-md); overflow: hidden; }
222
- .ui-seg button {
337
+ .ui-seg :is(button, a) {
223
338
  display: inline-grid; place-items: center; width: 36px; height: 36px;
224
339
  border: 0; background: transparent; color: var(--ui-text-muted); cursor: pointer; font-family: inherit;
225
340
  }
226
- .ui-seg button.is-text { width: auto; padding: 0 12px; font-size: var(--ui-fs-body); font-weight: 500; }
227
- .ui-seg button + button { border-left: 1px solid var(--ui-border); }
228
- .ui-seg button:hover { background: var(--ui-surface-2); color: var(--ui-text); }
341
+ .ui-seg :is(button, a).is-text { width: auto; padding: 0 12px; font-size: var(--ui-fs-body); font-weight: 500; }
342
+ .ui-seg :is(button, a) + :is(button, a) { border-left: 1px solid var(--ui-border); }
343
+ .ui-seg :is(button, a):hover { background: var(--ui-surface-2); color: var(--ui-text); }
344
+ /* Lọc / chế độ xem bằng LINK (trang server-render, chọn = đổi URL): `<a aria-current="page">` là ô đang chọn. */
345
+ .ui-seg a { display: inline-flex; align-items: center; justify-content: center; text-decoration: none; color: var(--ui-text-muted); }
346
+ .ui-seg a[aria-current="page"],
229
347
  .ui-seg button.active { background: var(--ui-accent-soft); color: var(--ui-accent-strong); }
230
348
  .ui-seg svg { width: 16px; height: 16px; fill: currentColor; }
231
349
 
@@ -307,7 +425,9 @@
307
425
  decision, not an oversight; it gets its own measurement pass or none. */
308
426
  .ui-btn:not(.ui-btn--sm),
309
427
  .ui-stat.is-clickable,
310
- .ui-seg button { min-height: 44px; }
428
+ .ui-chips > a.ui-tag--chip,
429
+ .ui-pager > a,
430
+ .ui-seg :is(button, a) { min-height: 44px; }
311
431
  /* A clickable stat whose label is one short token ("7d" on /admin/gem-intelligence) measured 40x44
312
432
  in the 13/09/2026 sweep: the height floor held, the width did not. */
313
433
  .ui-stat.is-clickable { min-width: 44px; justify-content: center; }
@@ -316,7 +436,7 @@
316
436
  .ui-select.ui-select,
317
437
  .ui-select--slim.ui-select--slim { min-height: 44px; }
318
438
  /* The icon-only segment is 36 WIDE as well — a target is an area. */
319
- .ui-seg button { min-width: 44px; }
439
+ .ui-seg :is(button, a) { min-width: 44px; }
320
440
  }
321
441
 
322
442
  /* ── Skeleton ─────────────────────────────────────────────────────────────────
@@ -340,6 +460,46 @@
340
460
 
341
461
  }
342
462
 
463
+ /* ── Resizer: the seam you drag between two panels ────────────────────────────
464
+ One look for every split in the fleet: the admin's editors (chat | preview) today; made for gemcare's
465
+ 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
466
+ placed strip) belongs to the surface. While a drag runs the surface puts `ui-resizing` on <body>
467
+ (and may put `dragging` on the seam itself to keep its grip lit).
468
+ Markup: <div class="ui-resizer" role="separator" aria-orientation="vertical" tabindex="0"
469
+ aria-label="…" aria-valuenow aria-valuemin aria-valuemax>. Pointer events, not mouse events (touch
470
+ is a pointer); arrow keys move it — a focusable separator that ignores the keyboard is INERT. */
471
+ /* `touch-action: none` is what makes a FINGER drag reach this element at all. `preventDefault()` on
472
+ `pointerdown` does not stop panning: the browser hands the gesture to the nearest scroller and sends
473
+ `pointercancel`, so the seam jumps once and stops. */
474
+ .ui-resizer { cursor: col-resize; position: relative; touch-action: none; outline: none; }
475
+ /* The grip pill is invisible at rest (a clean seam) and fades in over the zone, on keyboard focus,
476
+ and while a drag runs. Focus shows the pill rather than a ring: on a 5px seam a ring is a smear. */
477
+ .ui-resizer::before {
478
+ content: ""; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
479
+ width: 4px; height: 44px; border-radius: var(--gemboss-radius-full);
480
+ background: var(--gemboss-primary);
481
+ opacity: 0;
482
+ transition: opacity 0.14s ease, height 0.14s ease;
483
+ }
484
+ .ui-resizer:hover::before,
485
+ .ui-resizer:focus-visible::before,
486
+ .ui-resizer.dragging::before,
487
+ body.ui-resizing .ui-resizer::before { opacity: 1; height: 52px; }
488
+ /* A FINGER NEEDS MORE THAN 5px, and the line must still look like 5px. WCAG 2.5.8 puts the floor for
489
+ this kind of target at 24px, so the HIT area grows and the visible seam does not — widening the
490
+ element itself would also move the columns. Only where the pointer is coarse. */
491
+ @media (pointer: coarse) {
492
+ .ui-resizer::after {
493
+ content: ""; position: absolute; top: 0; bottom: 0; left: -14px; right: -14px;
494
+ touch-action: none; /* the band must claim the gesture too, or the scroller still wins */
495
+ z-index: 2; /* above the panels either side, transparent */
496
+ }
497
+ }
498
+ body.ui-resizing, body.ui-resizing * { cursor: col-resize !important; user-select: none !important; }
499
+ /* While dragging, a full-viewport transparent shield sits above everything — iframes included — so the
500
+ pointer stream is never captured by a cross-document frame (the classic "stuck cursor"). */
501
+ body.ui-resizing::after { content: ""; position: fixed; inset: 0; z-index: 9999; cursor: col-resize; }
502
+
343
503
  /* ── Tabs · Table · Empty state ───────────────────────────────────────────────
344
504
  Plain-CSS primitives for surfaces that are NOT React (gemcare's vanilla dashboard reads this
345
505
  file through `npm run export:ui-css`). The React app has Polaris <Tabs>/<DataTable>/<EmptyState>
@@ -354,7 +514,8 @@
354
514
  display: flex; gap: var(--ui-space-1); border-bottom: 1px solid var(--ui-border);
355
515
  overflow-x: auto; scrollbar-width: none;
356
516
  }
357
- .ui-tabs > button {
517
+ .ui-tabs > button,
518
+ .ui-tabs > a {
358
519
  position: relative; flex: 0 0 auto; display: inline-flex; align-items: center; gap: var(--ui-space-2);
359
520
  min-height: 44px; padding: 0 var(--ui-space-3); border: 0; background: transparent; cursor: pointer;
360
521
  border-radius: var(--ui-radius-sm) var(--ui-radius-sm) 0 0;
@@ -362,9 +523,15 @@
362
523
  color: var(--ui-text-muted);
363
524
  transition: color var(--gemboss-transition-fast) ease, background-color var(--gemboss-transition-fast) ease;
364
525
  }
365
- .ui-tabs > button:hover { color: var(--ui-text); background: var(--ui-surface-2); }
366
- .ui-tabs > button:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: -2px; }
526
+ .ui-tabs > button:hover,
527
+ .ui-tabs > a:hover { color: var(--ui-text); background: var(--ui-surface-2); }
528
+ .ui-tabs > button:focus-visible,
529
+ .ui-tabs > a:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: -2px; }
530
+ .ui-tabs > a { text-decoration: none; }
531
+ /* 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. */
532
+ .ui-tabs > a[aria-current="page"],
367
533
  .ui-tabs > button[aria-selected="true"] { color: var(--ui-text-strong); font-weight: 600; }
534
+ .ui-tabs > a[aria-current="page"]::after,
368
535
  .ui-tabs > button[aria-selected="true"]::after {
369
536
  content: ""; position: absolute; left: var(--ui-space-3); right: var(--ui-space-3); bottom: 0;
370
537
  height: 2px; border-radius: var(--gemboss-radius-full); background: var(--ui-accent);
@@ -390,6 +557,12 @@
390
557
  .ui-table tbody tr:last-child > td { border-bottom: 0; }
391
558
  .ui-table tbody tr:hover { background: var(--ui-surface-2); }
392
559
  .ui-table .is-num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
560
+ /* 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>`.
561
+ Trước đây mỗi surface tự viết `.muted` riêng trong ô bảng (partner-radar, gemwatcher). */
562
+ /* 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).
563
+ Chữ 13px cho hộp 17px; visual-gate đo đỏ ở partner-radar /competitors 01/10/2026. inline-flex giữ nó trên dòng. */
564
+ .ui-table td > a { display: inline-flex; align-items: center; min-height: 24px; }
565
+ .ui-table .ui-sub { display: block; margin-top: 2px; font-size: var(--gemboss-fs-caption); color: var(--ui-text-muted); font-weight: 400; }
393
566
 
394
567
  /* Empty state: `<div class="ui-empty"><p class="ui-empty__title">…</p><p class="ui-empty__body">…</p>
395
568
  <button class="ui-btn">…</button></div>`. Says what is missing and what to do next. */