diff --git a/package.json b/package.json index 298fbf36..a89e624c 100644 --- a/package.json +++ b/package.json @@ -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/*", diff --git a/scripts/preview-config.js b/scripts/preview-config.js new file mode 100644 index 00000000..589f8efa --- /dev/null +++ b/scripts/preview-config.js @@ -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}`); diff --git a/src/app/features/scene/BackgroundScene.tsx b/src/app/features/scene/BackgroundScene.tsx new file mode 100644 index 00000000..a0ae4d49 --- /dev/null +++ b/src/app/features/scene/BackgroundScene.tsx @@ -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 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(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 ( +
+ +
+ ); +} diff --git a/src/app/features/scene/host.test.ts b/src/app/features/scene/host.test.ts new file mode 100644 index 00000000..920e98e4 --- /dev/null +++ b/src/app/features/scene/host.test.ts @@ -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; + dispose: ReturnType; +} { + return { login: vi.fn(), dispose: vi.fn() }; +} +const flush = () => + new Promise((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(); + }); +}); diff --git a/src/app/features/scene/host.ts b/src/app/features/scene/host.ts new file mode 100644 index 00000000..bd28f45c --- /dev/null +++ b/src/app/features/scene/host.ts @@ -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; + }; +} diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 52ec7f20..7b0a1f91 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -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) => ( + diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 5834ad84..a9733ef0 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -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, +});