@cahyo-dimas/freeday 2.1.0 → 3.0.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/COMPONENTS.md +191 -18
  3. package/README.id.md +1 -1
  4. package/README.md +1 -1
  5. package/USAGE.md +1 -1
  6. package/adapters/blazor/FdyAppShell.razor +1 -1
  7. package/adapters/blazor/FdyAppShell.razor.cs +35 -0
  8. package/adapters/blazor/FdyAutocomplete.razor +4 -1
  9. package/adapters/blazor/FdyAutocomplete.razor.cs +26 -0
  10. package/adapters/blazor/FdyCascade.razor +6 -1
  11. package/adapters/blazor/FdyCascade.razor.cs +24 -0
  12. package/adapters/blazor/FdyCombo.razor +1 -0
  13. package/adapters/blazor/FdyCombo.razor.cs +13 -0
  14. package/adapters/blazor/FdyDatepicker.razor +15 -1
  15. package/adapters/blazor/FdyDatepicker.razor.cs +46 -0
  16. package/adapters/blazor/FdyTable.razor +44 -4
  17. package/adapters/blazor/FdyTable.razor.cs +110 -0
  18. package/adapters/blazor/freeday-blazor.js +8 -0
  19. package/adapters/react/components/FdyAppShell.tsx +52 -11
  20. package/adapters/react/components/FdyDrawer.tsx +3 -1
  21. package/adapters/react/components/FdyModal.tsx +3 -1
  22. package/adapters/react/components/FdyTable.tsx +123 -2
  23. package/adapters/vue/components/FdyAppShell.vue +41 -11
  24. package/adapters/vue/components/FdyDrawer.vue +4 -1
  25. package/adapters/vue/components/FdyModal.vue +4 -1
  26. package/adapters/vue/components/FdyTable.vue +125 -4
  27. package/dist/freeday-app-shell.js +23 -5
  28. package/dist/freeday-autocomplete.js +17 -1
  29. package/dist/freeday-busy.js +168 -0
  30. package/dist/freeday-cascade.js +53 -3
  31. package/dist/freeday-chart.js +33 -3
  32. package/dist/freeday-datepicker.js +109 -17
  33. package/dist/freeday-select.js +18 -1
  34. package/dist/freeday-stepper.js +54 -4
  35. package/dist/freeday-table.js +4 -2
  36. package/dist/freeday-timepicker.js +19 -1
  37. package/dist/freeday.bundle.css +616 -39
  38. package/dist/freeday.css +93 -11
  39. package/dist/freeday.js +499 -37
  40. package/dist/freeday.tokens.css +523 -28
  41. package/docs/agent-onboarding.md +4 -0
  42. package/docs/getting-started.md +1 -1
  43. package/package.json +4 -3
  44. package/src/components/app-shell.css +29 -5
  45. package/src/components/appbar.css +2 -2
  46. package/src/components/busy.css +33 -0
  47. package/src/components/card.css +1 -1
  48. package/src/components/drawer.css +1 -1
  49. package/src/components/menu.css +1 -1
  50. package/src/components/modal.css +1 -1
  51. package/src/components/stepper.css +11 -0
  52. package/src/components/table.css +13 -0
  53. package/tokens/tokens.json +54 -10
package/CHANGELOG.md CHANGED
@@ -9,6 +9,250 @@ 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.0.0] - 2026-08-26
13
+
14
+ **Kenapa MAJOR padahal rilis ini seluruhnya aditif.** Tak ada satu pun perubahan di bawah yang
15
+ mematahkan konsumen: prop-nya opsional, class-nya baru, dan `fdy-step-before-change` tanpa listener
16
+ berperilaku persis seperti sebelumnya. Secara semver murni ini MINOR. Nomor mayor diambil sebagai
17
+ keputusan pemilik untuk **membuka lini adopsi back-office** yang dispesifikasikan di
18
+ [`docs/superpowers/specs/2026-08-26-back-office-adoption-design.md`](docs/superpowers/specs/2026-08-26-back-office-adoption-design.md):
19
+ delapan item, empat di antaranya terkirim di sini, dan **paruh yang benar-benar breaking masih di
20
+ depan** — sumbu `data-style` mengubah arti atribut yang sudah terdokumentasi, `FdyTableColumn`
21
+ melebar untuk edit sel, `FdyAppShell` mendapat mode nav baru. Konsumen 2.x bisa naik ke 3.0.0 tanpa
22
+ mengubah satu baris pun.
23
+
24
+ ### Added
25
+ - **Row selection terkontrol di keempat stack** (`NEXT-UP.md` #3). Enhancer vanilla sudah punya
26
+ `.fdy-table-bulkbar` + kolom pilih sejak awal; adapter typed tidak, jadi tiap layar list yang
27
+ butuh aksi massal merakit kolom checkbox sendiri di samping tabel terkontrol. Kini `selectable` +
28
+ `selectedKeys` di Vue/React/Blazor, ber-key `rowKey` — identitas yang sudah dipakai
29
+ `expandedKeys`, karena sebuah key selamat dari re-fetch yang mengganti seluruh objek baris,
30
+ sedangkan identitas objek tidak. Select-all menyentuh **halaman yang terlihat**, bukan seluruh
31
+ hasil filter: checkbox header yang diam-diam memilih baris yang tak bisa dilihat pembaca adalah
32
+ cara bulk delete jadi bencana. Key dari halaman lain dipertahankan, jadi hitungan di bulk bar
33
+ bisa lebih besar dari jumlah baris di layar — didokumentasikan, karena kalau tidak terbaca
34
+ seperti bug.
35
+ - **`.fdy-table--striped`** + prop `striped` di keempat adapter, plus token Tier-3
36
+ `--fdy-table-stripe`. Warnanya **setengah** langkah surface, bukan langkah penuh yang dipakai
37
+ hover: stripe dengan warna hover membuat hover tak terlihat di setiap baris genap. Kolom beku
38
+ ikut dicat ulang, karena sel beku membawa background opak sendiri.
39
+ - **`Freeday.busy()` / `Freeday.idle()`** — overlay yang menghalangi layar selama operasi yang tak
40
+ boleh diinterupsi. Imperatif seperti `toast()`, dan alasannya lebih tajam: API komponen mengundang
41
+ dua instans, dan dua scrim dengan dua caption dari dua komponen yang sama-sama merasa memiliki
42
+ layar adalah kegagalan yang komponen ini cegah. Bukan dialog — tak ada yang ditanyakan dan tak ada
43
+ yang bisa ditutup, jadi interaksi dicabut dengan `inert`, bukan dengan focus trap. Jeda default
44
+ 120 ms supaya operasi 80 ms tak mengedipkan scrim, dan `idle()` membatalkan tampilan yang masih
45
+ tertunda alih-alih menyembunyikannya setelah terlanjur terlihat.
46
+ - **Token semantic `--color-scrim`**, sengaja identik di light dan dark: scrim menggelapkan apa pun
47
+ di belakangnya, jadi ia tak ikut membalik bersama palet.
48
+ - **`.fdy-step__badge`** dan status `is-error` stepper yang **sudah ada di CSS tapi tak
49
+ terdokumentasi**, sehingga tak terjangkau siapa pun. Label kini ikut ke danger, dan badge memuat
50
+ jumlahnya — karena cincin merah bilang "ada sesuatu" dan tak mengatakan apa-apa kepada pembaca
51
+ yang tak bisa melihat merah. Badge adalah **saudara** marker, bukan anaknya: enhancer menulis
52
+ ulang `__marker.innerHTML` di tiap render.
53
+ - **`fdy-step-before-change`**, event cancelable yang membuat Lanjut bisa ditolak. Event saja tak
54
+ cukup — validasi biasanya sejauh satu round-trip — jadi handler bisa `preventDefault()` **atau**
55
+ menitipkan promise ke `detail.waitFor`. Selama promise itu tertunda kedua tombol nav dinonaktifkan
56
+ dan header membawa `aria-busy`. Menjawab apa pun selain `false` meneruskan (handler yang lupa
57
+ `return` bukan penolakan), dan guard yang **melempar** tak meneruskan apa pun karena ia tak
58
+ memutuskan apa pun. Mundur tak pernah ditanya; lompat maju ke langkah yang sudah dicapai ditanya.
59
+ **Cara validitas diputuskan tetap milik aplikasi** — kit tak berpendapat soal library form, dan
60
+ event inilah batasnya.
61
+ - **`FreedayBlazor.setIndeterminate`**, satu helper interop, karena `indeterminate` adalah properti
62
+ DOM tanpa atribut HTML: renderer Blazor tak bisa mengekspresikannya, dan halaman yang separuh
63
+ terpilih akan tampil polos "belum tercentang".
64
+ - **Mode nav overlay di layar lebar** — `.fdy-app--nav-overlay`, dan `navMode` di keempat adapter
65
+ (Blazor: enum `FdyNavMode`). Nav mengambang di atas konten alih-alih menggesernya, yaitu pilihan
66
+ yang selama ini hanya bisa terjadi di layar sempit. **Modifier ini mengubah tata letak, bukan
67
+ perilaku**: backdrop, Escape, tutup-saat-link-diikuti, fokus masuk-dan-kembali, `inert`, dan Tab
68
+ trap semuanya jalur kode drawer yang sudah ada — shell kini bertanya pada sebuah class, bukan pada
69
+ media query, sehingga layar lebar tak lagi berarti "kolom". Konsekuensinya melintasi breakpoint
70
+ **tidak** menutup panel yang mengambang di kedua sisi. `FreedayAppShell.refresh(root)` ditambahkan
71
+ untuk jalur mentah yang mengubah class itu sendiri.
72
+
73
+ - **Sumbu palet primary: `data-primary`, 18 pilihan.** `azure` (default) · `sky` `blue` `indigo`
74
+ `violet` `purple` `fuchsia` `pink` `rose` · `orange` `amber` `yellow` `lime` `green` `emerald`
75
+ `teal` `cyan` · `noir`. Dipasang di mana pun `data-theme` bisa, termasuk di wrapper untuk
76
+ me-rebrand satu region.
77
+
78
+ Yang di-redefinisi sebuah palet adalah **ramp alias delapan shade**, bukan token semantic-nya.
79
+ Pemetaan "shade mana yang jadi fill, mana hover, mana wash" ditulis **sekali** di `color.primary*`,
80
+ jadi palet yang salah memetakan salah satunya bukan bentuk yang bisa dihasilkan sistem ini — dan
81
+ `--color-primary`, `-hover`, `-active`, `-soft`, `-border`, `-strong` serta `--focus-ring`
82
+ semuanya ikut, di kedua tema, tanpa apa pun untuk disetel.
83
+
84
+ **Ke-18 dijaga gerbang kontras di kedua tema**: label di atas fill (AA 4.5), fill terhadap surface
85
+ dan cincin fokus (1.4.11, 3:1), dan `primary-strong` di atas wash-nya sendiri di atas ketiga
86
+ surface. 36 kombinasi, dan tiga hasilnya **dipaksa oleh pengukuran, bukan selera**: `yellow`
87
+ memakai fill lebih gelap di tema terang (di shade aslinya cuma 2.94:1 di atas putih, di bawah
88
+ ambang untuk sebuah fill UI) dan karena itu berlabel putih sementara semua palet hangat lain
89
+ berlabel gelap; `indigo` dan `violet` bergeser satu langkah lebih terang di tema gelap, karena di
90
+ shade tengahnya tak ada — putih maupun mendekati-hitam — yang mencapai 4.5:1. Ketiganya
91
+ didokumentasikan, karena tanpa penjelasan mereka terlihat seperti kekeliruan.
92
+
93
+ `noir` bukan hue: ia mengarahkan ramp alias ke ramp netral, jadi "primary" jadi mendekati hitam di
94
+ terang dan mendekati putih di gelap.
95
+
96
+ - **Sumbu gaya visual: `data-style="soft | glass"`.** `soft` default dan tampilan kit selama ini;
97
+ `glass` memfrost **permukaan terangkat** — card, modal, drawer, menu, appbar, sidebar & topbar app
98
+ shell. Dipasang di mana pun `data-theme` bisa, dan `[data-style="soft"]` adalah **rule sungguhan**,
99
+ jadi satu region bisa keluar dari root yang kaca. Sumbu ini menyerap toggle glass yang selama ini
100
+ jadi fitur terpisah di app konsumen: satu sumbu dua nilai, bukan empat kombinasi yang dua di
101
+ antaranya tak punya definisi visual.
102
+
103
+ Yang difrost adalah `--color-surface-raised`, **bukan `--color-surface`**: token itu juga isian
104
+ input, chip, sel tabel dan **kolom beku**, dan kolom beku yang tembus pandang memperlihatkan baris
105
+ yang menggulung di bawahnya. Komponen tak membawa selector `[data-style]` sama sekali; mereka
106
+ membaca tiga knob yang no-op di `soft` — `--color-surface-raised`, `--surface-filter`,
107
+ `--surface-inset`.
108
+
109
+ **Seberapa tembus kaca boleh jadi ditentukan gerbang kontras, bukan selera.** Di bawah `.82`
110
+ (terang) / `.90` (gelap), tinta muted dan `primary-strong` berhenti lolos 4.5:1 begitu panel
111
+ dikomposit di atas latar sembarang. Gerbangnya sengaja diuji terhadap **dua ekstrem — di atas hitam
112
+ dan di atas putih** — bukan terhadap warna halaman kit sendiri: versi pertama memakai warna halaman,
113
+ dan panel empat kali lebih tembus pun lolos, jadi gerbangnya nyaris hampa. Konsekuensinya jujur:
114
+ kaca di sini **halus**, dan frost yang terlihat datang dari blur-nya, bukan dari alpha-nya.
115
+
116
+ ### Fixed
117
+ - **`--color-surface-raised` tak ikut saat satu subtree di-gelapkan.** Custom property beralias
118
+ `var(--color-surface)` di-compute di tempat ia dideklarasikan, jadi tanpa nilai `$dark` sendiri ia
119
+ membeku pada permukaan terang dan sebuah `.fdy-card` di dalam panel `data-theme="dark"` tetap
120
+ mengecat terang. Ditangkap spec browser `theme.mjs` yang sudah ada, bukan oleh review.
121
+ - **`resolveValue` melewatkan referensi token di dalam ekspresi.** Pola lamanya mengikat seluruh
122
+ string, jadi `{primary.600}` di dalam `color-mix(...)` lolos mentah ke stylesheet sebagai teks
123
+ literal. Ketahuan saat wash `-soft` gelap diturunkan dari alias alih-alih memaku warna azure.
124
+ - **Gerbang kontras memipihkan alpha-nya sendiri.** Cabang `color-mix` mengembalikan `a: 1` apa pun
125
+ masukannya, jadi setiap wash `-soft` diuji seolah-olah opak — kekeliruan yang justru merupakan
126
+ hal yang pasangan `-soft` itu ada untuk menangkapnya, bersembunyi di dalam alatnya. Kini
127
+ premultiplied dan alpha-nya dibawa keluar.
128
+ - **`--focus-ring` menunjuk ramp brand langsung**, jadi cincin fokus akan tetap biru di aplikasi
129
+ yang memilih palet hijau. Kini ikut alias primary.
130
+ - **`CLAUDE.md` mengklaim `data-style` sudah ada.** Grep ke `src/`, `dist/` dan `tokens/`
131
+ mengembalikan nol: kit belum punya sumbu gaya sama sekali, dan `soft` adalah deskripsi tampilan,
132
+ bukan sebuah nilai. Spec `2026-07-21` §6 juga menyebut knob `--blur`/`--sat`/`--inset`
133
+ "dicadangkan, default no-op" — ketiganya tak pernah dibuat. Keduanya kini dikoreksi, karena klaim
134
+ palsu di dokumen menaikkan estimasi pekerjaan yang bergantung padanya.
135
+ - **`.fdy-busy__panel` disamakan dengan doktrin elevasi** (`USAGE.md`): panel di atas scrim adalah
136
+ kerabat modal, jadi `--shadow-lift`, bukan `--shadow-3` yang dipakai benda melayang di atas
137
+ halaman.
138
+ - **Backdrop app-shell menelan klik selama transisinya.** `visibility` adalah properti yang
139
+ di-transisi, jadi backdrop yang sedang menghilang tetap `visible` sepanjang durasinya dan terus
140
+ menangkap klik yang ditujukan ke topbar di bawahnya — dan masuk ke mode overlay lebih buruk lagi,
141
+ karena elemennya berpindah `display:none` → `display:block` dengan visibility beranimasi dari
142
+ `visible` bawaan, sehingga **sekadar mengganti mode memberi halaman ~200 ms klik mati**. Terukur:
143
+ `elementFromPoint` di atas tombol nav mengembalikan backdrop-nya. Kini `pointer-events:none`
144
+ selama tertutup, di jalur mobile maupun overlay. Spec app-shell yang lama sudah menyebut gejala
145
+ ini dan menyiasatinya dengan menunggu; ini menghapus sebabnya.
146
+ - **Backdrop mobile berhenti memakai warna mentah** (`rgba(8,10,20,.45)`), nilai kasar terakhir yang
147
+ tersisa di stylesheet komponen. Kini `--color-scrim`, jadi ia menggelap persis seperti overlay lain.
148
+
149
+ ### Guarded
150
+ - **Harness Blazor Server + prerender yang sungguhan** (`test/blazor-server/`, `npm run
151
+ test:blazor-server`) — host Blazor Server betulan, bukan double bUnit, digerakkan lewat CDP. bUnit
152
+ merender in-process dengan JS runtime tiruan, jadi setiap test Blazor selama ini **berangkat dari
153
+ dunia di mana interop sudah bekerja**; justru itu yang tak bisa menjawab `NEXT-UP` #2. **4/4
154
+ hijau**: prerender mengeluarkan markup penuh tanpa satu pun penanda hidrasi, enhancer hidrat begitu
155
+ circuit tersambung, `<dialog>` yang dibuka dari .NET benar-benar terbuka lalu tertutup Escape, dan
156
+ combo yang dipilih dengan mouse asli sampai ke binding .NET.
157
+
158
+ **Satu check sempat merah dan sempat dilaporkan sebagai cacat kit. Ia bukan cacat kit.** Enhancer
159
+ auto-init saat `DOMContentLoaded`, jadi mereka menstempel `data-fdy-*-ready` pada markup
160
+ **prerender yang sebentar lagi dibuang** Blazor ketika circuit tersambung. Test yang menunggu
161
+ penanda itu menunggu hal yang salah, dan klik-nya mendarat di node yang akan dilepas — persis
162
+ gejala "node dilepas, bukan fokus berpindah" yang terukur. Menunggu komponen benar-benar interaktif
163
+ membuatnya hijau, 3 dari 3 run. Pelajarannya tetap berlaku meski cacatnya tidak ada: **di bawah
164
+ prerendering, penanda hidrasi bisa benar tentang DOM yang tak punya masa depan.**
165
+ - 4 test node baru (invariant urutan stripe + warnanya), 11 test Chrome baru (selection lintas
166
+ halaman & non-aktivasi baris, `inert` yang mendarat **dan dilepas**, operasi cepat yang tak
167
+ melukis apa pun, guard stepper yang menolak), 7 test bUnit baru. **Setiap invariant diverifikasi
168
+ dengan mutasi**, bukan hanya dijalankan sekali.
169
+ - node 68 → 113 · browser 83 → 92 · bUnit 14 → 21 · **suite baru** blazor-server 4.
170
+
171
+ ### Fixed: the kit's own suite
172
+ - **A coordinate click now reaches its target on a window that is not the author's.** CI went red
173
+ on a **docs-only** commit — five lines of `HANDOFF.md` — with three unrelated specs failing at
174
+ once: the stretched card target, the app shell's wide toggle, and the Vue combo's mouse select.
175
+ All three click at coordinates. The tree that failed differs from the tree that passed by nothing
176
+ a browser can see, so the cause was the runner's window, not the commit: a target below the fold
177
+ is clicked where nobody can see it, `elementFromPoint` there is `null`, the click reaches nothing,
178
+ and the run reports whatever assertion came three lines later. `clickCenter` scrolls the target
179
+ into view before measuring, and refuses an empty point with an error naming the viewport and the
180
+ element's rect. Demonstrated on the real failure mode: with the target pushed below a 420×240
181
+ window the point is provably empty, and the click lands anyway.
182
+ - **`browser/harness-contract.mjs`** (2 tests) pins both halves, because a harness that quietly
183
+ stops clicking is the one bug the rest of the suite cannot report.
184
+ - An earlier attempt at this also rejected *intercepted* clicks and was reverted: `.fdy-btn--stretch`
185
+ covers its card through an `::after` pseudo-element that `getBoundingClientRect` cannot see, so
186
+ the check fired on two tests that were asserting exactly that behaviour. A check with false
187
+ positives on a documented pattern is worse than no check.
188
+
189
+ ## [2.2.0] - 2026-08-26
190
+ ### Added
191
+ - **The raw path can disable, lock and invalidate a picker** (`NEXT-UP.md` #12). `datepicker.css`
192
+ and `cascade.css` have styled `:disabled`, `[aria-readonly="true"]` and `[aria-invalid="true"]`
193
+ since they were written, and the enhancers never set any of them — so the states existed only in
194
+ the stacks that re-implement the control natively (Vue, React), and a **Blazor** app, whose
195
+ picker *is* the enhancer, could not disable a field at all. Now `data-disabled` /
196
+ `data-readonly` / `data-invalid` on the seed, plus `data-id` and `data-describedby` so the
197
+ trigger the enhancer builds can be labelled and described by a form's own markup.
198
+ - **`setState` on four enhancers** — `FreedayDatepicker`, `FreedayCascade`, `FreedayCombo`,
199
+ `FreedayAutocomplete`: `setState(root, { disabled, readonly, invalid })`. Not decoration: every
200
+ Blazor wrapper stops rendering once hydrated (`ShouldRender => !Hydrated`), so a parameter that
201
+ is only *rendered* goes quiet the moment a real form toggles it. The wrappers push through this
202
+ instead.
203
+ - **The Blazor pickers reach parity** with their Vue and React twins. `FdyDatepicker` goes from 6
204
+ parameters to 20 (the three states, `Id`, `Describedby`, and the nine calendar navigation
205
+ labels), `FdyAutocomplete` from 6 to 12, `FdyCascade` from 8 to 13, and `FdyCombo` finally has
206
+ `Describedby`. Four exemptions remain, each about the platform rather than effort, and each
207
+ named in `COMPONENTS.md`: `locale` (the enhancer formats through `Intl` from `<html lang>`),
208
+ `clearable`/`clearLabel` (this path builds no clear button), `ariaLabelledby` (the built trigger
209
+ is named by `Label`).
210
+ - **`closeLabel` on `FdyModal` and `FdyDrawer`** in Vue and React. Blazor has had `CloseLabel`
211
+ since it shipped; the other two hard-coded `aria-label="Close"`, so the gap ran in both
212
+ directions and this is the half nobody had reported yet.
213
+ - **The datepicker's ten labels are overridable**, as `data-fdy-text-prev-month`,
214
+ `-next-month`, `-prev-year`, `-next-year`, `-prev-years`, `-next-years`, `-choose-month`,
215
+ `-choose-year`, `-back-to-months`. Month and weekday NAMES still come from `Intl` via the page's
216
+ `lang`, which is the better hatch; these are the buttons around them.
217
+ ### Fixed
218
+ - **A chart series with no label read `Seri 1`.** Indonesian, in a release train whose 2.0.0
219
+ exists precisely to stop the enhancers mixing languages. It is `Series {n}` now, and
220
+ overridable. The donut's centre caption (`Total`) was hard-coded in the same file and is
221
+ overridable too; it is also built as an element rather than assigned through `innerHTML`, since
222
+ an author-supplied string is not markup.
223
+ - **The table's number-range filter offered `Min` and `Maks`.** Same leftover, same fix:
224
+ `filterMin` / `filterMax` in the strings table, English by default.
225
+ - **The cascade's own `Select` / `Select…` defaults and the timepicker's `Choose a time`** were
226
+ literals no host could reach. Both tables now carry them.
227
+ - **A `readonly` autocomplete opened its suggestion list.** The input carries `readonly` natively
228
+ and the enhancer never read it, so the list dropped over a field nobody could edit.
229
+ ### Changed
230
+ - **The hard-coded-string guard (#016) is inverted.** It used to find the line that writes to the
231
+ DOM and read the literals on it, which cannot see a string passed to a helper —
232
+ `navButton('‹', 'Previous month', fn)` writes its label inside `navButton`, where the value is a
233
+ variable. Ten datepicker labels, the timepicker's, the cascade's two, the table's two and the
234
+ chart's `Seri 1` all hid there, some of them for the whole life of the file. It now reads every
235
+ literal in every enhancer and asks the opposite question — is this prose, and is it in the
236
+ `TEXT` table — dropping non-prose by shape (selectors, CSS custom properties, kebab keys,
237
+ camelCase identifiers, URLs, locale tags) and one short vocabulary of DOM tokens. Verified by
238
+ reintroducing both blind spots and watching it fail.
239
+ ### Added: guards
240
+ - `test/blazor/FdyPickerStateTests.cs`: 7 bUnit tests covering what `dotnet build` cannot see —
241
+ each parameter reaching the markup the enhancer reads, an absent one leaving no empty attribute
242
+ behind, and a state change **after** the first render arriving through interop.
243
+ - `browser/picker-states.mjs`: 7 tests in real Chrome with real mouse gestures — a disabled picker
244
+ that will not open, a readonly one that keeps focus and still refuses, both `aria-invalid` and
245
+ the `--error` class, `setState` unlocking a picker, and a nav label overridden per element.
246
+ - `test/docs.test.mjs`: the Blazor picker parity guard, Vue's prop surface against the four
247
+ wrappers, with the four exemptions written out with their reasons rather than a bare list.
248
+ ### Notes on the shape of the fix
249
+ - The regression this work caused, and what caught it: naming the new flag `isDisabled` shadowed
250
+ an **existing** `isDisabled(date)` in the datepicker that decides whether a day falls outside
251
+ `min`/`max`, so the day grid threw on every render — while the panel still opened and only its
252
+ cells went missing. Nothing in the node suite noticed; `browser/vanilla.mjs`, which clicks
253
+ through the calendar in a real browser, failed immediately. The flags are named `state*` now,
254
+ with the collision written above them.
255
+
12
256
  ## [2.1.0] - 2026-08-25
13
257
  ### Added
14
258
  - **`singleRow()`, exported from `@cahyo-dimas/freeday/vue` and `/react`** (#045). `FdyCfl` emits
package/COMPONENTS.md CHANGED
@@ -126,6 +126,7 @@ which is why React's list is longer while offering the same thing.
126
126
  |---|---|---|
127
127
  | `navOpen?` | `boolean` | Whether the nav is visible. Optional because the default is the **viewport's**, and a caller cannot state that in one initial value. Vue binds `v-model:navOpen`. |
128
128
  | `onNavOpenChange?` | `(open: boolean) => void` | React. Visibility changed: the toggle, Escape, the backdrop, or a followed nav item. Vue emits `update:navOpen`. |
129
+ | `navMode?` | `'push' \| 'overlay'` | How a **visible** nav sits on a **wide** viewport: `push` (default) is the column that displaces the content, `overlay` floats it over the page with a backdrop. Below the nav breakpoint it is ignored — the nav is off-canvas there by definition, so there is nothing to choose. Blazor takes the enum `FdyNavMode`. |
129
130
  | `title?` | `string` (Vue) · `ReactNode` | Topbar title. |
130
131
  | `toggleLabel?` | `string` | Accessible name for the nav toggle button. |
131
132
  | `toggleIcon?` | `ReactNode` | React. Replaces the default hamburger; Vue uses the slot of that name. |
@@ -141,8 +142,18 @@ off-canvas drawer and backdrop are built in.
141
142
 
142
143
  - Parts: `__sidebar` `__brand` (`__brand-mark` `__brand-text` `__brand-title` `__brand-subtitle`)
143
144
  `__content` `__topbar` `__navtoggle` `__title` `__main` `__backdrop`
144
- - Modifiers: `--nav-open` (mobile drawer open, ≤720px) · `--nav-collapsed` (collapse to zero width,
145
- ≥721px) · `--static` (embed the shell in a page instead of filling the viewport)
145
+ - Modifiers: `--nav-open` (drawer open) · `--nav-collapsed` (collapse to zero width, ≥721px) ·
146
+ `--nav-overlay` (≥721px: float the nav over the content instead of displacing it) · `--static`
147
+ (embed the shell in a page instead of filling the viewport)
148
+
149
+ **`--nav-overlay` is a layout choice, not a second behaviour.** With it the nav floats at *every*
150
+ width, so the drawer's whole code path — backdrop, Escape, close-on-follow, focus into the panel and
151
+ back, `inert` on the content, the Tab trap — is reused rather than re-implemented; only the
152
+ positioning rules differ, and only above the breakpoint. It also changes which state class means
153
+ "visible": `--nav-open` instead of the absence of `--nav-collapsed`, exactly as on a narrow screen.
154
+ On the raw path, if you add or remove the class yourself, call
155
+ **`FreedayAppShell.refresh(root)`** afterwards — the shell has to re-read the DOM, or `inert` and
156
+ `aria-expanded` keep describing the arrangement you just left.
146
157
  - Also: `.fdy-skip`, the skip-to-content link, first child of the shell.
147
158
  - **Behaviour: `freeday-app-shell.js`.** Opt in with `data-fdy-app` on the root. The markup below
148
159
  is unchanged. It owns the toggle in both modes plus everything an overlay needs that hand-rolling
@@ -278,6 +289,57 @@ sidebar from off-canvas drawer to static column at 721px, *not* at `md`. Any `ma
278
289
  utility variant that has to agree with the shell must use `nav`; using `md` leaves 721–959px broken
279
290
  (sidebar already static while your script still treats it as an overlay).
280
291
 
292
+ **Visual style: `data-style="soft | glass"`.** `soft` is the default and the look the kit has always
293
+ had; `glass` frosts the **raised** surfaces — card, modal, drawer, menu, appbar, the app shell's
294
+ sidebar and topbar. Set it wherever `data-theme` goes, and `[data-style="soft"]` is a real rule, so a
295
+ region can opt back **out** of a glass root.
296
+
297
+ ```html
298
+ <html data-style="glass">
299
+ <section data-style="soft"><!-- this region stays flat --></section>
300
+ ```
301
+
302
+ It frosts `--color-surface-raised`, **never `--color-surface`** — that token is also the fill of
303
+ inputs, chips, table cells and frozen columns, and a translucent frozen column shows the rows
304
+ scrolling underneath it. Components carry no `[data-style]` selector of their own; they read three
305
+ knobs that are no-ops under `soft`: `--color-surface-raised`, `--surface-filter` (the
306
+ `backdrop-filter` value) and `--surface-inset` (a hairline top edge, chained onto the panel's
307
+ shadow). A component that cannot follow the axis through those is reporting a **missing token**, not
308
+ asking for a rule.
309
+
310
+ **How transparent glass may be is decided by the contrast gate, not by taste.** Below `.82` in light
311
+ and `.90` in dark, muted text and `primary-strong` stop clearing 4.5:1 once the panel is composited
312
+ over an arbitrary ground — measured, and asserted against the two extremes (over black and over
313
+ white) rather than against the kit's own page colour, which is close enough to the panel's own
314
+ lightness that a panel four times more transparent still passed. So glass here is a **subtle** frost:
315
+ the effect people actually see comes from the blur, not from the alpha. Put a photo or a gradient
316
+ behind a panel and the promise still holds; that is what "over any ground" buys.
317
+
318
+ **Primary palette: `data-primary`.** 18 options — `azure` (the default, and what you get with no
319
+ attribute) · `sky` `blue` `indigo` `violet` `purple` `fuchsia` `pink` `rose` · `orange` `amber`
320
+ `yellow` `lime` `green` `emerald` `teal` `cyan` · `noir`. Set it anywhere `data-theme` works,
321
+ including on a wrapper to re-brand one region:
322
+
323
+ ```html
324
+ <html data-primary="emerald" data-theme="dark">
325
+ ```
326
+
327
+ A palette redefines an eight-shade **alias ramp**, never the semantic tokens. Which shade is the
328
+ fill, which is hover, which is the soft wash is stated once in the token source, so a palette cannot
329
+ disagree with the others about what "primary" means — and `--color-primary`, `--color-primary-hover`,
330
+ `--color-primary-soft`, `--color-primary-border`, `--color-primary-strong` and `--focus-ring` all
331
+ follow, in both themes, with nothing to wire.
332
+
333
+ **Every palette is contrast-gated, and two carry an exception the gate forced.** All 18 are asserted
334
+ in both themes for the label on a primary fill (AA 4.5), the fill against the surface and the focus
335
+ ring (1.4.11, 3:1), and `primary-strong` on its soft wash over all three surfaces. Three results
336
+ worth knowing, because they look like mistakes otherwise: `yellow` uses a **darker** fill in light
337
+ than the other palettes (at its natural shade it is 2.94:1 on white — below the floor for a UI
338
+ fill), and takes a **white** label there while every other warm palette takes a dark one; `indigo`
339
+ and `violet` shift one step lighter in dark, because at their mid shade neither white nor near-black
340
+ reaches 4.5:1. `noir` is not a hue at all — it re-points the alias ramp at the neutral ramp, so
341
+ "primary" becomes near-black in light and near-white in dark.
342
+
281
343
  Density: `data-density="compact"` works on `<html>` **or any wrapper**, because the selector is a bare
282
344
  `[data-density="compact"]` over inheriting custom properties, so one screen can be dense while the
283
345
  rest of the app is not. `data-density="comfortable"` is a real rule too, so the reverse also works:
@@ -456,8 +518,9 @@ Native inputs, styled. `.fdy-check` · `.fdy-radio` · `.fdy-switch` on the wrap
456
518
  | `invalid?` | `boolean` | Sets `aria-invalid`; pair it with `describedby` pointing at the error text. |
457
519
  | `describedby?` | `string` | Id of the help or error text (`aria-describedby`). |
458
520
 
459
- Blazor: the same names in `PascalCase`, bound with `@bind-Value` (`Value` / `ValueChanged`),
460
- with one absence there is no `Describedby`, so a Blazor combo cannot point at its own error text.
521
+ Blazor: the same names in `PascalCase`, bound with `@bind-Value` (`Value` / `ValueChanged`).
522
+ `FreedayCombo.setState(root, { disabled, readonly, invalid })` pushes a state change onto a combo
523
+ whose host has stopped re-rendering.
461
524
 
462
525
  Fully styleable dropdown, APG combobox+listbox. Needs `freeday-select.js`.
463
526
 
@@ -501,9 +564,10 @@ keeps matching after selection. Do not put a glyph in that span.
501
564
  | `id?` · `ariaLabel?` · `ariaLabelledby?` · `describedby?` | `string` | Input id; its accessible name as text or as a reference; the help/error text it points at. |
502
565
  | `disabled?` · `readonly?` · `invalid?` | `boolean` | `readonly` keeps focus and tab order and shows its value, but the input is not editable and the list will not open. Unlike `disabled` it is not greyed. |
503
566
 
504
- Blazor takes six of these `Value` / `ValueChanged`, `Options`, `Placeholder`, `AriaLabel`,
505
- `EmptyText` — and no state flags or ids. Its wrapper renders a fixed seed element and does not
506
- splat unmatched attributes, so those are not reachable from a Blazor page at all.
567
+ Blazor carries all of these. The states live on the input natively, and
568
+ `FreedayAutocomplete.setState(root, { disabled, readonly, invalid })` changes them after the first
569
+ render. A `readonly` input no longer opens its suggestion list, which it used to do over a field
570
+ nobody could edit.
507
571
 
508
572
  Editable combobox that filters as you type. Needs `freeday-autocomplete.js`.
509
573
 
@@ -528,7 +592,10 @@ Editable combobox that filters as you type. Needs `freeday-autocomplete.js`.
528
592
  | `id?` · `ariaLabelledby?` · `describedby?` | `string` | Trigger id; the element that labels it; the help/error text it points at. |
529
593
  | `disabled?` · `readonly?` · `invalid?` | `boolean` | As on `<FdyCombo>`. |
530
594
 
531
- Blazor calls the tree `Nodes`, adds `SubmenuLabel`, and takes neither the state flags nor the ids.
595
+ Raw path: `data-disabled` / `data-readonly` / `data-invalid`, `data-id`, `data-describedby`, and
596
+ `FreedayCascade.setState(root, { disabled, readonly, invalid })` for a host that rendered once.
597
+ Blazor calls the tree `Nodes`, adds `SubmenuLabel`, and carries everything else except
598
+ `ariaLabelledby` — the trigger it builds is named by `Label`.
532
599
 
533
600
  Hierarchical drill-down. The data model is a **nested `<ul>`** inside the wrapper: an `<li>` with a
534
601
  child `<ul>` is a branch, one without is a leaf. Needs `freeday-cascade.js`.
@@ -675,9 +742,15 @@ a new class.
675
742
  The ten label props exist because month and weekday names follow `locale` while the buttons around
676
743
  them do not: without these, a Spanish calendar would be navigated by English arrows.
677
744
 
678
- Blazor's picker is much thinner `Value` / `ValueChanged`, `Label`, `Placeholder`, `Min`, `Max`
679
- and its seed element takes no unmatched attributes, so state flags, ids, `clearable` and every
680
- label above are unreachable there.
745
+ On the **raw path** these all have hooks now: `data-disabled` / `data-readonly` / `data-invalid`,
746
+ `data-id`, `data-describedby`, and one `data-fdy-text-<key>` per label above
747
+ (`data-fdy-text-prev-month`, …). A host that renders its markup once and cannot re-render it can
748
+ change the three states afterwards with `FreedayDatepicker.setState(root, { disabled, readonly,
749
+ invalid })`.
750
+
751
+ Blazor carries the same surface bar four: `locale` (the enhancer formats through `Intl` from the
752
+ page's `<html lang>`), `clearable` and `clearLabel` (this path builds no clear button), and
753
+ `ariaLabelledby` (the trigger it builds is named by `Label`).
681
754
 
682
755
  ### Props — `<FdyDateRange>`
683
756
 
@@ -842,11 +915,42 @@ variant is `role="img"` + `aria-label`; each interactive star needs a `.fdy-visu
842
915
  Linear multi-step flow: marker → check, one panel at a time, back/next. Needs
843
916
  `freeday-stepper.js`.
844
917
 
845
- - `.fdy-stepper` (`<ol>`) of `.fdy-step` (`is-active` / `is-complete` set by JS) ·
846
- `.fdy-step__btn` `__marker` `__label`
918
+ - `.fdy-stepper` (`<ol>`) of `.fdy-step` (`is-active` / `is-complete` set by JS, `is-error` set by
919
+ **you**) · `.fdy-step__btn` `__marker` `__badge` `__label`
847
920
  - `.fdy-step-panels` wrapping one `.fdy-step-panel` per step (`hidden` on the inactive ones)
848
921
  - `.fdy-step-nav` with `data-fdy-step-prev` / `data-fdy-step-next` buttons
849
922
 
923
+ **A step with problems: `is-error` + `__badge`.** The marker turns danger and the label with it,
924
+ but colour is never the carrier — put the count in `.fdy-step__badge` and name it in text, because
925
+ a red ring says "something" and a reader who cannot see red is told nothing at all. The badge is a
926
+ **sibling** of the marker, not a child: the enhancer rewrites the marker's contents on every render,
927
+ so a badge inside it would vanish the first time the reader moved a step.
928
+
929
+ ```html
930
+ <li class="fdy-step is-error">
931
+ <span class="fdy-step__marker">2</span>
932
+ <span class="fdy-step__badge" aria-hidden="true">3</span>
933
+ <span class="fdy-step__label">Lines <span class="fdy-visually-hidden">— 3 problems</span></span>
934
+ </li>
935
+ ```
936
+
937
+ **Leaving a step is refusable.** Next, and a forward jump to an already-reached step, both fire a
938
+ cancelable `fdy-step-before-change` (`detail: { from, to, waitFor }`). Going *back* never asks —
939
+ nothing is being committed.
940
+
941
+ ```js
942
+ el.addEventListener('fdy-step-before-change', (e) => {
943
+ e.preventDefault(); // refuse now, when the answer is already known
944
+ e.detail.waitFor = validate(e.detail.from); // …or defer: a promise resolving false refuses
945
+ });
946
+ ```
947
+
948
+ The enhancer disables both nav buttons and sets `aria-busy` on the `.fdy-stepper` while a promise is
949
+ outstanding, so a second click has nothing to aim at. Resolving to anything but `false` advances —
950
+ a handler that forgets to return is not read as a rejection — and a guard that **throws** advances
951
+ nothing, since it decided nothing. **How validity is decided stays with you**: the kit takes no
952
+ opinion on form libraries, and this event is the line that keeps it that way.
953
+
850
954
  ---
851
955
 
852
956
  # Data
@@ -868,6 +972,12 @@ Semantic static table. Wrap in `.fdy-table-wrap` (bordered surface) or `.fdy-tab
868
972
  (horizontal scroll). Always a `<caption>` (use `.fdy-visually-hidden` if it shouldn't show) and
869
973
  `<th scope="col">`. Numeric cells get `.fdy-table__num` (right-aligned, tabular).
870
974
 
975
+ **`--striped`** zebras the even body rows. The tint is deliberately *half* a surface step, not the
976
+ full one hover uses, so hover still reads on a striped row instead of matching it — and stripe,
977
+ hover and selection are ordered so the later state always wins. Override the colour through the
978
+ Tier-3 token **`--fdy-table-stripe`** (set it on any ancestor; the rule reads it as a `var()`
979
+ fallback, so your value wins without a specificity fight). Frozen columns stay striped.
980
+
871
981
  ```html
872
982
  <div class="fdy-table-wrap">
873
983
  <table class="fdy-table">
@@ -980,6 +1090,12 @@ provide the state prop for a concern and you own that concern instead. React nam
980
1090
  | `rowActivatable?` · `onRowActivate?` | `boolean` · `(row) => void` | Rows become focusable and activate on click, Enter or Space. |
981
1091
  | `rowClass?` | `(row: Row) => string \| undefined` | Per-row class hook, e.g. marking the selected row. |
982
1092
  | `expandedKeys?` · `renderRowDetail?` | `ReadonlyArray<string \| number>` · `(row) => ReactNode` | Controlled expansion: these keys get a full-width detail row. Vue uses the `row-detail` slot. |
1093
+ | `striped?` | `boolean` | Zebra-stripe the body rows (`.fdy-table--striped`). |
1094
+ | `selectable?` | `boolean` | Render the checkbox column and the bulk bar. |
1095
+ | `selectedKeys?` · `onSelectedKeysChange?` | `ReadonlyArray<string \| number>` · `(keys) => void` | Controlled selection, as `rowKey` values — the same identity `expandedKeys` uses, because a key survives the re-fetch that replaces every row object. Omit `selectedKeys` and the column still works, keeping the selection internally. The callback fires in **both** modes, so a screen can watch the selection without owning it. Vue emits `update:selectedKeys`; Blazor binds `@bind-SelectedKeys`. |
1096
+ | `bulkActions?` | `ReactNode` | React. Content in the bulk bar beside the count; Vue uses the `bulk-actions` slot (scoped: `keys`, `clear`), Blazor `BulkContent`. |
1097
+ | `selectedText?` · `clearSelectionText?` | `string` | Bulk-bar copy. Defaults `{n} selected` (with `{n}` substituted) and `Clear`. |
1098
+ | `selectAllLabel?` · `selectRowLabel?` · `bulkLabel?` | `string` | Accessible names for the header checkbox, each row checkbox, and the bulk-bar region. Defaults `Select all rows on this page`, `Select row`, `Bulk actions`. |
983
1099
  | `renderCell?` | `(column, row, value) => ReactNode` | React. Custom cell rendering; Vue uses the `cell` slot. |
984
1100
  | `toolbar?` | `ReactNode` | React. Content above the table; Vue uses the `toolbar` slot. |
985
1101
  | `onProcess?` | `(result: { rows, total }) => void` | React. The processed page after filter/sort/paginate, in **both** modes. Vue emits `process`, Blazor calls it `Process`. Render the same set elsewhere — a card list, a summary, a CSV export — without re-deriving the pipeline. |
@@ -988,6 +1104,14 @@ Blazor matches this surface (`LoadingText` and `EmptyContent` in place of `empty
988
1104
  `Toolbar` and `RowDetail` as `RenderFragment`s) and is the one adapter that also exposes
989
1105
  `FiltersChanged`.
990
1106
 
1107
+ **Select-all ticks the current page, not the whole result set.** A header checkbox that silently
1108
+ selects rows the reader cannot see is how a bulk delete goes wrong, so it acts on the rows on
1109
+ screen — and it is `indeterminate` while only some of them are ticked. Keys picked on *other* pages
1110
+ are kept rather than dropped, so paging away and back does not lose them; the bulk bar's count is
1111
+ the true total across pages, which is why it can read higher than the rows in view. `Clear` empties
1112
+ the whole selection. A row checkbox stops its own click, so ticking a row in a `rowActivatable`
1113
+ table selects it instead of navigating away from it.
1114
+
991
1115
  The interactive table: global search, sort, per-column filters, row selection + bulk bar,
992
1116
  pagination. Needs `freeday-table.js`. Wrap the whole thing in `.fdy-datatable` + `data-fdy-table`
993
1117
  (`data-page-size="N"`).
@@ -1038,7 +1162,11 @@ pagination. Needs `freeday-table.js`. Wrap the whole thing in `.fdy-datatable` +
1038
1162
  (`data-fdy-text-required`, `data-fdy-text-type`, …), narrower than the per-field
1039
1163
  `data-fdy-msg-<alias>` that still wins. `Freeday.toast()` takes `closeLabel` in its options
1040
1164
  object. `npm test` asserts no enhancer string is written outside its `TEXT` table, so a new one
1041
- arrives overridable or not at all.
1165
+ arrives overridable or not at all — a claim that was only half true until **2.2.0**, when the
1166
+ guard stopped looking for the line that writes to the DOM and started reading every literal in
1167
+ the file. The old shape could not see a string handed to a helper, which is how ten datepicker
1168
+ labels, the timepicker's, the cascade's two defaults, the table's `Min`/`Maks` filter
1169
+ placeholders and the chart's `Seri 1` all sat outside it.
1042
1170
 
1043
1171
  **Testing note:** a column's filter button and the dialog it opens deliberately share one
1044
1172
  accessible name (`Filter <column>`), since a dialog named after its trigger is the normal pattern. In a
@@ -1132,6 +1260,10 @@ Pure SVG/CSS, no dependency, re-colours with the theme. Needs `freeday-chart.js`
1132
1260
  - Colour: `data-fdy-color="primary"` or `data-fdy-colors="success,warning,danger"` for semantic token
1133
1261
  names **or** `chart-1`…`chart-8` slots to pin a category's colour. Multi-series defaults to the
1134
1262
  validated categorical palette `--chart-1`…`--chart-8`.
1263
+ - **Its two strings are overridable**, and until 2.2.0 neither was: a series with no `label` fell
1264
+ back to `Seri 1` — Indonesian, three months after 2.0.0 turned the enhancers English — and the
1265
+ donut's centre caption was hard-coded. Both take the usual hook now:
1266
+ `data-fdy-text-series="Series {n}"` and `data-fdy-text-total="Total"`.
1135
1267
  - Format: `data-fdy-format="number|percent|currency"`; legend `data-fdy-legend` (`none` to drop);
1136
1268
  axes `data-fdy-axes`
1137
1269
  - Sizing is already set by the kit. Override **these**, never a `height` on the chart root (a
@@ -1223,9 +1355,13 @@ Transient notification in a live region. Imperative only:
1223
1355
  ```js
1224
1356
  const node = Freeday.toast({ variant: 'success', title: 'Saved', message: 'INV-1042 saved.' })
1225
1357
  Freeday.toast({ variant: 'danger', title: 'Failed', message: '…', key: 'net-fail' }) // same key replaces
1358
+ Freeday.toast({ message: 'Still uploading…', timeout: 0 }) // 0 = stays until dismissed
1226
1359
  Freeday.dismiss('net-fail') // or Freeday.dismiss(node)
1227
1360
  ```
1228
- Every field is optional; it returns the toast element. Classes (rendered for you):
1361
+ Every field is optional; it returns the toast element. **`timeout`** is ms before it auto-dismisses,
1362
+ default `4000`, and `0` makes it stick — the enhancer has honoured it since it was written, but this
1363
+ line did not exist until 3.0.0, so every app that needed a sticky error toast hand-rolled one.
1364
+ Classes (rendered for you):
1229
1365
  `.fdy-toast-region`, `.fdy-toast` (+`--info` `--success` `--warning` `--danger`) · `__accent`
1230
1366
  `__body` `__title` `__text` `__close`.
1231
1367
 
@@ -1245,6 +1381,41 @@ gets `aria-describedby` → the `.fdy-tooltip[role="tooltip"]` `id`.
1245
1381
  duration. `role="progressbar"` with `aria-valuenow/min/max` (omit `valuenow` when indeterminate).
1246
1382
  `.fdy-spinner` (+`--sm` `--lg`) with `role="status"` + `aria-label`.
1247
1383
 
1384
+ ## Busy overlay — `Freeday.busy()`
1385
+ Blocks the screen while an operation runs — a save that must not be double-submitted, a report the
1386
+ server is still building. Imperative only, and that is the design, not a shortcut:
1387
+
1388
+ ```js
1389
+ Freeday.busy({ caption: 'Posting invoice…' }) // returns the element
1390
+ Freeday.idle() // releases it
1391
+ ```
1392
+
1393
+ - `caption?` — what is happening. Announced politely (`role="status"`). Omitted, it falls back to
1394
+ the kit's default, overridable page-wide with `data-fdy-text-caption` on `<html>`.
1395
+ - `delay?` — ms before it appears, default `120`, `0` shows immediately. An operation that finishes
1396
+ in 80ms should never flash a scrim; that reads as a glitch rather than as progress. `idle()`
1397
+ cancels a still-pending show, so a fast operation leaves nothing behind.
1398
+ - `mark?` — an `Element` to use instead of the spinner. **Element only, never an HTML string** — a
1399
+ string here would be an injection point in every app that passed user text through it. Brand marks
1400
+ are yours; the kit ships the box and one spinner.
1401
+
1402
+ Classes (rendered for you): `.fdy-busy` (+`.is-open`) · `__panel` `__mark` `__caption`.
1403
+
1404
+ **One overlay, always.** A second `busy()` while one is up replaces the caption instead of stacking
1405
+ a second scrim. That is the whole reason there is no component API and no markup to hand-write: two
1406
+ of these on screen, with two captions, from two components that each thought they owned it, is the
1407
+ bug this prevents.
1408
+
1409
+ **It is not a dialog.** Nothing asks a question and nothing can be dismissed, so there is no focus
1410
+ trap and no Escape. Interaction is removed with `inert` on every other child of `<body>` — and only
1411
+ on the ones the kit set it on, so an app's own `inert` is never cleared. Focus is parked on the panel
1412
+ and handed back to the element that had it once `idle()` runs, because by then that element's
1413
+ ancestor is inert and the browser would otherwise have dropped focus to `<body>`.
1414
+
1415
+ **Above modals, and not by z-index.** Like the toast region it is a `popover`, so it enters the top
1416
+ layer over an open `<dialog>`. Where the Popover API is missing it falls back to a fixed layer at
1417
+ `z-index: 190`, which cannot clear an open modal — the same trade the toast region documents.
1418
+
1248
1419
  ## Skeleton — `.fdy-skeleton`
1249
1420
  Size-matched placeholders so nothing shifts when data lands: `--title` `--text` `--circle`
1250
1421
  `--avatar` (+`--avatar-sm` `--avatar-lg`, exactly `.fdy-avatar`'s box).
@@ -1328,12 +1499,13 @@ so the same look serves routed sub-navigation built from plain links. See the no
1328
1499
  | `onClose` | `() => void` | React. Vue emits `close`. Fires from Escape, a backdrop click and the ×, all three of which exist only under `dismissible`. |
1329
1500
  | `size?` | `'sm' \| 'md' \| 'lg' \| 'wide'` | Dialog width. |
1330
1501
  | `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
+ | `closeLabel?` | `string` | Accessible name for the × button. Default `Close`. |
1331
1503
  | `footer?` | `ReactNode` | React. Vue: the `footer` slot. |
1332
1504
  | `children?` | `ReactNode` | React. The body. Vue: the default slot. |
1333
1505
 
1334
- Blazor binds `@bind-Open` and takes `TitleContent` / `ChildContent` / `FooterContent`, plus two
1335
- things Vue and React do not have: `OnClose` and **`CloseLabel`**. The × in Vue and React is labelled
1336
- `Close` and cannot be renamed.
1506
+ Blazor binds `@bind-Open` and takes `TitleContent` / `ChildContent` / `FooterContent`, plus
1507
+ `OnClose`. `CloseLabel` used to be Blazor-only, which left the × in Vue and React fixed at `Close`;
1508
+ all three stacks carry it now.
1337
1509
 
1338
1510
  Native `<dialog>`: focus trap, Esc and backdrop come from the browser. Sizes `--sm` `--md` `--lg`
1339
1511
  `--wide`; `--cfl` for the choose-from-list dialog.
@@ -1381,6 +1553,7 @@ The modal's props with `side` in place of `size`; the behaviour notes there appl
1381
1553
  | `onClose` | `() => void` | React. Vue emits `close`. |
1382
1554
  | `side?` | `'left' \| 'right'` | Which edge it slides from. |
1383
1555
  | `dismissible?` | `boolean` | Escape, backdrop and × on or off. |
1556
+ | `closeLabel?` | `string` | Accessible name for the × button. Default `Close`. |
1384
1557
  | `footer?` | `ReactNode` | React. Vue: the `footer` slot. |
1385
1558
  | `children?` | `ReactNode` | React. The body. Vue: the default slot. |
1386
1559
 
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-v2.1.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v2.1.0)
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)
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-v2.1.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v2.1.0)
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)
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
@@ -65,7 +65,7 @@ Shadow is a signal, not decoration. There are two families, and the difference m
65
65
  | Raised | `--shadow-2` | `.fdy-tooltip`, `.fdy-appbar--elevated` |
66
66
  | Floating | `--shadow-3` | Things that float over the page: `.fdy-menu`, `.fdy-filter`, `.fdy-toast`, `.fdy-fab` |
67
67
  | Overlay | `--shadow-4` | `.fdy-drawer` |
68
- | **Lift** | `--shadow-lift` / `--shadow-lift-hover` | **`.fdy-card`** (and `--elevated` / `--interactive:hover`), `.fdy-modal` |
68
+ | **Lift** | `--shadow-lift` / `--shadow-lift-hover` | **`.fdy-card`** (and `--elevated` / `--interactive:hover`), `.fdy-modal`, `.fdy-busy__panel` |
69
69
 
70
70
  **`.fdy-card` is a lifted surface, not a hairline one.** `--shadow-lift` is a real 34px lift, ~6×
71
71
  heavier than `--shadow-1`. That is the whole point of a card, and it is also why a *stack* of them
@@ -6,7 +6,7 @@
6
6
  `data-fdy-app` and binds to it: the enhancer announces changes as a bubbling `fdy-app-nav` event
7
7
  and accepts them through FreedayAppShell.setVisible. That is the bridge's own doctrine: the
8
8
  enhancers stay the source of truth, and it keeps the focus trap from existing a third time. *@
9
- <div @ref="Root" class="fdy-app" data-fdy-app>
9
+ <div @ref="Root" class="@ShellClass" data-fdy-app>
10
10
  @SkipContent
11
11
 
12
12
  <aside class="fdy-app__sidebar">