scene: background-scene host (ridge on the login screen) #26
7 changed files with 248 additions and 1 deletions
|
|
@ -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
12
scripts/preview-config.js
Normal 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}`);
|
||||
47
src/app/features/scene/BackgroundScene.tsx
Normal file
47
src/app/features/scene/BackgroundScene.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/app/features/scene/host.test.ts
Normal file
106
src/app/features/scene/host.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
49
src/app/features/scene/host.ts
Normal file
49
src/app/features/scene/host.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue