diff --git a/docs/analytics.md b/docs/analytics.md index d9ee3e7..0589830 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -5,6 +5,15 @@ acceptance. They are not part of the desktop app. Source: `src/lib/analytics.ts`, `src/lib/analyticsConsent.ts`, and `src/components/PrivacyChoices.tsx`. Policy text lives in `src/data/legal.ts`. +The same choice panel is prerendered into `#openstudio-privacy`, outside the +temporarily hidden route root. A small pre-paint script uses the shared +`consentRecord.ts` validator to hide it for an unexpired saved choice. This +script never loads analytics or grants consent. The client portal replaces the +inert static panel before paint and keeps router links, focus and choice handlers +inside the existing app. Without JavaScript, or when initial application loading +fails, the static panel stays hidden so unusable controls cannot cover legal text. +The original loader still covers initial non-legal navigation until it finishes. + ## Build configuration Set these in the hosting provider's **build** environment; Vite embeds them at diff --git a/docs/hero-aura.md b/docs/hero-aura.md new file mode 100644 index 0000000..72342d6 --- /dev/null +++ b/docs/hero-aura.md @@ -0,0 +1,86 @@ +# Home hero Aura lifecycle + +The hero retains the original `pastel-abstract-background-soft-glowing-hd-web-designs` +embed at `aura.promad.design`, with `theme=light`. No renderer settings, effects, +colours, resolution or frame rate are overridden by the website. + +The page renders its existing light fallback before JavaScript. Loading the iframe +starts automatically after the route-ready and intro-hidden signals, two animation +frames and an idle opportunity, with a visible hero in a visible document. There +is no interaction requirement or fixed quiet-period delay. The idle callback has +a two-second maximum scheduling wait; browsers without that API start after the +paint frames. Initial reduced motion does not mount the iframe. + +The readiness signals describe the rendered page, not every lazy image, optional +animation engine or third-party network request. On client navigation the mounted +hero gets its own paint frames even when the initial readiness flags are already +set. Unmounting or enabling reduced motion cancels pending startup work. + +## Readiness and failure + +An iframe's load event cannot confirm that the asynchronous scene request or lazy +renderer succeeded, and is not used to trigger readiness. Active startup frames +remain renderable at zero opacity: `visibility:hidden` can suspend frame callbacks +in a visible browser and deadlock readiness. The host uses the provider's existing `promad-aura:capture` +message API and validates replies against the exact origin, iframe window and +request ID. This API was inspected and exercised against the live embed on +20 September 2026. It is an external, unversioned integration; it is not a web +standard or a provider-guaranteed readiness contract. + +Readiness requires an actual PNG with the opaque, near-white upper centre of this +specific light scene, followed by another successful check after a +1-second settling period. The current capture implementation returns black if +the lazy canvas is absent/unpainted, and an error if the scene container is absent. +Neither is revealed. Replies containing malformed, oversized, transparent or dark +images also leave the fallback in place. A 30-second foreground probe timeout +removes an unresponsive iframe. Unsupported/changed provider behaviour therefore +keeps the fallback instead of revealing a browser error or black background. +The timeout does not automatically retry; a new iframe instance is needed. +Pixel readiness does not establish a healthy frame rate. There is currently no +automatic fallback for slow software rendering after the frame has been verified. + +After verification, the frame fades in over 650 ms using `ease-in-out`. The same +duration applies to fade-out and viewport re-entry. The settling period is a +presentation buffer, not proof of readiness; both successful frame checks remain +required. These shorter timings remove 4.25 seconds of deliberate waiting compared +with the previous 3.5-second settle and 2.4-second fade. Network time and renderer +settings are unchanged. The former interaction/10-second gate has been removed. + +Captures use quarter-size output only for the startup check, never for the visible +animation. The PNG passes between frames inside the browser; the host does not +upload it or invoke a Netlify function. This adds the provider's capture helper download and a small amount of +startup work. Probes stop after readiness, while offscreen, in a hidden tab, on +reduced motion, and on unmount. The timeout restarts if an unfinished scene returns +to the viewport. It does not run indefinitely while offscreen. + +## Pause and resume + +Scrolling away retains the same iframe and its ready state; returning reveals the +same scene without a reload or repeated startup checks. The host tracks viewport +intersection and document visibility and hides the frame when inactive. The +observer consumes all queued visibility records in order: under CPU throttling, +the hidden initial layout and its visible replacement can arrive in one batch. +Reading only the first record strands startup until another viewport change. +The browser regression suite explicitly reproduces this batch. +The current provider also observes intersection and reduced motion internally. +Chromium checks verify actual animation callbacks stop offscreen and resume on +return, not merely that the host's `data-playing` attribute changes. CSS visibility +alone is not a cross-browser pause API, so other engines require live verification. + +Changing the OS reduced-motion preference removes the iframe. Re-enabling motion +creates a new component instance with fresh readiness and cancelled old probes. +This accessibility preference is separate from ordinary viewport pause/resume. + +## Verification + +`test/hero-aura-browser.test.mjs` exercises the cross-origin protocol with controlled +slow, dark, unresponsive and successful scenes, stale readiness after preference +changes, and iframe identity across scrolling. Run `npm run build`, `npm run lint`, +`npm test` and `npm run verify:perf`. + +The initial-load performance gate can now include the automatically started iframe. +Inspect the live scene after activation as well; passing the short gate does not +demonstrate low ongoing CPU use. Preserve the approved scene when comparing visuals, including +phone, tablet, both sides of the 900 px navigation breakpoint, and desktop, and +test delayed uncached navigation and reduced motion. Never make CI depend on live +third-party availability. diff --git a/docs/illustration-loading.md b/docs/illustration-loading.md index 0a5973a..8b669f4 100644 --- a/docs/illustration-loading.md +++ b/docs/illustration-loading.md @@ -25,6 +25,17 @@ their bandwidth without fixing the scene change. ## Current implementation +- Meter canvases use `stage/meterPlayback.ts` in addition to the GSAP gate. + Each canvas must intersect the viewport and the document must be visible; + continuous drawing also requires an active stage and normal motion. Pauses + retain the canvas, RMS smoothing and peak-hold time. Visible static/reduced-motion + meters receive a rest-frame paint, including after resizing, without a loop. + The upstream drawing code and 20 fps limit are unchanged; the integration is a + reproducible patch in `scripts/vendor-openstudio-ui.mjs`. +- Track activity and mixer meters update through local contexts so unchanged + labels/controls skip React rendering. Clip artwork has a separate memoized + boundary whose props exclude playhead time and continuously changing levels. + Choreography, styles and the 30 fps timeline commit rate are unchanged. - Each lazy route imports the renderers it uses. Home, Features, AI and NAM Rack render their real controls and scene in the rest state. There is no temporary screenshot, bitmap crossfade or second scene to replace it. diff --git a/index.html b/index.html index ea8ef21..5fc81ad 100644 --- a/index.html +++ b/index.html @@ -561,6 +561,8 @@ ); })(); + + diff --git a/scripts/prerender-site.mjs b/scripts/prerender-site.mjs index 8452357..9b4f9b1 100644 --- a/scripts/prerender-site.mjs +++ b/scripts/prerender-site.mjs @@ -112,6 +112,11 @@ export function buildRouteHtml(template, route, { manifest, imageIndex = {} }) { } const content = route.html.replace(/(data-sp-reveal="[^"]+")/g, '$1 data-sp-in="true"'); html = html.replace(/
<\/div>/, `
${content}
`); + if (route.privacyHtml && route.privacyBootstrap) { + html = html.replace('id="openstudio-privacy" class="sp-root min-h-0 bg-transparent" hidden inert>
', + `id="openstudio-privacy" class="sp-root min-h-0 bg-transparent" hidden inert>
${route.privacyHtml}
`) + .replace('', ``); + } if (/^\/(privacy|security|terms)$/.test(route.path)) { html = html .replace('', '') diff --git a/scripts/vendor-openstudio-ui.mjs b/scripts/vendor-openstudio-ui.mjs index 2c95892..d25557c 100644 --- a/scripts/vendor-openstudio-ui.mjs +++ b/scripts/vendor-openstudio-ui.mjs @@ -31,7 +31,15 @@ const ATLAS_COLUMNS = 11; /** @type {{ from: string; to: string; patches?: [string, string][] }[]} */ const FILES = [ { from: "components/meterConfig.ts", to: "meterConfig.ts" }, - { from: "components/PeakMeter.tsx", to: "PeakMeter.tsx" }, + { from: "components/PeakMeter.tsx", to: "PeakMeter.tsx", patches: [ + ['import { useEffect, useRef, useCallback } from "react";', 'import { useEffect, useRef, useCallback } from "react";\nimport { startMeterPlayback } from "../stage/meterPlayback";'], + [' const animFrameRef = useRef(null);\n', ''], + ['{ animFrameRef.current = requestAnimationFrame(draw); return; }', '{ return; }'], + [' animFrameRef.current = requestAnimationFrame(draw);\n', ''], + [' animFrameRef.current = requestAnimationFrame(draw);\n', ''], + [' animFrameRef.current = requestAnimationFrame(draw);\n', ''], + [' return () => {\n if (animFrameRef.current !== null) {\n cancelAnimationFrame(animFrameRef.current);\n }\n };', ' const canvas = canvasRef.current;\n if (!canvas) return;\n return startMeterPlayback(canvas, draw, lastDrawTimeRef.current);'], + ] }, { from: "components/MasterPeakMeterCluster.tsx", to: "MasterPeakMeterCluster.tsx" }, { from: "components/NAMRackControlAssets.ts", diff --git a/src/components/HeroAuraBackdrop.tsx b/src/components/HeroAuraBackdrop.tsx new file mode 100644 index 0000000..5fe592d --- /dev/null +++ b/src/components/HeroAuraBackdrop.tsx @@ -0,0 +1,98 @@ +import { useEffect, useRef, useState } from "react"; +import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; +import { afterHeroPageReady } from "@/features/hero-aura/startup"; +import { AURA_ORIGIN, observeAuraReadiness } from "@/features/hero-aura/readiness"; + +const AURA_SCENE = "pastel-abstract-background-soft-glowing-hd-web-designs"; +const AURA_SRC = `${AURA_ORIGIN}/embed/${AURA_SCENE}?theme=light`; +/** + * Full-bleed original Aura scene behind the home hero. + * + * The host repeats the intro loader's surface, so the reveal is continuous, and is what + * prerendering, no-JavaScript and reduced-motion visitors see. The live embed is requested only after the page has + * rendered and the initial loader has finished, and only + * while the hero is on screen in a visible tab. It fades in once its scene has settled, + * and fades out to `visibility: hidden` whenever the hero leaves the viewport or the tab + * is hidden, so the embed stops compositing when nobody can see it. + */ +const HeroAuraBackdrop = () => { + const hostRef = useRef(null); + const reducedMotion = usePrefersReducedMotion(); + const [triggered, setTriggered] = useState(false); + const [active, setActive] = useState(false); + const [mounted, setMounted] = useState(false); + + // Hero on screen and the document visible. + useEffect(() => { + const host = hostRef.current; + if (!host || !("IntersectionObserver" in window)) return; + let intersecting = false; + const update = () => setActive(intersecting && document.visibilityState === "visible"); + const observer = new IntersectionObserver((entries) => { + // A busy main thread can batch the hidden initial layout and its reveal. + // Consume every record so an earlier hidden state cannot strand startup. + for (const entry of entries) { + if (entry.target === host) intersecting = entry.isIntersecting; + } + update(); + }); + observer.observe(host); + document.addEventListener("visibilitychange", update); + return () => { + observer.disconnect(); + document.removeEventListener("visibilitychange", update); + }; + }, []); + + // Start automatically after the page is ready; no interaction or quiet timer. + useEffect(() => { + if (reducedMotion || triggered) return; + return afterHeroPageReady(() => setTriggered(true)); + }, [reducedMotion, triggered]); + + useEffect(() => { + if (triggered && active && !reducedMotion) setMounted(true); + }, [triggered, active, reducedMotion]); + + const showFrame = mounted && !reducedMotion; + + return ( + + ); +}; + +// Own readiness alongside the iframe: removing it for reduced motion also +// discards readiness. Scrolling offscreen keeps this same instance mounted. +const AuraFrame = ({ active }: { active: boolean }) => { + const frameRef = useRef(null); + const [ready, setReady] = useState(false); + const [failed, setFailed] = useState(false); + + useEffect(() => { + const frame = frameRef.current; + if (!frame || !active || ready || failed) return; + return observeAuraReadiness(frame, () => setReady(true), () => setFailed(true)); + }, [active, ready, failed]); + + if (failed) return null; + return ( +