Skip to content

feat(theme): align component recipes with the Open Components standard - #382

Merged
alexgrozav merged 65 commits into
mainfrom
align-component-styles-uxf-487
Oct 30, 2026
Merged

alexgrozav merged 65 commits into
mainfrom
align-component-styles-uxf-487

Conversation

@alexgrozav

@alexgrozav alexgrozav commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

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

  • A color.focus token, which defaults to @color.primary in the light and dark themes. The focus.outline-color default now points at it.
  • Five modifiers:
    • hover-capable: @media (hover: hover)
    • any-pointer-coarse: @media (any-pointer: coarse)
    • aria-invalid: &[aria-invalid="true"]
    • data-loading: &[data-loading]
    • readonly-attr: &[readonly]
  • A position-try-fallbacks utility.

Every component

  • Focus rings use @color.focus in every state, invalid included.
  • Hover styles apply only on devices that can hover.
  • 44 × 44px hit areas on coarse pointers.
  • Forced-colours and more-contrast handling, and reduced motion wherever something moves.
  • States are styled from the attributes the standard names, beside the existing class variants.
  • Contrast is tested for every colour and variant in both themes.
  • Each component's stories cover every styled state, and its docs page gains an Accessibility section.

Additions

  • A placement axis on Popover and Tooltip. It defaults to today's side and flips with CSS anchor positioning.
  • A 1em Spinner size.
  • A card-media recipe.
  • An autogrow axis on Textarea, which defaults to false.

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

  • 🐞 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 💥 Breaking change (fix or feature that changes existing behavior)
  • 📖 Documentation
  • ♻️ Refactor / internal (no functional change)
  • 🔧 Build / tooling / CI

Checklist

  • My commits follow Conventional Commits with a package scope (e.g. feat(theme): …)
  • I ran pnpm build:nodocs && pnpm lint && pnpm typecheck && pnpm test and everything passes
  • I added a changeset (pnpm changeset) for changes to publishable packages, or this change only touches docs/storybook/app/playground/tests
  • I added or updated tests where relevant
  • I updated documentation where relevant
  • I did not edit generated files (dist/, .styleframe/)
  • My PR targets main and stays focused in scope

Notes / screenshots

  • pnpm build:nodocs, pnpm lint and pnpm typecheck pass. In pnpm test, every package passes except @styleframe/playground's test/theme.test.ts. It fails 8 tests under Node 26, where localStorage is undefined, and fails the same way without this change. CI runs Node 22.
  • axe-core finds no WCAG 2.2 A or AA violations in the 215 stories of these components, in light or dark.
  • Forced colours, touch and zoom were checked with Chromium emulation only, not in Windows High Contrast, Safari, Firefox or on a real touch device.
  • Popover and Tooltip arrows are hidden in forced colours, and an arrow keeps the side its placement names after the browser flips the popover.

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.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Cancelled 🚫

View logs ↗
d9032c0 2026-10-30T16:46:37.552Z View logs ↗
  • Build: Failed ❌

View logs ↗
1abc3ef 2026-10-30T16:25:17.228Z View logs ↗
  • Build: Failed ❌

View logs ↗
183538f 2026-10-30T09:06:03.804Z View logs ↗

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Failed ❌

View logs ↗
1abc3ef 2026-10-30T16:27:33.370Z View logs ↗
  • Build: Failed ❌

View logs ↗
183538f 2026-10-30T09:07:18.413Z View logs ↗

| `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 |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
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.
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.
…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.
@alexgrozav
alexgrozav marked this pull request as ready for review October 30, 2026 16:46
@alexgrozav
alexgrozav merged commit a70e8e7 into main Oct 30, 2026
12 of 14 checks passed
@alexgrozav
alexgrozav deleted the align-component-styles-uxf-487 branch October 30, 2026 16:46

This branch was successfully deployed

2 active deployments
Preview – styleframe-dev — d9032c03 Deployed Oct 30, 2026 by vercel[bot]
Preview – styleframe-dev-storybook — d9032c03 Deployed Oct 30, 2026 by vercel[bot]
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.

1 participant