diff --git a/README.md b/README.md index 2c3130a..189da0a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Math Visualizer is an interactive collection of mathematical visualizations buil > glyph outlines become one closed path, the path's DFT becomes a chain of rotating > circles, and the chain's tip traces the letters live (pen lifts between glyphs). > Hundreds of circles render in one instanced draw call. +> Share a message with `#/fourier?text=YOUR+TEXT` (`&n=` sets the epicycle count) — +> the URL updates as you type, and **Copy link** puts it on the clipboard. > > The midpoint-on-circle and ColorCycle rules remain in the codebase as alternative > examples. See [`docs/superpowers/specs/`](docs/superpowers/specs/) for designs and diff --git a/web/src/lib/__tests__/router.test.ts b/web/src/lib/__tests__/router.test.ts index 5ecec6a..2536c75 100644 --- a/web/src/lib/__tests__/router.test.ts +++ b/web/src/lib/__tests__/router.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseHash, DEFAULT_LAB, LAB_IDS } from '../router'; +import { parseHash, parseHashQuery, buildQuery, DEFAULT_LAB, LAB_IDS } from '../router'; describe('parseHash', () => { it('maps #/fourier to fourier', () => { @@ -22,3 +22,19 @@ describe('parseHash', () => { for (const id of LAB_IDS) expect(parseHash(`#/${id}`)).toBe(id); }); }); + +describe('parseHashQuery / buildQuery', () => { + it('extracts the query part of a hash route', () => { + expect(parseHashQuery('#/fourier?text=HI&n=5')).toBe('text=HI&n=5'); + expect(parseHashQuery('#/fourier')).toBe(''); + expect(parseHashQuery('')).toBe(''); + }); + it('route id parsing ignores the query', () => { + expect(parseHash('#/fourier?text=HI')).toBe('fourier'); + }); + it('buildQuery omits undefined/empty values and encodes the rest', () => { + expect(buildQuery({ text: 'HELLO WORLD', n: '12' })).toBe('text=HELLO+WORLD&n=12'); + expect(buildQuery({ text: undefined, n: '' })).toBe(''); + expect(new URLSearchParams(buildQuery({ text: 'a&b=c' })).get('text')).toBe('a&b=c'); + }); +}); diff --git a/web/src/lib/components/__tests__/FourierLab.test.ts b/web/src/lib/components/__tests__/FourierLab.test.ts index 2b50b2f..2fcfd32 100644 --- a/web/src/lib/components/__tests__/FourierLab.test.ts +++ b/web/src/lib/components/__tests__/FourierLab.test.ts @@ -105,3 +105,32 @@ describe('FourierLab.svelte', () => { expect(getByText('Sierpinski Pyramid', { selector: 'h2' })).toBeTruthy(); }); }); + +describe('FourierLab.svelte — shareable link params', () => { + beforeEach(() => { + dispatchSpy.mockClear(); + updateRuleConfigSpy.mockClear(); + }); + + it('reads text and n from the hash query and uses them for the first push', async () => { + navigate('fourier', 'text=HELLO&n=12'); + const { getByLabelText } = render(App); + await vi.waitFor(() => expect(updateRuleConfigSpy).toHaveBeenCalled()); + const cfg = updateRuleConfigSpy.mock.calls[0][0] as { epicycles: number }; + expect(cfg.epicycles).toBe(12); + expect((getByLabelText('Text to trace') as HTMLInputElement).value).toBe('HELLO'); + }); + + it('keeps the URL in sync as the text changes and offers a copy-link button', async () => { + navigate('fourier'); + const { getByLabelText, getByTitle } = render(App); + await vi.waitFor(() => expect(updateRuleConfigSpy).toHaveBeenCalledTimes(1)); + expect(location.hash).toBe('#/fourier'); // defaults are omitted from the link + + const input = getByLabelText('Text to trace') as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'ABC' } }); + await vi.waitFor(() => expect(updateRuleConfigSpy).toHaveBeenCalledTimes(2)); + expect(location.hash).toBe('#/fourier?text=ABC'); + expect(getByTitle('Copy a link to this message')).toBeTruthy(); + }); +}); diff --git a/web/src/lib/components/labs/FourierLab.svelte b/web/src/lib/components/labs/FourierLab.svelte index 82437be..da5fd9e 100644 --- a/web/src/lib/components/labs/FourierLab.svelte +++ b/web/src/lib/components/labs/FourierLab.svelte @@ -1,19 +1,75 @@