feat(css): ordered CSS value fallbacks - #3745
Open
segunadebayo wants to merge 1 commit into
Open
Conversation
Add `fallback(a, b)`, a value form that emits one declaration per member for
one property, so a modern value can pair with a supported one:
css({ color: css.fallback('oklch(55% 0.18 250)', '#0057b8') })
.c_fallback\(oklch\(55\%_0\.18_250\)\,_\#0057b8\) {
color: #0057b8;
color: oklch(55% 0.18 250);
}
Members are written most-preferred first, matching `var(--brand, red)`, and
emitted in reverse because CSS keeps the last declaration it understands.
The value is a plain string, so extraction, encoding, and build info carry it
as one atom and one class with no new IR and no schema change. Only the
stylesheet expands it, through one path shared by atoms and recipe entries.
`css.fallback()` and `cssFallback()` from @pandacss/dev build the same string.
Both use two overloads: a uniform one whose type parameter comes from the
property, so its values autocomplete, and a second that infers each position
separately for members of differing types.
Malformed runs emit nothing rather than leaking `fallback(...)` into the sheet,
and report one of seven diagnostics.
馃 Changeset detectedLatest commit: f0f299b The changes in this PR will be included in the next version bump. This PR includes changesets to release 19 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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.
CSS has always let you write a property twice so an older browser keeps the value it understands. Panda could not express it, because the array slot that would have carried it already means responsive values.
This adds
fallback(a, b).You write the value you want first, the same shape as
var(--brand, red). Panda emits the members in reverse, because CSS keeps the last declaration it understands.The value is a string, and that is the whole design
An earlier draft of the design note proposed a marker object,
{ __panda: 'fallback', values: [...] }, carried by newFallbackvariants onStyleTree,Literal, andAtomValue, plus aFallbackScalartype, a build-info wire change, and aSCHEMA_VERSIONbump from 5 to 6.That version was written and compiling before it got thrown away. None of it was needed. A fallback run is one value for one property, so writing it as one string says exactly that, and every stage Panda already has treats a string correctly without being taught anything:
StyleTree/LiteralThree problems the marker design had went away with it. Token references keep working, because
collect_token_refsalready scans raw value strings for{colors.brand}. There is no runtime parity contract to maintain, because class names come from the value text through the same escaping every arbitrary value uses. And design systems need no compatibility gate, because a published library's build info carries a string that every consumer version already understands.Class names are escaped, not hashed
Hashing was considered and rejected. It would make
fallback()the only value form with a bespoke naming rule, it would create a runtime parity contract where none is needed, and it would produce class names that say nothing when readable class names are the entire point of the default.hashClassNames: truealready hashes everything uniformly for anyone who wants short names.Members are typed by the property they sit in
The first overload is what makes the editor useful.
Thas no argument to infer from before you type one, so it comes from the contextual return type, and every parameter is typed as that property's value union. Insidecss.fallback(you get the same 33 color tokens you get oncolor:itself, measured through the TypeScript language service against the real generatedstyled-system.The second overload catches members that do not share a type, where the first fails.
Two earlier attempts each lost something measurable. A phantom-branded
CssFallbackValue<T>autocompleted but forced every member to one type, socss.fallback(4, '1rem')failed. StyleX'sfirstThatWorkssignature alone allowed mixed types but offered zero completions, because parameters inferred from arguments have no contextual type to suggest from. The overload pair keeps both and needs no brand, soWithEscapeHatchis untouched by this feature.Config recipes
A config file loads before
styled-system/cssexists, so it writes the value form directly, or uses the same helper from@pandacss/dev:The helper buys arity as a compile error, a function name that cannot be silently mistyped, and keyword autocomplete inside the call: 13 completions for
position, versus 0 for a non-generic signature. It does not buy value validation.@pandacss/typeshas no token unions and csstype admitsstring & {}for every property, so any string is a legal config value with or without it.Importance belongs to the run
!importantapplies to a whole run or to none of it. Marking every member individually means the same thing and is accepted. Marking only some is rejected, because an important declaration beats the others whatever the order, so the rest could never apply.fallback(a !important, b)leavesbunprotected onceaturns out unsupported.fallback(a, b !important)is worse: the fallback always wins, so the preferred value never applies at all.This needed its own handling.
split_importanttakes the first!anywhere in a value, which for a run hoists one member's marker onto every declaration.split_run_importantstrips only a marker after the closing paren.Malformed runs emit nothing
fallback(...)is not real CSS, so passing a malformed one through guarantees a broken declaration. Every drop is reported instead, across seven diagnostic codes at two layers: the extractor reports misuse of the API with a call span, the stylesheet reports malformed values, which is the only layer that sees a hand-written string. They do not double-report, because a refusedcss.fallback()never folds to a value.A dynamic member is deliberately not one of them. It is an ordinary runtime bailout and already reports
panda_call_unextractable.What is out of scope
Custom-property declarations are rejected with a warning.
--accentaccepts an arbitrary token stream, so an older browser keeps the second declaration and only discovers the unsupported value whenvar(--accent)is substituted, too late to recover the first. The warning points atvar(--accent, ...), which is the construct that actually works.That is also why this does not collapse variable members into nested
var()the way StyleX does. Panda emits:root { --colors-brand: ... }for every token it uses, so the failure barely arises on the token path, and for genuinely external variables nativevar()fallback syntax already works and already passes through Panda untouched.Verification
Beyond the test suite, the emitted CSS was checked in Chrome 151 through
getComputedStyle, against a stylesheet produced by the real CLI insandbox/vite-ts:fallback(oklch(55% .18 250), #0057b8)oklch(0.55 0.18 250)fallback(not-a-real-color(1), #0057b8)rgb(0, 87, 184)fallback(bogus-fn(9), color(display-p3 ...), #0057b8)color(display-p3 0 0.6 0.2)_hover: { fallback(nope-fn(2), magenta) }rgb(255, 0, 255)_dark > md > _hoverrunThe second row is the feature working. Nothing in a unit test can demonstrate that a browser discards an unparseable declaration and falls back.
Not covered: Firefox, Safari, and
@media print.Tests
130 new tests:
pandacss_shared)css.fallback(), including binding-aware rejection and source spansstrictTokenstyping, andcssFallback()cargo nextest run --workspaceis at 2481 passing,pnpm test sandbox/codegenat 259.Three bugs this found in existing code
Two of them were only reachable through a fallback, but both were latent in code that predates it.
append_declarationsappended one declaration at a time, and a single declaration replaces an existing one for the same property. Any rule with two runs silently lost all but the last member of the second one. It now groups consecutive same-property declarations and moves them as a unit.collect_atom_usagehanded the wholefallback(...)string to the utility transform, so a token used only inside a run never resolved and got pruned while the CSS still referenced it.The third is worth knowing but was left alone:
important_markertakes the first!anywhere in a value, socontent: "'!'"reads as important too. Fixing that shared helper is a separate change with its own parity tests.design-notes/css-value-fallbacks.mdhas the full design, including both rejected type signatures so they do not get retried.