Skip to content
kerfjsGitHub

CanvasStage

Paints the page laid out inside it on one canvas, in any look: cards, buttons, fields, modals, type and scroll.

SourcePackage @kerf-js/veneer

Preview

tsx
import { state, untrack } from '@kerf-js/core'import {  Button,  CanvasStage,  DitherStage,  Grid,  Row,  Split,  Stack,  StageSelect} from '@kerf-js/veneer'import type { State } from '@kerf-js/core'import type {  Scene,  StagePainter,  StagePiece} from '@kerf-js/veneer'export const StageLanding = () => {  const look = state<Look>('dither')  return (    <Stack gap="related">      <Row        gap="tight"        role="group"        aria-label="Look"      >        {LOOK_NAMES.map(([id, label]) => (          <Button            key={id}            size="sm"            tone="neutral"            emphasis={              look.value === id ? 'solid' : 'outline'            }            aria-pressed={              look.value === id ? 'true' : 'false'            }            onClick={() => (look.value = id)}          >            {label}          </Button>        ))}      </Row>      {look.value === 'dither' && (        <Landing look="dither" />      )}      {look.value === 'flat' && <Landing look="flat" />}      {look.value === 'sketch' && (        <Landing look="sketch" />      )}      {look.value === 'neon' && <Landing look="neon" />}    </Stack>  )}type LookName = 'flat' | 'sketch' | 'neon'type Style = {  ink: string  paper: string  accent: string  soft: string  /** Text on a filled button or chip. */  onFill: string  /** Pixels of wobble: 0 draws clean lines. */  rough: number  /** Glow, in pixels of blur. */  glow: number  /** Fills as pencil hatching instead of solid. */  hatch: boolean  shadow: 'soft' | 'offset' | 'none'  ground: (s: Scene) => void  art: (p: StagePiece, s: Scene) => void  type: (p: StagePiece, s: Scene) => void}const seeds = new WeakMap<Element, number>()let seedNext = 1const seedOf = (el: Element) => {  let v = seeds.get(el)  if (v === undefined)    seeds.set(el, (v = seedNext++ * 7919))  return v}const rng = (seed: number) => () => {  seed = (seed * 16807) % 2147483647  return seed / 2147483647 - 0.5}type Pt = [number, number]type Box = {  x: number  y: number  w: number  h: number  r: number  seed: number}function outline(  ctx: CanvasRenderingContext2D,  box: Box,  st: Style,) {  const { x, y, w, h, r, seed } = box  ctx.beginPath()  if (!st.rough) {    ctx.roundRect(x, y, w, h, Math.min(r, h / 2, w / 2))    return  }  const j = rng(seed),    k = st.rough,    pts: Pt[] = []  const edge = (a: Pt, b: Pt) => {    const n = Math.max(      2,      Math.round(        Math.hypot(b[0] - a[0], b[1] - a[1]) / 28,      ),    )    for (let i = 0; i < n; i++)      pts.push([        a[0] + ((b[0] - a[0]) * i) / n + j() * k,        a[1] + ((b[1] - a[1]) * i) / n + j() * k,      ])  }  edge([x, y], [x + w, y])  edge([x + w, y], [x + w, y + h])  edge([x + w, y + h], [x, y + h])  edge([x, y + h], [x, y])  ctx.moveTo(pts[0]![0], pts[0]![1])  for (let i = 1; i <= pts.length; i++) {    const [px, py] = pts[i % pts.length]!    ctx.lineTo(px, py)  }  ctx.lineTo(pts[0]![0] + j() * k, pts[0]![1] + j() * k)}function stroke(  ctx: CanvasRenderingContext2D,  st: Style,  pen: { color: string; width: number },) {  const { color, width } = pen  ctx.strokeStyle = color  ctx.lineWidth = width  ctx.lineJoin = ctx.lineCap = 'round'  if (st.glow) {    ctx.shadowColor = color    ctx.shadowBlur = st.glow  }  ctx.stroke()  ctx.shadowBlur = 0}function paint(  ctx: CanvasRenderingContext2D,  st: Style,  fill: {    color: string    box: [number, number, number, number]    density?: number  },) {  const { color, box, density = 1 } = fill  if (!st.hatch) {    ctx.fillStyle = color    ctx.fill()    return  }  // Pencil hatching: the shape clips a run of diagonal  // strokes.  const [x, y, w, h] = box,    gap = 7 / density  ctx.save()  ctx.clip()  ctx.beginPath()  for (let d = -h; d < w; d += gap) {    ctx.moveTo(x + d, y + h)    ctx.lineTo(x + d + h, y)  }  ctx.strokeStyle = color  ctx.lineWidth = 1.6  ctx.globalAlpha = 0.55  ctx.stroke()  ctx.restore()}type PaintColor = (  s: Scene,  name: string,) => stringtype PaintRing = (p: StagePiece, s: Scene) => voidtype PaintLift = (  p: StagePiece,  s: Scene,  depth: number,) => voidtype PaintBody = (  p: StagePiece,  s: Scene,  look: {    fill: string    line: string    width?: number    solid?: boolean  },) => voidtype Hands = {  c: PaintColor  ring: PaintRing  lift: PaintLift  body: PaintBody}function surfacePainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, lift, body } = hands  return {    button(p, s) {      const outlineOnly = p.el.dataset.look === 'outline',        y = p.press * 2      lift({ ...p, y: p.y + y }, s, 4 - p.press * 3)      const at = { ...p, y: p.y + y }      body(at, s, {        fill: outlineOnly          ? c(s, p.hover ? st.soft : st.paper)          : c(s, p.disabled ? st.soft : st.accent),        line: c(          s,          outlineOnly || st.rough ? st.ink : st.accent,        ),        width: outlineOnly ? 1.5 : 1,      })      if (p.hover && !st.hatch && !outlineOnly) {        const g = s.ctx.createRadialGradient(          p.x + p.px,          p.y + p.py,          0,          p.x + p.px,          p.y + p.py,          p.w * 0.7,        )        g.addColorStop(          0,          `rgb(255 255 255 / ${0.28 * p.hover})`,        )        g.addColorStop(1, 'rgb(255 255 255 / 0)')        outline(s.ctx, { ...at, seed: seedOf(p.el) }, st)        s.ctx.fillStyle = g        s.ctx.fill()      }      ring(p, s)    },    card(p, s) {      const live = p.el.matches('a[href], button'),        chosen = p.on > 0.5      lift(p, s, 6 + (live ? p.hover * 6 : 0))      body(p, s, {        fill: c(s, st.paper),        line: c(          s,          chosen            ? st.accent            : st.rough              ? st.ink              : st.soft,        ),        width: chosen ? 3 : 1.5,      })      ring(p, s)    },    panel(p, s) {      lift(p, s, 14)      body(p, s, {        fill: c(s, st.paper),        line: c(s, st.ink),        width: 1.5,        solid: true,      })    },  }}function fieldPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, lift, body } = hands  return {    bar(p, s) {      const ctx = s.ctx      ctx.beginPath()      for (let y = p.y + 3; y < p.y + p.h - 2; y += 4) {        ctx.moveTo(p.x + 6, y)        ctx.lineTo(p.x + p.w - 6, y)      }      stroke(ctx, st, { color: c(s, st.soft), width: 1 })    },    field(p, s) {      const line = p.invalid        ? c(s, '--veneer-color-critical-solid')        : c(s, p.focus > 0.5 ? st.accent : st.soft)      body(p, s, {        fill: c(s, st.paper),        line,        width: 1.5 + p.focus,        solid: true,      })      if (        p.el.tagName === 'SELECT' ||        p.el.getAttribute('aria-haspopup') === 'listbox'      ) {        const ctx = s.ctx,          x = p.x + p.w - 18,          y = p.y + p.h / 2,          d = p.on > 0.5 ? -3 : 3        ctx.beginPath()        ctx.moveTo(x - 5, y - d)        ctx.lineTo(x, y + d)        ctx.lineTo(x + 5, y - d)        stroke(ctx, st, {          color: c(s, st.ink),          width: 1.6,        })      }    },    // A StageSelect's list, in a stage of its own: a    // panel on a short lift, and the row under the keys    // or pointer filled.    menu(p, s) {      lift(p, s, 6)      body(p, s, {        fill: c(s, st.paper),        line: c(s, st.rough ? st.ink : st.soft),        width: 1.5,        solid: true,      })    },    option(p, s) {      if (p.hover > 0.5)        body(p, s, {          fill: c(s, st.accent),          line: c(s, st.accent),          width: 1,        })      else if (p.on > 0.5) {        const ctx = s.ctx        ctx.beginPath()        ctx.moveTo(p.x + 3, p.y + 8)        ctx.lineTo(p.x + 3, p.y + p.h - 8)        stroke(ctx, st, {          color: c(s, st.accent),          width: 3,        })      }    },  }}function checkPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    check(p, s) {      const ctx = s.ctx,        radio =          (p.el as HTMLInputElement).type === 'radio',        round = radio ? p.w / 2 : 5      body({ ...p, r: round }, s, {        fill: c(s, p.hover ? st.soft : st.paper),        line: c(          s,          st.rough            ? st.ink            : p.on > 0.5              ? st.accent              : st.soft,        ),        width: 1.6,      })      if (p.on > 0) {        if (radio) {          ctx.beginPath()          ctx.arc(            p.x + p.w / 2,            p.y + p.h / 2,            (p.w / 4) * p.on,            0,            Math.PI * 2,          )          paint(            ctx,            { ...st, hatch: false },            {              color: c(s, st.accent),              box: [p.x, p.y, p.w, p.h],            },          )        } else {          // The tick draws itself on as `on` eases in.          ctx.beginPath()          ctx.moveTo(p.x + p.w * 0.22, p.y + p.h * 0.52)          ctx.lineTo(p.x + p.w * 0.42, p.y + p.h * 0.72)          ctx.lineTo(p.x + p.w * 0.8, p.y + p.h * 0.28)          ctx.setLineDash([40 * p.on, 40])          stroke(ctx, st, {            color: c(s, st.accent),            width: 2.6,          })          ctx.setLineDash([])        }      }      ring({ ...p, r: round }, s)    },  }}function switchPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    switch(p, s) {      const ctx = s.ctx,        d = p.h - 6,        kx = p.x + 3 + (p.w - p.h) * p.on      body({ ...p, r: p.h / 2 }, s, {        fill:          p.on > 0.5 ? c(s, st.accent) : c(s, st.soft),        line: c(s, st.rough ? st.ink : st.soft),        width: 1.4,      })      ctx.beginPath()      ctx.arc(        kx + d / 2,        p.y + 3 + d / 2,        d / 2,        0,        Math.PI * 2,      )      ctx.fillStyle = c(s, st.paper)      ctx.fill()      stroke(ctx, st, { color: c(s, st.ink), width: 1.2 })      ring({ ...p, r: p.h / 2 }, s)    },  }}function rangeMeterPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    range(p, s) {      const ctx = s.ctx,        y = p.y + p.h / 2,        kx = p.x + 8 + (p.w - 16) * p.value      ctx.beginPath()      ctx.moveTo(p.x + 2, y)      ctx.lineTo(p.x + p.w - 2, y)      stroke(ctx, st, { color: c(s, st.soft), width: 4 })      ctx.beginPath()      ctx.moveTo(p.x + 2, y)      ctx.lineTo(kx, y)      stroke(ctx, st, {        color: c(s, st.accent),        width: 4,      })      ctx.beginPath()      ctx.arc(kx, y, 8 + p.hover * 2, 0, Math.PI * 2)      ctx.fillStyle = c(s, st.paper)      ctx.fill()      stroke(ctx, st, {        color: c(s, st.accent),        width: 2.5,      })      if (p.focus > 0.5)        ring(          {            ...p,            x: kx - 10,            y: y - 10,            w: 20,            h: 20,            r: 10,          },          s,        )    },    meter(p, s) {      body({ ...p, r: p.h / 2 }, s, {        fill: c(s, st.paper),        line: c(s, st.soft),        width: 1.2,      })      const ctx = s.ctx      outline(        ctx,        {          ...p,          w: Math.max(p.h, p.w * p.value),          r: p.h / 2,          seed: seedOf(p.el) + 5,        },        st,      )      paint(ctx, st, {        color: c(s, st.accent),        box: [p.x, p.y, p.w * p.value, p.h],      })    },  }}function chipRulePainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    chip(p, s) {      body({ ...p, r: p.h / 2 }, s, {        fill: c(          s,          p.on > 0.5            ? st.accent            : p.hover              ? st.soft              : st.paper,        ),        line: c(s, p.on > 0.5 ? st.accent : st.soft),        width: 1.2,      })      ring({ ...p, r: p.h / 2 }, s)    },    rule(p, s) {      const ctx = s.ctx,        y = p.y + p.h / 2,        j = rng(seedOf(p.el))      ctx.beginPath()      ctx.moveTo(p.x, y)      for (let x = p.x; x <= p.x + p.w; x += 24)        ctx.lineTo(x, y + j() * st.rough * 1.5)      stroke(ctx, st, {        color: c(s, st.soft),        width: 1.5,      })    },  }}function detailPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c } = hands  return {    clear(p, s) {      const ctx = s.ctx      ctx.beginPath()      ctx.roundRect(p.x - 4, p.y - 4, p.w + 8, p.h + 8, 8)      ctx.globalAlpha = 0.86      ctx.fillStyle = c(s, st.paper)      ctx.fill()    },    type: st.type,    art: st.art,  }}function makeFocusRing(st: Style, c: PaintColor) {  return (p: StagePiece, s: Scene) => {    if (p.focus < 0.5) return    const ctx = s.ctx    ctx.setLineDash([6, 5])    ctx.lineDashOffset = -s.time * 24    outline(      ctx,      {        x: p.x - 4,        y: p.y - 4,        w: p.w + 8,        h: p.h + 8,        r: p.r + 4,        seed: seedOf(p.el) + 3,      },      st,    )    stroke(ctx, st, { color: c(s, st.accent), width: 2 })    ctx.setLineDash([])  }}function makeSurfaceLift(st: Style, c: PaintColor) {  return (p: StagePiece, s: Scene, depth: number) => {    const ctx = s.ctx    if (st.shadow === 'soft') {      ctx.shadowColor = 'rgb(0 0 0 / 0.2)'      ctx.shadowBlur = depth * 3      ctx.shadowOffsetY = depth      outline(ctx, { ...p, seed: seedOf(p.el) }, st)      ctx.fillStyle = c(s, st.paper)      ctx.fill()      ctx.shadowColor = 'transparent'    }    if (st.shadow === 'offset') {      outline(        ctx,        {          ...p,          x: p.x + depth,          y: p.y + depth,          seed: seedOf(p.el) + 1,        },        st,      )      paint(ctx, st, {        color: c(s, st.soft),        box: [p.x + depth, p.y + depth, p.w, p.h],        density: 0.7,      })    }  }}type BodyLook = Parameters<PaintBody>[2]function makeSurfaceBody(st: Style) {  return (    p: StagePiece,    s: Scene,    { fill, line, width = 1.5, solid = false }: BodyLook,  ) => {    const ctx = s.ctx    outline(ctx, { ...p, seed: seedOf(p.el) }, st)    paint(ctx, solid ? { ...st, hatch: false } : st, {      color: fill,      box: [p.x, p.y, p.w, p.h],    })    outline(ctx, { ...p, seed: seedOf(p.el) }, st)    stroke(ctx, st, { color: line, width })  }}function makeOverlay(st: Style, c: PaintColor) {  return (s: Scene) => {    const ctx = s.ctx    for (const press of s.presses) {      ctx.globalAlpha = 1 - press.age      ctx.beginPath()      ctx.arc(        press.x,        press.y,        6 + press.age * 60,        0,        Math.PI * 2,      )      stroke(ctx, st, {        color: c(s, st.accent),        width: 2,      })    }    ctx.globalAlpha = 1    if (s.rail) {      ctx.beginPath()      ctx.roundRect(        s.rail.x,        s.rail.top,        s.rail.w,        s.rail.len,        s.rail.w / 2,      )      ctx.fillStyle = c(        s,        s.rail.drag ? st.accent : st.soft,      )      ctx.fill()    }  }}function makePlain(st: Style, c: PaintColor) {  return (s: Scene) => {    s.ctx.fillStyle = c(s, st.paper)    s.ctx.fillRect(0, 0, s.width, s.height)  }}function makeVeil() {  return (s: Scene) => {    s.ctx.globalAlpha =      Math.min(1, s.live ? s.shown / 0.2 : 1) * 0.5    s.ctx.fillStyle = '#000'    s.ctx.fillRect(0, 0, s.width, s.height)    s.ctx.globalAlpha = 1  }}function lookKit(st: Style) {  const c = (s: Scene, name: string) => s.color(name)  const ring = makeFocusRing(st, c)  const lift = makeSurfaceLift(st, c)  // `solid` keeps a surface's paper whole: a panel or a  // list floats over the page, and a field's words need  // clean ground.  const body = makeSurfaceBody(st)  const hands = { c, ring, lift, body }  const painters: Record<string, StagePainter> = {    ...surfacePainters(st, hands),    ...fieldPainters(st, hands),    ...checkPainters(st, hands),    ...switchPainters(st, hands),    ...rangeMeterPainters(st, hands),    ...chipRulePainters(st, hands),    ...detailPainters(st, hands),  }  const overlay = makeOverlay(st, c)  const plain = makePlain(st, c)  // A modal's stage covers the page with a wash that  // fades in each time it opens.  const veil = makeVeil()  return {    stage: { painters, overlay, background: st.ground },    plain,    veil,    style: st,  }}type WordInk = {  fill: (ctx: CanvasRenderingContext2D) => void  tilt?: number}const words = (  p: StagePiece,  s: Scene,  { fill, tilt = 0 }: WordInk,) => {  const ctx = s.ctx,    j = rng(seedOf(p.el))  fill(ctx)  for (const w of s.words(p.el)) {    ctx.font = w.font    ctx.letterSpacing = `${w.spacing}px`    ctx.save()    ctx.translate(w.x, w.y)    ctx.rotate(j() * tilt)    ctx.fillText(w.text, 0, 0)    ctx.restore()  }}const TOKENS = {  ink: 'var(--stage-ink, var(--veneer-color-text))',  paper:    'var(--stage-paper, var(--veneer-color-surface))',  accent:    'var(--stage-accent, ' +    'var(--veneer-color-accent-solid))',}const LOOKS = {  flat: lookKit({    ...TOKENS,    soft: 'var(--stage-soft, var(--veneer-color-border))',    onFill: 'var(--veneer-color-accent-on-solid)',    rough: 0,    glow: 0,    hatch: false,    shadow: 'soft',    ground(s) {      // Dots that scroll with the page, painted under      // everything.      const ctx = s.ctx,        gap = 28,        off = (s.scroll?.top ?? 0) % gap      ctx.fillStyle = s.color(        '--veneer-color-surface-sunken',      )      ctx.fillRect(0, 0, s.width, s.height)      ctx.fillStyle = s.color('--veneer-color-border')      for (let y = -off; y < s.height; y += gap)        for (let x = gap / 2; x < s.width; x += gap)          ctx.fillRect(x, y, 2, 2)    },    art(p, s) {      const ctx = s.ctx,        t = s.time * 0.4      ctx.beginPath()      ctx.roundRect(p.x, p.y, p.w, p.h, p.r)      ctx.clip()      ctx.fillStyle = s.color(        '--veneer-color-accent-soft',      )      ctx.fillRect(p.x, p.y, p.w, p.h)      for (const [i, tone] of [        '--veneer-color-accent-solid',        '--veneer-color-info-solid',        '--veneer-color-highlight-solid',      ].entries()) {        const cx =            p.x +            p.w * (0.5 + Math.sin(t + i * 2.1) * 0.3),          cy =            p.y +            p.h * (0.5 + Math.cos(t * 1.3 + i) * 0.3),          g = ctx.createRadialGradient(            cx,            cy,            0,            cx,            cy,            p.w * 0.45,          )        g.addColorStop(0, s.color(tone))        g.addColorStop(1, 'transparent')        ctx.globalAlpha = 0.75        ctx.fillStyle = g        ctx.fillRect(p.x, p.y, p.w, p.h)      }    },    type(p, s) {      words(p, s, {        fill: (ctx) => {          const g = ctx.createLinearGradient(            p.x,            p.y,            p.x + p.w,            p.y + p.h,          )          g.addColorStop(0, s.color(TOKENS.accent))          g.addColorStop(            1,            s.color('--veneer-color-info-solid'),          )          ctx.fillStyle = g        },      })    },  }),  sketch: lookKit({    ...TOKENS,    soft:      'var(--stage-soft, ' +      'var(--veneer-color-text-subtle))',    onFill: 'var(--veneer-color-text)',    rough: 2.2,    glow: 0,    hatch: true,    shadow: 'offset',    ground(s) {      // Ruled paper: the lines are fixed to the page, so      // they scroll with it, and a margin rule runs down      // the side.      const ctx = s.ctx,        gap = 30,        off = (s.scroll?.top ?? 0) % gap      ctx.fillStyle = s.color(TOKENS.paper)      ctx.fillRect(0, 0, s.width, s.height)      ctx.strokeStyle = s.color(        '--veneer-color-info-border',      )      ctx.lineWidth = 1      ctx.beginPath()      for (let y = gap - off; y < s.height; y += gap) {        ctx.moveTo(0, y + 0.5)        ctx.lineTo(s.width, y + 0.5)      }      ctx.stroke()      ctx.strokeStyle = s.color(        '--veneer-color-critical-border',      )      ctx.beginPath()      ctx.moveTo(18.5, 0)      ctx.lineTo(18.5, s.height)      ctx.stroke()    },    art(p, s) {      const ctx = s.ctx,        j = rng(seedOf(p.el)),        cx = p.x + p.w / 2,        cy = p.y + p.h / 2      ctx.strokeStyle = s.color(TOKENS.ink)      ctx.lineWidth = 1.4      for (        let r = 8;        r < Math.min(p.w, p.h) / 2;        r += 9      ) {        ctx.beginPath()        for (let a = 0; a <= Math.PI * 2.05; a += 0.3)          ctx.lineTo(            cx + Math.cos(a) * (r + j() * 3),            cy + Math.sin(a) * (r + j() * 3),          )        ctx.stroke()      }      outline(        ctx,        { ...p, r: 0, seed: seedOf(p.el) },        LOOKS.sketch.style,      )      stroke(ctx, LOOKS.sketch.style, {        color: s.color(TOKENS.ink),        width: 1.5,      })    },    type(p, s) {      words(p, s, {        fill: (ctx) =>          (ctx.fillStyle = s.color(TOKENS.ink)),        tilt: 0.04,      })    },  }),  neon: lookKit({    // Its own colours, a pair for each theme: an app's    // --stage-* still wins over them.    ink: 'var(--stage-ink, light-dark(#3a1060, #f4ecff))',    paper:      'var(--stage-paper, light-dark(#fff2fa, #120a24))',    accent:      'var(--stage-accent, light-dark(#d81b78, #ff3ea5))',    soft:      'var(--stage-soft, ' +      'light-dark(#e3b8ef, #4a3a7a))',    onFill: '#fff',    rough: 0,    glow: 14,    hatch: false,    shadow: 'none',    ground(s) {      // A horizon and a floor grid that rolls toward you      // as the page scrolls and time passes.      const ctx = s.ctx,        w = s.width,        h = s.height,        hz = h * 0.42,        sky = ctx.createLinearGradient(0, 0, 0, h)      sky.addColorStop(        0,        s.color('light-dark(#fff2fa, #07021a)'),      )      sky.addColorStop(        0.42,        s.color('light-dark(#ffd1ec, #2a0b4a)'),      )      sky.addColorStop(        1,        s.color('light-dark(#fff2fa, #07021a)'),      )      ctx.fillStyle = sky      ctx.fillRect(0, 0, w, h)      ctx.strokeStyle = ctx.shadowColor = s.color(        'light-dark(#c86bff, #7b2cff)',      )      ctx.lineWidth = 1      ctx.shadowBlur = 8      const roll =        ((s.scroll?.top ?? 0) / 40 + s.time * 0.6) % 1      ctx.beginPath()      for (let i = 0; i < 14; i++) {        const z = (i + roll) / 14,          y = hz + (h - hz) * z * z        ctx.moveTo(0, y)        ctx.lineTo(w, y)      }      for (let i = -12; i <= 12; i++) {        ctx.moveTo(w / 2 + i * 12, hz)        ctx.lineTo(w / 2 + i * w * 0.18, h)      }      ctx.stroke()      ctx.shadowBlur = 0    },    art(p, s) {      // A striped sun setting into the floor.      const ctx = s.ctx,        cx = p.x + p.w / 2,        cy = p.y + p.h * 0.62,        r = Math.min(p.w, p.h) * 0.38,        g = ctx.createLinearGradient(0, cy - r, 0, cy + r)      ctx.beginPath()      ctx.roundRect(p.x, p.y, p.w, p.h, p.r)      ctx.clip()      g.addColorStop(0, '#ffd23f')      g.addColorStop(1, s.color(LOOKS.neon.style.accent))      ctx.shadowColor = s.color(LOOKS.neon.style.accent)      ctx.shadowBlur = 30      ctx.fillStyle = g      ctx.beginPath()      ctx.arc(        cx,        cy,        r * (1 + p.hover * 0.06),        0,        Math.PI * 2,      )      ctx.fill()      ctx.shadowBlur = 0      ctx.fillStyle = s.color(LOOKS.neon.style.paper)      for (let i = 0; i < 6; i++) {        const y =          cy +          r * (0.1 + i * 0.16) -          ((s.time * 6) % (r * 0.16))        ctx.fillRect(p.x, y, p.w, 2 + i * 1.2)      }    },    type(p, s) {      s.ctx.shadowColor = s.color(LOOKS.neon.style.accent)      s.ctx.shadowBlur = 18      words(p, s, {        fill: (ctx) => {          const g = ctx.createLinearGradient(            p.x,            p.y,            p.x,            p.y + p.h,          )          g.addColorStop(            0,            s.color('light-dark(#0aa6c9, #28e7ff)'),          )          g.addColorStop(            1,            s.color(LOOKS.neon.style.accent),          )          ctx.fillStyle = g        },      })    },  }),} satisfies Record<LookName, ReturnType<typeof lookKit>>const lookStyle = (name: LookName, rest = '') =>  `${rest};--stage-on-fill:${LOOKS[name].style.onFill}`type Look = 'dither' | LookNameconst jump = (e: MouseEvent, to?: string) => {  e.preventDefault()  const page = (      e.currentTarget as HTMLElement    ).closest<HTMLElement>('[data-layout="scroll"]'),    target = to      ? page?.querySelector<HTMLElement>(          `[data-land="${to}"]`,        )      : undefined  page?.scrollTo({    top: target ? target.offsetTop - 64 : 0,    behavior: 'smooth',  })}function LookStage({  look,  role,  children,  ...rest}: {  look: Look  role: 'page' | 'header' | 'modal'  children?: unknown  [attr: string]: unknown}) {  const name = untrack(() => look),    part = untrack(() => role)  if (name === 'dither')    return ditherStage(part, () => children, rest)  const kit = LOOKS[name],    page = lookStyle(name, 'block-size:720px'),    plain = lookStyle(name)  if (part === 'page')    return (      <CanvasStage        {...rest}        {...kit.stage}        layout="scroll"        rail        animate={name === 'neon'}        style={page}      >        {children}      </CanvasStage>    )  if (part === 'header')    return (      <CanvasStage        {...rest}        {...kit.stage}        background={kit.plain}        style={plain}      >        {children}      </CanvasStage>    )  return (    <CanvasStage      {...rest}      {...kit.stage}      layout="page"      veil      background={kit.veil}      style={plain}    >      {children}    </CanvasStage>  )}function ditherStage(  part: 'page' | 'header' | 'modal',  children: () => unknown,  rest: Record<string, unknown>,) {  if (part === 'page')    return (      <DitherStage        {...rest}        layout="scroll"        rail        kind="weave"        ground={0.42}        drift={0.4}        style="block-size:720px"      >        {children()}      </DitherStage>    )  if (part === 'header')    return (      <DitherStage        {...rest}        ground={0}        style="background:var(--veneer-color-surface)"      >        {children()}      </DitherStage>    )  return (    <DitherStage      {...rest}      layout="page"      veil={0.5}      ground={0}    >      {children()}    </DitherStage>  )}const LOOK_NAMES: [Look, string][] = [  ['dither', 'Dither'],  ['flat', 'Flat'],  ['sketch', 'Sketch'],  ['neon', 'Neon'],]const LAND_WRAP =  'padding-inline:clamp(16px,5cqi,56px);' +  'max-inline-size:1120px;margin-inline:auto'const LAND_SECTION =  LAND_WRAP + ';padding-block:clamp(40px,8cqi,88px)'const DISPLAY =  'margin:0;line-height:0.95;' +  'font-family:var(--stage-type-face);' +  'font-weight:var(--stage-type-heft);' +  'letter-spacing:var(--stage-type-track)'const LEDE =  'margin:0;font-size:clamp(16px,2cqi,19px);' +  'line-height:1.5;max-inline-size:52ch;' +  'padding:10px 12px;border-radius:6px'const H2 =  DISPLAY + ';font-size:clamp(32px,5cqi,52px)'const LAND_FEATURES = [  {    kind: 'weave',    title: 'Previews on every push',    body:      'Each branch gets its own URL ' +      'in about forty seconds, torn ' +      'down when it merges.',  },  {    kind: 'flow',    title: 'Rollbacks in one press',    body:      'Every deploy is kept. Go back ' +      'to any of them without a ' +      'rebuild.',  },  {    kind: 'drift',    title: 'Edge cache, tuned',    body:      'Static work is cached near ' +      'your readers, and purged the ' +      'moment you ship.',  },] as constconst LAND_PLANS = [  {    name: 'Hobby',    monthly: 0,    blurb: 'One project, previews, a shared build queue.',  },  {    name: 'Team',    monthly: 24,    blurb: 'Unlimited projects, rollbacks, five seats.',  },  {    name: 'Scale',    monthly: 89,    blurb: 'Edge cache, audit log, priority builds.',  },] as constconst LAND_FAQ = [  {    q: 'Is any of this really a canvas?',    a:      'Every border, fill, shadow, ' +      'field, check and heading you ' +      'see is painted on one canvas. ' +      'The DOM underneath keeps the ' +      'layout, the text, focus and ' +      'the accessibility tree.',  },  {    q: 'Does it have to be dithered?',    a:      'No. Dithering is one look. A ' +      'look is a set of painters, so ' +      'the same page can be flat ' +      'vector, pencil, neon or ' +      'anything a 2D context can ' +      'draw. Switch looks above.',  },  {    q: 'What happens on a phone?',    a:      'The page reflows like any ' +      'other: the grids stack, the ' +      'hero wraps, and the stage ' +      'paints wherever the boxes ' +      'land.',  },  {    q: 'And with reduced motion?',    a:      'Backgrounds hold still, pieces ' +      'appear without their entrance, ' +      'and every state still shows.',  },]function landingHeader(  name: Look,  open: () => void,) {  return (    <header style="position:sticky;top:0;z-index:2">      <LookStage        look={name}        role="header"      >        <Row          justify="between"          gap="related"          style={`${LAND_WRAP};padding-block:12px`}        >          <a            href="#top"            data-skin="type"            data-ramp="accent"            style="font-size:30px;color:inherit"            onClick={(e: MouseEvent) => jump(e)}          >            kerf          </a>          <Row            gap="tight"            wrap          >            <a              href="#features"              data-skin="chip"              onClick={(e: MouseEvent) =>                jump(e, 'features')              }            >              Features            </a>            <a              href="#pricing"              data-skin="chip"              onClick={(e: MouseEvent) =>                jump(e, 'pricing')              }            >              Pricing            </a>            <a              href="#faq"              data-skin="chip"              onClick={(e: MouseEvent) => jump(e, 'faq')}            >              FAQ            </a>            <button              data-skin="button"              onClick={open}            >              Start free            </button>          </Row>        </Row>        <div data-skin="rule"></div>      </LookStage>    </header>  )}function landingHero(open: () => void) {  return (    <section style={LAND_SECTION}>      <Split        side="end"        width="half"        gap="section"      >        <Stack          gap="group"          align="start"        >          <span            data-skin="chip"            data-ramp="highlight"          >            New: edge cache in 14 regions          </span>          <h1            data-skin="type"            data-ramp="accent"            style={              DISPLAY + ';font-size:clamp(44px,9cqi,96px)'            }          >            Ship it on canvas.          </h1>          <p            data-skin="clear"            style={LEDE}          >            kerf builds, previews and ships every push.            This page is one canvas: every surface on it            is painted, and every control is still real            HTML.          </p>          <Row            gap="related"            wrap          >            <button              data-skin="button"              onClick={open}            >              Start a project            </button>            <a              href="#pricing"              data-skin="button"              data-look="outline"              onClick={(e: MouseEvent) =>                jump(e, 'pricing')              }            >              See pricing            </a>          </Row>        </Stack>        <div          data-skin="art"          data-kind="rings"          data-ramp="accent"          role="img"          aria-label="An animated picture"          style={            '--stage-art-ratio:4/3;' +            'min-block-size:220px'          }        ></div>      </Split>    </section>  )}function landingFeatures() {  return (    <section      data-land="features"      style={LAND_SECTION}    >      <Stack gap="group">        <h2          data-skin="type"          style={H2}        >          Less to look after        </h2>        <Grid          min="md"          cols={3}          gap="group"        >          {LAND_FEATURES.map((f) => (            <article              key={f.title}              data-skin="card"              style="--stage-pad-card:0.875rem"            >              <div                data-skin="art"                data-kind={f.kind}                style="--stage-art-ratio:16/7"              ></div>              <h3 style="margin:0;font-size:19px">                {f.title}              </h3>              <p style="margin:0;line-height:1.5">                {f.body}              </p>            </article>          ))}        </Grid>      </Stack>    </section>  )}function landingControls(  name: Look,  open: () => void,  minutes: State<number>,) {  return (    <section style={LAND_SECTION}>      <Split        side="start"        width="half"        gap="section"      >        <Stack          gap="related"          align="start"        >          <h2            data-skin="type"            style={H2}          >            Try the knobs          </h2>          <p            data-skin="clear"            style={LEDE}          >            Fields, checks, a switch and a slider, all            painted by the stage. Tab through them: focus            is drawn round whatever has it.          </p>        </Stack>        {controlsForm(name, open, minutes)}      </Split>    </section>  )}function controlsForm(  name: Look,  open: () => void,  minutes: State<number>,) {  const submit = (e: Event) => {    e.preventDefault()    open()  }  return (    <form      data-skin="card"      style="--stage-gap-card:1rem"      onSubmit={submit}    >      {controlsFields(name, minutes)}    </form>  )}function controlsFields(  name: Look,  minutes: State<number>,) {  return (    <>      {identityFields(name)}      {optionFields()}      {minuteFields(minutes)}    </>  )}function identityFields(name: Look) {  return (    <>      <label data-part="label">        Project name        <input          data-skin="field"          required          value="storefront"        />      </label>      <label data-part="label">        Region        <StageSelect          name="region"          options={['eu-west', 'us-east', 'ap-south']}        />      </label>      <fieldset        style={          'border:0;padding:0;margin:0;' +          'display:flex;gap:18px;flex-wrap:wrap'        }      >        <legend          style={            'font-size:14px;font-weight:600;' +            'margin-block-end:8px'          }        >          Runtime        </legend>        <label data-part="option">          <input            type="radio"            name={`land-${name}`}            data-skin="check"            checked          />          Node        </label>        <label data-part="option">          <input            type="radio"            name={`land-${name}`}            data-skin="check"          />          Bun        </label>        <label data-part="option">          <input            type="radio"            name={`land-${name}`}            data-skin="check"          />          Deno        </label>      </fieldset>    </>  )}function optionFields() {  return (    <>      <label data-part="option">        <input          type="checkbox"          data-skin="check"          checked        />        Preview every pull request      </label>      <label data-part="option">        <input          type="checkbox"          role="switch"          data-skin="switch"          checked        />        Deploy on push to main      </label>    </>  )}function minuteFields(minutes: State<number>) {  return (    <>      <label data-part="label">        Build minutes:{' '}        {minutes.value.toLocaleString('en')}        <input          type="range"          data-skin="range"          min="0"          max="6000"          step="100"          value={minutes.value}          onInput={(e: Event) =>            (minutes.value = +(              e.currentTarget as HTMLInputElement            ).value)          }        />      </label>      <label data-part="label">        Of the Team plan's 6,000        <meter          data-skin="meter"          min="0"          max="6000"          value={minutes.value}        ></meter>      </label>      <button data-skin="button">Create project</button>    </>  )}function billingPeriod(yearly: State<boolean>) {  return (    <Row      gap="tight"      role="group"      aria-label="Billing period"    >      <button        data-skin="chip"        aria-pressed={yearly.value ? 'false' : 'true'}        onClick={() => (yearly.value = false)}      >        Monthly      </button>      <button        data-skin="chip"        aria-pressed={yearly.value ? 'true' : 'false'}        onClick={() => (yearly.value = true)}      >        Yearly, two months free      </button>    </Row>  )}function landingPricing(  yearly: State<boolean>,  plan: State<string>,  price: (monthly: number) => number,) {  return (    <section      data-land="pricing"      style={LAND_SECTION}    >      <Stack gap="group">        <Row          justify="between"          gap="related"          wrap        >          <h2            data-skin="type"            style={H2}          >            Pricing          </h2>          {billingPeriod(yearly)}        </Row>        <Grid          min="md"          cols={3}          gap="group"        >          {LAND_PLANS.map((p) => (            <button              key={p.name}              data-skin="card"              data-ramp={                p.name === 'Team' ? 'accent' : undefined              }              aria-pressed={                plan.value === p.name ? 'true' : 'false'              }              onClick={() => (plan.value = p.name)}            >              <span                style={'font-weight:700;font-size:18px'}              >                {p.name}              </span>              <span                style={                  'font-family:' +                  'var(--veneer-font-display);' +                  'font-size:44px;font-weight:800;' +                  'line-height:1'                }              >                ${price(p.monthly)}                <span                  style={'font-size:15px;font-weight:500'}                >                  {yearly.value ? ' a year' : ' a month'}                </span>              </span>              <span style="line-height:1.5">                {p.blurb}              </span>            </button>          ))}        </Grid>      </Stack>    </section>  )}function landingFaq() {  return (    <section      data-land="faq"      style={LAND_SECTION}    >      <Stack gap="group">        <h2          data-skin="type"          style={H2}        >          Questions        </h2>        {LAND_FAQ.map((f) => (          <details            key={f.q}            data-skin="card"          >            <summary>{f.q}</summary>            <p style="line-height:1.55">{f.a}</p>          </details>        ))}      </Stack>    </section>  )}function landingFooter(joined: State<boolean>) {  const submit = (e: Event) => {    e.preventDefault()    joined.value = true  }  return (    <footer      style={`${LAND_SECTION};display:grid;gap:24px`}    >      <form        data-skin="card"        onSubmit={submit}      >        <h2          data-skin="type"          data-ramp="accent"          style={            DISPLAY + ';font-size:clamp(26px,4cqi,40px)'          }        >          {joined.value            ? 'You are on the list.'            : 'Get the changelog'}        </h2>        <Row          gap="related"          wrap        >          <input            type="email"            required            placeholder="you@company.com"            aria-label="Email"            data-skin="field"            style="inline-size:auto;flex:1 1 220px"          />          <button data-skin="button">Subscribe</button>        </Row>      </form>      <div data-skin="rule"></div>      <Row        justify="between"        wrap        gap="related"      >        <span data-skin="clear">© 2026 kerf</span>        <span data-skin="clear">          Painted on one canvas.        </span>      </Row>    </footer>  )}type DialogProps = {  name: Look  plan: State<string>  signedUp: State<boolean>  setDialog: (n: HTMLDialogElement) => HTMLDialogElement  close: () => void}function landingDialog(o: DialogProps) {  const submit = (e: Event) => {    e.preventDefault()    o.signedUp.value = true  }  return (    <dialog      ref={o.setDialog}      aria-label="Start a project"      onClose={() => (o.signedUp.value = false)}    >      <LookStage        look={o.name}        role="modal"        onClick={(e: MouseEvent) =>          e.target === e.currentTarget && o.close()        }      >        <form          data-skin="panel"          onSubmit={submit}        >          <div data-skin="bar">            <span>Start a project</span>          </div>          <Stack gap="related">            {o.signedUp.value ? (              <p style="margin:0;line-height:1.5">                Done. Your first preview builds as soon as                you push.              </p>            ) : (              signupFields(o.plan)            )}            <Row              gap="related"              justify="end"            >              <button                type="button"                data-skin="button"                data-look="outline"                onClick={() => o.close()}              >                {o.signedUp.value ? 'Close' : 'Cancel'}              </button>              {!o.signedUp.value && (                <button data-skin="button">Create</button>              )}            </Row>          </Stack>        </form>      </LookStage>    </dialog>  )}function signupFields(plan: State<string>) {  return (    <>      <label data-part="label">        Email        <input          type="email"          required          autofocus          placeholder="you@company.com"          data-skin="field"        />      </label>      <label data-part="label">        Plan        <StageSelect          name="plan"          options={LAND_PLANS.map((p) => p.name)}          value={plan.value}          onChange={(v: string) => (plan.value = v)}        />      </label>      <label data-part="option">        <input          type="checkbox"          data-skin="check"          checked        />        Send me the changelog      </label>    </>  )}function Landing({ look }: { look: Look }) {  const yearly = state(false)  const plan = state('Team')  const minutes = state(1800)  const joined = state(false)  const signedUp = state(false)  const name = untrack(() => look)  let dialog: HTMLDialogElement | undefined  const price = (monthly: number) =>    yearly.value ? Math.round(monthly * 10) : monthly  const open = () => dialog?.showModal()  const close = () => dialog?.close()  const setDialog = (n: HTMLDialogElement) => (dialog = n)  return (    <LookStage      look={name}      role="page"      aria-label={[        'kerf, a landing page painted ',        'on one canvas',      ].join('')}    >      {landingHeader(name, open)}      {landingHero(open)}      {landingFeatures()}      {landingControls(name, open, minutes)}      {landingPricing(yearly, plan, price)}      {landingFaq()}      {landingFooter(joined)}      {landingDialog({        name,        plan,        signedUp,        setDialog,        close,      })}    </LookStage>  )}

Installation

npm install @kerf-js/veneer

Usage

tsx
import { CanvasStage } from '@kerf-js/veneer'

Looks

The same markup under three sets of painters. A look is only painters, a background and an overlay.

Flat

Clean vector: rounded shapes, soft shadows, a gradient on the type and dots that scroll with the page.

tsx
import {  CanvasStage,  Grid,  Row,  Stack,  StageSelect} from '@kerf-js/veneer'import type {  Scene,  StagePainter,  StagePiece} from '@kerf-js/veneer'export function Flat() {  return (  <CanvasStage    {...LOOKS.flat.stage}    style={FLAT_STYLE}  >    <Skins group="look-flat" />  </CanvasStage>  )}type LookName = 'flat' | 'sketch' | 'neon'type Style = {  ink: string  paper: string  accent: string  soft: string  /** Text on a filled button or chip. */  onFill: string  /** Pixels of wobble: 0 draws clean lines. */  rough: number  /** Glow, in pixels of blur. */  glow: number  /** Fills as pencil hatching instead of solid. */  hatch: boolean  shadow: 'soft' | 'offset' | 'none'  ground: (s: Scene) => void  art: (p: StagePiece, s: Scene) => void  type: (p: StagePiece, s: Scene) => void}const seeds = new WeakMap<Element, number>()let seedNext = 1const seedOf = (el: Element) => {  let v = seeds.get(el)  if (v === undefined)    seeds.set(el, (v = seedNext++ * 7919))  return v}const rng = (seed: number) => () => {  seed = (seed * 16807) % 2147483647  return seed / 2147483647 - 0.5}type Pt = [number, number]type Box = {  x: number  y: number  w: number  h: number  r: number  seed: number}function outline(  ctx: CanvasRenderingContext2D,  box: Box,  st: Style,) {  const { x, y, w, h, r, seed } = box  ctx.beginPath()  if (!st.rough) {    ctx.roundRect(x, y, w, h, Math.min(r, h / 2, w / 2))    return  }  const j = rng(seed),    k = st.rough,    pts: Pt[] = []  const edge = (a: Pt, b: Pt) => {    const n = Math.max(      2,      Math.round(        Math.hypot(b[0] - a[0], b[1] - a[1]) / 28,      ),    )    for (let i = 0; i < n; i++)      pts.push([        a[0] + ((b[0] - a[0]) * i) / n + j() * k,        a[1] + ((b[1] - a[1]) * i) / n + j() * k,      ])  }  edge([x, y], [x + w, y])  edge([x + w, y], [x + w, y + h])  edge([x + w, y + h], [x, y + h])  edge([x, y + h], [x, y])  ctx.moveTo(pts[0]![0], pts[0]![1])  for (let i = 1; i <= pts.length; i++) {    const [px, py] = pts[i % pts.length]!    ctx.lineTo(px, py)  }  ctx.lineTo(pts[0]![0] + j() * k, pts[0]![1] + j() * k)}function stroke(  ctx: CanvasRenderingContext2D,  st: Style,  pen: { color: string; width: number },) {  const { color, width } = pen  ctx.strokeStyle = color  ctx.lineWidth = width  ctx.lineJoin = ctx.lineCap = 'round'  if (st.glow) {    ctx.shadowColor = color    ctx.shadowBlur = st.glow  }  ctx.stroke()  ctx.shadowBlur = 0}function paint(  ctx: CanvasRenderingContext2D,  st: Style,  fill: {    color: string    box: [number, number, number, number]    density?: number  },) {  const { color, box, density = 1 } = fill  if (!st.hatch) {    ctx.fillStyle = color    ctx.fill()    return  }  // Pencil hatching: the shape clips a run of diagonal  // strokes.  const [x, y, w, h] = box,    gap = 7 / density  ctx.save()  ctx.clip()  ctx.beginPath()  for (let d = -h; d < w; d += gap) {    ctx.moveTo(x + d, y + h)    ctx.lineTo(x + d + h, y)  }  ctx.strokeStyle = color  ctx.lineWidth = 1.6  ctx.globalAlpha = 0.55  ctx.stroke()  ctx.restore()}type PaintColor = (  s: Scene,  name: string,) => stringtype PaintRing = (p: StagePiece, s: Scene) => voidtype PaintLift = (  p: StagePiece,  s: Scene,  depth: number,) => voidtype PaintBody = (  p: StagePiece,  s: Scene,  look: {    fill: string    line: string    width?: number    solid?: boolean  },) => voidtype Hands = {  c: PaintColor  ring: PaintRing  lift: PaintLift  body: PaintBody}function surfacePainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, lift, body } = hands  return {    button(p, s) {      const outlineOnly = p.el.dataset.look === 'outline',        y = p.press * 2      lift({ ...p, y: p.y + y }, s, 4 - p.press * 3)      const at = { ...p, y: p.y + y }      body(at, s, {        fill: outlineOnly          ? c(s, p.hover ? st.soft : st.paper)          : c(s, p.disabled ? st.soft : st.accent),        line: c(          s,          outlineOnly || st.rough ? st.ink : st.accent,        ),        width: outlineOnly ? 1.5 : 1,      })      if (p.hover && !st.hatch && !outlineOnly) {        const g = s.ctx.createRadialGradient(          p.x + p.px,          p.y + p.py,          0,          p.x + p.px,          p.y + p.py,          p.w * 0.7,        )        g.addColorStop(          0,          `rgb(255 255 255 / ${0.28 * p.hover})`,        )        g.addColorStop(1, 'rgb(255 255 255 / 0)')        outline(s.ctx, { ...at, seed: seedOf(p.el) }, st)        s.ctx.fillStyle = g        s.ctx.fill()      }      ring(p, s)    },    card(p, s) {      const live = p.el.matches('a[href], button'),        chosen = p.on > 0.5      lift(p, s, 6 + (live ? p.hover * 6 : 0))      body(p, s, {        fill: c(s, st.paper),        line: c(          s,          chosen            ? st.accent            : st.rough              ? st.ink              : st.soft,        ),        width: chosen ? 3 : 1.5,      })      ring(p, s)    },    panel(p, s) {      lift(p, s, 14)      body(p, s, {        fill: c(s, st.paper),        line: c(s, st.ink),        width: 1.5,        solid: true,      })    },  }}function fieldPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, lift, body } = hands  return {    bar(p, s) {      const ctx = s.ctx      ctx.beginPath()      for (let y = p.y + 3; y < p.y + p.h - 2; y += 4) {        ctx.moveTo(p.x + 6, y)        ctx.lineTo(p.x + p.w - 6, y)      }      stroke(ctx, st, { color: c(s, st.soft), width: 1 })    },    field(p, s) {      const line = p.invalid        ? c(s, '--veneer-color-critical-solid')        : c(s, p.focus > 0.5 ? st.accent : st.soft)      body(p, s, {        fill: c(s, st.paper),        line,        width: 1.5 + p.focus,        solid: true,      })      if (        p.el.tagName === 'SELECT' ||        p.el.getAttribute('aria-haspopup') === 'listbox'      ) {        const ctx = s.ctx,          x = p.x + p.w - 18,          y = p.y + p.h / 2,          d = p.on > 0.5 ? -3 : 3        ctx.beginPath()        ctx.moveTo(x - 5, y - d)        ctx.lineTo(x, y + d)        ctx.lineTo(x + 5, y - d)        stroke(ctx, st, {          color: c(s, st.ink),          width: 1.6,        })      }    },    // A StageSelect's list, in a stage of its own: a    // panel on a short lift, and the row under the keys    // or pointer filled.    menu(p, s) {      lift(p, s, 6)      body(p, s, {        fill: c(s, st.paper),        line: c(s, st.rough ? st.ink : st.soft),        width: 1.5,        solid: true,      })    },    option(p, s) {      if (p.hover > 0.5)        body(p, s, {          fill: c(s, st.accent),          line: c(s, st.accent),          width: 1,        })      else if (p.on > 0.5) {        const ctx = s.ctx        ctx.beginPath()        ctx.moveTo(p.x + 3, p.y + 8)        ctx.lineTo(p.x + 3, p.y + p.h - 8)        stroke(ctx, st, {          color: c(s, st.accent),          width: 3,        })      }    },  }}function checkPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    check(p, s) {      const ctx = s.ctx,        radio =          (p.el as HTMLInputElement).type === 'radio',        round = radio ? p.w / 2 : 5      body({ ...p, r: round }, s, {        fill: c(s, p.hover ? st.soft : st.paper),        line: c(          s,          st.rough            ? st.ink            : p.on > 0.5              ? st.accent              : st.soft,        ),        width: 1.6,      })      if (p.on > 0) {        if (radio) {          ctx.beginPath()          ctx.arc(            p.x + p.w / 2,            p.y + p.h / 2,            (p.w / 4) * p.on,            0,            Math.PI * 2,          )          paint(            ctx,            { ...st, hatch: false },            {              color: c(s, st.accent),              box: [p.x, p.y, p.w, p.h],            },          )        } else {          // The tick draws itself on as `on` eases in.          ctx.beginPath()          ctx.moveTo(p.x + p.w * 0.22, p.y + p.h * 0.52)          ctx.lineTo(p.x + p.w * 0.42, p.y + p.h * 0.72)          ctx.lineTo(p.x + p.w * 0.8, p.y + p.h * 0.28)          ctx.setLineDash([40 * p.on, 40])          stroke(ctx, st, {            color: c(s, st.accent),            width: 2.6,          })          ctx.setLineDash([])        }      }      ring({ ...p, r: round }, s)    },  }}function switchPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    switch(p, s) {      const ctx = s.ctx,        d = p.h - 6,        kx = p.x + 3 + (p.w - p.h) * p.on      body({ ...p, r: p.h / 2 }, s, {        fill:          p.on > 0.5 ? c(s, st.accent) : c(s, st.soft),        line: c(s, st.rough ? st.ink : st.soft),        width: 1.4,      })      ctx.beginPath()      ctx.arc(        kx + d / 2,        p.y + 3 + d / 2,        d / 2,        0,        Math.PI * 2,      )      ctx.fillStyle = c(s, st.paper)      ctx.fill()      stroke(ctx, st, { color: c(s, st.ink), width: 1.2 })      ring({ ...p, r: p.h / 2 }, s)    },  }}function rangeMeterPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    range(p, s) {      const ctx = s.ctx,        y = p.y + p.h / 2,        kx = p.x + 8 + (p.w - 16) * p.value      ctx.beginPath()      ctx.moveTo(p.x + 2, y)      ctx.lineTo(p.x + p.w - 2, y)      stroke(ctx, st, { color: c(s, st.soft), width: 4 })      ctx.beginPath()      ctx.moveTo(p.x + 2, y)      ctx.lineTo(kx, y)      stroke(ctx, st, {        color: c(s, st.accent),        width: 4,      })      ctx.beginPath()      ctx.arc(kx, y, 8 + p.hover * 2, 0, Math.PI * 2)      ctx.fillStyle = c(s, st.paper)      ctx.fill()      stroke(ctx, st, {        color: c(s, st.accent),        width: 2.5,      })      if (p.focus > 0.5)        ring(          {            ...p,            x: kx - 10,            y: y - 10,            w: 20,            h: 20,            r: 10,          },          s,        )    },    meter(p, s) {      body({ ...p, r: p.h / 2 }, s, {        fill: c(s, st.paper),        line: c(s, st.soft),        width: 1.2,      })      const ctx = s.ctx      outline(        ctx,        {          ...p,          w: Math.max(p.h, p.w * p.value),          r: p.h / 2,          seed: seedOf(p.el) + 5,        },        st,      )      paint(ctx, st, {        color: c(s, st.accent),        box: [p.x, p.y, p.w * p.value, p.h],      })    },  }}function chipRulePainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    chip(p, s) {      body({ ...p, r: p.h / 2 }, s, {        fill: c(          s,          p.on > 0.5            ? st.accent            : p.hover              ? st.soft              : st.paper,        ),        line: c(s, p.on > 0.5 ? st.accent : st.soft),        width: 1.2,      })      ring({ ...p, r: p.h / 2 }, s)    },    rule(p, s) {      const ctx = s.ctx,        y = p.y + p.h / 2,        j = rng(seedOf(p.el))      ctx.beginPath()      ctx.moveTo(p.x, y)      for (let x = p.x; x <= p.x + p.w; x += 24)        ctx.lineTo(x, y + j() * st.rough * 1.5)      stroke(ctx, st, {        color: c(s, st.soft),        width: 1.5,      })    },  }}function detailPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c } = hands  return {    clear(p, s) {      const ctx = s.ctx      ctx.beginPath()      ctx.roundRect(p.x - 4, p.y - 4, p.w + 8, p.h + 8, 8)      ctx.globalAlpha = 0.86      ctx.fillStyle = c(s, st.paper)      ctx.fill()    },    type: st.type,    art: st.art,  }}function makeFocusRing(st: Style, c: PaintColor) {  return (p: StagePiece, s: Scene) => {    if (p.focus < 0.5) return    const ctx = s.ctx    ctx.setLineDash([6, 5])    ctx.lineDashOffset = -s.time * 24    outline(      ctx,      {        x: p.x - 4,        y: p.y - 4,        w: p.w + 8,        h: p.h + 8,        r: p.r + 4,        seed: seedOf(p.el) + 3,      },      st,    )    stroke(ctx, st, { color: c(s, st.accent), width: 2 })    ctx.setLineDash([])  }}function makeSurfaceLift(st: Style, c: PaintColor) {  return (p: StagePiece, s: Scene, depth: number) => {    const ctx = s.ctx    if (st.shadow === 'soft') {      ctx.shadowColor = 'rgb(0 0 0 / 0.2)'      ctx.shadowBlur = depth * 3      ctx.shadowOffsetY = depth      outline(ctx, { ...p, seed: seedOf(p.el) }, st)      ctx.fillStyle = c(s, st.paper)      ctx.fill()      ctx.shadowColor = 'transparent'    }    if (st.shadow === 'offset') {      outline(        ctx,        {          ...p,          x: p.x + depth,          y: p.y + depth,          seed: seedOf(p.el) + 1,        },        st,      )      paint(ctx, st, {        color: c(s, st.soft),        box: [p.x + depth, p.y + depth, p.w, p.h],        density: 0.7,      })    }  }}type BodyLook = Parameters<PaintBody>[2]function makeSurfaceBody(st: Style) {  return (    p: StagePiece,    s: Scene,    { fill, line, width = 1.5, solid = false }: BodyLook,  ) => {    const ctx = s.ctx    outline(ctx, { ...p, seed: seedOf(p.el) }, st)    paint(ctx, solid ? { ...st, hatch: false } : st, {      color: fill,      box: [p.x, p.y, p.w, p.h],    })    outline(ctx, { ...p, seed: seedOf(p.el) }, st)    stroke(ctx, st, { color: line, width })  }}function makeOverlay(st: Style, c: PaintColor) {  return (s: Scene) => {    const ctx = s.ctx    for (const press of s.presses) {      ctx.globalAlpha = 1 - press.age      ctx.beginPath()      ctx.arc(        press.x,        press.y,        6 + press.age * 60,        0,        Math.PI * 2,      )      stroke(ctx, st, {        color: c(s, st.accent),        width: 2,      })    }    ctx.globalAlpha = 1    if (s.rail) {      ctx.beginPath()      ctx.roundRect(        s.rail.x,        s.rail.top,        s.rail.w,        s.rail.len,        s.rail.w / 2,      )      ctx.fillStyle = c(        s,        s.rail.drag ? st.accent : st.soft,      )      ctx.fill()    }  }}function makePlain(st: Style, c: PaintColor) {  return (s: Scene) => {    s.ctx.fillStyle = c(s, st.paper)    s.ctx.fillRect(0, 0, s.width, s.height)  }}function makeVeil() {  return (s: Scene) => {    s.ctx.globalAlpha =      Math.min(1, s.live ? s.shown / 0.2 : 1) * 0.5    s.ctx.fillStyle = '#000'    s.ctx.fillRect(0, 0, s.width, s.height)    s.ctx.globalAlpha = 1  }}function lookKit(st: Style) {  const c = (s: Scene, name: string) => s.color(name)  const ring = makeFocusRing(st, c)  const lift = makeSurfaceLift(st, c)  // `solid` keeps a surface's paper whole: a panel or a  // list floats over the page, and a field's words need  // clean ground.  const body = makeSurfaceBody(st)  const hands = { c, ring, lift, body }  const painters: Record<string, StagePainter> = {    ...surfacePainters(st, hands),    ...fieldPainters(st, hands),    ...checkPainters(st, hands),    ...switchPainters(st, hands),    ...rangeMeterPainters(st, hands),    ...chipRulePainters(st, hands),    ...detailPainters(st, hands),  }  const overlay = makeOverlay(st, c)  const plain = makePlain(st, c)  // A modal's stage covers the page with a wash that  // fades in each time it opens.  const veil = makeVeil()  return {    stage: { painters, overlay, background: st.ground },    plain,    veil,    style: st,  }}type WordInk = {  fill: (ctx: CanvasRenderingContext2D) => void  tilt?: number}const words = (  p: StagePiece,  s: Scene,  { fill, tilt = 0 }: WordInk,) => {  const ctx = s.ctx,    j = rng(seedOf(p.el))  fill(ctx)  for (const w of s.words(p.el)) {    ctx.font = w.font    ctx.letterSpacing = `${w.spacing}px`    ctx.save()    ctx.translate(w.x, w.y)    ctx.rotate(j() * tilt)    ctx.fillText(w.text, 0, 0)    ctx.restore()  }}const TOKENS = {  ink: 'var(--stage-ink, var(--veneer-color-text))',  paper:    'var(--stage-paper, var(--veneer-color-surface))',  accent:    'var(--stage-accent, ' +    'var(--veneer-color-accent-solid))',}const LOOKS = {  flat: lookKit({    ...TOKENS,    soft: 'var(--stage-soft, var(--veneer-color-border))',    onFill: 'var(--veneer-color-accent-on-solid)',    rough: 0,    glow: 0,    hatch: false,    shadow: 'soft',    ground(s) {      // Dots that scroll with the page, painted under      // everything.      const ctx = s.ctx,        gap = 28,        off = (s.scroll?.top ?? 0) % gap      ctx.fillStyle = s.color(        '--veneer-color-surface-sunken',      )      ctx.fillRect(0, 0, s.width, s.height)      ctx.fillStyle = s.color('--veneer-color-border')      for (let y = -off; y < s.height; y += gap)        for (let x = gap / 2; x < s.width; x += gap)          ctx.fillRect(x, y, 2, 2)    },    art(p, s) {      const ctx = s.ctx,        t = s.time * 0.4      ctx.beginPath()      ctx.roundRect(p.x, p.y, p.w, p.h, p.r)      ctx.clip()      ctx.fillStyle = s.color(        '--veneer-color-accent-soft',      )      ctx.fillRect(p.x, p.y, p.w, p.h)      for (const [i, tone] of [        '--veneer-color-accent-solid',        '--veneer-color-info-solid',        '--veneer-color-highlight-solid',      ].entries()) {        const cx =            p.x +            p.w * (0.5 + Math.sin(t + i * 2.1) * 0.3),          cy =            p.y +            p.h * (0.5 + Math.cos(t * 1.3 + i) * 0.3),          g = ctx.createRadialGradient(            cx,            cy,            0,            cx,            cy,            p.w * 0.45,          )        g.addColorStop(0, s.color(tone))        g.addColorStop(1, 'transparent')        ctx.globalAlpha = 0.75        ctx.fillStyle = g        ctx.fillRect(p.x, p.y, p.w, p.h)      }    },    type(p, s) {      words(p, s, {        fill: (ctx) => {          const g = ctx.createLinearGradient(            p.x,            p.y,            p.x + p.w,            p.y + p.h,          )          g.addColorStop(0, s.color(TOKENS.accent))          g.addColorStop(            1,            s.color('--veneer-color-info-solid'),          )          ctx.fillStyle = g        },      })    },  }),  sketch: lookKit({    ...TOKENS,    soft:      'var(--stage-soft, ' +      'var(--veneer-color-text-subtle))',    onFill: 'var(--veneer-color-text)',    rough: 2.2,    glow: 0,    hatch: true,    shadow: 'offset',    ground(s) {      // Ruled paper: the lines are fixed to the page, so      // they scroll with it, and a margin rule runs down      // the side.      const ctx = s.ctx,        gap = 30,        off = (s.scroll?.top ?? 0) % gap      ctx.fillStyle = s.color(TOKENS.paper)      ctx.fillRect(0, 0, s.width, s.height)      ctx.strokeStyle = s.color(        '--veneer-color-info-border',      )      ctx.lineWidth = 1      ctx.beginPath()      for (let y = gap - off; y < s.height; y += gap) {        ctx.moveTo(0, y + 0.5)        ctx.lineTo(s.width, y + 0.5)      }      ctx.stroke()      ctx.strokeStyle = s.color(        '--veneer-color-critical-border',      )      ctx.beginPath()      ctx.moveTo(18.5, 0)      ctx.lineTo(18.5, s.height)      ctx.stroke()    },    art(p, s) {      const ctx = s.ctx,        j = rng(seedOf(p.el)),        cx = p.x + p.w / 2,        cy = p.y + p.h / 2      ctx.strokeStyle = s.color(TOKENS.ink)      ctx.lineWidth = 1.4      for (        let r = 8;        r < Math.min(p.w, p.h) / 2;        r += 9      ) {        ctx.beginPath()        for (let a = 0; a <= Math.PI * 2.05; a += 0.3)          ctx.lineTo(            cx + Math.cos(a) * (r + j() * 3),            cy + Math.sin(a) * (r + j() * 3),          )        ctx.stroke()      }      outline(        ctx,        { ...p, r: 0, seed: seedOf(p.el) },        LOOKS.sketch.style,      )      stroke(ctx, LOOKS.sketch.style, {        color: s.color(TOKENS.ink),        width: 1.5,      })    },    type(p, s) {      words(p, s, {        fill: (ctx) =>          (ctx.fillStyle = s.color(TOKENS.ink)),        tilt: 0.04,      })    },  }),  neon: lookKit({    // Its own colours, a pair for each theme: an app's    // --stage-* still wins over them.    ink: 'var(--stage-ink, light-dark(#3a1060, #f4ecff))',    paper:      'var(--stage-paper, light-dark(#fff2fa, #120a24))',    accent:      'var(--stage-accent, light-dark(#d81b78, #ff3ea5))',    soft:      'var(--stage-soft, ' +      'light-dark(#e3b8ef, #4a3a7a))',    onFill: '#fff',    rough: 0,    glow: 14,    hatch: false,    shadow: 'none',    ground(s) {      // A horizon and a floor grid that rolls toward you      // as the page scrolls and time passes.      const ctx = s.ctx,        w = s.width,        h = s.height,        hz = h * 0.42,        sky = ctx.createLinearGradient(0, 0, 0, h)      sky.addColorStop(        0,        s.color('light-dark(#fff2fa, #07021a)'),      )      sky.addColorStop(        0.42,        s.color('light-dark(#ffd1ec, #2a0b4a)'),      )      sky.addColorStop(        1,        s.color('light-dark(#fff2fa, #07021a)'),      )      ctx.fillStyle = sky      ctx.fillRect(0, 0, w, h)      ctx.strokeStyle = ctx.shadowColor = s.color(        'light-dark(#c86bff, #7b2cff)',      )      ctx.lineWidth = 1      ctx.shadowBlur = 8      const roll =        ((s.scroll?.top ?? 0) / 40 + s.time * 0.6) % 1      ctx.beginPath()      for (let i = 0; i < 14; i++) {        const z = (i + roll) / 14,          y = hz + (h - hz) * z * z        ctx.moveTo(0, y)        ctx.lineTo(w, y)      }      for (let i = -12; i <= 12; i++) {        ctx.moveTo(w / 2 + i * 12, hz)        ctx.lineTo(w / 2 + i * w * 0.18, h)      }      ctx.stroke()      ctx.shadowBlur = 0    },    art(p, s) {      // A striped sun setting into the floor.      const ctx = s.ctx,        cx = p.x + p.w / 2,        cy = p.y + p.h * 0.62,        r = Math.min(p.w, p.h) * 0.38,        g = ctx.createLinearGradient(0, cy - r, 0, cy + r)      ctx.beginPath()      ctx.roundRect(p.x, p.y, p.w, p.h, p.r)      ctx.clip()      g.addColorStop(0, '#ffd23f')      g.addColorStop(1, s.color(LOOKS.neon.style.accent))      ctx.shadowColor = s.color(LOOKS.neon.style.accent)      ctx.shadowBlur = 30      ctx.fillStyle = g      ctx.beginPath()      ctx.arc(        cx,        cy,        r * (1 + p.hover * 0.06),        0,        Math.PI * 2,      )      ctx.fill()      ctx.shadowBlur = 0      ctx.fillStyle = s.color(LOOKS.neon.style.paper)      for (let i = 0; i < 6; i++) {        const y =          cy +          r * (0.1 + i * 0.16) -          ((s.time * 6) % (r * 0.16))        ctx.fillRect(p.x, y, p.w, 2 + i * 1.2)      }    },    type(p, s) {      s.ctx.shadowColor = s.color(LOOKS.neon.style.accent)      s.ctx.shadowBlur = 18      words(p, s, {        fill: (ctx) => {          const g = ctx.createLinearGradient(            p.x,            p.y,            p.x,            p.y + p.h,          )          g.addColorStop(            0,            s.color('light-dark(#0aa6c9, #28e7ff)'),          )          g.addColorStop(            1,            s.color(LOOKS.neon.style.accent),          )          ctx.fillStyle = g        },      })    },  }),} satisfies Record<LookName, ReturnType<typeof lookKit>>function Skins(props: { group: string }) {  return (    <Grid      min="md"      cols={2}      gap="group"    >      <Stack        gap="related"        data-skin="card"      >        <Row          gap="related"          wrap        >          <button data-skin="button">Deploy</button>          <button            data-skin="button"            data-look="outline"          >            Preview          </button>          <button            data-skin="button"            disabled          >            Paused          </button>        </Row>        <label data-part="label">          Project          <input            data-skin="field"            value="storefront"          />        </label>        <label data-part="label">          Region          <StageSelect options={['eu-west', 'us-east']} />        </label>      </Stack>      {skinControls(() => props.group)}    </Grid>  )}function skinControls(group: () => string) {  return (    <Stack      gap="related"      data-skin="card"    >      <label data-part="option">        <input          type="checkbox"          data-skin="check"          checked        />        Preview every push      </label>      <Row gap="group">        <label data-part="option">          <input            type="radio"            name={group()}            data-skin="check"            checked          />          Node        </label>        <label data-part="option">          <input            type="radio"            name={group()}            data-skin="check"          />          Bun        </label>      </Row>      <label data-part="option">        <input          type="checkbox"          role="switch"          data-skin="switch"          checked        />        Deploy on push      </label>      <label data-part="label">        Build minutes        <input          type="range"          data-skin="range"          value="60"        />      </label>      <Row        gap="tight"        wrap      >        <button          data-skin="chip"          aria-pressed="true"        >          Live        </button>        <button          data-skin="chip"          aria-pressed="false"        >          Building        </button>      </Row>    </Stack>  )}const lookStyle = (name: LookName, rest = '') =>  `${rest};--stage-on-fill:${LOOKS[name].style.onFill}`const FLAT_STYLE = lookStyle(  'flat',  'padding:28px',)

Sketch

Pencil on ruled paper: wobbly outlines seeded per element so they hold still, and hatched fills.

tsx
import {  CanvasStage,  Grid,  Row,  Stack,  StageSelect} from '@kerf-js/veneer'import type {  Scene,  StagePainter,  StagePiece} from '@kerf-js/veneer'export function Sketch() {  return (  <CanvasStage    {...LOOKS.sketch.stage}    style={SKETCH_STYLE}  >    <Skins group="look-sketch" />  </CanvasStage>  )}type LookName = 'flat' | 'sketch' | 'neon'type Style = {  ink: string  paper: string  accent: string  soft: string  /** Text on a filled button or chip. */  onFill: string  /** Pixels of wobble: 0 draws clean lines. */  rough: number  /** Glow, in pixels of blur. */  glow: number  /** Fills as pencil hatching instead of solid. */  hatch: boolean  shadow: 'soft' | 'offset' | 'none'  ground: (s: Scene) => void  art: (p: StagePiece, s: Scene) => void  type: (p: StagePiece, s: Scene) => void}const seeds = new WeakMap<Element, number>()let seedNext = 1const seedOf = (el: Element) => {  let v = seeds.get(el)  if (v === undefined)    seeds.set(el, (v = seedNext++ * 7919))  return v}const rng = (seed: number) => () => {  seed = (seed * 16807) % 2147483647  return seed / 2147483647 - 0.5}type Pt = [number, number]type Box = {  x: number  y: number  w: number  h: number  r: number  seed: number}function outline(  ctx: CanvasRenderingContext2D,  box: Box,  st: Style,) {  const { x, y, w, h, r, seed } = box  ctx.beginPath()  if (!st.rough) {    ctx.roundRect(x, y, w, h, Math.min(r, h / 2, w / 2))    return  }  const j = rng(seed),    k = st.rough,    pts: Pt[] = []  const edge = (a: Pt, b: Pt) => {    const n = Math.max(      2,      Math.round(        Math.hypot(b[0] - a[0], b[1] - a[1]) / 28,      ),    )    for (let i = 0; i < n; i++)      pts.push([        a[0] + ((b[0] - a[0]) * i) / n + j() * k,        a[1] + ((b[1] - a[1]) * i) / n + j() * k,      ])  }  edge([x, y], [x + w, y])  edge([x + w, y], [x + w, y + h])  edge([x + w, y + h], [x, y + h])  edge([x, y + h], [x, y])  ctx.moveTo(pts[0]![0], pts[0]![1])  for (let i = 1; i <= pts.length; i++) {    const [px, py] = pts[i % pts.length]!    ctx.lineTo(px, py)  }  ctx.lineTo(pts[0]![0] + j() * k, pts[0]![1] + j() * k)}function stroke(  ctx: CanvasRenderingContext2D,  st: Style,  pen: { color: string; width: number },) {  const { color, width } = pen  ctx.strokeStyle = color  ctx.lineWidth = width  ctx.lineJoin = ctx.lineCap = 'round'  if (st.glow) {    ctx.shadowColor = color    ctx.shadowBlur = st.glow  }  ctx.stroke()  ctx.shadowBlur = 0}function paint(  ctx: CanvasRenderingContext2D,  st: Style,  fill: {    color: string    box: [number, number, number, number]    density?: number  },) {  const { color, box, density = 1 } = fill  if (!st.hatch) {    ctx.fillStyle = color    ctx.fill()    return  }  // Pencil hatching: the shape clips a run of diagonal  // strokes.  const [x, y, w, h] = box,    gap = 7 / density  ctx.save()  ctx.clip()  ctx.beginPath()  for (let d = -h; d < w; d += gap) {    ctx.moveTo(x + d, y + h)    ctx.lineTo(x + d + h, y)  }  ctx.strokeStyle = color  ctx.lineWidth = 1.6  ctx.globalAlpha = 0.55  ctx.stroke()  ctx.restore()}type PaintColor = (  s: Scene,  name: string,) => stringtype PaintRing = (p: StagePiece, s: Scene) => voidtype PaintLift = (  p: StagePiece,  s: Scene,  depth: number,) => voidtype PaintBody = (  p: StagePiece,  s: Scene,  look: {    fill: string    line: string    width?: number    solid?: boolean  },) => voidtype Hands = {  c: PaintColor  ring: PaintRing  lift: PaintLift  body: PaintBody}function surfacePainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, lift, body } = hands  return {    button(p, s) {      const outlineOnly = p.el.dataset.look === 'outline',        y = p.press * 2      lift({ ...p, y: p.y + y }, s, 4 - p.press * 3)      const at = { ...p, y: p.y + y }      body(at, s, {        fill: outlineOnly          ? c(s, p.hover ? st.soft : st.paper)          : c(s, p.disabled ? st.soft : st.accent),        line: c(          s,          outlineOnly || st.rough ? st.ink : st.accent,        ),        width: outlineOnly ? 1.5 : 1,      })      if (p.hover && !st.hatch && !outlineOnly) {        const g = s.ctx.createRadialGradient(          p.x + p.px,          p.y + p.py,          0,          p.x + p.px,          p.y + p.py,          p.w * 0.7,        )        g.addColorStop(          0,          `rgb(255 255 255 / ${0.28 * p.hover})`,        )        g.addColorStop(1, 'rgb(255 255 255 / 0)')        outline(s.ctx, { ...at, seed: seedOf(p.el) }, st)        s.ctx.fillStyle = g        s.ctx.fill()      }      ring(p, s)    },    card(p, s) {      const live = p.el.matches('a[href], button'),        chosen = p.on > 0.5      lift(p, s, 6 + (live ? p.hover * 6 : 0))      body(p, s, {        fill: c(s, st.paper),        line: c(          s,          chosen            ? st.accent            : st.rough              ? st.ink              : st.soft,        ),        width: chosen ? 3 : 1.5,      })      ring(p, s)    },    panel(p, s) {      lift(p, s, 14)      body(p, s, {        fill: c(s, st.paper),        line: c(s, st.ink),        width: 1.5,        solid: true,      })    },  }}function fieldPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, lift, body } = hands  return {    bar(p, s) {      const ctx = s.ctx      ctx.beginPath()      for (let y = p.y + 3; y < p.y + p.h - 2; y += 4) {        ctx.moveTo(p.x + 6, y)        ctx.lineTo(p.x + p.w - 6, y)      }      stroke(ctx, st, { color: c(s, st.soft), width: 1 })    },    field(p, s) {      const line = p.invalid        ? c(s, '--veneer-color-critical-solid')        : c(s, p.focus > 0.5 ? st.accent : st.soft)      body(p, s, {        fill: c(s, st.paper),        line,        width: 1.5 + p.focus,        solid: true,      })      if (        p.el.tagName === 'SELECT' ||        p.el.getAttribute('aria-haspopup') === 'listbox'      ) {        const ctx = s.ctx,          x = p.x + p.w - 18,          y = p.y + p.h / 2,          d = p.on > 0.5 ? -3 : 3        ctx.beginPath()        ctx.moveTo(x - 5, y - d)        ctx.lineTo(x, y + d)        ctx.lineTo(x + 5, y - d)        stroke(ctx, st, {          color: c(s, st.ink),          width: 1.6,        })      }    },    // A StageSelect's list, in a stage of its own: a    // panel on a short lift, and the row under the keys    // or pointer filled.    menu(p, s) {      lift(p, s, 6)      body(p, s, {        fill: c(s, st.paper),        line: c(s, st.rough ? st.ink : st.soft),        width: 1.5,        solid: true,      })    },    option(p, s) {      if (p.hover > 0.5)        body(p, s, {          fill: c(s, st.accent),          line: c(s, st.accent),          width: 1,        })      else if (p.on > 0.5) {        const ctx = s.ctx        ctx.beginPath()        ctx.moveTo(p.x + 3, p.y + 8)        ctx.lineTo(p.x + 3, p.y + p.h - 8)        stroke(ctx, st, {          color: c(s, st.accent),          width: 3,        })      }    },  }}function checkPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    check(p, s) {      const ctx = s.ctx,        radio =          (p.el as HTMLInputElement).type === 'radio',        round = radio ? p.w / 2 : 5      body({ ...p, r: round }, s, {        fill: c(s, p.hover ? st.soft : st.paper),        line: c(          s,          st.rough            ? st.ink            : p.on > 0.5              ? st.accent              : st.soft,        ),        width: 1.6,      })      if (p.on > 0) {        if (radio) {          ctx.beginPath()          ctx.arc(            p.x + p.w / 2,            p.y + p.h / 2,            (p.w / 4) * p.on,            0,            Math.PI * 2,          )          paint(            ctx,            { ...st, hatch: false },            {              color: c(s, st.accent),              box: [p.x, p.y, p.w, p.h],            },          )        } else {          // The tick draws itself on as `on` eases in.          ctx.beginPath()          ctx.moveTo(p.x + p.w * 0.22, p.y + p.h * 0.52)          ctx.lineTo(p.x + p.w * 0.42, p.y + p.h * 0.72)          ctx.lineTo(p.x + p.w * 0.8, p.y + p.h * 0.28)          ctx.setLineDash([40 * p.on, 40])          stroke(ctx, st, {            color: c(s, st.accent),            width: 2.6,          })          ctx.setLineDash([])        }      }      ring({ ...p, r: round }, s)    },  }}function switchPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    switch(p, s) {      const ctx = s.ctx,        d = p.h - 6,        kx = p.x + 3 + (p.w - p.h) * p.on      body({ ...p, r: p.h / 2 }, s, {        fill:          p.on > 0.5 ? c(s, st.accent) : c(s, st.soft),        line: c(s, st.rough ? st.ink : st.soft),        width: 1.4,      })      ctx.beginPath()      ctx.arc(        kx + d / 2,        p.y + 3 + d / 2,        d / 2,        0,        Math.PI * 2,      )      ctx.fillStyle = c(s, st.paper)      ctx.fill()      stroke(ctx, st, { color: c(s, st.ink), width: 1.2 })      ring({ ...p, r: p.h / 2 }, s)    },  }}function rangeMeterPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    range(p, s) {      const ctx = s.ctx,        y = p.y + p.h / 2,        kx = p.x + 8 + (p.w - 16) * p.value      ctx.beginPath()      ctx.moveTo(p.x + 2, y)      ctx.lineTo(p.x + p.w - 2, y)      stroke(ctx, st, { color: c(s, st.soft), width: 4 })      ctx.beginPath()      ctx.moveTo(p.x + 2, y)      ctx.lineTo(kx, y)      stroke(ctx, st, {        color: c(s, st.accent),        width: 4,      })      ctx.beginPath()      ctx.arc(kx, y, 8 + p.hover * 2, 0, Math.PI * 2)      ctx.fillStyle = c(s, st.paper)      ctx.fill()      stroke(ctx, st, {        color: c(s, st.accent),        width: 2.5,      })      if (p.focus > 0.5)        ring(          {            ...p,            x: kx - 10,            y: y - 10,            w: 20,            h: 20,            r: 10,          },          s,        )    },    meter(p, s) {      body({ ...p, r: p.h / 2 }, s, {        fill: c(s, st.paper),        line: c(s, st.soft),        width: 1.2,      })      const ctx = s.ctx      outline(        ctx,        {          ...p,          w: Math.max(p.h, p.w * p.value),          r: p.h / 2,          seed: seedOf(p.el) + 5,        },        st,      )      paint(ctx, st, {        color: c(s, st.accent),        box: [p.x, p.y, p.w * p.value, p.h],      })    },  }}function chipRulePainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    chip(p, s) {      body({ ...p, r: p.h / 2 }, s, {        fill: c(          s,          p.on > 0.5            ? st.accent            : p.hover              ? st.soft              : st.paper,        ),        line: c(s, p.on > 0.5 ? st.accent : st.soft),        width: 1.2,      })      ring({ ...p, r: p.h / 2 }, s)    },    rule(p, s) {      const ctx = s.ctx,        y = p.y + p.h / 2,        j = rng(seedOf(p.el))      ctx.beginPath()      ctx.moveTo(p.x, y)      for (let x = p.x; x <= p.x + p.w; x += 24)        ctx.lineTo(x, y + j() * st.rough * 1.5)      stroke(ctx, st, {        color: c(s, st.soft),        width: 1.5,      })    },  }}function detailPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c } = hands  return {    clear(p, s) {      const ctx = s.ctx      ctx.beginPath()      ctx.roundRect(p.x - 4, p.y - 4, p.w + 8, p.h + 8, 8)      ctx.globalAlpha = 0.86      ctx.fillStyle = c(s, st.paper)      ctx.fill()    },    type: st.type,    art: st.art,  }}function makeFocusRing(st: Style, c: PaintColor) {  return (p: StagePiece, s: Scene) => {    if (p.focus < 0.5) return    const ctx = s.ctx    ctx.setLineDash([6, 5])    ctx.lineDashOffset = -s.time * 24    outline(      ctx,      {        x: p.x - 4,        y: p.y - 4,        w: p.w + 8,        h: p.h + 8,        r: p.r + 4,        seed: seedOf(p.el) + 3,      },      st,    )    stroke(ctx, st, { color: c(s, st.accent), width: 2 })    ctx.setLineDash([])  }}function makeSurfaceLift(st: Style, c: PaintColor) {  return (p: StagePiece, s: Scene, depth: number) => {    const ctx = s.ctx    if (st.shadow === 'soft') {      ctx.shadowColor = 'rgb(0 0 0 / 0.2)'      ctx.shadowBlur = depth * 3      ctx.shadowOffsetY = depth      outline(ctx, { ...p, seed: seedOf(p.el) }, st)      ctx.fillStyle = c(s, st.paper)      ctx.fill()      ctx.shadowColor = 'transparent'    }    if (st.shadow === 'offset') {      outline(        ctx,        {          ...p,          x: p.x + depth,          y: p.y + depth,          seed: seedOf(p.el) + 1,        },        st,      )      paint(ctx, st, {        color: c(s, st.soft),        box: [p.x + depth, p.y + depth, p.w, p.h],        density: 0.7,      })    }  }}type BodyLook = Parameters<PaintBody>[2]function makeSurfaceBody(st: Style) {  return (    p: StagePiece,    s: Scene,    { fill, line, width = 1.5, solid = false }: BodyLook,  ) => {    const ctx = s.ctx    outline(ctx, { ...p, seed: seedOf(p.el) }, st)    paint(ctx, solid ? { ...st, hatch: false } : st, {      color: fill,      box: [p.x, p.y, p.w, p.h],    })    outline(ctx, { ...p, seed: seedOf(p.el) }, st)    stroke(ctx, st, { color: line, width })  }}function makeOverlay(st: Style, c: PaintColor) {  return (s: Scene) => {    const ctx = s.ctx    for (const press of s.presses) {      ctx.globalAlpha = 1 - press.age      ctx.beginPath()      ctx.arc(        press.x,        press.y,        6 + press.age * 60,        0,        Math.PI * 2,      )      stroke(ctx, st, {        color: c(s, st.accent),        width: 2,      })    }    ctx.globalAlpha = 1    if (s.rail) {      ctx.beginPath()      ctx.roundRect(        s.rail.x,        s.rail.top,        s.rail.w,        s.rail.len,        s.rail.w / 2,      )      ctx.fillStyle = c(        s,        s.rail.drag ? st.accent : st.soft,      )      ctx.fill()    }  }}function makePlain(st: Style, c: PaintColor) {  return (s: Scene) => {    s.ctx.fillStyle = c(s, st.paper)    s.ctx.fillRect(0, 0, s.width, s.height)  }}function makeVeil() {  return (s: Scene) => {    s.ctx.globalAlpha =      Math.min(1, s.live ? s.shown / 0.2 : 1) * 0.5    s.ctx.fillStyle = '#000'    s.ctx.fillRect(0, 0, s.width, s.height)    s.ctx.globalAlpha = 1  }}function lookKit(st: Style) {  const c = (s: Scene, name: string) => s.color(name)  const ring = makeFocusRing(st, c)  const lift = makeSurfaceLift(st, c)  // `solid` keeps a surface's paper whole: a panel or a  // list floats over the page, and a field's words need  // clean ground.  const body = makeSurfaceBody(st)  const hands = { c, ring, lift, body }  const painters: Record<string, StagePainter> = {    ...surfacePainters(st, hands),    ...fieldPainters(st, hands),    ...checkPainters(st, hands),    ...switchPainters(st, hands),    ...rangeMeterPainters(st, hands),    ...chipRulePainters(st, hands),    ...detailPainters(st, hands),  }  const overlay = makeOverlay(st, c)  const plain = makePlain(st, c)  // A modal's stage covers the page with a wash that  // fades in each time it opens.  const veil = makeVeil()  return {    stage: { painters, overlay, background: st.ground },    plain,    veil,    style: st,  }}type WordInk = {  fill: (ctx: CanvasRenderingContext2D) => void  tilt?: number}const words = (  p: StagePiece,  s: Scene,  { fill, tilt = 0 }: WordInk,) => {  const ctx = s.ctx,    j = rng(seedOf(p.el))  fill(ctx)  for (const w of s.words(p.el)) {    ctx.font = w.font    ctx.letterSpacing = `${w.spacing}px`    ctx.save()    ctx.translate(w.x, w.y)    ctx.rotate(j() * tilt)    ctx.fillText(w.text, 0, 0)    ctx.restore()  }}const TOKENS = {  ink: 'var(--stage-ink, var(--veneer-color-text))',  paper:    'var(--stage-paper, var(--veneer-color-surface))',  accent:    'var(--stage-accent, ' +    'var(--veneer-color-accent-solid))',}const LOOKS = {  flat: lookKit({    ...TOKENS,    soft: 'var(--stage-soft, var(--veneer-color-border))',    onFill: 'var(--veneer-color-accent-on-solid)',    rough: 0,    glow: 0,    hatch: false,    shadow: 'soft',    ground(s) {      // Dots that scroll with the page, painted under      // everything.      const ctx = s.ctx,        gap = 28,        off = (s.scroll?.top ?? 0) % gap      ctx.fillStyle = s.color(        '--veneer-color-surface-sunken',      )      ctx.fillRect(0, 0, s.width, s.height)      ctx.fillStyle = s.color('--veneer-color-border')      for (let y = -off; y < s.height; y += gap)        for (let x = gap / 2; x < s.width; x += gap)          ctx.fillRect(x, y, 2, 2)    },    art(p, s) {      const ctx = s.ctx,        t = s.time * 0.4      ctx.beginPath()      ctx.roundRect(p.x, p.y, p.w, p.h, p.r)      ctx.clip()      ctx.fillStyle = s.color(        '--veneer-color-accent-soft',      )      ctx.fillRect(p.x, p.y, p.w, p.h)      for (const [i, tone] of [        '--veneer-color-accent-solid',        '--veneer-color-info-solid',        '--veneer-color-highlight-solid',      ].entries()) {        const cx =            p.x +            p.w * (0.5 + Math.sin(t + i * 2.1) * 0.3),          cy =            p.y +            p.h * (0.5 + Math.cos(t * 1.3 + i) * 0.3),          g = ctx.createRadialGradient(            cx,            cy,            0,            cx,            cy,            p.w * 0.45,          )        g.addColorStop(0, s.color(tone))        g.addColorStop(1, 'transparent')        ctx.globalAlpha = 0.75        ctx.fillStyle = g        ctx.fillRect(p.x, p.y, p.w, p.h)      }    },    type(p, s) {      words(p, s, {        fill: (ctx) => {          const g = ctx.createLinearGradient(            p.x,            p.y,            p.x + p.w,            p.y + p.h,          )          g.addColorStop(0, s.color(TOKENS.accent))          g.addColorStop(            1,            s.color('--veneer-color-info-solid'),          )          ctx.fillStyle = g        },      })    },  }),  sketch: lookKit({    ...TOKENS,    soft:      'var(--stage-soft, ' +      'var(--veneer-color-text-subtle))',    onFill: 'var(--veneer-color-text)',    rough: 2.2,    glow: 0,    hatch: true,    shadow: 'offset',    ground(s) {      // Ruled paper: the lines are fixed to the page, so      // they scroll with it, and a margin rule runs down      // the side.      const ctx = s.ctx,        gap = 30,        off = (s.scroll?.top ?? 0) % gap      ctx.fillStyle = s.color(TOKENS.paper)      ctx.fillRect(0, 0, s.width, s.height)      ctx.strokeStyle = s.color(        '--veneer-color-info-border',      )      ctx.lineWidth = 1      ctx.beginPath()      for (let y = gap - off; y < s.height; y += gap) {        ctx.moveTo(0, y + 0.5)        ctx.lineTo(s.width, y + 0.5)      }      ctx.stroke()      ctx.strokeStyle = s.color(        '--veneer-color-critical-border',      )      ctx.beginPath()      ctx.moveTo(18.5, 0)      ctx.lineTo(18.5, s.height)      ctx.stroke()    },    art(p, s) {      const ctx = s.ctx,        j = rng(seedOf(p.el)),        cx = p.x + p.w / 2,        cy = p.y + p.h / 2      ctx.strokeStyle = s.color(TOKENS.ink)      ctx.lineWidth = 1.4      for (        let r = 8;        r < Math.min(p.w, p.h) / 2;        r += 9      ) {        ctx.beginPath()        for (let a = 0; a <= Math.PI * 2.05; a += 0.3)          ctx.lineTo(            cx + Math.cos(a) * (r + j() * 3),            cy + Math.sin(a) * (r + j() * 3),          )        ctx.stroke()      }      outline(        ctx,        { ...p, r: 0, seed: seedOf(p.el) },        LOOKS.sketch.style,      )      stroke(ctx, LOOKS.sketch.style, {        color: s.color(TOKENS.ink),        width: 1.5,      })    },    type(p, s) {      words(p, s, {        fill: (ctx) =>          (ctx.fillStyle = s.color(TOKENS.ink)),        tilt: 0.04,      })    },  }),  neon: lookKit({    // Its own colours, a pair for each theme: an app's    // --stage-* still wins over them.    ink: 'var(--stage-ink, light-dark(#3a1060, #f4ecff))',    paper:      'var(--stage-paper, light-dark(#fff2fa, #120a24))',    accent:      'var(--stage-accent, light-dark(#d81b78, #ff3ea5))',    soft:      'var(--stage-soft, ' +      'light-dark(#e3b8ef, #4a3a7a))',    onFill: '#fff',    rough: 0,    glow: 14,    hatch: false,    shadow: 'none',    ground(s) {      // A horizon and a floor grid that rolls toward you      // as the page scrolls and time passes.      const ctx = s.ctx,        w = s.width,        h = s.height,        hz = h * 0.42,        sky = ctx.createLinearGradient(0, 0, 0, h)      sky.addColorStop(        0,        s.color('light-dark(#fff2fa, #07021a)'),      )      sky.addColorStop(        0.42,        s.color('light-dark(#ffd1ec, #2a0b4a)'),      )      sky.addColorStop(        1,        s.color('light-dark(#fff2fa, #07021a)'),      )      ctx.fillStyle = sky      ctx.fillRect(0, 0, w, h)      ctx.strokeStyle = ctx.shadowColor = s.color(        'light-dark(#c86bff, #7b2cff)',      )      ctx.lineWidth = 1      ctx.shadowBlur = 8      const roll =        ((s.scroll?.top ?? 0) / 40 + s.time * 0.6) % 1      ctx.beginPath()      for (let i = 0; i < 14; i++) {        const z = (i + roll) / 14,          y = hz + (h - hz) * z * z        ctx.moveTo(0, y)        ctx.lineTo(w, y)      }      for (let i = -12; i <= 12; i++) {        ctx.moveTo(w / 2 + i * 12, hz)        ctx.lineTo(w / 2 + i * w * 0.18, h)      }      ctx.stroke()      ctx.shadowBlur = 0    },    art(p, s) {      // A striped sun setting into the floor.      const ctx = s.ctx,        cx = p.x + p.w / 2,        cy = p.y + p.h * 0.62,        r = Math.min(p.w, p.h) * 0.38,        g = ctx.createLinearGradient(0, cy - r, 0, cy + r)      ctx.beginPath()      ctx.roundRect(p.x, p.y, p.w, p.h, p.r)      ctx.clip()      g.addColorStop(0, '#ffd23f')      g.addColorStop(1, s.color(LOOKS.neon.style.accent))      ctx.shadowColor = s.color(LOOKS.neon.style.accent)      ctx.shadowBlur = 30      ctx.fillStyle = g      ctx.beginPath()      ctx.arc(        cx,        cy,        r * (1 + p.hover * 0.06),        0,        Math.PI * 2,      )      ctx.fill()      ctx.shadowBlur = 0      ctx.fillStyle = s.color(LOOKS.neon.style.paper)      for (let i = 0; i < 6; i++) {        const y =          cy +          r * (0.1 + i * 0.16) -          ((s.time * 6) % (r * 0.16))        ctx.fillRect(p.x, y, p.w, 2 + i * 1.2)      }    },    type(p, s) {      s.ctx.shadowColor = s.color(LOOKS.neon.style.accent)      s.ctx.shadowBlur = 18      words(p, s, {        fill: (ctx) => {          const g = ctx.createLinearGradient(            p.x,            p.y,            p.x,            p.y + p.h,          )          g.addColorStop(            0,            s.color('light-dark(#0aa6c9, #28e7ff)'),          )          g.addColorStop(            1,            s.color(LOOKS.neon.style.accent),          )          ctx.fillStyle = g        },      })    },  }),} satisfies Record<LookName, ReturnType<typeof lookKit>>function Skins(props: { group: string }) {  return (    <Grid      min="md"      cols={2}      gap="group"    >      <Stack        gap="related"        data-skin="card"      >        <Row          gap="related"          wrap        >          <button data-skin="button">Deploy</button>          <button            data-skin="button"            data-look="outline"          >            Preview          </button>          <button            data-skin="button"            disabled          >            Paused          </button>        </Row>        <label data-part="label">          Project          <input            data-skin="field"            value="storefront"          />        </label>        <label data-part="label">          Region          <StageSelect options={['eu-west', 'us-east']} />        </label>      </Stack>      {skinControls(() => props.group)}    </Grid>  )}function skinControls(group: () => string) {  return (    <Stack      gap="related"      data-skin="card"    >      <label data-part="option">        <input          type="checkbox"          data-skin="check"          checked        />        Preview every push      </label>      <Row gap="group">        <label data-part="option">          <input            type="radio"            name={group()}            data-skin="check"            checked          />          Node        </label>        <label data-part="option">          <input            type="radio"            name={group()}            data-skin="check"          />          Bun        </label>      </Row>      <label data-part="option">        <input          type="checkbox"          role="switch"          data-skin="switch"          checked        />        Deploy on push      </label>      <label data-part="label">        Build minutes        <input          type="range"          data-skin="range"          value="60"        />      </label>      <Row        gap="tight"        wrap      >        <button          data-skin="chip"          aria-pressed="true"        >          Live        </button>        <button          data-skin="chip"          aria-pressed="false"        >          Building        </button>      </Row>    </Stack>  )}const lookStyle = (name: LookName, rest = '') =>  `${rest};--stage-on-fill:${LOOKS[name].style.onFill}`const SKETCH_STYLE = lookStyle(  'sketch',  'padding:28px 28px 28px 40px',)

Neon

Glowing strokes over a floor grid that rolls with time and scroll. animate keeps the background moving.

tsx
import {  CanvasStage,  Grid,  Row,  Stack,  StageSelect} from '@kerf-js/veneer'import type {  Scene,  StagePainter,  StagePiece} from '@kerf-js/veneer'export function Neon() {  return (  <CanvasStage    {...LOOKS.neon.stage}    animate    style={NEON_STYLE}  >    <Skins group="look-neon" />  </CanvasStage>  )}type LookName = 'flat' | 'sketch' | 'neon'type Style = {  ink: string  paper: string  accent: string  soft: string  /** Text on a filled button or chip. */  onFill: string  /** Pixels of wobble: 0 draws clean lines. */  rough: number  /** Glow, in pixels of blur. */  glow: number  /** Fills as pencil hatching instead of solid. */  hatch: boolean  shadow: 'soft' | 'offset' | 'none'  ground: (s: Scene) => void  art: (p: StagePiece, s: Scene) => void  type: (p: StagePiece, s: Scene) => void}const seeds = new WeakMap<Element, number>()let seedNext = 1const seedOf = (el: Element) => {  let v = seeds.get(el)  if (v === undefined)    seeds.set(el, (v = seedNext++ * 7919))  return v}const rng = (seed: number) => () => {  seed = (seed * 16807) % 2147483647  return seed / 2147483647 - 0.5}type Pt = [number, number]type Box = {  x: number  y: number  w: number  h: number  r: number  seed: number}function outline(  ctx: CanvasRenderingContext2D,  box: Box,  st: Style,) {  const { x, y, w, h, r, seed } = box  ctx.beginPath()  if (!st.rough) {    ctx.roundRect(x, y, w, h, Math.min(r, h / 2, w / 2))    return  }  const j = rng(seed),    k = st.rough,    pts: Pt[] = []  const edge = (a: Pt, b: Pt) => {    const n = Math.max(      2,      Math.round(        Math.hypot(b[0] - a[0], b[1] - a[1]) / 28,      ),    )    for (let i = 0; i < n; i++)      pts.push([        a[0] + ((b[0] - a[0]) * i) / n + j() * k,        a[1] + ((b[1] - a[1]) * i) / n + j() * k,      ])  }  edge([x, y], [x + w, y])  edge([x + w, y], [x + w, y + h])  edge([x + w, y + h], [x, y + h])  edge([x, y + h], [x, y])  ctx.moveTo(pts[0]![0], pts[0]![1])  for (let i = 1; i <= pts.length; i++) {    const [px, py] = pts[i % pts.length]!    ctx.lineTo(px, py)  }  ctx.lineTo(pts[0]![0] + j() * k, pts[0]![1] + j() * k)}function stroke(  ctx: CanvasRenderingContext2D,  st: Style,  pen: { color: string; width: number },) {  const { color, width } = pen  ctx.strokeStyle = color  ctx.lineWidth = width  ctx.lineJoin = ctx.lineCap = 'round'  if (st.glow) {    ctx.shadowColor = color    ctx.shadowBlur = st.glow  }  ctx.stroke()  ctx.shadowBlur = 0}function paint(  ctx: CanvasRenderingContext2D,  st: Style,  fill: {    color: string    box: [number, number, number, number]    density?: number  },) {  const { color, box, density = 1 } = fill  if (!st.hatch) {    ctx.fillStyle = color    ctx.fill()    return  }  // Pencil hatching: the shape clips a run of diagonal  // strokes.  const [x, y, w, h] = box,    gap = 7 / density  ctx.save()  ctx.clip()  ctx.beginPath()  for (let d = -h; d < w; d += gap) {    ctx.moveTo(x + d, y + h)    ctx.lineTo(x + d + h, y)  }  ctx.strokeStyle = color  ctx.lineWidth = 1.6  ctx.globalAlpha = 0.55  ctx.stroke()  ctx.restore()}type PaintColor = (  s: Scene,  name: string,) => stringtype PaintRing = (p: StagePiece, s: Scene) => voidtype PaintLift = (  p: StagePiece,  s: Scene,  depth: number,) => voidtype PaintBody = (  p: StagePiece,  s: Scene,  look: {    fill: string    line: string    width?: number    solid?: boolean  },) => voidtype Hands = {  c: PaintColor  ring: PaintRing  lift: PaintLift  body: PaintBody}function surfacePainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, lift, body } = hands  return {    button(p, s) {      const outlineOnly = p.el.dataset.look === 'outline',        y = p.press * 2      lift({ ...p, y: p.y + y }, s, 4 - p.press * 3)      const at = { ...p, y: p.y + y }      body(at, s, {        fill: outlineOnly          ? c(s, p.hover ? st.soft : st.paper)          : c(s, p.disabled ? st.soft : st.accent),        line: c(          s,          outlineOnly || st.rough ? st.ink : st.accent,        ),        width: outlineOnly ? 1.5 : 1,      })      if (p.hover && !st.hatch && !outlineOnly) {        const g = s.ctx.createRadialGradient(          p.x + p.px,          p.y + p.py,          0,          p.x + p.px,          p.y + p.py,          p.w * 0.7,        )        g.addColorStop(          0,          `rgb(255 255 255 / ${0.28 * p.hover})`,        )        g.addColorStop(1, 'rgb(255 255 255 / 0)')        outline(s.ctx, { ...at, seed: seedOf(p.el) }, st)        s.ctx.fillStyle = g        s.ctx.fill()      }      ring(p, s)    },    card(p, s) {      const live = p.el.matches('a[href], button'),        chosen = p.on > 0.5      lift(p, s, 6 + (live ? p.hover * 6 : 0))      body(p, s, {        fill: c(s, st.paper),        line: c(          s,          chosen            ? st.accent            : st.rough              ? st.ink              : st.soft,        ),        width: chosen ? 3 : 1.5,      })      ring(p, s)    },    panel(p, s) {      lift(p, s, 14)      body(p, s, {        fill: c(s, st.paper),        line: c(s, st.ink),        width: 1.5,        solid: true,      })    },  }}function fieldPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, lift, body } = hands  return {    bar(p, s) {      const ctx = s.ctx      ctx.beginPath()      for (let y = p.y + 3; y < p.y + p.h - 2; y += 4) {        ctx.moveTo(p.x + 6, y)        ctx.lineTo(p.x + p.w - 6, y)      }      stroke(ctx, st, { color: c(s, st.soft), width: 1 })    },    field(p, s) {      const line = p.invalid        ? c(s, '--veneer-color-critical-solid')        : c(s, p.focus > 0.5 ? st.accent : st.soft)      body(p, s, {        fill: c(s, st.paper),        line,        width: 1.5 + p.focus,        solid: true,      })      if (        p.el.tagName === 'SELECT' ||        p.el.getAttribute('aria-haspopup') === 'listbox'      ) {        const ctx = s.ctx,          x = p.x + p.w - 18,          y = p.y + p.h / 2,          d = p.on > 0.5 ? -3 : 3        ctx.beginPath()        ctx.moveTo(x - 5, y - d)        ctx.lineTo(x, y + d)        ctx.lineTo(x + 5, y - d)        stroke(ctx, st, {          color: c(s, st.ink),          width: 1.6,        })      }    },    // A StageSelect's list, in a stage of its own: a    // panel on a short lift, and the row under the keys    // or pointer filled.    menu(p, s) {      lift(p, s, 6)      body(p, s, {        fill: c(s, st.paper),        line: c(s, st.rough ? st.ink : st.soft),        width: 1.5,        solid: true,      })    },    option(p, s) {      if (p.hover > 0.5)        body(p, s, {          fill: c(s, st.accent),          line: c(s, st.accent),          width: 1,        })      else if (p.on > 0.5) {        const ctx = s.ctx        ctx.beginPath()        ctx.moveTo(p.x + 3, p.y + 8)        ctx.lineTo(p.x + 3, p.y + p.h - 8)        stroke(ctx, st, {          color: c(s, st.accent),          width: 3,        })      }    },  }}function checkPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    check(p, s) {      const ctx = s.ctx,        radio =          (p.el as HTMLInputElement).type === 'radio',        round = radio ? p.w / 2 : 5      body({ ...p, r: round }, s, {        fill: c(s, p.hover ? st.soft : st.paper),        line: c(          s,          st.rough            ? st.ink            : p.on > 0.5              ? st.accent              : st.soft,        ),        width: 1.6,      })      if (p.on > 0) {        if (radio) {          ctx.beginPath()          ctx.arc(            p.x + p.w / 2,            p.y + p.h / 2,            (p.w / 4) * p.on,            0,            Math.PI * 2,          )          paint(            ctx,            { ...st, hatch: false },            {              color: c(s, st.accent),              box: [p.x, p.y, p.w, p.h],            },          )        } else {          // The tick draws itself on as `on` eases in.          ctx.beginPath()          ctx.moveTo(p.x + p.w * 0.22, p.y + p.h * 0.52)          ctx.lineTo(p.x + p.w * 0.42, p.y + p.h * 0.72)          ctx.lineTo(p.x + p.w * 0.8, p.y + p.h * 0.28)          ctx.setLineDash([40 * p.on, 40])          stroke(ctx, st, {            color: c(s, st.accent),            width: 2.6,          })          ctx.setLineDash([])        }      }      ring({ ...p, r: round }, s)    },  }}function switchPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    switch(p, s) {      const ctx = s.ctx,        d = p.h - 6,        kx = p.x + 3 + (p.w - p.h) * p.on      body({ ...p, r: p.h / 2 }, s, {        fill:          p.on > 0.5 ? c(s, st.accent) : c(s, st.soft),        line: c(s, st.rough ? st.ink : st.soft),        width: 1.4,      })      ctx.beginPath()      ctx.arc(        kx + d / 2,        p.y + 3 + d / 2,        d / 2,        0,        Math.PI * 2,      )      ctx.fillStyle = c(s, st.paper)      ctx.fill()      stroke(ctx, st, { color: c(s, st.ink), width: 1.2 })      ring({ ...p, r: p.h / 2 }, s)    },  }}function rangeMeterPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    range(p, s) {      const ctx = s.ctx,        y = p.y + p.h / 2,        kx = p.x + 8 + (p.w - 16) * p.value      ctx.beginPath()      ctx.moveTo(p.x + 2, y)      ctx.lineTo(p.x + p.w - 2, y)      stroke(ctx, st, { color: c(s, st.soft), width: 4 })      ctx.beginPath()      ctx.moveTo(p.x + 2, y)      ctx.lineTo(kx, y)      stroke(ctx, st, {        color: c(s, st.accent),        width: 4,      })      ctx.beginPath()      ctx.arc(kx, y, 8 + p.hover * 2, 0, Math.PI * 2)      ctx.fillStyle = c(s, st.paper)      ctx.fill()      stroke(ctx, st, {        color: c(s, st.accent),        width: 2.5,      })      if (p.focus > 0.5)        ring(          {            ...p,            x: kx - 10,            y: y - 10,            w: 20,            h: 20,            r: 10,          },          s,        )    },    meter(p, s) {      body({ ...p, r: p.h / 2 }, s, {        fill: c(s, st.paper),        line: c(s, st.soft),        width: 1.2,      })      const ctx = s.ctx      outline(        ctx,        {          ...p,          w: Math.max(p.h, p.w * p.value),          r: p.h / 2,          seed: seedOf(p.el) + 5,        },        st,      )      paint(ctx, st, {        color: c(s, st.accent),        box: [p.x, p.y, p.w * p.value, p.h],      })    },  }}function chipRulePainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c, ring, body } = hands  return {    chip(p, s) {      body({ ...p, r: p.h / 2 }, s, {        fill: c(          s,          p.on > 0.5            ? st.accent            : p.hover              ? st.soft              : st.paper,        ),        line: c(s, p.on > 0.5 ? st.accent : st.soft),        width: 1.2,      })      ring({ ...p, r: p.h / 2 }, s)    },    rule(p, s) {      const ctx = s.ctx,        y = p.y + p.h / 2,        j = rng(seedOf(p.el))      ctx.beginPath()      ctx.moveTo(p.x, y)      for (let x = p.x; x <= p.x + p.w; x += 24)        ctx.lineTo(x, y + j() * st.rough * 1.5)      stroke(ctx, st, {        color: c(s, st.soft),        width: 1.5,      })    },  }}function detailPainters(  st: Style,  hands: Hands,): Record<string, StagePainter> {  const { c } = hands  return {    clear(p, s) {      const ctx = s.ctx      ctx.beginPath()      ctx.roundRect(p.x - 4, p.y - 4, p.w + 8, p.h + 8, 8)      ctx.globalAlpha = 0.86      ctx.fillStyle = c(s, st.paper)      ctx.fill()    },    type: st.type,    art: st.art,  }}function makeFocusRing(st: Style, c: PaintColor) {  return (p: StagePiece, s: Scene) => {    if (p.focus < 0.5) return    const ctx = s.ctx    ctx.setLineDash([6, 5])    ctx.lineDashOffset = -s.time * 24    outline(      ctx,      {        x: p.x - 4,        y: p.y - 4,        w: p.w + 8,        h: p.h + 8,        r: p.r + 4,        seed: seedOf(p.el) + 3,      },      st,    )    stroke(ctx, st, { color: c(s, st.accent), width: 2 })    ctx.setLineDash([])  }}function makeSurfaceLift(st: Style, c: PaintColor) {  return (p: StagePiece, s: Scene, depth: number) => {    const ctx = s.ctx    if (st.shadow === 'soft') {      ctx.shadowColor = 'rgb(0 0 0 / 0.2)'      ctx.shadowBlur = depth * 3      ctx.shadowOffsetY = depth      outline(ctx, { ...p, seed: seedOf(p.el) }, st)      ctx.fillStyle = c(s, st.paper)      ctx.fill()      ctx.shadowColor = 'transparent'    }    if (st.shadow === 'offset') {      outline(        ctx,        {          ...p,          x: p.x + depth,          y: p.y + depth,          seed: seedOf(p.el) + 1,        },        st,      )      paint(ctx, st, {        color: c(s, st.soft),        box: [p.x + depth, p.y + depth, p.w, p.h],        density: 0.7,      })    }  }}type BodyLook = Parameters<PaintBody>[2]function makeSurfaceBody(st: Style) {  return (    p: StagePiece,    s: Scene,    { fill, line, width = 1.5, solid = false }: BodyLook,  ) => {    const ctx = s.ctx    outline(ctx, { ...p, seed: seedOf(p.el) }, st)    paint(ctx, solid ? { ...st, hatch: false } : st, {      color: fill,      box: [p.x, p.y, p.w, p.h],    })    outline(ctx, { ...p, seed: seedOf(p.el) }, st)    stroke(ctx, st, { color: line, width })  }}function makeOverlay(st: Style, c: PaintColor) {  return (s: Scene) => {    const ctx = s.ctx    for (const press of s.presses) {      ctx.globalAlpha = 1 - press.age      ctx.beginPath()      ctx.arc(        press.x,        press.y,        6 + press.age * 60,        0,        Math.PI * 2,      )      stroke(ctx, st, {        color: c(s, st.accent),        width: 2,      })    }    ctx.globalAlpha = 1    if (s.rail) {      ctx.beginPath()      ctx.roundRect(        s.rail.x,        s.rail.top,        s.rail.w,        s.rail.len,        s.rail.w / 2,      )      ctx.fillStyle = c(        s,        s.rail.drag ? st.accent : st.soft,      )      ctx.fill()    }  }}function makePlain(st: Style, c: PaintColor) {  return (s: Scene) => {    s.ctx.fillStyle = c(s, st.paper)    s.ctx.fillRect(0, 0, s.width, s.height)  }}function makeVeil() {  return (s: Scene) => {    s.ctx.globalAlpha =      Math.min(1, s.live ? s.shown / 0.2 : 1) * 0.5    s.ctx.fillStyle = '#000'    s.ctx.fillRect(0, 0, s.width, s.height)    s.ctx.globalAlpha = 1  }}function lookKit(st: Style) {  const c = (s: Scene, name: string) => s.color(name)  const ring = makeFocusRing(st, c)  const lift = makeSurfaceLift(st, c)  // `solid` keeps a surface's paper whole: a panel or a  // list floats over the page, and a field's words need  // clean ground.  const body = makeSurfaceBody(st)  const hands = { c, ring, lift, body }  const painters: Record<string, StagePainter> = {    ...surfacePainters(st, hands),    ...fieldPainters(st, hands),    ...checkPainters(st, hands),    ...switchPainters(st, hands),    ...rangeMeterPainters(st, hands),    ...chipRulePainters(st, hands),    ...detailPainters(st, hands),  }  const overlay = makeOverlay(st, c)  const plain = makePlain(st, c)  // A modal's stage covers the page with a wash that  // fades in each time it opens.  const veil = makeVeil()  return {    stage: { painters, overlay, background: st.ground },    plain,    veil,    style: st,  }}type WordInk = {  fill: (ctx: CanvasRenderingContext2D) => void  tilt?: number}const words = (  p: StagePiece,  s: Scene,  { fill, tilt = 0 }: WordInk,) => {  const ctx = s.ctx,    j = rng(seedOf(p.el))  fill(ctx)  for (const w of s.words(p.el)) {    ctx.font = w.font    ctx.letterSpacing = `${w.spacing}px`    ctx.save()    ctx.translate(w.x, w.y)    ctx.rotate(j() * tilt)    ctx.fillText(w.text, 0, 0)    ctx.restore()  }}const TOKENS = {  ink: 'var(--stage-ink, var(--veneer-color-text))',  paper:    'var(--stage-paper, var(--veneer-color-surface))',  accent:    'var(--stage-accent, ' +    'var(--veneer-color-accent-solid))',}const LOOKS = {  flat: lookKit({    ...TOKENS,    soft: 'var(--stage-soft, var(--veneer-color-border))',    onFill: 'var(--veneer-color-accent-on-solid)',    rough: 0,    glow: 0,    hatch: false,    shadow: 'soft',    ground(s) {      // Dots that scroll with the page, painted under      // everything.      const ctx = s.ctx,        gap = 28,        off = (s.scroll?.top ?? 0) % gap      ctx.fillStyle = s.color(        '--veneer-color-surface-sunken',      )      ctx.fillRect(0, 0, s.width, s.height)      ctx.fillStyle = s.color('--veneer-color-border')      for (let y = -off; y < s.height; y += gap)        for (let x = gap / 2; x < s.width; x += gap)          ctx.fillRect(x, y, 2, 2)    },    art(p, s) {      const ctx = s.ctx,        t = s.time * 0.4      ctx.beginPath()      ctx.roundRect(p.x, p.y, p.w, p.h, p.r)      ctx.clip()      ctx.fillStyle = s.color(        '--veneer-color-accent-soft',      )      ctx.fillRect(p.x, p.y, p.w, p.h)      for (const [i, tone] of [        '--veneer-color-accent-solid',        '--veneer-color-info-solid',        '--veneer-color-highlight-solid',      ].entries()) {        const cx =            p.x +            p.w * (0.5 + Math.sin(t + i * 2.1) * 0.3),          cy =            p.y +            p.h * (0.5 + Math.cos(t * 1.3 + i) * 0.3),          g = ctx.createRadialGradient(            cx,            cy,            0,            cx,            cy,            p.w * 0.45,          )        g.addColorStop(0, s.color(tone))        g.addColorStop(1, 'transparent')        ctx.globalAlpha = 0.75        ctx.fillStyle = g        ctx.fillRect(p.x, p.y, p.w, p.h)      }    },    type(p, s) {      words(p, s, {        fill: (ctx) => {          const g = ctx.createLinearGradient(            p.x,            p.y,            p.x + p.w,            p.y + p.h,          )          g.addColorStop(0, s.color(TOKENS.accent))          g.addColorStop(            1,            s.color('--veneer-color-info-solid'),          )          ctx.fillStyle = g        },      })    },  }),  sketch: lookKit({    ...TOKENS,    soft:      'var(--stage-soft, ' +      'var(--veneer-color-text-subtle))',    onFill: 'var(--veneer-color-text)',    rough: 2.2,    glow: 0,    hatch: true,    shadow: 'offset',    ground(s) {      // Ruled paper: the lines are fixed to the page, so      // they scroll with it, and a margin rule runs down      // the side.      const ctx = s.ctx,        gap = 30,        off = (s.scroll?.top ?? 0) % gap      ctx.fillStyle = s.color(TOKENS.paper)      ctx.fillRect(0, 0, s.width, s.height)      ctx.strokeStyle = s.color(        '--veneer-color-info-border',      )      ctx.lineWidth = 1      ctx.beginPath()      for (let y = gap - off; y < s.height; y += gap) {        ctx.moveTo(0, y + 0.5)        ctx.lineTo(s.width, y + 0.5)      }      ctx.stroke()      ctx.strokeStyle = s.color(        '--veneer-color-critical-border',      )      ctx.beginPath()      ctx.moveTo(18.5, 0)      ctx.lineTo(18.5, s.height)      ctx.stroke()    },    art(p, s) {      const ctx = s.ctx,        j = rng(seedOf(p.el)),        cx = p.x + p.w / 2,        cy = p.y + p.h / 2      ctx.strokeStyle = s.color(TOKENS.ink)      ctx.lineWidth = 1.4      for (        let r = 8;        r < Math.min(p.w, p.h) / 2;        r += 9      ) {        ctx.beginPath()        for (let a = 0; a <= Math.PI * 2.05; a += 0.3)          ctx.lineTo(            cx + Math.cos(a) * (r + j() * 3),            cy + Math.sin(a) * (r + j() * 3),          )        ctx.stroke()      }      outline(        ctx,        { ...p, r: 0, seed: seedOf(p.el) },        LOOKS.sketch.style,      )      stroke(ctx, LOOKS.sketch.style, {        color: s.color(TOKENS.ink),        width: 1.5,      })    },    type(p, s) {      words(p, s, {        fill: (ctx) =>          (ctx.fillStyle = s.color(TOKENS.ink)),        tilt: 0.04,      })    },  }),  neon: lookKit({    // Its own colours, a pair for each theme: an app's    // --stage-* still wins over them.    ink: 'var(--stage-ink, light-dark(#3a1060, #f4ecff))',    paper:      'var(--stage-paper, light-dark(#fff2fa, #120a24))',    accent:      'var(--stage-accent, light-dark(#d81b78, #ff3ea5))',    soft:      'var(--stage-soft, ' +      'light-dark(#e3b8ef, #4a3a7a))',    onFill: '#fff',    rough: 0,    glow: 14,    hatch: false,    shadow: 'none',    ground(s) {      // A horizon and a floor grid that rolls toward you      // as the page scrolls and time passes.      const ctx = s.ctx,        w = s.width,        h = s.height,        hz = h * 0.42,        sky = ctx.createLinearGradient(0, 0, 0, h)      sky.addColorStop(        0,        s.color('light-dark(#fff2fa, #07021a)'),      )      sky.addColorStop(        0.42,        s.color('light-dark(#ffd1ec, #2a0b4a)'),      )      sky.addColorStop(        1,        s.color('light-dark(#fff2fa, #07021a)'),      )      ctx.fillStyle = sky      ctx.fillRect(0, 0, w, h)      ctx.strokeStyle = ctx.shadowColor = s.color(        'light-dark(#c86bff, #7b2cff)',      )      ctx.lineWidth = 1      ctx.shadowBlur = 8      const roll =        ((s.scroll?.top ?? 0) / 40 + s.time * 0.6) % 1      ctx.beginPath()      for (let i = 0; i < 14; i++) {        const z = (i + roll) / 14,          y = hz + (h - hz) * z * z        ctx.moveTo(0, y)        ctx.lineTo(w, y)      }      for (let i = -12; i <= 12; i++) {        ctx.moveTo(w / 2 + i * 12, hz)        ctx.lineTo(w / 2 + i * w * 0.18, h)      }      ctx.stroke()      ctx.shadowBlur = 0    },    art(p, s) {      // A striped sun setting into the floor.      const ctx = s.ctx,        cx = p.x + p.w / 2,        cy = p.y + p.h * 0.62,        r = Math.min(p.w, p.h) * 0.38,        g = ctx.createLinearGradient(0, cy - r, 0, cy + r)      ctx.beginPath()      ctx.roundRect(p.x, p.y, p.w, p.h, p.r)      ctx.clip()      g.addColorStop(0, '#ffd23f')      g.addColorStop(1, s.color(LOOKS.neon.style.accent))      ctx.shadowColor = s.color(LOOKS.neon.style.accent)      ctx.shadowBlur = 30      ctx.fillStyle = g      ctx.beginPath()      ctx.arc(        cx,        cy,        r * (1 + p.hover * 0.06),        0,        Math.PI * 2,      )      ctx.fill()      ctx.shadowBlur = 0      ctx.fillStyle = s.color(LOOKS.neon.style.paper)      for (let i = 0; i < 6; i++) {        const y =          cy +          r * (0.1 + i * 0.16) -          ((s.time * 6) % (r * 0.16))        ctx.fillRect(p.x, y, p.w, 2 + i * 1.2)      }    },    type(p, s) {      s.ctx.shadowColor = s.color(LOOKS.neon.style.accent)      s.ctx.shadowBlur = 18      words(p, s, {        fill: (ctx) => {          const g = ctx.createLinearGradient(            p.x,            p.y,            p.x,            p.y + p.h,          )          g.addColorStop(            0,            s.color('light-dark(#0aa6c9, #28e7ff)'),          )          g.addColorStop(            1,            s.color(LOOKS.neon.style.accent),          )          ctx.fillStyle = g        },      })    },  }),} satisfies Record<LookName, ReturnType<typeof lookKit>>function Skins(props: { group: string }) {  return (    <Grid      min="md"      cols={2}      gap="group"    >      <Stack        gap="related"        data-skin="card"      >        <Row          gap="related"          wrap        >          <button data-skin="button">Deploy</button>          <button            data-skin="button"            data-look="outline"          >            Preview          </button>          <button            data-skin="button"            disabled          >            Paused          </button>        </Row>        <label data-part="label">          Project          <input            data-skin="field"            value="storefront"          />        </label>        <label data-part="label">          Region          <StageSelect options={['eu-west', 'us-east']} />        </label>      </Stack>      {skinControls(() => props.group)}    </Grid>  )}function skinControls(group: () => string) {  return (    <Stack      gap="related"      data-skin="card"    >      <label data-part="option">        <input          type="checkbox"          data-skin="check"          checked        />        Preview every push      </label>      <Row gap="group">        <label data-part="option">          <input            type="radio"            name={group()}            data-skin="check"            checked          />          Node        </label>        <label data-part="option">          <input            type="radio"            name={group()}            data-skin="check"          />          Bun        </label>      </Row>      <label data-part="option">        <input          type="checkbox"          role="switch"          data-skin="switch"          checked        />        Deploy on push      </label>      <label data-part="label">        Build minutes        <input          type="range"          data-skin="range"          value="60"        />      </label>      <Row        gap="tight"        wrap      >        <button          data-skin="chip"          aria-pressed="true"        >          Live        </button>        <button          data-skin="chip"          aria-pressed="false"        >          Building        </button>      </Row>    </Stack>  )}const lookStyle = (name: LookName, rest = '') =>  `${rest};--stage-on-fill:${LOOKS[name].style.onFill}`const NEON_STYLE = lookStyle(  'neon',  'padding:28px',)

From scratch

A painter gets the piece (its box, corner radius, hover, press, focus, checked, value) and the scene, whose ctx is a plain 2D context in CSS pixels.

tsx
import { CanvasStage } from '@kerf-js/veneer'import type { StagePainter } from '@kerf-js/veneer'export function Plain() {  return (  <CanvasStage    painters={PLAIN}    style="padding:32px"  >    <a      href="#"      data-skin="card"      style="max-inline-size:340px"      onClick={(e: MouseEvent) => e.preventDefault()}    >      <strong>storefront</strong>      <span>        Every border, shadow and fill here is painted on        one canvas. The text, the link and the button are        HTML.      </span>      <button data-skin="button">Deploy</button>    </a>  </CanvasStage>  )}const PLAIN: Record<string, StagePainter> = {  button(p, s) {    s.ctx.fillStyle = s.color(      p.hover        ? '--veneer-color-accent-solid-hover'        : '--veneer-color-accent-solid',    )    s.ctx.beginPath()    s.ctx.roundRect(p.x, p.y + p.press * 2, p.w, p.h, p.r)    s.ctx.fill()  },  card(p, s) {    s.ctx.shadowColor = 'rgb(0 0 0 / 0.18)'    s.ctx.shadowBlur = 12 + p.hover * 12    s.ctx.shadowOffsetY = 4 + p.hover * 4    s.ctx.fillStyle = s.color('--veneer-color-surface')    s.ctx.beginPath()    s.ctx.roundRect(p.x, p.y, p.w, p.h, p.r)    s.ctx.fill()  },}

How a stage works

The DOM lays the page out and keeps everything it is good at: wrapping, grids that stack on a phone, focus, typing, selection, forms and the accessibility tree. Each element marked data-skin gives up its paint, and the stage hands it to the painter of that name every frame something moves, under the element's own text.

The skins the looks here share are button (data-look="outline"), card (a link or button card can be chosen with aria-pressed), panel and bar for windows, field, menu and option for a StageSelect's list, check (checkboxes and radios), switch, range, meter, chip, rule, type, art and clear. Any other name works too: give it a painter.

The scene carries what a look needs beyond the pieces: scroll to move a background with the page, presses to answer a click where it landed, rail for a drawn scrollbar you can drag, shown for an entrance such as a modal's veil, color() to read veneer's tokens, and words() to paint an element's text where the DOM set it. layout="scroll" makes the stage a scroller and paints its view; layout="page" paints the viewport, for a whole site. A stage inside another, such as a sticky header's or a dialog's, paints over it.

Styling

Skins need no styles of their own. The stage's CSS gives each one its padding, corners and size at zero specificity, from properties you can set on any element above it, so a page starts styled and any class of yours wins.

  • Colours: --stage-ink, --stage-paper, --stage-accent, --stage-soft, and --stage-on-fill for text on a filled button or chip. Unset, they follow veneer's tokens, so light and dark come free.
  • Corners: --stage-radius for controls, --stage-radius-surface for cards, panels and art.
  • Padding: --stage-pad-button, --stage-pad-field, --stage-pad-menu, --stage-pad-option, --stage-pad-chip, --stage-pad-card, --stage-pad-bar, --stage-pad-clear, and --stage-gap, --stage-gap-card inside them.
  • Sizes: --stage-check-size, --stage-switch-inline, --stage-switch-block, --stage-range-block, --stage-meter-block, --stage-rule-block, --stage-art-ratio.
  • Type: --stage-type-font, --stage-type-weight and --stage-type-spacing, which each stage resolves to --stage-type-face, -heft and -track for a heading under your own h1 rule to name. The dither look defaults to a sans, which holds up a cell at a time. --stage-label-size for data-part="label", the label over a field. data-part="option" sets a check or switch beside its words.

A look reads the colour properties too, so one set of values restyles every look on the page. A panel in a veiled stage rises in as its dialog opens, and the paint follows it.

When to use

Use it for

  • A page or section should be drawn on canvas but still behave like HTML
  • Controls in a look CSS can't draw: pencil, glow, pixels, dithering

Reach for something else

  • Canvas, when it is a drawing, not a page
  • DitherStage, when you want the dithered look, ready made

Accessibility

  • Every control is real HTML, so keyboard, screen readers, autofill and form validation work as they do anywhere. Draw focus in your painters: piece.focus follows :focus-visible.
  • Before the stage paints, and under forced colours, skins keep plain borders and the canvas is hidden.
  • type keeps its words in the page, selectable and read aloud, while the look paints them.
  • Set --stage-on-fill to the text colour for filled buttons and chips in your look, and check its contrast.

API Reference

PropTypeDefaultDescription
layoutStageLayout'box'box paints its own block, scroll is a scroller and paints its view, page paints the viewport.
veilbooleanfalseA modal's stage: its dialog spans the viewport, and the stage centres its panel.
childrenunknown—What it contains.
paintersRecord<string, StagePainter>—Painters by skin name. Each gets the piece and the scene.
background((scene: Scene) => void)—Paints under the pieces, on a cleared canvas.
overlay((scene: Scene) => void)—Paints over the pieces: presses, the rail, anything on top.
paint((scene: Scene) => boolean | void)—Replaces the whole frame, for a look that paints its own way, as the dither look does.
animatebooleanfalsePaint every frame while motion is on, for a moving background. Pieces animate without it.
cellnumber—Whole-cell mode: units are cells of this many CSS pixels, scaled up without smoothing.
fpsnumber—Caps the frame rate while it animates. Uncapped by default, at the display's rate.
railbooleanfalseScroll when the rail is dragged. The look paints it from scene.rail.
contextCanvasRenderingContext2DSettings—Settings for the 2D context, such as { alpha: false }. Read once, when the canvas attaches.
EventTypeDefaultDescription
onTheme(() => void)—Called when the theme or dial changes, before the repaint.

Parts

Every element carries a stable name, so you can style it from your own CSS: [data-scope="stage"] [data-part="paint"]

paint

RelatedDither stageStage selectCanvas