@cahyo-dimas/freeday 1.20.0 → 1.21.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,71 @@
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.21.0] — 2026-08-12
7
+ Fifth round of real-app consumption feedback, written while that app adopted 1.20.0. Two findings —
8
+ and a **withdrawal**: the reporter retracted 1.20's rejected §A themselves after isolating the real
9
+ mechanism, which turned out to be a genuine kit bug hiding behind it. Both halves of that rejection
10
+ reproduce here, so the rejection stands and the withdrawal is recorded rather than re-litigated.
11
+ ### Fixed
12
+ - **Hidden labels could scroll the whole page.** `.fdy-visually-hidden` is `position:absolute`, and
13
+ `clip` hides *painting*, not *layout*. With no positioned ancestor its containing block is the
14
+ document, and `overflow` clips only what is contained inside the overflow box — so a hidden label in
15
+ a horizontally scrolling table (the kit's own recommended way to name an icon button) parks at its
16
+ static position and drags the document sideways. Measured in Chrome: **1351px** of phantom page
17
+ scroll from 11 spans in one table; `overflow-x:hidden` on the scroller, the shell, `body` and `html`
18
+ each changed it by **0px**; removing the spans took it to 0. Every clipping/scrolling container that
19
+ holds consumer markup now declares `position:relative` — `.fdy-table-scroll`, `.fdy-table-wrap`,
20
+ `.fdy-list`, `.fdy-card`, `.fdy-tabs__list`, `.fdy-carousel__viewport`, `.fdy-accordion` — each
21
+ measured to take its own case to 0 with the scroller still scrolling internally. The rest are
22
+ already inside something the kit positions (`<dialog>`, the sticky sidebar, a fixed popover) and are
23
+ listed with that reason in the new test.
24
+ **`.fdy-accordion` is the one to note:** it was contained only by its panel's reveal *animation*
25
+ (a transform makes a containing block), and that animation sits behind
26
+ `prefers-reduced-motion: no-preference` — so the bug was reachable **only** by readers who asked for
27
+ reduced motion. Measured with the animation off: 2906px → 0.
28
+ *Possible migration:* if you absolutely positioned something inside a card, list or table scroller
29
+ and relied on it escaping, it now anchors to that container. Anchoring is the reason for the change.
30
+ - **`--button` rows and cards ignored `:disabled`.** `.fdy-list__row--button` adopts the UA button box
31
+ but not its disabled state, so a row disabled mid-flight kept its hover tint and pointer cursor — a
32
+ control answering the pointer while refusing input. `:disabled` and `[aria-disabled="true"]` now dim
33
+ it and withdraw hover, for `.fdy-list__row--button`, `.fdy-list__row--interactive`,
34
+ `.fdy-card--button` and `.fdy-card--interactive`. The report suggested `cursor:default`; the kit uses
35
+ `opacity:.5` + `cursor:not-allowed` on every other disabled control, and consistency wins.
36
+ ### Changed
37
+ - **`data-theme` is no longer root-scoped** — the same move density made in 1.20.0, and for the same
38
+ reason. The two *explicit* selectors are now bare `[data-theme="dark"]` / `[data-theme="light"]`, so
39
+ a `<section data-theme="dark">` inverts that region and **every component inside it follows** —
40
+ card surfaces, inputs, and text roles like `.fdy-title-page` that set `color: var(--color-text)`
41
+ explicitly and therefore never saw a consumer's hand-rolled override. A dark brand panel beside a
42
+ light sign-in form is an ordinary layout; it should not cost a re-colouring pass. Setting the
43
+ attribute on `<html>` is unchanged, and a `[data-theme="light"]` island nested inside a dark region
44
+ wins in turn.
45
+ **The system default stays root-scoped, deliberately** — the report asked for "the two generated
46
+ selectors", but there are three. Un-rooting `@media (prefers-color-scheme: dark) { :root:not(...) }`
47
+ would make it match every element that does not itself carry `data-theme="light"`, including the
48
+ *children* of a light island, dragging them back to dark. Measured: with the un-rooted variant that
49
+ island renders light ink on a light surface; root-scoped, it stays correct.
50
+ ### Added — guards
51
+ - **`test/css.test.mjs`** — the containment invariant, CI-gated: a rule that declares `overflow` must
52
+ also be positioned, or be listed with the ancestor that already contains it. Single-line truncation
53
+ (`text-overflow:ellipsis` on a label) is excluded by shape, not by name, so new truncating labels
54
+ don't accumulate in an allowlist. A new clipping container fails the test until someone decides
55
+ which case it is.
56
+ - **`browser/layout.mjs`** — the same bug end-to-end in real Chrome (`npm run test:browser`): ten
57
+ hidden labels in a wide table, asserting the page cannot scroll horizontally *and* that the scroller
58
+ still scrolls. A static test cannot see this failure; only a layout engine can. Both guards
59
+ mutation-checked — reverting any single `position:relative` fails them.
60
+ - **`browser/theme.mjs`** — subtree theming end-to-end: a `.fdy-title-page` and a `.fdy-card` inside
61
+ `<section data-theme="dark">` take the dark tokens, a nested light island goes back, and
62
+ `data-theme` on `<html>` still themes everything. `test/build.test.mjs` guards the selector shape
63
+ (not root-scoped, media block still is, and the block order the cascade depends on); this guards the
64
+ behaviour that shape exists for. Mutation-checked: re-rooting the selectors fails it.
65
+ ### Notes
66
+ - Not adopted from the report, deliberately: `.fdy-datatable`, `.fdy-modal__body`, `.fdy-drawer__body`
67
+ were listed as needing the same fix. Measured: they do not escape — `<dialog>` is `position:fixed`
68
+ and is already their containing block, and the datatable's own scrolling child now carries it. They
69
+ are in the test's allowlist with that reason instead of carrying a declaration that does nothing.
70
+
6
71
  ## [1.20.0] — 2026-08-12
7
72
  Two bodies of work in one release (1.19.0 was prepared but never committed, tagged or published, so
8
73
  it is folded in here rather than left as a phantom version):
package/COMPONENTS.md CHANGED
@@ -31,6 +31,25 @@ live docs.
31
31
  6. **Interactive components need their enhancer script.** Static ones (button, card, badge, table,
32
32
  alert, breadcrumb, timeline, accordion, tree-without-cascade…) are CSS-only.
33
33
 
34
+ ### Containment — why the kit's containers are `position:relative`
35
+
36
+ `.fdy-visually-hidden` is `position:absolute`, and `clip` hides **painting**, not **layout**. An
37
+ absolutely positioned box resolves against its nearest *positioned* ancestor, and `overflow` clips
38
+ only what is contained that way — so in an unpositioned scroller, a hidden label parks at its static
39
+ position (possibly thousands of px to the right) and drags the **whole document** sideways. It is
40
+ invisible in the DOM and immune to `overflow-x: hidden` on every wrapper.
41
+
42
+ Every kit container that clips or scrolls therefore declares `position: relative` —
43
+ `.fdy-table-scroll`, `.fdy-table-wrap`, `.fdy-list`, `.fdy-card`, `.fdy-tabs__list`,
44
+ `.fdy-carousel__viewport`, `.fdy-accordion` (the rest are already inside a positioned ancestor).
45
+ Two consequences for you:
46
+
47
+ - **Do the same in your own scrollers.** A container with `overflow` that holds arbitrary markup
48
+ needs `position: relative`, or the hidden labels *you* write will escape it.
49
+ - **Diagnose it correctly.** `document.documentElement.scrollWidth` sees the escaped box;
50
+ `document.body.scrollWidth` does not. The honest check is `window.scrollTo(9999, 0)` then reading
51
+ `window.scrollX`.
52
+
34
53
  ## Enhancers — hook, script, global, events
35
54
 
36
55
  Every enhancer is zero-dependency, auto-initialises once on `DOMContentLoaded`, and is idempotent:
@@ -572,6 +591,10 @@ responsive `.fdy-datatable` should become below `md` — not a stack of `.fdy-ca
572
591
  control (render it as a real `<button>`/`<a>`; the UA box is reset without losing the list surface)
573
592
  - Row internals: `.fdy-list__main` (truncating stack) → `.fdy-list__title` + `.fdy-list__meta`, and
574
593
  `.fdy-list__aside` pinned right
594
+ - **Disabled** — `disabled` on the `<button>` (or `aria-disabled="true"` when the row is an `<a>`/
595
+ `<div>`) dims the row and withdraws the hover tint and the pointer cursor, same as every other
596
+ control in the kit. Do not hand-roll it: an undimmed row that still lights up under the pointer
597
+ reads as clickable while it is refusing input.
575
598
  - Not `.fdy-list-reset` — that utility only strips UA bullets/indent from a semantic list.
576
599
 
577
600
  ```html
@@ -709,6 +732,10 @@ Parts `__body` `__title` `__desc` `__footer`. Modifiers:
709
732
  the UA button box **without** losing the card surface/border
710
733
  - `.fdy-card--button` never replaces keyboard semantics — a clickable card must be a real
711
734
  `<button>` or `<a>`.
735
+ - **Disabled** — `disabled` (or `aria-disabled="true"`) dims the card and withdraws the pointer
736
+ cursor and the `--interactive` hover-lift.
737
+ - The card is `position:relative`, so a badge or ribbon you absolutely position inside it anchors
738
+ to the card. That is also what keeps hidden labels inside it from escaping — see *Containment*.
712
739
 
713
740
  ## Badge — `.fdy-badge`
714
741
  Inline status pill: `--success` `--warning` `--danger` `--info` `--outline`. Never colour-only —
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.20.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.20.0)
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)
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`.
@@ -121,6 +121,8 @@ container-nya, bukan scroll sendiri.
121
121
 
122
122
  ### 3. Theming — 3 sumbu lewat `data-*` di root
123
123
  - `data-theme="light|dark"` — redefinisi token semantic (bind ke state tema app-mu).
124
+ Berlaku di **ancestor mana pun**, tak harus root: `<section data-theme="dark">` membalik region itu
125
+ dan semua komponen di dalamnya ikut (lihat [`USAGE.md`](USAGE.md) §5b).
124
126
  - `data-density="comfortable|compact"` — tinggi kontrol (`--control-h`) untuk layar padat data.
125
127
  Ini auto-apply hanya ke kontrol bawaan Freeday (button, input, combo, dst); komponen
126
128
  custom/hand-built harus baca `--control-h` sendiri (mis. `height: var(--control-h)`) supaya ikut
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.20.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.20.0)
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)
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`.
@@ -121,6 +121,8 @@ of scrolling.
121
121
 
122
122
  ### 3. Theming — 3 axes via `data-*` on the root
123
123
  - `data-theme="light|dark"` — redefines the semantic tokens (bind it to your app's theme state).
124
+ Works on **any ancestor**, not just the root: `<section data-theme="dark">` inverts that region
125
+ and every component inside it follows (see [`USAGE.md`](USAGE.md) §5b).
124
126
  - `data-density="comfortable|compact"` — control height (`--control-h`) for data-dense screens.
125
127
  This auto-applies only to Freeday's built-in controls (button, input, combo, etc.);
126
128
  custom/hand-built components must read `--control-h` themselves (e.g. `height: var(--control-h)`)
package/USAGE.md CHANGED
@@ -82,6 +82,27 @@ point, everything around it quiet.
82
82
  - **Surfaces:** most backgrounds are `--color-surface`; `--color-surface-2`/`-3` for a recessed area;
83
83
  `--color-primary-soft` only when you want a tinted callout, not as a default panel colour.
84
84
 
85
+ ## 5b. Theme — global by default, per-subtree when a region is inverted
86
+
87
+ `data-theme="light|dark"` redefines the semantic tokens. Set it on `<html>` and it themes the app;
88
+ that is the normal case and nothing about it has changed.
89
+
90
+ **It is also per-subtree.** The two explicit selectors are bare `[data-theme="dark"]` /
91
+ `[data-theme="light"]`, and semantic tokens are inheriting custom properties — so a
92
+ `<section data-theme="dark">` inverts that region and **every Freeday component inside it follows**:
93
+ card surfaces, inputs, buttons, and text roles like `.fdy-title-page` that set
94
+ `color: var(--color-text)` explicitly. A dark brand panel beside a light sign-in form needs no
95
+ per-element re-colouring, and a `[data-theme="light"]` island nested back inside a dark region wins
96
+ in turn.
97
+
98
+ Do **not** invert a region by hand with `--color-inverse-*` plus `color-mix`. That pair is right for
99
+ a one-off band of your own markup, but it stops scaling the moment the region contains a real
100
+ component — anything that sets its own colour from a token never sees your override, and you end up
101
+ restating colours per element.
102
+
103
+ The **system** default (`@media (prefers-color-scheme: dark)`) stays root-scoped on purpose: that
104
+ rule is about the document, and un-rooting it would re-darken the children of a light island.
105
+
85
106
  ## 6. Density — `compact` for data-dense screens
86
107
 
87
108
  `data-density="compact"` tightens control height **and** the mid-range spacing scale
@@ -177,6 +177,9 @@
177
177
  --focus-ring: var(--azure-600);
178
178
  --focus-ring-width: 2px;
179
179
  }
180
+ /* The SYSTEM default stays root-scoped: it is a statement about the document, and dropping :root
181
+ * here would match every element that is not itself [data-theme="light"] — which would re-darken
182
+ * the children of a light panel, since they carry no attribute of their own. */
180
183
  @media (prefers-color-scheme: dark) {
181
184
  :root:not([data-theme="light"]) {
182
185
  --shadow-1: 0 1px 2px rgba(0,0,0,.4);
@@ -235,7 +238,13 @@
235
238
  --focus-ring: var(--azure-400);
236
239
  }
237
240
  }
238
- :root[data-theme="dark"] {
241
+ /* The two EXPLICIT opt-ins are deliberately NOT scoped to :root, for the same reason as density
242
+ * below: these are inheriting custom properties, so data-theme on any ancestor re-themes just that
243
+ * subtree — a dark brand panel beside a light form is an ordinary layout, and it should not require
244
+ * re-colouring each component by hand. The root still matches, so data-theme on <html> is unchanged.
245
+ * Both keep the same specificity (0,1,0) as :root and come after it, so they still win there; and
246
+ * --light after --dark means a light island inside a dark region wins in turn. */
247
+ [data-theme="dark"] {
239
248
  --shadow-1: 0 1px 2px rgba(0,0,0,.4);
240
249
  --shadow-2: 0 2px 6px rgba(0,0,0,.45);
241
250
  --shadow-3: 0 6px 20px -2px rgba(0,0,0,.6),0 1px 3px rgba(0,0,0,.5);
@@ -291,7 +300,7 @@
291
300
  --chart-tick: var(--slate-600);
292
301
  --focus-ring: var(--azure-400);
293
302
  }
294
- :root[data-theme="light"] {
303
+ [data-theme="light"] {
295
304
  --shadow-1: 0 1px 2px rgba(16,14,30,.06);
296
305
  --shadow-2: 0 2px 6px rgba(16,14,30,.07),0 1px 2px rgba(16,14,30,.05);
297
306
  --shadow-3: 0 4px 16px -2px rgba(16,14,30,.14),0 1px 3px rgba(16,14,30,.10);
@@ -376,6 +385,21 @@ h1, h2, h3, h4 { font-family: var(--font-display); font-weight: var(--weight-bol
376
385
  a { color: var(--color-primary); }
377
386
  :focus-visible { outline: var(--focus-ring-width) solid var(--focus-ring); outline-offset: var(--focus-ring-width); border-radius: var(--radius-xs); }
378
387
  :where(button, input, select, textarea) { font: inherit; }
388
+ /* Hidden from sight, kept for assistive tech. `clip` hides PAINTING, not LAYOUT: the box still
389
+ * has a position, and because it is position:absolute its containing block is the nearest
390
+ * POSITIONED ancestor — with none, the initial containing block (the document itself).
391
+ *
392
+ * That matters, because `overflow` only clips a descendant whose containing block is inside the
393
+ * overflow box. So a hidden span in a horizontally scrolling table (the kit's own recommended way
394
+ * to name an icon button) parks at its static position — possibly thousands of px to the right —
395
+ * and drags the whole DOCUMENT sideways: invisible, and immune to `overflow-x:hidden` on every
396
+ * wrapper. Measured before the fix: 1351px of phantom page scroll from 11 spans.
397
+ *
398
+ * The fix is not here — it is on the containers: every clipping/scrolling container in the kit
399
+ * that holds consumer markup declares `position:relative` so it becomes the containing block for
400
+ * its own out-of-flow content (see test/css.test.mjs, which will not let a new one skip it).
401
+ * Note the measurement trap: documentElement.scrollWidth sees it, body.scrollWidth does not —
402
+ * the honest check is `window.scrollTo(9999, 0)` then reading `window.scrollX`. */
379
403
  .fdy-visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
380
404
  /* Opt-in list reset. base.css is intentionally a *light* reset — it does NOT strip list/paragraph
381
405
  * margins (see the kit's own list components, which each reset themselves). If you run a utility
@@ -387,7 +411,11 @@ a { color: var(--color-primary); }
387
411
  }
388
412
 
389
413
  /* Freeday — Accordion (native <details>/<summary>, zero-JS) */
390
- .fdy-accordion{border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;background:var(--color-surface);}
414
+ /* position:relative containing block for out-of-flow panel content (see base.css). NOT redundant
415
+ with the panel's reveal animation: that animation gives the panel a transform (which happens to
416
+ make it a containing block too), but it lives behind prefers-reduced-motion:no-preference — so
417
+ without this line the escape bug appears ONLY for readers who asked for reduced motion. */
418
+ .fdy-accordion{position:relative;border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;background:var(--color-surface);}
391
419
  .fdy-accordion__item + .fdy-accordion__item{border-top:var(--bw) solid var(--color-border);}
392
420
  .fdy-accordion__item > summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) var(--space-4);font-weight:var(--weight-medium);color:var(--color-text);}
393
421
  .fdy-accordion__item > summary::-webkit-details-marker{display:none;}
@@ -673,7 +701,9 @@ a { color: var(--color-primary); }
673
701
  .fdy-fab--accent{background:linear-gradient(180deg,var(--color-accent),color-mix(in srgb,#000 10%,var(--color-accent)));color:var(--color-on-accent);}
674
702
 
675
703
  /* Freeday — Card */
676
- .fdy-card{background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-lift);transition:box-shadow var(--dur-base) var(--ease-standard),transform var(--dur-base) var(--ease-standard);}
704
+ /* position:relative containing block for out-of-flow card content (see base.css); it also gives
705
+ consumers the anchor a corner ribbon/badge needs. */
706
+ .fdy-card{position:relative;background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-lift);transition:box-shadow var(--dur-base) var(--ease-standard),transform var(--dur-base) var(--ease-standard);}
677
707
  .fdy-card--elevated{box-shadow:var(--shadow-lift-hover);}
678
708
  .fdy-card--interactive{cursor:pointer;}
679
709
  .fdy-card--interactive:hover{box-shadow:var(--shadow-lift-hover);transform:translateY(-3px);}
@@ -684,6 +714,10 @@ a { color: var(--color-primary); }
684
714
  * announced — reset the UA button box. Note: it does NOT reset background/border, because
685
715
  * .fdy-card already sets its own surface + border (resetting them here would strip the card). */
686
716
  .fdy-card--button{display:block;width:100%;text-align:inherit;color:inherit;appearance:none;-webkit-appearance:none;}
717
+ /* Disabled card-as-control — same contract as .fdy-list__row--button: dim + not-allowed, and the
718
+ --interactive lift is withdrawn so a refusing control stops answering the pointer. */
719
+ .fdy-card--button:disabled,.fdy-card--button[aria-disabled="true"],.fdy-card--interactive[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
720
+ .fdy-card--interactive:disabled:hover,.fdy-card--interactive[aria-disabled="true"]:hover{box-shadow:var(--shadow-lift);transform:none;}
687
721
  .fdy-card__body{padding:var(--space-5);}
688
722
  .fdy-card__title{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);color:var(--color-text);margin:0 0 var(--space-1);}
689
723
  .fdy-card__desc{font-size:var(--text-sm);color:var(--color-text-muted);margin:0;line-height:var(--leading-normal);}
@@ -691,7 +725,10 @@ a { color: var(--color-primary); }
691
725
 
692
726
  /* Freeday — Carousel (scroll-snap track + arrows + dots). Enhanced by freeday-carousel.js. */
693
727
  .fdy-carousel{position:relative;}
694
- .fdy-carousel__viewport{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;border-radius:var(--radius-lg);scrollbar-width:none;-ms-overflow-style:none;}
728
+ /* position:relative — the arrows are positioned against .fdy-carousel (the parent), so this only
729
+ catches out-of-flow content inside the SLIDES, which would otherwise escape the viewport
730
+ entirely and scroll the page by one slide-offset per slide (see base.css). */
731
+ .fdy-carousel__viewport{position:relative;display:flex;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;border-radius:var(--radius-lg);scrollbar-width:none;-ms-overflow-style:none;}
695
732
  .fdy-carousel__viewport::-webkit-scrollbar{display:none;}
696
733
  .fdy-carousel__slide{flex:0 0 100%;min-width:0;scroll-snap-align:center;}
697
734
  .fdy-carousel__arrow{position:absolute;top:50%;transform:translateY(-50%);z-index:2;display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border:0;border-radius:var(--radius-full);background:color-mix(in srgb,var(--color-surface) 86%,transparent);color:var(--color-text);box-shadow:var(--shadow-2);cursor:pointer;transition:background var(--dur-fast) var(--ease-standard);}
@@ -1198,7 +1235,8 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1198
1235
  *
1199
1236
  * Not to be confused with .fdy-list-reset (base.css), which only strips UA bullets/indent.
1200
1237
  * Works on <ul>/<ol> (list-style is reset here) or on plain <div>s. */
1201
- .fdy-list{list-style:none;margin:0;padding:0;background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;}
1238
+ /* position:relative containing block for out-of-flow row content (see base.css). */
1239
+ .fdy-list{position:relative;list-style:none;margin:0;padding:0;background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;}
1202
1240
  .fdy-list__row{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4) var(--space-5);min-width:0;}
1203
1241
  /* Divider between rows. Two shapes are supported and both are load-bearing: rows as direct children
1204
1242
  * of the list, and rows wrapped in <li> (the semantic shape, where the ADJACENT siblings are the
@@ -1211,6 +1249,12 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1211
1249
  .fdy-list__row--button:hover,.fdy-list__row--interactive:hover{background:var(--color-surface-2);}
1212
1250
  .fdy-list__row--button:focus-visible{outline:none;box-shadow:inset 0 0 0 2px var(--color-primary);}
1213
1251
  .fdy-list__row--interactive{cursor:pointer;}
1252
+ /* Disabled row. The reset above adopts the UA button box, but not its disabled state — so a row
1253
+ disabled mid-flight (a redirect in progress, say) kept lighting up and kept a pointer cursor:
1254
+ a control answering the pointer while refusing input. Same contract as every other disabled
1255
+ control in the kit (dim + not-allowed), hover withdrawn, both the native and the aria- form. */
1256
+ .fdy-list__row--button:disabled,.fdy-list__row--button[aria-disabled="true"],.fdy-list__row--interactive[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
1257
+ .fdy-list__row--button:disabled:hover,.fdy-list__row--button[aria-disabled="true"]:hover,.fdy-list__row--interactive[aria-disabled="true"]:hover{background:none;}
1214
1258
  /* Row internals: a title/meta stack that truncates, and a trailing slot pinned right. */
1215
1259
  .fdy-list__main{display:flex;flex-direction:column;gap:var(--space-1);min-width:0;flex:1;}
1216
1260
  .fdy-list__title{font-weight:var(--weight-medium);color:var(--color-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@@ -1395,7 +1439,10 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1395
1439
  .fdy-step-nav{display:flex;justify-content:space-between;gap:var(--space-2);margin-top:var(--space-5);}
1396
1440
 
1397
1441
  /* Freeday — Table */
1398
- .fdy-table-wrap{overflow-x:auto;border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);background:var(--color-surface);}
1442
+ /* position:relative on every scroller/clipper — it makes the box the containing block for its own
1443
+ absolutely positioned content (.fdy-visually-hidden above all), which `overflow` alone does NOT
1444
+ clip. Without it a hidden label in a wide table scrolls the whole page. See base.css. */
1445
+ .fdy-table-wrap{position:relative;overflow-x:auto;border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);background:var(--color-surface);}
1399
1446
  .fdy-table{width:100%;border-collapse:collapse;font-size:var(--text-sm);}
1400
1447
  .fdy-table caption{text-align:left;padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--color-text);}
1401
1448
  .fdy-table th{text-align:left;padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:var(--weight-semibold);text-transform:uppercase;letter-spacing:var(--tracking-wide);color:var(--color-text-muted);background:var(--color-surface-2);border-bottom:var(--bw) solid var(--color-border);white-space:nowrap;}
@@ -1432,7 +1479,7 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1432
1479
 
1433
1480
  /* Data-table shell (toolbar + scroll + footer stay put while the table scrolls) */
1434
1481
  .fdy-datatable{border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);background:var(--color-surface);overflow:hidden;}
1435
- .fdy-table-scroll{overflow-x:auto;}
1482
+ .fdy-table-scroll{position:relative;overflow-x:auto;}
1436
1483
  .fdy-table-toolbar{display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap;padding:var(--space-3) var(--space-4);border-bottom:var(--bw) solid var(--color-border);}
1437
1484
  .fdy-table-toolbar__search{max-width:18rem;}
1438
1485
  .fdy-table-toolbar__spacer{flex:1;}
@@ -1465,7 +1512,8 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1465
1512
  .fdy-table-bulkbar__actions{display:flex;gap:var(--space-2);}
1466
1513
 
1467
1514
  /* Freeday — Tabs (WAI-ARIA APG) */
1468
- .fdy-tabs__list{display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
1515
+ /* position:relative — containing block for out-of-flow content in the tabs (see base.css). */
1516
+ .fdy-tabs__list{position:relative;display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
1469
1517
  .fdy-tabs__tab{appearance:none;border:0;background:transparent;cursor:pointer;white-space:nowrap;font-family:var(--font-body);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);padding:var(--space-3) var(--space-4);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
1470
1518
  .fdy-tabs__tab:hover{color:var(--color-text);}
1471
1519
  .fdy-tabs__tab:disabled,.fdy-tabs__tab[aria-disabled="true"]{opacity:.5;cursor:not-allowed;color:var(--color-text-muted);}
package/dist/freeday.css CHANGED
@@ -15,6 +15,21 @@ h1, h2, h3, h4 { font-family: var(--font-display); font-weight: var(--weight-bol
15
15
  a { color: var(--color-primary); }
16
16
  :focus-visible { outline: var(--focus-ring-width) solid var(--focus-ring); outline-offset: var(--focus-ring-width); border-radius: var(--radius-xs); }
17
17
  :where(button, input, select, textarea) { font: inherit; }
18
+ /* Hidden from sight, kept for assistive tech. `clip` hides PAINTING, not LAYOUT: the box still
19
+ * has a position, and because it is position:absolute its containing block is the nearest
20
+ * POSITIONED ancestor — with none, the initial containing block (the document itself).
21
+ *
22
+ * That matters, because `overflow` only clips a descendant whose containing block is inside the
23
+ * overflow box. So a hidden span in a horizontally scrolling table (the kit's own recommended way
24
+ * to name an icon button) parks at its static position — possibly thousands of px to the right —
25
+ * and drags the whole DOCUMENT sideways: invisible, and immune to `overflow-x:hidden` on every
26
+ * wrapper. Measured before the fix: 1351px of phantom page scroll from 11 spans.
27
+ *
28
+ * The fix is not here — it is on the containers: every clipping/scrolling container in the kit
29
+ * that holds consumer markup declares `position:relative` so it becomes the containing block for
30
+ * its own out-of-flow content (see test/css.test.mjs, which will not let a new one skip it).
31
+ * Note the measurement trap: documentElement.scrollWidth sees it, body.scrollWidth does not —
32
+ * the honest check is `window.scrollTo(9999, 0)` then reading `window.scrollX`. */
18
33
  .fdy-visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
19
34
  /* Opt-in list reset. base.css is intentionally a *light* reset — it does NOT strip list/paragraph
20
35
  * margins (see the kit's own list components, which each reset themselves). If you run a utility
@@ -26,7 +41,11 @@ a { color: var(--color-primary); }
26
41
  }
27
42
 
28
43
  /* Freeday — Accordion (native <details>/<summary>, zero-JS) */
29
- .fdy-accordion{border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;background:var(--color-surface);}
44
+ /* position:relative containing block for out-of-flow panel content (see base.css). NOT redundant
45
+ with the panel's reveal animation: that animation gives the panel a transform (which happens to
46
+ make it a containing block too), but it lives behind prefers-reduced-motion:no-preference — so
47
+ without this line the escape bug appears ONLY for readers who asked for reduced motion. */
48
+ .fdy-accordion{position:relative;border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;background:var(--color-surface);}
30
49
  .fdy-accordion__item + .fdy-accordion__item{border-top:var(--bw) solid var(--color-border);}
31
50
  .fdy-accordion__item > summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) var(--space-4);font-weight:var(--weight-medium);color:var(--color-text);}
32
51
  .fdy-accordion__item > summary::-webkit-details-marker{display:none;}
@@ -312,7 +331,9 @@ a { color: var(--color-primary); }
312
331
  .fdy-fab--accent{background:linear-gradient(180deg,var(--color-accent),color-mix(in srgb,#000 10%,var(--color-accent)));color:var(--color-on-accent);}
313
332
 
314
333
  /* Freeday — Card */
315
- .fdy-card{background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-lift);transition:box-shadow var(--dur-base) var(--ease-standard),transform var(--dur-base) var(--ease-standard);}
334
+ /* position:relative containing block for out-of-flow card content (see base.css); it also gives
335
+ consumers the anchor a corner ribbon/badge needs. */
336
+ .fdy-card{position:relative;background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-lift);transition:box-shadow var(--dur-base) var(--ease-standard),transform var(--dur-base) var(--ease-standard);}
316
337
  .fdy-card--elevated{box-shadow:var(--shadow-lift-hover);}
317
338
  .fdy-card--interactive{cursor:pointer;}
318
339
  .fdy-card--interactive:hover{box-shadow:var(--shadow-lift-hover);transform:translateY(-3px);}
@@ -323,6 +344,10 @@ a { color: var(--color-primary); }
323
344
  * announced — reset the UA button box. Note: it does NOT reset background/border, because
324
345
  * .fdy-card already sets its own surface + border (resetting them here would strip the card). */
325
346
  .fdy-card--button{display:block;width:100%;text-align:inherit;color:inherit;appearance:none;-webkit-appearance:none;}
347
+ /* Disabled card-as-control — same contract as .fdy-list__row--button: dim + not-allowed, and the
348
+ --interactive lift is withdrawn so a refusing control stops answering the pointer. */
349
+ .fdy-card--button:disabled,.fdy-card--button[aria-disabled="true"],.fdy-card--interactive[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
350
+ .fdy-card--interactive:disabled:hover,.fdy-card--interactive[aria-disabled="true"]:hover{box-shadow:var(--shadow-lift);transform:none;}
326
351
  .fdy-card__body{padding:var(--space-5);}
327
352
  .fdy-card__title{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);color:var(--color-text);margin:0 0 var(--space-1);}
328
353
  .fdy-card__desc{font-size:var(--text-sm);color:var(--color-text-muted);margin:0;line-height:var(--leading-normal);}
@@ -330,7 +355,10 @@ a { color: var(--color-primary); }
330
355
 
331
356
  /* Freeday — Carousel (scroll-snap track + arrows + dots). Enhanced by freeday-carousel.js. */
332
357
  .fdy-carousel{position:relative;}
333
- .fdy-carousel__viewport{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;border-radius:var(--radius-lg);scrollbar-width:none;-ms-overflow-style:none;}
358
+ /* position:relative — the arrows are positioned against .fdy-carousel (the parent), so this only
359
+ catches out-of-flow content inside the SLIDES, which would otherwise escape the viewport
360
+ entirely and scroll the page by one slide-offset per slide (see base.css). */
361
+ .fdy-carousel__viewport{position:relative;display:flex;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;border-radius:var(--radius-lg);scrollbar-width:none;-ms-overflow-style:none;}
334
362
  .fdy-carousel__viewport::-webkit-scrollbar{display:none;}
335
363
  .fdy-carousel__slide{flex:0 0 100%;min-width:0;scroll-snap-align:center;}
336
364
  .fdy-carousel__arrow{position:absolute;top:50%;transform:translateY(-50%);z-index:2;display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border:0;border-radius:var(--radius-full);background:color-mix(in srgb,var(--color-surface) 86%,transparent);color:var(--color-text);box-shadow:var(--shadow-2);cursor:pointer;transition:background var(--dur-fast) var(--ease-standard);}
@@ -837,7 +865,8 @@ fieldset.fdy-field>legend{padding:0;float:none;}
837
865
  *
838
866
  * Not to be confused with .fdy-list-reset (base.css), which only strips UA bullets/indent.
839
867
  * Works on <ul>/<ol> (list-style is reset here) or on plain <div>s. */
840
- .fdy-list{list-style:none;margin:0;padding:0;background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;}
868
+ /* position:relative containing block for out-of-flow row content (see base.css). */
869
+ .fdy-list{position:relative;list-style:none;margin:0;padding:0;background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;}
841
870
  .fdy-list__row{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4) var(--space-5);min-width:0;}
842
871
  /* Divider between rows. Two shapes are supported and both are load-bearing: rows as direct children
843
872
  * of the list, and rows wrapped in <li> (the semantic shape, where the ADJACENT siblings are the
@@ -850,6 +879,12 @@ fieldset.fdy-field>legend{padding:0;float:none;}
850
879
  .fdy-list__row--button:hover,.fdy-list__row--interactive:hover{background:var(--color-surface-2);}
851
880
  .fdy-list__row--button:focus-visible{outline:none;box-shadow:inset 0 0 0 2px var(--color-primary);}
852
881
  .fdy-list__row--interactive{cursor:pointer;}
882
+ /* Disabled row. The reset above adopts the UA button box, but not its disabled state — so a row
883
+ disabled mid-flight (a redirect in progress, say) kept lighting up and kept a pointer cursor:
884
+ a control answering the pointer while refusing input. Same contract as every other disabled
885
+ control in the kit (dim + not-allowed), hover withdrawn, both the native and the aria- form. */
886
+ .fdy-list__row--button:disabled,.fdy-list__row--button[aria-disabled="true"],.fdy-list__row--interactive[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
887
+ .fdy-list__row--button:disabled:hover,.fdy-list__row--button[aria-disabled="true"]:hover,.fdy-list__row--interactive[aria-disabled="true"]:hover{background:none;}
853
888
  /* Row internals: a title/meta stack that truncates, and a trailing slot pinned right. */
854
889
  .fdy-list__main{display:flex;flex-direction:column;gap:var(--space-1);min-width:0;flex:1;}
855
890
  .fdy-list__title{font-weight:var(--weight-medium);color:var(--color-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@@ -1034,7 +1069,10 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1034
1069
  .fdy-step-nav{display:flex;justify-content:space-between;gap:var(--space-2);margin-top:var(--space-5);}
1035
1070
 
1036
1071
  /* Freeday — Table */
1037
- .fdy-table-wrap{overflow-x:auto;border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);background:var(--color-surface);}
1072
+ /* position:relative on every scroller/clipper — it makes the box the containing block for its own
1073
+ absolutely positioned content (.fdy-visually-hidden above all), which `overflow` alone does NOT
1074
+ clip. Without it a hidden label in a wide table scrolls the whole page. See base.css. */
1075
+ .fdy-table-wrap{position:relative;overflow-x:auto;border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);background:var(--color-surface);}
1038
1076
  .fdy-table{width:100%;border-collapse:collapse;font-size:var(--text-sm);}
1039
1077
  .fdy-table caption{text-align:left;padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--color-text);}
1040
1078
  .fdy-table th{text-align:left;padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:var(--weight-semibold);text-transform:uppercase;letter-spacing:var(--tracking-wide);color:var(--color-text-muted);background:var(--color-surface-2);border-bottom:var(--bw) solid var(--color-border);white-space:nowrap;}
@@ -1071,7 +1109,7 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1071
1109
 
1072
1110
  /* Data-table shell (toolbar + scroll + footer stay put while the table scrolls) */
1073
1111
  .fdy-datatable{border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);background:var(--color-surface);overflow:hidden;}
1074
- .fdy-table-scroll{overflow-x:auto;}
1112
+ .fdy-table-scroll{position:relative;overflow-x:auto;}
1075
1113
  .fdy-table-toolbar{display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap;padding:var(--space-3) var(--space-4);border-bottom:var(--bw) solid var(--color-border);}
1076
1114
  .fdy-table-toolbar__search{max-width:18rem;}
1077
1115
  .fdy-table-toolbar__spacer{flex:1;}
@@ -1104,7 +1142,8 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1104
1142
  .fdy-table-bulkbar__actions{display:flex;gap:var(--space-2);}
1105
1143
 
1106
1144
  /* Freeday — Tabs (WAI-ARIA APG) */
1107
- .fdy-tabs__list{display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
1145
+ /* position:relative — containing block for out-of-flow content in the tabs (see base.css). */
1146
+ .fdy-tabs__list{position:relative;display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
1108
1147
  .fdy-tabs__tab{appearance:none;border:0;background:transparent;cursor:pointer;white-space:nowrap;font-family:var(--font-body);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);padding:var(--space-3) var(--space-4);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
1109
1148
  .fdy-tabs__tab:hover{color:var(--color-text);}
1110
1149
  .fdy-tabs__tab:disabled,.fdy-tabs__tab[aria-disabled="true"]{opacity:.5;cursor:not-allowed;color:var(--color-text-muted);}
@@ -176,6 +176,9 @@
176
176
  --focus-ring: var(--azure-600);
177
177
  --focus-ring-width: 2px;
178
178
  }
179
+ /* The SYSTEM default stays root-scoped: it is a statement about the document, and dropping :root
180
+ * here would match every element that is not itself [data-theme="light"] — which would re-darken
181
+ * the children of a light panel, since they carry no attribute of their own. */
179
182
  @media (prefers-color-scheme: dark) {
180
183
  :root:not([data-theme="light"]) {
181
184
  --shadow-1: 0 1px 2px rgba(0,0,0,.4);
@@ -234,7 +237,13 @@
234
237
  --focus-ring: var(--azure-400);
235
238
  }
236
239
  }
237
- :root[data-theme="dark"] {
240
+ /* The two EXPLICIT opt-ins are deliberately NOT scoped to :root, for the same reason as density
241
+ * below: these are inheriting custom properties, so data-theme on any ancestor re-themes just that
242
+ * subtree — a dark brand panel beside a light form is an ordinary layout, and it should not require
243
+ * re-colouring each component by hand. The root still matches, so data-theme on <html> is unchanged.
244
+ * Both keep the same specificity (0,1,0) as :root and come after it, so they still win there; and
245
+ * --light after --dark means a light island inside a dark region wins in turn. */
246
+ [data-theme="dark"] {
238
247
  --shadow-1: 0 1px 2px rgba(0,0,0,.4);
239
248
  --shadow-2: 0 2px 6px rgba(0,0,0,.45);
240
249
  --shadow-3: 0 6px 20px -2px rgba(0,0,0,.6),0 1px 3px rgba(0,0,0,.5);
@@ -290,7 +299,7 @@
290
299
  --chart-tick: var(--slate-600);
291
300
  --focus-ring: var(--azure-400);
292
301
  }
293
- :root[data-theme="light"] {
302
+ [data-theme="light"] {
294
303
  --shadow-1: 0 1px 2px rgba(16,14,30,.06);
295
304
  --shadow-2: 0 2px 6px rgba(16,14,30,.07),0 1px 2px rgba(16,14,30,.05);
296
305
  --shadow-3: 0 4px 16px -2px rgba(16,14,30,.14),0 1px 3px rgba(16,14,30,.10);
@@ -82,8 +82,10 @@ The order matters; skipping to components is what produces flat, identical-card
82
82
  you build the frame instead of inventing a class. Not in the npm package — read it on GitHub.
83
83
  1. **Shell** — is `.fdy-app` already in place (usually once, in the app layout)? If not, copy it
84
84
  from `docs/getting-started.md` §The app shell.
85
- 2. **Theme** — `data-theme="light|dark"` + `data-density="comfortable|compact"` on `<html>`, set
86
- once at the root. Use `compact` for table-heavy back-office screens.
85
+ 2. **Theme** — `data-theme="light|dark"` + `data-density="comfortable|compact"`, normally on
86
+ `<html>`, set once at the root. Use `compact` for table-heavy back-office screens. Both attributes
87
+ also work on **any ancestor**: `<section data-theme="dark">` inverts that region and every
88
+ component inside it follows, so never hand-colour an inverted panel.
87
89
  3. **Fonts** — the package ships **no** `@font-face`. Load Sora / IBM Plex Sans / JetBrains Mono
88
90
  yourself, or override `--font-display`/`--font-body`/`--font-mono`. Skipping this reads as
89
91
  "unfinished design", not "missing dependency".
@@ -24,9 +24,11 @@ Freeday = **CSS** (semantic tokens + `fdy-*` classes) + **zero-dependency JS enh
24
24
  3. **Hydrate dynamic DOM.** Enhancers auto-init once on `DOMContentLoaded`. DOM an SPA renders
25
25
  **after** that must be re-hydrated: `window.Freeday<X>.initAll(el)` (idempotent, safe to repeat).
26
26
  Each framework's adapter wraps this — you don't call it manually.
27
- 4. **Theme via `data-*` on `<html>`.** `data-theme="light|dark"` (all semantic tokens switch) +
28
- `data-density="comfortable|compact"` (control height, for data-dense screens). Change at runtime:
29
- `document.documentElement.dataset.theme = 'dark'`.
27
+ 4. **Theme via `data-*`.** `data-theme="light|dark"` (all semantic tokens switch) +
28
+ `data-density="comfortable|compact"` (control height, for data-dense screens). Normally on
29
+ `<html>`; change at runtime with `document.documentElement.dataset.theme = 'dark'`. Both also
30
+ work on **any ancestor** — these are inheriting custom properties, so `<section data-theme="dark">`
31
+ inverts just that region and every component inside it follows. See [`USAGE.md`](../USAGE.md) §5b.
30
32
  5. **3-tier token rule.** Components only touch **Tier 2/3** (`var(--color-primary)`,
31
33
  `var(--space-4)`, `var(--radius-md)`…). **Never** write raw hex/px.
32
34
  6. **Scope: components + tokens, deliberately *not* layout.** Freeday ships components and tokens;
@@ -177,7 +179,7 @@ live docs also have a copy button per component.
177
179
  ```bash
178
180
  npm i @cahyo-dimas/freeday
179
181
  ```
180
- Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.20.0"` (public npm package). `dist/` is
182
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.21.0"` (public npm package). `dist/` is
181
183
  committed and published → no build step; `npm ci` runs without auth.
182
184
 
183
185
  ### 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.20.0",
3
+ "version": "1.21.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",
74
+ "test:browser": "node --test browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.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"
package/src/base.css CHANGED
@@ -14,6 +14,21 @@ h1, h2, h3, h4 { font-family: var(--font-display); font-weight: var(--weight-bol
14
14
  a { color: var(--color-primary); }
15
15
  :focus-visible { outline: var(--focus-ring-width) solid var(--focus-ring); outline-offset: var(--focus-ring-width); border-radius: var(--radius-xs); }
16
16
  :where(button, input, select, textarea) { font: inherit; }
17
+ /* Hidden from sight, kept for assistive tech. `clip` hides PAINTING, not LAYOUT: the box still
18
+ * has a position, and because it is position:absolute its containing block is the nearest
19
+ * POSITIONED ancestor — with none, the initial containing block (the document itself).
20
+ *
21
+ * That matters, because `overflow` only clips a descendant whose containing block is inside the
22
+ * overflow box. So a hidden span in a horizontally scrolling table (the kit's own recommended way
23
+ * to name an icon button) parks at its static position — possibly thousands of px to the right —
24
+ * and drags the whole DOCUMENT sideways: invisible, and immune to `overflow-x:hidden` on every
25
+ * wrapper. Measured before the fix: 1351px of phantom page scroll from 11 spans.
26
+ *
27
+ * The fix is not here — it is on the containers: every clipping/scrolling container in the kit
28
+ * that holds consumer markup declares `position:relative` so it becomes the containing block for
29
+ * its own out-of-flow content (see test/css.test.mjs, which will not let a new one skip it).
30
+ * Note the measurement trap: documentElement.scrollWidth sees it, body.scrollWidth does not —
31
+ * the honest check is `window.scrollTo(9999, 0)` then reading `window.scrollX`. */
17
32
  .fdy-visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
18
33
  /* Opt-in list reset. base.css is intentionally a *light* reset — it does NOT strip list/paragraph
19
34
  * margins (see the kit's own list components, which each reset themselves). If you run a utility
@@ -1,5 +1,9 @@
1
1
  /* Freeday — Accordion (native <details>/<summary>, zero-JS) */
2
- .fdy-accordion{border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;background:var(--color-surface);}
2
+ /* position:relative containing block for out-of-flow panel content (see base.css). NOT redundant
3
+ with the panel's reveal animation: that animation gives the panel a transform (which happens to
4
+ make it a containing block too), but it lives behind prefers-reduced-motion:no-preference — so
5
+ without this line the escape bug appears ONLY for readers who asked for reduced motion. */
6
+ .fdy-accordion{position:relative;border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;background:var(--color-surface);}
3
7
  .fdy-accordion__item + .fdy-accordion__item{border-top:var(--bw) solid var(--color-border);}
4
8
  .fdy-accordion__item > summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) var(--space-4);font-weight:var(--weight-medium);color:var(--color-text);}
5
9
  .fdy-accordion__item > summary::-webkit-details-marker{display:none;}
@@ -1,5 +1,7 @@
1
1
  /* Freeday — Card */
2
- .fdy-card{background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-lift);transition:box-shadow var(--dur-base) var(--ease-standard),transform var(--dur-base) var(--ease-standard);}
2
+ /* position:relative containing block for out-of-flow card content (see base.css); it also gives
3
+ consumers the anchor a corner ribbon/badge needs. */
4
+ .fdy-card{position:relative;background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-lift);transition:box-shadow var(--dur-base) var(--ease-standard),transform var(--dur-base) var(--ease-standard);}
3
5
  .fdy-card--elevated{box-shadow:var(--shadow-lift-hover);}
4
6
  .fdy-card--interactive{cursor:pointer;}
5
7
  .fdy-card--interactive:hover{box-shadow:var(--shadow-lift-hover);transform:translateY(-3px);}
@@ -10,6 +12,10 @@
10
12
  * announced — reset the UA button box. Note: it does NOT reset background/border, because
11
13
  * .fdy-card already sets its own surface + border (resetting them here would strip the card). */
12
14
  .fdy-card--button{display:block;width:100%;text-align:inherit;color:inherit;appearance:none;-webkit-appearance:none;}
15
+ /* Disabled card-as-control — same contract as .fdy-list__row--button: dim + not-allowed, and the
16
+ --interactive lift is withdrawn so a refusing control stops answering the pointer. */
17
+ .fdy-card--button:disabled,.fdy-card--button[aria-disabled="true"],.fdy-card--interactive[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
18
+ .fdy-card--interactive:disabled:hover,.fdy-card--interactive[aria-disabled="true"]:hover{box-shadow:var(--shadow-lift);transform:none;}
13
19
  .fdy-card__body{padding:var(--space-5);}
14
20
  .fdy-card__title{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);color:var(--color-text);margin:0 0 var(--space-1);}
15
21
  .fdy-card__desc{font-size:var(--text-sm);color:var(--color-text-muted);margin:0;line-height:var(--leading-normal);}
@@ -1,6 +1,9 @@
1
1
  /* Freeday — Carousel (scroll-snap track + arrows + dots). Enhanced by freeday-carousel.js. */
2
2
  .fdy-carousel{position:relative;}
3
- .fdy-carousel__viewport{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;border-radius:var(--radius-lg);scrollbar-width:none;-ms-overflow-style:none;}
3
+ /* position:relative — the arrows are positioned against .fdy-carousel (the parent), so this only
4
+ catches out-of-flow content inside the SLIDES, which would otherwise escape the viewport
5
+ entirely and scroll the page by one slide-offset per slide (see base.css). */
6
+ .fdy-carousel__viewport{position:relative;display:flex;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;border-radius:var(--radius-lg);scrollbar-width:none;-ms-overflow-style:none;}
4
7
  .fdy-carousel__viewport::-webkit-scrollbar{display:none;}
5
8
  .fdy-carousel__slide{flex:0 0 100%;min-width:0;scroll-snap-align:center;}
6
9
  .fdy-carousel__arrow{position:absolute;top:50%;transform:translateY(-50%);z-index:2;display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border:0;border-radius:var(--radius-full);background:color-mix(in srgb,var(--color-surface) 86%,transparent);color:var(--color-text);box-shadow:var(--shadow-2);cursor:pointer;transition:background var(--dur-fast) var(--ease-standard);}
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * Not to be confused with .fdy-list-reset (base.css), which only strips UA bullets/indent.
10
10
  * Works on <ul>/<ol> (list-style is reset here) or on plain <div>s. */
11
- .fdy-list{list-style:none;margin:0;padding:0;background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;}
11
+ /* position:relative containing block for out-of-flow row content (see base.css). */
12
+ .fdy-list{position:relative;list-style:none;margin:0;padding:0;background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;}
12
13
  .fdy-list__row{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4) var(--space-5);min-width:0;}
13
14
  /* Divider between rows. Two shapes are supported and both are load-bearing: rows as direct children
14
15
  * of the list, and rows wrapped in <li> (the semantic shape, where the ADJACENT siblings are the
@@ -21,6 +22,12 @@
21
22
  .fdy-list__row--button:hover,.fdy-list__row--interactive:hover{background:var(--color-surface-2);}
22
23
  .fdy-list__row--button:focus-visible{outline:none;box-shadow:inset 0 0 0 2px var(--color-primary);}
23
24
  .fdy-list__row--interactive{cursor:pointer;}
25
+ /* Disabled row. The reset above adopts the UA button box, but not its disabled state — so a row
26
+ disabled mid-flight (a redirect in progress, say) kept lighting up and kept a pointer cursor:
27
+ a control answering the pointer while refusing input. Same contract as every other disabled
28
+ control in the kit (dim + not-allowed), hover withdrawn, both the native and the aria- form. */
29
+ .fdy-list__row--button:disabled,.fdy-list__row--button[aria-disabled="true"],.fdy-list__row--interactive[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
30
+ .fdy-list__row--button:disabled:hover,.fdy-list__row--button[aria-disabled="true"]:hover,.fdy-list__row--interactive[aria-disabled="true"]:hover{background:none;}
24
31
  /* Row internals: a title/meta stack that truncates, and a trailing slot pinned right. */
25
32
  .fdy-list__main{display:flex;flex-direction:column;gap:var(--space-1);min-width:0;flex:1;}
26
33
  .fdy-list__title{font-weight:var(--weight-medium);color:var(--color-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@@ -1,5 +1,8 @@
1
1
  /* Freeday — Table */
2
- .fdy-table-wrap{overflow-x:auto;border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);background:var(--color-surface);}
2
+ /* position:relative on every scroller/clipper — it makes the box the containing block for its own
3
+ absolutely positioned content (.fdy-visually-hidden above all), which `overflow` alone does NOT
4
+ clip. Without it a hidden label in a wide table scrolls the whole page. See base.css. */
5
+ .fdy-table-wrap{position:relative;overflow-x:auto;border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);background:var(--color-surface);}
3
6
  .fdy-table{width:100%;border-collapse:collapse;font-size:var(--text-sm);}
4
7
  .fdy-table caption{text-align:left;padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--color-text);}
5
8
  .fdy-table th{text-align:left;padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:var(--weight-semibold);text-transform:uppercase;letter-spacing:var(--tracking-wide);color:var(--color-text-muted);background:var(--color-surface-2);border-bottom:var(--bw) solid var(--color-border);white-space:nowrap;}
@@ -36,7 +39,7 @@
36
39
 
37
40
  /* Data-table shell (toolbar + scroll + footer stay put while the table scrolls) */
38
41
  .fdy-datatable{border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);background:var(--color-surface);overflow:hidden;}
39
- .fdy-table-scroll{overflow-x:auto;}
42
+ .fdy-table-scroll{position:relative;overflow-x:auto;}
40
43
  .fdy-table-toolbar{display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap;padding:var(--space-3) var(--space-4);border-bottom:var(--bw) solid var(--color-border);}
41
44
  .fdy-table-toolbar__search{max-width:18rem;}
42
45
  .fdy-table-toolbar__spacer{flex:1;}
@@ -1,5 +1,6 @@
1
1
  /* Freeday — Tabs (WAI-ARIA APG) */
2
- .fdy-tabs__list{display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
2
+ /* position:relative — containing block for out-of-flow content in the tabs (see base.css). */
3
+ .fdy-tabs__list{position:relative;display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
3
4
  .fdy-tabs__tab{appearance:none;border:0;background:transparent;cursor:pointer;white-space:nowrap;font-family:var(--font-body);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text-muted);padding:var(--space-3) var(--space-4);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
4
5
  .fdy-tabs__tab:hover{color:var(--color-text);}
5
6
  .fdy-tabs__tab:disabled,.fdy-tabs__tab[aria-disabled="true"]{opacity:.5;cursor:not-allowed;color:var(--color-text-muted);}