@cahyo-dimas/freeday 2.2.0 → 3.1.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 (41) hide show
  1. package/CHANGELOG.md +251 -0
  2. package/COMPONENTS.md +183 -7
  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/FdyTable.razor +44 -4
  9. package/adapters/blazor/FdyTable.razor.cs +110 -0
  10. package/adapters/blazor/FdyTableFooter.razor +1 -1
  11. package/adapters/blazor/freeday-blazor.js +8 -0
  12. package/adapters/react/components/FdyAppShell.tsx +52 -11
  13. package/adapters/react/components/FdyTable.tsx +123 -2
  14. package/adapters/react/components/FdyTableFooter.tsx +1 -1
  15. package/adapters/vue/components/FdyAppShell.vue +41 -11
  16. package/adapters/vue/components/FdyTable.vue +125 -4
  17. package/adapters/vue/components/FdyTableFooter.vue +1 -1
  18. package/dist/freeday-app-shell.js +23 -5
  19. package/dist/freeday-busy.js +168 -0
  20. package/dist/freeday-stepper.js +54 -4
  21. package/dist/freeday-table.js +7 -0
  22. package/dist/freeday.bundle.css +652 -43
  23. package/dist/freeday.css +129 -15
  24. package/dist/freeday.js +253 -9
  25. package/dist/freeday.tokens.css +523 -28
  26. package/docs/agent-onboarding.md +4 -0
  27. package/docs/getting-started.md +1 -1
  28. package/package.json +4 -3
  29. package/src/components/app-shell.css +29 -5
  30. package/src/components/appbar.css +2 -2
  31. package/src/components/busy.css +42 -0
  32. package/src/components/card.css +1 -1
  33. package/src/components/composition.css +15 -2
  34. package/src/components/drawer.css +1 -1
  35. package/src/components/filterbar.css +3 -2
  36. package/src/components/input.css +9 -0
  37. package/src/components/menu.css +1 -1
  38. package/src/components/modal.css +1 -1
  39. package/src/components/stepper.css +11 -0
  40. package/src/components/table.css +13 -0
  41. package/tokens/tokens.json +54 -10
package/CHANGELOG.md CHANGED
@@ -9,6 +9,257 @@ 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.1.0] - 2026-08-27
13
+
14
+ **Dua cacat lebar yang dilaporkan dari layar sungguhan, dan keduanya tak kelihatan di DOM.** Markup-nya
15
+ benar, class-nya benar, kotaknya cuma berhenti lebih awal. Rilis ini juga membuat satu kalimat
16
+ `COMPONENTS.md` jadi benar setelah lama tidak.
17
+
18
+ ### Fixed
19
+ - **Kontrol di dalam `.fdy-field` kini selebar field-nya** (`improvement-notes` #051, dari
20
+ `IDU_EMATE_APPL_WEB` dan `IDU_EMATE_ACCT_WEB`). `.fdy-input-group`, `.fdy-combo`,
21
+ `.fdy-autocomplete` dan `.fdy-cascade` masing-masing di-cap `22rem` — cap untuk kontrol yang
22
+ berdiri sendiri, tanpa field yang bisa memberinya lebar — dan cap itu hanya dilepas **di satu
23
+ container**, `.fdy-filterbar`, yang menyebut dua dari empatnya. Akibatnya baris `--full` di
24
+ `.fdy-form-grid` memegang combo 352px di bawah label 600px, sementara `.fdy-input`
25
+ di sebelahnya (yang tak pernah punya cap) mengisi penuh: satu form terlihat tak sejajar dengan
26
+ dirinya sendiri. Yang paling mahal justru versi yang dilaporkan pengguna tanpa diminta — *"jarak
27
+ search ke filter status agak jauh, ya?"* — pada toolbar dengan field `26rem`: kontrolnya 352px,
28
+ jadi **64px ruang mati ada DI DALAM field**, dan `gap:var(--space-3)` yang ditulis penulisnya
29
+ terbaca 76px di layar. Tak ada satu pun langkah debug yang wajar bisa melihatnya: gap-nya benar,
30
+ flex-nya benar.
31
+ Perbaikannya satu aturan di `input.css`, bukan selector ketiga di samping filterbar, dan bentuknya
32
+ **sudah ada di kit sejak #017** — `.fdy-field>.fdy-datepicker` melepas cap picker dengan komentar
33
+ "a picker fills the field *like every other control*". Kalimat itu tidak benar untuk empat kontrol
34
+ di atas, dan sekarang benar. Cap `22rem` tetap ada **di `.fdy-field`**, jadi field yang tak
35
+ dilebarkan siapa pun tak berubah sedikit pun.
36
+ Ikut terangkat, dan tak ada di laporan mana pun: `.fdy-autocomplete` dan `.fdy-cascade` di field
37
+ `--w-2xl` (25rem) berhenti 48px lebih pendek **di dalam filterbar sendiri** — container yang kit
38
+ kira sudah beres. Aturan lama di `filterbar.css` dihapus, bukan dibiarkan di samping yang baru:
39
+ dua aturan yang mengatakan hal sama persis adalah cara filterbar jadi tahu sesuatu yang tak
40
+ diketahui `input.css`.
41
+ - **`.fdy-pagination` akhirnya benar-benar dikirim** (#050 §2). `COMPONENTS.md` menyuruh konsumen
42
+ menulis `<nav class="fdy-pagination" data-fdy-table-pagination>`, dan class blok itu **nol match di
43
+ seluruh paket** — bukan di enhancer, bukan di tiga footer typed. Tak ada yang tampak berbeda,
44
+ karena blok itu memang tak punya rule (`NEXT-UP` #9), tapi selector konsumen atau asersi e2e pada
45
+ blok itu **lulus terhadap markup tulisan tangan dan gagal terhadap keluaran kit sendiri**, padahal
46
+ kedua jalur seharusnya bisa saling menggantikan. Enhancer kini menyetelnya sekali saat init;
47
+ `FdyTableFooter` Vue/React/Blazor merendernya.
48
+
49
+ ### Added
50
+ - **`.fdy-stats--inline`** — strip KPI yang **memeluk angkanya** alih-alih membentang, untuk yang
51
+ duduk di samping judul halaman (#050 §1). Ini jawaban atas cacat yang tak bisa diperbaiki dengan
52
+ dokumentasi saja: `.fdy-stat` membawa `container-type:inline-size` (itu yang memberi `__value`
53
+ tipe fluidnya), dan **inline-size containment berarti tile tak menyumbang lebar intrinsik sama
54
+ sekali**. Jadi floor `11rem` bawaan bukan pilihan rasa — ia satu-satunya yang memberi track itu
55
+ ukuran — dan konsumen yang me-retrack grid ke konten mendapat tiga track nol: tiga label saling
56
+ menimpa (`COMPAMEMBERSTA…`) dan halaman meluber ke samping. Terukur dari header
57
+ `IDU_EMATE_ACCT_WEB`: strip 40px, seluruhnya gap. `justify-items` lebih tajam lagi — apa pun
58
+ selain `stretch` mengecilkan tile ke sumbangan nol itu **walau track-nya sudah benar lebarnya**.
59
+ `--inline` melepas container-nya, jadi tile-nya berukuran sesuai kontennya sendiri dan `__value`
60
+ memakai `--text-3xl` datar: track yang seukuran kontennya tak pernah jadi track sempit yang
61
+ membuat tipe fluid itu ada. Konsumen tak perlu lagi menebak floor sendiri.
62
+
63
+ ### Docs
64
+ - **`COMPONENTS.md` §Stats akhirnya menyebut containment-nya.** `container-type`, `cqw`,
65
+ `inline-size` dan `contain` nol match di seluruh berkas itu sebelum rilis ini, padahal detail
66
+ implementasi tipe `__value` itu bocor langsung ke kontrak layout siapa pun yang me-retrack grid-nya.
67
+ Kini tertulis sebagai batasan: override `grid-template-columns` wajib punya floor eksplisit, dan
68
+ `justify-items` harus tetap `stretch`.
69
+ - **§Field menyebut cap-nya.** `22rem` dan `max-width` juga nol match di `COMPONENTS.md` sebelum ini,
70
+ jadi cap maupun pengecualian filterbar-nya hanya bisa ditemukan dengan mengukur form yang sudah
71
+ dirender. Aturannya sekarang satu kalimat: **beri ukuran pada field, jangan pada kontrolnya.**
72
+
73
+ ### Guarded
74
+ - `test/css.test.mjs`: bentuk CSS-nya — keempat kontrol dilepas di `.fdy-field`, `.fdy-field`
75
+ mempertahankan cap-nya sendiri, aturan filterbar lama tak boleh kembali, dan `--inline` harus
76
+ keluar dari container maupun clamp `cqw`-nya.
77
+ - `browser/field-width.mjs` (6 test, Chrome sungguhan): hasilnya — keduanya adalah **outcome layout**
78
+ yang tak terlihat di stylesheet. Termasuk asersi bahwa gap yang terlihat pembaca sama dengan gap
79
+ yang ditulis penulisnya, dan tile `--inline` diukur terhadap referensi yang berukuran-konten
80
+ secara konstruksi, bukan sekadar "bukan nol": tanpa itu, satu track yang masih memegang floor
81
+ `11rem` lolos karena strip-nya tetap masuk akal.
82
+ - `test/docs.test.mjs`: berkas mana pun yang merender `__element` sebuah blok harus menyebut bloknya.
83
+ Dicari **secara struktur**, bukan dari daftar empat berkas yang salah — renderer pager kelima
84
+ adalah yang tak akan diingat siapa pun.
85
+
86
+ ## [3.0.0] - 2026-08-26
87
+
88
+ **Kenapa MAJOR padahal rilis ini seluruhnya aditif.** Tak ada satu pun perubahan di bawah yang
89
+ mematahkan konsumen: prop-nya opsional, class-nya baru, dan `fdy-step-before-change` tanpa listener
90
+ berperilaku persis seperti sebelumnya. Secara semver murni ini MINOR. Nomor mayor diambil sebagai
91
+ keputusan pemilik untuk **membuka lini adopsi back-office** yang dispesifikasikan di
92
+ [`docs/superpowers/specs/2026-08-26-back-office-adoption-design.md`](docs/superpowers/specs/2026-08-26-back-office-adoption-design.md):
93
+ delapan item, empat di antaranya terkirim di sini, dan **paruh yang benar-benar breaking masih di
94
+ depan** — sumbu `data-style` mengubah arti atribut yang sudah terdokumentasi, `FdyTableColumn`
95
+ melebar untuk edit sel, `FdyAppShell` mendapat mode nav baru. Konsumen 2.x bisa naik ke 3.0.0 tanpa
96
+ mengubah satu baris pun.
97
+
98
+ ### Added
99
+ - **Row selection terkontrol di keempat stack** (`NEXT-UP.md` #3). Enhancer vanilla sudah punya
100
+ `.fdy-table-bulkbar` + kolom pilih sejak awal; adapter typed tidak, jadi tiap layar list yang
101
+ butuh aksi massal merakit kolom checkbox sendiri di samping tabel terkontrol. Kini `selectable` +
102
+ `selectedKeys` di Vue/React/Blazor, ber-key `rowKey` — identitas yang sudah dipakai
103
+ `expandedKeys`, karena sebuah key selamat dari re-fetch yang mengganti seluruh objek baris,
104
+ sedangkan identitas objek tidak. Select-all menyentuh **halaman yang terlihat**, bukan seluruh
105
+ hasil filter: checkbox header yang diam-diam memilih baris yang tak bisa dilihat pembaca adalah
106
+ cara bulk delete jadi bencana. Key dari halaman lain dipertahankan, jadi hitungan di bulk bar
107
+ bisa lebih besar dari jumlah baris di layar — didokumentasikan, karena kalau tidak terbaca
108
+ seperti bug.
109
+ - **`.fdy-table--striped`** + prop `striped` di keempat adapter, plus token Tier-3
110
+ `--fdy-table-stripe`. Warnanya **setengah** langkah surface, bukan langkah penuh yang dipakai
111
+ hover: stripe dengan warna hover membuat hover tak terlihat di setiap baris genap. Kolom beku
112
+ ikut dicat ulang, karena sel beku membawa background opak sendiri.
113
+ - **`Freeday.busy()` / `Freeday.idle()`** — overlay yang menghalangi layar selama operasi yang tak
114
+ boleh diinterupsi. Imperatif seperti `toast()`, dan alasannya lebih tajam: API komponen mengundang
115
+ dua instans, dan dua scrim dengan dua caption dari dua komponen yang sama-sama merasa memiliki
116
+ layar adalah kegagalan yang komponen ini cegah. Bukan dialog — tak ada yang ditanyakan dan tak ada
117
+ yang bisa ditutup, jadi interaksi dicabut dengan `inert`, bukan dengan focus trap. Jeda default
118
+ 120 ms supaya operasi 80 ms tak mengedipkan scrim, dan `idle()` membatalkan tampilan yang masih
119
+ tertunda alih-alih menyembunyikannya setelah terlanjur terlihat.
120
+ - **Token semantic `--color-scrim`**, sengaja identik di light dan dark: scrim menggelapkan apa pun
121
+ di belakangnya, jadi ia tak ikut membalik bersama palet.
122
+ - **`.fdy-step__badge`** dan status `is-error` stepper yang **sudah ada di CSS tapi tak
123
+ terdokumentasi**, sehingga tak terjangkau siapa pun. Label kini ikut ke danger, dan badge memuat
124
+ jumlahnya — karena cincin merah bilang "ada sesuatu" dan tak mengatakan apa-apa kepada pembaca
125
+ yang tak bisa melihat merah. Badge adalah **saudara** marker, bukan anaknya: enhancer menulis
126
+ ulang `__marker.innerHTML` di tiap render.
127
+ - **`fdy-step-before-change`**, event cancelable yang membuat Lanjut bisa ditolak. Event saja tak
128
+ cukup — validasi biasanya sejauh satu round-trip — jadi handler bisa `preventDefault()` **atau**
129
+ menitipkan promise ke `detail.waitFor`. Selama promise itu tertunda kedua tombol nav dinonaktifkan
130
+ dan header membawa `aria-busy`. Menjawab apa pun selain `false` meneruskan (handler yang lupa
131
+ `return` bukan penolakan), dan guard yang **melempar** tak meneruskan apa pun karena ia tak
132
+ memutuskan apa pun. Mundur tak pernah ditanya; lompat maju ke langkah yang sudah dicapai ditanya.
133
+ **Cara validitas diputuskan tetap milik aplikasi** — kit tak berpendapat soal library form, dan
134
+ event inilah batasnya.
135
+ - **`FreedayBlazor.setIndeterminate`**, satu helper interop, karena `indeterminate` adalah properti
136
+ DOM tanpa atribut HTML: renderer Blazor tak bisa mengekspresikannya, dan halaman yang separuh
137
+ terpilih akan tampil polos "belum tercentang".
138
+ - **Mode nav overlay di layar lebar** — `.fdy-app--nav-overlay`, dan `navMode` di keempat adapter
139
+ (Blazor: enum `FdyNavMode`). Nav mengambang di atas konten alih-alih menggesernya, yaitu pilihan
140
+ yang selama ini hanya bisa terjadi di layar sempit. **Modifier ini mengubah tata letak, bukan
141
+ perilaku**: backdrop, Escape, tutup-saat-link-diikuti, fokus masuk-dan-kembali, `inert`, dan Tab
142
+ trap semuanya jalur kode drawer yang sudah ada — shell kini bertanya pada sebuah class, bukan pada
143
+ media query, sehingga layar lebar tak lagi berarti "kolom". Konsekuensinya melintasi breakpoint
144
+ **tidak** menutup panel yang mengambang di kedua sisi. `FreedayAppShell.refresh(root)` ditambahkan
145
+ untuk jalur mentah yang mengubah class itu sendiri.
146
+
147
+ - **Sumbu palet primary: `data-primary`, 18 pilihan.** `azure` (default) · `sky` `blue` `indigo`
148
+ `violet` `purple` `fuchsia` `pink` `rose` · `orange` `amber` `yellow` `lime` `green` `emerald`
149
+ `teal` `cyan` · `noir`. Dipasang di mana pun `data-theme` bisa, termasuk di wrapper untuk
150
+ me-rebrand satu region.
151
+
152
+ Yang di-redefinisi sebuah palet adalah **ramp alias delapan shade**, bukan token semantic-nya.
153
+ Pemetaan "shade mana yang jadi fill, mana hover, mana wash" ditulis **sekali** di `color.primary*`,
154
+ jadi palet yang salah memetakan salah satunya bukan bentuk yang bisa dihasilkan sistem ini — dan
155
+ `--color-primary`, `-hover`, `-active`, `-soft`, `-border`, `-strong` serta `--focus-ring`
156
+ semuanya ikut, di kedua tema, tanpa apa pun untuk disetel.
157
+
158
+ **Ke-18 dijaga gerbang kontras di kedua tema**: label di atas fill (AA 4.5), fill terhadap surface
159
+ dan cincin fokus (1.4.11, 3:1), dan `primary-strong` di atas wash-nya sendiri di atas ketiga
160
+ surface. 36 kombinasi, dan tiga hasilnya **dipaksa oleh pengukuran, bukan selera**: `yellow`
161
+ memakai fill lebih gelap di tema terang (di shade aslinya cuma 2.94:1 di atas putih, di bawah
162
+ ambang untuk sebuah fill UI) dan karena itu berlabel putih sementara semua palet hangat lain
163
+ berlabel gelap; `indigo` dan `violet` bergeser satu langkah lebih terang di tema gelap, karena di
164
+ shade tengahnya tak ada — putih maupun mendekati-hitam — yang mencapai 4.5:1. Ketiganya
165
+ didokumentasikan, karena tanpa penjelasan mereka terlihat seperti kekeliruan.
166
+
167
+ `noir` bukan hue: ia mengarahkan ramp alias ke ramp netral, jadi "primary" jadi mendekati hitam di
168
+ terang dan mendekati putih di gelap.
169
+
170
+ - **Sumbu gaya visual: `data-style="soft | glass"`.** `soft` default dan tampilan kit selama ini;
171
+ `glass` memfrost **permukaan terangkat** — card, modal, drawer, menu, appbar, sidebar & topbar app
172
+ shell. Dipasang di mana pun `data-theme` bisa, dan `[data-style="soft"]` adalah **rule sungguhan**,
173
+ jadi satu region bisa keluar dari root yang kaca. Sumbu ini menyerap toggle glass yang selama ini
174
+ jadi fitur terpisah di app konsumen: satu sumbu dua nilai, bukan empat kombinasi yang dua di
175
+ antaranya tak punya definisi visual.
176
+
177
+ Yang difrost adalah `--color-surface-raised`, **bukan `--color-surface`**: token itu juga isian
178
+ input, chip, sel tabel dan **kolom beku**, dan kolom beku yang tembus pandang memperlihatkan baris
179
+ yang menggulung di bawahnya. Komponen tak membawa selector `[data-style]` sama sekali; mereka
180
+ membaca tiga knob yang no-op di `soft` — `--color-surface-raised`, `--surface-filter`,
181
+ `--surface-inset`.
182
+
183
+ **Seberapa tembus kaca boleh jadi ditentukan gerbang kontras, bukan selera.** Di bawah `.82`
184
+ (terang) / `.90` (gelap), tinta muted dan `primary-strong` berhenti lolos 4.5:1 begitu panel
185
+ dikomposit di atas latar sembarang. Gerbangnya sengaja diuji terhadap **dua ekstrem — di atas hitam
186
+ dan di atas putih** — bukan terhadap warna halaman kit sendiri: versi pertama memakai warna halaman,
187
+ dan panel empat kali lebih tembus pun lolos, jadi gerbangnya nyaris hampa. Konsekuensinya jujur:
188
+ kaca di sini **halus**, dan frost yang terlihat datang dari blur-nya, bukan dari alpha-nya.
189
+
190
+ ### Fixed
191
+ - **`--color-surface-raised` tak ikut saat satu subtree di-gelapkan.** Custom property beralias
192
+ `var(--color-surface)` di-compute di tempat ia dideklarasikan, jadi tanpa nilai `$dark` sendiri ia
193
+ membeku pada permukaan terang dan sebuah `.fdy-card` di dalam panel `data-theme="dark"` tetap
194
+ mengecat terang. Ditangkap spec browser `theme.mjs` yang sudah ada, bukan oleh review.
195
+ - **`resolveValue` melewatkan referensi token di dalam ekspresi.** Pola lamanya mengikat seluruh
196
+ string, jadi `{primary.600}` di dalam `color-mix(...)` lolos mentah ke stylesheet sebagai teks
197
+ literal. Ketahuan saat wash `-soft` gelap diturunkan dari alias alih-alih memaku warna azure.
198
+ - **Gerbang kontras memipihkan alpha-nya sendiri.** Cabang `color-mix` mengembalikan `a: 1` apa pun
199
+ masukannya, jadi setiap wash `-soft` diuji seolah-olah opak — kekeliruan yang justru merupakan
200
+ hal yang pasangan `-soft` itu ada untuk menangkapnya, bersembunyi di dalam alatnya. Kini
201
+ premultiplied dan alpha-nya dibawa keluar.
202
+ - **`--focus-ring` menunjuk ramp brand langsung**, jadi cincin fokus akan tetap biru di aplikasi
203
+ yang memilih palet hijau. Kini ikut alias primary.
204
+ - **`CLAUDE.md` mengklaim `data-style` sudah ada.** Grep ke `src/`, `dist/` dan `tokens/`
205
+ mengembalikan nol: kit belum punya sumbu gaya sama sekali, dan `soft` adalah deskripsi tampilan,
206
+ bukan sebuah nilai. Spec `2026-07-21` §6 juga menyebut knob `--blur`/`--sat`/`--inset`
207
+ "dicadangkan, default no-op" — ketiganya tak pernah dibuat. Keduanya kini dikoreksi, karena klaim
208
+ palsu di dokumen menaikkan estimasi pekerjaan yang bergantung padanya.
209
+ - **`.fdy-busy__panel` disamakan dengan doktrin elevasi** (`USAGE.md`): panel di atas scrim adalah
210
+ kerabat modal, jadi `--shadow-lift`, bukan `--shadow-3` yang dipakai benda melayang di atas
211
+ halaman.
212
+ - **Backdrop app-shell menelan klik selama transisinya.** `visibility` adalah properti yang
213
+ di-transisi, jadi backdrop yang sedang menghilang tetap `visible` sepanjang durasinya dan terus
214
+ menangkap klik yang ditujukan ke topbar di bawahnya — dan masuk ke mode overlay lebih buruk lagi,
215
+ karena elemennya berpindah `display:none` → `display:block` dengan visibility beranimasi dari
216
+ `visible` bawaan, sehingga **sekadar mengganti mode memberi halaman ~200 ms klik mati**. Terukur:
217
+ `elementFromPoint` di atas tombol nav mengembalikan backdrop-nya. Kini `pointer-events:none`
218
+ selama tertutup, di jalur mobile maupun overlay. Spec app-shell yang lama sudah menyebut gejala
219
+ ini dan menyiasatinya dengan menunggu; ini menghapus sebabnya.
220
+ - **Backdrop mobile berhenti memakai warna mentah** (`rgba(8,10,20,.45)`), nilai kasar terakhir yang
221
+ tersisa di stylesheet komponen. Kini `--color-scrim`, jadi ia menggelap persis seperti overlay lain.
222
+
223
+ ### Guarded
224
+ - **Harness Blazor Server + prerender yang sungguhan** (`test/blazor-server/`, `npm run
225
+ test:blazor-server`) — host Blazor Server betulan, bukan double bUnit, digerakkan lewat CDP. bUnit
226
+ merender in-process dengan JS runtime tiruan, jadi setiap test Blazor selama ini **berangkat dari
227
+ dunia di mana interop sudah bekerja**; justru itu yang tak bisa menjawab `NEXT-UP` #2. **4/4
228
+ hijau**: prerender mengeluarkan markup penuh tanpa satu pun penanda hidrasi, enhancer hidrat begitu
229
+ circuit tersambung, `<dialog>` yang dibuka dari .NET benar-benar terbuka lalu tertutup Escape, dan
230
+ combo yang dipilih dengan mouse asli sampai ke binding .NET.
231
+
232
+ **Satu check sempat merah dan sempat dilaporkan sebagai cacat kit. Ia bukan cacat kit.** Enhancer
233
+ auto-init saat `DOMContentLoaded`, jadi mereka menstempel `data-fdy-*-ready` pada markup
234
+ **prerender yang sebentar lagi dibuang** Blazor ketika circuit tersambung. Test yang menunggu
235
+ penanda itu menunggu hal yang salah, dan klik-nya mendarat di node yang akan dilepas — persis
236
+ gejala "node dilepas, bukan fokus berpindah" yang terukur. Menunggu komponen benar-benar interaktif
237
+ membuatnya hijau, 3 dari 3 run. Pelajarannya tetap berlaku meski cacatnya tidak ada: **di bawah
238
+ prerendering, penanda hidrasi bisa benar tentang DOM yang tak punya masa depan.**
239
+ - 4 test node baru (invariant urutan stripe + warnanya), 11 test Chrome baru (selection lintas
240
+ halaman & non-aktivasi baris, `inert` yang mendarat **dan dilepas**, operasi cepat yang tak
241
+ melukis apa pun, guard stepper yang menolak), 7 test bUnit baru. **Setiap invariant diverifikasi
242
+ dengan mutasi**, bukan hanya dijalankan sekali.
243
+ - node 68 → 113 · browser 83 → 92 · bUnit 14 → 21 · **suite baru** blazor-server 4.
244
+
245
+ ### Fixed: the kit's own suite
246
+ - **A coordinate click now reaches its target on a window that is not the author's.** CI went red
247
+ on a **docs-only** commit — five lines of `HANDOFF.md` — with three unrelated specs failing at
248
+ once: the stretched card target, the app shell's wide toggle, and the Vue combo's mouse select.
249
+ All three click at coordinates. The tree that failed differs from the tree that passed by nothing
250
+ a browser can see, so the cause was the runner's window, not the commit: a target below the fold
251
+ is clicked where nobody can see it, `elementFromPoint` there is `null`, the click reaches nothing,
252
+ and the run reports whatever assertion came three lines later. `clickCenter` scrolls the target
253
+ into view before measuring, and refuses an empty point with an error naming the viewport and the
254
+ element's rect. Demonstrated on the real failure mode: with the target pushed below a 420×240
255
+ window the point is provably empty, and the click lands anyway.
256
+ - **`browser/harness-contract.mjs`** (2 tests) pins both halves, because a harness that quietly
257
+ stops clicking is the one bug the rest of the suite cannot report.
258
+ - An earlier attempt at this also rejected *intercepted* clicks and was reverted: `.fdy-btn--stretch`
259
+ covers its card through an `::after` pseudo-element that `getBoundingClientRect` cannot see, so
260
+ the check fired on two tests that were asserting exactly that behaviour. A check with false
261
+ positives on a documented pattern is worse than no check.
262
+
12
263
  ## [2.2.0] - 2026-08-26
13
264
  ### Added
14
265
  - **The raw path can disable, lock and invalidate a picker** (`NEXT-UP.md` #12). `datepicker.css`
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
@@ -216,7 +227,22 @@ carry the doctrine in markup.
216
227
  sits half a label-height low in it, so labelled fields belong in `.fdy-filterbar`
217
228
  (`align-items:flex-end`). In a toolbar, hide the label with `.fdy-visually-hidden`.
218
229
  - `.fdy-stats` is a KPI grid of `.fdy-stat` (`__label` `__value` `__meta`). Deliberately **not**
219
- 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.
220
246
 
221
247
  ```html
222
248
  <div class="fdy-page">
@@ -278,6 +304,57 @@ sidebar from off-canvas drawer to static column at 721px, *not* at `md`. Any `ma
278
304
  utility variant that has to agree with the shell must use `nav`; using `md` leaves 721–959px broken
279
305
  (sidebar already static while your script still treats it as an overlay).
280
306
 
307
+ **Visual style: `data-style="soft | glass"`.** `soft` is the default and the look the kit has always
308
+ had; `glass` frosts the **raised** surfaces — card, modal, drawer, menu, appbar, the app shell's
309
+ sidebar and topbar. Set it wherever `data-theme` goes, and `[data-style="soft"]` is a real rule, so a
310
+ region can opt back **out** of a glass root.
311
+
312
+ ```html
313
+ <html data-style="glass">
314
+ <section data-style="soft"><!-- this region stays flat --></section>
315
+ ```
316
+
317
+ It frosts `--color-surface-raised`, **never `--color-surface`** — that token is also the fill of
318
+ inputs, chips, table cells and frozen columns, and a translucent frozen column shows the rows
319
+ scrolling underneath it. Components carry no `[data-style]` selector of their own; they read three
320
+ knobs that are no-ops under `soft`: `--color-surface-raised`, `--surface-filter` (the
321
+ `backdrop-filter` value) and `--surface-inset` (a hairline top edge, chained onto the panel's
322
+ shadow). A component that cannot follow the axis through those is reporting a **missing token**, not
323
+ asking for a rule.
324
+
325
+ **How transparent glass may be is decided by the contrast gate, not by taste.** Below `.82` in light
326
+ and `.90` in dark, muted text and `primary-strong` stop clearing 4.5:1 once the panel is composited
327
+ over an arbitrary ground — measured, and asserted against the two extremes (over black and over
328
+ white) rather than against the kit's own page colour, which is close enough to the panel's own
329
+ lightness that a panel four times more transparent still passed. So glass here is a **subtle** frost:
330
+ the effect people actually see comes from the blur, not from the alpha. Put a photo or a gradient
331
+ behind a panel and the promise still holds; that is what "over any ground" buys.
332
+
333
+ **Primary palette: `data-primary`.** 18 options — `azure` (the default, and what you get with no
334
+ attribute) · `sky` `blue` `indigo` `violet` `purple` `fuchsia` `pink` `rose` · `orange` `amber`
335
+ `yellow` `lime` `green` `emerald` `teal` `cyan` · `noir`. Set it anywhere `data-theme` works,
336
+ including on a wrapper to re-brand one region:
337
+
338
+ ```html
339
+ <html data-primary="emerald" data-theme="dark">
340
+ ```
341
+
342
+ A palette redefines an eight-shade **alias ramp**, never the semantic tokens. Which shade is the
343
+ fill, which is hover, which is the soft wash is stated once in the token source, so a palette cannot
344
+ disagree with the others about what "primary" means — and `--color-primary`, `--color-primary-hover`,
345
+ `--color-primary-soft`, `--color-primary-border`, `--color-primary-strong` and `--focus-ring` all
346
+ follow, in both themes, with nothing to wire.
347
+
348
+ **Every palette is contrast-gated, and two carry an exception the gate forced.** All 18 are asserted
349
+ in both themes for the label on a primary fill (AA 4.5), the fill against the surface and the focus
350
+ ring (1.4.11, 3:1), and `primary-strong` on its soft wash over all three surfaces. Three results
351
+ worth knowing, because they look like mistakes otherwise: `yellow` uses a **darker** fill in light
352
+ than the other palettes (at its natural shade it is 2.94:1 on white — below the floor for a UI
353
+ fill), and takes a **white** label there while every other warm palette takes a dark one; `indigo`
354
+ and `violet` shift one step lighter in dark, because at their mid shade neither white nor near-black
355
+ reaches 4.5:1. `noir` is not a hue at all — it re-points the alias ramp at the neutral ramp, so
356
+ "primary" becomes near-black in light and near-white in dark.
357
+
281
358
  Density: `data-density="compact"` works on `<html>` **or any wrapper**, because the selector is a bare
282
359
  `[data-density="compact"]` over inheriting custom properties, so one screen can be dense while the
283
360
  rest of the app is not. `data-density="comfortable"` is a real rule too, so the reverse also works:
@@ -347,6 +424,13 @@ native control, otherwise a `<div>` + explicitly associated label.
347
424
 
348
425
  - `.fdy-field` (+ `--full` inside `.fdy-form-grid`; widths `--w-sm` `--w-lg` `--w-xl` `--w-2xl`
349
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.
350
434
  - `.fdy-label` (+`--required`) · `.fdy-input` (+`--error`) · `.fdy-textarea` · `.fdy-help` (+`--error`)
351
435
 
352
436
  **Grouped controls are a `<fieldset>`, not a new block.** Put `.fdy-field` on the fieldset and
@@ -853,11 +937,42 @@ variant is `role="img"` + `aria-label`; each interactive star needs a `.fdy-visu
853
937
  Linear multi-step flow: marker → check, one panel at a time, back/next. Needs
854
938
  `freeday-stepper.js`.
855
939
 
856
- - `.fdy-stepper` (`<ol>`) of `.fdy-step` (`is-active` / `is-complete` set by JS) ·
857
- `.fdy-step__btn` `__marker` `__label`
940
+ - `.fdy-stepper` (`<ol>`) of `.fdy-step` (`is-active` / `is-complete` set by JS, `is-error` set by
941
+ **you**) · `.fdy-step__btn` `__marker` `__badge` `__label`
858
942
  - `.fdy-step-panels` wrapping one `.fdy-step-panel` per step (`hidden` on the inactive ones)
859
943
  - `.fdy-step-nav` with `data-fdy-step-prev` / `data-fdy-step-next` buttons
860
944
 
945
+ **A step with problems: `is-error` + `__badge`.** The marker turns danger and the label with it,
946
+ but colour is never the carrier — put the count in `.fdy-step__badge` and name it in text, because
947
+ a red ring says "something" and a reader who cannot see red is told nothing at all. The badge is a
948
+ **sibling** of the marker, not a child: the enhancer rewrites the marker's contents on every render,
949
+ so a badge inside it would vanish the first time the reader moved a step.
950
+
951
+ ```html
952
+ <li class="fdy-step is-error">
953
+ <span class="fdy-step__marker">2</span>
954
+ <span class="fdy-step__badge" aria-hidden="true">3</span>
955
+ <span class="fdy-step__label">Lines <span class="fdy-visually-hidden">— 3 problems</span></span>
956
+ </li>
957
+ ```
958
+
959
+ **Leaving a step is refusable.** Next, and a forward jump to an already-reached step, both fire a
960
+ cancelable `fdy-step-before-change` (`detail: { from, to, waitFor }`). Going *back* never asks —
961
+ nothing is being committed.
962
+
963
+ ```js
964
+ el.addEventListener('fdy-step-before-change', (e) => {
965
+ e.preventDefault(); // refuse now, when the answer is already known
966
+ e.detail.waitFor = validate(e.detail.from); // …or defer: a promise resolving false refuses
967
+ });
968
+ ```
969
+
970
+ The enhancer disables both nav buttons and sets `aria-busy` on the `.fdy-stepper` while a promise is
971
+ outstanding, so a second click has nothing to aim at. Resolving to anything but `false` advances —
972
+ a handler that forgets to return is not read as a rejection — and a guard that **throws** advances
973
+ nothing, since it decided nothing. **How validity is decided stays with you**: the kit takes no
974
+ opinion on form libraries, and this event is the line that keeps it that way.
975
+
861
976
  ---
862
977
 
863
978
  # Data
@@ -879,6 +994,12 @@ Semantic static table. Wrap in `.fdy-table-wrap` (bordered surface) or `.fdy-tab
879
994
  (horizontal scroll). Always a `<caption>` (use `.fdy-visually-hidden` if it shouldn't show) and
880
995
  `<th scope="col">`. Numeric cells get `.fdy-table__num` (right-aligned, tabular).
881
996
 
997
+ **`--striped`** zebras the even body rows. The tint is deliberately *half* a surface step, not the
998
+ full one hover uses, so hover still reads on a striped row instead of matching it — and stripe,
999
+ hover and selection are ordered so the later state always wins. Override the colour through the
1000
+ Tier-3 token **`--fdy-table-stripe`** (set it on any ancestor; the rule reads it as a `var()`
1001
+ fallback, so your value wins without a specificity fight). Frozen columns stay striped.
1002
+
882
1003
  ```html
883
1004
  <div class="fdy-table-wrap">
884
1005
  <table class="fdy-table">
@@ -991,6 +1112,12 @@ provide the state prop for a concern and you own that concern instead. React nam
991
1112
  | `rowActivatable?` · `onRowActivate?` | `boolean` · `(row) => void` | Rows become focusable and activate on click, Enter or Space. |
992
1113
  | `rowClass?` | `(row: Row) => string \| undefined` | Per-row class hook, e.g. marking the selected row. |
993
1114
  | `expandedKeys?` · `renderRowDetail?` | `ReadonlyArray<string \| number>` · `(row) => ReactNode` | Controlled expansion: these keys get a full-width detail row. Vue uses the `row-detail` slot. |
1115
+ | `striped?` | `boolean` | Zebra-stripe the body rows (`.fdy-table--striped`). |
1116
+ | `selectable?` | `boolean` | Render the checkbox column and the bulk bar. |
1117
+ | `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`. |
1118
+ | `bulkActions?` | `ReactNode` | React. Content in the bulk bar beside the count; Vue uses the `bulk-actions` slot (scoped: `keys`, `clear`), Blazor `BulkContent`. |
1119
+ | `selectedText?` · `clearSelectionText?` | `string` | Bulk-bar copy. Defaults `{n} selected` (with `{n}` substituted) and `Clear`. |
1120
+ | `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`. |
994
1121
  | `renderCell?` | `(column, row, value) => ReactNode` | React. Custom cell rendering; Vue uses the `cell` slot. |
995
1122
  | `toolbar?` | `ReactNode` | React. Content above the table; Vue uses the `toolbar` slot. |
996
1123
  | `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. |
@@ -999,6 +1126,14 @@ Blazor matches this surface (`LoadingText` and `EmptyContent` in place of `empty
999
1126
  `Toolbar` and `RowDetail` as `RenderFragment`s) and is the one adapter that also exposes
1000
1127
  `FiltersChanged`.
1001
1128
 
1129
+ **Select-all ticks the current page, not the whole result set.** A header checkbox that silently
1130
+ selects rows the reader cannot see is how a bulk delete goes wrong, so it acts on the rows on
1131
+ screen — and it is `indeterminate` while only some of them are ticked. Keys picked on *other* pages
1132
+ are kept rather than dropped, so paging away and back does not lose them; the bulk bar's count is
1133
+ the true total across pages, which is why it can read higher than the rows in view. `Clear` empties
1134
+ the whole selection. A row checkbox stops its own click, so ticking a row in a `rowActivatable`
1135
+ table selects it instead of navigating away from it.
1136
+
1002
1137
  The interactive table: global search, sort, per-column filters, row selection + bulk bar,
1003
1138
  pagination. Needs `freeday-table.js`. Wrap the whole thing in `.fdy-datatable` + `data-fdy-table`
1004
1139
  (`data-page-size="N"`).
@@ -1103,7 +1238,9 @@ and breaks the moment the class changes.
1103
1238
  ## Pagination — `.fdy-pagination`
1104
1239
  The block class on the `<nav>` is a **structural hook only**. It carries no rule of its own; the
1105
1240
  `__list` / `__link` / `__ellipsis` elements do all the styling, and the data table targets
1106
- `data-fdy-table-pagination`. Keep it on the wrapper anyway, for consistency with the rest of the kit.
1241
+ `data-fdy-table-pagination`. Keep it on the wrapper anyway, for consistency with the rest of the kit
1242
+ — and the kit keeps it too: the enhancer sets it on the element it finds, and all three typed
1243
+ footers render it, so a selector or an e2e assertion on the block matches either path.
1107
1244
 
1108
1245
  `<nav class="fdy-pagination" aria-label="Pagination">` → `<ul class="fdy-pagination__list">`;
1109
1246
  each item is a `.fdy-pagination__link` (`<button>` when navigable, `<span aria-current="page">`
@@ -1242,9 +1379,13 @@ Transient notification in a live region. Imperative only:
1242
1379
  ```js
1243
1380
  const node = Freeday.toast({ variant: 'success', title: 'Saved', message: 'INV-1042 saved.' })
1244
1381
  Freeday.toast({ variant: 'danger', title: 'Failed', message: '…', key: 'net-fail' }) // same key replaces
1382
+ Freeday.toast({ message: 'Still uploading…', timeout: 0 }) // 0 = stays until dismissed
1245
1383
  Freeday.dismiss('net-fail') // or Freeday.dismiss(node)
1246
1384
  ```
1247
- Every field is optional; it returns the toast element. Classes (rendered for you):
1385
+ Every field is optional; it returns the toast element. **`timeout`** is ms before it auto-dismisses,
1386
+ default `4000`, and `0` makes it stick — the enhancer has honoured it since it was written, but this
1387
+ line did not exist until 3.0.0, so every app that needed a sticky error toast hand-rolled one.
1388
+ Classes (rendered for you):
1248
1389
  `.fdy-toast-region`, `.fdy-toast` (+`--info` `--success` `--warning` `--danger`) · `__accent`
1249
1390
  `__body` `__title` `__text` `__close`.
1250
1391
 
@@ -1264,6 +1405,41 @@ gets `aria-describedby` → the `.fdy-tooltip[role="tooltip"]` `id`.
1264
1405
  duration. `role="progressbar"` with `aria-valuenow/min/max` (omit `valuenow` when indeterminate).
1265
1406
  `.fdy-spinner` (+`--sm` `--lg`) with `role="status"` + `aria-label`.
1266
1407
 
1408
+ ## Busy overlay — `Freeday.busy()`
1409
+ Blocks the screen while an operation runs — a save that must not be double-submitted, a report the
1410
+ server is still building. Imperative only, and that is the design, not a shortcut:
1411
+
1412
+ ```js
1413
+ Freeday.busy({ caption: 'Posting invoice…' }) // returns the element
1414
+ Freeday.idle() // releases it
1415
+ ```
1416
+
1417
+ - `caption?` — what is happening. Announced politely (`role="status"`). Omitted, it falls back to
1418
+ the kit's default, overridable page-wide with `data-fdy-text-caption` on `<html>`.
1419
+ - `delay?` — ms before it appears, default `120`, `0` shows immediately. An operation that finishes
1420
+ in 80ms should never flash a scrim; that reads as a glitch rather than as progress. `idle()`
1421
+ cancels a still-pending show, so a fast operation leaves nothing behind.
1422
+ - `mark?` — an `Element` to use instead of the spinner. **Element only, never an HTML string** — a
1423
+ string here would be an injection point in every app that passed user text through it. Brand marks
1424
+ are yours; the kit ships the box and one spinner.
1425
+
1426
+ Classes (rendered for you): `.fdy-busy` (+`.is-open`) · `__panel` `__mark` `__caption`.
1427
+
1428
+ **One overlay, always.** A second `busy()` while one is up replaces the caption instead of stacking
1429
+ a second scrim. That is the whole reason there is no component API and no markup to hand-write: two
1430
+ of these on screen, with two captions, from two components that each thought they owned it, is the
1431
+ bug this prevents.
1432
+
1433
+ **It is not a dialog.** Nothing asks a question and nothing can be dismissed, so there is no focus
1434
+ trap and no Escape. Interaction is removed with `inert` on every other child of `<body>` — and only
1435
+ on the ones the kit set it on, so an app's own `inert` is never cleared. Focus is parked on the panel
1436
+ and handed back to the element that had it once `idle()` runs, because by then that element's
1437
+ ancestor is inert and the browser would otherwise have dropped focus to `<body>`.
1438
+
1439
+ **Above modals, and not by z-index.** Like the toast region it is a `popover`, so it enters the top
1440
+ layer over an open `<dialog>`. Where the Popover API is missing it falls back to a fixed layer at
1441
+ `z-index: 190`, which cannot clear an open modal — the same trade the toast region documents.
1442
+
1267
1443
  ## Skeleton — `.fdy-skeleton`
1268
1444
  Size-matched placeholders so nothing shifts when data lands: `--title` `--text` `--circle`
1269
1445
  `--avatar` (+`--avatar-sm` `--avatar-lg`, exactly `.fdy-avatar`'s box).
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.2.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v2.2.0)
8
+ [![Release](https://img.shields.io/badge/release-v3.1.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.1.0)
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.2.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v2.2.0)
8
+ [![Release](https://img.shields.io/badge/release-v3.1.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.1.0)
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">
@@ -43,8 +43,20 @@ public partial class FdyAppShell
43
43
 
44
44
  [Parameter] public string ToggleLabel { get; set; } = "Toggle navigation";
45
45
 
46
+ /// <summary>
47
+ /// How a VISIBLE nav sits on a wide viewport: <see cref="FdyNavMode.Push"/> (default) makes it
48
+ /// a column that displaces the content, <see cref="FdyNavMode.Overlay"/> floats it over the page
49
+ /// with a backdrop. Below the nav breakpoint it is ignored — the nav is off-canvas there by
50
+ /// definition, so there is nothing to choose.
51
+ /// </summary>
52
+ [Parameter] public FdyNavMode NavMode { get; set; } = FdyNavMode.Push;
53
+
54
+ private string ShellClass =>
55
+ NavMode == FdyNavMode.Overlay ? "fdy-app fdy-app--nav-overlay" : "fdy-app";
56
+
46
57
  private int _navToken;
47
58
  private bool? _lastNavOpen;
59
+ private FdyNavMode _lastNavMode = FdyNavMode.Push;
48
60
 
49
61
  protected override async ValueTask HydrateAsync()
50
62
  {
@@ -77,6 +89,15 @@ public partial class FdyAppShell
77
89
  _lastNavOpen = wanted;
78
90
  await JS.InvokeVoidAsync("FreedayAppShell.setVisible", Root, wanted);
79
91
  }
92
+
93
+ // Switching the mode moves the answer to "is the nav visible?" from one state class to the
94
+ // other, so `inert` and `aria-expanded` describe the old arrangement until the shell re-reads
95
+ // the DOM. Blazor has rendered the new class by now; this tells the enhancer to look again.
96
+ if (_navToken != 0 && NavMode != _lastNavMode)
97
+ {
98
+ _lastNavMode = NavMode;
99
+ await JS.InvokeVoidAsync("FreedayAppShell.refresh", Root);
100
+ }
80
101
  }
81
102
 
82
103
  /// <summary>Invoked by the bridge when the shell's nav visibility changes for any reason,
@@ -112,3 +133,17 @@ public partial class FdyAppShell
112
133
 
113
134
  public sealed record NavDetail(bool Visible);
114
135
  }
136
+
137
+ /// <summary>
138
+ /// How a visible nav sits on a wide viewport. The JS adapters take the same idea as the string
139
+ /// union <c>'push' | 'overlay'</c>; C# gets an enum, for the same reason the column filter type is
140
+ /// one — a typo in a string reaches the renderer, a typo in an enum member does not compile.
141
+ /// </summary>
142
+ public enum FdyNavMode
143
+ {
144
+ /// <summary>A column that displaces the content (the default, and what every 2.x shell did).</summary>
145
+ Push,
146
+
147
+ /// <summary>Floats over the page with a backdrop, the way it already behaves on a narrow one.</summary>
148
+ Overlay,
149
+ }