Lean on the platform: native overlays, themed select picker, and native-grade CSS polish - #132
Open
developit wants to merge 20 commits into
Open
Lean on the platform: native overlays, themed select picker, and native-grade CSS polish#132developit wants to merge 20 commits into
developit wants to merge 20 commits into
Conversation
✅ Deploy Preview for kinu-sh ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Contributor
|
Size Change: +7.17 kB (+16.98%) Total Size: 49.4 kB 📦 View Changed
ℹ️ View Unchanged
|
developit
force-pushed
the
claude/great-einstein-x105es
branch
from
August 14, 2026 13:56
255712d to
d2ae776
Compare
developit
force-pushed
the
claude/great-einstein-x105es
branch
3 times, most recently
from
August 15, 2026 17:30
ebe77c7 to
216e51c
Compare
…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
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
force-pushed
the
claude/great-einstein-x105es
branch
from
August 15, 2026 20:45
216e51c to
8402a35
Compare
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
marked this pull request as ready for review
August 20, 2026 12:30
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
@supportsor a runtime check, andbrowsers 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:
z-index: 1000fixeddiv, andnothing can out-
z-indexthe top layer thatshowModal()uses. The container is now a manualpopover, 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.
Dialog, so analert 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).mobile="drawer"conversion never fired for native events. Itsbeforetoggleinterception listened on the bubble phase, and toggle events don't bubble; it now listens on
capture.
300ms but
backdrop-filterwas static, so the blur hit full strength on the first frame, helduntil
display: none, then snapped back to sharp — measured mid-open, the page behind was fullyblurred 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, noz-indexfights), native lightdismiss 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.command="toggle-popover"; the command polyfill maps popover commands onto dialogmethods on the fallback path, makes
closeclose the target however it's shown, and restoresdialog.show()'s focus behaviour after opening a popover so menu arrow-key navigation still works.from non-
<button>sources, and itspreventDefaultsuppresses native double-invocation.closedby="any"; the JS backdrop hit-testremains as a fallback and honours the same attribute either way.
Themed
<select>pickerWhere
appearance: base-selectis supported, the open drop-down renders with kinu's overlaytreatment — squircle radius, elevation, material translucency, item-styled options with a tinted
::checkmark, and the@starting-styleopen animation. Zero JavaScript; other engines keeptheir native popup untouched.
Styling polish
corner-shape) on rounded-rectangle surfaces, via a curated allow-list sopills, circles and thin bars keep their true shape.
same radius, so the same number reads as under-rounded. The
--k-radius-1..4ramp now hangs offa derived
--k-corner-radius, which under@supports (corner-shape: squircle)resolves tocalc(var(--k-radius) * 1.8408964)— the ratio that matches corner depth,1 + 2^(-1/4). Youstill theme in round-corner terms via
--k-radius, and browsers withoutcorner-shaperenderexactly 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.
--k-shadow-1/2/3) is tinted from a single hue rather than pure black:retune every elevated surface by moving
--k-shadow-hslalone, and it darkens underprefers-contrast. Values and role ordering are unchanged, so themes overriding the three tokensare unaffected.
--k-backdrop-blur) that animates with the dimming and tracksswipe gestures, and drops to
noneunderprefers-reduced-transparencyandforced-colors.::scroll-markercarousel dots.(
text-wrap: balance/pretty,text-box: trim-both).Smaller platform wins
scrollbar-colorfor engines without::-webkit-scrollbar, gated so Chromium keeps its richer WebKit styling — plusscrollbar-gutter: stableto stop layout shift.scroll-state()containerqueries.
position-visibility).virtualattribute on List and Tree:content-visibilityrenders rows on demand withno virtualization library, and unlike JS virtualization the content stays in the DOM for
find-in-page and assistive tech.
elements, no DOM mutation.
interestfor+popover="hint"where Interest Invokers exist, so keyboardfocus and touch long-press can finally open it (the CSS
:hoverpath had neither). Attributesare only set when supported, so the fallback is untouched elsewhere.
<ColorPicker eyedropper>renders a screen-sampling button where the EyeDropper APIexists.
Behaviour changes worth knowing
clipped by ancestor
overflow— and consumer CSS targeting the shown state should match:is([open], :popover-open)rather than[open]alone.hard-code px radii opt out of it, as they already opt out of
--k-radius.--k-backdrop-blur(includingnone) 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:
mobile="drawer"overlays still take the--modal→showModal()path, which sets[open]—exactly what Swipe-to-dismiss for Drawer, Sheet, Sidebar and mobile="drawer" overlays #124's swipe CSS keys on. Popover mode applies on desktop.
whole viewport, so its dismiss taps land inside the dialog box, where native light dismiss can
never fire. The
closedby-aware backdrop fallback runs after it.SheetContentkeeps Swipe-to-dismiss for Drawer, Sheet, Sidebar and mobile="drawer" overlays #124's[k="sheet-panel"]wrapper and gainsclosedby="any".k-swipe-fadekeyframes now interpolatebackdrop-filtertoo, so the scrim blur tracksthe swipe alongside the dimming it already drove.
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:
--k-radius-1..4hang off--k-corner-radius, so every step is compensated from one anchor and components keep using theramp vocabulary the refactor introduced. That removed ~30 files of token renaming from this branch.
--k-shadow-1/2/3names and role ordering (themes override allthree, so renaming would break them) and moved this branch's tinting onto them.
base.css— dropped this branch's hard-coded::selectionrule in favour of the tokenized--k-selection-bgone, which it would otherwise have shadowed and made un-themeable; the Firefoxscrollbar now reads
--k-scrollbar-thumbso both engines follow the same token.compensated radius clamps to a pill. The strip caps at
0.75remand the tab (and its slidingindicator) at
0.5625rem— the strip's cap minus its 3px padding, so corners stay concentric.Size
Measured against
mainrebuilt at the current commit: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 tscand the full suite pass (62 tests, including the swipe suite runningagainst the rewritten
commands.ts); Biome holds at its existing baseline. Verified in Chromium 152::popover-openwith real-click light dismiss, Escape, arrow-keynavigation and item-click close; AlertDialog refuses outside clicks while Dialog accepts them;
themed
base-selectpicker; combobox filtering with Highlight ranges registered; hover card openson keyboard focus; toast renders above an open modal.
mobile="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 alongsideclosedby.decimal places in both directions; blur follows scroll position mid-swipe;
noneunderemulated
prefers-reduced-transparencywith the dimming retained.corner-shapefallback (identical totoday's radii), and under the claw theme, where claw's
--k-radiusflows through thecompensation while its own literal radii and its
--k-shadow-*/--k-selection-bgoverridesstill win. No component exceeds 45% of its short side in any mode.
https://claude.ai/code/session_01F3Jt1mTthRAT6HwyXQqqma