feat(date-picker): apply pressed scale to Date Picker and add pressed bg in month selector - #1931
feat(date-picker): apply pressed scale to Date Picker and add pressed bg in month selector#1931iamchanii wants to merge 37 commits into
Conversation
iamchanii
commented
Aug 7, 2026
- feat(rootage): describe the pressed scale with a scaleScope enum
- feat(qvism-preset): derive a press scale from the element's rendered size
- feat(react): publish an element's rendered size as CSS custom properties
- feat: apply the press scale to self-scoped slots
- feat(rootage): give callout and page banner close buttons their own specs
- docs: render the close button specs on callout and page banner
- refactor(qvism-preset): pin the resting press scale on the class
- feat(css): publish the press scale class name
- docs: warn that the duplicated breakpoint values must move together
- feat(css): publish the press scale class name and values
- docs: explain applying the pressed scale to custom components
- docs: document opting a nested mark out of the pressed scale
- docs: turn the pressed scale caveats into guidance
- docs: describe turning the pressed scale off project-wide
- feat(date-picker): apply pressed scale
- feat(date-picker): add pressed header style
- docs(date-picker): align custom cell example
Component specs pinned the pressed scale to fixed token values on action-button and list-item, and said nothing at all for the other 27 components that shrink on press. Replace that with a scaleScope enum on every slot that participates: `self` shrinks the whole slot, `content` keeps the background in place and shrinks only the content layer. - 35 slots across 33 specs declare `scaleScope: enum [self, content]`, assigned under base.pressed. - action-button and list-item drop their fixed scale values. The qvism and lynx-qvism recipes now read $scale.s95/s97/s98 straight from the token vars instead of routing them through component spec vars. - The press-scale prose on checkmark/radiomark/switchmark moves off the slot and splits: the opt-out caveat goes on the scaleScope property, and the hit area fact goes to checkbox/radio/switch, whose specs otherwise read as if they have no pressed feedback at all. The old wording named a single wrapper even though all three marks are reused elsewhere. No slot that declares scaleScope publishes its own scaleDuration / scaleTimingFunction. The press transition is one curve for the whole system, so a per-component value was an override surface with nothing per-component to resolve; the two recipes that read it now take $duration.pressed-scale and $timing-function.pressed-scale from the token vars directly. Kept where the properties describe a different effect: switchmark's thumb, whose `scale` is the selected-state size and now says so, and slider-thumb, which grows on press rather than shrinking. tab's base.enabled.root held nothing but those two properties, so the slot goes with them. enum properties emit no CSS variables, so the generated vars only move where a fixed scale or a transition was removed, or a description was added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1ZqXpgCJJr6hNqPKFcWMw
…size A pressed element shrinks by a fixed depth on its dominant dimension rather than by a per-size `$scale.sXX` token, so every pressable loses the same amount regardless of how large it is: basis = max(height, width / 4, 24) scale = (basis - 2) / basis A first-order variant of react-spectrum S2's `p / (p + depth)`, computed as a plain scale with no 3D transform involved. The derivation ships once in base.css on `.seed-press-scale` rather than being inlined into every recipe that scales; the declarations land on the same element either way. Reduced motion pins the output on that element after the derivation, so no value a consumer sets upstream brings the scale back. The three parameters are constants, not tokens. They describe the curve for every pressable in the system, so there is nothing per-theme or per-component to resolve, and as `:root` custom properties they would have been an override surface — including one that defeats the reduced-motion rule. `--seed-element-width` / `--seed-element-height` are published by `@seed-design/react`. Without JS they are unset, which makes the derivation guaranteed-invalid, and every consumer's `var(--seed-press-scale, 1)` falls back to 1 — no scale, no breakage. Ported from feat/dynamic-scale, minus its counter-scale half: the slots that keep a fixed background while only their content shrinks (`scaleScope: content`) need two boxes at different scales and are held for the next major. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PYxdfTtfB8oKBcWKu5kKc4
`useElementSizeVars` keeps unitless `--seed-element-width` /
`--seed-element-height` in sync on the element and stops there. The size is a
plain fact about the element rather than a feature, so whatever needs it derives
its own values in CSS — today that is the press scale in
`@seed-design/qvism-preset`, which owns the formula and the parameters.
Press detection stays in CSS `:active` gates, so nothing here tracks
interaction. One shared `ResizeObserver` serves every measured element, created
lazily so the module stays importable during SSR.
`usePressScale` hands back the size-var ref and the class that derives
`--seed-press-scale` together, so the two halves of the input cannot drift
apart; if one goes missing the derivation chain stays invalid and consumers fall
back to `scale: 1`, costing the effect rather than producing a wrong scale.
Two details are load-bearing and easy to lose:
- Compose the ref with `useComposedRefs`, never a bare `composeRefs`. The latter
returns a new function every render, which makes React detach and re-attach
the ref — re-registering the observation and republishing the vars on every
render instead of on every resize, restarting the press transition each frame
for a component that re-renders while held.
- Observe with `{ box: "border-box" }`. The content box would miss a border-box
change that leaves it untouched, e.g. a variant that only alters padding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYxdfTtfB8oKBcWKu5kKc4
Every slot whose spec declares `scaleScope: self` now scales as a whole on
press, driven by the runtime value instead of a fixed `$scale.sXX` token: 24
recipes and 26 components across action-button, the attachment-input family,
chip, chip-tabs, tabs, toggle-button, the marks, the floating buttons, the
reaction buttons, quantity-picker's decrement/increment buttons, and the
close/action buttons of bottom-sheet, menu-sheet, side-panel, snackbar,
help-bubble, callout, page-banner, input-button and content-dialog.
The scale always gates on `active` — `pseudo(not(disabled), active)` where the
slot has a disabled state, plain `pseudo(active)` where it doesn't, and
`[aria-grabbed=true]` for attachment-input so a dnd-kit dragged item shrinks by
the same depth, composing with the drag translate rather than clobbering it.
quantity-picker's buttons and content-dialog's close button keep `engaged` for
their pressed background, but take the scale on `active` alongside it: `engaged`
resolves to `:hover` on a hover-capable device, which is right for a background
tint and wrong for press feedback — it would shrink the button on hover alone,
before any press.
Marks read the value through `--seed-{checkmark,radiomark,switchmark}-press-scale`
so an ancestor that already gives its own pressed feedback can opt a nested mark
out; list-item does that on `prefix` and `suffix`.
Slots declaring `scaleScope: content` are deliberately untouched. Their
implementation needs a fixed background layer at a different scale from the
content, which is held for the next major — accordion-item, input-button root,
list-item, menu-item, menu-sheet-item, page-banner root, segmented-control-item,
select-box, select-trigger and select-item keep their current behaviour.
callout root is `self` rather than `content`: the whole card shrinks, background
and focus ring included, so the ring can stay on the root instead of moving to a
counter-scaled layer.
content-dialog's close button switches from `composeRefs` to `useComposedRefs`,
which also stops its render-tracking ref from detaching and re-attaching on
every render.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7NT69oFSVhhKgaKgZsdk
…pecs Both close buttons borrowed the parent's `suffixIcon` slot for their target size, so the parent carried a `targetSize` nothing else read. Each close button now owns a spec of its own — `callout-close-button` and `page-banner-close-button` — alongside the existing dialog, side panel and bottom sheet ones, and gains a pressed background the transparent version never had. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXwDCV9syRzMUfzfr1wzgj
Side panel already lists its close button spec next to its own; these two pages showed only the parent, leaving the new specs invisible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXwDCV9syRzMUfzfr1wzgj
The resting `scale: 1` was declared per slot by `createPressScaleRestStyles`, so a consumer building their own pressable had to remember it too. Forgetting it was the one failure that did not fail closed: the element became a stacking context only while pressed, which moves `position: fixed` descendants mid-gesture, with no error to trace. `.seed-press-scale` already had to be on the element for the derivation, so it declares the identity value as well and every element that opts in gets it. Callout is the one behaviour change: its resting scale was nested under `:is(button, a)`, so a non-actionable callout is now a stacking context too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZDvriSH2q9jDqgwdrr7Qi
`seed-press-scale` was hardcoded in two packages that cannot import each other: qvism-preset emits the rule into base.css, and react has to put the class on every element it measures. A mismatch produced no error — the derivation would simply never apply. `@seed-design/css/class-names` makes it a declared value react imports, the same topology `@seed-design/css/breakpoints` already uses. qvism-preset still keeps a copy, since css is generated from it, and now says so. Consumers building a custom pressable get the string too, instead of copying a literal out of the docs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZDvriSH2q9jDqgwdrr7Qi
The copy in qvism-preset named its origin, but the origin never named the copy — and packages/css is the side someone editing a breakpoint value would reach for, since it is the published one. Drift there silently splits the generated media queries from the runtime store that reads the same names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZDvriSH2q9jDqgwdrr7Qi
`--seed-press-scale` and `--seed-press-scale-transition` are meant to be read by styles this package does not author, unlike the component vars next to them. Export them as strings so consumers can splice them into their own rules. `pressScale` carries the resting fallback because the custom property is not registered with `@property`: consumed without it, `scale` computes to `none` before the element is measured and drops the stacking context the resting `scale: 1` holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZDvriSH2q9jDqgwdrr7Qi
The scale is derived at runtime from the element's own size, so a consumer building a pressable of their own needs two halves: `usePressScale` publishes the size, and their own rule consumes the ratio. Cover both, with the Tailwind CSS, vanilla-extract and plain CSS forms of the consuming rule. The live example is a card and a row of tiles rather than a button — SEED ships no card, so that is the surface consumers actually hand-build, and the 5x size gap between the two makes the fixed 2px depth visible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZDvriSH2q9jDqgwdrr7Qi
A custom pressable wrapping a mark scales twice — once on the wrapper, once on the mark. Each mark's doc now names the custom property that turns its own scale off, framed around the mark rather than the composed component since all three are also documented for standalone use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZDvriSH2q9jDqgwdrr7Qi
The `position: fixed` note explained why the resting `scale` is pinned, which describes a symptom the pinning already prevents. Say instead that the containing block stays put across the press, so one check of the descendant's placement covers both states. The `scale` note offered `transform` as an equal alternative. Recommend the individual property outright: `pressScaleClassName` already sets `scale`, so `transform` splits the element's scaling across two sources on top of having to carry any other transform in the same value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZDvriSH2q9jDqgwdrr7Qi
Every consumer reads one variable, so pinning it to 1 on the class that declares it turns the effect off everywhere at once. The rule has to land after SEED's own CSS to win the cascade, so the note leads with that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZDvriSH2q9jDqgwdrr7Qi
🦋 Changeset detectedLatest commit: 4284002 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Alpha Preview (Stackflow SPA)
|
Alpha Preview (Storybook)
|
Alpha Preview (Docs)
|
…clared Every rule that consumed the pressed scale had to spell the resting fallback itself, and omitting it was silent: `--seed-press-scale` was guaranteed-invalid until the element was measured, so a bare `scale: var(--seed-press-scale)` computed to `none` and dropped the stacking context the resting `scale: 1` exists to hold. Split the name in two. `--seed-press-scale-measured` holds the derivation and stays guaranteed-invalid before measurement; `--seed-press-scale` is what everything reads and absorbs that invalidity into 1 where it is declared. A matching `:root` default keeps it defined outside any pressable, so the ratio resolves document-wide and no consuming rule needs a fallback of its own. `@property` with `initial-value: 1` would give the same guarantee without the second name, but SEED supports Safari 15, which shipped individual `scale` in 14.1 and `@property` only in 16.4 — a registration would hold on newer engines alone, turning a missing fallback into a bug that reproduces on the old browsers exclusively. `pressScale` from `@seed-design/css/press-scale` drops its baked-in fallback accordingly. Rules already written as `var(--seed-press-scale, 1)` keep working unchanged; the fallback is simply never taken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhMn2efohuBnctJ2V53xJg
The ratio now resolves to 1 wherever no measurement exists, so a consumer's own rule reads `var(--seed-press-scale)` bare. Removes the warning callout that existed only to stop readers from forgetting the fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhMn2efohuBnctJ2V53xJg
Puts every element the pressed scale touches on one screen so the behaviour can be pressed rather than reasoned about: SEED components, the three ways a custom component opts in, and the cases where it deliberately does nothing — a missing ref, a missing class, and a mark whose ancestor pinned its scale. Each panel carries a readout of the size the element published, the ratio derived from it, and the distance it actually loses. Reading it as a table rather than prose is the point: a column of identical shrink values is how "a 32px button and a 343px button both lose the same 2px" becomes visible without doing arithmetic. The readout resolves a mark's override the way the recipe does, so a Checkmark inside a ListItem reports the 1 it actually applies instead of the ratio it derived and never uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhMn2efohuBnctJ2V53xJg
The hook required callers to hand it a stable ref. A fresh function every render
makes React detach and re-attach, which unobserved the element and republished
the vars on every render instead of on every resize — so the rule landed on every
caller, and getting it wrong was silent.
The hook now tells a genuine detach from an identity change itself. It tracks
what React last handed it against what the observer is watching, and defers the
`null` teardown to a microtask: a commit runs to completion synchronously, so the
re-attach an identity change performs has already landed by the time the
microtask runs, and the two disagree only when the element really left.
Teardown still runs on a real detach — the element is unobserved and its vars
removed — so an element that keeps the class but loses the ref falls back to a
ratio of 1 rather than holding a stale one.
Also records why the `offsetWidth` / `offsetHeight` fallback and the dropped
`box` option are live paths rather than formalities: `borderBoxSize` and
`observe({ box })` both landed in Safari 15.4, and SEED supports Safari 15.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A7ciSNTQ7FfFZa6WuJ3ZfU
`usePressScale` hands back two halves that have to land on the same element, and dropping either one is silent — the ratio stays 1 and nothing else about the element changes. `PressScale` pairs them up: wrap the element and the wiring is done. It slots onto its child rather than rendering an element of its own. A wrapper would be the element measured, and a block wrapper around an inline-block button reports the container's width, so the ratio would come out wrong rather than absent; the resting `scale: 1` would settle on the wrapper too, leaving the child to become a stacking context mid-press. That is also why there is no `asChild`. Applying the ratio is still the consumer's to write. In development the component warns when the child never forwarded `ref` to a DOM element or dropped the `className` it was handed — the two ways slotting fails without leaving a trace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A7ciSNTQ7FfFZa6WuJ3ZfU
`@seed-design/css/class-names` held one export, and that export was already re-exported from `@seed-design/css/press-scale` beside the two values its rules consume. The split published one constant under two names and left the library importing from a different entrypoint than the docs teach. `class-names` also had no organising principle to say what else belonged in it. Every other entrypoint is named for a concept — `theming`, `breakpoints`, `press-scale` — so a second behaviour class can get its own drawer once one exists. Neither entrypoint has shipped, so there is nothing to deprecate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A7ciSNTQ7FfFZa6WuJ3ZfU
Wrapping is the shorter path and the one that cannot be half-applied, so it takes the main flow and `usePressScale` moves to a section for elements you can't wrap. The advice to compose refs with `useComposedRefs` goes with it — the hook now survives an unstable ref on its own. Adds registering the ratio as a Tailwind utility, since a project applying it in more than one place would otherwise repeat the variable at every call site. The note has to say `@utility` rather than `@theme`: a `@theme` entry resolves at `:root`, so the ratio an element computed from its own size never reaches it and the class is generated while nothing shrinks. The transition can't be folded in the same way — `transition-*` applies Tailwind's own duration and timing function over the ones the variable carries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A7ciSNTQ7FfFZa6WuJ3ZfU
Each panel carried a table reporting the size the element published, the ratio derived from it, and the distance it lost, and a further panel compared the current size observation against a reimplementation of the Safari 15.0-15.3 path. Both had to duplicate the derivation in JS to report on it — a second implementation to keep true to the first, for values devtools already shows. The specimens are the point, and pressing them is what makes the behaviour visible. Removes the readouts along with the `data-probe` labels that only fed them, leaving the panels and the section descriptions that say what to look for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A7ciSNTQ7FfFZa6WuJ3ZfU
# Conflicts: # packages/css/all.layered.min.css # packages/css/all.min.css # packages/css/base.layered.min.css # packages/css/base.min.css
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A7ciSNTQ7FfFZa6WuJ3ZfU
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A7ciSNTQ7FfFZa6WuJ3ZfU
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0124vgqeUQ5E9RyBmE8c8FFv
The feature is called scale feedback everywhere a consumer can see it, and dynamic scale names the runtime derivation behind it, so the implementation can change without the public name moving. Names that hold a number end in -scale: --seed-dynamic-scale-basis max(height, width / 4, 24) --seed-dynamic-scale the derivation, invalid until measured --seed-feedback-scale var(--seed-dynamic-scale, 1) — what rules read `--seed-element-width` / `--seed-element-height` keep their names: they report size and know nothing about the feature consuming it. None of this has shipped, so no alias is left behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyadPw4ZLB6nGDSkoCLNAf
$duration.pressed-scale and $timing-function.pressed-scale keep their names while everything around them moves to scale feedback. Both already ship as CSS custom properties, so renaming them is a breaking change that belongs on its own schedule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyadPw4ZLB6nGDSkoCLNAf
Both Tailwind tabs opened by promising `active:feedback-scale` as an alternative, before saying the class has to be registered to exist. Naming the condition and the action first puts the payoff last, where it reads as a result rather than a claim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyadPw4ZLB6nGDSkoCLNAf
The list stages sat flush against the panel, so a pressed row's background cleared the panel by 6px at the sides and nothing at the top — no radius can look concentric against that. Pad the stage vertically by the same 6px the background is already held off the row's sides, and take that 6px off the panel's radius. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyadPw4ZLB6nGDSkoCLNAf
008877a to
4284002
Compare