design language · context & defaults (context-defaults-economy, 2026-09-03)
five dialects became one language — Defaults is the organization layer
A button standing alone is outline; the same button inside a dialog footer is ghost, and neither site passes a prop. That is the ambient style contract, and until now it was written in five dialects — density had a key and a plugin seam, paint had a zone scope, entity depth accumulated, medium projected, hue adapted. This change does not add a new foundation: Svelte context, the ?? operator, undefined-as-sentinel and the setup window were all already there, exactly as CompositionLocal, takeOrElse, Color.Unspecified and @Composable are in Jetpack Compose. What was missing is what Compose has and we lacked — an organization layer. Every family with a style vocabulary now ships one XxxDefaults object: the single declared place that answers which vocabulary props the environment may manage, audited by a gate, versioned by classification — the physics texture axis (raised) stays outside by ruling, on its own zone key.
recipes · for consumers
Change a subtree's defaults — the three doors
The question the whole economy answers, asked the way a page asks it: how do I make every button in this region ghost — or every control compact — without drilling a prop into every call site? You never touch a Defaults object from outside; the ambient economy reaches your page through exactly three doors. A zone scope changes the paint default for a subtree; a density stamp changes the scale of a subtree; a plugin intervenes on axis values under a medium. Everything else on this page — the contract files, the slot factories, the gate — exists so these three doors stay honest.
button variant scope
<script lang="ts">
import PressButton from '@ui/press-button/press-button.svelte';
import ButtonGroup from '@ui/button-group/button-group.svelte';
import ButtonVariantScope from '@ui/button-group/button-variant-scope.svelte';
</script>
<!-- three answers from one component, zero prop-drilling -->
<PressButton>lone — outline</PressButton>
<ButtonVariantScope variant="ghost">
<PressButton>adopts ghost</PressButton>
<PressButton>adopts ghost</PressButton>
<PressButton variant="fill">keeps fill</PressButton>
</ButtonVariantScope>
<ButtonGroup variant="ghost" label="zone + join">
<PressButton>joined ghost</PressButton>
<PressButton variant="fill">keeps fill</PressButton>
</ButtonGroup>Three answers from one component, zero prop-drilling: the lone button never saw a zone (its own default — outline); inside the scope, unprefixed buttons adopt ghost while the explicit fill wins; the group adds the join on top of the same zone.
<script lang="ts">
import PressButton from '@ui/press-button/press-button.svelte';
import { ButtonVariantScope } from '@ui/button-group/index';
</script>
<!-- the zone, layout-free — nothing renders but the buttons: no
group, no seams, no DOM of its own. Every PressButton/IconButton
inside that passes no variant adopts ghost; an explicit variant
always wins (explicit ?? ambient ?? own). This is Dialog's own
maneuver, verbatim — Card's head and foot zones practice it too
(with raised={false} on the foot: the physics lane's zone
default): both wrap their content in exactly this scope. -->
<ButtonVariantScope variant="ghost">
<PressButton>adopts ghost</PressButton>
<PressButton>adopts ghost</PressButton>
<PressButton variant="fill">keeps fill</PressButton>
</ButtonVariantScope>
<!-- link is NOT a zone value: variant="link" is a compile error —
link stays reachable only through PressButton's own explicit
prop (the interaction exception has no zone meaning) --><!-- the zone AND the join in one: variant here is the zone
default the children adopt, not a bezel paint — the group paints
nothing of its own -->
<ButtonGroup variant="ghost" label="row actions">
<PressButton>adopts ghost</PressButton>
<PressButton variant="fill">keeps fill</PressButton>
</ButtonGroup><!-- density needs no provider component: the ambient css
scope channel reads [data-density] on ANY ancestor — stamp a
wrapper and every density-aware component in the subtree follows
(the four-tier ruler switches as one); a component's own density
prop still wins. The full ruler lives in /tokens.html -->
<section data-density="sm">
<!-- tables, menus, inputs — the whole subtree takes the compact tier -->
</section>| you want to change… | the answer |
|---|---|
| a button's default variant | ButtonVariantScope — or ButtonGroup when the join is also wanted (the paint zone; link is excluded, so the interaction exception keeps its only route through the explicit prop) |
| a region's density | data-density on ANY ancestor — the css scope channel; a composition component's density prop provides for its subtree (ambient scope) |
| a literal default (badge tone, kbd rung, dialog surface) | not ambient yet (class b) — the explicit prop per instance; the own value is declared and audited in the family's defaults file, and the upgrade path is an opened axis |
| instance semantics (open, callbacks, aria) | never ambient (class d) — per instance by design; Compose does not ambient-ize onClick either |
// layer 3 — plugins intervene on AXIS VALUES, before any slot reads:
// the print story (real source, print/context-plugin.ts) maps any
// resolved density onto the EXISTING sm tier — no invented paper
// tier, the four-tier law holds — gated to the print projection
import { definePlugin } from '@lib/context-plugin.svelte';
import { DENSITY_DEF } from '@lib/density.svelte';
import type { Density } from '@lib/density.svelte';
// targets bind a DEF (an identity object, not a string): the hook
// types flow from the def — before receives the FULL resolution
// domain, Density | undefined, wherever the value came from
export const printDensityPlugin = definePlugin({
name: 'jx-print-density',
targets: [DENSITY_DEF], // object identity — the imported def, or no match
filter: printMediumGate, // reversible: screen → print → screen round-trips
before: (value: Density | undefined) => 'sm',
});
// the medium axis is environment truth: its def is read-only — a
// plugin targeting MEDIUM_DEF is rejected at the type level AND at
// runtime. Components never see the intervention — Defaults knows
// nothing of plugins, plugins know nothing of components; the axis
// value is the only meeting point.definePlugin is not how components read context
Each component page's props table states the truth per prop — the own value in the
Default column with the ambient tail (ambient zone, ambient scope, or Own default, not ambient). If a fourth door seems
needed, that is a design conversation, not a prop.
The origin story — a dialog footer's buttons
can't Context control the default variant of the buttons
inside DialogHeader / DialogFooter? The answer became ButtonVariantScope — a zero-DOM context boundary. Dialog wraps its
head and foot zones in a ghost scope, so every PressButton and IconButton inside —
joined or free-floating — defaults to ghost, while the same button on the page floor
stays outline. Defaults is that maneuver, promoted from one component's trick to every
family's declared contract.why
Five dialects → one language
The context economy was already running — but each axis spoke its own dialect, and the reads were scattered through per-component ?? chains. The ambient contract was invisible (no one place answers which props the environment may manage), unauditable (no gate could enforce it) and unversionable (opening one property meant shotgun changes). The fix is Compose's shape, translated one-to-one onto machinery Svelte 5 already had.
/* the five dialects (before) — one ambient channel per axis,
each with its own key, its own helper, its own ?? chain: */
density DENSITY_KEY + plugin seam resolveDensity(e, ctx)
medium read-only projection getMedium()
hue runtime adapter createHueContext()
button paint BUTTON_GROUP_KEY zone variant ?? scope.variant
entity depth entity.svelte.ts accum provideEntity()
/* the reads lived inline, in every component: */
variant ?? group?.variant ?? 'outline' // press-button, before| Jetpack Compose (verified in source) | jixoai counterpart |
|---|---|
| MaterialTheme is a facade over three CompositionLocals | the axes stay FEW and structural — density, paint, entity depth, medium, hue |
each component's <Component>Defaults factory object | XxxDefaults per family — the organization layer of ambient reads, a vocabulary adapter from axis words to component words |
Color.Unspecified sentinel + takeOrElse | TypeScript's undefined is a free sentinel; ?? is takeOrElse — null is rejected at compile time, undefined is the ONLY sentinel |
@Composable marks context consumers at the type level | the setup / $derived window is the evaluation timing; the one resolve line per family is the audited evidence |
@ReadOnlyComposable fine-grained invalidation | getter-endorsed context payloads + $derived chains — axis flips re-project automatically, without Compose's snapshot churn |
plugins (print & other cross-cutting) ← kernel never knows components,
↓ intervenes on AXIS VALUES only
axes (few, structural)
density │ paint ← the two collected into slots
entity-depth │ medium (read-only) │ hue ← keep their own mechanisms
↓ ONE read point per family
<Component>Defaults ← this change: the organization layer
↓ projects
resolved props (full values, inferred types, getter-endorsed)
↓ overridden by
explicit props (omit → the factory decides)The layering has two deliberate firewalls. Plugins (print and other cross-cutting
concerns) never know components — they intervene on axis values only; and Defaults
never knows plugins — it just resolves. The second firewall runs the other way too: lib never imports ui (a reverse-dependency gate asserts it), so the tool layer and the axis modules install
whole into any consumer.
law · honest edges
The axes & the coverage classes
Coverage means every public style prop has a slot — not that every prop is ambient today. A prop's slot is either an axis slot (ambient, the environment may manage it) or a literal family slot (own value declared and auditable, no axis yet). Ambient capability grows as axes open; the classification is gated. Exactly two axes are collected into slots; three keep their own mechanisms; two are honestly on the roadmap.
Collected into slots
density — the four-tier channel (lg / default /
sm / xs) with its plugin seam; paint — the
variant ladder's zone channel, on its own key
(PAINT_ZONE_KEY, values only — layout stays with
ButtonGroup's family key — one paint key, no compatibility
shims). Both become slot factories any family can declare.
Keeping their own mechanisms
entity depth (accumulates via provideEntity —
border-is-objecthood), medium (a read-only
projection — environment truth, never an opinion), hue (the runtime adapter). These are structural and environmental axes with no
per-prop consumption shape — forcing them into slots would be ceremony, not
coverage. They keep their channels; Defaults never wraps them.
On the roadmap (honestly)
elevation and shape / radius:
vocabulary not yet mature or zero real consumers — and a zero-consumer axis is
speculative generality. The open-axis protocol template (closed union + token map,
zone scope shape + slot factory signature, first-consumer contract) is written;
the first real consumer activates it.
| Class | Meaning | Examples |
|---|---|---|
a · axis open | axis slot — the environment may manage it | density, paint |
b · literal, pending axis | own value declared, no axis yet — auditable in the contract | Dialog surface variant, size classes, kbd's ladder |
c · axis roadmap | registered roadmap entry naming the prop and the target axis | elevation, shape / radius |
d · never ambient | instance semantics — a slot would be a category error | open / bind, callbacks, aria / data attrs, class, id, page-owned toggles |
The gate asserts the FULL classification: every style prop is a, b or d and carries a slot (or an explicit exemption), every class-c prop has a roadmap entry. Compose makes the same split — it does not ambient-ize onClick either; instance semantics are not style, and no amount of context economy changes that.
verification
Guards — what verify:context locks
A contract this central only survives if drift fails loudly. The gate (scripts/verify-context-coverage.mjs) walks the registry with the Svelte compiler's AST — not regexes — and every exemption is explicit, versioned and reasoned.
The assertions
- existence — every vocabulary-hit prop has a defaults file and a covering slot
- slot legality — a slot must be a REGISTERED factory product: a module-private type brand plus a factory-product registry (a bare function, a literal or a forged object is rejected at compile time, and at runtime in dev — the type brand is the production contract)
- family contract — the component calls its family's resolve, and the retired channels (the old helpers, raw axis-key reads) stay retired
- availability consistency — family variant unions stay inside the paint axis and match the frozen table (link stays PressButton-only)
- lib→ui reverse dependency — the tool and axis modules never import a component
- exemptions explicit — bindable / passthrough / no-style / provider / roadmap entries must all hit and carry a reason
Where this surfaces to readers
Each component page's props table states the ambient truth per prop (the own value plus the ambient wording tail); this page is the map of the system, the component pages are the per-family ground truth. The search corpus and the llms.txt mirrors regenerate at build — this page joins them automatically.
Honest edges, on record: elevation and shape are roadmap classes, not shipped axes (class c — the open-axis protocol activates with the first real consumer); instance semantics are class d forever; and the paint axis is a single-key lane (PAINT_ZONE_KEY) — ButtonGroup's family key carries layout only, never paint. The physics texture axis (press-button's raised) also stays outside this economy by ruling: it rides PRESS_TEXTURE_KEY — its own zone key, resolved explicit ?? zone ?? true — never a Defaults slot, because physics must flow through joined groups while paint does not.