VSTDOCSv0.0.34

DOCS / UI REFERENCE

Reference-art workflow

Some panels shouldn't be built from components — they are the designer's render, shipped pixel-exact, with live parts patched on top. This guide distils that workflow: it's how you turn a Figma export, a web prototype, or a photograph of real hardware into a working plugin UI. It's how StashTrack ships in production.

The idea

One full-panel image (the plate) is the layout, the lighting, the texture — everything static. On top of it you place exactly three kinds of things: invisible hit zones that take the gestures, covers that hide a baked control at its default pose, and live parts (an indicator, a film-strip knob face, a readout) drawn where the covered pixels were. Everything is positioned in plate space — the image's own pixel coordinates — and multiplied by one scale factor at paint time, so measurements taken in your design tool transfer directly.

the shape of every reference-art UI
const S = 0.5;                       // 1536x1024 plate -> 768x512 editor
const W = 1536 * S, H = 1024 * S;
const px = (n: number) => n * S;     // plate-px -> editor-px

<View style={{ width: W, height: H, position: "relative" }}>
  <Plate />                          {/* the art, whole */}
  {/* live parts, positioned in plate space */}
  {/* invisible hit zones LAST, so they sit on top */}
</View>

Ship the plate

Export the panel at exactly the pixel size you'll draw it at (a 1:1 blit always beats runtime rescaling; 2x and S = 0.5 is the usual choice), encode it as lossless WebP, and inline it as a data URI at build time — a plugin has no file server to fetch from. Then intern it with registerImage: the bitmap decodes once into native memory and the bridge only ever carries a short "img:N" handle instead of megabytes of base64.

ui/src/main.tsx
import { registerImage } from "@vsreact/core";
import { assets } from "./_assets";   // generated: build.ts inlines src/assets/

const plate = registerImage(assets["plate.webp"]);

function Plate() {
  return <Image src={plate} style={{
    position: "absolute", left: 0, top: 0, width: W, height: H, objectFit: "fill",
  }} />;
}

Export the plate with every control at its default pose — the UI then only has to draw differences, and a freshly opened editor is mostly just the photograph.

Invisible hit zones

The visible knob is a photograph; the thing that takes the drag is a transparent View over it, bound through useParamGestures — vertical drag, double-click reset and wheel nudge, inside the begin()/end() automation gesture hosts need. Make hit zones larger and squarer than the drawn control; nobody ever complained a knob was too easy to grab.

a knob that is a photo
function KnobHit({ id, cx, cy, size }: { id: string; cx: number; cy: number; size: number }) {
  const p = useParameter(id);
  return (
    <View
      style={{ position: "absolute", left: px(cx - size / 2), top: px(cy - size / 2),
               width: px(size), height: px(size), cursor: "ns-resize" }}
      {...useParamGestures(p)}
    />
  );
}

Render hit zones after everything else so no later sibling covers them — a panel can render perfectly and still be dead if a decorative layer eats the drags. The examples' interaction tests exist precisely because this failure is invisible in a screenshot.

Cover the moving parts

When a control moves, the plate's baked version of it is wrong. The trick is a Cover: a clipped window onto a shifted copy of the plate, so clean panel pixels from a blank region nearby paint over the baked mark — grain, lighting and all. Then draw the live part on top.

the load-bearing ten lines
// A resampled patch of the plate clipped to rect (x,y,w,h) in plate space,
// shifted by (ox,oy) plate-px so clean pixels cover a baked mark underneath.
function Cover({ x, y, w, h, ox = 0, oy = 0 }) {
  return (
    <View style={{ position: "absolute", left: px(x), top: px(y),
                   width: px(w), height: px(h), overflow: "hidden" }}>
      <Image src={plate} style={{ position: "absolute",
        left: px(-x + ox), top: px(-y + oy), width: W, height: H, objectFit: "fill" }} />
    </View>
  );
}

Pick the shift so the source region is genuinely blank and shares the local lighting. When no blank region exists — the delay example's LED glass has a strong brightness gradient — erase the baked mark from the asset once at build time instead (the examples do per-column interpolation in a small sharp script) and skip the runtime cover entirely.

Film-strip knobs

A photoreal knob face that turns is a film strip: N pre-rendered rotation frames stacked in one lossless-WebP sprite, one frame shown at runtime. Bake it with bakeKnobStrip (or export frames from your DSCC/renderer), and show it with the built-in FilmStripKnob — which interns the strip via registerImage, so turning it ships one number over the bridge.

bake once, turn forever
# build time — writes knob-strip.webp + knob-strip.json
bun run vsreact/js/src/tools/bakeKnobStrip.ts <outDir> 90 180

// runtime — the visible face; a KnobHit over it takes the gestures
<FilmStripKnob rotation={-135 + 270 * p.value} strip={knobStrip} displaySize={disc} />

Why not shade the knob live in JS? Measured: ~217ms per 180x180 frame under QuickJS. Bake at build time; the runtime shows pixels.

Readouts & fonts

Live values need the design's typeface: registerFont a bundled OTF/TTF (also inlined as a data URI) and it resolves through fontFamily before any system lookup. Two style keys exist for exactly this job: textLength scales a line to a fixed width so digit readouts never shift layout, and textStroke* outlines glyphs like the printed panel. Derive the displayed value from the parameter's own host range — normalizedToNatural(p.value, p) — so nothing mirrors the APVTS in TS.

a readout in the panel's own voice
registerFont({ family: "Panel Narrow", src: assets["narrow.otf"] });

const ms = normalizedToNatural(time.value, time);  // 1..1000 from the C++ range
<Text style={{ fontFamily: "Panel Narrow", fontSize: px(40),
               textLength: px(62), textAlign: "center", color: "#eee1d4" }}>
  {Math.round(ms)}
</Text>

Verify it moves

Screenshot-compare the editor against the design export — but don't stop there: hit-test each control point and assert the parameter actually changed, with the param:begin/param:end bracket around it. The examples' ExampleInteractionTests are a ready-made template: they drive vsreact::hitTest, dispatch drag events, and check the APVTS write — because a reference-art panel can look perfect and be completely dead.

For working code at scale, read StashTrack — a production VST whose whole UI is this workflow. The bundled examples were also built this way before 0.0.33 (their plate versions live in git log; gain's was the smallest complete instance of everything above) before moving to the stock-component style most plugins start from.