Give a skin its own pictures: an illustrated window frame, pane-header artwork and knob ornaments, carried in one portable pack.
Assets and decorations in skin.json#
Schema 2 adds two keys. assets names the skin's PNG files by ID; a decoration slot places one. IDs use the skin-name alphabet. A file is always assets/<name>.png in the skin's own folder. Paths and URLs are refused.
{
"skin": 2, "name": "vinework", "extends": "analog",
"assets": { "frame": { "file": "assets/frame.png" },
"ring": { "file": "assets/ring.png" } },
"decorations": {
"frame": { "asset": "frame", "nine": 24, "scale": 0.5 },
"control": { "asset": "ring", "fit": "contain", "opacity": 0.5 }
},
"modes": { "dark": {},
"light": { "decorations": { "frame": { "tint": "#c8d8c0" } } } }
}
| Slot | Where it paints |
|---|---|
frame | The whole window: over the backdrop, under the chassis and every pane. It shows at the bezel and in the gutters, and the chassis turns transparent so it can. |
pane-header | A workspace pane's title row (Display → Workspace layouts), under its title and chips. |
pane | Every pane ground (kit::Surface::Panel), under its content. |
card | Every card surface (Rack nodes, macro and hole cards), under its content. |
control | A knob's footprint, under its face and marks. |
| Key | Meaning |
|---|---|
asset | Required: an ID from assets (an inherited slot looks it up in the skin that declared it). |
nine | Nine-slice margins in texels, one number or [top, right, bottom, left]. Corners keep their size × scale. Edges stretch along one axis, the centre along both. Corners shrink together in a slot too small for them. They must leave a centre. |
fit | stretch (default), contain, cover, natural (one texel per scale points), or tile. Ignored with nine. A tile far smaller than its slot is drawn coarser, never more than 1024 quads. |
anchor | top-left … bottom-right, center (default): where contain, cover and natural sit. |
crop | [x, y, width, height] in texels, inside the image. |
scale | 0.25–4 points per texel (default 1). |
opacity | 0–1. frame may reach 1. pane-header and control are capped at 0.5, pane and card at 0.35, because they sit under text; a higher value is lowered with a warning — unless the final-composite check (below) proves the floors at the asked value, or a reading plate covers it. |
tint | A colour multiplied into every texel. |
clip | slot (default) or inset (clear of the slot's rounded corners). |
Each mode may choose its own asset or tint through modes.<mode>.decorations, merged key by key like every per-mode section. Changing them never changes layout. A child skin inherits each slot it does not declare from its extends chain of user skins. An unknown slot or key, or an out-of-range number, warns. A missing ID or file, a refused PNG, a crop outside the image or margins that leave no centre is an error. A skin with errors never loads.
A skin that writes "skin": 1 and uses these keys still loads, with a warning. Writing 2 makes an older build report the file as newer instead of quietly dropping the artwork.
Admission and budgets#
ui/src/skin_assets.rs admits artwork before anything paints:
- at most 32 files per skin, each at most 2 MiB encoded;
- a PNG at most 4096 px a side and 4 megapixels decoded, read from its header before the decoder may allocate;
- each skin's decoded artwork charged at 8 bytes per pixel (the CPU copy plus the GPU copy) against 64 MiB, so a tiny compressed file cannot buy a huge resident image. The distinct images one appearance places are charged against the same 64 MiB at resolve time. No mipmaps are made.
The whole .voltaskin stays capped at 10 MB. These limits are provisional until Stage 8 measures them on both hosts.
Loading, residency and paint#
- Desktop: the skin-folder watcher reads
assets/*within the caps and DECODES them on its own thread (app-native/src/skins.rs). A replaced PNG moves the folder fingerprint and hot-reloads. A file past a cap is listed with its refusal and no bytes. - Browser: the stored packs are unpacked once per pushed set and their art is held as BYTES (
SkinPack::into_file_undecoded). The shell decodes every skin for that generation's resolution, then keeps only the active theme's skin and itsextendsparents decoded; selecting another skin decodes its art again (skin_assets::hydrate/dehydrate). So CPU residency is the active appearance's art, not up to 32 MiB per installed skin (VoltaUi::skin_art_resident_bytes; the browser'scustomization_state().artResidentBytes). Nothing decodes per frame.runtime/public/studio-skin-store.jsstores packs as opaque bytes. - Resolve: the shell indexes the prepared images per generation, and the resolver places them into the theme's
Decorations. A hot reload with a broken PNG keeps the last good theme and its artwork and toasts the error. - GPU:
Theme::applymakes the theme's images the context's active set. It uploads each image once per content hash and releases every other handle, so switching to a built-in frees them.skin_assets::context_lostforgets the handles after GPU-context loss; the next paint of an active image re-uploads it. An inactive image never re-uploads. - Paint:
kit::decorationdraws one textured mesh per slot. It is called where the slot's ground is down and before the slot's content. Decorations take no input and add no accessibility node.
Format-2 packs#
A pack that carries artwork is format 2. It holds a mandatory manifest.json beside skin.json:
{
"format": 2,
"name": "vinework",
"appearance": "skin.json",
"capabilities": {
"required": ["appearance", "assets.png", "decorations", "decorations.nine-slice"],
"optional": {}
},
"assets": {
"frame": { "file": "assets/frame.png", "sha256": "…", "bytes": 3763,
"width": 96, "height": 96, "roles": ["decoration.frame"] }
}
}
This build supports appearance, fonts, assets.png, decorations, decorations.nine-slice, materials, faces and workspace. workspace (an offered workspace.json, Designing a workbench pack) is always declared OPTIONAL and must be a valid layout. A pack is checked whole before anything is written (skinpack::check_import_with, skin_manifest::check). Any of these refuses it and installs nothing:
- a newer
format; - a REQUIRED capability this build lacks;
assets/without a manifest;- an inventory that disagrees with the files or with
skin.json'sassets(a missing, extra or renamed file, or a size, SHA-256 or decoded dimension mismatch); - a capability the pack uses but does not declare;
- a corrupt, oversized, too-many or over-budget image;
- any skin error.
An OPTIONAL capability this build lacks falls back to its declared built-in ("fallback": "built-in"). Its files under assets/ (for example a later assets.svg) are left out of the install. A slot that names one keeps its built-in paint with a warning. The import toast lists each fallback.
An older build refuses manifest.json and assets/ entries outright, so a format-2 pack never half-installs there. A pack without artwork is still format 1, byte for byte as before.
Export#
- Export… (
skin_manifest::export) writes the declared assets and a manifest built from them. Stray files inassets/that no ID names are left out. The export is deterministic: the same skin always gives the same bytes, and export → import → export is byte-identical. - Inherited artwork is bundled. A slot the skin inherits from a parent user skin is written into the child's own
modes.<mode>.decorations. The parent's file travels asassets/<parent>--<file>under the stable ID<parent>--<id>. Installing then needs no other folder's files.skin.jsonis rewritten in that case and its comments are dropped. The colours stillextendsthe parent, which must be installed, as before. - Export v1… (
skin_manifest::export_v1) writes the reduced format-1 pack an older build installs. It dropsassets, every decoration and the manifest, and sets"skin": 1. The toast lists each dropped slot and file and says the result will not look the same.
Layered materials#
A skin: 2 surface material may be an ordered layer stack (ui/src/material.rs) instead of one texture. A v1 texture is translated to a one-layer stack and paints exactly as before.
"materials": { "lagoon": { "layers": [
{ "fill": "surface" },
{ "material": "marbled-ink", "colors": ["surface", "primary", "event"], "scale": 1.5 },
{ "texture": "grain", "strength": 0.4, "blend": "screen" },
{ "image": "fibre", "blend": "multiply", "opacity": 0.5, "wrap": "mirror" } ] } },
"material": { "panel": { "layers": "lagoon", "opacity": 0.8, "angle": 12 },
"card": { "recipe": "bevel", "layers": "optical-rings" } }
Each layer names one source. fill is a palette role. gradient is [from, to] roles plus angle. Both are allowed as layer 0 only. A texture is any v1 tile, with strength, period and duty. A material is a built-in (marbled-ink, optical-rings, iridescent-foil) with 2–4 colors. An image is an asset ID. Colours are always roles: ground, surface, bezel, primary, secondary, accent, ink, alert, audio, control, event, pattern. Literals are refused, so a material follows the mode and the solved palette.
Each layer also takes opacity, blend (normal, multiply, screen), scale (0.25–4), angle (snapped to quarter turns, with a warning), offset [x, y] px, wrap (repeat, or mirror, which makes any image seamless) and sampling (nearest, linear). The stack takes layers (an array, a named material or a built-in name), a free angle, opacity, sampling and reading (auto or direct). A material.<kind> with layers may not also name texture. Stacks, named materials and their image layers inherit through extends like decorations, and merge per mode.
Baking. Tile layers are composited on the CPU into one repeat-wrapped tile with W3C blend math on premultiplied gamma-space colour (egui's space), the same native and wasm; built-ins use no libm trigonometry, so their tiles are bit-identical. The tile edge is the largest layer period; smaller layers repeat a whole number of times across it; a solid layer 0 is baked in. Bakes are cached by content, so re-resolving bakes nothing. Tiles go GPU-resident through the artwork's asset cache. Paint is one quad per surface; nothing bakes in a frame.
Limits. At most 8 layers per stack. At most 16 named materials. A baked tile is at most 512 px a side; an image layer that would pass it is scaled down with a warning. Baked tiles are charged with the artwork against the same 64 MiB.
Readability. The resolver checks the final composite, not each layer. That composite is the baked tile at the stack's opacity over the surface's real fill (the gradient and glass recipes included), plus the slot's artwork, the plate and the screen overlay. Its envelope is the exact set of texel colours when the tile is unturned and sampled nearest. Otherwise it is their per-channel box, which also bounds egui's gamma-space filtering. Every pairing row the bare surface carries must still clear its floor against both ends, and so must every ink the editor and lanes actually render (the --syn-* palette or its role fallbacks, comments and --faint included). Over a material each ink keeps at least 90 % of its bare contrast (up to 4.5), not just its table floor. An ink whose luminance falls inside the range fails. Rate, alert and accent rows count like any other. A busy pattern also fails: under body and code text (every kind but bezel), the 90th-percentile lightness spread (L*) over 8 px windows must stay ≤ 0.02, or the surface plates even when every ink clears. Inks are never repainted for a material. Each surface then takes one policy, recorded in the report's materials list with the envelope, the deciding row and why:
direct— the envelope clears;plate— a reading plate of the surface's own fill, at the least alpha (1/64 steps) that clears, is drawn under the content. It is inset 3 px, so the material stays vivid around it and muted under it;lowered—"reading": "direct"refuses a plate, so the stack's opacity steps down × 0.75 until it clears.
Artwork caps are revisited on the same composite: pane/card art keeps its asked opacity when its surface's analysis (with the art in it) clears or plates; a pane-header or control ornament when the panel's (or every surface's) final composite still clears with it over. Otherwise the cap and its warning stand. On a light theme the plates come out near-opaque: its inks clear the paper by thin margins, so materials there show mostly around the plate (pane rims, gutters, the chassis), by design. This bounds what the composite can produce; it does not measure a glyph.
Where bakes run (material::BakeMode). A 512 px tile costs tens of milliseconds. Tests, volta-shot and the desktop's launch-time first read bake inline. After that the desktop bakes on a worker thread and the browser bakes one tile per frame: a dry resolution records the tiles the cache lacks, they bake, and the real resolution then runs on cache hits. The theme on screen keeps painting (its tiles stay resident) until the new one lands; a theme switch whose tiles are cached applies at once. The pixels are the same function of the same inputs, so the result equals an inline bake (ui/tests/prismatic_eden.rs). A face's built-in material paints its fallback until its tile lands.
material::lookup(theme, name) returns a skin's named material, else a built-in in the theme's colours (baked once per palette), for a control face. Packs that use stacks require the materials capability, and image layers list material.<kind> or materials.<name> roles. Export writes inherited stacks into the child and bundles their images. Export v1 drops them and says so.
Not yet#
- Restricted SVG is not enabled. A pack may declare it only as an optional capability, which falls back as above.
- The MCP skin tools validate artwork already in a skin folder, but
write_skinwritesskin.jsononly.write_skin_packwrites a whole pack with its artwork (Designing a workbench pack). - A face's
materialfill paints throughmaterial::lookup; its readability is the face's protected overlay's, not this check's. - Named materials are not readability-checked until a surface uses them.