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.7.1: Turn the Dial

v0.7.0 made the accent family a knob and retired an algorithm into it. That knob turned out to be unreachable from anywhere but the site, the retirement only half-landed, and the one algorithm with a knob of its own had that knob held for it by the engine. This is the repair.

The knob tier is reachable

  • --knob <name>=<value> on the CLI (repeatable, alias -k), and a knobs input on the MCP tools (xtyle_derive / xtyle_coverage / xtyle_audit). The knob tier is the whole casual UX, the tier where one small input reshapes a theme, and none of it had a headless door. step and duo could not be derived by any means outside the browser, and shade was worse than unreachable: it had been reachable as -a xtyle-brand right up until v0.7.0 retired that id, so the release that promoted the strategy to a knob is the release that took away the only way to ask for it. xtyle derive --knob accentStrategy=duo works now, which is what “it’s a knob, not an algorithm” was supposed to mean in the first place
    • a knob’s value is typed on the way in, because the derivation guards on typeof knobs.surfaceRamp === "number" and a shell hands over "-0.05"; the string would have been accepted, ignored, and derived as though you had never set it
  • xtyle knobs prints every algorithm’s dials and exactly what each one accepts: kind, range, options, default. It reads the algorithm’s own knobSpecs, so a third-party algorithm’s novel knob is as discoverable as a blessed one, and --knob has a vocabulary you can look up instead of guess at. xtyle list still prints bare ids, unchanged
  • the MCP format enum is built from emitters() rather than a hand-kept second copy, which had already drifted: v0.7.0 shipped the terminal emitter, and the server would advertise it in xtyle_list_algorithms and then reject it in xtyle_derive

The retirement actually retires

  • Every consumer migrates a retired algorithm; before, half of them died on it. xtyle derive and xtyle gauntlet ran the migration map. xtyle audit -a xtyle-brand and xtyle coverage -a xtyle-brand hard-failed on a dead id, and all four MCP tools resolved the raw id too. There is one migratedTarget() in the engine now and everything reads through it, which is what the code comment beside the map had been claiming all along
    • worst of it: xtyle_derive with format: "theme" wrote the un-migrated id into recipe.algorithm. A theme file is the source of truth of a re-derivable artifact, so the tool was minting brand-new files naming an algorithm the engine can no longer resolve: a fresh theme, born broken
    • -a xtyle-brand now derives byte-identical to --knob accentStrategy=shade, which is the entire claim the retirement rests on and is now a test rather than an assertion

An algorithm owns its own knobs

  • hour moved out of the engine and into nxi-nite, where it always belonged. The engine’s shared knob registry was holding the domain of a knob exactly one algorithm reads, with a comment explaining that this saved nxi-nite from declaring it; that is the hardcoded, name-keyed table knobSpecs exists to delete, relocated one layer down. nxi-nite declares its own hour now, and becomes the first bundled algorithm to exercise the novel-knob path at all: the feature’s whole justification had shipped with no algorithm using it
  • nxi-nite lost two working knobs from its rail, and has them back. Its derivation read accentStrategy and surfaceRamp and its declaration named neither. Nobody noticed while the rail was a hardcoded table; the moment v0.7.0 made the rail render from the declaration, both dials vanished from the UI, while a saved recipe that set them still applied them. Declaration and behavior had drifted in opposite directions

A knob is checked against its declared domain

  • --knob accentStrategy=duoo used to exit 0 and hand you a different theme. Every knob reader in the derivation falls back quietly on a value it doesn’t recognize. Right for the derivation, because a theme has to come out the other side; catastrophic for an input surface, because a typo becomes a silently different design. The bench never had this problem: its controls are built from the domain and cannot express a value outside it. The new headless doors handed the engine a raw envelope and hoped. A knob is checked against the domain its algorithm declares now, and a bad one is loud: an unknown name lists the knobs that algorithm actually has, a bad select lists the values it takes, and an out-of-range number names the range
    • the check is the algorithm’s, not the CLI’s: hour is a knob on nxi-nite and on nothing else, so -a xtyle-default --knob hour=3 is an error rather than a value quietly dropped on the floor
    • a value is coerced by the kind the algorithm declared, never by the shape the string happens to have. Guessing from the shape is wrong in both directions: it reads a text knob set to 12 as a number, and a select set to false as a boolean
  • A group can say that it’s a group. anchors and fonts are clusters a consumer expands into several controls, not single dials, and the engine used to know that by keeping a list of their names: the same hardcoded, name-keyed table knobSpecs exists to delete. They declare kind: "composite" now, which means a third-party algorithm’s own composite knob (a palette array, a per-role font map) can say so too, instead of being rendered as a scalar it isn’t

Fixes

  • Switching surfaceRamp to “custom” inverted the entire surface stack on a light theme. The knob is signed and its default sign follows the scheme (dark ascends, light descends), but it declared one static +0.045. On a light theme the control opened on a value the derivation would never have produced, and merely unlocking the slider, touching nothing and choosing nothing, flipped every surface from descending to ascending. KnobSpec takes a defaultByScheme now, and the control seeds from the scheme the theme actually derived under, so the toggle unlocks the dial without moving it
  • A knob an algorithm declared but never described used to vanish. It resolved to no control, no warning, nothing: the one thing the author asked for, silently unreachable. It degrades to a text field now and carries an undeclared flag rather than quiet tolerance, so a crude control beats an absent one and xtyle knobs shows the gap in the declaration instead of a mystery text box
  • qr.ts was a binary file. A literal NUL byte, used as a cache-key delimiter, made git classify 250 lines of TypeScript as binary: no diff, no blame, no review, on that file, forever. It is an escape sequence now, byte-identical at runtime and legible to git blame again
  • AlgorithmManifest understated its own contract by three fields. The host reads knobSpecs, invariantCount, and passNames off the manifest() export; the manifest declared none of them, so a third party writing an algorithm against the published type would ship a manifest() the host reads undefined off. The type declares what the host actually reads, and KnobSpec is declared beside it
    • invariantCount is required, and the host now refuses a mod that omits it. The invariant list is sized from that number, so an absent one yielded a zero-length list: the mod loaded, reported no invariants at all, and sailed through the gauntlet having proven nothing. An algorithm that cannot say how many invariants it has does not get to be assumed to have none
  • A mod that described only some of its knobs lost the rest. The host took a partially-declaring manifest() at its word for the whole list, so an algorithm that described the one novel knob it invented (precisely the case knobSpecs exists for) dropped the domains of every shared knob it also read. The mod’s own specs win by name and the registry fills the rest
  • The gauntlet couldn’t finish a hosted run. --mode hosted --depth standard and --depth full died on interrupted, so the documented way to prove invariants against the shipped sandboxed mods had been unable to run past quick since long before this release. The sandbox’s 5s rail is an anti-runaway guard, not a performance budget, but a single interpreted derivation against a hostile seed already takes ~3s, so a deep sweep tripped it on the slowest seeds. A correctness battery is supervised by the person running it, so it loads under its own rail; the browser and every production path keep the 5s guard exactly as it was
packagetests
@xtyle/core980

v0.7.0: Show Your Work

The accent family

  • How --accent-2/3/4 relate to --accent is a knob now, accentStrategy. It had been a frozen engine constant, then an algorithm-only taste field, and neither was right: it is the one input that reshapes the accent family rather than tuning it, so reaching for a whole new algorithm to get a different one was a tax. It takes four values, and an algorithm still declares the posture it ships with, but that posture is a default now and not a lock
    • fan is the default: 2 and 3 flank the accent at ∓accentSplit and 4 is its 180° complement. Pin either wing and the other mirrors it across the accent, so the pair stays symmetric around your choice
    • step walks the hue circle evenly, each accent one accentShiftStep past the last; a pinned wing carries the chain with it
    • shade holds one hue at four depths: 2/3/4 keep the accent’s hue and step its lightness instead. Because the rungs separate on lightness, a near-gray accent still fans into four distinguishable shades where the hue strategies collapse to four identical grays
    • duo takes two brand colors instead of one. --accent and --accent-2 are both inputs, and 3/4 become their shades. Pinning a fan slot has always fed the rest of the family (a pinned wing mirrors under fan and carries the chain under step); what duo changes is that the second brand is the point of the strategy rather than a correction to it. The shades sit against the pair’s mean lightness rather than each anchor’s own, so both land on one common lightness and read as a matched secondary tier belonging to one system, instead of two ladders standing next to each other. Set no second brand and it falls out of the accent by the fan distance, so a duo theme is never broken for want of a second color
    • the constant-L/C fan invariant is strategy-aware (shade and duo step lightness on purpose, so that check is the hue postures’ promise and not theirs), the gauntlet fuzzes every strategy against every algorithm’s hostile seeds, and the algorithms page renders all four side by side off one accent, so the choice is something you look at rather than a line of prose
    • the vocabulary collapsed on the way through: split-complement / wheel / shade-ladder were a second set of names for the same three things, and fan / step / shade are the only ones now
  • xtyle-brand retired into the knob. A blessed algorithm whose entire reason to exist was “the default, but with a shade ladder” is a knob wearing an algorithm’s clothes. Its output is accentStrategy: "shade", and a saved theme that names it migrates onto the knob rather than falling back to a bare default, so it keeps deriving what it derived. Five blessed algorithms, and one more thing you can do with any of them

New components

  • Dot (<xtyle-dot>), the standalone status dot as a first-class custom element that self-styles in its own shadow root, so wrapper-model consumers (shadow DOM, no global sheet) can finally reach it. It carries tone, size, pulse (slow / fast), ping (an expanding ring), glow, a color escape hatch, and an optional label (a named role="img", else decorative); the .xtyle-dot utility class stays available for global-CSS pages
  • Ribbon (<xtyle-ribbon>), a corner ribbon: a diagonal banner pinned to any of the four corners of a card, tile, or image for a short label like “New” or “Beta”. tone takes the full semantic and hue roster, alongside variant (solid / soft), size, corner, and a color escape hatch, and the element fills its container as a clipping overlay so the band trims to the edges. Self-styling like Dot, with a .xtyle-ribbon utility class alongside, bound across @xtyle/svelte and @xtyle/astro
  • QrCode (<xtyle-qr>), a themeable QR code that inks its modules from the active theme (--fg-0 on --bg-0) and encodes in the browser, so it recolors live when the theme changes. moduleShape picks square / dot / rounded, an optional center icon mark knocks a clean hole in the code (or iconOverlay + iconOutline lays it over the top with a halo), frame prints the payload beneath as a real, followable link for safe schemes, modeToggle flips themed and bitonal live, and ecLevel steps L / M / Q / H
    • scannability is a contract rather than a hope: mode="theme" / bitonal / auto is floored by xtyle’s own contrast audit pointed at the module-to-background pairing, and the cleared patch wraps the injected mark tightly, so a bigger icon-size reads as a bigger glyph instead of more whitespace. Every size preset round-trips through a real decode at level H. Bound across @xtyle/svelte and @xtyle/astro
  • MobileShell (and BottomNav), the touch frame AppShell was never going to be. AppShell is desktop chrome by construction: a three-row grid whose body is a left/main/right split with px-width resizable rails. On a phone that doesn’t degrade so much as stop being the right shape, and the touch-nav primitives were missing besides. So MobileShell is a separate frame (a sticky app bar with heading / brand / actions slots, one scrolling <main> column, and a nav slot in thumb reach) rather than a responsive bend of AppShell, because desktop-IDE chrome and touch-app chrome are two interaction models, not one layout at two widths. An app picks one at its entry
    • the bar and the nav absorb the safe-area insets (env(safe-area-inset-*)), so on a notched phone the chrome takes the hardware and the content column is never the thing sliding under it; the column scrolls itself rather than the page, so the bar and the nav hold their place
    • BottomNav (<xtyle-bottom-nav>) lands as its own component: the app’s top-level sections as a real WAI-ARIA tablist with a roving tabindex, so the whole bar is one tab stop and the arrows (plus Home and End) move between sections inside it, rather than every tab being another stop to tab past. Each tab takes an optional icon and a badge, and the selected one is marked by an accent bar as well as by color, so which section you’re in never rests on hue alone
    • both are fragment-backed, like AppShell / Tabs / Segmented and unlike the decorator components: a shell owns its chrome, so it renders through its own xript fragment and a mod can reshape the frame exactly as it can reshape any other component. The first pass built them as light-DOM decorators, which would have put the shell’s markup outside the fragment model and quietly made the one surface an app most wants to reskin the one surface it couldn’t

Media

  • Image and Carousel play a preview on hover. An image can reveal a preview on hover and focus, then reset to the still on leave. hover-src takes a video (muted, looping) or a gif; a hover slot takes anything richer, a <video>, images, or a whole nested <xtyle-carousel>, so carousel → image:hover → carousel composes for free. The preview reveals on focus too (never mouse-only), the overlay is aria-hidden so the still’s alt stays the single accessible name, and under prefers-reduced-motion it neither reveals nor plays. hover-audio allows sound behind a mute toggle, starting muted so autoplay is never blocked and unmuting is a real click. In a Carousel, hovering a slide reveals its preview and pauses autoplay in the same gesture. Threaded through the element, the fragment, the manifest, and all three bindings, with new volume / volume-off icons
    • the image fragment’s update became a non-destructive patch (it repaints the media and the attributes, never the overlay), so a live re-render can’t wipe an SSR-composed preview
    • a nested carousel revealed on hover wants transition="fade": the scroll-snap track can’t measure itself while the overlay is hidden and stalls on its first slide, but a stacked cross-fade has no scroll math and cycles reliably
    • the overlay’s content-sizing rule skips script and style now: a slotted island (a nested carousel) carries its own hydration <script>, and sizing that non-visual node to the frame was pushing the real preview out of the clipped frame, so a nested-carousel preview rendered blank
  • Carousel took a transition prop. slide (scroll-snap, the standing default), fade (cross-fade opacity, stacked slides, no scroll-snap), scale (fade plus a subtle zoom), and flip (a 2D card-flip). The stacked modes overlay every slide in one grid cell and cross-fade the active one instead of scrolling a track, so looping is inherently seamless; the inactive slides go inert and aria-hidden, and everything holds still under prefers-reduced-motion
    • the stacked transitions reveal rather than dissolve into each other. Fading two slides against one another (one out as the other comes in) never adds up to full coverage mid-dissolve, so whatever sat behind the carousel bled through on every change: a page background, or the still under an Image hover-preview, which read as the cover flickering back between every slide. The incoming slide is stacked under the outgoing one already fully opaque, and the outgoing dissolves away on top to reveal it, so the transition can’t gap no matter how the frames land
  • Carousel loops smoothly. Killed the jarring rewind when loop wraps last to first with the seam-clone technique: inert, id-stripped, aria-hidden clones of the first and last slide sit just past each end, so advancing off an end scrolls forward into the look-alike clone and then silently (behavior: auto) snaps to the real slide. The wrap reads as continuous motion instead of scrubbing back through every slide. It settles on scrollend (with a timer fallback), corrects a manual swipe that lands on a clone, and resolves an in-flight wrap when a second advance arrives
  • Carousel took a direction. right (the default), left, up, and down: the cardinal sets both the axis (horizontal or vertical scroll-snap) and the sense of advance for a slide carousel. The reverse sense (left / up) is a pure *-reverse flex track, so the seam-clone loop and all the scroll math generalize to one axis-aware offset, and the arrow keys follow the axis (Up/Down against Left/Right). A vertical track takes its height from --carousel-height (default 18rem). Moot for the stacked transitions
  • An autoplaying Carousel can be stopped, not just paused. It grows a persistent play/pause toggle in its control bar (WCAG 2.2.2), so the motion stops outright rather than only while hovered or focused, and a deliberate pause survives the pointer leaving. A polite live region announces the active slide (Slide N of M), so a screen reader hears each change even though scrolling (or a stacked cross-fade) never moves focus
  • Carousel took pause-on-hover. The transient hover/focus pause became an option (default true). Set it false when the rotation is the point and should keep running under the pointer: a decorative marquee, an ambient gallery, or a preview revealed inside an Image’s hover slot. That last one was a dead end before, because a hover-preview carousel is only visible while hovered, so the built-in hover-pause froze it on its first slide every time and there was no way to opt out. The explicit toggle and prefers-reduced-motion still stop it, so the content stays pausable
  • Carousel took controls="overlay". A third controls placement floats the prev/next arrows on the slide edges and the dots (and the play toggle) over the bottom, instead of a bar beneath the track, for an in-image gallery. The overlay layer is click-through except for the controls, so a swipe or a link inside a slide still works, and the buttons carry a surface-overlay fill and elevation so they stay legible over any photo

Components

  • Avatar derives its own initials from a userName. The manifest had advertised an initials-or-icon fallback since day one, the CSS had the uppercasing and the per-size font scaling waiting for it, and the component never had a prop to feed it, so every caller hand-typed AL into the slot. userName derives them now ("Ada Lovelace"AL, "Prince"P), splitting on code points so a name opening on an astral character keeps it whole. It’s userName and not name because name carries form-participation meaning on an element and an avatar is not a form control; slotted content still wins, and an image-less avatar is announced by the person’s name rather than by two bare letters
  • Slider took fine/coarse stepping, an editable value, and overflow. It picked up the number field’s alt-step / modifier / alt-default contract: holding the modifier (Shift by default, settable) while dragging or arrowing swaps step for alt-step, so a coarse-stepped track fine-tunes to 1° without losing its big-increment feel. The show-value readout became a click-to-edit numeric field that steps on the same keys, and overflow lets a typed value pass min/max (the thumb pins at the edge and the announced range widens to keep aria-valuenow valid). Threaded through the element, the fragment, and all three bindings
  • Dialog fits editor-class content. size="xl" (64rem) and size="full" joined the ramp, and the existing ::part(dialog) seam is documented for an exact width, so an editor or a multi-column modal is no longer crushed to the 48rem lg cap
  • Heatmap took a categorical coloring mode. categorical colors each column (or, with categoryAxis="row", each row) a distinct hue from a categorical SeriesScheme (accents / skittles / statuses) and washes it up by value, so a matrix whose axis is a set of unrelated categories reads by hue instead of one shared intensity ramp. It colors through the same seriesPalette path Bar and Pie use, so the four canonical series schemes now render across the whole metrics set; a glow halo takes its cell’s category hue, and scale swaps the ramp key for a hue legend. Threaded through the engine (categoricalHeatColors), the element, and the SSR path, so all three bindings and the zero-JS render agree
  • Radio took a description line and an option-card variant. description adds a secondary explanation line under the label (wired to aria-describedby), and card renders the radio as a full-width bordered box that takes an accent ring and a tint when selected: the title-plus-explanation option-card pattern, with selection and a11y still owned by RadioGroup
  • Tree took richer trailing content and svelte events. TreeNode.badge takes multiple toned pills now (a count beside a distinct status pill), TreeAction gained disabled (greyed and non-firing in place, so a positional control set keeps a stable count), and the @xtyle/svelte wrapper forwards the node tree-action event as an ontreeaction prop
  • Sparkline speaks its metric’s units on hover. A format attribute (percent / duration / bytes / unit) makes the hover readout say 1.5m or 87% instead of a bare number, and format and bounds surface as typed props on the svelte and astro wrappers
  • Segmented options take a title hint. A structured option renders its title as the segment’s native hover hint, so a pill row of terse labels can carry disambiguating context without a bespoke tooltip wrapper

Engine

  • The terminal / ANSI palette derives. The 20-token --terminal-* family (bg / fg / cursor / cursor-accent, plus the 8 ANSI colors and their 8 bright twins) are first-class, scheme-aware members of the register now, not ride-through extras. The six chromatic slots take fixed hue angles and sweep to clear AA on --terminal-bg; the bright tier pushes each toward the readable pole so bright and normal stay separable; black and white anchor the achromatic endpoints. Two gauntlet invariants guard legibility and mutual distinguishability, the generator’s token editor shows a live Terminal / ANSI group, and xtyle derive --format terminal emits an xterm.js ITheme
  • Scrollbars ink from the theme. A --scrollbar-track / --scrollbar-thumb / --scrollbar-thumb-hover family joined the register and emits a root scrollbar-color, so a scroll container’s bar takes the active theme instead of the browser’s default gray. The engine used to emit only color-scheme (light/dark), which is exactly why scrollbars tracked the scheme but never the theme. The thumb is a theme neutral (--line-2’s tone) over a surface-blending transparent track, scrollbar-color inherits to every scroller, and it recolors live on a theme change; the tokens are overridable like any other register entry, and .xtyle-panel’s scroll body drops its --line-2 one-off for the shared ones
  • A signed surfaceRamp knob drives the whole surface stack. One value carries the per-step lightness delta the stack walks from --bg-0, positive to ascend the ramp (each surface lighter) and negative to descend it (each darker), and --body-bg / --bg-1/2/3 / --bg-sunken all fall out of it, so flipping the ramp direction no longer means hand-pinning every surface. Unset by default and resolving to the scheme-derived direction at the standard magnitude, so an existing theme derives byte-identically
  • An algorithm owns its knob domains. A new Algorithm.knobSpecs carries every scalar knob’s kind, range, and accepted options, and the generator’s knob rail renders from it instead of from a table hardcoded in the site and keyed by knob name. The algorithm owns the domain (what a knob accepts and how it’s shaped); the consumer keeps only cosmetics (a localized label, digit precision, the “unset” wording). A dial now only shows on the algorithms that read it (hour stays with the day/night algorithm), two knobs the engine already honored but the UI never surfaced became reachable (cues, color-only against redundant non-color markers, and accentShiftStep, the accent family’s hue step), and a novel algorithm’s own knob self-renders straight from its declared spec instead of vanishing for want of a hardcoded UI entry. The domain flows through the manifest() export and the sandboxed host facade (with a resolve-from-names fallback for a spec-less mod), byte-identical derivation intact

Icon builder

  • The work-in-progress mark survives a reload. It round-trips through localStorage, so a reload reopens on the mark you were building (name, layers, locks, and finish) instead of a fresh random example
  • A saved-icon library. Save keeps the current mark in a USER CREATED row above the examples, each tile removable and the whole set persisted separately from the session restore; the filter spans both rows
  • A letter text primitive. The icon grammar gained a letter shape that draws a single glyph as a mark layer, with builder controls for the character and its typographic knobs. An outlined letter reads as a whole-shape border around the glyph rather than a stroked outline of each stem
  • The size and rotate controls take exact values past the slider. A scale past 200%, an off-step angle: the slider’s own editable value accepts it, so a precise mark isn’t held back by the slider’s ergonomic range. They ride the new Slider overflow contract rather than a bolt-on number field
  • A duplicate-layer button, so a symmetric or repeated mark starts from a copy of the selected layer instead of a rebuild
  • An icon’s outline holds a constant visual weight regardless of the mark’s scale, so a shrunk or enlarged icon keeps the same border presence instead of the stroke scaling with it

The bench

  • The theme-bench mockups are apps that actually compose the library. They had been a facade: mostly hand-rolled CSS with a few components sprinkled on, barely touching the token register, so the surface people use to judge a theme was not built out of the thing being judged. All seven were rebuilt out of real components (the email client went from roughly 10 on screen to about 78), and the accent family is wired in everywhere it honestly belongs: mail labels, news sections, CRM pipeline stages, editor file-type tags, playlist moods, pricing tiers, and the chart series (Bar and Pie had been defaulting to skittles and never touching the accent family at all). Every one of those axes is categorical, never semantic, and color is never the only signal, so a shade theme reads exactly as well as a fan one
    • two new scenes: Brand Site, a marketing landing page, and the natural home for a two-brand duo theme, and Music Player, a consumer media player, so the set isn’t nine flavors of enterprise SaaS. Code Workspace is now Editor. Nine scenes
  • One derivation per settled change, not two. A derivation is pure in its inputs, but the builder asked for the same one twice on every settled change: derive() resolved the token graph and lineage() rebuilt it from scratch just to read its edges, each paying a full OKLCH pass over all 299 tokens, with the graph inspector deriving a third whenever its tab was open. They share one cached pass now (bounded and evicting oldest-first, so a slider drag can’t grow it for the life of the page). Roughly half the work per keystroke, slider tick, and color-picker sweep (98.7ms → 51.9ms on xtyle-default), and switching algorithms no longer pays twice to tell you what changed
  • The heavy pipeline runs on a settled trail. The OKLCH derive, the lineage graph, and the synced site reapply wait for a burst of input to settle rather than firing on every event; the controls stay bound to the live state, so slider thumbs and numeric readouts are still instant
  • The changelog page reads as a single-open accordion, each version a native exclusive <details> with the newest open, so the page is a table of contents rather than a scroll

Fixes

  • Hero split never worked through the Svelte binding, and four more like it. The bindings passed "" for a boolean attribute, but Svelte sets properties on custom elements, and the element’s setter read "" as falsy and removed the attribute, so <Hero split> silently rendered a one-column hero. The same bug quietly disabled Icon spin, Carousel autoplay, Carousel loop, and Image lightbox. The Astro and HTML paths were unaffected (attributes are real strings there), which is why every demo looked right
  • Three separate status dots collapsed onto one. Badge and Avatar each hand-rolled their own circle, and Avatar’s pulse animation reached into xtyle-badge-pulse, so avatar CSS depended on badge CSS to animate a dot neither of them got from Dot. Both wear the shared .xtyle-dot primitive now and only retint it; the duplicate size ramps, the duplicate pulse rules, and the badge keyframes are gone
  • Dot sits on the line, and starts bigger than a pinhead. It inherited the surrounding line-height, so a single stray whitespace text node raised a text strut that inflated the host to a full line: a 12px dot living in a 24px box, parked off-centre inside it. The host collapses to exactly the dot now, and the size ramp steps 8 / 12 / 16 instead of opening on a 4px speck. The residual offset against adjacent text is 0.48px, which is the font’s own descender asymmetry and nothing else
  • AppShell no longer overflows behind the status bar. The shell body’s grid row was content-sized, so any rail or main column taller than the viewport-bounded shell (the nav rail on a short window, a long reference page) blew the row out and pushed every column down past the status bar: the main scroll region ran under the bar, its scrollbar cut straight through it, and the last stretch of content (and the rail’s own tail) was unreachable. The body row is clamped to its own height now and each column scrolls its own overflow, so main and the rails stay inside the shell and scroll to their true bottoms
  • A Carousel no longer paints as a pile before its runtime owns it. Until the element upgraded, a carousel was just its slides sitting in the page with no track to hold them, so they stacked full-width and then snapped into shape once the script caught up. The un-enhanced host lays out as the same scroll-snap row the built track uses, so the first paint is already a carousel and the no-JS render is a usable swipeable strip rather than a stack
  • <xtyle-image> sizes itself in a flex column. The light-DOM host carried no display: block (only its shadow :host did), so an image in a centered flex column collapsed to zero width; it matches carousel / hero / parallax now
  • A Heatmap in categorical mode types its own scheme. HeatmapScheme was still RampScheme | string[], so a perfectly valid scheme="skittles" was a type error that had been quietly failing astro check since the categorical mode landed; SeriesScheme is unioned in, and the continuous-ramp call sites narrow back to the ramp names they actually take

v0.6.0: Components Catalog

New components

  • Empty, a centered placeholder for a no-data state (no results, an empty inbox, a fresh list). It frames whatever you put in it: an icon in .xtyle-empty__media sits muted and enlarged at the top, the first heading reads as the title, a <p> as the muted body, and buttons in .xtyle-empty__actions line up beneath, all from the derived register. It renders no markup and adds no roles of its own, so the heading and the action keep their semantics
  • Rating, a rating control that reads two ways: a read-only display or an interactive input. Give it readonly and it draws a static score, overlaying a color-clipped copy over a neutral base row so a fractional value like 4.5 shows an exact half mark instead of rounding; drop readonly and it becomes a role="slider" driven by pointer drag, click, and arrow keys, with a hover preview that tracks the pointer and an optional hidden input (name) so a form submits the picked value. allowHalf opens half-step selection
    • it draws any icon, not just stars: icon takes a functional glyph, a primitive, or a full mark spec, rendered as a neutral base row under the color-clipped overlay, so a five-heart favourite or a five-bolt intensity is the same control with a different mark. colors and tone skin the filled marks off the derived register, and the empty/track marks use a legible muted token (--fg-disabled / --neutral-bg) that reads on light and dark alike
    • it announces itself once (the read-only face as role="img", the interactive one as a labelled role="slider" carrying aria-valuenow / min / max), so the score never rests on color alone. Moved Content → Controls, shipped across @xtyle/core/elements, @xtyle/svelte, and @xtyle/astro
  • Steps, a horizontal step indicator for a linear flow (checkout, onboarding, a multi-part form). It decorates a semantic <ol> of steps and splits them by a current index: everything before it is done (an accent-filled marker with a check), the step at it is current (an accent-outlined marker flagged with aria-current="step"), and everything after is upcoming (a muted, numbered marker), with a connector track that fills in behind the markers up to the current step
    • state never rests on color alone: a done step shows a check glyph and the current step is outlined and emphasized, so progress reads for a color-deficient user (WCAG 1.4.1). Standalone like Table, shipped across @xtyle/core/elements, @xtyle/svelte, and @xtyle/astro
  • Timeline, a vertical activity feed. Wrap a semantic <ol> of <li> events and it decorates them: each item grows a themed dot on a connector rail that runs from one event to the next and stops at the last. It renders no markup of its own beyond the classes it adds, so the list stays the source of truth for order and content, and assistive tech hears a plain ordered list
    • inside an item a <strong> reads as the title, a <time> as the timestamp, and a <p> as the body (or the matching xtyle-timeline__title / __meta / __body classes), so a consumer writes ordinary semantic HTML and the dots, rail, and type hierarchy all draw from the derived register
    • standalone like Table (no runtime needed to render), shipped across @xtyle/core/elements, @xtyle/svelte, and @xtyle/astro

Accessibility

  • The scrollable Code block shows a focus ring too. A code block whose lines run wider than the frame becomes keyboard-scrollable and paints a focus outline, the same treatment Table got, so arrow keys can pan it (WCAG 2.1.1 / 2.4.7); a wrap block soft-wraps instead and is never a scroll stop
  • The scrollable Table region shows a focus ring. A table wide or tall enough to scroll turns keyboard-focusable so arrow keys can pan it; it now paints a focus outline when focused, so a keyboard user can see where they’ve landed (WCAG 2.4.7). Before, the region took focus silently
  • The non-color selection cue reaches Swatch. When a theme sets --selection-cue: marker (a high-contrast or redundant-cues algorithm), a picked swatch keeps its accent ring and gains a second neutral outline around it, so selection reads by an added ring rather than the accent hue alone (WCAG 1.4.1). It joins Tree, Tabs, Segmented, and Pagination, which already honor the cue
  • The non-color selection cue reaches Carousel. Under the same --selection-cue: marker, the active pagination dot elongates into a pill beside its accent fill, so which slide is current reads by shape and not the accent hue alone (WCAG 1.4.1). A dot has no room for a check glyph, so it takes the shape-cue treatment Pagination uses; it rounds out the selection-bearing set alongside Tree, Tabs, Segmented, Pagination, and Swatch
  • Spinner, Skeleton, and the Button loading spinner self-guard against reduced-motion. Their looping animation always stopped under prefers-reduced-motion via the global base reset, but each now also carries its own guard so a consumer who adopts a single component’s CSS in isolation, without the base layer, still gets a still animation for a motion-sensitive user (WCAG 2.3.3). It brings the three in line with the eight other animated components that already self-guard

Image

  • The lightbox opens against the viewport, not its container. <xtyle-image lightbox> now builds its modal on document.body instead of inside the element, so an ancestor with transform, filter, backdrop-filter, will-change, or contain (a frosted panel, an animated card) can no longer become the dialog’s containing block and drag it off-center. The dialog carries its own styling, so it stays themed outside the shadow root, and it tears down with its host
    • fixed in the same pass: a closed lightbox kept its display: flex (an author rule outranks the browser’s own closed-dialog hide), so it lingered as an invisible full-viewport box that swallowed clicks; the closed state is hidden explicitly now
  • A trigger for where the zoom lives. trigger="frame" (the default) keeps the whole image as the zoom target; trigger="button" moves it onto a dedicated zoom button that reveals on hover and focus, so click-to-zoom sits beside selectable text without fighting it
    • the button is a real <button> (keyboard-focusable, Enter or Space), stays visible on touch where there is no hover, and keeps a ring and a lift so it holds its edge over any image instead of dissolving into a dark photo
  • A maximize glyph joined the functional icon set, the diagonal-expand mark the zoom button draws; it rides as a symbol-maximize primitive in the icon builder too
  • A standalone, delegated lightbox for images no component rendered. The per-<Image lightbox> case only covers images the component drew; a marked/CMS body injected as {@html}, or a gallery of mixed sources, couldn’t share it. Now one <xtyle-lightbox> mounted once opens any [data-xtyle-lightbox] element in its scope (the whole document, or a scope selector’s subtree), and openLightbox(src, { alt, caption }) from @xtyle/core/elements drives the same dialog from any handler. One controller, one dialog, every image source, and <Image> itself now routes through it instead of building its own
    • a non-interactive trigger (a bare <img data-xtyle-lightbox>) is promoted to keyboard-operable (role="button", a tab stop, an aria-label from the image alt) and opens on Enter or Space, so the delegated path is never mouse-only. data-lightbox-src / -alt / -caption override the resolved source for a full-res image behind a thumbnail, and the lightbox now renders an optional caption beneath the image

Metrics

  • Sparkline gained kind-aware bounds so a typed metric ranges itself. bounds="percent" pins [0, 100], bounds="unit" pins [0, 1] (a fraction or a bool), and bounds="duration" caps at a rolling power of two so a latency spike lifts the ceiling instead of squashing the baseline. It’s the per-kind range every consumer of a typed metric would otherwise re-derive by hand at each call site; an explicit min/max still overrides it, and the pure resolveSparklineBounds is exported for computing the same range yourself
  • A filled occupancy variant for a binary series. variant="occupancy" fills each “on” sample of a bool series as a solid block over a faint track, the uptime / presence / connection-state reading a 0/1 step line can’t give; an all-off strip stays empty rather than collapsing to a solid block. Pair it with bounds="unit" for a clean threshold

Components

  • Card took a depthStrength knob for how far the surface lifts off the page. depthStrength="sm" | "md" | "lg" (default md) tunes the shadow weight: sm is a whisper (a soft custom shadow), md is a newly eased default, and lg restores the previous heavier weight. In the same pass the resting shadow eased from --elevation-2 down to --elevation-1 and the interactive-hover shadow from the heavy --elevation-4 down to --elevation-2, so a default card sits lighter on the page than it used to; an interactive card still bumps one step heavier on hover. Threads through @xtyle/core/elements, @xtyle/svelte, and @xtyle/astro
  • Progress absorbed the standalone gauge and grew a unit suffix. A separate Meter was briefly its own component, but Progress already carried the same meter role, threshold zones, and pulse, so the gauge was retired back into it; the one thing it lacked, a unit prop that trails the value / value-max readout with a suffix (910/1000 GB), landed with it. Tagged meter / gauge / capacity keywords so a capacity gauge is discoverable as Progress and never gets rebuilt as its own component
  • A Progress fill can color by its own value with a ramp. ramp colors the fill along a scale instead of the flat tone, so a fuller bar reads hotter: a built-in scheme (accent / thermal / status), a JSON array of stop colors, or a comma-separated stop list. rampMode="solid" (the default) samples one color at the current value off the live cascade (needs the runtime); rampMode="gradient" paints the whole scale as a pure-CSS sweep clipped to the fill (zero-JS, SSR-safe, linear only), and reverse flips it end for end. The ramp tracks the theme’s own hues, and the pure rampColor / rampGradientStops helpers are exported from @xtyle/core
  • The standalone status dot reads as live in three composable ways. .xtyle-dot kept only an opacity breathe; it now also takes --ping (an expanding ring, a stronger status light), --glow (a soft halo), and an inline --dot-color escape hatch for an arbitrary per-state color the tone classes can’t name. The three compose, and all hold still under prefers-reduced-motion
  • Table exports its part-class names as the typed tableParts map. A framework that classes its own rows at author time (skipping the runtime decorate) no longer hard-codes "xtyle-table__row" string literals: tableParts.head / .row / .cell / .headerCell / .body come from @xtyle/core, the same constants the decorator itself writes, so a part rename is a compile error instead of a silent style regression

Engine

  • auditRegister takes a consumer’s own contrast pairs. A theme whose token contract differs from xtyle’s canonical surfaces (status inks read on --bg-0, not each tone’s soft tint) passes opts.pairs to grade what it actually renders, with an optional per-pair label. The canonical set is exported as data via canonicalContrastPairs() (and the PairSpec type), so a consumer extends it. A QuickJS/xript sandbox that can’t import from @xtyle/core inlines the small list instead and pairs it with the equally-pure contrast() to run the same grade the frontend runs
  • The chart component types resolve from the package root. SparklineVariant / SparklineTone / SparklineBounds, PieDatum / PieScheme / PieVariant, BarSeries / BarScheme, HeatmapScheme, and ImageFit / ImageRadius / ImageLoading are now exported from @xtyle/core proper, not only its subpaths, so the Svelte and Astro wrappers (and any consumer) get real types for those props instead of a silent any
  • The icon color grammar went to a nine-slot hex-nibble palette with per-slot overrides. A mark’s colors read off 16 nibble slots now: c0 clear, c1c9 the nine series colors, and the semantic chrome, ca the active ink, cb --bg-0, ce --neutral-bg (the neutral track an unfilled Rating or Progress groove shows), cf --fg-0, with cd held in reserve. ICON_SERIES_COUNT is 9, so every scheme fills nine slots: skittles finishes its crayon box with a new --brown, and a smaller scheme cycles its own
    • a ---pc{n}-{hex} finish overrides a single slot and ---pc-{hex} recolors the whole silhouette, both resolved at compose time; the value takes a hex, a palette nibble, or a token name (so an override stays theme-reactive), including hyphenated tokens like neutral-bg, since finish flags delimit on --. SLOT_TABLE is exported as the blessed default palette for tooling and third-party mark work
  • 11 new draw-primitives joined ICON_PRIMITIVES. Filled curves (half, quarter, wedge, oval, pill, drop, pentagon) and open pen-strokes (line, arc, corner, vee), each keyworded and tagged since: "0.6.0". The strokes take a layer’s c color through currentColor the way the frames do, and every primitive rotates about the grid center, so a wedge tiles a pie and a line-r45 is a diagonal rule. They surface in the Icon Builder bench palette (a new Strokes group; the curves fold into Shapes) and are documented in icon-name-grammar.md
    • symbol-heart was redrawn from cubic Béziers so the lobes round cleanly and the upper seam is gone; the old lobe arcs had chords longer than their own diameter, which forced flat near-semicircles
  • Every component manifest carries keywords and seeAlso now, so a component is findable by what it does. All 65 manifests gained capability-synonym keywords and related-component seeAlso: the xtyle_components MCP list returns them (an agent resolves meterProgress or kebabMenu by capability instead of guessing the tag), each reference page grew keyword chips and a Related section, and the components index gained a client-side capability search

Fixes

  • A plain Card no longer paints a phantom divider or reserves phantom spacing. The card fragment always emits .xtyle-card__header and .xtyle-card__footer wrappers even when those slots go unused, and an empty footer still drew its border-top (a hairline ruling off to nothing) while both empty wrappers held layout gap. .xtyle-card__header:empty and .xtyle-card__footer:empty collapse to display: none now, so a card with only body content shows no stray line above the fold and no stray gap where a header or footer never was
  • Alert / Toast centers its icon on a message-only notice. The alert laid its icon out at the flex default align-items: stretch, which pinned the icon to the top: right for a titled alert whose icon should sit on the first line, wrong for a message-only notice that wraps to two lines, where the icon then read as floating too high above its text. A message-only alert now centers the icon against its (possibly wrapped) text and top-aligns only when a title or actions are actually present (:has(.xtyle-alert__title:not(:empty)) / :has(.xtyle-alert__actions:not(:empty))). Same empty-wrapper cleanup as the card: the always-emitted-but-empty .xtyle-alert__title / .xtyle-alert__actions wrappers collapse so they add no phantom gap either
  • A left dock no longer bleeds past its rail in AppShell. A dock inside a shell rail is constrained to the rail width now, so a size="sm" dock stops overhanging its default 14rem or cutting its right border 24px into the main pane
  • A color-only Alert / Toast hides its icon in both render modes. A shadow-only :host(:has([slot="icon"])) rule quietly re-showed the icon when an icon-suppressed alert also carried an explicit slotted icon, a contradictory pair that behaved differently on the site (where the rule can’t match); dropped it, so an icon-suppressed banner stays icon-free everywhere. The suppression is automatic: a notice with no severity (a color-only tone) carries no icon, slotted or not. To show a custom icon, give the alert or toast a severity (or a severity-matching tone) and project it into the icon slot
  • wrap, line-numbers, and highlight work on server-rendered Code again. Their CSS keyed on :host([…]), which only matches a shadow host, so on the site (the light-DOM path) a wrap block didn’t wrap, the line-number gutter stayed empty, and highlight did nothing. Each state selector now carries a light-DOM twin, so the three land in both render modes, and the copy button reveals on hover on the site too

v0.5.0: The xtyle Rename

Renamed: xoji is now xtyle

  • The engine is renamed from xoji to xtyle. xtylestyle — is the truer name for what it is: the tokens, themes, and component derivation the engine has always produced. The code, the algorithms, and the token contract are unchanged; only the name is.
    • Packages: @xoji/core@xtyle/core, alongside @xtyle/astro and @xtyle/svelte; the CLI is now xtyle (xtyle derive, xtyle gauntlet, xtyle coverage, …)
    • Tokens & markup: the custom-property prefix --xoji-*--xtyle-*, component tags <xoji-*><xtyle-*>, and the BEM classes and data- attributes with them
    • API surface: makeXojiAlgorithm / defineXojiAlgorithmmakeXtyleAlgorithm / defineXtyleAlgorithm, the XojiElement base and every element class, and the Xoji*Algorithm types
    • Batteries: the four built-in algorithms xoji-default / -hc / -quiet / -loudxtyle-* (the nxi-nite battery keeps its own name)
    • The rest: the MCP server and its xtyle_* tools and xtyle:// URIs, capability ids, storage and event keys, and the .xtyle.json manifest suffix
    • Home: the site and docs moved to xtyle.dev
  • Migrating off the pre-release @xoji line: swap @xoji/@xtyle/ in imports, --xoji---xtyle- in any hand-written CSS overrides, and <xoji-*><xtyle-*> in markup. The old @xoji/* packages (0.1–0.4) are superseded by @xtyle/*; their notes below stay as they shipped.
  • xript is untouched — the extensibility protocol the algorithm mods run on is a separate product and keeps its name.

v0.4.0: Icons & Media

Icon builder

  • composeIcon, a first slice of the programmatic icon builder in @xoji/core: it assembles a layered mark (a crest, a badge, a composite glyph) from a small instruction set, the same way an algorithm assembles a token register from anchors. The composition is the cheap materialized artifact; the compose engine and the primitive library are the durable asset
    • a composition is a back-to-front list of layers, each placing a named primitive with an optional transform (scale, rotate, and translate about the grid center) and a color; an unknown primitive renders a visible placeholder rather than nothing
    • ICON_PRIMITIVES seeds the library with shapes (shape-circle / shape-shield / shape-square / shape-hex), frames (frame-ring / frame-border), and the functional glyphs from the icon set as symbols (symbol-check, symbol-warning, …), so the primitive set the components already ship doubles as the builder’s vocabulary
    • coloring resolves three ways: a literal, a theme token (--accent), or a series slot (series:2) off a scheme; with a derived register the token and series colors bake to concrete values for a static export, and without one they emit var(--…) so the mark re-colors live with the theme, the same dual-entry split the rest of the engine uses
    • grew the primitive library toward a fuller vocabulary: bands (bar-top / bar-row / bar-column / bar-diagonal / bar-cross) that stripe across a shape, a shape-diamond, and decorative symbols (symbol-star / symbol-heart / symbol-crescent / symbol-bolt) alongside the functional glyphs
  • The icon name grammar, so a mark’s spec is its name. parseIconName reads a terse, human- and machine-writable string (dice-3--square3-c3--dot-p7-s14-ko--dot-s14-ko--dot-p3-s14-ko) into a composition, and <xoji-icon name="…"> composes it on the fly when the name isn’t a functional glyph. Baked-or-generated, the same split the rest of the engine uses: a known glyph is a lookup, everything else generates. The full grammar is recorded in docs/icon-name-grammar.md
    • an object is a primitive keyword plus flags in any order: a phone-keypad grid position (p1p9) with fine offsets (x / y), size as a percent of the grid (s), rotation (r), fill and outline colors off a ladder (c / o), opacity (a), flips (fh / fv), and a ko knockout that punches the shape through the art beneath it; rounded corners are an indexed shape (square1 / square2 / square3) and a trailing --- finish carries whole-mark modifiers like a drop shadow and per-layer locks
    • the keywords cover the shape primitives (square, shield, hex, star, bolt, …) and the single-token functional glyphs as charges by their bare name, so a check badge is check-badge--circle-c5--check-s55-c1
    • rounded out the primitive set with a triangle field and a divider rule (a bar that rotates to vertical with r90), so shapes / frames / bands / dividers / symbols all have a seed
    • the color ladder reads one scale (c0 transparent, c1 --fg-0, c2 --bg-0, c3+ the theme’s series), so a mark colors from the register and the colors attribute re-skins the same spec across schemes (accents, skittles, statuses, …); an uncolored object inherits currentColor and renders like a flat glyph
    • composeIcon grew to match: grid placement, in-place rotation, stroked outlines, flips, knockout via nested SVG masks, and a rounded-rect clip; composeIconThemed bakes only the series slots against the live cascade and leaves token fills as var(--…), so a generated mark recolors with the theme for free
    • the generator is swappable, not law: registerIconGenerator adds an alternative name→mark generator (a hashed-identicon mode, a domain-specific set) tried after the blessed grammar, and <xoji-icon> resolves a name through the registry rather than a hardwired parser, so the mechanism (the primitive library and composeIcon) stays in the engine and the opinion (how a string becomes a mark) stays swappable
    • <xoji-icon name="…"> generates live and bakes the SVG for SSR across html / svelte / astro; the Bench’s icon forge (below) is the authoring surface that emits these names, and a baked-file cache tier for the resolution cascade is still ahead

Icons

  • <xoji-icon>, a new primitive that renders one glyph from a built-in functional set as inline SVG. It carries no color of its own: every glyph draws in currentColor, so an icon inherits the text color around it and matches the derived theme with nothing to wire, and an optional tone tints it to a semantic role or named hue off the token register
    • the set ships 29 glyphs, the ones the components already reach for: the four chevrons and a chevron-expand, the four arrows, close, check, plus, minus, menu, more-vertical / more-horizontal, search, the status marks info / warning / error / success, external-link, dot, a loader, and the media-transport family play / pause / stop / skip-forward / skip-back (filled shapes in currentColor), so a run / execute / start-stop control renders in one voice instead of dropping to a bare text char beside real glyphs
    • size steps it in fixed em (sm / md / lg / xl) or omit it to match the surrounding text; spin turns the loader into a lightweight busy indicator that honors prefers-reduced-motion; and it is decorative (aria-hidden) by default, promoted to a named role="img" the moment you give it a label
    • the glyph set lives in @xoji/core as a name-to-SVG map with a renderIcon helper, so the engine can produce an icon on its own (the same source the component fragment and the Astro SSR path both render through), and an unknown name renders a visible placeholder box rather than silently vanishing
    • ships across html / svelte / astro, where the Astro path bakes the SVG into zero-JS light-DOM markup and composes cleanly inside a Button (an icon-start / icon-end control, or an icon-only button) and inline in Text

Media

  • <xoji-image>, a responsive image in an aspect-ratio frame. Give it a ratio and the frame reserves the space so the page never reflows as images load, a shimmer placeholder fills the frame until the pixels arrive, and the image fades in on load
    • progressive enhancement throughout: with JavaScript off the image renders at full opacity with no shimmer to hide it, and both the blur-up and the lightbox layer on top only when the runtime is present, so the static markup stays inert and the image is never trapped behind a script that failed to run
    • an opt-in lightbox makes the frame a control that opens the full image in a top-layer native <dialog>, with a --scrim backdrop, backdrop and Escape to dismiss, platform-handled focus trap and restore, and a close button drawn with the new close icon
    • fit (cover / contain), radius off the scale (none / sm / md / lg), an optional caption rendered as a figcaption, native loading="lazy" by default, and a muted warning-glyph fallback when an image fails to load
    • ships across html / svelte / astro (the Astro path bakes the frame as zero-JS light-DOM markup) and drops into a Card media slot or a Grid gallery
  • <xoji-carousel>, a scroll-snap track of slotted slides. Each child is a slide and the browser’s own scrolling does the paging, so the track is swipeable and keyboard-scrollable with no JavaScript at all
    • when the runtime is present it grows a control bar: prev/next buttons drawn with the chevron icons, a row of pagination dots that track and drive the active slide (off an IntersectionObserver), and arrow-key plus Home/End navigation, all wired over the same native scroll
    • an opt-in autoplay advances on an interval, pausing on hover and focus and standing still under prefers-reduced-motion; loop wraps the ends; controls / dots toggle the chrome
    • content-agnostic (slides can be Images, Cards, or any markup) and a standalone light-DOM element like Table, so framework content stays live; it exposes a labelled carousel region with each slide named N of M for assistive tech, and tears down its observer, timer, and listeners on disconnect
  • <xoji-parallax>, a layered banner with scroll-driven parallax. CSS stacks every child into one grid cell, so the layers overlay and the content centers over the background with no JavaScript at all
    • a layer with a data-speed drifts vertically as the banner scrolls through the viewport (the faster the speed, the deeper the drift), driven off a requestAnimationFrame-throttled scroll listener; a layer with no data-speed is the still content on top
    • the motion is pure enhancement: with JavaScript off the layers sit at rest, and under prefers-reduced-motion the runtime never wires the scroll at all, so the banner is always a legible layered composition; min-height sizes the band and amplitude scales the travel
    • each moving layer picks a travel axis with data-direction, a compass token (n / s / e / w and the diagonals ne / nw / se / sw) or an angle in degrees clockwise from north, so layers drift up, sideways, diagonally, or opposite one another; the default stays vertical, so a bare data-speed layer is unchanged, and a negative data-speed reverses one layer
    • mode="cursor" swaps the scroll driver for the pointer: the layers push away from the cursor for a depth tilt (full 2D, or locked to a layer’s data-direction axis) and ease back to center when the pointer leaves; still static under prefers-reduced-motion
    • amplitude takes a negative value to flip the whole banner’s direction at once, composing with a layer’s own data-speed sign
    • the scroll driver listens in the capture phase now, so the effect tracks an ancestor scroll container (an app-shell main region), not just the window; it used to sit frozen inside a scrolling app region because scroll doesn’t bubble
    • shipped as a standalone light-DOM element across html / svelte / astro, tearing down its scroll listener and pending frame on disconnect
  • <xoji-hero>, the top-of-page band, assembled from the primitives you already have: drop an Eyebrow, Heading, Text, a Cluster of Buttons, and maybe an Image inside it and they stack into a centered hero with a comfortable measure and generous spacing
    • a thin presentational element with no behavior and no chrome of its own, so it inherits the page surface and pairs cleanly over a Parallax band; the layout is pure CSS, identical with JavaScript off
    • align switches the stack from centered to left-aligned, and split turns it into a two-column content-and-media band that folds back to one column on a narrow screen
    • completes the media family (Image, Carousel, Parallax, Hero) across html / svelte / astro

Metrics

  • <xoji-heatmap>, a new metrics primitive: the activity-grid / punch-card shape. A dense values matrix renders as a grid of cells, each colored by its value on a theme-derived intensity ramp, so a contribution-graph / runs-per-hour view derives in step with the rest of the chrome. The ramp is accent by default (a faint-surface-to-accent wash), with thermal, status, or an explicit stop array to swap the scale, and every cell normalizes against the data max or an explicit max so two grids share one scale. Cells are focusable tab stops with a floating value readout, the grid mirrors into a visually-hidden data table for assistive tech, and showValues prints each number. selectable makes cells role="button" that fire a select event ({ row, col, value, rowIndex, colIndex }) for a drill-in, composing with the same seam Bar carries. Ships across html/svelte/astro (the Astro path bakes the cell colors into zero-JS markup), on a new rampColor engine helper, the continuous sibling of seriesPalette that maps a scalar 0..1 intensity to an OKLCH-interpolated color
    • an optional second glow matrix carries a second metric as a per-cell halo, so one grid shows two signals at once (fill by run count, glow by total runtime): a cell can be dim-but-glowing or bright-but-quiet. The halo takes the ramp’s hot-end color off the live theme and scales its blur with an independently-normalized intensity, on a new glowFilter engine helper beside rampColor
      • glowMax shares one glow scale across grids and glowBlur tunes the halo’s reach (lower it on a dense contribution-graph grid so neighboring halos don’t merge); the glow stays honest for assistive tech: glowLabel names the metric so each cell’s value folds into its accessible name and hover readout, and the second metric is never visual-only
    • a scale key reads a cell’s shade as a magnitude without hovering: five swatches sampled from the same ramp as the cells, sitting between the 0 and max value endpoints, the contribution-graph’s own low-to-high legend. Threads through all three bindings (the Astro path bakes the swatches into zero-JS markup) on the existing chrome tokens, so coverage is unchanged
    • a current-cell marker and per-cell titles, the two things an activity-over-time grid needs. current takes a [rowIndex, colIndex][] list and rings those cells (the “you are here” on a time grid: the live hour, today’s column), currentPulse breathes the ring so a live position reads as live (honoring prefers-reduced-motion), and currentTone tints it to a semantic tone. titles, a matrix parallel to values, gives a cell a fuller hover readout (a full timestamp, a breakdown) that becomes the cell’s accessible name too, so the richer detail is never mouse-only; an untitled cell keeps the default row/column/value readout. Both are data-driven, so they bake into the zero-JS Astro markup and re-resolve live, and the ring tones are register tokens, so coverage stays honest
  • <xoji-sparkline> grows a time-windowed mode for a real time-series. Alongside the even-spaced values, it now takes points (timestamped samples, at as epoch ms or a date string) mapped onto a sliding window (default 5 minutes) or an explicit domain, so irregular samples sit at their true position and slide left over real time instead of the x-axis pretending they were evenly spaced. A step flag draws the line as a sample-and-hold for an on/off signal, and the hover marker snaps to the nearest sample by time. The element resolves the window against the live clock and drops samples that have aged off the left; the astro path bakes the SSR frame against the build clock and the upgraded element re-resolves live. All three bindings carry it
  • <xoji-bar> bars can be made actionable. A selectable bar chart turns each bar into a role="button" that fires a select CustomEvent on click or Enter/Space, its detail carrying { series, category, value, seriesIndex, categoryIndex }, so a dashboard chart drives a drill-in (click a bar to filter a table, open that category’s detail, route to a filtered view) instead of reaching through the shadow boundary to wire the raw SVG rects. It joins the library’s existing select vocabulary (Tree, Menu, Segmented, Breadcrumb). Opt-in and honest: a read-only chart keeps its bars as focusable role="img" data points with no pointer cursor. @xoji/svelte surfaces it as an onselect callback, and the zero-JS Astro path bakes the button role so the markup is right before hydration
  • statuses, a categorical series scheme for discrete-outcome charts. The chart scheme shipped a sequential status ramp (success→warn→danger) but no way to pin discrete categories to distinct semantic tones. statuses does exactly that: each slice takes its own theme tone in a stable order (--success, --danger, --warn, --info, --neutral, --accent), so a run-outcome breakdown (passed, failed, flaky, running, skipped, live) reads by meaning instead of an interpolated scale
    • a categorical scheme like accents and skittles, so it cycles past six categories and reverses; all six are register tokens the theme already derives, so it adds nothing to the algorithm and sits beside the sequential status ramp rather than replacing it
    • the SeriesScheme type now documents that a var(--token) string in an explicit scheme array lands straight in the SVG fill and resolves against the theme, the clean way to pin exact or theme-token colors; the pie reference page grew a Discrete outcomes example and a six-slice demo donut
    • a PieDatum / BarSeries can carry a semantic tone (success / failed / warn / info / skipped / live) that statuses maps by name, so a chart colors by meaning even when a category is absent. Without it the scheme assigns by position, so a run where nothing failed drops the failed slice and slides every survivor to the wrong tone; a tone pins each slice to its outcome regardless of which categories are present. Backed by a named STATUS_TONES map and a seriesColorsFor helper on @xoji/core (the by-name companion to seriesPalette), wired through the pie/bar elements and the zero-JS Astro bake, with a side-by-side clean run donut on the reference page proving the survivors hold their colors
  • Every metrics chart shows a No data state instead of an empty frame. A Pie (empty set or all-zero values), Sparkline, Bar (no categories or series), or Heatmap (empty matrix) with nothing to plot now renders a muted No data message where the marks would be, rather than a bare frame or a blank box, so a no-data period on a dashboard reads as intentional rather than broken. The message is the chart’s accessible name too (honoring a supplied label), and the donut variant drops its center total when empty so the two can’t overlap. Built on the register’s own --fg-2, so coverage stays honest
  • Breadcrumb crumbs can drive app state, not just navigate. A BreadcrumbItem takes an optional value: a non-current crumb with a value and no href renders as a real <button> and the element fires a select event carrying { value, index } on click or Enter/Space. So an in-app trail (clicking an ancestor selects that node instead of following a URL) adopts Breadcrumb directly instead of dropping to the custom-crumb slot; href still wins when both are set, and @xoji/svelte surfaces an onselect callback
    • the slotted escape hatch is sturdier too: the runtime fill projected consumer crumbs through a <span> wrapper, which put <li>s inside a <span> inside the <ol> (invalid nesting) and kept the list’s own flex layout from reaching them. The fill now projects the <slot> directly into the <ol>, matching the SSR path, and ::slotted() rules bridge the reachable layer (a shadow-DOM note in the manifest spells out that deep slotted styling can’t cross the boundary, so items mode is the route when you want the component’s styling on every crumb)
    • a BreadcrumbItem also takes an optional title, rendered as the crumb’s hover tooltip so an abbreviated, truncated, or glyph label (a shortened file path, an ID-clipped entity trail) keeps its full value reachable. It rides as the crumb’s accessible description rather than overriding its name, so the visible label stays the accessible name while the detail still reaches assistive tech, closing the last gap between the slot escape hatch and data-driven items

Form

  • NumberInput honors step="any" for a free-form field. With step="any", typed values commit verbatim (no grid snap, no six-decimal precision cap) and, with no min/max, stay unbounded, exactly the native <input type="number" step="any"> contract. So a field holding an arbitrary literal (a graph parameter, a coordinate, a scientific value like 1e-3) adopts NumberInput instead of dropping to a bespoke native input; the steppers keep working, falling back to a whole-number nudge that preserves the fraction. Stepped fields are unchanged; the snap/clamp math moved into a pure, tested clampNumber helper
  • Radio takes bind:group in @xoji/svelte. A group prop (bindable) drives the idiomatic Svelte radio-group binding: each radio is checked when group equals its value, and selecting one sets group, so a group of choices is <Radio bind:group={choice} value="a" /> instead of hand-wiring checked and an onchange on every option. A standalone radio keeps its own two-way checked. Surfaced while dogfooding a settings surface built end to end from the form controls, where the group had no idiomatic binding

Components

  • Avatar grew a pulse for a live / online presence. A pulse attribute breathes the status dot on a soft opacity loop, so an avatar reads as live in real time: a bare pulse runs the slow cadence, pulse="fast" the quick one, both on the Badge dot’s own keyframe and easing, so no new keyframe and no new token. It is a no-op without a status dot to animate and holds still under prefers-reduced-motion, and the live meaning must ride on statusLabel, never the motion alone. Rendered through the one avatar fill so all three bindings carry it, with a typed pulse prop in @xoji/svelte and @xoji/astro and a Live presence demo card
  • <xoji-avatar-group>, the overlapping-avatar stack. Slot a set of Avatar children and the group overlaps them into a compact row, the shape a contributor list, an attendee strip, or a reviewer cluster takes; each avatar carries a ring in the page background (--bg-0) so the faces read as distinct rather than a blur, and later avatars sit over earlier ones. Set overflow to the number of people beyond the shown set and it caps the row with a +N chip in the neutral tone (its accessible name reads “N more”); size matches the chip to the avatars, spacing (snug / normal / loose) tightens or loosens the overlap, and a label names the role="group". Because the faces overlap, hovering or focusing a covered avatar raises it above its neighbours so its full face reads. The overflow count is explicit rather than auto-counted, so the stack renders identically with no JavaScript, on the server, and in the browser (the light-DOM path keeps the adopted avatars in place and only re-classes the row). Ships across html / svelte / astro with a manifest, a reference page, and a demo

Bench

  • The icon forge, the Bench’s interactive authoring surface for the icon builder. Compose a mark from stacked primitives on a keypad grid, and the name box round-trips the icon-name string both ways, so building a mark and pasting a name are one act
    • each layer carries an inspector: grid position, size, rotation, fine offsets, a color off the ladder, outline, opacity, flips, and knockout, plus a per-property lock that Randomize and Reset both honor, and the whole mark takes a drop-shadow finish
    • an examples gallery seeds it and the builder opens on a random example each visit; Reset returns each layer’s unlocked properties to their defaults (honoring locks, the way Randomize does), and Clear empties the mark
  • SVG and PNG export off the composed mark. The mark downloads as a self-contained SVG (series and token colors baked to concrete values, currentColor pinned, no var(--…) left to inherit) or a 512px PNG rasterized through a transparent canvas so it keeps its alpha, drop-shadow finish and all; the name copies too, full or stripped of its --- lock flags for a clean production name
  • The theme studio composes from the Button it ships. Its toolbar (New, Save a copy, Reset, Delete, Library, and the export tab’s Import / Share / Copy / Load) was hand-rolled raw <button>s with bespoke styling; every one is the shipped @xoji/svelte Button now, carrying its own tones (warn for Reset, danger for Delete), so the flagship theme surface runs on its own components

Engine

  • hostedAlgorithm(id) makes the sandboxed mod the synchronous default. The canonical resolution path (resolveAlgorithm) is async, so a synchronous caller reached for the baked getAlgorithm registry and never touched the shipped xript mod. hostedAlgorithm(id) (on @xoji/core/algorithms) closes that: it returns the resolved hosted mod once its cache is warm, and on the cold first call it kicks off the resolve and returns the byte-identical baked oracle to bridge the async load, so the next call is canonical with no await. The site’s imperative theme derivation (applying a saved theme, rendering theme thumbnails) now derives through it, so the mod that ships is what runs. Baked stays only as the reactive-first-paint bridge and the byte-identical test oracle
  • The component host manifest documents its slots now. All 54 component fill slots in the host manifest carried no description, so a mod author reading it couldn’t tell what each slot overrides. Each now describes its component’s fill (sourced from that fragment’s own manifest), so the host passes xript lint clean, and a test guards every slot for a description and a capability
  • The importable surface reaches the canonical sandboxed algorithm now. @xoji/core/algorithms gained resolveAlgorithm and snapshotAlgorithm: a plain import { resolveAlgorithm } from "@xoji/core/algorithms" loads the algorithm’s shipped xript mod through the zero-authority sandbox and derives through the same mod the CLI and site run, proven byte-identical to the baked build across every algorithm. getAlgorithm stays beside it as the synchronous baked oracle and the first-paint fallback while an async resolve is still in flight. The resolver reads the mod bundle embedded in the package, so it works from a plain install with no repo layout
  • Seeding a theme from a color got friendlier, and mis-seeding fails loudly. derive now takes an anchors shape ({ bg, fg, accent }) alongside the raw constraints map, folding it into the same pinned-token channel (accent--accent, and so on) so derive(algorithm, { anchors: { accent: "#7c5cff" } }) just works. A seed passed through a shape the engine doesn’t read ({ seeds }, { inputs }, a typo) used to be silently dropped, returning a register built from the default accent; it now throws a TypeError naming the real channels instead of handing back a plausible wrong theme
  • constraintsFrom is exported from @xoji/core. The helper that builds a pinned-token map from { bg, fg, accent, overrides } is now on the main entry beside derive, so the explicit seed path is self-sufficient without reaching for a subpath
  • auditRegister, a register-level contrast audit: the consumer complement to the algorithm-level gauntlet. Where the gauntlet fuzzes an algorithm’s invariants across random seeds, auditRegister(register, opts?) grades one materialized theme against xoji’s own canonical text/fill pairs and returns a per-pair { pair, fg, bg, ratio, tier } (AAA / AA / fail) plus tallies, so a theme editor, an a11y linter, or a CI gate reads a ready contrast report instead of re-encoding the token contract by hand. The pair list is xoji’s knowledge, versioned with the tokens: the body-text ramp and the neutral / brand inks on the page base and on both panel surfaces, each tone’s on-fill text, and the status inks on their own soft tint, every token graded on the surface it actually sits on rather than a naive foreground×background cross that cries wolf. opts takes a pass level (AA / AAA) and a largeText floor, and a partial register is graded on the pairs it has without throwing. Reaches three ways: the importable API, a xoji audit CLI command (exit 1 when a pair misses the level, for a CI gate), and an xoji_audit MCP tool beside xoji_coverage; the Bench’s own contrast report reads through it, so the site, the CLI, and the tool all agree on one pair list
  • argbToRgbHex, a blessed converter for alpha-first ARGB colors, and a loud contrast contract. contrast(fg, bg) reads CSS hex, where an 8-digit value is alpha-last (#RRGGBBAA); an alpha-first #AARRGGBB (the Material Color Utilities / Android Color.toArgb() / Jetpack Compose serialization) is byte-identical to a valid #RRGGBBAA, so it used to parse silently wrong: the alpha byte became the red channel and the returned ratio was plausible but off, the worst failure for an accessibility check. argbToRgbHex(hex) drops the leading alpha byte (passing #RRGGBB through, expanding #RGB), so a consumer whose palette comes through an ARGB pipeline has a one-call path to a CSS color before grading it. Because the two 8-digit forms can’t be told apart, guessing would just move the silent-wrong around; instead contrast and toOklchColor now document the contract explicitly (CSS hex only, alpha ignored, convert ARGB first), and the converter is the honest escape hatch

Fixes

  • Bar, Pie, Heatmap, and a generated <xoji-icon> mark no longer render colorless when a theme is applied to their subtree. These elements resolve their series / ramp palette off the live cascade with getComputedStyle, which returns nothing when the theme lands after the element mounts (a scoped apply(register, { target }) on a widget, or a first render before connection), so every segment fell back to a single flat currentColor while a :root theme worked. A shared readLiveRegister helper now re-resolves once on the next frame when the first read finds no tokens, so a scoped-themed chart or mark colors itself as soon as the theme lands; a :root theme resolves on the first read, so there is no retry and no recolor flash. Found dogfooding a metrics dashboard and a themed brand crest built from the components under a theme applied to a scoped subtree
  • A generated mark’s c1 / c2 color slots point at real tokens now. The icon color ladder mapped c1--fg and c2--bg, but the algorithm produces --fg-0 / --bg-0 (the base foreground / background), so a mark colored c1 rendered black and c2 rendered transparent instead of tracking the theme. Both slots now resolve to --fg-0 / --bg-0, so a check-badge--circle-c5--check-s55-c1 draws its check in the theme’s foreground as intended
  • The image lightbox no longer opens at zero size. <xoji-image lightbox> gave its full-image dialog a max-width/max-height only, so the modal shrink-wrapped to its content, and an image with an intrinsic ratio but no intrinsic size (an SVG with a viewBox and no width/height) collapsed the dialog to 0x0, opening an invisible lightbox. The dialog now takes a definite 92vw/92vh frame and the image fills it with object-fit: contain, so any image (a sized raster or an intrinsic-less SVG) shows centered and contained; the image is pointer-events: none so a click on the letterbox still falls through to dismiss, alongside the close button and Escape. Browser-proven on the live site under a derived theme
  • A dead link hover is now separated for every accent, not just near-gray ones. --link-hover derives as --link stepped in lightness, and two cases collapsed it onto --link and killed the default link’s color-only hover: a near-gray accent (no hue to hold the two apart once contrast enforcement pulls both to one readable lightness), and a high-chroma accent pinned near a lightness pole (gamut clamping erases the step, so both lightnesses land on one displayable hex, e.g. a light pink #ffcfe1 or a light yellow #fefea4). When the two emit the same value the algorithm now searches for a distinct emitted color, re-enforcing each candidate so it always clears the contrast floor: it grows the lightness step toward the readable pole, then nudges the other way where an extreme leaves no room toward it, then drops chroma when the hue is pinned at a pole. A link hover distinct from link gauntlet invariant guards the line across the full battery, exempting only the genuine pole where a mid-gray page forces --link to pure black or white and no readable hover neighbor exists. Chromatic accents that already resolve distinct stay byte-identical, since the search runs only on a true collapse
  • Button no longer warns that a labelled icon-only button is unnamed. A statically-rendered icon-only <xoji-button aria-label="…"> fired a spurious “no accessible name” console warning on upgrade: the attributes process in order, so a render could run its check before the aria-label (applied last) was captured, and the check only read the captured value. It now also honors the live host attribute, so a correctly-labelled button stays quiet while a genuinely unnamed one still warns
  • AppShell no longer lets a wide child scroll the whole page sideways on mobile. Below 900px the shell hands vertical scrolling to the document, which also let an over-wide descendant (a code sample, a data table) leak horizontally and give the entire page a sideways scroll into dead space. The shell now clips its own x-axis at that width (overflow-x: clip), so a wide child scrolls itself while the page never scrolls sideways, and vertical scrolling is untouched
  • Tree and Segmented no longer throw on a value that carries a quote. Their selection update re-queried the active node with a raw [data-value="…"] selector, so a node whose value held a " (or \, or a line break) built an invalid selector and the whole render threw. Both fills now escape the value for a double-quoted CSS attribute selector before matching, so arbitrary strings are safe in a value
  • Table styles rows added after mount now. <xoji-table> classes its slotted <table> on connect, but a reactive framework recreating rows afterward (a new keyed {#each} page, appended columns, live-appended rows) left them without the xoji-table__* part classes, so a data-driven table lost its zebra, cell padding, hover tint, and header rule on exactly the rows it did not have at mount, while a static table looked perfect (which is why the demos never surfaced it). The element now watches its own subtree with a MutationObserver and re-decorates when rows or cells change, so the wrap-a-<table>-get-chrome promise holds for dynamic tables; it disconnects the observer while decorating so its own class writes and the sort-header wrapper can’t re-trigger it into a loop
  • Bar, Pie, and Sparkline are now reachable from @xoji/svelte. The chart wrappers shipped as files but were never added to the package barrel, so import { Sparkline } from "@xoji/svelte" failed to type-check and the elements never registered through the wrapper. All three now export from index.ts and register alongside Stat and Tree; a binding-parity test guards against a wrapper shipping un-barrelled again
  • The standalone .xoji-dot indicator can pulse now. .xoji-dot--pulse-slow / --pulse-fast breathe a chip-less dot (a connection light, a per-row streaming pip) on the same keyframe and two speeds the Badge dot already uses, held still under prefers-reduced-motion. No new tokens; the badge live demo grew a chip-less status row
  • A headless Tabs item no longer needs a dummy panel. TabItemData.panel is optional, so a tablist-mode items array is a clean { value, label, disabled? }[] (the panels come from your own content in that mode) instead of forcing an empty panel on every item to satisfy the type. The runtime is unchanged; a full items-mode tab still carries its panel
  • Chart and toast readouts escape consumer strings before writing them to the DOM. Bar’s hover tooltip (category and series name), Pie’s (slice label and value), and Toast’s body (message, action label, and close label) interpolated their consumer-provided text straight into innerHTML, so a string carrying markup could inject it into the page. Each value now passes through escapeHtml / escapeAttr at its interpolation site, matching the Heatmap tooltip, and a source guard holds every one of them to it. No visible change for ordinary text
  • A near-gray accent no longer collapses the accent-2/3/4 fan into four identical grays. The fan varies hue at the accent’s lightness and chroma, so an accent with near-zero chroma (a muted or monochrome brand) fanned into four copies of the same gray, and a multi-series chart on the accents scheme (the Bar default) rendered indistinguishable series. The fan now floors its own base chroma, so a near-gray accent fans into distinct faint tints while the primary --accent stays exactly as authored. Chromatic accents derive byte-identically (the floor only lifts a base already below it), so every blessed algorithm and every existing theme is unchanged; verified against the full gauntlet battery
  • The accent-2/3/4 fan’s lineage() edges are honest now. The derivation graph declared a wheel-style chain (accent-4 derives from accent-3 and accent-2, accent-2 from --accent-shift-step), but the shipped fan is split-complement: accent-2 and accent-3 flank the primary accent and accent-4 is its 180° complement, none reading another fan slot. So lineage() (the honest derivation graph a trace, a doc, or an impact-of-pinning tool reads) mis-described what pinning a slot re-threads. The edges are declared per-fan now, so accent-4 names --accent alone, and pinning a wing pulls its mirror into the other wing’s lineage. Pure metadata: the graph resolves in declaration order off literal values, so every derived theme is byte-identical (guarded by the resolveGraph(lineage) === derive and byte-identical-to-baked tests); a new lineage test holds the fan edges to the actual derivation. Found dogfooding by pinning --accent-2 and reading the graph
  • A client-only app whose fragment runtime fails to load now fails loud, not silent. Every xoji-* element renders correctly in a no-SSR SPA: a bare shadow projects the consumer’s light-DOM children through a native <slot>, proven across Button / Badge / Kbd / Bar / Panel with a cold first paint around 20ms. But that render path is the one place the fragment runtime is genuinely required, and its async load carried no rejection handler, so if the runtime could not initialize in a given environment (a Content-Security-Policy blocking the wasm, a blocked asset fetch) every element silently collapsed to its empty scaffold with no clue why. A failed load now sets data-xoji-fill-error on the host (inspectable, and a CSS hook a consumer can style a fallback against) and logs one attributed, per-page diagnostic naming the likely wasm/CSP cause, so a blank client-only UI announces itself instead of costing a debugging session. Server-rendered content is unaffected, since its markup is baked at build and only live updates need the runtime
testscount
passed710
skipped10

v0.3.0: Metrics & Movable Panels

Metrics & charts

  • added a Metrics component category and moved <xoji-stat> into it from Display, so the single-figure stat sits with the charts it belongs beside rather than under generic display
  • a series-palette engine landed in @xoji/core (seriesPalette): it resolves N chart-series colors straight off the derived token register, so any theme charts coherently out of the box with no hand-picked palette; four schemes ship, accents (the accent fan, ranked), skittles (the named hues sampled evenly for separation), thermal (a cold-to-hot OKLCH scale, blue through cyan and yellow to red), and status (success / warn / danger), an explicit string[] overrides with the caller’s own colors, and a reverse flips any scheme end for end
    • it exposes SERIES_TOKENS so a browser consumer rebuilds the minimal register from the applied CSS variables, the same live-cascade seam the chart elements re-resolve their colors through on upgrade
  • <xoji-bar>, an interactive bar chart: grouped or stacked, vertical or orientation="horizontal", its colors drawn from a scheme / reverse / explicit array off the live theme, with an auto colorBy that varies a single series by category (so one series still fans across the scheme) and a multi-series set by series
    • a value axis with gridlines, category labels, an optional showValues, and a multi-series legend; hovering or focusing a bar dims the rest and floats a value readout
    • accessible by construction: the SVG is decorative and the data mirrors into a visually-hidden <table>, and each bar is a focusable tab stop naming its series, category, and value; height sets the plot while width fills the container
    • across html / svelte / astro, where the Astro path bakes an SSR snapshot and the element re-resolves its colors from the live cascade on upgrade
  • <xoji-sparkline>, a word-sized inline chart: a line, a filled area, or a mini bar run in a single tone from the theme roster, with an end dot on the latest point, auto-ranged or pinned with min / max, and sized through --spark-width / --spark-height to sit inline in text or beside a Stat
    • sweeping across it floats a marker and the value at the nearest point; role="img" plus a label carry it to assistive tech
  • <xoji-pie> with a donut variant: parts-of-a-whole as arc slices colored by the palette (skittles by default), the donut carrying the total in its center; an optional showValues for percentages, a legend, and reverse
    • hovering a slice dims the rest and floats its value and share, each slice is a focusable tab stop, and the data mirrors into a visually-hidden table; zero and negative values drop out

Components

  • <xoji-card> took an action attribute that makes the card itself a button: it reflects role="button", takes a tab stop, and maps Enter and Space to the same activation a click fires, so a card that runs a JS action (open this item, select this row) is keyboard-reachable with nothing to wrap; it implies the interactive hover lift, and its focus ring shows on keyboard entry only (:focus-visible), so a pointer click never paints it
    • interactive stays exactly as it was, the presentational lift plus a :focus-within ring for a card whose own content carries the control (a link or button inside); action is the new path for the card that is the control, and the two are kept apart so a button never wraps another button or link
    • the attribute threads through the Svelte and Astro bindings and bakes the visual treatment into the zero-JS Astro markup, with the keyboard behavior wired on upgrade; ships with a reference example and a live demo, on already-produced tokens so coverage is unchanged
  • <xoji-field> took an options input for type-ahead: pass a string[] or { value, label }[] (the JS property in html and Svelte, a JSON array attribute in Astro) and the field renders a native <datalist>, wiring the input’s list to it; the list lives inside the input’s own root, so suggestions work even though the input sits in a shadow root, where a page-authored <datalist> could never reach it
    • threads through the Svelte and Astro bindings, ships with a reference example and a live demo, and consumes no new tokens (the dropdown is the browser’s own), so coverage is unchanged
  • <xoji-field> and <xoji-textarea> took a mono attribute that renders the control in the monospace stack (--font-mono) for code-shaped values (identifiers, paths, hex colors, expressions, env values); a presentational swap on an existing token, so it bakes into the zero-JS Astro markup too
    • the two elements now forward a known set of native input attributes (spellcheck, inputmode, autocomplete, autocapitalize, autocorrect, enterkeyhint) from the host to the inner control, so spellcheck="false" on a code field or inputmode="numeric" on a number-shaped one finally lands on the element that styles the text instead of dying on the host; the control lives in the component’s own root, so only the component can reach it, and the forwarding follows the same shape as the <datalist> shim
  • <xoji-segmented> options now accepts a structured { value, label }[] alongside the comma-string shorthand, so a pill row whose visible label differs from the value the handler needs (an export row showing Markdown / HTML / EPUB while the change carries md / html / epub) no longer has to fork to a hand-rolled <Button> row; pass the array as the JS property in html and Svelte, or a JSON array attribute in Astro; the comma string stays the zero-ceremony path for symmetric sets, and a bare string[] works too
  • <xoji-dock-zone> grew a per-panel control surface, so a docked panel is a container with chrome, not just a tab: a panel declares data-closable for a built-in close button, data-actions (a JSON { id, label, icon? }[]) for direct header buttons, and data-menu (a JSON menu-item array) for a kebab overflow rendered by the real <xoji-menu>, so the overflow carries headings, accelerator hints, and a danger-tinted row for free
    • a header button or a menu row both report through one panel-action event ({ panelId, actionId }); a close fires a cancelable panel-close first, so a consumer can veto the removal (an unsaved-changes guard) before the zone drops the panel and re-reports the tree through layout-change
    • the controls always reflect the zone’s active panel, and a public closePanel(panelId) method closes through the same path as the built-in button; pulling a panel from the tree by hand and re-setting layout re-adds it, since the panel is still a DOM child the zone recovers, so closePanel is the seam a data-menu “close” row wires to
    • the Svelte binding adds onPanelClose and onPanelAction callbacks beside onLayoutChange, and the whole surface is browser-proven under a derived theme: close and its veto, the direct buttons, the kebab menu, and the active-panel reflection
  • <xoji-dock-zone> grew a second leaf mode, stack, for the tool-rail half of an editor shell: where tabs (the default) shows one active panel behind a tab strip, stack renders every panel as a collapsible section stacked top-to-bottom, so an inspector or tool rail keeps several panels open at once
    • set mode="stack" on the element for a single stacked rail, or carry a per-leaf mode (and a collapsed id list) in the layout tree, so a stacked rail and a tabbed editor coexist in one workspace on one persistence format
    • a section header is a real disclosure <button> with aria-expanded: click it to collapse (the body hides but keeps its content and state), drag it to re-dock the panel exactly like a tab, and the collapse rides layout-change so it persists; each section carries the same data-actions / data-menu / close chrome a tab does
    • mode and collapsed travel in the serializable DockNode, and new toggleCollapsed / setLeafMode helpers join the headless model exports from @xoji/core/elements; browser-proven under a derived theme (collapse/expand, content survival, the persisted collapsed set, per-section chrome), with tabs mode untouched
  • <xoji-tree> nodes carry per-row trailing content now, so the file-tree-with-inline-controls shape (the most common real tree) fits without forking to a hand-rolled <ul>: a TreeNode takes a badge (trailing text after the label, a count or status, shown always) and actions (a { id, label, icon? }[] of hover-revealed row buttons)
    • a row button fires a tree-action event (detail: { value, action }) and stops there, so clicking it runs the action without selecting or toggling the row; the badge is decorative (aria-hidden) so the row’s accessible name stays the bare label, and the buttons carry their label as an accessible name
    • both render through the one tree fill, so the runtime, the zero-JS Astro path, and the Svelte and Astro bindings carry them with no per-binding work; actions render on non-link rows (a <button> can’t nest in a row that is itself an <a>), and ::part(badge) / ::part(row-action) theme the trailing parts
    • browser-proven under a derived theme: badges align right, actions reveal on hover and keyboard focus, a click fires tree-action without selecting the row, and normal row selection is untouched
  • <xoji-stat> split its trend into two axes, so a metric where up is bad or a direction that carries no judgment can finally adopt the component: trend (up / down / flat) now drives only the arrow, and a new sentiment (positive / negative / neutral) drives the delta color
    • sentiment defaults to the trend’s own reading (up is positive and green, down negative and red, flat neutral), so every existing trend="up" stays green with no change; set it explicitly for a rising error rate (trend="up" sentiment="negative", a red up-arrow), a falling latency (trend="down" sentiment="positive", a green down-arrow), or a neutral run-count delta (sentiment="neutral", the arrow in a muted tint)
    • threads through all three bindings and the zero-JS Astro path, with a reference prop and a live demo; the color is always paired with the arrow, so meaning never rides on color alone, and no new tokens (the sentiment colors are the register’s own --success-vivid / --danger-vivid / --neutral-vivid)
  • <xoji-segmented>’s structured options grew two per-option fields, so a data-conditional view switch can adopt it instead of hand-rolling a radiogroup: a disabled (a choice the current data can’t offer) and a badge (trailing text like a count) per segment
    • a disabled segment is skipped by pointer and keyboard, the arrow keys hop over it, and the default selection lands on the first enabled option (never a disabled one); a badge rides inside its segment after the label
    • both travel in the same { value, label }[] the structured form already took (the JS property in html / svelte, a JSON array attribute in Astro), so the bindings carry them with no new code; the badge reaches ::part(badge) and the whole thing consumes no new tokens
    • browser-proven under a derived theme: a disabled segment rejects a click, the arrow keys skip it to the next enabled one, the default lands on the first enabled option, and the badge renders inside its segment
  • <xoji-dock-zone> panels take a data-badge now, so a panel wears a status count (an unread tally, a problem count) on its own tab, the way an editor’s chrome labels its panes
    • the badge rides on every panel’s tab, not just the active one, and on its stacked-section header in stack mode; it is decorative (aria-hidden), so the tab’s accessible name stays its title (put anything a screen reader must announce in data-title)
    • it slots in beside the rest of the per-panel chrome (data-closable / data-actions / data-menu) with no new tokens (the badge is muted --fg-2 at a relative size); browser-proven on the live tabs and a stacked rail
  • the @xoji/svelte Tabs wrapper took a lazy prop, so a tab set with heavy or expensive panels (an editor, a chart, a data grid) can adopt it instead of hand-rolling {#if activeTab === ...}
    • with lazy, a panel’s panel snippet mounts only once its tab is first shown and then stays mounted (keep-alive), so a strip of heavy panels no longer all mount up front and a panel that must measure itself lays out correctly because it mounts while visible; off by default, so existing eager rendering is unchanged
    • the tab strip, roving focus, and a11y are identical either way; proven on the real Svelte 5 runtime that only the active panel mounts on load, that switching mounts the newly-shown panel while the prior ones stay, and that eager mode still renders every panel
  • <xoji-kbd> took a tone axis (any semantic role or named hue) that tints the whole keycap, its face, edge, and label together, so a primary-chord or a status keycap needs no ::part override
  • <xoji-badge> took a pulse that breathes its dot to read as live, streaming, or connected, mirroring Progress’s own two-speed pulse (slow at 1.8s, fast at 0.9s) and stilled under prefers-reduced-motion
  • components carry an optional since version now and surface a “new” tag in the site nav and the components index while they sit ahead of the released baseline; it clears itself once the next release baselines past that version, so the tag never goes stale by hand
  • <xoji-button> took an align (start / center / end) for its content, so a full-width button labels to the left or right instead of only centered
  • <xoji-tabs> grew a headless tablist mode that drives selection and roving focus with no panel region of its own, so a consumer renders the panel content itself and reads the active key off the element
  • the component once called “Segmented Control” is just Segmented now, since the word never appeared in its element name
  • <xoji-dock-zone> grew tear-off-to-float, so a panel is no longer bound to the zones: tear one out into a floating window over the workspace (drag its titlebar, resize it from the corner, dock it back), or drag a tab out past every zone to float it on the spot
    • a float persists on the same one DockLayout the docks ride, so it survives a reload like any docked panel, and a re-docked float returns to the zone it tore out of

Algorithms

  • the default algorithm’s accent fan is symmetric either way now: pin either --accent-2 or --accent-3 and the other mirrors its hue across the accent, so the two wings stay balanced around the author’s pick; the fan used to be fixed, so pinning one wing left the pair lopsided

Fixes

  • <xoji-tooltip> no longer leaves a narrow scrollable pane with a permanent horizontal scrollbar: a closed tip hid with visibility: hidden, which keeps its position: absolute; width: max-content content box in layout, so an up-to-18rem panel anchored near a pane’s right edge pushed that pane’s scrollWidth past its width and grew a scrollbar that sat there even while no tip showed, across every rail and dock pane a tooltip touched
    • a closed tip now leaves layout entirely (display: none), so a hidden tooltip costs its ancestors nothing; the fade survives through a discrete display transition where the engine supports it, and show() opens the panel before it measures so placement still reads a real box
  • <xoji-statusbar overflow="collapse"> collapses in light DOM now, the Astro and SSR path the site itself renders: it read its cells through slot.assignedElements(), but light DOM has no <slot>, so it found zero cells and dropped none
    • it gathers the bar’s own item children when there is no slot now, and arms the collapse machinery from the fragment-apply hook as well as the first render, so a statically-authored collapsing bar arms on a cold mount instead of waiting for an attribute change; <xoji-form-group> took the same cold-mount arming fix for its control rewire
  • <xoji-splitter> no longer paints its focus ring on an ordinary mouse drag: the handle focuses itself on pointerdown so the arrow keys can drive it after the click, but a scripted focus() mid-pointer trips the browser’s :focus-visible heuristic, so every click-drag left a big accent ring around the gutter until focus moved away
    • the ring keys on a modality attribute the element arms only on genuine keyboard entry (Tab focus or a key press) now, so a mouse drag never rings while Tab and the arrow keys still do; a consumer can drop the ::part(splitter):focus-visible override it used to need and still get its keyboard focus cue
  • the @xoji/svelte wrappers no longer drop an aria-label passed in the kebab form: twelve wrappers spread {...rest} onto the host and then set aria-label={ariaLabel} on the line after, so a consumer writing <Field aria-label="..."> (which arrives through ...rest) had it overwritten with undefined by the later explicit binding, leaving the control with no accessible name and firing the element’s no-accessible-name warning on every render
    • the explicit binding falls back to the rest-passed value now (aria-label={ariaLabel ?? rest["aria-label"]}), so both the camel ariaLabel prop and the kebab aria-label attribute land; a guard test holds every current and future wrapper to the non-clobbering form
  • the <xoji-dock-zone> kebab overflow trigger no longer balloons to the width of its own hidden label: it sized itself to its visually-hidden text and sat misaligned and framed against the icon buttons beside it, and it is a correctly sized, frameless, vertically-aligned icon button like its siblings now
  • <xoji-menu> sets its own line-height now, so a host that collapses line-height (as the dock-zone kebab does to size its trigger) no longer folds the menu’s own items in on themselves

Engine (@xoji/core)

  • coverComponents takes component ids and an explicit produced register now: coverComponents(["button", "card"], { produced }) returns { id, covered, missing }[] and throws on an unknown id, so an adopter that bakes and applies its own token set checks it against each component’s consumedTokens in one call instead of a hand-rolled getComponent loop, and a CI gate fails fast on a typo’d id
    • coverage gained the matching coverage({ produced, consumed }) object form; both are additive, discriminated overloads, so the CLI and MCP callers keep their positional form unchanged
testscount
passed489
skipped10

v0.2.0: Docking & MCP

Components

  • <xoji-progress> took a meter attribute that reports role="meter" (a measurement against a capacity, like disk used) instead of the default role="progressbar" (a task advancing), so a capacity bar reads correctly to assistive tech; the visual treatment is unchanged
    • its already-built threshold engine is now documented and demoed: <threshold below tone pulse> children recolor the bar by value (green under a band, amber past it, red and pulsing when critical), and the value-format / value-position / colorize-value readout options join them on the reference page, which had shipped them silently
    • the Svelte Progress wrapper reached parity with the element and the Astro wrapper, which had pulled ahead: it now surfaces valueFormat, valuePosition, and colorizeValue (plus meter) as typed props instead of dropping them
  • <xoji-menu> grew the shape a real app menu needs: a { heading: string } item opens a labeled section (a role="group" named by the heading) that the following actions sit under, and an action takes a hint for a trailing accelerator keycap like Ctrl+S, rendered muted and monospaced at the end of the row
    • the heading is aria-hidden so it shows without a double read, and it is not a focus target, so arrow navigation walks only the actions; the hint is aria-hidden too, so the keycap shows visually while the action’s accessible name stays the bare label
    • both render through the one fragment fill, so the runtime, the zero-JS Astro path, and the Svelte and Astro bindings all carry them with no per-binding work; the keycap reads in the theme’s mono face and the heading in its muted ink, and there is a ::part(item-hint) to restyle the keycap
    • an action also takes intent: "danger", which tints a destructive row (a delete, discard, or close) in the theme’s danger ink and gives it the danger tint on hover instead of the accent one, so the one irreversible item in a list reads as different; every item button now carries part="item" too, so a consumer can reach a single row from the light side without forking the menu
  • the typographic trio, <xoji-heading> / <xoji-text> / <xoji-eyebrow>, took the full tone roster: alongside the default / muted / subtle emphasis ramp, any of the 21 tones (the semantic roles, the accent variants, the twelve named hues) now paints colored type, so a danger heading or a success eyebrow is one attribute
    • each tone renders in its on-surface ink (--{tone}-vivid), derived to clear AA against the page; a brand-palette regression test pins every tone across all five algorithms and five real-world palettes, so the contract fails loudly if a tone ever drops below the floor
    • the accent tone moved from --accent-text to the brighter --accent-vivid to match the rest of the roster, the punchier on-surface accent the other tones already use
  • <xoji-splitter> resets its size on a double-click of the handle, mirroring <xoji-slider>: it restores the default size, or the size it first rendered with when default is omitted; the keyboard reset follows the same fallback
  • <xoji-code> took a caption attribute: a header strip above the block (a filename, say) that rounds the block’s top corners into it and reads in the mono face; it renders on the runtime and the zero-JS Astro paths, threads through the Svelte and Astro bindings, and reuses chrome tokens so coverage is unchanged
  • a headless docking seam landed for drag-and-drop panel workspaces: resolveDrop (from @xoji/core/elements) takes the pointer and a set of zone rectangles and returns where a dragged panel would land (a left/right/top/bottom split or a center tab) plus the preview rectangle to highlight, so a consumer wires its own panel rendering into xoji’s layout physics instead of rebuilding the hit-testing and drop resolution
    • pure geometry, the same shape as the overlay positioner: a target’s regions narrow the accepted drops, corners resolve to the nearer edge, and a pointer over nothing returns null for a float; verified against real browser geometry, with the docking dock-zone / dock-panel elements that ride on it to follow
  • the docking seam grew its state half: a serializable zone/panel tree (DockNode) plus immutable mutations (dockPanel, removePanel, activatePanel, parseLayout) from @xoji/core/elements, so resolveDrop says where a panel lands and dockPanel applies it to the layout
    • a dockPanel moves a panel onto a target as a tab (center) or a split (an edge), pruning the leaf it leaves behind and collapsing any single-child split, so the tree never accumulates dead structure; every node is plain data, so persistence is JSON.stringify and parseLayout reads it back
      • proven end-to-end in a real browser: a working two-pane docker built from the seam, dragging panels between zones with no panel lost or duplicated
    • layoutRects closes the geometry loop: it lays the tree out inside a container (splits divide by their sizes, children inset by a gap) and returns each zone’s rectangle, which a renderer positions by and resolveDrop reads back as its targets, so the engine round-trips from tree to rects to drop to tree
  • <xoji-dock-zone> lands the drag-and-drop dockable-panel workspace on top of that engine: its children are the panels (any element with a data-panel-id and a data-title), and it renders them as tabbed zones that rearrange by dragging a tab, joining another zone as a tab over the center or splitting it against an edge
    • every rearrangement dispatches a layout-change event carrying the serializable layout tree, and setting the layout property restores a saved one, so a workspace persists across reloads; the tabs are real <button>s with role="tab" and aria-selected
      • browser-dogfooded under a derived theme: dragging a tab to a zone’s edge shows the drop preview and splits the zone, with the layout tree updating live
    • <xoji-dock-zone> now ships its Svelte and Astro bindings alongside the raw element, so it reaches binding parity with the rest of the set: @xoji/svelte’s DockZone surfaces the saved layout as a prop and reports each rearrangement through an onLayoutChange callback, and @xoji/astro’s DockZone renders the panels and upgrades the workspace on the client
    • <xoji-dock-zone> then took a round of refinement on that first landing:
      • a tab re-docks only after the pointer travels past a small threshold now, so a plain click just selects the tab where any click used to trigger an erratic split
      • a declarative JSON layout attribute authors a split multi-zone workspace up front with no hydration script, the markup twin of the JS layout property that already handled restore and persist
      • the drag preview now films every live zone by role instead of one flat highlight rectangle: the drop target tints in --accent, the remnant a split would leave behind in --accent-2, and every other zone in --accent-3
      • the leaf border moved off a bogus --border token the register never produced onto --line, closing the component-coverage check at 51/51
  • recategorized the reference catalog so each component sits under what it is: Eyebrow moved into Display, and Splitter and Dock Zone into Shell

Fixes

  • clearing a value-bearing control through its value property now empties it, so a form that resets its model (each bound value going undefined) clears the boxes instead of leaving stale text. <xoji-field>, <xoji-textarea>, and <xoji-select> reflected an empty value to the attribute but never wrote it to the live inner control, which is dirty once typed into, so el.value = "" left the old text sitting there while a native <input bind:value> would have cleared. The setter now drives the inner element’s property too (<xoji-number-input> already did). This is what bind:value rides on, so a Svelte reset clears with no {#key} remount.
  • <xoji-splitter> wires its drag the instant its handle is built, so a cold-mounted splitter is live with no remount and no consumer workaround. The handle is built asynchronously, so a splitter present at first paint (before the component runtime had warmed) wired its drag against a handle that did not exist yet, and nothing re-ran the wiring once the handle arrived, leaving the divider dead until something forced a remount.
  • <xoji-splitter> keeps a drag alive once the pointer leaves the handle: the move and release listeners now sit on window instead of the handle element, so a thin vertical divider (a few-pixel grid column) tracks the pointer anywhere on screen. It used to lean entirely on pointer capture, which a narrow handle can lose the instant the pointer crosses out of the strip, freezing the drag with no resize.
  • the @xoji/core quick-start in the README imported xojiDefault from the neutral entry, where it never lived, so the documented first call derived against undefined; the examples now import the blessed algorithms from @xoji/core/algorithms, the surface that actually carries them
  • <xoji-tooltip> was inert in light DOM, which is exactly how the SSR and Astro paths render: it looked the trigger up through slot.assignedElements(), but the SSR composition replaces the <slot> with the trigger itself, so nothing was found and no hover or focus listeners ever wired, and the hint only appeared when pinned open. The trigger is now read as the [data-root] element child, so hover and focus reveal it again. Two follow-on bugs fell out of the fix: rich-mode and content-slot tooltips rendered an empty panel because the update hook re-injected an inert light-DOM <slot> over the composed content on every hydration (the content slot is left intact now), and once hover worked a top-placed tip popped to the lower-right because placement anchored to the Button’s hydration <script>, a zero-size node (non-rendering nodes are excluded now, so placement lands right on all four sides).
  • the build-time fragment renderer corrupted any consumer content carrying a $-sequence: it applied ops through String.prototype.replace with a $1...$2 replacement string, so a $17.50 table cell, a shell $1, or a regex example (anything with $1..$9, $&, $`, $', or $$) got read as a backreference and rewritten, duplicating the scaffold’s own tag mid-content into malformed markup that broke the page layout. The applier now inserts values verbatim through function replacements, with regression tests across all three op paths.

Engine (@xoji/core)

  • @xoji/core/algorithms re-exports the engine (derive, deriveTraced, emit, emitCss, emitJson) alongside the blessed algorithms (getAlgorithm, the registry, the named set), so an embedder reaches the whole pure-derive path through one import: import { derive, getAlgorithm, emitCss } from "@xoji/core/algorithms"
    • the neutral @xoji/core entry can’t carry getAlgorithm itself (the blessed presets live in the sibling algorithms/ workspace, outside the engine’s compile root, and are bundled separately), so the batteries surface is where the two halves meet

Tooling

  • added an MCP server, xoji mcp, that hands an agent the same engine the CLI hands a human over one stdio connection
    • tools for xoji_derive, xoji_coverage, xoji_components (list a component or describe its full manifest), xoji_gauntlet, and xoji_list_algorithms, each running the same code the matching CLI path runs
    • resources serving the concept docs and every component manifest, so an agent answers from what ships rather than from memory
  • consolidated the concept narrative into @xoji/core (the new @xoji/core/concepts entry), one source read by both the MCP and the site’s llms generator
  • xoji derive (and xoji coverage) took a repeatable --set <token>=<value> flag (alias --constraint) that pins any token, not just the three --bg/--fg/--accent headline anchors, so a full multi-anchor recipe (a secondary accent, font stacks, radii) bakes straight from the CLI instead of dropping to the importable derive API
    • the leading -- on the token is optional (--set radius-md=10px and --set --radius-md=10px both pin --radius-md), and the pins feed back into derivation like any constraint, so pinning --accent-2 re-coheres its -bg/-fg/-text family

Docs

  • documented the MCP server: a /mcp reference page on the site, xoji mcp --help text, and a README section
  • generated llms.txt and llms-full.txt at the site root so agents can read xoji’s reference the way they read its dependencies
    • llms.txt is a curated index: the guides and every component page, grouped by category, each with its one-line summary
    • llms-full.txt inlines the whole corpus, the concept narrative plus a full per-component reference (props, variants, states, slots, consumed tokens, accessibility, examples) generated from the manifests, so it never drifts from what ships
    • pointed robots.txt at both

v0.1.1: README fixes & publish CI

A corrections release on the heels of Genesis: one real install bug, plus the docs that had drifted from the engine.

Fixes

  • gave @xoji/astro its missing @xoji/core dependency, so a fresh npm install @xoji/astro resolves the @xoji/core/markup, @xoji/core/elements, and @xoji/core imports its components make
  • corrected the @xoji/core and root READMEs to match the engine: constraints (not anchors) is the derive input, the register is 276 tokens across the seven-dimension contract, and the knob list now carries accentSplit and cues

Tooling

  • added a GitHub Actions workflow that publishes @xoji/core, @xoji/svelte, and @xoji/astro on each release through npm OIDC trusted publishing
  • tightened the docs and the per-package READMEs
testscount
passed238
skipped10

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