Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# 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.
- 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
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
Expand Down
59 changes: 42 additions & 17 deletions src/tweaks.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -67,11 +67,19 @@
* 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
* 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
Expand Down Expand Up @@ -212,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
Expand Down Expand Up @@ -250,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); }
Expand All @@ -266,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
Expand Down Expand Up @@ -332,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. */
Expand Down Expand Up @@ -779,20 +787,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; }
Expand Down
8 changes: 6 additions & 2 deletions src/tweaks/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1413,7 +1417,7 @@ export async function enhance(root: Document | Element = document): Promise<void
if (!toggle && title) { toggle = btn("tw-header-toggle"); title.replaceWith(toggle); toggle.append(title); }
if (!toggle) return;
toggle.setAttribute("aria-expanded", "true");
toggle.addEventListener("click", () => { 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).
Expand Down
Loading