Give the workbench a new look from a small JSON file that stays readable by construction.
A skin gives a few seed colours per mode, a shape, materials, type and direct token overrides; Studio resolves it into the same Theme every painter reads, so a skin reaches everything a built-in theme reaches. Two rules hold for every skin:
- Skins pick, they never draw. A skin chooses values and vocabularies; nothing in the file is code.
- A skin that loads is readable. The resolver walks every ink that falls below its contrast floor on the surface it sits on until it passes, with its hue held. The built-in themes go through the same path and clear every floor as authored, so they render exactly as before.
In the UI the word stays Theme: the Display window's Theme picker lists the built-in table and then your skins. "Skin" names the file format.
The file#
~/Documents/Volta/skins/<name>/skin.json (the folder name is the skin's identity; VOLTA_SKINS_DIR overrides the folder for tests and volta-shot).
{
"skin": 1,
"name": "walnut",
"extends": "analog",
"modes": {
"dark": {
"palette": { "ground": "#2a1d12", "primary": "#ffb454",
"secondary": "#3fd2c0", "accent": "#ff7e5f" },
"tokens": { "--kwell": "#120c07" }
},
"light": {}
},
"contrast": "standard",
"shape": { "corner": 5, "stroke": 1.5, "light_angle": 315,
"glow": 0.4, "depth": 1.2 }
}
| Key | Meaning |
|---|---|
skin | Schema version; this build reads 1 and 2 (another version warns, still loads). Schema 2 adds assets and decorations: see Skin artwork and format-2 packs. |
name | Lowercase letters, digits, -, _; must equal the folder name. |
extends | A built-in family (analog), a full theme (analog-dark) or another skin in the folder. Absent: the :root defaults. Chains deeper than 8, cycles and unknown names are errors. |
modes | dark and/or light. Each mode becomes a picker entry, <name>-dark / <name>-light. |
modes.*.palette | Seeds: ground (the screen surface, --sc0) and primary are required; secondary, accent, ink, alert are optional. Every other colour token is derived. |
modes.*.tokens | Direct token values ("--pri": "#ffcc00"), applied after derivation. An override is an input to the solver, not an escape from it. |
contrast | standard (default) or high: every floor one tier up (4.5 → 7, 3 → 4.5, 2 → 3). |
shape | corner (px, --radius-control; card, panel and screen radii follow unless set in tokens), stroke, hair, light_angle, glow, depth, halo. A light mode's glow and halo are always 0. |
material | Per surface kind (bezel, screen, panel, card, well, popup): a recipe name — flat, gradient, bevel, inset — or { "recipe", "texture", "strength": 0–1, "scale", "angle", "period", "duty" }. Becomes --surface-<kind>, --texture-<kind>, --texture-<kind>-strength and --texture-<kind>-<param>, which the paint kit reads. Textures: brushed, grain, paper, walnut, concrete, weave, stripes, scanlines, greenbar, dots, anodised, carbon, none; their parameters are in § Textures. |
type | A bundled face per role — caption, label, heading, body, code, value, title, display — from geist-mono, geist-mono-medium, geist-mono-semibold, geist-mono-italic, barlow, barlow-medium, barlow-semibold, barlow-condensed-medium, barlow-condensed-semibold, the skin faces vt323 (pixel terminal), instrument-serif, big-shoulders-stencil (industrial stencil, bold), varela-round, knewave (brush display), default, or "file:<name>.ttf" — one of the skin's own font files (see Font files); tracking (label), caption_tracking, heading_tracking, display_tracking as fractions of the size (−0.05–0.4); caps (captions and headings in capitals; default true). Becomes the --font-<role>, --tracking-<role> and --caps tokens; Theme::type_style installs them as the type_scale::TypeStyle every role helper reads, so a silent theme paints exactly the static table. |
syntax | The editor's per-tag highlight, tag → colour, for both modes: comment, string, pattern, pitch-lit, number, keyword, osc, filter, env, lfo, pitch, macro, noise, unit, ident, operator, bracket (the --syn-<tag> tokens of colors.css § SYNTAX). A skin that gives none keeps its base's; a base with none takes the token-role recipe in ui/src/codeline.rs (keywords --brt, builtin families the rate palette, strings and patterns --sec, numbers --pri, comments --com). The six exploration-handoff families (signal, console, synthwave, analog, spectrum, acid, dark modes) carry their full palettes as these tokens, byte for byte what the editor painted before. |
| recipes | Component recipes, for both modes: knob, fader, meter, button and surface.<kind> (bezel, screen, panel, card, well, popup) → a recipe name from Recipes. Each becomes a token (--recipe-knob: dial, --surface-card: bevel); a mode's own tokens entry for the same token wins. An unknown key or value warns, naming the legal ones, and the base's choice stands. A skin that names none keeps its base's. |
| fx | The screen overlay (per mode too): opacity (--fxop), scanlines, grain, vignette, tint, phosphor — each a bare number or an object of its parameters. See Effects. | | rates | The signal-rate palette: "house" (default: the base's four), "tinted" (amount 0.35), { "tinted": 0–1 }, or all four { "audio", "control", "event", "pattern" }. See Rate harmony. | | backdrop | { "preset", "print", "amount" (0–1), "scale" (0.5–2), "intensity" } naming the backdrop library's presets, print styles and intensities. See Skin backdrops. | | modes.*.<section> | shape, material, recipes, type, syntax, rates, backdrop, fx, decorations per mode, merged over the document-level section key by key. See Per-mode sections. |
// line comments are allowed. Unknown keys warn and are ignored, so a skin written for a newer build still loads.
Per-mode sections#
modes.<mode> may carry any of shape, material, recipes, type, syntax, rates, backdrop, fx and decorations. The mode's section is merged over the document-level one key by key on the JSON (skin::mode_view) before the section is read: objects merge recursively, anything else replaces. So "material": { "card": { "recipe": "bevel", "texture": "paper" } } at the top and "modes": { "dark": { "material": { "card": { "texture": "weave" } } } } give the dark mode a bevelled weave card and the light mode a bevelled paper one. Because the merge never names a key, every key a section learns works per mode with no further code. Warnings and errors a mode's own values cause are reported at modes.<mode>.<path>; a mode section that does not parse is an error naming it. A child skin sees its parent's per-mode sections through extends.
Fill role#
--pri is the solved ink — text, strokes, marks — and the solver may move it. --pri-fill, --sec-fill and --acc-fill are the colours where the accent is an area (an on or pressed chip, a wash, the egui active widget, a primary button). Each defaults to its ink's value as authored, before solving, so a fluorescent pink on paper still fills its buttons pink while its text walks to a readable magenta; a tokens entry sets one directly. Floors: --onpri is solved to 3:1 on --pri-fill (the --onpri row measures on the fill when there is one), and on_pri_ink() lifts to 4.5 on it at paint. A fill under 1.5:1 against either screen tone keeps its colour and gains --pri-fill-edge: 1 (likewise sec/acc): the kit then outlines filled chips in the --pri ink. Theme::pri_fill() / sec_fill() / acc_fill() fall back to the ink, and no built-in table sets a fill, so every built-in paints exactly as before.
Rate harmony#
rates decides the four signal-rate colours (--audio, --control, --event, --pattern). house keeps the base's. tinted rotates each hue toward the authored primary's OKLCH hue by the amount (the short way round) and moves its chroma toward the primary's by the same fraction, lightness held; an achromatic primary moves chroma only. An explicit set replaces all four (fewer than four is an error).
Then the solver: the rates are solved for contrast like any ink, and the solved four must stay pairwise at OKLab ΔE ≥ 0.10 seen normally and under simulated deuteranopia and protanopia (Machado, Oliveira & Fernandes 2009, severity 1.0, in linear RGB; skin::rate_collision). Hue alone cannot do it: the house teal (--audio) and pink (--event) are one grey to a deuteranope, so every built-in light table's house rates sit ΔE 0.02–0.06 apart under deuteranopia (the built-ins are left as they are; house makes no claim). The solver therefore spreads colliding rates in OKLCH lightness, hue held, always AWAY from the ground so no contrast floor is lost (skin::separate_rates: each order of the four, each next colour stepped until it clears the ones before; least total movement wins). A spread is reported per rate as a warning (rates.event: #923c5c → #9f4868: lightness spread …).
tinted: tint, solve, spread within a small budget (0.16 of lightness in total). If the spread needs more, or the tint crowds the track palette (two adjacent tracks of the first 16 under ΔE 0.05, typical vision — the built-ins' closest adjacent pair is ~0.06), the amount is backed off 0.05 at a time and the report warnsrates.tinted: lowered 1 → 0.45 …naming the pair. At 0 the spread has no budget; a reading surface's texture is lowered first if it is what leaves no lightness.- explicit: solve, spread; when no spread separates them the skin does not load: an error at
rates.<name>naming the pair and the vision. - What cannot be helped: under
contrast: high, or on a mid-grey paper (OKLCH L under ~0.7), the floor pins the rates near black or white and there is no lightness to spread; arateswarning says so.
The per-track palette (timeline::lane_color, which the timeline, mixer, rack and song map draw from) is built from the four rate colours, so a tinted skin tints its tracks too; lanes past the four are inserted in the widest hue gaps of the anchors, as for every theme.
Skin backdrops#
backdrop names the backdrop library's real values: preset (a scene label, backdrop::Preset), print (a print style), amount, scale, intensity (auto, work, monitor, performance). An unknown name warns with the legal list and is left out; numbers clamp with a warning. They become --backdrop-* tokens, read back by Theme::skin_backdrop(). Selecting the theme applies them; a hot reload re-applies them only while the Display backdrop settings still equal what the skin last applied, so a later explicit choice in Display wins for the session. A built-in names no backdrop and leaves it alone. volta-shot --theme <skin>-dark reads the skin folder first, so the skin's backdrop is applied and the --preset / --print-* flags still override it.
Three depths of authoring, each a complete skin: seeds only (a palette; everything else derived and the base's shape kept), choices (add shape), overrides (tokens for any derived value).
Font files#
A skin may carry its own faces in fonts/ beside skin.json (~/Documents/Volta/skins/<name>/fonts/): .ttf or .otf, at most 4 files (by name order; the rest are refused), each at most 2 MB, named in type as "file:<name>" (a plain file name, no path). The skin folder watcher (app-native/src/skins.rs) reads them off the paint thread with skin.json and fingerprints their length and mtime, so replacing a font hot-reloads; a file over the size limit is listed, never read. The shell hands them to the UI in SkinFile::fonts (SkinFont { file, len, bytes }).
Before anything installs a file, fonts::check_font checks it with ab_glyph, the parser epaint loads fonts with: the extension, the size, a parse, units-per-em, a line height within 0.5–3 em, every printable ASCII character (95), an H with an outline (labels centre on it), and every glyph outline within 4 em (a larger one would trip epaint's glyph-atlas assertion). A file that fails is an error at type.<role> naming it (fonts/x.ttf (skin inky) refused: lacks 52 of the 95 printable ASCII characters …), or a warning at fonts/<file> when no role names it; hostile files never panic (fuzzed in fonts::skin_fonts). A file face is scaled so its capitals stand at the house cap height (0.70 em, scale bounded 0.8–1.3). Glyphs it lacks fall back to Barlow, then the house stack.
The token is --font-<role>: file:<skin>/<file> — keyed by the skin whose type named it, so a skin that extends another keeps its base's file. A resolved theme carries the checked files (Theme::user_fonts), and Theme::apply binds them to the eight volta-file-N families (fonts::use_user_fonts), rebuilding the font definitions only when the set changed — on a skin change, never per frame (fonts::builds counts it). skin::validate_with_fonts takes the folders' checked files, which is how volta-shot --skin <dir>/<name>/skin.json (it loads <dir>/<name>/fonts/) and the MCP skin tools validate a skin with fonts.
The five skin faces are SIL OFL 1.1, bundled in both the desktop and the browser build (about 350 KB, 160 KB gzipped), licences beside them in ui/assets/fonts/ and listed in THIRD_PARTY.md.
Recipes#
A recipe is HOW a component is drawn; the paint kit (ui/src/kit.rs) holds each one as Rust code, and a skin only names one. Every recipe paints inside the rect the caller gives it (only a shadow, glow, detent mark or focus ring reaches a few points past it), never sizes anything, and keeps the component's semantics: values, hover, detents, focus, lamps and inks behave the same under every recipe.
| Component | Recipe | Looks like |
|---|---|---|
| knob | arc (default) | 270° ring with the value arc, calibration ticks, a recessed face and a pointer; motion bloom while the value travels. The strip's pan pot is its compact size: a thinner ring, a raised cap face, no calibration ticks. |
dial | a hardware dial: a raised cap with a bevel lit from --light-angle, a pointer line, ticks at the ends and the detents | |
dot | minimal: a thin track ring and a dot at the value | |
notch | a flat disc with one notch cut at the value, the value arc outside it | |
| fader | cap (default) | the mixer fader: a recessed slot filled to a raised, ribbed cap with a drop shadow |
slot | a thin slot and a small bar handle across it, no cap | |
bar | a well filled from the empty end, a hairline head across the column | |
| meter | bar (default) | the console ladder: fine 2 + 1 pt LED ramp, head glow, peak hold, unity rule, clip lamp |
led | chunky LED segments (about one per 6 pt) with hairline gaps; the last doubles as the clip lamp | |
needle | a VU arc in zone colours with a heavier red zone and a needle; a rect that is not square-ish (aspect outside 0.8‥3) gets bar | |
| button / chip | flat (default) | fill plus outline, the house chip |
outline | outline only until on or pressed, then filled | |
bevel | flat plus a bevel lit from the light (reversed while pressed) | |
keycap | a solid key: bevel, a bottom skirt, the fill lightened toward the lit edge | |
pill | flat with a full-height corner | |
| surface | flat · gradient · bevel · inset | per kind; unset = the house default (the screen's top-glow gradient, flat elsewhere). A surface built as an egui::Frame (card_frame) takes the bevel or inset edges but cannot hold a gradient. |
glass | a translucent fill, frost, sheen and lit top edge over the backdrop field blurred under it — see Glass |
Labels sit on the face. kit::ControlPaint returns the control's visible face — the rect, less a keycap skirt — and chip and readout labels (and any widgets::paint_role label anchored at a vertical centre) centre their capitals on it through widgets::label_pos: the band from the baseline of the label's own face to the cap top of its H, with the trailing tracking left out of the width. Centring the galley's line box instead put all-caps labels up to a pixel or two low, by a different amount per face and per fallback glyph (■, ●), so neighbouring chips disagreed (widgets::tests::labels_centre_their_capitals_on_every_face_and_density).
The :root defaults (colors.css § RECIPES) are the painters the workbench always had, so every shipped family renders as before. Only the experimental stress skins choose: brutal notch knobs, bar faders, LED meters, outline buttons and every surface flat; pillow dials, cap faders, keycap buttons, bevelled cards and inset wells; onebit dot knobs, slot faders, LED meters and outline buttons.
The rules are tests (kit::tests, under spectrum, brutal, pillow and onebit): each recipe stays inside its rect plus a stated decoration margin; a knob or fader at 0, 0.5 and 1 paints three different shape lists; a meter at full scale shows all three zone colours; rest, hover, pressed and on are four different chip looks, and on differs from off by a luminance step of at least 1.2:1 or by shape, never by hue alone. They prove distinguishability under those themes, not taste; the kit sheet (ui/tests/skin_sheet.rs) shows every recipe for a person to judge (The skin sheet).
Derivation#
Seeds become the full token set in OKLCH (skin::derive): surfaces as lightness steps of ground (--bg, --bz0/1, --sc1, --kwell, --kgap), the border ramp as one ground-tinted neutral at stepped alpha, the ink ramp (--ink → --com) from the ink seed or a near-white / near-black tinted by the ground hue, --onpri as whichever of the ground tone and the ink reads on primary, the glow triplets from primary, --mid and accent. secondary defaults to primary rotated 150° in hue; accent defaults to secondary; alert to a red pitched for the mode. The rate palette (--audio …) and the SHAPE tokens come from the base, unless rates says otherwise (Rate harmony).
The solver and the pairing table#
skin::PAIRINGS is the contract: one row per (ink, surface, dark floor, light floor). The same rows drive the solver and the theme tests, so a floor is stated once.
| Ink | On | Dark | Light |
|---|---|---|---|
--audio --control --event --pattern | screen | 4.5 | 4.5 |
--alert | screen | 4.5 | 4.5 |
--pri --sec --acc | screen | 3.0 | 3.5 |
--ink --ink2 --brt | screen | 4.5 | 4.5 |
--mid | screen | 4.0 | 4.0 |
--lab | screen | 3.0 | 3.0 |
--com | screen | 2.0 | 4.5 |
--onpri | --pri-fill (else --pri) | 3.0 | 3.0 |
--syn-comment --syn-operator --syn-bracket | screen | 2.0 | 4.5 |
--syn-noise | screen | 3.0 | 4.5 |
every other --syn-* | screen | 4.5 | 4.5 |
"Screen" is the harder of the two screen tones: the darker of --sc0/--sc1 for a light mode (inks walk down), the lighter for a dark mode (inks walk up). A light ink is saturated 1.2× and walked toward black; a dark ink is walked toward white. Both moves hold the HSV hue exactly. A token that already clears its floor is left byte-exact; the built-in table is never touched (shipped_themes_resolve_byte_identical_to_the_old_path). When the pole itself cannot clear the floor (a mid-grey ground under contrast: high), the repair is recorded with reached: false: the ground is the problem, and the resolved value is the best there is. The paint-time recipes over the top (operational(), readout_ink(), on_pri_ink(), action_ink()) are unchanged and still apply to every theme.
Derived built-in families#
Three built-ins are authored through the skin derivation rather than by hand — seeds, shape and material in ui/src/skin.rs § authoring, pasted into colors.css post-solve as plain tokens — so the derivation is proven on shipped families and the range materials and type give is on the picker: studio (mid-grey hardware: brushed, bevelled bezel, bevelled cards, inset wells), stage (contrast: high; black or white ground, 2 px strokes, square, flat — projectors and low vision) and walnut (a wood bezel, paper-textured cards and panels). Re-derive them with cargo test -p volta-ui --lib -- skin::authoring --ignored --nocapture. On studio's grey ground the constant rate palette would fall under the text bar as authored, so its block carries the four colours lifted (hue held); every near-black family keeps them byte for byte.
Textures#
A texture is a greyscale luminance modulation of the surface it sits on (ui/src/texture.rs): it lightens or darkens the surface colour by at most its swing × the skin's strength, and it can never introduce a hue. Every tile is generated at first use from periodic functions and hash-based value noise whose lattice wraps at the tile, so nothing is decoded from disk and each tile's swing is measured exactly (texture::Spec::swing). A tile is cached per process and uploads once per context and parameter set, with repeat wrapping; it paints as two tiled image layers, white where the tile is lighter than mid and black where it is darker, each at |v − ½| · 2 · strength alpha. Under ordinary alpha blending that is a luminance modulation (egui has no multiply blend).
| Texture | What it is | Parameters | Measured swing at defaults (declared bound) |
|---|---|---|---|
brushed | fine horizontal streaks | scale, angle | 0.21 (0.22) |
grain | film grain, one hash per texel | scale | 0.28 (0.28) |
paper | low mottle and a faint fibre | scale | 0.16 (0.18) |
walnut | warped rings and pores | scale, angle | 0.36 (0.37) |
concrete | three octaves of blotch | scale | 0.30 (0.36) |
weave | crossed bands, over and under | scale, angle | 0.30 (0.30) |
stripes | hard bands, dark for duty of each period | scale, angle, period (8), duty (0.5) | 0.24 (0.24) |
scanlines | CRT lines: one dark line in three, horizontal | scale | 0.28 (0.28) |
greenbar | a tractor-feed printout's faint alternating bands, horizontal | scale, period (48) | 0.08 (0.08) |
dots | a halftone dot screen (6 px pitch) | scale, angle | 0.24 (0.24) |
anodised | very fine directional grain, far quieter than brushed | scale, angle | 0.08 (0.08) |
carbon | 2/2 twill weave of shaded tows (4 px cells) | scale, angle | 0.20 (0.22) |
The parameters, each a material.<kind> key and a --texture-<kind>-<param> token:
scale(0.25–4, default 1) — every texture. The tile edge is64 · scaletexels (quantised to whole texels), or for a banded texture a whole number of its periods, so the tile is seamless at every scale. Below 1 a noise texture box-filters each texel's footprint rather than point-sampling it (point samples ofweaveat ¼ land on its zeros).angle(degrees, clockwise, wrapped to 0–359) — the directional textures. Rotation turns the mesh's UVs over the repeat-wrapped tile rather than regenerating it: a rotated view of a periodic tile is the same periodic function, so it is seamless by construction at any angle, where a generator would have to snap every angle to a lattice direction that tiles. A rotated tile samples linearly (an unrotated one stays nearest, one texel per logical pixel), and bilinear sampling only averages texels, so rotation never raises the swing.scanlinesandgreenbarare horizontal by definition (a tilted band isstripes).period(px at scale 1, 2–128) —stripesandgreenbar.duty(0.05–0.95, the dark share of a period) —stripes.
A skin: 2 material may instead be a layer stack checked on its final composite (Skin artwork and format-2 packs § Layered materials); a texture is its one-layer case and paints unchanged.
An out-of-range value clamps with a warning; a parameter the texture does not take, or a parameter with no texture named, is ignored with a warning; an unknown texture name is an error listing the legal ones. Naming a texture (or none) resets the parameters a base skin gave the one it replaces. The banded textures and dots are zero-mean marks (the larger side at ± the amplitude whatever the duty), so a thin stripe is faint rather than a darkened surface; stripes at period 2 and duty 0.05 measures a swing of 0.013, not 0.24.
The solver treats a texture on a reading surface (screen, panel, card) as part of the worst case: every ink row is measured against the surface pushed toward white (dark mode) or black (light mode) by the largest swing × strength among them, the swing measured on the tile the skin's own parameters generate. When a floor is out of reach only because of that push, the resolver lowers the texture's strength (× 0.75 a step, to 0) until the ink can pass and says so in warnings (material.<kind>.strength: lowered 1 → 0.56 …); a floor the bare ground cannot meet stays reported with reached: false. The texture is never the thing that makes text unreadable. bezel, well and popup carry no reading text and take their texture at the authored strength.
Where a texture paints: every surface drawn through kit::surface, the shell's bezel and its pane grounds — Source, the right column, the split, the song outline, the Bench canvas (kit::ground, called first in the pane's body so the texture lands between the fill and the content) — and the cards. A card built from an egui::Frame goes through kit::show_textured, which reserves a slot right after the frame's fill and sets the texture into it once the frame's rect is known: widgets::card_frame wells (well), mixer track and VCA strips (card), the master strip (bezel) and the Perform macro cards (card). Cards the Rack paints straight onto a painter — graph nodes, grid and chain tiles, the module composer's nodes — call kit::surface_texture (card) right after their fill.
Effects#
A skin's fx section asks for a screen overlay (ui/src/fx.rs), painted as the frame's last layer (an Order::Debug layer above popups and tooltips; a painted layer senses nothing, so it never takes input). Built-in themes have no fx and paint nothing.
"fx": { "opacity": 0.6,
"scanlines": { "strength": 0.35, "pitch": 3 },
"grain": { "amount": 0.25, "motion": "static" },
"vignette": 0.45,
"tint": { "color": "#ffb000", "amount": 0.05, "mode": "add" },
"phosphor": 0.3 }
| Effect | Parameters | What it is |
|---|---|---|
opacity | 0–1 | The whole overlay's opacity, --fxop — the token the web overlay always used (opacity: var(--fxop)). Unset, a skin keeps its base's: 0.5 dark, 0.15 light in the house families. Each effect's own number is its strength at full opacity. |
scanlines | strength 0–1, pitch 2–8 physical px (default 3) | A black line a third of the pitch tall (at least 1 px) every pitch physical pixels, snapped to the pixel grid; 60 % black at strength 1. |
grain | amount 0–1, motion static (default) / animated | The grain texture tile at one texel per physical pixel, lightening and darkening. Animated grain re-seeds 12 times a second (and asks for a repaint that often); it never moves under Reduce motion. |
vignette | amount 0–1 | The corners darken toward black (70 % at amount 1), clear at the centre. |
tint | color (required), amount 0–0.3, mode over (default) / add | A colour over everything: an alpha wash, or an additive lift (what CSS screen amounts to at these alphas). egui has no multiply or screen blend, so neither is offered. |
phosphor | amount 0–1 | A soft additive haze of --pri, strongest at the window's centre. Not a bloom: a bloom needs the finished UI as a texture, which eframe does not expose; this is light the overlay adds, not a glow of what is drawn. |
A bare number is the effect's strength or amount ("vignette": 0.4). Numbers out of range are clamped with a warning; an unknown effect or parameter warns; a tint without a colour is an error.
Readability. The overlay yields to the text, never the reverse. The inks are solved exactly as without it (a skin resolves the same inks with or without fx); then every pairing row is measured under the overlay's charge (Fx::worst_pair), and where a row fails the resolver lowers --fxop (× 0.75 a step, to 0) until every row the bare ground carries passes, with a warning (fx.opacity: lowered 0.7 → 0.39 …).
The charge is a perceptual model, not a per-pixel floor. Effects that change faster than a glyph stroke count at their mean over one; effects that change slower count at the point:
- scanlines: line alpha × the dark fraction of a pitch (a 1 px line every 3 px counts a third of its alpha) — the average darkening over a stroke that spans a full pitch, charged to the lighter of ink and surface only;
- grain: 0.25 × its amplitude, toward whichever side hurts. The tile is zero-mean noise at one texel per physical pixel; a body stroke covers about 16 texels or more, whose mean spans about σ/4, and the tile's σ is under half its amplitude, so 0.25 × amplitude allows a local cluster at about two standard errors;
- vignette, phosphor haze and tint: at the point, both ink and surface, at the worst of seven distances from the centre (the haze peaks at the centre, the vignette at the corners).
So a single pixel row of a glyph under a scanline can sit below the floor; what the model holds is that the stroke, as read, clears it. In practice the vignette binds first: on signal-dark a vignette of 0.5 alone is lowered to 0.42 opacity, while scanlines 0.4 with grain 0.3 are carried at full opacity. Keep the vignette low for a visible CRT.
Cost. A handful of meshes per frame: one quad per scanline (about 600 at a 1800 px window and a 3 px pitch), a 17 × 11 vertex grid for the vignette and haze, two textured quads for the grain. Static effects ask for no repaint.
Crossfade#
When the theme changes — the picker, a hot reload of the skin on screen, the OS flipping light/dark under Follow system — colours travel from the old theme to the new one in OKLCH over 250 ms (theme::ThemeFade, driven from ui/src/app/theme_fade.rs). The two resolved token maps are paired once at the start (every colour both sides parse, plus the overlay's numbers); each frame interpolates those pairs into a copy of the target's map and re-applies the egui style. Everything else (recipes, faces, radii, dark) is the target's from the first frame. When it ends the theme is the target, byte for byte. Reduce motion skips it; the first theme a session shows (and so every volta-shot --theme) snaps. A volta-shot --drive state theme=… lands on the settled theme after 15 frames.
Glass#
"material": { "panel": "glass" } (any surface kind) paints a surface as frosted glass:
- the kind's fill at 78 % alpha (a glass panel's ground never fills more than that, whatever Panel opacity says);
- a frost (the
graintile at 0.10), a sheen (white 6 % at the top, clear by 40 % of the height) and a lit hairline along the top edge; - when the reactive background is on, the field under the surface's rect is really blurred: the backdrop pass renders the field a second time into a quarter-resolution texture and draws it back, under each glass rect only, through two rings of bilinear taps (
ui/src/shaders/glass.wgsl,ui/src/backdrop/glass.rs). That draw sits after the field and before the scrim and the panels, so the scrim veils the blurred field exactly as it veils the sharp one and no UI — text included — is ever blurred.
Limits, stated plainly: what is blurred is the backdrop field (fragment scenes, Chladni's membrane without its GPU grains, the Liquid composite), never other UI behind a glass popup — eframe gives no access to the finished UI. At the default Work field level the scrim lets about 3 % of the field through, so the blur is visible mainly at Monitor and Performance or with the panel opacity lowered. With the background off there is nothing behind to blur, and glass is the tint, frost, sheen and edge alone. The frost and sheen count in the solver's surface swing.
The report#
Resolving returns a SkinReport: ok (no errors), errors (the skin does not load: a parse error, an unknown extends, a bad seed, a name that does not match its folder, a cycle; each with the JSON path), repairs (token, surface, floor, authored, measured, resolved, reached) and warnings (clamped shape and fx values, unknown keys, a light mode asked to glow, a texture or the overlay lowered for readability). skin::validate wraps it for a whole file: it parses, resolves every declared mode against the other installed skins (the file under test replacing its own name among them) and returns one report per mode, as JSON { ok, name, errors, modes: { dark: SkinReport, … } } — what the MCP tools and volta-shot --skin print.
The skin sheet#
ui/src/skinsheet.rs paints one fixed 600 × 780 pt image through the kit itself: every surface kind in the theme's recipe; the card in each surface recipe beside both gradients; the chip frame in rest, hover, pressed, on and focused, a real chip at rest and disabled, and card_frame; hairline, dashed line, bevel, glow and shadow; the four signal-rate hues; every button, knob, fader and meter recipe at several values; each text role (title, body, label, value, caption) in each ink (ink, mid, lab, faint, pri) on each surface; a syntax-coloured line of Volta on the screen; and a four-bar timeline strip with clips in the rate hues and a playhead. It is the picture a person or an agent judges a skin by — it shows, it does not judge — and the snapshot target of ui/tests/skin_sheet.rs (spectrum and the three stress skins; not every built-in, which would be ~36 images).
volta-shot --sheet [--theme <name>] [--out-dir <dir>]writessheet-<theme>.pngwithout a song or the engine (app-native/src/offscreen.rsrenders one egui closure offscreen).volta-shot --skin <dir>/<name>/skin.jsonvalidates the file against its sibling skins in<dir>, prints the report, writes a sheet per mode that resolves, and exits 1 when the skin has errors.
The agent loop#
The Volta MCP server (Use Volta from your own AI agent (MCP)) carries five skin tools (app-native/src/mcp/skins.rs), plus the pack tools below, and a guide, volta://guide/skins — the server's tool loop (app-native/src/mcp/skins.md) followed by the schema half (ui/src/skin_authoring.md, skin::AUTHORING), which the copilot's skin mode carries too; the repository skill volta-skin (.agents/skills/volta-skin/) is the same procedure for an agent working in a checkout.
| Tool | Does |
|---|---|
list_skins | The skin folder's skins (modes, resolves or not, repair count, hash) and the built-in families. |
read_skin | A user skin's text and hash, or a built-in's derived palette tokens per mode. |
validate_skin | The report for a skin.json text; writes nothing. |
preview_skin | The sheet per mode as PNG images, with the report; by json or name. With views (up to 4 of source, mixer, rack, timeline, bench, perform), also the real workbench under the skin on the bundled bench song, captured by running volta-shot beside the server against a temporary skin folder (a development build; a packaged Studio without volta-shot says so). |
write_skin | Writes <folder>/<name>/skin.json only when the report has no errors; replacing a skin needs the hash it was read at (the apply_edit gate). |
Packs with artwork, faces and an offered workspace have their own tools — customization_catalog, validate_skin_pack, write_skin_pack and the workspace tools — in Designing a workbench pack. preview_skin also takes candidate assets and a workspace there.
The loop is seeds → validate → preview → adjust → write. What it proves: validate_skin proves readability and the meaning invariants; whether a skin looks good is a judgement made from the sheet, and no tool claims it. preview_skin pictures the sheet, and with views the real workbench.
In the copilot#
Copilot → skin drafts a skin from a brief ("a walnut console", "amber phosphor terminal"). The mode has one tool, validate_skin — the same resolver, solver and report as the MCP tool — and its system block carries the schema half of the authoring guide. The request shares nothing of the song: no buffer, selection, module map or retrieval. It carries one context line instead: the theme on screen, the built-in families, the installed user skins, and — when the theme on screen is a user skin — that skin's skin.json, so "make it warmer" revises it (keeping the name, so installing replaces it).
The reply holds the skin in one ```volta-skin fence, which renders as a card computed on this machine (aicanvas::skin_card, validated against the installed skins and re-validated when they change): per declared mode whether it loads and how many colours the solver repaired, the first errors with their JSON paths, a chip per mode, and the skin sheet (skinsheet::inline) under that mode's resolved theme, scrolled inside the card. The sheet paints with the workbench's own type faces; a skin's type shows once it is installed. When the skin loads and its name is a folder name, install & use validates the JSON again in the app (install_skin_json), installs it through the shell's install_skin seam (the same path as a .voltaskin import, without font files) and selects it in the mode on screen. A name an installed skin already has says so on the card: installing replaces it.
In Studio#
- The desktop reads the folder once at launch (
app-native/src/skins.rs), before the saved theme is restored and the menus are built, then polls it every half second from a background thread; a changed file bumps a generation and the UI re-resolves only then (ui/src/app/skins.rs). Nothing about a skin is parsed on the paint thread per frame. - Picker: Display → Theme lists the built-ins, a separator, then your skins. Hovering a user entry shows its path and how many colours were adjusted. A skin that failed is listed in the alert colour with its first error and cannot be selected.
- Hot reload: saving the file of the theme on screen repaints it. A broken save keeps the last good theme, lists the error in the picker and toasts the path.
- Saved pick:
theme=<name>-<mode>in the shell state names a user skin like any built-in. If it is restored before the folder has been read it is kept pending and applied on the first generation that carries it;volta-shot --theme walnut-darkworks the same way. - Follow system: Display → Light / dark → Follow system keeps the theme family and swaps
-dark/-lightwith the OS setting (thememode=systemin the shell state; absent = fixed). egui reports the OS mode on native and web. - A skin folder may also hold
fonts/<file>.ttf|otf(at most 4, each at most 2 MB); the scan reads them into the skin'sSkinFile.files(folder-relative paths) so they travel with an export. The watcher polls lengths and mtimes only and reads contents when they move.
In the browser#
The browser workbench resolves skins with the same code; only the "folder" differs. Each installed skin is kept as its .voltaskin pack (below) in the page's IndexedDB (volta-skins, one record per name; runtime/public/studio-skin-store.js). The page pushes the whole set at boot and after every change (Studio.push_user_skins); ui-wasm/src/skins.rs unpacks it once per push into the same SkinFiles the desktop scan makes and moves the generation, so the Theme picker, the broken-skin listing and the hot-reload path are the desktop's. Without IndexedDB (a private window that refuses it) skins last for the session and the workbench says so.
- Saved pick: the page mirrors
theme=/thememode=out of the shell state intolocalStorage["volta-studio-theme"]with the tab set and pushes it back at boot, so a skin chosen in the browser survives a reload (a pick restored before the store has loaded waits, as on the desktop). - Page chrome follows the theme. Whenever the theme on screen changes (
EngineBridge::theme_applied, afterTheme::apply), the browser shell sends every resolved token (Theme::css_vars:--name→ value, sorted) to the page, which sets them inline on<html>, removes the ones the previous theme set and this one lacks, setsdata-themeandcolor-scheme, and firesvolta:theme(runtime/public/studio-theme-vars.js). A density change that leaves every token alone sends nothing. Page CSS readsvar(--bg)etc. as it always did; a user skin now reaches it too.
Sharing a skin#
A skin travels as ONE file, <name>.voltaskin: an ordinary zip of the skin's folder, entries rooted at the folder name (walnut/skin.json, walnut/fonts/Plex.ttf), so unzipping it gives exactly the folder. ui/src/skinpack.rs writes and reads it for both shells.
- Export (Display → Skin file → Export…, enabled when the theme on screen is one of your skins): packs
skin.json, its fonts and, for a skin with artwork, its assets and manifest (inherited artwork bundled; Export v1… writes the reduced format-1 pack and lists what it dropped), then the desktop's save dialog or a browser download. Packing is deterministic (sorted entries, a fixed 1980 timestamp). - Import (Import…): the desktop's open dialog or the browser's file picker. The pack is opened and
skin.jsonvalidated throughskin::validateagainst the skins already installed (soextendsmay name one) BEFORE anything is written; a pack that does not open, anamethat disagrees with its folder, or an error in any declared mode installs nothing and the Display window lists the report. Contrast repairs and warnings do not refuse. A clean import is installed (desktop: written beside the folder, then swapped in; browser: re-packed canonically into IndexedDB) and put on screen in the current mode. - Replace and remove: importing a name you already have replaces it; Remove takes the skin on screen out of the picker. On the desktop both move the old folder to
skins/.removed/<name>-<secs>/(the scan skips dot-folders), never delete it; the browser deletes the record. - What a pack may hold: the format carries any small file under the folder, but this build accepts only
skin.json(≤ 256 KB, UTF-8),fonts/<file>.ttf|otf(≤ 4 files, ≤ 2 MB each) and, in a format-2 pack,manifest.jsonandassets/<file>(≤ 32, ≤ 2 MiB each; see Skin artwork and format-2 packs), and refuses a pack with any other entry, so a newer pack never half-installs. Finder's__MACOSX/and.DS_Storeare skipped. The whole file is capped at 10 MB before it is opened, each entry by its kind and again while it inflates (a header that lies about its size cannot get past the cap), at most 64 entries. Absolute paths,.././empty components, backslashes, drive letters, symlinks, a second folder and encrypted entries are refused. Font files are carried, not parsed: whether a face loads is the fonts stage's check. - Community: not yet. Skins are not a community item kind; see the spec's Stage 7 open item for what listing them beside songs would take.
Widget feel tokens#
Theme::apply maps a second set of SHAPE tokens onto the egui style fields it used to leave stock. Every :root value is egui's own value (widget_feel_root_reproduces_egui_stock), so a theme that says nothing feels exactly as before.
| Token | Default | Drives |
|---|---|---|
--light-angle | 315 | bevel light direction, degrees (paint kit) |
--text-gamma | auto | text anti-alias weight: auto = egui's per-mode curve, 1 = linear, else coverage^g |
--cursor-width / --cursor-blink | 2px / 1 | the text caret |
--hover-expansion | 1px | how far a control swells on hover and press |
--handle / --handle-aspect | circle / 0.5 | slider thumb shape (rect = fader cap) |
--rail | 8px | slider rail height |
--scroll-width / --scroll-floating | 6px / 1 | scroll bars |
--striped / --indent-vline | 0 / 1 | table stripes, tree guide lines |
--disabled-alpha | 0.5 | disabled widgets |