training/dashboard(theme): continuous harmony · count + spread sliders

Replaces the discrete harmony dropdown with two continuous sliders
that together cover every named scheme:

  - count   (1..6) — number of palette colors
  - spread  (0..300°) — total angular range across the palette

Offsets fan symmetrically from the primary, so n=3 spread=60 →
[0, +30, -30] and n=4 spread=270 → [0, +90, -90, +180]. Common
named harmonies fall out as specific (count, spread) values:

   mono                   count=1, any spread
   complementary          count=2, spread=180
   analogous              count=3-5, spread<=60
   split-complementary    count=3, spread~180-210
   triadic                count=3, spread=240
   tetradic / square      count=4, spread=270

A small hint line under the sliders shows the matched harmony name
when (count, spread) lands in a recognized neighborhood, or "custom"
otherwise. Per-marker drags continue to work on top of the
continuous setting; touching the count or spread slider regenerates
to the symmetric distribution (i.e. drags are reset on slider use,
which is the natural "clean me up" action).

Migration: any prior localStorage state with the old discrete
`harmony` field or multiplicative `spread` is silently dropped at
boot and replaced with the new defaults (count=3, spread=60).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Max Gorog 2026-05-07 22:40:43 -05:00
parent 924ac9daac
commit 1cce655a6a
3 changed files with 126 additions and 76 deletions

View file

@ -177,6 +177,12 @@ body[data-theme="laser"] .bg-laser { display: block; }
to { transform: rotate(360deg); }
}
/* Continuous-harmony hint line */
.theme-harmony-hint {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; color: var(--fg-mute); padding: 2px 0;
}
/* Theme-panel advanced section accordion */
.theme-advanced { border-top: 1px solid var(--line); padding-top: 6px; }
.theme-advanced > summary {

View file

@ -237,55 +237,74 @@
}
// ────────────────────────────────────────────────────────────────
// 3.5. Theme machinery (OKLCH palette + harmony + 5 backgrounds)
// 3.5. Theme machinery (OKLCH palette · continuous harmony · 5 BGs)
// ────────────────────────────────────────────────────────────────
// Palette is derived from a base OKLCH color (L, C, H) plus a list
// of angular OFFSETS that come from a harmony preset but can be
// individually customized by dragging non-primary markers. The
// "spread" multiplier scales all offsets uniformly. L/C variance
// ladders give per-color lightness/chroma variation.
// Harmony is parameterized continuously by `count` (1..6 colors)
// and `spread` (0..300° angular range). Together they cover every
// named scheme: mono = count 1; complementary = count 2 spread 180;
// analogous = count 3-5 with low spread; triadic = count 3 spread
// 240; tetradic = count 4 spread 270; etc. Markers fan symmetrically
// around the primary so a 60° spread with 3 colors is [0, +30, -30].
// Any marker can be dragged individually to break the symmetry.
//
// Animations across the bg-canvas read --anim-speed from CSS;
// global blur reads --bg-blur. All theme state persists in
// localStorage.
// Animations across the bg-canvas read --anim-speed; the bg blur
// reads --bg-blur. State persists in localStorage.
(function () {
const HARMONIES = {
mono: [0],
analogous: [-30, 0, 30],
complementary: [0, 180],
triadic: [0, 120, 240],
tetradic: [0, 90, 180, 270],
'split-comp': [0, 150, 210],
};
const DEFAULTS = {
background: 'black',
L: 70, C: 0.15, H: 250,
harmony: 'analogous',
offsets: HARMONIES.analogous.slice(),
spread: 1.0,
lVar: 0, // L variance amplitude (0-40)
cVar: 0, // C variance amplitude (0-0.15)
count: 3,
spread: 60,
offsets: null, // populated by regenerate() at boot
lVar: 0,
cVar: 0,
animSpeed: 1.0,
bgBlur: 0, // px
tint: 0.10, // strength of the palette-tinted vignette
bgBlur: 0,
tint: 0.10,
};
const state = Object.assign({}, DEFAULTS);
// Even, symmetric distribution: primary at 0, siblings fan
// alternately to +/- around it. e.g. n=4 spread=270 → [0, 90,
// -90, 180] (which is tetradic, just stated symmetrically).
function evenOffsets(n, spread) {
const r = [0];
if (n <= 1) return r;
const step = spread / (n - 1);
for (let i = 1; i < n; i++) {
const sign = i % 2 === 1 ? 1 : -1;
const k = Math.ceil(i / 2);
r.push(sign * k * step);
}
return r;
}
try {
const stored = JSON.parse(localStorage.getItem('cis490-theme') || '{}');
// Migrate older state shapes (had `harmony` + multiplicative
// spread). Drop those fields silently.
delete stored.harmony;
Object.assign(state, stored);
if (!Array.isArray(state.offsets) || !state.offsets.length) {
state.offsets = HARMONIES[state.harmony || 'analogous'].slice();
if (!Array.isArray(state.offsets) || state.offsets.length !== state.count) {
state.offsets = evenOffsets(state.count, state.spread);
}
// Coerce spread back to angular if it looks like a stale multiplier.
if (typeof state.spread === 'number' && state.spread <= 5) {
state.spread = 60;
state.offsets = evenOffsets(state.count, state.spread);
}
} catch {}
if (!state.offsets) state.offsets = evenOffsets(state.count, state.spread);
const $ = id => document.getElementById(id);
const els = {
bg: $('theme-bg'), harmony: $('theme-harmony'),
bg: $('theme-bg'),
L: $('theme-l'), Lv: $('theme-l-val'),
C: $('theme-c'), Cv: $('theme-c-val'),
H: $('theme-h'), Hv: $('theme-h-val'),
count: $('theme-count'), countV: $('theme-count-val'),
spread: $('theme-spread'), spreadV: $('theme-spread-val'),
hint: $('theme-harmony-hint'),
lvar: $('theme-lvar'), lvarV: $('theme-lvar-val'),
cvar: $('theme-cvar'), cvarV: $('theme-cvar-val'),
speed: $('theme-speed'), speedV: $('theme-speed-val'),
@ -297,11 +316,29 @@
close: $('theme-close'), reset: $('theme-reset'),
};
// Best-effort name for the current (count, spread) combination —
// labels purely informational, not used for state.
function harmonyLabel() {
const n = state.count, s = state.spread;
if (n === 1) return 'mono';
if (n === 2) {
if (s >= 170 && s <= 190) return 'complementary';
if (s < 60) return 'split-mono';
return null;
}
if (n === 3) {
if (s <= 60) return 'analogous';
if (s >= 230 && s <= 250) return 'triadic';
if (s >= 170 && s <= 210) return 'split-complementary';
}
if (n === 4 && s >= 260 && s <= 280) return 'tetradic';
return null;
}
// Compute one palette color (OKLCH triple) for index i.
function colorAt(i) {
const off = (state.offsets[i] || 0) * state.spread;
const off = state.offsets[i] || 0;
const h = (state.H + off + 360) % 360;
// Alternating ladder so non-primary colors get +/- variance.
const sign = i === 0 ? 0 : (i % 2 === 1 ? 1 : -1);
const L = Math.max(5, Math.min(98, state.L + sign * state.lVar));
const C = Math.max(0, state.C + sign * state.cVar);
@ -330,17 +367,20 @@
function applyForm() {
els.bg.value = state.background;
els.harmony.value = state.harmony;
els.L.value = state.L; els.Lv.textContent = state.L;
els.C.value = state.C; els.Cv.textContent = state.C.toFixed(3);
els.H.value = state.H; els.Hv.textContent = Math.round(state.H);
els.spread.value = state.spread; els.spreadV.textContent = state.spread.toFixed(2);
els.lvar.value = state.lVar; els.lvarV.textContent = state.lVar;
els.cvar.value = state.cVar; els.cvarV.textContent = state.cVar.toFixed(3);
els.L.value = state.L; els.Lv.textContent = state.L;
els.C.value = state.C; els.Cv.textContent = state.C.toFixed(3);
els.H.value = state.H; els.Hv.textContent = Math.round(state.H);
els.count.value = state.count; els.countV.textContent = state.count;
els.spread.value = state.spread; els.spreadV.textContent = Math.round(state.spread);
els.lvar.value = state.lVar; els.lvarV.textContent = state.lVar;
els.cvar.value = state.cVar; els.cvarV.textContent = state.cVar.toFixed(3);
els.speed.value = state.animSpeed; els.speedV.textContent = state.animSpeed.toFixed(2);
els.blur.value = state.bgBlur; els.blurV.textContent = state.bgBlur;
els.tint.value = state.tint; els.tintV.textContent = state.tint.toFixed(2);
const label = harmonyLabel();
els.hint.textContent = label ? `${label}` : '· custom';
const primary = colorAt(0);
els.meta.textContent = ok(primary);
@ -430,15 +470,12 @@
if (dragIsPrimary) {
state.H = (angle + 360) % 360;
} else {
// Marker's hue should equal `angle`; its hue is H + offset*spread.
// → offset = (angle - H) / spread, clamped to a sane range.
const target = (angle - state.H + 540) % 360 - 180; // -180..180
const newOffset = state.spread !== 0 ? target / state.spread : target;
state.offsets[dragIdx] = newOffset;
// Mark the harmony as custom by clearing the dropdown match
// — easiest is to leave it and let the saved offsets win.
// Marker's absolute hue should equal `angle`; its hue is
// (H + offset). So offset = angle - H, normalized to -180..180.
const target = (angle - state.H + 540) % 360 - 180;
state.offsets[dragIdx] = target;
}
apply(); // in-place marker update; doesn't destroy the dragged element
apply();
}
function endDrag(e) {
if (!dragMarker) return;
@ -468,26 +505,39 @@
els.close.addEventListener('click', e => { e.stopPropagation(); togglePanel(false); });
els.reset.addEventListener('click', e => {
e.stopPropagation();
Object.assign(state, DEFAULTS, { offsets: HARMONIES.analogous.slice() });
Object.assign(state, DEFAULTS);
state.offsets = evenOffsets(state.count, state.spread);
apply({ rebuildMarkers: true });
});
els.bg.addEventListener('change', e => { state.background = e.target.value; apply(); });
els.harmony.addEventListener('change', e => {
state.harmony = e.target.value;
state.offsets = HARMONIES[state.harmony].slice();
// count + spread together regenerate offsets to an evenly-fanned
// distribution. Any prior per-marker drags are overwritten —
// the user is asking for a clean palette by touching these.
function regenAndApply() {
state.offsets = evenOffsets(state.count, state.spread);
apply({ rebuildMarkers: true });
}
els.count.addEventListener('input', e => {
state.count = Math.max(1, Math.min(6, parseInt(e.target.value, 10)));
regenAndApply();
});
const bind = (input, setter) => input.addEventListener('input', e => { setter(parseFloat(e.target.value)); apply(); });
bind(els.L, v => state.L = v);
bind(els.C, v => state.C = v);
bind(els.H, v => state.H = v);
bind(els.spread, v => state.spread = v);
bind(els.lvar, v => state.lVar = v);
bind(els.cvar, v => state.cVar = v);
bind(els.speed, v => state.animSpeed = v);
bind(els.blur, v => state.bgBlur = v);
bind(els.tint, v => state.tint = v);
els.spread.addEventListener('input', e => {
state.spread = Math.max(0, Math.min(300, parseFloat(e.target.value)));
regenAndApply();
});
const bind = (input, setter) => input.addEventListener('input',
e => { setter(parseFloat(e.target.value)); apply(); });
bind(els.L, v => state.L = v);
bind(els.C, v => state.C = v);
bind(els.H, v => state.H = v);
bind(els.lvar, v => state.lVar = v);
bind(els.cvar, v => state.cVar = v);
bind(els.speed, v => state.animSpeed = v);
bind(els.blur, v => state.bgBlur = v);
bind(els.tint, v => state.tint = v);
// Stop panel interactions from bubbling to stage-col click-to-advance.
els.panel.addEventListener('click', e => e.stopPropagation());

View file

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CIS490 — live</title>
<link rel="stylesheet" href="/static/dashboard.css?v=cad0ab54">
<link rel="stylesheet" href="/static/dashboard.css?v=d28553bc">
</head>
<body>
<!-- SVG filter defs for the lava-lamp goo effect. Width/height 0
@ -76,18 +76,6 @@
</select>
</label>
<label class="theme-row">
<span>harmony</span>
<select id="theme-harmony">
<option value="mono">mono</option>
<option value="analogous">analogous (3 × 30°)</option>
<option value="complementary">complementary</option>
<option value="triadic">triadic (3 × 120°)</option>
<option value="tetradic">tetradic / square</option>
<option value="split-comp">split-complement</option>
</select>
</label>
<div class="theme-wheel-block">
<div class="theme-wheel" id="theme-wheel">
<div class="wheel-disc"></div>
@ -104,11 +92,17 @@
</div>
</div>
<div class="theme-sliders theme-harmony-block">
<label>colors · count · <span id="theme-count-val">3</span>
<input type="range" id="theme-count" min="1" max="6" value="3" step="1"></label>
<label>spread · angular range · <span id="theme-spread-val">60</span>°
<input type="range" id="theme-spread" min="0" max="300" value="60" step="1"></label>
<div class="theme-harmony-hint" id="theme-harmony-hint"></div>
</div>
<details class="theme-advanced">
<summary>advanced — palette ladder &amp; spread</summary>
<summary>advanced — palette ladder</summary>
<div class="theme-sliders">
<label>spread · scale harmony offsets · <span id="theme-spread-val">1.00</span>×
<input type="range" id="theme-spread" min="0.2" max="2" value="1" step="0.05"></label>
<label>L variance · per-color lightness ladder · <span id="theme-lvar-val">0</span>
<input type="range" id="theme-lvar" min="0" max="40" value="0" step="1"></label>
<label>C variance · per-color chroma ladder · <span id="theme-cvar-val">0.00</span>
@ -489,6 +483,6 @@
</article>
</div>
<script src="/static/dashboard.js?v=c42321c9"></script>
<script src="/static/dashboard.js?v=a8db3285"></script>
</body>
</html>