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>
413 lines
15 KiB
HTML
413 lines
15 KiB
HTML
<!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>
|