scene: background-scene host (ridge on the login screen) #26

Closed
nibu wants to merge 2 commits from nibu/scene-host into nibu/ridge-scene
7 changed files with 248 additions and 1 deletions

View file

@ -10,6 +10,7 @@
"scripts": {
"start": "vite",
"build": "vite build",
"build:preview": "vite build && node scripts/preview-config.js low",
"preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src/*",

12
scripts/preview-config.js Normal file
View file

@ -0,0 +1,12 @@
// Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json
// in the repo stays 'off'. Usage: node scripts/preview-config.js [tier]
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const tier = process.argv[2] ?? 'low';
const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json');
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
config.backgroundScene = { ...config.backgroundScene, tier };
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
console.log(`${file}: backgroundScene.tier = ${tier}`);

View file

@ -0,0 +1,47 @@
import React, { useEffect, useRef } from 'react';
import { useClientConfig } from '../../hooks/useClientConfig';
import { getFallbackSession } from '../../state/sessions';
import { mountBackgroundScene } from './host';
import { readCapabilities, resolveTier } from './tier';
/** Set on <html> while a scene runs, so signed-out screens can let it show through. */
export const SCENE_ACTIVE_ATTRIBUTE = 'data-scene';
/**
* The one host for the background scene, mounted once at the app root. Fixed behind everything
* (z-index -1 sits above the body's background and below all normal content, so the opaque app
* covers it and the signed-out screens see it through their transparent ground). The ridge scene
* draws its layers into this wrapper beside the canvas, so the wrapper is the positioned box.
* Tier 'off' renders nothing and loads nothing.
*/
export function BackgroundScene() {
const config = useClientConfig().backgroundScene;
const enabled = resolveTier(config, readCapabilities(navigator)) !== 'off';
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!enabled || !canvas) return undefined;
const root = document.documentElement;
const dispose = mountBackgroundScene(config, readCapabilities(navigator), canvas, {
loggedIn: !!getFallbackSession(),
onActive: () => root.setAttribute(SCENE_ACTIVE_ATTRIBUTE, ''),
// eslint-disable-next-line no-console
onFail: (e) => console.warn('Background scene unavailable:', e),
});
return () => {
dispose();
root.removeAttribute(SCENE_ACTIVE_ATTRIBUTE);
};
}, [config, enabled]);
if (!enabled) return null;
return (
<div
aria-hidden
style={{ position: 'fixed', inset: 0, zIndex: -1, pointerEvents: 'none', overflow: 'hidden' }}
>
<canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%' }} />
</div>
);
}

View file

@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Scene } from './index';
import { mountBackgroundScene } from './host';
const { startBackgroundScene } = vi.hoisted(() => ({ startBackgroundScene: vi.fn() }));
vi.mock('./index', () => ({ startBackgroundScene }));
const canvas = {} as HTMLCanvasElement;
const config = { tier: 'low' as const };
const caps = { hardwareConcurrency: 8 };
function fakeScene(): Scene & {
login: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
} {
return { login: vi.fn(), dispose: vi.fn() };
}
const flush = () =>
new Promise<void>((r) => {
setTimeout(r, 0);
});
afterEach(() => {
startBackgroundScene.mockReset();
});
describe('mountBackgroundScene', () => {
it('starts the scene on the canvas with the config and capabilities', async () => {
const scene = fakeScene();
startBackgroundScene.mockResolvedValue(scene);
const onActive = vi.fn();
mountBackgroundScene(config, caps, canvas, { loggedIn: false, onActive });
await flush();
expect(startBackgroundScene).toHaveBeenCalledWith(
config,
caps,
expect.objectContaining({ canvas })
);
expect(onActive).toHaveBeenCalledOnce();
expect(scene.login).not.toHaveBeenCalled();
});
it('disposes the scene on unmount, once', async () => {
const scene = fakeScene();
startBackgroundScene.mockResolvedValue(scene);
const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: false });
await flush();
dispose();
dispose();
expect(scene.dispose).toHaveBeenCalledOnce();
});
it('disposes a scene that resolves after unmount, without activating or logging in', async () => {
const scene = fakeScene();
let resolve!: (s: Scene) => void;
startBackgroundScene.mockReturnValue(
new Promise((r) => {
resolve = r;
})
);
const onActive = vi.fn();
const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: true, onActive });
dispose();
resolve(scene);
await flush();
expect(scene.dispose).toHaveBeenCalledOnce();
expect(scene.login).not.toHaveBeenCalled();
expect(onActive).not.toHaveBeenCalled();
});
it('leaves the ground alone when there is no scene', async () => {
startBackgroundScene.mockResolvedValue(null);
const onActive = vi.fn();
const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: true, onActive });
await flush();
expect(onActive).not.toHaveBeenCalled();
expect(dispose).not.toThrow();
});
it('reports a failed start instead of throwing', async () => {
startBackgroundScene.mockRejectedValue(new Error('boom'));
const onFail = vi.fn();
mountBackgroundScene(config, caps, canvas, { loggedIn: false, onFail });
await flush();
expect(onFail).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' }));
});
it('forwards the scene own compile failure to onFail', async () => {
startBackgroundScene.mockImplementation(async (_c, _k, o) => {
o.onCompileFail(new Error('shader'));
return null;
});
const onFail = vi.fn();
mountBackgroundScene(config, caps, canvas, { loggedIn: false, onFail });
await flush();
expect(onFail).toHaveBeenCalledWith(expect.objectContaining({ message: 'shader' }));
});
it('calls login() once the scene starts when a session already exists', async () => {
const scene = fakeScene();
startBackgroundScene.mockResolvedValue(scene);
mountBackgroundScene(config, caps, canvas, { loggedIn: true });
await flush();
expect(scene.login).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,49 @@
import { startBackgroundScene } from './index';
import type { Scene } from './index';
import type { BackgroundSceneConfig, Capabilities } from './tier';
export type HostOptions = {
/** A Matrix session already exists, so the scene starts past its login moment. */
loggedIn: boolean;
/** The scene is running: the host may now let it show through. Not called when there is none. */
onActive?: () => void;
/** A shader failed to compile, the chunk failed to load, or starting threw. The CSS ground stays. */
onFail?: (e: Error) => void;
};
/**
* Start the background scene on `canvas` and return the function that ends it. The start is async,
* so a dispose that arrives first wins: the scene is disposed the moment it resolves. A null scene
* (tier off, no 2D context, load failure) leaves the canvas untouched and the ground showing.
*/
export function mountBackgroundScene(
config: BackgroundSceneConfig | undefined,
capabilities: Capabilities,
canvas: HTMLCanvasElement,
{ loggedIn, onActive, onFail }: HostOptions
): () => void {
let cancelled = false;
let scene: Scene | null = null;
startBackgroundScene(config, capabilities, { canvas, onCompileFail: onFail }).then(
(started) => {
if (cancelled) {
started?.dispose();
return;
}
scene = started;
if (!scene) return;
onActive?.();
if (loggedIn) scene.login();
},
(e) => {
if (!cancelled) onFail?.(e instanceof Error ? e : new Error(String(e)));
}
);
return () => {
cancelled = true;
scene?.dispose();
scene = null;
};
}

View file

@ -11,6 +11,7 @@ import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig';
import { FeatureCheck } from './FeatureCheck';
import { createRouter } from './Router';
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
import { BackgroundScene } from '../features/scene/BackgroundScene';
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
const queryClient = new QueryClient();
@ -35,6 +36,7 @@ function App() {
>
{(clientConfig) => (
<ClientConfigProvider value={clientConfig}>
<BackgroundScene />
<QueryClientProvider client={queryClient}>
<JotaiProvider>
<RouterProvider router={createRouter(clientConfig, screenSize)} />

View file

@ -1,4 +1,4 @@
import { style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const AuthLayout = style({
@ -51,3 +51,33 @@ export const AuthCardContent = style({
export const AuthFooter = style({
padding: config.space.S200,
});
// While the background scene runs (html[data-scene], set by features/scene/BackgroundScene), the
// signed-out ground is clear and the card is a translucent, blurred pane shaped like a message
// bubble (BubbleContent: radius R500, no border), filled with the sky's bubble colour. The
// --sky-bubble variables come from the sky theme (PR #22); the plain colours are the fallback
// until it lands. 85% fill keeps text >= 4.5:1 over the scene's brightest and darkest backdrops.
// The card is also made shorter so it leaves more of the scene visible on a phone.
const scene = ':root[data-scene]';
globalStyle(`${scene} ${AuthLayout}`, {
backgroundColor: 'transparent',
backgroundImage: 'none',
});
globalStyle(`${scene} ${AuthCard}`, {
backgroundColor: [
'rgba(13, 26, 38, 0.85)',
'color-mix(in srgb, var(--sky-bubble, #0d1a26) 85%, transparent)',
],
color: 'var(--sky-bubble-text, #b7c7d9)',
borderRadius: config.radii.R500,
borderColor: 'transparent',
backdropFilter: 'blur(12px)',
});
globalStyle(`${scene} ${AuthHeader}`, {
backgroundColor: 'transparent',
});
globalStyle(`${scene} ${AuthCardContent}`, {
paddingTop: config.space.S500,
paddingBottom: config.space.S400,
gap: config.space.S500,
});