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:
112
docs/roadmap.md
Normal file
112
docs/roadmap.md
Normal 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 2–3 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 2–3 variants and pick before
|
||||
committing.
|
||||
157
web/index.html
157
web/index.html
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user