HTML skins
Status: implemented in Jiggy and seen working in Chrome for range, checkbox and select controls, with Canticle, Drumkit and Quartet, with 16, 75 and 59 controls, as the first plugins to ship one. It extends svg-skins.md, which should be read first, and the requirement it adds is host-plugin-contract.md section 9.4. The text below was a proposal first and is kept in that shape; "What was built" says where the build differs from it.
An HTML skin is a plugin interface made of data, like an SVG skin, but laid out by the browser instead of drawn at fixed coordinates: a fragment of HTML and a stylesheet, with no script. It would let a plugin author write a responsive panel (a row of sliders that wraps on a phone, a section that collapses to one column) and have the host draw it in its own document, with the host's accessibility and none of the code an editor needs.
It does not replace jig:ui. A transfer curve, an XY pad or a waveform needs code and stays an editor, built with
the bridge. An HTML skin is for the plugins whose interface is controls and labels, which is most of
them.
| The plugin declares | The host shows |
|---|---|
jig:skin with an SVG artwork |
Artwork at fixed coordinates, drawn and operated by the host |
jig:skin with an HTML artwork |
A laid-out panel of native controls, styled by the author's stylesheet, drawn by the host |
| no skin, or a skin that fails | The generated panel |
jig:ui, as well as any of these |
An editor beside the panel, in a sandboxed frame |
Why consider it
SVG skins fit a phone badly. Their layout is one viewBox, so a dense drawing is put away below a width and replaced
by generated controls, and Parameq's six bands fall back below about 520 pixels (svg-skins.md,
"A phone"). A designer cannot say "two columns above 600 pixels, one below". HTML and CSS say exactly that, and a native
input under every control means the platform already provides the role, the value, the keys and the focus order.
The cost is a larger attack surface. An SVG skin can draw only inside its own box. CSS can position things anywhere,
fetch resources through url() and @import, and probe the page with selectors. So the proposal is only as good as
its list of what is refused, and that list is the main part of this document.
The artwork
The skin is a single HTML file. The profile points at it directly, and jig:mediaType "text/html" tells the host which
kind of skin it has:
<> jig:skin <#skin> .
<#skin>
a jig:Skin ;
jig:location <skin.html> ;
jig:mediaType "text/html" ;
jig:integrity "sha384-..." .There is no skin.ttl. SVG skins keep their bindings in a separate Turtle file so that the picture stays ordinary
artwork a drawing program can rewrite (svg-skins.md, "Why not RDFa in the SVG"). That reason does not apply here. The
HTML is written for this purpose, and in HTML the structure and the binding are the same fact: a slider inside a
labelled row is bound by the attribute on the slider. A second file would repeat the structure in Turtle. This is a
real divergence from the SVG format and is an open question below.
<section class="comp">
<h2>Compressor</h2>
<div class="row">
<label for="th" data-param-name="threshold"></label>
<input id="th" type="range" data-param="threshold">
<output data-param-text="threshold"></output>
</div>
<div class="row">
<label for="ra" data-param-name="ratio"></label>
<input id="ra" type="range" data-param="ratio">
<output data-param-text="ratio"></output>
</div>
</section>
<style>
@layer skin {
.comp { display: grid; gap: 8px; }
.row { display: grid; grid-template-columns: 6rem 1fr 5rem; align-items: center; }
@media (max-width: 360px) { .row { grid-template-columns: 1fr; } }
}
</style>The attributes are the ones the bridge uses (plugin-ui.md), so a page can move between an editor and a skin by
deleting its script. Controls are native: input type="range" for a continuous port, input type="checkbox" for a
two-position port, select for an enumeration, whose options the host fills from the port's scale points. The widget
follows the shape of the port (contract section 5.3), and a control of the wrong kind for its port is dropped and
reported, not drawn disabled.
What the host does
The host never inserts the file, as for SVG. It parses it with DOMParser as text/html, which produces an inert
document in which no script runs, then builds new nodes from an allowlist into a shadow root. It never serialises
the markup back to a string, which is what makes mutation-based sanitiser bypasses (markup that parses differently the
second time) a non-issue: the markup is read once and copied, never re-parsed. The stylesheet is the one thing written
out as text, and only from rules the host recognised (see "Style").
The host supplies everything a person or a tool relies on, and the artwork supplies none of it:
- Name and value. The accessible name is the port's
lv2:nameand the value text is the value with its unit, taken from the port. Author text in anaria-labelis dropped. - Label in name. WCAG 2.5.3 wants the visible label to be part of the accessible name.
data-param-namemakes that true by construction. A hand-writtenlabelwhose text is not contained in the port's name is reported to the console, since the host cannot fix it. - Tab order. Document order. A
tabindexis refused, since a positive one reorders focus (WCAG 2.4.3). - Focus and size. The host's own rules give every control a visible focus indicator and a target of at least
44 CSS pixels, and a
selecta font size of at least 16 pixels, which is what stops iOS zooming the page on focus. They sit outside the author's cascade layer, so a stylesheet cannot remove them (see "Style"). - Help. A port's
rdfs:commentsays what it does. The host makes it the control's description, said by a screen reader on focus, and shows it on a help line that follows the pointer and the focus, as the generated panel does (WCAG 1.4.13: Escape puts it away, and it stays while the pointer is over it). The author writes none of this. An element withdata-param-helpmarks where the line goes, so a layout can place it; without one, the host draws the line after the skin. The line is hidden from assistive technology, since the controls carry the words already. - Operations. Each gesture becomes the same operation the generated panel issues (contract section 9.3), so undo, automation recording and the WebMCP surface see a skin and a panel identically.
- Echo. A control shows the value the host applied, never the one it asked for (messaging.md section 2.3).
What the markup may contain
| Allowed | Notes |
|---|---|
section, div, span, header, footer, p, h1 to h6, ul, ol, li, fieldset, legend |
Structure |
label, output |
for must name an id in the same file |
input of type range or checkbox, and select |
Only with data-param. No option is accepted: the host makes them |
svg and its allowlist from svg-skins.md |
Not in the first build: an svg is refused for now, see "What was built" |
style |
At most one, parsed as described under "Style" |
class, id, lang, title, aria-hidden, data-param, data-param-name, data-param-text, data-param-help |
Everything else is dropped |
Refused, and the whole skin with it: script, link, meta, base, iframe, object, embed, img, picture,
video, audio, canvas, form, button, a, template, slot, noscript, math, any custom element, any
input type other than range and checkbox, any on* attribute, any href, src, srcset, action, style
attribute with a disallowed property, is, contenteditable, draggable, autofocus, accesskey and
tabindex. A refusal names the element and its position in the tree. The host falls back to the generated panel
(contract section 10.2).
Limits, which a host may raise: 256 KiB for the file and 2000 elements.
ids are prefixed by the host and every same-file reference (for, SVG href) is rewritten, so two skins on one page
cannot collide. Inside a shadow root they could not reach the host's ids anyway; the prefix keeps for and use
working as written.
Style
The stylesheet is the new surface. The host does not hand the author's text to the browser. It parses the style element
with its own parser (src/ui/HtmlSkinCss.js), checks every rule and declaration, and writes a new sheet from what it
recognised. A rule it does not recognise refuses the skin. The characters a value or a selector may hold are limited too:
no quote, no backslash and no !, so there is no string to hide url( in, no escape to spell it with, and no
!important to put an author declaration above the host's own, which in a lower cascade layer it otherwise would.
Scope. The skin lives in a shadow root, so its selectors cannot match anything in the host. The host sets
contain: layout paint style and isolation: isolate on the root, so nothing the skin draws can overflow the box the
host gave it or sit over the host's own controls.
Rules allowed. Style rules, and @container and @media with width and height conditions only. Prefer
@container: a panel sits in a column of a rack, narrower than the window, and @media measures the window. The host
sets container-type: inline-size on the skin's root, so a skin's @container rules query the box it was given.
container-type is otherwise allowed only with the value inline-size. All of it must sit
inside @layer skin, and the host's own rules are unlayered, which makes them win over any layered declaration
regardless of selector weight. A skin cannot therefore out-specify the focus ring or the 44 pixel minimum.
Rules refused. @import, @font-face, @keyframes, @property, @namespace, @supports and every other at-rule.
Selectors allowed. Type, class and #id selectors, descendant and child combinators, the attribute selector
[data-param] family, and the pseudo-classes :hover, :focus-visible, :checked and :first-child. A selector may
have at most four compound parts. :has, :not, :is, :where, :visited, :target and every pseudo-element are
refused: :has and :visited are the two that let a stylesheet learn things about the page, and the rest are not
needed.
Properties allowed. Layout (display limited to block, inline, inline-block, flex, grid and contents;
flex-*, grid-*, gap, row-gap, column-gap, align-*, justify-*, order, width, height, min-*, max-*, margin, padding,
box-sizing, overflow), paint (color, background-color, accent-color, color-scheme, border* except border-image,
border-radius, box-shadow with a blur of at most 16 pixels, outline with outline-style), and text (font-family from the generic families only, font-size,
font-weight, font-style, line-height, letter-spacing, text-align, text-transform, white-space).
Properties refused. position: fixed and sticky (absolute stays allowed, since the containing box clips it), z-index above 10, display: none, visibility, opacity, clip, clip-path, pointer-events,
content, filter, backdrop-filter, transform, transition, animation, cursor, resize, appearance,
-webkit-* and any custom property. The ones that hide a control or make it unreachable (display: none,
visibility, opacity, clip, pointer-events) are refused because a skin must not make a port unreachable.
Values refused. url(), image-set(), attr(), env(), var() and expression(). No stylesheet can then cause
a request, and no value depends on anything outside the file. Math and layout functions (min(), max(), clamp(),
calc(), repeat(), minmax()) are allowed, and one of them, min(), turned out to be needed (see the first step below).
After layout the host measures the skin. If a control has no size, falls outside the root, or the content is wider than the root and would scroll horizontally, the skin is put away and the generated panel is drawn, and the skin returns when the width allows. This is the fallback svg-skins.md describes for a cramped drawing, with the check made on the result rather than on sizes worked out in advance.
Ports the skin does not bind
As in svg-skins.md. A skin may bind a subset, may not make a port unreachable, and the host draws every unbound port as the generated panel would, in a group after the skin. A binding to a port the profile does not declare is left out and reported.
Threats and what answers them
| Threat | Answer |
|---|---|
| Markup that runs code | Parsed inert, copied from an allowlist, never inserted or serialised |
| A control drawn over the host's own (clickjacking) | Shadow root, contain: paint, no fixed or sticky, z-index capped |
| A request out of the page (tracking, data sent in a URL) | No url(), @import, @font-face, img, link, a or form |
| Learning about the page or the person | No :has, :visited, :target, no custom fonts, generic families only |
| A control that cannot be reached | No hiding properties, host-owned focus and 44 pixel target, unbound ports always drawn |
| A skin that makes the page slow | Size and element limits, selector depth, no filter, bounded box-shadow |
| A skin that cannot be told what is wrong | Whole-skin refusal with the element or rule named |
Validation
vocabs/shapes.ttl checks the profile's jig:skin node as before, with jig:mediaType allowed to be either media
type. There is no cross-file check, since there is one file. The host checks, at load, that every data-param names a
port the profile declares and that the control's kind fits the port's shape.
npm run check:skins runs the builder in a real Chrome over every HTML skin in plugins/, and over a corpus of hostile
skins, one per refusal above, expecting each to be refused for its stated reason. It has to run in Chrome because a DOM
without a renderer accepts markup and CSS a browser refuses: the real parser moves a div out of a table, ends a stylesheet
at a stray </style> and puts an svg's script in the SVG namespace, and what the checker sees is what it made. It also
mounts a built panel in a real shadow root and holds the cascade to what this document says: an author who shrinks a slider
to 1 pixel, sets a select's text to 3 pixels, removes the focus ring or throws an element 4000 pixels away is not obeyed.
First step: Canticle
The proposal asked whether an interface of only controls and labels needs code. Canticle, a Rust instrument with sixteen
ports, was given an editor to find out. It was written with no script of its own, in the rules above, and then held to them by
the host's own checker. It needed none, and it became the first HTML skin (plugins/canticle/skin/skin.html).
| Editor | Own script | What would not fit a skin |
|---|---|---|
| Canticle | 0 lines | Nothing |
| Dynamix | 80 lines | The transfer curve and the gain reduction meter. Its fourteen controls would fit |
| Tremolo | 122 lines | Almost all of the script is the message handshake the bridge now supplies |
What came out of it:
- A controls-only plugin needs no code. Four sections, sixteen bound controls and a layout that goes from one column to four, in about 30 lines of markup and under 20 of CSS. Beside it the generated panel shows three controls and folds the rest under "More controls", which is the case for the proposal.
- Dynamix splits cleanly. A plugin is one or the other, not half of each, because an editor is a frame and a skin is not.
- The writing found gaps in this document's lists, now corrected above:
accent-colorandcolor-scheme(without them a dark skin on native sliders looks wrong),row-gapandcolumn-gap, andmin(), which stops a card minimum from overflowing a narrow panel. - Measuring in a browser found one bug the checker could not. At 240 pixels the first version overflowed sideways, since its card minimum was wider than the space. After the fix there is no horizontal overflow from 200 to 1200 pixels, with one, two, three and four columns at the widths in between and every row at least 44 pixels tall.
@mediais the wrong query. The first version used it and reflowed at the window's width. Hence@container.- Help text was a gap, and is closed. The frame's
initprofile did not carry a port's comment. It does now, and the host makes the comment each control's description and shows it on a help line (see "What the host does"). Six of Canticle's sixteen ports have one.
What was built
| What | Where |
|---|---|
| The CSS parser and checker | src/ui/HtmlSkinCss.js |
The markup checker and builder, and HtmlSkinError |
src/ui/HtmlSkin.js |
| The panel, with the same interface as an SVG skin's | src/ui/HtmlSkinPanel.js, drawn by Panel.js |
| A drag that survives a redraw, and the keyboard put back inside a shadow root | src/ui/SliderDrag.js, src/ui/Focus.js |
Loading: one file, chosen by the profile's jig:mediaType |
src/host/SkinLoader.js |
The profile: "skin": { "html": "skin/skin.html" } in profile.json |
bin/write-profile.js |
| The Chrome check and its hostile corpus | bin/check-skins.js |
A first draft from profile.json |
bin/skin-from-profile.js |
| The skins | plugins/canticle/skin/skin.html, plugins/drumkit/skin/skin.html, plugins/quartet/skin/skin.html |
| The procedure, for an agent | .claude/skills/html-skin/SKILL.md |
Where the build differs from the proposal above:
- No inline
svgyet. It is refused. An SVG inside a skin would need the SVG skin's builder run on a subtree the HTML parser put in the SVG namespace, and nothing needed it. The proposal's table still lists it, with this note. - Position and
z-indexare allowed in a narrow form.positionmay bestatic,relativeorabsolute, andz-indexa whole number from 0 to 10. The proposal's lists did not include them, and an author needsabsolutefor a badge on a control.fixedandstickystay refused, and the box clips what it holds. - The host's rules are unlayered and the author's sit in
@layer skin. As proposed, and it is what makes the host's 44 pixel targets, 16 pixel select text and focus ring beat any author declaration. The Chrome check holds it. - Wrong kind of control: left out, not refused. A range bound to a switch, or a binding to a port the plugin does not declare, is dropped and reported, and the skin still draws. The label stays, since it is text.
- A visible label that is not part of the port's name is reported (WCAG 2.5.3) and not changed.
- A skin too wide for its room is put away, judged on the result. The panel measures after layout, shows itself for the measurement and hides again in the same task, so nothing is painted between.
Drumkit, the second skin, is eleven voice cards each with a mute in its header, and a master card, all 75 ports. It was
generated once from profile.json to avoid mistyping 75 rows, and is committed as ordinary markup to edit by hand. Its row
labels are short ("Pitch") because the accessible name is the port's ("Kick Pitch"), which contains them. Writing it found two
more things:
- A skin can overflow at some widths and not at others. A grid track's minimum is not its card's content minimum: a range
input is about 129 pixels wide whatever its column, so a row inside a 260 pixel card spilled 59 pixels out of it, and the
skin overflowed by 38 pixels at 1100 and 12 at 1500, and at nine other widths, while fitting at the few sizes first tried.
The cure is to let the slider shrink (
min-width: 0andwidth: 100%) and give the grid a track minimum the rows can live in.npm run check:skinsnow sweeps every width from 200 to 1600 pixels in steps of 50, for every HTML skin, and measures where every drawn element sits against the box the host gave. The first version of that sweep measured the skin'sscrollWidthand then only the controls, and passed the broken skin both times: reverting the fix is what showed it. - A dark skin must ask for dark controls. The host's 44 pixel checkbox was a large white square on a dark card until the
skin set
color-scheme: dark, which the allowlist already permits.
Quartet, the third skin, was the first made with the script, which drafted all 59 ports into nine cards without a hand edit
and passed every check as written. The look is what needed tuning: a switch row (Fixed Frequency) wrapped in the same narrow
label column as a slider and wanted its label to take the room and its checkbox the end; a value readout such as "2400 Hz"
wrapped in a 3.5rem column, and needs about 4.5rem and white-space: nowrap; and the card minimum grew to 18rem so the
sliders stay usable. Those went back into the script's stylesheet. Four operators with ten identical controls each sit side by
side at the top, as an FM synth's panel does, and the algorithm select works through the host with the focus kept.
Two bugs that no unit test could have found, both found by a real drag and counted arrow keys in Chrome, and both a result of
the rack being redrawn on every edit (web/app/Runtime.js):
- A native slider stopped following the pointer after one step. The redraw detaches the slider, which ends the browser's
drag. The generated dial is immune because it listens on the document.
SliderDrag.jsdoes the same for a slider, taking the value from where the pointer is, and keeps the thumb under the pointer when it is grabbed. - The keyboard could nudge a slider once.
preserveFocusreadsdocument.activeElement, which for a control in a shadow root is the host, which has no id. It now sees through the shadow root, and the skin's host has a stable id.
Open questions
- Inline
svg. Worth adding when a plugin wants a drawn meter beside its controls, and not before. - Native hosts. The native adapter has no web engine and ignores
jig:skin, as the contract permits, so an HTML skin changes nothing there. If it ever embeds a web view, this is a smaller thing to host than ajig:ui, since it has no script and no messages. - Theming. svg-skins.md draws a skin as authored.
prefers-color-schemewould be a one-line addition here, and it leaks a preference to a stylesheet that cannot send it anywhere, so it is probably safe, but it is not in this version. - Whether it earns its cost. The case is strong for plugins like Canticle and weak for Tremolo-sized ones, which a generated panel already serves. It is built, so the question is now which further plugins should have one.
Settled by building it: one file and no skin.ttl, since the structure and the binding are the same fact in HTML; container
queries, not media queries, since a panel is narrower than the window; and help, which the host supplies.