diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts index 3aecad12..711054f6 100644 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -437,6 +437,30 @@ describe('createRidgeScene', () => { expect(parent.children.some((c: any) => c === readout)).toBe(false); }); + it('a scene started at zero size paints nothing, then builds on the first non-zero resize', async () => { + const { parent, canvas } = host(); + canvas.clientWidth = 0; + canvas.clientHeight = 0; + (window as any).innerWidth = 0; + (window as any).innerHeight = 0; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY }); + expect(s).not.toBeNull(); + expect(parent.children).toEqual([canvas]); + expect(draws).toHaveLength(0); + // Still zero: a resize does not paint either. + window.dispatchEvent(new Event('resize')); + await new Promise((r) => setTimeout(r, 300)); + expect(parent.children).toEqual([canvas]); + (window as any).innerWidth = 400; + (window as any).innerHeight = 800; + window.dispatchEvent(new Event('resize')); + await new Promise((r) => setTimeout(r, 300)); + expect(parent.children.slice(1)).toHaveLength(LAYERS.length); + expect(draws.filter((d) => d === 'sky')).toHaveLength(1); + s!.dispose(); + expect(parent.children).toEqual([canvas]); + }); + it('dispose cancels the loop, the animations and the layers', () => { const { parent, canvas } = host(); const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index eae0df08..d2312aaf 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -180,9 +180,18 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { built = undefined; }; - /** Bake the sky, then the layers. Returns false when a 2D context is missing. */ + /** True while the host has no area to paint (a scene started in a hidden or collapsed container). */ + const hasNoArea = () => { + const { w, h, scale } = viewSize(); + const size = layerSize(w, h, scale); + return size.period < 1 || size.height < 1 || Math.round(h * scale) < 1; + }; + + /** Bake the sky, then the layers. Returns false when a 2D context is missing or there is no area yet. */ const build = (): boolean => { teardownLayers(); + // A zero-size canvas throws on drawImage; wait for the first resize that gives the host an area. + if (hasNoArea()) return false; const { w, h, scale } = viewSize(); const size = layerSize(w, h, scale); canvas.width = size.period; @@ -343,7 +352,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { raf = requestAnimationFrame(tick); }; - if (!build()) return null; + // No area yet is not a failure: the resize listener below builds once the host has one. + if (!build() && !hasNoArea()) return null; if (probe) { readout = document.createElement('div'); Object.assign(readout.style, { diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index 4472b98e..c231527c 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -1,12 +1,8 @@ import { globalStyle, style } from '@vanilla-extract/css'; import { color, config } from 'folds'; import { MessageBase, BubbleContent } from '../../components/message/layout/layout.css'; -import { PageContent, PageNavContent } from '../../components/page/style.css'; import { Editor } from '../../components/editor/Editor.css'; -import { MembersDrawer } from '../room/MembersDrawer.css'; import { CallStatus } from '../call-status/styles.css'; -import { RoomViewFollowing } from '../room/RoomViewFollowing.css'; -import { RoomViewTyping } from '../room/RoomViewTyping.css'; import { PLATE_TINT, SKY_PLATE_FALLBACK } from './skyPlate'; // Over the scene (html[data-scene]) the signed-in shell is clear (sceneShell.css.ts), so text @@ -26,11 +22,6 @@ const base = (recipe: () => string) => `.${recipe().split(' ')[0]}`; // Meta, 2026-11-09: no chrome. Title bars, the room list, list pages, the drawer and the bars over the composer are bare // ink on the sky (dusk is allowed to be low contrast: it is the crossover between the light and dark inks). -void PageNavContent; -void PageContent; -void MembersDrawer; -void RoomViewTyping; -void RoomViewFollowing; export const plateRegions = [ `.${SkyPlate}`, // The composer and the call status bar. diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 3229cf36..80bc0423 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -39,11 +39,17 @@ describe('skyThemeId', () => { expect(skyThemeId('night')).toBe('midnight-sky'); }); - it('picks none for auto, at any hour: auto never overrides a signed-in theme', () => { + it('picks the theme of the state auto resolves to, at any hour', () => { [0, 6, 12, 18, 23].forEach((h) => { setSkyClock(() => atStandardTime(h, 10)); - expect(skyThemeId('auto')).toBeUndefined(); + const id = skyThemeId('auto'); + expect(id).toBeDefined(); + expect(id).toBe(skyThemeId(getResolvedSkyState())); }); + setSkyClock(() => atStandardTime(12)); + expect(skyThemeId('auto')).toBe('noonday-sky'); + setSkyClock(() => atStandardTime(0)); + expect(skyThemeId('auto')).toBe('midnight-sky'); setSkyClock(); }); @@ -145,19 +151,26 @@ describe('regions that sit on the plate', () => { // and link of that plate are pinned above; this pins who is on it. const on = (cls: string) => expect(plateRegions).toContain(cls); - it('plates the composer, the members drawer, the call status bar and the call view', () => { + it('plates the composer, the call status bar and the call view', () => { on(`.${Editor}`); - on(`.${MembersDrawer}`); on(`.${CallStatus}`); on(`.${SkyPlate}`); }); - it('plates the room list, list pages, typing and Following bars and every header', () => { - on(`.${PageNavContent}`); - on(`.${PageContent}`); - on(`.${RoomViewTyping}`); - on('#root header'); + it('plates a message that is not a bubble, and nothing else of the signed-in shell', () => { expect(plateRegions.some((r) => r.includes(':not(:has('))).toBe(true); + expect(plateRegions).toHaveLength(4); + }); + + // Meta, 2026-11-09: no chrome. These are bare ink on the sky, not plates. + it('leaves title bars, the room list, list pages, the drawer and the bars over the composer bare', () => { + [ + `.${MembersDrawer}`, + `.${PageNavContent}`, + `.${PageContent}`, + `.${RoomViewTyping}`, + '#root header', + ].forEach((cls) => expect(plateRegions).not.toContain(cls)); }); }); diff --git a/src/app/features/scene/terrain/terrainWorld.test.ts b/src/app/features/scene/terrain/terrainWorld.test.ts index 78ab2700..70a9d763 100644 --- a/src/app/features/scene/terrain/terrainWorld.test.ts +++ b/src/app/features/scene/terrain/terrainWorld.test.ts @@ -41,8 +41,13 @@ describe('grid', () => { }); it('each tier fits a 32-bit index and the cheap tiers stay cheap', () => { - // 205k at 320 quads: a guess at what a phone carries (SwiftShader only, no phone frame time), kept as a ceiling so a bigger grid is a deliberate change. - expect(triangleCount(GRID.low.grid)).toBeLessThan(210000); + // 524k at 512 quads (low), 1.18M at 768 (full): Meta asked for denser terrain on 2026-11-10, up from 205k at 320. No + // phone frame time is measured (SwiftShader only); kept as a ceiling so a bigger grid is a deliberate change. + expect(triangleCount(GRID.low.grid)).toBeLessThan(540000); + expect(triangleCount(GRID.full.grid)).toBeLessThan(1200000); + (['still', 'low', 'full'] as const).forEach((t) => + expect((GRID[t].grid + 1) ** 2).toBeLessThan(2 ** 32) + ); expect(GRID.full.grid).toBeGreaterThan(GRID.low.grid); expect(GRID.still.tex).toBeLessThanOrEqual(GRID.low.tex); }); diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts index 64f18970..82a16bfb 100644 --- a/src/app/features/scene/terrain/terrainWorld.ts +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -11,7 +11,7 @@ export const TERRAIN = { span: 3000, hmax: 256 }; * under shading read from the height map shows as hard-edged facets and stepped crests. */ export const GRID = { - still: { grid: 256, tex: 1024 }, + still: { grid: 320, tex: 1024 }, low: { grid: 512, tex: 1024 }, full: { grid: 768, tex: 2048 }, } as const;