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
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/veneerUsage
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.
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.
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.
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.
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-fillfor text on a filled button or chip. Unset, they follow veneer's tokens, so light and dark come free. - Corners:
--stage-radiusfor controls,--stage-radius-surfacefor 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-cardinside 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-weightand--stage-type-spacing, which each stage resolves to--stage-type-face,-heftand-trackfor a heading under your ownh1rule to name. The dither look defaults to a sans, which holds up a cell at a time.--stage-label-sizefordata-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.focusfollows:focus-visible. - Before the stage paints, and under forced colours, skins keep plain borders and the canvas is hidden.
typekeeps its words in the page, selectable and read aloud, while the look paints them.- Set
--stage-on-fillto the text colour for filled buttons and chips in your look, and check its contrast.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
layout | StageLayout | 'box' | box paints its own block, scroll is a scroller and paints its view, page paints the viewport. |
veil | boolean | false | A modal's stage: its dialog spans the viewport, and the stage centres its panel. |
children | unknown | — | What it contains. |
painters | Record<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. |
animate | boolean | false | Paint every frame while motion is on, for a moving background. Pieces animate without it. |
cell | number | — | Whole-cell mode: units are cells of this many CSS pixels, scaled up without smoothing. |
fps | number | — | Caps the frame rate while it animates. Uncapped by default, at the display's rate. |
rail | boolean | false | Scroll when the rail is dragged. The look paints it from scene.rail. |
context | CanvasRenderingContext2DSettings | — | Settings for the 2D context, such as { alpha: false }. Read once, when the canvas attaches. |
| Event | Type | Default | Description |
|---|---|---|---|
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