SVG skins
Status: draft. Implemented in Jiggy for rotary, linear and readout controls and seen working in Chrome; toggle and selector controls are tested only without a browser. The requirement it adds to the contract is host-plugin-contract.md section 9.4.
A skin is a plugin interface made of data: a picture in SVG and a Turtle file saying which part of the picture sets which port. (A skin may also be laid out by the browser instead of drawn at fixed coordinates, see html-skins.md.) It has no code, so a host can draw it in its own document, which a plugin interface otherwise may not be. It is a better-looking panel, not a replacement for the editor a plugin may also have.
| The plugin declares | The host shows |
|---|---|
jig:skin |
The panel is the author's artwork, drawn and operated by the host. Ports it does not bind follow, generated |
| no skin, or a skin that fails | The panel is generated from the port declarations |
jig:ui, as well as either |
An editor beside the panel: the author's page, in a sandboxed frame, on the plugin's origin or the host's own |
The panel is always there and always accessible, so a jig:ui never has to be. A skin that is
refused or fails to load leaves the generated panel, and the editor, where there is one, is
unaffected.
Three files and one arc
The skin is a sidecar. The profile holds one pointer and learns nothing else about it.
profile.ttl ──jig:skin──▶ skin.ttl ──jig:artwork──▶ panel.svg
│
└─ jig:boundSymbol "rate" (a name, not a reference)| File | Knows about | Does not know about |
|---|---|---|
profile.ttl |
That a skin exists, and the two files it ships, with their digests | What the artwork is for, the bindings, any element id |
skin.ttl |
Port symbols, element ids, the behaviour of each control | The processor, the module, the profile's other terms |
panel.svg |
Nothing outside itself | JigDAW |
So a designer can redraw panel.svg without touching a binding that still names the same
ids. A port can gain a unit in the profile without the skin changing. The processor and the
module are never in the picture at all. Each of the three has its own jig:integrity digest.
Redrawing the artwork changes the artwork's digest, the skin's, which carries it, and the profile's,
which lists both (see "The profile side").
The binding names a port by its lv2:symbol rather than its IRI. The symbol is what the wire
format already uses (messaging.md), and the profile's port IRIs are fragments
of an IRI that moves with the plugin.
The profile side
<> jig:skin <#skin> .
<#skin>
a jig:Skin ;
jig:location <skin.ttl> ;
jig:mediaType "text/turtle" ;
jig:integrity "sha384-..." .At most one skin per plugin. A composite has none.
The profile also lists the skin document and the artwork as jig:assets. That is what the term
is for: a bundle, a verifier and an offline copy know a plugin is complete by walking the
asset list, and the artwork is named only inside skin.ttl, where none of them look. It is the
one place the profile mentions the artwork, as a file the plugin ships and not as one that has a
role, so redrawing the artwork changes the artwork's digest, the skin's (which carries it) and
the profile's (which lists both).
A flattened bundle carries both files as data: locations in the profile. The skin document
still names its artwork by the absolute IRI its @base gives, so a flat copy reads the skin
from the file and fetches the artwork from that IRI, verified against the digest as always.
The skin document
Its subject is its own IRI. It names the artwork and lists the controls.
@base <https://example.org/jigdaw/plugins/tremolo/skin.ttl> .
<>
jig:artwork <#art> ;
jig:control <#rate>, <#depth> .
<#art>
a jig:Artwork ;
jig:location <panel.svg> ;
jig:mediaType "image/svg+xml" ;
jig:integrity "sha384-..." .
<#rate>
a jig:RotaryControl ;
jig:boundSymbol "rate" ;
jig:element "rate-knob" ;
jig:pivotElement "rate-hub" ;
jig:angleMin -135 ; jig:angleMax 135 .
<#depth>
a jig:LinearControl ;
jig:boundSymbol "depth" ;
jig:element "depth-thumb" ;
jig:trackElement "depth-slot" .Controls are named resources, never blank nodes, like everything else addressable.
What a control does
Every control binds one port to one element of the artwork. The behaviour comes from the class, and the host does all of it.
| Class | The host moves the element by | Role |
|---|---|---|
jig:RotaryControl |
Turning it about the centre of jig:pivotElement (default: its own centre), measured as drawn and before any turn, from jig:angleMin at the port minimum to jig:angleMax at the maximum, in degrees clockwise |
slider |
jig:LinearControl |
Sliding it along the longer side of the bounding box of jig:trackElement. It is drawn at the low end, and the host moves it by the track's length for the full range |
slider |
jig:ToggleControl |
Drawing the first child group when the port is off and the second when it is on | switch |
jig:SelectorControl |
Drawing only the child group for the current step of an enumerated or integer port | slider, with the step's label as its value text |
jig:ReadoutControl |
Setting the content of an SVG text element to the value and unit |
none: the text is hidden from assistive technology, because the control beside it already reports its value |
Values map the way the generated panel maps them: linearly between the port's minimum and
maximum, and by scale point for a selector. jig:hitElement names the element that takes the pointer where
that is not the moving element, as for a small thumb on a long slot. The host pads the target
to 44 CSS pixels either way.
What the host adds
The artwork supplies pixels. The host supplies everything a person or a tool relies on:
- Name and value. The accessible name is the port's
lv2:name. The value text is the value with its unit, such as "5 Hz", not "5". A name written inside the SVG is ignored. - Tab order. Rows from the top of the picture, and left to right along a row, whatever order the skin document lists the controls in, since a set of triples has no order (WCAG 2.4.3).
- Keyboard before pointer. Arrow keys, Home, End and Page keys on a slider, Space on a
switch, all of them the platform's own, because the host lays a native
inputover each hit area, invisible but focusable, assrc/ui/Dial.jsdoes. All with a visible focus indicator drawn around the hit area, which the artwork cannot remove. - Pointer. A drag that continues outside the element and ends on release wherever it occurs, as the generated dial does.
- Operations. Each gesture issues the same operation the generated panel does, so undo, automation recording and the WebMCP surface see a skin and a generated panel identically (contract section 9.3).
- Echo. The control is drawn from the value the host applied, never from the pointer's own position (messaging.md section 2.3).
- Size. The
viewBoxwidth is the artwork's natural size in CSS pixels. It shrinks to fit a narrow panel and is never drawn larger than that. - A phone. The artwork scales with its
viewBoxto the width available. A target smaller than 44 CSS pixels at that scale is padded to 44. Where two padded targets would cover more than a quarter of the smaller one, a tap would often land on the wrong control, so the host puts the drawing away and draws every port as a generated control instead, and draws the skin again when the width allows. It works this out from the sizes in the drawing's own units and the width it is given, so it holds while the drawing is put away. A skin is not invalid for being dense: Parameq's six bands are comfortable from about 520 pixels up and fall back below that, where Tremolo, Cascade and Boost keep their skins down to 240.
Ports the skin does not bind
A port has at most one control that sets it, plus any number of readouts, which only show the value and do not make the port reachable. A skin may show a subset of the ports, and it may not make a port unreachable. The host draws every unbound port as the generated panel would, in a group after the artwork. This is also the answer when a profile gains a port the skin has not heard of.
The reverse also holds. A binding whose port symbol is not declared by the profile is left out and reported, not drawn disabled: a control nobody can use is not shown.
What the artwork may contain
The host never inserts the file. It parses it as image/svg+xml and builds new nodes from an
allowlist, so the file is read rather than adopted.
| Allowed | Notes |
|---|---|
svg, g, defs, title, desc |
title and desc are kept for the picture and never used as the control's name |
path, rect, circle, ellipse, line, polyline, polygon |
Geometry |
text, tspan |
Static labels and readouts |
linearGradient, radialGradient, stop, clipPath |
Paint and clipping |
use |
Only with an href that is a fragment of the same file |
Geometry and presentation attributes, and a style attribute |
The style attribute is parsed property by property. url() is accepted only as url(#id) |
Refused, and the whole skin with it: any element in the SVG namespace not listed (script,
foreignObject, image, style, filter, a, animate, set and the rest), any on*
attribute, and any href or url() that leaves the file. Elements and attributes in other
namespaces, which is how Inkscape stores its editor data, are dropped silently, since they
cannot do anything. Limits apply as well: 512 KiB and 5000 elements, which a host may raise.
On insertion the host prefixes every id and rewrites the same-file references that name
it, so two skins on one page, or a skin and the host's own ids, cannot collide. The ids in
skin.ttl are the author's; the ids in the document are not.
Validation
vocabs/shapes.ttl checks the skin document on its own: one artwork, at least one control,
and the properties each class needs. Two checks cross files and so cannot be shapes:
- Every
jig:element,jig:hitElement,jig:pivotElementandjig:trackElementexists in the artwork. - Every
jig:boundSymbolis a port the plugin declares, no port has two controls that set it, and ajig:SelectorControlorjig:ToggleControlhas as many child groups as its port has steps.
The first is a host check at load, which reports each missing id by name. Both are also run
over every skin in plugins/ by npm run check:skins, in a real Chrome, because the artwork and
the bindings are two files that must agree and nothing else connects them. The same command
feeds the builder hostile artwork through the browser's own XML parser.
Not in this version
- Meters. A meter binds a signal, not a port, and the host's meter feed is not yet a named thing a skin can point at. This waits for that.
- Layouts for narrow and wide screens. One
viewBoxfirst. Two artworks selected by width is the obvious extension, and thejig:skinarc would not change. html-skins.md proposes a different answer, a laid-out skin whose layout is CSS. - Curves, XY pads, waveform displays. They need code. They are what
jig:uiis for. - Animation. None. Controls move because the host sets an attribute.
- Theming. A skin is drawn as authored. A host that wants dark mode draws a different skin, or none.
Why not RDFa in the SVG
An SVG could carry its own bindings as attributes. That would tie the picture to JigDAW, put the binding in a file a drawing program rewrites, and make the SVG the thing a digest on the profile has to track. A separate Turtle file keeps the artwork ordinary, and keeps the anchoring in RDF where the rest of the plugin is.