sandbox: 3 knob variants for design exploration (track 2 prep)

Standalone page at web/sandbox/knobs.html showing three aesthetic
directions side-by-side:

- A "hardware": radial gradient + perimeter ridges + specular highlight,
  reads as a photographed knob.
- B "geometric": flat shapes, dot indicator at perimeter + short tick
  from center; OP-1 / diagram register.
- C "phosphor": outline + heavy glow on accent arc and indicator;
  synthwave / vacuum-tube feel.

Each variant shows 4 static knobs (0/33/66/100%) plus an interactive
draggable instance. DPR-aware canvas, vertical drag with shift-fine,
double-click to recenter. Pick one and the rest of the surfaces align.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis Montañes
2026-05-01 17:46:13 +02:00
parent 3c1b1d4aff
commit 299f365d71

412
web/sandbox/knobs.html Normal file
View File

@@ -0,0 +1,412 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>knob design exploration</title>
<style>
:root {
--bg: #0e0f12;
--panel: #14161a;
--panel-hi: #1a1d22;
--gutter: #2a2f38;
--gutter-hi: #404652;
--divider: #1c1f25;
--fg: #d6dae0;
--fg-mute: #aab0b8;
--comment: #5a6470;
--accent: #7af0c0;
--accent-glow: rgba(122, 240, 192, 0.30);
--t-fast: 80ms;
--t-base: 160ms;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--fg);
font-family: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
font-size: 14px; }
body { padding: 28px 32px 60px; max-width: 1200px; margin: 0 auto; }
h1 { font-size: 14px; font-weight: 500; color: var(--fg-mute);
letter-spacing: 0.06em; text-transform: uppercase; margin: 0 0 6px; }
.lede { color: var(--comment); font-size: 12px; margin-bottom: 36px; max-width: 60ch; }
.variants { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.variant { background: var(--panel); border: 1px solid var(--gutter);
border-radius: 6px; padding: 22px 18px; display: flex; flex-direction: column;
gap: 16px; transition: border-color var(--t-base); }
.variant:hover { border-color: var(--gutter-hi); }
.variant-header { display: flex; align-items: baseline; justify-content: space-between; }
.variant-tag { font-size: 10px; color: var(--comment); letter-spacing: 0.08em;
text-transform: uppercase; }
.variant-name { font-size: 16px; color: var(--fg); }
.variant-desc { color: var(--comment); font-size: 11px; line-height: 1.55; min-height: 4em; }
.row-static { display: grid; grid-template-columns: repeat(4, 1fr);
gap: 8px; padding: 10px 0;
border-top: 1px solid var(--divider);
border-bottom: 1px solid var(--divider); }
.stat { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.stat .v { font-size: 9px; color: var(--comment);
font-variant-numeric: tabular-nums; }
.row-live { display: flex; flex-direction: column; align-items: center;
gap: 8px; padding-top: 4px; }
.row-live .label { font-size: 10px; color: var(--fg-mute);
letter-spacing: 0.06em; text-transform: uppercase; }
.row-live .value { font-size: 11px; color: var(--accent);
font-variant-numeric: tabular-nums; }
.row-live .hint { font-size: 10px; color: var(--comment); margin-top: 4px; }
.knob { cursor: grab; touch-action: none; }
.knob.dragging { cursor: grabbing; }
footer { margin-top: 44px; color: var(--comment); font-size: 11px;
border-top: 1px solid var(--divider); padding-top: 18px;
line-height: 1.7; }
kbd { background: var(--panel-hi); border: 1px solid var(--gutter);
border-radius: 3px; padding: 1px 6px; font-size: 10px; color: var(--fg); }
</style>
</head>
<body>
<h1>Knob design exploration</h1>
<p class="lede">Three directions for the knob aesthetic, each shown statically at 0% / 33% / 66% / 100%
and as a draggable instance below. Pick one and the rest of the surfaces (faders,
sequencers, piano roll) align to that language.</p>
<div class="variants">
<section class="variant" id="variant-a">
<div class="variant-header">
<span class="variant-name">A · hardware</span>
<span class="variant-tag">dimensional</span>
</div>
<p class="variant-desc">Radial gradient, perimeter ridges, specular highlight from
top-left, drop shadow. Reads as a real photographed knob. Premium but heavier;
can compete visually with the rest of the UI.</p>
<div class="row-static" data-variant="A">
<div class="stat"><canvas data-norm="0"></canvas><span class="v">0%</span></div>
<div class="stat"><canvas data-norm="0.33"></canvas><span class="v">33%</span></div>
<div class="stat"><canvas data-norm="0.66"></canvas><span class="v">66%</span></div>
<div class="stat"><canvas data-norm="1"></canvas><span class="v">100%</span></div>
</div>
<div class="row-live" data-variant="A">
<canvas class="knob" data-live="true"></canvas>
<div class="value">0.50</div>
<div class="label">cutoff</div>
<div class="hint">drag vertically · shift = fine</div>
</div>
</section>
<section class="variant" id="variant-b">
<div class="variant-header">
<span class="variant-name">B · geometric</span>
<span class="variant-tag">flat / technical</span>
</div>
<p class="variant-desc">Crisp 2D shapes, no fake depth. Bold accent arc, dot
indicator on the perimeter, short tick from center. Reads like a Teenage
Engineering OP-1 or a clean diagram. Quietest of the three.</p>
<div class="row-static" data-variant="B">
<div class="stat"><canvas data-norm="0"></canvas><span class="v">0%</span></div>
<div class="stat"><canvas data-norm="0.33"></canvas><span class="v">33%</span></div>
<div class="stat"><canvas data-norm="0.66"></canvas><span class="v">66%</span></div>
<div class="stat"><canvas data-norm="1"></canvas><span class="v">100%</span></div>
</div>
<div class="row-live" data-variant="B">
<canvas class="knob" data-live="true"></canvas>
<div class="value">0.50</div>
<div class="label">cutoff</div>
<div class="hint">drag vertically · shift = fine</div>
</div>
</section>
<section class="variant" id="variant-c">
<div class="variant-header">
<span class="variant-name">C · phosphor</span>
<span class="variant-tag">glow / CRT</span>
</div>
<p class="variant-desc">Outline-only with strong glow. The accent arc and indicator
both bleed light. Soft inner radial gradient. Synthwave / vacuum-tube feel.
Brightest, most "instrument" — but the glow can be fatiguing en masse.</p>
<div class="row-static" data-variant="C">
<div class="stat"><canvas data-norm="0"></canvas><span class="v">0%</span></div>
<div class="stat"><canvas data-norm="0.33"></canvas><span class="v">33%</span></div>
<div class="stat"><canvas data-norm="0.66"></canvas><span class="v">66%</span></div>
<div class="stat"><canvas data-norm="1"></canvas><span class="v">100%</span></div>
</div>
<div class="row-live" data-variant="C">
<canvas class="knob" data-live="true"></canvas>
<div class="value">0.50</div>
<div class="label">cutoff</div>
<div class="hint">drag vertically · shift = fine</div>
</div>
</section>
</div>
<footer>
Tradeoff summary: <strong>A</strong> looks most premium but loudest in a panel of 12 controls.
<strong>B</strong> is the safest at scale (a panel of 30 knobs stays readable). <strong>C</strong>
has the strongest "instrument" identity and pairs with the existing accent
glow elsewhere; risk of visual noise. Once chosen, the same language extends to
faders, step cells, piano roll keys, and indicators.
</footer>
<script>
const STATIC_SIZE = 56;
const LIVE_SIZE = 80;
// --- canvas setup with DPR-aware sizing -------------------------------------
function setupCanvas(canvas, size) {
const dpr = window.devicePixelRatio || 1;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
canvas.width = Math.round(size * dpr);
canvas.height = Math.round(size * dpr);
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
return ctx;
}
// =====================================================================
// Variant A — "hardware". Radial gradient, ridges, specular, drop shadow.
// =====================================================================
function drawA(ctx, size, norm) {
const cx = size / 2, cy = size / 2 + 1;
const outerR = size * 0.42;
const innerR = size * 0.34;
ctx.clearRect(0, 0, size, size);
// outer body with drop shadow
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.55)';
ctx.shadowBlur = 6;
ctx.shadowOffsetY = 2;
ctx.fillStyle = '#1a1d22';
ctx.beginPath();
ctx.arc(cx, cy, outerR, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// arc track + value arc
const startA = Math.PI * 0.75, endA = Math.PI * 2.25;
ctx.lineCap = 'round';
ctx.lineWidth = 3;
ctx.strokeStyle = '#2a2f38';
ctx.beginPath();
ctx.arc(cx, cy, outerR + 4, startA, endA);
ctx.stroke();
ctx.strokeStyle = '#7af0c0';
ctx.beginPath();
ctx.arc(cx, cy, outerR + 4, startA, startA + norm * (endA - startA));
ctx.stroke();
// dial — radial gradient
const grad = ctx.createRadialGradient(
cx - innerR * 0.4, cy - innerR * 0.5, innerR * 0.05,
cx, cy, innerR * 1.1
);
grad.addColorStop(0, '#3f4651');
grad.addColorStop(0.55, '#1e2128');
grad.addColorStop(1, '#0a0c10');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(cx, cy, innerR, 0, Math.PI * 2);
ctx.fill();
// perimeter ridges
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
for (let i = 0; i < 32; i++) {
const a = (i / 32) * Math.PI * 2;
const r1 = innerR * 0.93, r2 = innerR * 0.99;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1);
ctx.lineTo(cx + Math.cos(a) * r2, cy + Math.sin(a) * r2);
ctx.stroke();
}
// specular highlight (light from NW)
const hgrad = ctx.createRadialGradient(
cx - innerR * 0.35, cy - innerR * 0.5, 0,
cx - innerR * 0.35, cy - innerR * 0.5, innerR
);
hgrad.addColorStop(0, 'rgba(255,255,255,0.13)');
hgrad.addColorStop(0.6, 'rgba(255,255,255,0.02)');
hgrad.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = hgrad;
ctx.beginPath();
ctx.arc(cx, cy, innerR, 0, Math.PI * 2);
ctx.fill();
// indicator line
const ang = startA + norm * (endA - startA);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.shadowBlur = 4;
ctx.shadowColor = 'rgba(255,255,255,0.4)';
ctx.beginPath();
ctx.moveTo(cx + Math.cos(ang) * innerR * 0.42, cy + Math.sin(ang) * innerR * 0.42);
ctx.lineTo(cx + Math.cos(ang) * innerR * 0.86, cy + Math.sin(ang) * innerR * 0.86);
ctx.stroke();
ctx.shadowBlur = 0;
}
// =====================================================================
// Variant B — "geometric". Flat shapes, dot indicator, short tick.
// =====================================================================
function drawB(ctx, size, norm) {
const cx = size / 2, cy = size / 2 + 1;
const r = size * 0.40;
const startA = Math.PI * 0.75, endA = Math.PI * 2.25;
ctx.clearRect(0, 0, size, size);
// background panel disk (gives separation from container)
ctx.fillStyle = '#0e0f12';
ctx.beginPath();
ctx.arc(cx, cy, r - 2, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#1c1f25';
ctx.lineWidth = 1;
ctx.stroke();
// outer track + value arc
ctx.lineCap = 'round';
ctx.lineWidth = 3.5;
ctx.strokeStyle = '#2a2f38';
ctx.beginPath();
ctx.arc(cx, cy, r, startA, endA);
ctx.stroke();
ctx.strokeStyle = '#7af0c0';
ctx.beginPath();
ctx.arc(cx, cy, r, startA, startA + norm * (endA - startA));
ctx.stroke();
// tick from center
const ang = startA + norm * (endA - startA);
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(cx + Math.cos(ang) * 4, cy + Math.sin(ang) * 4);
ctx.lineTo(cx + Math.cos(ang) * (r - 9), cy + Math.sin(ang) * (r - 9));
ctx.stroke();
// dot at the perimeter (on the value arc)
ctx.fillStyle = '#7af0c0';
ctx.beginPath();
ctx.arc(cx + Math.cos(ang) * (r - 4), cy + Math.sin(ang) * (r - 4), 2.5, 0, Math.PI * 2);
ctx.fill();
}
// =====================================================================
// Variant C — "phosphor". Outline-only with heavy glow.
// =====================================================================
function drawC(ctx, size, norm) {
const cx = size / 2, cy = size / 2 + 1;
const r = size * 0.40;
const startA = Math.PI * 0.75, endA = Math.PI * 2.25;
ctx.clearRect(0, 0, size, size);
// soft inner radial fill (very subtle bloom)
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
grad.addColorStop(0, 'rgba(122,240,192,0.10)');
grad.addColorStop(1, 'rgba(122,240,192,0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
// bg circle outline
ctx.strokeStyle = 'rgba(122,240,192,0.18)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
// track
ctx.lineCap = 'round';
ctx.lineWidth = 3.5;
ctx.strokeStyle = 'rgba(122,240,192,0.16)';
ctx.beginPath();
ctx.arc(cx, cy, r, startA, endA);
ctx.stroke();
// value arc with glow
ctx.strokeStyle = '#7af0c0';
ctx.shadowBlur = 12;
ctx.shadowColor = '#7af0c0';
ctx.beginPath();
ctx.arc(cx, cy, r, startA, startA + norm * (endA - startA));
ctx.stroke();
ctx.shadowBlur = 0;
// indicator beam
const ang = startA + norm * (endA - startA);
ctx.strokeStyle = '#cfffe8';
ctx.shadowBlur = 8;
ctx.shadowColor = '#7af0c0';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(cx + Math.cos(ang) * 4, cy + Math.sin(ang) * 4);
ctx.lineTo(cx + Math.cos(ang) * (r - 4), cy + Math.sin(ang) * (r - 4));
ctx.stroke();
ctx.shadowBlur = 0;
}
const drawFns = { A: drawA, B: drawB, C: drawC };
// --- render static rows -----------------------------------------------------
document.querySelectorAll('.row-static').forEach((row) => {
const variant = row.dataset.variant;
const draw = drawFns[variant];
row.querySelectorAll('canvas').forEach((c) => {
const ctx = setupCanvas(c, STATIC_SIZE);
draw(ctx, STATIC_SIZE, parseFloat(c.dataset.norm));
});
});
// --- live (draggable) knobs -------------------------------------------------
document.querySelectorAll('.row-live').forEach((row) => {
const variant = row.dataset.variant;
const draw = drawFns[variant];
const canvas = row.querySelector('canvas.knob');
const valueEl = row.querySelector('.value');
const ctx = setupCanvas(canvas, LIVE_SIZE);
let value = 0.5;
const redraw = () => {
draw(ctx, LIVE_SIZE, value);
valueEl.textContent = value.toFixed(2);
};
redraw();
canvas.addEventListener('pointerdown', (e) => {
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
canvas.classList.add('dragging');
document.body.style.cursor = 'grabbing';
const startY = e.clientY;
const startV = value;
const onMove = (ev) => {
const dy = startY - ev.clientY;
const factor = ev.shiftKey ? 4 : 1;
const delta = (dy / 200) / factor;
value = Math.max(0, Math.min(1, startV + delta));
redraw();
};
const onUp = () => {
canvas.classList.remove('dragging');
document.body.style.cursor = '';
canvas.removeEventListener('pointermove', onMove);
canvas.removeEventListener('pointerup', onUp);
canvas.removeEventListener('pointercancel', onUp);
};
canvas.addEventListener('pointermove', onMove);
canvas.addEventListener('pointerup', onUp);
canvas.addEventListener('pointercancel', onUp);
});
canvas.addEventListener('dblclick', () => { value = 0.5; redraw(); });
});
</script>
</body>
</html>