Skip to content

Lean on the platform: native overlays, themed select picker, and native-grade CSS polish - #132

Open
developit wants to merge 20 commits into
mainfrom
claude/great-einstein-x105es
Open

Lean on the platform: native overlays, themed select picker, and native-grade CSS polish#132
developit wants to merge 20 commits into
mainfrom
claude/great-einstein-x105es

Conversation

@developit

@developit developit commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Summary

Two rounds of work that push kinu further onto native DOM and CSS features, so behaviour that
competitors reimplement in JavaScript comes from the browser instead. Round 1 was the styling layer
(squircles, materials, elevation, scroll-driven effects). Round 2 went after the platform layer —
the overlay engine, the <select> picker, and a set of small feature-detected wins.

Everything is progressively enhanced: each feature sits behind @supports or a runtime check, and
browsers without it keep exactly today's behaviour. Rebased onto the swipe-to-dismiss work (#124)
and the theming-token work (#126 / #129) — see the two Interaction sections below.

Bug fixes

Four real defects surfaced while auditing the DOM layer:

  • Toasts rendered below open modals. The container was a z-index: 1000 fixed div, and
    nothing can out-z-index the top layer that showModal() uses. The container is now a manual
    popover, so it joins the top layer. Top-layer stacking is insertion-ordered, so it re-promotes
    itself when new toasts arrive and when another top-layer element opens.
  • AlertDialog light-dismissed on outside click. It was a verbatim re-export of Dialog, so an
    alert could be dismissed by clicking away — the one thing an alert shouldn't allow. It is now a
    real wrapper defaulting to closedby="closerequest" (Escape still closes).
  • The adaptive mobile="drawer" conversion never fired for native events. Its beforetoggle
    interception listened on the bubble phase, and toggle events don't bubble; it now listens on
    capture.
  • Modal scrims blurred instantly instead of fading. The scrim's opacity transitioned over
    300ms but backdrop-filter was static, so the blur hit full strength on the first frame, held
    until display: none, then snapped back to sharp — measured mid-open, the page behind was fully
    blurred while the dim was only half in. Blur now transitions with the opacity and tracks the
    finger during a swipe.

Overlay engine: the Popover API

Popover, DropdownMenu and Combobox now open via the Popover API (<dialog popover>), which buys:
top layer (no more clipping under an ancestor's overflow, no z-index fights), native light
dismiss and Escape, and correct nested-menu management.

This engages only where the Popover API and CSS anchor positioning are both available. That
pairing is deliberate: a top-layer popover without anchor positioning would resolve its fallback
position against the viewport instead of its trigger, so the two ship together or not at all.
Everything else keeps the previous <dialog>.show() path.

  • Triggers issue command="toggle-popover"; the command polyfill maps popover commands onto dialog
    methods on the fallback path, makes close close the target however it's shown, and restores
    dialog.show()'s focus behaviour after opening a popover so menu arrow-key navigation still works.
  • The polyfill stays installed alongside native Invoker Commands on purpose — kinu issues commands
    from non-<button> sources, and its preventDefault suppresses native double-invocation.
  • Modals (Dialog/Sheet/Drawer/ContextMenu) default to closedby="any"; the JS backdrop hit-test
    remains as a fallback and honours the same attribute either way.

Themed <select> picker

Where appearance: base-select is supported, the open drop-down renders with kinu's overlay
treatment — squircle radius, elevation, material translucency, item-styled options with a tinted
::checkmark, and the @starting-style open animation. Zero JavaScript; other engines keep
their native popup untouched.

Styling polish

  • Squircle corners (corner-shape) on rounded-rectangle surfaces, via a curated allow-list so
    pills, circles and thin bars keep their true shape.
  • Radii compensated for squircles. A squircle cuts its corner less deeply than a circle of the
    same radius, so the same number reads as under-rounded. The --k-radius-1..4 ramp now hangs off
    a derived --k-corner-radius, which under @supports (corner-shape: squircle) resolves to
    calc(var(--k-radius) * 1.8408964) — the ratio that matches corner depth, 1 + 2^(-1/4). You
    still theme in round-corner terms via --k-radius, and browsers without corner-shape render
    exactly the radii they do today. Components too small to hold the larger radius cap it, so
    checkboxes, kbd, badges, tabs, toggles, skeletons and the date/time inputs keep their shape
    instead of clamping to pills.
  • The elevation scale (--k-shadow-1/2/3) is tinted from a single hue rather than pure black:
    retune every elevated surface by moving --k-shadow-hsl alone, and it darkens under
    prefers-contrast. Values and role ordering are unchanged, so themes overriding the three tokens
    are unaffected.
  • Translucent "material" surfaces on floating menus, hover cards and toasts.
  • Modal scrim blur is now a token (--k-backdrop-blur) that animates with the dimming and tracks
    swipe gestures, and drops to none under prefers-reduced-transparency and forced-colors.
  • Scroll-driven edge fades on ScrollArea; native ::scroll-marker carousel dots.
  • Tabular figures, accent/caret theming, and optical text wrapping/trimming
    (text-wrap: balance/pretty, text-box: trim-both).

Smaller platform wins

  • Firefox finally gets themed scrollbarsscrollbar-color for engines without
    ::-webkit-scrollbar, gated so Chromium keeps its richer WebKit styling — plus
    scrollbar-gutter: stable to stop layout shift.
  • Sticky table headers cast a shadow only while actually stuck, via scroll-state() container
    queries.
  • Anchored overlays hide when their trigger scrolls out of view (position-visibility).
  • New opt-in virtual attribute on List and Tree: content-visibility renders rows on demand with
    no virtualization library, and unlike JS virtualization the content stays in the DOM for
    find-in-page and assistive tech.
  • Combobox/Listbox highlight matched substrings with the Custom Highlight API — no wrapper
    elements, no DOM mutation.
  • HoverCard wires up interestfor + popover="hint" where Interest Invokers exist, so keyboard
    focus and touch long-press can finally open it
    (the CSS :hover path had neither). Attributes
    are only set when supported, so the fallback is untouched elsewhere.
  • New opt-in <ColorPicker eyedropper> renders a screen-sampling button where the EyeDropper API
    exists.

Behaviour changes worth knowing

  • AlertDialog no longer closes on an outside click (see above).
  • Toasts now paint above modal surfaces rather than behind them.
  • Popover/Dropdown/Combobox content is in the top layer on supporting browsers, so it is no longer
    clipped by ancestor overflow — and consumer CSS targeting the shown state should match
    :is([open], :popover-open) rather than [open] alone.
  • Corner radii render larger under squircles by design (the compensation above); themes that
    hard-code px radii opt out of it, as they already opt out of --k-radius.
  • Modal scrim blur ramps rather than appearing instantly; override --k-backdrop-blur (including
    none) to tune or disable it.

Interaction with #124 (swipe-to-dismiss)

Both branches reworked the overlay layer, so the merge was resolved on meaning rather than text:

Interaction with #126 / #129 (theme system + token refactor)

Those PRs introduced a radius ramp and an elevation scale covering the same ground as this branch,
so the two were reconciled into one model rather than left side by side:

  • Radius — the compensation moved under the ramp: --k-radius-1..4 hang off
    --k-corner-radius, so every step is compensated from one anchor and components keep using the
    ramp vocabulary the refactor introduced. That removed ~30 files of token renaming from this branch.
  • Elevation — kept the merged --k-shadow-1/2/3 names and role ordering (themes override all
    three, so renaming would break them) and moved this branch's tinting onto them.
  • base.css — dropped this branch's hard-coded ::selection rule in favour of the tokenized
    --k-selection-bg one, which it would otherwise have shadowed and made un-themeable; the Firefox
    scrollbar now reads --k-scrollbar-thumb so both engines follow the same token.
  • Tabs needed new caps: the density ramp makes a tab ~25px tall inside a 32px strip, where the
    compensated radius clamps to a pill. The strip caps at 0.75rem and the tab (and its sliding
    indicator) at 0.5625rem — the strip's cap minus its 3px padding, so corners stay concentric.

Size

Measured against main rebuilt at the current commit:

Scenario main (brotli) this PR delta
One component (Button) 0.78 KiB 0.78 KiB ±0.00
A few components (Button + Input + Dialog + Popover + Tabs) 2.89 KiB 3.02 KiB +0.13
Nearly all components (namespace import) 14.32 KiB 16.80 KiB +2.48

The overlay engine is close to JS-neutral — the polyfill gating offsets the additions — so most of
the delta is CSS, and a single-component build is unchanged.

Testing

pnpm build, pnpm tsc and the full suite pass (62 tests, including the swipe suite running
against the rewritten commands.ts); Biome holds at its existing baseline. Verified in Chromium 152:

  • Desktop — dropdown opens as :popover-open with real-click light dismiss, Escape, arrow-key
    navigation and item-click close; AlertDialog refuses outside clicks while Dialog accepts them;
    themed base-select picker; combobox filtering with Highlight ranges registered; hover card opens
    on keyboard focus; toast renders above an open modal.
  • Touch emulationmobile="drawer" converts correctly ([open], :modal, --swipe: y,
    scroller jumped to its open position) and dismisses on a swipe to the rail; Drawer and Sheet
    swipe behaviour intact; [k="sheet-panel"] present alongside closedby.
  • Backdrop — blur sampled frame by frame through open and close, tracking opacity to two
    decimal places in both directions; blur follows scroll position mid-swipe; none under
    emulated prefers-reduced-transparency with the dimming retained.
  • Radii, three ways — squircle (compensated), the no-corner-shape fallback (identical to
    today's radii), and under the claw theme, where claw's --k-radius flows through the
    compensation while its own literal radii and its --k-shadow-* / --k-selection-bg overrides
    still win. No component exceeds 45% of its short side in any mode.

https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma

@netlify

netlify Bot commented Jun 25, 2026

Copy link
Copy Markdown

Deploy Preview for kinu-sh ready!

Name Link
🔨 Latest commit 13f51a3
🔍 Latest deploy log https://app.netlify.com/projects/kinu-sh/deploys/6a8b3096e1c1c20008dfe281
😎 Deploy Preview https://deploy-preview-132--kinu-sh.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Size Change: +7.17 kB (+16.98%) ⚠️

Total Size: 49.4 kB

📦 View Changed
Filename Size Change
benchmarks/size/.tmp/few-components/bundle.css 3.15 kB +272 B (+9.45%) 🔍
benchmarks/size/.tmp/nearly-all-components/bundle.css 14.3 kB +1.88 kB (+15.11%) ⚠️
benchmarks/size/.tmp/nearly-all-components/bundle.js 5.83 kB +1.36 kB (+30.34%) 🚨
benchmarks/size/.tmp/one-component/bundle.css 780 B +1 B (+0.13%)
dist/index.css 14.4 kB +1.91 kB (+15.29%) ⚠️
dist/index.js 8.44 kB +1.72 kB (+25.64%) 🚨
dist/themes/claw.css 1.72 kB +19 B (+1.12%)
ℹ️ View Unchanged
Filename Size
benchmarks/size/.tmp/few-components/bundle.js 491 B
benchmarks/size/.tmp/one-component/bundle.js 203 B

compressed-size-action

@developit
developit force-pushed the claude/great-einstein-x105es branch from 255712d to d2ae776 Compare August 14, 2026 13:56
@developit developit changed the title Add native CSS features and material design polish Lean on the platform: native overlays, themed select picker, and native-grade CSS polish Aug 15, 2026
@developit
developit force-pushed the claude/great-einstein-x105es branch 3 times, most recently from ebe77c7 to 216e51c Compare August 15, 2026 17:30
claude added 12 commits August 15, 2026 20:39
…levation)

Adopt a tier of modern CSS, all progressively enhanced behind @supports so
unsupported browsers keep today's look. Defaults-on with a revert knob.

Tokens (variables.css):
- --k-corner-shape (round → squircle under @supports; override on :root to revert)
- elevation scale --k-shadow-hsl/-color/-sm/-md/-lg (tinted, themeable; dark
  mode shadows go pure black). Completes the --k-shadow-color token tabs already
  referenced; replaces five hand-rolled shadows across card/dialog/popover/
  toast/sheet/sidebar/dropdown/hover-card.
- material tokens --k-material-blur/-opacity

base.css:
- corner-shape: squircle on a curated allow-list of rounded rectangles (pills,
  circles and thin bars are excluded so they keep their true shape)
- text-box: trim-both on label-like controls for optical vertical centering
- font-variant-numeric: tabular-nums on numeric surfaces
- accent-color / caret-color / ::selection tinted with the theme accent
- text-wrap: balance (titles) / pretty (body)
- prefers-contrast and forced-colors backstops for the new effects

Components:
- Overlays (popover/dropdown/context-menu/combobox, toast, hover-card) gain
  translucent backdrop-filter "materials", guarded by reduced-transparency /
  contrast and falling back to the solid background
- scroll-area: scroll-driven edge-fade mask via @property-interpolated lengths;
  inactive (no fade) when content doesn't overflow
- carousel: opt-in native CSS pagination dots (<CarouselContent dots>) via
  scroll-marker-group/::scroll-marker/:target-current, plus scroll-snap-stop;
  existing JS prev/next buttons unchanged

biome.json: disable noUnknownMediaFeatureName (false-positives on valid modern
media features), mirroring the existing noUnknownProperty override.

Size (brotli): +0.11 KiB per component, +0.74 KiB for the near-complete bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
…SS virtualization

- base.css: standard scrollbar-color theming for engines without
  ::-webkit-scrollbar (Firefox finally gets themed scrollbars), gated so
  Chromium keeps the richer WebKit styling; scrollbar-gutter: stable on
  scrollables to prevent layout shift.
- table: sticky headers gain a downward shadow only while actually stuck,
  via scroll-state() container queries.
- popover/dropdown/combobox: position-visibility: anchors-visible hides
  anchored overlays when their trigger scrolls out of view.
- list/tree: opt-in `virtual` attribute — content-visibility: auto +
  contain-intrinsic-size render rows on demand with no virtualization
  library, keeping content in the DOM for find-in-page and a11y.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
Where `appearance: base-select` is supported, the open dropdown renders with
kinu's overlay treatment — squircle radius, elevation, material translucency
(same reduced-transparency/contrast guards), item-styled options with a
tinted ::checkmark, and the @starting-style open animation. Engines without
support keep their native popup untouched. Zero JS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
Popover, Dropdown and Combobox overlays now display via the Popover API
(<dialog popover>) when both the Popover API and CSS anchor positioning are
available: top layer (immune to ancestor overflow clipping and z-index),
native light dismiss and Escape, and correct nested-menu management. Without
that pair they fall back to the previous <dialog>.show() path — a top-layer
popover without anchors would position against the viewport, so the two
features are gated together (usePopoverOverlays).

- Triggers send command="toggle-popover"; the command polyfill maps popover
  commands to dialog methods on fallback browsers, makes `close` close the
  target however it is shown (hidePopover when :popover-open), and mirrors
  dialog.show()'s focus behavior after opening a popover so menu arrow-key
  navigation keeps working. The polyfill stays installed alongside native
  Invoker Commands on purpose: kinu supports non-button command sources and
  its preventDefault suppresses native double-invocation.
- The adaptive mobile-drawer intercept now listens for beforetoggle in the
  capture phase — native popover/dialog toggle events don't bubble, so the
  window-level bubble listener only ever saw the polyfill's synthetic event.
- Modals (Dialog/Sheet/Drawer/ContextMenu) default to closedby="any" for
  native light dismiss; the JS backdrop hit-test remains as a fallback and
  now honors the closedby attribute either way.
- AlertDialog is a real wrapper defaulting closedby="closerequest" — alerts
  no longer close on outside click (previously light-dismissed like any
  Dialog), on both native and fallback paths.
- Overlay CSS styles [open] and :popover-open identically, and neutralizes
  UA [popover] inset so the absolute-position fallback keeps its geometry.

Verified in Chrome 149: anchor-positioned popover mode, real-click light
dismiss, Escape, arrow-key navigation, item-click close, combobox filter and
pick, adaptive mobile drawer (true :modal, bottom sheet), Dialog outside-
click close, AlertDialog refusing outside click while Escape still closes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
…voker hover cards, eyedropper

Four small feature-detected platform wins:

- Toast: the container is a manual popover, so toasts join the top layer and
  render above open modal dialogs/sheets/drawers — previously any showModal()
  surface covered them regardless of z-index. Top-layer stacking is insertion
  -ordered, so the container re-promotes itself on new toasts and (via a
  capture-phase toggle listener) whenever another top-layer element opens.
  Verified above an open modal in both orderings.
- Combobox/Listbox: filtered items paint their matched substring via the
  Custom Highlight API (::highlight(k-filter-match)) — zero DOM mutation, no
  wrapper spans.
- HoverCard: where Interest Invokers exist, the trigger's link/button gets
  interestfor + the content becomes popover="hint" — keyboard focus and touch
  long-press now open the card (the CSS :hover path had neither), in the top
  layer, anchored via the implicit anchor. Attributes are only set when
  supported so the CSS fallback keeps working everywhere else.
- ColorPicker: opt-in <ColorPicker eyedropper> renders a screen-sampling
  button (EyeDropper API browsers only) that writes back to the input and
  fires input/change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
Covers both rounds: the Popover API overlay engine and `closedby` modals,
the squircle/material/elevation styling pass, the base-select picker, and the
opt-in virtual/Highlight/interest-invoker/EyeDropper additions — calling out
the three behaviour changes consumers can notice (AlertDialog no longer
light-dismisses, toasts render above modals, mobile="drawer" converts again).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
The committed numbers were measured before this branch was rebased onto the
swipe-to-dismiss work, so they understated the merged size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
The scrim faded over 300ms but `backdrop-filter` was static, so it was never
part of the transition: the blur hit full strength on the very first frame and
held there until display:none, then snapped back to sharp on close. Measured
mid-open, the page behind was fully blurred while the dim was only ~half in.

Blur now transitions alongside opacity — closed state blur(0), open state the
new --k-backdrop-blur token — so the two read as one effect at every frame
(verified: opacity 0.48 → blur 0.48px, 0.94 → 0.94px, symmetric on close).
The swipe keyframes interpolate it too, so on touch the blur tracks the finger
with the dimming rather than sitting at full strength for the whole drag
(75% → 0.75px, 50% → 0.51px, 25% → 0.25px).

The radius moves to --k-backdrop-blur so it can be themed or switched off in
one line, and base.css drops it to none under prefers-reduced-transparency and
forced-colors, keeping the dimming that does the actual layer separation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
A squircle is the superellipse |x|^4 + |y|^4 = r^4; `round` is the circle
|x|^2 + |y|^2 = r^2. At equal r the squircle bulges toward the corner, cutting
it less deeply, so corners read as under-rounded once corner-shape applies.

Matching corner depth — the setback along the 45° diagonal, r(1 - 2^(-1/k)) —
gives an exact ratio, since with a = 2^(-1/4) it collapses to (1 - a²)/(1 - a):

    r_squircle / r_round = 1 + 2^(-1/4) = 1.8408964…

Rather than restate the scale, components read a derived --k-corner-radius,
which base.css redefines inside @supports (corner-shape: squircle) as
calc(var(--k-radius) * 1.8408964). So --k-radius keeps its meaning and its
0.5rem default — you theme in round-corner terms and the compensation follows
any value you set, including one scoped to a subtree — and browsers without
corner-shape, where the corners really are circular, render exactly the radii
they do today.

Verified against the browser rather than assumed: Chrome computes `round` as
superellipse(1) and `squircle` as superellipse(2), i.e. the CSS parameter is a
log2 exponent (k = 2^s), confirming k=2 and k=4. Measuring the rendered
geometry by hit-testing reproduces both curves to within a constant ~1.4px
bias. Of the candidate equivalences — corner depth (1.841), removed area
(1.715) and apex curvature (2.523) — depth matched the reference most closely
side by side; curvature clearly overshot.

Small components can't hold the larger radius: past ~50% of the short side the
browser clamps to a pill, which turned checkboxes into radios and kbd keys
into lozenges. Those cap via min() at the largest radius their box supports.
The caps are inert in the fallback, so those components also come back to
their original values there.

The swipe drawers keep painting their corners from the raw --k-radius: those
are radial-gradient circles, not corner-shape, and an 8px circular corner has
the same depth as a 15px squircle — compensating them would over-round them.

Swept every allow-listed component in both modes: no radius exceeds 45% of its
short side, and the fallback reproduces today's radii exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
…oken model

Follow-up to the rebase onto the theming-token work (#126/#129), resolving the
two systems into one rather than leaving both.

Radius: the compensation no longer renames what components read. --k-radius-1..4
now hang off --k-corner-radius instead of --k-radius, so the whole ramp is
compensated from a single anchor and every component keeps using the ramp
vocabulary the token refactor introduced. That drops ~30 files of renaming from
this branch.

Elevation: kept the merged --k-shadow-1/2/3 names and role ordering (claw
overrides all three, so renaming them would break it) and moved the tinting
onto them — the scale derives from --k-shadow-hsl and darkens under
prefers-contrast, which is what our --k-shadow-sm/md/lg carried. Values are
unchanged aside from the tint.

base.css: dropped our hard-coded ::selection block in favour of the tokenized
--k-selection-bg rule, which ours would otherwise have shadowed and made
un-themeable; the Firefox scrollbar now reads --k-scrollbar-thumb so both
engines follow the same token.

Tabs needed new caps: the density ramp makes a tab ~25px tall inside a 32px
strip, where the compensated radius clamps to a pill. The strip caps at 0.75rem
and the tab (and its sliding indicator) at 0.5625rem — exactly the strip's cap
minus its 3px padding, so the corners stay concentric.

Verified in all three modes: squircle (compensated), the no-corner-shape
fallback (identical to today's radii), and under the claw theme, where claw's
--k-radius flows through the compensation while its own literal radii and its
--k-shadow-*/--k-selection-bg overrides still win.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
@developit
developit force-pushed the claude/great-einstein-x105es branch from 216e51c to 8402a35 Compare August 15, 2026 20:45
claude added 6 commits August 15, 2026 21:45
The squircle radius scaling is only correct while the corners actually are
squircles, but --k-corner-shape could be restyled without it — leaving
squircle-sized radii on a circular corner.

base.css now reads the modifier as the fallback of --k-corner-scale:

    --k-corner-radius: calc(var(--k-radius) * var(--k-corner-scale, 1.8408964));

so setting `--k-corner-scale: 1` anywhere turns the scaling off. Because kinu
never declares the variable itself, a setter is the only declaration there is —
it wins outright, with no !important and no specificity or source-order
contest. Themes pair it with whatever they set --k-corner-shape to.

claw does exactly that. Its radii come from Claude's design tokens (8px card /
6px pill / 4px), drawn as circular arcs, and it hard-codes them rather than
deriving from --k-radius — so squircles left them under-rounded, and its
non-overridden components (button, input) drew scaled radii next to
un-scaled literal ones. It now sets --k-corner-shape: round with
--k-corner-scale: 1.

Two scope bugs surfaced while verifying, both from variables.css declaring
these tokens on `:where(:root), :where([data-color-scheme="light"])` while
base.css only overrode :root:

- A [data-color-scheme="light"] subtree fell back to the layered `round`
  default and rendered circular, un-scaled corners inside a squircle page. The
  overrides now match that selector, so the scoped reset lands on kinu's
  defaults rather than half of them. Deliberately NOT [data-color-scheme] in
  general: the dark block doesn't re-declare these, so asserting there would
  clobber an ancestor theme's opt-out in every dark subtree.
- The scale itself had to reset at that scope (`--k-corner-scale: initial`),
  or a theme's `1` inherited past the reset and left the subtree drawing
  squircles at un-scaled radii.

Verified in Chromium across the matrix: default (squircle, scaled), claw
(round, un-scaled, literals intact), no-corner-shape fallback (identical to
today's radii), shape-set-without-scale (still scaled, as documented), and
shape+scale (round, un-scaled) — each checked at the root and in light and
dark scoped subtrees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
The material (saturate(180%) blur(20px)) is meant for a small menu panel
floating over content. The mobile-drawer block overrode its background-color
but never its backdrop-filter, so the blur carried into drawer mode — where the
element is a completely different shape.

On touch that is worst: swipe mode makes the overlay a transparent box the size
of the whole viewport (inset: 0; height: 100dvh; background-color: transparent,
with the sheet surface painted in background-image), so a backdrop-filter on it
blurred the entire screen at 20px rather than anything behind a menu. Measured
at 390x844: the panel box is the full viewport with backdrop-filter
saturate(1.8) blur(20px).

Neutralized in the drawer block, which outranks the material rule (0,2,0 vs
0,1,0) regardless of source order. Desktop is untouched — a 59x77 panel over an
80%-opaque background keeps its vibrancy. The scrim continues to do the
separating, and still settles at its own blur(1px).

Applies to all three adaptive overlays (Popover, DropdownMenu, ContextMenu),
which share the block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
k-swipe-fade animated backdrop-filter alongside opacity, and in swipe mode that
animation is scroll-driven — so it re-evaluated on every frame of a drag, and
each new radius invalidates the blurred backdrop, forcing a fresh full-viewport
blur per frame. That is the most expensive place to pay for it (mid-gesture, on
mobile, on the weakest hardware) to move a 1px blur nobody can perceive while
the panel is sliding under their finger. Opacity composites for free.

The keyframes now animate opacity only. The blur still fades in with the scrim
on open and out on close, via the transition on ::backdrop — that path is
bounded (300ms, twice per overlay) and it is where the difference is actually
visible: previously the blur hit full strength on the first frame while the dim
was only half in.

Verified on a touch viewport: at open +60ms opacity 0.22 pairs with
blur(0.224px), and across drag positions opacity tracks the scroll (0.76 / 0.50
/ 0.26) while the blur holds steady at blur(1px).

Kept at 300ms rather than shortened: matching the dim's duration is what makes
the two read as one effect, and the saving from a shorter ramp is a handful of
frames on open/close, which was never the costly part.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
…s it

A scrim's own opacity composites its filtered backdrop, so the blur arrives and
leaves with the dimming on its own. Verified by isolating it: a backdrop-filter
with no background colour at opacity 0.25 / 0.5 / 0.75 / 1 renders the page
progressively softer, sharp through to fully blurred.

Transitioning backdrop-filter on top of that was ramping the same effect twice,
and paying for it — a radius change invalidates the blurred backdrop, so every
frame of the ramp re-blurs the viewport. Dropped from the ::backdrop rules
(dialog, drawer, sheet, adaptive mobile drawers) along with the blur(0) closed
state and the @starting-style resets; the radius is now simply constant, which
is also what sidebar already did.

Nothing animates a filter radius anymore — neither the transitions nor (as of
the previous commit) the scroll-driven swipe keyframes — so the blur can afford
to be a real one: --k-backdrop-blur goes 1px → 4px. At a hairline it had to be
cheap and static to be defensible; blended by opacity it reads as a gradual
frost through the whole open, drag and close.

Measured across a drag: opacity tracks the scroll (0.76 / 0.50 / 0.26) while
the radius holds at blur(4px).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
Both are full-bleed panels, so content wider than the viewport should be cut
rather than open a sideways scroll nobody asked for. overflow becomes
`hidden auto` on [k="drawer-content"] and on the mobile-drawer variants of
Popover / DropdownMenu / ContextMenu.

Only safe because those swipe vertically. Sheet (--swipe: x) and Sidebar
(--swipe: -x) dismiss along the horizontal axis and keep overflow-x scrollable
— clipping there would disable the gesture outright. Confirmed after the
change: sheet still computes overflow-x: auto with scroll-snap-type: x
mandatory, while drawer and mobile="drawer" compute hidden/auto with y
mandatory and still scroll vertically.

Nested scrollers are unaffected — only the panel's own content box clips — so
the carousel-inside-a-drawer pattern the demo uses still scrolls sideways
(verified: scrollLeft moves 0 → 400 inside a drawer whose overflow-x is
hidden).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
…CSS bloat

Seven defects found reviewing the branch end to end, each verified before and
after:

- AlertDialog.Content wasn't forwardRef-tagged, so a ref landed on the wrapper
  component instead of the <dialog> — a regression from the old verbatim Dialog
  re-export, which the ref-forwarding tests only cover for Dialog.Content.
- Sidebar stopped dismissing on a scrim click. Gating the JS backdrop hit-test
  on native closedby support assumed a modal dialog light-dismisses by default;
  it does not — the missing-value default computes to `closerequest` (measured),
  so a dialog with no attribute is Escape-only. Sidebar was the one overlay
  without `closedby`; it now sets `any` like the rest.
- prefers-contrast re-tinted --k-shadow-hsl on :where([k]), but --k-shadow-1/2/3
  are substituted where they're declared, so no elevated surface ever saw it.
  Moved to the scopes variables.css builds them on — same shape as the earlier
  [data-color-scheme] fix.
- scroll-area/style.css imported variables.css, which that file explicitly
  forbids: @layer-wrapped imports can't be de-duplicated, so the whole token
  block was inlined a second time. Removing it cuts dist/index.css by 5.7 kB
  (87.4 → 81.8), and the tokens now appear exactly once.
- The popover-mode gate tested position-anchor alone while the CSS requires
  position-anchor *and* position-try-fallbacks. In the gap an overlay would
  enter the top layer without the anchored rules and land at the bottom of the
  viewport; the gate now mirrors the @supports condition exactly.
- HoverCard's :popover-open reset top/left but not right/bottom, leaving the UA
  [popover] inset:0 — the card pinned itself to the viewport bottom (measured
  rectTop 564 of 600, now 4). popover/style.css already had this neutralization.
- The eyedropper button was insertAdjacentElement'd into a Preact-managed
  parent, so it drifted once a later sibling rendered. It is a fragment sibling
  now — no wrapper element either way — and reads the input via
  previousElementSibling instead of an injected reference.

Also: filtered lists clear the shared k-filter-match highlight when they close,
rather than leaving Ranges pointing into a hidden list; and the new opt-in props
(virtual, eyedropper, dots) plus AlertDialog's changed dismissal are documented
in docs/metadata.mjs, the source generate-docs.mjs builds from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
@developit
developit marked this pull request as ready for review August 20, 2026 12:30
claude added 2 commits August 20, 2026 13:42
Three touch-native behaviours, the first two entirely in CSS.

**`<select>` opens as a bottom sheet on phones.** `appearance: base-select`
opts out of the OS picker, so the themed drop-down was landing as a small
anchored menu on touch — a regression against what a bare `<select>` already
did. At ≤640px the picker is now viewport-positioned, slides up from the
bottom edge with a dimmed scrim, and gets thumb-sized rows. Also fixes the
picker's `box-shadow`, which referenced a `--k-shadow-lg` token that does not
exist, so it rendered flat.

**Tooltips answer a press and hold.** `:hover` is emulated and sticky on
touch, so a tap pinned a tooltip open until you tapped elsewhere. On coarse
pointers the tooltip is driven by `:active` — which tracks the finger exactly
and is dropped when a scroll starts — behind a 500ms `transition-delay`,
Android's long-press threshold. The `@starting-style` rule is what makes the
delay bite: without a start value to interpolate from no transition runs, and
with no transition there is no delay either.

**Drawers can open to a detent.** The touch rail is exactly one viewport tall,
which makes the scroll offset equal the exposed height of the sheet, so every
resting height is just a snap position. `--k-drawer-height` adds a third one
between dismissed and fully expanded: the sheet opens there, expands when
flicked up, and stops there again on the way down. Mandatory snapping refuses
to rest anywhere without a snap area — not even at a scroll boundary — so the
fully-expanded snap moves onto the last child, freeing `::after` to be parked
one viewport plus the detent down the scroller.

While the sheet still has room to grow, scrollable subregions inside it are
frozen via a `scroll-state()` container query so the drag grows the sheet
instead of scrolling a list; once expanded they scroll normally and chain
their overscroll back out. This is the only way to get the hand-off: a touch
gesture latches onto whichever scroller it starts on and is never re-targeted
mid-drag, so the list has to already not be a scroller when the finger lands.
Unset, drawers behave exactly as before.

Also fixes Drawer never calling `installSwipe()`. Every other swipe surface
does; Drawer relied on one of them sharing the page, and on touch a Drawer by
itself opened to a blank screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
…r docs

The mobile picker was a bottom sheet in position only. It now borrows Drawer's
surface wholesale — `--k-background`, the same top radius, 1rem padding, the
0.8 scrim with `--k-backdrop-blur`, and on touch the grab pill painted from the
same gradient at the same size and offset — so it reads as the same kind of
object as every other kinu sheet.

The exit was also broken: the desktop fade's `opacity: 0` base leaked into the
closed state, so closing snapped the sheet invisible on the first frame and the
slide-out was never seen. Same fix the mobile drawer already carries.

Swipe-to-dismiss is the one thing it can't borrow. `::picker(select)` is a UA
pseudo-element: there is no box to hold a reference to, its scroll offset can
be neither read nor set, and it emits no scroll events — so the rail
architecture has nothing to drive it. Tap-outside and Escape dismiss natively.

Also moves the grab pill's clearance off the first child and onto the rail
above it. It was `> :first-child { margin-top: 1rem }`, which a heading with
`margin: 0` — the usual way to start a sheet — took away, landing the text on
top of the pill.

The Drawer docs example gains a second drawer: 50 animals in a ScrollArea,
with a 50dvh detent so the touch hand-off is visible — the sheet opens half
height, a flick up expands it, and only then does the list scroll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants