Commits
* Capture full content for tall visual elements beyond viewport (#271)
Element-level screenshots of components taller than the 800px viewport
(e.g. the token board with 128 leaves, which is ~7k px) only painted
the first viewport's worth of content. The PNG was sized to the full
document height but rows below the fold were blank because Playwright
omits content outside the viewport by default.
Add captureBeyondViewport: true to the visual project's toMatchScreenshot
config so Playwright renders the full element regardless of viewport
bounds. This applies to all 126+ existing captures; only the handful
that are taller than 800px change in practice.
Add a post-capture check that warns when scrollHeight exceeds the
viewport but no child element actually reaches below it — a sanity
check that catches scrollHeight inflation without false positives.
The visual regression comparison pipeline in scripts/visual-regression
will flag any capture with dimension mismatch as 'changed', so if
captureBeyondViewport is ever removed the regression is caught at the
image-comparison level.
Closes #271.
* Fix lint and type errors in visual capture config
- Type the resolveScreenshotPath callback params explicitly so the as any
cast on toMatchScreenshot doesn't lose type safety for known properties
- Suppress the no-console lint warning on the post-capture warn call
* Use CDP viewport resize instead of captureBeyondViewport config
captureBeyondViewport: true in the vitest toMatchScreenshot config
caused ALL visual captures to time out in CI (Matcher did not succeed
in time). The Playwright internal handler only sets captureBeyondViewport
when !fitsViewport, but forcing it globally adds overhead even for
elements that fit within the viewport.
Instead, resize the viewport to the element's full height via CDP in
captureVisual only when the element extends beyond the 800px viewport.
This ensures Playwright renders the full content without affecting
the majority of captures that fit within the viewport.
The viewport metadata embedded in the filename is captured before
resizing (so it always shows the configured 1024x800 viewport). The
viewport is restored to its original size after capture.
* Configure vitest API write and Vite FS allow for visual captures in worktree
Two guards in @vitest/browser 4.1.x block screenshot writes
when vitest runs in a git worktree:
- assertBrowserApiWrite requires api.allowWrite and
browser.api.allowWrite to be truthy
- assertBrowserFileAccess requires the capture path to be
within Vite's server.fs.allow scope (fails when
VISUAL_CAPTURE_DIR points outside the worktree root)
Add api.allowWrite at project level, browser.api.allowWrite
in the visual project, and server.fs.allow pointing at
.artifacts (found by searching up from the config dir) so
captures written to the cache directory pass the Vite FS
check.
The previous PR (#284) landed AGENTS.md content that wasn't formatted
by vp fmt, causing check:format-root to fail on main.
The issue called for a deliberate decision on whether the root 'check'
task should include 'test'. The answer settled during #270 was no:
browser tests need Playwright and are markedly slower, so test runs in
its own workflow (storybook.yml). Update AGENTS.md to reflect this so
contributors know what 'check' actually covers.
Closes #267.
* Move component props tables into page routes
* Complete component props page split
* Update deps
* Update dependencies in pnpm workspace for improved stability and features
* Distill documentation examples
* Align type scale specimens to guide
* Address documentation example feedback
* Remove overflow wrapper in typography example
* Update Knip config
* Give docs pages and the playground room to breathe
Docs pages now separate their prev/next footer from page content with
real spacing (mt-12/pt-8, growing at md/xl) instead of the stock 16px
gap, so a card-grid-ending page doesn't read as one continuous block.
The editor loading pill gets the same background scrim the preview
loading pill already has, so skeleton bars don't run through it. The
playground's resize separator now shows a visible grab handle at rest
in both orientations instead of only appearing on hover/focus/active.
Closes #260
* Address playground visual review
* Finish playground loading and resize affordances
* Use a semantic surface for loading pills
* Fix the theming and styling docs that no longer match the code
Every entry point into the theme and styling layers misled a reader,
because the docs were written before the colour engine was rewritten.
AGENTS.md tells agents to read these docs before changing code, so a wrong
doc generates wrong code.
foundation.ts and define-theme.ts both said the background input is not
consumed and that the canvas still comes from neutral. buildTheme now
takes background directly as the canvas anchor for every family's ramp and
for the elevation surfaces, so anyone tracing the canvas was sent to the
wrong module. The same overclaim had also leaked into the public JSDoc in
theme/index.tsx, which promised a single-value-accent throw and listed an
incomplete set of gates for ThemeContrastError. Internal stage numbers are
gone repo-wide, per the documentation guide.
The authoring page told theme authors that non-text pairs must reach 3:1.
That is not what the compiler does. It now separates what is guaranteed
from what is only measured: text and on-solid pairs are hard-gated at
4.5:1, border.focus and border.control at 3:1, the five intent border
pairs are measured but cannot fail the build, and decorative borders are
never checked at all. An advisory border must never be the only cue for a
state, so the page says so. color.background and ThemeGenerationError are
documented for the first time.
The page no longer promises a "no accessible lightness" error for a
single-value accent. A sweep of the on-solid gate at the accent adaptation
targets found no failing hue or chroma, so the search loop and its throw
are unreachable under the current constants and the error cannot fire.
Authors are pointed at the reachable path instead. Whether to keep that
search as defence-in-depth or delete it is an accent-policy decision,
left alone here.
The styling guide told authors to call recipeInLayer, which no longer
exists, and never documented the recipe engine they actually have to use.
It now covers single-part and slotted recipes, how variant types are
derived rather than hand-maintained, and the shared input-state selectors.
The testing guide's visual test example used a Button tone that does not
exist, so the canonical template did not compile.
Docs and comments only: no non-comment source line changed.
* Make theming and styling copy easier to read
* Share one top nav across docs, playground, and 404
Docs and the playground carried different chrome: the docs sidebar owned
the wordmark, search, nav links, and theme controls, while the playground
had a bespoke bar with a plain-text title and one "Docs" link. Nothing
carried across but the theme controls.
Both surfaces now render one SiteNav: wordmark, Docs/Playground/Storybook,
search, and the appearance controls. The 404 renders it too, so no surface
is left with its own bar.
The docs move from Fumadocs' docs layout to its notebook layout with
nav.mode 'top', which spans the header full width and starts the sidebar
beneath it. SiteNav is supplied through the layout's header slot, so it
also carries the sidebar collapse and drawer triggers, and the sidebar
footer no longer renders the theme switch.
Below lg the docs hand the destinations to the sidebar, which already
lists them there; surfaces with no sidebar keep them on a second nav row
below md. Below md the appearance controls move behind a disclosure --
they are 230px of pills that cannot share a 320px row with the wordmark,
search, and the sidebar trigger, and letting them wrap made the docs bar
113px tall while the layout still offset content by 56px.
Pre-bundle lucide-react: Fumadocs' search trigger and popover import from
its barrel, which unbundled is ~1750 module requests. Adding that to the
playground, which also loads Monaco, exhausted the browser's connection
pool in dev and the page never hydrated.
* Cover shared navigation behavior
The 4.5 text ratio was declared in build-theme.ts, define-theme.ts and
scale.ts, each with a comment promising it matched the others. clampUnit
was exported properly from color.ts and then redeclared privately in
define-theme.ts and scale.ts. The rounding headroom appeared under two
names and the search step under three, all with the same values. Worst,
the intent role lists lived in both the compiler and the semantic map,
where the failure was asymmetric and half silent: adding a role to the
map alone emitted an ungated colour, while adding it to the compiler
alone threw an internal error.
All of it now lives in contrast-policy.ts, which imports nothing, so it
cannot close a cycle. Consolidation was value- and order-neutral, and
each call site keeps its own loop arithmetic so float accumulation cannot
shift.
Two pieces of code decided whether a solid can carry readable text. The
pre-conditioner tested three states at a plain 4.5, the generator tested
two at 4.55, and the third state the pre-conditioner checked was a pressed
colour the engine no longer generates since pressed reuses the hover step.
The pre-conditioner now calls the generator's exported gate, so it can
never be stricter than the solver, and the rule that a single-value accent
never fails the build holds for one reason instead of two.
Contrast checks now record whether they are a hard gate. The diagnostics
inspector was reconstructing that with a regular expression over token
paths, which was correct only until another pair became advisory.
The goldens do not move. The ticket predicted they would, but both bundled
themes author explicit per-mode accents, so the pre-conditioner is never
called for either, and a sweep of the reachable hue and chroma space shows
the mode target always clears the gate. Both goldens were regenerated
deliberately and came back byte-identical rather than being rebaselined.
* Improve docs example loading
* Polish loading state changes
Two Storybook surfaces rendered the same tokens by different means. The
hand-written one was 939 lines listing all 128 contract leaves by hand,
rebuilding each token's path with string helpers that duplicate logic
contract.ts already owns. The contract-driven one derived everything but
covered colours only.
Generalise the board to the whole contract and delete the hand-written
story: 1227 lines down to 650, while coverage grows from 47 colour leaves
to all 128. The parts only the hand-written story could do survive. The
live resolved-value column still watches the colour-mode attribute and
shows the computed value, now reading the bare custom-property name from
flattenThemeContract() instead of re-parsing it. The previews a plain
swatch cannot express — the animated motion dot, the spacing bar, the
radius corner, the shadow sample and the control-finish gradient — are now
a lookup keyed by token family, typed from the contract so a new family is
a type error until it has a renderer.
Renamed to TokenBoard, since the board is no longer colour-only. It was
never exported from theme/index.tsx or listed in package.json exports, so
the rename is internal.
The coverage assertion from the previous commit is strengthened rather
than weakened: it now pins the rendered swatch count to all 128 contract
leaves instead of the 47 colour ones. The diagnostics story is untouched,
because it reads the compiler's diagnostics rather than resolved custom
properties, and the v2 goldens are byte-identical.
Note the four visual captures are renamed rather than genuinely widened.
The capture pipeline paints only the first viewport of a tall story, so
the board's new families sit in the unpainted region. The DOM-level gate
does cover all 128 leaves; the visual one still does not, and that is
tracked separately.
The recipe engine had two features with no consumers. `extend` was
declared on both config shapes and implemented across three functions,
including the recursive config merging that was also the only untested
logic in the file. Slotted compound variants were similar: the four
slot-using recipes declare no compound variants, and the two recipes that
do are both single part. Deleting both takes recipe.ts from 457 to 354
lines. Single-part compound variants stay, because button and text use
them. Re-add either the day a second recipe needs it.
Delete the nine hand-written Text variant types rather than deriving
them. Four string unions and three key arrays duplicated what the recipe
already knows with nothing type-checking the two against each other, so
adding a wrap value or a colour role would have gone stale silently. All
nine were exported publicly with no consumers, invisible to knip because
the barrel is an entry point. Text's own component already derives its
prop types by indexing TextVariants, and button, field and icon already
export only the aggregate, so the barrel now matches that convention.
input-states.css.ts emits no CSS at all, yet the suffix marked it as
having side effects and kept it out of consumer tree shaking. Renamed to
input-states.ts.
The barrel exported loadingSkeleton as a plain class string among twelve
callable recipes, which invited a consumer to call it; it is now
loadingSkeletonClassName. Renaming rather than wrapping it in recipe()
keeps the emitted CSS untouched. Two recipes also both exported something
named `icon`, so icon-button's is now iconButtonIcon.
The emitted stylesheet is byte-identical at 78007 bytes and the v2
goldens are unchanged, which is expected: the deleted code paths were
never exercised, and the renames do not reach content-hashed class names.
Delete the theme tests that could not fail. validateContrast already
hard-gates text against every surface at 4.5:1 and border.control against
canvas and recessed at 3:1, throwing before any assertion recomputing
those ratios from the emitted CSS could run. Replace each with the honest
statement of the same property: that building the theme does not throw.
The neutral half of the dark subtle-hover test was dead for the same
reason, so keep only the accent pair, which is genuinely ungated.
Drop the loading-skeleton tautology, which asserted a contrast ratio
above 1 immediately after asserting the two colours differ. The real
gate is in loading-skeleton.browser.test.ts, samples the animated pulse
and requires 1.4:1, so it both subsumes and beats the static check.
Drop the migration-era assertions: the hand-written list of 25 removed
tokens, and the hardcoded 128 leaf count, which contract.test.ts already
asserts properly. Derive the count from the contract instead. Drop the
theme test that read the combobox stylesheet source as text and grepped
it for a string.
Make the local and CI checks agree. `pnpm run check` ran check:barrels
but not root formatting; CI ran root formatting but not check:barrels, so
a barrel violation could merge and a docs-only change could pass locally
and fail CI. Adding //#check:format-root to the check task and having CI
run that same task leaves both sides on an identical 28-task graph, with
--affected only ever an additive PR filter. knip stays a separate
unscoped invocation because it is repo-wide.
Run the docs app tests in CI, which nothing did. Four of them failed on
main because the harness rendered ThemeControls as a sibling of the
StoryWrapper that owns the only IconSpritesheetProvider; wrapping
DocsThemeRoot mirrors what __root.tsx actually does. Ignore the docs
failure screenshots, which were written into a tracked path.
Give the token board the coverage check its own comment claims. The
swatch count and the tree builder now both derive from the contract.
Verified by dropping a leaf: 3 unit tests fail and the story fails with
"expected 46 to be 47".
* Move viewport and fullscreen controls into the preview panel
The playground header mixed page-level appearance controls with preview-only
controls, and the fullscreen buttons never selected an appearance/tone, so
enter-fullscreen rendered as the loudest element on the page and
exit-fullscreen was a white icon on a white circle with a leftover gradient
overlay — a non-text contrast failure.
Add a PreviewToolbar owned by the preview panel that holds the viewport
toggle and a single fullscreen control. Enter-fullscreen uses the ghost
appearance so it reads as a quiet toolbar utility; exit-fullscreen uses the
solid appearance so both the icon and its container clear 3:1 non-text
contrast against the toolbar background. The header keeps only whole-page
controls (brand, docs link, theme controls).
* Update fullscreen button
The Capsize trim compound variants set fontSize and lineHeight, and
vanilla-extract applies compound variants after simple ones, so they won
over the shouldInheritFont variant's `inherit`. Family, weight and colour
inherited; size and line height did not. The broken combination was the
default one, because trim is only disabled automatically when lineClamp
is set, and every existing test for the prop passed shouldDisableTrim,
so the broken path had no coverage.
Condition the trim compounds on shouldInheritFont: false. The fix sits in
the recipe rather than the Text component because the recipe is exported
publicly from @luke-ui/react/recipes, so a component-level fix would
leave direct recipe consumers broken.
Also validate two theme inputs the emitter trusted. The scrim was the
only authored colour written into the stylesheet unchecked, because it is
deliberately excluded from OKLCH parsing (its alpha channel does not fit
the pattern) and nothing was put in its place, so a scrim carrying a
semicolon or brace emitted malformed CSS into all four theme scopes. It
now shares one isUnsafeCssValue helper with the depth and control finish
checks. That helper tests the type before trimming, so a rung explicitly
set to undefined produces the validator's message naming the field
instead of a TypeError, and defineTheme filters undefined values before
merging so an explicit undefined falls back to the curated default rather
than overwriting it.
Emitted theme CSS is unchanged: the v2 goldens and visual captures are
byte-identical.
* Clean up the docs page header
Render frontmatter descriptions through Fumadocs' DocsDescription slot
instead of an MDX blockquote, so the page's own summary no longer reads
as a quotation. Collapse Copy Markdown, View as Markdown, and Edit on
GitHub from a row of filled pill buttons into a single compact "View
options" popover built from Fumadocs' own popover primitives, so the
first paragraph of prose is the first thing a reader meets.
* Add rel=noreferrer noopener to the page-actions popover links
Every new-tab link in the view-options popover handed the destination a
live window.opener reference. Matches the rel pair Fumadocs' own
EditOnGitHub and footer links already set. Also documents why the copy
callback deliberately has no try/catch: useCopyButton only shows
"Copied" once its promise resolves, so a fetch or clipboard failure
already leaves the control honest without one.
* Reject on a non-ok markdown fetch instead of copying the error page
fetch resolves normally on an HTTP error response — it does not reject —
so a 404 from a stale route or a missing generated .md file was
copying the error page's body to the clipboard while the control still
claimed "Copied". Throw on a non-ok response so useCopyButton's lack of
an onRejected handler protects this case the same way it already
protects a genuine fetch/clipboard rejection.
* Replace the docs theme identity select with a matching pill control
The theme identity picker was a raw native <select>, the only native
form control on the docs surface, squeezed to ~200px in the sidebar
footer and truncating "Tactile". Add a text-label segmented toggle
group alongside the existing icon-only one, sharing the same pill
shape, height, and focus treatment as the colour-mode group beside it,
and use it for theme identity in both the sidebar footer and the
playground header.
Also give system colour mode a settings-gear glyph so it no longer
collides with the monitor icon used for the Desktop viewport option.
* Swap the system colour-mode glyph for a half-filled circle
A settings cog reads as "open theme options", not "follow the
operating system" — a misleading glyph, not just an ambiguous one.
Replace it with the conventional split-circle contrast mark (one
half filled, one half outline), which reads as "either of these two,
decided for you". Matches the existing icon set's outline
conventions (24x24 viewBox, currentColor stroke) apart from the
filled half the mark requires.
The fixtures existed only to diff the pre-v2 to v2 colour repaint, which has
shipped. The v2 goldens remain the live regression baseline, and the pre-v2
output stays recoverable from git history at cef0076.
Part of #231 (#240).
Render the private families, generated surfaces, solid-anchor searches,
hard-vs-advisory contrast checks, and gamut reductions from compileTheme's
diagnostics. Add a colour token board driven off flattenThemeContract, with a
visual capture per bundled theme and mode so a future generator or mapping
change produces an obvious diff. Document the v2 pipeline, the expected
repaint, and the border, accent, and alpha policies.
Part of #231 (#239).
Block-mode LoadingSkeleton rounded only the ::after overlay; the child surface
underneath kept square corners and showed through at the arcs, so
radius="control" still rendered a sharp rectangle (getComputedStyle and the
pixel-diff tolerance both missed it). Apply the same border-radius var to the
child surface, and assert the surface's own border-radius in the Radius story
so it can't silently regress.
Closes #225.
* Wire the private scale pipeline into buildTheme
Replace buildModeColors with generate-families -> generate-surfaces ->
apply-semantic-map, behind a new compileTheme() that returns css +
diagnostics; buildTheme returns the css. WCAG validation stays authoritative.
Dedupe the elevation constants, re-add the theme-level diagnostics types, and
commit v2 regression goldens alongside the frozen pre-v2 reference.
Part of #231 (#238).
* Hard-gate border.control as a solved contrast boundary
Per the Stage 6 border-contrast decision (Option B): border.control is now a
dedicated boundary solved to >=3:1 against canvas and recessed in both modes,
rather than a subtle step-7 alias. Decorative and intent borders stay advisory;
text/on-solid/focus stay hard gates. Regenerate the v2 goldens.
Part of #231 (#238).
* Map loadingSkeleton to a visible neutral step
Stage 6's neutral.3 skeleton rendered ~1.05:1 against the canvas (near
invisible) and failed the loading-skeleton pulse-contrast test. Remap to
neutral.7 -- the lowest discrete scale step that clears the component's 1.4:1
perceptibility floor (~1.64-1.68:1 across both bundled themes and modes;
step 6 lands ~1.37-1.40, just under). Regenerate the v2 goldens.
Part of #231 (#238).
Implement mapSemanticColors({ families, surfaces, scrim, focus, mode })
resolving every colour contract leaf onto the private families and surfaces
per the locked mapping table; scrim passes through verbatim. Pure and
isolated -- not wired into buildTheme yet.
Part of #231 (#237).
* Add private 12-step scale generator (scale.ts)
Implement generateFamily({ source, background, mode, role }) producing a
Radix-shaped 12-step OKLCH family + contrast, with capability-based role
requirements and a constrained solid-anchor search. Add Radix reference
fixtures (test-only) and family-level diagnostics. Isolated -- not wired
into buildTheme yet.
Part of #231 (#235).
* Drop unused exports flagged by knip
Un-export the internal Radix fixture helpers and remove the theme-level
diagnostics types (ContrastCheck / ThemeModeDiagnostics / ThemeDiagnostics /
ThemeGenerationDiagnostics) that were declared ahead of use. They return in
Stage 6 (#238) when compileTheme consumes them.
Part of #231 (#235).
* Add mode-aware elevation surface model (elevation.ts)
Implement generateSurfaces({ background, mode }) deriving canvas/recessed/
floating/overlay by mode-aware lightness deltas off the background canvas
anchor (canvas === background), dropping the hidden resting rung. Isolated
-- not wired into buildTheme yet.
Part of #231 (#236).
* Remove export from ElevationMode
* Reorder properties in Oklch objects
Introduce optional ThemeInput.color.background, resolved per the locked
precedence (explicit -> adapted opposite mode -> derived from the resolved
neutral canvas anchor) and carried in the foundation for the scale/elevation
stages to consume. Output is byte-identical; the pre-v2 goldens are unchanged.
Part of #231 (#234).
* Freeze pre-v2 theme goldens and add generator version
Snapshot the tactile and paper theme CSS as permanent pre-v2 compatibility
fixtures before the v2 generator rewrite, and add THEME_GENERATOR_VERSION
threaded into the stylesheet header. Token values stay byte-identical; only
the header line is excluded from the golden comparison.
Part of #231 (#232).
* Drop generator versioning from theme build
Revert the stylesheet header to its original string per PR review;
versioning is deferred until v1. With the header unchanged, the pre-v2
compatibility goldens now match emitted output verbatim, so the golden
tests compare full output directly and the header-diff test is removed.
* Flip to the 128-leaf theme contract and defineTheme-only authoring
Reduce the emitted contract 152->128: feedback intents (info/success/
warning) collapse to a static subtle+border+text kit; drop surface.resting,
surfaceDisabled, borderDisabled, and the unused motion tail; rename
textDisabled->text.disabled (CSS-var-stable); add color.scrim.
Emit color.scrim from the theme and replace the hardcoded combobox tray
scrim with vars.color.scrim. Make defineTheme the sole public authoring
API: drop buildTheme and the raw foundation types from the package entry
(kept internal for the build pipeline), rewrite Tactile/Paper as defineTheme
inputs, and route build-themes/themes through defineTheme. validateContrast
drops removed-token checks and adds text.primary x neutral.surface.subtle*.
Retained token values are byte-identical (zero visual drift; goldens
unchanged). Breaking major. Part of #226 / #228.
* Update docs for the 128-leaf contract and defineTheme authoring
Rewrite the theming and overview docs plus STYLING.md to document
defineTheme as the sole public authoring API and the reduced 128-leaf
token contract (static feedback intents, text.disabled, scrim; removed
surface.resting, the disabled surface/border tokens, and the unused
motion tail). Repoint live docs examples/samples off removed tokens.
Part of #226 / #228.
Introduce defineTheme alongside the existing buildTheme/ThemeFoundation:
it normalises a curated ThemeInput into the per-mode foundation, with
single-value accent adaptation via an on-solid lightness search that
mirrors build-theme's validation, neutralStyle-driven neutrals,
generative radius, and optional materials merged over curated
extremely-subtle defaults.
The emitted contract is unchanged (still 152 leaves); the resolved scrim
is computed but not emitted yet (lands in #228). Slice 1 of #226.
* Simplify LoadingSpinner around a children loading API
* Rename loading prop to isLoading for consistency across components
* Refactor LoadingSpinner to use slotted recipe architecture
* Add VisuallyHidden component
* Switch from render to elementType
* Use WCAG clip technique for visually-hidden styles
Replace clip-path: circle(0) with the WCAG-standard clip technique (1px clipped box, overflow hidden, white-space nowrap) in both the visuallyHidden recipe and Text's isVisuallyHidden variant. circle(0) left a full-size layout box (overflow risk) and has questionable Safari focus-ring support.
* Share visually-hidden style between recipe and Text variant
* Migrate remaining single-part recipes to the recipe() helper
Sweep button, button-composed, icon, icon-button, link, loading-spinner, and
text from recipeInLayer() to the recipe() architecture from #223, keeping
each file's call count and order 1:1 so the emitted stylesheet stays
byte-identical. loading-skeleton stays on styleInLayer/globalStyleInLayer
since it has no variants to recipe-ify. recipeInLayer is now unused and
removed from styles/layered-style.css.ts; styleInLayer and
globalStyleInLayer are untouched. Also updates the turbo-generators recipe
scaffold template to the new helper.
* Remove changeset
* Rework multi-part recipes to a slotted recipe() architecture
Replace the flat multi-part recipe exports with a HeroUI/Tailwind-Variants-style
recipe() helper (issue #215). field and textInput from @luke-ui/react/recipes are
now slotted recipes: select variants at the outer call, then read each slot, e.g.
field({ necessityIndicator, tone }).label(className) and textInput({ size }).group().
Slot functions take only an optional extra class. combobox migrates to one internal
slotted recipe with a shared size group. Types are inferred from each config; no
Vanilla Extract implementation types (RuntimeFn/StyleRule) leak into the public .d.ts.
recipe() runs at build time in a .css.ts, decomposing a slotted config into one
recipeInLayer-equivalent recipe per slot in declaration order, so the emitted CSS and
class names are byte-identical to the previous hand-written recipes. The returned
callable is registered with Vanilla Extract's function serializer so it survives the
.css.ts build boundary. Single-base extend inheritance is supported.
The composed ComboboxField and TextField components are unchanged: they keep reusing
the Field primitive internally and gain the migrated recipes through the primitives
they render.
Pre-1.0 breaking change to @luke-ui/react/recipes. Removed with no aliases: fieldLabel,
fieldMessage, textInputGroup, textInputControl, textInputAdornmentStart,
textInputAdornmentEnd. The *Variants types are kept.
* Delete changesets
* Simplify props
* Improve types and comments
* Restore the shipped stylesheet contract
* Make stylesheet selector check portable
* Replace Capsize adapter with layer-aware @capsizecss/core
- Replace @capsizecss/vanilla-extract (createTextStyle) with
@capsizecss/core (createStyleObject) so Text trim declarations
are emitted through recipeInLayer and owned by
- Replace string-based stylesheet scanner with PostCSS-based
structural contract test using postcss and postcss-selector-parser
- Add postcss and postcss-selector-parser as dev dependencies
- Remove @capsizecss/vanilla-extract dependency
- Update docs/STYLING.md to document the Capsize approach
* Revert Capsize dependency change, use logical properties, move side-effect import
- Keep @capsizecss/vanilla-extract as the dependency (remove @capsizecss/core direct dep)
- Replace createStyleObject usage with direct style construction from vars
- Use logical properties (marginBlockEnd/marginBlockStart) in text CSS
- Move stylesheet root side-effect import from styles/index.ts to recipes/index.ts
- Update stylesheet-contract test to match logical property names
* Update STYLING.md to reflect direct Capsize style construction
* Move stylesheet root from side-effect import to build entry
- Remove side-effect import from recipes/index.ts
- Add stylesheet.css.ts as an extra Rollup input via inputOptions
- Clean up empty stylesheet-entry JS output in build:done hook
- Remove unused unlink import
* Revert inputOptions approach, restore side-effect import with clear comment
- Remove inputOptions and build:done hook from vite.config.ts
- Remove stale ./_ export from package.json
- Restore stylesheet root import in recipes/index.ts with explicit comment
explaining it is a VE build-time side-effect (stripped from compiled output)
* Expand styling documentation
* Tighten the styling page prose
* Clarify static CSS usage in styling documentation
* Add shadow documentation
* Remove stray responsive claim from the depth example description
* Tighten the shadow page prose
* Visualise theme depth
* Simplify depth example
* Clarify depth documentation example
Adds unbarrelify as a dev tool (catalog + @luke-ui/react devDependency)
and rewires the five internal consumers that imported through
src/styles/index.ts and src/combobox-field/primitive/index.tsx to
import directly from utilities.css.js and item.js instead.
Neither barrel file is deleted: both back public subpath exports
(./styles and ./combobox-field/primitive). Because those exports point
at built dist/ paths rather than the src/ files themselves,
unbarrelify's built-in package-export exception does not recognise
them and would delete both as internal barrels, which breaks the
public API (confirmed via a failing tsc run and vp pack silently
dropping both exports entries). check:barrels therefore skips these
two known package-export barrels; every other barrel in the source
tree is still caught, verified with a throwaway negative test.
Wires check:barrels ("unbarrelify --check") into turbo.json's
aggregate check task the same way check:format/check:lint/check:types
are wired, as its own global task that no-ops for packages without the
script, so `pnpm run check` exercises it repo-wide.
pnpm-lock.yaml picks up unrelated transitive churn (mostly optional
oxc-transform/oxc-minify platform binaries no longer needed after a
srvx bump) that surfaced when the lockfile was re-resolved for the new
dependency; build and tests pass against the regenerated lockfile.
* Replace DS icons with Heroicons
* Simplify copied icon rendering
* Consolidate polymorphic APIs onto elementType and render
Drop the hand-rolled `as` prop (used only by LoadingSkeleton) in favour
of `elementType`, the react-aria-components convention already used by
Text, Heading, and Numeral. `as` and `elementType` did the same job
(pick the DOM tag); this removes the duplicate, non-idiomatic path.
Also extract Box's local `BoxRender` type into a shared, documented
`RenderProp` type. react-aria-components does not publicly export its
render-function type, so this is the canonical Luke UI definition,
stopping the next component from inventing another render-prop variant.
Net API surface: `elementType` = which tag, `render` = take over
rendering. Two orthogonal, RAC-aligned APIs, no bespoke `as`.
- LoadingSkeleton: `as` -> `elementType` (component, stories, docs, example)
- heading-context: fix stale JSDoc example that referenced `as`
- Box: use shared RenderProp<'div'> instead of local BoxRender
- add packages/@luke-ui/react/src/types/render-prop.ts
* Reflow loading-skeleton docs prose to satisfy formatter
* Ship theming docs and global controls
* Tighten theming documentation
* Clarify consumer-owned themes
* Migrate loading feedback primitives (#102)
* Address PR review feedback
* Fix AllIcons story
* Make Storybook canvas fill previews
* Tune loading animations
* Set Storybook canvas foreground colour
* Improve LoadingSkeleton dark contrast
* Migrate typography to composite type roles
* Address typography review feedback
* Migrate Link to semantic navigation states
* Migrate typography to composite type roles
* Address typography review feedback
* Migrate Combobox to the new theme
* Address Combobox review feedback
* Polish combobox theme previews
* Fix Combobox option hover contrast
* Capture full content for tall visual elements beyond viewport (#271)
Element-level screenshots of components taller than the 800px viewport
(e.g. the token board with 128 leaves, which is ~7k px) only painted
the first viewport's worth of content. The PNG was sized to the full
document height but rows below the fold were blank because Playwright
omits content outside the viewport by default.
Add captureBeyondViewport: true to the visual project's toMatchScreenshot
config so Playwright renders the full element regardless of viewport
bounds. This applies to all 126+ existing captures; only the handful
that are taller than 800px change in practice.
Add a post-capture check that warns when scrollHeight exceeds the
viewport but no child element actually reaches below it — a sanity
check that catches scrollHeight inflation without false positives.
The visual regression comparison pipeline in scripts/visual-regression
will flag any capture with dimension mismatch as 'changed', so if
captureBeyondViewport is ever removed the regression is caught at the
image-comparison level.
Closes #271.
* Fix lint and type errors in visual capture config
- Type the resolveScreenshotPath callback params explicitly so the as any
cast on toMatchScreenshot doesn't lose type safety for known properties
- Suppress the no-console lint warning on the post-capture warn call
* Use CDP viewport resize instead of captureBeyondViewport config
captureBeyondViewport: true in the vitest toMatchScreenshot config
caused ALL visual captures to time out in CI (Matcher did not succeed
in time). The Playwright internal handler only sets captureBeyondViewport
when !fitsViewport, but forcing it globally adds overhead even for
elements that fit within the viewport.
Instead, resize the viewport to the element's full height via CDP in
captureVisual only when the element extends beyond the 800px viewport.
This ensures Playwright renders the full content without affecting
the majority of captures that fit within the viewport.
The viewport metadata embedded in the filename is captured before
resizing (so it always shows the configured 1024x800 viewport). The
viewport is restored to its original size after capture.
* Configure vitest API write and Vite FS allow for visual captures in worktree
Two guards in @vitest/browser 4.1.x block screenshot writes
when vitest runs in a git worktree:
- assertBrowserApiWrite requires api.allowWrite and
browser.api.allowWrite to be truthy
- assertBrowserFileAccess requires the capture path to be
within Vite's server.fs.allow scope (fails when
VISUAL_CAPTURE_DIR points outside the worktree root)
Add api.allowWrite at project level, browser.api.allowWrite
in the visual project, and server.fs.allow pointing at
.artifacts (found by searching up from the config dir) so
captures written to the cache directory pass the Vite FS
check.
The issue called for a deliberate decision on whether the root 'check'
task should include 'test'. The answer settled during #270 was no:
browser tests need Playwright and are markedly slower, so test runs in
its own workflow (storybook.yml). Update AGENTS.md to reflect this so
contributors know what 'check' actually covers.
Closes #267.
* Give docs pages and the playground room to breathe
Docs pages now separate their prev/next footer from page content with
real spacing (mt-12/pt-8, growing at md/xl) instead of the stock 16px
gap, so a card-grid-ending page doesn't read as one continuous block.
The editor loading pill gets the same background scrim the preview
loading pill already has, so skeleton bars don't run through it. The
playground's resize separator now shows a visible grab handle at rest
in both orientations instead of only appearing on hover/focus/active.
Closes #260
* Address playground visual review
* Finish playground loading and resize affordances
* Use a semantic surface for loading pills
* Fix the theming and styling docs that no longer match the code
Every entry point into the theme and styling layers misled a reader,
because the docs were written before the colour engine was rewritten.
AGENTS.md tells agents to read these docs before changing code, so a wrong
doc generates wrong code.
foundation.ts and define-theme.ts both said the background input is not
consumed and that the canvas still comes from neutral. buildTheme now
takes background directly as the canvas anchor for every family's ramp and
for the elevation surfaces, so anyone tracing the canvas was sent to the
wrong module. The same overclaim had also leaked into the public JSDoc in
theme/index.tsx, which promised a single-value-accent throw and listed an
incomplete set of gates for ThemeContrastError. Internal stage numbers are
gone repo-wide, per the documentation guide.
The authoring page told theme authors that non-text pairs must reach 3:1.
That is not what the compiler does. It now separates what is guaranteed
from what is only measured: text and on-solid pairs are hard-gated at
4.5:1, border.focus and border.control at 3:1, the five intent border
pairs are measured but cannot fail the build, and decorative borders are
never checked at all. An advisory border must never be the only cue for a
state, so the page says so. color.background and ThemeGenerationError are
documented for the first time.
The page no longer promises a "no accessible lightness" error for a
single-value accent. A sweep of the on-solid gate at the accent adaptation
targets found no failing hue or chroma, so the search loop and its throw
are unreachable under the current constants and the error cannot fire.
Authors are pointed at the reachable path instead. Whether to keep that
search as defence-in-depth or delete it is an accent-policy decision,
left alone here.
The styling guide told authors to call recipeInLayer, which no longer
exists, and never documented the recipe engine they actually have to use.
It now covers single-part and slotted recipes, how variant types are
derived rather than hand-maintained, and the shared input-state selectors.
The testing guide's visual test example used a Button tone that does not
exist, so the canonical template did not compile.
Docs and comments only: no non-comment source line changed.
* Make theming and styling copy easier to read
* Share one top nav across docs, playground, and 404
Docs and the playground carried different chrome: the docs sidebar owned
the wordmark, search, nav links, and theme controls, while the playground
had a bespoke bar with a plain-text title and one "Docs" link. Nothing
carried across but the theme controls.
Both surfaces now render one SiteNav: wordmark, Docs/Playground/Storybook,
search, and the appearance controls. The 404 renders it too, so no surface
is left with its own bar.
The docs move from Fumadocs' docs layout to its notebook layout with
nav.mode 'top', which spans the header full width and starts the sidebar
beneath it. SiteNav is supplied through the layout's header slot, so it
also carries the sidebar collapse and drawer triggers, and the sidebar
footer no longer renders the theme switch.
Below lg the docs hand the destinations to the sidebar, which already
lists them there; surfaces with no sidebar keep them on a second nav row
below md. Below md the appearance controls move behind a disclosure --
they are 230px of pills that cannot share a 320px row with the wordmark,
search, and the sidebar trigger, and letting them wrap made the docs bar
113px tall while the layout still offset content by 56px.
Pre-bundle lucide-react: Fumadocs' search trigger and popover import from
its barrel, which unbundled is ~1750 module requests. Adding that to the
playground, which also loads Monaco, exhausted the browser's connection
pool in dev and the page never hydrated.
* Cover shared navigation behavior
The 4.5 text ratio was declared in build-theme.ts, define-theme.ts and
scale.ts, each with a comment promising it matched the others. clampUnit
was exported properly from color.ts and then redeclared privately in
define-theme.ts and scale.ts. The rounding headroom appeared under two
names and the search step under three, all with the same values. Worst,
the intent role lists lived in both the compiler and the semantic map,
where the failure was asymmetric and half silent: adding a role to the
map alone emitted an ungated colour, while adding it to the compiler
alone threw an internal error.
All of it now lives in contrast-policy.ts, which imports nothing, so it
cannot close a cycle. Consolidation was value- and order-neutral, and
each call site keeps its own loop arithmetic so float accumulation cannot
shift.
Two pieces of code decided whether a solid can carry readable text. The
pre-conditioner tested three states at a plain 4.5, the generator tested
two at 4.55, and the third state the pre-conditioner checked was a pressed
colour the engine no longer generates since pressed reuses the hover step.
The pre-conditioner now calls the generator's exported gate, so it can
never be stricter than the solver, and the rule that a single-value accent
never fails the build holds for one reason instead of two.
Contrast checks now record whether they are a hard gate. The diagnostics
inspector was reconstructing that with a regular expression over token
paths, which was correct only until another pair became advisory.
The goldens do not move. The ticket predicted they would, but both bundled
themes author explicit per-mode accents, so the pre-conditioner is never
called for either, and a sweep of the reachable hue and chroma space shows
the mode target always clears the gate. Both goldens were regenerated
deliberately and came back byte-identical rather than being rebaselined.
Two Storybook surfaces rendered the same tokens by different means. The
hand-written one was 939 lines listing all 128 contract leaves by hand,
rebuilding each token's path with string helpers that duplicate logic
contract.ts already owns. The contract-driven one derived everything but
covered colours only.
Generalise the board to the whole contract and delete the hand-written
story: 1227 lines down to 650, while coverage grows from 47 colour leaves
to all 128. The parts only the hand-written story could do survive. The
live resolved-value column still watches the colour-mode attribute and
shows the computed value, now reading the bare custom-property name from
flattenThemeContract() instead of re-parsing it. The previews a plain
swatch cannot express — the animated motion dot, the spacing bar, the
radius corner, the shadow sample and the control-finish gradient — are now
a lookup keyed by token family, typed from the contract so a new family is
a type error until it has a renderer.
Renamed to TokenBoard, since the board is no longer colour-only. It was
never exported from theme/index.tsx or listed in package.json exports, so
the rename is internal.
The coverage assertion from the previous commit is strengthened rather
than weakened: it now pins the rendered swatch count to all 128 contract
leaves instead of the 47 colour ones. The diagnostics story is untouched,
because it reads the compiler's diagnostics rather than resolved custom
properties, and the v2 goldens are byte-identical.
Note the four visual captures are renamed rather than genuinely widened.
The capture pipeline paints only the first viewport of a tall story, so
the board's new families sit in the unpainted region. The DOM-level gate
does cover all 128 leaves; the visual one still does not, and that is
tracked separately.
The recipe engine had two features with no consumers. `extend` was
declared on both config shapes and implemented across three functions,
including the recursive config merging that was also the only untested
logic in the file. Slotted compound variants were similar: the four
slot-using recipes declare no compound variants, and the two recipes that
do are both single part. Deleting both takes recipe.ts from 457 to 354
lines. Single-part compound variants stay, because button and text use
them. Re-add either the day a second recipe needs it.
Delete the nine hand-written Text variant types rather than deriving
them. Four string unions and three key arrays duplicated what the recipe
already knows with nothing type-checking the two against each other, so
adding a wrap value or a colour role would have gone stale silently. All
nine were exported publicly with no consumers, invisible to knip because
the barrel is an entry point. Text's own component already derives its
prop types by indexing TextVariants, and button, field and icon already
export only the aggregate, so the barrel now matches that convention.
input-states.css.ts emits no CSS at all, yet the suffix marked it as
having side effects and kept it out of consumer tree shaking. Renamed to
input-states.ts.
The barrel exported loadingSkeleton as a plain class string among twelve
callable recipes, which invited a consumer to call it; it is now
loadingSkeletonClassName. Renaming rather than wrapping it in recipe()
keeps the emitted CSS untouched. Two recipes also both exported something
named `icon`, so icon-button's is now iconButtonIcon.
The emitted stylesheet is byte-identical at 78007 bytes and the v2
goldens are unchanged, which is expected: the deleted code paths were
never exercised, and the renames do not reach content-hashed class names.
Delete the theme tests that could not fail. validateContrast already
hard-gates text against every surface at 4.5:1 and border.control against
canvas and recessed at 3:1, throwing before any assertion recomputing
those ratios from the emitted CSS could run. Replace each with the honest
statement of the same property: that building the theme does not throw.
The neutral half of the dark subtle-hover test was dead for the same
reason, so keep only the accent pair, which is genuinely ungated.
Drop the loading-skeleton tautology, which asserted a contrast ratio
above 1 immediately after asserting the two colours differ. The real
gate is in loading-skeleton.browser.test.ts, samples the animated pulse
and requires 1.4:1, so it both subsumes and beats the static check.
Drop the migration-era assertions: the hand-written list of 25 removed
tokens, and the hardcoded 128 leaf count, which contract.test.ts already
asserts properly. Derive the count from the contract instead. Drop the
theme test that read the combobox stylesheet source as text and grepped
it for a string.
Make the local and CI checks agree. `pnpm run check` ran check:barrels
but not root formatting; CI ran root formatting but not check:barrels, so
a barrel violation could merge and a docs-only change could pass locally
and fail CI. Adding //#check:format-root to the check task and having CI
run that same task leaves both sides on an identical 28-task graph, with
--affected only ever an additive PR filter. knip stays a separate
unscoped invocation because it is repo-wide.
Run the docs app tests in CI, which nothing did. Four of them failed on
main because the harness rendered ThemeControls as a sibling of the
StoryWrapper that owns the only IconSpritesheetProvider; wrapping
DocsThemeRoot mirrors what __root.tsx actually does. Ignore the docs
failure screenshots, which were written into a tracked path.
Give the token board the coverage check its own comment claims. The
swatch count and the tree builder now both derive from the contract.
Verified by dropping a leaf: 3 unit tests fail and the story fails with
"expected 46 to be 47".
* Move viewport and fullscreen controls into the preview panel
The playground header mixed page-level appearance controls with preview-only
controls, and the fullscreen buttons never selected an appearance/tone, so
enter-fullscreen rendered as the loudest element on the page and
exit-fullscreen was a white icon on a white circle with a leftover gradient
overlay — a non-text contrast failure.
Add a PreviewToolbar owned by the preview panel that holds the viewport
toggle and a single fullscreen control. Enter-fullscreen uses the ghost
appearance so it reads as a quiet toolbar utility; exit-fullscreen uses the
solid appearance so both the icon and its container clear 3:1 non-text
contrast against the toolbar background. The header keeps only whole-page
controls (brand, docs link, theme controls).
* Update fullscreen button
The Capsize trim compound variants set fontSize and lineHeight, and
vanilla-extract applies compound variants after simple ones, so they won
over the shouldInheritFont variant's `inherit`. Family, weight and colour
inherited; size and line height did not. The broken combination was the
default one, because trim is only disabled automatically when lineClamp
is set, and every existing test for the prop passed shouldDisableTrim,
so the broken path had no coverage.
Condition the trim compounds on shouldInheritFont: false. The fix sits in
the recipe rather than the Text component because the recipe is exported
publicly from @luke-ui/react/recipes, so a component-level fix would
leave direct recipe consumers broken.
Also validate two theme inputs the emitter trusted. The scrim was the
only authored colour written into the stylesheet unchecked, because it is
deliberately excluded from OKLCH parsing (its alpha channel does not fit
the pattern) and nothing was put in its place, so a scrim carrying a
semicolon or brace emitted malformed CSS into all four theme scopes. It
now shares one isUnsafeCssValue helper with the depth and control finish
checks. That helper tests the type before trimming, so a rung explicitly
set to undefined produces the validator's message naming the field
instead of a TypeError, and defineTheme filters undefined values before
merging so an explicit undefined falls back to the curated default rather
than overwriting it.
Emitted theme CSS is unchanged: the v2 goldens and visual captures are
byte-identical.
* Clean up the docs page header
Render frontmatter descriptions through Fumadocs' DocsDescription slot
instead of an MDX blockquote, so the page's own summary no longer reads
as a quotation. Collapse Copy Markdown, View as Markdown, and Edit on
GitHub from a row of filled pill buttons into a single compact "View
options" popover built from Fumadocs' own popover primitives, so the
first paragraph of prose is the first thing a reader meets.
* Add rel=noreferrer noopener to the page-actions popover links
Every new-tab link in the view-options popover handed the destination a
live window.opener reference. Matches the rel pair Fumadocs' own
EditOnGitHub and footer links already set. Also documents why the copy
callback deliberately has no try/catch: useCopyButton only shows
"Copied" once its promise resolves, so a fetch or clipboard failure
already leaves the control honest without one.
* Reject on a non-ok markdown fetch instead of copying the error page
fetch resolves normally on an HTTP error response — it does not reject —
so a 404 from a stale route or a missing generated .md file was
copying the error page's body to the clipboard while the control still
claimed "Copied". Throw on a non-ok response so useCopyButton's lack of
an onRejected handler protects this case the same way it already
protects a genuine fetch/clipboard rejection.
* Replace the docs theme identity select with a matching pill control
The theme identity picker was a raw native <select>, the only native
form control on the docs surface, squeezed to ~200px in the sidebar
footer and truncating "Tactile". Add a text-label segmented toggle
group alongside the existing icon-only one, sharing the same pill
shape, height, and focus treatment as the colour-mode group beside it,
and use it for theme identity in both the sidebar footer and the
playground header.
Also give system colour mode a settings-gear glyph so it no longer
collides with the monitor icon used for the Desktop viewport option.
* Swap the system colour-mode glyph for a half-filled circle
A settings cog reads as "open theme options", not "follow the
operating system" — a misleading glyph, not just an ambiguous one.
Replace it with the conventional split-circle contrast mark (one
half filled, one half outline), which reads as "either of these two,
decided for you". Matches the existing icon set's outline
conventions (24x24 viewBox, currentColor stroke) apart from the
filled half the mark requires.
Render the private families, generated surfaces, solid-anchor searches,
hard-vs-advisory contrast checks, and gamut reductions from compileTheme's
diagnostics. Add a colour token board driven off flattenThemeContract, with a
visual capture per bundled theme and mode so a future generator or mapping
change produces an obvious diff. Document the v2 pipeline, the expected
repaint, and the border, accent, and alpha policies.
Part of #231 (#239).
Block-mode LoadingSkeleton rounded only the ::after overlay; the child surface
underneath kept square corners and showed through at the arcs, so
radius="control" still rendered a sharp rectangle (getComputedStyle and the
pixel-diff tolerance both missed it). Apply the same border-radius var to the
child surface, and assert the surface's own border-radius in the Radius story
so it can't silently regress.
Closes #225.
* Wire the private scale pipeline into buildTheme
Replace buildModeColors with generate-families -> generate-surfaces ->
apply-semantic-map, behind a new compileTheme() that returns css +
diagnostics; buildTheme returns the css. WCAG validation stays authoritative.
Dedupe the elevation constants, re-add the theme-level diagnostics types, and
commit v2 regression goldens alongside the frozen pre-v2 reference.
Part of #231 (#238).
* Hard-gate border.control as a solved contrast boundary
Per the Stage 6 border-contrast decision (Option B): border.control is now a
dedicated boundary solved to >=3:1 against canvas and recessed in both modes,
rather than a subtle step-7 alias. Decorative and intent borders stay advisory;
text/on-solid/focus stay hard gates. Regenerate the v2 goldens.
Part of #231 (#238).
* Map loadingSkeleton to a visible neutral step
Stage 6's neutral.3 skeleton rendered ~1.05:1 against the canvas (near
invisible) and failed the loading-skeleton pulse-contrast test. Remap to
neutral.7 -- the lowest discrete scale step that clears the component's 1.4:1
perceptibility floor (~1.64-1.68:1 across both bundled themes and modes;
step 6 lands ~1.37-1.40, just under). Regenerate the v2 goldens.
Part of #231 (#238).
* Add private 12-step scale generator (scale.ts)
Implement generateFamily({ source, background, mode, role }) producing a
Radix-shaped 12-step OKLCH family + contrast, with capability-based role
requirements and a constrained solid-anchor search. Add Radix reference
fixtures (test-only) and family-level diagnostics. Isolated -- not wired
into buildTheme yet.
Part of #231 (#235).
* Drop unused exports flagged by knip
Un-export the internal Radix fixture helpers and remove the theme-level
diagnostics types (ContrastCheck / ThemeModeDiagnostics / ThemeDiagnostics /
ThemeGenerationDiagnostics) that were declared ahead of use. They return in
Stage 6 (#238) when compileTheme consumes them.
Part of #231 (#235).
* Add mode-aware elevation surface model (elevation.ts)
Implement generateSurfaces({ background, mode }) deriving canvas/recessed/
floating/overlay by mode-aware lightness deltas off the background canvas
anchor (canvas === background), dropping the hidden resting rung. Isolated
-- not wired into buildTheme yet.
Part of #231 (#236).
* Remove export from ElevationMode
* Reorder properties in Oklch objects
Introduce optional ThemeInput.color.background, resolved per the locked
precedence (explicit -> adapted opposite mode -> derived from the resolved
neutral canvas anchor) and carried in the foundation for the scale/elevation
stages to consume. Output is byte-identical; the pre-v2 goldens are unchanged.
Part of #231 (#234).
* Freeze pre-v2 theme goldens and add generator version
Snapshot the tactile and paper theme CSS as permanent pre-v2 compatibility
fixtures before the v2 generator rewrite, and add THEME_GENERATOR_VERSION
threaded into the stylesheet header. Token values stay byte-identical; only
the header line is excluded from the golden comparison.
Part of #231 (#232).
* Drop generator versioning from theme build
Revert the stylesheet header to its original string per PR review;
versioning is deferred until v1. With the header unchanged, the pre-v2
compatibility goldens now match emitted output verbatim, so the golden
tests compare full output directly and the header-diff test is removed.
* Flip to the 128-leaf theme contract and defineTheme-only authoring
Reduce the emitted contract 152->128: feedback intents (info/success/
warning) collapse to a static subtle+border+text kit; drop surface.resting,
surfaceDisabled, borderDisabled, and the unused motion tail; rename
textDisabled->text.disabled (CSS-var-stable); add color.scrim.
Emit color.scrim from the theme and replace the hardcoded combobox tray
scrim with vars.color.scrim. Make defineTheme the sole public authoring
API: drop buildTheme and the raw foundation types from the package entry
(kept internal for the build pipeline), rewrite Tactile/Paper as defineTheme
inputs, and route build-themes/themes through defineTheme. validateContrast
drops removed-token checks and adds text.primary x neutral.surface.subtle*.
Retained token values are byte-identical (zero visual drift; goldens
unchanged). Breaking major. Part of #226 / #228.
* Update docs for the 128-leaf contract and defineTheme authoring
Rewrite the theming and overview docs plus STYLING.md to document
defineTheme as the sole public authoring API and the reduced 128-leaf
token contract (static feedback intents, text.disabled, scrim; removed
surface.resting, the disabled surface/border tokens, and the unused
motion tail). Repoint live docs examples/samples off removed tokens.
Part of #226 / #228.
Introduce defineTheme alongside the existing buildTheme/ThemeFoundation:
it normalises a curated ThemeInput into the per-mode foundation, with
single-value accent adaptation via an on-solid lightness search that
mirrors build-theme's validation, neutralStyle-driven neutrals,
generative radius, and optional materials merged over curated
extremely-subtle defaults.
The emitted contract is unchanged (still 152 leaves); the resolved scrim
is computed but not emitted yet (lands in #228). Slice 1 of #226.
* Simplify LoadingSpinner around a children loading API
* Rename loading prop to isLoading for consistency across components
* Refactor LoadingSpinner to use slotted recipe architecture
* Add VisuallyHidden component
* Switch from render to elementType
* Use WCAG clip technique for visually-hidden styles
Replace clip-path: circle(0) with the WCAG-standard clip technique (1px clipped box, overflow hidden, white-space nowrap) in both the visuallyHidden recipe and Text's isVisuallyHidden variant. circle(0) left a full-size layout box (overflow risk) and has questionable Safari focus-ring support.
* Share visually-hidden style between recipe and Text variant
* Migrate remaining single-part recipes to the recipe() helper
Sweep button, button-composed, icon, icon-button, link, loading-spinner, and
text from recipeInLayer() to the recipe() architecture from #223, keeping
each file's call count and order 1:1 so the emitted stylesheet stays
byte-identical. loading-skeleton stays on styleInLayer/globalStyleInLayer
since it has no variants to recipe-ify. recipeInLayer is now unused and
removed from styles/layered-style.css.ts; styleInLayer and
globalStyleInLayer are untouched. Also updates the turbo-generators recipe
scaffold template to the new helper.
* Remove changeset
* Rework multi-part recipes to a slotted recipe() architecture
Replace the flat multi-part recipe exports with a HeroUI/Tailwind-Variants-style
recipe() helper (issue #215). field and textInput from @luke-ui/react/recipes are
now slotted recipes: select variants at the outer call, then read each slot, e.g.
field({ necessityIndicator, tone }).label(className) and textInput({ size }).group().
Slot functions take only an optional extra class. combobox migrates to one internal
slotted recipe with a shared size group. Types are inferred from each config; no
Vanilla Extract implementation types (RuntimeFn/StyleRule) leak into the public .d.ts.
recipe() runs at build time in a .css.ts, decomposing a slotted config into one
recipeInLayer-equivalent recipe per slot in declaration order, so the emitted CSS and
class names are byte-identical to the previous hand-written recipes. The returned
callable is registered with Vanilla Extract's function serializer so it survives the
.css.ts build boundary. Single-base extend inheritance is supported.
The composed ComboboxField and TextField components are unchanged: they keep reusing
the Field primitive internally and gain the migrated recipes through the primitives
they render.
Pre-1.0 breaking change to @luke-ui/react/recipes. Removed with no aliases: fieldLabel,
fieldMessage, textInputGroup, textInputControl, textInputAdornmentStart,
textInputAdornmentEnd. The *Variants types are kept.
* Delete changesets
* Simplify props
* Improve types and comments
* Restore the shipped stylesheet contract
* Make stylesheet selector check portable
* Replace Capsize adapter with layer-aware @capsizecss/core
- Replace @capsizecss/vanilla-extract (createTextStyle) with
@capsizecss/core (createStyleObject) so Text trim declarations
are emitted through recipeInLayer and owned by
- Replace string-based stylesheet scanner with PostCSS-based
structural contract test using postcss and postcss-selector-parser
- Add postcss and postcss-selector-parser as dev dependencies
- Remove @capsizecss/vanilla-extract dependency
- Update docs/STYLING.md to document the Capsize approach
* Revert Capsize dependency change, use logical properties, move side-effect import
- Keep @capsizecss/vanilla-extract as the dependency (remove @capsizecss/core direct dep)
- Replace createStyleObject usage with direct style construction from vars
- Use logical properties (marginBlockEnd/marginBlockStart) in text CSS
- Move stylesheet root side-effect import from styles/index.ts to recipes/index.ts
- Update stylesheet-contract test to match logical property names
* Update STYLING.md to reflect direct Capsize style construction
* Move stylesheet root from side-effect import to build entry
- Remove side-effect import from recipes/index.ts
- Add stylesheet.css.ts as an extra Rollup input via inputOptions
- Clean up empty stylesheet-entry JS output in build:done hook
- Remove unused unlink import
* Revert inputOptions approach, restore side-effect import with clear comment
- Remove inputOptions and build:done hook from vite.config.ts
- Remove stale ./_ export from package.json
- Restore stylesheet root import in recipes/index.ts with explicit comment
explaining it is a VE build-time side-effect (stripped from compiled output)
Adds unbarrelify as a dev tool (catalog + @luke-ui/react devDependency)
and rewires the five internal consumers that imported through
src/styles/index.ts and src/combobox-field/primitive/index.tsx to
import directly from utilities.css.js and item.js instead.
Neither barrel file is deleted: both back public subpath exports
(./styles and ./combobox-field/primitive). Because those exports point
at built dist/ paths rather than the src/ files themselves,
unbarrelify's built-in package-export exception does not recognise
them and would delete both as internal barrels, which breaks the
public API (confirmed via a failing tsc run and vp pack silently
dropping both exports entries). check:barrels therefore skips these
two known package-export barrels; every other barrel in the source
tree is still caught, verified with a throwaway negative test.
Wires check:barrels ("unbarrelify --check") into turbo.json's
aggregate check task the same way check:format/check:lint/check:types
are wired, as its own global task that no-ops for packages without the
script, so `pnpm run check` exercises it repo-wide.
pnpm-lock.yaml picks up unrelated transitive churn (mostly optional
oxc-transform/oxc-minify platform binaries no longer needed after a
srvx bump) that surfaced when the lockfile was re-resolved for the new
dependency; build and tests pass against the regenerated lockfile.
* Consolidate polymorphic APIs onto elementType and render
Drop the hand-rolled `as` prop (used only by LoadingSkeleton) in favour
of `elementType`, the react-aria-components convention already used by
Text, Heading, and Numeral. `as` and `elementType` did the same job
(pick the DOM tag); this removes the duplicate, non-idiomatic path.
Also extract Box's local `BoxRender` type into a shared, documented
`RenderProp` type. react-aria-components does not publicly export its
render-function type, so this is the canonical Luke UI definition,
stopping the next component from inventing another render-prop variant.
Net API surface: `elementType` = which tag, `render` = take over
rendering. Two orthogonal, RAC-aligned APIs, no bespoke `as`.
- LoadingSkeleton: `as` -> `elementType` (component, stories, docs, example)
- heading-context: fix stale JSDoc example that referenced `as`
- Box: use shared RenderProp<'div'> instead of local BoxRender
- add packages/@luke-ui/react/src/types/render-prop.ts
* Reflow loading-skeleton docs prose to satisfy formatter