Repository navigation
feat(theme): align component recipes with the Open Components standard - #382
Merged
Merged
Conversation
Components need one colour for every focus ring, and state hooks the Open Components pages style from: hover only where a pointer can hover, larger hit areas for coarse pointers, and the aria-invalid, readonly and data-loading attributes. color.focus is a reference to color.primary in the light and dark themes, and the focus.outline-color default points at it, so focus rings look the same. Reference colours get no level, shade or tint keys in the preset's return type, matching what it generates. The new modifiers are hover-capable, any-pointer-coarse, aria-invalid, readonly-attr and data-loading; readonly and read-only still emit :read-only. The position-try-fallbacks utility sits beside position-area and ships without defaults.
A project that replaces the default colours and defines no focus colour threw on the new @color.focus default, so useFocusState now references @color.primary there, as it did before. The colour docs now say the focus token colours the global focus ring, and that colors and themes.dark.colors each set one theme.
Meet the Open Components Button page's stylesheet rules in useButtonRecipe: label contrast in every state and theme, a color.focus ring, an underlined link variant, min-height sizing with wrapping labels, logical inline padding, a 44px touch hit area, hover only on devices that can hover, and disabled, pressed and loading styled from their attributes, with forced colors and more contrast handling. Add the recipe's tests, a contrast test, focus, pressed and loading stories, and an Accessibility section on the docs page.
Stretch the heading's link over the card, keep actions above it, and draw the focus ring around the card in color.focus. Hover only on hover-capable devices, full-strength borders with more contrast, system colours in forced colours mode, logical padding, and no clipping. Add a card-media part for the media rules.
The field surface now meets the Input page's stylesheet rules. The focus ring is color.focus in every state, and invalid shows through the error border and an inset shadow. The wrapper reads aria-invalid, readonly and disabled from the nested input through :has(), hover styles sit behind hover-capable, and sizes get a min-height. On touch screens the input's text is at least 16px with a 44px hit area. Disabled fields turn GrayText in forced colors, and borders use the text color with more contrast. The placeholder, prefix and suffix take the field's text color at 75%, the prefix and suffix use logical padding, the read-only state keeps the fill, and light and dark ghost fields take their own fill on a page of the other mode, so text keeps 4.5:1 in both themes. Textarea, input-date and the OTP cell share the surface and get the same changes.
The modal panel now fits the screen less a 1rem margin, caps its width per size in rem, wraps long titles, keeps its header and footer in view while the body scrolls, and locks the page while an overlay is open. Its border stays visible in forced colors mode and goes to full strength with more contrast. The shared section recipes pad with logical properties, and soft panels gain a border in the dark theme, so the drawers inherit both.
The prefers-contrast override wrote the --color--gray-450 and --color--gray-500 names by hand, so an instance with its own variable naming kept the faint borders. It now sets the two variables through variable(), which follows the instance's naming.
Popovers need to open in the top layer beside their trigger, flip when there's no room, fit the screen and keep their edge in every contrast mode, the way the Open Components Popover page's reference CSS does. The popover and its arrow gain a placement axis, top by default, the side the arrow pointed from before. On an element with the popover attribute, the setup callback positions it with position: fixed, inset: auto, the new popover.offset margin, position-area from placement and position-try-fallbacks with three @position-try rules, and fades it in only without a reduced-motion preference. Every other element keeps position: relative. The panel never outgrows the screen less spacing.xl and wraps long words, and its body scrolls. More contrast and forced colours draw its edge as an outline, since the colour compounds' border classes come after the base modifier classes. The arrow draws in currentColor, turns with placement through logical properties, sits on the facing edge in the top layer, and steps aside in forced colours, which would fill its transparent sides.
A spinner should match the text around it, keep showing that something's happening when people ask for less motion, and stay visible on a dark page. The spinner, circle and text recipes gain an em size, 1em wide and tall, and the spinner no longer shrinks in a flex row. With reduced motion the circle pulses to 40% and back every 1.6 seconds instead of turning, and the arc stops growing and shrinking. The pulse's duration and iteration count are important, because the sanitize preset stops every animation under reduced motion. The light colour switches to white in the dark theme, where gray-700 was nearly invisible on the page. The Storybook spinner hides its svg from assistive technologies, renders as a span so it can sit in a paragraph or heading, and gets an em story. The docs page describes what the recipes do for each rule.
The radio, radio-field and radio-group recipes now meet the stylesheet rules of the Open Components Radio Group page: a @color.focus ring, rings with 3:1 in both modes, rem sizing with the standard's row heights and 44px rows on touch screens, forced-colors and more-contrast handling, and the group's legend, hint, error and aria-invalid state. Stories use a fieldset with a legend and cover the focus, invalid and disabled states, and both docs pages describe the accessibility behaviour.
… page Each off track now keeps 3:1 against the page in both themes, with a knob at 3:1 against it, and the focus ring is color.focus. The field takes its invalid and read-only looks from aria-invalid and aria-readonly, sizes in rem, mirrors its knob in right-to-left layouts and stops sliding with reduced motion. The wrapper label gives a 24px hit area, 44px on touch screens. Forced colors and more contrast draw the switch in system and full-strength colors.
…ld docs The 16px touch-screen text sat on every field's nested control, so on the Input Date it grew only the separators. It now sits in the Input recipe beside the touch hit area. The Input Date and OTP pages, the Input page and the changeset no longer claim a red invalid ring, a primary ring on the wrapper or a ghost without a fill, and each story's input has its own id.
The footer's actions wrap onto a new row, so a third action no longer clips at 320px, and the panel scrolls as a whole when even its header and footer don't fit. The docs no longer credit other recipes' focus rings to color.focus, and the changeset opens its sentences on words.
The browser's own [popover] padding inset the header and footer of a top-layer popover, so their separators stopped short of the panel's edges. The panel now resets it. A top-layer arrow goes first in the panel, so the header drops its top separator after a leading arrow too. The popover/top-layer, popover/points-at-trigger, popover/edge and popover/reduced-motion tests now assert the offset margin and its default, every triangle's widths and offsets, the arrow's centring, borders that differ from their background, and the @starting-style block. The docs page states the 0.375rem arrow default, shows the anchoring selectors and the arrow's place in the panel, and the stories keep their ids apart on the docs page.
Style aria-expanded like pressed, give each size a min-width matching its min-height, color and size the loading spinner from the button, and let the forced colors disabled rule outrank the dark theme's pressed rules.
The dark spinner was white on the light page in its stories, and the label, which takes the spinner's color, fell under 4.5:1 for primary in the dark theme and for dark on the overlay. Primary switches to primary-400 in dark mode, and the overlay's backdrop goes from 50% to 75% black, the modal overlay's value. The stories show dark on that backdrop, and the contrast test checks the indicator at 3:1 and the label at 4.5:1 there. The pulse uses the ease-in-out easing token, and the em size sits 0.125em below the baseline so it centres on the text.
The checkbox and checkbox-field recipes now meet the stylesheet rules of the Open Components Checkbox page. The focus ring uses color.focus, and the unchecked border moves to gray-450 and gray-500, so it has 3:1 against the page in both themes. The box is sized in rem, the wrapper draws a 1.5rem hit area around it and pads each label to that height, and a wrapping label keeps the box beside its first line in either direction. Read-only and invalid come from aria-readonly and aria-invalid, never :read-only or :invalid. More contrast draws the border at full strength, and forced colors mode marks checked, focus and disabled with system colours. A disabled box inside the wrapper no longer dims twice.
Open a tooltip with the popover attribute in the top layer, place it with a new placement axis and CSS anchor positioning, and flip it when there's no room. Wrap the content at 32ch, keep its edge in forced colours mode, and only fade it in under reduced motion. The arrow turns with placement and sits on the edge that faces the trigger.
Keep the card recipe working when a project defines no color.focus, stretch the link over the border, draw the forced-colours border from the setup selector, and add a focused story.
The large field is already taller than the input's 44px hit area, so the docs and the recipe comment no longer say it reaches past the field, and the story comment says the docs page shows the primary story's id twice.
Drop pointer-events: none from the disabled states, and put each button's resting colors back while it's disabled through trailing compound variants, so hover and press change nothing. Keep the pressed background on a disabled toggle in forced colors and turn loading buttons GrayText, as the reference CSS does. Give every border 3:1 against the page, set the label color in every hover block so an <a href> button keeps it over the global a:hover styles, and test the borders and the rule order.
Assert every forced-colors block of the checkbox field exactly, the neutral dark re-assertions included, and pin the checked and indeterminate marks to their own paths, so swapping a mark or dropping a re-assertion fails. Assert each size's hit-area position. Re-assert the invalid border on checked and indeterminate boxes instead of relying on emission order, take the dashed read-only border from the border-style token, and match the double-dim reset to the label's descendant selector. The docs show the alignment the recipe emits and say which states need wiring.
Assert each placement of the arrow whole, so a second coloured side, a dropped fill or a moved inset fails. Show the anchoring CSS in the docs, say that the width changed, and keep the stories' ids and anchor names unique on the docs page.
🚀 Deploying Preview to Cloudflare 🚀Preview Deployments by commit
|
🚀 Deploying Preview to Cloudflare 🚀Preview Deployments by commit
|
alexgrozav
marked this pull request as ready for review
October 30, 2026 09:17
alexgrozav
commented
Oct 30, 2026
| | `sm` | `@2` × `@2` | `@font-size.xs` | | ||
| | `md` | `@3` × `@3` | `@font-size.sm` | | ||
| | `lg` | `@4` × `@4` | `@font-size.md` | | ||
| | `em` | `1em` × `1em` | Inherits the surrounding text | |
Contributor
Author
There was a problem hiding this comment.
This should be named inherit instead of em.
| | `light` | `@color.gray-700` | Loading on dark backgrounds, stays fixed in dark mode | | ||
| | `dark` | `@color.white` | Loading on light backgrounds, stays fixed in dark mode | | ||
| | `primary` | `@color.primary`, `@color.primary-400` in dark mode | Default loading state, primary actions | | ||
| | `light` | `@color.gray-700`, `@color.white` in dark mode | Loading on the page, switching to white on the dark theme's background | |
Contributor
Author
There was a problem hiding this comment.
Declined: Light is supposed to keep its color in both light and dark mode, same for Dark color. The light-dark adaptive theme is Neutral. This applies to all components that have a light/dark/neutral setup.
alexgrozav
marked this pull request as draft
October 30, 2026 14:22
The size that follows the surrounding text is now `inherit` in the spinner, spinner-circle and spinner-text recipes, their stories and docs. It hasn't shipped yet, so nothing breaks. The `light` spinner stays `gray-700` in the dark theme, as `dark` stays white, so only `neutral` adapts. The contrast test checks `light` on light surfaces and `dark` on dark ones in both themes, and the Light story shows it on a light surface.
7 of 13 tasks
Only `neutral` adapts to the theme. The `light` and `dark` ghost fields stay transparent in both themes, the `light` and `dark` soft overlay panels lose their dark-theme-only border, `light` and `dark` switches keep their own border with more contrast, and the Input Date separator follows the field's muted text, so it keeps one colour on `light` and `dark` fields.
7 of 13 tasks
…ders The Input Date wrapper gets `-light` and `-dark` class hooks, so only those fields' separators take the muted text. The `neutral` separator keeps `@color.text-weak`. The field contrast tests check the `light` and `dark` borders against the pages of both themes again.
The separator's dark override was nested in a raw selector as `&:dark`, which compiles to a `:dark` pseudo-class that no browser matches, so the `neutral` separator never took `@color.gray-400`. It's now written under the selector the `dark` modifier emits, and the test checks the emitted CSS.
The Input, Textarea, OTP and Input Date grids show `light` on a white surface and `dark` on a dark one, in both themes, as the pages document them. The OTP cells and Input Date segments get accessible names.
7 of 13 tasks
This branch was successfully deployed
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.
Description
Brings the styles of the 12 components that Styleframe and Open Components share in line with the accessibility rules on their Open Components pages that a stylesheet decides: Button, Card, Checkbox, Checkbox Group, Modal (the Dialog page), Input, Popover, Radio Group, Spinner, Switch, Textarea and Tooltip. Nothing in the published API is renamed or removed.
Shared foundation
color.focustoken, which defaults to@color.primaryin the light and dark themes. Thefocus.outline-colordefault now points at it.hover-capable:@media (hover: hover)any-pointer-coarse:@media (any-pointer: coarse)aria-invalid:&[aria-invalid="true"]data-loading:&[data-loading]readonly-attr:&[readonly]position-try-fallbacksutility.Every component
@color.focusin every state, invalid included.Additions
placementaxis on Popover and Tooltip. It defaults to today's side and flips with CSS anchor positioning.1emSpinner size.card-mediarecipe.autogrowaxis on Textarea, which defaults tofalse.The global link hover (
useLinkElement) now applies only on hover-capable devices, so a tapped<a href>, such as a Button rendered as a link, keeps its own colours on a touch screen.Layers merged into this branch: #365, #367, #368, #369, #370, #371, #372, #374, #375, #376, #377, #379, #380, #381, #385.
Related issue
None.
Type of change
Checklist
feat(theme): …)pnpm build:nodocs && pnpm lint && pnpm typecheck && pnpm testand everything passespnpm changeset) for changes to publishable packages, or this change only touches docs/storybook/app/playground/testsdist/,.styleframe/)mainand stays focused in scopeNotes / screenshots
pnpm build:nodocs,pnpm lintandpnpm typecheckpass. Inpnpm test, every package passes except@styleframe/playground'stest/theme.test.ts. It fails 8 tests under Node 26, wherelocalStorageis undefined, and fails the same way without this change. CI runs Node 22.placementnames after the browser flips the popover.