Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
ignores techniques), and `split_hands` doesn't yet respect per-note
overrides — both land with the hand-aware lift slice.

- **View ▸ Canvas appearance… — customizable grid & canvas** (community ask:
beat grid lines were invisible on some screens; the request was Live-style
customization, not just a brighter constant). Four live sliders — **Grid
lines** (strength of beat/measure lines and lane separators, 25–400%),
**Brightness**, **Color intensity**, and **Hue** — applied to the canvas's
STRUCTURAL palette only: string colors, drum piece colors, note,
selection, and playhead colors never restyle. Settings are a global editor
preference (`editorCanvasAppearance`); adjusted colors are cached per
settings generation so draw cost is a map lookup. New `src/canvas-appearance.js`
palette module; lanes/grid/gutters (draw.js), drum lanes, and the parts
overview now read from it.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **Brighter default grid** — beat lines were `#16162c` at 0.5 px (the
invisible-grid report); the defaults are now `#20203e` at 1 px for beats and
`#32325c` for measures, with the slider scaling from there.

### Changed

- **The mixer is now a proper DAW console.** The docked side panel is
Expand Down
14 changes: 14 additions & 0 deletions docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ notes) — useful for lining a grid up fast.
Press **`?`** at any time for the searchable shortcut panel, or **`Ctrl+K`** for
the command palette.

### Canvas appearance

If the beat grid is hard to see on your screen — or you just want the canvas
your way — open **View ▸ Canvas appearance…** and adjust:

- **Grid lines** — strength of the beat/measure lines and lane separators.
Turn this up if beat lines are hard to see.
- **Brightness** — overall canvas background lift.
- **Color intensity** and **Hue** — restyle the canvas palette.

Changes apply live, and only to the canvas structure — string colors, note
colors, selection, and the playhead always keep their meaning. **Reset to
defaults** puts everything back.

---

## 3. Play and navigate
Expand Down
62 changes: 62 additions & 0 deletions screen.html
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,68 @@
</div>
</div>

<!-- Canvas appearance dialog (View ▸ Canvas appearance…) — Ableton-parity
grid/canvas customization: grid strength, brightness, intensity, hue.
Live-updating; the canvas is its own preview. -->
<div id="editor-canvas-appearance-modal" class="hidden absolute inset-0 z-50 flex items-center justify-center" style="background:rgba(0,0,0,0.7)" role="dialog" aria-modal="true" aria-labelledby="editor-canvas-appearance-title">
<div class="bg-dark-700 rounded-xl border border-gray-700 w-full max-w-sm flex flex-col">
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-700">
<span id="editor-canvas-appearance-title" class="text-sm font-medium">Canvas Appearance</span>
<button onclick="editorHideCanvasAppearance()" class="text-gray-500 hover:text-white" aria-label="Close canvas appearance (Esc)" title="Close (Esc)">&times;</button>
</div>
<div class="p-4 space-y-4">
<div>
<div class="flex justify-between items-baseline mb-1">
<label for="editor-canvas-app-grid" class="text-xs font-medium text-gray-400">Grid lines</label>
<span id="editor-canvas-app-grid-val" class="text-xs text-gray-500">100%</span>
</div>
<input type="range" id="editor-canvas-app-grid" min="25" max="400" step="5" value="100"
class="w-full" oninput="editorCanvasAppearanceInput('grid', this.value)"
title="Strength of grid lines and lane separators — raise it if beat lines are hard to see">
</div>
<div>
<div class="flex justify-between items-baseline mb-1">
<label for="editor-canvas-app-brightness" class="text-xs font-medium text-gray-400">Brightness</label>
<span id="editor-canvas-app-brightness-val" class="text-xs text-gray-500">100%</span>
</div>
<input type="range" id="editor-canvas-app-brightness" min="50" max="250" step="5" value="100"
class="w-full" oninput="editorCanvasAppearanceInput('brightness', this.value)"
title="Overall canvas background brightness">
</div>
<div>
<div class="flex justify-between items-baseline mb-1">
<label for="editor-canvas-app-intensity" class="text-xs font-medium text-gray-400">Color intensity</label>
<span id="editor-canvas-app-intensity-val" class="text-xs text-gray-500">100%</span>
</div>
<input type="range" id="editor-canvas-app-intensity" min="0" max="200" step="5" value="100"
class="w-full" oninput="editorCanvasAppearanceInput('intensity', this.value)"
title="Color saturation of the canvas palette (0 = grayscale)">
</div>
<div>
<div class="flex justify-between items-baseline mb-1">
<label for="editor-canvas-app-hue" class="text-xs font-medium text-gray-400">Hue</label>
<span id="editor-canvas-app-hue-val" class="text-xs text-gray-500">0°</span>
</div>
<input type="range" id="editor-canvas-app-hue" min="-180" max="180" step="5" value="0"
class="w-full" oninput="editorCanvasAppearanceInput('hue', this.value)"
title="Hue rotation of the canvas palette (structural colors only — string, note and drum-piece colors keep their meaning)">
</div>
<p class="text-[11px] text-gray-600">Applies to the canvas structure only — string, note,
selection and playhead colors never change.</p>
<div class="flex gap-2">
<button onclick="editorCanvasAppearanceReset()"
class="flex-1 px-4 py-2 bg-dark-600 hover:bg-dark-500 rounded-lg text-sm font-medium">
Reset to defaults
</button>
<button onclick="editorHideCanvasAppearance()"
class="flex-1 px-4 py-2 bg-accent hover:bg-accent-light rounded-lg text-sm font-medium">
Done
</button>
</div>
</div>
</div>
</div>

<!-- Sync tempo dialog -->
<div id="editor-sync-dialog" class="hidden absolute z-50 bg-dark-700 border border-gray-700 rounded-lg shadow-xl p-4 w-72">
<div class="text-xs font-medium text-gray-300 mb-3">Sync Tempo</div>
Expand Down
250 changes: 250 additions & 0 deletions src/canvas-appearance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
// Canvas appearance — user-customizable grid/canvas palette (View ▸ Canvas
// appearance…). The DAW-parity model is Ableton Live 12's Theme ▸
// Customization tab: grid-line opacity, brightness, color intensity, hue —
// applied here to the timeline canvas's STRUCTURAL colors only (lane
// backgrounds, separators, grid lines, gutters). Identity-bearing colors are
// deliberately out of scope: string/lane colors, drum piece colors, note and
// selection/playhead colors all carry meaning and never restyle.
//
// Community origin: an EOF charter couldn't see the 2/3/4 beat grid lines at
// all on the default palette; an Ableton user asked for Live-style grid
// customization rather than a one-off brighter constant.
//
// Settings are a GLOBAL editor preference (localStorage, like editorTheme —
// the chrome theme deliberately leaves the canvas alone; this is the canvas
// counterpart). Draw code reads colors via `CP(name)`; adjusted colors are
// cached per settings generation, so the per-frame cost is a map lookup.

import { host } from './host.js';

/* @pure:canvas-appearance:start */
// Slider ranges. 100 = the shipped default palette. Grid strength runs past
// 100 so a charter can push faint subdivision lines all the way to obvious.
export const CANVAS_APPEARANCE_DEFAULTS = Object.freeze({
grid: 100, // grid/separator line strength, 25..400 (%)
brightness: 100, // canvas background lift, 50..250 (%)
intensity: 100, // color saturation, 0..200 (%)
hue: 0, // hue rotation, -180..180 (degrees)
});
const _RANGES = {
grid: [25, 400], brightness: [50, 250], intensity: [0, 200], hue: [-180, 180],
};

// Clamp one field to its range; junk (NaN, strings, missing) falls back to
// the default so a hand-edited localStorage blob can't produce an invisible
// or blinding canvas.
export function _appearanceFieldPure(field, value) {
const def = CANVAS_APPEARANCE_DEFAULTS[field];
if (def === undefined) return undefined;
// Number(null) and Number('') are both 0 (→ clamped to the min, not the
// default), so reject null and blank/whitespace-only strings before coercion.
if (value === null) return def;
if (typeof value === 'string' && value.trim() === '') return def;
const n = Number(value);
if (!Number.isFinite(n)) return def;
const [lo, hi] = _RANGES[field];
return Math.min(hi, Math.max(lo, Math.round(n)));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export function _sanitizeAppearancePure(raw) {
const src = (raw && typeof raw === 'object') ? raw : {};
const out = {};
for (const k of Object.keys(CANVAS_APPEARANCE_DEFAULTS)) {
out[k] = _appearanceFieldPure(k, src[k]);
}
return out;
}

// #rgb / #rrggbb → {h: 0..360, s: 0..1, l: 0..1}, null on junk.
export function _hexToHslPure(hex) {
if (typeof hex !== 'string') return null;
let m = hex.trim();
if (/^#[0-9a-fA-F]{3}$/.test(m)) {
m = '#' + m[1] + m[1] + m[2] + m[2] + m[3] + m[3];
}
if (!/^#[0-9a-fA-F]{6}$/.test(m)) return null;
const r = parseInt(m.slice(1, 3), 16) / 255;
const g = parseInt(m.slice(3, 5), 16) / 255;
const b = parseInt(m.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
else if (max === g) h = ((b - r) / d + 2) * 60;
else h = ((r - g) / d + 4) * 60;
return { h, s, l };
}

export function _hslToHexPure(h, s, l) {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const hh = ((h % 360) + 360) % 360 / 360;
let r, g, b;
if (s === 0) { r = g = b = l; }
else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, hh + 1 / 3);
g = hue2rgb(p, q, hh);
b = hue2rgb(p, q, hh - 1 / 3);
}
const c = (v) => Math.round(Math.min(1, Math.max(0, v)) * 255)
.toString(16).padStart(2, '0');
return '#' + c(r) + c(g) + c(b);
}

// One structural color through the appearance settings. Grid-bucket entries
// (grid lines, separators) additionally take the grid-strength multiplier on
// lightness — the "can't see the beat lines" lever. Junk hex passes through
// unchanged so a bad palette entry degrades to the old look, never a crash.
export function _adjustHexPure(hex, settings, isGrid) {
const hsl = _hexToHslPure(hex);
if (!hsl) return hex;
const s = _sanitizeAppearancePure(settings);
let { h, s: sat, l } = hsl;
h += s.hue;
sat = Math.min(1, sat * (s.intensity / 100));
l *= (s.brightness / 100);
if (isGrid) l *= (s.grid / 100);
l = Math.min(0.92, Math.max(0, l)); // never clip to pure white
return _hslToHexPure(h, sat, l);
}
/* @pure:canvas-appearance:end */

// ── The structural canvas palette (base = the shipped defaults) ──────
// gridBeat/gridMeasure are deliberately brighter than the pre-appearance
// constants (#16162c/#2a2a50): the old beat lines were invisible on some
// panels (the community report), and 100% should be visible out of the box.
const _BASE = {
laneEven: '#0c0c1c',
laneOdd: '#0f0f24',
laneSep: '#1a1a35',
pianoRowBlack: '#0a0a1a',
pianoRowWhite: '#0e0e22',
octaveLine: '#2a2a55',
gridBeat: '#20203e',
gridMeasure: '#32325c',
gutterBg: '#0a0a1a',
partsLaneArmed: '#141432',
partsHeader: '#101024',
partsHeaderArmed: '#191945',
partsGutterLine: '#22224a',
partsDownbeat: '#1a1a2e',
};
const _GRID_KEYS = new Set([
'gridBeat', 'gridMeasure', 'laneSep', 'octaveLine',
'partsGutterLine', 'partsDownbeat',
]);

const _LS_KEY = 'editorCanvasAppearance';
let _settings = null; // lazily loaded, sanitized
let _gen = 0; // bumped on every change → invalidates the color cache
let _cache = { gen: -1, colors: Object.create(null) };

function _load() {
if (_settings) return _settings;
let raw = null;
try { raw = JSON.parse(localStorage.getItem(_LS_KEY) || 'null'); }
catch (_) { /* ignore */ }
_settings = _sanitizeAppearancePure(raw);
return _settings;
}

function _save() {
try { localStorage.setItem(_LS_KEY, JSON.stringify(_settings)); }
catch (_) { /* ignore */ }
}

export function canvasAppearance() {
return { ..._load() };
}

export function setCanvasAppearance(field, value) {
const s = _load();
const v = _appearanceFieldPure(field, value);
if (v === undefined || s[field] === v) return;
s[field] = v;
_gen++;
_save();
}

export function resetCanvasAppearance() {
_settings = { ...CANVAS_APPEARANCE_DEFAULTS };
_gen++;
_save();
}

// The palette accessor draw code uses. Cached per settings generation —
// steady-state cost is one map lookup per call, no per-frame color math
// (the rAF draw-coalesce rule).
export function CP(name) {
if (_cache.gen !== _gen) _cache = { gen: _gen, colors: Object.create(null) };
const hit = _cache.colors[name];
if (hit) return hit;
const base = _BASE[name];
if (!base) return '#000000';
const c = _adjustHexPure(base, _load(), _GRID_KEYS.has(name));
_cache.colors[name] = c;
return c;
}

// ── Dialog (View ▸ Canvas appearance…) ───────────────────────────────
// Markup lives in screen.html (#editor-canvas-appearance-modal); handlers are
// window-globals like every other modal. Inputs update live so the canvas is
// its own preview. The whole block is window-guarded so the module imports
// cleanly under node (the unit-test environment) — same degrade-don't-crash
// contract as host.js.

function _syncDialog() {
const s = _load();
for (const k of Object.keys(CANVAS_APPEARANCE_DEFAULTS)) {
const input = document.getElementById('editor-canvas-app-' + k);
const label = document.getElementById('editor-canvas-app-' + k + '-val');
if (input) input.value = String(s[k]);
if (label) label.textContent = k === 'hue' ? s[k] + '°' : s[k] + '%';
}
}

let _prevFocus = null; // element to restore focus to on close

if (typeof window !== 'undefined') {
window.editorShowCanvasAppearance = function () {
const modal = document.getElementById('editor-canvas-appearance-modal');
if (!modal) return;
_syncDialog();
modal.classList.remove('hidden');
// Move focus into the dialog (first slider) and remember who had it.
_prevFocus = document.activeElement;
document.getElementById('editor-canvas-app-grid')?.focus();
};

window.editorHideCanvasAppearance = function () {
const modal = document.getElementById('editor-canvas-appearance-modal');
if (modal) modal.classList.add('hidden');
// Hand the keyboard back to the control that opened the dialog.
const prev = _prevFocus;
_prevFocus = null;
if (prev && prev.isConnected && prev.focus) prev.focus();
};

window.editorCanvasAppearanceInput = function (field, value) {
setCanvasAppearance(field, value);
_syncDialog();
host.draw();
};

window.editorCanvasAppearanceReset = function () {
resetCanvasAppearance();
_syncDialog();
host.draw();
};
}
Loading
Loading