From 5ec1281e659c18443e25af89c94ed80e64fc002e Mon Sep 17 00:00:00 2001 From: Ryan Kiley Date: Sat, 20 Jun 2026 22:25:20 -0700 Subject: [PATCH 1/3] Smoother folder collapse, with keyboard a11y and spacing fixes Folders now fade and clip away together when collapsing instead of squashing and stretching, with the caret turning in step. Collapsed folders and panels drop their hidden controls from the tab order, and a panel ending in a folder no longer carries extra space at the bottom. --- CHANGELOG.md | 11 +++++++++++ src/tweaks.css | 35 +++++++++++++++++++++++++++++------ src/tweaks/core.ts | 8 ++++++-- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 474536c..36c61f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### Changed +- Folders collapse and expand with a smoother fade — the controls fade and clip away + together instead of squashing and stretching, and the caret turns in step with them. + +### Fixed +- A collapsed folder (or a collapsed panel) no longer leaves its hidden controls in the + keyboard tab order and accessibility tree. +- Removed extra empty space at the bottom of a panel whose last item is a folder. + ## 0.2.0 — 2026-06-20 ### Added diff --git a/src/tweaks.css b/src/tweaks.css index 10724de..1de281b 100644 --- a/src/tweaks.css +++ b/src/tweaks.css @@ -72,6 +72,12 @@ --tw-ease-out: cubic-bezier(0.25, 1, 0.5, 1); --tw-ease-spring: cubic-bezier(0.22, 1, 0.36, 1); --tw-ease-pop: cubic-bezier(0.34, 1.56, 0.64, 1); + /* --fold is a folder's open/shut settle: a normalised spring position-curve (the kit's + * own spring tuner at visualDuration 0.35s, bounce 0.1) sampled into linear(). Unlike the + * three eases above — all front-loaded ease-outs — a spring starts from rest, so the curve + * is a soft S: the children dissolve gradually in lockstep with the height instead of + * blinking off in the first 60ms. Pairs with a 0.35s duration to let the full settle land. */ + --tw-ease-fold: linear(0, 0.077, 0.237, 0.411, 0.570, 0.699, 0.797, 0.869, 0.918, 0.952, 0.973, 0.986, 0.994, 0.998, 1); font-family: var(--tw-font-sans); /* Pin the line-height the design's vertical metrics were built against. Buttons use @@ -779,20 +785,37 @@ .tw-folder-header:hover .tw-chevron { color: var(--tw-text-primary); } /* Same optical pull as the select chevron: its 15px box carries 3.75px of side-bearing, * so the glyph's visible edge lands at the title's 2px inset rather than ~5.75px in. */ -.tw-folder-header .tw-chevron { color: var(--tw-text-section); margin-right: -3.75px; } -.tw-folder.is-collapsed .tw-folder-header .tw-chevron { transform: rotate(-90deg); } -.tw-folder-body { display: grid; grid-template-rows: 1fr; transition: grid-template-rows 0.26s cubic-bezier(0.22, 1, 0.36, 1); } +/* The folder caret rides the body's own 0.35s --fold clock (not the shared 0.2s chevron + * transition), so it rotates in lockstep with the fade instead of snapping ~0.15s ahead. */ +.tw-folder-header .tw-chevron { color: var(--tw-text-section); margin-right: -3.75px; transition: transform 0.35s var(--tw-ease-fold), color 0.15s; } +/* Collapsed caret flips to point up (open is the resting down-chevron) — an up/down toggle. */ +.tw-folder.is-collapsed .tw-folder-header .tw-chevron { transform: rotate(180deg); } +/* contain: layout isolates the fold's per-frame track re-sizing (the subtree stays mounted + + * clipped, so it lays out every frame for 0.35s) from the rest of the panel. */ +.tw-folder-body { display: grid; grid-template-rows: 1fr; transition: grid-template-rows 0.35s var(--tw-ease-fold); contain: layout; } .tw-folder.is-collapsed .tw-folder-body { grid-template-rows: 0fr; } -/* Fade the nested controls as the folder collapses — animating height AND - * opacity together, so the children dissolve rather than just unrolling. */ +/* Fade the nested controls as the folder collapses — height AND opacity share the + * same spring settle (--fold) over the same 0.35s, so the children dissolve in + * lockstep with the unrolling rather than blinking off ahead of it. */ /* y-clip only (see .tw-body > .tw-controls) so a slider inside a folder can still * rubber-band past its end without the folder's collapse-clip cutting it off. */ /* The row-height header already gives the top separation; the body just needs a little * room below its last control before the closing rule. */ -.tw-folder-body > .tw-controls { overflow-x: visible; overflow-y: clip; min-height: 0; padding-bottom: 8px; opacity: 1; transition: opacity 0.26s cubic-bezier(0.22, 1, 0.36, 1); } +.tw-folder-body > .tw-controls { overflow-x: visible; overflow-y: clip; min-height: 0; padding-bottom: 8px; opacity: 1; transition: opacity 0.35s var(--tw-ease-fold), padding-bottom 0.35s var(--tw-ease-fold); } /* Collapse that padding too, so a collapsed group is exactly its header — the title sits * centred in its row between the two rules, matching the open state. */ .tw-folder.is-collapsed .tw-folder-body > .tw-controls { opacity: 0; padding-bottom: 0; } +/* That 8px only exists to clear the folder's bottom hairline. The last folder drops the + * hairline (border-bottom:none above), so the padding is redundant there — it would stack + * on the panel's own bottom padding as dead space. Drop it so a panel ending in a folder + * closes like one ending in a plain control (panel padding only). */ +.tw-folder:last-child .tw-folder-body > .tw-controls { padding-bottom: 0; } +/* Hold each control at full height while the body collapses. The grid track constrains + * .tw-controls, and as a flex column it would otherwise shrink its rows to fit — they'd + * squash on collapse and stretch on open. flex-shrink:0 keeps every row at its natural + * size so the overflow-y:clip wipes them away top-down (and the opacity fades them) rather + * than deforming them — a clean dissolve, the way an animated auto-height would clip. */ +.tw-folder-body > .tw-controls > * { flex-shrink: 0; } /* ── Spring config — settle-curve preview + stiffness/damping/mass ── */ .tw-spring { display: flex; flex-direction: column; gap: 8px; background: var(--tw-surface); border-radius: var(--tw-radius); padding: 8px; } diff --git a/src/tweaks/core.ts b/src/tweaks/core.ts index 630bcaf..e2b37f0 100644 --- a/src/tweaks/core.ts +++ b/src/tweaks/core.ts @@ -587,7 +587,10 @@ function createFolder(meta) { const body = el("div", "tw-folder-body"); const inner = el("div", "tw-controls"); body.append(inner); root.append(header, body); - const setCollapsed = (c) => { root.classList.toggle("is-collapsed", c); header.setAttribute("aria-expanded", c ? "false" : "true"); }; + // inert on the collapsed body takes its (still-mounted, clip-faded) controls out of the + // tab order + a11y tree — otherwise a keyboard/SR user lands on invisible zero-height rows + // that aria-expanded="false" claims are hidden. Synchronous, so it's correct under reduced-motion. + const setCollapsed = (c) => { root.classList.toggle("is-collapsed", c); header.setAttribute("aria-expanded", c ? "false" : "true"); body.inert = c; }; header.addEventListener("click", () => setCollapsed(!root.classList.contains("is-collapsed"))); // setCollapsed lets the panel read + restore the open/closed state (toJSON/fromJSON). return { el: root, body: inner, setCollapsed }; @@ -846,6 +849,7 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): if (dragMoved) { dragMoved = false; return; } const collapsed = panel.classList.toggle("is-collapsed"); titleBtn.setAttribute("aria-expanded", collapsed ? "false" : "true"); + body.inert = collapsed; // same a11y reason as the folder: a collapsed panel's controls leave the tab order + a11y tree // A bottom-parked floating panel grows past the viewport when it expands — re-clamp // once the 0.25s body collapse has settled and the height is real. if (panel.dataset.mode === "floating") setTimeout(() => { if (panel.dataset.mode === "floating" && panel.isConnected) { clampPos(); apply(); } }, 270); @@ -1413,7 +1417,7 @@ export async function enhance(root: Document | Element = document): Promise { const c = panel.classList.toggle("is-collapsed"); toggle.setAttribute("aria-expanded", c ? "false" : "true"); }); + toggle.addEventListener("click", () => { const c = panel.classList.toggle("is-collapsed"); toggle.setAttribute("aria-expanded", c ? "false" : "true"); body.inert = c; }); // Copy + reset are part of the component, so the static samples carry them too — // the same toolbar tweaks() builds, operating over this panel's own [data-tw] // controls (gathered lazily at click time; they're created in the pass below). From a49edee52c70dc7bcef19b4af3044805e9611b8d Mon Sep 17 00:00:00 2001 From: Ryan Kiley Date: Sat, 20 Jun 2026 22:42:35 -0700 Subject: [PATCH 2/3] Route standalone easing curves through the motion tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --tw-ease-inout for the symmetric ease that was hardcoded five times (panel-body collapse, toolbar icon swap, reset spin settle), and replace an inline copy of --tw-ease-spring with the token. No behavior change — the resolved curves are identical, the values just live in one place now. --- src/tweaks.css | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/tweaks.css b/src/tweaks.css index 1de281b..aa0fba4 100644 --- a/src/tweaks.css +++ b/src/tweaks.css @@ -67,11 +67,13 @@ * continuous slider does, rather than each picking its own curve. --out is the * quick decelerate (chevrons, sliding pills); --spring is the slider's signature * organic settle (reveals, thumb scale-ups); --pop carries a hair of overshoot for - * press-releases that should feel springy. Reduced-motion neutralises them globally - * (see the transition kill-switch at the foot of the file). */ + * press-releases that should feel springy; --inout is a symmetric ease for longer + * mechanical moves (the panel-body collapse, the toolbar icon swap + reset settle). + * Reduced-motion neutralises them globally (see the kill-switch at the foot of the file). */ --tw-ease-out: cubic-bezier(0.25, 1, 0.5, 1); --tw-ease-spring: cubic-bezier(0.22, 1, 0.36, 1); --tw-ease-pop: cubic-bezier(0.34, 1.56, 0.64, 1); + --tw-ease-inout: cubic-bezier(0.4, 0, 0.2, 1); /* --fold is a folder's open/shut settle: a normalised spring position-curve (the kit's * own spring tuner at visualDuration 0.35s, bounce 0.1) sampled into linear(). Unlike the * three eases above — all front-loaded ease-outs — a spring starts from rest, so the curve @@ -218,7 +220,7 @@ .tw-toast.is-open { opacity: 1; transform: translate(-50%, 0); } /* Collapse — grid-rows 1fr→0fr animates the body height, dependency-free */ -.tw-body { display: grid; grid-template-rows: 1fr; transition: grid-template-rows 0.25s cubic-bezier(0.4, 0, 0.2, 1); } +.tw-body { display: grid; grid-template-rows: 1fr; transition: grid-template-rows 0.25s var(--tw-ease-inout); } /* Clip the y-axis only (for the collapse) — overflow-x stays visible so a slider * rubber-banding past its end (up to MAX_STRETCH px) shows in the panel's 12px * padding rather than being clipped. Must be `clip`, not `hidden`: a `hidden` on @@ -256,7 +258,7 @@ * the raster to device pixels; at a fractional button position (a dragged panel, flex * subpixels) that demotion read as a ~1px icon shift after every copy/reset. */ .tw-toolbar-btn__icons { position: relative; display: block; width: 14px; height: 14px; } -.tw-toolbar-btn__icons svg { position: absolute; inset: 0; will-change: transform, filter; filter: blur(0); transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1), transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), filter 0.25s cubic-bezier(0.4, 0, 0.2, 1); } +.tw-toolbar-btn__icons svg { position: absolute; inset: 0; will-change: transform, filter; filter: blur(0); transition: opacity 0.25s var(--tw-ease-inout), transform 0.25s var(--tw-ease-inout), filter 0.25s var(--tw-ease-inout); } .tw-toolbar-btn__check { opacity: 0; transform: scale(0.25); filter: blur(2px); color: var(--tw-success); } .tw-toolbar-btn--swap.is-copied .tw-toolbar-btn__copy { opacity: 0; transform: scale(0.25); filter: blur(2px); } .tw-toolbar-btn--swap.is-copied .tw-toolbar-btn__check { opacity: 1; transform: none; filter: blur(0); } @@ -272,7 +274,7 @@ * will-change keeps it on its compositor layer across hover/spin — same Safari fix. */ .tw-toolbar-btn--reset svg { --tw-spin: 0deg; --tw-wind: 0deg; will-change: transform; transform: rotate(calc(var(--tw-spin) + var(--tw-wind))); transition: transform 0.34s cubic-bezier(0.34, 1.55, 0.5, 1); } .tw-toolbar-btn--reset:hover svg { --tw-wind: 22deg; } -.tw-toolbar-btn--reset.is-spinning svg { transition-duration: 0.5s; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } +.tw-toolbar-btn--reset.is-spinning svg { transition-duration: 0.5s; transition-timing-function: var(--tw-ease-inout); } /* Focus rings — one accent ring, three insets, every focusable in the kit in one * place. Inset (−2): anything inside the controls stack, which is overflow:hidden @@ -338,7 +340,7 @@ position: absolute; top: 50%; left: 0; width: 3px; height: 20px; border-radius: 999px; background: var(--tw-text-primary); transform: translateY(-50%) scaleX(0.25); opacity: 0; pointer-events: none; - transition: opacity 0.15s, transform 0.2s cubic-bezier(0.22, 1, 0.36, 1); + transition: opacity 0.15s, transform 0.2s var(--tw-ease-spring); } /* Reveal on hover/focus too, at resting weight. * .is-hover is set in JS (pointerenter/leave) as a robust companion to :hover. */ From 75f7d1fe15edd783dac2926222dd56cc295a9e21 Mon Sep 17 00:00:00 2001 From: Ryan Kiley Date: Sat, 20 Jun 2026 22:50:32 -0700 Subject: [PATCH 3/3] Subdue the panel shadow and remove the top-edge highlight Drop the inset lit top edge from the dark panel shadows and replace the heavy three-layer drop shadow with a softer, more subdued pool. The light theme already had neither, so it is unchanged. --- CHANGELOG.md | 2 ++ src/tweaks.css | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c61f3..a556748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Changed - Folders collapse and expand with a smoother fade — the controls fade and clip away together instead of squashing and stretching, and the caret turns in step with them. +- The panel's drop shadow is softer and more subdued, and no longer draws a lit + highlight along its top edge. ### Fixed - A collapsed folder (or a collapsed panel) no longer leaves its hidden controls in the diff --git a/src/tweaks.css b/src/tweaks.css index aa0fba4..1f98449 100644 --- a/src/tweaks.css +++ b/src/tweaks.css @@ -43,11 +43,11 @@ --tw-radius: 8px; --tw-row-height: 32px; --tw-shadow-dropdown: 0 12px 32px -8px rgba(0, 0, 0, 0.6); - /* Container elevation — on dark, the readable cues are the 1px rim-light and the lit - * top edge (they separate the panel from the page where a black shadow can't), backed - * by a layered soft pool. -lifted deepens it while floating / being dragged. */ - --tw-shadow-panel: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 2px 6px -1px rgba(0, 0, 0, 0.5), 0 16px 32px -8px rgba(0, 0, 0, 0.6), 0 32px 64px -16px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.09); - --tw-shadow-panel-lifted: 0 0 0 1px rgba(255, 255, 255, 0.1), 0 6px 16px -3px rgba(0, 0, 0, 0.6), 0 36px 80px -18px rgba(0, 0, 0, 0.72), inset 0 1px 0 rgba(255, 255, 255, 0.12); + /* Container elevation — a faint 1px rim-light separates the panel from the page (a black + * shadow alone can't on dark), backed by a soft, subdued pool (no lit top edge). + * -lifted deepens it a touch while floating / being dragged. */ + --tw-shadow-panel: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 8px 28px -8px rgba(0, 0, 0, 0.5), 0 18px 48px -16px rgba(0, 0, 0, 0.38); + --tw-shadow-panel-lifted: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 12px 32px -8px rgba(0, 0, 0, 0.55), 0 28px 64px -16px rgba(0, 0, 0, 0.45); /* Success accent — the copy-confirmation check. Self-contained (no host --green-* * or color-scheme reliance); flips in the light-mode block below like every other * --tw-* colour, so the kit confirms in green wherever it's lifted. */