@cahyo-dimas/freeday 3.1.0 → 3.2.0

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/CHANGELOG.md CHANGED
@@ -9,6 +9,76 @@ benar. Perubahan seperti itu ditulis di bawah `### Changed: BREAKING (types)`
9
9
  lama → tipe baru, dan cara menyempitkannya — bukan di bawah `### Added`, betapapun aditifnya dari
10
10
  sisi kit.
11
11
 
12
+ ## [3.2.0] - 2026-08-28
13
+
14
+ **Dua cacat yang tak bisa di-screenshot, dan satu komponen yang akhirnya muat di panel 420px.**
15
+ Ketiganya datang dari layar sungguhan. Yang menyatukannya: tak satu pun terlihat di DOM atau di
16
+ stylesheet — yang satu hanya bergerak, yang satu hanya terbaca, yang satu hanya muat atau tidak.
17
+
18
+ ### Fixed
19
+ - **Strip tab tak lagi menggulung vertikal, dan garis aktifnya kini benar-benar 2px** (`#052`, dari
20
+ `IDU_AI_DOC_SAPB1_CLIENT`). Dilaporkan pengguna yang bisa merasakannya tapi tak bisa memotretnya:
21
+ *"panel tab ini menggulung vertikal, susah di-screenshot."* Memang begitu — **1px**.
22
+ `overflow-x:auto` tak bisa meminta satu sumbu saja (CSS Overflow §3: sumbu lain yang `visible`
23
+ ikut jadi `auto`), jadi 1px yang digantungkan `margin-bottom:-1px` milik tab — yang ada justru
24
+ untuk mengangkat underline 2px-nya ke atas garis strip — berubah jadi scrollport sungguhan yang
25
+ memakan gestur trackpad. Yang tak dilaporkan siapa pun, dan hanya piksel yang bisa membedakannya:
26
+ scroll container **memotong** keturunannya di padding box, jadi separuh bawah underline itu ikut
27
+ terpotong. Underline aktif terkirim **1px** selama negative margin itu ada — CSS-nya bilang 2px,
28
+ DOM-nya bilang 2px, layarnya bilang 1. Garis strip kini `box-shadow` inset (dicat di kotaknya,
29
+ bukan di konten yang menggulung), dan tab tak lagi menggantung. **Bagi konsumen yang menyembunyikan
30
+ garis itu:** `border-bottom:0` tak lagi menjangkaunya, pakai `box-shadow:none`. Sapuan ke seluruh
31
+ scroller lain (`.fdy-table-scroll`, `.fdy-nav--horizontal`, `.fdy-carousel__viewport`) bersih —
32
+ `.fdy-tabs__list` satu-satunya.
33
+ - **`--color-text-subtle` gelap naik ke `slate-450`** (`#053` §2, dari
34
+ `IDU_AI_DOC_SAPB1_ADDON_DESKTOP_APP`). 4.02:1 di `--color-surface` gelap, di bawah AA, dan yang
35
+ memakainya bukan cuma app: **placeholder kit sendiri** (`.fdy-datepicker__value--placeholder` dan
36
+ sebelas `.fdy-cal__day.is-outside` dalam satu kalender). Yang menarik, tokennya tak melanggar
37
+ kontraknya — gerbang kontras memang menguji tier ini di **3:1 sebagai non-teks**; yang salah
38
+ adalah kit memakai tier dekoratif untuk teks yang berutang AA. Sekarang **4.88:1** di
39
+ `--color-surface`, yaitu permukaan yang dicat setiap `.fdy-input`, jadi placeholder lolos di tempat
40
+ placeholder benar-benar duduk. Light tak disentuh (sudah 4.69). **AA di ketiga permukaan gelap
41
+ sengaja tidak dikejar**: mencapainya butuh nada sejengkal dari `slate-400`, yang *adalah*
42
+ `--color-text-muted`, dan subtle yang sama dengan muted bukan tier lagi.
43
+
44
+ ### Added
45
+ - **`FdyCfl` punya layout sempit, tanpa satu pun prop baru** (`#053` §1). Di bawah **30rem**,
46
+ barisnya menata diri sebagai list item: kolom pertama jadi judul, sisanya satu baris detail di
47
+ bawahnya, `<thead>` dilepas. **Container query, bukan media query** — dialog bukan viewport, dan
48
+ komponen yang sama adalah tabel di halaman desktop sekaligus daftar di dalam panel 420px yang
49
+ ditambatkan di SAP Business One. Kontrak datanya tak berubah: `columns` sudah menyebut kolom mana
50
+ yang identitas. Keempat stack dapat sekaligus, karena yang bekerja CSS.
51
+ - **`size="cfl"` di `FdyModal`** (`#053` §4), Vue · React · Blazor. `.fdy-modal--cfl` (46rem) sudah
52
+ ada di CSS sejak lama tapi tak terjangkau dari adapter mana pun; `FdyCfl` memasang kelasnya
53
+ sendiri, jadi celah ini hanya muncul bagi yang membangun dialog sendiri selebar itu. **Bukan
54
+ breaking** meski union publiknya melebar: kebijakan 2.1.0 menyasar tipe di posisi *keluaran*
55
+ (handler yang menerima nilai lebih lebar); ini masukan, dan pemanggil yang mengirim nilai lama
56
+ tetap valid.
57
+
58
+ ### Added: guards
59
+ - `browser/tabs-strip.mjs` — nol overflow vertikal, underline 2px terbaca dari **piksel** yang
60
+ benar-benar dicat, dan gulir horizontal yang tetap hidup. Diverifikasi mutasi: dengan aturan 3.1.0
61
+ dikembalikan, 2 dari 3 merah.
62
+ - `browser/cfl-narrow.mjs` — menjalankan `FdyCfl.vue` sungguhan di viewport **420px**, lebar
63
+ konsumen yang melaporkannya, karena container query menanyakan lebar *dialog* dan hanya dialog
64
+ sungguhan yang punya lebar.
65
+ - `test/contrast.test.mjs` dapat pasangan baru khusus placeholder, terpisah dari tier dekoratifnya,
66
+ jadi kedua paruh keputusan itu dijaga: gagal di 4.02 kalau tokennya dikembalikan, dan tetap 3:1
67
+ untuk chevron/separator.
68
+
69
+ ### Docs
70
+ - `COMPONENTS.md`: kontrak override garis strip tab, layout sempit `FdyCfl`, dan `size="cfl"`.
71
+ - `USAGE.md`: `.fdy-text-subtle` akhirnya masuk tabel peran teks — tabel itu berhenti di "muted",
72
+ padahal kelasnya publik — beserta kontrak kontrasnya di kedua tema.
73
+
74
+ ### Tidak diperbaiki, dan alasannya
75
+ - **`--color-text-muted` 3.94:1 di `--color-surface-raised`** (`#053` §3) **tidak reproduksi.** Di
76
+ `soft`, `--color-surface-raised` *identik* dengan `--color-surface` (7.28:1); di `glass`,
77
+ komposit terburuknya 6.70:1; dan muted tak pernah turun di bawah **5.46:1** di mana pun dalam ramp
78
+ gelap. Angka 3.94 itu sendiri nyata: ia persis `slate-400` di atas `slate-700`, yaitu
79
+ `--color-border-strong` — token border yang tak dipakai komponen mana pun sebagai latar. Item ini
80
+ dibiarkan **open** dengan pertanyaan balik ke pelapor, bukan ditutup sebagai withdrawn.
81
+
12
82
  ## [3.1.0] - 2026-08-27
13
83
 
14
84
  **Dua cacat lebar yang dilaporkan dari layar sungguhan, dan keduanya tak kelihatan di DOM.** Markup-nya
package/COMPONENTS.md CHANGED
@@ -640,6 +640,15 @@ child `<ul>` is a branch, one without is a leaf. Needs `freeday-cascade.js`.
640
640
  ## Choose-from-list (CFL) — `data-fdy-cfl`
641
641
  > **Typed wrapper: `<FdyCfl>`**. Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
642
642
 
643
+ > **Narrow: the results stack, and there is no prop for it** (`#053`). Under a `.fdy-cfl__results`
644
+ > narrower than **30rem** — a docked add-on panel, a phone — the rows lay out as list items instead
645
+ > of table cells: the first column becomes the title, the remaining columns become one detail line
646
+ > under it, and `<thead>` is dropped. It is a **container** query, so what decides is the dialog's
647
+ > width and not the viewport's; the same component is a table on a desktop page and a list inside a
648
+ > 420px panel, with no flag to pass and nothing to keep in sync. Your data contract does not change:
649
+ > `columns`, `display` and `rowKey` already say which column is the identity. If a screen genuinely
650
+ > needs columns at that width, override the `@container` block — do not widen the dialog.
651
+
643
652
  ### Props — `<FdyCfl>`
644
653
 
645
654
  Four of these are required and carry the whole component: `fetchPage`, `columns`, `display`,
@@ -1496,6 +1505,16 @@ APG tabs: ←/→, Home/End, roving tabindex. Needs `freeday-tabs.js`.
1496
1505
  `.fdy-tabs__tab` marks its active state from **`aria-selected="true"` or `aria-current="page"`**,
1497
1506
  so the same look serves routed sub-navigation built from plain links. See the note under Nav.
1498
1507
 
1508
+ > **The strip's line is an inset `box-shadow`, not a `border-bottom`** (`#052`; through 3.1.0 it
1509
+ > was a border, so an app upgrading past that release changes the override with it). To
1510
+ > remove it — a strip inside a header that draws its own rule — override `box-shadow:none`;
1511
+ > `border-bottom:0` no longer reaches it. The swap is not cosmetic: `.fdy-tabs__list` scrolls
1512
+ > horizontally, a scroll container clips its descendants to the padding box, and the tab's
1513
+ > `margin-bottom:-1px` (which lifted its 2px underline onto that border) therefore pushed the
1514
+ > underline's lower half into the clip — the underline painted 1px, and the strip carried 1px of
1515
+ > vertical scroll that ate trackpad gestures. A shadow paints on the box, not on the scrolled
1516
+ > content, so the line needs no overhang to sit under. Guarded in `browser/tabs-strip.mjs`.
1517
+
1499
1518
  ```html
1500
1519
  <div class="fdy-tabs" data-fdy-tabs>
1501
1520
  <div class="fdy-tabs__list" role="tablist" aria-label="Invoice detail">
@@ -1521,7 +1540,7 @@ so the same look serves routed sub-navigation built from plain links. See the no
1521
1540
  | `open` | `boolean` | Controlled: your state decides. The wrapper cancels the native `cancel` event so the DOM can never disagree with it. |
1522
1541
  | `title` | `string` (Vue) · `ReactNode` | Names the dialog through `aria-labelledby`. Vue also offers a `title` slot when the heading needs markup. |
1523
1542
  | `onClose` | `() => void` | React. Vue emits `close`. Fires from Escape, a backdrop click and the ×, all three of which exist only under `dismissible`. |
1524
- | `size?` | `'sm' \| 'md' \| 'lg' \| 'wide'` | Dialog width. |
1543
+ | `size?` | `'sm' \| 'md' \| 'lg' \| 'wide' \| 'cfl'` | Dialog width. `cfl` is the 46rem choose-from-list width; `FdyCfl` applies it itself, so pass it only for a dialog you build at that width. |
1525
1544
  | `dismissible?` | `boolean` | Whether Escape, the backdrop and the × can close it. Off means the reader has to take a footer action, which is the point of a blocking confirm. |
1526
1545
  | `closeLabel?` | `string` | Accessible name for the × button. Default `Close`. |
1527
1546
  | `footer?` | `ReactNode` | React. Vue: the `footer` slot. |
package/README.id.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **Lebih banyak _free day_ buat dev. UI kit-nya sudah siap pakai.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v3.1.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.1.0)
8
+ [![Release](https://img.shields.io/badge/release-v3.2.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.2.0)
9
9
 
10
10
  UI KIT yang token-driven & framework-agnostic. Satu sumber kebenaran untuk warna, tipografi,
11
11
  spasi, dan komponen. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs. The UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v3.1.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.1.0)
8
+ [![Release](https://img.shields.io/badge/release-v3.2.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.2.0)
9
9
 
10
10
  A token-driven, framework-agnostic UI kit. One source of truth for color, typography,
11
11
  spacing, and components. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
package/USAGE.md CHANGED
@@ -23,10 +23,20 @@ the single biggest cause of "flat grey mush". Pick the role, not the size:
23
23
  | Card / row title | `.fdy-title-card` (or `.fdy-card__title`) | A title inside a card or list row (`<h3>`). |
24
24
  | Body | *(default)* | Running text. |
25
25
  | Muted / caption | `.fdy-text-muted`, `.fdy-text-caption` | Secondary text, timestamps, help. |
26
+ | Subtle | `.fdy-text-subtle` | The faintest ink. Placeholders and decoration — chevrons, separators, out-of-month days. **Read the contract below before using it for a sentence.** |
26
27
 
27
28
  Everything else is body. If you reach for a fourth title size, you probably need a section, not a
28
29
  font size.
29
30
 
31
+ **`--color-text-subtle` is not a third body-text tier, and dark is where that bites.** It clears AA
32
+ on `--color-surface` in both themes (light 4.69:1, dark 4.88:1) — which is what `.fdy-input` and
33
+ `.fdy-textarea` paint, so a placeholder is safe. It clears AA on **nothing else**, in either theme:
34
+ `--color-surface-3` reads 4.11:1 in light and 3.66:1 in dark. Nor will it ever: reaching 4.5 there
35
+ needs a tone within a hair of `--color-text-muted`, and a subtle that equals muted is not a tier. So a small uppercase
36
+ label, a card's second line, or anything a reader must actually read on a raised or tinted surface
37
+ takes `--color-text-muted`. `test/contrast.test.mjs` holds both halves of that split, which is why
38
+ the ramp cannot quietly drift into either mistake.
39
+
30
40
 
31
41
  ## Borders: two roles, two contrast contracts
32
42
 
@@ -19,7 +19,7 @@ public partial class FdyModal
19
19
  /// <summary>Optional footer (action buttons). Omitted entirely when null.</summary>
20
20
  [Parameter] public RenderFragment? FooterContent { get; set; }
21
21
 
22
- /// <summary>Size modifier: <c>sm</c> | <c>md</c> | <c>lg</c> | <c>wide</c>.</summary>
22
+ /// <summary>Size modifier: <c>sm</c> | <c>md</c> | <c>lg</c> | <c>wide</c> | <c>cfl</c> (the 46rem choose-from-list width).</summary>
23
23
  [Parameter] public string? Size { get; set; }
24
24
 
25
25
  /// <summary>When true (default), Esc and a backdrop click dismiss the dialog and a close
@@ -13,7 +13,8 @@ export interface FdyModalProps {
13
13
  open: boolean;
14
14
  title: ReactNode;
15
15
  onClose: () => void;
16
- size?: 'sm' | 'md' | 'lg' | 'wide';
16
+ /* 'cfl' is the choose-from-list width (46rem); see the Vue wrapper for why it was missing. */
17
+ size?: 'sm' | 'md' | 'lg' | 'wide' | 'cfl';
17
18
  dismissible?: boolean;
18
19
  /** aria-label for the × button. Default 'Close'. */
19
20
  closeLabel?: string;
@@ -16,7 +16,10 @@ import { computed, onMounted, useId, watch, type ComputedRef, type Ref, ref } fr
16
16
  const props = withDefaults(defineProps<{
17
17
  open: boolean;
18
18
  title: string;
19
- size?: 'sm' | 'md' | 'lg' | 'wide';
19
+ /* 'cfl' is the width the choose-from-list dialog uses (46rem). It was reachable from the CSS
20
+ and from nowhere else until #053: FdyCfl sets the class itself, so the gap only ever showed up
21
+ when an app built its own dialog at that width and had to hand-write the class. */
22
+ size?: 'sm' | 'md' | 'lg' | 'wide' | 'cfl';
20
23
  dismissible?: boolean;
21
24
  /** aria-label for the × button. Default 'Close'. Blazor has had `CloseLabel` since it
22
25
  * shipped; these two hard-coded the string, so a non-English app could not rename it. */
@@ -323,7 +323,7 @@
323
323
  --color-surface-raised: var(--color-surface);
324
324
  --color-text: var(--slate-100);
325
325
  --color-text-muted: var(--slate-400);
326
- --color-text-subtle: var(--slate-500);
326
+ --color-text-subtle: var(--slate-450);
327
327
  --color-on-brand: var(--slate-0);
328
328
  --color-border: var(--slate-800);
329
329
  --color-border-strong: var(--slate-700);
@@ -387,7 +387,7 @@
387
387
  --color-surface-raised: var(--color-surface);
388
388
  --color-text: var(--slate-100);
389
389
  --color-text-muted: var(--slate-400);
390
- --color-text-subtle: var(--slate-500);
390
+ --color-text-subtle: var(--slate-450);
391
391
  --color-on-brand: var(--slate-0);
392
392
  --color-border: var(--slate-800);
393
393
  --color-border-strong: var(--slate-700);
@@ -1470,9 +1470,16 @@ a { color: var(--color-primary); }
1470
1470
  /* Search bar, single leading-icon field; Enter/typing filters the results. */
1471
1471
  .fdy-cfl__search{flex:none;padding:var(--space-4) var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
1472
1472
 
1473
- /* Results, dense rows, sticky header, its own scroll region. */
1474
- .fdy-cfl__results{overflow:auto;max-height:min(24rem,50vh);}
1473
+ /* Results, dense rows, sticky header, its own scroll region.
1474
+ *
1475
+ * `container-type: inline-size` makes this box the query container for the stacked layout below.
1476
+ * Safe here and nowhere near free in general: a size container stops contributing intrinsic inline
1477
+ * size, which is what made `.fdy-stats--inline` collapse to three zero tracks in 3.1.0. This box
1478
+ * takes its width from the dialog (`min(46rem, 100vw - …)`, an explicit width) and already scrolls
1479
+ * its own content, so it never sized to the table to begin with. */
1480
+ .fdy-cfl__results{container:fdy-cfl / inline-size;overflow:auto;max-height:min(24rem,50vh);}
1475
1481
  .fdy-cfl__results .fdy-table th{position:sticky;top:0;z-index:1;}
1482
+
1476
1483
  .fdy-cfl__row{cursor:pointer;}
1477
1484
  .fdy-cfl__row:hover{background:var(--color-surface-2);}
1478
1485
  .fdy-cfl__row:focus-visible{outline:none;background:var(--color-primary-soft);box-shadow:inset 2px 0 0 var(--color-primary);}
@@ -1490,6 +1497,54 @@ a { color: var(--color-primary); }
1490
1497
  .fdy-cfl__count{font-size:var(--text-sm);color:var(--color-text-muted);}
1491
1498
  .fdy-cfl__actions{display:flex;gap:var(--space-2);}
1492
1499
 
1500
+ /* Narrow: the same rows as list items, not table cells (#053).
1501
+ *
1502
+ * A 420px add-on panel gives this dialog ~372px. A three- or four-column master-data table in that
1503
+ * space either squeezes every column below legibility or scrolls on both axes, in a dialog whose
1504
+ * whole job is "search, look, click one". So the first cell becomes the title and the rest become
1505
+ * one detail line under it — the shape a picker takes when it is narrow.
1506
+ *
1507
+ * A CONTAINER query, not a media query: the dialog is not the viewport. The same component is wide
1508
+ * on a desktop page and narrow inside a docked panel, and only the container knows which.
1509
+ *
1510
+ * No ARIA roles come with this, and that is a MEASURED decision rather than an omission. The
1511
+ * received wisdom from the responsive-table era is that `display:block` on a table box drops its
1512
+ * implicit role; explicit `role="table"/"row"/"cell"` were written into all four stacks here on
1513
+ * that belief and then taken out again, because Chrome's own accessibility tree says otherwise:
1514
+ * with the rows at `display:flex`, stripping every `role` attribute leaves
1515
+ * `table → row → cell` intact (Chromium 133; only `rowgroup`, which associates nothing, is lost).
1516
+ * Redundant ARIA is what the kit's own rule calls worse than none, so the guard asserts the
1517
+ * OUTCOME — that a stacked row is still a row in the a11y tree — and stays silent about how.
1518
+ * `browser/cfl-narrow.mjs` is where a future engine that does dissolve them shows up as red.
1519
+ *
1520
+ * Column headers ARE a real loss here, and a deliberate one: `<thead>` is hidden, because a header
1521
+ * row that costs a third of the width buys nothing when each row is labelled by its own title. */
1522
+ @container fdy-cfl (max-width: 30rem){
1523
+ .fdy-cfl__results .fdy-table,
1524
+ .fdy-cfl__results .fdy-table tbody{display:block;width:100%;}
1525
+ .fdy-cfl__results .fdy-table thead{display:none;}
1526
+ .fdy-cfl__row{display:flex;flex-wrap:wrap;align-items:baseline;column-gap:var(--space-2);padding:var(--space-3) var(--space-4);border-bottom:var(--bw) solid var(--color-border-muted);}
1527
+ .fdy-cfl__results .fdy-table tbody tr:last-child{border-bottom:0;}
1528
+ .fdy-cfl__row > td{display:block;padding:0;border:0;}
1529
+ /* The title takes the whole first line, which is what pushes every remaining cell onto the second
1530
+ one — no selector has to know how many columns there are, or which of them is last. */
1531
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check),
1532
+ .fdy-cfl__row > .fdy-cfl__check + td{font-weight:var(--weight-semibold);color:var(--color-text);}
1533
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check){flex:0 0 100%;}
1534
+ /* Multi-select: the checkbox shares line one with the title, so the title claims the rest of it. */
1535
+ .fdy-cfl__row > .fdy-cfl__check + td{flex:1 0 calc(100% - var(--space-6));}
1536
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check) ~ td,
1537
+ .fdy-cfl__row > .fdy-cfl__check + td ~ td{font-size:var(--text-xs);color:var(--color-text-muted);}
1538
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check) ~ td:empty,
1539
+ .fdy-cfl__row > .fdy-cfl__check + td ~ td:empty{display:none;}
1540
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check) ~ td::before,
1541
+ .fdy-cfl__row > .fdy-cfl__check + td ~ td::before{content:"·";margin-right:var(--space-2);color:var(--color-text-subtle);}
1542
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check) + td::before,
1543
+ .fdy-cfl__row > .fdy-cfl__check + td + td::before{content:none;}
1544
+ /* The checkbox keeps its own line position beside the title rather than above it. */
1545
+ .fdy-cfl__row > .fdy-cfl__check{flex:none;width:auto;align-self:center;}
1546
+ }
1547
+
1493
1548
  /* Freeday. Charts (zero-dependency CSS/SVG, rendered by freeday-chart.js).
1494
1549
  * Colour comes from --fdy-chart (a token, set inline by the renderer from data-fdy-color). */
1495
1550
 
@@ -2428,9 +2483,24 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
2428
2483
  .fdy-table td>.fdy-input,.fdy-table td>.fdy-textarea,.fdy-table td>.fdy-combo,.fdy-table td>.fdy-input-group,.fdy-table td>.fdy-datepicker,.fdy-table td>.fdy-timepicker{min-width:7rem;}
2429
2484
 
2430
2485
  /* Freeday. Tabs (WAI-ARIA APG) */
2431
- /* position:relative, containing block for out-of-flow content in the tabs (see base.css). */
2432
- .fdy-tabs__list{position:relative;display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
2433
- .fdy-tabs__tab{appearance:none;border:0;background:transparent;cursor:pointer;white-space:nowrap;font-family:var(--font-body);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);padding:var(--space-3) var(--space-4);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
2486
+ /* position:relative, containing block for out-of-flow content in the tabs (see base.css).
2487
+ *
2488
+ * The strip's own line is an INSET SHADOW, not a border, and the tab carries no negative margin
2489
+ * (#052). The pair those two replace — `border-bottom` here plus `margin-bottom:-1px` on the tab,
2490
+ * to lift the active tab's 2px underline onto the strip's 1px line so the two read as one — was
2491
+ * broken by the `overflow-x:auto` beside it, in two ways at once:
2492
+ * 1. CSS Overflow §3 gives no way to scroll one axis alone: `overflow-y` computes from `visible`
2493
+ * to `auto` because the other axis is not visible, so the 1px the tab hangs below the content
2494
+ * box became a real scrollport. Measured 47 client / 48 scroll, `scrollTop` settling at 1 —
2495
+ * invisible in a screenshot, and a trackpad gesture over the strip is eaten by it.
2496
+ * 2. A scroll container clips its descendants to the padding box, so the pixel the negative
2497
+ * margin pushed out was clipped away and the strip's own line painted in its place: the
2498
+ * active underline rendered ONE pixel of primary, never the two it asks for.
2499
+ * An inset shadow paints on the box, not on the scrolled content, so the line stays put while the
2500
+ * tabs scroll sideways, and the tab's own border-bottom paints over it — same look, no overflow.
2501
+ * To hide the line, override `box-shadow`, not `border-bottom` (guarded in browser/tabs-strip.mjs). */
2502
+ .fdy-tabs__list{position:relative;display:flex;gap:var(--space-1);box-shadow:inset 0 calc(var(--bw) * -1) 0 var(--color-border);overflow-x:auto;}
2503
+ .fdy-tabs__tab{appearance:none;border:0;background:transparent;cursor:pointer;white-space:nowrap;font-family:var(--font-body);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);padding:var(--space-3) var(--space-4);border-bottom:2px solid transparent;transition:color var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
2434
2504
  .fdy-tabs__tab:hover{color:var(--color-text);}
2435
2505
  .fdy-tabs__tab:disabled,.fdy-tabs__tab[aria-disabled="true"]{opacity:.5;cursor:not-allowed;color:var(--color-text-muted);}
2436
2506
  .fdy-tabs__tab:disabled:hover,.fdy-tabs__tab[aria-disabled="true"]:hover{color:var(--color-text-muted);}
package/dist/freeday.css CHANGED
@@ -592,9 +592,16 @@ a { color: var(--color-primary); }
592
592
  /* Search bar, single leading-icon field; Enter/typing filters the results. */
593
593
  .fdy-cfl__search{flex:none;padding:var(--space-4) var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
594
594
 
595
- /* Results, dense rows, sticky header, its own scroll region. */
596
- .fdy-cfl__results{overflow:auto;max-height:min(24rem,50vh);}
595
+ /* Results, dense rows, sticky header, its own scroll region.
596
+ *
597
+ * `container-type: inline-size` makes this box the query container for the stacked layout below.
598
+ * Safe here and nowhere near free in general: a size container stops contributing intrinsic inline
599
+ * size, which is what made `.fdy-stats--inline` collapse to three zero tracks in 3.1.0. This box
600
+ * takes its width from the dialog (`min(46rem, 100vw - …)`, an explicit width) and already scrolls
601
+ * its own content, so it never sized to the table to begin with. */
602
+ .fdy-cfl__results{container:fdy-cfl / inline-size;overflow:auto;max-height:min(24rem,50vh);}
597
603
  .fdy-cfl__results .fdy-table th{position:sticky;top:0;z-index:1;}
604
+
598
605
  .fdy-cfl__row{cursor:pointer;}
599
606
  .fdy-cfl__row:hover{background:var(--color-surface-2);}
600
607
  .fdy-cfl__row:focus-visible{outline:none;background:var(--color-primary-soft);box-shadow:inset 2px 0 0 var(--color-primary);}
@@ -612,6 +619,54 @@ a { color: var(--color-primary); }
612
619
  .fdy-cfl__count{font-size:var(--text-sm);color:var(--color-text-muted);}
613
620
  .fdy-cfl__actions{display:flex;gap:var(--space-2);}
614
621
 
622
+ /* Narrow: the same rows as list items, not table cells (#053).
623
+ *
624
+ * A 420px add-on panel gives this dialog ~372px. A three- or four-column master-data table in that
625
+ * space either squeezes every column below legibility or scrolls on both axes, in a dialog whose
626
+ * whole job is "search, look, click one". So the first cell becomes the title and the rest become
627
+ * one detail line under it — the shape a picker takes when it is narrow.
628
+ *
629
+ * A CONTAINER query, not a media query: the dialog is not the viewport. The same component is wide
630
+ * on a desktop page and narrow inside a docked panel, and only the container knows which.
631
+ *
632
+ * No ARIA roles come with this, and that is a MEASURED decision rather than an omission. The
633
+ * received wisdom from the responsive-table era is that `display:block` on a table box drops its
634
+ * implicit role; explicit `role="table"/"row"/"cell"` were written into all four stacks here on
635
+ * that belief and then taken out again, because Chrome's own accessibility tree says otherwise:
636
+ * with the rows at `display:flex`, stripping every `role` attribute leaves
637
+ * `table → row → cell` intact (Chromium 133; only `rowgroup`, which associates nothing, is lost).
638
+ * Redundant ARIA is what the kit's own rule calls worse than none, so the guard asserts the
639
+ * OUTCOME — that a stacked row is still a row in the a11y tree — and stays silent about how.
640
+ * `browser/cfl-narrow.mjs` is where a future engine that does dissolve them shows up as red.
641
+ *
642
+ * Column headers ARE a real loss here, and a deliberate one: `<thead>` is hidden, because a header
643
+ * row that costs a third of the width buys nothing when each row is labelled by its own title. */
644
+ @container fdy-cfl (max-width: 30rem){
645
+ .fdy-cfl__results .fdy-table,
646
+ .fdy-cfl__results .fdy-table tbody{display:block;width:100%;}
647
+ .fdy-cfl__results .fdy-table thead{display:none;}
648
+ .fdy-cfl__row{display:flex;flex-wrap:wrap;align-items:baseline;column-gap:var(--space-2);padding:var(--space-3) var(--space-4);border-bottom:var(--bw) solid var(--color-border-muted);}
649
+ .fdy-cfl__results .fdy-table tbody tr:last-child{border-bottom:0;}
650
+ .fdy-cfl__row > td{display:block;padding:0;border:0;}
651
+ /* The title takes the whole first line, which is what pushes every remaining cell onto the second
652
+ one — no selector has to know how many columns there are, or which of them is last. */
653
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check),
654
+ .fdy-cfl__row > .fdy-cfl__check + td{font-weight:var(--weight-semibold);color:var(--color-text);}
655
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check){flex:0 0 100%;}
656
+ /* Multi-select: the checkbox shares line one with the title, so the title claims the rest of it. */
657
+ .fdy-cfl__row > .fdy-cfl__check + td{flex:1 0 calc(100% - var(--space-6));}
658
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check) ~ td,
659
+ .fdy-cfl__row > .fdy-cfl__check + td ~ td{font-size:var(--text-xs);color:var(--color-text-muted);}
660
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check) ~ td:empty,
661
+ .fdy-cfl__row > .fdy-cfl__check + td ~ td:empty{display:none;}
662
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check) ~ td::before,
663
+ .fdy-cfl__row > .fdy-cfl__check + td ~ td::before{content:"·";margin-right:var(--space-2);color:var(--color-text-subtle);}
664
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check) + td::before,
665
+ .fdy-cfl__row > .fdy-cfl__check + td + td::before{content:none;}
666
+ /* The checkbox keeps its own line position beside the title rather than above it. */
667
+ .fdy-cfl__row > .fdy-cfl__check{flex:none;width:auto;align-self:center;}
668
+ }
669
+
615
670
  /* Freeday. Charts (zero-dependency CSS/SVG, rendered by freeday-chart.js).
616
671
  * Colour comes from --fdy-chart (a token, set inline by the renderer from data-fdy-color). */
617
672
 
@@ -1550,9 +1605,24 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1550
1605
  .fdy-table td>.fdy-input,.fdy-table td>.fdy-textarea,.fdy-table td>.fdy-combo,.fdy-table td>.fdy-input-group,.fdy-table td>.fdy-datepicker,.fdy-table td>.fdy-timepicker{min-width:7rem;}
1551
1606
 
1552
1607
  /* Freeday. Tabs (WAI-ARIA APG) */
1553
- /* position:relative, containing block for out-of-flow content in the tabs (see base.css). */
1554
- .fdy-tabs__list{position:relative;display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
1555
- .fdy-tabs__tab{appearance:none;border:0;background:transparent;cursor:pointer;white-space:nowrap;font-family:var(--font-body);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);padding:var(--space-3) var(--space-4);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
1608
+ /* position:relative, containing block for out-of-flow content in the tabs (see base.css).
1609
+ *
1610
+ * The strip's own line is an INSET SHADOW, not a border, and the tab carries no negative margin
1611
+ * (#052). The pair those two replace — `border-bottom` here plus `margin-bottom:-1px` on the tab,
1612
+ * to lift the active tab's 2px underline onto the strip's 1px line so the two read as one — was
1613
+ * broken by the `overflow-x:auto` beside it, in two ways at once:
1614
+ * 1. CSS Overflow §3 gives no way to scroll one axis alone: `overflow-y` computes from `visible`
1615
+ * to `auto` because the other axis is not visible, so the 1px the tab hangs below the content
1616
+ * box became a real scrollport. Measured 47 client / 48 scroll, `scrollTop` settling at 1 —
1617
+ * invisible in a screenshot, and a trackpad gesture over the strip is eaten by it.
1618
+ * 2. A scroll container clips its descendants to the padding box, so the pixel the negative
1619
+ * margin pushed out was clipped away and the strip's own line painted in its place: the
1620
+ * active underline rendered ONE pixel of primary, never the two it asks for.
1621
+ * An inset shadow paints on the box, not on the scrolled content, so the line stays put while the
1622
+ * tabs scroll sideways, and the tab's own border-bottom paints over it — same look, no overflow.
1623
+ * To hide the line, override `box-shadow`, not `border-bottom` (guarded in browser/tabs-strip.mjs). */
1624
+ .fdy-tabs__list{position:relative;display:flex;gap:var(--space-1);box-shadow:inset 0 calc(var(--bw) * -1) 0 var(--color-border);overflow-x:auto;}
1625
+ .fdy-tabs__tab{appearance:none;border:0;background:transparent;cursor:pointer;white-space:nowrap;font-family:var(--font-body);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);padding:var(--space-3) var(--space-4);border-bottom:2px solid transparent;transition:color var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
1556
1626
  .fdy-tabs__tab:hover{color:var(--color-text);}
1557
1627
  .fdy-tabs__tab:disabled,.fdy-tabs__tab[aria-disabled="true"]{opacity:.5;cursor:not-allowed;color:var(--color-text-muted);}
1558
1628
  .fdy-tabs__tab:disabled:hover,.fdy-tabs__tab[aria-disabled="true"]:hover{color:var(--color-text-muted);}
@@ -322,7 +322,7 @@
322
322
  --color-surface-raised: var(--color-surface);
323
323
  --color-text: var(--slate-100);
324
324
  --color-text-muted: var(--slate-400);
325
- --color-text-subtle: var(--slate-500);
325
+ --color-text-subtle: var(--slate-450);
326
326
  --color-on-brand: var(--slate-0);
327
327
  --color-border: var(--slate-800);
328
328
  --color-border-strong: var(--slate-700);
@@ -386,7 +386,7 @@
386
386
  --color-surface-raised: var(--color-surface);
387
387
  --color-text: var(--slate-100);
388
388
  --color-text-muted: var(--slate-400);
389
- --color-text-subtle: var(--slate-500);
389
+ --color-text-subtle: var(--slate-450);
390
390
  --color-on-brand: var(--slate-0);
391
391
  --color-border: var(--slate-800);
392
392
  --color-border-strong: var(--slate-700);
@@ -191,7 +191,7 @@ live docs also have a copy button per component.
191
191
  ```bash
192
192
  npm i @cahyo-dimas/freeday
193
193
  ```
194
- Lands in `package.json` as `"@cahyo-dimas/freeday": "^3.1.0"` (public npm package). `dist/` is
194
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^3.2.0"` (public npm package). `dist/` is
195
195
  committed and published → no build step; `npm ci` runs without auth.
196
196
 
197
197
  ### 2. Import the CSS + enhancers **once** in your entry (`src/main.ts`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Freeday: token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -92,7 +92,7 @@
92
92
  "scripts": {
93
93
  "build": "node tokens/build.mjs",
94
94
  "test": "node --test",
95
- "test:browser": "node --test --test-concurrency=3 browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs browser/card-stretch.mjs browser/text-override.mjs browser/control-heights.mjs browser/field-width.mjs browser/cfl-multi.mjs browser/crowding.mjs browser/over-dialog.mjs browser/chart-scale.mjs browser/overlay-stack.mjs browser/chart-a11y.mjs browser/app-shell.mjs browser/picker-states.mjs browser/harness-contract.mjs",
95
+ "test:browser": "node --test --test-concurrency=3 browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs browser/card-stretch.mjs browser/text-override.mjs browser/control-heights.mjs browser/field-width.mjs browser/cfl-multi.mjs browser/crowding.mjs browser/over-dialog.mjs browser/chart-scale.mjs browser/overlay-stack.mjs browser/chart-a11y.mjs browser/app-shell.mjs browser/picker-states.mjs browser/tabs-strip.mjs browser/cfl-narrow.mjs browser/harness-contract.mjs",
96
96
  "prepack": "node tokens/build.mjs",
97
97
  "version": "node tokens/build.mjs && git add dist",
98
98
  "typecheck:react": "tsc -p adapters/react/tsconfig.json --noEmit",
@@ -7,9 +7,16 @@
7
7
  /* Search bar, single leading-icon field; Enter/typing filters the results. */
8
8
  .fdy-cfl__search{flex:none;padding:var(--space-4) var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
9
9
 
10
- /* Results, dense rows, sticky header, its own scroll region. */
11
- .fdy-cfl__results{overflow:auto;max-height:min(24rem,50vh);}
10
+ /* Results, dense rows, sticky header, its own scroll region.
11
+ *
12
+ * `container-type: inline-size` makes this box the query container for the stacked layout below.
13
+ * Safe here and nowhere near free in general: a size container stops contributing intrinsic inline
14
+ * size, which is what made `.fdy-stats--inline` collapse to three zero tracks in 3.1.0. This box
15
+ * takes its width from the dialog (`min(46rem, 100vw - …)`, an explicit width) and already scrolls
16
+ * its own content, so it never sized to the table to begin with. */
17
+ .fdy-cfl__results{container:fdy-cfl / inline-size;overflow:auto;max-height:min(24rem,50vh);}
12
18
  .fdy-cfl__results .fdy-table th{position:sticky;top:0;z-index:1;}
19
+
13
20
  .fdy-cfl__row{cursor:pointer;}
14
21
  .fdy-cfl__row:hover{background:var(--color-surface-2);}
15
22
  .fdy-cfl__row:focus-visible{outline:none;background:var(--color-primary-soft);box-shadow:inset 2px 0 0 var(--color-primary);}
@@ -26,3 +33,51 @@
26
33
  .fdy-modal--cfl .fdy-modal__footer{justify-content:space-between;}
27
34
  .fdy-cfl__count{font-size:var(--text-sm);color:var(--color-text-muted);}
28
35
  .fdy-cfl__actions{display:flex;gap:var(--space-2);}
36
+
37
+ /* Narrow: the same rows as list items, not table cells (#053).
38
+ *
39
+ * A 420px add-on panel gives this dialog ~372px. A three- or four-column master-data table in that
40
+ * space either squeezes every column below legibility or scrolls on both axes, in a dialog whose
41
+ * whole job is "search, look, click one". So the first cell becomes the title and the rest become
42
+ * one detail line under it — the shape a picker takes when it is narrow.
43
+ *
44
+ * A CONTAINER query, not a media query: the dialog is not the viewport. The same component is wide
45
+ * on a desktop page and narrow inside a docked panel, and only the container knows which.
46
+ *
47
+ * No ARIA roles come with this, and that is a MEASURED decision rather than an omission. The
48
+ * received wisdom from the responsive-table era is that `display:block` on a table box drops its
49
+ * implicit role; explicit `role="table"/"row"/"cell"` were written into all four stacks here on
50
+ * that belief and then taken out again, because Chrome's own accessibility tree says otherwise:
51
+ * with the rows at `display:flex`, stripping every `role` attribute leaves
52
+ * `table → row → cell` intact (Chromium 133; only `rowgroup`, which associates nothing, is lost).
53
+ * Redundant ARIA is what the kit's own rule calls worse than none, so the guard asserts the
54
+ * OUTCOME — that a stacked row is still a row in the a11y tree — and stays silent about how.
55
+ * `browser/cfl-narrow.mjs` is where a future engine that does dissolve them shows up as red.
56
+ *
57
+ * Column headers ARE a real loss here, and a deliberate one: `<thead>` is hidden, because a header
58
+ * row that costs a third of the width buys nothing when each row is labelled by its own title. */
59
+ @container fdy-cfl (max-width: 30rem){
60
+ .fdy-cfl__results .fdy-table,
61
+ .fdy-cfl__results .fdy-table tbody{display:block;width:100%;}
62
+ .fdy-cfl__results .fdy-table thead{display:none;}
63
+ .fdy-cfl__row{display:flex;flex-wrap:wrap;align-items:baseline;column-gap:var(--space-2);padding:var(--space-3) var(--space-4);border-bottom:var(--bw) solid var(--color-border-muted);}
64
+ .fdy-cfl__results .fdy-table tbody tr:last-child{border-bottom:0;}
65
+ .fdy-cfl__row > td{display:block;padding:0;border:0;}
66
+ /* The title takes the whole first line, which is what pushes every remaining cell onto the second
67
+ one — no selector has to know how many columns there are, or which of them is last. */
68
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check),
69
+ .fdy-cfl__row > .fdy-cfl__check + td{font-weight:var(--weight-semibold);color:var(--color-text);}
70
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check){flex:0 0 100%;}
71
+ /* Multi-select: the checkbox shares line one with the title, so the title claims the rest of it. */
72
+ .fdy-cfl__row > .fdy-cfl__check + td{flex:1 0 calc(100% - var(--space-6));}
73
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check) ~ td,
74
+ .fdy-cfl__row > .fdy-cfl__check + td ~ td{font-size:var(--text-xs);color:var(--color-text-muted);}
75
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check) ~ td:empty,
76
+ .fdy-cfl__row > .fdy-cfl__check + td ~ td:empty{display:none;}
77
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check) ~ td::before,
78
+ .fdy-cfl__row > .fdy-cfl__check + td ~ td::before{content:"·";margin-right:var(--space-2);color:var(--color-text-subtle);}
79
+ .fdy-cfl__row > td:first-child:not(.fdy-cfl__check) + td::before,
80
+ .fdy-cfl__row > .fdy-cfl__check + td + td::before{content:none;}
81
+ /* The checkbox keeps its own line position beside the title rather than above it. */
82
+ .fdy-cfl__row > .fdy-cfl__check{flex:none;width:auto;align-self:center;}
83
+ }
@@ -1,7 +1,22 @@
1
1
  /* Freeday. Tabs (WAI-ARIA APG) */
2
- /* position:relative, containing block for out-of-flow content in the tabs (see base.css). */
3
- .fdy-tabs__list{position:relative;display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
4
- .fdy-tabs__tab{appearance:none;border:0;background:transparent;cursor:pointer;white-space:nowrap;font-family:var(--font-body);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);padding:var(--space-3) var(--space-4);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
2
+ /* position:relative, containing block for out-of-flow content in the tabs (see base.css).
3
+ *
4
+ * The strip's own line is an INSET SHADOW, not a border, and the tab carries no negative margin
5
+ * (#052). The pair those two replace — `border-bottom` here plus `margin-bottom:-1px` on the tab,
6
+ * to lift the active tab's 2px underline onto the strip's 1px line so the two read as one — was
7
+ * broken by the `overflow-x:auto` beside it, in two ways at once:
8
+ * 1. CSS Overflow §3 gives no way to scroll one axis alone: `overflow-y` computes from `visible`
9
+ * to `auto` because the other axis is not visible, so the 1px the tab hangs below the content
10
+ * box became a real scrollport. Measured 47 client / 48 scroll, `scrollTop` settling at 1 —
11
+ * invisible in a screenshot, and a trackpad gesture over the strip is eaten by it.
12
+ * 2. A scroll container clips its descendants to the padding box, so the pixel the negative
13
+ * margin pushed out was clipped away and the strip's own line painted in its place: the
14
+ * active underline rendered ONE pixel of primary, never the two it asks for.
15
+ * An inset shadow paints on the box, not on the scrolled content, so the line stays put while the
16
+ * tabs scroll sideways, and the tab's own border-bottom paints over it — same look, no overflow.
17
+ * To hide the line, override `box-shadow`, not `border-bottom` (guarded in browser/tabs-strip.mjs). */
18
+ .fdy-tabs__list{position:relative;display:flex;gap:var(--space-1);box-shadow:inset 0 calc(var(--bw) * -1) 0 var(--color-border);overflow-x:auto;}
19
+ .fdy-tabs__tab{appearance:none;border:0;background:transparent;cursor:pointer;white-space:nowrap;font-family:var(--font-body);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);padding:var(--space-3) var(--space-4);border-bottom:2px solid transparent;transition:color var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
5
20
  .fdy-tabs__tab:hover{color:var(--color-text);}
6
21
  .fdy-tabs__tab:disabled,.fdy-tabs__tab[aria-disabled="true"]{opacity:.5;cursor:not-allowed;color:var(--color-text-muted);}
7
22
  .fdy-tabs__tab:disabled:hover,.fdy-tabs__tab[aria-disabled="true"]:hover{color:var(--color-text-muted);}
@@ -122,7 +122,7 @@
122
122
 
123
123
  "text": { "$value": "{slate.900}", "$dark": "{slate.100}" },
124
124
  "text-muted": { "$value": "{slate.600}", "$dark": "{slate.400}" },
125
- "text-subtle": { "$value": "{slate.500}", "$dark": "{slate.500}" },
125
+ "text-subtle": { "$comment": "The faintest ink, and the ONLY text tier whose dark value is not the light one's mirror (#053). Light slate-500 on white is 4.69:1 and clears AA on its own; the same primitive on the dark surface is 4.02:1, and the tier is exactly what a placeholder uses — the one line telling an operator what an empty field wants. Dark is slate-450 for that reason: 4.88:1 on --color-surface, which is what every .fdy-input paints itself, so the placeholder clears AA where placeholders actually sit. It is still NOT a body-text tier on the darker surfaces (3.66:1 on surface-3); reaching AA there needs slate-400, which is --color-text-muted, and a subtle that equals muted is not a tier. test/contrast.test.mjs asserts both halves of that split.", "$value": "{slate.500}", "$dark": "{slate.450}" },
126
126
  "on-brand": { "$value": "{slate.0}", "$dark": "{slate.0}" },
127
127
 
128
128
  "border": { "$value": "{slate.200}", "$dark": "{slate.800}" },