@cahyo-dimas/freeday 1.21.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,69 @@
3
3
  Semua perubahan penting dicatat di sini. Format longgar mengikuti
4
4
  [Keep a Changelog](https://keepachangelog.com/); tiap versi = git tag.
5
5
 
6
+ ## [1.22.0] — 2026-08-12
7
+ Two bodies of work. **(a)** three more findings from consumption round 5 — the report grew §5-§7
8
+ after 1.21.0 was cut; **(b)** a **routing** failure found in the same adopted project, which had been
9
+ built on raw markup + enhancers inside a framework that has typed wrappers because nothing in the
10
+ package ever told it otherwise. (b) was prepared as 1.21.1 and folded in here rather than shipped as
11
+ a separate patch minutes earlier.
12
+ ### Added — from consumption round 5 (§5, §6)
13
+ - **`.fdy-nav--horizontal`** — the same navigation links laid out as a **row**, for a top-nav
14
+ application that puts its primary nav in `.fdy-appbar` / `.fdy-app__topbar` and has no sidebar.
15
+ Deliberately a modifier, not a new block: the item, its states and `aria-current="page"` are
16
+ unchanged. On `.fdy-appbar--primary` the links go on-colour automatically (full on-colour ink in
17
+ every state; the current page is carried by the background wash plus the semibold weight the base
18
+ rule already applies, so no dimmed variant trades away the bar's guaranteed contrast).
19
+ Reported as "there is no horizontal navigation component": `.fdy-nav` was the sidebar's vertical
20
+ menu and `.fdy-appbar` had `__brand`/`__spacer`/`__actions` but nothing to put *links* in — so a
21
+ top-nav app had a component for the bar and none for what the bar is mostly made of.
22
+ - **`.fdy-tabs__tab` also honours `aria-current="page"`**, so the tab *look* legitimately serves
23
+ **routed** sub-navigation (`/settings/profile` · `/settings/billing`) built from real links. A link
24
+ cannot be `aria-selected` — that is invalid ARIA on an anchor — so the app that borrowed the class
25
+ had to restate the active style for a second attribute. Keep `role="tab"` + `freeday-tabs.js` for
26
+ **in-page** tabs, where the roving-tabindex / one-panel-per-tab contract is real.
27
+ - **Pressed state for toggle buttons** — `aria-pressed="true"` now styles: soft primary fill + strong
28
+ ink on `--ghost`/`--text`, an inverted-gradient sunken look on the solid button, and the same
29
+ treatment in the danger hue. No new class or markup contract, since `aria-pressed` is already the
30
+ correct attribute — and it is what turns `.fdy-btn-group` from a joined row into a **complete**
31
+ segmented control. Previously "which one am I on" was left to each app to hand-tint, which is a
32
+ colour decision the kit should own.
33
+ *Found while building it:* one shared `.fdy-btn[aria-pressed="true"]` rule **cannot** work. A
34
+ gradient is a background-**image**, so the `background` shorthand resets `background-color` to
35
+ transparent — at equal specificity the later rule won and the ghost segment lost its fill entirely.
36
+ Each fill variant now defines its own pressed look. The CSS read correctly in the file; only a real
37
+ engine showed it, which is why the new guard lives in `browser/`.
38
+ ### Fixed — from consumption round 5 (§7)
39
+ - **Hard rule 1 in `COMPONENTS.md` now records its one exception.** The rule says a modifier is
40
+ always written beside its block class, but `.fdy-input-group__addon--icon` is standalone by design
41
+ — adding the base `__addon` gives a search glyph the grey fill and divider of a `Rp` / `%` prefix.
42
+ An agent following the rule literally produced the wrong control; one following the CSS comment
43
+ broke the stated rule. The exception is now written down, and it is the only one.
44
+ ### Fixed — the package now routes by stack
45
+ - **The paste-block in `docs/agent-onboarding.md` never mentioned the adapters.** That block is the
46
+ only text that lands in a consuming project's agent instructions, so it is the only text an agent
47
+ re-reads on every task — and it opened with "a CSS kit + enhancers, **not a component framework**",
48
+ which actively steers a Vue/React project away from the typed components. It now opens with a
49
+ **step 0 routing table** (Vue → `/vue` · React → `/react` · Blazor → RCL · everything else → raw
50
+ markup), names the ten wrapped components, and states the failure mode: the raw path renders
51
+ correctly the first time and then silently stops hydrating.
52
+ - **`COMPONENTS.md` flags the wrapper at each of the ten sections**, so the choice is visible at the
53
+ point of use rather than only on an onboarding page read once — with each component's *real*
54
+ binding (`v-model` / `value`+`onChange` / `@bind-Value` for the six value-bound ones; `open`+`close`
55
+ for modal and drawer; data props for chart; `columns`+`rows`+events for table). Hard rule 7 states
56
+ it up front.
57
+ - **Both READMEs lead with the routing table**, above the first import block. Previously the first
58
+ runnable snippet was the vanilla one and the adapter table sat ~130 lines below it.
59
+ - **`docs/getting-started.md`** core concept 2 ("the enhancer is the source of truth") is now scoped
60
+ to the raw path, and notes that Vue/React re-implement the ten natively while Blazor wraps the
61
+ enhancer over interop.
62
+ ### Notes
63
+ - Root cause was structural, not editorial: at **v1.18.0** `docs/getting-started.md` — the only
64
+ per-stack router the kit had — was **not in `files`**, so `npm i` delivered a README whose
65
+ "Starting a project? per stack" link pointed at a path that does not exist inside `node_modules`.
66
+ `adapters/` *was* shipped, so the wrappers sat in the install, unused, with nothing pointing at
67
+ them. v1.20.0 started shipping the docs; this release makes them route.
68
+
6
69
  ## [1.21.0] — 2026-08-12
7
70
  Fifth round of real-app consumption feedback, written while that app adopted 1.20.0. Two findings —
8
71
  and a **withdrawal**: the reporter retracted 1.20's rejected §A themselves after isolating the real
@@ -57,6 +120,11 @@ reproduce here, so the rejection stands and the withdrawal is recorded rather th
57
120
  hidden labels in a wide table, asserting the page cannot scroll horizontally *and* that the scroller
58
121
  still scrolls. A static test cannot see this failure; only a layout engine can. Both guards
59
122
  mutation-checked — reverting any single `position:relative` fails them.
123
+ - **`browser/state.mjs`** — pressed toggles and nav orientation in a real engine: the pressed ghost
124
+ segment must keep a flat background-colour (the mutation that reproduces the shorthand bug fails
125
+ it), the solid toggle must read as inset, `--horizontal` must actually be a row with the current
126
+ link marked, on-colour nav ink must stay on-colour in both states, and a routed tab must take its
127
+ underline from `aria-current`. Mutation-checked against all three fixes.
60
128
  - **`browser/theme.mjs`** — subtree theming end-to-end: a `.fdy-title-page` and a `.fdy-card` inside
61
129
  `<section data-theme="dark">` take the dark tokens, a nested light island goes back, and
62
130
  `data-theme` on `<html>` still themes everything. `test/build.test.mjs` guards the selector shape
package/COMPONENTS.md CHANGED
@@ -15,6 +15,10 @@ 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
19
+ borderless leading glyph, and adding the base `.fdy-input-group__addon` would give it the grey
20
+ fill and divider of a `Rp` / `%` prefix, which is not what a search icon is. Written
21
+ `class="fdy-input-group__addon--icon"`, alone. It is the only one; everywhere else the rule holds.
18
22
  2. **Never invent a class.** If it is not in this file, it does not exist. Freeday has no
19
23
  `fdy-grid`, no `fdy-flex`, no `fdy-mt-4`, no `fdy-primary`.
20
24
  3. **Never write a raw hex or px value** in app CSS. Use the tokens — `var(--color-primary)`,
@@ -30,6 +34,14 @@ live docs.
30
34
  invalid state does not belong to the control itself.
31
35
  6. **Interactive components need their enhancer script.** Static ones (button, card, badge, table,
32
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` ·
38
+ `FdyDatepicker` · `FdyDateRange` · `FdyAutocomplete` · `FdyCascade` · `FdyCfl` · `FdyChart` ·
39
+ `FdyTable` · `FdyModal` · `FdyDrawer`, from `@cahyo-dimas/freeday/vue`, `/react`, or the
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
42
+ breaks quietly: later-rendered DOM is never hydrated and the widget's state sits in the DOM
43
+ instead of your framework's. Everything else is the same markup everywhere; hydrate the other
44
+ interactive components with `useFreeday` (Vue/React) or `FreedayBlazor.initAll` (Blazor).
33
45
 
34
46
  ### Containment — why the kit's containers are `position:relative`
35
47
 
@@ -227,6 +239,10 @@ rest of the app is not.
227
239
  - Modifiers: `--ghost` `--danger` `--text` `--icon` (square, icon-only) `--sm` `--lg`
228
240
  - Parts: `.fdy-btn__icon` (wraps the `<svg>`) · `.fdy-btn-group` (segmented row, `role="group"` +
229
241
  `aria-label`)
242
+ - **Toggle / segmented:** `aria-pressed="true"` gives a real pressed state (soft primary fill on
243
+ `--ghost`/`--text`, an inset-sunk gradient on the solid button). No extra class — `aria-pressed`
244
+ is already the right attribute, and it is what turns `.fdy-btn-group` from a joined row into a
245
+ complete segmented control.
230
246
  - A11y: `--icon` **requires** `aria-label`. Use `<button type="button">` unless it submits.
231
247
 
232
248
  ```html
@@ -312,6 +328,8 @@ Native inputs, styled. `.fdy-check` · `.fdy-radio` · `.fdy-switch` on the wrap
312
328
  ```
313
329
 
314
330
  ## Select / combobox — `.fdy-combo`
331
+ > **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).
332
+
315
333
  Fully styleable dropdown, APG combobox+listbox. Needs `freeday-select.js`.
316
334
 
317
335
  - `.fdy-combo` (+`--error`, `--no-icon`) · `__button` `__value` (+`--placeholder`) `__listbox`
@@ -334,6 +352,8 @@ Fully styleable dropdown, APG combobox+listbox. Needs `freeday-select.js`.
334
352
  ```
335
353
 
336
354
  ## Autocomplete — `.fdy-autocomplete`
355
+ > **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).
356
+
337
357
  Editable combobox that filters as you type. Needs `freeday-autocomplete.js`.
338
358
 
339
359
  - `.fdy-autocomplete` · `__listbox` `__option` `__empty`
@@ -341,6 +361,8 @@ Editable combobox that filters as you type. Needs `freeday-autocomplete.js`.
341
361
  `aria-controls` → the listbox `id`, `autocomplete="off"`.
342
362
 
343
363
  ## Cascade select — `.fdy-cascade`
364
+ > **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).
365
+
344
366
  Hierarchical drill-down. The data model is a **nested `<ul>`** inside the wrapper: an `<li>` with a
345
367
  child `<ul>` is a branch, one without is a leaf. Needs `freeday-cascade.js`.
346
368
 
@@ -360,6 +382,8 @@ child `<ul>` is a branch, one without is a leaf. Needs `freeday-cascade.js`.
360
382
  ```
361
383
 
362
384
  ## Choose-from-list (CFL) — `data-fdy-cfl`
385
+ > **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).
386
+
363
387
  A read-only field backed by master data: the button opens a searchable dialog, the picked row fills
364
388
  the field. The value is always **chosen, never typed**. Needs `freeday-cfl.js`.
365
389
 
@@ -410,6 +434,8 @@ the field. The value is always **chosen, never typed**. Needs `freeday-cfl.js`.
410
434
  ```
411
435
 
412
436
  ## Date picker — `data-fdy-datepicker`
437
+ > **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).
438
+
413
439
  Input-styled trigger + calendar popover. Needs `freeday-datepicker.js`. **Author an empty `<div>`**
414
440
  — the enhancer builds everything.
415
441
 
@@ -417,6 +443,7 @@ Input-styled trigger + calendar popover. Needs `freeday-datepicker.js`. **Author
417
443
  `__panel`; calendar internals `.fdy-cal__head` `__nav` `__title` `__grid` `__dow` `__day`
418
444
  - Range: wrap two pickers in `.fdy-daterange` + `data-fdy-daterange` (`role="group"`), children
419
445
  get `data-role="from"` / `"to"`; separator `.fdy-daterange__sep`. The end can't precede the start.
446
+ **Typed wrapper: `<FdyDateRange>`** (Vue/React/Blazor) — use it in those stacks.
420
447
  - Attributes: `data-value="YYYY-MM-DD"`, `data-placeholder`, `data-label`, `data-fdy-no-icon`
421
448
 
422
449
  ```html
@@ -517,6 +544,8 @@ Semantic static table. Wrap in `.fdy-table-wrap` (bordered surface) or `.fdy-tab
517
544
  ```
518
545
 
519
546
  ## Data table — `.fdy-datatable`
547
+ > **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.
548
+
520
549
  The interactive table: global search, sort, per-column filters, row selection + bulk bar,
521
550
  pagination. Needs `freeday-table.js`. Wrap the whole thing in `.fdy-datatable` + `data-fdy-table`
522
551
  (`data-page-size="N"`).
@@ -556,6 +585,8 @@ Empty / error placeholder for a data area. `--danger` for failures. Parts: `__ic
556
585
  replaces.
557
586
 
558
587
  ## Charts — `data-fdy-chart`
588
+ > **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.
589
+
559
590
  Pure SVG/CSS, no dependency, re-colours with the theme. Needs `freeday-chart.js`; call
560
591
  `FreedayChart.update(el)` after changing data (or use `FdyChart` in Vue/React/Blazor).
561
592
 
@@ -662,18 +693,43 @@ Size-matched placeholders so nothing shifts when data lands: `--title` `--text`
662
693
 
663
694
  # Navigation
664
695
 
665
- ## Nav (sidebar menu) — `.fdy-nav`
666
- Vertical navigation, used by the app shell. Items are `<a class="fdy-nav__item">` with
696
+ ## Nav (menu) — `.fdy-nav`
697
+ Navigation links **vertical by default** (the app shell sidebar), horizontal with `--horizontal`. Items are `<a class="fdy-nav__item">` with
667
698
  `__icon` / `__label` / `__badge`; the current one gets `aria-current="page"`.
668
699
  `--flat` drops the surface. Nested groups are native `<details>`:
669
700
 
670
701
  - `.fdy-nav__tree` + `<summary class="fdy-nav__item">` + `.fdy-nav__caret` → children in
671
702
  `.fdy-nav__sub`
672
703
  - `.fdy-nav__group` + `<summary class="fdy-nav__grouplabel">` → a collapsible section
704
+ - **`--horizontal`** lays the same links out as a row, for a **top-nav application** (primary
705
+ navigation in `.fdy-appbar` or `.fdy-app__topbar`, no sidebar). Same item, same states, same
706
+ `aria-current="page"`; the row scrolls if it runs out of width. On `.fdy-appbar--primary` the
707
+ links go on-colour automatically.
708
+
709
+ ```html
710
+ <header class="fdy-appbar">
711
+ <a class="fdy-appbar__brand" href="/">Acme</a>
712
+ <nav class="fdy-nav fdy-nav--horizontal" aria-label="Main">
713
+ <a class="fdy-nav__item" href="/invoices" aria-current="page">Invoices</a>
714
+ <a class="fdy-nav__item" href="/customers">Customers</a>
715
+ </nav>
716
+ <span class="fdy-appbar__spacer"></span>
717
+ <div class="fdy-appbar__actions"><!-- buttons --></div>
718
+ </header>
719
+ ```
720
+
721
+ > **Which one for navigation?** A nav link is a link marked `aria-current="page"` — never
722
+ > `aria-selected` (invalid ARIA on an anchor) and never `role="tab"`. Use `.fdy-nav--horizontal`
723
+ > for an application's **primary** navigation. For **routed sub-navigation** that should look like
724
+ > tabs (`/settings/profile` · `/settings/billing`), put `.fdy-tabs__list` / `.fdy-tabs__tab` on
725
+ > plain `<a>`s — those classes honour `aria-current="page"` too — and do **not** add the tab roles
726
+ > or `freeday-tabs.js`, which promise a keyboard contract routes do not have.
673
727
 
674
728
  ## App bar — `.fdy-appbar`
675
729
  Standalone top bar (distinct from the shell's `.fdy-app__topbar`). Modifiers `--sticky`
676
730
  `--elevated` `--dense` `--primary` (on-colour controls). Parts `__brand` `__spacer` `__actions`.
731
+ It ships **no link class of its own** — put `<nav class="fdy-nav fdy-nav--horizontal">` in it
732
+ for primary navigation (see Nav above).
677
733
 
678
734
  ## Breadcrumb — `.fdy-breadcrumb`
679
735
  `<nav class="fdy-breadcrumb" aria-label="Breadcrumb">` → `<ol class="fdy-breadcrumb__list">` with
@@ -682,6 +738,8 @@ Standalone top bar (distinct from the shell's `.fdy-app__topbar`). Modifiers `--
682
738
 
683
739
  ## Tabs — `.fdy-tabs`
684
740
  APG tabs: ←/→, Home/End, roving tabindex. Needs `freeday-tabs.js`.
741
+ `.fdy-tabs__tab` marks its active state from **`aria-selected="true"` or `aria-current="page"`**,
742
+ so the same look serves routed sub-navigation built from plain links — see the note under Nav.
685
743
 
686
744
  ```html
687
745
  <div class="fdy-tabs" data-fdy-tabs>
@@ -699,12 +757,16 @@ APG tabs: ←/→, Home/End, roving tabindex. Needs `freeday-tabs.js`.
699
757
  # Overlays
700
758
 
701
759
  ## Modal — `.fdy-modal`
760
+ > **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).
761
+
702
762
  Native `<dialog>`: focus trap, Esc and backdrop come from the browser. Sizes `--sm` `--md` `--lg`
703
763
  `--wide`; `--cfl` for the choose-from-list dialog. Parts `__header` `__title` `__body` `__footer`
704
764
  `__close`. The body scrolls; the footer never clips. `aria-labelledby` → the title's `id`;
705
765
  `data-close` on any button that should close it.
706
766
 
707
767
  ## Drawer — `.fdy-drawer`
768
+ > **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).
769
+
708
770
  Temporary side panel on native `<dialog>` — left by default, `--right` to flip. Parts `__header`
709
771
  `__title` `__body` `__footer` `__close`. Open it from any
710
772
  `<button data-fdy-drawer="<dialog id>">`. Needs `freeday-drawer.js`.
package/README.id.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **Lebih banyak _free day_ buat dev — UI kit-nya sudah siap pakai.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.21.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.21.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.22.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.22.0)
9
9
 
10
10
  UI KIT yang token-driven & framework-agnostic — satu sumber kebenaran untuk warna, tipografi,
11
11
  spasi, dan komponen. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
@@ -31,6 +31,28 @@ npm test # tes transformasi build + kontras WCAG (node:test)
31
31
 
32
32
  ## Pakai di project
33
33
 
34
+ ### Pertama: pintu masuk mana yang kamu pakai?
35
+
36
+ Sebagian besar Freeday itu markup + kelas `fdy-*` dan sama di mana pun. **Sepuluh komponen
37
+ interaktif juga punya wrapper typed** — di stack berikut, pakai wrapper-nya, bukan markup mentah +
38
+ enhancer:
39
+
40
+ | Stack-mu | Impor kesepuluhnya dari | Binding |
41
+ |---|---|---|
42
+ | **Vue 3** | `@cahyo-dimas/freeday/vue` | `v-model` |
43
+ | **React 18/19** | `@cahyo-dimas/freeday/react` | `value` + `onChange` |
44
+ | **Blazor (net8.0)** | RCL `Freeday.Blazor` | `@bind-Value` |
45
+ | HTML statis · Svelte · template server-rendered | *(tanpa wrapper)* markup mentah + enhancer, di bawah | event `fdy-*` |
46
+
47
+ Kesepuluhnya: `FdyCombo` · `FdyDatepicker` · `FdyDateRange` · `FdyAutocomplete` · `FdyCascade` ·
48
+ `FdyCfl` · `FdyChart` · `FdyTable` · `FdyModal` · `FdyDrawer`. Menulis markup mentah untuk
49
+ kesepuluh ini di dalam Vue/React/Blazor *kelihatan* baik-baik saja — render pertama benar — lalu
50
+ rusak diam-diam: DOM yang di-render framework belakangan tak pernah ter-hydrate, dan state widget
51
+ berakhir di DOM alih-alih di state framework-mu. Selebihnya (button, card, badge, alert, markup
52
+ tabel, layout, dan komponen interaktif yang tak punya wrapper) sama persis di semua stack — untuk
53
+ yang terakhir, hydrate dengan `useFreeday` (Vue/React) atau `FreedayBlazor.initAll` (Blazor).
54
+ Panduan lengkap per stack: [`docs/getting-started.md`](docs/getting-started.md).
55
+
34
56
  **Sebagai paket (project dengan bundler — Vue/React/Blazor/Vite):**
35
57
  ```bash
36
58
  npm i @cahyo-dimas/freeday
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs — the UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.21.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.21.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.22.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.22.0)
9
9
 
10
10
  A token-driven, framework-agnostic UI kit — one source of truth for color, typography,
11
11
  spacing, and components. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
@@ -31,6 +31,27 @@ npm test # build-transform tests + WCAG contrast (node:test)
31
31
 
32
32
  ## Use in a project
33
33
 
34
+ ### First: which entry point is yours?
35
+
36
+ Most of Freeday is markup + `fdy-*` classes and is identical everywhere. **Ten interactive
37
+ components also ship a typed wrapper** — in these stacks, use the wrapper, not raw markup + enhancer:
38
+
39
+ | Your stack | Import the ten from | Binding |
40
+ |---|---|---|
41
+ | **Vue 3** | `@cahyo-dimas/freeday/vue` | `v-model` |
42
+ | **React 18/19** | `@cahyo-dimas/freeday/react` | `value` + `onChange` |
43
+ | **Blazor (net8.0)** | `Freeday.Blazor` RCL | `@bind-Value` |
44
+ | Static HTML · Svelte · server-rendered templates | *(no wrapper)* raw markup + enhancers, below | `fdy-*` events |
45
+
46
+ The ten: `FdyCombo` · `FdyDatepicker` · `FdyDateRange` · `FdyAutocomplete` · `FdyCascade` ·
47
+ `FdyCfl` · `FdyChart` · `FdyTable` · `FdyModal` · `FdyDrawer`. Hand-writing the raw markup for
48
+ these inside Vue/React/Blazor *looks* fine — the first render is correct — and then breaks quietly:
49
+ DOM the framework renders later is never hydrated, and the widget's state ends up in the DOM instead
50
+ of your framework's. Everything else (button, card, badge, alert, table markup, layout, and the
51
+ interactive components with no wrapper) is the same plain markup in every stack — hydrate the latter
52
+ with `useFreeday` (Vue/React) or `FreedayBlazor.initAll` (Blazor). Full walkthrough per stack:
53
+ [`docs/getting-started.md`](docs/getting-started.md).
54
+
34
55
  **As a package (projects with a bundler — Vue/React/Blazor/Vite):**
35
56
  ```bash
36
57
  npm i @cahyo-dimas/freeday
package/USAGE.md CHANGED
@@ -115,6 +115,27 @@ container, a single `<section>`. An app whose two list screens are dense and who
115
115
  are not should scope it per screen rather than densifying everything. Set it at one level per screen,
116
116
  never per component.
117
117
 
118
+ ## 6b. Navigation: one component, two orientations — and never a tab role
119
+
120
+ A navigation link is an `<a class="fdy-nav__item">` marked **`aria-current="page"`**. That does not
121
+ change with the layout:
122
+
123
+ - **Sidebar app** — `.fdy-nav` inside `.fdy-app__sidebar` (the default).
124
+ - **Top-nav app** — `.fdy-nav.fdy-nav--horizontal` inside `.fdy-appbar` or `.fdy-app__topbar`, with
125
+ no sidebar. Same items, same states; on `.fdy-appbar--primary` the links go on-colour for you.
126
+ - **Routed sub-navigation that should look like tabs** (`/settings/profile` · `/settings/billing`) —
127
+ put `.fdy-tabs__list` / `.fdy-tabs__tab` on plain `<a>`s. Those classes honour `aria-current="page"`
128
+ as well as `aria-selected="true"` precisely for this. Do **not** add `role="tab"`/`role="tablist"`
129
+ or `freeday-tabs.js`: those promise a roving-tabindex, arrow-key, one-panel-per-tab contract that
130
+ route links do not honour, and `aria-selected` is invalid ARIA on an anchor in the first place.
131
+
132
+ Reserve the full `.fdy-tabs` component (with its roles and its enhancer) for **in-page** tabs, where
133
+ nothing navigates.
134
+
135
+ **Toggles:** a button that is on/off carries `aria-pressed`, and the kit styles it — soft primary
136
+ fill on `--ghost`/`--text`, a sunken gradient on the solid button. A `.fdy-btn-group` of `--ghost`
137
+ buttons with exactly one `aria-pressed="true"` **is** the segmented control; don't hand-tint it.
138
+
118
139
  ## 7. Assemble the page from the frame down
119
140
 
120
141
  1. **Shell:** every application starts inside **`.fdy-app`** — a flex row of `__sidebar` (with
@@ -473,6 +473,15 @@ a { color: var(--color-primary); }
473
473
  .fdy-app__brand-title{display:block;font-family:var(--font-display);font-weight:var(--weight-bold);font-size:var(--text-lg);color:var(--color-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
474
474
  .fdy-app__brand-subtitle{display:block;font-size:var(--text-xs);font-weight:var(--weight-regular);color:var(--color-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
475
475
  .fdy-nav{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;display:flex;flex-direction:column;gap:2px;padding:var(--space-3) var(--space-3) var(--space-6);}
476
+ /* Horizontal variant — the SAME nav links laid out as a row, for a top-nav application (one that
477
+ * puts its primary navigation in `.fdy-appbar` / `.fdy-app__topbar` and has no sidebar). Deliberately
478
+ * a modifier and not a new block: the item, its states and `aria-current="page"` are unchanged. That
479
+ * is the point — a navigation link is a link marked `aria-current`, never `aria-selected` (invalid
480
+ * ARIA on an anchor) and never a tab role, so borrowing `.fdy-tabs` for routes half-adopts a contract
481
+ * (roving tabindex, a panel per tab) that route links do not honour. position:relative because the
482
+ * row scrolls; see base.css on why a scroller must be a containing block. */
483
+ .fdy-nav--horizontal{position:relative;flex:0 1 auto;flex-direction:row;align-items:center;gap:var(--space-1);padding:0;min-height:0;overflow-x:auto;}
484
+ .fdy-nav--horizontal .fdy-nav__item{white-space:nowrap;}
476
485
  .fdy-nav__item{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);text-decoration:none;cursor:pointer;transition:background var(--dur-fast) var(--ease-standard),color var(--dur-fast) var(--ease-standard);}
477
486
  .fdy-nav__item:hover{background:var(--color-surface-2);color:var(--color-text);}
478
487
  /* Active indicator stays in the primary ramp so it never clashes with a re-themed accent. */
@@ -548,9 +557,18 @@ a { color: var(--color-primary); }
548
557
  .fdy-appbar__brand svg{display:block;width:1.5rem;height:1.5rem;}
549
558
  .fdy-appbar__spacer{flex:1;}
550
559
  .fdy-appbar__actions{display:inline-flex;align-items:center;gap:var(--space-1);}
560
+ /* Primary navigation inside the bar: `<nav class="fdy-nav fdy-nav--horizontal">` with
561
+ `.fdy-nav__item` links (see app-shell.css). The bar ships no link class of its own — a nav link
562
+ is the same component whether the nav is a sidebar column or a top row. */
551
563
  /* Controls on a coloured app bar go on-colour (icons stay legible, hover is a light wash) */
552
564
  .fdy-appbar--primary .fdy-btn--ghost{background:transparent;border-color:transparent;color:var(--color-on-primary);box-shadow:none;}
553
565
  .fdy-appbar--primary .fdy-btn--ghost:hover{background:color-mix(in srgb,var(--color-on-primary) 16%,transparent);color:var(--color-on-primary);transform:none;}
566
+ /* Nav links on a coloured bar: full on-colour ink in every state (a dimmed variant would trade away
567
+ the contrast the bar's own token pair guarantees). The current page is carried by the background
568
+ wash plus the semibold weight the base rule already applies — and by `aria-current` itself. */
569
+ .fdy-appbar--primary .fdy-nav__item{color:var(--color-on-primary);}
570
+ .fdy-appbar--primary .fdy-nav__item:hover{background:color-mix(in srgb,var(--color-on-primary) 16%,transparent);color:var(--color-on-primary);}
571
+ .fdy-appbar--primary .fdy-nav__item[aria-current="page"]{background:color-mix(in srgb,var(--color-on-primary) 24%,transparent);color:var(--color-on-primary);}
554
572
 
555
573
  /* Freeday — Autocomplete (text input + filtered suggestion listbox, APG editable combobox).
556
574
  * Enhanced by freeday-autocomplete.js. */
@@ -653,6 +671,14 @@ a { color: var(--color-primary); }
653
671
  .fdy-btn:hover{transform:translateY(-1px);box-shadow:0 6px 18px -2px color-mix(in srgb,var(--color-primary) 60%,transparent),inset 0 1px 0 rgba(255,255,255,.26);}
654
672
  .fdy-btn:active{transform:translateY(1px);box-shadow:0 1px 4px -1px color-mix(in srgb,var(--color-primary) 46%,transparent);}
655
673
  .fdy-btn:disabled{opacity:.5;cursor:not-allowed;transform:none;box-shadow:none;}
674
+ /* Pressed / toggle. `aria-pressed` is already the right attribute for a toggle button, so this adds
675
+ no class and no new markup contract — and it is what makes .fdy-btn-group a complete segmented
676
+ control: the group joins the borders, this says which segment you are on. Each fill variant
677
+ defines its OWN pressed look below; a single shared rule cannot work, because a gradient is a
678
+ background-IMAGE and would blank the ghost variant's background-color. Solid: invert the gradient
679
+ and sink the shadow inward, so a held button reads as depressed, not merely primary-coloured. */
680
+ .fdy-btn[aria-pressed="true"]{background:linear-gradient(180deg,color-mix(in srgb,#000 12%,var(--color-primary)),var(--color-primary));box-shadow:inset 0 2px 6px color-mix(in srgb,#000 22%,transparent);}
681
+ .fdy-btn[aria-pressed="true"]:hover{transform:none;box-shadow:inset 0 2px 6px color-mix(in srgb,#000 22%,transparent);}
656
682
 
657
683
  .fdy-btn--ghost{background:var(--color-surface);color:var(--color-primary-strong);border-color:var(--color-border-strong);box-shadow:var(--shadow-1);}
658
684
  .fdy-btn--ghost:hover{transform:none;background:var(--color-primary-soft);border-color:var(--color-primary-border);box-shadow:var(--shadow-1);}
@@ -661,6 +687,7 @@ a { color: var(--color-primary); }
661
687
  .fdy-btn--danger{background:linear-gradient(180deg,var(--color-danger-btn),color-mix(in srgb,#000 12%,var(--color-danger-btn)));color:var(--color-on-danger);box-shadow:0 2px 9px -1px color-mix(in srgb,var(--color-danger-btn) 46%,transparent),inset 0 1px 0 rgba(255,255,255,.22);}
662
688
  .fdy-btn--danger:hover{transform:translateY(-1px);box-shadow:0 6px 18px -2px color-mix(in srgb,var(--color-danger-btn) 54%,transparent),inset 0 1px 0 rgba(255,255,255,.22);}
663
689
  .fdy-btn--danger:active{transform:translateY(1px);}
690
+ .fdy-btn--danger[aria-pressed="true"]{background:linear-gradient(180deg,color-mix(in srgb,#000 14%,var(--color-danger-btn)),var(--color-danger-btn));box-shadow:inset 0 2px 6px color-mix(in srgb,#000 24%,transparent);}
664
691
 
665
692
  .fdy-btn--sm{height:calc(var(--control-h) - var(--space-2));padding:0 var(--space-3);font-size:var(--text-xs);border-radius:var(--radius-sm);}
666
693
  .fdy-btn--lg{height:calc(var(--control-h) + var(--space-2));padding:0 var(--space-6);font-size:var(--text-base);border-radius:var(--radius-lg);}
@@ -673,6 +700,11 @@ a { color: var(--color-primary); }
673
700
  .fdy-btn--text{background:none;color:var(--color-primary-strong);border-color:transparent;box-shadow:none;}
674
701
  .fdy-btn--text:hover{transform:none;background:var(--color-primary-soft);box-shadow:none;}
675
702
  .fdy-btn--text:active{transform:translateY(1px);box-shadow:none;}
703
+ /* Ghost/text pressed: the kit's selected-surface pair (soft fill + strong ink) — the same pairing
704
+ .fdy-nav__item uses for the current page, and one the AA contrast gate already covers. This is
705
+ the realistic segmented control: a row of --ghost buttons with exactly one aria-pressed. */
706
+ .fdy-btn--ghost[aria-pressed="true"],.fdy-btn--text[aria-pressed="true"]{background:var(--color-primary-soft);color:var(--color-primary-strong);border-color:var(--color-primary-border);box-shadow:none;}
707
+ .fdy-btn--ghost[aria-pressed="true"]:hover,.fdy-btn--text[aria-pressed="true"]:hover{background:var(--color-primary-soft);transform:none;box-shadow:none;}
676
708
 
677
709
  /* Icon-only — square; combine with --ghost/--text/--sm/--lg. Requires aria-label. */
678
710
  .fdy-btn--icon{width:var(--control-h);padding:0;}
@@ -1518,7 +1550,13 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1518
1550
  .fdy-tabs__tab:hover{color:var(--color-text);}
1519
1551
  .fdy-tabs__tab:disabled,.fdy-tabs__tab[aria-disabled="true"]{opacity:.5;cursor:not-allowed;color:var(--color-text-muted);}
1520
1552
  .fdy-tabs__tab:disabled:hover,.fdy-tabs__tab[aria-disabled="true"]:hover{color:var(--color-text-muted);}
1521
- .fdy-tabs__tab[aria-selected="true"]{color:var(--color-primary-strong);border-bottom-color:var(--color-primary);}
1553
+ /* `aria-current="page"` is honoured alongside `aria-selected="true"` so the tab look can be used for
1554
+ ROUTE-driven sub-navigation (/settings/profile · /settings/billing), where the tabs are real links.
1555
+ A link cannot be `aria-selected` — that is invalid ARIA on an anchor; `aria-current` is the only
1556
+ correct marker, and it is what a router sets. Use `.fdy-tabs` + `freeday-tabs.js` (roving tabindex,
1557
+ a panel per tab) for in-page tabs; use these classes on plain links for routed ones, and
1558
+ `.fdy-nav--horizontal` for an application's PRIMARY navigation. */
1559
+ .fdy-tabs__tab[aria-selected="true"],.fdy-tabs__tab[aria-current="page"]{color:var(--color-primary-strong);border-bottom-color:var(--color-primary);}
1522
1560
  .fdy-tabs__tab:focus-visible{outline:none;border-radius:var(--radius-sm);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
1523
1561
  .fdy-tabs__panel{padding:var(--space-5) 0;}
1524
1562
  .fdy-tabs__panel[hidden]{display:none;}
package/dist/freeday.css CHANGED
@@ -103,6 +103,15 @@ a { color: var(--color-primary); }
103
103
  .fdy-app__brand-title{display:block;font-family:var(--font-display);font-weight:var(--weight-bold);font-size:var(--text-lg);color:var(--color-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
104
104
  .fdy-app__brand-subtitle{display:block;font-size:var(--text-xs);font-weight:var(--weight-regular);color:var(--color-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
105
105
  .fdy-nav{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;display:flex;flex-direction:column;gap:2px;padding:var(--space-3) var(--space-3) var(--space-6);}
106
+ /* Horizontal variant — the SAME nav links laid out as a row, for a top-nav application (one that
107
+ * puts its primary navigation in `.fdy-appbar` / `.fdy-app__topbar` and has no sidebar). Deliberately
108
+ * a modifier and not a new block: the item, its states and `aria-current="page"` are unchanged. That
109
+ * is the point — a navigation link is a link marked `aria-current`, never `aria-selected` (invalid
110
+ * ARIA on an anchor) and never a tab role, so borrowing `.fdy-tabs` for routes half-adopts a contract
111
+ * (roving tabindex, a panel per tab) that route links do not honour. position:relative because the
112
+ * row scrolls; see base.css on why a scroller must be a containing block. */
113
+ .fdy-nav--horizontal{position:relative;flex:0 1 auto;flex-direction:row;align-items:center;gap:var(--space-1);padding:0;min-height:0;overflow-x:auto;}
114
+ .fdy-nav--horizontal .fdy-nav__item{white-space:nowrap;}
106
115
  .fdy-nav__item{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);text-decoration:none;cursor:pointer;transition:background var(--dur-fast) var(--ease-standard),color var(--dur-fast) var(--ease-standard);}
107
116
  .fdy-nav__item:hover{background:var(--color-surface-2);color:var(--color-text);}
108
117
  /* Active indicator stays in the primary ramp so it never clashes with a re-themed accent. */
@@ -178,9 +187,18 @@ a { color: var(--color-primary); }
178
187
  .fdy-appbar__brand svg{display:block;width:1.5rem;height:1.5rem;}
179
188
  .fdy-appbar__spacer{flex:1;}
180
189
  .fdy-appbar__actions{display:inline-flex;align-items:center;gap:var(--space-1);}
190
+ /* Primary navigation inside the bar: `<nav class="fdy-nav fdy-nav--horizontal">` with
191
+ `.fdy-nav__item` links (see app-shell.css). The bar ships no link class of its own — a nav link
192
+ is the same component whether the nav is a sidebar column or a top row. */
181
193
  /* Controls on a coloured app bar go on-colour (icons stay legible, hover is a light wash) */
182
194
  .fdy-appbar--primary .fdy-btn--ghost{background:transparent;border-color:transparent;color:var(--color-on-primary);box-shadow:none;}
183
195
  .fdy-appbar--primary .fdy-btn--ghost:hover{background:color-mix(in srgb,var(--color-on-primary) 16%,transparent);color:var(--color-on-primary);transform:none;}
196
+ /* Nav links on a coloured bar: full on-colour ink in every state (a dimmed variant would trade away
197
+ the contrast the bar's own token pair guarantees). The current page is carried by the background
198
+ wash plus the semibold weight the base rule already applies — and by `aria-current` itself. */
199
+ .fdy-appbar--primary .fdy-nav__item{color:var(--color-on-primary);}
200
+ .fdy-appbar--primary .fdy-nav__item:hover{background:color-mix(in srgb,var(--color-on-primary) 16%,transparent);color:var(--color-on-primary);}
201
+ .fdy-appbar--primary .fdy-nav__item[aria-current="page"]{background:color-mix(in srgb,var(--color-on-primary) 24%,transparent);color:var(--color-on-primary);}
184
202
 
185
203
  /* Freeday — Autocomplete (text input + filtered suggestion listbox, APG editable combobox).
186
204
  * Enhanced by freeday-autocomplete.js. */
@@ -283,6 +301,14 @@ a { color: var(--color-primary); }
283
301
  .fdy-btn:hover{transform:translateY(-1px);box-shadow:0 6px 18px -2px color-mix(in srgb,var(--color-primary) 60%,transparent),inset 0 1px 0 rgba(255,255,255,.26);}
284
302
  .fdy-btn:active{transform:translateY(1px);box-shadow:0 1px 4px -1px color-mix(in srgb,var(--color-primary) 46%,transparent);}
285
303
  .fdy-btn:disabled{opacity:.5;cursor:not-allowed;transform:none;box-shadow:none;}
304
+ /* Pressed / toggle. `aria-pressed` is already the right attribute for a toggle button, so this adds
305
+ no class and no new markup contract — and it is what makes .fdy-btn-group a complete segmented
306
+ control: the group joins the borders, this says which segment you are on. Each fill variant
307
+ defines its OWN pressed look below; a single shared rule cannot work, because a gradient is a
308
+ background-IMAGE and would blank the ghost variant's background-color. Solid: invert the gradient
309
+ and sink the shadow inward, so a held button reads as depressed, not merely primary-coloured. */
310
+ .fdy-btn[aria-pressed="true"]{background:linear-gradient(180deg,color-mix(in srgb,#000 12%,var(--color-primary)),var(--color-primary));box-shadow:inset 0 2px 6px color-mix(in srgb,#000 22%,transparent);}
311
+ .fdy-btn[aria-pressed="true"]:hover{transform:none;box-shadow:inset 0 2px 6px color-mix(in srgb,#000 22%,transparent);}
286
312
 
287
313
  .fdy-btn--ghost{background:var(--color-surface);color:var(--color-primary-strong);border-color:var(--color-border-strong);box-shadow:var(--shadow-1);}
288
314
  .fdy-btn--ghost:hover{transform:none;background:var(--color-primary-soft);border-color:var(--color-primary-border);box-shadow:var(--shadow-1);}
@@ -291,6 +317,7 @@ a { color: var(--color-primary); }
291
317
  .fdy-btn--danger{background:linear-gradient(180deg,var(--color-danger-btn),color-mix(in srgb,#000 12%,var(--color-danger-btn)));color:var(--color-on-danger);box-shadow:0 2px 9px -1px color-mix(in srgb,var(--color-danger-btn) 46%,transparent),inset 0 1px 0 rgba(255,255,255,.22);}
292
318
  .fdy-btn--danger:hover{transform:translateY(-1px);box-shadow:0 6px 18px -2px color-mix(in srgb,var(--color-danger-btn) 54%,transparent),inset 0 1px 0 rgba(255,255,255,.22);}
293
319
  .fdy-btn--danger:active{transform:translateY(1px);}
320
+ .fdy-btn--danger[aria-pressed="true"]{background:linear-gradient(180deg,color-mix(in srgb,#000 14%,var(--color-danger-btn)),var(--color-danger-btn));box-shadow:inset 0 2px 6px color-mix(in srgb,#000 24%,transparent);}
294
321
 
295
322
  .fdy-btn--sm{height:calc(var(--control-h) - var(--space-2));padding:0 var(--space-3);font-size:var(--text-xs);border-radius:var(--radius-sm);}
296
323
  .fdy-btn--lg{height:calc(var(--control-h) + var(--space-2));padding:0 var(--space-6);font-size:var(--text-base);border-radius:var(--radius-lg);}
@@ -303,6 +330,11 @@ a { color: var(--color-primary); }
303
330
  .fdy-btn--text{background:none;color:var(--color-primary-strong);border-color:transparent;box-shadow:none;}
304
331
  .fdy-btn--text:hover{transform:none;background:var(--color-primary-soft);box-shadow:none;}
305
332
  .fdy-btn--text:active{transform:translateY(1px);box-shadow:none;}
333
+ /* Ghost/text pressed: the kit's selected-surface pair (soft fill + strong ink) — the same pairing
334
+ .fdy-nav__item uses for the current page, and one the AA contrast gate already covers. This is
335
+ the realistic segmented control: a row of --ghost buttons with exactly one aria-pressed. */
336
+ .fdy-btn--ghost[aria-pressed="true"],.fdy-btn--text[aria-pressed="true"]{background:var(--color-primary-soft);color:var(--color-primary-strong);border-color:var(--color-primary-border);box-shadow:none;}
337
+ .fdy-btn--ghost[aria-pressed="true"]:hover,.fdy-btn--text[aria-pressed="true"]:hover{background:var(--color-primary-soft);transform:none;box-shadow:none;}
306
338
 
307
339
  /* Icon-only — square; combine with --ghost/--text/--sm/--lg. Requires aria-label. */
308
340
  .fdy-btn--icon{width:var(--control-h);padding:0;}
@@ -1148,7 +1180,13 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1148
1180
  .fdy-tabs__tab:hover{color:var(--color-text);}
1149
1181
  .fdy-tabs__tab:disabled,.fdy-tabs__tab[aria-disabled="true"]{opacity:.5;cursor:not-allowed;color:var(--color-text-muted);}
1150
1182
  .fdy-tabs__tab:disabled:hover,.fdy-tabs__tab[aria-disabled="true"]:hover{color:var(--color-text-muted);}
1151
- .fdy-tabs__tab[aria-selected="true"]{color:var(--color-primary-strong);border-bottom-color:var(--color-primary);}
1183
+ /* `aria-current="page"` is honoured alongside `aria-selected="true"` so the tab look can be used for
1184
+ ROUTE-driven sub-navigation (/settings/profile · /settings/billing), where the tabs are real links.
1185
+ A link cannot be `aria-selected` — that is invalid ARIA on an anchor; `aria-current` is the only
1186
+ correct marker, and it is what a router sets. Use `.fdy-tabs` + `freeday-tabs.js` (roving tabindex,
1187
+ a panel per tab) for in-page tabs; use these classes on plain links for routed ones, and
1188
+ `.fdy-nav--horizontal` for an application's PRIMARY navigation. */
1189
+ .fdy-tabs__tab[aria-selected="true"],.fdy-tabs__tab[aria-current="page"]{color:var(--color-primary-strong);border-bottom-color:var(--color-primary);}
1152
1190
  .fdy-tabs__tab:focus-visible{outline:none;border-radius:var(--radius-sm);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
1153
1191
  .fdy-tabs__panel{padding:var(--space-5) 0;}
1154
1192
  .fdy-tabs__panel[hidden]{display:none;}
@@ -18,8 +18,38 @@ the **root of the consuming project**:
18
18
  ```markdown
19
19
  ## UI: Freeday design system (@cahyo-dimas/freeday)
20
20
 
21
- All UI in this project is built from Freeday. It is a **token-driven CSS kit + zero-dependency JS
22
- enhancers**, not a component framework components are plain markup with `fdy-*` classes.
21
+ All UI in this project is built from Freeday: a **token-driven CSS kit** (`fdy-*` classes on plain
22
+ markup) with **typed components for Vue, React and Blazor** layered on top. Most of the kit is
23
+ markup + classes; ten interactive components also ship a typed wrapper, and in those three stacks
24
+ the wrapper is the correct way to use them.
25
+
26
+ **0. First decide which entry point this project uses. This is not an optimisation — get it wrong
27
+ and the code looks correct and fails later.**
28
+
29
+ | This project's stack | Import the ten components from | Binding |
30
+ |---|---|---|
31
+ | Vue 3 | `@cahyo-dimas/freeday/vue` | `v-model` |
32
+ | React 18/19 | `@cahyo-dimas/freeday/react` | `value` + `onChange` |
33
+ | Blazor (net8.0) | `@using Freeday.Blazor` (RCL) | `@bind-Value` |
34
+ | Static HTML, Svelte, server-rendered templates… | no wrapper — raw markup + the enhancer script | `fdy-*` DOM events |
35
+
36
+ The ten: **FdyCombo · FdyDatepicker · FdyDateRange · FdyAutocomplete · FdyCascade · FdyCfl ·
37
+ FdyChart · FdyTable · FdyModal · FdyDrawer**. In Vue/React/Blazor, **never hand-write the raw
38
+ markup + enhancer for these ten.** The raw path *appears* to work — the enhancer auto-initialises
39
+ once on `DOMContentLoaded` and the first render is correct — then fails quietly: DOM your framework
40
+ renders later is never hydrated, and the widget's state lives in the DOM instead of in your
41
+ framework's state.
42
+
43
+ Everything else is the same in every stack: plain `fdy-*` markup (button, card, badge, alert,
44
+ table markup, layout…). For the *other* interactive components (chips, stepper, input mask, file
45
+ upload, tree, tabs, menu, rating, slider, form validation, carousel, timepicker) there is no
46
+ wrapper — use the raw markup and hydrate it:
47
+
48
+ - **Vue / React** — `useFreeday(rootRef)` from the same import path, plus `import '@cahyo-dimas/freeday'`
49
+ once at app entry to register the enhancers. (The ten typed components do **not** need this: they
50
+ are native Vue/React implementations of the same markup, not wrappers over the enhancer.)
51
+ - **Blazor** — `FreedayBlazor.initAll` interop. (Here the typed components *are* thin wrappers over
52
+ the enhancers, so the enhancer script is always required.)
23
53
 
24
54
  **Before writing or editing any markup/CSS, read these (they ship inside the package):**
25
55
  - `node_modules/@cahyo-dimas/freeday/COMPONENTS.md` — every class that exists, with minimal markup
@@ -40,8 +70,9 @@ enhancers**, not a component framework — components are plain markup with `fdy
40
70
  5. Assemble from the frame down: `.fdy-app` → `.fdy-page` → `.fdy-page-section` → components.
41
71
  6. Form errors: `aria-invalid="true"` + `aria-describedby` → a `.fdy-help.fdy-help--error`.
42
72
  Icon-only buttons need `aria-label`. Status is never colour-only.
43
- 7. Interactive components need their enhancer script loaded (see the table in COMPONENTS.md);
44
- in an SPA, re-hydrate dynamic DOM (`useFreeday` in Vue/React does this).
73
+ 7. Interactive components need their enhancer script loaded (see the table in COMPONENTS.md); in an
74
+ SPA, re-hydrate dynamic DOM with `useFreeday` / `FreedayBlazor.initAll`. This applies to the
75
+ components **without** a typed wrapper — for the ten in step 0, use the wrapper instead.
45
76
  8. Freeday owns components + tokens, **not layout**. Grids/stacks/one-off gaps come from our own
46
77
  layout layer — build its theme on `var(--space-N)` so both systems stay in step.
47
78
  ```
@@ -17,10 +17,15 @@ Freeday = **CSS** (semantic tokens + `fdy-*` classes) + **zero-dependency JS enh
17
17
  1. **Static vs interactive.** Static components (button, card, badge, plain input, layout) need
18
18
  only the **`fdy-*` classes** — no JS. Interactive components (select/combo, cascade, date/time
19
19
  picker, table, dropzone, form validation, input mask, chip) need the **JS enhancers**.
20
- 2. **The enhancer is the source of truth.** You don't re-implement components; the enhancer owns
21
- the widget's DOM. You **listen for `fdy-*` events** (all bubbling `CustomEvent`s, data in
22
- `event.detail`) → store them in your framework state. Event/API contract table:
23
- [`integrations.md` §Event & API contract](integrations.md).
20
+ 2. **The enhancer is the source of truth — *on the raw path*.** You don't re-implement components;
21
+ the enhancer owns the widget's DOM. You **listen for `fdy-*` events** (all bubbling
22
+ `CustomEvent`s, data in `event.detail`) → store them in your framework state. Event/API contract
23
+ table: [`integrations.md` §Event & API contract](integrations.md).
24
+ **On Vue, React or Blazor this is not the path to take for ten of the components** — `FdyCombo`,
25
+ `FdyDatepicker`, `FdyDateRange`, `FdyAutocomplete`, `FdyCascade`, `FdyCfl`, `FdyChart`,
26
+ `FdyTable`, `FdyModal`, `FdyDrawer` ship typed wrappers that own the state properly (Vue and
27
+ React re-implement the interaction natively; Blazor wraps the enhancer over interop). Use them;
28
+ the raw path is for the components without a wrapper, and for stacks without an adapter.
24
29
  3. **Hydrate dynamic DOM.** Enhancers auto-init once on `DOMContentLoaded`. DOM an SPA renders
25
30
  **after** that must be re-hydrated: `window.Freeday<X>.initAll(el)` (idempotent, safe to repeat).
26
31
  Each framework's adapter wraps this — you don't call it manually.
@@ -179,7 +184,7 @@ live docs also have a copy button per component.
179
184
  ```bash
180
185
  npm i @cahyo-dimas/freeday
181
186
  ```
182
- Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.21.0"` (public npm package). `dist/` is
187
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.22.0"` (public npm package). `dist/` is
183
188
  committed and published → no build step; `npm ci` runs without auth.
184
189
 
185
190
  ### 2. Import the CSS + enhancers **once** in your entry (`src/main.ts`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "1.21.0",
3
+ "version": "1.22.0",
4
4
  "description": "Freeday — token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -71,7 +71,7 @@
71
71
  "scripts": {
72
72
  "build": "node tokens/build.mjs",
73
73
  "test": "node --test",
74
- "test:browser": "node --test browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs",
74
+ "test:browser": "node --test browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs",
75
75
  "prepack": "node tokens/build.mjs",
76
76
  "version": "node tokens/build.mjs && git add dist",
77
77
  "typecheck:react": "tsc -p adapters/react/tsconfig.json --noEmit"
@@ -24,6 +24,15 @@
24
24
  .fdy-app__brand-title{display:block;font-family:var(--font-display);font-weight:var(--weight-bold);font-size:var(--text-lg);color:var(--color-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
25
25
  .fdy-app__brand-subtitle{display:block;font-size:var(--text-xs);font-weight:var(--weight-regular);color:var(--color-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26
26
  .fdy-nav{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;display:flex;flex-direction:column;gap:2px;padding:var(--space-3) var(--space-3) var(--space-6);}
27
+ /* Horizontal variant — the SAME nav links laid out as a row, for a top-nav application (one that
28
+ * puts its primary navigation in `.fdy-appbar` / `.fdy-app__topbar` and has no sidebar). Deliberately
29
+ * a modifier and not a new block: the item, its states and `aria-current="page"` are unchanged. That
30
+ * is the point — a navigation link is a link marked `aria-current`, never `aria-selected` (invalid
31
+ * ARIA on an anchor) and never a tab role, so borrowing `.fdy-tabs` for routes half-adopts a contract
32
+ * (roving tabindex, a panel per tab) that route links do not honour. position:relative because the
33
+ * row scrolls; see base.css on why a scroller must be a containing block. */
34
+ .fdy-nav--horizontal{position:relative;flex:0 1 auto;flex-direction:row;align-items:center;gap:var(--space-1);padding:0;min-height:0;overflow-x:auto;}
35
+ .fdy-nav--horizontal .fdy-nav__item{white-space:nowrap;}
27
36
  .fdy-nav__item{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);text-decoration:none;cursor:pointer;transition:background var(--dur-fast) var(--ease-standard),color var(--dur-fast) var(--ease-standard);}
28
37
  .fdy-nav__item:hover{background:var(--color-surface-2);color:var(--color-text);}
29
38
  /* Active indicator stays in the primary ramp so it never clashes with a re-themed accent. */
@@ -10,6 +10,15 @@
10
10
  .fdy-appbar__brand svg{display:block;width:1.5rem;height:1.5rem;}
11
11
  .fdy-appbar__spacer{flex:1;}
12
12
  .fdy-appbar__actions{display:inline-flex;align-items:center;gap:var(--space-1);}
13
+ /* Primary navigation inside the bar: `<nav class="fdy-nav fdy-nav--horizontal">` with
14
+ `.fdy-nav__item` links (see app-shell.css). The bar ships no link class of its own — a nav link
15
+ is the same component whether the nav is a sidebar column or a top row. */
13
16
  /* Controls on a coloured app bar go on-colour (icons stay legible, hover is a light wash) */
14
17
  .fdy-appbar--primary .fdy-btn--ghost{background:transparent;border-color:transparent;color:var(--color-on-primary);box-shadow:none;}
15
18
  .fdy-appbar--primary .fdy-btn--ghost:hover{background:color-mix(in srgb,var(--color-on-primary) 16%,transparent);color:var(--color-on-primary);transform:none;}
19
+ /* Nav links on a coloured bar: full on-colour ink in every state (a dimmed variant would trade away
20
+ the contrast the bar's own token pair guarantees). The current page is carried by the background
21
+ wash plus the semibold weight the base rule already applies — and by `aria-current` itself. */
22
+ .fdy-appbar--primary .fdy-nav__item{color:var(--color-on-primary);}
23
+ .fdy-appbar--primary .fdy-nav__item:hover{background:color-mix(in srgb,var(--color-on-primary) 16%,transparent);color:var(--color-on-primary);}
24
+ .fdy-appbar--primary .fdy-nav__item[aria-current="page"]{background:color-mix(in srgb,var(--color-on-primary) 24%,transparent);color:var(--color-on-primary);}
@@ -10,6 +10,14 @@
10
10
  .fdy-btn:hover{transform:translateY(-1px);box-shadow:0 6px 18px -2px color-mix(in srgb,var(--color-primary) 60%,transparent),inset 0 1px 0 rgba(255,255,255,.26);}
11
11
  .fdy-btn:active{transform:translateY(1px);box-shadow:0 1px 4px -1px color-mix(in srgb,var(--color-primary) 46%,transparent);}
12
12
  .fdy-btn:disabled{opacity:.5;cursor:not-allowed;transform:none;box-shadow:none;}
13
+ /* Pressed / toggle. `aria-pressed` is already the right attribute for a toggle button, so this adds
14
+ no class and no new markup contract — and it is what makes .fdy-btn-group a complete segmented
15
+ control: the group joins the borders, this says which segment you are on. Each fill variant
16
+ defines its OWN pressed look below; a single shared rule cannot work, because a gradient is a
17
+ background-IMAGE and would blank the ghost variant's background-color. Solid: invert the gradient
18
+ and sink the shadow inward, so a held button reads as depressed, not merely primary-coloured. */
19
+ .fdy-btn[aria-pressed="true"]{background:linear-gradient(180deg,color-mix(in srgb,#000 12%,var(--color-primary)),var(--color-primary));box-shadow:inset 0 2px 6px color-mix(in srgb,#000 22%,transparent);}
20
+ .fdy-btn[aria-pressed="true"]:hover{transform:none;box-shadow:inset 0 2px 6px color-mix(in srgb,#000 22%,transparent);}
13
21
 
14
22
  .fdy-btn--ghost{background:var(--color-surface);color:var(--color-primary-strong);border-color:var(--color-border-strong);box-shadow:var(--shadow-1);}
15
23
  .fdy-btn--ghost:hover{transform:none;background:var(--color-primary-soft);border-color:var(--color-primary-border);box-shadow:var(--shadow-1);}
@@ -18,6 +26,7 @@
18
26
  .fdy-btn--danger{background:linear-gradient(180deg,var(--color-danger-btn),color-mix(in srgb,#000 12%,var(--color-danger-btn)));color:var(--color-on-danger);box-shadow:0 2px 9px -1px color-mix(in srgb,var(--color-danger-btn) 46%,transparent),inset 0 1px 0 rgba(255,255,255,.22);}
19
27
  .fdy-btn--danger:hover{transform:translateY(-1px);box-shadow:0 6px 18px -2px color-mix(in srgb,var(--color-danger-btn) 54%,transparent),inset 0 1px 0 rgba(255,255,255,.22);}
20
28
  .fdy-btn--danger:active{transform:translateY(1px);}
29
+ .fdy-btn--danger[aria-pressed="true"]{background:linear-gradient(180deg,color-mix(in srgb,#000 14%,var(--color-danger-btn)),var(--color-danger-btn));box-shadow:inset 0 2px 6px color-mix(in srgb,#000 24%,transparent);}
21
30
 
22
31
  .fdy-btn--sm{height:calc(var(--control-h) - var(--space-2));padding:0 var(--space-3);font-size:var(--text-xs);border-radius:var(--radius-sm);}
23
32
  .fdy-btn--lg{height:calc(var(--control-h) + var(--space-2));padding:0 var(--space-6);font-size:var(--text-base);border-radius:var(--radius-lg);}
@@ -30,6 +39,11 @@
30
39
  .fdy-btn--text{background:none;color:var(--color-primary-strong);border-color:transparent;box-shadow:none;}
31
40
  .fdy-btn--text:hover{transform:none;background:var(--color-primary-soft);box-shadow:none;}
32
41
  .fdy-btn--text:active{transform:translateY(1px);box-shadow:none;}
42
+ /* Ghost/text pressed: the kit's selected-surface pair (soft fill + strong ink) — the same pairing
43
+ .fdy-nav__item uses for the current page, and one the AA contrast gate already covers. This is
44
+ the realistic segmented control: a row of --ghost buttons with exactly one aria-pressed. */
45
+ .fdy-btn--ghost[aria-pressed="true"],.fdy-btn--text[aria-pressed="true"]{background:var(--color-primary-soft);color:var(--color-primary-strong);border-color:var(--color-primary-border);box-shadow:none;}
46
+ .fdy-btn--ghost[aria-pressed="true"]:hover,.fdy-btn--text[aria-pressed="true"]:hover{background:var(--color-primary-soft);transform:none;box-shadow:none;}
33
47
 
34
48
  /* Icon-only — square; combine with --ghost/--text/--sm/--lg. Requires aria-label. */
35
49
  .fdy-btn--icon{width:var(--control-h);padding:0;}
@@ -5,7 +5,13 @@
5
5
  .fdy-tabs__tab:hover{color:var(--color-text);}
6
6
  .fdy-tabs__tab:disabled,.fdy-tabs__tab[aria-disabled="true"]{opacity:.5;cursor:not-allowed;color:var(--color-text-muted);}
7
7
  .fdy-tabs__tab:disabled:hover,.fdy-tabs__tab[aria-disabled="true"]:hover{color:var(--color-text-muted);}
8
- .fdy-tabs__tab[aria-selected="true"]{color:var(--color-primary-strong);border-bottom-color:var(--color-primary);}
8
+ /* `aria-current="page"` is honoured alongside `aria-selected="true"` so the tab look can be used for
9
+ ROUTE-driven sub-navigation (/settings/profile · /settings/billing), where the tabs are real links.
10
+ A link cannot be `aria-selected` — that is invalid ARIA on an anchor; `aria-current` is the only
11
+ correct marker, and it is what a router sets. Use `.fdy-tabs` + `freeday-tabs.js` (roving tabindex,
12
+ a panel per tab) for in-page tabs; use these classes on plain links for routed ones, and
13
+ `.fdy-nav--horizontal` for an application's PRIMARY navigation. */
14
+ .fdy-tabs__tab[aria-selected="true"],.fdy-tabs__tab[aria-current="page"]{color:var(--color-primary-strong);border-bottom-color:var(--color-primary);}
9
15
  .fdy-tabs__tab:focus-visible{outline:none;border-radius:var(--radius-sm);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
10
16
  .fdy-tabs__panel{padding:var(--space-5) 0;}
11
17
  .fdy-tabs__panel[hidden]{display:none;}