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:
parent
924ac9daac
commit
1cce655a6a
3 changed files with 126 additions and 76 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue