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

design language · variant-grammar (frozen r1)

one ladder, four slots — and a TW4 plugin with zero JS

Every painted surface in this registry answers two orthogonal questions. HOW LOUD is it? — the prominence ladder: fill, tonal, outline, ghost, plus fused (the backdrop-fusion rung, promoted 2026-09-08 — paint derived from the ground behind the element). WHAT DOES IT MEAN? — a hue injected into four global, inheritable slots (--jx-fill, --jx-fill-ink, --jx-tonal, --jx-outline). The old semantic-name variants (primary, secondary, destructive as prop values) are retired: they multiplied a color union into every component and fused prominence with meaning. The grammar keeps one small ladder per surface and moves intent to the call site — and since 2026-08-27 the curated intents ship as Tailwind v4 @utility rules authored inside the theme sheet itself: installing the theme IS installing the plugin. This page is the standard and the plugin's manual.

4 rungs + 1 exception + 1 fusion 4 hue slots @utility intent layer action ≠ status forced-colors law

demo

The ladder — fill, tonal, outline, ghost, fused

Prominence is a ladder, and it is the ONLY thing the variant prop encodes. fill speaks loudest (solid ground, same-hue border, inverted ink); tonal is the tinted voice (a 12% tint of the hue); outline draws structure only (transparent ground, border-colored border); ghost is interactive chrome (transparent at rest, tonal on hover, geometry preserved through a transparent border). fused is the backdrop-fusion rung (2026-09-08): paint derived from the ground BEHIND the element — a transparent chip + a backdrop contrast filter pull whatever sits behind toward mid, so the band reads over any ground with zero color tokens; the quietest rung, no own color, the filter IS the frame (forced-colors repaints it CanvasText). link is deliberately NOT on the ladder — it is PressButton's one interaction exception: no frame, no press shadow, primary text, hover underline. Availability is per-component: banners never get fill (readability), badges never get ghost (they are display, not chrome), quotes stop at outline/tonal — quote readability excludes fill/ghost, and ghost is interactive-chrome vocabulary a static quote misuses (the Blockquote row joins the frozen availability table in the same two-rung shape as Alert).

the ladder — every rung, live

One prop per surface, one ladder shared by every painted module: PressButton's full union (link included — the interaction exception), Badge and Chip on their frozen subsets, Alert/Blockquote on the reading two, InlineCode riding fused as its own default.

PressButton — the full union (default: outline)
Badge — fill / tonal (default) / outline
new running beta
Chip — all four rungs, control-scale on the hit lane
Alert — outline (default) / tonal; Blockquote — outline (default) / tonal; InlineCode — fused (default) / tonal / outline

Build queued

The canary build enters the queue behind two commits.

Heads up

Outline keeps the muted body — the neutral rung's own ink ramp for long copy.

Note

The quote surface rides the same two rungs as the banner — availability frozen (fill/ghost excluded: readability, and ghost is interactive-chrome vocabulary).
Outline is the classic left-rule quote — the reading-content posture, own default; the rule itself is now the shadow channel (an inset rule at 1px by default, the rule×size axis beside the rungs).
the variant grammar, §ladder

Inline code rides the same ladder: npm run verify is the fused default (the band fused from the backdrop behind it), and npm run verify is its tonal twin — npm run verify the structural one.

variant-grammar-ladder-usage.svelte svelte
<script lang="ts">
import Alert from '@ui/alert';
import Badge from '@ui/badge';
import Blockquote from '@ui/blockquote';
import Chip from '@ui/chip';
import InlineCode from '@ui/inline-code';
import PressButton from '@ui/press-button';
</script>

<div class={cx(rt.col24)}>
  <div class={cx(rt.col12)}>
    <span class={cx(rt.note11)}>PressButton — the full union (default: outline)</span>
    <div class={cx(rt.rowC12, rt.wrap)}>
      <PressButton variant="fill">deploy</PressButton>
      <PressButton variant="tonal">preview</PressButton>
      <PressButton variant="outline">cancel</PressButton>
      <PressButton variant="ghost">skip</PressButton>
      <PressButton variant="link">read the docs</PressButton>
    </div>
  </div>
  <div class={cx(rt.grid760g24)}>
    <div class={cx(rt.col12)}>
      <span class={cx(rt.note11)}>Badge — fill / tonal (default) / outline</span>
      <div class={cx(rt.rowC12, rt.wrap)}>
        <Badge variant="fill">new</Badge>
        <Badge>running</Badge>
        <Badge variant="outline">beta</Badge>
      </div>
      <span class={cx(rt.note11)}>
        Chip — all four rungs, control-scale on the hit lane
      </span>
      <div class={cx(rt.rowC12, rt.wrap)}>
        <Chip variant="fill">filter: owner</Chip>
        <Chip>filter: open</Chip>
        <Chip variant="outline">filter: label</Chip>
        <Chip variant="ghost">clear filters</Chip>
      </div>
    </div>
    <div class={cx(rt.col12)}>
      <span class={cx(rt.note11)}>
        Alert — outline (default) / tonal; Blockquote — outline (default) / tonal;
        InlineCode — fused (default) / tonal / outline
      </span>
      <Alert variant="tonal" title="Build queued">
        The canary build enters the queue behind two commits.
      </Alert>
      <Alert title="Heads up">
        Outline keeps the muted body — the neutral rung's own ink ramp for long copy.
      </Alert>
      <Blockquote variant="tonal" label="Note">
        The quote surface rides the same two rungs as the banner — availability frozen
        (fill/ghost excluded: readability, and ghost is interactive-chrome vocabulary).
      </Blockquote>
      <Blockquote cite="the variant grammar, §ladder">
        Outline is the classic left-rule quote — the reading-content posture, own default;
        the rule itself is now the shadow channel (an inset rule at 1px by default, the
        rule×size axis beside the rungs).
      </Blockquote>
      <p class={cx(rt.body13)}>
        Inline code rides the same ladder:
        <InlineCode>npm run verify</InlineCode> is the fused default (the band fused
        from the backdrop behind it), and
        <InlineCode variant="tonal">npm run verify</InlineCode> is its tonal twin —
        <InlineCode variant="outline">npm run verify</InlineCode> the structural one.
      </p>
    </div>
  </div>
</div>
RungReads asAvailability (default)
fillsolid ground + same-hue border + inverted inkBadge, Chip, PressButton
tonal12% ground, 45% border, the hue as inkBadge (default), InlineCode, Chip (default), PressButton, Alert, Blockquote
outlinetransparent ground, structural border, foreground inkall — PressButton (default), Alert (default), Blockquote (default)
ghosttransparent rest; tonal hover; geometry preservedChip, PressButton only
linkthe interaction exception — no frame, no press shadowPressButton only
fusedthe backdrop-fusion rung — paint from the ground behind the element; quietest, no own color, the filter is the frameInlineCode (default, since 2026-09-08)
the paint recipes (normative)
/* fill — loudest */
background: var(--jx-fill);
border: 1px solid var(--jx-fill);
color: var(--jx-fill-ink);

/* tonal — the default voice */
background: color-mix(in oklab, var(--jx-tonal) 12%, transparent);
border: 1px solid color-mix(in oklab, var(--jx-tonal) 45%, transparent);
color: var(--jx-tonal);

/* outline — structure only */
background: transparent;
border: 1px solid var(--jx-outline);
color: var(--foreground);

/* ghost — transparent rest, tonal hover, geometry preserved */
rest:  transparent ground, transparent border, foreground ink;
hover: color-mix(in oklab, var(--jx-tonal) 8%, transparent), tonal ink;

/* fused — the backdrop-fusion rung: paint derived from the ground
   BEHIND the element (the separator's ink technique, promoted
   2026-09-08); quietest, zero own color, the filter IS the frame */
background: transparent;
border: 1px solid transparent;
backdrop-filter: contrast(0.85);
color: var(--foreground);

law

The four tokens — global, inheritable, theme-owned

Variant paint never names a color; it consumes four custom properties defined once on :root (and the byte-identical registry mirror). They are inheritable by nature: mount an injection on a subtree and every ladder consumer inside retunes. They are density-free: color only, no geometry. And the set is closed on purpose — there is no --jx-ghost (ghost derives its hover tint from --jx-tonal) and no --jx-tonal-ink (tonal text IS the hue).

jixoai.css — live extract
TokenDefaultSource
--jx-fillvar(--primary)color
--jx-fill-inkvar(--primary-foreground)color
--jx-tonalvar(--primary)color
--jx-outlinevar(--border)color

The color-mix math

tonal grounds at 12% of the hue over transparent, borders at 45%, and sets text to the hue at 100% — one hue, three weights, no derived ink token needed. outline's interactive hover adds an 8% overlay of --jx-tonal with the border unchanged; ghost rests transparent and hovers to the same 8% ground plus tonal ink. The mixes ride color-mix(in oklab, …) so tints stay perceptual, and the utilities emit @supports fallbacks to the plain var() for engines without color-mix.

The .pill lineage

The recipe has a fossil in this very site: the item-name pill in the docs tables hardcodes the 12%/45%/primary triple. The tonal rung is that pill with its hue source generalized from var(--primary) to var(--jx-tonal).

the ancestor
/* apps/www/src/lib/site/docs-tables.css — .pill */
:where(.pill) {
  background: color-mix(in oklab, var(--primary) 12%, transparent);
  border: 1px solid color-mix(in oklab, var(--primary) 45%, transparent);
  color: var(--primary);
}
/* the tonal rung is this recipe with var(--primary) generalized to
   var(--jx-tonal) — one hue source instead of a hardcoded brand */

law

Semantic hue injection — meaning rides the slots

A failed build is not a variant of loudness, so failure is not a variant name. Intent is expressed by injecting a semantic value into the slots, and ONE law governs the vocabulary: action vs status is MANDATORY. destructive is an ACTION hue — it may only ride the fill pair, on things that delete. error is a STATUS hue — it rides the tonal slot, on things that report. The split is not stylistic: destructive stays the theme's monochrome inversion pair (black-on-light, white-on-dark) while error is the red semantic — conflating them painted delete buttons red and failure banners inverted, both wrong.

IntentFill pairTonal slotNotes
Brandprimary / primary-foreground--primarydefault emphasis
Neutral / metamuted / muted-foreground--muted-foregroundthe old secondary; metadata
Destructive ACTION--destructive + --destructive-foreground--destructivedelete/cancel actions only (PressButton)
Error STATUSerror / error-foreground--errorfailed/invalid STATUSES (Badge, Alert)
Success statussuccess / success-foreground--successcopied / passing
Warning / infomatching pairs--warning / --infoonly when the intent needs it

hue injection — status vs action, live

STATUS hues ride the tonal slot on things that report (Badge, Alert); the ACTION hue rides the fill pair on things that destroy (PressButton + jx-pair-destructive). The right column is the same failure in both grammars, and the inheritance demo: one jx-hue-info on a wrapper retunes every consumer below it.

STATUS hues — the tonal slot, reported states
draft failed passing degraded canary
ACTION hue — the fill pair, verbs that destroy

The pair law: --jx-fill and --jx-fill-ink are ALWAYS injected together — one without the other paints brand ink on a destructive ground.

the same failure, both grammars — STATUS error (left) vs ACTION destructive (right)
confirm the destructive action:
inheritance — one injection on the wrapper retunes every consumer below
info badge

The wrapper carries class="jx-hue-info" — the slots are ordinary custom properties, so the subtree inherits them; even the outline rung's 8% hover overlay follows the retuned hue.

variant-grammar-injection-usage.svelte svelte
<script lang="ts">
import Alert from '@ui/alert';
import Badge from '@ui/badge';
import Chip from '@ui/chip';
import Icon from '@ui/icon';
import PressButton from '@ui/press-button';
</script>

<div class={cx(rt.grid760g24)}>
  <div class={cx(rt.col12)}>
    <span class={cx(rt.note11)}>STATUS hues — the tonal slot, reported states</span>
    <div class={cx(rt.rowC12, rt.wrap)}>
      <Badge class="jx-hue-neutral">draft</Badge>
      <Badge class="jx-hue-error">failed</Badge>
      <Badge shape="pill" class="jx-hue-success">
        {#snippet slotStart()}<Icon name="check" />{/snippet}
        passing
      </Badge>
      <Badge class="jx-hue-warning">degraded</Badge>
      <Badge class="jx-hue-info">canary</Badge>
    </div>
    <span class={cx(rt.note11)}>ACTION hue — the fill pair, verbs that destroy</span>
    <div class={cx(rt.rowC12, rt.wrap)}>
      <PressButton variant="fill" class="jx-pair-destructive">delete workspace</PressButton>
      <PressButton variant="outline">cancel</PressButton>
    </div>
    <p class={cx(rt.bodyMuted)}>
      The pair law: <code class={cx(rt.inkAccent)}>--jx-fill</code> and
      <code class={cx(rt.inkAccent)}>--jx-fill-ink</code> are ALWAYS injected together — one
      without the other paints brand ink on a destructive ground.
    </p>
  </div>
  <div class={cx(rt.col12)}>
    <span class={cx(rt.note11)}>
      the same failure, both grammars — STATUS error (left) vs ACTION destructive (right)
    </span>
    <div class={cx(rt.grid, rt.gap12)}>
      <Alert variant="tonal" assertive title="Canary failed">
        The canary build errored on seat 3 — an error STATUS reads tonal + the error hue.
      </Alert>
      <div class={cx(rt.rowC12, rt.wrap)}>
        <span class={cx(rt.note11)}>confirm the destructive action:</span>
        <PressButton variant="fill" class="jx-pair-destructive">discard changes</PressButton>
      </div>
    </div>
    <span class={cx(rt.note11)}>
      inheritance — one injection on the wrapper retunes every consumer below
    </span>
    <div class="jx-hue-info {cx(rt.rowC12, rt.wrap)}">
      <Badge>info badge</Badge>
      <Chip>info chip</Chip>
      <PressButton variant="tonal">tonal button</PressButton>
      <PressButton variant="outline">outline — hover me</PressButton>
    </div>
    <p class={cx(rt.bodyMuted)}>
      The wrapper carries <code class={cx(rt.inkAccent)}>class="jx-hue-info"</code> — the
      slots are ordinary custom properties, so the subtree inherits them; even the
      outline rung's 8% hover overlay follows the retuned hue.
    </p>
  </div>
</div>

plugin · @utility

The intent layer — a Tailwind v4 plugin with zero JS

The arbitrary-property seam (class with bracketed custom properties) works everywhere, but it is verbose, typo-fragile — a misspelled token still emits a well-formed rule that silently dead-styles — and its pair invariant was enforced only by prose. The fix is not a JavaScript plugin: the intent names are authored as TW4-native @utility rules INSIDE the theme sheet. Installing the theme IS installing the plugin; registry consumers need zero JS setup. The set is closed, so a typo matches nothing and emits nothing — a compile-time miss instead of silent dead CSS. And the emission is not an approximation: on the resolved Tailwind 4.3.3, @tailwindcss/node's candidatesToCss probes the @utility output as declaration-equivalent to the arbitrary-property form — identical declarations, predictably different selectors.

jixoai.css — live extract
UtilityEmitsSemantics
jx-hue-primary--jx-tonal: var(--primary)brand emphasis — the default
jx-hue-neutral--jx-tonal: var(--muted-foreground)metadata; the old secondary
jx-hue-error--jx-tonal: var(--error)failed / invalid STATUS
jx-hue-success--jx-tonal: var(--success)passing / copied STATUS
jx-hue-warning--jx-tonal: var(--warning)caution STATUS
jx-hue-info--jx-tonal: var(--info)informational STATUS
jx-pair-destructive--jx-fill + --jx-fill-ink (destructive pair)destructive ACTION — both slots, one class: the pair law made structural
jx-hue-destructive— does not exist, by constructiondestructive is an ACTION hue and may only ride the pair

the intent layer — live

The closed set as one class per intent, the pair utility beside the arbitrary pair it replaces (identical paint; the utility cannot half-apply or typo a token name), and the arbitrary form as the escape hatch for hues no semantic token owns.

the closed set, live — every intent is one class
primary neutral error success warning info
jx-pair-destructive vs the arbitrary pair it replaces

Identical paint — but the utility cannot half-apply, cannot typo a token name, and documents its intent in the class list.

the escape hatch — anything outside the closed set
untitled violet a hue no semantic token owns — the arbitrary form stays canonical for it
variant-grammar-intent-usage.svelte svelte
<script lang="ts">
import Badge from '@ui/badge';
import PressButton from '@ui/press-button';
</script>

<div class={cx(rt.col16)}>
  <span class={cx(rt.note11)}>the closed set, live — every intent is one class</span>
  <div class={cx(rt.rowC12, rt.wrap)}>
    <Badge class="jx-hue-primary">primary</Badge>
    <Badge class="jx-hue-neutral">neutral</Badge>
    <Badge class="jx-hue-error">error</Badge>
    <Badge class="jx-hue-success">success</Badge>
    <Badge class="jx-hue-warning">warning</Badge>
    <Badge class="jx-hue-info">info</Badge>
  </div>
  <span class={cx(rt.note11)}>
    jx-pair-destructive vs the arbitrary pair it replaces
  </span>
  <div class={cx(rt.rowC12, rt.wrap)}>
    <PressButton variant="fill" class="jx-pair-destructive">the pair utility</PressButton>
    <PressButton
      variant="fill"
      class={cx(rt.vgFillDestructive, rt.vgInkDestructive)}
    >
      the arbitrary pair
    </PressButton>
  </div>
  <p class={cx(rt.bodyMuted)}>
    Identical paint — but the utility cannot half-apply, cannot typo a token name, and
    documents its intent in the class list.
  </p>
  <span class={cx(rt.note11)}>the escape hatch — anything outside the closed set</span>
  <div class={cx(rt.rowC12, rt.wrap)}>
    <Badge class={cx(rt.vgTonalViolet)}>untitled violet</Badge>
    <span class={cx(rt.note11)}>
      a hue no semantic token owns — the arbitrary form stays canonical for it
    </span>
  </div>
</div>

cn() dedupe — last-wins

The middle badge carries BOTH classes and the winner is the sheet's internal sort order; the third resolves through cn(), which registers the closed set as tailwind-merge dedupe groups — one class, one hue, guaranteed.

cn() dedupe — last-wins, exactly like the arbitrary form
base — error naive concat cn() deduped
variant-grammar-dedupe-usage.svelte svelte
<script lang="ts">
import Badge from '@ui/badge';
import { cn } from '@lib/utils';
</script>

<div class={cx(rt.col16)}>
  <span class={cx(rt.note11)}>
    cn() dedupe — last-wins, exactly like the arbitrary form
  </span>
  <div class={cx(rt.rowC12, rt.wrap)}>
    <Badge class="jx-hue-error">base — error</Badge>
    <Badge class="jx-hue-error jx-hue-success">naive concat</Badge>
    <Badge class={cn('jx-hue-error', 'jx-hue-success')}>cn() deduped</Badge>
  </div>
</div>

The middle badge carries BOTH classes — the winner is the sheet's internal sort order, not the order you typed and not your intent. cn() registers the closed set as tailwind-merge dedupe groups, so the third badge resolves to jx-hue-success — one class, one hue, guaranteed.

cn() — class hygiene
import { cn } from '@lib/utils';

cn('jx-hue-error', 'jx-hue-success')             // 'jx-hue-success' — last wins
cn('jx-hue-error', 'jx-hue-error')               // 'jx-hue-error'   — idempotent
cn('jx-hue-error', 'text-xs', 'jx-hue-neutral')  // 'text-xs jx-hue-neutral'
usage
<script lang="ts">
import Alert from '@ui/alert';
import Badge from '@ui/badge';
import Chip from '@ui/chip';
import Icon from '@ui/icon';
import PressButton from '@ui/press-button';
</script>

<div class={cx(rt.grid760g24)}>
  <div class={cx(rt.col12)}>
    <span class={cx(rt.note11)}>STATUS hues — the tonal slot, reported states</span>
    <div class={cx(rt.rowC12, rt.wrap)}>
      <Badge class="jx-hue-neutral">draft</Badge>
      <Badge class="jx-hue-error">failed</Badge>
      <Badge shape="pill" class="jx-hue-success">
        {#snippet slotStart()}<Icon name="check" />{/snippet}
        passing
      </Badge>
      <Badge class="jx-hue-warning">degraded</Badge>
      <Badge class="jx-hue-info">canary</Badge>
    </div>
    <span class={cx(rt.note11)}>ACTION hue — the fill pair, verbs that destroy</span>
    <div class={cx(rt.rowC12, rt.wrap)}>
      <PressButton variant="fill" class="jx-pair-destructive">delete workspace</PressButton>
      <PressButton variant="outline">cancel</PressButton>
    </div>
    <p class={cx(rt.bodyMuted)}>
      The pair law: <code class={cx(rt.inkAccent)}>--jx-fill</code> and
      <code class={cx(rt.inkAccent)}>--jx-fill-ink</code> are ALWAYS injected together — one
      without the other paints brand ink on a destructive ground.
    </p>
  </div>
  <div class={cx(rt.col12)}>
    <span class={cx(rt.note11)}>
      the same failure, both grammars — STATUS error (left) vs ACTION destructive (right)
    </span>
    <div class={cx(rt.grid, rt.gap12)}>
      <Alert variant="tonal" assertive title="Canary failed">
        The canary build errored on seat 3 — an error STATUS reads tonal + the error hue.
      </Alert>
      <div class={cx(rt.rowC12, rt.wrap)}>
        <span class={cx(rt.note11)}>confirm the destructive action:</span>
        <PressButton variant="fill" class="jx-pair-destructive">discard changes</PressButton>
      </div>
    </div>
    <span class={cx(rt.note11)}>
      inheritance — one injection on the wrapper retunes every consumer below
    </span>
    <div class="jx-hue-info {cx(rt.rowC12, rt.wrap)}">
      <Badge>info badge</Badge>
      <Chip>info chip</Chip>
      <PressButton variant="tonal">tonal button</PressButton>
      <PressButton variant="outline">outline — hover me</PressButton>
    </div>
    <p class={cx(rt.bodyMuted)}>
      The wrapper carries <code class={cx(rt.inkAccent)}>class="jx-hue-info"</code> — the
      slots are ordinary custom properties, so the subtree inherits them; even the
      outline rung's 8% hover overlay follows the retuned hue.
    </p>
  </div>
</div>

One form per slot — the mixing law

Intent utilities are canonical for the curated set; the arbitrary-property class is the escape hatch. They are not composable: cross-form mixing is not dedupable — cn() cannot see that a utility and an arbitrary property fight over the same slot, so the sheet's sort order decides again. The law: ONE form per slot in a class list.

the mixing law
<!-- BOTH set --jx-tonal through different mechanisms — cn() cannot
     see the collision, and the sheet's own sort order decides. ONE
     form per slot in a class list: -->
<Badge class="jx-hue-error [--jx-tonal:var(--success)]">undefined behavior</Badge>

<!-- pick a form — either is lawful alone: -->
<Badge class="jx-hue-error">error, canonical form</Badge>
<Badge class="[--jx-tonal:var(--success)]">success, escape-hatch form</Badge>

law

The TW4 laws this grammar rides

The grammar is authored as utilities in the markup, which buys theme-scale retuning at the cost of living inside Tailwind's cascade. Three laws keep that trade honest.

Sole border-color source per rung

The war story: an early map gave the shared frame a named border-color utility (border-something) and let rungs override it with the arbitrary form. But in a TW4 sheet, NAMED border-color utilities sort AFTER arbitrary ones, and same-family utility order inside one class list is not consumer-guaranteed — so the frame's color silently, deterministically, wrongly won. The paint looked fine in isolation and drifted the moment anyone composed classes. The law that ended it: the frame contributes WIDTH and PHYSICS only (jx-press border), and every rung is the sole source of each paint channel it sets — no two same-property utilities ever meet in one class list. Collision-free by construction, in Badge, Chip, PressButton, Alert and InlineCode alike.

The glued-pair trap

Two arbitrary properties typed without the space glue into ONE class token — Tailwind emits only the first property and the pair silently half-applies (fill without its ink). The guard suite walks every .svelte file and fails on the adjacency of a closing bracket directly followed by an opening [--jx- property — this page's own demo string is concatenated at runtime to keep the file itself clean.

glued vs split
glued — ONE class token; the second property never emits:
  class="[--jx-fill:var(--destructive)][--jx-fill-ink:var(--destructive-foreground)]"

split — two utilities; both properties land:
  class="[--jx-fill:var(--destructive)] [--jx-fill-ink:var(--destructive-foreground)]"

kbd-law geometry; the hit lane belongs to controls

A Badge is display: its height comes from --jx-line-secondary with inline insets only — it never claims a hit lane, and no paint decision ever changes that. A Chip is a control wearing badge paint: its root carries min-block-size: var(--jx-hit) (control-scale, ~44px at default density), measured on the real box — pseudo-element lane expansion was rejected in design review. Paint and lane are separate axes; the ladder touches only the first.

Press physics never change with paint

One press law for every rung: hover grows the shadow only — the body never moves; active presses +1px into the page while the shadow's offsets counter-shrink, so the shadow paint stays anchored. Variant paint swaps color channels and nothing else. ghost is not an exception — it keeps the box geometry (the width class is load-bearing; a bare transparent color would compute the border away) and opts out of the SHADOW through the theme's own pose tokens (--jx-press-shadow* set to none), while link opts out of the frame entirely.

Valued hooks, not class probes

Each module publishes its rung on a valued data attribute — data-jx-badge, data-jx-chip, data-jx-press-button, data-jx-alert, data-jx-blockquote — so tests and assistive tooling read the variant without reverse-engineering utility soup.

law

Forced colors — explicit degradation

Under forced-colors the color-mix tints do not drop on their own — the r2 review's Chromium probe measured exactly that — so every rung carries its own degradation utilities instead of trusting the engine. A full forced-colors browser probe for all five modules is a pending gate of the variant-grammar contract (see its verification.md honest-gaps list). The system palette gets the message across; the 1px border survives; nothing informational is lost.

SurfaceForced-colors result
fill controlButtonFace bg + ButtonText ink/border
tonal / outlineCanvas (or transparent) bg + CanvasText ink/border; color-mix tints dropped
ghost controltransparent rest; hover ButtonFace / ButtonText
Badge / InlineCodeCanvas + CanvasText; 1px border survives on every rung (fused: the CanvasText frame IS the degrade)
AlertCanvas + CanvasText, 1px border; role and text intact

The focus law rides above every rung: 2px solid Highlight, offset 2 — never removed. The site's ring variable does not survive forced colors, so the degradation pins the system color directly.

migration

Old vocabulary → the grammar

The frozen mapping tables (variant-grammar design.md §5, plus the intent-utility mapping). The one deliberate visual change: the old yellow secondary button softens to tonal + neutral — Owner-reviewed in the browser before release.

PressButton (old variant)New authoring
primaryvariant="fill"
secondaryvariant="tonal" class="jx-hue-neutral"
destructivevariant="fill" class="jx-pair-destructive"
outline / ghost / linkunchanged
copied (union, removed)transient state: variant="tonal" class="jx-hue-success"
Alert (old variant)New authoring
defaultvariant="outline"
primaryvariant="tonal"
destructivevariant="tonal" class="jx-hue-error" — STATUS, not the action hue
Badge (old variant)New authoring
bare / defaultbare Badge — tonal, brand tint (the default)
primarydrop the prop; neutral metadata gets class="jx-hue-neutral"
outlinevariant="outline"
destructivevariant="tonal" class="jx-hue-error"
Arbitrary form (escape hatch, still valid)Intent utility (canonical for the closed set)
[--jx-tonal:var(--primary)]jx-hue-primary
[--jx-tonal:var(--muted-foreground)]jx-hue-neutral
[--jx-tonal:var(--error)]jx-hue-error
[--jx-tonal:var(--success)]jx-hue-success
[--jx-tonal:var(--warning)]jx-hue-warning
[--jx-tonal:var(--info)]jx-hue-info
[--jx-fill:var(--destructive)] [--jx-fill-ink:var(--destructive-foreground)]jx-pair-destructive — one class, both slots

SystemDialogAction migrated INTO the grammar: its tone: destructive | primary prop is replaced by the same variant + injection interface. IconButton forwards the new union rather than owning a separate color decision. The tone-law prose on the alert and badge pages was rewritten to the grammar's terms.

verification

Guards & a11y — what the suite locks

A grammar this small only survives if drift fails loudly. Spec files and guards lock the source today; the contrast and forced-colors browser probes are the contract's pending gates.

test/hue-injection.spec.ts — the intent layer

Four locks: the theme sheet carries every @utility of the closed set VERBATIM (the regexes match the exact emission, not a fuzzy contains); every injected var() target exists as a real token in the light or dark block; no jx-hue-destructive may ever appear — the action/status split holds by construction, not convention; and cn() dedupes the closed set last-wins, matching the arbitrary form's semantics.

test/variant-grammar.spec.ts — the seam

No glued arbitrary-property utilities anywhere under src (the one-dead-token trap from the laws section), and every docs source drawer names REAL registry paths — the same-source law, mechanically enforced.

The browser probes

The contract's PLAN (pending gates, honestly listed in the variant-grammar change's verification.md): a contrast probe walking every shipped semantic injection's used fg/bg pairs, light and dark, representative hues — REPORTING failures, never recomputing --primary; a forced-colors probe covering all five modules against the degradation table. SHIPPED today: the physical hit probe asserts the Chip root's rectangle clears var(--jx-hit), verify-press drives the five-rung × four-effect paint matrix in real Chromium, the hook-law checks pin the valued data-jx-* attributes to the variant unions, and the registry mirror regenerates against byte identity.

ARIA

AttributeValueDescription
color + textnever color aloneA status is visible text first — the hue is emphasis. error/success/warning tints never carry the only signal.
forced colorsCanvas / CanvasTextEvery rung degrades explicitly; the 1px border survives on tonal/outline surfaces so shape information is kept.
focus2px Highlight, offset 2Pinned per rung under forced-colors; never removed, never restyled by paint.
data-jx-*valued variant hooksdata-jx-badge / -chip / -press-button / -alert expose the rung for tests and AT tooling without class probing.
role axisuntouched by paintAlert keeps its role=alert/status (assertive axis) independent of variant — the grammar never moves semantics.

Deferred on record: style-attribute injection waits for rest-attribute forwarding on PressButton/Alert; promoting this grammar to a standalone design-tokens spec section is a candidate follow-up change.

grammar

Elevation — the shadow physics

Paint says what a surface is; elevation says where it sits. Shadow offset ∝ the distance between an element's plane and the surface it casts upon (Owner ruling, 2026-09-01): the website-scaffold's top layer floats above the shell body, so what crosses that gap projects large, what stays in the layer lifts little, and what is cut into the plane casts nothing outward at all. Ink inverts per theme; geometry never drifts between themes.

elevation — the five expressions, live

raise (press law), engrave (kbd's incised inset), the inset rule consumed as structure (blockquote's shadow rule channel), the raise twin on badge scale, and the flush display glyph — one shadow physics per tier, ink inverted per theme, geometry never drifting.

press law · xs → sm on hover
engrave --shadow-engrave · incised inset
inset rule — blockquote's rule channel
shadow-4 · the inset standard consumed as structure
badge scale, press physics
flush display glyph · no elevation
variant-grammar-elevation-usage.svelte svelte
<script lang="ts">
import Badge from '@ui/badge';
import Blockquote from '@ui/blockquote';
import Chip from '@ui/chip';
import Kbd from '@ui/kbd';
import PressButton from '@ui/press-button';
</script>

<div class={cx(rt.flex, rt.wrap, rt.itemsEnd, rt.gapX10, rt.gapY20, rt.text125)}>
  <div class={cx(rt.col8, rt.inkMuted)}>
    <PressButton variant="fill">raise</PressButton>
    <span>press law · xs → sm on hover</span>
  </div>
  <div class={cx(rt.col8, rt.inkMuted)}>
    <Kbd>engrave</Kbd>
    <span>--shadow-engrave · incised inset</span>
  </div>
  <div class={cx(rt.col8, rt.inkMuted, rt.maxW16)}>
    <Blockquote rule="shadow" ruleSize={4}>inset rule — blockquote's rule channel</Blockquote>
    <span>shadow-4 · the inset standard consumed as structure</span>
  </div>
  <div class={cx(rt.col8, rt.inkMuted)}>
    <Chip variant="outline">raise twin</Chip>
    <span>badge scale, press physics</span>
  </div>
  <div class={cx(rt.col8, rt.inkMuted)}>
    <Badge>flush</Badge>
    <span>display glyph · no elevation</span>
  </div>
</div>
TokenDefaultSource
float--shadow · --shadow-md
raise.jx-press: xs → sm
lift--shadow-2xs
engrave--shadow-engrave (inset)
well--shadow-well (+ -hover)
  • interaction NEVER changes an element's tier: a well's hover deepens the inset's intensity only (--shadow-well-hover), and its focus tints border + caret color — the shadow's hierarchy never moves
  • these are the DEFAULT expressions: a deliberate off-default modulation is a prop or a --jx-press-shadow* custom (e.g. code-card's quiet copy button), never a per-component invention

law

The entity law — border is objecthood

Elevation says where a surface sits; entity says whether it should BE a surface at all. Border is objecthood: a bordered box asserts itself as a solid object, and inside an already-solid entity (a dialog panel) every extra FILL is another object competing with its container. The law is mathematical, not per-component taste (Owner rulings 2026-09-01 + r14-12): the entity context ACCUMULATES depth — at depth ≥1 a form control is an engraved WELL of the entity’s surface: the GROUND dissolves (the entity’s surface is the basin’s interior — never a second opaque fill, backdrop-filter as the tint) while the well’s hairline EDGE STAYS (a boundary must never ride on the shadow alone; the edge is structure, not objecthood); data-assert-border force-spends the ground back early, and data-dissolve-border opts a flush edge-to-edge field out (the palette’s head — the edge there would double the panel’s border).

TokenDefaultSource
depth 0the page floor
depth ≥1the engraved well
data-assert-borderthe force-spend
data-dissolve-borderthe opt-out
  • the channel is the Context technique: provideEntity() (dialog panels today, the popover family next) reads the ambient depth and accumulates +1 — nesting is automatic, no per-component accounting
  • a hairline is NOT an entity border: the dialog head/foot rules organize their panel; they never objectify. The law governs object edges only
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h