Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions docs/hero-aura.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions docs/illustration-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,8 @@
);
})();
</script>
<div id="openstudio-privacy" class="sp-root min-h-0 bg-transparent" hidden inert></div>
<!-- privacy-bootstrap -->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
5 changes: 5 additions & 0 deletions scripts/prerender-site.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 id="root"><\/div>/, `<div id="root"><div data-static-route-content>${content}</div></div>`);
if (route.privacyHtml && route.privacyBootstrap) {
html = html.replace('id="openstudio-privacy" class="sp-root min-h-0 bg-transparent" hidden inert></div>',
`id="openstudio-privacy" class="sp-root min-h-0 bg-transparent" hidden inert><div data-privacy-prerender>${route.privacyHtml}</div></div>`)
.replace('<!-- privacy-bootstrap -->', `<script>${route.privacyBootstrap}</script>`);
}
if (/^\/(privacy|security|terms)$/.test(route.path)) {
html = html
.replace('<html lang="en">', '<html lang="en" data-openstudio-immediate-content>')
Expand Down
10 changes: 9 additions & 1 deletion scripts/vendor-openstudio-ui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>(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",
Expand Down
98 changes: 98 additions & 0 deletions src/components/HeroAuraBackdrop.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div
aria-hidden="true"
className="sp-hero-aura__bg"
data-aura-scene={AURA_SCENE}
data-playing={showFrame && active ? "true" : "false"}
ref={hostRef}
>
{showFrame ? <AuraFrame active={active} /> : null}
</div>
);
};

// 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<HTMLIFrameElement>(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 (
<iframe
ref={frameRef}
className="sp-hero-aura__frame"
data-ready={ready ? "true" : "false"}
src={AURA_SRC}
tabIndex={-1}
title="Pastel Abstract Background – Soft Glowing HD Web Designs"
/>
);
};

export default HeroAuraBackdrop;
15 changes: 15 additions & 0 deletions src/components/PrivacyChoicesPortal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import PrivacyChoices from "./PrivacyChoices";

/** Keep consent outside the route root, which is hidden during lazy startup. */
export default function PrivacyChoicesPortal() {
const host = document.getElementById("openstudio-privacy");
useLayoutEffect(() => {
if (!host) return;
host.querySelector("[data-privacy-prerender]")?.remove();
host.hidden = false;
host.removeAttribute("inert");
}, [host]);
return host ? createPortal(<PrivacyChoices />, host) : <PrivacyChoices />;
}
4 changes: 2 additions & 2 deletions src/components/layout/SiteShell.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback, useContext, useState, type ReactNode } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { StaticRenderContext } from "@/lib/staticRender";
import PrivacyChoices from "@/components/PrivacyChoices";
import PrivacyChoicesPortal from "@/components/PrivacyChoicesPortal";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { IconGradientDefs } from "@/components/ui/primitives";
import { useHashScroll } from "@/hooks/useHashScroll";
Expand Down Expand Up @@ -34,7 +34,7 @@ const SiteShell = () => {
</ErrorBoundary>
</main>
<SiteFooter lead={footerLead} />
{staticRender ? null : <PrivacyChoices />}
{staticRender ? null : <PrivacyChoicesPortal />}
</div>
</FooterLeadContext.Provider>
);
Expand Down
76 changes: 45 additions & 31 deletions src/features/daw-preview/ArrangementLanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,37 +151,9 @@ export const ArrangementLanes = memo(function ArrangementLanes({
data-lane={lane.name}
style={{ height: laneHeight, background: index % 2 === 0 ? "#1a1a1a" : "#171717" }}
>
{lane.clips.map((clip) => {
const metrics = rowMetrics(clip.kind === "midi" ? "midi" : kind, laneHeight);
const clipWidth = Math.max(0, clip.duration * pixelsPerSecond);
if (clipWidth < 1) return null;
const common = {
color: lane.color,
duration: clip.duration,
height: metrics.clipHeight,
muted: lane.muted,
name: clip.label,
offset: clip.offset,
pixelsPerSecond,
recording: clip.recording,
seed: clip.seed ?? index + 1,
tempo,
width: clipWidth,
};
return (
<div
key={clip.label}
className="absolute z-[1] transition-opacity duration-300"
style={{ left: clip.start * pixelsPerSecond, top: metrics.clipInsetY, width: clipWidth, height: metrics.clipHeight, opacity: silenced && transport === "playing" ? 0.45 : 1 }}
>
{clip.kind === "midi" ? (
<MidiClipLite {...common} profile={(clip.profile as MidiProfile) ?? "keys"} selected={clip.selected} />
) : (
<AudioClipLite {...common} profile={(clip.profile as AudioProfile) ?? "mix"} />
)}
</div>
);
})}
<LaneClips clips={lane.clips} color={lane.color} muted={lane.muted} kind={kind}
seed={index + 1} laneHeight={laneHeight} pixelsPerSecond={pixelsPerSecond} tempo={tempo}
silenced={silenced && transport === "playing"} />
</div>
);
})}
Expand All @@ -192,3 +164,45 @@ export const ArrangementLanes = memo(function ArrangementLanes({
</div>
);
});

// Time and meter levels change every frame; the clip artwork usually does not.
const LaneClips = memo(function LaneClips({ clips, color, muted, kind, seed, laneHeight, pixelsPerSecond, tempo, silenced }: {
clips: readonly LaneClip[]; color: string; muted?: boolean; kind: "audio" | "midi";
seed: number; laneHeight: number; pixelsPerSecond: number; tempo: number; silenced: boolean;
}) {
return (
<>
{clips.map((clip) => {
const metrics = rowMetrics(clip.kind === "midi" ? "midi" : kind, laneHeight);
const clipWidth = Math.max(0, clip.duration * pixelsPerSecond);
if (clipWidth < 1) return null;
const common = {
color,
duration: clip.duration,
height: metrics.clipHeight,
muted,
name: clip.label,
offset: clip.offset,
pixelsPerSecond,
recording: clip.recording,
seed: clip.seed ?? seed,
tempo,
width: clipWidth,
};
return (
<div
key={clip.label}
className="absolute z-[1] transition-opacity duration-300"
style={{ left: clip.start * pixelsPerSecond, top: metrics.clipInsetY, width: clipWidth, height: metrics.clipHeight, opacity: silenced ? 0.45 : 1 }}
>
{clip.kind === "midi" ? (
<MidiClipLite {...common} profile={(clip.profile as MidiProfile) ?? "keys"} selected={clip.selected} />
) : (
<AudioClipLite {...common} profile={(clip.profile as AudioProfile) ?? "mix"} />
)}
</div>
);
})}
</>
);
});
Loading
Loading