Site colours: pick-from-image and palette-from-image modals

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 09:53:13 -08:00
1 parent e4ba07b47e
commit 54920dff08
4 files changed
+435

No files matched your search

@@ -0,0 +1,88 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
vi.mock('../../../utils/assets', () => ({ listAssets: vi.fn(), uploadAsset: vi.fn() }));
vi.mock('../../../utils/image-sample', async () => {
const actual = await vi.importActual<typeof import('../../../utils/image-sample')>('../../../utils/image-sample');
return { ...actual, loadImageForSampling: vi.fn() };
});
import { listAssets } from '../../../utils/assets';
import { loadImageForSampling, CROSS_ORIGIN_MESSAGE } from '../../../utils/image-sample';
import { ImagePickModal } from './ImagePickModal';
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const listMock = listAssets as unknown as ReturnType<typeof vi.fn>;
const loadMock = loadImageForSampling as unknown as ReturnType<typeof vi.fn>;
// 2x1 image: left red, right transparent
const imageData = { width: 2, height: 1, data: new Uint8ClampedArray([255, 0, 0, 255, 0, 0, 0, 0]) };
const okImage = () => ({ ok: true, width: 2, height: 1, imageData, draw: vi.fn() });
let container: HTMLDivElement;
let root: Root;
async function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
await act(async () => { root = createRoot(container); root.render(ui); });
await act(async () => {});
}
afterEach(() => { act(() => root.unmount()); container.remove(); vi.clearAllMocks(); });
beforeEach(() => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); listMock.mockResolvedValue([{ name: 'a.png', url: '/a.png', type: 'image/png' }]); });
const q = (id: string) => document.querySelector(`[data-testid="${id}"]`) as HTMLElement | null;
function mouse(el: HTMLElement, type: string, clientX: number) {
el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 100, height: 50, right: 100, bottom: 50, x: 0, y: 0, toJSON() {} }) as DOMRect;
act(() => { el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX, clientY: 10 })); });
}
describe('ImagePickModal', () => {
test('lists uploaded assets and loads the chosen one', async () => {
loadMock.mockResolvedValue(okImage());
await render(<ImagePickModal onPick={vi.fn()} onClose={vi.fn()} />);
expect(q('image-asset-a.png')).toBeTruthy();
expect(loadMock).not.toHaveBeenCalled();
await act(async () => { q('image-asset-a.png')!.click(); });
expect(loadMock).toHaveBeenCalledWith('/a.png');
expect(q('image-pick-canvas')).toBeTruthy();
});
test('initialSrc loads immediately; hover shows hex; click picks and closes', async () => {
loadMock.mockResolvedValue(okImage());
const onPick = vi.fn(), onClose = vi.fn();
await render(<ImagePickModal onPick={onPick} onClose={onClose} initialSrc="/sel.png" />);
expect(loadMock).toHaveBeenCalledWith('/sel.png');
const canvas = q('image-pick-canvas')!;
mouse(canvas, 'mousemove', 10);
expect(q('image-pick-hex')!.textContent).toBe('#ff0000');
mouse(canvas, 'click', 10);
expect(onPick).toHaveBeenCalledWith('#ff0000');
expect(onClose).toHaveBeenCalled();
});
test('clicking a transparent pixel does not pick', async () => {
loadMock.mockResolvedValue(okImage());
const onPick = vi.fn(), onClose = vi.fn();
await render(<ImagePickModal onPick={onPick} onClose={onClose} initialSrc="/sel.png" />);
mouse(q('image-pick-canvas')!, 'click', 90);
expect(onPick).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(container.ownerDocument.body.textContent).toContain('transparent');
});
test('loader failure is shown as role=alert', async () => {
loadMock.mockResolvedValue({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE });
await render(<ImagePickModal onPick={vi.fn()} onClose={vi.fn()} initialSrc="https://x.test/a.png" />);
const alert = document.querySelector('[role="alert"]');
expect(alert?.textContent).toBe(CROSS_ORIGIN_MESSAGE);
expect(q('image-pick-canvas')).toBeNull();
});
test('Escape closes', async () => {
const onClose = vi.fn();
await render(<ImagePickModal onPick={vi.fn()} onClose={onClose} />);
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,169 @@
import React, { useEffect, useRef, useState, CSSProperties } from 'react';
import { Modal } from '../../../ui/Modal';
import { listAssets, Asset } from '../../../utils/assets';
import { clickableProps } from '../../../utils/a11y';
import { loadImageForSampling, sampleAt, SampledImage } from '../../../utils/image-sample';
export type SampleState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; img: SampledImage }
| { status: 'failed'; message: string };
/** Load `src` for pixel reading; re-runs when `src` changes, ignoring stale results. */
export function useSampledImage(src: string | null): SampleState {
const [state, setState] = useState<SampleState>({ status: 'idle' });
useEffect(() => {
if (!src) { setState({ status: 'idle' }); return; }
let live = true;
setState({ status: 'loading' });
loadImageForSampling(src).then((r) => {
if (!live) return;
setState(r.ok ? { status: 'ready', img: r } : { status: 'failed', message: r.message });
});
return () => { live = false; };
}, [src]);
return state;
}
/** Uploaded-image thumbnails; `selected` highlights the current one. */
export const ImageAssetList: React.FC<{
assets: Asset[];
selected: string | null;
onSelect: (url: string) => void;
}> = ({ assets, selected, onSelect }) => (
<div data-testid="image-assets" style={assetGridStyle}>
{assets.map((a) => (
<div
key={a.url}
{...clickableProps(() => onSelect(a.url))}
data-testid={`image-asset-${a.name}`}
title={a.name}
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', aspectRatio: '1', border: `2px solid ${a.url === selected ? '#3b82f6' : 'transparent'}` }}
>
<img src={a.url} alt={a.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
))}
{assets.length === 0 && (
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>
No images uploaded yet. Upload one from an Image block first.
</p>
)}
</div>
);
/** Uploaded images for the picker, loaded once on mount. */
export function useImageAssets(): Asset[] {
const [assets, setAssets] = useState<Asset[]>([]);
useEffect(() => {
let live = true;
listAssets('image').then((a) => { if (live) setAssets(a); }).catch(() => {});
return () => { live = false; };
}, []);
return assets;
}
export const modalPanelStyle: CSSProperties = {
background: '#18181b', border: '1px solid #3f3f46', borderRadius: 8, padding: 16,
color: '#e4e4e7', width: 460, maxWidth: '92vw', maxHeight: '88vh', overflowY: 'auto', fontSize: 12,
};
export const modalTitleStyle: CSSProperties = { fontSize: 15, fontWeight: 600, margin: 0 };
export const modalBtnStyle: CSSProperties = {
padding: '6px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#27272a', color: '#e4e4e7',
};
export const errorStyle: CSSProperties = {
margin: '10px 0 0', padding: '8px 10px', borderRadius: 4, background: '#3f1d1d', border: '1px solid #7f1d1d', color: '#fecaca', fontSize: 12,
};
const assetGridStyle: CSSProperties = {
maxHeight: 140, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 4, background: '#09090b', borderRadius: 6, padding: 4,
};
export interface ImagePickModalProps {
onPick: (hex: string) => void;
onClose: () => void;
/** The selected image's src, offered as the starting image. */
initialSrc?: string;
}
const ZOOM = 9;
export const ImagePickModal: React.FC<ImagePickModalProps> = ({ onPick, onClose, initialSrc }) => {
const assets = useImageAssets();
const [src, setSrc] = useState<string | null>(initialSrc || null);
const state = useSampledImage(src);
const canvasRef = useRef<HTMLCanvasElement>(null);
const zoomRef = useRef<HTMLCanvasElement>(null);
const [hover, setHover] = useState<string>('');
const [note, setNote] = useState('');
useEffect(() => {
if (state.status === 'ready' && canvasRef.current) state.img.draw(canvasRef.current);
}, [state]);
const locate = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (state.status !== 'ready') return null;
const rect = e.currentTarget.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
return {
x: Math.floor(((e.clientX - rect.left) / rect.width) * state.img.width),
y: Math.floor(((e.clientY - rect.top) / rect.height) * state.img.height),
};
};
const onMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const p = locate(e);
if (!p || state.status !== 'ready') return;
setHover(sampleAt(state.img.imageData, p.x, p.y));
setNote('');
const z = zoomRef.current?.getContext?.('2d');
if (z && canvasRef.current) {
z.imageSmoothingEnabled = false;
z.clearRect(0, 0, 72, 72);
z.drawImage(canvasRef.current, p.x - 4, p.y - 4, ZOOM, ZOOM, 0, 0, 72, 72);
}
};
const onClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const p = locate(e);
if (!p || state.status !== 'ready') return;
const hex = sampleAt(state.img.imageData, p.x, p.y);
if (!hex) { setNote('That spot is transparent. Click a coloured part of the image.'); return; }
onPick(hex);
onClose();
};
return (
<Modal open onClose={onClose} backdropProps={{ role: 'dialog', 'aria-modal': true, 'aria-label': 'Pick a colour from an image' } as React.HTMLAttributes<HTMLDivElement>}>
<div style={modalPanelStyle} data-testid="image-pick-modal">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<h3 style={modalTitleStyle}>Pick a colour from an image</h3>
<button type="button" style={modalBtnStyle} onClick={onClose} data-testid="image-pick-close">Close</button>
</div>
<ImageAssetList assets={assets} selected={src} onSelect={setSrc} />
{state.status === 'loading' && <p style={{ color: '#a1a1aa' }}>Loading image...</p>}
{state.status === 'failed' && <div role="alert" style={errorStyle} data-testid="image-pick-error">{state.message}</div>}
{src && state.status !== 'failed' && (
<div style={{ marginTop: 10 }}>
<canvas
ref={canvasRef}
data-testid="image-pick-canvas"
onMouseMove={onMove}
onMouseLeave={() => setHover('')}
onClick={onClick}
style={{ display: state.status === 'ready' ? 'block' : 'none', maxWidth: '100%', maxHeight: 280, margin: '0 auto', cursor: 'crosshair', border: '1px solid #3f3f46', borderRadius: 4 }}
/>
{state.status === 'ready' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 8 }}>
<canvas ref={zoomRef} width={72} height={72} aria-hidden="true" style={{ width: 72, height: 72, border: '1px solid #3f3f46', borderRadius: 4, imageRendering: 'pixelated', background: '#09090b' }} />
<span data-testid="image-pick-swatch" style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: hover || 'transparent' }} />
<code data-testid="image-pick-hex" style={{ fontSize: 13 }}>{hover || 'Move over the image'}</code>
</div>
)}
{note && <p style={{ color: '#fbbf24', margin: '6px 0 0' }}>{note}</p>}
</div>
)}
{!src && <p style={{ color: '#a1a1aa', marginTop: 10 }}>Choose an uploaded image above, then click a spot in it to use that colour.</p>}
</div>
</Modal>
);
};
@@ -0,0 +1,100 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
vi.mock('../../../utils/assets', () => ({ listAssets: vi.fn(), uploadAsset: vi.fn() }));
vi.mock('../../../utils/image-sample', async () => {
const actual = await vi.importActual<typeof import('../../../utils/image-sample')>('../../../utils/image-sample');
return { ...actual, loadImageForSampling: vi.fn() };
});
import { listAssets } from '../../../utils/assets';
import { loadImageForSampling, CROSS_ORIGIN_MESSAGE } from '../../../utils/image-sample';
import { PaletteFromImageModal } from './PaletteFromImageModal';
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const listMock = listAssets as unknown as ReturnType<typeof vi.fn>;
const loadMock = loadImageForSampling as unknown as ReturnType<typeof vi.fn>;
// 10 px: 5 red, 3 blue, 2 green
function image() {
const px: number[] = [];
const push = (c: number[], n: number) => { for (let i = 0; i < n; i++) px.push(...c, 255); };
push([255, 0, 0], 5); push([0, 0, 255], 3); push([0, 160, 0], 2);
return { ok: true, width: 10, height: 1, imageData: { width: 10, height: 1, data: new Uint8ClampedArray(px) }, draw: vi.fn() };
}
let container: HTMLDivElement;
let root: Root;
async function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
await act(async () => { root = createRoot(container); root.render(ui); });
await act(async () => {});
}
afterEach(() => { act(() => root.unmount()); container.remove(); vi.clearAllMocks(); });
beforeEach(() => { listMock.mockResolvedValue([{ name: 'a.png', url: '/a.png', type: 'image/png' }]); });
const q = (id: string) => document.querySelector(`[data-testid="${id}"]`) as HTMLButtonElement | null;
const click = (el: HTMLElement | null) => act(() => { el!.click(); });
describe('PaletteFromImageModal', () => {
test('nothing is applied automatically', async () => {
loadMock.mockResolvedValue(image());
const onAdd = vi.fn();
await render(<PaletteFromImageModal onAdd={onAdd} onClose={vi.fn()} existingHexes={[]} initialSrc="/a.png" />);
expect(q('palette-row-#ff0000')).toBeTruthy();
expect(onAdd).not.toHaveBeenCalled();
});
test('swatches in population order, Add calls onAdd then shows Added', async () => {
loadMock.mockResolvedValue(image());
const onAdd = vi.fn();
await render(<PaletteFromImageModal onAdd={onAdd} onClose={vi.fn()} existingHexes={[]} initialSrc="/a.png" />);
const rows = [...document.querySelectorAll('[data-testid^="palette-row-"]')].map((r) => r.getAttribute('data-testid'));
expect(rows).toEqual(['palette-row-#ff0000', 'palette-row-#0000ff', 'palette-row-#00a000']);
click(q('palette-add-#0000ff'));
expect(onAdd).toHaveBeenCalledWith('#0000ff');
expect(q('palette-add-#0000ff')!.disabled).toBe(true);
expect(q('palette-add-#0000ff')!.textContent).toBe('Added');
});
test('existing hexes are disabled / Added (case-insensitive)', async () => {
loadMock.mockResolvedValue(image());
await render(<PaletteFromImageModal onAdd={vi.fn()} onClose={vi.fn()} existingHexes={['#FF0000']} initialSrc="/a.png" />);
expect(q('palette-add-#ff0000')!.disabled).toBe(true);
expect(q('palette-add-#ff0000')!.textContent).toBe('Added');
expect(q('palette-add-#0000ff')!.disabled).toBe(false);
});
test('Add all adds only the not-yet-added colours', async () => {
loadMock.mockResolvedValue(image());
const onAdd = vi.fn();
await render(<PaletteFromImageModal onAdd={onAdd} onClose={vi.fn()} existingHexes={['#ff0000']} initialSrc="/a.png" />);
click(q('palette-add-all'));
expect(onAdd.mock.calls.map((c) => c[0])).toEqual(['#0000ff', '#00a000']);
expect(q('palette-add-all')!.disabled).toBe(true);
});
test('picking a listed asset loads it', async () => {
loadMock.mockResolvedValue(image());
await render(<PaletteFromImageModal onAdd={vi.fn()} onClose={vi.fn()} existingHexes={[]} />);
expect(q('palette-row-#ff0000')).toBeNull();
await act(async () => { q('image-asset-a.png')!.click(); });
expect(loadMock).toHaveBeenCalledWith('/a.png');
expect(q('palette-row-#ff0000')).toBeTruthy();
});
test('loader failure shown as role=alert, no swatches', async () => {
loadMock.mockResolvedValue({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE });
await render(<PaletteFromImageModal onAdd={vi.fn()} onClose={vi.fn()} existingHexes={[]} initialSrc="https://x.test/a.png" />);
expect(document.querySelector('[role="alert"]')?.textContent).toBe(CROSS_ORIGIN_MESSAGE);
expect(document.querySelector('[data-testid^="palette-row-"]')).toBeNull();
});
test('Escape closes', async () => {
const onClose = vi.fn();
await render(<PaletteFromImageModal onAdd={vi.fn()} onClose={onClose} existingHexes={[]} />);
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,78 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Modal } from '../../../ui/Modal';
import { extractPalette } from '../../../utils/palette-extract';
import {
ImageAssetList, useImageAssets, useSampledImage,
modalPanelStyle, modalTitleStyle, modalBtnStyle, errorStyle,
} from './ImagePickModal';
export interface PaletteFromImageModalProps {
onAdd: (hex: string, label?: string) => void;
onClose: () => void;
existingHexes: string[];
/** The selected image's src, offered as the starting image. */
initialSrc?: string;
}
export const PaletteFromImageModal: React.FC<PaletteFromImageModalProps> = ({ onAdd, onClose, existingHexes, initialSrc }) => {
const assets = useImageAssets();
const [src, setSrc] = useState<string | null>(initialSrc || null);
const [count, setCount] = useState(6);
const [added, setAdded] = useState<string[]>([]);
const state = useSampledImage(src);
useEffect(() => { setAdded([]); }, [src]);
const colours = useMemo(
() => (state.status === 'ready' ? extractPalette(state.img.imageData.data, { count }) : []),
[state, count],
);
const taken = useMemo(() => new Set([...existingHexes, ...added].map((h) => h.toLowerCase())), [existingHexes, added]);
const add = (hex: string) => {
onAdd(hex);
setAdded((a) => [...a, hex]);
};
const addAll = () => colours.filter((h) => !taken.has(h)).forEach(add);
const remaining = colours.filter((h) => !taken.has(h)).length;
return (
<Modal open onClose={onClose} backdropProps={{ role: 'dialog', 'aria-modal': true, 'aria-label': 'Palette from an image' } as React.HTMLAttributes<HTMLDivElement>}>
<div style={modalPanelStyle} data-testid="palette-from-image-modal">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<h3 style={modalTitleStyle}>Palette from an image</h3>
<button type="button" style={modalBtnStyle} onClick={onClose} data-testid="palette-close">Close</button>
</div>
<ImageAssetList assets={assets} selected={src} onSelect={setSrc} />
{!src && <p style={{ color: '#a1a1aa', marginTop: 10 }}>Choose an uploaded image to see its main colours. Nothing is added until you press Add.</p>}
{state.status === 'loading' && <p style={{ color: '#a1a1aa' }}>Reading colours...</p>}
{state.status === 'failed' && <div role="alert" style={errorStyle} data-testid="palette-error">{state.message}</div>}
{state.status === 'ready' && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<label htmlFor="palette-count">Colours</label>
<select id="palette-count" data-testid="palette-count" value={count} onChange={(e) => setCount(Number(e.target.value))} className="guided-input" style={{ width: 60 }}>
{[5, 6, 7, 8].map((n) => <option key={n} value={n}>{n}</option>)}
</select>
<span style={{ flex: 1 }} />
<button type="button" style={modalBtnStyle} onClick={addAll} disabled={remaining === 0} data-testid="palette-add-all">Add all</button>
</div>
{colours.length === 0 && <p style={{ color: '#a1a1aa' }}>No colours found. This image may be fully transparent.</p>}
{colours.map((hex) => {
const done = taken.has(hex);
return (
<div key={hex} data-testid={`palette-row-${hex}`} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '4px 0' }}>
<span style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: hex }} />
<code style={{ flex: 1, fontSize: 13 }}>{hex}</code>
<button type="button" style={{ ...modalBtnStyle, opacity: done ? 0.6 : 1 }} disabled={done} onClick={() => add(hex)} data-testid={`palette-add-${hex}`}>
{done ? 'Added' : 'Add'}
</button>
</div>
);
})}
</div>
)}
</div>
</Modal>
);
};