Skip to main content

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 + overrides into 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 via culori, an open emitter set (css / json), coverage(), and a per-algorithm gauntlet()
  • shipped xoji-default, the neutral built-in algorithm: surfaces / content / accents / status / state overlays / links derived from bg + 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-0 to white over a near-black anchor and --fg-0 re-derives to clear AA); the gauntlet asserts every invariant still holds under a pin
  • kept the dual-entry discipline physical: the neutral index and its imports carry no node:*/DOM; Node-only code lives in cli.ts, browser-only in dom.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 4xl and 5xl display steps: the modular ratio now climbs two stops past 3xl (≈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: both emitCss and the apply DOM helper now stamp color-scheme: dark or light (read from --bg-0’s lightness via schemeOf), 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.accent is now optional (a new exported PresetAnchors type) 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_ANCHORS and SHARED_KNOBS alongside makeXojiAlgorithm, so a flavor author composes from the same primitives the blessed five use instead of restating them
  • made status *-text carry 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 *-text to 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; *-bg now stays a tint and *-text routes 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}-text set, 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 (-fg on --{tone}) and soft pairing (-text on --{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 their subtle/strong stops 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
  • opened the register to non-color intent tokens, the first being --selection-cue (tint | marker); a new cross-cutting cues knob (color | redundant) drives it and high-contrast emits marker by 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 through var()) still counts as consumed
  • gave intent tokens typed value sets: a KEYWORD_DOMAINS registry declares each keyword token’s legal vocabulary (--selection-cuetint | marker), and the format invariant rejects any algorithm that emits outside it; the gauntlet now catches a stray --selection-cue: sparkle before it ships, the safety rail the intent-token layer needed before its vocabulary grows
    • closed the loop with the consume side of that contract: lintStyleQueryDomains reads 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 (a marekr that 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 existing consumedTokens lint
  • 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-bg already 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): the prism emitter 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; the monaco emitter builds a Monaco IStandaloneThemeData (rule foregrounds + workbench colors, scopes folded onto Monaco’s token names), dogfooded in a live editor across the blessed themes
  • 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 --danger carries its hue through --danger-bg and the on-tint --danger-text instead 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-2 and -3/-4 re-thread, but pin --green and only the solid moved while its tint and ink stayed catalog-green; palette and semantic tones now reach the same depth, dogfooded by pinning --danger violet and watching all three Badge fills (solid / soft / outline) re-hue and stay readable
  • set the default accent ramp to a split-complement fan: accent-2/accent-3 flank the accent at ∓half the shift step and accent-4 is 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 minimum 1.5:1 separation from it, pushed along lightness away from the surface with its hue and chroma intact; found dogfooding hostile anchors: an achromatic-dark accent collapsed --accent onto --bg-0 (both #141414, a primary button you couldn’t see), and a mid-gray page sank --neutral and --danger with 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 new solid fills separate from --bg-0 invariant guards the line for every algorithm
  • floored the divider tokens so a border is always a border: --line, --line-2, and --field-border each clear a minimum contrast against the surface they delineate (1.5:1 for the hairlines, 1.8:1 for 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 #0e0e0e on a #000000 page (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 new borders separate from their surface invariant guards the line for every algorithm
  • fixed the achromatic named hues reading blue: --gray, --white, and --black derived their on-color inks (-fg, -text, and the swatch -contrast stop) at a hardcoded hue, so a gray chip’s text came out a slate blue (#abc0d7) and a black badge’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 -bg into a true wash so the soft-tint contract holds the same shape across the whole roster: a named hue’s -bg used to reuse its swatch ramp’s subtle chip, a near-full-strength color that read garish as a full background, and now derives a pale wash sitting just off --bg-0 at a fraction of the tone’s chroma, like --accent-bg already did, with -text re-derived to clear AA on it; the --color-* swatch ramp keeps its own chip, and the achromatic white/black/gray keep their lightness identity since a uniform wash would collapse them into one gray
    • the same pass fixed the four status -bg collapsing to pure white on light themes; the tint lightness was reaching above the page and clamping out, so a soft danger/success/warn/info surface 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
  • defined the canonical theme file: a self-describing, re-derivable artifact carrying meta (provenance), recipe (the algorithm plus the anchors / knobs / overrides that print it, the source of truth), and tokens (the materialized register, a cache so a consumer applies the theme without ever running the engine); buildThemeFile / serializeThemeFile / parseThemeFile ship from @xoji/core, the JSON Schema is published at xoji.dev/schema/theme.v1.json, and xoji derive --format theme emits one straight from the CLI
  • made bare xoji print 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 curious xoji shows is what it can do, not a wall of custom properties (found dogfooding the CLI)
  • gave the gauntlet CLI 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 hosted still runs the shipped sandboxed mod for the production battery, --depth quick|standard|full dials 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.json plus an esbuild-bundled, self-contained mod.js; run through @xriptjs/runtime in a zero-authority sandbox and deriving byte-identical to the baked output across the whole matrix
  • shipped nxi-nite as the fifth blessed algorithm and the worked example of passes: a Day/Night taste that adds an hour knob 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-default collapses to a single line) and defineAlgorithm({ derive }) for a from-scratch algorithm, both importing core by name like any third party would
  • exposed color primitives to mods as the cuti host binding, gated by the color-math capability; 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, and gauntlet, so the invariant proof runs against the sandboxed mod that ships rather than a baked copy of it
    • baked getAlgorithm stays as the byte-identical test oracle and as the browser’s synchronous first-paint fallback (before the async mod load lands); a new snapshotAlgorithm(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
  • added an in-browser authoring loader; loadAuthoredAlgorithm(source) runs author-written defineAlgorithm / defineXojiAlgorithm source through the hosted sandbox with no bundler, prepending a pre-built authoring prelude (the whole @xoji/core/authoring surface, bundled once) to the import-free source and supplying it on the sandbox scope, so a self-authored derive body runs with the same color-math-gated isolation a third-party pack gets, never the baked path
    • the prelude exposes the full surface, not just the two define helpers; 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

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 to min/max, snapping to an integral step, and writing the result into a CSS custom property a grid or flex track reads, so the layout resizes declaratively
    • it fires resize live through the drag and resize-end on release (and on each keyboard commit), so a consumer can preview or persist; orientation picks the axis, reversed flips direction for a trailing-edge rail, double-click resets to a default
    • 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
  • 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’s template() 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 static prop 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) and AppShell (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
  • 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/#rrggbbaa I/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-value readout with a format hook (a (value) => string property, so 0.8 reads 80%) and a hide-label option that keeps the accessible name while dropping the visible one; both retire the wrapper a consumer had to write around it
  • gave Button a controlled toggle state; a pressed attribute reflects to aria-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 a variant
    • added a selected sibling (reflected to aria-selected) for selected-in-a-set semantics, and a dense xs size below sm for compact toolbar pills; both consume only already-produced tokens (--state-selected, --text-xs, --space-0)
  • 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 Copied before reverting
    • on by default; pass copy="false" to drop it
    • the fragment ships the button hidden and 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 wrap option 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 with wrap so a wrapped line keeps a single number anchored at its top
      • pure derived chrome borrowing --code-comment and --field-border, so it adds no tokens and renders on the zero-JS Astro path too
    • 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 with line-numbers, holds the tint full-width even as a line scrolls or soft-wraps, and renders on the zero-JS Astro path
  • taught Tree to honor --selection-cue: when a theme sets it to marker (high-contrast, or any algorithm with cues: 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-cue resolves to marker, 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 ::before since each already owns its ::after state overlay
  • opened every general-purpose tone-driven component to the full vocabulary: Button, Badge, Avatar, Progress, Radio, Spinner, and then Switch, Slider, Checkbox, and Segmented take any of the 21 tones through one shared FULL_TONES list, so a pink button, a teal switch, an accent-3 slider, 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 / -ink variable so the existing disabled and state rules keep winning without a specificity fight; every manifest’s consumedTokens regenerates from FULL_TONES so the coverage lint stays exact, and the reference demos grew an “every tone” showcase rendered straight from the list
    • then split Alert and Toast onto 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, so danger/warn announce assertively, while tone carries the color from the full vocabulary; a severity paints its own --{severity} family by default; a tone overrides that color (a danger repainted pink still announces as danger); and a color-only tone with no severity shows no glyph and announces politely, the awareness-banner case a status-locked component forbade; a bare status-named tone still 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 icon slot 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
  • gave the neutral container components an opt-in accent edge: Card takes a tone that paints a leading accent bar and Dock a 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 FullTone end to end: the custom-element tone setters on Button, Spinner, Avatar, Progress, Badge, and Radio had lagged at the six-role Tone (or the 18-tone BadgeTone) while their css, manifests, and wrappers already spoke the full vocabulary, so a TS consumer assigning tone="purple" hit a phantom type error; the elements now type the full set, while a presence dot’s status stays the six semantic roles where a free color would be wrong
  • gave Statusbar an overflow prop for when content outruns the strip: clip (default) hides the spill, wrap flows items onto another line, scroll adds a horizontal track; it was a plain flex row that just overflowed before
    • landed the flagship collapse mode: a ResizeObserver ranks cells by a per-cell data-priority (a data-required cell never drops), folds the lowest-priority ones first when the row can’t fit, and tucks them into a +N overflow 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: collapse clones 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 new manual-overflow suppresses the built-in popover and the element instead fires an overflow-change event 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 separated mode so the bar draws the divider between cells itself, at its own spacing, and each item stays a single cell the collapse ranking counts independently, instead of every item carrying its own Separator, 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 a spacer) and stays right as items collapse, and it closed a latent collapse bug found dogfooding the footer: cells defaulted to flex-shrink: 1, so they shrank instead of overflowing and the detector never tripped, clipping the right edge instead of folding into +N
  • 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 own header slot names itself with a new label prop, since aria-labelledby cannot 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 or multiple, with a settable headingLevel, arrow/Home/End keyboard, and the WAI-ARIA heading-button-region wiring; 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 items array of label/href/children with expanded/selected/disabled flags) rendering the WAI-ARIA tree/treeitem/group pattern 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-2 face with a weighted --border-thick bottom 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; size steps sm/md/lg, and a row of keys in a Cluster spells 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 + dot row and palette rail is built from; the color it shows is data (an inline color, 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; size steps sm/md/lg
    • gave it three opt-in interactions: interactive renders the chip as a <button> that emits a select event for a pickable palette, selected rings the dot in the accent and reflects aria-pressed (controlled, so the consumer owns single-vs-multi select), and details reveals a hover/focus popover reading the color across hex/rgb/hsl/oklch through the engine’s parseColor/formatColor; the display chip stays the zero-JS default, and a details chip is keyboard-reachable via tabindex and aria-describedby, not hover alone
  • 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-expanded on 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 a select event 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 from page + total and tuned by siblings (links each side of current) and boundaries (links pinned at each end); give it an href template 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 a page-change event with the chosen number; the current page is a tone-colored pill carrying aria-current="page", prev/next go aria-disabled (not removed) at the ends, every page and arrow has an explicit accessible name, and the ellipsis is decorative and aria-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}-vivid ink rather than --{tone}-text: -text is 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 -vivid clears 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 extend XojiElement directly 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 live xoji-toggle-button that subclasses the controlled Button to flip its own pressed on click; with the pressed/selected state 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 in hex/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 EyeDropper API (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 swatches list 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 contrastAgainst to 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 same contrast the 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 alpha knob (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, and oklab to @xoji/core’s conversion API (culori-backed, full round-trip in and out), and gave the picker a modes knob: a comma-separated, ordered subset of the formats the format button 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/core carries its own naive, profile-free cmyk() formatter and parser; cmyk(C% M% Y% K%) reads out and types back in, with an optional / alpha, and joins the configurable modes set (a hex,rgb,cmyk picker covers web-and-print in one)
  • gave the picker a harmony mode: a complementary/triadic/analogous/split-complementary/tetradic/monochromatic/shades/tints scheme 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; the harmony() 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 (channelsOf returns a model’s channel defs, colorToChannels reads a color’s channels in display units, colorFromChannels rebuilds it, exact round-trips across all eight models) backs a channels knob 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) and nearestNamedColor (the perceptually closest CSS named color, by OKLab distance over the 148-color set) joined @xoji/core, behind a snap knob that adds buttons for either: web-safe quantizes, and named reads 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, oklchToDisplay in @xoji/core returns a coordinate’s clamped sRGB color plus whether it was in gamut, and a plane knob 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 live L · C readout 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 native popover: top-layer, anchored below the button, with outside-click and Esc light-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 ColorPicker in trigger mode 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 @xoji components down to its most-used control
  • taught Number Input a second granularity: an altStep (10× step out of the box) that a modifier (Shift by default, or alt/ctrl/meta) swaps to on a click or arrow, with PageUp/PageDown always taking the big jump and altDefault flipping 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 value under a small uppercase label, with an optional delta whose trend (up / down / flat) tints it and pairs the tint with a directional arrow, plus a caption for context; pure presentation across all three bindings, built to line up digit-for-digit in a Grid dashboard strip, and the first of the structural primitives the site refactor surfaced
    • gave it an inline variant: 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
  • added Section to layout, a structural page strip in two shapes: a band (a plain/quiet/accent surface with optional bordered hairlines and a padding rhythm that eases off under 40rem) and a stage (an elevated, accent-tinted frame with an optional corner label, 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-stage rules are gone
    • fixed a drift bug it surfaced: the by-the-numbers band referenced an x-band--quiet class that was never defined, so it silently rendered as a plain accent band; it now carries a real quiet tone
    • then opened its band tone to the full 21-tone roster: plain/quiet keep their surface-raise meaning while every semantic role, accent variant, and named hue now tints a band through var(--{tone}-bg), so a soft awareness strip or a status band is a tone rather 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
  • added Eyebrow to layout: the small uppercase kicker above a heading, accent-toned by default with muted/subtle tones and a normal/wide tracking; one element with no layout of its own, so a Stack gap 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-eyebrow and .cx-eyebrow scoped rules
  • gave Heading and Text an accent tone: 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 use tone="accent" instead of a scoped .numbers__value color 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 from Heading at display size, retiring the bespoke .x-display clamp (and a dead copy of the class that had been silently doing nothing on the Bench)
  • added Card Link: a Card that is itself one <a>, so the whole card is the click target (header/body/footer slots, the interactive/overlay/compact looks, interactive on by default, the underline and ink reset, a focus-visible ring on the card); the reference-page prev/next pager now composes from it instead of a Link wrapping an interactive Card, dropping the link-reset scoped rules it needed
  • added Toc, an on-this-page table of contents that scrollspies the section in view: an items list of { id, label } renders a labelled nav of in-page anchors, and an IntersectionObserver marks the active link with aria-current and the accent rail (links work with no script, the spy only decorates); folds to a chip row on narrow screens, takes sticky; the reference pages’ on-this-page rail now composes from it, retiring the bespoke .ref-onthispage styling 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/astro binding silently dropped the content slot: Astro strips the routing slot= 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 at min(18rem, calc(100vw - var(--space-8)))
  • 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 for left/right); the shift rides two component-internal custom properties the element sets from the shared placeOverlay geometry, so with them unset the no-JS/SSR layout stays exactly centered; a pure tooltipTetherShift helper 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, with variant="soft"/"solid", a fully washed or filled surface with a matching arrow, the same soft/solid shape Alert and Section use; a mode of rich opens 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), and size (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 keydown listener 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; onKeydown now stops propagation, so only the focused node handles its own key
  • gave Tree a locked branch, 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’s Components branch 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-x made it a nested scroll container the header bound to instead of the page, and border-collapse: collapse blocks sticky table cells in Firefox outright; the wrapper is now the single scroll container via a maxHeight prop, and the table moved to border-collapse: separate so the header’s own border travels with it, with the bordered grid 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; AppShell now takes leftSize/rightSize that seed the rail width server-side), the nav dock didn’t scroll (its :host couldn’t shrink as a grid item; min-height: 0), and the line Splitter 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; a minmax(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 innerHTML into 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 static items/SSR path that bakes panel HTML is unchanged)
  • gave Tabs a sticky option 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 is xoji-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 a sections + panel-snippet API (mirroring Tabs) 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 / disabled as 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
  • fixed Avatar loading a broken image when given only initials: the Svelte binding assigns src as a DOM property, and the element’s setter stamped the literal string "undefined" onto the attribute, so an initials-only avatar fetched …/undefined and 404’d before falling back; reflecting string setters now drop a nullish value (a new reflectString base 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 bare setAttribute stamp the literal "undefined", surfacing as a broken …/undefined link, an "undefined" form value, or stray undefined text; found dogfooding a Swatch palette that rendered red 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, and Toolbar now route their optional string props through reflectString (and Link.target / Section / Stat lost a === null guard that let undefined slip past)
  • made the Svelte wrappers transparent to arbitrary attributes: every one of the 52 now spreads {...rest} onto its root element (an index-signature Props plus a ...rest ahead of the explicit props, so a passed title / 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 light bg derives a light theme on the spot; the knob used to pin to dark, 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
  • 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 reads light everywhere it’s named
  • reset color-scheme when an active theme is cleared: clearing a light site theme back to the baseline used to leave color-scheme: light stamped on a now-dark page, so native dropdowns and scrollbars rendered light-on-dark; the clear path now matches the engine’s apply/clear contract
  • 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 stale 312 tokens / 34 manifests / 179-token copy 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 vX growth 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-default fonts knob rather than raw CSS, so the token register re-solves around them, and bundled via @fontsource so 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 AppShell right slot: the same scroll-spy Toc the 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 Tabs component, 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 and Reset moved into the document header
  • rebuilt the Bench’s control rail as an Accordion of 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 the Algorithm pick 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 json dump 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 Custom algorithm opens a live editor for a defineXojiAlgorithm taste 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 reproducible makeXojiAlgorithm(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 toPreset backfill a partial spec’s anchors over the engine default, so a { bg, accent } taste vector with no fg builds a valid algorithm instead of one that crashes on derive
    • the code tier landed too; a Custom code algorithm opens an editor for import-free defineAlgorithm / defineXojiAlgorithm source 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

Docs

  • recorded the design in docs/: derivation-model.md (the spine), dimensional-contract.md (the token register), repo-layout.md (packaging, dual-entry, discovery), and open-questions.md (the live forks)
packagetests
xoji1041