@cahyo-dimas/freeday 3.0.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,150 @@ 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
+
82
+ ## [3.1.0] - 2026-08-27
83
+
84
+ **Dua cacat lebar yang dilaporkan dari layar sungguhan, dan keduanya tak kelihatan di DOM.** Markup-nya
85
+ benar, class-nya benar, kotaknya cuma berhenti lebih awal. Rilis ini juga membuat satu kalimat
86
+ `COMPONENTS.md` jadi benar setelah lama tidak.
87
+
88
+ ### Fixed
89
+ - **Kontrol di dalam `.fdy-field` kini selebar field-nya** (`improvement-notes` #051, dari
90
+ `IDU_EMATE_APPL_WEB` dan `IDU_EMATE_ACCT_WEB`). `.fdy-input-group`, `.fdy-combo`,
91
+ `.fdy-autocomplete` dan `.fdy-cascade` masing-masing di-cap `22rem` — cap untuk kontrol yang
92
+ berdiri sendiri, tanpa field yang bisa memberinya lebar — dan cap itu hanya dilepas **di satu
93
+ container**, `.fdy-filterbar`, yang menyebut dua dari empatnya. Akibatnya baris `--full` di
94
+ `.fdy-form-grid` memegang combo 352px di bawah label 600px, sementara `.fdy-input`
95
+ di sebelahnya (yang tak pernah punya cap) mengisi penuh: satu form terlihat tak sejajar dengan
96
+ dirinya sendiri. Yang paling mahal justru versi yang dilaporkan pengguna tanpa diminta — *"jarak
97
+ search ke filter status agak jauh, ya?"* — pada toolbar dengan field `26rem`: kontrolnya 352px,
98
+ jadi **64px ruang mati ada DI DALAM field**, dan `gap:var(--space-3)` yang ditulis penulisnya
99
+ terbaca 76px di layar. Tak ada satu pun langkah debug yang wajar bisa melihatnya: gap-nya benar,
100
+ flex-nya benar.
101
+ Perbaikannya satu aturan di `input.css`, bukan selector ketiga di samping filterbar, dan bentuknya
102
+ **sudah ada di kit sejak #017** — `.fdy-field>.fdy-datepicker` melepas cap picker dengan komentar
103
+ "a picker fills the field *like every other control*". Kalimat itu tidak benar untuk empat kontrol
104
+ di atas, dan sekarang benar. Cap `22rem` tetap ada **di `.fdy-field`**, jadi field yang tak
105
+ dilebarkan siapa pun tak berubah sedikit pun.
106
+ Ikut terangkat, dan tak ada di laporan mana pun: `.fdy-autocomplete` dan `.fdy-cascade` di field
107
+ `--w-2xl` (25rem) berhenti 48px lebih pendek **di dalam filterbar sendiri** — container yang kit
108
+ kira sudah beres. Aturan lama di `filterbar.css` dihapus, bukan dibiarkan di samping yang baru:
109
+ dua aturan yang mengatakan hal sama persis adalah cara filterbar jadi tahu sesuatu yang tak
110
+ diketahui `input.css`.
111
+ - **`.fdy-pagination` akhirnya benar-benar dikirim** (#050 §2). `COMPONENTS.md` menyuruh konsumen
112
+ menulis `<nav class="fdy-pagination" data-fdy-table-pagination>`, dan class blok itu **nol match di
113
+ seluruh paket** — bukan di enhancer, bukan di tiga footer typed. Tak ada yang tampak berbeda,
114
+ karena blok itu memang tak punya rule (`NEXT-UP` #9), tapi selector konsumen atau asersi e2e pada
115
+ blok itu **lulus terhadap markup tulisan tangan dan gagal terhadap keluaran kit sendiri**, padahal
116
+ kedua jalur seharusnya bisa saling menggantikan. Enhancer kini menyetelnya sekali saat init;
117
+ `FdyTableFooter` Vue/React/Blazor merendernya.
118
+
119
+ ### Added
120
+ - **`.fdy-stats--inline`** — strip KPI yang **memeluk angkanya** alih-alih membentang, untuk yang
121
+ duduk di samping judul halaman (#050 §1). Ini jawaban atas cacat yang tak bisa diperbaiki dengan
122
+ dokumentasi saja: `.fdy-stat` membawa `container-type:inline-size` (itu yang memberi `__value`
123
+ tipe fluidnya), dan **inline-size containment berarti tile tak menyumbang lebar intrinsik sama
124
+ sekali**. Jadi floor `11rem` bawaan bukan pilihan rasa — ia satu-satunya yang memberi track itu
125
+ ukuran — dan konsumen yang me-retrack grid ke konten mendapat tiga track nol: tiga label saling
126
+ menimpa (`COMPAMEMBERSTA…`) dan halaman meluber ke samping. Terukur dari header
127
+ `IDU_EMATE_ACCT_WEB`: strip 40px, seluruhnya gap. `justify-items` lebih tajam lagi — apa pun
128
+ selain `stretch` mengecilkan tile ke sumbangan nol itu **walau track-nya sudah benar lebarnya**.
129
+ `--inline` melepas container-nya, jadi tile-nya berukuran sesuai kontennya sendiri dan `__value`
130
+ memakai `--text-3xl` datar: track yang seukuran kontennya tak pernah jadi track sempit yang
131
+ membuat tipe fluid itu ada. Konsumen tak perlu lagi menebak floor sendiri.
132
+
133
+ ### Docs
134
+ - **`COMPONENTS.md` §Stats akhirnya menyebut containment-nya.** `container-type`, `cqw`,
135
+ `inline-size` dan `contain` nol match di seluruh berkas itu sebelum rilis ini, padahal detail
136
+ implementasi tipe `__value` itu bocor langsung ke kontrak layout siapa pun yang me-retrack grid-nya.
137
+ Kini tertulis sebagai batasan: override `grid-template-columns` wajib punya floor eksplisit, dan
138
+ `justify-items` harus tetap `stretch`.
139
+ - **§Field menyebut cap-nya.** `22rem` dan `max-width` juga nol match di `COMPONENTS.md` sebelum ini,
140
+ jadi cap maupun pengecualian filterbar-nya hanya bisa ditemukan dengan mengukur form yang sudah
141
+ dirender. Aturannya sekarang satu kalimat: **beri ukuran pada field, jangan pada kontrolnya.**
142
+
143
+ ### Guarded
144
+ - `test/css.test.mjs`: bentuk CSS-nya — keempat kontrol dilepas di `.fdy-field`, `.fdy-field`
145
+ mempertahankan cap-nya sendiri, aturan filterbar lama tak boleh kembali, dan `--inline` harus
146
+ keluar dari container maupun clamp `cqw`-nya.
147
+ - `browser/field-width.mjs` (6 test, Chrome sungguhan): hasilnya — keduanya adalah **outcome layout**
148
+ yang tak terlihat di stylesheet. Termasuk asersi bahwa gap yang terlihat pembaca sama dengan gap
149
+ yang ditulis penulisnya, dan tile `--inline` diukur terhadap referensi yang berukuran-konten
150
+ secara konstruksi, bukan sekadar "bukan nol": tanpa itu, satu track yang masih memegang floor
151
+ `11rem` lolos karena strip-nya tetap masuk akal.
152
+ - `test/docs.test.mjs`: berkas mana pun yang merender `__element` sebuah blok harus menyebut bloknya.
153
+ Dicari **secara struktur**, bukan dari daftar empat berkas yang salah — renderer pager kelima
154
+ adalah yang tak akan diingat siapa pun.
155
+
12
156
  ## [3.0.0] - 2026-08-26
13
157
 
14
158
  **Kenapa MAJOR padahal rilis ini seluruhnya aditif.** Tak ada satu pun perubahan di bawah yang
package/COMPONENTS.md CHANGED
@@ -227,7 +227,22 @@ carry the doctrine in markup.
227
227
  sits half a label-height low in it, so labelled fields belong in `.fdy-filterbar`
228
228
  (`align-items:flex-end`). In a toolbar, hide the label with `.fdy-visually-hidden`.
229
229
  - `.fdy-stats` is a KPI grid of `.fdy-stat` (`__label` `__value` `__meta`). Deliberately **not**
230
- cards. `.fdy-stats--boxed` for one shared surface with dividers.
230
+ cards. `.fdy-stats--boxed` for one shared surface with dividers; `.fdy-stats--inline` for a strip
231
+ that **hugs its numbers** instead of spanning the row, which is what a page header wants.
232
+ - **A `.fdy-stat` in the default grid is size-contained, and that constrains what you may override.**
233
+ It carries `container-type:inline-size` — that is what gives `__value` its fluid type — and
234
+ inline-size containment means the tile contributes **no intrinsic width**. Two consequences, and
235
+ re-tracking the grid hits both at once:
236
+ - Content-based track sizing cannot work: `auto`, `max-content`, `min-content` and `minmax(0, …)`
237
+ all resolve to a **zero** track. The default `minmax(11rem, 1fr)` floor is not a style choice,
238
+ it is the only thing giving these tracks a size, so **any `grid-template-columns` override needs
239
+ an explicit floor of its own**.
240
+ - `justify-items` must stay `stretch`. A non-stretched grid item is sized to its (zero)
241
+ contribution, so `start` collapses the tile to nothing **even when the track is correctly wide**.
242
+
243
+ Use `--inline` rather than working around either: it drops the container, so its tiles size to
244
+ their own content, and its `__value` keeps the flat `--text-3xl`. A hugging track is never the
245
+ narrow track the fluid type exists for.
231
246
 
232
247
  ```html
233
248
  <div class="fdy-page">
@@ -409,6 +424,13 @@ native control, otherwise a `<div>` + explicitly associated label.
409
424
 
410
425
  - `.fdy-field` (+ `--full` inside `.fdy-form-grid`; widths `--w-sm` `--w-lg` `--w-xl` `--w-2xl`
411
426
  `--w-grow` inside `.fdy-filterbar`)
427
+ - **The field owns the width; the control in it follows.** `.fdy-field` caps at `22rem` so a lone
428
+ field does not run the width of the page. `.fdy-form-grid` and `.fdy-filterbar` lift that cap, and
429
+ so does any width you state yourself — and in every one of those cases the control (`.fdy-input`,
430
+ `.fdy-input-group`, `.fdy-combo`, `.fdy-autocomplete`, `.fdy-cascade`, and the pickers) spans the
431
+ field. So **size the field, never the control**: styling the control instead leaves the field
432
+ wider than what is in it, and that dead space is invisible in the DOM — it reads on screen as a
433
+ gap you did not write.
412
434
  - `.fdy-label` (+`--required`) · `.fdy-input` (+`--error`) · `.fdy-textarea` · `.fdy-help` (+`--error`)
413
435
 
414
436
  **Grouped controls are a `<fieldset>`, not a new block.** Put `.fdy-field` on the fieldset and
@@ -618,6 +640,15 @@ child `<ul>` is a branch, one without is a leaf. Needs `freeday-cascade.js`.
618
640
  ## Choose-from-list (CFL) — `data-fdy-cfl`
619
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).
620
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
+
621
652
  ### Props — `<FdyCfl>`
622
653
 
623
654
  Four of these are required and carry the whole component: `fetchPage`, `columns`, `display`,
@@ -1216,7 +1247,9 @@ and breaks the moment the class changes.
1216
1247
  ## Pagination — `.fdy-pagination`
1217
1248
  The block class on the `<nav>` is a **structural hook only**. It carries no rule of its own; the
1218
1249
  `__list` / `__link` / `__ellipsis` elements do all the styling, and the data table targets
1219
- `data-fdy-table-pagination`. Keep it on the wrapper anyway, for consistency with the rest of the kit.
1250
+ `data-fdy-table-pagination`. Keep it on the wrapper anyway, for consistency with the rest of the kit
1251
+ — and the kit keeps it too: the enhancer sets it on the element it finds, and all three typed
1252
+ footers render it, so a selector or an e2e assertion on the block matches either path.
1220
1253
 
1221
1254
  `<nav class="fdy-pagination" aria-label="Pagination">` → `<ul class="fdy-pagination__list">`;
1222
1255
  each item is a `.fdy-pagination__link` (`<button>` when navigable, `<span aria-current="page">`
@@ -1472,6 +1505,16 @@ APG tabs: ←/→, Home/End, roving tabindex. Needs `freeday-tabs.js`.
1472
1505
  `.fdy-tabs__tab` marks its active state from **`aria-selected="true"` or `aria-current="page"`**,
1473
1506
  so the same look serves routed sub-navigation built from plain links. See the note under Nav.
1474
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
+
1475
1518
  ```html
1476
1519
  <div class="fdy-tabs" data-fdy-tabs>
1477
1520
  <div class="fdy-tabs__list" role="tablist" aria-label="Invoice detail">
@@ -1497,7 +1540,7 @@ so the same look serves routed sub-navigation built from plain links. See the no
1497
1540
  | `open` | `boolean` | Controlled: your state decides. The wrapper cancels the native `cancel` event so the DOM can never disagree with it. |
1498
1541
  | `title` | `string` (Vue) · `ReactNode` | Names the dialog through `aria-labelledby`. Vue also offers a `title` slot when the heading needs markup. |
1499
1542
  | `onClose` | `() => void` | React. Vue emits `close`. Fires from Escape, a backdrop click and the ×, all three of which exist only under `dismissible`. |
1500
- | `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. |
1501
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. |
1502
1545
  | `closeLabel?` | `string` | Accessible name for the × button. Default `Close`. |
1503
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.0.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.0.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.0.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.0.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
@@ -24,7 +24,7 @@
24
24
 
25
25
  @if (HasPager)
26
26
  {
27
- <nav aria-label="Pagination">
27
+ <nav class="fdy-pagination" aria-label="Pagination">
28
28
  <ul class="fdy-pagination__list">
29
29
  <li>
30
30
  <button type="button" class="fdy-pagination__link" aria-label="Previous page"
@@ -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;
@@ -75,7 +75,7 @@ export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
75
75
  )}
76
76
 
77
77
  {hasPager && (
78
- <nav aria-label="Pagination">
78
+ <nav className="fdy-pagination" aria-label="Pagination">
79
79
  <ul className="fdy-pagination__list">
80
80
  <li>
81
81
  <button type="button" className="fdy-pagination__link" aria-label="Previous page" disabled={currentPage1 === 1} onClick={(): void => goTo(currentPage1 - 1)}>‹</button>
@@ -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. */
@@ -90,7 +90,7 @@ function onSize(value: string): void {
90
90
  />
91
91
  </div>
92
92
 
93
- <nav v-if="hasPager" aria-label="Pagination">
93
+ <nav v-if="hasPager" class="fdy-pagination" aria-label="Pagination">
94
94
  <ul class="fdy-pagination__list">
95
95
  <li>
96
96
  <button
@@ -84,6 +84,13 @@
84
84
  var countEl = root.querySelector('[data-fdy-table-count]');
85
85
  var infoEl = root.querySelector('[data-fdy-table-info]');
86
86
  var pagerEl = root.querySelector('[data-fdy-table-pagination]');
87
+ /* COMPONENTS.md documents the wrapper as <nav class="fdy-pagination" data-fdy-table-pagination>
88
+ and nothing in the kit ever wrote that class, here or in the three typed footers (#050 §2).
89
+ It carries no rule (NEXT-UP #9), so nothing rendered differently, but a consumer selector or
90
+ an e2e assertion on the block passed against hand-written markup and failed against the kit's
91
+ own output, and the raw and typed paths are supposed to be interchangeable. Set once at init,
92
+ not per render: the wrapper outlives every pager we build into it. */
93
+ if (pagerEl) pagerEl.classList.add('fdy-pagination');
87
94
  var pageSizeEl = root.querySelector('[data-fdy-table-page-size]');
88
95
  var selectAll = root.querySelector('[data-fdy-select-all]');
89
96
  var pageSize = parseInt(root.getAttribute('data-page-size'), 10) || 0; // 0 = no pagination
@@ -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);
@@ -1242,7 +1242,16 @@ a { color: var(--color-primary); }
1242
1242
 
1243
1243
  /* The caption sits on a solid surface, not on the scrim. A scrim is semi-transparent over unknown
1244
1244
  * content, so text on it has no computable contrast ratio and no gate could ever cover it. On
1245
- * --color-surface it is the same pair the contrast suite already checks. */
1245
+ * --color-surface it is the same pair the contrast suite already checks.
1246
+ *
1247
+ * THIS IS ALSO WHY THIS PANEL DOES NOT JOIN THE GLASS AXIS, and the omission is a decision rather
1248
+ * than an oversight. Every other raised panel reads --color-surface-raised and frosts under
1249
+ * [data-style="glass"]; this one stays opaque. The glass gate asserts its ink against the panel
1250
+ * composited over the PAGE GROUND (--color-surface-2), which is what sits behind a card or a modal.
1251
+ * Behind this one is the scrim, over arbitrary content — the very case that has no computable
1252
+ * ratio. A frosted busy panel would be the one surface in the kit whose caption nothing could
1253
+ * verify, while it is up precisely because the app is doing something the reader must not
1254
+ * interrupt. Left opaque on purpose; do not "fix" it for consistency. */
1246
1255
  .fdy-busy__panel{display:flex;flex-direction:column;align-items:center;gap:var(--space-4);padding:var(--space-6) var(--space-8);border-radius:var(--radius-lg);background:var(--color-surface);box-shadow:var(--shadow-lift);max-width:min(20rem,calc(100vw - var(--space-8)));}
1247
1256
  .fdy-busy__mark{display:flex;align-items:center;justify-content:center;min-height:2.5rem;}
1248
1257
  .fdy-busy__caption{margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text);text-align:center;}
@@ -1461,9 +1470,16 @@ a { color: var(--color-primary); }
1461
1470
  /* Search bar, single leading-icon field; Enter/typing filters the results. */
1462
1471
  .fdy-cfl__search{flex:none;padding:var(--space-4) var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
1463
1472
 
1464
- /* Results, dense rows, sticky header, its own scroll region. */
1465
- .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);}
1466
1481
  .fdy-cfl__results .fdy-table th{position:sticky;top:0;z-index:1;}
1482
+
1467
1483
  .fdy-cfl__row{cursor:pointer;}
1468
1484
  .fdy-cfl__row:hover{background:var(--color-surface-2);}
1469
1485
  .fdy-cfl__row:focus-visible{outline:none;background:var(--color-primary-soft);box-shadow:inset 2px 0 0 var(--color-primary);}
@@ -1481,6 +1497,54 @@ a { color: var(--color-primary); }
1481
1497
  .fdy-cfl__count{font-size:var(--text-sm);color:var(--color-text-muted);}
1482
1498
  .fdy-cfl__actions{display:flex;gap:var(--space-2);}
1483
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
+
1484
1548
  /* Freeday. Charts (zero-dependency CSS/SVG, rendered by freeday-chart.js).
1485
1549
  * Colour comes from --fdy-chart (a token, set inline by the renderer from data-fdy-color). */
1486
1550
 
@@ -1670,9 +1734,22 @@ a { color: var(--color-primary); }
1670
1734
  grid track, `container-type:inline-size` on a standalone .fdy-stat would stop it sizing to its
1671
1735
  own content. The unconditional rule above stays as the fallback wherever @container is missing. */
1672
1736
  @supports (container-type:inline-size){
1673
- .fdy-stats>.fdy-stat{container-type:inline-size;}
1674
- .fdy-stats>.fdy-stat .fdy-stat__value{font-size:clamp(var(--text-xl),11cqw,var(--text-3xl));}
1675
- }
1737
+ .fdy-stats:not(.fdy-stats--inline)>.fdy-stat{container-type:inline-size;}
1738
+ .fdy-stats:not(.fdy-stats--inline)>.fdy-stat .fdy-stat__value{font-size:clamp(var(--text-xl),11cqw,var(--text-3xl));}
1739
+ }
1740
+ /* The price of that container, and the reason --inline opts out of it (#050). `container-type`
1741
+ applies inline-size CONTAINMENT: the tile's min-content and max-content contributions are both
1742
+ ZERO. So the 11rem floor above is not a taste choice, it is the only thing giving these tracks a
1743
+ size, and re-tracking the grid on content (`auto`, `max-content`, `minmax(0,…)`) resolves every
1744
+ track to 0. `justify-items` is the sharper edge: anything but the default `stretch` sizes the
1745
+ tile to that zero contribution even when the track is correctly wide, and three labels paint on
1746
+ top of each other. Measured from IDU_EMATE_ACCT_WEB's page header, where a hugging strip came out
1747
+ 40px wide, all of it gap.
1748
+ --inline is the shape that header wanted: a strip that hugs its numbers and sits beside a page
1749
+ title, instead of a full-width band whose 11rem floor collapses to one column at 390px. It cannot
1750
+ have both, so it drops the container and keeps the flat --text-3xl the base rule already sets;
1751
+ a track sized to its own content is never the narrow track the clamp exists for. */
1752
+ .fdy-stats--inline{grid-template-columns:none;grid-auto-flow:column;grid-auto-columns:minmax(0,max-content);}
1676
1753
  .fdy-stats--boxed{background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);gap:0;}
1677
1754
  .fdy-stats--boxed .fdy-stat{padding:var(--space-5);border-right:var(--bw) solid var(--color-border-muted);}
1678
1755
  .fdy-stats--boxed .fdy-stat:last-child{border-right:0;}
@@ -1878,8 +1955,9 @@ a { color: var(--color-primary); }
1878
1955
  /* Fields are direct children. Drop the standalone field's 22rem cap; default to the
1879
1956
  * medium column width with no unbounded growth (that's what caused the raggedness). */
1880
1957
  .fdy-filterbar>.fdy-field{max-width:none;flex:0 0 auto;width:12rem;}
1881
- /* Controls fill their field's column (drop the standalone input-group/combo caps). */
1882
- .fdy-filterbar>.fdy-field .fdy-input-group,.fdy-filterbar>.fdy-field .fdy-combo{max-width:none;width:100%;}
1958
+ /* Controls fill their field's column: released in input.css for every field, not just this one.
1959
+ The rule used to live here, naming the input group and the combo only, which is why an
1960
+ .fdy-autocomplete or an .fdy-cascade in a --w-2xl field still stopped 48px short of it (#051). */
1883
1961
  /* Column-width rhythm, pick one per field so bars across the app line up. */
1884
1962
  .fdy-filterbar>.fdy-field--w-sm{width:9rem;}
1885
1963
  .fdy-filterbar>.fdy-field--w-lg{width:15rem;}
@@ -1956,6 +2034,15 @@ a { color: var(--color-primary); }
1956
2034
 
1957
2035
  /* Freeday. Field & Input */
1958
2036
  .fdy-field{display:flex;flex-direction:column;gap:var(--space-2);max-width:22rem;}
2037
+ /* Inside a labelled field, a control fills the field (#051). The 22rem cap those controls carry is
2038
+ for one standing on its own, with no field to take a width from; #017 already released the date
2039
+ and time pickers here for exactly this reason, and its comment claims a picker then fills its
2040
+ field "like every other control". That was not true of these four: a `--full` row in a
2041
+ .fdy-form-grid held a 352px combo under a 600px label, and a 26rem toolbar search field hid 64px
2042
+ of dead space INSIDE itself, so an author who wrote `gap:var(--space-3)` measured 76px on screen.
2043
+ The field's own max-width above is what governs, and it still caps a field nobody has widened, so
2044
+ this changes nothing wherever the cap was already doing its job. */
2045
+ .fdy-field>.fdy-input-group,.fdy-field>.fdy-combo,.fdy-field>.fdy-autocomplete,.fdy-field>.fdy-cascade{max-width:none;}
1959
2046
  /* A grouped field is legitimately a <fieldset> (it needs a <legend>), e.g. a radio/checkbox group.
1960
2047
  Neutralise the UA groove border / padding / min-inline-size so it lays out identically to the
1961
2048
  <div> form (min-inline-size:min-content would otherwise block the filterbar column widths). */
@@ -2396,9 +2483,24 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
2396
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;}
2397
2484
 
2398
2485
  /* Freeday. Tabs (WAI-ARIA APG) */
2399
- /* position:relative, containing block for out-of-flow content in the tabs (see base.css). */
2400
- .fdy-tabs__list{position:relative;display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
2401
- .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);}
2402
2504
  .fdy-tabs__tab:hover{color:var(--color-text);}
2403
2505
  .fdy-tabs__tab:disabled,.fdy-tabs__tab[aria-disabled="true"]{opacity:.5;cursor:not-allowed;color:var(--color-text-muted);}
2404
2506
  .fdy-tabs__tab:disabled:hover,.fdy-tabs__tab[aria-disabled="true"]:hover{color:var(--color-text-muted);}
package/dist/freeday.css CHANGED
@@ -364,7 +364,16 @@ a { color: var(--color-primary); }
364
364
 
365
365
  /* The caption sits on a solid surface, not on the scrim. A scrim is semi-transparent over unknown
366
366
  * content, so text on it has no computable contrast ratio and no gate could ever cover it. On
367
- * --color-surface it is the same pair the contrast suite already checks. */
367
+ * --color-surface it is the same pair the contrast suite already checks.
368
+ *
369
+ * THIS IS ALSO WHY THIS PANEL DOES NOT JOIN THE GLASS AXIS, and the omission is a decision rather
370
+ * than an oversight. Every other raised panel reads --color-surface-raised and frosts under
371
+ * [data-style="glass"]; this one stays opaque. The glass gate asserts its ink against the panel
372
+ * composited over the PAGE GROUND (--color-surface-2), which is what sits behind a card or a modal.
373
+ * Behind this one is the scrim, over arbitrary content — the very case that has no computable
374
+ * ratio. A frosted busy panel would be the one surface in the kit whose caption nothing could
375
+ * verify, while it is up precisely because the app is doing something the reader must not
376
+ * interrupt. Left opaque on purpose; do not "fix" it for consistency. */
368
377
  .fdy-busy__panel{display:flex;flex-direction:column;align-items:center;gap:var(--space-4);padding:var(--space-6) var(--space-8);border-radius:var(--radius-lg);background:var(--color-surface);box-shadow:var(--shadow-lift);max-width:min(20rem,calc(100vw - var(--space-8)));}
369
378
  .fdy-busy__mark{display:flex;align-items:center;justify-content:center;min-height:2.5rem;}
370
379
  .fdy-busy__caption{margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text);text-align:center;}
@@ -583,9 +592,16 @@ a { color: var(--color-primary); }
583
592
  /* Search bar, single leading-icon field; Enter/typing filters the results. */
584
593
  .fdy-cfl__search{flex:none;padding:var(--space-4) var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
585
594
 
586
- /* Results, dense rows, sticky header, its own scroll region. */
587
- .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);}
588
603
  .fdy-cfl__results .fdy-table th{position:sticky;top:0;z-index:1;}
604
+
589
605
  .fdy-cfl__row{cursor:pointer;}
590
606
  .fdy-cfl__row:hover{background:var(--color-surface-2);}
591
607
  .fdy-cfl__row:focus-visible{outline:none;background:var(--color-primary-soft);box-shadow:inset 2px 0 0 var(--color-primary);}
@@ -603,6 +619,54 @@ a { color: var(--color-primary); }
603
619
  .fdy-cfl__count{font-size:var(--text-sm);color:var(--color-text-muted);}
604
620
  .fdy-cfl__actions{display:flex;gap:var(--space-2);}
605
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
+
606
670
  /* Freeday. Charts (zero-dependency CSS/SVG, rendered by freeday-chart.js).
607
671
  * Colour comes from --fdy-chart (a token, set inline by the renderer from data-fdy-color). */
608
672
 
@@ -792,9 +856,22 @@ a { color: var(--color-primary); }
792
856
  grid track, `container-type:inline-size` on a standalone .fdy-stat would stop it sizing to its
793
857
  own content. The unconditional rule above stays as the fallback wherever @container is missing. */
794
858
  @supports (container-type:inline-size){
795
- .fdy-stats>.fdy-stat{container-type:inline-size;}
796
- .fdy-stats>.fdy-stat .fdy-stat__value{font-size:clamp(var(--text-xl),11cqw,var(--text-3xl));}
859
+ .fdy-stats:not(.fdy-stats--inline)>.fdy-stat{container-type:inline-size;}
860
+ .fdy-stats:not(.fdy-stats--inline)>.fdy-stat .fdy-stat__value{font-size:clamp(var(--text-xl),11cqw,var(--text-3xl));}
797
861
  }
862
+ /* The price of that container, and the reason --inline opts out of it (#050). `container-type`
863
+ applies inline-size CONTAINMENT: the tile's min-content and max-content contributions are both
864
+ ZERO. So the 11rem floor above is not a taste choice, it is the only thing giving these tracks a
865
+ size, and re-tracking the grid on content (`auto`, `max-content`, `minmax(0,…)`) resolves every
866
+ track to 0. `justify-items` is the sharper edge: anything but the default `stretch` sizes the
867
+ tile to that zero contribution even when the track is correctly wide, and three labels paint on
868
+ top of each other. Measured from IDU_EMATE_ACCT_WEB's page header, where a hugging strip came out
869
+ 40px wide, all of it gap.
870
+ --inline is the shape that header wanted: a strip that hugs its numbers and sits beside a page
871
+ title, instead of a full-width band whose 11rem floor collapses to one column at 390px. It cannot
872
+ have both, so it drops the container and keeps the flat --text-3xl the base rule already sets;
873
+ a track sized to its own content is never the narrow track the clamp exists for. */
874
+ .fdy-stats--inline{grid-template-columns:none;grid-auto-flow:column;grid-auto-columns:minmax(0,max-content);}
798
875
  .fdy-stats--boxed{background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);gap:0;}
799
876
  .fdy-stats--boxed .fdy-stat{padding:var(--space-5);border-right:var(--bw) solid var(--color-border-muted);}
800
877
  .fdy-stats--boxed .fdy-stat:last-child{border-right:0;}
@@ -1000,8 +1077,9 @@ a { color: var(--color-primary); }
1000
1077
  /* Fields are direct children. Drop the standalone field's 22rem cap; default to the
1001
1078
  * medium column width with no unbounded growth (that's what caused the raggedness). */
1002
1079
  .fdy-filterbar>.fdy-field{max-width:none;flex:0 0 auto;width:12rem;}
1003
- /* Controls fill their field's column (drop the standalone input-group/combo caps). */
1004
- .fdy-filterbar>.fdy-field .fdy-input-group,.fdy-filterbar>.fdy-field .fdy-combo{max-width:none;width:100%;}
1080
+ /* Controls fill their field's column: released in input.css for every field, not just this one.
1081
+ The rule used to live here, naming the input group and the combo only, which is why an
1082
+ .fdy-autocomplete or an .fdy-cascade in a --w-2xl field still stopped 48px short of it (#051). */
1005
1083
  /* Column-width rhythm, pick one per field so bars across the app line up. */
1006
1084
  .fdy-filterbar>.fdy-field--w-sm{width:9rem;}
1007
1085
  .fdy-filterbar>.fdy-field--w-lg{width:15rem;}
@@ -1078,6 +1156,15 @@ a { color: var(--color-primary); }
1078
1156
 
1079
1157
  /* Freeday. Field & Input */
1080
1158
  .fdy-field{display:flex;flex-direction:column;gap:var(--space-2);max-width:22rem;}
1159
+ /* Inside a labelled field, a control fills the field (#051). The 22rem cap those controls carry is
1160
+ for one standing on its own, with no field to take a width from; #017 already released the date
1161
+ and time pickers here for exactly this reason, and its comment claims a picker then fills its
1162
+ field "like every other control". That was not true of these four: a `--full` row in a
1163
+ .fdy-form-grid held a 352px combo under a 600px label, and a 26rem toolbar search field hid 64px
1164
+ of dead space INSIDE itself, so an author who wrote `gap:var(--space-3)` measured 76px on screen.
1165
+ The field's own max-width above is what governs, and it still caps a field nobody has widened, so
1166
+ this changes nothing wherever the cap was already doing its job. */
1167
+ .fdy-field>.fdy-input-group,.fdy-field>.fdy-combo,.fdy-field>.fdy-autocomplete,.fdy-field>.fdy-cascade{max-width:none;}
1081
1168
  /* A grouped field is legitimately a <fieldset> (it needs a <legend>), e.g. a radio/checkbox group.
1082
1169
  Neutralise the UA groove border / padding / min-inline-size so it lays out identically to the
1083
1170
  <div> form (min-inline-size:min-content would otherwise block the filterbar column widths). */
@@ -1518,9 +1605,24 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1518
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;}
1519
1606
 
1520
1607
  /* Freeday. Tabs (WAI-ARIA APG) */
1521
- /* position:relative, containing block for out-of-flow content in the tabs (see base.css). */
1522
- .fdy-tabs__list{position:relative;display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
1523
- .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);}
1524
1626
  .fdy-tabs__tab:hover{color:var(--color-text);}
1525
1627
  .fdy-tabs__tab:disabled,.fdy-tabs__tab[aria-disabled="true"]{opacity:.5;cursor:not-allowed;color:var(--color-text-muted);}
1526
1628
  .fdy-tabs__tab:disabled:hover,.fdy-tabs__tab[aria-disabled="true"]:hover{color:var(--color-text-muted);}
package/dist/freeday.js CHANGED
@@ -3905,6 +3905,13 @@
3905
3905
  var countEl = root.querySelector('[data-fdy-table-count]');
3906
3906
  var infoEl = root.querySelector('[data-fdy-table-info]');
3907
3907
  var pagerEl = root.querySelector('[data-fdy-table-pagination]');
3908
+ /* COMPONENTS.md documents the wrapper as <nav class="fdy-pagination" data-fdy-table-pagination>
3909
+ and nothing in the kit ever wrote that class, here or in the three typed footers (#050 §2).
3910
+ It carries no rule (NEXT-UP #9), so nothing rendered differently, but a consumer selector or
3911
+ an e2e assertion on the block passed against hand-written markup and failed against the kit's
3912
+ own output, and the raw and typed paths are supposed to be interchangeable. Set once at init,
3913
+ not per render: the wrapper outlives every pager we build into it. */
3914
+ if (pagerEl) pagerEl.classList.add('fdy-pagination');
3908
3915
  var pageSizeEl = root.querySelector('[data-fdy-table-page-size]');
3909
3916
  var selectAll = root.querySelector('[data-fdy-select-all]');
3910
3917
  var pageSize = parseInt(root.getAttribute('data-page-size'), 10) || 0; // 0 = no pagination
@@ -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.0.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.0.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/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",
@@ -21,7 +21,16 @@
21
21
 
22
22
  /* The caption sits on a solid surface, not on the scrim. A scrim is semi-transparent over unknown
23
23
  * content, so text on it has no computable contrast ratio and no gate could ever cover it. On
24
- * --color-surface it is the same pair the contrast suite already checks. */
24
+ * --color-surface it is the same pair the contrast suite already checks.
25
+ *
26
+ * THIS IS ALSO WHY THIS PANEL DOES NOT JOIN THE GLASS AXIS, and the omission is a decision rather
27
+ * than an oversight. Every other raised panel reads --color-surface-raised and frosts under
28
+ * [data-style="glass"]; this one stays opaque. The glass gate asserts its ink against the panel
29
+ * composited over the PAGE GROUND (--color-surface-2), which is what sits behind a card or a modal.
30
+ * Behind this one is the scrim, over arbitrary content — the very case that has no computable
31
+ * ratio. A frosted busy panel would be the one surface in the kit whose caption nothing could
32
+ * verify, while it is up precisely because the app is doing something the reader must not
33
+ * interrupt. Left opaque on purpose; do not "fix" it for consistency. */
25
34
  .fdy-busy__panel{display:flex;flex-direction:column;align-items:center;gap:var(--space-4);padding:var(--space-6) var(--space-8);border-radius:var(--radius-lg);background:var(--color-surface);box-shadow:var(--shadow-lift);max-width:min(20rem,calc(100vw - var(--space-8)));}
26
35
  .fdy-busy__mark{display:flex;align-items:center;justify-content:center;min-height:2.5rem;}
27
36
  .fdy-busy__caption{margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text);text-align:center;}
@@ -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
+ }
@@ -60,9 +60,22 @@
60
60
  grid track, `container-type:inline-size` on a standalone .fdy-stat would stop it sizing to its
61
61
  own content. The unconditional rule above stays as the fallback wherever @container is missing. */
62
62
  @supports (container-type:inline-size){
63
- .fdy-stats>.fdy-stat{container-type:inline-size;}
64
- .fdy-stats>.fdy-stat .fdy-stat__value{font-size:clamp(var(--text-xl),11cqw,var(--text-3xl));}
63
+ .fdy-stats:not(.fdy-stats--inline)>.fdy-stat{container-type:inline-size;}
64
+ .fdy-stats:not(.fdy-stats--inline)>.fdy-stat .fdy-stat__value{font-size:clamp(var(--text-xl),11cqw,var(--text-3xl));}
65
65
  }
66
+ /* The price of that container, and the reason --inline opts out of it (#050). `container-type`
67
+ applies inline-size CONTAINMENT: the tile's min-content and max-content contributions are both
68
+ ZERO. So the 11rem floor above is not a taste choice, it is the only thing giving these tracks a
69
+ size, and re-tracking the grid on content (`auto`, `max-content`, `minmax(0,…)`) resolves every
70
+ track to 0. `justify-items` is the sharper edge: anything but the default `stretch` sizes the
71
+ tile to that zero contribution even when the track is correctly wide, and three labels paint on
72
+ top of each other. Measured from IDU_EMATE_ACCT_WEB's page header, where a hugging strip came out
73
+ 40px wide, all of it gap.
74
+ --inline is the shape that header wanted: a strip that hugs its numbers and sits beside a page
75
+ title, instead of a full-width band whose 11rem floor collapses to one column at 390px. It cannot
76
+ have both, so it drops the container and keeps the flat --text-3xl the base rule already sets;
77
+ a track sized to its own content is never the narrow track the clamp exists for. */
78
+ .fdy-stats--inline{grid-template-columns:none;grid-auto-flow:column;grid-auto-columns:minmax(0,max-content);}
66
79
  .fdy-stats--boxed{background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);gap:0;}
67
80
  .fdy-stats--boxed .fdy-stat{padding:var(--space-5);border-right:var(--bw) solid var(--color-border-muted);}
68
81
  .fdy-stats--boxed .fdy-stat:last-child{border-right:0;}
@@ -13,8 +13,9 @@
13
13
  /* Fields are direct children. Drop the standalone field's 22rem cap; default to the
14
14
  * medium column width with no unbounded growth (that's what caused the raggedness). */
15
15
  .fdy-filterbar>.fdy-field{max-width:none;flex:0 0 auto;width:12rem;}
16
- /* Controls fill their field's column (drop the standalone input-group/combo caps). */
17
- .fdy-filterbar>.fdy-field .fdy-input-group,.fdy-filterbar>.fdy-field .fdy-combo{max-width:none;width:100%;}
16
+ /* Controls fill their field's column: released in input.css for every field, not just this one.
17
+ The rule used to live here, naming the input group and the combo only, which is why an
18
+ .fdy-autocomplete or an .fdy-cascade in a --w-2xl field still stopped 48px short of it (#051). */
18
19
  /* Column-width rhythm, pick one per field so bars across the app line up. */
19
20
  .fdy-filterbar>.fdy-field--w-sm{width:9rem;}
20
21
  .fdy-filterbar>.fdy-field--w-lg{width:15rem;}
@@ -1,5 +1,14 @@
1
1
  /* Freeday. Field & Input */
2
2
  .fdy-field{display:flex;flex-direction:column;gap:var(--space-2);max-width:22rem;}
3
+ /* Inside a labelled field, a control fills the field (#051). The 22rem cap those controls carry is
4
+ for one standing on its own, with no field to take a width from; #017 already released the date
5
+ and time pickers here for exactly this reason, and its comment claims a picker then fills its
6
+ field "like every other control". That was not true of these four: a `--full` row in a
7
+ .fdy-form-grid held a 352px combo under a 600px label, and a 26rem toolbar search field hid 64px
8
+ of dead space INSIDE itself, so an author who wrote `gap:var(--space-3)` measured 76px on screen.
9
+ The field's own max-width above is what governs, and it still caps a field nobody has widened, so
10
+ this changes nothing wherever the cap was already doing its job. */
11
+ .fdy-field>.fdy-input-group,.fdy-field>.fdy-combo,.fdy-field>.fdy-autocomplete,.fdy-field>.fdy-cascade{max-width:none;}
3
12
  /* A grouped field is legitimately a <fieldset> (it needs a <legend>), e.g. a radio/checkbox group.
4
13
  Neutralise the UA groove border / padding / min-inline-size so it lays out identically to the
5
14
  <div> form (min-inline-size:min-content would otherwise block the filterbar column widths). */
@@ -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}" },