sandbox/studio: add faders sub-panel between knobs and seq

Four channel-volume faders matching the patch (KICK/HAT/MEL/MIX),
in their own sub-panel with the same brushed-metal treatment as
.knobs and .seq.

- Track is a deep slot cut into the panel — heavy inset shadow,
  thin highlight at the bottom edge for chamfer feel.
- Side ticks drawn with repeating-linear-gradient (1px on / 6px off).
- Cap uses the same metal language as the knob disc: brushed stripes
  + vertical light gradient + grip line in the middle + 4-edge bevel
  via inset shadows + drop shadow underneath.
- Click anywhere on the track jumps the cap to that value, then
  dragging continues. Pointer capture on the track itself so the cap
  follows even outside the bounds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis Montañes
2026-05-01 18:15:48 +02:00
parent 29f1a1e58b
commit f163a24f62

View File

@@ -85,7 +85,7 @@
gap: 12px;
}
.left-stack { display: grid; grid-template-rows: auto 1fr auto; gap: 8px; min-width: 0; }
.right-stack { display: grid; grid-template-rows: auto auto; gap: 8px; min-width: 0; }
.right-stack { display: grid; grid-template-rows: auto auto auto; gap: 8px; min-width: 0; }
/* corner screws — only on the outermost frame */
.screw {
@@ -235,7 +235,7 @@
/* ===================================================================== */
/* shared brushed-metal panel treatment for sub-panels inside the rack */
.knobs, .seq {
.knobs, .seq, .faders {
border: 1px solid var(--hw-edge);
border-radius: 6px;
background:
@@ -274,6 +274,82 @@
.hw-knob { cursor: grab; touch-action: none; }
.hw-knob.dragging { cursor: grabbing; }
/* faders sub-panel */
.faders {
padding: 10px 10px 14px;
display: flex;
justify-content: space-around;
align-items: flex-start;
gap: 8px;
}
.fader-cell {
display: flex; flex-direction: column; align-items: center;
gap: 4px; flex: 1; min-width: 0;
}
.fader-cell .label {
font-size: 8px; letter-spacing: 0.18em; text-transform: uppercase;
color: var(--hw-engrave);
text-shadow: 0 1px 0 rgba(0,0,0,0.7), 0 -1px 0 rgba(255,220,180,0.04);
}
/* track is a deep slot cut into the panel */
.fd-track {
position: relative;
width: 5px; height: 78px;
background:
linear-gradient(180deg, #050402 0%, #0a0808 50%, #060403 100%);
border-radius: 3px;
box-shadow:
inset 0 1px 3px rgba(0,0,0,0.85),
inset 0 0 0 1px rgba(0,0,0,0.6),
inset 0 -1px 0 rgba(255,220,180,0.05),
0 1px 0 rgba(255,220,180,0.04);
cursor: pointer; touch-action: none;
}
/* tick marks on either side of the track */
.fd-ticks {
position: absolute; top: 4px; bottom: 4px;
width: 16px; pointer-events: none;
background: repeating-linear-gradient(
180deg,
rgba(255,220,180,0.20) 0px,
rgba(255,220,180,0.20) 1px,
transparent 1px,
transparent 7px);
}
.fd-ticks.left { left: -18px; }
.fd-ticks.right { right: -18px; }
/* the cap — metallic, sits on the track */
.fd-cap {
position: absolute;
left: 50%; transform: translate(-50%, -50%);
width: 26px; height: 14px;
border-radius: 2px;
background:
/* horizontal grip line in middle */
linear-gradient(180deg,
transparent 38%, rgba(0,0,0,0.55) 46%, rgba(0,0,0,0.55) 54%,
transparent 62%),
/* very fine brushed stripes */
repeating-linear-gradient(0deg,
rgba(255,250,235,0.04) 0px, rgba(255,250,235,0.04) 1px,
rgba(0,0,0,0.05) 1px, rgba(0,0,0,0.05) 2px),
/* vertical light gradient */
linear-gradient(180deg, #b6b0a4 0%, #807a6e 45%, #3a352e 80%, #1a1612 100%);
box-shadow:
inset 0 1px 0 rgba(255,252,240,0.55),
inset 0 -1px 0 rgba(0,0,0,0.7),
inset 1px 0 0 rgba(255,250,235,0.15),
inset -1px 0 0 rgba(0,0,0,0.45),
0 2px 3px rgba(0,0,0,0.6);
cursor: grab; touch-action: none;
}
.fd-cap.dragging { cursor: grabbing; }
.fader-cell .value {
font-size: 10px; font-variant-numeric: tabular-nums;
color: var(--hw-fg-hi); letter-spacing: 0.05em;
margin-top: 2px;
}
/* sequencer sub-panel — inherits brushed-metal background from .knobs,.seq */
.seq {
padding: 8px 10px;
@@ -380,6 +456,45 @@
</div>
</section>
<section class="faders">
<div class="fader-cell">
<span class="label">kick</span>
<div class="fd-track" data-value="0.78">
<div class="fd-ticks left"></div>
<div class="fd-ticks right"></div>
<div class="fd-cap"></div>
</div>
<span class="value">0.78</span>
</div>
<div class="fader-cell">
<span class="label">hat</span>
<div class="fd-track" data-value="0.55">
<div class="fd-ticks left"></div>
<div class="fd-ticks right"></div>
<div class="fd-cap"></div>
</div>
<span class="value">0.55</span>
</div>
<div class="fader-cell">
<span class="label">mel</span>
<div class="fd-track" data-value="0.62">
<div class="fd-ticks left"></div>
<div class="fd-ticks right"></div>
<div class="fd-cap"></div>
</div>
<span class="value">0.62</span>
</div>
<div class="fader-cell">
<span class="label">mix</span>
<div class="fd-track" data-value="0.85">
<div class="fd-ticks left"></div>
<div class="fd-ticks right"></div>
<div class="fd-cap"></div>
</div>
<span class="value">0.85</span>
</div>
</section>
<section class="seq">
<div class="seq-row" data-pattern="1000100010001000">
<span class="label">kicks</span>
@@ -677,6 +792,51 @@ document.querySelectorAll('canvas.hw-knob').forEach((canvas) => {
});
});
// ===========================================================================
// faders — drag the cap or click anywhere on the track to jump
// ===========================================================================
document.querySelectorAll('.fader-cell').forEach((cell) => {
const track = cell.querySelector('.fd-track');
const cap = cell.querySelector('.fd-cap');
const valueEl = cell.querySelector('.value');
let value = parseFloat(track.dataset.value || '0.5');
function paint() {
const trackH = track.clientHeight;
cap.style.top = ((1 - value) * trackH) + 'px';
if (valueEl) valueEl.textContent = value.toFixed(2);
}
// wait for layout
requestAnimationFrame(paint);
function valueAt(clientY) {
const rect = track.getBoundingClientRect();
const norm = 1 - (clientY - rect.top) / rect.height;
return Math.max(0, Math.min(1, norm));
}
function startDrag(e, jumpToPointer) {
e.preventDefault();
cap.classList.add('dragging');
track.setPointerCapture(e.pointerId);
document.body.style.cursor = 'grabbing';
if (jumpToPointer) { value = valueAt(e.clientY); paint(); }
const onMove = (ev) => { value = valueAt(ev.clientY); paint(); };
const onUp = () => {
cap.classList.remove('dragging');
document.body.style.cursor = '';
track.removeEventListener('pointermove', onMove);
track.removeEventListener('pointerup', onUp);
track.removeEventListener('pointercancel', onUp);
};
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
track.addEventListener('pointercancel', onUp);
}
// click on track jumps to that position; drag continues from there
track.addEventListener('pointerdown', (e) => startDrag(e, true));
});
// ===========================================================================
// step LEDs
// ===========================================================================