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
139 changes: 132 additions & 7 deletions packages/ui/__tests__/grace-rings.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand All @@ -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];
}
};
Expand Down Expand Up @@ -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%)');
Expand Down
65 changes: 58 additions & 7 deletions packages/ui/src/components/grace-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { useCallback, useMemo, useState } from 'react';

import {
graceGradients,
graceMotion,
graceStills,
type Gradient,
gradientCss,
GRADIENTS,
hsbCss,
type Look,
pairGradient,
Expand All @@ -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 }) {
Expand All @@ -41,17 +46,42 @@ 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 (
<button
onClick={onClick}
title={gradient.name}
className="relative overflow-hidden transition-all active:scale-93"
style={{
width: 56,
height: 56,
borderRadius: 14,
background: gradientCss(gradient),
border: active ? '2.5px solid #fff' : '2.5px solid transparent'
}}
>
<span
className="absolute bottom-0.5 left-0 right-0 text-center font-semibold"
style={{ fontSize: 9, color: '#fff', textShadow: '0 1px 4px rgba(0,0,0,0.9)', letterSpacing: '0.02em' }}
>
{gradient.name}
</span>
</button>
);
}

function LookTile({
look,
pair,
background,
active,
onClick,
showPreview,
speed,
fixtures
}: {
look: Look;
pair: RingPair;
background: string;
active: boolean;
onClick: () => void;
showPreview: boolean;
Expand All @@ -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'
}}
>
Expand Down Expand Up @@ -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);
Expand All @@ -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) => (
<ControlGroup label={label}>
<div className="flex gap-2.5 flex-wrap overflow-y-auto" style={{ maxHeight: showPreview ? 320 : undefined }}>
{looks.map((l) => (
<LookTile
key={`${prefix}-${l.name}`}
look={l}
pair={pair}
background={background}
active={activePattern === `${prefix}-${l.name}`}
onClick={() => handleSelect(prefix, l)}
showPreview={showPreview}
Expand Down Expand Up @@ -197,8 +237,19 @@ export function GraceTab({
Inner 12
</div>
</ControlGroup>
{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))}
<ControlGroup label={`Gradient — ${gradient.name}`}>
<div className="flex gap-2.5 flex-wrap">
{GRADIENTS.map((g) => (
<GradientSwatch key={g.name} gradient={g} active={g.name === gradient.name} onClick={() => pickGradient(g)} />
))}
</div>
<div className="pt-1" style={{ fontSize: 10, color: '#888898' }}>
Slow sweeps of blended colour — one lap about a minute at 1×
</div>
</ControlGroup>
{renderGroup('Slow Gradients', GRADIENT_PREFIX, gradients, gradientCss(gradient))}
</ControlGrid>
</div>
);
Expand Down
Loading
Loading