jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

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.

5 dialects → 1 language density + paint axes every vocabulary style prop has a slot explicit ?? ambient ?? own verify:context gate

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.

door 1, live — the scope changes the DEFAULT, the explicit prop overrides it

button variant scope

variant-scope-demo.svelte svelte
<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.

door 1 — ButtonVariantScope, the zone without layout
<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) -->
door 1b — ButtonGroup, zone + join in one
<!-- 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>
door 2 — density, the css scope channel
<!-- 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 variantButtonVariantScope — 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 densitydata-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
door 3 — the plugin seam
// 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

A component author never imports the kernel — a family reads ambient values only through its own Defaults resolve line. definePlugin exists for the other side of the wall: an app-level intervention on axis values, which components and Defaults are oblivious to (the plugins → axes → Defaults firewalls, in the why section below).

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

This whole economy has one ancestor question (r14, the Owner): 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 before — five channels, five idioms
/* 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 CompositionLocalsthe axes stay FEW and structural — density, paint, entity depth, medium, hue
each component's <Component>Defaults factory objectXxxDefaults per family — the organization layer of ambient reads, a vocabulary adapter from axis words to component words
Color.Unspecified sentinel + takeOrElseTypeScript'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 levelthe setup / $derived window is the evaluation timing; the one resolve line per family is the audited evidence
@ReadOnlyComposable fine-grained invalidationgetter-endorsed context payloads + $derived chains — axis flips re-project automatically, without Compose's snapshot churn
the layer stack — plugins → axes → Defaults → props
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.

ClassMeaningExamples
a · axis openaxis slot — the environment may manage itdensity, paint
b · literal, pending axisown value declared, no axis yet — auditable in the contractDialog surface variant, size classes, kbd's ladder
c · axis roadmapregistered roadmap entry naming the prop and the target axiselevation, shape / radius
d · never ambientinstance semantics — a slot would be a category erroropen / 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.

how-to · for component authors

One contract file, one resolve line

Authoring against the seam is two moves. Ship a family defaults file — the family's single declared ambient contract, one object whose slots cover every vocabulary-hit style prop, zero kernel imports, installing with the family. Then read it in exactly one place inside the component — the $derived window. Everything else is the frozen idiom checklist below, learned in the pilot batch and the four migration waves.

the contract file — kbd, the values-first sample
// kbd-defaults.svelte.ts — the family's ONE ambient contract
import { defineComponentDefaults, defineLiteralSlot } from '@lib/defaults.svelte';
import { densitySlot } from '@lib/density.svelte';

// the named slot constant — the values tuple IS the union's source:
// default ∈ values is compile-locked, an own outside the tuple
// never compiles
export const kbdVariantSlot = defineLiteralSlot(['fill', 'tonal', 'outline'], 'tonal');

// the union, looked up — no hand-declared twin beside the values
export type KbdVariant = ReturnType<typeof kbdVariantSlot>;

export const KbdDefaults = defineComponentDefaults({
  variant: kbdVariantSlot,  // explicit ?? own, reads no context (class b)
  density: densitySlot(),   // no family own — a no-opinion slot
});
the read point — one line inside the component
<!-- inside press-button.svelte: the family's SINGLE audited read point.
     d.variant / d.density are fully typed and already resolved. -->
const d = $derived(PressButtonDefaults.resolve({ variant, density }));

// slot resolution law, one line, every slot the same:
//    explicit prop ?? ambient axis ?? own default
// a no-opinion slot (density with no own) has no last resort: it
// resolves to no opinion, stamps nothing, and the ambient css scope
// channel keeps flowing (the fleet law).
Slot factoryClassResolves
definePaintSlot(values, own)a — axisexplicit ?? paint zone ?? own; the values array is the family union's SOURCE — default ∈ values compile-locked — and the gate's availability carrier (AST-asserted bidirectionally against the frozen table; no runtime guard)
densitySlot(own?)a — axisexplicit ?? ambient density ?? own; no own means no opinion — nothing stamps
defineLiteralSlot(values, default)b — literalexplicit ?? own; reads no context, but the own is declared and audited — default ∈ values compile-locked
defineOpenSlot<T>(own)b — literalexplicit ?? own over an OPEN scalar domain — free lengths / numbers, no closed union to enumerate; no values to infer from, so the explicit type argument is the only enforcement face
absentSlot<T>()b — literalabsent HAS meaning — the resolved output honestly carries the absent state
classification b — the scalar faces: closed booleans + the open domain
// the literal family's scalar faces — trimmed from two families:
export const descriptionsBorderedSlot = defineLiteralSlot([false, true], false);

export const sheetSizeSlot = defineOpenSlot<string>('24rem');
// ↑ the drawer extent is a free CSS length — no closed union to
//   enumerate; a future size axis would first have to close the
//   union, at which point this slot migrates back to the values form.

// and the upgrade path stays documented in the file header: freeze a
// table row → promote to definePaintSlot(values, own) — kbd's
// convention.
the provider lane — inherit-then-provide, frozen form
// tabs.svelte — inherit-then-provide, the complete frozen form:
// 1. eager-capture the PARENT's value (argument position = statement
//    time, BEFORE this component writes its own key)
const resolvedDensity = $derived.by(
  ((inherited) => () => resolveDensity(density, inherited))(getDensityContext()),
);
provideDensity(() => resolvedDensity);

// 2. the family Defaults read stacks on the lane: the slot's ambient
//    read lands on the provider ABOVE, terminates at the captured
//    parent — one resolution per subtree, value pinned by tests
const d = $derived(TabsDefaults.resolve({ density }));

The frozen author checklist (pilot batch + four waves)

  • the values literal is the union's single source of truth — default ∈ values is compile-locked and the union is the named slot constant's ReturnType look-up; the gate AST-reads the inline first-argument array and asserts the paint rows equal the frozen availability table in BOTH directions — a missing or extra value fails (meta-feeding families keep their component-Props inline union instead: the surviving half of the drift double-lock — resolve-site assignability — is the documented exception)
  • the no-values slot factories — absentSlot and defineOpenSlot — take the explicit type argument (nothing to infer it from — omitting it is a compile error by construction, never a defaulted fallback); the values-first factories infer everything from the tuple: omitting either parameter, or a default outside the domain, cannot compile
  • the family barrel exports XxxDefaults and the slot types — the contract is part of the public face; use import type + export type (two statements) when re-exporting a union from the module script
  • inherit-then-provide families use the eager-capture provider lane and stack resolve on it (the snippet above) — read-before-write ordering is load-bearing; a lazy read inside the derived loops on the component's own write
  • multi-vocabulary families split honestly: the panel surface keeps variant, the action ladder takes an actionVariant slot — the split is recorded in the defaults file header
  • a family whose domain already lives in lib imports the values constant rather than re-declaring it — toast's TOAST_VARIANT_VALUES / TOAST_MATERIAL_VALUES tuples in the store are the only declaration source, the unions are typeof look-ups, and the slots feed the imported tuple to defineLiteralSlot — one source of truth beats two
  • zero vocabulary hits and no legacy reader → ship NO defaults file (an empty contract is speculation); a real density intent may ship a pure declaration slot
  • the docs props table states the truth per prop: the own value in the Default column, with the ambient wording tail — ambient zone, ambient scope, or Own default, not ambient

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.

jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h