Cascade
The four tiers a visual value resolves through, in order, and why a registered default beats the application's.
A component's appearance is decided by tokens, solid, faded, md, xs, primary,
rather than by raw values, and every token comes from the same four places in the same order.
That rule is the cascade. Once you know it, a component's props read as a record of which tier
each option stops at instead of a list to memorise.
The four tiers
resolveCascade(instance, section, globalDefault, hardFallback) is the whole rule: it returns
the first argument that states a value, that is, the first that is not undefined or null.
Components resolve through it, directly for a single axis or through resolveConfigCascade for
a group of them. A radius goes through resolveRadiusKey, the same rule with "md" as the
default value of its fallback argument.
| Order | Tier | Where it is stated | Example |
|---|---|---|---|
| 1 | The instance | a prop on the element | <Button radius="full"> |
| 2 | components.<name> |
the application's component section, with the component's registered default underneath it | components: { button: { radius: "full" } } |
| 3 | The global default | defaultVariant, defaultColor, defaultRadius |
defaultRadius: "full" |
| 4 | The hard fallback | the component's own FALLBACK_*_CONFIG |
"md" |
The first tier that states a value wins and the tiers below it are never consulted.
Tier 2 is one tier with two sources. The application's section and the component's registered default are combined before the cascade runs, with the application's value winning. So tier 2 is always populated for a registered axis, and populated only by the application for an unregistered one.
Tier 3 exists only where the configuration defines one. On the web that is exactly three
axes: defaultVariant, defaultColor and defaultRadius. A component passes undefined at
this tier for every other axis, size, fullWidth, animate, labelAlign, status, a
layout's spacing, and those axes go from tier 2 straight to tier 4.
Tier 4 is a floor rather than a tier you normally see. On the three global axes the provider always resolves a value, so the hard fallback is reached only by an axis that has no global default; there it is the value the component documents, and the one it renders when no other tier speaks.
defaultTheme is not part of this cascade: it selects a palette, and a palette is not a value
any of these axes resolves. See Theming.
The native renderer resolves the same four tiers, and its default treatment is the same faded
as the web's: an axis the global tier answers has to answer the same way on both platforms. Only
the baselines around it differ, the platform configuration adds a defaultSize, and its radius
baseline is md rather than the web's xs. The order is unchanged, and a registered axis still
answers first. See React Native.
What a registered default does
A component's config module registers its defaults when the module is evaluated:
import { registerComponentDefaults } from "@asheeui/core";
registerComponentDefaults("button", {
size: "md",
animate: true,
fullWidth: false,
});
On the web, the components object the provider publishes is a getter over that registry
rather than a snapshot: it merges the registered defaults with the application's sections at
the moment a component reads it. That laziness is deliberate. A code-split client can evaluate
a component's module after the provider has mounted, and a snapshot taken at mount would then
disagree with what the server rendered.
Registration fills tier 2. It does not add a tier above the application and it does not lock
the axis: components: { button: { size: "sm" } } still beats the registered md, because
both are tier 2 and the application's object is applied second.
What registration does change is which tier answers for that axis. A registered axis has already answered at tier 2 for every instance, so the global default never reaches it. That is a decision per axis, and three components show the whole shape of it:
| Component | Registers | Leaves to the application | The result |
|---|---|---|---|
| Button | size, animate, fullWidth |
variant, color, radius |
defaultVariant, defaultColor and defaultRadius reach every button |
| Alert | type, radius: "md" |
variant |
an alert is md whatever defaultRadius states |
| Badge | size, radius: "full" |
variant, color |
a badge is a pill in a boxy product, unless it is told otherwise |
This is the one thing to check when defaultRadius does nothing. A component that registers
radius has already answered the tier the application is trying to set, so the global key is
not ignored, it never gets that far. State the axis for that component instead:
<AsheeUIProvider config={{ components: { alert: { radius: "lg" } } }}>
<App />
</AsheeUIProvider>
The field family makes the same point from the other direction. The web's field default
registers variant: "bordered", so an application's defaultVariant: "solid" reaches buttons,
badges and cards but not an input, which registers the family's own surface: a control's
treatment is a different decision from a button's, and registering the axis is how the family
says so. See Field anatomy.
A worked example
A button with no configuration resolves like this on the web:
| Axis | 1 instance | 2 components.button |
3 global | 4 fallback | Resolves to |
|---|---|---|---|---|---|
variant |
(not registered) | faded |
bordered |
faded |
|
color |
(not registered) | primary |
primary |
primary |
|
size |
md (registered) |
md |
md |
||
radius |
(not registered) | xs |
md |
xs |
So <Button>Save</Button> is a faded, primary, md button with xs corners: the
framework's boxy default, with the treatment, accent and corners coming from the configuration
and the density from the component's own registration.
Two lines change it for every button in the application:
import type { ExternalConfig } from "@asheeui/web";
export const config: ExternalConfig = {
defaultVariant: "solid",
defaultRadius: "full",
};
| Axis | Resolves to | Which tier answered |
|---|---|---|
variant |
solid |
3, because the button leaves variant unregistered |
color |
primary |
3 |
size |
md |
2, because the configuration states nothing for it |
radius |
full |
3, because the button leaves radius unregistered |
Nothing about the component changed. variant and radius were left unregistered so the
application could answer them, and size was registered so it could not. The same two lines
leave an Alert at md, because it registers that radius; a Badge registers full and
would ignore defaultRadius: "none" in the same way.
When the options are a group
Some components do not expose one axis but a set of options: a hero's align, spacing and
background; a navbar's position, variant and align; a split's stackAt and ratio.
resolveConfigCascade(instance, section, fallback) applies the same precedence to every key
of that set in one call:
- the instance's key wins, then the component section's, then the fallback's;
- there is no global tier, because an option set has no application-wide default;
- only the keys the fallback declares are resolved and returned, so a key the fallback does not know is dropped rather than half-applied. A typo in a component section therefore cannot render a value nobody designed;
- the instance argument is the component's own props gathered by hand, in
Hero,{ align, spacing, background, mediaPosition, containerSize, contained }, so the precedence is written once for all six.
The helper is one level deep. A group that nests, the Card's image, the sidebar's
options.active, the menu options a Dropdown passes under components.dropmenu.menu, is
resolved one key at a time with resolveCascade, which is the same rule applied one level
down. Image resolves its five flat options the same way: the helper is a convenience for a
flat set, not a second rule.
The instance is a decision
The rule compares with ??, so stating nothing and stating a deliberate value are different
things:
| The instance states | What happens | Why |
|---|---|---|
undefined, or null from JavaScript |
falls through to the next tier | nothing was stated |
false |
stops the cascade | "off" is a decision |
0 or "" |
stops the cascade | a zero or an empty string is a value |
"ghost" |
stops the cascade | the instance is the first tier |
So <Button fullWidth={false}> is narrower than a configured fullWidth: true, and
showSkeleton={false} turns a picture's placeholder off against a registered true. A
component that read its props with || would lose both.
The corollary concerns configuration rather than an instance: undefined inside a
configuration object is skipped by the merge, so a registered default cannot be removed by
stating nothing. State the value you want, on the component or on the instance.
Validation
The configuration is validated once per AsheeUIProvider mount, while the provider renders, so
a mistake is reported rather than silently replaced by a default:
- an invalid value throws, and the error lists every problem at once. The enumerable axes are
checked:
defaultTheme,defaultVariant,defaultColor,defaultRadius, every colour value in a theme, a custom theme'sextends, and the typography section's roles and tokens. - an unknown key warns in development and is silent in production. A stray key is usually a typo, and a typo should not take a running application down.
Two things are deliberately not checked, because the check would be wrong rather than because
they do not matter: a component section's name and its option names. Component defaults
register as their modules are evaluated, so at mount time the registry is incomplete and an
unknown name cannot be told from a not-yet-imported one; and the option names differ per
component ("card" is a radio's treatment and not a card's). A configuration typed as
ExternalConfig gets TypeScript's help instead, but a JavaScript or JSON configuration does
not: components.Button (capitalised) and components.button.varaint are both silent, and
both do nothing. Configuration states the file format and the
accepted keys.
Where the resolved value changes
The cascade decides which value is used; it does not decide what the value means. A family narrows a resolved name before it reaches a class map, and each rule is stated once so both renderers agree:
- a button with the
underlinedtreatment has no rounded corners, soUnderlineRadiusresolves any radius tonone; - a message surface has no underlined treatment, so
resolveMessageVariantturns a globalunderlinedintoborderedfor anAlertor a toast; - a card, a badge and a chip can draw only some of the shared treatments, so
resolveCardVariant,resolveBadgeVariantandresolveChipVariantland the rest on the nearest surface each one has; resolveVariantClasshas a last resort ofborderedwith thesecondaryaccent, for a variant and colour pair no dictionary carries.
None of these is a fifth tier. Each runs after resolution, on the one value the four tiers produced, and each is a statement about what the family can render rather than about where the value came from.
The tiers are the same for every component on both platforms; what differs is which axes a component leaves open. Each component page states which axes its own component registers, Configuration states the file and its keys, Theming the palette a colour role resolves against, and styling how a resolved name becomes a class.