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

112
docs/roadmap.md Normal file
View File

@@ -0,0 +1,112 @@
# roadmap — code-sinth MVP → product
The MVP works end-to-end: parser, audio engine (Python + JS/AudioWorklet),
hot-reload, inline waveforms, knobs, faders, step sequencer, piano roll.
Now we polish the web app into something that looks and feels like an
instrument, not a developer demo.
## Vision
A single static web page where:
- The patch language is the source of truth for **what's connected**.
- The control surface (right pane) is the source of truth for **how you play it**:
positions, layout, color, scrubbing.
- Everything you change in either place persists across reloads (patch in
whatever editor you use; surface state in `localStorage` indexed by patch).
- The aesthetic sits between Reaktor 6, Teenage Engineering OP-1, and a
modern terminal — dark base, phosphor accents, no skeuomorphism.
## Tracks
Four tracks. Each is independently shippable; the order below reflects
"unblocks the most other work first".
### Track 1 — Foundation polish · status: in progress
Concrete bricks the rest of the work sits on.
- [ ] **Resizable splitter** between editor and control surface. Drag the
vertical seam to rebalance widths. `localStorage` persistence.
Min widths so neither pane collapses.
- [ ] **Custom scrollbars** (WebKit `::-webkit-scrollbar` + Firefox
`scrollbar-color`) — thin, dark, accent on hover. Applied globally
so CodeMirror's `.cm-scroller`, the right pane, the piano roll
and step seq overflow inherit the same style.
- [ ] **Focus / hover micro-states**. Buttons, inputs, controls all get
consistent `transition` timings and `:focus-visible` rings.
- [ ] **Header polish**. The top bar gets a proper visual hierarchy,
grouped controls (transport, gain, status) instead of a flex blob.
Done when: dragging the seam feels natural, scrollbars match the rest
of the dark theme everywhere, no default-browser styling leaks through.
### Track 2 — Surface aesthetics
Rebuild each surface to feel premium. For each, deliver 23 visual
variants in a sandbox before committing.
- [ ] **Knob**: dimensional dial with gradient + accent ring, micro-anim
on change, label in small caps, value display below.
- [ ] **Fader**: gradient track, slimmer cap, optional scale marks.
- [ ] **Step sequencer**: cells with subtle inner glow when on, more
prominent beat dividers, animated playhead trail.
- [ ] **Piano roll**: more realistic key strip (white keys with border,
black keys inset), cells with rounded corners, beat dividers every
4 columns more visible, octave C row marked.
- [ ] **Wave widgets** (inline): grid lines optional, peak indicator,
smoother line drawing (anti-aliased Bezier).
Reference: Ableton Live's macros, Bitwig modulators, Reaktor 6 Blocks,
Teenage Engineering's hardware.
### Track 3 — Organizable surface panel
Each surface becomes a **card** the user can rearrange and customize.
This is the largest track — needs careful state design.
- [ ] **Card chrome**: header with node name, drag handle, color
override picker, compact/expanded toggle.
- [ ] **Drag-drop reorder** within the right pane. Probable approach:
CSS Grid with grid-auto-flow + a Sortable-style implementation
(we won't pull in a library — ~150 lines hand-rolled).
- [ ] **Per-card accent color** override. Defaults to the global
`--accent`; user can pick per-surface.
- [ ] **Per-card size** (compact knob row vs full panel) where it makes
sense.
- [ ] **Persistence**: layout state in `localStorage`, keyed by a hash
of the patch text + node name so each patch remembers its layout.
- [ ] **Reset layout** button.
Decision deferred: should the layout be expressible in the patch
language as `@layout` directives? Pro: shareable, reproducible.
Con: muddies the language. Default answer for now: no, layout is
UI-only state. Add export/import to a JSON blob if shareability
matters later.
### Track 4 — Theming
The current single-file approach already uses CSS custom properties.
Make it a real theme system.
- [ ] Theme tokens consolidated and named coherently
(`--surface-1`, `--surface-2`, `--accent-primary`, `--accent-mute`).
- [ ] 3 packaged themes: **phosphor** (current), **monochrome**, **warm**.
- [ ] Theme picker in the header. Persists in `localStorage`.
- [ ] Per-card override (Track 3) layered on top.
## Out of scope (for now)
- MIDI input. Web MIDI works but not on Safari and the UX needs design.
- Mobile / touch optimization. Worth a pass later but not in this round.
- A landing / docs page. After the app itself feels finished.
- Patch-language enhancements (string literals, named voices in poly,
modulation matrices). Engine-track work, separate roadmap.
## Working agreement
- One track at a time, but tracks are paralleliable when ergonomic.
- Each track ends with a screenshot review before merging the changes
into the main `web/index.html`.
- For visually-driven changes, propose 23 variants and pick before
committing.

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>