track 1: foundation polish — splitter, custom scrollbars, micro-states

Adds docs/roadmap.md outlining the 4-track plan (foundation, surface
aesthetics, organizable panel, theming) and ships the first track.

- Resizable splitter between editor and control surface (drag the seam,
  double-click to reset). Width persists in localStorage.
- Custom scrollbars (WebKit pseudo-elements + Firefox scrollbar-color)
  applied globally so CodeMirror's .cm-scroller, the right pane and the
  piano roll all match the dark theme.
- Header separators between transport / gain / info groups.
- Smooth transitions on buttons, range thumbs and surface borders.
- :focus-visible rings using accent-glow color token.
- Pulse animation on the live status dot.
- New design-system tokens for layout (--right-w, --splitter-w),
  timing (--t-fast/base/slow), and panel hierarchy (--panel-hi,
  --gutter-hi, --divider, --fg-mute, --accent-glow).

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:42:53 +02:00
parent 7debc7436e
commit 3c1b1d4aff
2 changed files with 258 additions and 11 deletions

View File

@@ -27,11 +27,16 @@
</script>
<style>
/* === design tokens === */
:root {
--bg: #0e0f12;
--panel: #14161a;
--panel-hi: #1a1d22;
--gutter: #2a2f38;
--gutter-hi: #404652;
--divider: #1c1f25;
--fg: #d6dae0;
--fg-mute: #aab0b8;
--comment: #5a6470;
--kw: #c678dd;
--num: #d19a66;
@@ -41,36 +46,111 @@
--wave-bg: #161a1f;
--error: #f08080;
--accent: #7af0c0;
--accent-glow: rgba(122, 240, 192, 0.30);
--knob-track: #2a2f38;
/* scrollbar */
--scrollbar-thumb: #2a2f38;
--scrollbar-thumb-hover: #3a4048;
/* layout */
--right-w: 360px;
--splitter-w: 4px;
/* timing */
--t-fast: 80ms;
--t-base: 160ms;
--t-slow: 280ms;
}
/* === reset / base === */
* { 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 { display: flex; flex-direction: column; }
/* === scrollbars (global, also apply to CodeMirror's .cm-scroller) === */
* { scrollbar-width: thin; scrollbar-color: var(--scrollbar-thumb) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb);
border-radius: 6px; border: 2px solid transparent;
background-clip: padding-box;
transition: background var(--t-base); }
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover);
background-clip: padding-box;
border: 2px solid transparent; }
::-webkit-scrollbar-corner { background: transparent; }
/* === header === */
header { padding: 8px 14px; font-size: 12px; color: var(--comment);
border-bottom: 1px solid #1c1f25; display: flex; gap: 14px;
border-bottom: 1px solid var(--divider); display: flex; gap: 12px;
flex-wrap: wrap; align-items: center; }
header button { background: var(--panel); border: 1px solid var(--gutter);
color: var(--fg); padding: 5px 14px; cursor: pointer;
font-family: inherit; font-size: 12px; border-radius: 3px; }
font-family: inherit; font-size: 12px; border-radius: 3px;
transition: border-color var(--t-fast), color var(--t-fast),
background var(--t-fast), transform var(--t-fast); }
header button:hover { border-color: var(--accent); color: var(--accent); }
header button:focus-visible { outline: 2px solid var(--accent-glow);
outline-offset: 2px; }
header button:active:not(:disabled) { transform: translateY(1px); }
header button:disabled { opacity: 0.4; cursor: default; }
.header-sep { width: 1px; height: 16px; background: var(--gutter);
opacity: 0.7; }
/* range input (gain slider in header + everywhere) */
input[type=range] { -webkit-appearance: none; appearance: none;
height: 4px; background: var(--knob-track);
border-radius: 2px; vertical-align: middle;
width: 90px; cursor: pointer; outline: none; }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 12px; height: 12px; border-radius: 50%; background: var(--fg);
cursor: grab; transition: background var(--t-fast),
box-shadow var(--t-fast); }
input[type=range]:hover::-webkit-slider-thumb { background: var(--accent); }
input[type=range]:active::-webkit-slider-thumb { cursor: grabbing;
box-shadow: 0 0 0 4px var(--accent-glow); }
input[type=range]::-moz-range-thumb {
width: 12px; height: 12px; border-radius: 50%; background: var(--fg);
border: none; cursor: grab; transition: background var(--t-fast); }
input[type=range]:focus-visible::-webkit-slider-thumb {
box-shadow: 0 0 0 3px var(--accent-glow); }
header .dot { width: 8px; height: 8px; border-radius: 50%; background: #555;
display: inline-block; vertical-align: middle; margin-right: 6px; }
header .dot.live { background: var(--accent); box-shadow: 0 0 8px var(--accent); }
display: inline-block; vertical-align: middle; margin-right: 6px;
transition: background var(--t-base), box-shadow var(--t-base); }
header .dot.live { background: var(--accent);
animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 6px var(--accent); }
50% { box-shadow: 0 0 14px var(--accent); }
}
#error { color: var(--error); flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
main { flex: 1; display: grid; grid-template-columns: 1fr 320px; min-height: 0; }
#editor { background: var(--panel); border-right: 1px solid #1c1f25;
overflow: hidden; }
/* === main layout: editor | splitter | controls === */
main { flex: 1; display: grid;
grid-template-columns: minmax(320px, 1fr) var(--splitter-w) minmax(260px, var(--right-w));
min-height: 0; }
#editor { background: var(--panel); overflow: hidden; }
.cm-editor { height: 100%; font-size: 14px; }
.cm-editor.cm-focused { outline: none; }
.cm-content { font-family: inherit; }
/* draggable seam between editor and control surface */
.splitter { background: var(--divider); position: relative;
cursor: col-resize; transition: background var(--t-base); }
.splitter::before { /* widen hit area to ~12px without changing visual width */
content: ''; position: absolute; inset: 0 -4px; z-index: 2; }
.splitter:hover, .splitter.dragging { background: var(--accent); }
.wave-widget { display: inline-block; vertical-align: middle; margin-left: 12px;
background: var(--wave-bg); border-radius: 3px; }
/* control surface */
/* === control surface === */
#controls { padding: 16px; overflow: auto;
display: flex; flex-wrap: wrap; gap: 18px; align-content: flex-start; }
#controls.empty::before { content: 'declare faders or knobs in the patch to populate this surface →←';
@@ -79,9 +159,12 @@
.ctrl { display: flex; flex-direction: column; align-items: center;
gap: 6px; min-width: 70px; padding: 10px;
background: var(--panel); border: 1px solid var(--gutter);
border-radius: 6px; }
.ctrl-label { font-size: 11px; color: var(--fg); user-select: none;
max-width: 80px; overflow: hidden; text-overflow: ellipsis; }
border-radius: 6px;
transition: border-color var(--t-base), box-shadow var(--t-base); }
.ctrl:hover { border-color: var(--gutter-hi); }
.ctrl-label { font-size: 10px; color: var(--fg-mute); user-select: none;
max-width: 80px; overflow: hidden; text-overflow: ellipsis;
text-transform: uppercase; letter-spacing: 0.06em; }
.ctrl-value { font-size: 10px; color: var(--accent); font-variant-numeric: tabular-nums;
user-select: none; }
.knob-canvas { cursor: grab; touch-action: none; }
@@ -132,12 +215,15 @@
<header>
<button id="start">Start audio</button>
<span><span id="dot" class="dot"></span><span id="status">stopped</span></span>
<span class="header-sep"></span>
<span>gain <input id="gain" type="range" min="0" max="1" step="0.01" value="0.3"></span>
<span class="header-sep"></span>
<span id="info"></span>
<span id="error"></span>
</header>
<main>
<div id="editor"></div>
<div class="splitter" id="splitter" title="drag to resize · double-click to reset"></div>
<div id="controls" class="empty"></div>
</main>
@@ -838,6 +924,55 @@ function tick() {
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
// =====================================================================
// splitter — drag the seam between editor and surface to rebalance.
// width persists in localStorage; double-click resets to default.
// =====================================================================
{
const splitter = document.getElementById('splitter');
const root = document.documentElement;
const KEY = 'cs.right-w';
const MIN_LEFT = 320;
const MIN_RIGHT = 260;
try {
const saved = localStorage.getItem(KEY);
if (saved) root.style.setProperty('--right-w', saved);
} catch {}
splitter.addEventListener('pointerdown', (e) => {
e.preventDefault();
splitter.setPointerCapture(e.pointerId);
splitter.classList.add('dragging');
document.body.style.cursor = 'col-resize';
const onMove = (ev) => {
const winW = root.clientWidth;
const want = winW - ev.clientX;
const w = Math.max(MIN_RIGHT, Math.min(winW - MIN_LEFT, want));
root.style.setProperty('--right-w', w + 'px');
};
const onUp = () => {
splitter.classList.remove('dragging');
document.body.style.cursor = '';
splitter.removeEventListener('pointermove', onMove);
splitter.removeEventListener('pointerup', onUp);
splitter.removeEventListener('pointercancel', onUp);
try {
const w = getComputedStyle(root).getPropertyValue('--right-w').trim();
if (w) localStorage.setItem(KEY, w);
} catch {}
};
splitter.addEventListener('pointermove', onMove);
splitter.addEventListener('pointerup', onUp);
splitter.addEventListener('pointercancel', onUp);
});
splitter.addEventListener('dblclick', () => {
root.style.removeProperty('--right-w');
try { localStorage.removeItem(KEY); } catch {}
});
}
</script>
</body>
</html>