diff --git a/.changeset/rain-backdrop-component.md b/.changeset/rain-backdrop-component.md new file mode 100644 index 0000000..8585902 --- /dev/null +++ b/.changeset/rain-backdrop-component.md @@ -0,0 +1,7 @@ +--- +'@positronick/ui': minor +--- + +feat: add `RainBackdrop` — an animated rain background, drop-in sibling of `Backdrop` + +A canvas-based full-page rain layer that follows the theme ink (white rain on `machine`, black on `samaritan`), with a small accent-red fraction of drops and splash-particle bursts where drops hit the bottom edge. Props: `intensity` (0–1 rain amount), `accentRatio` (red-drop fraction, default 0.03), `speed` (fall multiplier), `splash` (impact bursts on/off). Honors `prefers-reduced-motion` with a static sparse frame, recolors live on theme change without a remount, and bounds its canvas/drop count so very tall pages degrade gracefully. diff --git a/src/lib/components/RainBackdrop.reduced.svelte.test.ts b/src/lib/components/RainBackdrop.reduced.svelte.test.ts new file mode 100644 index 0000000..d819afe --- /dev/null +++ b/src/lib/components/RainBackdrop.reduced.svelte.test.ts @@ -0,0 +1,45 @@ +import { vi } from 'vitest'; + +// Force the reduced-motion branch: a single static frame, no animation loop. +vi.mock('../actions/reducedMotion.js', () => ({ reducedMotion: { current: true } })); + +import { render } from 'vitest-browser-svelte'; +import { createRawSnippet } from 'svelte'; +import { describe, expect, test } from 'vitest'; +import ThemedHarness from '../../test-support/ThemedHarness.svelte'; +import RainBackdrop from './RainBackdrop.svelte'; + +const body = createRawSnippet(() => ({ + render: () => `

STILL

` +})); + +function canvas(): HTMLCanvasElement { + const el = document.querySelector('.pn-rain-backdrop__canvas'); + if (!el) throw new Error('.pn-rain-backdrop__canvas not found'); + return el; +} + +function hasInk(): boolean { + const c = canvas(); + if (c.width === 0 || c.height === 0) return false; + const ctx = c.getContext('2d'); + if (!ctx) return false; + const data = ctx.getImageData(0, 0, c.width, c.height).data; + for (let i = 3; i < data.length; i += 4) if (data[i] > 0) return true; + return false; +} + +describe('RainBackdrop (reduced motion)', () => { + test('paints a single static frame that never changes', async () => { + render(ThemedHarness, { + theme: 'machine', + Comp: RainBackdrop, + componentProps: { children: body, intensity: 1 } + }); + await expect.poll(hasInk, { timeout: 3000 }).toBe(true); + const frame = canvas().toDataURL(); + // If the rAF loop were running, drops would have moved well within 250ms. + await new Promise((r) => setTimeout(r, 250)); + expect(canvas().toDataURL()).toBe(frame); + }); +}); diff --git a/src/lib/components/RainBackdrop.svelte b/src/lib/components/RainBackdrop.svelte new file mode 100644 index 0000000..5c2757c --- /dev/null +++ b/src/lib/components/RainBackdrop.svelte @@ -0,0 +1,284 @@ + + +
+ +
{@render children()}
+
+ + diff --git a/src/lib/components/RainBackdrop.svelte.test.ts b/src/lib/components/RainBackdrop.svelte.test.ts new file mode 100644 index 0000000..c832522 --- /dev/null +++ b/src/lib/components/RainBackdrop.svelte.test.ts @@ -0,0 +1,126 @@ +import { render } from 'vitest-browser-svelte'; +import { createRawSnippet } from 'svelte'; +import { describe, expect, test } from 'vitest'; +import ThemedHarness from '../../test-support/ThemedHarness.svelte'; +import RainBackdrop from './RainBackdrop.svelte'; + +const body = createRawSnippet(() => ({ + render: () => `

IT'S RAINING

` +})); + +function canvas(): HTMLCanvasElement { + const el = document.querySelector('.pn-rain-backdrop__canvas'); + if (!el) throw new Error('.pn-rain-backdrop__canvas not found'); + return el; +} + +function layer(): HTMLElement { + const el = document.querySelector('.pn-rain-backdrop__layer'); + if (!el) throw new Error('.pn-rain-backdrop__layer not found'); + return el; +} + +/** True once the animation has painted anything onto the (transparent) canvas. */ +function hasInk(): boolean { + const c = canvas(); + if (c.width === 0 || c.height === 0) return false; + const ctx = c.getContext('2d'); + if (!ctx) return false; + const data = ctx.getImageData(0, 0, c.width, c.height).data; + for (let i = 3; i < data.length; i += 4) if (data[i] > 0) return true; + return false; +} + +/** Finds a painted pixel and reports whether it is light (white ink) or dark (black ink). */ +function inkShade(): 'light' | 'dark' | null { + const c = canvas(); + const ctx = c.getContext('2d'); + if (!ctx) return null; + const data = ctx.getImageData(0, 0, c.width, c.height).data; + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] > 50) return data[i] > 200 ? 'light' : data[i] < 60 ? 'dark' : null; + } + return null; +} + +describe('RainBackdrop', () => { + test('renders content above the rain layer, layer is decorative and non-interactive', async () => { + const screen = render(ThemedHarness, { + theme: 'machine', + Comp: RainBackdrop, + componentProps: { children: body } + }); + await expect.element(screen.getByTestId('rain-body')).toBeInTheDocument(); + expect(document.querySelector('.pn-rain-backdrop__content')).not.toBeNull(); + expect(layer().getAttribute('aria-hidden')).toBe('true'); + expect(getComputedStyle(layer()).pointerEvents).toBe('none'); + }); + + test('fills the viewport (does not collapse to content height)', () => { + render(ThemedHarness, { + theme: 'machine', + Comp: RainBackdrop, + componentProps: { children: body } + }); + const root = document.querySelector('.pn-rain-backdrop'); + if (!root) throw new Error('.pn-rain-backdrop not found'); + expect(root.getBoundingClientRect().height).toBeGreaterThanOrEqual(window.innerHeight); + }); + + test('sizes the canvas backing store to the layer at (capped) devicePixelRatio', async () => { + render(ThemedHarness, { + theme: 'machine', + Comp: RainBackdrop, + componentProps: { children: body } + }); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + await expect.poll(() => canvas().width).toBe(Math.round(layer().clientWidth * dpr)); + expect(canvas().height).toBe(Math.round(layer().clientHeight * dpr)); + }); + + test('rain animates: frames are painted and change over time', async () => { + render(ThemedHarness, { + theme: 'machine', + Comp: RainBackdrop, + componentProps: { children: body, intensity: 1 } + }); + await expect.poll(hasInk, { timeout: 3000 }).toBe(true); + const first = canvas().toDataURL(); + await expect.poll(() => canvas().toDataURL() !== first, { timeout: 3000 }).toBe(true); + }); + + test('rain uses the theme ink: white on The Machine, recolors to black when the theme flips', async () => { + render(ThemedHarness, { + theme: 'machine', + Comp: RainBackdrop, + // High speed exercises bottom impacts (splash bursts) while we watch colors. + componentProps: { children: body, intensity: 1, speed: 3 } + }); + await expect.poll(() => inkShade(), { timeout: 3000 }).toBe('light'); + // ThemeProvider swaps data-theme in place (no remount) — the rain must follow. + document.querySelector('.pn-root')?.setAttribute('data-theme', 'samaritan'); + await expect.poll(() => inkShade(), { timeout: 3000 }).toBe('dark'); + }); + + test('intensity 0 paints no rain', async () => { + render(ThemedHarness, { + theme: 'machine', + Comp: RainBackdrop, + componentProps: { children: body, intensity: 0 } + }); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + await expect.poll(() => canvas().width).toBe(Math.round(layer().clientWidth * dpr)); + // Give the loop a few frames, then confirm the canvas is still blank. + await new Promise((r) => setTimeout(r, 200)); + expect(hasInk()).toBe(false); + }); + + test('edge props render and animate without error', async () => { + render(ThemedHarness, { + theme: 'samaritan', + Comp: RainBackdrop, + componentProps: { children: body, intensity: 1, accentRatio: 1, speed: 5, splash: false } + }); + await expect.poll(hasInk, { timeout: 3000 }).toBe(true); + }); +}); diff --git a/src/lib/index.ts b/src/lib/index.ts index 52d17cb..15c3c28 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -35,6 +35,7 @@ export { default as PositronickMark } from './components/PositronickMark.svelte' export { default as ProgressBar } from './components/ProgressBar.svelte'; export { default as Prose } from './components/Prose.svelte'; export { default as ProviderIcon } from './components/ProviderIcon.svelte'; +export { default as RainBackdrop } from './components/RainBackdrop.svelte'; export { default as RecDot } from './components/RecDot.svelte'; export { default as ScrollTopButton } from './components/ScrollTopButton.svelte'; export { default as SectionNav } from './components/SectionNav.svelte'; diff --git a/src/stories/RainBackdrop.stories.svelte b/src/stories/RainBackdrop.stories.svelte new file mode 100644 index 0000000..e4c3833 --- /dev/null +++ b/src/stories/RainBackdrop.stories.svelte @@ -0,0 +1,75 @@ + + +{#snippet content()} +
+ + + +
+{/snippet} + + + + {@render content()} + + + + + + {@render content()} + + + + + + {#snippet template(args)} + + +
+ {@render content()} +
+
+ {/snippet} +