@cahyo-dimas/freeday 1.54.0 → 2.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 (147) hide show
  1. package/CHANGELOG.md +713 -545
  2. package/COMPONENTS.md +417 -139
  3. package/README.id.md +46 -45
  4. package/README.md +46 -43
  5. package/USAGE.md +34 -34
  6. package/adapters/blazor/FdyAppShell.razor +2 -2
  7. package/adapters/blazor/FdyAppShell.razor.cs +6 -6
  8. package/adapters/blazor/FdyCfl.razor.cs +1 -1
  9. package/adapters/blazor/FdyChart.razor +1 -1
  10. package/adapters/blazor/FdyChart.razor.cs +3 -3
  11. package/adapters/blazor/FdyChartSeries.cs +1 -1
  12. package/adapters/blazor/FdyCombo.razor.cs +2 -2
  13. package/adapters/blazor/FdyModal.razor +1 -1
  14. package/adapters/blazor/FdyTable.razor.cs +7 -7
  15. package/adapters/blazor/FdyTableFilter.razor +1 -1
  16. package/adapters/blazor/FdyTableFooter.razor +1 -1
  17. package/adapters/blazor/FdyTableFooter.razor.cs +4 -4
  18. package/adapters/blazor/Freeday.Blazor.csproj +1 -1
  19. package/adapters/blazor/FreedayComponentBase.cs +1 -1
  20. package/adapters/blazor/TableModel.cs +3 -3
  21. package/adapters/blazor/TableTypes.cs +3 -3
  22. package/adapters/blazor/freeday-blazor.js +5 -5
  23. package/adapters/core/app-shell.d.ts +1 -1
  24. package/adapters/core/app-shell.js +6 -6
  25. package/adapters/core/cfl-value.d.ts +11 -0
  26. package/adapters/core/cfl-value.js +31 -0
  27. package/adapters/core/table-model.d.ts +2 -2
  28. package/adapters/core/table-model.js +8 -8
  29. package/adapters/react/components/FdyAppShell.tsx +5 -5
  30. package/adapters/react/components/FdyAutocomplete.tsx +1 -1
  31. package/adapters/react/components/FdyCascade.tsx +1 -1
  32. package/adapters/react/components/FdyCfl.tsx +11 -11
  33. package/adapters/react/components/FdyDateRange.tsx +2 -2
  34. package/adapters/react/components/FdyDatepicker.tsx +5 -5
  35. package/adapters/react/components/FdyDrawer.tsx +1 -1
  36. package/adapters/react/components/FdyModal.tsx +2 -2
  37. package/adapters/react/components/FdyTable.tsx +7 -7
  38. package/adapters/react/components/FdyTableFilter.tsx +1 -1
  39. package/adapters/react/components/FdyTableFooter.tsx +5 -5
  40. package/adapters/react/index.d.ts +4 -1
  41. package/adapters/react/index.js +1 -0
  42. package/adapters/react/useFreeday.js +3 -3
  43. package/adapters/vue/components/FdyAppShell.vue +5 -5
  44. package/adapters/vue/components/FdyAutocomplete.vue +1 -1
  45. package/adapters/vue/components/FdyCascade.vue +1 -1
  46. package/adapters/vue/components/FdyCfl.vue +8 -8
  47. package/adapters/vue/components/FdyChart.vue +2 -2
  48. package/adapters/vue/components/FdyCombo.vue +1 -1
  49. package/adapters/vue/components/FdyDateRange.vue +2 -2
  50. package/adapters/vue/components/FdyDatepicker.vue +5 -5
  51. package/adapters/vue/components/FdyDrawer.vue +3 -3
  52. package/adapters/vue/components/FdyModal.vue +3 -3
  53. package/adapters/vue/components/FdyTable.vue +7 -7
  54. package/adapters/vue/components/FdyTableFilter.vue +1 -1
  55. package/adapters/vue/components/FdyTableFooter.vue +5 -5
  56. package/adapters/vue/index.d.ts +5 -2
  57. package/adapters/vue/index.js +1 -0
  58. package/adapters/vue/useFreeday.js +3 -3
  59. package/dist/asset.d.ts +3 -3
  60. package/dist/freeday-app-shell.js +9 -9
  61. package/dist/freeday-autocomplete.js +2 -2
  62. package/dist/freeday-breakpoint.js +1 -1
  63. package/dist/freeday-carousel.js +8 -7
  64. package/dist/freeday-cascade.js +10 -9
  65. package/dist/freeday-cfl.js +12 -11
  66. package/dist/freeday-chart.js +11 -11
  67. package/dist/freeday-chip.js +3 -3
  68. package/dist/freeday-datepicker.js +11 -7
  69. package/dist/freeday-datetime.js +5 -5
  70. package/dist/freeday-drawer.js +1 -1
  71. package/dist/freeday-form.js +17 -16
  72. package/dist/freeday-mask.js +9 -8
  73. package/dist/freeday-menu.js +1 -1
  74. package/dist/freeday-number.js +3 -3
  75. package/dist/freeday-popover.js +3 -3
  76. package/dist/freeday-rating.js +2 -2
  77. package/dist/freeday-select.js +5 -5
  78. package/dist/freeday-slider.js +2 -2
  79. package/dist/freeday-stepper.js +10 -9
  80. package/dist/freeday-table.js +18 -17
  81. package/dist/freeday-tabs.js +1 -1
  82. package/dist/freeday-timepicker.js +2 -2
  83. package/dist/freeday-toast.js +11 -10
  84. package/dist/freeday-tree.js +3 -3
  85. package/dist/freeday-upload.js +28 -27
  86. package/dist/freeday.bundle.css +176 -176
  87. package/dist/freeday.css +170 -170
  88. package/dist/freeday.d.ts +2 -2
  89. package/dist/freeday.js +189 -176
  90. package/dist/freeday.tokens.css +5 -5
  91. package/docs/agent-onboarding.md +49 -46
  92. package/docs/getting-started.md +49 -40
  93. package/docs/integrations.md +18 -18
  94. package/docs/reference-screen.html +14 -14
  95. package/package.json +4 -3
  96. package/src/base.css +6 -6
  97. package/src/components/accordion.css +4 -4
  98. package/src/components/alert.css +1 -1
  99. package/src/components/app-shell.css +10 -10
  100. package/src/components/appbar.css +3 -3
  101. package/src/components/autocomplete.css +1 -1
  102. package/src/components/avatar.css +3 -3
  103. package/src/components/badge.css +5 -5
  104. package/src/components/breadcrumb.css +1 -1
  105. package/src/components/breakpoints.css +4 -4
  106. package/src/components/button.css +13 -13
  107. package/src/components/card.css +8 -8
  108. package/src/components/carousel.css +2 -2
  109. package/src/components/cascade.css +2 -2
  110. package/src/components/cfl.css +4 -4
  111. package/src/components/chart.css +3 -3
  112. package/src/components/chip.css +2 -2
  113. package/src/components/combo.css +3 -3
  114. package/src/components/composition.css +7 -7
  115. package/src/components/datepicker.css +6 -6
  116. package/src/components/datetimepicker.css +1 -1
  117. package/src/components/description-list.css +1 -1
  118. package/src/components/divider.css +1 -1
  119. package/src/components/drawer.css +1 -1
  120. package/src/components/file-upload.css +5 -5
  121. package/src/components/filterbar.css +7 -7
  122. package/src/components/form-grid.css +2 -2
  123. package/src/components/input-group.css +6 -6
  124. package/src/components/input.css +6 -6
  125. package/src/components/kbd.css +1 -1
  126. package/src/components/list.css +6 -6
  127. package/src/components/menu.css +3 -3
  128. package/src/components/modal.css +1 -1
  129. package/src/components/pagination.css +1 -1
  130. package/src/components/progress.css +1 -1
  131. package/src/components/rating.css +2 -2
  132. package/src/components/selection.css +4 -4
  133. package/src/components/skeleton.css +1 -1
  134. package/src/components/slider.css +1 -1
  135. package/src/components/spinner.css +1 -1
  136. package/src/components/states.css +1 -1
  137. package/src/components/stepper.css +2 -2
  138. package/src/components/table.css +15 -15
  139. package/src/components/tabs.css +3 -3
  140. package/src/components/timeline.css +1 -1
  141. package/src/components/timepicker.css +1 -1
  142. package/src/components/toast.css +3 -3
  143. package/src/components/tooltip.css +1 -1
  144. package/src/components/tree.css +2 -2
  145. package/tokens/breakpoints.d.ts +2 -2
  146. package/tokens/breakpoints.mjs +3 -3
  147. package/tokens/tokens.json +1 -1
package/COMPONENTS.md CHANGED
@@ -15,30 +15,31 @@ live docs.
15
15
  1. **Prefix is `fdy-`, pattern is compact BEM**: block `fdy-card`, element `fdy-card__title`,
16
16
  modifier `fdy-card--elevated`. A modifier is **always** written alongside its block class:
17
17
  `class="fdy-btn fdy-btn--ghost"`, never `fdy-btn--ghost` alone.
18
- **One documented exception:** `.fdy-input-group__addon--icon` is **standalone** it is a
18
+ **One documented exception:** `.fdy-input-group__addon--icon` is **standalone**. It is a
19
19
  borderless leading glyph, and adding the base `.fdy-input-group__addon` would give it the grey
20
20
  fill and divider of a `Rp` / `%` prefix, which is not what a search icon is. Written
21
21
  `class="fdy-input-group__addon--icon"`, alone. It is the only one; everywhere else the rule holds.
22
22
  2. **Never invent a class.** If it is not in this file, it does not exist. Freeday has no
23
23
  `fdy-grid`, no `fdy-flex`, no `fdy-mt-4`, no `fdy-primary`.
24
- 3. **Never write a raw hex or px value** in app CSS. Use the tokens `var(--color-primary)`,
24
+ 3. **Never write a raw hex or px value** in app CSS. Use the tokens: `var(--color-primary)`,
25
25
  `var(--space-4)`, `var(--radius-md)`, `var(--shadow-1)`, `var(--dur-2)`. Spacing is a 4px scale
26
26
  (`--space-0`…`--space-24`); a loose value breaks density switching.
27
27
  4. **Freeday owns components + tokens, not layout.** The only layout it ships is the shell
28
28
  (`.fdy-app`), the composition primitives below, and `.fdy-hidden`/`.fdy-visually-hidden`.
29
29
  Grids, stacks and one-off gaps come from your own layout layer (a utility framework run
30
30
  **utilities-only, preflight off**, with its theme defined in terms of `var(--space-N)`).
31
- 5. **`aria-invalid="true"` is the canonical error state** on form controls it drives the visual
31
+ 5. **`aria-invalid="true"` is the canonical error state** on form controls. It drives the visual
32
32
  state *and* the screen-reader state. Pair it with `aria-describedby` → the `id` of a
33
33
  `.fdy-help.fdy-help--error`. The `--error` class modifiers exist only for the case where the
34
34
  invalid state does not belong to the control itself.
35
35
  6. **Interactive components need their enhancer script.** Static ones (button, card, badge, table,
36
36
  alert, breadcrumb, timeline, accordion, tree-without-cascade…) are CSS-only.
37
- 7. **On Vue, React or Blazor, ten components have a typed wrapper use it.** `FdyCombo` ·
37
+ 7. **On Vue, React or Blazor, eleven components have a typed wrapper. Use it.** `FdyCombo` ·
38
38
  `FdyDatepicker` · `FdyDateRange` · `FdyAutocomplete` · `FdyCascade` · `FdyCfl` · `FdyChart` ·
39
- `FdyTable` · `FdyModal` · `FdyDrawer`, from `@cahyo-dimas/freeday/vue`, `/react`, or the
39
+ `FdyTable` · `FdyModal` · `FdyDrawer` · `FdyAppShell`, from `@cahyo-dimas/freeday/vue`, `/react`, or the
40
40
  `Freeday.Blazor` RCL. Each is flagged at its own section below. Hand-writing their raw markup in
41
- those stacks *looks* right the enhancer initialises once and the first render is correct — then
41
+ those stacks *looks* right at first, because the enhancer initialises once and the first render is
42
+ correct. Then
42
43
  breaks quietly: later-rendered DOM is never hydrated and the widget's state sits in the DOM
43
44
  instead of your framework's. Everything else is the same markup everywhere; hydrate the other
44
45
  interactive components with `useFreeday` (Vue/React) or `FreedayBlazor.initAll` (Blazor).
@@ -47,11 +48,11 @@ live docs.
47
48
 
48
49
  `.fdy-visually-hidden` is `position:absolute`, and `clip` hides **painting**, not **layout**. An
49
50
  absolutely positioned box resolves against its nearest *positioned* ancestor, and `overflow` clips
50
- only what is contained that way so in an unpositioned scroller, a hidden label parks at its static
51
+ only what is contained that way, so in an unpositioned scroller, a hidden label parks at its static
51
52
  position (possibly thousands of px to the right) and drags the **whole document** sideways. It is
52
53
  invisible in the DOM and immune to `overflow-x: hidden` on every wrapper.
53
54
 
54
- Every kit container that clips or scrolls therefore declares `position: relative` —
55
+ Every kit container that clips or scrolls therefore declares `position: relative`:
55
56
  `.fdy-table-scroll`, `.fdy-table-wrap`, `.fdy-list`, `.fdy-card`, `.fdy-tabs__list`,
56
57
  `.fdy-carousel__viewport`, `.fdy-accordion` (the rest are already inside a positioned ancestor).
57
58
  Two consequences for you:
@@ -66,7 +67,7 @@ Two consequences for you:
66
67
 
67
68
  Every enhancer is zero-dependency, auto-initialises once on `DOMContentLoaded`, and is idempotent:
68
69
  re-hydrate SPA-rendered DOM with `window.Freeday<X>.initAll(root)`. **`root` may be the widget
69
- itself or an ancestor of it** both work, so a framework ref placed directly on the enhanced
70
+ itself or an ancestor of it**. Both work, so a framework ref placed directly on the enhanced
70
71
  element is fine. Events are bubbling
71
72
  `CustomEvent`s; the payload is in `event.detail`.
72
73
 
@@ -114,7 +115,26 @@ is a no-op.
114
115
  # Shell & composition
115
116
 
116
117
  ## App shell — `.fdy-app`
117
- > **Typed wrapper: `<FdyAppShell>`** Vue (`v-model:navOpen`) · React (`navOpen`/`onNavOpenChange`) · Blazor (`@bind-NavOpen`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
118
+ > **Typed wrapper: `<FdyAppShell>`**. Vue (`v-model:navOpen`) · React (`navOpen`/`onNavOpenChange`) · Blazor (`@bind-NavOpen`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
119
+
120
+ ### Props — `<FdyAppShell>`
121
+
122
+ Vue and React names, `?` marking optional. Vue takes the regions as **slots** of the same names,
123
+ which is why React's list is longer while offering the same thing.
124
+
125
+ | Prop | Type | What it does |
126
+ |---|---|---|
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
+ | `onNavOpenChange?` | `(open: boolean) => void` | React. Visibility changed: the toggle, Escape, the backdrop, or a followed nav item. Vue emits `update:navOpen`. |
129
+ | `title?` | `string` (Vue) · `ReactNode` | Topbar title. |
130
+ | `toggleLabel?` | `string` | Accessible name for the nav toggle button. |
131
+ | `toggleIcon?` | `ReactNode` | React. Replaces the default hamburger; Vue uses the slot of that name. |
132
+ | `skip?` | `ReactNode` | React. Skip link, rendered first in the tab order. |
133
+ | `brand?` · `nav?` · `topbar?` | `ReactNode` | React. Sidebar brand, sidebar nav, topbar contents. |
134
+ | `children?` | `ReactNode` | React. The page itself; Vue's default slot. |
135
+
136
+ Blazor takes the regions as `RenderFragment`s — `BrandContent`, `NavContent`, `TopbarContent`,
137
+ `SkipContent`, `TitleContent`, `ChildContent` — and binds with `@bind-NavOpen`.
118
138
 
119
139
  The frame every application lives in. Do not hand-roll one from flexbox: the responsive sidebar,
120
140
  off-canvas drawer and backdrop are built in.
@@ -123,14 +143,14 @@ off-canvas drawer and backdrop are built in.
123
143
  `__content` `__topbar` `__navtoggle` `__title` `__main` `__backdrop`
124
144
  - Modifiers: `--nav-open` (mobile drawer open, ≤720px) · `--nav-collapsed` (collapse to zero width,
125
145
  ≥721px) · `--static` (embed the shell in a page instead of filling the viewport)
126
- - Also: `.fdy-skip` the skip-to-content link, first child of the shell.
127
- - **Behaviour: `freeday-app-shell.js`.** Opt in with `data-fdy-app` on the root the markup below
146
+ - Also: `.fdy-skip`, the skip-to-content link, first child of the shell.
147
+ - **Behaviour: `freeday-app-shell.js`.** Opt in with `data-fdy-app` on the root. The markup below
128
148
  is unchanged. It owns the toggle in both modes plus everything an overlay needs that hand-rolling
129
149
  reliably forgets: Escape, backdrop click, closing when a `.fdy-nav__item` is followed, focus moved
130
150
  into the panel on open and returned to `__navtoggle` on close, `inert` on `__content` while the
131
151
  overlay is up, and a Tab trap inside the panel. `aria-expanded` on `__navtoggle` answers "is the
132
152
  nav showing?" in both modes, so you never read the state classes yourself.
133
- - **A hidden nav is not a tabbable nav.** `__sidebar` is `inert` whenever the nav is not visible
153
+ - **A hidden nav is not a tabbable nav.** `__sidebar` is `inert` whenever the nav is not visible:
134
154
  collapsed at ≥721px (`width:0`) or off-canvas at ≤720px (`translateX(-100%)`). Both hide it from
135
155
  the eye; neither hides it from the keyboard, so without this a nav nobody can see still swallows
136
156
  Tab on the way to the page.
@@ -140,21 +160,21 @@ off-canvas drawer and backdrop are built in.
140
160
  - **`navOpen` is one idea, in both modes.** The typed wrappers expose "is the nav visible?" and the
141
161
  kit maps it: above the breakpoint a hidden nav is `--nav-collapsed`, below it a visible nav is
142
162
  `--nav-open`. Leave the prop unbound (`undefined` in Vue/React, `null` in Blazor) and the shell
143
- starts from the viewport a column on a wide screen, hidden on a narrow one. That default is why
163
+ starts from the viewport: a column on a wide screen, hidden on a narrow one. That default is why
144
164
  the prop is optional: a caller cannot express it as a single initial value before it knows the
145
165
  viewport.
146
166
  - **Binding your own state to the vanilla shell:** it emits a bubbling `fdy-app-nav` CustomEvent
147
- (`detail {visible}`) on every real change including the ones a viewport change causes, since a
167
+ (`detail {visible}`) on every real change, including the ones a viewport change causes, since a
148
168
  bound value that stayed `true` while the nav went off-canvas would be describing a panel nobody
149
169
  can see. Drive it with `FreedayAppShell.setVisible(root, visible)` rather than the class names;
150
170
  setting what is already set announces nothing, so a bound host cannot loop on its own echo.
151
171
  `FreedayAppShell.isVisible(root)` reads it back.
152
172
  - Skip the enhancer and nothing breaks: the classes still mean what they always meant and the
153
- behaviour is yours to write. `FreedayAppShell.init(root)` for markup mounted later hand it the
173
+ behaviour is yours to write. `FreedayAppShell.init(root)` for markup mounted later. Hand it the
154
174
  shell root itself, which is what the Blazor bridge does after its first render.
155
175
 
156
176
  **The nesting is fixed, not free-form:** `.fdy-app` is a flex **row** of `[__sidebar | __content]`,
157
- and `__content` is the column holding `__topbar` + `__main` it exists to give the sticky topbar a
177
+ and `__content` is the column holding `__topbar` + `__main`, which exists to give the sticky topbar a
158
178
  tall containing block. The brand goes in the **sidebar** (sized to match the topbar height), and
159
179
  `__main` already carries the page padding, so don't wrap your screen in another padded box.
160
180
 
@@ -187,15 +207,15 @@ tall containing block. The brand goes in the **sidebar** (sized to match the top
187
207
  Vertical section rhythm (`--space-8` between children). Assemble every screen from these; they
188
208
  carry the doctrine in markup.
189
209
 
190
- - `.fdy-page` the screen body
191
- - `.fdy-page__header` `.fdy-page__heading` (eyebrow + page title + `.fdy-page__desc`) on the
210
+ - `.fdy-page` is the screen body
211
+ - `.fdy-page__header` holds `.fdy-page__heading` (eyebrow + page title + `.fdy-page__desc`) on the
192
212
  left, `.fdy-page__actions` (**one** primary button) on the right
193
- - `.fdy-page-section` a region: `.fdy-page-section__head` + body, `--space-4` rhythm
194
- - `.fdy-toolbar` a horizontal control row; `__spacer` (flex filler), `__search` (capped width).
213
+ - `.fdy-page-section` is a region: `.fdy-page-section__head` + body, `--space-4` rhythm
214
+ - `.fdy-toolbar` is a horizontal control row; `__spacer` (flex filler), `__search` (capped width).
195
215
  It is `align-items:center`, so it is for **bare** controls. A `.fdy-field` with a *visible* label
196
- sits half a label-height low in it labelled fields belong in `.fdy-filterbar`
216
+ sits half a label-height low in it, so labelled fields belong in `.fdy-filterbar`
197
217
  (`align-items:flex-end`). In a toolbar, hide the label with `.fdy-visually-hidden`.
198
- - `.fdy-stats` a KPI grid of `.fdy-stat` (`__label` `__value` `__meta`). Deliberately **not**
218
+ - `.fdy-stats` is a KPI grid of `.fdy-stat` (`__label` `__value` `__meta`). Deliberately **not**
199
219
  cards. `.fdy-stats--boxed` for one shared surface with dividers.
200
220
 
201
221
  ```html
@@ -230,7 +250,7 @@ carry the doctrine in markup.
230
250
  ```
231
251
 
232
252
  ## Type roles & text
233
- One role per level of hierarchy never re-use a card title for a page title.
253
+ One role per level of hierarchy. Never re-use a card title for a page title.
234
254
 
235
255
  | Class | Element | Use |
236
256
  |---|---|---|
@@ -246,11 +266,11 @@ One role per level of hierarchy — never re-use a card title for a page title.
246
266
 
247
267
  ## Utilities
248
268
  `.fdy-hidden` · `.fdy-visually-hidden` (screen-reader-only) · `.fdy-list-reset` (strip native
249
- bullets/indent from a semantic list needed when your utility framework's preflight is off) ·
269
+ bullets/indent from a semantic list, needed when your utility framework's preflight is off) ·
250
270
  `.fdy-divider` (+`--vertical`) · `.fdy-kbd`.
251
271
 
252
272
  Responsive display: `.fdy-hide-below-sm|md|lg` · `.fdy-hide-above-sm|md|lg`.
253
- Breakpoints (min-width): `sm` 600 · `md` 960 · `lg` 1280 · `xl` 1920 importable as
273
+ Breakpoints (min-width): `sm` 600 · `md` 960 · `lg` 1280 · `xl` 1920, importable as
254
274
  `import { breakpoints } from '@cahyo-dimas/freeday/breakpoints'`.
255
275
 
256
276
  **`breakpoints.nav` (721) is separate and is the one the shell uses.** `.fdy-app` switches the
@@ -258,7 +278,7 @@ sidebar from off-canvas drawer to static column at 721px, *not* at `md`. Any `ma
258
278
  utility variant that has to agree with the shell must use `nav`; using `md` leaves 721–959px broken
259
279
  (sidebar already static while your script still treats it as an overlay).
260
280
 
261
- Density: `data-density="compact"` works on `<html>` **or any wrapper** the selector is a bare
281
+ Density: `data-density="compact"` works on `<html>` **or any wrapper**, because the selector is a bare
262
282
  `[data-density="compact"]` over inheriting custom properties, so one screen can be dense while the
263
283
  rest of the app is not. `data-density="comfortable"` is a real rule too, so the reverse also works:
264
284
  a compact root with one region opted back out (shared chrome that must match a sibling product).
@@ -268,13 +288,13 @@ a compact root with one region opted back out (shared chrome that must match a s
268
288
  # Actions
269
289
 
270
290
  ## Button — `.fdy-btn`
271
- **The base class is already the primary action** there is no `--primary`. Use it once per screen.
291
+ **The base class is already the primary action.** There is no `--primary`. Use it once per screen.
272
292
 
273
293
  - Modifiers: `--ghost` `--danger` `--text` `--icon` (square, icon-only) `--sm` `--lg`
274
294
  - Parts: `.fdy-btn__icon` (wraps the `<svg>`) · `.fdy-btn-group` (segmented row, `role="group"` +
275
295
  `aria-label`)
276
296
  - **Toggle / segmented:** `aria-pressed="true"` gives a real pressed state (soft primary fill on
277
- `--ghost`/`--text`, an inset-sunk gradient on the solid button). No extra class `aria-pressed`
297
+ `--ghost`/`--text`, an inset-sunk gradient on the solid button). No extra class: `aria-pressed`
278
298
  is already the right attribute, and it is what turns `.fdy-btn-group` from a joined row into a
279
299
  complete segmented control.
280
300
  - A11y: `--icon` **requires** `aria-label`. Use `<button type="button">` unless it submits.
@@ -288,7 +308,7 @@ a compact root with one region opted back out (shared chrome that must match a s
288
308
  ```
289
309
 
290
310
  **Quiet destructive:** combine `--danger` with `--ghost` or `--text`. The ground stays quiet and only
291
- the ink turns red `.fdy-menu__item--danger` has always worked this way. Use it whenever Delete
311
+ the ink turns red. `.fdy-menu__item--danger` has always worked this way. Use it whenever Delete
292
312
  shares a screen with the one primary action; a solid Delete beside Save is a second primary in all
293
313
  but name.
294
314
 
@@ -330,7 +350,7 @@ native control, otherwise a `<div>` + explicitly associated label.
330
350
  - `.fdy-label` (+`--required`) · `.fdy-input` (+`--error`) · `.fdy-textarea` · `.fdy-help` (+`--error`)
331
351
 
332
352
  **Grouped controls are a `<fieldset>`, not a new block.** Put `.fdy-field` on the fieldset and
333
- `.fdy-label` on the legend the kit already resets the UA border/padding and supplies the spacing:
353
+ `.fdy-label` on the legend. The kit already resets the UA border/padding and supplies the spacing:
334
354
 
335
355
  ```html
336
356
  <fieldset class="fdy-field">
@@ -342,7 +362,7 @@ native control, otherwise a `<div>` + explicitly associated label.
342
362
 
343
363
  **`.fdy-label--required` marks the label, not the accessibility tree.** The control already carries
344
364
  `required`; the asterisk is painted through `::after` with CSS alt text, so a screen reader never
345
- reads "star" after the label something a `<span>` you have to remember to mark `aria-hidden`
365
+ reads "star" after the label, which a `<span>` you have to remember to mark `aria-hidden`
346
366
  cannot guarantee.
347
367
  - `[readonly]` is styled on input/textarea: full contrast, focusable, copyable.
348
368
 
@@ -360,8 +380,8 @@ cannot guarantee.
360
380
  </label>
361
381
  ```
362
382
 
363
- **Which `type` does `.fdy-input` cover?** Every text-like type `text` `email` `password` `tel`
364
- `url` `search` `number` themes identically. Two carry a native widget the kit does *not* override:
383
+ **Which `type` does `.fdy-input` cover?** Every text-like type (`text` `email` `password` `tel`
384
+ `url` `search` `number`) themes identically. Two carry a native widget the kit does *not* override:
365
385
 
366
386
  | Type | What the UA still draws | What to do |
367
387
  |---|---|---|
@@ -386,7 +406,7 @@ Prefix/suffix addons around an input: text (`Rp`, `%`), a decorative icon
386
406
 
387
407
  ## Number field — `[data-fdy-number]`
388
408
  A number input with its increment/decrement affordance back, after `.fdy-input` removed the
389
- browser's own. **Not a new block** it is an `.fdy-input-group` with two `__btn`s, so it inherits
409
+ browser's own. **Not a new block.** It is an `.fdy-input-group` with two `__btn`s, so it inherits
390
410
  the shared border, focus ring and error promotion. Needs `freeday-number.js`.
391
411
 
392
412
  ```html
@@ -400,13 +420,13 @@ the shared border, focus ring and error promotion. Needs `freeday-number.js`.
400
420
  ```
401
421
 
402
422
  - **No custom event.** Stepping fires native bubbling `input` + `change` on the input, so `v-model`,
403
- `onChange` and `@bind` work with no adapter the input stays the source of truth.
423
+ `onChange` and `@bind` work with no adapter, because the input stays the source of truth.
404
424
  - `min` / `max` / `step` live on the **input**; the buttons never do the arithmetic themselves
405
425
  (`stepUp()`/`stepDown()` clamp for free) and go `disabled` at a bound, on a `disabled`/`readonly`
406
426
  field, and when `step="any"` (a stepper cannot express "no defined increment").
407
427
  - The buttons are **not tab stops** (`tabindex="-1"`): the input is already focusable and ↑/↓
408
428
  already step it, so extra stops would cost every keyboard user and buy nothing. Keep the
409
- `aria-label` pointer and browse-mode users still get a named control.
429
+ `aria-label`, so pointer and browse-mode users still get a named control.
410
430
  - `type="button"` is required. Inside a `<form>`, a bare `<button>` submits it.
411
431
 
412
432
  ## Checkbox · radio · switch
@@ -419,7 +439,25 @@ Native inputs, styled. `.fdy-check` · `.fdy-radio` · `.fdy-switch` on the wrap
419
439
  ```
420
440
 
421
441
  ## Select / combobox — `.fdy-combo`
422
- > **Typed wrapper: `<FdyCombo>`** Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
442
+ > **Typed wrapper: `<FdyCombo>`**. Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
443
+
444
+ ### Props — `<FdyCombo>`
445
+
446
+ | Prop | Type | What it does |
447
+ |---|---|---|
448
+ | `modelValue` · `value` | `T extends string` | The selected value: `v-model` in Vue, `value` in React. |
449
+ | `onChange` | `(value: T) => void` | React. A selection was committed. Vue emits `update:modelValue` and `change`. |
450
+ | `options` | `ReadonlyArray<{ value: T; label: string }>` | The list. React exports the row type as `FdyComboOption<T>`. |
451
+ | `id?` | `string` | Explicit id for the combobox button; otherwise one is generated. |
452
+ | `ariaLabelledby?` | `string` | Id of the element that labels the combobox. |
453
+ | `placeholder?` | `string` | Shown when no option matches the current value. |
454
+ | `disabled?` | `boolean` | Greyed and out of the tab order, the native `disabled` semantics. |
455
+ | `readonly?` | `boolean` | Locked/view mode: keeps focus and tab order and shows its value, but cannot be opened or changed. Unlike `disabled` it is not greyed. |
456
+ | `invalid?` | `boolean` | Sets `aria-invalid`; pair it with `describedby` pointing at the error text. |
457
+ | `describedby?` | `string` | Id of the help or error text (`aria-describedby`). |
458
+
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.
423
461
 
424
462
  Fully styleable dropdown, APG combobox+listbox. Needs `freeday-select.js`.
425
463
 
@@ -444,12 +482,28 @@ Fully styleable dropdown, APG combobox+listbox. Needs `freeday-select.js`.
444
482
 
445
483
  **The selected tick is CSS.** `.fdy-combo__check` is an empty box in the markup; the glyph is painted
446
484
  by the stylesheet on `[aria-selected="true"]` with alt text, so it never enters the accessibility
447
- tree. That keeps an option's accessible name **identical whether or not it is selected** the state
485
+ tree. That keeps an option's accessible name **identical whether or not it is selected**. The state
448
486
  is carried by `aria-selected` alone, announced once, and `getByRole('option', { name: 'August' })`
449
487
  keeps matching after selection. Do not put a glyph in that span.
450
488
 
451
489
  ## Autocomplete — `.fdy-autocomplete`
452
- > **Typed wrapper: `<FdyAutocomplete>`** Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
490
+ > **Typed wrapper: `<FdyAutocomplete>`**. Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
491
+
492
+ ### Props — `<FdyAutocomplete>`
493
+
494
+ | Prop | Type | What it does |
495
+ |---|---|---|
496
+ | `modelValue` · `value` | `string` | The typed text. Free text: the list suggests, it does not constrain. |
497
+ | `onChange` · `onSelect` | `(value: string) => void` | React. `onChange` on every keystroke, `onSelect` only when a suggestion is taken. Vue emits `update:modelValue` and `select`. |
498
+ | `options` | `ReadonlyArray<string>` | The suggestions to filter. |
499
+ | `emptyText?` | `string` | Shown when nothing matches. |
500
+ | `placeholder?` | `string` | |
501
+ | `id?` · `ariaLabel?` · `ariaLabelledby?` · `describedby?` | `string` | Input id; its accessible name as text or as a reference; the help/error text it points at. |
502
+ | `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
+
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.
453
507
 
454
508
  Editable combobox that filters as you type. Needs `freeday-autocomplete.js`.
455
509
 
@@ -458,7 +512,23 @@ Editable combobox that filters as you type. Needs `freeday-autocomplete.js`.
458
512
  `aria-controls` → the listbox `id`, `autocomplete="off"`.
459
513
 
460
514
  ## Cascade select — `.fdy-cascade`
461
- > **Typed wrapper: `<FdyCascade>`** Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
515
+ > **Typed wrapper: `<FdyCascade>`**. Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
516
+
517
+ ### Props — `<FdyCascade>`
518
+
519
+ | Prop | Type | What it does |
520
+ |---|---|---|
521
+ | `modelValue` · `value` | `string` | The selected **leaf** value; `''` means nothing selected. |
522
+ | `onChange` | `(value: string, labels: string[]) => void` | React. Vue emits `update:modelValue` and `change`. `labels` is the whole path, root → leaf. |
523
+ | `options` | `ReadonlyArray<CascadeNode>` | The tree. `CascadeNode` = `{ label, value, children? }`; a node **with** children is a branch, one without is selectable. |
524
+ | `separator?` | `string` | What joins the path in the display. Default `" / "`, matching the enhancer. |
525
+ | `backLabel?` | `string` | Accessible name for the up-one-level button. Default `Back one level`. |
526
+ | `label?` | `string` | Accessible name for the trigger and its listbox. |
527
+ | `placeholder?` | `string` | |
528
+ | `id?` · `ariaLabelledby?` · `describedby?` | `string` | Trigger id; the element that labels it; the help/error text it points at. |
529
+ | `disabled?` · `readonly?` · `invalid?` | `boolean` | As on `<FdyCombo>`. |
530
+
531
+ Blazor calls the tree `Nodes`, adds `SubmenuLabel`, and takes neither the state flags nor the ids.
462
532
 
463
533
  Hierarchical drill-down. The data model is a **nested `<ul>`** inside the wrapper: an `<li>` with a
464
534
  child `<ul>` is a branch, one without is a leaf. Needs `freeday-cascade.js`.
@@ -479,7 +549,38 @@ child `<ul>` is a branch, one without is a leaf. Needs `freeday-cascade.js`.
479
549
  ```
480
550
 
481
551
  ## Choose-from-list (CFL) — `data-fdy-cfl`
482
- > **Typed wrapper: `<FdyCfl>`** Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
552
+ > **Typed wrapper: `<FdyCfl>`**. Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
553
+
554
+ ### Props — `<FdyCfl>`
555
+
556
+ Four of these are required and carry the whole component: `fetchPage`, `columns`, `display`,
557
+ `rowKey`. The rest are copy and state.
558
+
559
+ | Prop | Type | What it does |
560
+ |---|---|---|
561
+ | `modelValue` · `value` | `Row \| Row[] \| null` | The picked row. An array is reachable only under `multiple`, `null` only under `clearable`; narrow it with `singleRow()` (see the bullet below the raw-path hooks). |
562
+ | `onChange` | `(value: Row \| Row[] \| null) => void` | React. Vue emits `update:modelValue` and `change`. |
563
+ | `fetchPage` | `(query: string, page: number) => Promise<CflPage<Row>>` | The only data source — `{ rows, hasMore }`. Called on open, on search, and on Load more. Server state, never a global store. |
564
+ | `columns` | `ReadonlyArray<CflColumn<Row>>` | `{ key, label }` per dialog column. |
565
+ | `display` | `(row: Row) => string` | What the closed field shows once a row is picked. |
566
+ | `rowKey` | `(row: Row) => string` | Row identity: ticks in multi mode, and seeding them when the dialog re-opens. |
567
+ | `pageSize?` | `number` | Advisory only. Your `fetchPage` owns paging; this documents the intent. |
568
+ | `title?` | `string` | The dialog's heading. Default `Choose data`. |
569
+ | `searchPlaceholder?` | `string` | Placeholder **and** accessible name of the search box. Default `Search…`. |
570
+ | `loadingText?` · `emptyText?` · `retryText?` · `moreText?` | `string` | Dialog states. Defaults `Loading…`, `No results.`, `Try again`, `Load more`. |
571
+ | `closeLabel?` · `openLabel?` | `string` | Accessible names for the dialog's × and the field's open button. Defaults `Close`, `Open search`. |
572
+ | `multiple?` | `boolean` | Tick rows and commit them together on Confirm instead of committing the clicked row, and widen the value to an array. |
573
+ | `selectedText?` · `confirmText?` · `hintText?` | `string` | Footer copy. Defaults `{n} selected` (with `{n}` substituted), `Confirm`, `Click a row to choose it`. |
574
+ | `placeholder?` | `string` | Field placeholder while nothing is picked. |
575
+ | `clearable?` | `boolean` | A clear button, so an **optional** foreign key can be unset. Without it the value type admits a `null` the component can never produce. |
576
+ | `clearLabel?` | `string` | Accessible name for that button. Default `Clear selection`. |
577
+ | `disabled?` · `readonly?` · `invalid?` | `boolean` | `readonly` keeps the picked value visible, focusable and copyable, but the dialog cannot be opened. |
578
+ | `id?` · `ariaLabelledby?` · `describedby?` | `string` | Field id; the element that labels it; the help/error text it points at. |
579
+
580
+ Blazor carries the same surface in `PascalCase` with three differences: multi-select is a second
581
+ pair (`Values` / `ValuesChanged`) rather than a widened single binding, the fetch delegate is
582
+ called `LoadPage`, and it has two parameters the JS adapters do not — `ErrorText` and
583
+ `SearchDebounceMs`.
483
584
 
484
585
  A read-only field backed by master data: the button opens a searchable dialog, the picked row fills
485
586
  the field. The value is always **chosen, never typed**. Needs `freeday-cfl.js`.
@@ -490,13 +591,20 @@ the field. The value is always **chosen, never typed**. Needs `freeday-cfl.js`.
490
591
  - Inside: `.fdy-cfl__search` `__results` `__row` `__check` `__empty` `__count` `__actions`
491
592
  - Hooks: `data-fdy-cfl-search` (the search input), `data-fdy-cfl-empty`, `data-fdy-cfl-count`,
492
593
  `data-fdy-cfl-confirm` (multi only), `data-fdy-cfl-trigger`, `data-fdy-cfl-open`
493
- - Rows are server state in a real app drive them from a controlled fetch, not a global store.
594
+ - Rows are server state. In a real app, drive them from a controlled fetch rather than a global store.
494
595
  - **Multi-select in the typed wrappers** (Vue · React · Blazor): `multiple` ticks rows and commits
495
596
  them together on **Confirm** instead of committing the row that was clicked. Vue and React widen
496
597
  their model to an array (`Row[] | null`); Blazor takes a second pair, `Values` /
497
598
  `ValuesChanged`, because a nullable union is not a C# shape. Closing without Confirm leaves the
498
599
  bound value untouched, and re-opening seeds the ticks from it. The field states `{n} selected`
499
600
  rather than one row's `display()`. Strings: `selectedText`, `confirmText`, `hintText`.
601
+ - **A single-select in Vue or React needs one narrowing, and the kit ships it.** The emitted value
602
+ is typed `Row | Row[] | null` whatever the props are, so a single-select handler cannot take it
603
+ as `Row`: the array is reachable only under `multiple`, the null only under `clearable`, and the
604
+ type cannot say which. Import the guard instead of writing one per app:
605
+ `import { singleRow } from '@cahyo-dimas/freeday/vue'` (or `/react`). It returns `Row | null`,
606
+ and throws if a `multiple` field's array ever reaches it, rather than silently keeping the first
607
+ row. Blazor is unaffected: multi-select there is a separate `Values` / `ValuesChanged` pair.
500
608
 
501
609
  ```html
502
610
  <div class="fdy-field">
@@ -537,32 +645,72 @@ the field. The value is always **chosen, never typed**. Needs `freeday-cfl.js`.
537
645
  ```
538
646
 
539
647
  **`clearable` makes the value removable.** Without it a choose-from-list can be set but never unset,
540
- which breaks every *optional* foreign key the value type already allows null, only the component
648
+ which breaks every *optional* foreign key. The value type already allows null; only the component
541
649
  could not produce it. With it, a clear button appears beside the trigger whenever a row is picked
542
650
  and the field is editable; it emits `null` (Vue `update:modelValue`/`change`, React `onChange`,
543
651
  Blazor `ValueChanged`) and returns focus to the trigger. It is a second `.fdy-input-group__btn`, not
544
652
  a new class.
545
653
 
546
654
  ## Date picker — `data-fdy-datepicker`
547
- > **Typed wrapper: `<FdyDatepicker>`** Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
655
+ > **Typed wrapper: `<FdyDatepicker>`**. Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
656
+
657
+ ### Props — `<FdyDatepicker>`
658
+
659
+ | Prop | Type | What it does |
660
+ |---|---|---|
661
+ | `modelValue` · `value` | `string \| null` | ISO `YYYY-MM-DD`, or `''`/`null` for empty. |
662
+ | `onChange` | `(value: string) => void` | React. Vue emits `update:modelValue` and `change`. Clearing sends `''`. |
663
+ | `min?` · `max?` | `string` | ISO bounds; days outside them cannot be picked. |
664
+ | `locale?` | `string` | BCP-47 tag deciding month and weekday names. Defaults to the page's `<html lang>`, then `en`. |
665
+ | `placeholder?` | `string` | |
666
+ | `id?` · `ariaLabelledby?` · `describedby?` | `string` | Trigger id; the element that labels it; the help/error text it points at. |
667
+ | `disabled?` · `readonly?` · `invalid?` | `boolean` | `readonly` keeps focus and tab order and shows its date, but it cannot be opened, cleared or changed. |
668
+ | `clearable?` | `boolean` | Show a × in the trigger once a date is set, so an optional date can be unset. Sends `''`. Off by default. |
669
+ | `clearLabel?` | `string` | Accessible name for that ×. Default `Clear date`. |
670
+ | `prevMonthLabel?` · `nextMonthLabel?` | `string` | The month arrows. Defaults `Previous month` / `Next month`. |
671
+ | `chooseMonthLabel?` · `chooseYearLabel?` | `string` | The title buttons that drill into the month and year grids. Defaults `Choose month` / `Choose year`. |
672
+ | `prevYearLabel?` · `nextYearLabel?` | `string` | Year arrows, shown in the month grid. Defaults `Previous year` / `Next year`. |
673
+ | `prevYearsLabel?` · `nextYearsLabel?` | `string` | Page arrows, shown in the year grid. Defaults `Previous years` / `Next years`. |
674
+
675
+ The ten label props exist because month and weekday names follow `locale` while the buttons around
676
+ them do not: without these, a Spanish calendar would be navigated by English arrows.
677
+
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.
681
+
682
+ ### Props — `<FdyDateRange>`
683
+
684
+ | Prop | Type | What it does |
685
+ |---|---|---|
686
+ | `modelValue` · `value` | `DateRangeValue` = `{ start, end }`, each `string \| null` | Both ends at once, so the pair can never be half-applied. |
687
+ | `onChange` | `(value: DateRangeValue) => void` | React. Vue emits `update:modelValue` and `change`. |
688
+ | `min?` · `max?` | `string` | ISO bounds for both ends. The end can never precede the start. |
689
+ | `locale?` | `string` | As on the single picker. |
690
+ | `startPlaceholder?` · `endPlaceholder?` | `string` | One per field. |
691
+ | `ariaLabel?` · `ariaLabelledby?` · `describedby?` | `string` | Names the group (`role="group"`), or points at the text that does. |
692
+ | `disabled?` · `readonly?` · `invalid?` | `boolean` | Applied to both pickers together. |
693
+
694
+ Blazor splits the value into two scalars, `From` / `FromChanged` and `To` / `ToChanged`, because a
695
+ C# binding cannot express one object bound in two places, and it adds `Separator`.
548
696
 
549
697
  Input-styled trigger + calendar popover. Needs `freeday-datepicker.js`. **Author an empty `<div>`**
550
- the enhancer builds everything.
698
+ The enhancer builds everything.
551
699
 
552
700
  - `.fdy-datepicker` (+`--error`) · `__trigger` `__value` (+`--placeholder`) `__icon` `__clear`
553
701
  `__panel`; calendar internals `.fdy-cal__head` `__nav` `__title` `__grid` (+`--months` `--years`)
554
702
  `__dow` `__day` `__month` `__year`
555
703
  - Range: wrap two pickers in `.fdy-daterange` + `data-fdy-daterange` (`role="group"`), children
556
704
  get `data-role="from"` / `"to"`; separator `.fdy-daterange__sep`. The end can't precede the start.
557
- **Typed wrapper: `<FdyDateRange>`** (Vue/React/Blazor) use it in those stacks.
705
+ **Typed wrapper: `<FdyDateRange>`** (Vue/React/Blazor). Use it in those stacks.
558
706
  **Getting to a distant date.** The title is a **button**, and it drills up one level each press:
559
707
  day grid → 12-cell month grid (`.fdy-cal__grid--months` of `.fdy-cal__month`) → 12-cell year grid
560
- (`.fdy-cal__grid--years` of `.fdy-cal__year`). The arrows follow the level shown months, then
708
+ (`.fdy-cal__grid--years` of `.fdy-cal__year`). The arrows follow the level shown: months, then
561
709
  years, then pages of twelve years. Picking a year drops to that year's months, picking a month
562
710
  drops to its days: every level above the day grid is **navigation**, so nothing is committed until a
563
711
  day is selected. From the year grid the title steps back down to months.
564
712
 
565
- Year pages are **aligned**, not centred on the year in view 2016–2027, then 2028–2039 so the
713
+ Year pages are **aligned**, not centred on the year in view (2016–2027, then 2028–2039), so the
566
714
  pages tile and a given year always sits in the same place. From August 2026 to March 2022 that is 7
567
715
  clicks; walking month by month is 53. To 1998 it is 8; walking is 336.
568
716
 
@@ -605,15 +753,15 @@ Click/Enter opens the file dialog; drop works too. Needs `freeday-upload.js`.
605
753
  - List: `.fdy-filelist` (+`--grid`) of `.fdy-file` (+`--success`, `--error`) · `__icon` `__meta`
606
754
  `__name` `__sub` `__progress` `__remove`
607
755
  - Attributes: `data-max-size` (bytes), `data-filelist="#id"`, `data-fdy-upload-simulate` (demo only
608
- the kit fakes a transfer to `done()`; never set it in an app)
756
+ the kit fakes a transfer to `done()`; never set it in an app)
609
757
  - A11y: the dropzone is `role="button" tabindex="0"` + `aria-label`.
610
758
 
611
- **Both events fire on the dropzone** `fdy-upload-add` *and* `fdy-upload-remove`. One listener, one
759
+ **Both events fire on the dropzone**: `fdy-upload-add` *and* `fdy-upload-remove`. One listener, one
612
760
  element. (The file list may be anywhere in the document; nothing is dispatched on the row, because a
613
761
  row in a sibling list would never bubble through the zone.)
614
762
 
615
763
  **The row is yours to drive.** `fdy-upload-add` carries `detail.row`, a small state machine over the
616
- rendered `.fdy-file`. A dropped file **rests** it shows its size and nothing else until you say a
764
+ rendered `.fdy-file`. A dropped file **rests**, showing its size and nothing else, until you say a
617
765
  transfer started; the kit never claims one it is not performing.
618
766
 
619
767
  | `detail.row` | State it renders |
@@ -627,15 +775,15 @@ transfer started; the kit never claims one it is not performing.
627
775
  | `.ready()` | back to **rest** (e.g. after a failed attempt the user will retry) |
628
776
  | `.el` | the row element |
629
777
 
630
- **If your request outlives the transfer, drive `waiting()`.** Server-side work after the last byte
631
- OCR, extraction, virus scanning, transcoding is not uploading, and `setProgress(100)` left standing
778
+ **If your request outlives the transfer, drive `waiting()`.** Server-side work after the last byte (OCR,
779
+ extraction, virus scanning, transcoding) is not uploading, and `setProgress(100)` left standing
632
780
  is read as a hang. `waiting()` is the state for it: the label says what the server is doing, and the
633
781
  bar stops claiming a percentage it no longer has.
634
782
 
635
783
  ```js
636
784
  zone.addEventListener('fdy-upload-add', (e) => {
637
785
  if (e.detail.rejected) return; // the kit already rendered the reason
638
- const { file, row } = e.detail; // row is at rest nothing is in flight yet
786
+ const { file, row } = e.detail; // row is at rest; nothing is in flight yet
639
787
  submitBtn.onclick = async () => {
640
788
  row.uploading();
641
789
  await send(file, (pct) => row.setProgress(pct));
@@ -648,7 +796,7 @@ zone.addEventListener('fdy-upload-remove', (e) => forget(e.detail.file)); // s
648
796
  ```
649
797
 
650
798
  **Bring your own row:** omit the file list entirely (no `data-filelist`, and no `.fdy-filelist`
651
- sibling) and the enhancer renders nothing while still dispatching `fdy-upload-add` `detail.row`
799
+ sibling) and the enhancer renders nothing while still dispatching `fdy-upload-add`, where `detail.row`
652
800
  still works, its element simply isn't attached. Note the fallback when `data-filelist` is absent is
653
801
  `parentNode.querySelector('.fdy-filelist')`, so a bare dropzone will adopt a list that happens to
654
802
  share its parent; give the dropzone its own container if you mean "no list".
@@ -675,7 +823,7 @@ A consistent filter row of `.fdy-field`s with a width rhythm (`--w-sm` · defaul
675
823
  `--w-xl` · `--w-2xl`), one `--w-grow` field (usually search) absorbing the slack, and
676
824
  `.fdy-filterbar__actions` pinned at the end. A control with **no label of its own** (a
677
825
  `.fdy-check`, `.fdy-switch`, `.fdy-radio`, or a lone `.fdy-btn`) is given `--control-h` so it
678
- centres on the input line instead of hanging below it the bar aligns `flex-end` to line up
826
+ centres on the input line instead of hanging below it. The bar aligns `flex-end` to line up
679
827
  labelled fields, which would otherwise drop a 20px checkbox below a 32px input's centre.
680
828
  `--actions-inline` keeps the actions on the control
681
829
  line. Zero JS. Composes with `.fdy-table-toolbar` on a shared baseline.
@@ -705,7 +853,7 @@ Linear multi-step flow: marker → check, one panel at a time, back/next. Needs
705
853
 
706
854
  **`__btn` is optional.** For a **read-only indicator** put `__marker` + `__label` straight in the
707
855
  `<li>`; for a **navigable** stepper wrap them in `<button class="fdy-step__btn">` (add `disabled`
708
- for a step that is not reachable yet). Both render identically the marker carries its own lift
856
+ for a step that is not reachable yet). Both render identically; the marker carries its own lift
709
857
  over the connector line, so omitting `__btn` cannot make the connector draw through the numbers.
710
858
 
711
859
  ```html
@@ -732,23 +880,23 @@ Semantic static table. Wrap in `.fdy-table-wrap` (bordered surface) or `.fdy-tab
732
880
  </div>
733
881
  ```
734
882
 
735
- **Frozen axes `.fdy-table--sticky`.** For a grid read against two axes at once (a rate
883
+ **Frozen axes, `.fdy-table--sticky`.** For a grid read against two axes at once (a rate
736
884
  matrix, a timetable) or a wide one whose identity columns must stay visible. `.fdy-table--sticky`
737
885
  is the enabler: it separates the borders, because a collapsed border belongs to the table rather
738
886
  than the cell and scrolls out from under whatever you froze. Then ask for the axes you have:
739
887
 
740
- - **Header row** add `.fdy-table--sticky-head`, and put `.fdy-table-scroll--frozen` on the
888
+ - **Header row:** add `.fdy-table--sticky-head`, and put `.fdy-table-scroll--frozen` on the
741
889
  wrapper (it scrolls both ways and is the scrollport the freeze resolves against; size it with
742
890
  `--fdy-table-frozen-h`, default `30rem`). It is a separate modifier because a table that only
743
891
  freezes columns scrolls with the **page**, and `top:0` against the page sticks the header under
744
- the viewport edge over the app's own top bar.
745
- - **Columns** put `.fdy-table__freeze` on **every cell of the column, header included**, and give
892
+ the viewport edge, over the app's own top bar.
893
+ - **Columns:** put `.fdy-table__freeze` on **every cell of the column, header included**, and give
746
894
  each frozen column its own `--fdy-freeze-left`: `0` for the first, then the summed widths of the
747
895
  frozen columns to its left. Mark the last one `.fdy-table__freeze--edge` so the reader can see
748
896
  where the frozen block ends. One frozen column needs no variable at all.
749
897
 
750
898
  The offsets are the caller's job because they depend on rendered column widths, which CSS cannot
751
- sum measure them once after render (and on resize) and set the variable.
899
+ sum, so measure them once after render (and on resize) and set the variable.
752
900
 
753
901
  ```html
754
902
  <div class="fdy-table-scroll fdy-table-scroll--frozen" style="--fdy-table-frozen-h:26rem">
@@ -769,7 +917,76 @@ sum — measure them once after render (and on resize) and set the variable.
769
917
  ```
770
918
 
771
919
  ## Data table — `.fdy-datatable`
772
- > **Typed wrapper: `<FdyTable>`** controlled: `columns` + `rows`, with sort/filter/page events (`update:pageIndex` · `onPageIndexChange` · `PageIndexChanged`) and `process` for driving a card list off the same processed set. The markup below is the raw enhancer path.
920
+ > **Typed wrapper: `<FdyTable>`**. controlled: `columns` + `rows`, with sort/filter/page events (`update:pageIndex` · `onPageIndexChange` · `PageIndexChanged`) and `process` for driving a card list off the same processed set. The markup below is the raw enhancer path.
921
+
922
+ ### Columns — the typed `FdyTableColumn<T>`
923
+
924
+ On Vue, React and Blazor, `columns` **is** the table: it is what those stacks write instead of the
925
+ markup hooks further down. Ten fields, `key` and `label` required. The authored doc comments live on
926
+ the type itself (`adapters/core/table-model.d.ts`, re-declared for Blazor in
927
+ `adapters/blazor/TableTypes.cs`); the list below is the discoverable copy, and `npm test` fails if
928
+ the two ever disagree.
929
+
930
+ | Field | Type | What it does |
931
+ |---|---|---|
932
+ | `key` | `string` | Row property key; the sort/filter identity and, unless `value` is set, the cell accessor. |
933
+ | `label` | `string` | Header label. |
934
+ | `labelHidden` | `boolean` | Render the label for assistive tech only; the header cell looks empty. |
935
+ | `sortable` | `boolean` | Show a sort toggle in the header. |
936
+ | `filter` | `'text' \| 'enum' \| 'number' \| 'date'` | Offer a column filter of this type (the funnel popover). |
937
+ | `align` | `'left' \| 'right' \| 'center'` | Cell alignment. Default `left`. |
938
+ | `mono` | `boolean` | Render cells in the monospace data font (`.fdy-mono`). |
939
+ | `sortType` | `'text' \| 'number' \| 'date'` | Override the comparator; defaults to one derived from `filter`, else `text`. |
940
+ | `value` | `(row: T) => unknown` | Custom accessor; defaults to `row[key]`. Feeds sort, filter and the default cell text. |
941
+ | `options` | `readonly string[]` | Explicit enum-filter options; they default to the distinct values in the rows on screen, so pass them in server-paged mode, where those are one page. |
942
+
943
+ Blazor spells the same fields `PascalCase` (`LabelHidden`, `SortType`), makes `Key`/`Label`
944
+ `required`, and adds one field with no TS twin: `Cell`, a `RenderFragment<TRow>` where Vue and React
945
+ use a slot.
946
+
947
+ A column of row controls is worth stating outright, because the obvious move is the wrong one:
948
+
949
+ ```ts
950
+ const columns: FdyTableColumn<User>[] = [
951
+ { key: 'email', label: 'Email', sortable: true, filter: 'text' },
952
+ { key: 'total', label: 'Total', align: 'right', mono: true, sortType: 'number' },
953
+ // Not `label: ''`. A <th> with no text announces as nothing, and the label also names this
954
+ // column's sort button and filter popover, so it has to stay meaningful.
955
+ { key: 'actions', label: 'Actions', labelHidden: true },
956
+ ];
957
+ ```
958
+
959
+ ### Props — `<FdyTable>`
960
+
961
+ Controlled by default: hand it `columns` + `rows` and it filters, sorts and paginates internally;
962
+ provide the state prop for a concern and you own that concern instead. React names the callbacks
963
+ `onXChange`, Vue emits `update:x`, Blazor exposes `XChanged`.
964
+
965
+ | Prop | Type | What it does |
966
+ |---|---|---|
967
+ | `columns` | `ReadonlyArray<FdyTableColumn<Row>>` | The column contract documented above. |
968
+ | `rows` | `ReadonlyArray<Row>` | The current page's rows in server mode, the whole set in client mode. |
969
+ | `rowKey` | `(row: Row) => string \| number` | Row identity: keys, expansion, activation. |
970
+ | `sort?` · `onSortChange?` | `FdySortState \| null` · `(sort) => void` | Pass `sort`, **even as `null`**, to own sorting; omit it for the internal sort. |
971
+ | `filters?` · `onFiltersChange?` | `FdyFilterMap` · `(filters) => void` | The same, for filtering. |
972
+ | `page?` · `onPageChange?` | `FdyPageState` · `(page) => void` | Its **presence switches the table into server mode**: you own paging and get `{ index, size, total }` back. |
973
+ | `pageSize?` | `number` | Client mode: rows per page. Absent or 0 renders every row with no pager. |
974
+ | `pageIndex?` · `onPageIndexChange?` | `number` · `(index) => void` | Client mode: own the 0-based index while the table keeps filtering, sorting and paginating — this is what lets one external pager drive both a table and a card list. |
975
+ | `pageSizes?` · `onPageSizeChange?` | `readonly number[]` · `(size) => void` | Offer a rows-per-page control. Server mode reports the pick through the page event (read its `size`); client mode applies it itself and reports it too, so it works with nothing wired. |
976
+ | `pager?` | `boolean` | Default true. Turn it off to withhold the table's own footer when the screen renders one. |
977
+ | `loading?` | `boolean` | Show the in-table loading state. |
978
+ | `emptyText?` · `empty?` | `string` · `ReactNode` | The empty state as text, or as markup (React `empty`, Vue's `empty` slot). |
979
+ | `ariaLabel?` | `string` | Names the table for assistive tech. |
980
+ | `rowActivatable?` · `onRowActivate?` | `boolean` · `(row) => void` | Rows become focusable and activate on click, Enter or Space. |
981
+ | `rowClass?` | `(row: Row) => string \| undefined` | Per-row class hook, e.g. marking the selected row. |
982
+ | `expandedKeys?` · `renderRowDetail?` | `ReadonlyArray<string \| number>` · `(row) => ReactNode` | Controlled expansion: these keys get a full-width detail row. Vue uses the `row-detail` slot. |
983
+ | `renderCell?` | `(column, row, value) => ReactNode` | React. Custom cell rendering; Vue uses the `cell` slot. |
984
+ | `toolbar?` | `ReactNode` | React. Content above the table; Vue uses the `toolbar` slot. |
985
+ | `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. |
986
+
987
+ Blazor matches this surface (`LoadingText` and `EmptyContent` in place of `emptyText`/`empty`,
988
+ `Toolbar` and `RowDetail` as `RenderFragment`s) and is the one adapter that also exposes
989
+ `FiltersChanged`.
773
990
 
774
991
  The interactive table: global search, sort, per-column filters, row selection + bulk bar,
775
992
  pagination. Needs `freeday-table.js`. Wrap the whole thing in `.fdy-datatable` + `data-fdy-table`
@@ -788,20 +1005,24 @@ pagination. Needs `freeday-table.js`. Wrap the whole thing in `.fdy-datatable` +
788
1005
  detail), `.fdy-table__state` (in-table empty/loading row)
789
1006
  - Footer: `.fdy-table-footer` · `__info` (`data-fdy-table-info`) + optional `__size` (a label and a
790
1007
  rows-per-page control) + `<nav class="fdy-pagination" data-fdy-table-pagination>`. The typed
791
- wrappers render `FdyCombo` there **never a native `<select>`**, whose open list is an OS menu no
1008
+ wrappers render `FdyCombo` there, **never a native `<select>`**, whose open list is an OS menu no
792
1009
  stylesheet reaches. In the raw path the control is app-authored markup (its options *are* the
793
1010
  offer) carrying `data-fdy-table-page-size`; the enhancer only wires it, and listens for both
794
1011
  `change` and the `fdy-change` a `.fdy-combo` emits, so either kind works.
795
1012
  - Sort values: put the raw value in `data-sort-value` when the cell text is formatted.
796
- - **Language caveat:** every user-visible string the **vanilla enhancers** write is Indonesian by
797
- default the table's footer and bulk count (`Menampilkan 1–5 dari 7`, `N dipilih`), its pager and
798
- filter UI (`Sebelumnya`, `Berikutnya`, `Filter kolom`, `Berisi teks`, `Reset`, `Tutup`), and the
799
- same elsewhere (`Format tidak valid.`, `Tampilkan kata sandi`, `Menunggu server…`). The
800
- Vue/React/Blazor components are English throughout, so **an English app should use the typed
801
- wrapper**.
802
- - **Overriding an enhancer's strings.** Each enhancer keeps its strings in one `TEXT` table and
1013
+ - **Language: English throughout, on every path.** The vanilla enhancers wrote Indonesian until
1014
+ 2.0.0 while the typed wrappers wrote English, which meant an app mixing the two paths read as
1015
+ two products. A Blazor app mixes them by construction, since `FreedayBlazor.initAll` runs the
1016
+ enhancers. The defaults are now English everywhere, and the suite enforces it rather than
1017
+ stating it.
1018
+ **Dates are the exception, and deliberately so:** the datepicker formats through `Intl` using the
1019
+ page's `<html lang>`, falling back to `en`. An Indonesian app writes `lang="id"` and gets
1020
+ Indonesian month and weekday names with no configuration at all, which is a better hatch than anything
1021
+ the kit could invent, because it is the platform's.
1022
+ - **Overriding an enhancer's strings.** This is also the migration path for an Indonesian app
1023
+ upgrading past 2.0.0. Each enhancer keeps its strings in one `TEXT` table and
803
1024
  reads them through `textOf()`, so any of them can be replaced per element with
804
- **`data-fdy-text-<key>`**, the key kebab-cased no forking, and no rendering the nodes yourself.
1025
+ **`data-fdy-text-<key>`**, the key kebab-cased. No forking, and no rendering the nodes yourself.
805
1026
  (HTML lowercases attribute names, so a camelCase key like `filterText` is written
806
1027
  `data-fdy-text-filter-text`; the run-together spelling still resolves.):
807
1028
 
@@ -820,44 +1041,52 @@ pagination. Needs `freeday-table.js`. Wrap the whole thing in `.fdy-datatable` +
820
1041
  arrives overridable or not at all.
821
1042
 
822
1043
  **Testing note:** a column's filter button and the dialog it opens deliberately share one
823
- accessible name (`Filter <column>`) a dialog named after its trigger is the normal pattern. In a
1044
+ accessible name (`Filter <column>`), since a dialog named after its trigger is the normal pattern. In a
824
1045
  Playwright/Testing-Library suite that means `getByLabel('Filter Name')` resolves to two elements;
825
1046
  reach for `getByRole('button', { name: 'Filter Name' })` instead.
826
1047
 
827
1048
  **Rows per page.** Pass `pageSizes` (Vue/React `:page-sizes="[10, 20, 50]"`, Blazor
828
1049
  `PageSizes="…"`) and the footer grows a rows-per-page control between the range and the pager.
829
- Omit it for none unchanged default. Picking a size keeps the reader on the row they were looking
1050
+ Omit it for none, which is the unchanged default. Picking a size keeps the reader on the row they were looking
830
1051
  at rather than dropping them on page 1.
831
1052
 
832
- - **server mode** reported through `update:page` / `onPageChange` / `PageChanged`, the same event
1053
+ - **server mode:** reported through `update:page` / `onPageChange` / `PageChanged`, the same event
833
1054
  as a page click, carrying the new `size`. Read `size` to tell the two apart.
834
- - **client mode** the table applies it itself, so the control works with nothing wired, and also
1055
+ - **client mode:** the table applies it itself, so the control works with nothing wired, and also
835
1056
  emits `update:pageSize` / `onPageSizeChange` / `PageSizeChanged` for a caller that wants to
836
1057
  persist the choice. Changing the `pageSize` prop wins back.
837
1058
 
838
- A footer with a size control stays visible on a single page otherwise picking "100" on a
1059
+ A footer with a size control stays visible on a single page. Otherwise picking "100" on a
839
1060
  ninety-row list would remove the only way back to twenty.
840
1061
 
841
- **Typed wrapper: `<FdyTableFooter>`** the footer alone (`page` in, `update:page` /
1062
+ **Typed wrapper: `<FdyTableFooter>`.** The footer alone (`page` in, `update:page` /
842
1063
  `onPageChange` / `PageChanged` out), for the one shape that cannot use the table's own: a
843
1064
  **responsive** list, where a `.fdy-datatable` at `lg` and a `.fdy-list` below it are two renderings
844
1065
  of one page of rows. A footer inside the table is inside the half a phone hides, so those screens
845
1066
  render it once, outside both, with `pager={false}` on the table.
846
1067
 
1068
+ ### Props — `<FdyTableFooter>`
1069
+
1070
+ | Prop | Type | What it does |
1071
+ |---|---|---|
1072
+ | `page` | `FdyPageState` = `{ index, size, total }` | The page being shown. `size` drives both the range text and the rows-per-page control's value. |
1073
+ | `onPageChange?` | `(page: FdyPageState) => void` | React. A page click or a size change. Vue emits `update:page`; Blazor `PageChanged`. |
1074
+ | `pageSizes?` | `readonly number[]` | Offer a rows-per-page control. Omit it and the footer is range + pager only. Picking a size reports the new `size` together with the index that still holds the row the reader was looking at. |
1075
+
847
1076
  **Who draws the pager.** The table renders its own footer (range + pager) whenever there is more
848
1077
  than one page. Two ways to take it over:
849
1078
 
850
- - **client mode** pass `pageIndex` (with `pageSize`, without `page`) and drive it yourself;
851
- - **either mode** pass `pager={false}` (Vue `:pager="false"`, Blazor `Pager="false"`) and render
1079
+ - **client mode:** pass `pageIndex` (with `pageSize`, without `page`) and drive it yourself;
1080
+ - **either mode:** pass `pager={false}` (Vue `:pager="false"`, Blazor `Pager="false"`) and render
852
1081
  your own control. In server mode this is the only way: the app already owns the page there, and
853
- was still being handed a second control. The typical shape is a responsive list a table at `lg`,
854
- `.fdy-list` below it where one pager has to serve both views so they cannot disagree.
1082
+ was still being handed a second control. The typical shape is a responsive list (a table at `lg`,
1083
+ `.fdy-list` below it) where one pager has to serve both views so they cannot disagree.
855
1084
 
856
1085
  Do not hide the footer with CSS. `display:none` works, but it reaches into a component's internals
857
1086
  and breaks the moment the class changes.
858
1087
 
859
1088
  ## Pagination — `.fdy-pagination`
860
- The block class on the `<nav>` is a **structural hook only** it carries no rule of its own; the
1089
+ The block class on the `<nav>` is a **structural hook only**. It carries no rule of its own; the
861
1090
  `__list` / `__link` / `__ellipsis` elements do all the styling, and the data table targets
862
1091
  `data-fdy-table-pagination`. Keep it on the wrapper anyway, for consistency with the rest of the kit.
863
1092
 
@@ -872,7 +1101,26 @@ Empty / error placeholder for a data area. `--danger` for failures. Parts: `__ic
872
1101
  replaces.
873
1102
 
874
1103
  ## Charts — `data-fdy-chart`
875
- > **Typed wrapper: `<FdyChart>`** data props in all three (`type` + `values` / `series`); it repaints on data change, so `FreedayChart.update(el)` is only for the raw path below.
1104
+ > **Typed wrapper: `<FdyChart>`**. data props in all three (`type` + `values` / `series`); it repaints on data change, so `FreedayChart.update(el)` is only for the raw path below.
1105
+
1106
+ ### Props — `<FdyChart>`
1107
+
1108
+ | Prop | Type | What it does |
1109
+ |---|---|---|
1110
+ | `type` | `'line' \| 'area' \| 'bar' \| 'sparkline' \| 'donut'` | Which chart to draw. |
1111
+ | `values?` | `ReadonlyArray<number>` | One series. |
1112
+ | `series?` | `ReadonlyArray<FdyChartSeries>` | Several: each `{ label, values }`. Use one or the other, not both. |
1113
+ | `labels?` | `ReadonlyArray<string>` | Category labels: the x axis, or the donut's slices. |
1114
+ | `format?` | `'number' \| 'percent' \| 'currency'` | How values are written in labels and the donut centre. |
1115
+ | `stacked?` | `boolean` | Bar charts: stack the series instead of grouping them side by side. |
1116
+ | `legend?` | `'auto' \| 'always' \| 'none'` | `auto` shows one when there is more than one series. |
1117
+ | `colors?` | `ReadonlyArray<string>` | Per-series override. Each entry is a semantic token name (`primary`, `accent`, `success`, `warning`, `danger`, `info`) or a categorical slot `chart-1`…`chart-8`. Omit for the default fixed-order palette. |
1118
+ | `color?` | `string` | The same, for a single-series chart (sparkline, simple bar or line). |
1119
+ | `center?` | `string \| number` | Donut only: what to print in the middle. |
1120
+ | `aria-label?` | `string` | React. The chart's accessible name, and in practice the **whole** text alternative: a named chart's painted subtree is `aria-hidden`, see the a11y note in this section. Vue and Blazor set it as a plain attribute (Blazor's parameter is `AriaLabel`). |
1121
+ | `children?` | `ReactNode` | React. Content inside the element before the chart paints. The renderer replaces it, so it is a pre-paint placeholder, not a text alternative. |
1122
+
1123
+ Blazor adds `ChildContent` for the same placeholder role.
876
1124
 
877
1125
  Pure SVG/CSS, no dependency, re-colours with the theme. Needs `freeday-chart.js`; call
878
1126
  `FreedayChart.update(el)` after changing data (or use `FdyChart` in Vue/React/Blazor).
@@ -881,27 +1129,27 @@ Pure SVG/CSS, no dependency, re-colours with the theme. Needs `freeday-chart.js`
881
1129
  - Roots: `.fdy-sparkline` · `.fdy-bars` · `.fdy-donut` · `.fdy-chart-xy` (built for line/area/bar)
882
1130
  - Data: `data-values="1,2,3"` (single series) or `data-series='[{"label":"A","values":[…]}]'`
883
1131
  (multi), `data-labels="Jan,Feb"`, `data-fdy-stacked`, `data-fdy-center` (donut)
884
- - Colour: `data-fdy-color="primary"` or `data-fdy-colors="success,warning,danger"` semantic token
1132
+ - Colour: `data-fdy-color="primary"` or `data-fdy-colors="success,warning,danger"` for semantic token
885
1133
  names **or** `chart-1`…`chart-8` slots to pin a category's colour. Multi-series defaults to the
886
1134
  validated categorical palette `--chart-1`…`--chart-8`.
887
1135
  - Format: `data-fdy-format="number|percent|currency"`; legend `data-fdy-legend` (`none` to drop);
888
1136
  axes `data-fdy-axes`
889
- - Sizing is already set by the kit override **these**, never a `height` on the chart root (a
1137
+ - Sizing is already set by the kit. Override **these**, never a `height` on the chart root (a
890
1138
  root height fights `aspect-ratio` instead of setting it): `.fdy-chart-xy__plot` is `width:100%`
891
1139
  + `aspect-ratio:16/9` + `min-height:8rem`; `.fdy-bars` is a fixed `height:9rem`; `.fdy-sparkline`
892
1140
  is an inline-block `8rem × 2.25rem`.
893
1141
  - Axis type: `--fdy-chart-tick-size` (default `var(--text-xs)`) sets the y-tick and x-label size on
894
- `.fdy-chart-xy`. It is a real CSS size a cartesian chart's `viewBox` is measured to its plot, so
895
- one user unit is one pixel and the renderer reads it back to size the y-gutter and to decide how
1142
+ `.fdy-chart-xy`. It is a real CSS size: a cartesian chart's `viewBox` is measured to its plot, so
1143
+ one user unit is one pixel, and the renderer reads it back to size the y-gutter and to decide how
896
1144
  many x-labels fit. Set the token rather than `font-size` on `__tick`/`__xlabel`, or the gutter and
897
1145
  the autoskip will still be computed from the old size.
898
1146
  - A11y: every chart is `role="img"` + an **author-supplied** `aria-label`, and that label is the
899
- *entire* text alternative write one that carries the numbers, not just the shape. The renderer
900
- marks everything it draws `aria-hidden="true"` legend, bar values and labels, donut centre, the
901
- SVG so nothing inside the chart competes with your label or repeats it. `role="img"` alone would
1147
+ *entire* text alternative, so write one that carries the numbers rather than just the shape. The renderer
1148
+ marks everything it draws `aria-hidden="true"` (legend, bar values and labels, donut centre, the
1149
+ SVG), so nothing inside the chart competes with your label or repeats it. `role="img"` alone would
902
1150
  **not** have done that: it is Children Presentational per ARIA, but browsers keep the subtree in
903
1151
  the accessibility tree anyway, so the kit hides it rather than relying on the spec. That hiding
904
- needs a name to work from a chart with **no** `aria-label`/`aria-labelledby` keeps its contents
1152
+ needs a name to work from: a chart with **no** `aria-label`/`aria-labelledby` keeps its contents
905
1153
  exposed, because an image with neither a name nor any text is worse than one that leaks its
906
1154
  legend. Any `<table>`/text placed inside the element is **pre-render only** and is replaced on
907
1155
  render. Good: `aria-label="Monthly posting trend: Posted 62% → 88%, Draft 24% → 16%"`.
@@ -911,7 +1159,7 @@ Pure SVG/CSS, no dependency, re-colours with the theme. Needs `freeday-chart.js`
911
1159
  `__center` `__hit`; `.fdy-sparkline__line` `__area` `__dot`.
912
1160
 
913
1161
  ## Tree view — `.fdy-tree`
914
- Native `<details>` hierarchy zero JS for expand/collapse. `.fdy-tree__branch` on `<details>`,
1162
+ Native `<details>` hierarchy, with zero JS for expand/collapse. `.fdy-tree__branch` on `<details>`,
915
1163
  `.fdy-tree__leaf` on a leaf `<li>`, plus `__chevron` and `__icon` svgs inside `<summary>`.
916
1164
  `.fdy-tree--checkbox` + `data-fdy-tree` (needs `freeday-tree.js`) adds cascading selection:
917
1165
  `.fdy-tree__check` on each `<input class="fdy-checkbox">`; checking a branch checks its children,
@@ -919,19 +1167,19 @@ a partial set makes the branch indeterminate. Checking never toggles expansion.
919
1167
 
920
1168
  ## List (flat rows) — `.fdy-list`
921
1169
  The **flat** row container: one bordered surface, hairline dividers, **no shadow**. This is what a
922
- responsive `.fdy-datatable` should become below `md` not a stack of `.fdy-card`s, which carries
1170
+ responsive `.fdy-datatable` should become below `md`, rather than a stack of `.fdy-card`s, which carries
923
1171
  `--shadow-lift` and turns ten rows into ten floating objects.
924
1172
 
925
1173
  - `.fdy-list` on a `<ul>`/`<ol>` (list-style is reset for you) or a `<div>`
926
- - `.fdy-list__row` one row; `--interactive` for hover feedback, `--button` when the row **is** the
1174
+ - `.fdy-list__row` is one row; `--interactive` for hover feedback, `--button` when the row **is** the
927
1175
  control (render it as a real `<button>`/`<a>`; the UA box is reset without losing the list surface)
928
1176
  - Row internals: `.fdy-list__main` (truncating stack) → `.fdy-list__title` + `.fdy-list__meta`, and
929
1177
  `.fdy-list__aside` pinned right
930
- - **Disabled** `disabled` on the `<button>` (or `aria-disabled="true"` when the row is an `<a>`/
1178
+ - **Disabled:** `disabled` on the `<button>` (or `aria-disabled="true"` when the row is an `<a>`/
931
1179
  `<div>`) dims the row and withdraws the hover tint and the pointer cursor, same as every other
932
1180
  control in the kit. Do not hand-roll it: an undimmed row that still lights up under the pointer
933
1181
  reads as clickable while it is refusing input.
934
- - Not `.fdy-list-reset` that utility only strips UA bullets/indent from a semantic list.
1182
+ - Not `.fdy-list-reset`, which only strips UA bullets/indent from a semantic list.
935
1183
 
936
1184
  ```html
937
1185
  <ul class="fdy-list">
@@ -982,11 +1230,11 @@ Every field is optional; it returns the toast element. Classes (rendered for you
982
1230
  `__body` `__title` `__text` `__close`.
983
1231
 
984
1232
  **Above modals, and not by z-index.** The region is a `popover` and re-enters the **top layer** as
985
- each toast lands, because `.fdy-modal` is a native `<dialog>` see the note under Modal below. If
1233
+ each toast lands, because `.fdy-modal` is a native `<dialog>`. See the note under Modal below. If
986
1234
  a toast of yours is hidden behind something, a bigger `z-index` is not the fix; check whether that
987
1235
  something is in the top layer too. Where the Popover API is unavailable (before Chrome 114 /
988
1236
  Safari 17 / Firefox 125) the region falls back to a fixed block at `z-index: 200`, which cannot
989
- clear an open modal the message is still announced to a screen reader.
1237
+ clear an open modal, the message is still announced to a screen reader.
990
1238
 
991
1239
  ## Tooltip — `.fdy-tooltip`
992
1240
  Hover/focus only, never the sole carrier of information. Wrap in `.fdy-tooltip-wrap`; the trigger
@@ -1006,7 +1254,7 @@ Size-matched placeholders so nothing shifts when data lands: `--title` `--text`
1006
1254
  # Navigation
1007
1255
 
1008
1256
  ## Nav (menu) — `.fdy-nav`
1009
- Navigation links **vertical by default** (the app shell sidebar), horizontal with `--horizontal`. Items are `<a class="fdy-nav__item">` with
1257
+ Navigation links, **vertical by default** (the app shell sidebar), horizontal with `--horizontal`. Items are `<a class="fdy-nav__item">` with
1010
1258
  `__icon` / `__label` / `__badge`; the current one gets `aria-current="page"`.
1011
1259
  `--flat` drops the surface. Nested groups are native `<details>`:
1012
1260
 
@@ -1030,17 +1278,17 @@ Navigation links — **vertical by default** (the app shell sidebar), horizontal
1030
1278
  </header>
1031
1279
  ```
1032
1280
 
1033
- > **Which one for navigation?** A nav link is a link marked `aria-current="page"` never
1281
+ > **Which one for navigation?** A nav link is a link marked `aria-current="page"`, never
1034
1282
  > `aria-selected` (invalid ARIA on an anchor) and never `role="tab"`. Use `.fdy-nav--horizontal`
1035
1283
  > for an application's **primary** navigation. For **routed sub-navigation** that should look like
1036
1284
  > tabs (`/settings/profile` · `/settings/billing`), put `.fdy-tabs__list` / `.fdy-tabs__tab` on
1037
- > plain `<a>`s those classes honour `aria-current="page"` too and do **not** add the tab roles
1285
+ > plain `<a>`s, since those classes honour `aria-current="page"` too, and do **not** add the tab roles
1038
1286
  > or `freeday-tabs.js`, which promise a keyboard contract routes do not have.
1039
1287
 
1040
1288
  ## App bar — `.fdy-appbar`
1041
1289
  Standalone top bar (distinct from the shell's `.fdy-app__topbar`). Modifiers `--sticky`
1042
1290
  `--elevated` `--dense` `--primary` (on-colour controls). Parts `__brand` `__spacer` `__actions`.
1043
- It ships **no link class of its own** put `<nav class="fdy-nav fdy-nav--horizontal">` in it
1291
+ It ships **no link class of its own**. Put `<nav class="fdy-nav fdy-nav--horizontal">` in it
1044
1292
  for primary navigation (see Nav above).
1045
1293
 
1046
1294
  ## Breadcrumb — `.fdy-breadcrumb`
@@ -1051,7 +1299,7 @@ for primary navigation (see Nav above).
1051
1299
  ## Tabs — `.fdy-tabs`
1052
1300
  APG tabs: ←/→, Home/End, roving tabindex. Needs `freeday-tabs.js`.
1053
1301
  `.fdy-tabs__tab` marks its active state from **`aria-selected="true"` or `aria-current="page"`**,
1054
- so the same look serves routed sub-navigation built from plain links see the note under Nav.
1302
+ so the same look serves routed sub-navigation built from plain links. See the note under Nav.
1055
1303
 
1056
1304
  ```html
1057
1305
  <div class="fdy-tabs" data-fdy-tabs>
@@ -1069,13 +1317,29 @@ so the same look serves routed sub-navigation built from plain links — see the
1069
1317
  # Overlays
1070
1318
 
1071
1319
  ## Modal — `.fdy-modal`
1072
- > **Typed wrapper: `<FdyModal>`** Vue (`:open` + `@close`) · React (`open` + `onClose`) · Blazor (`@bind-Open`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
1320
+ > **Typed wrapper: `<FdyModal>`**. Vue (`:open` + `@close`) · React (`open` + `onClose`) · Blazor (`@bind-Open`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
1321
+
1322
+ ### Props — `<FdyModal>`
1323
+
1324
+ | Prop | Type | What it does |
1325
+ |---|---|---|
1326
+ | `open` | `boolean` | Controlled: your state decides. The wrapper cancels the native `cancel` event so the DOM can never disagree with it. |
1327
+ | `title` | `string` (Vue) · `ReactNode` | Names the dialog through `aria-labelledby`. Vue also offers a `title` slot when the heading needs markup. |
1328
+ | `onClose` | `() => void` | React. Vue emits `close`. Fires from Escape, a backdrop click and the ×, all three of which exist only under `dismissible`. |
1329
+ | `size?` | `'sm' \| 'md' \| 'lg' \| 'wide'` | Dialog width. |
1330
+ | `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. |
1331
+ | `footer?` | `ReactNode` | React. Vue: the `footer` slot. |
1332
+ | `children?` | `ReactNode` | React. The body. Vue: the default slot. |
1333
+
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.
1073
1337
 
1074
1338
  Native `<dialog>`: focus trap, Esc and backdrop come from the browser. Sizes `--sm` `--md` `--lg`
1075
1339
  `--wide`; `--cfl` for the choose-from-list dialog.
1076
1340
 
1077
1341
  **It is in the TOP LAYER.** `showModal()` paints the dialog above every stacking context on the
1078
- page, so no `z-index` anywhere can put anything over it only another top-layer element (a
1342
+ page, so no `z-index` anywhere can put anything over it. Only another top-layer element (a
1079
1343
  `popover`, or another modal) can. It also makes the rest of the document inert, which means
1080
1344
  `elementFromPoint` outside the dialog returns the dialog: do not use a hit test to check what is
1081
1345
  painted on top of a modal, because it will tell you the dialog is, even when it visibly is not. Parts `__header` `__title` `__body` `__footer`
@@ -1083,34 +1347,50 @@ painted on top of a modal, because it will tell you the dialog is, even when it
1083
1347
  `data-close` on any button that should close it.
1084
1348
 
1085
1349
  **Stacking two overlays is supported.** Modal and drawer are both `<dialog>` opened with
1086
- `showModal()`, so both enter the top layer and paint in the order they were opened a confirm
1350
+ `showModal()`, so both enter the top layer and paint in the order they were opened. A confirm
1087
1351
  modal opened from inside an open drawer paints **above** it, and neither needs a `z-index` for
1088
1352
  that. Escape closes the topmost first, so cancelling returns to the still-open drawer; a second
1089
1353
  Escape then closes the drawer. Closing them out of order is fine too: closing the drawer while the
1090
1354
  modal is up leaves the modal open, painted and dismissible.
1091
1355
 
1092
1356
  One caveat, and it is the browser's rather than the kit's: **the second overlay's Escape can only
1093
- be refused if both were opened by a real gesture.** A `showModal()` that runs from script a timer,
1094
- or after an `await` that outlived the click still closes only the topmost, but its `cancel` event
1357
+ be refused if both were opened by a real gesture.** A `showModal()` that runs from script (a timer,
1358
+ or after an `await` that outlived the click) still closes only the topmost, but its `cancel` event
1095
1359
  arrives **non-cancelable**, so `preventDefault()` on it is ignored and an "unsaved changes, stay
1096
1360
  open" guard silently stops working. Routing Escape through app state instead, which is what the
1097
1361
  typed wrappers do, is unaffected: the dialog closes and your state follows it.
1098
1362
 
1099
1363
  *Correction to 1.51.1, which said such a dialog has its close watcher grouped with the one below so
1100
1364
  that a single Escape closes both.* That happens only on a page that has received **no user input at
1101
- all** a test harness, never an app; six seconds after one click the overlays are still
1365
+ all**: a test harness, never an app. Six seconds after one click the overlays are still
1102
1366
  independent. Open overlays with real clicks in a test, or a stacking assertion fails for a reason
1103
1367
  that is not the kit. Both rules measured on Chromium 133 and 151, and pinned as a matrix in
1104
1368
  `browser/overlay-stack.mjs`.
1105
1369
 
1106
1370
  ## Drawer — `.fdy-drawer`
1107
- > **Typed wrapper: `<FdyDrawer>`** Vue (`:open` + `@close`) · React (`open` + `onClose`) · Blazor (`@bind-Open`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
1371
+ > **Typed wrapper: `<FdyDrawer>`**. Vue (`:open` + `@close`) · React (`open` + `onClose`) · Blazor (`@bind-Open`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
1372
+
1373
+ ### Props — `<FdyDrawer>`
1374
+
1375
+ The modal's props with `side` in place of `size`; the behaviour notes there apply here too.
1376
+
1377
+ | Prop | Type | What it does |
1378
+ |---|---|---|
1379
+ | `open` | `boolean` | Controlled, as with the modal. |
1380
+ | `title` | `string` (Vue) · `ReactNode` | Names the drawer (`aria-labelledby`). |
1381
+ | `onClose` | `() => void` | React. Vue emits `close`. |
1382
+ | `side?` | `'left' \| 'right'` | Which edge it slides from. |
1383
+ | `dismissible?` | `boolean` | Escape, backdrop and × on or off. |
1384
+ | `footer?` | `ReactNode` | React. Vue: the `footer` slot. |
1385
+ | `children?` | `ReactNode` | React. The body. Vue: the default slot. |
1386
+
1387
+ Blazor: `@bind-Open`, `TitleContent` / `ChildContent` / `FooterContent`, `OnClose`, `CloseLabel`.
1108
1388
 
1109
- Temporary side panel on native `<dialog>` left by default, `--right` to flip. Parts `__header`
1389
+ Temporary side panel on native `<dialog>`, left by default, `--right` to flip. Parts `__header`
1110
1390
  `__title` `__body` `__footer` `__close`. Open it from any
1111
1391
  `<button data-fdy-drawer="<dialog id>">`. Needs `freeday-drawer.js`.
1112
1392
 
1113
- Opened with `showModal()`, so it is in the **top layer** on the same terms as the modal including
1393
+ Opened with `showModal()`, so it is in the **top layer** on the same terms as the modal, including
1114
1394
  opening a modal over an open drawer, which is supported and described under
1115
1395
  [Modal → *Stacking two overlays*](#modal--fdy-modal). It slides in on `transform` and never animates
1116
1396
  opacity: wait on its position, not on `opacity`, if you need to know when it has arrived.
@@ -1132,25 +1412,25 @@ is `role="group" aria-roledescription="slide"`.
1132
1412
  ## Card — `.fdy-card`
1133
1413
  Parts `__body` `__title` `__desc` `__footer`. Modifiers:
1134
1414
 
1135
- - `--elevated` raise it (use sparingly; most surfaces are flat)
1136
- - `--interactive` cursor + hover-lift for a card that *has* a click handler. **Presentational
1137
- only:** it is the one affordance in the kit whose correctness lives outside it the CSS cannot
1415
+ - `--elevated` raises it (use sparingly; most surfaces are flat)
1416
+ - `--interactive` gives cursor + hover-lift to a card that *has* a click handler. **Presentational
1417
+ only:** it is the one affordance in the kit whose correctness lives outside it, because the CSS cannot
1138
1418
  know whether a handler exists, so the modifier without a control (a card that looks clickable and
1139
1419
  swallows every click) and a control without the modifier both fail silently. Pair it with a real
1140
- control normally the stretched target below.
1141
- - `--button` for a card that **is** the control: render it as `<button>` and add this to reset
1420
+ control, normally the stretched target below.
1421
+ - `--button` is for a card that **is** the control: render it as `<button>` and add this to reset
1142
1422
  the UA button box **without** losing the card surface/border
1143
- - `.fdy-card--button` never replaces keyboard semantics a clickable card must be a real
1423
+ - `.fdy-card--button` never replaces keyboard semantics. A clickable card must be a real
1144
1424
  `<button>` or `<a>`.
1145
- - **Disabled** `disabled` (or `aria-disabled="true"`) dims the card and withdraws the pointer
1425
+ - **Disabled:** `disabled` (or `aria-disabled="true"`) dims the card and withdraws the pointer
1146
1426
  cursor and the `--interactive` hover-lift.
1147
1427
  - The card is `position:relative`, so a badge or ribbon you absolutely position inside it anchors
1148
- to the card. That is also what keeps hidden labels inside it from escaping see *Containment*.
1428
+ to the card. That is also what keeps hidden labels inside it from escaping. See *Containment*.
1149
1429
 
1150
1430
  ### One card, one primary action, one escape hatch
1151
1431
 
1152
1432
  A card with **two** actions cannot be `--button`: interactive content nested in a `<button>` is
1153
- invalid HTML. Give the primary control `.fdy-btn--stretch` its hit area covers the whole card,
1433
+ invalid HTML. Give the primary control `.fdy-btn--stretch`: its hit area covers the whole card,
1154
1434
  while it stays a real `<button>`/`<a>` with real keyboard semantics.
1155
1435
 
1156
1436
  ```html
@@ -1167,34 +1447,32 @@ while it stays a real `<button>`/`<a>` with real keyboard semantics.
1167
1447
  ```
1168
1448
 
1169
1449
  - **The escape hatch needs nothing.** Every focusable element in a card that holds a stretched
1170
- target is raised above the overlay automatically forgetting a `z-index` here fails silently, and
1450
+ target is raised above the overlay automatically. Forgetting a `z-index` here fails silently, and
1171
1451
  a silent failure is what this pattern exists to remove. The `position` that raising needs is only a
1172
- **default** (zero-specificity), so a control you pin yourself a corner dismiss, a favourite star
1452
+ **default** (zero-specificity), so a control you pin yourself (a corner dismiss, a favourite star)
1173
1453
  keeps its own `position:absolute` and is raised anyway.
1174
1454
  - **Name the stretched button for what the whole card does** ("Open workspace Alpha", not "Open").
1175
1455
  Its label is the accessible name of the entire hit area.
1176
- - **Do not add `.fdy-btn--stretch` to more than one control per card** the last one wins the
1456
+ - **Do not add `.fdy-btn--stretch` to more than one control per card.** The last one wins the
1177
1457
  overlap, and which one that is depends on source order.
1178
1458
  - Text under the overlay is no longer selectable; that is the cost of the pattern, not a bug.
1179
1459
  - **Cards and list rows.** The overlay anchors to the nearest *positioned* ancestor, and both hosts
1180
1460
  supply one: `.fdy-card` always, `.fdy-list__row` when it contains a stretched target. Everything
1181
- above applies to a row unchanged same markup, same automatic raise. In any *other* container,
1461
+ above applies to a row unchanged: same markup, same automatic raise. In any *other* container,
1182
1462
  make the element `position:relative` yourself, or the overlay resolves against whatever is
1183
1463
  positioned further up (in a list that used to be `.fdy-list` itself, so every row's target covered
1184
- the whole list and the last one in the DOM won a click on row one opened row two).
1464
+ the whole list and the last one in the DOM won, so a click on row one opened row two).
1185
1465
  A row whose *whole* surface is one control is still better as `.fdy-list__row--button`.
1186
1466
  - `.fdy-btn` nudges itself on `:hover`/`:active`, and a transformed element becomes the containing
1187
- block for its own absolutely positioned descendants so a hand-rolled `::after{inset:0}` overlay
1467
+ block for its own absolutely positioned descendants, so a hand-rolled `::after{inset:0}` overlay
1188
1468
  **re-anchors to the button mid-gesture** and the click never lands. `--stretch` neutralises those
1189
1469
  transforms and moves the press feedback to the card. This is why the pattern is shipped rather
1190
1470
  than documented.
1191
1471
 
1192
1472
  ## Badge — `.fdy-badge`
1193
- Inline status pill: `--success` `--warning` `--danger` `--info` `--outline`. Never colour-only
1194
- the text carries the meaning.
1473
+ Inline status pill: `--success` `--warning` `--danger` `--info` `--outline`. Never colour-only: the text carries the meaning.
1195
1474
 
1196
- `--tone-1`…`--tone-8` for a status vocabulary larger than the semantic palette. Semantics first
1197
- a state that IS good, bad or waiting takes `--success` / `--danger` / `--warning`; these are for
1475
+ `--tone-1`…`--tone-8` for a status vocabulary larger than the semantic palette. Semantics first: a state that IS good, bad or waiting takes `--success` / `--danger` / `--warning`; these are for
1198
1476
  the rest, so two states a workflow distinguishes never share a look. Map each status to a FIXED
1199
1477
  tone, never to a hash of the string: a status is a closed set, and a hash reassigns colours the
1200
1478
  moment the set grows.
@@ -1207,19 +1485,19 @@ Put the count in the wrapper's `aria-label` and mark the overlay `aria-hidden="t
1207
1485
  ## Avatar — `.fdy-avatar`
1208
1486
  Initials or image. Sizes `--sm` / default / `--lg`; `.fdy-avatar-group` stacks them (label the
1209
1487
  group). `--tone-1`…`--tone-8` are decorative tints (WCAG AA in both themes) to distinguish
1210
- same-initial avatars hash the index off the **full** name, not the initials.
1488
+ same-initial avatars. Hash the index off the **full** name, not the initials.
1211
1489
 
1212
1490
  Sizes: `--xs` 1.5rem · *(base)* 2.5rem · `--sm` 2rem · `--lg` 3.5rem. Use **`--xs` inside a control**
1213
- `.fdy-btn--sm` is itself 2rem tall, so an `--sm` avatar fills a small button edge to edge and the
1491
+ because `.fdy-btn--sm` is itself 2rem tall, so an `--sm` avatar fills a small button edge to edge and the
1214
1492
  ghost border cuts across the circle.
1215
1493
 
1216
1494
  ## Chip — `.fdy-chip`
1217
1495
  Three roles inside a `.fdy-chips` row:
1218
1496
 
1219
- - **static / removable** `<span class="fdy-chip">`, `--primary` for an active filter, with
1497
+ - **static / removable:** `<span class="fdy-chip">`, `--primary` for an active filter, with
1220
1498
  `.fdy-chip__remove` (`aria-label` says what it removes)
1221
- - **choice** `<button class="fdy-chip fdy-chip--choice" aria-pressed>` toggle
1222
- - **filter** `--filter`, same but with a `.fdy-chip__check` mark
1499
+ - **choice:** `<button class="fdy-chip fdy-chip--choice" aria-pressed>` toggle
1500
+ - **filter:** `--filter`, same but with a `.fdy-chip__check` mark
1223
1501
 
1224
1502
  Interactive chips are managed by `freeday-chip.js`: wrap the row in `data-fdy-chips` (+
1225
1503
  `data-single` for pick-one, `data-label` for the group name), give each `data-value`.
@@ -1233,5 +1511,5 @@ by the accompanying text, not the marker colour alone.
1233
1511
  ---
1234
1512
 
1235
1513
  *Anything not listed here is not part of the public surface. If a screen needs a primitive that
1236
- isn't in this file, it probably belongs in the kit open an issue instead of re-inventing it
1514
+ isn't in this file, it probably belongs in the kit. Open an issue instead of re-inventing it
1237
1515
  locally.*