Changelog
What's changed
Every user-facing change to the engine, the component contract, and this site — newest first. Internal refactors and housekeeping stay out of it.
Changelog
v0.1.0: Genesis
The spine chapter. The architecture settled and got written down, then went from spec to a working system: an OKLCH derivation engine mapping a small set of anchors and knobs into a full design-token register, a coverage contract as the one hard rule between what components consume and what an algorithm produces, the five blessed algorithms lifted into real xript mods that run in a zero-authority sandbox, a single-source custom-element component library with thin Svelte and Astro bindings, and a reference site that derives its own theme live. Everything below is the work that took each of those to green.
Architecture
- settled the spine: a named, swappable algorithm (a literal xript plugin) maps
anchors + knobs + overridesinto a full token register, and a theme is a materialized invocation of one; the algorithm is the durable, reusable engine and a theme the result, a split about reuse rather than worth - defined the open register (authors declare new tokens and rewire any derivation) with a coverage check as the only hard contract, between what components consume and what a module produces
- ruled out an engine-level “can’t look bad” gospel: invariants are per-algorithm policy, proven by a gauntlet parameterized by algorithm
- laid out the three input tiers (algorithm internals / knobs / token overrides) and a discovery model that is an index over npm, not a hosted registry
Engine (packages/xoji)
- built the derivation engine to green:
derive(algorithm, { anchors, knobs, constraints })over an open token graph, OKLCH color math viaculori, an open emitter set (css/json),coverage(), and a per-algorithmgauntlet() - shipped
xoji-default, the neutral built-in algorithm: surfaces / content / accents / status / state overlays / links derived frombg + fg + accent, with a WCAG-AA contrast floor it enforces and proves across 100+ random and extreme anchor sets - made constraints honored, not patched: pinning an input (
--accent,--bg-0,--fg-0) re-enters derivation so dependents re-solve around it (pin--bg-0to white over a near-black anchor and--fg-0re-derives to clear AA); the gauntlet asserts every invariant still holds under a pin - kept the dual-entry discipline physical: the neutral
indexand its imports carry nonode:*/DOM; Node-only code lives incli.ts, browser-only indom.ts - gamut-mapped accent rotation (constant lightness/hue, chroma reduced to the sRGB boundary) with a rotation-fidelity invariant, after an adversarial review caught hex-clamp hue/chroma distortion
- extended the type scale with
4xland5xldisplay steps: the modular ratio now climbs two stops past3xl(≈2.49rem / ≈2.99rem at the default scale), so a hero title is an algorithm-derived token rather than a hand-tuned clamp; all five blessed algorithms produce them and still derive byte-identical to baked - made a derived theme carry its own
color-scheme: bothemitCssand theapplyDOM helper now stampcolor-scheme: darkorlight(read from--bg-0’s lightness viaschemeOf), so native form controls (select dropdowns, scrollbars, date pickers) render in the theme’s mode instead of the OS preference; a light xoji theme no longer gets a dark dropdown on a dark-mode machine, found dogfooding the Select page - let a flavor preset bake its own accent;
defaultAnchors.accentis now optional (a new exportedPresetAnchorstype) and honored at derive exactly like a call-site anchor: declare it for a brand color, omit it for the bg-derived default- exported
DEFAULT_ANCHORSandSHARED_KNOBSalongsidemakeXojiAlgorithm, so a flavor author composes from the same primitives the blessed five use instead of restating them
- exported
- made status
*-textcarry the algorithm and stay visible on every surface:- the readable-ink sweep now seeds its chroma from the vibrancy knob, so a loud theme’s status text is vivid and a quiet theme’s stays calm, all of it still clearing the contrast floor; it used to ignore vibrancy and ship the same muted ink whatever the algorithm
- high-contrast mode stopped tuning
*-textto pure black/white for a saturated*-bg; that pairing had left status text ~1:1 against neutral panels, so Stat trends went invisible and half the Breadcrumb tones vanished;*-bgnow stays a tint and*-textroutes through the same panel-readable sweep as every algorithm, and the panel-contrast invariant guards extreme mode too
- gave every tone the same four-token family: each of the 21 tones (the six semantic roles,
accent-2/-3/-4, and the twelve named hues) now derives the uniform--{tone}/--{tone}-bg/--{tone}-fg/--{tone}-textset, so any component renders any tone through one code path instead of only badges reaching the named colors- a gauntlet invariant proves each tone’s solid pairing (
-fgon--{tone}) and soft pairing (-texton--{tone}-bg) clears AA across every algorithm, and it surfaced a latent gap (accent/neutral soft tints weren’t contrast-guaranteed), now fixed by deriving those tints to clear AA against their own ink - the achromatic poles (
white/black) compute a soft ink toward the opposite end, since theirsubtle/strongstops share a lightness and can’t pair against each other - gave the four status roles their own mutual-distinguishability invariant: the same perceptual OKLab-distance guard the code scopes already carry, now on
--success/--warn/--danger/--info, so two status fills can never derive close enough to read as one (mistaking success for danger is a usability failure WCAG contrast is blind to); floored well under the muted reality (xoji-quiet’s nearest pair sits ≈0.044), it guards a real collapse without policing taste; surfaced dogfooding seven brand palettes where the colors held but nothing enforced it, and the shared OKLab-distance helper now backs both the status and code-scope guards - added a fifth family member,
--{tone}-vivid, for vivid tone-colored text on a neutral surface: the ink at the tone’s hue that clears AA on the page while carrying the tone’s own theme-scaled chroma, so it stays perceptually even across hues and keeps its punch in light mode instead of blowing out to gamut-max neon; distinct from-text, which is muted because it must also read on the soft tint and so overshoots panel contrast;Stat’s trend deltas (up, down, flat) now use it, reading as a confident green and red rather than washed out; it derives for the whole tone vocabulary now: the base accent and its ramp (accent-2/-3/-4), the four status roles,--neutral, and the twelve named hues (--green-vivid,--pink-vivid, and the rest), so any tone in the register can be spoken vividly, with the achromatic named hues (white/black/gray) resolving to a readable ink on the panels rather than a color
- a gauntlet invariant proves each tone’s solid pairing (
- opened the register to non-color intent tokens, the first being
--selection-cue(tint | marker); a new cross-cuttingcuesknob (color | redundant) drives it and high-contrast emitsmarkerby default, so accessibility intent the algorithm can’t say in a color, a redundant non-color cue, now rides the same register and coverage contract as every other token- the coverage lint learned the
@container style(--token: …)consumption shape, so a token a component branches on (rather than reads throughvar()) still counts as consumed
- the coverage lint learned the
- gave intent tokens typed value sets: a
KEYWORD_DOMAINSregistry declares each keyword token’s legal vocabulary (--selection-cue→tint | marker), and the format invariant rejects any algorithm that emits outside it; the gauntlet now catches a stray--selection-cue: sparklebefore it ships, the safety rail the intent-token layer needed before its vocabulary grows- closed the loop with the consume side of that contract:
lintStyleQueryDomainsreads every@container style(--token: value)branch out of a component’s CSS and rejects a value outside the token’s declared domain, so a misspelled cue keyword (amarekrthat would silently never match at runtime) fails the build the same way an out-of-domain emit does; wired into the registry token-contract test, it now guards every component alongside the existingconsumedTokenslint
- closed the loop with the consume side of that contract:
- derived a syntax-highlighting token family (
--code-*) so an editor themes from the same anchors and algorithm as the chrome; change the accent and the highlighting re-themes with it, no app-vs-editor clash:- a canonical scope set that adapters fold the long TextMate tail onto:
--code-keyword / -string / -number / -function / -type / -variable / -comment / -operator / -punctuation / -tag / -attr / -regexp, plus the surface tokens--code-bg / -fg / -line-highlight / -selection - the eight colored scopes fan across the hue wheel anchored on the accent and sit inside the lightness band where
--code-bgalready clears AA, with hue decorrelated from lightness so a tight editor bg can’t crowd two scopes into one ink; comments recede at a relaxed floor, and a near-gray accent falls back to a stable base hue instead of collapsing the wheel - a mutual-distinguishability invariant the gauntlet enforces beside the per-token AA floors, by perceptual OKLab distance rather than luminance-only contrast (which is blind to two equally-light hues), so no two colored scopes can derive to the same color
- two adapter emitters alongside
css/json(xoji derive --format prism|monaco): theprismemitter maps Prism’s token classes onto the family through the custom properties, so a themed page re-colors its code blocks live as the theme switches; themonacoemitter builds a MonacoIStandaloneThemeData(rule foregrounds + workbench colors, scopes folded onto Monaco’s token names), dogfooded in a live editor across the blessed themes
- a canonical scope set that adapters fold the long TextMate tail onto:
- deepened tone pinning to the depth the accent ramp already had: pinning a tone solid (
--danger,--neutral, any of the twelve named hues) now re-hues its whole derived family, so a pinned--dangercarries its hue through--danger-bgand the on-tint--danger-textinstead of leaving them on the catalog red; the--color-*swatch ladder keeps its own monotonic lightness, and an achromatic pin is gated out so a gray can’t re-hue a family around a meaningless angle- the depth used to be the accent ramp’s alone: pin
--accent-2and-3/-4re-thread, but pin--greenand only the solid moved while its tint and ink stayed catalog-green; palette and semantic tones now reach the same depth, dogfooded by pinning--dangerviolet and watching all three Badge fills (solid / soft / outline) re-hue and stay readable
- the depth used to be the accent ramp’s alone: pin
- set the default accent ramp to a split-complement fan:
accent-2/accent-3flank the accent at ∓half the shift step andaccent-4is its 180° complement, so one anchor fans into a harmonious multi-accent palette (a red accent → magenta and amber flanks with a teal complement) rather than an even hue sweep; it is gated behind a single constant so the prior even-wheel fan is a one-line revert, and a pin on any rung still holds while the rest keep their fan - made the twelve named hues track the accent instead of a fixed catalog that ignored the theme: each named color (
--red,--green,--pink, …) keeps its canonical hue but derives its chroma from the accent’s own chroma about a neutral reference, so a vivid accent fans out vivid named colors, a muted accent mutes them, and a floor keeps a near-gray accent’s colors recognizable as themselves; the lightness ladder biases gently toward the accent’s lightness, the canonical hues stay put, and the swatch ramp keeps its monotonic ladder with the per-hue base-hue invariant still holding - kept solid controls from vanishing into the page: every solid fill that paints directly on
--bg-0(--accent,--neutral, and the four status fills) now clears a minimum1.5:1separation from it, pushed along lightness away from the surface with its hue and chroma intact; found dogfooding hostile anchors: an achromatic-dark accent collapsed--accentonto--bg-0(both#141414, a primary button you couldn’t see), and a mid-gray page sank--neutraland--dangerwith it; the per-token AA gauntlet never caught either, because each fill’s own text still read; it was the fill against the page that disappeared; a healthy chromatic fill already clears the floor and is untouched, a pinned fill is honored verbatim, and a newsolid fills separate from --bg-0invariant guards the line for every algorithm - floored the divider tokens so a border is always a border:
--line,--line-2, and--field-bordereach clear a minimum contrast against the surface they delineate (1.5:1for the hairlines,1.8:1for the stronger divider), pushed toward the readable pole when a fixed lightness step would collapse; found dogfooding a pure-black-pinned--bg-0: a card’s border derived to#0e0e0eon a#000000page (1.09:1, invisible), and with the elevation shadow black-on-black too, a bordered card lost every separation channel at once and dissolved into the page; mid-range themes are untouched (a hairline already clears the floor); a newborders separate from their surfaceinvariant guards the line for every algorithm - fixed the achromatic named hues reading blue:
--gray,--white, and--blackderived their on-color inks (-fg,-text, and the swatch-contraststop) at a hardcoded hue, so agraychip’s text came out a slate blue (#abc0d7) and ablackbadge’s a navy; identical across every theme, accent-blind; the achromatic ramp now derives its readable ink at zero chroma, so the text lands on the gray axis: neutral ink on a neutral tone; chromatic hues are untouched; found reading the derived register hex for a violet brand theme, a contrast-safe wrong the per-token gauntlet never caught - softened every tone’s
-bginto a true wash so the soft-tint contract holds the same shape across the whole roster: a named hue’s-bgused to reuse its swatch ramp’ssubtlechip, a near-full-strength color that read garish as a full background, and now derives a pale wash sitting just off--bg-0at a fraction of the tone’s chroma, like--accent-bgalready did, with-textre-derived to clear AA on it; the--color-*swatch ramp keeps its own chip, and the achromaticwhite/black/graykeep their lightness identity since a uniform wash would collapse them into one gray- the same pass fixed the four status
-bgcollapsing to pure white on light themes; the tint lightness was reaching above the page and clamping out, so a softdanger/success/warn/infosurface showed no tint at all; it now sits just under the surface like the accent tint, restoring the soft-alert and awareness-band case that had silently gone invisible
- the same pass fixed the four status
- defined the canonical theme file: a self-describing, re-derivable artifact carrying
meta(provenance),recipe(the algorithm plus theanchors/knobs/overridesthat print it, the source of truth), andtokens(the materialized register, a cache so a consumer applies the theme without ever running the engine);buildThemeFile/serializeThemeFile/parseThemeFileship from@xoji/core, the JSON Schema is published atxoji.dev/schema/theme.v1.json, andxoji derive --format themeemits one straight from the CLI - made bare
xojiprint the usage banner instead of silently dumping a default theme’s CSS: a no-argument invocation now routes to help like every other CLI, so the first thing a curiousxojishows is what it can do, not a wall of custom properties (found dogfooding the CLI) - gave the
gauntletCLI a fast spot-check path:--mode baked(the default) derives natively instead of crossing the sandbox, so proving an algorithm’s invariants across every algorithm (-a all --depth quick) takes seconds where the hosted path took minutes;--mode hostedstill runs the shipped sandboxed mod for the production battery,--depth quick|standard|fulldials the run count, and the report names the mode and depth it ran so coverage is never silently narrowed
Algorithms as xript mods
- lifted the five algorithms out of baked TypeScript into real xript mods, each a
mod-manifest.jsonplus an esbuild-bundled, self-containedmod.js; run through@xriptjs/runtimein a zero-authority sandbox and deriving byte-identical to the baked output across the whole matrix - shipped
nxi-niteas the fifth blessed algorithm and the worked example ofpasses: a Day/Night taste that adds anhourknob and layers a time-of-day pass over the base derivation, shifting the whole palette as the hour moves; it derives byte-identical and clears its gauntlet like the other four - gave authors an ergonomic surface at
@xoji/core/authoring:defineXojiAlgorithm({ … })for a taste-vector preset (xoji-defaultcollapses to a single line) anddefineAlgorithm({ derive })for a from-scratch algorithm, both importing core by name like any third party would - exposed color primitives to mods as the
cutihost binding, gated by thecolor-mathcapability; one implementation shared by the engine and every mod, so a token a mod derives can’t drift from one the engine derives - made the sandboxed mod the canonical resolution path (
resolveAlgorithm) for the CLI and the site, with an in-process derive cache so repeat derivations skip the sandbox; the site’s ~37 identical derives collapse from ~5.8s to ~0.16s- the whole CLI now resolves through it:
derive,coverage, andgauntlet, so the invariant proof runs against the sandboxed mod that ships rather than a baked copy of it - baked
getAlgorithmstays as the byte-identical test oracle and as the browser’s synchronous first-paint fallback (before the async mod load lands); a newsnapshotAlgorithm(id)exposes an already-resolved mod synchronously, the seam a sync caller will use to warm the hosted cache and then drop the baked fallback
- the whole CLI now resolves through it:
- added an in-browser authoring loader;
loadAuthoredAlgorithm(source)runs author-writtendefineAlgorithm/defineXojiAlgorithmsource through the hosted sandbox with no bundler, prepending a pre-built authoring prelude (the whole@xoji/core/authoringsurface, bundled once) to the import-free source and supplying it on the sandbox scope, so a self-authoredderivebody runs with the samecolor-math-gated isolation a third-party pack gets, never the baked path- the prelude exposes the full surface, not just the two
definehelpers; a from-scratch author reaches the engine’s color math (oklch/formatCss/contrast/ …) and the pass helpers (settlePass/runPipeline/ …) a custom pipeline needs, every one a pure function that adds no authority to the zero-authority sandbox
- the prelude exposes the full surface, not just the two
Components (@xoji/core · @xoji/svelte · @xoji/astro)
- shipped a single-layer custom-element library with thin Svelte and Astro wrappers over the same elements; the element is the component, styled only against the tokens it declares it consumes
- added Splitter, a resizable-pane divider, and made
AppShell’s rails resizable with it:- a
role="separator"control that resizes an adjacent pane by drag or keyboard, clamping a px size tomin/max, snapping to an integralstep, and writing the result into a CSS custom property a grid or flex track reads, so the layout resizes declaratively - it fires
resizelive through the drag andresize-endon release (and on each keyboard commit), so a consumer can preview or persist;orientationpicks the axis,reversedflips direction for a trailing-edge rail, double-click resets to adefault AppShell’s body grid is now var-driven (backward-compatible), so a splitter can size a rail; the site’s navigation dock is now draggable, clamped, and snappable, the first dogfood of the new control
- a
- made every binding render from one source, ending a duplication that had each Astro component reimplementing its markup in light DOM:
- a component’s markup now lives once in
@xoji/core/markup(pure, DOM-free), consumed by both the custom element’stemplate()and the Astro binding - the Astro binding emits a declarative shadow root from that source, so a component renders fully styled with zero JavaScript (component CSS inlined for the no-JS first paint), then hydrates in place onto the one shared constructable stylesheet; the result is the encapsulated widget set an app wants, with an SSR-fast, SEO-friendly static render when the runtime never loads
- a
staticprop on each Astro component renders it zero-JS on purpose, never loading the runtime to hydrate it - host attributes carry every render-affecting prop (and array data as the JSON the element parses), so hydration reproduces the server markup byte-for-byte instead of reverting to defaults; the always-render-on-connect base wires each element’s events on hydration, so a control hydrated from a declarative shadow is live, not just styled
- 46 components moved to the single source;
Table(a light-DOM decorator over a real<table>, no shadow to share) andAppShell(whose responsive grid is governed by global CSS spanning the shell and its slotted rails, which a shadow boundary would sever) stay light-DOM by design
- a component’s markup now lives once in
- enforced the coverage contract per component: a manifest declares
consumedTokens, a lint checks them against the CSS (component-internal props don’t masquerade as theme tokens), and the reference site shows live coverage against a derived register - added app-grade form controls: Slider (range, full keyboard, pointer-capture drag), Color Picker (an HSV field with hue and alpha tracks over a checkerboard,
#rrggbb/#rrggbbaaI/O), Number Input (a spinbutton with steppers, bounds, and decimals), and Segmented Control (a radiogroup toggle bar); each form-associated and driven live in a real browser- gave Slider a built-in
show-valuereadout with aformathook (a(value) => stringproperty, so0.8reads80%) and ahide-labeloption that keeps the accessible name while dropping the visible one; both retire the wrapper a consumer had to write around it
- gave Slider a built-in
- gave Button a controlled toggle state; a
pressedattribute reflects toaria-pressed(set it for on,pressed="false"for off, omit for a plain button) and paints the press overlay so an engaged toggle reads as sunken across every variant; it reflects state rather than self-toggling, so an active tool or mode flag stops having to masquerade as avariant- added a
selectedsibling (reflected toaria-selected) for selected-in-a-set semantics, and a densexssize belowsmfor compact toolbar pills; both consume only already-produced tokens (--state-selected,--text-xs,--space-0)
- added a
- gave the Code block a copy-to-clipboard button: it sits top-right, fades in on hover or focus, copies the raw source rather than the highlighted markup, and flashes a vivid
Copiedbefore reverting- on by default; pass
copy="false"to drop it - the fragment ships the button
hiddenand the custom element un-hides it only where the Clipboard API is present, so the zero-JS Astro path and insecure contexts never paint a dead control - the click is element-owned, so the zero-authority fill sandbox can’t reach the clipboard; the button markup still lives in the fill, so a skin can re-style or drop it
- gave it a
wrapoption for long lines: soft-wrap instead of a horizontal scrollbar, driven purely by a host attribute so it needs no JavaScript and works on the zero-JS Astro path - gave it
line-numbers, a counter gutter that sticks to the left edge as the code scrolls sideways and reads dimmed like a comment; it’s tag-aware, so a token spanning lines (a block comment, a multi-line string) still numbers cleanly, and it co-operates withwrapso a wrapped line keeps a single number anchored at its top- pure derived chrome borrowing
--code-commentand--field-border, so it adds no tokens and renders on the zero-JS Astro path too
- pure derived chrome borrowing
- gave it
highlight, a 1-based line spec (2,2,4,4-6) that tints the lines that matter with--code-line-highlight, a token the algorithm already derived but nothing in the component had ever painted; it pairs withline-numbers, holds the tint full-width even as a line scrolls or soft-wraps, and renders on the zero-JS Astro path
- on by default; pass
- taught Tree to honor
--selection-cue: when a theme sets it tomarker(high-contrast, or any algorithm withcues: redundant), the selected row gains a non-color check glyph beside the accent tint via a CSS@container style()query, so selection clears WCAG 1.4.1 instead of resting on color alone; the first component to consume an intent token - grew that intent-token honoring to the other selected-in-a-set controls: Tabs and Segmented now gain the same non-color check glyph on the selected tab or segment when
--selection-cueresolves tomarker, so a control distinguished only by an accent fill stops resting on color alone; both consume the token the register already produces (no coverage change), with the glyph on::beforesince each already owns its::afterstate overlay - opened every general-purpose tone-driven component to the full vocabulary:
Button,Badge,Avatar,Progress,Radio,Spinner, and thenSwitch,Slider,Checkbox, andSegmentedtake any of the 21 tones through one sharedFULL_TONESlist, so a pink button, a teal switch, anaccent-3slider, or a purple checkbox renders its own AA-cleared color instead of needing app-author CSS- each component’s checked/selected/fill surface routes through a per-tone rule, several through a
--{component}-fill/-inkvariable so the existing disabled and state rules keep winning without a specificity fight; every manifest’sconsumedTokensregenerates fromFULL_TONESso the coverage lint stays exact, and the reference demos grew an “every tone” showcase rendered straight from the list - then split
AlertandToastonto two independent axes instead of locking them to status:severity(success/warn/danger/info) carries the meaning, the status glyph and the live-region politeness, sodanger/warnannounce assertively, whiletonecarries the color from the full vocabulary; a severity paints its own--{severity}family by default; atoneoverrides that color (adangerrepainted pink still announces as danger); and a color-onlytonewith no severity shows no glyph and announces politely, the awareness-banner case a status-locked component forbade; a bare status-namedtonestill infers its severity, so existing markup keeps its glyph and announce level (an earlier pass had narrowed both back to the four roles to dodge a wrong-glyph wart; the wart was a conflation of color and meaning, not a reason to forbid color) - gave both an
iconslot that overrides the built-in severity glyph across all three bindings; the auto-glyph stays as the slot’s fallback, and a custom icon shows even on a severity-less banner
- each component’s checked/selected/fill surface routes through a per-tone rule, several through a
- gave the neutral container components an opt-in accent edge:
Cardtakes atonethat paints a leading accent bar andDocka toned rail edge, both off by default so an untoned card or rail keeps its plain surface; the tone marks it without recoloring the whole container - aligned the bindings on
FullToneend to end: the custom-elementtonesetters onButton,Spinner,Avatar,Progress,Badge, andRadiohad lagged at the six-roleTone(or the 18-toneBadgeTone) while their css, manifests, and wrappers already spoke the full vocabulary, so a TS consumer assigningtone="purple"hit a phantom type error; the elements now type the full set, while a presence dot’sstatusstays the six semantic roles where a free color would be wrong - gave Statusbar an
overflowprop for when content outruns the strip:clip(default) hides the spill,wrapflows items onto another line,scrolladds a horizontal track; it was a plain flex row that just overflowed before- landed the flagship
collapsemode: aResizeObserverranks cells by a per-celldata-priority(adata-requiredcell never drops), folds the lowest-priority ones first when the row can’t fit, and tucks them into a+Noverflow popover built on the native Popover API so it escapes the bar’s own clip; the responsive statusbar a consumer was blocked on - let the consumer own the overflow contents for rich cells:
collapseclones dropped cells into a shadow popover by default (right for plain text, but a clone can’t carry light-DOM styles or handlers), so a newmanual-overflowsuppresses the built-in popover and the element instead fires anoverflow-changeevent carrying the actual dropped cell elements; the consumer renders its own popover with styles and click handlers intact, while the element keeps owning the ranking - gave it a
separatedmode so the bar draws the divider between cells itself, at its own spacing, and each item stays a single cell thecollapseranking counts independently, instead of every item carrying its ownSeparator, which were extra cells that muddied the drop counting and pulled their spacing from whatever wrapper they sat in; the leading rule is suppressed at the start of each run (the first item and any item after aspacer) and stays right as items collapse, and it closed a latentcollapsebug found dogfooding the footer: cells defaulted toflex-shrink: 1, so they shrank instead of overflowing and the detector never tripped, clipping the right edge instead of folding into+N
- landed the flagship
- built the reference site on Astro: per-component pages with live demos, anatomy, props, accessibility, and coverage, all rendered under a live-derived theme
- hardened the Dialog contract: a closed
<dialog>stays hidden (author-origin styling no longer overrides the platform’s hide-when-closed rule), and a dialog that brings its ownheaderslot names itself with a newlabelprop, sincearia-labelledbycannot reach a slotted title across the shadow boundary - regrouped the reference catalog by what a component is (Controls for the things you click, toggle, and drag, Form for the fields you fill in), so the index reads true instead of piling every input under one heading
- added Accordion to the controls: a stack of collapsible sections paired from
[slot="header"]/[slot="panel"], single-open by default ormultiple, with a settableheadingLevel, arrow/Home/End keyboard, and the WAI-ARIA heading-button-regionwiring; the first of the two controls that turn the docs’ own side nav into dogfooded components - added Tree to navigation: a data-driven hierarchy (an
itemsarray oflabel/href/childrenwithexpanded/selected/disabledflags) rendering the WAI-ARIAtree/treeitem/grouppattern with one roving tab stop, the full Up/Down/Left/Right/Home/End keyboard, single selection, and link leaves; the pair’s second control and the natural shape for a docs or file sidebar - added Kbd to data display: a semantic
<kbd>keycap derived entirely from existing chrome tokens, a raised--bg-2face with a weighted--border-thickbottom edge for depth instead of a drop shadow, set in--font-mono, so it matches the themed UI it documents and adds nothing to the algorithm;sizestepssm/md/lg, and a row of keys in aClusterspells a chord; the contract proven from the other side, a new component covered entirely by the produced set - added Swatch to data display: a color chip pairing a colored dot with an optional name and a mono value, the shape every
label + value + dotrow and palette rail is built from; the color it shows is data (an inlinecolor, not a token), while its own chrome (the dot’s border, the label, the value) is derived theme, so the chip themes with the UI while displaying anything;sizestepssm/md/lg- gave it three opt-in interactions:
interactiverenders the chip as a<button>that emits aselectevent for a pickable palette,selectedrings the dot in the accent and reflectsaria-pressed(controlled, so the consumer owns single-vs-multi select), anddetailsreveals a hover/focus popover reading the color acrosshex/rgb/hsl/oklchthrough the engine’sparseColor/formatColor; the display chip stays the zero-JS default, and adetailschip is keyboard-reachable viatabindexandaria-describedby, not hover alone
- gave it three opt-in interactions:
- added Menu to the overlay set, a menu-button: a trigger that opens a data-driven (
items) popup of actions, separators, and disabled entries, built on the native Popover API so it escapes any ancestor’s clip; it follows the WAI-ARIA menu-button pattern (aria-haspopup/aria-expandedon the trigger,role="menu"/role="menuitem"in the popup) with one roving focus and the full keyboard: Enter/Space/Down open to the first item, Up to the last, arrows skip disabled and wrap, Home/End jump, Escape closes and returns focus to the trigger, and activating fires aselectevent with the chosen item; the last of the consumer’s missing primitives - added Pagination to the navigation set, a
<nav>walking a paged collection: previous/next arrows around a windowed page list with ellipses, the visible range computed frompage+totaland tuned bysiblings(links each side of current) andboundaries(links pinned at each end); give it anhreftemplate with{page}and every page is a real link, zero-JS navigation that works on the static Astro path, while omitting it renders pages as buttons that fire apage-changeevent with the chosen number; the current page is a tone-colored pill carryingaria-current="page", prev/next goaria-disabled(not removed) at the ends, every page and arrow has an explicit accessible name, and the ellipsis is decorative andaria-hidden; consumes only already-derived chrome and tone tokens, so it adds none - opened Breadcrumb to the full tone roster, the last component still capped at the six semantic roles; its ancestor links now take any of the 21 tones, drawn at the panel-readable
--{tone}-vividink rather than--{tone}-text:-textis only contrast-guaranteed against its own soft tint, so it was never a safe link color on the page once the named hues arrived, while-vividclears the floor against every surface by construction; the switch also closed a latent gap where the status links (danger/success/warn/info) had quietly ridden the not-page-guaranteed-text - blessed subclassing as a first-class extension path:
XojiElement’s protected surface (root/template()/styles()/render()) is now documented as a stable contract, so a consumer can extend a shipped element to add a behavior it leaves out (or extendXojiElementdirectly for a fully custom element) and still ride the shared token sheet and coverage contract; a new “Extending components” docs section shows it with a livexoji-toggle-buttonthat subclasses the controlled Button to flip its ownpressedon click; with thepressed/selectedstate seam already landed, this closes the composability gap a consumer raised: the out-of-the-box base is now extensible into the richer thing, not a dead end - put the new controls to work: the docs sidebar’s component listing (once bespoke
<details>markup) is now a live Tree, categories to pages, with the open page selected and its group expanded; the navigation that catalogs every component is now itself one of them - exposed a color-conversion surface from
@xoji/core:parseColor(any CSS Color 4 string → an sRGB pivot),formatColor(clean, rounded output inhex/rgb/hsl/oklch), and HSV ↔ sRGB, all culori-backed, so a component shares the engine’s color math instead of hand-rolling its own - overhauled Color Picker I/O on that surface: the value field reads out in a switchable
format(hex/rgb/hsl/oklch, cycled by a button) and accepts any CSS Color 4 string, reformatting on commit (OKLCH entry is live), and the wrapper now ships across all three bindings; the per-model 2D plane and a popover trigger are the remaining surface - gave the picker an eyedropper: where the browser exposes the
EyeDropperAPI (Chromium today) a button samples any pixel on screen back through the core parser, and it’s omitted entirely where the API is absent so it never shows a dead control - added preset swatches to the picker: a
swatcheslist renders clickable color chips below the field, alpha-aware over a checkerboard, each a toggle button that loads its color and reads pressed when it’s the active one - added a WCAG contrast panel to the picker: set
contrastAgainstto a reference color and it shows the live ratio of the current color against it, with AA/AAA grades that go green on pass and red on fail, read from the samecontrastthe engine derives with - made the picker’s opacity opt-in: the alpha track was always on, which baked an assumption most colors don’t need; an
alphaknob (off by default) now gates the track and whether the value carries an alpha channel; without it the picker drops any pasted alpha and reads out clean opaque values across every format - widened the picker’s color models from four to seven, adding
lab,lch, andoklabto@xoji/core’s conversion API (culori-backed, full round-trip in and out), and gave the picker amodesknob: a comma-separated, ordered subset of the formats theformatbutton cycles, so an author offers exactly the color spaces they want (perceptual-only, say) instead of the whole list - added CMYK as the eighth model: the one space CSS can’t parse, so
@xoji/corecarries its own naive, profile-freecmyk()formatter and parser;cmyk(C% M% Y% K%)reads out and types back in, with an optional/ alpha, and joins the configurablemodesset (ahex,rgb,cmykpicker covers web-and-print in one) - gave the picker a
harmonymode: acomplementary/triadic/analogous/split-complementary/tetradic/monochromatic/shades/tintsscheme generates a row of related colors from the current one, each chip clickable to adopt it and the whole row recomputing live as the color changes; theharmony()generator lives in@xoji/core(hue rotation and proportional lightness ramps over HSL), so the relationships are the engine’s, not the component’s - gave the picker per-channel sliders: a channel-model API in
@xoji/core(channelsOfreturns a model’s channel defs,colorToChannelsreads a color’s channels in display units,colorFromChannelsrebuilds it, exact round-trips across all eight models) backs achannelsknob that names a model (rgb/hsl/hsv/oklch/lab/lch/oklab/cmyk) to render a stack of native range sliders, one per channel, each labelled with a live numeric readout and editable directly; every edit round-trips through the engine math and re-threads the rest of the picker, so the decomposition is the engine’s, not the component’s - gave the picker palette snapping,
nearestWebSafe(quantize each channel to the 216-color web cube) andnearestNamedColor(the perceptually closest CSS named color, by OKLab distance over the 148-color set) joined@xoji/core, behind asnapknob that adds buttons for either:web-safequantizes, andnamedreads out the nearest color’s name live on the button and adopts it on click; the matching is the engine’s, the buttons just call it - gave the picker an OKLCH perceptual plane,
oklchToDisplayin@xoji/corereturns a coordinate’s clamped sRGB color plus whether it was in gamut, and aplaneknob paints a lightness × chroma<canvas>field at the current hue from it: out-of-gamut colors desaturate toward their own luminance (greyed, not darkened, so the gamut edge doesn’t read as “just dark”) with a contour drawn at the boundary, the chroma axis sizes itself to each hue’s in-gamut reach so the field isn’t mostly dead zone, and a liveL · Creadout names the axes; drag or arrow the handle to set lightness and chroma- fixed the plane drag drifting hue as it crossed the gamut boundary: the hue is now captured once at drag start, so the whole drag stays on one slice instead of compounding the per-clamp shift
- gave the picker a popover
trigger, set it and the picker collapses to a swatch button (with a corner caret and a hover lift, so it reads as openable) that opens the full UI in a nativepopover: top-layer, anchored below the button, with outside-click andEsclight-dismiss and focus return for free, plus a close-on-scroll so the panel never drifts from its anchor - put the picker to work in its own home: the Bench’s three theme anchors (background / foreground / accent) now open the
ColorPickerintriggermode instead of the OS<input type="color">window, so picking the colors that drive the whole derivation happens in the same anchored popover the rest of the studio uses, and the theme studio composes from@xojicomponents down to its most-used control - taught Number Input a second granularity: an
altStep(10×stepout of the box) that amodifier(Shift by default, oralt/ctrl/meta) swaps to on a click or arrow, withPageUp/PageDownalways taking the big jump andaltDefaultflipping which step is primary; the snap grid follows the finer of the two, so a fractional alternate survives - added Stat to data display: a single metric as a tabular
valueunder a small uppercaselabel, with an optionaldeltawhosetrend(up / down / flat) tints it and pairs the tint with a directional arrow, plus acaptionfor context; pure presentation across all three bindings, built to line up digit-for-digit in aGriddashboard strip, and the first of the structural primitives the site refactor surfaced- gave it an
inlinevariant: a horizontal ticker that lays the label, value, and trend delta on one baseline for a status strip or a dense row instead of a dashboard tile; the value is just the slot, so it reads non-numeric text or a link as readily as a figure, and the footer bar’s every cell (algorithm, scheme, the by-the-numbers, license, updated) is now one of these
- gave it an
- added Section to layout, a structural page strip in two shapes: a
band(aplain/quiet/accentsurface with optionalborderedhairlines and apaddingrhythm that eases off under 40rem) and astage(an elevated, accent-tinted frame with an optional cornerlabel, the demo ground the docs sit live examples in)- refit the homepage and the Bench onto it: the hero, the by-the-numbers band, and both stages now compose from
Section, and the bespoke.x-band/.x-stagerules are gone - fixed a drift bug it surfaced: the by-the-numbers band referenced an
x-band--quietclass that was never defined, so it silently rendered as a plain accent band; it now carries a realquiettone - then opened its band
toneto the full 21-tone roster:plain/quietkeep their surface-raise meaning while every semantic role, accent variant, and named hue now tints a band throughvar(--{tone}-bg), so a soft awareness strip or a status band is atonerather than bespoke CSS; the fragment already emitted the per-tone class, so only the css, the type unions, and the manifest options had been capping it
- refit the homepage and the Bench onto it: the hero, the by-the-numbers band, and both stages now compose from
- added Eyebrow to layout: the small uppercase kicker above a heading, accent-toned by default with
muted/subtletones and anormal/widetracking; one element with no layout of its own, so aStackgap does the spacing- refit every site consumer onto it (the homepage hero/numbers/pillars kickers, the docs and Bench intros, and the components index header), then deleted the duplicate
.x-eyebrowand.cx-eyebrowscoped rules
- refit every site consumer onto it (the homepage hero/numbers/pillars kickers, the docs and Bench intros, and the components index header), then deleted the duplicate
- gave Heading and Text an
accenttone: a fourth ink off the ramp that tints to--accent-text, for a highlighted title, a metric figure, or an emphasized word; the homepage’s by-the-numbers figures now usetone="accent"instead of a scoped.numbers__valuecolor rule - gave Heading two display sizes (
4xl,5xl) on the newly extended scale, and put them to work: the homepage hero title and the Bench heading now compose fromHeadingat display size, retiring the bespoke.x-displayclamp (and a dead copy of the class that had been silently doing nothing on the Bench) - added Card Link: a
Cardthat is itself one<a>, so the whole card is the click target (header/body/footer slots, theinteractive/overlay/compactlooks,interactiveon by default, the underline and ink reset, afocus-visiblering on the card); the reference-page prev/next pager now composes from it instead of aLinkwrapping an interactiveCard, dropping the link-reset scoped rules it needed - added Toc, an on-this-page table of contents that scrollspies the section in view: an
itemslist of{ id, label }renders a labellednavof in-page anchors, and anIntersectionObservermarks the active link witharia-currentand the accent rail (links work with no script, the spy only decorates); folds to a chip row on narrow screens, takessticky; the reference pages’ on-this-page rail now composes from it, retiring the bespoke.ref-onthispagestyling and its hand-rolled scrollspy script, and with it, the last of the component gaps the site refactor surfaced - fixed two Tooltip bugs found dogfooding the reference page
- the
@xoji/astrobinding silently dropped thecontentslot: Astro strips the routingslot=off a bare forwarded<slot>, so a rich-content tooltip got no tip and warned to the console; the forward now wraps in a real<span slot="content">, the shape the Svelte binding already used - tooltip text was capped at
--space-8(a 32px spacing token), wrapping every word onto its own line; content now reads atmin(18rem, calc(100vw - var(--space-8)))
- the
- finished the overlay positioner at the Tooltip: a tip near a viewport edge already flipped to the side that fit, but its cross-axis never clamped, so a left- or right-hugging tooltip spilled off-screen and a top-pinned side tip ran off the top edge; the tip now shifts back inside the viewport margin and counter-shifts its arrow the other way so it keeps pointing at the trigger, bounded so the arrow never leaves the content’s own edge, on both axes (horizontal for
top/bottom, vertical forleft/right); the shift rides two component-internal custom properties the element sets from the sharedplaceOverlaygeometry, so with them unset the no-JS/SSR layout stays exactly centered; a puretooltipTetherShifthelper carries the math under unit test, and the Menu and Swatch overlays already clamped through the same positioner; verified in a browser at all four edges: every tip lands inside the viewport with its arrow still on the trigger - grew the Tooltip past a one-line label: it now carries the same vocabulary the rest of the set speaks; a
tone(any of the register’s 21) colors it: a leading edge bar over the neutral overlay by default (the readable choice behind real content) or, withvariant="soft"/"solid", a fully washed or filled surface with a matching arrow, the same soft/solid shape Alert and Section use; amodeofrichopens the tight hint into a roomier, left-aligned panel that wraps multi-line prose and holds structured content (a detail readout, a stat or progress bar, a definition), andsize(sm/md) dials the padding, all four axes orthogonal; the reference page grew a tone row and a rich-content showcase; coverage tracks the new tone family; caught dogfooding it: the edge bar’s width default sat on the content element, where it out-specified the toned root’s value and rendered every edge-mode tip colorless, moved to the root so the tone inherits cleanly - fixed Tree keyboard navigation for nested items: every treeitem carried its own
keydownlistener and the event bubbled up through the ancestor treeitems, so an arrow key on a nested node ran the handler again on its parent and the outermost one jerked focus to the wrong row;onKeydownnow stops propagation, so only the focused node handles its own key - gave Tree a
lockedbranch, a node pinned permanently open: forced expanded, no twisty, and every collapse path suppressed (row click,Enter, and←fall through to navigation instead of toggling), so a section that should always show its children reads as a fixed header rather than a collapsible group; the site nav’sComponentsbranch now uses it to stay a constant index across every page - fixed Table’s sticky header, which didn’t stick; Firefox made it obvious, but it was broken in every browser: the wrapper’s own
overflow-xmade it a nested scroll container the header bound to instead of the page, andborder-collapse: collapseblocks sticky table cells in Firefox outright; the wrapper is now the single scroll container via amaxHeightprop, and the table moved toborder-collapse: separateso the header’s own border travels with it, with theborderedgrid rebuilt to stay single-line through the switch - fixed three AppShell rail bugs found dogfooding the nav: the nav reflowed hard on load (the left rail sized to its content until the splitter’s JS ran;
AppShellnow takesleftSize/rightSizethat seed the rail width server-side), the nav dock didn’t scroll (its:hostcouldn’t shrink as a grid item;min-height: 0), and thelineSplitter left a transparent gutter between panes (it collapses to a hairline now, with a transparent overlay for the drag target, so the panes butt against the divider) - stopped AppShell overflowing the viewport on a narrow screen: the shell’s top grid had no
grid-template-columns, so its single implicit column sized to content and a page wider than the viewport pushed the whole shell off-screen, clipped rather than reflowed; aminmax(0, 1fr)column now constrains it to the viewport so the content reflows, and every consumer gets it, not just the site - fixed Tabs freezing live framework panels: the element snapshotted each light-DOM panel’s
innerHTMLinto its shadow, so a panel built from real components (effects, handlers, nested state) rendered as dead static HTML and the originals sat unslotted at zero size; light-DOM panels now project through a per-index<slot>so each one stays mounted and reactive, found building a studio whose tabs host live panels (the staticitems/SSR path that bakes panel HTML is unchanged) - gave Tabs a
stickyoption that pins the tablist while the active panel scrolls beneath it; off by default so inline tabs scroll away with their content, opt-in for app surfaces where the tabs should stay in reach; the flagship Bench’s main bar (Preview · Components · Report · Export) now keeps its place down the tall live preview, and offsetting it past a fixed header isxoji-tabs::part(tablist) { top: … }through the already-exposed part, so no new token joins the coverage contract - fixed Accordion the same way: it froze live light-DOM section panels into static HTML; panels now project through a per-index
<slot>too, and the Svelte binding gained asections+panel-snippet API (mirroringTabs) so a consumer composes live collapsible sections without tripping Svelte’s named-slot uniqueness rule- and the light-DOM control protocol behind both now reads
value/open/disabledas an attribute or a DOM property, since Svelte sets them as properties on a plain element rather than attributes, so a default-open accordion section and a tab’s selection key survive the binding instead of silently falling back
- and the light-DOM control protocol behind both now reads
- fixed Avatar loading a broken image when given only initials: the Svelte binding assigns
srcas a DOM property, and the element’s setter stamped the literal string"undefined"onto the attribute, so an initials-only avatar fetched…/undefinedand 404’d before falling back; reflecting string setters now drop a nullish value (a newreflectStringbase helper), so the attribute is simply absent - swept that same footgun out of the reflecting string setters across the library, a framework that assigns
el.prop = undefined(Svelte does, for custom-element props) had every baresetAttributestamp the literal"undefined", surfacing as a broken…/undefinedlink, an"undefined"form value, or strayundefinedtext; found dogfooding aSwatchpalette that renderedred undefined, then closed across the whole set:Swatch,Breadcrumb,Field,Checkbox,Radio,Select,Switch,Textarea,FormGroup,Dock,Grid,Link,CardLink,Menu,Panel,Section,Stat,Statusbar,Tabs,Toc, andToolbarnow route their optional string props throughreflectString(andLink.target/Section/Statlost a=== nullguard that letundefinedslip past) - made the Svelte wrappers transparent to arbitrary attributes: every one of the 52 now spreads
{...rest}onto its root element (an index-signaturePropsplus a...restahead of the explicit props, so a passedtitle/id/data-*/aria-*lands on the element while declared props still win); a consumer wanting a tooltip on a toolbar control no longer wraps it in a<span title>shim, closing a reported composability gap across the display, layout, and overlay wrappers that the form controls had already closed (snippets and handlers stay bound by name, so they never leak into the spread)
Site (apps/site)
- gave the Bench an Auto scheme that follows the background: the scheme knob now defaults to
auto, which hands the call to the engine (scheme ?? schemeOf(bg)), so dropping in a lightbgderives a light theme on the spot; the knob used to pin todark, and a light background under a dark scheme collapsed every surface to white, caught dogfooding a light theme- and a scheme still forced against its background warns inline with a one-click revert to
auto, so the collapse is guarded rather than only sidestepped
- and a scheme still forced against its background warns inline with a one-click revert to
- made the theme status honest: the toolbar badges and the status bar now track the active theme’s algorithm, scheme, and token count instead of a baked-in
xoji-default/dark, so a light theme readslighteverywhere it’s named - reset
color-schemewhen an active theme is cleared: clearing a light site theme back to the baseline used to leavecolor-scheme: lightstamped on a now-dark page, so native dropdowns and scrollbars rendered light-on-dark; the clear path now matches the engine’sapply/clearcontract - rebuilt the Bench as a graduated control surface: the algorithm is the only required pick, and anchors, knobs, and per-token overrides are optional layers stacked on top, so set nothing and you get the algorithm’s native theme, set everything and you’ve hand-built one; an unset anchor or knob falls through to the algorithm’s own default, and the old mutually-exclusive tabs (which made the studio read as “only three colors” when it had always been more) are gone; the per-token “pinning” mechanic became direct inline editing (every derived token is grouped by category and editable in place, touching one feeds it back as a constraint so dependents re-solve), and each input tier carries an at-a-glance set-count, with a clear-all on the token register
- made the site’s live numbers a single source: one
getStats()derives the canonical theme once and counts components, tokens, categories, and bindings off it, so every surface that quotes a stat reads from one place instead of a hardcoded literal; the stale312 tokens/34 manifests/179-tokencopy scattered across the homepage, the Bench, the docs, and four component demos is gone, and a committed baseline plus a release-time snapshot let the by-the-numbers band show+N since vXgrowth once a version ships - gave the site a self-hosted type set (REM for headings, Poppins for body, Teko for the wordmark) routed through the
xoji-defaultfontsknob rather than raw CSS, so the token register re-solves around them, and bundled via@fontsourceso the site makes zero third-party font requests - renamed the Docs route to Getting started and repositioned it as the on-ramp (
Overview → Getting started → The Bench → Components), since the component reference is the real documentation and the standalone page is the engine intro it always was - gave the Getting started page an on-this-page rail in the
AppShellright slot: the same scroll-spyTocthe component reference pages carry, so the engine intro tracks your place through install → derive → emit → apply the way the rest of the docs do - rebuilt the Bench’s main as a tabbed application surface: Preview · Report · Export on the
Tabscomponent, lifting the derivation Report (contrast · coverage · gamut · graph) out of a cramped right rail into a full-width tab; the studio is now a two-column rail-and-tabs layout andResetmoved into the document header - rebuilt the Bench’s control rail as an
Accordionof collapsible sections (Anchors · Knobs · Tokens, the first two open by default), each panel a live editor (color pickers, knob fields, the full token register), with theAlgorithmpick pinned above as the one required choice - gave the Bench a Components tab: a systematic gallery of the component set (buttons · form · feedback · navigation · data · typography · overlays) rendered live under the derived theme, distinct from the curated “sample app” Preview; change an anchor and the whole contract re-themes at once, the flagship “any valid theme renders well across the set” surface
- taught the Bench to round-trip themes as files: Export serializes the canonical theme file (recipe plus the materialized token register), and Import adopts either that envelope or a bare
---prefixed token map, so a flat--format jsondump pastes straight in as overrides on the default algorithm - gave the Bench control rail a fourth section, Palette: a swatch-forward editor for the nine chromatic named hues, each a color picker over a live five-stop ramp; re-hue one and its whole ramp and family follow on the spot through deep pinning, with per-hue reset and a clear-all (the achromatic neutrals sit out, since pinning them only recolors the alias, not the ramp)
- turned the Bench into a theme-and-algorithm studio; a new
Customalgorithm opens a live editor for adefineXojiAlgorithmtaste vector (anchors, vibrancy, chroma, contrast, elevation) authored as JSON, and editing the spec rebuilds the algorithm in-browser and re-derives the whole theme live, with invalid JSON surfacing in the error banner behind the last valid theme; the authored algorithm is its own thing, not a knob preset, so anchors and knobs still layer on top, the spec rides the share-link and the theme store, and the Export → Invocation tab emits it as reproduciblemakeXojiAlgorithm(toPreset(...))code; the author’s taste vector is pure data, so it derives in-process, with the arbitrary-derive-code tier through the hosted sandbox as the next step- made
toPresetbackfill a partial spec’s anchors over the engine default, so a{ bg, accent }taste vector with nofgbuilds a valid algorithm instead of one that crashes on derive - the code tier landed too; a
Custom codealgorithm opens an editor for import-freedefineAlgorithm/defineXojiAlgorithmsource that runs through the hosted xript sandbox (loadAuthoredAlgorithm), debounced so a keystroke doesn’t spin a fresh runtime, with the last good theme held until the build resolves or its error surfaces- unlike the in-process taste-vector tier, this is arbitrary code, so it stays off the share-link until the link schema is tier-tagged
- made
Docs
- recorded the design in
docs/:derivation-model.md(the spine),dimensional-contract.md(the token register),repo-layout.md(packaging, dual-entry, discovery), andopen-questions.md(the live forks)
| package | tests |
|---|---|
xoji | 1041 |