From 1e8a52cbc788307eb88d3b97cbe4d332b76a5eab Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:38:38 -0700 Subject: [PATCH] fix: composite the pattern scenes as a handful of layers Each horizon scene's sun and moon are compositing layers on purpose: moved on the page's own layer, a masked, filtered disc left half of itself painted behind. WebKit gives every later sibling that overlaps a composited layer a layer of its own, so everything drawn in front of the disc was a separate full-viewport layer whose mask and blur the window server re-applied on every frame the page above it changed. Static dune had 52 of them. Everything in front of a disc now sits in one .scene-group (an isolated stacking context, painted as one layer), as does any still run over a moving part. Aurora's curtains and sun path move as a box that carries only its transform and opacity, with a paint child that draws the blur, masks and clip into the layer once. Window-server GPU time in WebKit (1400x900, a panel sliding over the scene): static dune 24% -> 7% (an empty page measures 7%), animated dune 52% -> 17%, animated aurora 42% -> 19%. All 48 WebKit snapshots (every scene, light and dark, both disc sides, animated scenes frozen at one instant) are pixel identical to the old build's. --- CHANGELOG.md | 1 + components/pattern-background.tsx | 390 ++++++++++++++++------------ public/r/background-controller.json | 2 +- tests/pattern-background.test.tsx | 78 +++++- 4 files changed, 307 insertions(+), 164 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2213383..15ff50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Horizon scenes**: added three pure-CSS animated pattern backgrounds on the shared 42% horizon, all drawn from photographic references rather than illustration: `clouds` (a deep-blue altitude sky over a parallax cloud deck, with a placeable moon), `dune` (golden-hour ridges receding into haze, blowing sand, a placeable moon), and `aurora` (borealis curtains mirrored in rippling water below the waterline). None of the three draws a horizon *line*: the sky dissolves into the ground across a mottled haze band, and distant layers wash toward the sky's own value. All are light/dark aware, take a half-strength theme cast in the mid-sky, move on compositor-only transforms, and respect reduced motion. `clouds` and `dune` swap the disc per mode: a **sun** by day (a hot core with a wide bloom, sitting low in the warm band for `dune`) and a prominent moon by night, from one shared rule, and `dune` adds a **blowing-sand control** (`breeze` / `storm` / `still`) driving wind veils that stream across the ridges. ### Fixed +- **Pattern scenes composite as a handful of layers, not dozens**: each horizon scene's sun and moon are compositing layers (they have to be: moved on the page's own layer, a masked, filtered disc left half of itself painted behind), and WebKit gives every later sibling that overlaps a composited layer a layer of its own. So everything drawn in front of the disc was a separate full-viewport layer, and the window server re-applied each one's mask and blur on every frame anything above it changed: counted from WebKit's own layer tree, dune made 52 of them while static (66 animated), forest 25, synthwave and moonrise 23, clouds 15 and aurora 11. The same effect made the forest's light/dark flip slow before it moved to SVG. Everything in front of a disc now sits in one `.scene-group`, a stacking context painted as one layer, as does any still run over a moving part (forest's rays and middle ridge over a fog bank, aurora's sun path and haze over the water, aurora's sky patch over the curtains), so only what moves gets a layer of its own: static, dune, forest, clouds and aurora are down to 7 layers each, clip layers included. Aurora's curtains move as two boxes: the box that sways and shimmers carries only its transform and opacity, and an `.au-paint` child draws the rays with their blur, masks and clip, so those are drawn into the layer once instead of being re-applied by the window server on every frame for all six curtains. The sun path keeps the layer it had by accident (it moves with the disc, so it gets the disc's treatment on purpose now) and draws its glints on a child the same way. Measured in WebKit at 1400×900 with a panel sliding over the scene, as window-server GPU time: static dune 24% → 7% (an empty page measures 7%), animated dune 52% → 17%, animated aurora 42% → 19%, and animated clouds' device utilization 20% → 9%. Forest and the other static scenes were near the floor already; synthwave and moonrise are unchanged, since their floor's 3D grids need masked layers of their own. One cost moved the other way: at some window sizes (1400px, as measured) aurora's widest curtain, now drawn with its blur, is just wide enough to cross WebKit's tiling threshold, and WebKit spends about 5% of a core on it while it sways; in wider windows the old build tiles it too, and at 3440px both measure the same. The scenes render pixel for pixel as before: 48 WebKit snapshots (every scene, light and dark, both disc sides, the animated ones frozen at the same instant) are identical to the old build's. `tests/pattern-background.test.tsx` pins the group and the paint children. - **Components, against upstream shadcn**: a review of all 52 components against the current shadcn `new-york-v4` sources (the style they came from), ignoring the deliberate material, icon and styling changes, found these, now fixed and pinned by `tests/upstream-parity.test.tsx`: - *Resizable*: the divider between side-by-side panels was drawn as a flat full-width bar with its grip turned. react-resizable-panels v4 reports the LINE's orientation (a horizontal group has a vertical separator), and the handle styling keyed on the group's. - *Input OTP*: shadcn's composition (`…`, which the site's own code example shows) neither typechecked nor showed typed characters: children were dropped and a render function always passed. InputOTP now renders children through input-otp's context and InputOTPSlot reads its slot by `index`; `containerClassName` merges into the surface instead of replacing it, and `className` styles the input, as in shadcn (**behaviour change**: it used to style the container). Slots take the aria-invalid error state. diff --git a/components/pattern-background.tsx b/components/pattern-background.tsx index 03c8fb0..bfe94ea 100644 --- a/components/pattern-background.tsx +++ b/components/pattern-background.tsx @@ -5,7 +5,9 @@ import { type CSSProperties, useId } from "react"; * (multiple backgrounds + gradients) and SVG `mask-image` silhouettes, all colored off the live * OKLCH tint tokens so they recolor with the theme. No canvas, no rAF loop: each static style is a * single GPU paint; the animated scenes (synthwave / moonrise grid scroll, the clouds / dune / aurora / - * forest drifts, the Pac-Man chase) move on compositor-only transforms. Sibling of GradientBackground + * forest drifts, the Pac-Man chase) move on compositor-only transforms. The cost to watch is LAYERS: + * `.scene-group` keeps what a scene draws in front of its disc to one, and a moving part draws its blur + * and masks on a still child (`.au-paint`), never on the box that moves. Sibling of GradientBackground * (CSS gradient) and CanvasBackground (JS canvas). */ @@ -31,11 +33,11 @@ export const PATTERN_STYLES: PatternStyle[] = [ * Both REMOVE the animation rather than pausing it. `animation-play-state: paused` * looks like it should be free — the transform never advances — but a paused * animation still leaves the element in an animating state, and WebKit keeps - * re-rendering its layer. On `.au-curtain` that layer carries a `filter: blur()` - * and a two-layer `mask-composite`, six of them at full-viewport size, so "paused" - * was measurably NOT free (AMXSF-559). `animation: none` also drops `will-change`, - * releasing the promoted layer — `will-change` on a stopped animation is a - * promoted layer doing no work. + * re-rendering its layer. On `.au-curtain` that layer carried a `filter: blur()` + * and a two-layer `mask-composite` (its `.au-paint` child draws them now), six of + * them at full-viewport size, so "paused" was measurably NOT free (AMXSF-559). + * `animation: none` also drops `will-change`, releasing the promoted layer — + * `will-change` on a stopped animation is a promoted layer doing no work. * * Emitted from one helper so the two conditions cannot drift apart: a scene that * respects reduced motion but not Speed 0 was exactly the bug. @@ -442,6 +444,19 @@ const SKY_STYLES = ` and the new one showed only its lower half. As a layer, the move just repositions it. */ will-change: transform; } +/* Paints its children as ONE compositing layer. WebKit gives every element that overlaps an earlier + composited one a layer of its own, so the z-order holds, and a disc is a layer (above), so loose, every + ridge, twin, deck and veil drawn after it became a full-viewport layer: dune had 52 of them while + static, and the window server re-applied each one's mask and blur whenever anything on the page above + them changed. Inside one stacking context they paint into a single backing store instead, masks and + blurs drawn once. Each scene wraps everything in front of its disc in one, and, inside it, any still + run that sits over a MOVING part (a fog bank, the water), so only what moves gets layers of its own. + Same box as the scene (inset 0), so the children's percentages and insets resolve unchanged. */ +.scene-group { + position: absolute; + inset: 0; + isolation: isolate; +} /* Whole-pixel discs. WebKit sizes a filtered element's buffer from its box, and at a fractional width (30vh is 275.4px on a 918px viewport) it clips a sliver off the disc's left edge, which flattens a moon visibly. Rounding to an even pixel count keeps the halves whole. Behind @supports because @@ -1429,75 +1444,77 @@ function AuroraStreaks({ layer }: { layer: AuLayer }) { const tiles = Math.ceil(700 / vh) + 1; return (
-
+ + + {streaks.map((st) => ( + + ))} + {streaks.map((st) => ( + + ))} + + + {Array.from( + { + length: tiles, + }, + (_, k) => ( + + ), + )} + +
); } @@ -1642,34 +1659,43 @@ const AURORA_STYLES = ` var(--au-water-a) 65% ); } +/* A curtain is two boxes: this one MOVES (sway, shimmer: transform and opacity only), and its .au-paint + child carries everything it draws. A moving element is a compositing layer, and on a layer its own + blur, masks and clip are re-applied by the window server on every frame; on a child they are drawn into + the layer once and then just carried along. Same picture, since all three apply before the transform. */ .au-curtain { position: absolute; top: -6%; + bottom: 39%; + left: -20vw; + right: -20vw; + transform-origin: 50% 100%; + transform: skewX(var(--au-lean, -6deg)); + opacity: calc(var(--au-lo, 1) * var(--au-dim, 1)); + animation: + au-sway calc(var(--pat-dur, 8s) * var(--au-ks, 3)) ease-in-out infinite alternate, + au-shimmer calc(var(--pat-dur, 8s) * var(--au-kp, 2)) ease-in-out infinite alternate; + animation-delay: var(--au-delay, 0s); + will-change: var(--pat-wc, transform), var(--pat-wc, opacity); +} +.au-paint { + position: absolute; + inset: 0; /* The box runs 3vh PAST the waterline and is CLIPPED exactly on it. Ending the box at 58% let the blur eat its last few px, a dark bar of bare scene gradient along the horizon; running it past and leaving it visible stacked it under the reflection, a bright strip; feathering it under the water sheet crossfaded three layers at once, a dark one. Clipped, the blurred lip runs at full strength right to the line and the reflection takes over there. (The clip is pre-transform, but sway is a skewX and a translateX, neither of which moves a horizontal edge.) */ - bottom: 39%; clip-path: inset(0 0 3vh 0); - left: -20vw; - right: -20vw; + background-image: var(--au-rays); background-repeat: repeat-x; background-size: var(--au-tile) 100%; - transform-origin: 50% 100%; - transform: skewX(var(--au-lean, -6deg)); - opacity: calc(var(--au-lo, 1) * var(--au-dim, 1)); filter: blur(calc(var(--au-blur, 1.2vh) * var(--au-blur-m, 1) * var(--au-crisp, 1))); -webkit-mask-image: var(--au-fade, linear-gradient(to top, #000 13%, rgb(0 0 0 / 0.62) 47%, rgb(0 0 0 / 0.2) 81%, transparent 97%)), linear-gradient(to right, transparent 3%, #000 14% 86%, transparent 97%); mask-image: var(--au-fade, linear-gradient(to top, #000 13%, rgb(0 0 0 / 0.62) 47%, rgb(0 0 0 / 0.2) 81%, transparent 97%)), linear-gradient(to right, transparent 3%, #000 14% 86%, transparent 97%); -webkit-mask-composite: source-in; mask-composite: intersect; - animation: - au-sway calc(var(--pat-dur, 8s) * var(--au-ks, 3)) ease-in-out infinite alternate, - au-shimmer calc(var(--pat-dur, 8s) * var(--au-kp, 2)) ease-in-out infinite alternate; - animation-delay: var(--au-delay, 0s); - will-change: var(--pat-wc, transform), var(--pat-wc, opacity); } @keyframes au-sway { from { transform: translateX(calc(-1 * var(--au-amp, 3vw))) skewX(var(--au-lean, -6deg)); } @@ -1688,7 +1714,7 @@ const AURORA_STYLES = ` --au-sway-to: 3deg; --au-blur: 1.2vh; --au-lo: 1; - background-image: ${rays(AU_TILES.a.tile)}; + --au-rays: ${rays(AU_TILES.a.tile)}; } .au-b { --au-tile: ${AU_TILES.b.vh}vh; @@ -1700,7 +1726,7 @@ const AURORA_STYLES = ` --au-blur: 2vh; --au-lo: 0.75; --au-delay: -7s; - background-image: ${rays(AU_TILES.b.tile)}; + --au-rays: ${rays(AU_TILES.b.tile)}; } .au-c { --au-tile: ${AU_TILES.c.vh}vh; @@ -1712,7 +1738,7 @@ const AURORA_STYLES = ` --au-blur: 0.7vh; --au-lo: 0.85; --au-delay: -13s; - background-image: ${rays(AU_TILES.c.tile)}; + --au-rays: ${rays(AU_TILES.c.tile)}; } /* By day the wide violet-pink veil layer (B) all but steps back: it filled every gap between the other two layers' rays, so no clear sky showed anywhere and the lights read as a haze, not as rays. */ @@ -1759,9 +1785,11 @@ const AURORA_STYLES = ` /* Past the waterline by the same 3vh as the sky curtains (the water box clips it there), so the blur cannot eat the reflection's lip at the line, and its skew pivot mirrors theirs through it. */ bottom: -3vh; - clip-path: none; --au-dim: 1; --au-blur-m: var(--au-mirror-blur-m, 1.5); +} +.au-mirror .au-paint { + clip-path: none; /* Same lip fade + side feathers as the sky curtains, INTERSECTED with a GENTLE wavelet ripple — The mirror runs at --au-dim 1 with a brighter vertical falloff to hold the reflection at full strength while the SKY curtains were thinned to let stars and sky through — same colours feed both, @@ -1777,7 +1805,7 @@ const AURORA_STYLES = ` /* By day AuroraStreaks' streaks stand in for the gradient mirror: with crisp day rays, a plain mirror of them read as identical bars ruled down the water. Night keeps the soft gradient mirror, where the blur already hides the edges. */ -.au-curtain.au-streaks { +.au-streaks .au-paint { background-image: none; overflow: hidden; } @@ -1829,7 +1857,10 @@ const AURORA_STYLES = ` (the curtains are literally mirrored), so a sun with no reflection was the one element that looked pasted on. Not a mirrored disc: on real water the reflection smears into a vertical column that widens with distance, so this is a tapered gradient broken up by the same ripple rhythm as the - mirror. Gated on --sw-sun-o, so it appears and disappears with the sun itself. */ + mirror. Gated on --sw-sun-o, so it appears and disappears with the sun itself. + Its own compositing layer, like the disc it reflects (see .sw-sun): it moves with the disc, and moving a + masked, blurred element painted on a shared layer is what left half a disc behind. The glints are drawn + by the child (.au-sunpath-paint), so their blur and masks go into the layer once, as a curtain's do. */ .au-sunpath { position: absolute; left: var(--sw-disc-x, 50%); @@ -1838,6 +1869,11 @@ const AURORA_STYLES = ` width: calc(var(--sun-size, 38vh) * 2.4); transform: translateX(-50%); opacity: calc(0.62 * var(--sw-sun-o, 0)); + will-change: transform; +} +.au-sunpath-paint { + position: absolute; + inset: 0; /* Built from STACKED GLINTS, not a clipped wedge. clip-path gave dead-straight diagonals that read as an engineered triangle; real glitter is a scatter of broken highlights that widens with distance. Each ellipse is one band of glints — progressively wider going down, nudged off the centre line so @@ -3903,13 +3939,15 @@ export function PatternBackground({ />
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -3944,11 +3982,13 @@ export function PatternBackground({
-
-
-
-
-
+
+
+
+
+
+
+
); @@ -4006,35 +4046,37 @@ export function PatternBackground({
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Wind veils ride ABOVE the ridges and BELOW the haze — sand in the air, not on the ground. */} -
-
-
-
- {/* Haze LAST-but-one: it must wash over the far ridges, not sit behind them. */} -
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Wind veils ride ABOVE the ridges and BELOW the haze — sand in the air, not on the ground. */} +
+
+
+
+ {/* Haze LAST-but-one: it must wash over the far ridges, not sit behind them. */} +
+
+
+
+
+
+
@@ -4067,30 +4109,53 @@ export function PatternBackground({ />
-
-
-
+
+
+
+
+
+
+
+
+
{/* AFTER the curtains: behind them, three layers of translucent light wash the disc green and it stops reading as a sun. Still before the glow and water, so the haze and waterline - cover it the way they cover everything else at the horizon. */} -
+ cover it the way they cover everything else at the horizon. The sky patch is grouped + because the curtains under it move: as a layer of its own, its mask would be re-applied on + every frame (see .scene-group). */} +
+
+
-
-
-
-
-
-
- - - +
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
+
-
-
+ {/* Still, over the moving reflection. */} +
+
+
+
+
-
-
); @@ -4142,17 +4207,22 @@ export function PatternBackground({ {/* The disc goes down first: every ridge stands in front of it. */}
- {ridge(0)} - {ridge(1)} -
-
- {ridge(2)} -
- {/* Rays between the middle ridges: in front of the far ranks, behind the near trees. */} -
- {ridge(3)} -
- {ridge(4)} +
+ {ridge(0)} + {ridge(1)} +
+
+ {ridge(2)} +
+ {/* Still, over a drifting fog bank: grouped so the rays' mask is drawn once (see .scene-group). + Rays between the middle ridges: in front of the far ranks, behind the near trees. */} +
+
+ {ridge(3)} +
+
+ {ridge(4)} +
); diff --git a/public/r/background-controller.json b/public/r/background-controller.json index 7d65b7e..10b769e 100644 --- a/public/r/background-controller.json +++ b/public/r/background-controller.json @@ -22,7 +22,7 @@ }, { "path": "components/pattern-background.tsx", - "content": "import { type CSSProperties, useId } from \"react\";\n\n/**\n * Pure-CSS pattern backgrounds — tileable wallpapers built entirely from `background-image` layers\n * (multiple backgrounds + gradients) and SVG `mask-image` silhouettes, all colored off the live\n * OKLCH tint tokens so they recolor with the theme. No canvas, no rAF loop: each static style is a\n * single GPU paint; the animated scenes (synthwave / moonrise grid scroll, the clouds / dune / aurora /\n * forest drifts, the Pac-Man chase) move on compositor-only transforms. Sibling of GradientBackground\n * (CSS gradient) and CanvasBackground (JS canvas).\n */\n\nexport type PatternStyle = \"dots\" | \"grid\" | \"mesh\" | \"starfield\" | \"synthwave\" | \"moonrise\" | \"clouds\" | \"dune\" | \"aurora\" | \"forest\" | \"chase\";\n\nexport const PATTERN_STYLES: PatternStyle[] = [\n \"dots\",\n \"grid\",\n \"mesh\",\n \"starfield\",\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"forest\",\n \"chase\",\n];\n\n/**\n * The two ways a scene stops: reduced motion, and Speed 0.\n *\n * Both REMOVE the animation rather than pausing it. `animation-play-state: paused`\n * looks like it should be free — the transform never advances — but a paused\n * animation still leaves the element in an animating state, and WebKit keeps\n * re-rendering its layer. On `.au-curtain` that layer carries a `filter: blur()`\n * and a two-layer `mask-composite`, six of them at full-viewport size, so \"paused\"\n * was measurably NOT free (AMXSF-559). `animation: none` also drops `will-change`,\n * releasing the promoted layer — `will-change` on a stopped animation is a\n * promoted layer doing no work.\n *\n * Emitted from one helper so the two conditions cannot drift apart: a scene that\n * respects reduced motion but not Speed 0 was exactly the bug.\n */\nexport function stopRules(...selectors: string[]): string {\n const stop = \"{ animation: none; will-change: auto; }\";\n return `@media (prefers-reduced-motion: reduce) { ${selectors.join(\", \")} ${stop} }\n${selectors.map((s) => `[data-pat-static] ${s}`).join(\", \")} ${stop}`;\n}\n\n/** Patterns that animate (CSS keyframes) — gates the speed control and shares the --pat-dur pace. */\nexport const ANIMATED_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"forest\",\n \"chase\",\n]);\n\n/** Horizon scenes with a sun/moon disc that can be placed left / center / right. */\nexport const DISC_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"forest\",\n]);\n\n/** Scenes with a blowing-sand control (still / breeze / storm). */\nexport const SAND_PATTERNS = new Set([\n \"dune\",\n]);\n\n/** Star-field density — how many of the star field's points render (a \"range of stars\"). */\nexport type PatternDensity = \"sparse\" | \"medium\" | \"dense\";\nexport const PATTERN_DENSITIES: PatternDensity[] = [\n \"sparse\",\n \"medium\",\n \"dense\",\n];\nconst STAR_COUNT: Record = {\n sparse: 16,\n medium: 33,\n dense: 50,\n};\n/** Chase pellet-cell scale per density — sparse = larger cells (fewer per screen), dense = smaller (more). */\nconst DENSITY_SCALE: Record = {\n sparse: 1.4,\n medium: 1,\n dense: 0.68,\n};\n/** Tile-size scale for the geometric tiles (dots / grid) — a much airier ladder: the old sparse (1.4)\n * is the new DENSE, with medium and sparse extrapolated outward on the same ~1.4× step. */\nexport const TILE_SCALE: Record = {\n sparse: 2.8,\n medium: 2,\n dense: 1.4,\n};\n\n// SVG silhouettes as mask sources. White fill + transparent ground works whether the browser samples\n// the mask by alpha or luminance. Wrapped in url(...) after encoding so it's a valid CSS value.\n// `stretch: true` sets preserveAspectRatio='none' so a wide silhouette (the dune crests) fills the\n// element's 100% × 100% mask box instead of letterboxing to the viewBox aspect (the SVG default —\n// which renders a full-width mask as a centered strip with cliff edges).\nconst svgMask = (body: string, viewBox = \"0 0 100 100\", stretch = false) =>\n `url(\"data:image/svg+xml,${encodeURIComponent(`${body}`)}\")`;\n\n// The ghost silhouette — used by the chase scene's fleeing ghosts. Solid: the eyes are painted over it\n// (.pac-ghost::after). Cut out of it, they showed whatever lay behind, so a pellet read as a stray pupil.\nconst GHOST = svgMask(\"\");\n\n// Scattered \"points\" for the galaxy — a fixed set so it's deterministic (SSR-safe, no Math.random).\n// Kept as an array so the density control can render just a slice of them.\nconst STAR_GRADIENTS = [\n [\n 8,\n 12,\n ],\n [\n 22,\n 64,\n ],\n [\n 15,\n 88,\n ],\n [\n 35,\n 26,\n ],\n [\n 44,\n 72,\n ],\n [\n 52,\n 16,\n ],\n [\n 61,\n 52,\n ],\n [\n 68,\n 84,\n ],\n [\n 73,\n 34,\n ],\n [\n 82,\n 66,\n ],\n [\n 88,\n 20,\n ],\n [\n 92,\n 80,\n ],\n [\n 28,\n 44,\n ],\n [\n 57,\n 90,\n ],\n [\n 4,\n 40,\n ],\n [\n 12,\n 6,\n ],\n [\n 19,\n 30,\n ],\n [\n 26,\n 78,\n ],\n [\n 33,\n 54,\n ],\n [\n 40,\n 20,\n ],\n [\n 47,\n 46,\n ],\n [\n 54,\n 68,\n ],\n [\n 63,\n 12,\n ],\n [\n 66,\n 40,\n ],\n [\n 70,\n 60,\n ],\n [\n 77,\n 78,\n ],\n [\n 80,\n 8,\n ],\n [\n 84,\n 48,\n ],\n [\n 90,\n 60,\n ],\n [\n 95,\n 34,\n ],\n [\n 6,\n 70,\n ],\n [\n 48,\n 4,\n ],\n [\n 58,\n 30,\n ],\n [\n 76,\n 22,\n ],\n [\n 2,\n 22,\n ],\n [\n 16,\n 50,\n ],\n [\n 24,\n 14,\n ],\n [\n 38,\n 88,\n ],\n [\n 50,\n 40,\n ],\n [\n 56,\n 58,\n ],\n [\n 64,\n 74,\n ],\n [\n 72,\n 46,\n ],\n [\n 86,\n 38,\n ],\n [\n 94,\n 12,\n ],\n [\n 10,\n 82,\n ],\n [\n 30,\n 68,\n ],\n [\n 42,\n 34,\n ],\n [\n 60,\n 6,\n ],\n [\n 74,\n 90,\n ],\n [\n 96,\n 66,\n ],\n].map(([x, y], i) => {\n // a range of stars — occasional bright glints, most fine\n const r = i % 7 === 0 ? \"2px\" : i % 4 === 0 ? \"1.6px\" : i % 3 === 0 ? \"1.3px\" : \"1px\";\n /* --star-core is the fraction of the dot that stays SOLID before the falloff starts. Default 0%\n reproduces the original `#fff, transparent` exactly (white at 0, transparent at 100% — a pure\n linear falloff), so every scene that does not set it is untouched.\n It exists because \"full opacity\" was not the same as \"not faded\": a linear falloff averages only\n alpha 0.333 across the dot's area, so on a light sky the stars delivered ~+7 L rather than the\n +22 their peak suggests. On a near-black night sky that is invisible as a problem — any white\n reads — which is why it only ever showed up by day. */\n return `radial-gradient(${r} ${r} at ${x}% ${y}%, #fff 0 var(--star-core, 0%), transparent)`;\n});\n\n// Easter egg: the \"Muse\" — the Agent mascot's head (fedora + visored helmet) traced as a sparse star\n// constellation in every star field (starfield / synthwave / moonrise / clouds / dune / aurora). The dots are anchor points sampled\n// from the mascot SVG's bezier paths, cropped to the head (the coat sprawled into unreadable scatter),\n// thinned to ~45 (downsample, dedupe) and re-normalized; rendered as stars only — no fill, no lines — so the\n// fedora-and-helmet silhouette hides among the real stars until you catch it. Hue follows the accent-or-tint.\n// biome-ignore format: keep the sampled dots as one compact \"x,y x,y …\" string (not a 400-line array).\nconst MUSE_DOTS = \"67,66.5 65.1,52.1 66.7,42.3 75.8,38.4 11,48.8 82.9,45.3 92.3,42.7 100,36.8 84.4,32.8 85.2,58.8 75.5,65.3 71.9,58.3 51.9,42.9 26.4,48.3 60.9,59.3 38.5,42.3 0,50.4 18.3,38 19.3,28.2 19.9,14.7 25.7,5.9 37.5,2.8 51.1,0 62.9,0.5 72.8,5.6 77.5,15.3 79.7,25.7 96.2,90.9 67,78 43.4,85.6 87.4,72.2 87.4,82.7 16.9,65.6 48.2,59.2 37.5,62 28.1,64 19,87 27.2,88.7 88.4,92.4 38.6,70.3 51.5,81.1 21.5,73 33.6,81 53.7,65.1 30.7,40.5\".split(\" \").map((p) => p.split(\",\").map(Number));\nconst MUSE_STYLES = `\n.star-muse {\n position: absolute;\n left: 6%;\n top: 3%;\n width: 58vh;\n height: 53.6vh;\n pointer-events: none;\n --muse-h: var(--accent-h, var(--glass-tint-h));\n filter: drop-shadow(0 0 1px oklch(0.72 0.17 var(--muse-h) / 0.5));\n /* Rides the star gate: hidden with the stars in the Tron scenes' day mode (they set it to 0). In\n starfield the gate is 1 in BOTH modes now, so this sits at a flat 0.9 — the constellation is an\n easter egg and reads a touch under the field it hides in, rather than being mode-dependent. */\n opacity: calc(0.9 * var(--sw-stars-o, 1));\n}\n.star-muse circle {\n fill: oklch(0.96 0.04 var(--muse-h));\n}`;\n\nfunction MuseConstellation() {\n return (\n <>\n \n \n {MUSE_DOTS.map(([x, y], i) => (\n \n ))}\n \n \n );\n}\n\n// Shared sky furniture for the horizon scenes — the star layer (upper sky, fading toward the horizon,\n// day-hidden via the --sw-stars-o gate the palettes flip) and the palette-driven DISC. Every disc color is\n// a scene var (--sw-moon-1/2/3 core, --sw-maria L C H triple, --sw-disc-glow halo) set per mode by the\n// scene's [data-pattern] blocks, and --sw-disc-x carries the left/center/right placement control — so one\n// rule paints a rising violet moon, a desert silver moon, or a SUN with zero reskins. The sun is not a\n// second element: --sw-maria-o zeroes the craters (a sun has none) and --sw-disc-bloom adds the wide\n// second halo a bright source throws, which a moon does not. Both default to the moon's behaviour, so\n// moonrise and every night palette are untouched by their existence.\n// Placement knobs default to moonrise's horizon-dipping moon (40vh disc sitting ON the 42% horizon);\n// sky-borne scenes (clouds / dune) lift it with --moon-bottom and cancel the dip.\nconst SKY_STYLES = `\n.sw-stars {\n position: absolute;\n inset: 0 0 42% 0;\n background-repeat: no-repeat;\n opacity: var(--sw-stars-o, 1);\n -webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n}\n.scene-moon {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: var(--moon-bottom, 42%);\n width: var(--moon-size, 40vh);\n height: var(--moon-size, 40vh);\n transform: translate(-50%, var(--moon-dip, 30%));\n border-radius: 50%;\n background:\n radial-gradient(34% 30% at 63% 30%, oklch(var(--sw-maria) / calc(0.55 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(20% 18% at 38% 56%, oklch(var(--sw-maria) / calc(0.5 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(13% 12% at 56% 72%, oklch(var(--sw-maria) / calc(0.45 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(circle at 42% 38%, var(--sw-moon-1) 0%, var(--sw-moon-2) 52%, var(--sw-moon-3) 100%);\n /* Two halos. The first is the moon's; the second is the sun's wide atmospheric bloom, a no-op\n (0 radius, transparent) unless a palette opts in. */\n filter:\n drop-shadow(0 0 var(--sw-disc-blur, 5vh) var(--sw-disc-glow))\n drop-shadow(0 0 var(--sw-disc-bloom, 0) var(--sw-disc-bloom-c, transparent));\n opacity: var(--sw-moon-o, 1);\n /* Its own compositing layer, as .sw-sun below: see the note there. */\n will-change: transform;\n}\n/* The OUTRUN SUN — synthwave's disc, promoted to shared furniture so clouds / dune / aurora can put the\n same low banded sun on their day horizon. It sits ON the 42% line and dips below it; whatever the scene\n draws next (cloud deck, sand, water) covers the dipped half, so it reads as sitting in the horizon.\n Both discs render in every scene that wants either; --sw-sun-o / --sw-moon-o pick which one shows per\n mode, the same way --sw-stars-o gates the stars. Defaults are 1, so synthwave and moonrise — which\n choose their disc in JSX instead — are untouched. */\n.sw-sun {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: 42%;\n width: var(--sun-size, 46vh);\n height: var(--sun-size, 46vh);\n transform: translate(-50%, 33%);\n border-radius: 50%;\n opacity: var(--sw-sun-o, 1);\n background: linear-gradient(\n to top,\n var(--sw-disc-1) 0%,\n var(--sw-disc-2) 24%,\n var(--sw-disc-3) 52%,\n var(--sw-disc-4) 78%,\n var(--sw-disc-5) 100%\n );\n -webkit-mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n filter: drop-shadow(0 0 5vh var(--sw-disc-glow));\n /* Its own compositing LAYER. Moving a disc (the left / center / right control changes \\`left\\`) on the\n page's own layer asks WebKit to repaint the old and new spots, and for a disc carrying a drop-shadow\n filter AND a mask it repainted only part of each: the old sun's top stayed painted at the old spot\n and the new one showed only its lower half. As a layer, the move just repositions it. */\n will-change: transform;\n}\n/* Whole-pixel discs. WebKit sizes a filtered element's buffer from its box, and at a fractional width\n (30vh is 275.4px on a 918px viewport) it clips a sliver off the disc's left edge, which flattens a\n moon visibly. Rounding to an even pixel count keeps the halves whole. Behind @supports because\n round() only reached Chrome in 125: inside var() an unsupported function is invalid at computed-value\n time, which would drop the size outright rather than fall back to the declaration above. */\n@supports (width: round(1px, 2px)) {\n .scene-moon {\n width: round(var(--moon-size, 40vh), 2px);\n height: round(var(--moon-size, 40vh), 2px);\n }\n .sw-sun {\n width: round(var(--sun-size, 46vh), 2px);\n height: round(var(--sun-size, 46vh), 2px);\n }\n}`;\n\n// Synthwave / Tron horizon — two variants: `synthwave` (outrun sun) and `moonrise` (purple-blue moon),\n// each with a DAY and a NIGHT palette so the scene follows the light/dark toggle. Every scene color is a\n// --sw-* custom property: day values sit on [data-pattern=\"…\"], night values on `.dark [data-pattern=\"…\"]`\n// (the injected \n
\n
\n {PAC_WALLS.map((w) => (\n \n ))}\n {/* Every wake under every sprite (see .pac-wake-runner), then the sprites. */}\n {PAC_LANES.map((lane) => (\n
\n
\n
\n
\n
\n ))}\n {PAC_VLANES.map((lane) => (\n
\n
\n
\n
\n
\n ))}\n {PAC_LANES.map((lane) => (\n
\n
\n
\n
\n
\n
\n
\n
\n ))}\n {PAC_VLANES.map((lane) => (\n
\n
\n
\n
\n
\n
\n
\n
\n ))}\n
\n
\n );\n }\n if (style === \"synthwave\" || style === \"moonrise\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n const moon = style === \"moonrise\";\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"clouds\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"dune\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n // Dune faces light toward the disc: the diagonal face gradients read --du-light, so moving the\n // moon left/center/right relights every dune (and the ground sheet) to match.\n const duneLight = {\n left: \"160deg\",\n center: \"180deg\",\n right: \"200deg\",\n }[disc];\n // Sand level → veil opacity + speck multiplier. \"still\" zeroes BOTH (a calm desert has no airborne\n // sand at all); it is not the same as the speed control's \"static\", which only pauses motion.\n const [sandVeil, sandGrain] = {\n still: [\n \"0\",\n \"0\",\n ],\n breeze: [\n \"0.55\",\n \"1\",\n ],\n storm: [\n \"1\",\n \"1.8\",\n ],\n }[sand];\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n {/* Wind veils ride ABOVE the ridges and BELOW the haze — sand in the air, not on the ground. */}\n
\n
\n
\n
\n {/* Haze LAST-but-one: it must wash over the far ridges, not sit behind them. */}\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"aurora\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n {/* AFTER the curtains: behind them, three layers of translucent light wash the disc green and\n it stops reading as a sun. Still before the glow and water, so the haze and waterline\n cover it the way they cover everything else at the horizon. */}\n
\n
\n
\n
\n
\n
\n
\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"forest\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n // Which facet takes the light: the side facing the disc. Centred, the light still leans left a\n // little; an even split would erase the facets and leave flat silhouettes.\n const [litL, litR] = {\n left: [\n \"1\",\n \"0\",\n ],\n center: [\n \"0.7\",\n \"0.25\",\n ],\n right: [\n \"0\",\n \"1\",\n ],\n }[disc];\n const ridge = (i: number) => ;\n return (\n \n \n \n
\n \n \n {/* The disc goes down first: every ridge stands in front of it. */}\n
\n
\n {ridge(0)}\n {ridge(1)}\n
\n
\n {ridge(2)}\n
\n {/* Rays between the middle ridges: in front of the far ranks, behind the near trees. */}\n
\n {ridge(3)}\n
\n {ridge(4)}\n
\n
\n );\n }\n if (style === \"starfield\") {\n // Scene colors live in STARFIELD_STYLES' palette blocks (an inline background would beat the .dark\n // day/night flip); only the star GLINTS — white in both modes — are painted inline, on their own\n // layer so the palette's --sw-stars-o can dim them by day.\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n
\n \n \n
\n );\n }\n return (\n \n );\n}\n", + "content": "import { type CSSProperties, useId } from \"react\";\n\n/**\n * Pure-CSS pattern backgrounds — tileable wallpapers built entirely from `background-image` layers\n * (multiple backgrounds + gradients) and SVG `mask-image` silhouettes, all colored off the live\n * OKLCH tint tokens so they recolor with the theme. No canvas, no rAF loop: each static style is a\n * single GPU paint; the animated scenes (synthwave / moonrise grid scroll, the clouds / dune / aurora /\n * forest drifts, the Pac-Man chase) move on compositor-only transforms. The cost to watch is LAYERS:\n * `.scene-group` keeps what a scene draws in front of its disc to one, and a moving part draws its blur\n * and masks on a still child (`.au-paint`), never on the box that moves. Sibling of GradientBackground\n * (CSS gradient) and CanvasBackground (JS canvas).\n */\n\nexport type PatternStyle = \"dots\" | \"grid\" | \"mesh\" | \"starfield\" | \"synthwave\" | \"moonrise\" | \"clouds\" | \"dune\" | \"aurora\" | \"forest\" | \"chase\";\n\nexport const PATTERN_STYLES: PatternStyle[] = [\n \"dots\",\n \"grid\",\n \"mesh\",\n \"starfield\",\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"forest\",\n \"chase\",\n];\n\n/**\n * The two ways a scene stops: reduced motion, and Speed 0.\n *\n * Both REMOVE the animation rather than pausing it. `animation-play-state: paused`\n * looks like it should be free — the transform never advances — but a paused\n * animation still leaves the element in an animating state, and WebKit keeps\n * re-rendering its layer. On `.au-curtain` that layer carried a `filter: blur()`\n * and a two-layer `mask-composite` (its `.au-paint` child draws them now), six of\n * them at full-viewport size, so \"paused\" was measurably NOT free (AMXSF-559).\n * `animation: none` also drops `will-change`, releasing the promoted layer —\n * `will-change` on a stopped animation is a promoted layer doing no work.\n *\n * Emitted from one helper so the two conditions cannot drift apart: a scene that\n * respects reduced motion but not Speed 0 was exactly the bug.\n */\nexport function stopRules(...selectors: string[]): string {\n const stop = \"{ animation: none; will-change: auto; }\";\n return `@media (prefers-reduced-motion: reduce) { ${selectors.join(\", \")} ${stop} }\n${selectors.map((s) => `[data-pat-static] ${s}`).join(\", \")} ${stop}`;\n}\n\n/** Patterns that animate (CSS keyframes) — gates the speed control and shares the --pat-dur pace. */\nexport const ANIMATED_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"forest\",\n \"chase\",\n]);\n\n/** Horizon scenes with a sun/moon disc that can be placed left / center / right. */\nexport const DISC_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"forest\",\n]);\n\n/** Scenes with a blowing-sand control (still / breeze / storm). */\nexport const SAND_PATTERNS = new Set([\n \"dune\",\n]);\n\n/** Star-field density — how many of the star field's points render (a \"range of stars\"). */\nexport type PatternDensity = \"sparse\" | \"medium\" | \"dense\";\nexport const PATTERN_DENSITIES: PatternDensity[] = [\n \"sparse\",\n \"medium\",\n \"dense\",\n];\nconst STAR_COUNT: Record = {\n sparse: 16,\n medium: 33,\n dense: 50,\n};\n/** Chase pellet-cell scale per density — sparse = larger cells (fewer per screen), dense = smaller (more). */\nconst DENSITY_SCALE: Record = {\n sparse: 1.4,\n medium: 1,\n dense: 0.68,\n};\n/** Tile-size scale for the geometric tiles (dots / grid) — a much airier ladder: the old sparse (1.4)\n * is the new DENSE, with medium and sparse extrapolated outward on the same ~1.4× step. */\nexport const TILE_SCALE: Record = {\n sparse: 2.8,\n medium: 2,\n dense: 1.4,\n};\n\n// SVG silhouettes as mask sources. White fill + transparent ground works whether the browser samples\n// the mask by alpha or luminance. Wrapped in url(...) after encoding so it's a valid CSS value.\n// `stretch: true` sets preserveAspectRatio='none' so a wide silhouette (the dune crests) fills the\n// element's 100% × 100% mask box instead of letterboxing to the viewBox aspect (the SVG default —\n// which renders a full-width mask as a centered strip with cliff edges).\nconst svgMask = (body: string, viewBox = \"0 0 100 100\", stretch = false) =>\n `url(\"data:image/svg+xml,${encodeURIComponent(`${body}`)}\")`;\n\n// The ghost silhouette — used by the chase scene's fleeing ghosts. Solid: the eyes are painted over it\n// (.pac-ghost::after). Cut out of it, they showed whatever lay behind, so a pellet read as a stray pupil.\nconst GHOST = svgMask(\"\");\n\n// Scattered \"points\" for the galaxy — a fixed set so it's deterministic (SSR-safe, no Math.random).\n// Kept as an array so the density control can render just a slice of them.\nconst STAR_GRADIENTS = [\n [\n 8,\n 12,\n ],\n [\n 22,\n 64,\n ],\n [\n 15,\n 88,\n ],\n [\n 35,\n 26,\n ],\n [\n 44,\n 72,\n ],\n [\n 52,\n 16,\n ],\n [\n 61,\n 52,\n ],\n [\n 68,\n 84,\n ],\n [\n 73,\n 34,\n ],\n [\n 82,\n 66,\n ],\n [\n 88,\n 20,\n ],\n [\n 92,\n 80,\n ],\n [\n 28,\n 44,\n ],\n [\n 57,\n 90,\n ],\n [\n 4,\n 40,\n ],\n [\n 12,\n 6,\n ],\n [\n 19,\n 30,\n ],\n [\n 26,\n 78,\n ],\n [\n 33,\n 54,\n ],\n [\n 40,\n 20,\n ],\n [\n 47,\n 46,\n ],\n [\n 54,\n 68,\n ],\n [\n 63,\n 12,\n ],\n [\n 66,\n 40,\n ],\n [\n 70,\n 60,\n ],\n [\n 77,\n 78,\n ],\n [\n 80,\n 8,\n ],\n [\n 84,\n 48,\n ],\n [\n 90,\n 60,\n ],\n [\n 95,\n 34,\n ],\n [\n 6,\n 70,\n ],\n [\n 48,\n 4,\n ],\n [\n 58,\n 30,\n ],\n [\n 76,\n 22,\n ],\n [\n 2,\n 22,\n ],\n [\n 16,\n 50,\n ],\n [\n 24,\n 14,\n ],\n [\n 38,\n 88,\n ],\n [\n 50,\n 40,\n ],\n [\n 56,\n 58,\n ],\n [\n 64,\n 74,\n ],\n [\n 72,\n 46,\n ],\n [\n 86,\n 38,\n ],\n [\n 94,\n 12,\n ],\n [\n 10,\n 82,\n ],\n [\n 30,\n 68,\n ],\n [\n 42,\n 34,\n ],\n [\n 60,\n 6,\n ],\n [\n 74,\n 90,\n ],\n [\n 96,\n 66,\n ],\n].map(([x, y], i) => {\n // a range of stars — occasional bright glints, most fine\n const r = i % 7 === 0 ? \"2px\" : i % 4 === 0 ? \"1.6px\" : i % 3 === 0 ? \"1.3px\" : \"1px\";\n /* --star-core is the fraction of the dot that stays SOLID before the falloff starts. Default 0%\n reproduces the original `#fff, transparent` exactly (white at 0, transparent at 100% — a pure\n linear falloff), so every scene that does not set it is untouched.\n It exists because \"full opacity\" was not the same as \"not faded\": a linear falloff averages only\n alpha 0.333 across the dot's area, so on a light sky the stars delivered ~+7 L rather than the\n +22 their peak suggests. On a near-black night sky that is invisible as a problem — any white\n reads — which is why it only ever showed up by day. */\n return `radial-gradient(${r} ${r} at ${x}% ${y}%, #fff 0 var(--star-core, 0%), transparent)`;\n});\n\n// Easter egg: the \"Muse\" — the Agent mascot's head (fedora + visored helmet) traced as a sparse star\n// constellation in every star field (starfield / synthwave / moonrise / clouds / dune / aurora). The dots are anchor points sampled\n// from the mascot SVG's bezier paths, cropped to the head (the coat sprawled into unreadable scatter),\n// thinned to ~45 (downsample, dedupe) and re-normalized; rendered as stars only — no fill, no lines — so the\n// fedora-and-helmet silhouette hides among the real stars until you catch it. Hue follows the accent-or-tint.\n// biome-ignore format: keep the sampled dots as one compact \"x,y x,y …\" string (not a 400-line array).\nconst MUSE_DOTS = \"67,66.5 65.1,52.1 66.7,42.3 75.8,38.4 11,48.8 82.9,45.3 92.3,42.7 100,36.8 84.4,32.8 85.2,58.8 75.5,65.3 71.9,58.3 51.9,42.9 26.4,48.3 60.9,59.3 38.5,42.3 0,50.4 18.3,38 19.3,28.2 19.9,14.7 25.7,5.9 37.5,2.8 51.1,0 62.9,0.5 72.8,5.6 77.5,15.3 79.7,25.7 96.2,90.9 67,78 43.4,85.6 87.4,72.2 87.4,82.7 16.9,65.6 48.2,59.2 37.5,62 28.1,64 19,87 27.2,88.7 88.4,92.4 38.6,70.3 51.5,81.1 21.5,73 33.6,81 53.7,65.1 30.7,40.5\".split(\" \").map((p) => p.split(\",\").map(Number));\nconst MUSE_STYLES = `\n.star-muse {\n position: absolute;\n left: 6%;\n top: 3%;\n width: 58vh;\n height: 53.6vh;\n pointer-events: none;\n --muse-h: var(--accent-h, var(--glass-tint-h));\n filter: drop-shadow(0 0 1px oklch(0.72 0.17 var(--muse-h) / 0.5));\n /* Rides the star gate: hidden with the stars in the Tron scenes' day mode (they set it to 0). In\n starfield the gate is 1 in BOTH modes now, so this sits at a flat 0.9 — the constellation is an\n easter egg and reads a touch under the field it hides in, rather than being mode-dependent. */\n opacity: calc(0.9 * var(--sw-stars-o, 1));\n}\n.star-muse circle {\n fill: oklch(0.96 0.04 var(--muse-h));\n}`;\n\nfunction MuseConstellation() {\n return (\n <>\n \n \n {MUSE_DOTS.map(([x, y], i) => (\n \n ))}\n \n \n );\n}\n\n// Shared sky furniture for the horizon scenes — the star layer (upper sky, fading toward the horizon,\n// day-hidden via the --sw-stars-o gate the palettes flip) and the palette-driven DISC. Every disc color is\n// a scene var (--sw-moon-1/2/3 core, --sw-maria L C H triple, --sw-disc-glow halo) set per mode by the\n// scene's [data-pattern] blocks, and --sw-disc-x carries the left/center/right placement control — so one\n// rule paints a rising violet moon, a desert silver moon, or a SUN with zero reskins. The sun is not a\n// second element: --sw-maria-o zeroes the craters (a sun has none) and --sw-disc-bloom adds the wide\n// second halo a bright source throws, which a moon does not. Both default to the moon's behaviour, so\n// moonrise and every night palette are untouched by their existence.\n// Placement knobs default to moonrise's horizon-dipping moon (40vh disc sitting ON the 42% horizon);\n// sky-borne scenes (clouds / dune) lift it with --moon-bottom and cancel the dip.\nconst SKY_STYLES = `\n.sw-stars {\n position: absolute;\n inset: 0 0 42% 0;\n background-repeat: no-repeat;\n opacity: var(--sw-stars-o, 1);\n -webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n}\n.scene-moon {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: var(--moon-bottom, 42%);\n width: var(--moon-size, 40vh);\n height: var(--moon-size, 40vh);\n transform: translate(-50%, var(--moon-dip, 30%));\n border-radius: 50%;\n background:\n radial-gradient(34% 30% at 63% 30%, oklch(var(--sw-maria) / calc(0.55 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(20% 18% at 38% 56%, oklch(var(--sw-maria) / calc(0.5 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(13% 12% at 56% 72%, oklch(var(--sw-maria) / calc(0.45 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(circle at 42% 38%, var(--sw-moon-1) 0%, var(--sw-moon-2) 52%, var(--sw-moon-3) 100%);\n /* Two halos. The first is the moon's; the second is the sun's wide atmospheric bloom, a no-op\n (0 radius, transparent) unless a palette opts in. */\n filter:\n drop-shadow(0 0 var(--sw-disc-blur, 5vh) var(--sw-disc-glow))\n drop-shadow(0 0 var(--sw-disc-bloom, 0) var(--sw-disc-bloom-c, transparent));\n opacity: var(--sw-moon-o, 1);\n /* Its own compositing layer, as .sw-sun below: see the note there. */\n will-change: transform;\n}\n/* The OUTRUN SUN — synthwave's disc, promoted to shared furniture so clouds / dune / aurora can put the\n same low banded sun on their day horizon. It sits ON the 42% line and dips below it; whatever the scene\n draws next (cloud deck, sand, water) covers the dipped half, so it reads as sitting in the horizon.\n Both discs render in every scene that wants either; --sw-sun-o / --sw-moon-o pick which one shows per\n mode, the same way --sw-stars-o gates the stars. Defaults are 1, so synthwave and moonrise — which\n choose their disc in JSX instead — are untouched. */\n.sw-sun {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: 42%;\n width: var(--sun-size, 46vh);\n height: var(--sun-size, 46vh);\n transform: translate(-50%, 33%);\n border-radius: 50%;\n opacity: var(--sw-sun-o, 1);\n background: linear-gradient(\n to top,\n var(--sw-disc-1) 0%,\n var(--sw-disc-2) 24%,\n var(--sw-disc-3) 52%,\n var(--sw-disc-4) 78%,\n var(--sw-disc-5) 100%\n );\n -webkit-mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n filter: drop-shadow(0 0 5vh var(--sw-disc-glow));\n /* Its own compositing LAYER. Moving a disc (the left / center / right control changes \\`left\\`) on the\n page's own layer asks WebKit to repaint the old and new spots, and for a disc carrying a drop-shadow\n filter AND a mask it repainted only part of each: the old sun's top stayed painted at the old spot\n and the new one showed only its lower half. As a layer, the move just repositions it. */\n will-change: transform;\n}\n/* Paints its children as ONE compositing layer. WebKit gives every element that overlaps an earlier\n composited one a layer of its own, so the z-order holds, and a disc is a layer (above), so loose, every\n ridge, twin, deck and veil drawn after it became a full-viewport layer: dune had 52 of them while\n static, and the window server re-applied each one's mask and blur whenever anything on the page above\n them changed. Inside one stacking context they paint into a single backing store instead, masks and\n blurs drawn once. Each scene wraps everything in front of its disc in one, and, inside it, any still\n run that sits over a MOVING part (a fog bank, the water), so only what moves gets layers of its own.\n Same box as the scene (inset 0), so the children's percentages and insets resolve unchanged. */\n.scene-group {\n position: absolute;\n inset: 0;\n isolation: isolate;\n}\n/* Whole-pixel discs. WebKit sizes a filtered element's buffer from its box, and at a fractional width\n (30vh is 275.4px on a 918px viewport) it clips a sliver off the disc's left edge, which flattens a\n moon visibly. Rounding to an even pixel count keeps the halves whole. Behind @supports because\n round() only reached Chrome in 125: inside var() an unsupported function is invalid at computed-value\n time, which would drop the size outright rather than fall back to the declaration above. */\n@supports (width: round(1px, 2px)) {\n .scene-moon {\n width: round(var(--moon-size, 40vh), 2px);\n height: round(var(--moon-size, 40vh), 2px);\n }\n .sw-sun {\n width: round(var(--sun-size, 46vh), 2px);\n height: round(var(--sun-size, 46vh), 2px);\n }\n}`;\n\n// Synthwave / Tron horizon — two variants: `synthwave` (outrun sun) and `moonrise` (purple-blue moon),\n// each with a DAY and a NIGHT palette so the scene follows the light/dark toggle. Every scene color is a\n// --sw-* custom property: day values sit on [data-pattern=\"…\"], night values on `.dark [data-pattern=\"…\"]`\n// (the injected \n
\n
\n {PAC_WALLS.map((w) => (\n \n ))}\n {/* Every wake under every sprite (see .pac-wake-runner), then the sprites. */}\n {PAC_LANES.map((lane) => (\n
\n
\n
\n
\n
\n ))}\n {PAC_VLANES.map((lane) => (\n
\n
\n
\n
\n
\n ))}\n {PAC_LANES.map((lane) => (\n
\n
\n
\n
\n
\n
\n
\n
\n ))}\n {PAC_VLANES.map((lane) => (\n
\n
\n
\n
\n
\n
\n
\n
\n ))}\n
\n
\n );\n }\n if (style === \"synthwave\" || style === \"moonrise\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n const moon = style === \"moonrise\";\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"clouds\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"dune\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n // Dune faces light toward the disc: the diagonal face gradients read --du-light, so moving the\n // moon left/center/right relights every dune (and the ground sheet) to match.\n const duneLight = {\n left: \"160deg\",\n center: \"180deg\",\n right: \"200deg\",\n }[disc];\n // Sand level → veil opacity + speck multiplier. \"still\" zeroes BOTH (a calm desert has no airborne\n // sand at all); it is not the same as the speed control's \"static\", which only pauses motion.\n const [sandVeil, sandGrain] = {\n still: [\n \"0\",\n \"0\",\n ],\n breeze: [\n \"0.55\",\n \"1\",\n ],\n storm: [\n \"1\",\n \"1.8\",\n ],\n }[sand];\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n {/* Wind veils ride ABOVE the ridges and BELOW the haze — sand in the air, not on the ground. */}\n
\n
\n
\n
\n {/* Haze LAST-but-one: it must wash over the far ridges, not sit behind them. */}\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"aurora\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n {/* AFTER the curtains: behind them, three layers of translucent light wash the disc green and\n it stops reading as a sun. Still before the glow and water, so the haze and waterline\n cover it the way they cover everything else at the horizon. The sky patch is grouped\n because the curtains under it move: as a layer of its own, its mask would be re-applied on\n every frame (see .scene-group). */}\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n \n \n \n
\n
\n
\n
\n
\n {/* Still, over the moving reflection. */}\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"forest\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n // Which facet takes the light: the side facing the disc. Centred, the light still leans left a\n // little; an even split would erase the facets and leave flat silhouettes.\n const [litL, litR] = {\n left: [\n \"1\",\n \"0\",\n ],\n center: [\n \"0.7\",\n \"0.25\",\n ],\n right: [\n \"0\",\n \"1\",\n ],\n }[disc];\n const ridge = (i: number) => ;\n return (\n \n \n \n
\n \n \n {/* The disc goes down first: every ridge stands in front of it. */}\n
\n
\n
\n {ridge(0)}\n {ridge(1)}\n
\n
\n {ridge(2)}\n
\n {/* Still, over a drifting fog bank: grouped so the rays' mask is drawn once (see .scene-group).\n Rays between the middle ridges: in front of the far ranks, behind the near trees. */}\n
\n
\n {ridge(3)}\n
\n
\n {ridge(4)}\n
\n
\n
\n );\n }\n if (style === \"starfield\") {\n // Scene colors live in STARFIELD_STYLES' palette blocks (an inline background would beat the .dark\n // day/night flip); only the star GLINTS — white in both modes — are painted inline, on their own\n // layer so the palette's --sw-stars-o can dim them by day.\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n
\n \n \n
\n );\n }\n return (\n \n );\n}\n", "type": "registry:component", "target": "components/pattern-background.tsx" }, diff --git a/tests/pattern-background.test.tsx b/tests/pattern-background.test.tsx index 7209231..0b60d3b 100644 --- a/tests/pattern-background.test.tsx +++ b/tests/pattern-background.test.tsx @@ -1,14 +1,14 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { ANIMATED_PATTERNS, PATTERN_STYLES, PatternBackground, stopRules } from "@/components/pattern-background"; +import { ANIMATED_PATTERNS, DISC_PATTERNS, PATTERN_STYLES, PatternBackground, stopRules } from "@/components/pattern-background"; /** * A scene stops for two reasons — `prefers-reduced-motion` and Speed 0 — and the * bug these tests pin is the two DRIFTING APART: every scene honoured reduced * motion while Speed 0 only set `animation-play-state: paused`, which leaves the * element in an animating state and keeps WebKit re-rendering its layer - * (`.au-curtain` carries a `filter: blur()` and a two-layer `mask-composite`, six - * of them at full-viewport size). See AMXSF-562. + * (`.au-curtain` carried a `filter: blur()` and a two-layer `mask-composite`, six + * of them at full-viewport size; its `.au-paint` child draws them now). See AMXSF-562. * * `stopRules` is the single source both conditions are emitted from, so these * assert the property that makes drift impossible rather than the CSS text. @@ -99,6 +99,78 @@ describe("PatternBackground data-pat-static", () => { }); }); +/** + * How many compositing layers a scene costs. A disc is its own layer (`.sw-sun`), and WebKit gives every + * later sibling that overlaps a composited layer a layer of its own, so the z-order holds. Loose, dune drew + * 52 masked full-viewport layers while static, and the window server re-applied every mask and blur on any + * frame where the page above them changed. Two structures keep it to a handful, and these pin both: all + * that a scene draws in front of its disc sits in ONE `.scene-group` (a stacking context, painted as one + * layer), and a box that is a layer of its own (a swaying curtain, the sun path that moves with the disc) + * draws its blur and masks on a child, never on itself. + */ +describe("scene compositing", () => { + const scene = (style: (typeof PATTERN_STYLES)[number]) => { + const host = document.createElement("div"); + host.innerHTML = renderToStaticMarkup(); + return host; + }; + + it.each([ + ...DISC_PATTERNS, + ])("%s draws everything in front of its disc inside one .scene-group", (style) => { + const discs = scene(style).querySelectorAll(".sw-sun, .scene-moon"); + const after: Element[] = []; + for (let el = discs[discs.length - 1]?.nextElementSibling; el; el = el.nextElementSibling) after.push(el); + expect(discs.length).toBeGreaterThan(0); + expect(after.map((el) => el.className)).toEqual([ + "scene-group", + ]); + }); + + // Three curtains in the sky, and the reflection's two sets of three: the night gradient mirror and the + // day streaks. One sun path. + it.each([ + [ + "au-curtain", + "au-paint", + 9, + ], + [ + "au-sunpath", + "au-sunpath-paint", + 1, + ], + ] as const)("draws .%s's blur, masks and clip on its .%s child, not on the layer itself", (box, paint, count) => { + const root = scene("aurora"); + const boxes = [ + ...root.querySelectorAll(`.${box}`), + ]; + expect(boxes).toHaveLength(count); + for (const el of boxes) { + expect( + [ + ...el.children, + ].map((child) => child.className), + ).toEqual([ + paint, + ]); + } + const css = [ + ...root.querySelectorAll("style"), + ] + .map((s) => s.textContent ?? "") + .join("\n") + .replace(/\/\*[\s\S]*?\*\//g, ""); + const rules = [ + ...css.matchAll(/([^{}]*)\{([^{}]*)\}/g), + ].filter(([, selector]) => selector.includes(`.${box}`) && !selector.includes(`.${paint}`)); + expect(rules.length).toBeGreaterThan(0); + for (const [, selector, body] of rules) { + expect(body, selector.trim()).not.toMatch(/filter|mask|clip-path/); + } + }); +}); + describe("ANIMATED_PATTERNS", () => { it("only names real pattern styles", () => { // The set gates the speed control; a stale name there means a scene whose