transitions-refine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.agents/skills/refine-live/SKILL.md +205 -0
  2. package/.agents/skills/transitions-dev/01-card-resize.md +53 -0
  3. package/.agents/skills/transitions-dev/02-number-pop-in.md +119 -0
  4. package/.agents/skills/transitions-dev/03-notification-badge.md +110 -0
  5. package/.agents/skills/transitions-dev/04-text-states-swap.md +97 -0
  6. package/.agents/skills/transitions-dev/05-menu-dropdown.md +105 -0
  7. package/.agents/skills/transitions-dev/06-modal.md +94 -0
  8. package/.agents/skills/transitions-dev/07-panel-reveal.md +81 -0
  9. package/.agents/skills/transitions-dev/08-page-side-by-side.md +100 -0
  10. package/.agents/skills/transitions-dev/09-icon-swap.md +78 -0
  11. package/.agents/skills/transitions-dev/10-success-check.md +169 -0
  12. package/.agents/skills/transitions-dev/11-avatar-group-hover.md +200 -0
  13. package/.agents/skills/transitions-dev/12-error-state-shake.md +202 -0
  14. package/.agents/skills/transitions-dev/13-input-clear-dissolve.md +276 -0
  15. package/.agents/skills/transitions-dev/14-skeleton-reveal.md +149 -0
  16. package/.agents/skills/transitions-dev/15-shimmer-text.md +95 -0
  17. package/.agents/skills/transitions-dev/16-tabs-sliding.md +146 -0
  18. package/.agents/skills/transitions-dev/17-tooltip.md +103 -0
  19. package/.agents/skills/transitions-dev/18-texts-reveal.md +110 -0
  20. package/.agents/skills/transitions-dev/19-card-tilt.md +170 -0
  21. package/.agents/skills/transitions-dev/20-plus-menu-morph.md +167 -0
  22. package/.agents/skills/transitions-dev/21-accordion.md +124 -0
  23. package/.agents/skills/transitions-dev/SKILL.md +225 -0
  24. package/.agents/skills/transitions-dev/_root.css +204 -0
  25. package/README.md +89 -0
  26. package/bin/cli.mjs +264 -0
  27. package/demo.html +2531 -0
  28. package/package.json +37 -0
  29. package/server/inject.mjs +116 -0
  30. package/server/motion-tokens.mjs +106 -0
  31. package/server/refine-agent.mjs +86 -0
  32. package/server/relay.mjs +421 -0
@@ -0,0 +1,170 @@
1
+ # Card hover tilt
2
+
3
+ ## When to use
4
+
5
+ A card / tile / media surface that tilts in 3D toward the pointer while hovered, with a soft light "glare" tracking the cursor across it. Use for product cards, credit / membership cards, feature tiles, cover art — anything that should feel physical and reactive on hover. Pointer-only (skips touch) and flattens under reduced motion.
6
+
7
+ The pointer is tracked on an **outer flat wrapper** (`.t-tilt`) that never transforms, so the tilting card can't rotate its own edges out from under the cursor (which causes hover flicker). The inner `.t-tilt-card` is the element that actually rotates.
8
+
9
+ ## HTML usage
10
+
11
+ ```html
12
+ <div class="t-tilt"> <!-- flat hit area -->
13
+ <div class="t-tilt-card"> <!-- the element that tilts -->
14
+ … card content …
15
+ <div class="t-tilt-glare"></div>
16
+ </div>
17
+ </div>
18
+ ```
19
+
20
+ Track the pointer on the OUTER `.t-tilt` (it never
21
+ transforms) and write four custom properties from JS:
22
+ el.style.setProperty('--tilt-rx', rxDeg + 'deg');
23
+ el.style.setProperty('--tilt-ry', ryDeg + 'deg');
24
+ el.style.setProperty('--tilt-gx', gxPct + '%');
25
+ el.style.setProperty('--tilt-gy', gyPct + '%');
26
+ Add `.is-tilting` while moving (fast follow) and
27
+ `.is-hover` to fade the glare in; remove both on leave.
28
+
29
+ ## Tunable variables
30
+
31
+ | Variable | Default | Notes |
32
+ | --- | --- | --- |
33
+ | `--tilt-perspective` | `1000px` | sourced from `--p19-perspective` |
34
+ | `--tilt-return` | `1000ms` | sourced from `--p19-return-dur` |
35
+ | `--tilt-return-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p19-return-ease` |
36
+ | `--tilt-follow` | `400ms` | sourced from `--p19-follow-dur` |
37
+ | `--tilt-follow-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p19-follow-ease` |
38
+ | `--tilt-glare-opacity` | `0.32` | sourced from `--p19-glare-opacity` |
39
+ | `--tilt-glare-fade` | `300ms` | sourced from `--p19-glare-fade` |
40
+ | `--tilt-glare-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p19-glare-ease` |
41
+
42
+ The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.
43
+
44
+ ```css
45
+ :root {
46
+ --tilt-perspective: 1000px;
47
+ --tilt-return: 1000ms;
48
+ --tilt-return-ease: cubic-bezier(0.22, 1, 0.36, 1);
49
+ --tilt-follow: 400ms;
50
+ --tilt-follow-ease: cubic-bezier(0.22, 1, 0.36, 1);
51
+ --tilt-glare-opacity: 0.32;
52
+ --tilt-glare-fade: 300ms;
53
+ --tilt-glare-ease: cubic-bezier(0.22, 1, 0.36, 1);
54
+ }
55
+ ```
56
+
57
+ ## CSS
58
+
59
+ ```css
60
+ /* The outer wrapper is the flat hit area; touch-action:none
61
+ lets a finger drag tilt the card instead of scrolling the
62
+ page, so tap-hold-drag works on mobile. */
63
+ .t-tilt { touch-action: none; }
64
+ /* The card tilts toward the pointer via rotateX/rotateY fed
65
+ from JS; on leave it eases back to flat. A separate
66
+ .is-tilting class swaps in a short linear follow while the
67
+ pointer moves so the tilt tracks the cursor 1:1. */
68
+ .t-tilt-card {
69
+ position: relative;
70
+ border-radius: 12px;
71
+ overflow: hidden;
72
+ transform:
73
+ perspective(var(--tilt-perspective))
74
+ rotateX(var(--tilt-rx, 0deg))
75
+ rotateY(var(--tilt-ry, 0deg));
76
+ transform-style: preserve-3d;
77
+ transition: transform var(--tilt-return) var(--tilt-return-ease);
78
+ will-change: transform;
79
+ }
80
+ .t-tilt-card.is-tilting {
81
+ transition: transform var(--tilt-follow) var(--tilt-follow-ease);
82
+ }
83
+ /* Cursor-tracked glare: layered soft circles that add like
84
+ light (screen blend) at the pointer position. */
85
+ .t-tilt-glare {
86
+ position: absolute;
87
+ inset: 0;
88
+ pointer-events: none;
89
+ opacity: 0;
90
+ mix-blend-mode: screen;
91
+ background:
92
+ radial-gradient(circle 95px at var(--tilt-gx, 50%) var(--tilt-gy, 50%),
93
+ rgba(255,255,255,0.48), rgba(255,255,255,0.06) 52%, rgba(255,255,255,0) 84%),
94
+ radial-gradient(circle 200px at var(--tilt-gx, 50%) var(--tilt-gy, 50%),
95
+ rgba(255,255,255,0.22), rgba(255,255,255,0.04) 58%, rgba(255,255,255,0) 78%),
96
+ radial-gradient(circle 360px at var(--tilt-gx, 50%) var(--tilt-gy, 50%),
97
+ rgba(255,255,255,0.10), rgba(255,255,255,0) 88%);
98
+ transition: opacity var(--tilt-glare-fade) var(--tilt-glare-ease);
99
+ }
100
+ .t-tilt.is-hover .t-tilt-glare { opacity: var(--tilt-glare-opacity); }
101
+
102
+ @media (prefers-reduced-motion: reduce) {
103
+ .t-tilt-card { transform: none !important; transition: none !important; }
104
+ }
105
+ ```
106
+
107
+ The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.
108
+
109
+ ## JavaScript orchestration
110
+
111
+ ```js
112
+ // Track the pointer on the OUTER .t-tilt (never transforms) and write
113
+ // rotation + glare position onto the inner card. Works for mouse
114
+ // (hover) and touch / pen (tap-hold-drag) — a touch pointermove only
115
+ // fires while a finger is down, so the press naturally drives the tilt.
116
+ const tilt = document.querySelector(".t-tilt");
117
+ const card = tilt.querySelector(".t-tilt-card");
118
+ const reduce = matchMedia("(prefers-reduced-motion: reduce)");
119
+
120
+ const MAX = 14; // peak tilt in degrees at the card edges (raise for a stronger lean)
121
+
122
+ function reset() {
123
+ tilt.classList.remove("is-hover");
124
+ card.classList.remove("is-tilting");
125
+ card.style.setProperty("--tilt-rx", "0deg");
126
+ card.style.setProperty("--tilt-ry", "0deg");
127
+ }
128
+
129
+ function track(e) {
130
+ if (reduce.matches) return;
131
+ const r = tilt.getBoundingClientRect();
132
+ const px = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
133
+ const py = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
134
+ tilt.classList.add("is-hover");
135
+ card.classList.add("is-tilting");
136
+ card.style.setProperty("--tilt-ry", ((px - 0.5) * MAX).toFixed(2) + "deg");
137
+ card.style.setProperty("--tilt-rx", ((0.5 - py) * MAX).toFixed(2) + "deg");
138
+ card.style.setProperty("--tilt-gx", (px * 100).toFixed(1) + "%");
139
+ card.style.setProperty("--tilt-gy", (py * 100).toFixed(1) + "%");
140
+ }
141
+
142
+ tilt.addEventListener("pointerdown", (e) => {
143
+ // Touch / pen: capture so the drag keeps targeting the card even if
144
+ // the finger drifts past its edge. Pair with touch-action: none on
145
+ // .t-tilt so the drag tilts instead of scrolling the page.
146
+ if (e.pointerType !== "mouse") {
147
+ try { tilt.setPointerCapture(e.pointerId); } catch (_) {}
148
+ }
149
+ });
150
+ tilt.addEventListener("pointermove", track);
151
+ tilt.addEventListener("pointerup", reset);
152
+ tilt.addEventListener("pointercancel", reset);
153
+ tilt.addEventListener("pointerleave", (e) => {
154
+ // Mouse: leaving the card flattens it. Touch already reset on up.
155
+ if (e.pointerType === "mouse") reset();
156
+ });
157
+ ```
158
+
159
+ ### Peak tilt angle
160
+
161
+ The rotation magnitude is a JS constant (`MAX`, in degrees), not a CSS variable — the orchestration writes `--tilt-rx` / `--tilt-ry` from the pointer position scaled by `MAX`. Raise it for a stronger lean (the live demo goes up to ~40°); 10–16° reads as a subtle, tasteful tilt.
162
+
163
+ ### Why the pointer is tracked on the flat wrapper
164
+
165
+ Bind `pointermove` to the outer `.t-tilt` (which never transforms), not the `.t-tilt-card` that rotates. If you track the tilting element, its rotating edges slip out from under the cursor near the borders and the hover flickers on and off.
166
+
167
+ ### Touch / mobile
168
+
169
+ Because it uses Pointer Events, the tilt also works on touch: tap-hold-drag on the card and it follows your finger (a touch `pointermove` only fires while pressed). Two pieces make this reliable — `touch-action: none` on `.t-tilt` so the drag tilts instead of scrolling the page, and `setPointerCapture` on `pointerdown` so the gesture keeps targeting the card even if the finger drifts past its edge.
170
+
@@ -0,0 +1,167 @@
1
+ # Plus to menu morph
2
+
3
+ ## When to use
4
+
5
+ A small circular trigger (a "+" FAB, a compose button, an add-action affordance) that **morphs into the menu / panel it opens** instead of popping a separate surface next to it. The button's box grows in width / height and relaxes its corner radius into a rounded panel while the plus icon cross-fades + rotates out and the menu content slides in.
6
+
7
+ Reach for this over **menu dropdown** when the trigger and the surface are the *same* element (the button becomes the panel). Use plain **menu dropdown** when the surface is a distinct popover that merely grows from the trigger's corner.
8
+
9
+ ## HTML usage
10
+
11
+ ```html
12
+ <div class="t-morph" data-open="false">
13
+ <div class="t-morph-menu"> … menu items … </div>
14
+ <button class="t-morph-plus" aria-expanded="false">+</button>
15
+ </div>
16
+ ```
17
+
18
+ Toggle `data-open` on the container (and `aria-expanded`
19
+ on the button). CSS animates the surface size + corner
20
+ radius and cross-fades the plus ↔ menu. Wrap the morph in
21
+ a relatively-positioned anchor sized to the OPEN footprint
22
+ if you want it to grow out of a fixed corner.
23
+
24
+ ## Tunable variables
25
+
26
+ | Variable | Default | Notes |
27
+ | --- | --- | --- |
28
+ | `--morph-open-dur` | `350ms` | sourced from `--p20-open-dur` |
29
+ | `--morph-close-dur` | `250ms` | sourced from `--p20-close-dur` |
30
+ | `--morph-ease` | `cubic-bezier(0.34, 1.25, 0.64, 1)` | sourced from `--p20-ease` |
31
+ | `--morph-close-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p20-close-ease` |
32
+ | `--morph-r-closed` | `40px` | sourced from `--p20-r-closed` |
33
+ | `--morph-r-open` | `20px` | sourced from `--p20-r-open` |
34
+ | `--morph-fade-dur` | `200ms` | sourced from `--p20-fade-dur` |
35
+ | `--morph-slide` | `40px` | sourced from `--p20-slide-in-shift` |
36
+ | `--morph-rotate` | `45deg` | sourced from `--p20-rotate` |
37
+ | `--morph-scale` | `0.97` | sourced from `--p20-scale` |
38
+ | `--morph-blur` | `2px` | sourced from `--p20-blur` |
39
+
40
+ The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.
41
+
42
+ ```css
43
+ :root {
44
+ --morph-open-dur: 350ms;
45
+ --morph-close-dur: 250ms;
46
+ --morph-ease: cubic-bezier(0.34, 1.25, 0.64, 1);
47
+ --morph-close-ease: cubic-bezier(0.22, 1, 0.36, 1);
48
+ --morph-r-closed: 40px;
49
+ --morph-r-open: 20px;
50
+ --morph-fade-dur: 200ms;
51
+ --morph-slide: 40px;
52
+ --morph-rotate: 45deg;
53
+ --morph-scale: 0.97;
54
+ --morph-blur: 2px;
55
+ }
56
+ ```
57
+
58
+ ## CSS
59
+
60
+ ```css
61
+ /* Closed: a small circular button. Open: a rounded panel.
62
+ Width/height/border-radius animate; the open state uses a
63
+ bouncier ease than the close. */
64
+ .t-morph {
65
+ position: relative;
66
+ width: 40px;
67
+ height: 40px;
68
+ border-radius: var(--morph-r-closed);
69
+ overflow: hidden;
70
+ transition:
71
+ width var(--morph-close-dur) var(--morph-close-ease),
72
+ height var(--morph-close-dur) var(--morph-close-ease),
73
+ border-radius var(--morph-close-dur) var(--morph-close-ease);
74
+ }
75
+ .t-morph[data-open="true"] {
76
+ width: 183px;
77
+ height: 172px;
78
+ border-radius: var(--morph-r-open);
79
+ transition:
80
+ width var(--morph-open-dur) var(--morph-ease),
81
+ height var(--morph-open-dur) var(--morph-ease),
82
+ border-radius var(--morph-open-dur) var(--morph-ease);
83
+ }
84
+ /* Plus fades + slides out and the icon rotates into an ×. */
85
+ .t-morph-plus {
86
+ position: absolute;
87
+ inset: auto 0 0 auto;
88
+ width: 40px; height: 40px;
89
+ display: grid; place-items: center;
90
+ border: 0; background: transparent; cursor: pointer;
91
+ transition:
92
+ opacity var(--morph-fade-dur) var(--morph-close-ease),
93
+ transform var(--morph-open-dur) var(--morph-close-ease),
94
+ filter var(--morph-fade-dur) var(--morph-close-ease);
95
+ }
96
+ .t-morph-plus svg {
97
+ transition: transform var(--morph-open-dur) var(--morph-close-ease);
98
+ }
99
+ .t-morph[data-open="true"] .t-morph-plus {
100
+ opacity: 0;
101
+ transform: translateX(calc(-1 * var(--morph-slide)));
102
+ filter: blur(var(--morph-blur));
103
+ pointer-events: none;
104
+ }
105
+ .t-morph[data-open="true"] .t-morph-plus svg {
106
+ transform: scale(var(--morph-scale)) rotate(var(--morph-rotate));
107
+ }
108
+ /* Menu starts slid in + scaled + blurred; reveals on open. */
109
+ .t-morph-menu {
110
+ position: absolute;
111
+ inset: 0;
112
+ opacity: 0;
113
+ transform: translateX(var(--morph-slide)) scale(var(--morph-scale));
114
+ filter: blur(var(--morph-blur));
115
+ pointer-events: none;
116
+ transition:
117
+ opacity var(--morph-fade-dur) var(--morph-close-ease),
118
+ transform var(--morph-open-dur) var(--morph-close-ease),
119
+ filter var(--morph-fade-dur) var(--morph-close-ease);
120
+ }
121
+ .t-morph[data-open="true"] .t-morph-menu {
122
+ opacity: 1;
123
+ transform: translateX(0) scale(1);
124
+ filter: blur(0);
125
+ pointer-events: auto;
126
+ }
127
+
128
+ @media (prefers-reduced-motion: reduce) {
129
+ .t-morph, .t-morph-plus, .t-morph-menu { transition: none !important; }
130
+ }
131
+ ```
132
+
133
+ The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.
134
+
135
+ ## JavaScript orchestration
136
+
137
+ ```js
138
+ // Toggle data-open on the container; CSS owns the morph. Mirror the
139
+ // state to aria-expanded and close on outside click / Escape.
140
+ const morph = document.querySelector(".t-morph");
141
+ const plus = morph.querySelector(".t-morph-plus");
142
+
143
+ function setOpen(open) {
144
+ morph.setAttribute("data-open", String(open));
145
+ plus.setAttribute("aria-expanded", String(open));
146
+ }
147
+
148
+ plus.addEventListener("click", (e) => {
149
+ e.stopPropagation();
150
+ setOpen(morph.getAttribute("data-open") !== "true");
151
+ });
152
+ document.addEventListener("click", (e) => {
153
+ if (!morph.contains(e.target)) setOpen(false);
154
+ });
155
+ document.addEventListener("keydown", (e) => {
156
+ if (e.key === "Escape") setOpen(false);
157
+ });
158
+ ```
159
+
160
+ ### Pin the plus button to a corner
161
+
162
+ The plus button must overlay the panel, pinned to a corner (`inset: auto 0 0 auto`), so it stays put while the box grows up-and-left out of it. If it's in normal flow it gets shoved around as the container resizes. `overflow: hidden` on `.t-morph` is load-bearing — it clips the menu content during the size morph so items don't spill outside the growing rounded box.
163
+
164
+ ### Open and close use different eases
165
+
166
+ The bouncy `--morph-ease` only drives the open; the close falls back to the calm `--morph-close-ease`. Don't collapse them into one variable. Adjust the open `width` / `height` in the snippet to your real panel size — they're hardcoded, not derived from the content.
167
+
@@ -0,0 +1,124 @@
1
+ # Accordion expand
2
+
3
+ ## When to use
4
+
5
+ A disclosure / accordion / collapsible section whose panel grows and shrinks in height when toggled, with the header chevron flipping between a downward "v" and an upward "^". Use for settings groups, FAQs, filter sections, "show more" details — any header + collapsible body.
6
+
7
+ Height animates via `grid-template-rows: 0fr ↔ 1fr`, so there's **no JS height measuring** and content of any size animates cleanly. The chevron flips vertically (`scaleY`) from a "v" to a "^", passing through a flat line at the midpoint.
8
+
9
+ ## HTML usage
10
+
11
+ ```html
12
+ <div class="t-acc" data-open="false">
13
+ <button class="t-acc-head" aria-expanded="false">
14
+ Title
15
+ <span class="t-acc-chevron">
16
+ <svg viewBox="0 0 16 16"><path d="M4 6.5L8 10.5L12 6.5"/></svg>
17
+ </span>
18
+ </button>
19
+ <div class="t-acc-panel"><div class="t-acc-panel-inner"> … </div></div>
20
+ </div>
21
+ ```
22
+
23
+ Toggle `data-open` on the item. The panel animates via
24
+ grid-template-rows 0fr ↔ 1fr (no JS height measuring) and
25
+ the chevron flips vertically (scaleY) from a "v" to a "^".
26
+
27
+ ## Tunable variables
28
+
29
+ | Variable | Default | Notes |
30
+ | --- | --- | --- |
31
+ | `--acc-expand` | `250ms` | sourced from `--p21-expand-dur` |
32
+ | `--acc-collapse` | `250ms` | sourced from `--p21-collapse-dur` |
33
+ | `--acc-chevron` | `250ms` | sourced from `--p21-chevron-dur` |
34
+ | `--acc-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p21-ease` |
35
+
36
+ The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.
37
+
38
+ ```css
39
+ :root {
40
+ --acc-expand: 250ms;
41
+ --acc-collapse: 250ms;
42
+ --acc-chevron: 250ms;
43
+ --acc-ease: cubic-bezier(0.22, 1, 0.36, 1);
44
+ }
45
+ ```
46
+
47
+ ## CSS
48
+
49
+ ```css
50
+ /* grid-template-rows 0fr → 1fr gives a clean height animation
51
+ with no JS measurement; the inner element clips overflow. */
52
+ .t-acc-panel {
53
+ display: grid;
54
+ grid-template-rows: 0fr;
55
+ transition: grid-template-rows var(--acc-collapse) var(--acc-ease);
56
+ }
57
+ .t-acc[data-open="true"] .t-acc-panel {
58
+ grid-template-rows: 1fr;
59
+ transition: grid-template-rows var(--acc-expand) var(--acc-ease);
60
+ }
61
+ .t-acc-panel-inner {
62
+ overflow: hidden;
63
+ opacity: 0;
64
+ filter: blur(2px);
65
+ transition:
66
+ opacity var(--acc-collapse) var(--acc-ease),
67
+ filter var(--acc-collapse) var(--acc-ease);
68
+ }
69
+ .t-acc[data-open="true"] .t-acc-panel-inner {
70
+ opacity: 1;
71
+ filter: blur(0);
72
+ transition:
73
+ opacity var(--acc-expand) var(--acc-ease),
74
+ filter var(--acc-expand) var(--acc-ease);
75
+ }
76
+ /* Flip the chevron vertically to turn the "v" into a "^".
77
+ scaleY(-1) about the centre passes through a flat line at
78
+ the midpoint (same look as a `d:` path morph) but animates
79
+ in every browser, unlike CSS `d:` morphing (Chromium only).
80
+ The chevron path is symmetric about the 16x16 viewBox
81
+ centre, so the flip lands exactly on the "^"; non-scaling
82
+ -stroke keeps the stroke width constant through the flip. */
83
+ .t-acc-chevron {
84
+ display: inline-flex;
85
+ transform: scaleY(1);
86
+ transform-origin: center;
87
+ transition: transform var(--acc-chevron) var(--acc-ease);
88
+ }
89
+ .t-acc-chevron path { vector-effect: non-scaling-stroke; }
90
+ .t-acc[data-open="true"] .t-acc-chevron {
91
+ transform: scaleY(-1);
92
+ }
93
+
94
+ @media (prefers-reduced-motion: reduce) {
95
+ .t-acc-panel, .t-acc-panel-inner, .t-acc-chevron {
96
+ transition: none !important;
97
+ }
98
+ }
99
+ ```
100
+
101
+ The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.
102
+
103
+ ## JavaScript orchestration
104
+
105
+ ```js
106
+ // Toggle data-open on the item; CSS owns the height + chevron morph.
107
+ const acc = document.querySelector(".t-acc");
108
+ const head = acc.querySelector(".t-acc-head");
109
+
110
+ head.addEventListener("click", () => {
111
+ const open = acc.getAttribute("data-open") === "true";
112
+ acc.setAttribute("data-open", String(!open));
113
+ head.setAttribute("aria-expanded", String(!open));
114
+ });
115
+ ```
116
+
117
+ ### Two-element panel + padding placement
118
+
119
+ The panel needs the two-element structure (`.t-acc-panel` grid track + `.t-acc-panel-inner` with `overflow: hidden`). The `0fr → 1fr` track can only collapse a child that clips its own overflow. Keep padding on `.t-acc-panel-inner`, never on `.t-acc-panel` — padding on the `0fr` track leaves a residual height strip so the panel never fully closes.
120
+
121
+ ### Why the chevron flips instead of morphing its path
122
+
123
+ The natural way to turn the "v" into a "^" is to morph the chevron's SVG `d` between two vertex sets — but CSS `d:` path interpolation is **Chromium-only**, so on mobile Safari and Firefox it snaps (or doesn't move at all). A vertical flip (`transform: scaleY(-1)`) reproduces the same motion — it passes through a flat horizontal line at the midpoint, exactly like the path morph — and animates in every browser. Two requirements make it land cleanly: the chevron path must be **symmetric about the centre of its viewBox** (so the flip maps the "v" onto the "^"), and the path needs `vector-effect: non-scaling-stroke` so the stroke width stays constant while the box is squashed mid-flip.
124
+