@cahyo-dimas/freeday 1.27.0 → 1.28.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,51 @@
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.28.0] — 2026-08-18
7
+ Improvement note 007, written while fixing a workspace picker whose cards showed a pointer cursor
8
+ and swallowed every click.
9
+ ### Added
10
+ - **`.fdy-btn--stretch`** — the pattern for **one card, one primary action, one escape hatch**, which
11
+ the kit had no shape for: `--button` is a card that *is* one control, `--interactive` a card that
12
+ merely *has* one, and a card with two actions can be neither (interactive content nested in a
13
+ `<button>` is invalid HTML). The primary control keeps its real `<button>`/`<a>` semantics and
14
+ spreads its hit area over the card with a pseudo-element.
15
+ This is shipped rather than documented because it **cannot be hand-rolled on a `.fdy-btn`**: the
16
+ button nudges itself with a transform on `:hover`/`:active`, a transformed element becomes the
17
+ containing block for its own absolutely positioned descendants, and the overlay therefore
18
+ re-anchors from the card to the button's own box mid-gesture. `mousedown` lands on the button,
19
+ `mouseup` somewhere else, and the browser fires `click` on their common ancestor — the button never
20
+ gets one. The symptom is identical to the bug being fixed.
21
+ The report found this on `--text`/`--ghost`, which break on press. The kit's **base** rule is
22
+ `.fdy-btn:hover{transform:translateY(-1px)}`, so the default and `--danger` fills break one step
23
+ earlier, on hover: neutralising only `:active` — the obvious half-fix — still leaks.
24
+ - **Escape hatches are raised automatically.** Every focusable element in a card holding a stretched
25
+ target sits above the overlay without markup or CSS from the consumer, because forgetting a
26
+ `z-index` here fails silently — the control looks and hovers exactly as before and simply never
27
+ receives the click.
28
+ ### Fixed
29
+ - `--interactive` is now documented as **presentational**: the only affordance in the kit whose
30
+ correctness lives outside it. The CSS cannot know whether a handler exists, so the modifier without
31
+ a control and a control without the modifier both fail silently. It now points at the shape that
32
+ makes the promise true.
33
+ ### Notes on the shape of the fix
34
+ - **Raising the escape hatch must not outweigh the app.** The first version declared
35
+ `position:relative` at normal specificity and *measurably* dragged an absolutely positioned corner
36
+ dismiss button back into the flow — a control the app had pinned itself, moved by the kit, with no
37
+ error. `z-index` still wins, but the `position` it needs is now a zero-specificity `:where()`
38
+ default that any app rule beats. The two rules must stay split; the gate asserts it.
39
+ - **Cards only.** The overlay anchors to the nearest *positioned* ancestor. `.fdy-list__row` is not
40
+ positioned (`.fdy-list` is), so a stretched target in a list row would cover the whole list —
41
+ documented, with `.fdy-list__row--button` as the answer for clickable rows.
42
+ ### Added — guards
43
+ - `browser/card-stretch.mjs` drives real presses: the target receives clicks from anywhere on the
44
+ card, the click is dispatched **on the button** rather than on a common ancestor, the secondary
45
+ action and an inline link keep their own clicks, and an app-pinned control keeps its own
46
+ `position`. A synthetic `.click()` never enters `:active` and passes against the broken CSS, which
47
+ is why this spec is worth its weight.
48
+ - `test/css.test.mjs` guards all of it at the CI gate. Mutation-checked against six defects,
49
+ including both of the ones written during this change.
50
+
6
51
  ## [1.27.0] — 2026-08-14
7
52
  Improvement note #44, found while building a settings screen whose only numeric field looked like it
8
53
  belonged to a different application.
package/COMPONENTS.md CHANGED
@@ -885,7 +885,11 @@ is `role="group" aria-roledescription="slide"`.
885
885
  Parts `__body` `__title` `__desc` `__footer`. Modifiers:
886
886
 
887
887
  - `--elevated` — raise it (use sparingly; most surfaces are flat)
888
- - `--interactive` — cursor + hover-lift for a card that *has* a click handler
888
+ - `--interactive` — cursor + hover-lift for a card that *has* a click handler. **Presentational
889
+ only:** it is the one affordance in the kit whose correctness lives outside it — the CSS cannot
890
+ know whether a handler exists, so the modifier without a control (a card that looks clickable and
891
+ swallows every click) and a control without the modifier both fail silently. Pair it with a real
892
+ control — normally the stretched target below.
889
893
  - `--button` — for a card that **is** the control: render it as `<button>` and add this to reset
890
894
  the UA button box **without** losing the card surface/border
891
895
  - `.fdy-card--button` never replaces keyboard semantics — a clickable card must be a real
@@ -895,6 +899,44 @@ Parts `__body` `__title` `__desc` `__footer`. Modifiers:
895
899
  - The card is `position:relative`, so a badge or ribbon you absolutely position inside it anchors
896
900
  to the card. That is also what keeps hidden labels inside it from escaping — see *Containment*.
897
901
 
902
+ ### One card, one primary action, one escape hatch
903
+
904
+ A card with **two** actions cannot be `--button`: interactive content nested in a `<button>` is
905
+ invalid HTML. Give the primary control `.fdy-btn--stretch` — its hit area covers the whole card,
906
+ while it stays a real `<button>`/`<a>` with real keyboard semantics.
907
+
908
+ ```html
909
+ <article class="fdy-card fdy-card--interactive">
910
+ <div class="fdy-card__body">
911
+ <h3 class="fdy-card__title">Workspace Alpha</h3>
912
+ <p class="fdy-card__desc">12 members · 4 boards</p>
913
+ </div>
914
+ <div class="fdy-card__footer">
915
+ <button class="fdy-btn fdy-btn--text" type="button">Details</button> <!-- escape hatch -->
916
+ <button class="fdy-btn fdy-btn--stretch" type="button">Open workspace Alpha</button>
917
+ </div>
918
+ </article>
919
+ ```
920
+
921
+ - **The escape hatch needs nothing.** Every focusable element in a card that holds a stretched
922
+ target is raised above the overlay automatically — forgetting a `z-index` here fails silently, and
923
+ a silent failure is what this pattern exists to remove. The `position` that raising needs is only a
924
+ **default** (zero-specificity), so a control you pin yourself — a corner dismiss, a favourite star —
925
+ keeps its own `position:absolute` and is raised anyway.
926
+ - **Name the stretched button for what the whole card does** ("Open workspace Alpha", not "Open").
927
+ Its label is the accessible name of the entire hit area.
928
+ - **Do not add `.fdy-btn--stretch` to more than one control per card** — the last one wins the
929
+ overlap, and which one that is depends on source order.
930
+ - Text under the overlay is no longer selectable; that is the cost of the pattern, not a bug.
931
+ - **Cards only.** The overlay anchors to the nearest *positioned* ancestor, and in the kit that is
932
+ `.fdy-card`. A `.fdy-list__row` is **not** positioned (`.fdy-list` is), so a stretched target in a
933
+ list row would cover the **whole list** — for clickable rows use `.fdy-list__row--button`.
934
+ - `.fdy-btn` nudges itself on `:hover`/`:active`, and a transformed element becomes the containing
935
+ block for its own absolutely positioned descendants — so a hand-rolled `::after{inset:0}` overlay
936
+ **re-anchors to the button mid-gesture** and the click never lands. `--stretch` neutralises those
937
+ transforms and moves the press feedback to the card. This is why the pattern is shipped rather
938
+ than documented.
939
+
898
940
  ## Badge — `.fdy-badge`
899
941
  Inline status pill: `--success` `--warning` `--danger` `--info` `--outline`. Never colour-only —
900
942
  the text carries the meaning.
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.27.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.27.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.28.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.28.0)
9
9
 
10
10
  UI KIT yang token-driven & framework-agnostic — satu sumber kebenaran untuk warna, tipografi,
11
11
  spasi, dan komponen. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs — the UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.27.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.27.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.28.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.28.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`.
@@ -711,6 +711,22 @@ a { color: var(--color-primary); }
711
711
  .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;}
712
712
  .fdy-btn--ghost[aria-pressed="true"]:hover,.fdy-btn--text[aria-pressed="true"]:hover{background:var(--color-primary-soft);transform:none;box-shadow:none;}
713
713
 
714
+ /* Stretched hit area (#007) — spread this button's click target over its nearest positioned
715
+ * ancestor, so "one card, one primary action, one escape hatch" needs no click handler on the
716
+ * <article>: the real control stays a real <button>/<a> and keeps its keyboard semantics.
717
+ * `.fdy-card` is already position:relative, so it is the anchor.
718
+ *
719
+ * The transforms MUST go, and this is the whole reason the modifier exists rather than a docs line:
720
+ * a transformed element becomes the containing block for its own absolutely-positioned descendants,
721
+ * so `inset:0` re-anchors from the card to the button's own box the moment the button is hovered or
722
+ * pressed. Measured: mousedown lands on the button, the overlay collapses under the pointer, mouseup
723
+ * lands on some other element, and the browser fires `click` on their common ancestor — the button
724
+ * never gets one. It demos fine on hover for --text/--ghost (their hover is already transform:none)
725
+ * and only a real press reveals it; the default and --danger fills break one step earlier, on hover.
726
+ * Press feedback moves to the card, which is the object actually being pressed (see card.css). */
727
+ .fdy-btn--stretch::after{content:'';position:absolute;inset:0;}
728
+ .fdy-btn--stretch:hover,.fdy-btn--stretch:active{transform:none;}
729
+
714
730
  /* Icon-only — square; combine with --ghost/--text/--sm/--lg. Requires aria-label. */
715
731
  .fdy-btn--icon{width:var(--control-h);padding:0;}
716
732
  .fdy-btn--icon.fdy-btn--sm{width:calc(var(--control-h) - var(--space-2));}
@@ -744,9 +760,13 @@ a { color: var(--color-primary); }
744
760
  .fdy-card--elevated{box-shadow:var(--shadow-lift-hover);}
745
761
  .fdy-card--interactive{cursor:pointer;}
746
762
  .fdy-card--interactive:hover{box-shadow:var(--shadow-lift-hover);transform:translateY(-3px);}
763
+ /* Pressing a stretched target (see .fdy-btn--stretch) is pressing the CARD — the button's own
764
+ nudge had to go, and the object under the pointer is the card. Ordered before the reduced-motion
765
+ block and the disabled rules so both still win at equal specificity. */
766
+ .fdy-card--interactive:has(.fdy-btn--stretch:active){transform:translateY(-1px);}
747
767
  /* The global reduced-motion reset kills the ANIMATION; drop the -3px shift too so the card
748
768
  * doesn't jump. The shadow still communicates the affordance. */
749
- @media (prefers-reduced-motion:reduce){.fdy-card--interactive:hover{transform:none;}}
769
+ @media (prefers-reduced-motion:reduce){.fdy-card--interactive:hover,.fdy-card--interactive:has(.fdy-btn--stretch:active){transform:none;}}
750
770
  /* When the whole card IS the control — a real <button> so the row is keyboard-reachable and
751
771
  * announced — reset the UA button box. Note: it does NOT reset background/border, because
752
772
  * .fdy-card already sets its own surface + border (resetting them here would strip the card). */
@@ -755,6 +775,17 @@ a { color: var(--color-primary); }
755
775
  --interactive lift is withdrawn so a refusing control stops answering the pointer. */
756
776
  .fdy-card--button:disabled,.fdy-card--button[aria-disabled="true"],.fdy-card--interactive[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
757
777
  .fdy-card--interactive:disabled:hover,.fdy-card--interactive[aria-disabled="true"]:hover{box-shadow:var(--shadow-lift);transform:none;}
778
+ /* Escape hatches stay clickable above a stretched target — automatic, not a documented convention,
779
+ because forgetting it fails SILENTLY: the secondary control looks and hovers exactly as before and
780
+ simply never receives the click, which is the very symptom the pattern exists to cure. Matched by
781
+ shape (anything focusable) so a control the kit does not ship yet is covered too. */
782
+ .fdy-card:has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch){z-index:1;}
783
+ /* The position is a DEFAULT, not an override: z-index needs a positioned box (or a flex/grid item),
784
+ but an app that deliberately pins a control inside the card — a corner dismiss, a favourite star —
785
+ must keep its own `position:absolute`. Wrapped in :where() so this whole selector weighs nothing
786
+ and ANY app rule wins it; measured, because at normal specificity the kit silently dragged an
787
+ absolutely positioned dismiss button back into the flow. Do not merge these two rules. */
788
+ :where(.fdy-card:has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch)){position:relative;}
758
789
  .fdy-card__body{padding:var(--space-5);}
759
790
  .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);}
760
791
  .fdy-card__desc{font-size:var(--text-sm);color:var(--color-text-muted);margin:0;line-height:var(--leading-normal);}
package/dist/freeday.css CHANGED
@@ -341,6 +341,22 @@ a { color: var(--color-primary); }
341
341
  .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;}
342
342
  .fdy-btn--ghost[aria-pressed="true"]:hover,.fdy-btn--text[aria-pressed="true"]:hover{background:var(--color-primary-soft);transform:none;box-shadow:none;}
343
343
 
344
+ /* Stretched hit area (#007) — spread this button's click target over its nearest positioned
345
+ * ancestor, so "one card, one primary action, one escape hatch" needs no click handler on the
346
+ * <article>: the real control stays a real <button>/<a> and keeps its keyboard semantics.
347
+ * `.fdy-card` is already position:relative, so it is the anchor.
348
+ *
349
+ * The transforms MUST go, and this is the whole reason the modifier exists rather than a docs line:
350
+ * a transformed element becomes the containing block for its own absolutely-positioned descendants,
351
+ * so `inset:0` re-anchors from the card to the button's own box the moment the button is hovered or
352
+ * pressed. Measured: mousedown lands on the button, the overlay collapses under the pointer, mouseup
353
+ * lands on some other element, and the browser fires `click` on their common ancestor — the button
354
+ * never gets one. It demos fine on hover for --text/--ghost (their hover is already transform:none)
355
+ * and only a real press reveals it; the default and --danger fills break one step earlier, on hover.
356
+ * Press feedback moves to the card, which is the object actually being pressed (see card.css). */
357
+ .fdy-btn--stretch::after{content:'';position:absolute;inset:0;}
358
+ .fdy-btn--stretch:hover,.fdy-btn--stretch:active{transform:none;}
359
+
344
360
  /* Icon-only — square; combine with --ghost/--text/--sm/--lg. Requires aria-label. */
345
361
  .fdy-btn--icon{width:var(--control-h);padding:0;}
346
362
  .fdy-btn--icon.fdy-btn--sm{width:calc(var(--control-h) - var(--space-2));}
@@ -374,9 +390,13 @@ a { color: var(--color-primary); }
374
390
  .fdy-card--elevated{box-shadow:var(--shadow-lift-hover);}
375
391
  .fdy-card--interactive{cursor:pointer;}
376
392
  .fdy-card--interactive:hover{box-shadow:var(--shadow-lift-hover);transform:translateY(-3px);}
393
+ /* Pressing a stretched target (see .fdy-btn--stretch) is pressing the CARD — the button's own
394
+ nudge had to go, and the object under the pointer is the card. Ordered before the reduced-motion
395
+ block and the disabled rules so both still win at equal specificity. */
396
+ .fdy-card--interactive:has(.fdy-btn--stretch:active){transform:translateY(-1px);}
377
397
  /* The global reduced-motion reset kills the ANIMATION; drop the -3px shift too so the card
378
398
  * doesn't jump. The shadow still communicates the affordance. */
379
- @media (prefers-reduced-motion:reduce){.fdy-card--interactive:hover{transform:none;}}
399
+ @media (prefers-reduced-motion:reduce){.fdy-card--interactive:hover,.fdy-card--interactive:has(.fdy-btn--stretch:active){transform:none;}}
380
400
  /* When the whole card IS the control — a real <button> so the row is keyboard-reachable and
381
401
  * announced — reset the UA button box. Note: it does NOT reset background/border, because
382
402
  * .fdy-card already sets its own surface + border (resetting them here would strip the card). */
@@ -385,6 +405,17 @@ a { color: var(--color-primary); }
385
405
  --interactive lift is withdrawn so a refusing control stops answering the pointer. */
386
406
  .fdy-card--button:disabled,.fdy-card--button[aria-disabled="true"],.fdy-card--interactive[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
387
407
  .fdy-card--interactive:disabled:hover,.fdy-card--interactive[aria-disabled="true"]:hover{box-shadow:var(--shadow-lift);transform:none;}
408
+ /* Escape hatches stay clickable above a stretched target — automatic, not a documented convention,
409
+ because forgetting it fails SILENTLY: the secondary control looks and hovers exactly as before and
410
+ simply never receives the click, which is the very symptom the pattern exists to cure. Matched by
411
+ shape (anything focusable) so a control the kit does not ship yet is covered too. */
412
+ .fdy-card:has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch){z-index:1;}
413
+ /* The position is a DEFAULT, not an override: z-index needs a positioned box (or a flex/grid item),
414
+ but an app that deliberately pins a control inside the card — a corner dismiss, a favourite star —
415
+ must keep its own `position:absolute`. Wrapped in :where() so this whole selector weighs nothing
416
+ and ANY app rule wins it; measured, because at normal specificity the kit silently dragged an
417
+ absolutely positioned dismiss button back into the flow. Do not merge these two rules. */
418
+ :where(.fdy-card:has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch)){position:relative;}
388
419
  .fdy-card__body{padding:var(--space-5);}
389
420
  .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);}
390
421
  .fdy-card__desc{font-size:var(--text-sm);color:var(--color-text-muted);margin:0;line-height:var(--leading-normal);}
@@ -184,7 +184,7 @@ live docs also have a copy button per component.
184
184
  ```bash
185
185
  npm i @cahyo-dimas/freeday
186
186
  ```
187
- Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.27.0"` (public npm package). `dist/` is
187
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.28.0"` (public npm package). `dist/` is
188
188
  committed and published → no build step; `npm ci` runs without auth.
189
189
 
190
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.27.0",
3
+ "version": "1.28.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 browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs",
74
+ "test:browser": "node --test browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs browser/card-stretch.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"
@@ -45,6 +45,22 @@
45
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
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;}
47
47
 
48
+ /* Stretched hit area (#007) — spread this button's click target over its nearest positioned
49
+ * ancestor, so "one card, one primary action, one escape hatch" needs no click handler on the
50
+ * <article>: the real control stays a real <button>/<a> and keeps its keyboard semantics.
51
+ * `.fdy-card` is already position:relative, so it is the anchor.
52
+ *
53
+ * The transforms MUST go, and this is the whole reason the modifier exists rather than a docs line:
54
+ * a transformed element becomes the containing block for its own absolutely-positioned descendants,
55
+ * so `inset:0` re-anchors from the card to the button's own box the moment the button is hovered or
56
+ * pressed. Measured: mousedown lands on the button, the overlay collapses under the pointer, mouseup
57
+ * lands on some other element, and the browser fires `click` on their common ancestor — the button
58
+ * never gets one. It demos fine on hover for --text/--ghost (their hover is already transform:none)
59
+ * and only a real press reveals it; the default and --danger fills break one step earlier, on hover.
60
+ * Press feedback moves to the card, which is the object actually being pressed (see card.css). */
61
+ .fdy-btn--stretch::after{content:'';position:absolute;inset:0;}
62
+ .fdy-btn--stretch:hover,.fdy-btn--stretch:active{transform:none;}
63
+
48
64
  /* Icon-only — square; combine with --ghost/--text/--sm/--lg. Requires aria-label. */
49
65
  .fdy-btn--icon{width:var(--control-h);padding:0;}
50
66
  .fdy-btn--icon.fdy-btn--sm{width:calc(var(--control-h) - var(--space-2));}
@@ -5,9 +5,13 @@
5
5
  .fdy-card--elevated{box-shadow:var(--shadow-lift-hover);}
6
6
  .fdy-card--interactive{cursor:pointer;}
7
7
  .fdy-card--interactive:hover{box-shadow:var(--shadow-lift-hover);transform:translateY(-3px);}
8
+ /* Pressing a stretched target (see .fdy-btn--stretch) is pressing the CARD — the button's own
9
+ nudge had to go, and the object under the pointer is the card. Ordered before the reduced-motion
10
+ block and the disabled rules so both still win at equal specificity. */
11
+ .fdy-card--interactive:has(.fdy-btn--stretch:active){transform:translateY(-1px);}
8
12
  /* The global reduced-motion reset kills the ANIMATION; drop the -3px shift too so the card
9
13
  * doesn't jump. The shadow still communicates the affordance. */
10
- @media (prefers-reduced-motion:reduce){.fdy-card--interactive:hover{transform:none;}}
14
+ @media (prefers-reduced-motion:reduce){.fdy-card--interactive:hover,.fdy-card--interactive:has(.fdy-btn--stretch:active){transform:none;}}
11
15
  /* When the whole card IS the control — a real <button> so the row is keyboard-reachable and
12
16
  * announced — reset the UA button box. Note: it does NOT reset background/border, because
13
17
  * .fdy-card already sets its own surface + border (resetting them here would strip the card). */
@@ -16,6 +20,17 @@
16
20
  --interactive lift is withdrawn so a refusing control stops answering the pointer. */
17
21
  .fdy-card--button:disabled,.fdy-card--button[aria-disabled="true"],.fdy-card--interactive[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
18
22
  .fdy-card--interactive:disabled:hover,.fdy-card--interactive[aria-disabled="true"]:hover{box-shadow:var(--shadow-lift);transform:none;}
23
+ /* Escape hatches stay clickable above a stretched target — automatic, not a documented convention,
24
+ because forgetting it fails SILENTLY: the secondary control looks and hovers exactly as before and
25
+ simply never receives the click, which is the very symptom the pattern exists to cure. Matched by
26
+ shape (anything focusable) so a control the kit does not ship yet is covered too. */
27
+ .fdy-card:has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch){z-index:1;}
28
+ /* The position is a DEFAULT, not an override: z-index needs a positioned box (or a flex/grid item),
29
+ but an app that deliberately pins a control inside the card — a corner dismiss, a favourite star —
30
+ must keep its own `position:absolute`. Wrapped in :where() so this whole selector weighs nothing
31
+ and ANY app rule wins it; measured, because at normal specificity the kit silently dragged an
32
+ absolutely positioned dismiss button back into the flow. Do not merge these two rules. */
33
+ :where(.fdy-card:has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch)){position:relative;}
19
34
  .fdy-card__body{padding:var(--space-5);}
20
35
  .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);}
21
36
  .fdy-card__desc{font-size:var(--text-sm);color:var(--color-text-muted);margin:0;line-height:var(--leading-normal);}