From 3754878ef96a4435d8e9003568b807fc1c25de97 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 21 Sep 2026 08:29:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Grace=20tab=20=E2=80=94=2016=20grad?= =?UTF-8?q?ient=20palettes=20=C3=97=2014=20slow=20rotating=20looks;=20inne?= =?UTF-8?q?r=20four=20of=20grace-28=20read=20as=20centre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/__tests__/grace-rings.test.ts | 139 +++++++++++++- packages/ui/src/components/grace-tab.tsx | 65 ++++++- packages/ui/src/lib/grace-rings.ts | 212 +++++++++++++++++++++- 3 files changed, 399 insertions(+), 17 deletions(-) diff --git a/packages/ui/__tests__/grace-rings.test.ts b/packages/ui/__tests__/grace-rings.test.ts index e96443b..df9223a 100644 --- a/packages/ui/__tests__/grace-rings.test.ts +++ b/packages/ui/__tests__/grace-rings.test.ts @@ -1,6 +1,15 @@ import { resolveLayout } from '@wavegrid/layout'; -import { graceMotion, graceStills, hsbCss, type Look, PAIRS } from '../src/lib/grace-rings'; +import { + graceGradients, + graceMotion, + graceStills, + gradientCss, + GRADIENTS, + hsbCss, + type Look, + PAIRS +} from '../src/lib/grace-rings'; interface Cell { h: number; @@ -9,14 +18,15 @@ interface Cell { } const GRACE = resolveLayout({ preset: 'grace-cathedral' }); +const GRACE28 = resolveLayout({ preset: 'grace-28' }); const AMBER = PAIRS[0]; const CHAPEL = PAIRS.find(p => p.name === 'Chapel')!; /** A stand-in for the receiver's pattern ctx, over the real Grace geometry. */ -function run(code: string, t = 0): Cell[] { - const cells: Cell[] = GRACE.fixtures.map(() => ({ h: -1, s: -1, b: -1 })); +function run(code: string, t = 0, layout = GRACE): Cell[] { + const cells: Cell[] = layout.fixtures.map(() => ({ h: -1, s: -1, b: -1 })); const ctx = { - count: GRACE.count, + count: layout.count, cols: 0, rows: 0, t, @@ -29,15 +39,15 @@ function run(code: string, t = 0): Cell[] { return [c.h, c.s, c.b]; }, polar(i: number): [number, number] { - const f = GRACE.fixtures[i]; + const f = layout.fixtures[i]; return [f.radius, f.angle]; }, xy(i: number): [number, number] { - const f = GRACE.fixtures[i]; + const f = layout.fixtures[i]; return [f.x, f.y]; }, uv(i: number): [number, number] { - const f = GRACE.fixtures[i]; + const f = layout.fixtures[i]; return [f.u, f.v]; } }; @@ -247,6 +257,121 @@ describe('ring dynamics', () => { }); }); +describe('grace-28 (inner ring of four)', () => { + const INNER4 = GRACE28.fixtures.filter(f => f.radius < 0.45).map(f => f.index); + + it('has 12 + 12 + 4', () => { + expect(INNER4).toEqual([24, 25, 26, 27]); + }); + + it('treats the inner four as the centre in pair looks', () => { + const cells = run(get('Core'), 0, GRACE28); + for (const i of INNER4) expect(cells[i].b).toBe(100); + expect(cells[12].b).toBe(75); + expect(cells[0].b).toBe(22); + }); + + it('every pair look writes all 28 fixtures in range', () => { + for (const l of [...graceStills(CHAPEL), ...graceMotion(CHAPEL)]) { + const cells = run(l.code, 0.7, GRACE28); + expect(cells).toHaveLength(28); + for (const c of cells) { + expect(c.b).toBeGreaterThanOrEqual(0); + expect(c.b).toBeLessThanOrEqual(100); + } + } + }); +}); + +describe('grace gradients', () => { + const DAWN = GRADIENTS[0]; + const all = graceGradients(DAWN); + const gradientLook = (name: string) => { + const l = all.find(x => x.name === name); + if (!l) throw new Error(`no gradient look ${name}`); + return l.code; + }; + + it('offers many palettes and looks', () => { + expect(GRADIENTS.length).toBeGreaterThanOrEqual(12); + expect(all.length).toBeGreaterThanOrEqual(12); + expect(new Set(all.map(l => l.name)).size).toBe(all.length); + }); + + it.each(GRADIENTS.map(g => [g.name] as const))('%s: every look writes every fixture on 25 and 28', (name) => { + const g = GRADIENTS.find(x => x.name === name)!; + for (const l of graceGradients(g)) { + for (const layout of [GRACE, GRACE28]) { + for (const t of [0, 13.7, 61.2]) { + const cells = run(l.code, t, layout); + expect(cells).toHaveLength(layout.count); + for (const c of cells) { + expect(c.h).toBeGreaterThanOrEqual(0); + expect(c.h).toBeLessThan(360); + expect(c.s).toBeGreaterThanOrEqual(0); + expect(c.s).toBeLessThanOrEqual(100); + expect(c.b).toBeGreaterThanOrEqual(0); + expect(c.b).toBeLessThanOrEqual(100); + } + } + } + } + }); + + it('Wheel lays the gradient around the ring and turns a full lap in 60 s', () => { + const t0 = run(gradientLook('Wheel'), 0); + const hues = OUTER.map(i => t0[i].h); + expect(new Set(hues.map(h => Math.round(h))).size).toBeGreaterThan(6); + const lap = run(gradientLook('Wheel'), 60); + for (const i of OUTER) expect(lap[i].h).toBeCloseTo(t0[i].h, 3); + const quarter = run(gradientLook('Wheel'), 15); + expect(quarter[OUTER[0]].h).not.toBeCloseTo(t0[OUTER[0]].h, 0); + }); + + it('Wheel moves slowly: a second barely changes the colour', () => { + const a = run(gradientLook('Wheel'), 0)[OUTER[0]].h; + const b = run(gradientLook('Wheel'), 1)[OUTER[0]].h; + expect(Math.abs(a - b)).toBeLessThan(12); + }); + + it('Counter turns the inner ring against the outer', () => { + const t0 = run(gradientLook('Counter'), 0); + // 5 s is one twelfth of a lap: each ring's colours land on a neighbour, + // the outer ring on one side and the inner ring on the other. + const t1 = run(gradientLook('Counter'), 5); + const step = (ring: number[], from: number) => { + const j = ring.indexOf(from); + const next = ring[(j + 1) % 12]; + const prev = ring[(j + 11) % 12]; + if (Math.abs(t1[from].h - t0[next].h) < 0.01) return 1; + if (Math.abs(t1[from].h - t0[prev].h) < 0.01) return -1; + return 0; + }; + const outerDir = step(OUTER, OUTER[3]); + const innerDir = step(INNER, INNER[3]); + expect(outerDir).not.toBe(0); + expect(innerDir).toBe(-outerDir); + }); + + it('Sweep puts opposite sides of the window on opposite ends of the band', () => { + const cells = run(gradientLook('Sweep'), 0); + expect(cells[OUTER[0]].h).not.toBeCloseTo(cells[OUTER[6]].h, 0); + }); + + it('Still does not move', () => { + const a = run(gradientLook('Still'), 0); + const b = run(gradientLook('Still'), 100); + a.forEach((c, i) => expect(c.h).toBeCloseTo(b[i].h, 6)); + }); + + it('gradientCss is a conic loop back to its first stop', () => { + const css = gradientCss(DAWN); + expect(css.startsWith('conic-gradient(')).toBe(true); + expect(css).toContain('0%'); + expect(css).toContain('100%'); + }); +}); + describe('hsbCss', () => { it('maps a saturated hue and black', () => { expect(hsbCss([40, 100])).toBe('hsl(40 100% 50%)'); diff --git a/packages/ui/src/components/grace-tab.tsx b/packages/ui/src/components/grace-tab.tsx index f8aab30..1b89081 100644 --- a/packages/ui/src/components/grace-tab.tsx +++ b/packages/ui/src/components/grace-tab.tsx @@ -1,8 +1,12 @@ import { useCallback, useMemo, useState } from 'react'; import { + graceGradients, graceMotion, graceStills, + type Gradient, + gradientCss, + GRADIENTS, hsbCss, type Look, pairGradient, @@ -15,6 +19,7 @@ import { MiniGridPreview, type PreviewFixture } from './mini-grid-preview'; const STILL_PREFIX = 'grace-still'; const MOTION_PREFIX = 'grace-motion'; +const GRADIENT_PREFIX = 'grace-gradient'; /** The two rings of twelve, so a tile reads as the room even without previews. */ function PairSwatch({ pair, active, onClick }: { pair: RingPair; active: boolean; onClick: () => void }) { @@ -41,9 +46,34 @@ function PairSwatch({ pair, active, onClick }: { pair: RingPair; active: boolean ); } +/** One tile per gradient palette; the swatch is the loop itself. */ +function GradientSwatch({ gradient, active, onClick }: { gradient: Gradient; active: boolean; onClick: () => void }) { + return ( + + ); +} + function LookTile({ look, - pair, + background, active, onClick, showPreview, @@ -51,7 +81,7 @@ function LookTile({ fixtures }: { look: Look; - pair: RingPair; + background: string; active: boolean; onClick: () => void; showPreview: boolean; @@ -67,7 +97,7 @@ function LookTile({ width: tileSize, height: tileSize, borderRadius: 16, - background: showPreview ? '#0a0a12' : pairGradient(pair), + background: showPreview ? '#0a0a12' : background, border: active ? '2.5px solid #fff' : '2.5px solid transparent' }} > @@ -107,9 +137,12 @@ export function GraceTab({ const [showPreview, setShowPreview] = useState(true); const [pairName, setPairName] = useState(PAIRS[0].name); const pair = useMemo(() => PAIRS.find((p) => p.name === pairName) ?? PAIRS[0], [pairName]); + const [gradientName, setGradientName] = useState(GRADIENTS[0].name); + const gradient = useMemo(() => GRADIENTS.find((g) => g.name === gradientName) ?? GRADIENTS[0], [gradientName]); const stills = useMemo(() => graceStills(pair), [pair]); const motion = useMemo(() => graceMotion(pair), [pair]); + const gradients = useMemo(() => graceGradients(gradient), [gradient]); const pickPair = useCallback((next: RingPair) => { setPairName(next.name); @@ -121,19 +154,26 @@ export function GraceTab({ if (running) send({ type: 'evalPattern', code: running[1].code, params: {} }); }, [activePattern, send]); + const pickGradient = useCallback((next: Gradient) => { + setGradientName(next.name); + if (!activePattern) return; + const running = graceGradients(next).find((l) => activePattern === `${GRADIENT_PREFIX}-${l.name}`); + if (running) send({ type: 'evalPattern', code: running.code, params: {} }); + }, [activePattern, send]); + const handleSelect = useCallback((prefix: string, look: Look) => { onPatternSelect(`${prefix}-${look.name}`); send({ type: 'evalPattern', code: look.code, params: {} }); }, [onPatternSelect, send]); - const renderGroup = (label: string, prefix: string, looks: Look[]) => ( + const renderGroup = (label: string, prefix: string, looks: Look[], background: string) => (
{looks.map((l) => ( handleSelect(prefix, l)} showPreview={showPreview} @@ -197,8 +237,19 @@ export function GraceTab({ Inner 12
- {renderGroup('Shapes', STILL_PREFIX, stills)} - {renderGroup('Droplets & Chases', MOTION_PREFIX, motion)} + {renderGroup('Shapes', STILL_PREFIX, stills, pairGradient(pair))} + {renderGroup('Droplets & Chases', MOTION_PREFIX, motion, pairGradient(pair))} + +
+ {GRADIENTS.map((g) => ( + pickGradient(g)} /> + ))} +
+
+ Slow sweeps of blended colour — one lap about a minute at 1× +
+
+ {renderGroup('Slow Gradients', GRADIENT_PREFIX, gradients, gradientCss(gradient))} ); diff --git a/packages/ui/src/lib/grace-rings.ts b/packages/ui/src/lib/grace-rings.ts index 852121a..79bf12d 100644 --- a/packages/ui/src/lib/grace-rings.ts +++ b/packages/ui/src/lib/grace-rings.ts @@ -49,13 +49,14 @@ export function pairGradient(pair: RingPair): string { * Ring helpers every source below is compiled against. * * `ring(ctx, i)` is 1 on the outer ring, 0 on the inner one and -1 for the - * centre cannon, from the normalized radius `ctx.polar` reports (the outermost + * centre, from the normalized radius `ctx.polar` reports (the outermost * fixtures are at 1, the centre at 0). `RING_SPLIT` sits between the two rings - * of a 1 / 0.62 / 0 layout with room to spare either side. + * of twelve (1 / 0.62) and `CENTRE_MAX` below them, so both the single centre + * cannon of grace-cathedral and the inner four of grace-28 (0.25) read as centre. */ const HELPERS = ` var RING_SPLIT = 0.8; -var CENTRE_MAX = 0.15; +var CENTRE_MAX = 0.45; function ring(ctx, i) { var r = ctx.polar(i)[0]; if (r <= CENTRE_MAX) return -1; @@ -275,3 +276,208 @@ export function graceMotion(pair: RingPair): Look[] { } ]; } + +/* ───────────────────────── Gradients ───────────────────────── + * + * A second family for the room: a multi-stop colour gradient laid over the + * geometry and turned very slowly. Where the pair looks give each ring one + * colour, these give every fixture its own point on a blended sweep, so the + * window reads as one slow field of colour rather than two rings. Speeds are + * deliberately meditative — a full lap is about a minute at 1× — and the + * header speed slider scales them like every other pattern. + */ + +export interface Gradient { + name: string; + /** [hue, saturation] stops, in order around the loop; the last blends back into the first. */ + stops: [number, number][]; +} + +export const GRADIENTS: Gradient[] = [ + { name: 'Dawn', stops: [[335, 70], [20, 90], [45, 100]] }, + { name: 'Stained Glass', stops: [[225, 95], [275, 90], [345, 85], [40, 95]] }, + { name: 'Ocean', stops: [[185, 90], [210, 95], [250, 80]] }, + { name: 'Ember', stops: [[0, 100], [20, 100], [40, 100], [10, 60]] }, + { name: 'Moonlight', stops: [[210, 25], [200, 60], [0, 0]] }, + { name: 'Aurora', stops: [[150, 90], [175, 85], [270, 80], [330, 60]] }, + { name: 'Rose Gold', stops: [[340, 80], [15, 70], [45, 90], [30, 50]] }, + { name: 'Twilight', stops: [[260, 85], [300, 70], [20, 85]] }, + { name: 'Forest', stops: [[95, 80], [140, 90], [60, 85], [165, 70]] }, + { name: 'Candlelit', stops: [[35, 100], [30, 60], [40, 95], [25, 80]] }, + { name: 'Violet Hour', stops: [[275, 90], [230, 85], [320, 70]] }, + { name: 'Coral Reef', stops: [[10, 85], [175, 85], [45, 90], [200, 70]] }, + { name: 'Sunset', stops: [[15, 95], [40, 100], [340, 80], [280, 75]] }, + { name: 'Spectrum', stops: [[0, 90], [60, 90], [120, 90], [180, 90], [240, 90], [300, 90]] }, + { name: 'Ice Fire', stops: [[200, 90], [20, 100]] }, + { name: 'Sage', stops: [[140, 40], [90, 50], [60, 30], [170, 45]] } +]; + +/** The gradient as a CSS conic sweep, for swatches and tile backgrounds. */ +export function gradientCss(g: Gradient): string { + const n = g.stops.length; + const stops = [...g.stops, g.stops[0]].map((s, i) => `${hsbCss(s)} ${Math.round((i / n) * 100)}%`); + return `conic-gradient(from 0deg, ${stops.join(', ')})`; +} + +/** Shortest-way hue interpolation between the stops at a 0..1 loop position. */ +const GRADIENT_HELPERS = ` +function lerpHue(a, b, t) { + var d = b - a; + if (d > 180) d -= 360; + if (d < -180) d += 360; + return ((a + d * t) % 360 + 360) % 360; +} +// Colour at 0..1 around the loop, the last stop blending back into the first. +function grad(u) { + var n = STOPS.length; + var x = ((u % 1) + 1) % 1 * n; + var k = Math.floor(x); + var t = x - k; + var a = STOPS[k % n]; + var b = STOPS[(k + 1) % n]; + return [lerpHue(a[0], b[0], t), a[1] + (b[1] - a[1]) * t]; +} +function paint(ctx, i, u, level) { + var c = grad(u); + ctx.set(i, c[0], c[1], Math.max(0, Math.min(100, level))); +} +// 0..1 position around the ring, clockwise from 12 o'clock. +function pos(ctx, i) { + var ang = ctx.polar(i)[1] + Math.PI / 2; + return ((ang / (Math.PI * 2)) % 1 + 1) % 1; +} +function rad(ctx, i) { return ctx.polar(i)[0]; } +// One lap of the gradient at 1x takes LAP seconds. +var LAP = 60; +`; + +function buildGradient(name: string, g: Gradient, body: string): string { + const stops = `var STOPS = [${g.stops.map(([h, s]) => `[${h},${s}]`).join(',')}];`; + return `(function(){\n${stops}\n${GRADIENT_HELPERS}\nreturn {\nrender: function(ctx) {\n${body}\n},\nmeta: { name: '${name}' }\n};\n})()`; +} + +/** + * Slow gradient looks. Each maps a fixture to a point on the gradient loop + * from its angle and/or radius, then drifts that mapping with time. + */ +export function graceGradients(g: Gradient): Look[] { + return [ + { + name: 'Wheel', + code: buildGradient('grace-wheel', g, ` // The gradient wrapped once around the window, turning clockwise. + var turn = ctx.t / LAP; + for (var i = 0; i < ctx.count; i++) paint(ctx, i, pos(ctx, i) - turn, 90);`) + }, + { + name: 'Counter', + code: buildGradient('grace-counter-wheel', g, ` // Outer ring turns clockwise, everything inside it turns the other way. + var turn = ctx.t / LAP; + for (var i = 0; i < ctx.count; i++) { + var dir = rad(ctx, i) >= 0.8 ? -1 : 1; + paint(ctx, i, pos(ctx, i) + dir * turn, 90); + }`) + }, + { + name: 'Sweep', + code: buildGradient('grace-sweep', g, ` // A straight band of gradient across the whole window, slowly rotating — + // opposite sides of the room are always opposite colours. + var a = ctx.t / LAP * Math.PI * 2; + for (var i = 0; i < ctx.count; i++) { + var p = ctx.polar(i); + var d = p[0] * Math.cos(p[1] - a); + paint(ctx, i, (d + 1) / 2 * 0.5, 90); + }`) + }, + { + name: 'Spiral', + code: buildGradient('grace-gradient-spiral', g, ` // Angle plus a twist by radius, so the colours wind in toward the centre. + var turn = ctx.t / LAP; + for (var i = 0; i < ctx.count; i++) { + paint(ctx, i, pos(ctx, i) + (1 - rad(ctx, i)) * 0.5 - turn, 90); + }`) + }, + { + name: 'Petals', + code: buildGradient('grace-petals', g, ` // The gradient wrapped twice around, so the window shows a two-fold flower. + var turn = ctx.t / LAP; + for (var i = 0; i < ctx.count; i++) paint(ctx, i, pos(ctx, i) * 2 - turn, 90);`) + }, + { + name: 'Rings', + code: buildGradient('grace-gradient-rings', g, ` // Colour by radius — rim, inner ring and centre each a different point on + // the loop — cycling slowly inward. + var drift = ctx.t / LAP; + for (var i = 0; i < ctx.count; i++) paint(ctx, i, rad(ctx, i) * 0.6 + drift, 90);`) + }, + { + name: 'Tide', + code: buildGradient('grace-tide', g, ` // The whole room one colour, drifting through the loop together, the inner + // rings a little behind so it looks like the colour is arriving from outside. + var drift = ctx.t / (LAP * 1.5); + for (var i = 0; i < ctx.count; i++) paint(ctx, i, drift - (1 - rad(ctx, i)) * 0.12, 90);`) + }, + { + name: 'Pendulum', + code: buildGradient('grace-pendulum', g, ` // The wheel swings back and forth instead of turning — a quarter turn each way. + var swing = 0.25 * Math.sin(ctx.t / LAP * Math.PI * 2); + for (var i = 0; i < ctx.count; i++) paint(ctx, i, pos(ctx, i) + swing, 90);`) + }, + { + name: 'Drift', + code: buildGradient('grace-drift', g, ` // Each ring turns at its own pace, so the pattern between them is never the same twice. + var t = ctx.t / LAP; + for (var i = 0; i < ctx.count; i++) { + var r = rad(ctx, i); + var rate = r >= 0.8 ? 1 : r >= 0.45 ? 1.37 : 0.61; + paint(ctx, i, pos(ctx, i) - t * rate, 90); + }`) + }, + { + name: 'Glow', + code: buildGradient('grace-glow', g, ` // A turning wheel with a soft brightness swell riding round it — the light + // seems to travel, not just the colour. + var turn = ctx.t / LAP; + for (var i = 0; i < ctx.count; i++) { + var u = pos(ctx, i) - turn; + var swell = 0.5 + 0.5 * Math.sin(u * Math.PI * 2); + paint(ctx, i, u, 45 + 55 * swell); + }`) + }, + { + name: 'Breathe', + code: buildGradient('grace-gradient-breathe', g, ` // Wheel turning while the whole window breathes, inner rings a beat behind. + var turn = ctx.t / LAP; + for (var i = 0; i < ctx.count; i++) { + var lag = (1 - rad(ctx, i)) * 1.2; + var breath = 0.5 + 0.5 * Math.sin(ctx.t * 0.35 - lag); + paint(ctx, i, pos(ctx, i) - turn, 30 + 70 * breath); + }`) + }, + { + name: 'Embers', + code: buildGradient('grace-gradient-embers', g, ` // Slow wheel with each fixture flickering gently at its own rate: alive but still. + var turn = ctx.t / (LAP * 2); + for (var i = 0; i < ctx.count; i++) { + var p = pos(ctx, i); + var flicker = Math.sin(ctx.t * 1.3 + p * 11 + rad(ctx, i) * 7) * 0.6 + Math.sin(ctx.t * 0.8 + p * 27) * 0.4; + paint(ctx, i, p - turn, 60 + 35 * flicker); + }`) + }, + { + name: 'Bloom', + code: buildGradient('grace-bloom', g, ` // Colour opens from the centre outward and closes again, the wheel turning under it. + var turn = ctx.t / LAP; + var open = 0.5 + 0.5 * Math.sin(ctx.t * 0.25); + for (var i = 0; i < ctx.count; i++) { + var r = rad(ctx, i); + var level = 100 - Math.max(0, r - open) * 160; + paint(ctx, i, pos(ctx, i) * 0.5 - turn + r * 0.3, Math.max(15, level)); + }`) + }, + { + name: 'Still', + code: buildGradient('grace-gradient-still', g, ` // The wheel, not moving — the gradient as a held picture. + for (var i = 0; i < ctx.count; i++) paint(ctx, i, pos(ctx, i), 90);`) + } + ]; +}