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 aknobsinput 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.stepandduocould not be derived by any means outside the browser, andshadewas worse than unreachable: it had been reachable as-a xtyle-brandright 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=duoworks 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
- a knob’s value is typed on the way in, because the derivation guards on
xtyle knobsprints every algorithm’s dials and exactly what each one accepts: kind, range, options, default. It reads the algorithm’s ownknobSpecs, so a third-party algorithm’s novel knob is as discoverable as a blessed one, and--knobhas a vocabulary you can look up instead of guess at.xtyle liststill prints bare ids, unchanged- the MCP
formatenum is built fromemitters()rather than a hand-kept second copy, which had already drifted: v0.7.0 shipped theterminalemitter, and the server would advertise it inxtyle_list_algorithmsand then reject it inxtyle_derive
The retirement actually retires
- Every consumer migrates a retired algorithm; before, half of them died on it.
xtyle deriveandxtyle gauntletran the migration map.xtyle audit -a xtyle-brandandxtyle coverage -a xtyle-brandhard-failed on a dead id, and all four MCP tools resolved the raw id too. There is onemigratedTarget()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_derivewithformat: "theme"wrote the un-migrated id intorecipe.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-brandnow 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
- worst of it:
An algorithm owns its own knobs
hourmoved 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 tableknobSpecsexists to delete, relocated one layer down. nxi-nite declares its ownhournow, 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
accentStrategyandsurfaceRampand 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=duooused 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 badselectlists the values it takes, and an out-of-range number names the range- the check is the algorithm’s, not the CLI’s:
houris a knob on nxi-nite and on nothing else, so-a xtyle-default --knob hour=3is 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
12as a number, and a select set tofalseas a boolean
- the check is the algorithm’s, not the CLI’s:
- A group can say that it’s a group.
anchorsandfontsare 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 tableknobSpecsexists to delete. They declarekind: "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
surfaceRampto “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.KnobSpectakes adefaultBySchemenow, 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 knobsshows the gap in the declaration instead of a mystery text box qr.tswas 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 togit blameagainAlgorithmManifestunderstated its own contract by three fields. The host readsknobSpecs,invariantCount, andpassNamesoff themanifest()export; the manifest declared none of them, so a third party writing an algorithm against the published type would ship amanifest()the host readsundefinedoff. The type declares what the host actually reads, andKnobSpecis declared beside itinvariantCountis 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 caseknobSpecsexists 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 standardand--depth fulldied oninterrupted, so the documented way to prove invariants against the shipped sandboxed mods had been unable to run pastquicksince 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
| package | tests |
|---|---|
@xtyle/core | 980 |
v0.7.0: Show Your Work
The accent family
- How
--accent-2/3/4relate to--accentis 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 lockfanis the default: 2 and 3 flank the accent at ∓accentSplitand 4 is its 180° complement. Pin either wing and the other mirrors it across the accent, so the pair stays symmetric around your choicestepwalks the hue circle evenly, each accent oneaccentShiftSteppast the last; a pinned wing carries the chain with itshadeholds 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 graysduotakes two brand colors instead of one.--accentand--accent-2are both inputs, and 3/4 become their shades. Pinning a fan slot has always fed the rest of the family (a pinned wing mirrors underfanand carries the chain understep); whatduochanges 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 (
shadeandduostep 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-ladderwere a second set of names for the same three things, andfan/step/shadeare the only ones now
xtyle-brandretired 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 isaccentStrategy: "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 carriestone,size,pulse(slow/fast),ping(an expanding ring),glow, acolorescape hatch, and an optionallabel(a namedrole="img", else decorative); the.xtyle-dotutility class stays available for global-CSS pagesRibbon(<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”.tonetakes the full semantic and hue roster, alongsidevariant(solid/soft),size,corner, and acolorescape hatch, and the element fills its container as a clipping overlay so the band trims to the edges. Self-styling likeDot, with a.xtyle-ribbonutility class alongside, bound across@xtyle/svelteand@xtyle/astroQrCode(<xtyle-qr>), a themeable QR code that inks its modules from the active theme (--fg-0on--bg-0) and encodes in the browser, so it recolors live when the theme changes.moduleShapepickssquare/dot/rounded, an optional centericonmark knocks a clean hole in the code (oriconOverlay+iconOutlinelays it over the top with a halo),frameprints the payload beneath as a real, followable link for safe schemes,modeToggleflips themed and bitonal live, andecLevelstepsL/M/Q/H- scannability is a contract rather than a hope:
mode="theme"/bitonal/autois 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 biggericon-sizereads as a bigger glyph instead of more whitespace. Every size preset round-trips through a real decode at levelH. Bound across@xtyle/svelteand@xtyle/astro
- scannability is a contract rather than a hope:
MobileShell(andBottomNav), the touch frameAppShellwas never going to be.AppShellis 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. SoMobileShellis a separate frame (a sticky app bar withheading/brand/actionsslots, one scrolling<main>column, and anavslot in thumb reach) rather than a responsive bend ofAppShell, 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-ARIAtablistwith 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/Segmentedand 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
- the bar and the nav absorb the safe-area insets (
Media
ImageandCarouselplay a preview on hover. An image can reveal a preview on hover and focus, then reset to the still on leave.hover-srctakes a video (muted, looping) or a gif; ahoverslot takes anything richer, a<video>, images, or a whole nested<xtyle-carousel>, socarousel → image:hover → carouselcomposes for free. The preview reveals on focus too (never mouse-only), the overlay isaria-hiddenso the still’saltstays the single accessible name, and underprefers-reduced-motionit neither reveals nor plays.hover-audioallows sound behind a mute toggle, starting muted so autoplay is never blocked and unmuting is a real click. In aCarousel, 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 newvolume/volume-officons- 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
scriptandstylenow: 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
Carouseltook atransitionprop.slide(scroll-snap, the standing default),fade(cross-fade opacity, stacked slides, no scroll-snap),scale(fade plus a subtle zoom), andflip(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 goinertandaria-hidden, and everything holds still underprefers-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
Imagehover-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
- 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
Carouselloops smoothly. Killed the jarring rewind whenloopwraps last to first with the seam-clone technique: inert, id-stripped,aria-hiddenclones 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 onscrollend(with a timer fallback), corrects a manual swipe that lands on a clone, and resolves an in-flight wrap when a second advance arrivesCarouseltook adirection.right(the default),left,up, anddown: the cardinal sets both the axis (horizontal or vertical scroll-snap) and the sense of advance for aslidecarousel. The reverse sense (left/up) is a pure*-reverseflex 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(default18rem). Moot for the stacked transitions- An autoplaying
Carouselcan 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 Carouseltookpause-on-hover. The transient hover/focus pause became an option (defaulttrue). Set itfalsewhen the rotation is the point and should keep running under the pointer: a decorative marquee, an ambient gallery, or a preview revealed inside anImage’shoverslot. 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 andprefers-reduced-motionstill stop it, so the content stays pausableCarouseltookcontrols="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
Avatarderives its own initials from auserName. 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-typedALinto the slot.userNamederives them now ("Ada Lovelace"→AL,"Prince"→P), splitting on code points so a name opening on an astral character keeps it whole. It’suserNameand notnamebecausenamecarries 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 lettersSlidertook fine/coarse stepping, an editable value, and overflow. It picked up the number field’salt-step/modifier/alt-defaultcontract: holding the modifier (Shift by default, settable) while dragging or arrowing swapsstepforalt-step, so a coarse-stepped track fine-tunes to 1° without losing its big-increment feel. Theshow-valuereadout became a click-to-edit numeric field that steps on the same keys, andoverflowlets a typed value passmin/max(the thumb pins at the edge and the announced range widens to keeparia-valuenowvalid). Threaded through the element, the fragment, and all three bindingsDialogfits editor-class content.size="xl"(64rem) andsize="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 48remlgcapHeatmaptook a categorical coloring mode.categoricalcolors each column (or, withcategoryAxis="row", each row) a distinct hue from a categoricalSeriesScheme(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 sameseriesPalettepathBarandPieuse, so the four canonical series schemes now render across the whole metrics set; aglowhalo takes its cell’s category hue, andscaleswaps 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 agreeRadiotook a description line and an option-card variant.descriptionadds a secondary explanation line under the label (wired toaria-describedby), andcardrenders 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 byRadioGroupTreetook richer trailing content and svelte events.TreeNode.badgetakes multiple toned pills now (a count beside a distinct status pill),TreeActiongaineddisabled(greyed and non-firing in place, so a positional control set keeps a stable count), and the@xtyle/sveltewrapper forwards the nodetree-actionevent as anontreeactionpropSparklinespeaks its metric’s units on hover. Aformatattribute (percent/duration/bytes/unit) makes the hover readout say1.5mor87%instead of a bare number, andformatandboundssurface as typed props on the svelte and astro wrappersSegmentedoptions take atitlehint. A structured option renders itstitleas 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;blackandwhiteanchor the achromatic endpoints. Two gauntlet invariants guard legibility and mutual distinguishability, the generator’s token editor shows a liveTerminal / ANSIgroup, andxtyle derive --format terminalemits an xterm.jsITheme - Scrollbars ink from the theme. A
--scrollbar-track/--scrollbar-thumb/--scrollbar-thumb-hoverfamily joined the register and emits a rootscrollbar-color, so a scroll container’s bar takes the active theme instead of the browser’s default gray. The engine used to emit onlycolor-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-colorinherits 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-2one-off for the shared ones - A signed
surfaceRampknob 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-sunkenall 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.knobSpecscarries 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 (hourstays 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, andaccentShiftStep, 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 themanifest()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.
Savekeeps the current mark in aUSER CREATEDrow above the examples, each tile removable and the whole set persisted separately from the session restore; the filter spans both rows - A
lettertext primitive. The icon grammar gained alettershape that draws a single glyph as a mark layer, with builder controls for the character and its typographic knobs. An outlinedletterreads 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
Slideroverflow 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 (
BarandPiehad been defaulting toskittlesand never touching the accent family at all). Every one of those axes is categorical, never semantic, and color is never the only signal, so ashadetheme reads exactly as well as afanone- two new scenes: Brand Site, a marketing landing page, and the natural home for a two-brand
duotheme, and Music Player, a consumer media player, so the set isn’t nine flavors of enterprise SaaS. Code Workspace is now Editor. Nine scenes
- two new scenes: Brand Site, a marketing landing page, and the natural home for a two-brand
- 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 andlineage()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 onxtyle-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 splitnever 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 disabledIcon spin,Carousel autoplay,Carousel loop, andImage 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.
BadgeandAvatareach hand-rolled their own circle, andAvatar’s pulse animation reached intoxtyle-badge-pulse, so avatar CSS depended on badge CSS to animate a dot neither of them got fromDot. Both wear the shared.xtyle-dotprimitive now and only retint it; the duplicate size ramps, the duplicate pulse rules, and the badge keyframes are gone Dotsits on the line, and starts bigger than a pinhead. It inherited the surroundingline-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 elseAppShellno 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
Carouselno 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 nodisplay: block(only its shadow:hostdid), so an image in a centered flex column collapsed to zero width; it matchescarousel/hero/parallaxnow- A
Heatmapincategoricalmode types its ownscheme.HeatmapSchemewas stillRampScheme | string[], so a perfectly validscheme="skittles"was a type error that had been quietly failingastro checksince the categorical mode landed;SeriesSchemeis 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__mediasits muted and enlarged at the top, the first heading reads as the title, a<p>as the muted body, and buttons in.xtyle-empty__actionsline 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 semanticsRating, a rating control that reads two ways: a read-only display or an interactive input. Give itreadonlyand 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; dropreadonlyand it becomes arole="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.allowHalfopens half-step selection- it draws any icon, not just stars:
icontakes 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.colorsandtoneskin 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 labelledrole="slider"carryingaria-valuenow/min/max), so the score never rests on color alone. Moved Content → Controls, shipped across@xtyle/core/elements,@xtyle/svelte, and@xtyle/astro
- it draws any icon, not just stars:
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 acurrentindex: everything before it is done (an accent-filled marker with a check), the step at it is current (an accent-outlined marker flagged witharia-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
- 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
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 matchingxtyle-timeline__title/__meta/__bodyclasses), 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
- inside an item a
Accessibility
- The scrollable
Codeblock 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 treatmentTablegot, so arrow keys can pan it (WCAG 2.1.1 / 2.4.7); awrapblock soft-wraps instead and is never a scroll stop - The scrollable
Tableregion 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 joinsTree,Tabs,Segmented, andPagination, 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 treatmentPaginationuses; it rounds out the selection-bearing set alongsideTree,Tabs,Segmented,Pagination, andSwatch Spinner,Skeleton, and theButtonloading spinner self-guard against reduced-motion. Their looping animation always stopped underprefers-reduced-motionvia 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 ondocument.bodyinstead of inside the element, so an ancestor withtransform,filter,backdrop-filter,will-change, orcontain(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
- fixed in the same pass: a closed lightbox kept its
- A
triggerfor 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
- the button is a real
- A
maximizeglyph joined the functional icon set, the diagonal-expand mark the zoom button draws; it rides as asymbol-maximizeprimitive 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; amarked/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 ascopeselector’s subtree), andopenLightbox(src, { alt, caption })from@xtyle/core/elementsdrives 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, anaria-labelfrom the imagealt) and opens on Enter or Space, so the delegated path is never mouse-only.data-lightbox-src/-alt/-captionoverride the resolved source for a full-res image behind a thumbnail, and the lightbox now renders an optional caption beneath the image
- a non-interactive trigger (a bare
Metrics
Sparklinegained kind-aware bounds so a typed metric ranges itself.bounds="percent"pins[0, 100],bounds="unit"pins[0, 1](a fraction or a bool), andbounds="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 explicitmin/maxstill overrides it, and the pureresolveSparklineBoundsis exported for computing the same range yourself- A filled
occupancyvariant 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/1stepline can’t give; an all-off strip stays empty rather than collapsing to a solid block. Pair it withbounds="unit"for a clean threshold
Components
Cardtook adepthStrengthknob for how far the surface lifts off the page.depthStrength="sm" | "md" | "lg"(defaultmd) tunes the shadow weight:smis a whisper (a soft custom shadow),mdis a newly eased default, andlgrestores the previous heavier weight. In the same pass the resting shadow eased from--elevation-2down to--elevation-1and the interactive-hover shadow from the heavy--elevation-4down to--elevation-2, so a default card sits lighter on the page than it used to; aninteractivecard still bumps one step heavier on hover. Threads through@xtyle/core/elements,@xtyle/svelte, and@xtyle/astroProgressabsorbed the standalone gauge and grew aunitsuffix. A separateMeterwas briefly its own component, butProgressalready carried the same meter role, threshold zones, and pulse, so the gauge was retired back into it; the one thing it lacked, aunitprop that trails the value / value-max readout with a suffix (910/1000 GB), landed with it. Taggedmeter/gauge/capacitykeywords so a capacity gauge is discoverable asProgressand never gets rebuilt as its own component- A
Progressfill can color by its own value with aramp.rampcolors the fill along a scale instead of the flattone, 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), andreverseflips it end for end. The ramp tracks the theme’s own hues, and the purerampColor/rampGradientStopshelpers are exported from@xtyle/core - The standalone status dot reads as live in three composable ways.
.xtyle-dotkept only an opacity breathe; it now also takes--ping(an expanding ring, a stronger status light),--glow(a soft halo), and an inline--dot-colorescape hatch for an arbitrary per-state color the tone classes can’t name. The three compose, and all hold still underprefers-reduced-motion Tableexports its part-class names as the typedtablePartsmap. 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/.bodycome 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
auditRegistertakes 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) passesopts.pairsto grade what it actually renders, with an optional per-pairlabel. The canonical set is exported as data viacanonicalContrastPairs()(and thePairSpectype), so a consumer extends it. A QuickJS/xript sandbox that can’timportfrom@xtyle/coreinlines the small list instead and pairs it with the equally-purecontrast()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, andImageFit/ImageRadius/ImageLoadingare now exported from@xtyle/coreproper, not only its subpaths, so the Svelte and Astro wrappers (and any consumer) get real types for those props instead of a silentany - 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:
c0clear,c1–c9the nine series colors, and the semantic chrome,cathe active ink,cb--bg-0,ce--neutral-bg(the neutral track an unfilledRatingorProgressgroove shows),cf--fg-0, withcdheld in reserve.ICON_SERIES_COUNTis 9, so every scheme fills nine slots:skittlesfinishes 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 likeneutral-bg, since finish flags delimit on--.SLOT_TABLEis exported as the blessed default palette for tooling and third-party mark work
- a
- 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 taggedsince: "0.6.0". The strokes take a layer’sccolor throughcurrentColorthe way the frames do, and every primitive rotates about the grid center, so awedgetiles a pie and aline-r45is a diagonal rule. They surface in the Icon Builder bench palette (a newStrokesgroup; the curves fold intoShapes) and are documented inicon-name-grammar.mdsymbol-heartwas 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
keywordsandseeAlsonow, so a component is findable by what it does. All 65 manifests gained capability-synonymkeywordsand related-componentseeAlso: thextyle_componentsMCP list returns them (an agent resolvesmeter→Progressorkebab→Menuby 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
Cardno longer paints a phantom divider or reserves phantom spacing. The card fragment always emits.xtyle-card__headerand.xtyle-card__footerwrappers even when those slots go unused, and an empty footer still drew itsborder-top(a hairline ruling off to nothing) while both empty wrappers held layoutgap..xtyle-card__header:emptyand.xtyle-card__footer:emptycollapse todisplay: nonenow, 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/Toastcenters its icon on a message-only notice. The alert laid its icon out at the flex defaultalign-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__actionswrappers 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 asize="sm"dock stops overhanging its default14remor cutting its right border24pxinto the main pane - A color-only
Alert/Toasthides 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 noseverity(a color-onlytone) carries no icon, slotted or not. To show a custom icon, give the alert or toast aseverity(or a severity-matchingtone) and project it into theiconslot wrap,line-numbers, andhighlightwork on server-renderedCodeagain. Their CSS keyed on:host([…]), which only matches a shadow host, so on the site (the light-DOM path) awrapblock didn’t wrap, the line-number gutter stayed empty, andhighlightdid 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
xojitoxtyle.xtyle— style — 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/astroand@xtyle/svelte; the CLI is nowxtyle(xtyle derive,xtyle gauntlet,xtyle coverage, …) - Tokens & markup: the custom-property prefix
--xoji-*→--xtyle-*, component tags<xoji-*>→<xtyle-*>, and the BEM classes anddata-attributes with them - API surface:
makeXojiAlgorithm/defineXojiAlgorithm→makeXtyleAlgorithm/defineXtyleAlgorithm, theXojiElementbase and every element class, and theXoji*Algorithmtypes - Batteries: the four built-in algorithms
xoji-default/-hc/-quiet/-loud→xtyle-*(thenxi-nitebattery keeps its own name) - The rest: the MCP server and its
xtyle_*tools andxtyle://URIs, capability ids, storage and event keys, and the.xtyle.jsonmanifest suffix - Home: the site and docs moved to
xtyle.dev
- Packages:
- Migrating off the pre-release
@xojiline: 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_PRIMITIVESseeds 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 emitvar(--…)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, ashape-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.
parseIconNamereads 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 indocs/icon-name-grammar.md- an object is a primitive keyword plus flags in any order: a phone-keypad grid position (
p1–p9) 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 akoknockout 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 ischeck-badge--circle-c5--check-s55-c1 - rounded out the primitive set with a
trianglefield and adividerrule (a bar that rotates to vertical withr90), so shapes / frames / bands / dividers / symbols all have a seed - the color ladder reads one scale (
c0transparent,c1--fg-0,c2--bg-0,c3+the theme’s series), so a mark colors from the register and thecolorsattribute re-skins the same spec across schemes (accents,skittles,statuses, …); an uncolored object inheritscurrentColorand renders like a flat glyph composeIcongrew to match: grid placement, in-place rotation, stroked outlines, flips, knockout via nested SVG masks, and a rounded-rect clip;composeIconThemedbakes only the series slots against the live cascade and leaves token fills asvar(--…), so a generated mark recolors with the theme for free- the generator is swappable, not law:
registerIconGeneratoradds 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 andcomposeIcon) 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
- an object is a primitive keyword plus flags in any order: a phone-keypad grid position (
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 incurrentColor, so an icon inherits the text color around it and matches the derived theme with nothing to wire, and an optionaltonetints 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 achevron-expand, the fourarrows,close,check,plus,minus,menu,more-vertical/more-horizontal,search, the status marksinfo/warning/error/success,external-link,dot, aloader, and the media-transport familyplay/pause/stop/skip-forward/skip-back(filled shapes incurrentColor), so a run / execute / start-stop control renders in one voice instead of dropping to a bare▶text char beside real glyphs sizesteps it in fixedem(sm/md/lg/xl) or omit it to match the surrounding text;spinturns theloaderinto a lightweight busy indicator that honorsprefers-reduced-motion; and it is decorative (aria-hidden) by default, promoted to a namedrole="img"the moment you give it alabel- the glyph set lives in
@xoji/coreas a name-to-SVG map with arenderIconhelper, 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(anicon-start/icon-endcontrol, or an icon-only button) and inline inText
- the set ships 29 glyphs, the ones the components already reach for: the four
Media
<xoji-image>, a responsive image in an aspect-ratio frame. Give it aratioand 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
lightboxmakes the frame a control that opens the full image in a top-layer native<dialog>, with a--scrimbackdrop, backdrop and Escape to dismiss, platform-handled focus trap and restore, and a close button drawn with the newcloseicon fit(cover/contain),radiusoff the scale (none/sm/md/lg), an optionalcaptionrendered as afigcaption, nativeloading="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
Cardmedia slot or aGridgallery
<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
autoplayadvances on aninterval, pausing on hover and focus and standing still underprefers-reduced-motion;loopwraps the ends;controls/dotstoggle the chrome - content-agnostic (slides can be
Images,Cards, or any markup) and a standalone light-DOM element likeTable, so framework content stays live; it exposes a labelled carousel region with each slide namedN of Mfor assistive tech, and tears down its observer, timer, and listeners on disconnect
- 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
<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-speeddrifts vertically as the banner scrolls through the viewport (the faster the speed, the deeper the drift), driven off arequestAnimationFrame-throttled scroll listener; a layer with nodata-speedis the still content on top - the motion is pure enhancement: with JavaScript off the layers sit at rest, and under
prefers-reduced-motionthe runtime never wires the scroll at all, so the banner is always a legible layered composition;min-heightsizes the band andamplitudescales the travel - each moving layer picks a travel axis with
data-direction, a compass token (n/s/e/wand the diagonalsne/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 baredata-speedlayer is unchanged, and a negativedata-speedreverses 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’sdata-directionaxis) and ease back to center when the pointer leaves; still static underprefers-reduced-motionamplitudetakes a negative value to flip the whole banner’s direction at once, composing with a layer’s owndata-speedsign- 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
scrolldoesn’t bubble - shipped as a standalone light-DOM element across html / svelte / astro, tearing down its scroll listener and pending frame on disconnect
- a layer with a
<xoji-hero>, the top-of-page band, assembled from the primitives you already have: drop anEyebrow,Heading,Text, aClusterofButtons, and maybe anImageinside 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
Parallaxband; the layout is pure CSS, identical with JavaScript off alignswitches the stack from centered to left-aligned, andsplitturns 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
- a thin presentational element with no behavior and no chrome of its own, so it inherits the page surface and pairs cleanly over a
Metrics
<xoji-heatmap>, a new metrics primitive: the activity-grid / punch-card shape. A densevaluesmatrix 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 isaccentby default (a faint-surface-to-accent wash), withthermal,status, or an explicit stop array to swap the scale, and every cell normalizes against the data max or an explicitmaxso 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, andshowValuesprints each number.selectablemakes cellsrole="button"that fire aselectevent ({ row, col, value, rowIndex, colIndex }) for a drill-in, composing with the same seamBarcarries. Ships across html/svelte/astro (the Astro path bakes the cell colors into zero-JS markup), on a newrampColorengine helper, the continuous sibling ofseriesPalettethat maps a scalar 0..1 intensity to an OKLCH-interpolated color- an optional second
glowmatrix 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 newglowFilterengine helper besiderampColorglowMaxshares one glow scale across grids andglowBlurtunes 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:glowLabelnames the metric so each cell’s value folds into its accessible name and hover readout, and the second metric is never visual-only
- a
scalekey reads a cell’s shade as a magnitude without hovering: five swatches sampled from the same ramp as the cells, sitting between the0andmaxvalue 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.
currenttakes a[rowIndex, colIndex][]list and rings those cells (the “you are here” on a time grid: the live hour, today’s column),currentPulsebreathes the ring so a live position reads as live (honoringprefers-reduced-motion), andcurrentTonetints it to a semantic tone.titles, a matrix parallel tovalues, 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
- an optional second
<xoji-sparkline>grows a time-windowed mode for a real time-series. Alongside the even-spacedvalues, it now takespoints(timestamped samples,atas epoch ms or a date string) mapped onto a slidingwindow(default 5 minutes) or an explicitdomain, so irregular samples sit at their true position and slide left over real time instead of the x-axis pretending they were evenly spaced. Astepflag 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. Aselectablebar chart turns each bar into arole="button"that fires aselectCustomEventon click or Enter/Space, itsdetailcarrying{ 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 existingselectvocabulary (Tree,Menu,Segmented,Breadcrumb). Opt-in and honest: a read-only chart keeps its bars as focusablerole="img"data points with no pointer cursor.@xoji/sveltesurfaces it as anonselectcallback, and the zero-JS Astro path bakes the button role so the markup is right before hydrationstatuses, a categorical series scheme for discrete-outcome charts. The chartschemeshipped a sequentialstatusramp (success→warn→danger) but no way to pin discrete categories to distinct semantic tones.statusesdoes 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
accentsandskittles, 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 sequentialstatusramp rather than replacing it - the
SeriesSchemetype now documents that avar(--token)string in an explicitschemearray lands straight in the SVGfilland resolves against the theme, the clean way to pin exact or theme-token colors; the pie reference page grew aDiscrete outcomesexample and a six-slice demo donut - a
PieDatum/BarSeriescan carry a semantictone(success/failed/warn/info/skipped/live) thatstatusesmaps 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; atonepins each slice to its outcome regardless of which categories are present. Backed by a namedSTATUS_TONESmap and aseriesColorsForhelper on@xoji/core(the by-name companion toseriesPalette), wired through the pie/bar elements and the zero-JS Astro bake, with a side-by-sideclean rundonut on the reference page proving the survivors hold their colors
- a categorical scheme like
- Every metrics chart shows a
No datastate instead of an empty frame. APie(empty set or all-zero values),Sparkline,Bar(no categories or series), orHeatmap(empty matrix) with nothing to plot now renders a mutedNo datamessage 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 suppliedlabel), 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
Navigation
Breadcrumbcrumbs can drive app state, not just navigate. ABreadcrumbItemtakes an optionalvalue: a non-current crumb with avalueand nohrefrenders as a real<button>and the element fires aselectevent carrying{ value, index }on click or Enter/Space. So an in-app trail (clicking an ancestor selects that node instead of following a URL) adoptsBreadcrumbdirectly instead of dropping to the custom-crumb slot;hrefstill wins when both are set, and@xoji/sveltesurfaces anonselectcallback- 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, soitemsmode is the route when you want the component’s styling on every crumb) - a
BreadcrumbItemalso takes an optionaltitle, 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-drivenitems
- the slotted escape hatch is sturdier too: the runtime fill projected consumer crumbs through a
Form
NumberInputhonorsstep="any"for a free-form field. Withstep="any", typed values commit verbatim (no grid snap, no six-decimal precision cap) and, with nomin/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 like1e-3) adoptsNumberInputinstead 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, testedclampNumberhelperRadiotakesbind:groupin@xoji/svelte. Agroupprop (bindable) drives the idiomatic Svelte radio-group binding: each radio is checked whengroupequals itsvalue, and selecting one setsgroup, so a group of choices is<Radio bind:group={choice} value="a" />instead of hand-wiringcheckedand anonchangeon every option. A standalone radio keeps its own two-waychecked. Surfaced while dogfooding a settings surface built end to end from the form controls, where the group had no idiomatic binding
Components
Avatargrew apulsefor a live / online presence. Apulseattribute breathes the status dot on a soft opacity loop, so an avatar reads as live in real time: a barepulseruns the slow cadence,pulse="fast"the quick one, both on theBadgedot’s own keyframe and easing, so no new keyframe and no new token. It is a no-op without astatusdot to animate and holds still underprefers-reduced-motion, and the live meaning must ride onstatusLabel, never the motion alone. Rendered through the one avatar fill so all three bindings carry it, with a typedpulseprop in@xoji/svelteand@xoji/astroand aLive presencedemo card<xoji-avatar-group>, the overlapping-avatar stack. Slot a set ofAvatarchildren 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. Setoverflowto the number of people beyond the shown set and it caps the row with a+Nchip in the neutral tone (its accessible name reads “N more”);sizematches the chip to the avatars,spacing(snug/normal/loose) tightens or loosens the overlap, and alabelnames therole="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,
currentColorpinned, novar(--…)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
Buttonit 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/svelteButtonnow, carrying its own tones (warnfor Reset,dangerfor 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 bakedgetAlgorithmregistry 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 noawait. 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 lintclean, and a test guards every slot for a description and a capability - The importable surface reaches the canonical sandboxed algorithm now.
@xoji/core/algorithmsgainedresolveAlgorithmandsnapshotAlgorithm: a plainimport { 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.getAlgorithmstays 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.
derivenow takes ananchorsshape ({ bg, fg, accent }) alongside the rawconstraintsmap, folding it into the same pinned-token channel (accent→--accent, and so on) soderive(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 aTypeErrornaming the real channels instead of handing back a plausible wrong theme constraintsFromis exported from@xoji/core. The helper that builds a pinned-token map from{ bg, fg, accent, overrides }is now on the main entry besidederive, so the explicit seed path is self-sufficient without reaching for a subpathauditRegister, a register-level contrast audit: the consumer complement to the algorithm-levelgauntlet. 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.optstakes a passlevel(AA / AAA) and alargeTextfloor, and a partial register is graded on the pairs it has without throwing. Reaches three ways: the importable API, axoji auditCLI command (exit 1 when a pair misses the level, for a CI gate), and anxoji_auditMCP tool besidexoji_coverage; the Bench’s own contrast report reads through it, so the site, the CLI, and the tool all agree on one pair listargbToRgbHex, a blessed converter for alpha-first ARGB colors, and a loudcontrastcontract.contrast(fg, bg)reads CSS hex, where an 8-digit value is alpha-last (#RRGGBBAA); an alpha-first#AARRGGBB(the Material Color Utilities / AndroidColor.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#RRGGBBthrough, 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; insteadcontrastandtoOklchColornow 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 withgetComputedStyle, which returns nothing when the theme lands after the element mounts (a scopedapply(register, { target })on a widget, or a first render before connection), so every segment fell back to a single flatcurrentColorwhile a:roottheme worked. A sharedreadLiveRegisterhelper 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:roottheme 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/c2color slots point at real tokens now. The icon color ladder mappedc1→--fgandc2→--bg, but the algorithm produces--fg-0/--bg-0(the base foreground / background), so a mark coloredc1rendered black andc2rendered transparent instead of tracking the theme. Both slots now resolve to--fg-0/--bg-0, so acheck-badge--circle-c5--check-s55-c1draws 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 amax-width/max-heightonly, so the modal shrink-wrapped to its content, and an image with an intrinsic ratio but no intrinsic size (an SVG with aviewBoxand nowidth/height) collapsed the dialog to0x0, opening an invisible lightbox. The dialog now takes a definite92vw/92vhframe and the image fills it withobject-fit: contain, so any image (a sized raster or an intrinsic-less SVG) shows centered and contained; the image ispointer-events: noneso 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-hoverderives as--linkstepped in lightness, and two cases collapsed it onto--linkand 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#ffcfe1or 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. Alink hover distinct from linkgauntlet invariant guards the line across the full battery, exempting only the genuine pole where a mid-gray page forces--linkto 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 Buttonno 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 thearia-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 warnsAppShellno 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 untouchedTreeandSegmentedno 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 whosevalueheld 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 avalueTablestyles 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 thexoji-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 aMutationObserverand 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 loopBar,Pie, andSparklineare now reachable from@xoji/svelte. The chart wrappers shipped as files but were never added to the package barrel, soimport { Sparkline } from "@xoji/svelte"failed to type-check and the elements never registered through the wrapper. All three now export fromindex.tsand register alongsideStatandTree; a binding-parity test guards against a wrapper shipping un-barrelled again- The standalone
.xoji-dotindicator can pulse now..xoji-dot--pulse-slow/--pulse-fastbreathe a chip-less dot (a connection light, a per-row streaming pip) on the same keyframe and two speeds theBadgedot already uses, held still underprefers-reduced-motion. No new tokens; the badge live demo grew a chip-less status row - A headless
Tabsitem no longer needs a dummypanel.TabItemData.panelis optional, so atablist-modeitemsarray is a clean{ value, label, disabled? }[](the panels come from your own content in that mode) instead of forcing an emptypanelon every item to satisfy the type. The runtime is unchanged; a full items-mode tab still carries itspanel - 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), andToast’s body (message, action label, and close label) interpolated their consumer-provided text straight intoinnerHTML, so a string carrying markup could inject it into the page. Each value now passes throughescapeHtml/escapeAttrat its interpolation site, matching theHeatmaptooltip, 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/4fan 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 theaccentsscheme (theBardefault) rendered indistinguishable series. The fan now floors its own base chroma, so a near-gray accent fans into distinct faint tints while the primary--accentstays 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/4fan’slineage()edges are honest now. The derivation graph declared a wheel-style chain (accent-4derives fromaccent-3andaccent-2,accent-2from--accent-shift-step), but the shipped fan is split-complement:accent-2andaccent-3flank the primary accent andaccent-4is its 180° complement, none reading another fan slot. Solineage()(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, soaccent-4names--accentalone, 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 theresolveGraph(lineage) === deriveand byte-identical-to-baked tests); a new lineage test holds the fan edges to the actual derivation. Found dogfooding by pinning--accent-2and 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 acrossButton/Badge/Kbd/Bar/Panelwith 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 setsdata-xoji-fill-erroron 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
| tests | count |
|---|---|
| passed | 710 |
| skipped | 10 |
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), andstatus(success / warn / danger), an explicitstring[]overrides with the caller’s own colors, and areverseflips any scheme end for end- it exposes
SERIES_TOKENSso 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
- it exposes
<xoji-bar>, an interactive bar chart: grouped orstacked,verticalororientation="horizontal", its colors drawn from ascheme/reverse/ explicit array off the live theme, with an autocolorBythat 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;heightsets 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
- a value axis with gridlines, category labels, an optional
<xoji-sparkline>, a word-sized inline chart: aline, a filledarea, or a minibarrun in a singletonefrom the theme roster, with an end dot on the latest point, auto-ranged or pinned withmin/max, and sized through--spark-width/--spark-heightto sit inline in text or beside a Stat- sweeping across it floats a marker and the value at the nearest point;
role="img"plus alabelcarry it to assistive tech
- sweeping across it floats a marker and the value at the nearest point;
<xoji-pie>with adonutvariant: parts-of-a-whole as arc slices colored by the palette (skittlesby default), the donut carrying the total in its center; an optionalshowValuesfor percentages, a legend, andreverse- 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 anactionattribute that makes the card itself a button: it reflectsrole="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 itinteractivestays exactly as it was, the presentational lift plus a:focus-withinring for a card whose own content carries the control (a link or button inside);actionis 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 anoptionsinput for type-ahead: pass astring[]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’slistto 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 amonoattribute 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, sospellcheck="false"on a code field orinputmode="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
- the two elements now forward a known set of native input attributes (
<xoji-segmented>optionsnow 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 showingMarkdown/HTML/EPUBwhile the change carriesmd/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 barestring[]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 declaresdata-closablefor a built-in close button,data-actions(a JSON{ id, label, icon? }[]) for direct header buttons, anddata-menu(a JSON menu-item array) for a kebab overflow rendered by the real<xoji-menu>, so the overflow carries headings, accelerator hints, and adanger-tinted row for free- a header button or a menu row both report through one
panel-actionevent ({ panelId, actionId }); a close fires a cancelablepanel-closefirst, so a consumer can veto the removal (an unsaved-changes guard) before the zone drops the panel and re-reports the tree throughlayout-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-settinglayoutre-adds it, since the panel is still a DOM child the zone recovers, soclosePanelis the seam adata-menu“close” row wires to - the Svelte binding adds
onPanelCloseandonPanelActioncallbacks besideonLayoutChange, 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
- a header button or a menu row both report through one
<xoji-dock-zone>grew a second leaf mode,stack, for the tool-rail half of an editor shell: wheretabs(the default) shows one active panel behind a tab strip,stackrenders 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-leafmode(and acollapsedid 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>witharia-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 rideslayout-changeso it persists; each section carries the samedata-actions/data-menu/ close chrome a tab does modeandcollapsedtravel in the serializableDockNode, and newtoggleCollapsed/setLeafModehelpers 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
- set
<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>: aTreeNodetakes abadge(trailing text after the label, a count or status, shown always) andactions(a{ id, label, icon? }[]of hover-revealed row buttons)- a row button fires a
tree-actionevent (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 theirlabelas 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-actionwithout selecting the row, and normal row selection is untouched
- a row button fires a
<xoji-stat>split itstrendinto 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 newsentiment(positive / negative / neutral) drives the delta colorsentimentdefaults to the trend’s own reading (up is positive and green, down negative and red, flat neutral), so every existingtrend="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 structuredoptionsgrew two per-option fields, so a data-conditional view switch can adopt it instead of hand-rolling a radiogroup: adisabled(a choice the current data can’t offer) and abadge(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 adata-badgenow, 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
stackmode; it is decorative (aria-hidden), so the tab’s accessible name stays its title (put anything a screen reader must announce indata-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-2at a relative size); browser-proven on the live tabs and a stacked rail
- the badge rides on every panel’s tab, not just the active one, and on its stacked-section header in
- the
@xoji/svelteTabswrapper took alazyprop, 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’spanelsnippet 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
- with
<xoji-kbd>took atoneaxis (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::partoverride<xoji-badge>took apulsethat breathes itsdotto read as live, streaming, or connected, mirroring Progress’s own two-speed pulse (slowat 1.8s,fastat 0.9s) and stilled underprefers-reduced-motion- components carry an optional
sinceversion 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 analign(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 headlesstablistmode 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
DockLayoutthe docks ride, so it survives a reload like any docked panel, and a re-docked float returns to the zone it tore out of
- a float persists on the same one
Algorithms
- the default algorithm’s accent fan is symmetric either way now: pin either
--accent-2or--accent-3and 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 withvisibility: hidden, which keeps itsposition: absolute; width: max-contentcontent box in layout, so an up-to-18rem panel anchored near a pane’s right edge pushed that pane’sscrollWidthpast 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 discretedisplaytransition where the engine supports it, andshow()opens the panel before it measures so placement still reads a real box
- a closed tip now leaves layout entirely (
<xoji-statusbar overflow="collapse">collapses in light DOM now, the Astro and SSR path the site itself renders: it read its cells throughslot.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
- 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-splitter>no longer paints its focus ring on an ordinary mouse drag: the handle focuses itself onpointerdownso the arrow keys can drive it after the click, but a scriptedfocus()mid-pointer trips the browser’s:focus-visibleheuristic, 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-visibleoverride it used to need and still get its keyboard focus cue
- 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
- the
@xoji/sveltewrappers no longer drop anaria-labelpassed in the kebab form: twelve wrappers spread{...rest}onto the host and then setaria-label={ariaLabel}on the line after, so a consumer writing<Field aria-label="...">(which arrives through...rest) had it overwritten withundefinedby 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 camelariaLabelprop and the kebabaria-labelattribute land; a guard test holds every current and future wrapper to the non-clobbering form
- the explicit binding falls back to the rest-passed value now (
- 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 ownline-heightnow, so a host that collapsesline-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)
coverComponentstakes 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’sconsumedTokensin one call instead of a hand-rolledgetComponentloop, and a CI gate fails fast on a typo’d idcoveragegained the matchingcoverage({ produced, consumed })object form; both are additive, discriminated overloads, so the CLI and MCP callers keep their positional form unchanged
| tests | count |
|---|---|
| passed | 489 |
| skipped | 10 |
v0.2.0: Docking & MCP
Components
<xoji-progress>took ameterattribute that reportsrole="meter"(a measurement against a capacity, like disk used) instead of the defaultrole="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 thevalue-format/value-position/colorize-valuereadout options join them on the reference page, which had shipped them silently - the Svelte
Progresswrapper reached parity with the element and the Astro wrapper, which had pulled ahead: it now surfacesvalueFormat,valuePosition, andcolorizeValue(plusmeter) as typed props instead of dropping them
- its already-built threshold engine is now documented and demoed:
<xoji-menu>grew the shape a real app menu needs: a{ heading: string }item opens a labeled section (arole="group"named by the heading) that the following actions sit under, and an action takes ahintfor a trailing accelerator keycap likeCtrl+S, rendered muted and monospaced at the end of the row- the heading is
aria-hiddenso it shows without a double read, and it is not a focus target, so arrow navigation walks only the actions; the hint isaria-hiddentoo, 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 carriespart="item"too, so a consumer can reach a single row from the light side without forking the menu
- the heading is
- the typographic trio,
<xoji-heading>/<xoji-text>/<xoji-eyebrow>, took the full tone roster: alongside thedefault/muted/subtleemphasis ramp, any of the 21 tones (the semantic roles, the accent variants, the twelve named hues) now paints colored type, so adangerheading or asuccesseyebrow 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
accenttone moved from--accent-textto the brighter--accent-vividto match the rest of the roster, the punchier on-surface accent the other tones already use
- each tone renders in its on-surface ink (
<xoji-splitter>resets its size on a double-click of the handle, mirroring<xoji-slider>: it restores thedefaultsize, or the size it first rendered with whendefaultis omitted; the keyboard reset follows the same fallback<xoji-code>took acaptionattribute: 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 (aleft/right/top/bottomsplit or acentertab) 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
regionsnarrow the accepted drops, corners resolve to the nearer edge, and a pointer over nothing returnsnullfor a float; verified against real browser geometry, with the dockingdock-zone/dock-panelelements that ride on it to follow
- pure geometry, the same shape as the overlay positioner: a target’s
- the docking seam grew its state half: a serializable zone/panel tree (
DockNode) plus immutable mutations (dockPanel,removePanel,activatePanel,parseLayout) from@xoji/core/elements, soresolveDropsays where a panel lands anddockPanelapplies it to the layout- a
dockPanelmoves 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 isJSON.stringifyandparseLayoutreads 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
layoutRectscloses 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 andresolveDropreads back as its targets, so the engine round-trips from tree to rects to drop to tree
- a
<xoji-dock-zone>lands the drag-and-drop dockable-panel workspace on top of that engine: its children are the panels (any element with adata-panel-idand adata-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-changeevent carrying the serializable layout tree, and setting thelayoutproperty restores a saved one, so a workspace persists across reloads; the tabs are real<button>s withrole="tab"andaria-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’sDockZonesurfaces the savedlayoutas a prop and reports each rearrangement through anonLayoutChangecallback, and@xoji/astro’sDockZonerenders 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
layoutattribute authors a split multi-zone workspace up front with no hydration script, the markup twin of the JSlayoutproperty 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
--bordertoken the register never produced onto--line, closing the component-coverage check at 51/51
- every rearrangement dispatches a
- recategorized the reference catalog so each component sits under what it is:
Eyebrowmoved into Display, andSplitterandDock Zoneinto Shell
Fixes
- clearing a value-bearing control through its
valueproperty now empties it, so a form that resets its model (each bound value goingundefined) clears the boxes instead of leaving stale text.<xoji-field>,<xoji-textarea>, and<xoji-select>reflected an emptyvalueto the attribute but never wrote it to the live inner control, which is dirty once typed into, soel.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 whatbind:valuerides 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 onwindowinstead 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/corequick-start in the README importedxojiDefaultfrom the neutral entry, where it never lived, so the documented first call derived againstundefined; 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 throughslot.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 pinnedopen. 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 andcontent-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 atop-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 throughString.prototype.replacewith a$1...$2replacement string, so a$17.50table 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/algorithmsre-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/coreentry can’t carrygetAlgorithmitself (the blessed presets live in the siblingalgorithms/workspace, outside the engine’s compile root, and are bundled separately), so the batteries surface is where the two halves meet
- the neutral
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, andxoji_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
- tools for
- consolidated the concept narrative into
@xoji/core(the new@xoji/core/conceptsentry), one source read by both the MCP and the site’sllmsgenerator xoji derive(andxoji coverage) took a repeatable--set <token>=<value>flag (alias--constraint) that pins any token, not just the three--bg/--fg/--accentheadline anchors, so a full multi-anchor recipe (a secondary accent, font stacks, radii) bakes straight from the CLI instead of dropping to the importablederiveAPI- the leading
--on the token is optional (--set radius-md=10pxand--set --radius-md=10pxboth pin--radius-md), and the pins feed back into derivation like any constraint, so pinning--accent-2re-coheres its-bg/-fg/-textfamily
- the leading
Docs
- documented the MCP server: a
/mcpreference page on the site,xoji mcp --helptext, and a README section - generated
llms.txtandllms-full.txtat the site root so agents can read xoji’s reference the way they read its dependenciesllms.txtis a curated index: the guides and every component page, grouped by category, each with its one-line summaryllms-full.txtinlines 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.txtat 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/astroits missing@xoji/coredependency, so a freshnpm install @xoji/astroresolves the@xoji/core/markup,@xoji/core/elements, and@xoji/coreimports its components make - corrected the
@xoji/coreand root READMEs to match the engine:constraints(notanchors) is the derive input, the register is276tokens across the seven-dimension contract, and the knob list now carriesaccentSplitandcues
Tooling
- added a GitHub Actions workflow that publishes
@xoji/core,@xoji/svelte, and@xoji/astroon each release through npm OIDC trusted publishing - tightened the docs and the per-package READMEs
| tests | count |
|---|---|
| passed | 238 |
| skipped | 10 |
v0.1.0: Genesis
The spine chapter. The architecture settled and got written down, then went from spec to a working system: an OKLCH derivation engine mapping a small set of anchors and knobs into a full design-token register, a coverage contract as the one hard rule between what components consume and what an algorithm produces, the five blessed algorithms lifted into real xript mods that run in a zero-authority sandbox, a single-source custom-element component library with thin Svelte and Astro bindings, and a reference site that derives its own theme live. Everything below is the work that took each of those to green.
Architecture
- settled the spine: a named, swappable algorithm (a literal xript plugin) maps
anchors + knobs + overridesinto a full token register, and a theme is a materialized invocation of one; the algorithm is the durable, reusable engine and a theme the result, a split about reuse rather than worth - defined the open register (authors declare new tokens and rewire any derivation) with a coverage check as the only hard contract, between what components consume and what a module produces
- ruled out an engine-level “can’t look bad” gospel: invariants are per-algorithm policy, proven by a gauntlet parameterized by algorithm
- laid out the three input tiers (algorithm internals / knobs / token overrides) and a discovery model that is an index over npm, not a hosted registry
Engine (packages/xoji)
- built the derivation engine to green:
derive(algorithm, { anchors, knobs, constraints })over an open token graph, OKLCH color math viaculori, an open emitter set (css/json),coverage(), and a per-algorithmgauntlet() - shipped
xoji-default, the neutral built-in algorithm: surfaces / content / accents / status / state overlays / links derived frombg + fg + accent, with a WCAG-AA contrast floor it enforces and proves across 100+ random and extreme anchor sets - made constraints honored, not patched: pinning an input (
--accent,--bg-0,--fg-0) re-enters derivation so dependents re-solve around it (pin--bg-0to white over a near-black anchor and--fg-0re-derives to clear AA); the gauntlet asserts every invariant still holds under a pin - kept the dual-entry discipline physical: the neutral
indexand its imports carry nonode:*/DOM; Node-only code lives incli.ts, browser-only indom.ts - gamut-mapped accent rotation (constant lightness/hue, chroma reduced to the sRGB boundary) with a rotation-fidelity invariant, after an adversarial review caught hex-clamp hue/chroma distortion
- extended the type scale with
4xland5xldisplay steps: the modular ratio now climbs two stops past3xl(≈2.49rem / ≈2.99rem at the default scale), so a hero title is an algorithm-derived token rather than a hand-tuned clamp; all five blessed algorithms produce them and still derive byte-identical to baked - made a derived theme carry its own
color-scheme: bothemitCssand theapplyDOM helper now stampcolor-scheme: darkorlight(read from--bg-0’s lightness viaschemeOf), so native form controls (select dropdowns, scrollbars, date pickers) render in the theme’s mode instead of the OS preference; a light xoji theme no longer gets a dark dropdown on a dark-mode machine, found dogfooding the Select page - let a flavor preset bake its own accent;
defaultAnchors.accentis now optional (a new exportedPresetAnchorstype) and honored at derive exactly like a call-site anchor: declare it for a brand color, omit it for the bg-derived default- exported
DEFAULT_ANCHORSandSHARED_KNOBSalongsidemakeXojiAlgorithm, so a flavor author composes from the same primitives the blessed five use instead of restating them
- exported
- made status
*-textcarry the algorithm and stay visible on every surface:- the readable-ink sweep now seeds its chroma from the vibrancy knob, so a loud theme’s status text is vivid and a quiet theme’s stays calm, all of it still clearing the contrast floor; it used to ignore vibrancy and ship the same muted ink whatever the algorithm
- high-contrast mode stopped tuning
*-textto pure black/white for a saturated*-bg; that pairing had left status text ~1:1 against neutral panels, so Stat trends went invisible and half the Breadcrumb tones vanished;*-bgnow stays a tint and*-textroutes through the same panel-readable sweep as every algorithm, and the panel-contrast invariant guards extreme mode too
- gave every tone the same four-token family: each of the 21 tones (the six semantic roles,
accent-2/-3/-4, and the twelve named hues) now derives the uniform--{tone}/--{tone}-bg/--{tone}-fg/--{tone}-textset, so any component renders any tone through one code path instead of only badges reaching the named colors- a gauntlet invariant proves each tone’s solid pairing (
-fgon--{tone}) and soft pairing (-texton--{tone}-bg) clears AA across every algorithm, and it surfaced a latent gap (accent/neutral soft tints weren’t contrast-guaranteed), now fixed by deriving those tints to clear AA against their own ink - the achromatic poles (
white/black) compute a soft ink toward the opposite end, since theirsubtle/strongstops share a lightness and can’t pair against each other - gave the four status roles their own mutual-distinguishability invariant: the same perceptual OKLab-distance guard the code scopes already carry, now on
--success/--warn/--danger/--info, so two status fills can never derive close enough to read as one (mistaking success for danger is a usability failure WCAG contrast is blind to); floored well under the muted reality (xoji-quiet’s nearest pair sits ≈0.044), it guards a real collapse without policing taste; surfaced dogfooding seven brand palettes where the colors held but nothing enforced it, and the shared OKLab-distance helper now backs both the status and code-scope guards - added a fifth family member,
--{tone}-vivid, for vivid tone-colored text on a neutral surface: the ink at the tone’s hue that clears AA on the page while carrying the tone’s own theme-scaled chroma, so it stays perceptually even across hues and keeps its punch in light mode instead of blowing out to gamut-max neon; distinct from-text, which is muted because it must also read on the soft tint and so overshoots panel contrast;Stat’s trend deltas (up, down, flat) now use it, reading as a confident green and red rather than washed out; it derives for the whole tone vocabulary now: the base accent and its ramp (accent-2/-3/-4), the four status roles,--neutral, and the twelve named hues (--green-vivid,--pink-vivid, and the rest), so any tone in the register can be spoken vividly, with the achromatic named hues (white/black/gray) resolving to a readable ink on the panels rather than a color
- a gauntlet invariant proves each tone’s solid pairing (
- opened the register to non-color intent tokens, the first being
--selection-cue(tint | marker); a new cross-cuttingcuesknob (color | redundant) drives it and high-contrast emitsmarkerby default, so accessibility intent the algorithm can’t say in a color, a redundant non-color cue, now rides the same register and coverage contract as every other token- the coverage lint learned the
@container style(--token: …)consumption shape, so a token a component branches on (rather than reads throughvar()) still counts as consumed
- the coverage lint learned the
- gave intent tokens typed value sets: a
KEYWORD_DOMAINSregistry declares each keyword token’s legal vocabulary (--selection-cue→tint | marker), and the format invariant rejects any algorithm that emits outside it; the gauntlet now catches a stray--selection-cue: sparklebefore it ships, the safety rail the intent-token layer needed before its vocabulary grows- closed the loop with the consume side of that contract:
lintStyleQueryDomainsreads every@container style(--token: value)branch out of a component’s CSS and rejects a value outside the token’s declared domain, so a misspelled cue keyword (amarekrthat would silently never match at runtime) fails the build the same way an out-of-domain emit does; wired into the registry token-contract test, it now guards every component alongside the existingconsumedTokenslint
- closed the loop with the consume side of that contract:
- derived a syntax-highlighting token family (
--code-*) so an editor themes from the same anchors and algorithm as the chrome; change the accent and the highlighting re-themes with it, no app-vs-editor clash:- a canonical scope set that adapters fold the long TextMate tail onto:
--code-keyword / -string / -number / -function / -type / -variable / -comment / -operator / -punctuation / -tag / -attr / -regexp, plus the surface tokens--code-bg / -fg / -line-highlight / -selection - the eight colored scopes fan across the hue wheel anchored on the accent and sit inside the lightness band where
--code-bgalready clears AA, with hue decorrelated from lightness so a tight editor bg can’t crowd two scopes into one ink; comments recede at a relaxed floor, and a near-gray accent falls back to a stable base hue instead of collapsing the wheel - a mutual-distinguishability invariant the gauntlet enforces beside the per-token AA floors, by perceptual OKLab distance rather than luminance-only contrast (which is blind to two equally-light hues), so no two colored scopes can derive to the same color
- two adapter emitters alongside
css/json(xoji derive --format prism|monaco): theprismemitter maps Prism’s token classes onto the family through the custom properties, so a themed page re-colors its code blocks live as the theme switches; themonacoemitter builds a MonacoIStandaloneThemeData(rule foregrounds + workbench colors, scopes folded onto Monaco’s token names), dogfooded in a live editor across the blessed themes
- a canonical scope set that adapters fold the long TextMate tail onto:
- deepened tone pinning to the depth the accent ramp already had: pinning a tone solid (
--danger,--neutral, any of the twelve named hues) now re-hues its whole derived family, so a pinned--dangercarries its hue through--danger-bgand the on-tint--danger-textinstead of leaving them on the catalog red; the--color-*swatch ladder keeps its own monotonic lightness, and an achromatic pin is gated out so a gray can’t re-hue a family around a meaningless angle- the depth used to be the accent ramp’s alone: pin
--accent-2and-3/-4re-thread, but pin--greenand only the solid moved while its tint and ink stayed catalog-green; palette and semantic tones now reach the same depth, dogfooded by pinning--dangerviolet and watching all three Badge fills (solid / soft / outline) re-hue and stay readable
- the depth used to be the accent ramp’s alone: pin
- set the default accent ramp to a split-complement fan:
accent-2/accent-3flank the accent at ∓half the shift step andaccent-4is its 180° complement, so one anchor fans into a harmonious multi-accent palette (a red accent → magenta and amber flanks with a teal complement) rather than an even hue sweep; it is gated behind a single constant so the prior even-wheel fan is a one-line revert, and a pin on any rung still holds while the rest keep their fan - made the twelve named hues track the accent instead of a fixed catalog that ignored the theme: each named color (
--red,--green,--pink, …) keeps its canonical hue but derives its chroma from the accent’s own chroma about a neutral reference, so a vivid accent fans out vivid named colors, a muted accent mutes them, and a floor keeps a near-gray accent’s colors recognizable as themselves; the lightness ladder biases gently toward the accent’s lightness, the canonical hues stay put, and the swatch ramp keeps its monotonic ladder with the per-hue base-hue invariant still holding - kept solid controls from vanishing into the page: every solid fill that paints directly on
--bg-0(--accent,--neutral, and the four status fills) now clears a minimum1.5:1separation from it, pushed along lightness away from the surface with its hue and chroma intact; found dogfooding hostile anchors: an achromatic-dark accent collapsed--accentonto--bg-0(both#141414, a primary button you couldn’t see), and a mid-gray page sank--neutraland--dangerwith it; the per-token AA gauntlet never caught either, because each fill’s own text still read; it was the fill against the page that disappeared; a healthy chromatic fill already clears the floor and is untouched, a pinned fill is honored verbatim, and a newsolid fills separate from --bg-0invariant guards the line for every algorithm - floored the divider tokens so a border is always a border:
--line,--line-2, and--field-bordereach clear a minimum contrast against the surface they delineate (1.5:1for the hairlines,1.8:1for the stronger divider), pushed toward the readable pole when a fixed lightness step would collapse; found dogfooding a pure-black-pinned--bg-0: a card’s border derived to#0e0e0eon a#000000page (1.09:1, invisible), and with the elevation shadow black-on-black too, a bordered card lost every separation channel at once and dissolved into the page; mid-range themes are untouched (a hairline already clears the floor); a newborders separate from their surfaceinvariant guards the line for every algorithm - fixed the achromatic named hues reading blue:
--gray,--white, and--blackderived their on-color inks (-fg,-text, and the swatch-contraststop) at a hardcoded hue, so agraychip’s text came out a slate blue (#abc0d7) and ablackbadge’s a navy; identical across every theme, accent-blind; the achromatic ramp now derives its readable ink at zero chroma, so the text lands on the gray axis: neutral ink on a neutral tone; chromatic hues are untouched; found reading the derived register hex for a violet brand theme, a contrast-safe wrong the per-token gauntlet never caught - softened every tone’s
-bginto a true wash so the soft-tint contract holds the same shape across the whole roster: a named hue’s-bgused to reuse its swatch ramp’ssubtlechip, a near-full-strength color that read garish as a full background, and now derives a pale wash sitting just off--bg-0at a fraction of the tone’s chroma, like--accent-bgalready did, with-textre-derived to clear AA on it; the--color-*swatch ramp keeps its own chip, and the achromaticwhite/black/graykeep their lightness identity since a uniform wash would collapse them into one gray- the same pass fixed the four status
-bgcollapsing to pure white on light themes; the tint lightness was reaching above the page and clamping out, so a softdanger/success/warn/infosurface showed no tint at all; it now sits just under the surface like the accent tint, restoring the soft-alert and awareness-band case that had silently gone invisible
- the same pass fixed the four status
- defined the canonical theme file: a self-describing, re-derivable artifact carrying
meta(provenance),recipe(the algorithm plus theanchors/knobs/overridesthat print it, the source of truth), andtokens(the materialized register, a cache so a consumer applies the theme without ever running the engine);buildThemeFile/serializeThemeFile/parseThemeFileship from@xoji/core, the JSON Schema is published atxoji.dev/schema/theme.v1.json, andxoji derive --format themeemits one straight from the CLI - made bare
xojiprint the usage banner instead of silently dumping a default theme’s CSS: a no-argument invocation now routes to help like every other CLI, so the first thing a curiousxojishows is what it can do, not a wall of custom properties (found dogfooding the CLI) - gave the
gauntletCLI a fast spot-check path:--mode baked(the default) derives natively instead of crossing the sandbox, so proving an algorithm’s invariants across every algorithm (-a all --depth quick) takes seconds where the hosted path took minutes;--mode hostedstill runs the shipped sandboxed mod for the production battery,--depth quick|standard|fulldials the run count, and the report names the mode and depth it ran so coverage is never silently narrowed
Algorithms as xript mods
- lifted the five algorithms out of baked TypeScript into real xript mods, each a
mod-manifest.jsonplus an esbuild-bundled, self-containedmod.js; run through@xriptjs/runtimein a zero-authority sandbox and deriving byte-identical to the baked output across the whole matrix - shipped
nxi-niteas the fifth blessed algorithm and the worked example ofpasses: a Day/Night taste that adds anhourknob and layers a time-of-day pass over the base derivation, shifting the whole palette as the hour moves; it derives byte-identical and clears its gauntlet like the other four - gave authors an ergonomic surface at
@xoji/core/authoring:defineXojiAlgorithm({ … })for a taste-vector preset (xoji-defaultcollapses to a single line) anddefineAlgorithm({ derive })for a from-scratch algorithm, both importing core by name like any third party would - exposed color primitives to mods as the
cutihost binding, gated by thecolor-mathcapability; one implementation shared by the engine and every mod, so a token a mod derives can’t drift from one the engine derives - made the sandboxed mod the canonical resolution path (
resolveAlgorithm) for the CLI and the site, with an in-process derive cache so repeat derivations skip the sandbox; the site’s ~37 identical derives collapse from ~5.8s to ~0.16s- the whole CLI now resolves through it:
derive,coverage, andgauntlet, so the invariant proof runs against the sandboxed mod that ships rather than a baked copy of it - baked
getAlgorithmstays as the byte-identical test oracle and as the browser’s synchronous first-paint fallback (before the async mod load lands); a newsnapshotAlgorithm(id)exposes an already-resolved mod synchronously, the seam a sync caller will use to warm the hosted cache and then drop the baked fallback
- the whole CLI now resolves through it:
- added an in-browser authoring loader;
loadAuthoredAlgorithm(source)runs author-writtendefineAlgorithm/defineXojiAlgorithmsource through the hosted sandbox with no bundler, prepending a pre-built authoring prelude (the whole@xoji/core/authoringsurface, bundled once) to the import-free source and supplying it on the sandbox scope, so a self-authoredderivebody runs with the samecolor-math-gated isolation a third-party pack gets, never the baked path- the prelude exposes the full surface, not just the two
definehelpers; a from-scratch author reaches the engine’s color math (oklch/formatCss/contrast/ …) and the pass helpers (settlePass/runPipeline/ …) a custom pipeline needs, every one a pure function that adds no authority to the zero-authority sandbox
- the prelude exposes the full surface, not just the two
Components (@xoji/core · @xoji/svelte · @xoji/astro)
- shipped a single-layer custom-element library with thin Svelte and Astro wrappers over the same elements; the element is the component, styled only against the tokens it declares it consumes
- added Splitter, a resizable-pane divider, and made
AppShell’s rails resizable with it:- a
role="separator"control that resizes an adjacent pane by drag or keyboard, clamping a px size tomin/max, snapping to an integralstep, and writing the result into a CSS custom property a grid or flex track reads, so the layout resizes declaratively - it fires
resizelive through the drag andresize-endon release (and on each keyboard commit), so a consumer can preview or persist;orientationpicks the axis,reversedflips direction for a trailing-edge rail, double-click resets to adefault AppShell’s body grid is now var-driven (backward-compatible), so a splitter can size a rail; the site’s navigation dock is now draggable, clamped, and snappable, the first dogfood of the new control
- a
- made every binding render from one source, ending a duplication that had each Astro component reimplementing its markup in light DOM:
- a component’s markup now lives once in
@xoji/core/markup(pure, DOM-free), consumed by both the custom element’stemplate()and the Astro binding - the Astro binding emits a declarative shadow root from that source, so a component renders fully styled with zero JavaScript (component CSS inlined for the no-JS first paint), then hydrates in place onto the one shared constructable stylesheet; the result is the encapsulated widget set an app wants, with an SSR-fast, SEO-friendly static render when the runtime never loads
- a
staticprop on each Astro component renders it zero-JS on purpose, never loading the runtime to hydrate it - host attributes carry every render-affecting prop (and array data as the JSON the element parses), so hydration reproduces the server markup byte-for-byte instead of reverting to defaults; the always-render-on-connect base wires each element’s events on hydration, so a control hydrated from a declarative shadow is live, not just styled
- 46 components moved to the single source;
Table(a light-DOM decorator over a real<table>, no shadow to share) andAppShell(whose responsive grid is governed by global CSS spanning the shell and its slotted rails, which a shadow boundary would sever) stay light-DOM by design
- a component’s markup now lives once in
- enforced the coverage contract per component: a manifest declares
consumedTokens, a lint checks them against the CSS (component-internal props don’t masquerade as theme tokens), and the reference site shows live coverage against a derived register - added app-grade form controls: Slider (range, full keyboard, pointer-capture drag), Color Picker (an HSV field with hue and alpha tracks over a checkerboard,
#rrggbb/#rrggbbaaI/O), Number Input (a spinbutton with steppers, bounds, and decimals), and Segmented Control (a radiogroup toggle bar); each form-associated and driven live in a real browser- gave Slider a built-in
show-valuereadout with aformathook (a(value) => stringproperty, so0.8reads80%) and ahide-labeloption that keeps the accessible name while dropping the visible one; both retire the wrapper a consumer had to write around it
- gave Slider a built-in
- gave Button a controlled toggle state; a
pressedattribute reflects toaria-pressed(set it for on,pressed="false"for off, omit for a plain button) and paints the press overlay so an engaged toggle reads as sunken across every variant; it reflects state rather than self-toggling, so an active tool or mode flag stops having to masquerade as avariant- added a
selectedsibling (reflected toaria-selected) for selected-in-a-set semantics, and a densexssize belowsmfor compact toolbar pills; both consume only already-produced tokens (--state-selected,--text-xs,--space-0)
- added a
- gave the Code block a copy-to-clipboard button: it sits top-right, fades in on hover or focus, copies the raw source rather than the highlighted markup, and flashes a vivid
Copiedbefore reverting- on by default; pass
copy="false"to drop it - the fragment ships the button
hiddenand the custom element un-hides it only where the Clipboard API is present, so the zero-JS Astro path and insecure contexts never paint a dead control - the click is element-owned, so the zero-authority fill sandbox can’t reach the clipboard; the button markup still lives in the fill, so a skin can re-style or drop it
- gave it a
wrapoption for long lines: soft-wrap instead of a horizontal scrollbar, driven purely by a host attribute so it needs no JavaScript and works on the zero-JS Astro path - gave it
line-numbers, a counter gutter that sticks to the left edge as the code scrolls sideways and reads dimmed like a comment; it’s tag-aware, so a token spanning lines (a block comment, a multi-line string) still numbers cleanly, and it co-operates withwrapso a wrapped line keeps a single number anchored at its top- pure derived chrome borrowing
--code-commentand--field-border, so it adds no tokens and renders on the zero-JS Astro path too
- pure derived chrome borrowing
- gave it
highlight, a 1-based line spec (2,2,4,4-6) that tints the lines that matter with--code-line-highlight, a token the algorithm already derived but nothing in the component had ever painted; it pairs withline-numbers, holds the tint full-width even as a line scrolls or soft-wraps, and renders on the zero-JS Astro path
- on by default; pass
- taught Tree to honor
--selection-cue: when a theme sets it tomarker(high-contrast, or any algorithm withcues: redundant), the selected row gains a non-color check glyph beside the accent tint via a CSS@container style()query, so selection clears WCAG 1.4.1 instead of resting on color alone; the first component to consume an intent token - grew that intent-token honoring to the other selected-in-a-set controls: Tabs and Segmented now gain the same non-color check glyph on the selected tab or segment when
--selection-cueresolves tomarker, so a control distinguished only by an accent fill stops resting on color alone; both consume the token the register already produces (no coverage change), with the glyph on::beforesince each already owns its::afterstate overlay - opened every general-purpose tone-driven component to the full vocabulary:
Button,Badge,Avatar,Progress,Radio,Spinner, and thenSwitch,Slider,Checkbox, andSegmentedtake any of the 21 tones through one sharedFULL_TONESlist, so a pink button, a teal switch, anaccent-3slider, or a purple checkbox renders its own AA-cleared color instead of needing app-author CSS- each component’s checked/selected/fill surface routes through a per-tone rule, several through a
--{component}-fill/-inkvariable so the existing disabled and state rules keep winning without a specificity fight; every manifest’sconsumedTokensregenerates fromFULL_TONESso the coverage lint stays exact, and the reference demos grew an “every tone” showcase rendered straight from the list - then split
AlertandToastonto two independent axes instead of locking them to status:severity(success/warn/danger/info) carries the meaning, the status glyph and the live-region politeness, sodanger/warnannounce assertively, whiletonecarries the color from the full vocabulary; a severity paints its own--{severity}family by default; atoneoverrides that color (adangerrepainted pink still announces as danger); and a color-onlytonewith no severity shows no glyph and announces politely, the awareness-banner case a status-locked component forbade; a bare status-namedtonestill infers its severity, so existing markup keeps its glyph and announce level (an earlier pass had narrowed both back to the four roles to dodge a wrong-glyph wart; the wart was a conflation of color and meaning, not a reason to forbid color) - gave both an
iconslot that overrides the built-in severity glyph across all three bindings; the auto-glyph stays as the slot’s fallback, and a custom icon shows even on a severity-less banner
- each component’s checked/selected/fill surface routes through a per-tone rule, several through a
- gave the neutral container components an opt-in accent edge:
Cardtakes atonethat paints a leading accent bar andDocka toned rail edge, both off by default so an untoned card or rail keeps its plain surface; the tone marks it without recoloring the whole container - aligned the bindings on
FullToneend to end: the custom-elementtonesetters onButton,Spinner,Avatar,Progress,Badge, andRadiohad lagged at the six-roleTone(or the 18-toneBadgeTone) while their css, manifests, and wrappers already spoke the full vocabulary, so a TS consumer assigningtone="purple"hit a phantom type error; the elements now type the full set, while a presence dot’sstatusstays the six semantic roles where a free color would be wrong - gave Statusbar an
overflowprop for when content outruns the strip:clip(default) hides the spill,wrapflows items onto another line,scrolladds a horizontal track; it was a plain flex row that just overflowed before- landed the flagship
collapsemode: aResizeObserverranks cells by a per-celldata-priority(adata-requiredcell never drops), folds the lowest-priority ones first when the row can’t fit, and tucks them into a+Noverflow popover built on the native Popover API so it escapes the bar’s own clip; the responsive statusbar a consumer was blocked on - let the consumer own the overflow contents for rich cells:
collapseclones dropped cells into a shadow popover by default (right for plain text, but a clone can’t carry light-DOM styles or handlers), so a newmanual-overflowsuppresses the built-in popover and the element instead fires anoverflow-changeevent carrying the actual dropped cell elements; the consumer renders its own popover with styles and click handlers intact, while the element keeps owning the ranking - gave it a
separatedmode so the bar draws the divider between cells itself, at its own spacing, and each item stays a single cell thecollapseranking counts independently, instead of every item carrying its ownSeparator, which were extra cells that muddied the drop counting and pulled their spacing from whatever wrapper they sat in; the leading rule is suppressed at the start of each run (the first item and any item after aspacer) and stays right as items collapse, and it closed a latentcollapsebug found dogfooding the footer: cells defaulted toflex-shrink: 1, so they shrank instead of overflowing and the detector never tripped, clipping the right edge instead of folding into+N
- landed the flagship
- built the reference site on Astro: per-component pages with live demos, anatomy, props, accessibility, and coverage, all rendered under a live-derived theme
- hardened the Dialog contract: a closed
<dialog>stays hidden (author-origin styling no longer overrides the platform’s hide-when-closed rule), and a dialog that brings its ownheaderslot names itself with a newlabelprop, sincearia-labelledbycannot reach a slotted title across the shadow boundary - regrouped the reference catalog by what a component is (Controls for the things you click, toggle, and drag, Form for the fields you fill in), so the index reads true instead of piling every input under one heading
- added Accordion to the controls: a stack of collapsible sections paired from
[slot="header"]/[slot="panel"], single-open by default ormultiple, with a settableheadingLevel, arrow/Home/End keyboard, and the WAI-ARIA heading-button-regionwiring; the first of the two controls that turn the docs’ own side nav into dogfooded components - added Tree to navigation: a data-driven hierarchy (an
itemsarray oflabel/href/childrenwithexpanded/selected/disabledflags) rendering the WAI-ARIAtree/treeitem/grouppattern with one roving tab stop, the full Up/Down/Left/Right/Home/End keyboard, single selection, and link leaves; the pair’s second control and the natural shape for a docs or file sidebar - added Kbd to data display: a semantic
<kbd>keycap derived entirely from existing chrome tokens, a raised--bg-2face with a weighted--border-thickbottom edge for depth instead of a drop shadow, set in--font-mono, so it matches the themed UI it documents and adds nothing to the algorithm;sizestepssm/md/lg, and a row of keys in aClusterspells a chord; the contract proven from the other side, a new component covered entirely by the produced set - added Swatch to data display: a color chip pairing a colored dot with an optional name and a mono value, the shape every
label + value + dotrow and palette rail is built from; the color it shows is data (an inlinecolor, not a token), while its own chrome (the dot’s border, the label, the value) is derived theme, so the chip themes with the UI while displaying anything;sizestepssm/md/lg- gave it three opt-in interactions:
interactiverenders the chip as a<button>that emits aselectevent for a pickable palette,selectedrings the dot in the accent and reflectsaria-pressed(controlled, so the consumer owns single-vs-multi select), anddetailsreveals a hover/focus popover reading the color acrosshex/rgb/hsl/oklchthrough the engine’sparseColor/formatColor; the display chip stays the zero-JS default, and adetailschip is keyboard-reachable viatabindexandaria-describedby, not hover alone
- gave it three opt-in interactions:
- added Menu to the overlay set, a menu-button: a trigger that opens a data-driven (
items) popup of actions, separators, and disabled entries, built on the native Popover API so it escapes any ancestor’s clip; it follows the WAI-ARIA menu-button pattern (aria-haspopup/aria-expandedon the trigger,role="menu"/role="menuitem"in the popup) with one roving focus and the full keyboard: Enter/Space/Down open to the first item, Up to the last, arrows skip disabled and wrap, Home/End jump, Escape closes and returns focus to the trigger, and activating fires aselectevent with the chosen item; the last of the consumer’s missing primitives - added Pagination to the navigation set, a
<nav>walking a paged collection: previous/next arrows around a windowed page list with ellipses, the visible range computed frompage+totaland tuned bysiblings(links each side of current) andboundaries(links pinned at each end); give it anhreftemplate with{page}and every page is a real link, zero-JS navigation that works on the static Astro path, while omitting it renders pages as buttons that fire apage-changeevent with the chosen number; the current page is a tone-colored pill carryingaria-current="page", prev/next goaria-disabled(not removed) at the ends, every page and arrow has an explicit accessible name, and the ellipsis is decorative andaria-hidden; consumes only already-derived chrome and tone tokens, so it adds none - opened Breadcrumb to the full tone roster, the last component still capped at the six semantic roles; its ancestor links now take any of the 21 tones, drawn at the panel-readable
--{tone}-vividink rather than--{tone}-text:-textis only contrast-guaranteed against its own soft tint, so it was never a safe link color on the page once the named hues arrived, while-vividclears the floor against every surface by construction; the switch also closed a latent gap where the status links (danger/success/warn/info) had quietly ridden the not-page-guaranteed-text - blessed subclassing as a first-class extension path:
XojiElement’s protected surface (root/template()/styles()/render()) is now documented as a stable contract, so a consumer can extend a shipped element to add a behavior it leaves out (or extendXojiElementdirectly for a fully custom element) and still ride the shared token sheet and coverage contract; a new “Extending components” docs section shows it with a livexoji-toggle-buttonthat subclasses the controlled Button to flip its ownpressedon click; with thepressed/selectedstate seam already landed, this closes the composability gap a consumer raised: the out-of-the-box base is now extensible into the richer thing, not a dead end - put the new controls to work: the docs sidebar’s component listing (once bespoke
<details>markup) is now a live Tree, categories to pages, with the open page selected and its group expanded; the navigation that catalogs every component is now itself one of them - exposed a color-conversion surface from
@xoji/core:parseColor(any CSS Color 4 string → an sRGB pivot),formatColor(clean, rounded output inhex/rgb/hsl/oklch), and HSV ↔ sRGB, all culori-backed, so a component shares the engine’s color math instead of hand-rolling its own - overhauled Color Picker I/O on that surface: the value field reads out in a switchable
format(hex/rgb/hsl/oklch, cycled by a button) and accepts any CSS Color 4 string, reformatting on commit (OKLCH entry is live), and the wrapper now ships across all three bindings; the per-model 2D plane and a popover trigger are the remaining surface - gave the picker an eyedropper: where the browser exposes the
EyeDropperAPI (Chromium today) a button samples any pixel on screen back through the core parser, and it’s omitted entirely where the API is absent so it never shows a dead control - added preset swatches to the picker: a
swatcheslist renders clickable color chips below the field, alpha-aware over a checkerboard, each a toggle button that loads its color and reads pressed when it’s the active one - added a WCAG contrast panel to the picker: set
contrastAgainstto a reference color and it shows the live ratio of the current color against it, with AA/AAA grades that go green on pass and red on fail, read from the samecontrastthe engine derives with - made the picker’s opacity opt-in: the alpha track was always on, which baked an assumption most colors don’t need; an
alphaknob (off by default) now gates the track and whether the value carries an alpha channel; without it the picker drops any pasted alpha and reads out clean opaque values across every format - widened the picker’s color models from four to seven, adding
lab,lch, andoklabto@xoji/core’s conversion API (culori-backed, full round-trip in and out), and gave the picker amodesknob: a comma-separated, ordered subset of the formats theformatbutton cycles, so an author offers exactly the color spaces they want (perceptual-only, say) instead of the whole list - added CMYK as the eighth model: the one space CSS can’t parse, so
@xoji/corecarries its own naive, profile-freecmyk()formatter and parser;cmyk(C% M% Y% K%)reads out and types back in, with an optional/ alpha, and joins the configurablemodesset (ahex,rgb,cmykpicker covers web-and-print in one) - gave the picker a
harmonymode: acomplementary/triadic/analogous/split-complementary/tetradic/monochromatic/shades/tintsscheme generates a row of related colors from the current one, each chip clickable to adopt it and the whole row recomputing live as the color changes; theharmony()generator lives in@xoji/core(hue rotation and proportional lightness ramps over HSL), so the relationships are the engine’s, not the component’s - gave the picker per-channel sliders: a channel-model API in
@xoji/core(channelsOfreturns a model’s channel defs,colorToChannelsreads a color’s channels in display units,colorFromChannelsrebuilds it, exact round-trips across all eight models) backs achannelsknob that names a model (rgb/hsl/hsv/oklch/lab/lch/oklab/cmyk) to render a stack of native range sliders, one per channel, each labelled with a live numeric readout and editable directly; every edit round-trips through the engine math and re-threads the rest of the picker, so the decomposition is the engine’s, not the component’s - gave the picker palette snapping,
nearestWebSafe(quantize each channel to the 216-color web cube) andnearestNamedColor(the perceptually closest CSS named color, by OKLab distance over the 148-color set) joined@xoji/core, behind asnapknob that adds buttons for either:web-safequantizes, andnamedreads out the nearest color’s name live on the button and adopts it on click; the matching is the engine’s, the buttons just call it - gave the picker an OKLCH perceptual plane,
oklchToDisplayin@xoji/corereturns a coordinate’s clamped sRGB color plus whether it was in gamut, and aplaneknob paints a lightness × chroma<canvas>field at the current hue from it: out-of-gamut colors desaturate toward their own luminance (greyed, not darkened, so the gamut edge doesn’t read as “just dark”) with a contour drawn at the boundary, the chroma axis sizes itself to each hue’s in-gamut reach so the field isn’t mostly dead zone, and a liveL · Creadout names the axes; drag or arrow the handle to set lightness and chroma- fixed the plane drag drifting hue as it crossed the gamut boundary: the hue is now captured once at drag start, so the whole drag stays on one slice instead of compounding the per-clamp shift
- gave the picker a popover
trigger, set it and the picker collapses to a swatch button (with a corner caret and a hover lift, so it reads as openable) that opens the full UI in a nativepopover: top-layer, anchored below the button, with outside-click andEsclight-dismiss and focus return for free, plus a close-on-scroll so the panel never drifts from its anchor - put the picker to work in its own home: the Bench’s three theme anchors (background / foreground / accent) now open the
ColorPickerintriggermode instead of the OS<input type="color">window, so picking the colors that drive the whole derivation happens in the same anchored popover the rest of the studio uses, and the theme studio composes from@xojicomponents down to its most-used control - taught Number Input a second granularity: an
altStep(10×stepout of the box) that amodifier(Shift by default, oralt/ctrl/meta) swaps to on a click or arrow, withPageUp/PageDownalways taking the big jump andaltDefaultflipping which step is primary; the snap grid follows the finer of the two, so a fractional alternate survives - added Stat to data display: a single metric as a tabular
valueunder a small uppercaselabel, with an optionaldeltawhosetrend(up / down / flat) tints it and pairs the tint with a directional arrow, plus acaptionfor context; pure presentation across all three bindings, built to line up digit-for-digit in aGriddashboard strip, and the first of the structural primitives the site refactor surfaced- gave it an
inlinevariant: a horizontal ticker that lays the label, value, and trend delta on one baseline for a status strip or a dense row instead of a dashboard tile; the value is just the slot, so it reads non-numeric text or a link as readily as a figure, and the footer bar’s every cell (algorithm, scheme, the by-the-numbers, license, updated) is now one of these
- gave it an
- added Section to layout, a structural page strip in two shapes: a
band(aplain/quiet/accentsurface with optionalborderedhairlines and apaddingrhythm that eases off under 40rem) and astage(an elevated, accent-tinted frame with an optional cornerlabel, the demo ground the docs sit live examples in)- refit the homepage and the Bench onto it: the hero, the by-the-numbers band, and both stages now compose from
Section, and the bespoke.x-band/.x-stagerules are gone - fixed a drift bug it surfaced: the by-the-numbers band referenced an
x-band--quietclass that was never defined, so it silently rendered as a plain accent band; it now carries a realquiettone - then opened its band
toneto the full 21-tone roster:plain/quietkeep their surface-raise meaning while every semantic role, accent variant, and named hue now tints a band throughvar(--{tone}-bg), so a soft awareness strip or a status band is atonerather than bespoke CSS; the fragment already emitted the per-tone class, so only the css, the type unions, and the manifest options had been capping it
- refit the homepage and the Bench onto it: the hero, the by-the-numbers band, and both stages now compose from
- added Eyebrow to layout: the small uppercase kicker above a heading, accent-toned by default with
muted/subtletones and anormal/widetracking; one element with no layout of its own, so aStackgap does the spacing- refit every site consumer onto it (the homepage hero/numbers/pillars kickers, the docs and Bench intros, and the components index header), then deleted the duplicate
.x-eyebrowand.cx-eyebrowscoped rules
- refit every site consumer onto it (the homepage hero/numbers/pillars kickers, the docs and Bench intros, and the components index header), then deleted the duplicate
- gave Heading and Text an
accenttone: a fourth ink off the ramp that tints to--accent-text, for a highlighted title, a metric figure, or an emphasized word; the homepage’s by-the-numbers figures now usetone="accent"instead of a scoped.numbers__valuecolor rule - gave Heading two display sizes (
4xl,5xl) on the newly extended scale, and put them to work: the homepage hero title and the Bench heading now compose fromHeadingat display size, retiring the bespoke.x-displayclamp (and a dead copy of the class that had been silently doing nothing on the Bench) - added Card Link: a
Cardthat is itself one<a>, so the whole card is the click target (header/body/footer slots, theinteractive/overlay/compactlooks,interactiveon by default, the underline and ink reset, afocus-visiblering on the card); the reference-page prev/next pager now composes from it instead of aLinkwrapping an interactiveCard, dropping the link-reset scoped rules it needed - added Toc, an on-this-page table of contents that scrollspies the section in view: an
itemslist of{ id, label }renders a labellednavof in-page anchors, and anIntersectionObservermarks the active link witharia-currentand the accent rail (links work with no script, the spy only decorates); folds to a chip row on narrow screens, takessticky; the reference pages’ on-this-page rail now composes from it, retiring the bespoke.ref-onthispagestyling and its hand-rolled scrollspy script, and with it, the last of the component gaps the site refactor surfaced - fixed two Tooltip bugs found dogfooding the reference page
- the
@xoji/astrobinding silently dropped thecontentslot: Astro strips the routingslot=off a bare forwarded<slot>, so a rich-content tooltip got no tip and warned to the console; the forward now wraps in a real<span slot="content">, the shape the Svelte binding already used - tooltip text was capped at
--space-8(a 32px spacing token), wrapping every word onto its own line; content now reads atmin(18rem, calc(100vw - var(--space-8)))
- the
- finished the overlay positioner at the Tooltip: a tip near a viewport edge already flipped to the side that fit, but its cross-axis never clamped, so a left- or right-hugging tooltip spilled off-screen and a top-pinned side tip ran off the top edge; the tip now shifts back inside the viewport margin and counter-shifts its arrow the other way so it keeps pointing at the trigger, bounded so the arrow never leaves the content’s own edge, on both axes (horizontal for
top/bottom, vertical forleft/right); the shift rides two component-internal custom properties the element sets from the sharedplaceOverlaygeometry, so with them unset the no-JS/SSR layout stays exactly centered; a puretooltipTetherShifthelper carries the math under unit test, and the Menu and Swatch overlays already clamped through the same positioner; verified in a browser at all four edges: every tip lands inside the viewport with its arrow still on the trigger - grew the Tooltip past a one-line label: it now carries the same vocabulary the rest of the set speaks; a
tone(any of the register’s 21) colors it: a leading edge bar over the neutral overlay by default (the readable choice behind real content) or, withvariant="soft"/"solid", a fully washed or filled surface with a matching arrow, the same soft/solid shape Alert and Section use; amodeofrichopens the tight hint into a roomier, left-aligned panel that wraps multi-line prose and holds structured content (a detail readout, a stat or progress bar, a definition), andsize(sm/md) dials the padding, all four axes orthogonal; the reference page grew a tone row and a rich-content showcase; coverage tracks the new tone family; caught dogfooding it: the edge bar’s width default sat on the content element, where it out-specified the toned root’s value and rendered every edge-mode tip colorless, moved to the root so the tone inherits cleanly - fixed Tree keyboard navigation for nested items: every treeitem carried its own
keydownlistener and the event bubbled up through the ancestor treeitems, so an arrow key on a nested node ran the handler again on its parent and the outermost one jerked focus to the wrong row;onKeydownnow stops propagation, so only the focused node handles its own key - gave Tree a
lockedbranch, a node pinned permanently open: forced expanded, no twisty, and every collapse path suppressed (row click,Enter, and←fall through to navigation instead of toggling), so a section that should always show its children reads as a fixed header rather than a collapsible group; the site nav’sComponentsbranch now uses it to stay a constant index across every page - fixed Table’s sticky header, which didn’t stick; Firefox made it obvious, but it was broken in every browser: the wrapper’s own
overflow-xmade it a nested scroll container the header bound to instead of the page, andborder-collapse: collapseblocks sticky table cells in Firefox outright; the wrapper is now the single scroll container via amaxHeightprop, and the table moved toborder-collapse: separateso the header’s own border travels with it, with theborderedgrid rebuilt to stay single-line through the switch - fixed three AppShell rail bugs found dogfooding the nav: the nav reflowed hard on load (the left rail sized to its content until the splitter’s JS ran;
AppShellnow takesleftSize/rightSizethat seed the rail width server-side), the nav dock didn’t scroll (its:hostcouldn’t shrink as a grid item;min-height: 0), and thelineSplitter left a transparent gutter between panes (it collapses to a hairline now, with a transparent overlay for the drag target, so the panes butt against the divider) - stopped AppShell overflowing the viewport on a narrow screen: the shell’s top grid had no
grid-template-columns, so its single implicit column sized to content and a page wider than the viewport pushed the whole shell off-screen, clipped rather than reflowed; aminmax(0, 1fr)column now constrains it to the viewport so the content reflows, and every consumer gets it, not just the site - fixed Tabs freezing live framework panels: the element snapshotted each light-DOM panel’s
innerHTMLinto its shadow, so a panel built from real components (effects, handlers, nested state) rendered as dead static HTML and the originals sat unslotted at zero size; light-DOM panels now project through a per-index<slot>so each one stays mounted and reactive, found building a studio whose tabs host live panels (the staticitems/SSR path that bakes panel HTML is unchanged) - gave Tabs a
stickyoption that pins the tablist while the active panel scrolls beneath it; off by default so inline tabs scroll away with their content, opt-in for app surfaces where the tabs should stay in reach; the flagship Bench’s main bar (Preview · Components · Report · Export) now keeps its place down the tall live preview, and offsetting it past a fixed header isxoji-tabs::part(tablist) { top: … }through the already-exposed part, so no new token joins the coverage contract - fixed Accordion the same way: it froze live light-DOM section panels into static HTML; panels now project through a per-index
<slot>too, and the Svelte binding gained asections+panel-snippet API (mirroringTabs) so a consumer composes live collapsible sections without tripping Svelte’s named-slot uniqueness rule- and the light-DOM control protocol behind both now reads
value/open/disabledas an attribute or a DOM property, since Svelte sets them as properties on a plain element rather than attributes, so a default-open accordion section and a tab’s selection key survive the binding instead of silently falling back
- and the light-DOM control protocol behind both now reads
- fixed Avatar loading a broken image when given only initials: the Svelte binding assigns
srcas a DOM property, and the element’s setter stamped the literal string"undefined"onto the attribute, so an initials-only avatar fetched…/undefinedand 404’d before falling back; reflecting string setters now drop a nullish value (a newreflectStringbase helper), so the attribute is simply absent - swept that same footgun out of the reflecting string setters across the library, a framework that assigns
el.prop = undefined(Svelte does, for custom-element props) had every baresetAttributestamp the literal"undefined", surfacing as a broken…/undefinedlink, an"undefined"form value, or strayundefinedtext; found dogfooding aSwatchpalette that renderedred undefined, then closed across the whole set:Swatch,Breadcrumb,Field,Checkbox,Radio,Select,Switch,Textarea,FormGroup,Dock,Grid,Link,CardLink,Menu,Panel,Section,Stat,Statusbar,Tabs,Toc, andToolbarnow route their optional string props throughreflectString(andLink.target/Section/Statlost a=== nullguard that letundefinedslip past) - made the Svelte wrappers transparent to arbitrary attributes: every one of the 52 now spreads
{...rest}onto its root element (an index-signaturePropsplus a...restahead of the explicit props, so a passedtitle/id/data-*/aria-*lands on the element while declared props still win); a consumer wanting a tooltip on a toolbar control no longer wraps it in a<span title>shim, closing a reported composability gap across the display, layout, and overlay wrappers that the form controls had already closed (snippets and handlers stay bound by name, so they never leak into the spread)
Site (apps/site)
- gave the Bench an Auto scheme that follows the background: the scheme knob now defaults to
auto, which hands the call to the engine (scheme ?? schemeOf(bg)), so dropping in a lightbgderives a light theme on the spot; the knob used to pin todark, and a light background under a dark scheme collapsed every surface to white, caught dogfooding a light theme- and a scheme still forced against its background warns inline with a one-click revert to
auto, so the collapse is guarded rather than only sidestepped
- and a scheme still forced against its background warns inline with a one-click revert to
- made the theme status honest: the toolbar badges and the status bar now track the active theme’s algorithm, scheme, and token count instead of a baked-in
xoji-default/dark, so a light theme readslighteverywhere it’s named - reset
color-schemewhen an active theme is cleared: clearing a light site theme back to the baseline used to leavecolor-scheme: lightstamped on a now-dark page, so native dropdowns and scrollbars rendered light-on-dark; the clear path now matches the engine’sapply/clearcontract - rebuilt the Bench as a graduated control surface: the algorithm is the only required pick, and anchors, knobs, and per-token overrides are optional layers stacked on top, so set nothing and you get the algorithm’s native theme, set everything and you’ve hand-built one; an unset anchor or knob falls through to the algorithm’s own default, and the old mutually-exclusive tabs (which made the studio read as “only three colors” when it had always been more) are gone; the per-token “pinning” mechanic became direct inline editing (every derived token is grouped by category and editable in place, touching one feeds it back as a constraint so dependents re-solve), and each input tier carries an at-a-glance set-count, with a clear-all on the token register
- made the site’s live numbers a single source: one
getStats()derives the canonical theme once and counts components, tokens, categories, and bindings off it, so every surface that quotes a stat reads from one place instead of a hardcoded literal; the stale312 tokens/34 manifests/179-tokencopy scattered across the homepage, the Bench, the docs, and four component demos is gone, and a committed baseline plus a release-time snapshot let the by-the-numbers band show+N since vXgrowth once a version ships - gave the site a self-hosted type set (REM for headings, Poppins for body, Teko for the wordmark) routed through the
xoji-defaultfontsknob rather than raw CSS, so the token register re-solves around them, and bundled via@fontsourceso the site makes zero third-party font requests - renamed the Docs route to Getting started and repositioned it as the on-ramp (
Overview → Getting started → The Bench → Components), since the component reference is the real documentation and the standalone page is the engine intro it always was - gave the Getting started page an on-this-page rail in the
AppShellright slot: the same scroll-spyTocthe component reference pages carry, so the engine intro tracks your place through install → derive → emit → apply the way the rest of the docs do - rebuilt the Bench’s main as a tabbed application surface: Preview · Report · Export on the
Tabscomponent, lifting the derivation Report (contrast · coverage · gamut · graph) out of a cramped right rail into a full-width tab; the studio is now a two-column rail-and-tabs layout andResetmoved into the document header - rebuilt the Bench’s control rail as an
Accordionof collapsible sections (Anchors · Knobs · Tokens, the first two open by default), each panel a live editor (color pickers, knob fields, the full token register), with theAlgorithmpick pinned above as the one required choice - gave the Bench a Components tab: a systematic gallery of the component set (buttons · form · feedback · navigation · data · typography · overlays) rendered live under the derived theme, distinct from the curated “sample app” Preview; change an anchor and the whole contract re-themes at once, the flagship “any valid theme renders well across the set” surface
- taught the Bench to round-trip themes as files: Export serializes the canonical theme file (recipe plus the materialized token register), and Import adopts either that envelope or a bare
---prefixed token map, so a flat--format jsondump pastes straight in as overrides on the default algorithm - gave the Bench control rail a fourth section, Palette: a swatch-forward editor for the nine chromatic named hues, each a color picker over a live five-stop ramp; re-hue one and its whole ramp and family follow on the spot through deep pinning, with per-hue reset and a clear-all (the achromatic neutrals sit out, since pinning them only recolors the alias, not the ramp)
- turned the Bench into a theme-and-algorithm studio; a new
Customalgorithm opens a live editor for adefineXojiAlgorithmtaste vector (anchors, vibrancy, chroma, contrast, elevation) authored as JSON, and editing the spec rebuilds the algorithm in-browser and re-derives the whole theme live, with invalid JSON surfacing in the error banner behind the last valid theme; the authored algorithm is its own thing, not a knob preset, so anchors and knobs still layer on top, the spec rides the share-link and the theme store, and the Export → Invocation tab emits it as reproduciblemakeXojiAlgorithm(toPreset(...))code; the author’s taste vector is pure data, so it derives in-process, with the arbitrary-derive-code tier through the hosted sandbox as the next step- made
toPresetbackfill a partial spec’s anchors over the engine default, so a{ bg, accent }taste vector with nofgbuilds a valid algorithm instead of one that crashes on derive - the code tier landed too; a
Custom codealgorithm opens an editor for import-freedefineAlgorithm/defineXojiAlgorithmsource that runs through the hosted xript sandbox (loadAuthoredAlgorithm), debounced so a keystroke doesn’t spin a fresh runtime, with the last good theme held until the build resolves or its error surfaces- unlike the in-process taste-vector tier, this is arbitrary code, so it stays off the share-link until the link schema is tier-tagged
- made
Docs
- recorded the design in
docs/:derivation-model.md(the spine),dimensional-contract.md(the token register),repo-layout.md(packaging, dual-entry, discovery), andopen-questions.md(the live forks)
| package | tests |
|---|---|
xoji | 1041 |