From b8f2bb462e9e2bc0f5df2894866ee13f51ec7e9c Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 18:22:19 +0000 Subject: [PATCH 001/111] auth: login card fill 50% with 18px blur and a faint edge The blur flattens the sky and ridges behind the text, so the fill can be half as opaque and let the scene show through. Text over the unblurred horizon glow is 4.15:1 (4.5 needs 54%); measured over the real blurred backdrop at 390x844 it is >= 4.94:1. Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/styles.css.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index a9733ef0..b45539a6 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -56,7 +56,9 @@ export const AuthFooter = style({ // 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. +// until it lands. The fill is 50%: the 18px blur carries the readability, flattening the sky and +// ridges behind the text. Without the blur, text is 4.15:1 over the brightest backdrop (the horizon +// glow; 4.5:1 needs 54%) and >= 7.3:1 elsewhere in the scene. // 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}`, { @@ -65,13 +67,14 @@ globalStyle(`${scene} ${AuthLayout}`, { }); globalStyle(`${scene} ${AuthCard}`, { backgroundColor: [ - 'rgba(13, 26, 38, 0.85)', - 'color-mix(in srgb, var(--sky-bubble, #0d1a26) 85%, transparent)', + 'rgba(13, 26, 38, 0.5)', + 'color-mix(in srgb, var(--sky-bubble, #0d1a26) 50%, transparent)', ], color: 'var(--sky-bubble-text, #b7c7d9)', borderRadius: config.radii.R500, - borderColor: 'transparent', - backdropFilter: 'blur(12px)', + // A faint inner edge, so a pane this clear still reads as a surface. + borderColor: 'rgba(183, 199, 217, 0.14)', + backdropFilter: 'blur(18px)', }); globalStyle(`${scene} ${AuthHeader}`, { backgroundColor: 'transparent', From 833c477785c90eed5d414579edd41f773d49a70b Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 18:30:20 +0000 Subject: [PATCH 002/111] scene: baked starfield with a rolling twinkle window for dark skies Every star is seeded, placed toward the zenith, faded by the local sky luma, and baked once into the sky canvas. A night sky is one whose zenith luma is below 110; the ridge scene has no theme flag, so the dusk default qualifies faintly and a day sky gets no stars and no overlay. 32 slots twinkle at a time on a small overlay canvas under the ridges: each slot runs back-to-back one-cycle twinkles (own 2-7 s period and offset) of one star from its own partition, shuffled per epoch, never repeating across the seam. Gain is a pure function of (slot, t), 0.35x to 1.15x, exactly 1 at start and end. Dimming paints the sky's own colour over the baked star; brightening adds a star. The pass is clear + at most 32 arcs at 20 fps, stopped under reduced motion (live), while hidden, in tier 'still', and after the slow-frame fallback. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/ridge/Ridge.test.ts | 170 ++++++++++++-- src/app/features/scene/ridge/Ridge.ts | 66 +++++- src/app/features/scene/ridge/stars.test.ts | 185 +++++++++++++++ src/app/features/scene/ridge/stars.ts | 249 +++++++++++++++++++++ 4 files changed, 655 insertions(+), 15 deletions(-) create mode 100644 src/app/features/scene/ridge/stars.test.ts create mode 100644 src/app/features/scene/ridge/stars.ts diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts index bb35209e..3aecad12 100644 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -15,7 +15,8 @@ import { scatterAlpha, transmittance, } from './ridgeWorld'; -import { skyColours } from './skyColour'; +import { skyColours, SkyColours } from './skyColour'; +import { makeStars } from './stars'; import { startBackgroundScene } from '../index'; describe('noise', () => { @@ -245,6 +246,15 @@ function fakeEl(tag = 'div') { }; return el; } +// A day sky has no stars, so the scene tests below see only the ridge loop; the dusk default +// carries stars and a twinkle overlay, tested in 'the starfield in the scene'. +const DAY: SkyColours = { + top: [96, 150, 220], + mid: [150, 190, 235], + horizon: [240, 215, 190], + ground: [7, 17, 26], + sunAzimuth: 0.3, +}; const draws: string[] = []; function ctx(withFilter = false) { const c: any = { @@ -257,7 +267,19 @@ function ctx(withFilter = false) { moveTo() {}, lineTo() {}, closePath() {}, - fill: () => draws.push('fill'), + // Stars: arc+fill is one draw. A context that has cleared a rect is the twinkle overlay's. + clearRect() { + c.overlay = true; + draws.push('clear'); + }, + arc() { + draws.push(c.overlay ? 'twinkle' : 'star'); + c.arcing = true; + }, + fill: () => { + if (c.arcing) c.arcing = false; + else draws.push('fill'); + }, drawImage: () => draws.push('flatten'), set fillStyle(_v: unknown) {}, }; @@ -312,7 +334,8 @@ const runFrames = (dt: number, n: number, from = 0) => { let t = from; for (let i = 0; i < n && rafQueue.length; i += 1) { t += dt; - rafQueue.shift()!(t); + // One display frame: every callback queued for it sees the same timestamp. + rafQueue.splice(0).forEach((cb) => cb(t)); } return t; }; @@ -327,12 +350,12 @@ describe('createRidgeScene', () => { it('returns null when 2D context is missing, so the host keeps its CSS ground', () => { const { canvas } = host(); canvas.getContext = vi.fn(() => null); - expect(createRidgeScene({ canvas, tier: 'full' })).toBeNull(); + expect(createRidgeScene({ canvas, tier: 'full', sky: DAY })).toBeNull(); }); it('bakes the sky once and paints four layers at integer sizes, farthest first', () => { const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; expect(s).not.toBeNull(); const layers = parent.children.slice(1); expect(layers).toHaveLength(LAYERS.length); @@ -348,7 +371,7 @@ describe('createRidgeScene', () => { it('after the layers are painted, a smooth run draws nothing more and ends its rAF loop', () => { const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const before = draws.length; runFrames(16.7, 400); expect(draws.length).toBe(before); @@ -359,7 +382,7 @@ describe('createRidgeScene', () => { it('p95 over 33 ms across 2 s flattens to a still image and removes the layers', () => { const { parent, canvas } = host(); const onProbe = vi.fn(); - const s = createRidgeScene({ canvas, tier: 'full', onProbe })!; + const s = createRidgeScene({ canvas, tier: 'full', onProbe, sky: DAY })!; runFrames(100, 100); expect(draws.filter((d) => d === 'flatten')).toHaveLength(LAYERS.length); expect(parent.children).toEqual([canvas]); @@ -379,7 +402,7 @@ describe('createRidgeScene', () => { it('prefers-reduced-motion: layers are built but never played, and nothing is sampled', () => { motion.matches = true; const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const anims = canvas.anims.concat(...canvas.parentElement.children.map((c: any) => c.anims)); expect(anims.length).toBe(LAYERS.length); anims.forEach((a: any) => expect(a.play).not.toHaveBeenCalled()); @@ -389,7 +412,7 @@ describe('createRidgeScene', () => { it('pauses the drift when the tab is hidden and resumes when it is visible', () => { const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const anims = parent.children.slice(1).flatMap((l: any) => l.anims); anims.forEach((a: any) => a.pause.mockClear()); doc.visibilityState = 'hidden'; @@ -405,7 +428,7 @@ describe('createRidgeScene', () => { it('?probe=1 keeps the rAF loop alive and shows median and p95', () => { search = '?probe=1'; const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const readout = parent.children[parent.children.length - 1]; runFrames(16.7, 300); expect(rafQueue.length).toBe(1); // still sampling @@ -416,7 +439,7 @@ describe('createRidgeScene', () => { it('dispose cancels the loop, the animations and the layers', () => { const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const anims = parent.children.slice(1).flatMap((l: any) => l.anims); s.dispose(); anims.forEach((a: any) => expect(a.cancel).toHaveBeenCalled()); @@ -456,7 +479,7 @@ describe('fbm', () => { describe('aerial perspective in the scene', () => { it('is baked once: one scatter fill per hazy layer, none per frame, the nearest layer has none', () => { const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; expect(draws.filter((d) => d === 'scatter')).toHaveLength(LAYERS.length - 1); const before = draws.length; runFrames(16.7, 400); @@ -469,7 +492,7 @@ describe('aerial perspective in the scene', () => { const s = createRidgeScene({ canvas, tier: 'full', - sky: { ...skyColours(), sunAzimuth: undefined }, + sky: { ...DAY, sunAzimuth: undefined }, })!; expect(draws.filter((d) => d === 'scatter')).toHaveLength(0); s.dispose(); @@ -495,7 +518,7 @@ describe('aerial perspective in the scene', () => { }); return el; }; - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const blurs = filters.filter((f) => f !== 'none'); expect(blurs).toHaveLength(LAYERS.length - 1); // nearest is sharp expect(blurs[0]).toBe(`blur(${edgeBlur(LAYERS[0]) * 2}px)`); // DPR 2 @@ -511,3 +534,122 @@ describe('aerial perspective in the scene', () => { expect(draws.filter((d) => d === 'flatten')).toHaveLength(LAYERS.length); }); }); + +describe('the starfield in the scene', () => { + const count = (kind: string) => draws.filter((d) => d === kind).length; + const night = { + ...DAY, + top: [10, 14, 40], + mid: [30, 34, 80], + horizon: [60, 50, 90], + } as SkyColours; + const N = makeStars(night, 400, 800).length; + const hear = (name: string) => { + const calls = motion.addEventListener.mock.calls.filter((c: any[]) => c[0] === name); + return calls[calls.length - 1][1] as () => void; + }; + // Everything but the overlay's own marks: the bake and the ridges. + const baked = () => draws.filter((d) => d !== 'twinkle' && d !== 'clear').length; + + it('bakes every star once into the sky; per frame only the overlay is touched', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + expect(count('star')).toBe(N); + expect(count('sky')).toBe(1); + expect(parent.children.indexOf(parent.children[1])).toBe(1); + expect(parent.children[1].anims).toHaveLength(0); // the overlay: above the sky, under the ridges + expect(parent.children).toHaveLength(2 + LAYERS.length); + const before = baked(); + runFrames(16.7, 300); // 5 s at 60 fps; the overlay repaints at 20 fps + expect(baked()).toBe(before); + expect(count('clear')).toBeGreaterThan(60); + expect(count('clear')).toBeLessThanOrEqual(101); + // each pass paints at most SLOTS stars + expect(count('twinkle')).toBeLessThanOrEqual(count('clear') * 32); + expect(count('twinkle')).toBeGreaterThan(0); + s.dispose(); + }); + + it('a day sky has no stars and no overlay layer', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + expect(count('star')).toBe(0); + expect(parent.children).toHaveLength(1 + LAYERS.length); + runFrames(16.7, 60); + expect(count('clear')).toBe(0); + s.dispose(); + }); + + it("tier 'still' bakes the stars and has no overlay or loop", () => { + const { parent, canvas } = host(); + createRidgeScene({ canvas, tier: 'still', sky: night }); + expect(count('star')).toBe(N); + expect(parent.children).toEqual([canvas]); + expect(rafQueue).toHaveLength(0); + }); + + it('stops twinkling under reduced motion, live, and resumes when it is lifted', () => { + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + let t = runFrames(16.7, 30); + expect(count('twinkle')).toBeGreaterThan(0); + motion.matches = true; + hear('change')(); + t = runFrames(16.7, 5, t); + const stopped = draws.length; + t = runFrames(16.7, 200, t); + expect(draws.length).toBe(stopped); + expect(rafQueue).toHaveLength(0); + motion.matches = false; + hear('change')(); + runFrames(16.7, 30, t); + expect(draws.length).toBeGreaterThan(stopped); + s.dispose(); + }); + + it('starts steady, with nothing painted over the stars, when reduced motion is already on', () => { + motion.matches = true; + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + expect(rafQueue).toHaveLength(0); + runFrames(16.7, 60); + expect(count('twinkle')).toBe(0); + s.dispose(); + }); + + it('stops while the tab is hidden and resumes when it is visible', () => { + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + let t = runFrames(16.7, 30); + doc.visibilityState = 'hidden'; + doc.dispatchEvent(new Event('visibilitychange')); + const hidden = draws.length; + t = runFrames(16.7, 200, t); + expect(draws.length).toBe(hidden); + expect(rafQueue).toHaveLength(0); + doc.visibilityState = 'visible'; + doc.dispatchEvent(new Event('visibilitychange')); + runFrames(16.7, 30, t); + expect(draws.length).toBeGreaterThan(hidden); + s.dispose(); + }); + + it('flattening to a still (slow frames) drops the overlay and leaves every star baked under the ridges', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + runFrames(100, 100); + expect(parent.children).toEqual([canvas]); + expect(count('star')).toBe(N); + expect(draws.indexOf('flatten')).toBeGreaterThan(draws.lastIndexOf('star')); + expect(rafQueue).toHaveLength(0); + s.dispose(); + }); + + it('dispose removes the overlay and stops the loop', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + s.dispose(); + expect(parent.children).toEqual([canvas]); + expect(rafQueue).toHaveLength(0); + }); +}); diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index 218d7c65..eae0df08 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -1,6 +1,7 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from './probe'; import { skyColours, SkyColours, Srgb } from './skyColour'; +import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars'; import { LAYERS, LayerSpec, @@ -130,8 +131,13 @@ function paintLayer( interface Built { layers: HTMLCanvasElement[]; anims: Animation[]; + /** The twinkling stars' own canvas, between the sky and the ridges; absent in a day sky and in 'still'. */ + twinkle?: HTMLCanvasElement; } +/** The twinkle overlay redraws at most this often: the swings are 2 to 7 s, no need for 60 fps. */ +const TWINKLE_FRAME_MS = 50; + /** * The ridge scene: same contract as `createScene` ({ login, dispose }, null for "keep the CSS * ground"), no WebGL. The layers are painted once on the CPU; per frame the only work is the @@ -149,6 +155,9 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); let built: Built | undefined; + let field: Star[] = []; + let twRaf = 0; + let twLast = 0; let still = tier === 'still'; let disposed = false; let raf = 0; @@ -165,6 +174,9 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { const teardownLayers = () => { built?.anims.forEach((a) => a.cancel()); built?.layers.forEach((l) => l.remove()); + built?.twinkle?.remove(); + cancelAnimationFrame(twRaf); + twRaf = 0; built = undefined; }; @@ -177,10 +189,33 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { canvas.height = Math.round(h * scale); if (!paintSky(canvas, sky)) return false; const layers: HTMLCanvasElement[] = []; + // Every star is baked into the sky; the twinkling ones get their own canvas, which a per-frame + // pass clears and paints over. 'still' has no overlay. + const stars = makeStars(sky, w, h); + const skyCtx = context2d(canvas); + if (skyCtx) drawStars(skyCtx, stars, canvas.width, canvas.height, scale); + let twinkle: HTMLCanvasElement | undefined; + if (stars.length && tier !== 'still') { + twinkle = document.createElement('canvas'); + twinkle.width = canvas.width; + twinkle.height = canvas.height; + twinkle.setAttribute('aria-hidden', 'true'); + Object.assign(twinkle.style, { + position: 'absolute', + left: '0', + top: '0', + width: '100%', + height: '100%', + pointerEvents: 'none', + }); + parent.appendChild(twinkle); + } + field = stars; for (const spec of LAYERS) { const el = paintLayer(spec, sky, size, canvas.height, scale); if (!el) { layers.forEach((l) => l.remove()); + twinkle?.remove(); return false; } el.setAttribute('aria-hidden', 'true'); @@ -208,7 +243,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { ) ) .filter((a): a is Animation => !!a); - built = { layers, anims }; + built = { layers, anims, twinkle }; syncPlayState(); return true; }; @@ -219,8 +254,37 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { !(reducedMotion?.matches ?? false) && document.visibilityState === 'visible'; + /** Paint the overlay at t seconds (stars away from base), or just clear it with no t (all at base). */ + const drawTwinkle = (t?: number) => { + const el = built?.twinkle; + const ctx = el && context2d(el); + if (!el || !ctx) return; + const active = t === undefined ? [] : activeAt(field.length, t); + paintTwinkle(ctx, field, active, el.width, el.height, viewSize().scale); + }; + + // The same guard as the drift: not still, not disposed, not reduced motion, tab visible. Not moving + // stops the loop and leaves the stars steady at base brightness. + const twTick = (now: number) => { + twRaf = 0; + if (!moving() || !built?.twinkle) return; + if (now - twLast >= TWINKLE_FRAME_MS) { + twLast = now; + drawTwinkle(now / 1000); + } + twRaf = requestAnimationFrame(twTick); + }; + function syncPlayState() { built?.anims.forEach((a) => (moving() ? a.play() : a.pause())); + if (!built?.twinkle) return; + if (moving()) { + if (!twRaf) twRaf = requestAnimationFrame(twTick); + } else { + cancelAnimationFrame(twRaf); + twRaf = 0; + drawTwinkle(); + } } /** Flatten the sky and layers into the host canvas and drop the layers: a still image, no compositor layers left. */ diff --git a/src/app/features/scene/ridge/stars.test.ts b/src/app/features/scene/ridge/stars.test.ts new file mode 100644 index 00000000..38b4e7e8 --- /dev/null +++ b/src/app/features/scene/ridge/stars.test.ts @@ -0,0 +1,185 @@ +/* eslint-disable no-restricted-syntax, no-continue -- test loops over sample times */ +import { describe, expect, it } from 'vitest'; +import { skyColours } from './skyColour'; +import { + MAX_STARS, + NIGHT_LUMA, + SLOTS, + TWINKLE_GAIN, + TWINKLE_PERIOD, + activeAt, + makeStars, + skyLuma, + slotCount, + slotOffset, + slotPeriod, + starCount, + twinkleGain, +} from './stars'; + +const DAY = { + ...skyColours(), + top: [96, 150, 220], + mid: [150, 190, 235], + horizon: [240, 215, 190], +} as const; +const NIGHT = { + ...skyColours(), + top: [10, 14, 40], + mid: [30, 34, 80], + horizon: [60, 50, 90], +} as const; +const N = makeStars(NIGHT, 390, 844).length; + +function hash2(a: number, b: number) { + return ((a * 7919 + b * 104729) % 1000) / 1000; +} + +describe('star placement', () => { + it('is deterministic: the same sky and size give the identical field', () => { + expect(makeStars(NIGHT, 390, 844)).toEqual(makeStars(NIGHT, 390, 844)); + expect(makeStars(skyColours(), 390, 844)).toEqual(makeStars(skyColours(), 390, 844)); + }); + + it('draws none in a day sky, and the dusk default is dark enough for some', () => { + expect(makeStars(DAY, 390, 844)).toHaveLength(0); + expect(makeStars(skyColours(), 390, 844).length).toBeGreaterThan(50); + }); + + it('is a few hundred at most: bounded by MAX_STARS and by the viewport', () => { + for (const [w, h] of [ + [390, 844], + [1920, 1080], + [100, 100], + ] as const) { + expect(makeStars(NIGHT, w, h).length).toBeLessThanOrEqual(starCount(w, h)); + expect(starCount(w, h)).toBeLessThanOrEqual(MAX_STARS); + } + expect(N).toBeGreaterThan(150); + }); + + it('thins toward the horizon: more than twice the stars in the top third than the bottom third of the sky', () => { + const all = makeStars(NIGHT, 390, 844); + expect(all.filter((s) => s.y < 0.26).length).toBeGreaterThan( + 2 * all.filter((s) => s.y > 0.52).length + ); + }); + + it('fades with the sky: every star sits where the sky is dark, alpha within bounds', () => { + for (const s of makeStars(NIGHT, 390, 844)) { + expect(skyLuma(NIGHT, s.y)).toBeLessThan(NIGHT_LUMA); + expect(s.alpha).toBeLessThanOrEqual(0.9); + expect(s.alpha).toBeGreaterThanOrEqual(0.05); + } + }); + + it('varies in size and brightness', () => { + const all = makeStars(NIGHT, 390, 844); + expect(new Set(all.map((s) => s.r.toFixed(2))).size).toBeGreaterThan(50); + expect( + Math.max(...all.map((s) => s.alpha)) - Math.min(...all.map((s) => s.alpha)) + ).toBeGreaterThan(0.3); + }); +}); + +describe('twinkle schedule', () => { + const times = Array.from({ length: 400 }, (_, i) => i * 0.37); + + it('is a pure function of (seed, time): same t, same set and values', () => { + for (const t of [0, 0.4, 13.7, 3599.9]) expect(activeAt(N, t)).toEqual(activeAt(N, t)); + }); + + it('the set is SLOTS stars at every sampled time (inside 24..48), never more than the field has', () => { + expect(SLOTS).toBeGreaterThanOrEqual(24); + expect(SLOTS).toBeLessThanOrEqual(48); + for (const t of times) expect(activeAt(N, t)).toHaveLength(SLOTS); + expect(activeAt(10, 5)).toHaveLength(10); + expect(slotCount(10)).toBe(10); + expect(activeAt(0, 5)).toHaveLength(0); + }); + + it('no star is twice in the set at one instant, and every index is a real star', () => { + for (const t of times) { + const ids = activeAt(N, t).map((a) => a.star); + expect(new Set(ids).size).toBe(ids.length); + ids.forEach((i) => expect(i >= 0 && i < N).toBe(true)); + } + }); + + it("periods are each slot's own, within 2..7 s, and the offsets differ", () => { + for (let j = 0; j < SLOTS; j += 1) { + expect(slotPeriod(j)).toBeGreaterThanOrEqual(TWINKLE_PERIOD.min); + expect(slotPeriod(j)).toBeLessThanOrEqual(TWINKLE_PERIOD.max); + } + expect(new Set(Array.from({ length: SLOTS }, (_, j) => slotPeriod(j))).size).toBe(SLOTS); + expect(new Set(Array.from({ length: SLOTS }, (_, j) => slotOffset(j))).size).toBe(SLOTS); + for (const a of activeAt(N, 100)) expect(a.period).toBeCloseTo(slotPeriod(a.slot), 12); + }); + + it('over an hour every star twinkles at least once', () => { + const seen = new Set(); + for (let t = 0; t < 3600; t += 1) for (const a of activeAt(N, t + 0.5)) seen.add(a.star); + expect(seen.size).toBe(N); + }); + + it('a star joins at its base brightness and leaves at exactly base, with no pop', () => { + const seed = { flip: false, ripple: 3, phase: 1 }; + expect(twinkleGain(0, seed)).toBe(1); + expect(twinkleGain(1, seed)).toBe(1); + for (const flip of [true, false]) + for (const e of [1e-4, 1 - 1e-4]) + expect(Math.abs(twinkleGain(e, { ...seed, flip }) - 1)).toBeLessThan(0.01); + // through the schedule: the instant a twinkle ends the slot is on a new star's first instant, at gain 1 + for (let j = 0; j < SLOTS; j += 1) { + const end = slotOffset(j) + 40 * slotPeriod(j); + const at = activeAt(N, end).find((a) => a.slot === j)!; + expect(at.gain).toBeCloseTo(1, 9); + } + }); + + it('the star that joins is never the one that just left', () => { + for (let j = 0; j < SLOTS; j += 1) { + let prev = -1; + for (let k = 0; k < 300; k += 1) { + const t = slotOffset(j) + (k + 0.5) * slotPeriod(j); + const { star } = activeAt(N, t).find((a) => a.slot === j)!; + expect(star).not.toBe(prev); + prev = star; + } + } + }); + + it('joins never coincide: every (slot, cycle) start over an hour is distinct', () => { + const starts: number[] = []; + for (let j = 0; j < SLOTS; j += 1) + for (let k = 0; slotOffset(j) + k * slotPeriod(j) < 3600; k += 1) + starts.push(slotOffset(j) + k * slotPeriod(j)); + starts.sort((a, b) => a - b); + let min = Infinity; + for (let i = 1; i < starts.length; i += 1) min = Math.min(min, starts[i] - starts[i - 1]); + expect(min).toBeGreaterThan(0); + console.log(`joins/hour ${starts.length}, min gap between joins ${(min * 1000).toFixed(3)} ms`); + }); + + it('dims deeper than it brightens, within 0.35x..1.15x', () => { + let lo = Infinity; + let hi = -Infinity; + for (let j = 0; j < 20; j += 1) + for (let k = 0; k < 20; k += 1) { + const seed = { flip: hash2(j, k) < 0.5, ripple: 2 + (k % 2), phase: j + k }; + for (let i = 0; i <= 200; i += 1) { + const g = twinkleGain(i / 200, seed); + lo = Math.min(lo, g); + hi = Math.max(hi, g); + } + } + expect(lo).toBeGreaterThanOrEqual(TWINKLE_GAIN.dim - 1e-9); + expect(hi).toBeLessThanOrEqual(TWINKLE_GAIN.bright + 1e-9); + expect(1 - lo).toBeGreaterThan(2 * (hi - 1)); + }); + + it('stars outside the set are steady: only the active ones have a gain to apply', () => { + const inSet = new Set(activeAt(N, 50).map((a) => a.star)); + expect(inSet.size).toBeLessThan(N / 4); + }); +}); diff --git a/src/app/features/scene/ridge/stars.ts b/src/app/features/scene/ridge/stars.ts new file mode 100644 index 00000000..0e6d2628 --- /dev/null +++ b/src/app/features/scene/ridge/stars.ts @@ -0,0 +1,249 @@ +// The Midnight starfield: placed once from a seeded PRNG and baked once into the sky canvas. A +// rolling window of them twinkles on an overlay canvas: SLOTS slots, each running back-to-back +// twinkles of one star at a time, so the active set is always SLOTS stars and every star gets its turn. +// The schedule is a pure function of (seed, time): no state accumulates and no Math.random runs +// after the field is built, so any instant maps straight to the active set and its brightnesses. + +/* eslint-disable no-bitwise -- the seeded PRNG and hash are 32-bit integer arithmetic */ +import type { SkyColours, Srgb } from './skyColour'; + +/** + * Stars show when the sky behind them is darker than this sRGB luma (0..255). The ridge scene has + * no night flag (the sky theme's Midnight/Noonday is a body class chosen by the app, not seen here), + * so night is the sky's own darkness: a star's alpha is 1 - luma/NIGHT_LUMA at its height in the + * gradient, and a sky whose zenith is lighter than this gets no stars at all. + */ +export const NIGHT_LUMA = 110; +/** The sky gradient runs from the top (0) through mid (0.5) to the horizon glow (0.78 and below). */ +export const SKY_STOPS = { mid: 0.5, horizon: 0.78 }; +export const MAX_STARS = 400; +/** Twinkling stars at any instant (fewer only if the field has fewer stars). */ +export const SLOTS = 32; +export const TWINKLE_PERIOD = { min: 2, max: 7 } as const; +/** Gain at the dimmest and brightest swing: dimming goes deeper than brightening. */ +export const TWINKLE_GAIN = { dim: 0.35, bright: 1.15 } as const; +const SEED = 0x57a45eed; + +export interface Star { + /** Position as fractions of the sky canvas width and height. */ + x: number; + y: number; + /** Radius in CSS px. */ + r: number; + /** Base alpha 0..0.9 (brightness times how dark the sky is there). */ + alpha: number; + /** CSS colour, white tinted blue or warm. */ + fill: string; + /** The sky gradient's colour at this height: dimming paints it over the baked star. */ + cover: string; +} + +const luma = (c: Srgb) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + +/** The baked gradient's colour at fraction t of the canvas height (the canvas interpolates in sRGB). */ +function skyAt(sky: SkyColours, t: number): Srgb { + const mix = (a: Srgb, b: Srgb, k: number): Srgb => [ + Math.round(lerp(a[0], b[0], k)), + Math.round(lerp(a[1], b[1], k)), + Math.round(lerp(a[2], b[2], k)), + ]; + if (t <= SKY_STOPS.mid) return mix(sky.top, sky.mid, t / SKY_STOPS.mid); + if (t <= SKY_STOPS.horizon) + return mix(sky.mid, sky.horizon, (t - SKY_STOPS.mid) / (SKY_STOPS.horizon - SKY_STOPS.mid)); + return sky.horizon; +} + +export const skyLuma = (sky: SkyColours, t: number): number => luma(skyAt(sky, t)); +export const isNight = (sky: SkyColours): boolean => luma(sky.top) < NIGHT_LUMA; + +/** mulberry32: 32-bit seeded PRNG, one float in [0, 1) per call. */ +function prng(seed: number) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Stateless seeded hash of up to three integers to [0, 1). */ +export function hash(a: number, b = 0, c = 0): number { + let h = Math.imul(a | 0, 0x9e3779b1) ^ SEED; + h = Math.imul(h ^ (h >>> 15) ^ Math.imul(b | 0, 0x85ebca6b), 0xc2b2ae35); + h = Math.imul(h ^ (h >>> 13) ^ Math.imul(c | 0, 0x27d4eb2f), 0x165667b1); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} + +/** How many stars a viewport carries: about one per 1100 CSS px squared, 150..MAX_STARS. */ +export const starCount = (cssW: number, cssH: number): number => + Math.min(MAX_STARS, Math.max(150, Math.round((cssW * cssH) / 1100))); + +/** + * Seeded placement. Each candidate draws all its random numbers whether it is kept or not, so a + * given seed and sky always give the same field and the prefix does not shift with the count. + * Heights are squeezed toward the zenith (y = 0.78 u^1.7) and alpha falls with the sky's luma, so + * the stars thin out and fade into the horizon glow. None for a day sky. + */ +export function makeStars(sky: SkyColours, cssW: number, cssH: number): Star[] { + if (!isNight(sky)) return []; + const rnd = prng(SEED); + const n = starCount(cssW, cssH); + // Every candidate draws all its numbers, kept or not, so the prefix is stable. + const candidates = Array.from({ length: MAX_STARS }, () => ({ + x: rnd(), + y: SKY_STOPS.horizon * rnd() ** 1.7, + r: 0.35 + 1.0 * rnd() ** 4, + bright: 0.35 + 0.65 * rnd() ** 1.5, + tint: rnd(), + })).slice(0, n); + return candidates.flatMap(({ x, y, r, bright, tint }) => { + const alpha = Math.min(0.9, bright * Math.max(0, 1 - skyLuma(sky, y) / NIGHT_LUMA)); + if (alpha < 0.05) return []; + // White, pulled toward blue or toward warm by the tint draw. + const to: Srgb = tint < 0.5 ? [165, 195, 255] : [255, 222, 185]; + const k = 0.55 * Math.abs(tint - 0.5) * 2; + const c = [255, 255, 255].map((v, j) => Math.round(lerp(v, to[j], k))); + const s = skyAt(sky, y); + return [ + { + x, + y, + r, + alpha, + fill: `rgb(${c[0]},${c[1]},${c[2]})`, + cover: `rgb(${s[0]},${s[1]},${s[2]})`, + }, + ]; + }); +} + +// ---- The schedule --------------------------------------------------------------------------- + +export const slotCount = (stars: number): number => Math.min(SLOTS, stars); +/** A slot's twinkle period in seconds: its own, in TWINKLE_PERIOD. */ +export const slotPeriod = (j: number): number => + TWINKLE_PERIOD.min + (TWINKLE_PERIOD.max - TWINKLE_PERIOD.min) * hash(j, 1); +/** A slot's phase: seconds into its first cycle at t = 0. */ +export const slotOffset = (j: number): number => hash(j, 2) * slotPeriod(j); + +/** Stars in slot j are those with index = j (mod slots), `m` of them; no star is in two slots. */ +const partitionSize = (stars: number, slots: number, j: number): number => + Math.floor((stars - 1 - j) / slots) + 1; + +/** The k-th star slot j twinkles: epochs of a seeded shuffle of its partition, never repeating across the seam. */ +function partitionPick(stars: number, slots: number, j: number, k: number): number { + const m = partitionSize(stars, slots, j); + const shuffled = (e: number) => { + const order = Array.from({ length: m }, (_, q) => q).sort( + (a, b) => hash(j, e, a + 10) - hash(j, e, b + 10) || a - b + ); + return order; + }; + const e = Math.floor(k / m); + const order = shuffled(e); + if (m > 1 && order[0] === shuffled(e - 1)[m - 1]) [order[0], order[1]] = [order[1], order[0]]; + return j + slots * order[k - e * m]; +} + +/** + * Brightness multiplier at fraction u of one twinkle (0..1): exactly 1 at both ends, so a star + * joins and leaves at its base brightness. One swing, a little unrelated shimmer, and the + * asymmetric map below: dimming goes to TWINKLE_GAIN.dim, brightening only to .bright. + */ +export function twinkleGain( + u: number, + seed: { flip: boolean; ripple: number; phase: number } +): number { + if (u <= 0 || u >= 1) return 1; + const main = Math.sin(2 * Math.PI * u) * (seed.flip ? -1 : 1); + const shimmer = Math.sin(2 * Math.PI * seed.ripple * u + seed.phase) * Math.sin(Math.PI * u); + const s = Math.max(-1, Math.min(1, 0.85 * main + 0.15 * shimmer)); + return s >= 0 ? 1 + (TWINKLE_GAIN.bright - 1) * s : 1 + (1 - TWINKLE_GAIN.dim) * s; +} + +export interface Twinkling { + slot: number; + star: number; + /** Seconds since this twinkle started, and its length. */ + start: number; + period: number; + gain: number; +} + +/** The active set at time `t` seconds for a field of `stars` stars, in slot order. */ +export function activeAt(stars: number, t: number): Twinkling[] { + const slots = slotCount(stars); + return Array.from({ length: slots }, (_, j) => { + const period = slotPeriod(j); + const off = slotOffset(j); + const k = Math.floor((t - off) / period); + const start = off + k * period; + const seed = { + flip: hash(j, k, 3) < 0.5, + ripple: 2 + Math.floor(hash(j, k, 4) * 2), + phase: 2 * Math.PI * hash(j, k, 5), + }; + return { + slot: j, + star: partitionPick(stars, slots, j, k), + start, + period, + gain: twinkleGain((t - start) / period, seed), + }; + }); +} + +// ---- Drawing -------------------------------------------------------------------------------- + +/** Bake `stars` into `ctx` (canvas `w` x `h` device px, `scale` device px per CSS px) at base alpha. */ +export function drawStars( + ctx: CanvasRenderingContext2D, + stars: readonly Star[], + w: number, + h: number, + scale: number +) { + stars.forEach((s) => { + ctx.globalAlpha = s.alpha; + ctx.fillStyle = s.fill; + ctx.beginPath(); + ctx.arc(s.x * w, s.y * h, s.r * scale, 0, 2 * Math.PI); + ctx.fill(); + }); + ctx.globalAlpha = 1; +} + +/** + * The overlay pass: clear, then for each active star whose gain is off base, paint over the baked + * star. Dimming covers it with the sky's own colour at (1 - gain), which leaves exactly + * alpha * gain of the star; brightening adds a second star on top. A star at base draws nothing. + */ +export function drawTwinkle( + ctx: CanvasRenderingContext2D, + stars: readonly Star[], + active: readonly Twinkling[], + w: number, + h: number, + scale: number +) { + ctx.clearRect(0, 0, w, h); + active.forEach((a) => { + const s = stars[a.star]; + if (!s || Math.abs(a.gain - 1) < 0.01) return; + if (a.gain < 1) { + ctx.globalAlpha = 1 - a.gain; + ctx.fillStyle = s.cover; + } else { + ctx.globalAlpha = Math.min(1, ((a.gain - 1) * s.alpha) / (1 - s.alpha)); + ctx.fillStyle = s.fill; + } + ctx.beginPath(); + ctx.arc(s.x * w, s.y * h, s.r * scale + (a.gain < 1 ? 1 : 0), 0, 2 * Math.PI); + ctx.fill(); + }); + ctx.globalAlpha = 1; +} From 077737830e3363b79cb6467f5827a3479bc03c54 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 18:36:18 +0000 Subject: [PATCH 003/111] scene: baked sky numbers and lighting/haze maths for a terrain tier Vendors the small set the terrain needs from Void's baked sky (PR #22, 7b4aa2f): sun elevation, the 7-stop gradient, sun.js light().direct, plus a sun azimuth computed from the same series (not in the bake). Lambert from the sun, ambient from the gradient zenith, exp(-k d) haze toward the gradient's horizon stop; a sun below the horizon lights nothing. Pure functions, no GL. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/terrain/skyData.ts | 44 ++++++ .../features/scene/terrain/terrainSky.test.ts | 138 ++++++++++++++++++ src/app/features/scene/terrain/terrainSky.ts | 82 +++++++++++ 3 files changed, 264 insertions(+) create mode 100644 src/app/features/scene/terrain/skyData.ts create mode 100644 src/app/features/scene/terrain/terrainSky.test.ts create mode 100644 src/app/features/scene/terrain/terrainSky.ts diff --git a/src/app/features/scene/terrain/skyData.ts b/src/app/features/scene/terrain/skyData.ts new file mode 100644 index 00000000..2b75f405 --- /dev/null +++ b/src/app/features/scene/terrain/skyData.ts @@ -0,0 +1,44 @@ +// The sky numbers the terrain scene lights and hazes with, vendored from Void's baked sky (GameCult/Void +// PR #22, branch void/sky-tokens, revision 7b4aa2f): `src/sky/baked.ts` (generated by scripts/bake-sky.mjs +// from src/sky/sun.js and src/sky/skyTokens.js) and `light()` in `src/sky/sun.js`. Both moments are +// 2026-09-22 at lat 45, lon 0. +// +// elevation, gradient copied from baked.ts (`baked.midnight` / `baked.noonday`). +// direct `light({time, lat, lon}).direct` from sun.js at that moment: sun colour at normal +// incidence, linear RGB, clear zenith sun ~ 1. baked.ts does not carry it. +// azimuth NOT in #22. Computed here from the same NOAA/Meeus series sun.js uses for the +// elevation (hour angle and declination), degrees clockwise from north. It is the +// one number in this file Void's bake does not produce; see the report on the table shape. +// +// These are copies, not a link: #22 is not in this tree, so nothing fails if Void re-bakes. When #22 lands, +// replace this module's literals with an import of `baked` (plus the fields above, if the bake adds them) and +// keep the exports. + +export type SkyName = 'midnight' | 'noonday'; +export type Vec3 = readonly [number, number, number]; + +export interface SkyData { + /** Degrees above the horizon, no refraction. Negative: the sun is down. */ + elevation: number; + /** Degrees clockwise from north. */ + azimuth: number; + /** Sun colour at normal incidence, linear RGB. Black when the sun is down. */ + direct: Vec3; + /** Seven sRGB hex stops, zenith first, horizon last. */ + gradient: readonly string[]; +} + +export const SKY: Record = { + midnight: { + elevation: -44.579021600011224, + azimuth: 2.4988773054640205, + direct: [0, 0, 0], + gradient: ['#37446a', '#38456a', '#3b4669', '#3d4868', '#414a67', '#444c66', '#484e64'], + }, + noonday: { + elevation: 45.16994813502928, + azimuth: 182.58737070473097, + direct: [0.9189659216820631, 0.8444983652129638, 0.6933684802959983], + gradient: ['#95c6ff', '#9ac9ff', '#a5cfff', '#b3d6ff', '#c2dfff', '#d2e9ff', '#e2f4ff'], + }, +}; diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts new file mode 100644 index 00000000..44b77f36 --- /dev/null +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -0,0 +1,138 @@ +/* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ +import { describe, expect, it } from 'vitest'; +import { SKY } from './skyData'; +import { + ambientColour, + hazeColour, + hexToLinear, + shade, + skyNameFor, + sunColour, + sunDirection, + transmittance, +} from './terrainSky'; +import { skyColours } from '../ridge/skyColour'; + +const UP = [0, 1, 0] as const; +const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; + +describe('baked sky numbers', () => { + it('keep the two moments apart: night sun below the horizon, noon sun well above', () => { + expect(SKY.midnight.elevation).toBeLessThan(0); + expect(SKY.noonday.elevation).toBeGreaterThan(30); + expect(SKY.midnight.gradient).toHaveLength(7); + expect(SKY.noonday.gradient).toHaveLength(7); + }); + + it('sunDirection is a unit vector with y = sin(elevation), north at -z, south at +z', () => { + for (const d of Object.values(SKY)) { + const s = sunDirection(d); + expect(Math.hypot(...s)).toBeCloseTo(1, 9); + expect(s[1]).toBeCloseTo(Math.sin((d.elevation * Math.PI) / 180), 9); + } + expect(sunDirection({ ...SKY.noonday, elevation: 0, azimuth: 0 })[2]).toBeCloseTo(-1, 9); + expect(sunDirection({ ...SKY.noonday, elevation: 0, azimuth: 180 })[2]).toBeCloseTo(1, 9); + }); + + it('hexToLinear decodes sRGB', () => { + expect(hexToLinear('#000000')).toEqual([0, 0, 0]); + expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]); + expect(hexToLinear('#808080')[0]).toBeCloseTo(0.2158, 3); + }); + + it('the default sky from skyColours() is a night one, as the stars see it', () => { + expect(skyNameFor(skyColours())).toBe('midnight'); + expect(skyNameFor({ ...skyColours(), top: [150, 198, 255] })).toBe('noonday'); + }); +}); + +describe('lighting', () => { + it('noonday lights an upward face with the sun; midnight has none', () => { + expect(luma(sunColour(SKY.noonday))).toBeGreaterThan(0.5); + expect(sunColour(SKY.midnight)).toEqual([0, 0, 0]); + }); + + it('a sun below the horizon gives no negative or sun light on any normal, even with a lit colour', () => { + const forcedDown = { ...SKY.noonday, elevation: -30 }; + expect(sunColour(forcedDown)).toEqual([0, 0, 0]); + for (const n of [UP, [1, 0, 0], [0, -1, 0], [0, 0.6, -0.8]] as const) { + const c = shade(forcedDown, [1, 1, 1], n, 0); + for (const ch of c) expect(ch).toBeGreaterThanOrEqual(0); + } + }); + + it('the sun fades out through the horizon instead of switching', () => { + const at = (elevation: number) => luma(sunColour({ ...SKY.noonday, elevation })); + expect(at(-2)).toBe(0); + expect(at(0.3)).toBeGreaterThan(0); + expect(at(0.3)).toBeLessThan(at(1.5)); + expect(at(1.5)).toBeCloseTo(at(60), 9); + }); + + it('shade never goes negative on any normal at either sky, at any distance', () => { + for (const d of Object.values(SKY)) + for (const n of [UP, [0, -1, 0], [1, 0, 0], [-0.6, 0.0, 0.8]] as const) + for (const dist of [0, 100, 5000]) + for (const ch of shade(d, [0.3, 0.3, 0.3], n, dist)) expect(ch).toBeGreaterThanOrEqual(0); + }); + + it('a face turned to the noon sun is brighter than one turned away; at midnight the two match to ambient', () => { + const toSun = sunDirection(SKY.noonday); + const away = [-toSun[0], 0, -toSun[2]] as const; + const lit = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], toSun, 0)); + const dark = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], away, 0)); + expect(lit).toBeGreaterThan(3 * dark); + const m = (n: readonly [number, number, number]) => shade(SKY.midnight, [0.5, 0.5, 0.5], n, 0); + expect(luma(m(UP))).toBeGreaterThan(0); // moonlit fill, not black + expect(luma(m(UP))).toBeLessThan(0.05); + }); + + it('noon is much brighter than midnight, ambient included', () => { + expect(luma(ambientColour(SKY.noonday))).toBeGreaterThan(2 * luma(ambientColour(SKY.midnight))); + expect(luma(shade(SKY.noonday, [0.4, 0.4, 0.4], UP, 0))).toBeGreaterThan( + 10 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0)) + ); + }); + + it('noon ambient matches sun.js diffuse at that moment to within a few percent (the gain)', () => { + const a = ambientColour(SKY.noonday); + const diffuse = [0.07, 0.132, 0.27]; // light() at 2026-09-22T12:00Z, 45N 0E, revision 7b4aa2f + for (let i = 0; i < 3; i += 1) expect(a[i] / diffuse[i]).toBeGreaterThan(0.85); + for (let i = 0; i < 3; i += 1) expect(a[i] / diffuse[i]).toBeLessThan(1.2); + }); +}); + +describe('haze', () => { + it('transmittance falls monotonically from 1 toward 0', () => { + let prev = transmittance(0); + expect(prev).toBe(1); + for (const d of [10, 100, 400, 1000, 3000, 8000]) { + const t = transmittance(d); + expect(t).toBeLessThan(prev); + expect(t).toBeGreaterThan(0); + prev = t; + } + }); + + it('far surfaces take the horizon colour of the baked gradient, at both skies', () => { + for (const d of Object.values(SKY)) { + const far = shade(d, [0.9, 0.2, 0.1], UP, 100000); + const h = hazeColour(d); + for (let i = 0; i < 3; i += 1) expect(far[i]).toBeCloseTo(h[i], 6); + } + expect(hazeColour(SKY.noonday)).toEqual(hexToLinear('#e2f4ff')); + expect(hazeColour(SKY.midnight)).toEqual(hexToLinear('#484e64')); + }); + + it('haze colour differs between the two skies: pale day horizon, dim night one', () => { + expect(luma(hazeColour(SKY.noonday))).toBeGreaterThan(5 * luma(hazeColour(SKY.midnight))); + }); + + it('with distance a dark surface rises toward the haze and a bright one falls toward it', () => { + const d = SKY.noonday; + const h = luma(hazeColour(d)); + const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x)); + expect(dark(2000)).toBeGreaterThan(dark(200)); + expect(dark(2000)).toBeLessThan(h); + }); +}); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts new file mode 100644 index 00000000..8f255206 --- /dev/null +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -0,0 +1,82 @@ +// Lighting and haze for the terrain scene, as pure functions of one sky's baked numbers (./skyData.ts). +// No DOM, no GL. The fragment shader does the same arithmetic with these results as uniforms. +import { isNight } from '../ridge/stars'; +import type { SkyColours } from '../ridge/skyColour'; +import { SKY, SkyData, SkyName, Vec3 } from './skyData'; + +const RAD = Math.PI / 180; +const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); +const smooth = (a: number, b: number, x: number) => { + const t = clamp01((x - a) / (b - a)); + return t * t * (3 - 2 * t); +}; + +/** Which baked sky a ridge-style `SkyColours` means: the night notion the stars use (`isNight`). */ +export const skyNameFor = (sky: SkyColours): SkyName => (isNight(sky) ? 'midnight' : 'noonday'); + +export const isSkyName = (s: unknown): s is SkyName => s === 'midnight' || s === 'noonday'; + +/** sRGB hex `#rrggbb` to linear RGB 0..1. */ +export function hexToLinear(hex: string): Vec3 { + const ch = (at: number) => { + const s = parseInt(hex.slice(at, at + 2), 16) / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return [ch(1), ch(3), ch(5)]; +} + +/** Unit vector toward the sun: x east, y up, z south (so azimuth 0, north, is -z). */ +export function sunDirection(d: SkyData): Vec3 { + const el = d.elevation * RAD; + const az = d.azimuth * RAD; + return [Math.sin(az) * Math.cos(el), Math.sin(el), -Math.cos(az) * Math.cos(el)]; +} + +/** + * The sun's colour as the ground sees it: baked `direct`, off at the horizon the way sun.js fades the disc + * (gone at -0.83 degrees, soft to 1.5). A sun below the horizon lights nothing. + */ +export function sunColour(d: SkyData): Vec3 { + const k = smooth(-0.83, 1.5, d.elevation); + return [d.direct[0] * k, d.direct[1] * k, d.direct[2] * k]; +} + +/** + * Ambient light on an upward surface, from the baked gradient's zenith stop. The gain is chosen so the + * noonday value (0.075, 0.14, 0.25) matches sun.js's own `diffuse` there (0.07, 0.13, 0.27); at midnight + * the same gain turns the gradient's glow into a faint moonlit fill, where `diffuse` alone would be black. + */ +export const AMBIENT_GAIN = 0.25; +export function ambientColour(d: SkyData): Vec3 { + const z = hexToLinear(d.gradient[0]); + return [z[0] * AMBIENT_GAIN, z[1] * AMBIENT_GAIN, z[2] * AMBIENT_GAIN]; +} + +/** The colour distance fades to: the gradient's horizon stop, linear. */ +export const hazeColour = (d: SkyData): Vec3 => hexToLinear(d.gradient[d.gradient.length - 1]); + +/** Haze: transmittance T = exp(-k d) at distance d metres. */ +export const HAZE_K = 0.0012; +export const transmittance = (distance: number): number => + Math.exp(-HAZE_K * Math.max(0, distance)); + +/** + * Linear colour of a surface: lambert from the sun plus ambient (full on an upward face, half on a + * vertical one, none on a downward one: `0.5 + 0.5 n.y`), then hazed toward the horizon colour by distance. + * `normal` is a unit vector. Never negative, whatever the sun does. + */ +export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number): Vec3 { + const sun = sunDirection(d); + const lambert = Math.max(0, normal[0] * sun[0] + normal[1] * sun[1] + normal[2] * sun[2]); + const direct = sunColour(d); + const amb = ambientColour(d); + const hemi = 0.5 + 0.5 * normal[1]; + const haze = hazeColour(d); + const t = transmittance(distance); + return [0, 1, 2].map((i) => { + const lit = albedo[i] * (direct[i] * lambert + amb[i] * Math.max(0, hemi)); + return lit * t + haze[i] * (1 - t); + }) as unknown as Vec3; +} + +export const skyData = (name: SkyName): SkyData => SKY[name]; From 5642acf2f9ea10097c92a7dbda518231592ad1ab Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 18:36:18 +0000 Subject: [PATCH 004/111] scene: terrain style, a baked Phacelle height map on a displaced grid lit by the baked sky style 'terrain' (WebGL2, tier-gated, dynamic import): height map baked once on the GPU into RGBA8 (promoted from the terrain-tier-spike), a displaced grid, the baked gradient as sky, baked sun/ambient/haze. Sky from option, ?sky=, or the stars' night notion. Cadence guard, pauses, context loss and compile failure follow the ridge scene. preview-config.js takes an optional style argument. Co-Authored-By: Claude Sonnet 5.5 --- scripts/preview-config.js | 7 +- src/app/features/scene/index.ts | 10 +- .../features/scene/terrain/Terrain.test.ts | 235 ++++++++++++ src/app/features/scene/terrain/Terrain.ts | 359 ++++++++++++++++++ .../features/scene/terrain/terrainShaders.ts | 146 +++++++ .../scene/terrain/terrainWorld.test.ts | 81 ++++ .../features/scene/terrain/terrainWorld.ts | 126 ++++++ src/app/features/scene/tier.ts | 7 +- 8 files changed, 961 insertions(+), 10 deletions(-) create mode 100644 src/app/features/scene/terrain/Terrain.test.ts create mode 100644 src/app/features/scene/terrain/Terrain.ts create mode 100644 src/app/features/scene/terrain/terrainShaders.ts create mode 100644 src/app/features/scene/terrain/terrainWorld.test.ts create mode 100644 src/app/features/scene/terrain/terrainWorld.ts diff --git a/scripts/preview-config.js b/scripts/preview-config.js index 589f8efa..75746f98 100644 --- a/scripts/preview-config.js +++ b/scripts/preview-config.js @@ -1,12 +1,13 @@ // 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] +// in the repo stays 'off'. Usage: node scripts/preview-config.js [tier] [style] import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const tier = process.argv[2] ?? 'low'; +const style = process.argv[3]; 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 }; +config.backgroundScene = { ...config.backgroundScene, tier, ...(style ? { style } : {}) }; fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); -console.log(`${file}: backgroundScene.tier = ${tier}`); +console.log(`${file}: backgroundScene.tier = ${tier}${style ? `, style = ${style}` : ''}`); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index a5b2de7d..c324b2d5 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -12,7 +12,7 @@ export type StartOptions = Omit Scene | null; try { - createScene = - config?.style === 'raymarch' - ? (await import('./Scene')).createScene - : (await import('./ridge/Ridge')).createRidgeScene; + if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; + else if (config?.style === 'terrain') + createScene = (await import('./terrain/Terrain')).createTerrainScene; + else createScene = (await import('./ridge/Ridge')).createRidgeScene; } catch (e) { options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); return null; diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts new file mode 100644 index 00000000..8338721e --- /dev/null +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -0,0 +1,235 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-empty-function -- fake GL and DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTerrainScene } from './Terrain'; +import { GRID, triangleCount } from './terrainWorld'; +import { SKY } from './skyData'; +import { ambientColour } from './terrainSky'; +import { startBackgroundScene } from '../index'; + +/** A GL context that accepts every call, remembers them, reports success for compile, link and framebuffer. */ +function fakeGl(over: Record = {}) { + const calls: Record = {}; + const gl: any = new Proxy( + {}, + { + get(_t, name: string) { + if (name in over) return over[name]; + if (name === 'getShaderParameter' || name === 'getProgramParameter') return () => true; + if (name === 'getAttribLocation' || name === 'getUniformLocation') return () => 1; + if (name === 'checkFramebufferStatus') return () => 1; + if (name === 'getParameter') return () => 16; + if (/^[A-Z_0-9]+$/.test(name)) return 1; + return (...args: unknown[]) => { + (calls[name] ??= []).push(args); + return {}; + }; + }, + } + ); + return { gl, calls }; +} + +let rafQueue: Array<(t: number) => void>; +let visibility: string; +let search: string; +beforeEach(() => { + rafQueue = []; + visibility = 'visible'; + search = ''; + vi.stubGlobal('document', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + get visibilityState() { + return visibility; + }, + createElement: () => ({ style: {}, remove() {} }), + }); + vi.stubGlobal('window', { + devicePixelRatio: 1, + innerWidth: 400, + innerHeight: 800, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + get location() { + return { search }; + }, + setTimeout, + }); + vi.stubGlobal('performance', { now: () => 0 }); + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb)); + vi.stubGlobal('cancelAnimationFrame', () => { + rafQueue = []; + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function canvasWith(gl: unknown) { + return { + width: 0, + height: 0, + clientWidth: 400, + clientHeight: 800, + parentElement: { appendChild: vi.fn() }, + getContext: vi.fn(() => gl), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as any; +} + +describe('createTerrainScene', () => { + it("returns null at tier 'off' and touches nothing", () => { + const canvas = canvasWith(null); + expect(createTerrainScene({ canvas, tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('returns null without WebGL2, so the host keeps its CSS ground', () => { + const canvas = canvasWith(null); + expect(createTerrainScene({ canvas, tier: 'full' })).toBeNull(); + expect(canvas.getContext).toHaveBeenCalledWith('webgl2', expect.anything()); + }); + + it('returns null with no vertex texture units, or an incomplete bake target', () => { + expect( + createTerrainScene({ canvas: canvasWith(fakeGl({ getParameter: () => 0 }).gl), tier: 'full' }) + ).toBeNull(); + const { gl, calls } = fakeGl({ checkFramebufferStatus: () => 0 }); + expect(createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })).toBeNull(); + expect(calls.deleteProgram).toHaveLength(3); + }); + + it('reports a shader that does not compile to onCompileFail and returns null', () => { + const { gl } = fakeGl({ getShaderParameter: () => false, getShaderInfoLog: () => 'bad' }); + const onCompileFail = vi.fn(); + expect(createTerrainScene({ canvas: canvasWith(gl), tier: 'full', onCompileFail })).toBeNull(); + expect(onCompileFail).toHaveBeenCalledOnce(); + expect(onCompileFail.mock.calls[0][0].log).toContain('terrain height'); + }); + + it('bakes the height map once, then draws sky and grid each frame and keeps turning', () => { + const { gl, calls } = fakeGl(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'low' })!; + expect(scene).not.toBeNull(); + const bakes = calls.drawArrays.length; + expect(bakes).toBe(1); + rafQueue.shift()!(16); + rafQueue.shift()!(32); + expect(calls.drawArrays).toHaveLength(bakes + 2); // one sky draw per frame + expect(calls.drawElements).toHaveLength(2); + expect(calls.drawElements[0][1]).toBe(triangleCount(GRID.low.grid) * 3); + expect(calls.texImage2D).toHaveLength(1); // the baked map is never re-uploaded + expect(rafQueue).toHaveLength(1); + scene.dispose(); + }); + + it("'still' draws one frame and queues nothing", () => { + const { gl, calls } = fakeGl(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'still' })!; + expect(calls.drawElements).toHaveLength(1); + expect(rafQueue).toHaveLength(0); + scene.dispose(); + }); + + it('lights with the sky chosen: option, then ?sky=, then the night default', () => { + const ambientUploaded = (opts: { sky?: 'midnight' | 'noonday' }) => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); + const hit = calls.uniform3fv.filter((a) => (a[1] as ArrayLike).length === 3); + // uniform3fv order in draw(): right, up, forward, then sun, sunColour, ambient, haze (+ camera) + return hit.map((a) => Array.from(a[1] as ArrayLike)); + }; + const night = ambientUploaded({}); + const day = ambientUploaded({ sky: 'noonday' }); + const nightA = Array.from(ambientColour(SKY.midnight)); + const dayA = Array.from(ambientColour(SKY.noonday)); + const has = (all: number[][], v: number[]) => + all.some((a) => a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); + expect(has(night, nightA)).toBe(true); + expect(has(night, dayA)).toBe(false); + expect(has(day, dayA)).toBe(true); + search = '?sky=noonday'; + expect(has(ambientUploaded({}), dayA)).toBe(true); + search = '?sky=garbage'; + expect(has(ambientUploaded({}), nightA)).toBe(true); + }); + + it('falls back to a still frame when the measured p95 is over 33 ms', () => { + const { gl, calls } = fakeGl(); + const onProbe = vi.fn(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'full', onProbe })!; + let t = 0; + for (let i = 0; i < 400 && rafQueue.length; i += 1) rafQueue.shift()!((t += 50)); + expect(rafQueue).toHaveLength(0); + expect(onProbe).toHaveBeenLastCalledWith(expect.objectContaining({ still: true })); + expect(calls.drawElements.length).toBeLessThan(400); + scene.dispose(); + }); + + it('does not draw while the tab is hidden, or under reduced motion', () => { + const { gl, calls } = fakeGl(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!; + const before = (calls.drawElements ?? []).length; + visibility = 'hidden'; + rafQueue.shift()!(16); + expect((calls.drawElements ?? []).length).toBe(before); + scene.dispose(); + vi.stubGlobal('window', { + ...(window as object), + matchMedia: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }), + }); + visibility = 'visible'; + const g2 = fakeGl(); + const still = createTerrainScene({ canvas: canvasWith(g2.gl), tier: 'full' })!; + expect(rafQueue).toHaveLength(0); + expect(g2.calls.drawElements).toHaveLength(1); // one frame, then still + still.dispose(); + }); + + it('a lost context stops the scene and tells the host', () => { + const { gl, calls } = fakeGl(); + const canvas = canvasWith(gl); + const onContextLost = vi.fn(); + createTerrainScene({ canvas, tier: 'full', onContextLost })!; + const handler = canvas.addEventListener.mock.calls.find( + (c: unknown[]) => c[0] === 'webglcontextlost' + )[1]; + handler({ preventDefault() {} }); + expect(onContextLost).toHaveBeenCalledOnce(); + const n = (calls.drawElements ?? []).length; + rafQueue.splice(0).forEach((f) => f(16)); + expect((calls.drawElements ?? []).length).toBe(n); + }); + + it('dispose frees its programs, buffers and texture and queues nothing more', () => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); + expect(calls.deleteProgram).toHaveLength(3); + expect(calls.deleteBuffer).toHaveLength(2); + expect(calls.deleteTexture).toHaveLength(1); + expect(rafQueue).toHaveLength(0); + }); +}); + +describe("startBackgroundScene style 'terrain'", () => { + it('requests webgl2 and returns a scene once the tier is on', async () => { + const { gl } = fakeGl(); + const canvas = canvasWith(gl); + const scene = await startBackgroundScene({ tier: 'still', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext).toHaveBeenCalledWith('webgl2', expect.anything()); + scene!.dispose(); + }); + + it('stays off by default, and the default style is still the ridge (no WebGL2)', async () => { + const canvas = canvasWith(null); + expect(await startBackgroundScene({ style: 'terrain' }, {}, { canvas })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + const { gl } = fakeGl(); + const c2 = { ...canvasWith(gl), getContext: vi.fn(() => null) }; + await startBackgroundScene({ tier: 'still' }, {}, { canvas: c2 }); + expect(c2.getContext).not.toHaveBeenCalledWith('webgl2', expect.anything()); + }); +}); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts new file mode 100644 index 00000000..2a81aa56 --- /dev/null +++ b/src/app/features/scene/terrain/Terrain.ts @@ -0,0 +1,359 @@ +import type { Scene, SceneOptions } from '../Scene'; +import { FrameSampler, FrameStats } from '../ridge/probe'; +import { skyColours } from '../ridge/skyColour'; +import { SkyName } from './skyData'; +import { + ambientColour, + hazeColour, + HAZE_K, + hexToLinear, + isSkyName, + skyData, + skyNameFor, + sunColour, + sunDirection, +} from './terrainSky'; +import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; +import { + FULLSCREEN_VERT, + GRID_FRAG, + GRID_VERT, + heightFragmentSource, + SKY_FRAG, +} from './terrainShaders'; + +export type TerrainOptions = Pick< + SceneOptions, + 'canvas' | 'tier' | 'onCompileFail' | 'onContextLost' +> & { + /** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else the night notion of `skyColours()`. */ + sky?: SkyName; + /** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */ + seed?: number; + /** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`. */ + probe?: boolean; + onProbe?: (s: FrameStats & { still: boolean; triangles: number }) => void; +}; + +/** Same shape as Scene.ts's ShaderCompileError, redeclared so this chunk does not import the ray-march scene. */ +class ShaderCompileError extends Error { + constructor(readonly stage: 'vertex', readonly log: string) { + super(`${stage} shader failed to compile:\n${log}`); + } +} + +function program(gl: WebGL2RenderingContext, name: string, vs: string, fs: string): WebGLProgram { + const make = (type: number, src: string, which: string) => { + const s = gl.createShader(type); + if (!s) throw new Error('createShader failed'); + gl.shaderSource(s, src); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(s) ?? ''; + gl.deleteShader(s); + throw new ShaderCompileError('vertex', `terrain ${name} ${which} shader: ${log}`); + } + return s; + }; + const v = make(gl.VERTEX_SHADER, vs, 'vertex'); + const f = make(gl.FRAGMENT_SHADER, fs, 'fragment'); + const p = gl.createProgram(); + if (!p) throw new Error('createProgram failed'); + gl.attachShader(p, v); + gl.attachShader(p, f); + gl.linkProgram(p); + gl.deleteShader(v); + gl.deleteShader(f); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(p) ?? ''; + gl.deleteProgram(p); + throw new ShaderCompileError('vertex', `terrain ${name} link: ${log}`); + } + return p; +} + +/** The sky to use: the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ +function chooseSky(opt: SkyName | undefined): SkyName { + if (opt) return opt; + const q = new URLSearchParams(window.location.search).get('sky'); + return isSkyName(q) ? q : skyNameFor(skyColours()); +} + +/** + * The terrain scene: same contract as `createRidgeScene`/`createScene` ({ login, dispose }, null for "keep the + * CSS ground"). WebGL2 only. The height map is baked once on the GPU (Phacelle, see terrainShaders.ts) into an + * RGBA8 texture; each frame is two draws: the baked gradient sky, then a displaced grid lit by the baked sun and + * ambient and hazed toward the baked horizon colour. Cadence guard, pauses and the `?probe=1` readout match the + * ridge scene. + */ +export function createTerrainScene(opts: TerrainOptions): Scene | null { + const tier = opts.tier ?? 'full'; + if (tier === 'off') return null; + const { canvas } = opts; + const probe = opts.probe ?? new URLSearchParams(window.location.search).get('probe') === '1'; + const gl = canvas.getContext('webgl2', { + antialias: false, + alpha: false, + }) as WebGL2RenderingContext | null; + if (!gl) return null; + const units = gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS); + if (typeof units === 'number' && units < 1) return null; + + const sky = skyData(chooseSky(opts.sky)); + const spec = GRID[tier]; + + let skyProg: WebGLProgram; + let gridProg: WebGLProgram; + let bakeProg: WebGLProgram; + try { + bakeProg = program(gl, 'height', FULLSCREEN_VERT, heightFragmentSource()); + skyProg = program(gl, 'sky', FULLSCREEN_VERT, SKY_FRAG); + gridProg = program(gl, 'grid', GRID_VERT, GRID_FRAG); + } catch (e) { + opts.onCompileFail?.(e as ShaderCompileError); + return null; + } + + // The height map, baked once. + const heightTex = gl.createTexture(); + const fbo = gl.createFramebuffer(); + gl.bindTexture(gl.TEXTURE_2D, heightTex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, spec.tex, spec.tex, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, heightTex, 0); + const complete = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE; + const free: Array<() => void> = []; + const release = () => { + free.forEach((f) => f()); + gl.deleteTexture(heightTex); + gl.deleteFramebuffer(fbo); + gl.deleteProgram(bakeProg); + gl.deleteProgram(skyProg); + gl.deleteProgram(gridProg); + }; + if (!complete) { + release(); + return null; + } + gl.viewport(0, 0, spec.tex, spec.tex); + gl.disable(gl.DEPTH_TEST); + gl.useProgram(bakeProg); + gl.uniform1f(gl.getUniformLocation(bakeProg, 'uSpan'), TERRAIN.span); + gl.uniform1f(gl.getUniformLocation(bakeProg, 'uTexels'), spec.tex); + gl.uniform2fv(gl.getUniformLocation(bakeProg, 'uSeed'), seedOffset(opts.seed ?? 0)); + gl.drawArrays(gl.TRIANGLES, 0, 3); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + // The displaced grid. + const g = buildGrid(spec.grid); + const vao = gl.createVertexArray(); + const vbuf = gl.createBuffer(); + const ibuf = gl.createBuffer(); + gl.bindVertexArray(vao); + gl.bindBuffer(gl.ARRAY_BUFFER, vbuf); + gl.bufferData(gl.ARRAY_BUFFER, g.uv, gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibuf); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, g.indices, gl.STATIC_DRAW); + gl.bindVertexArray(null); + free.push( + () => gl.deleteVertexArray(vao), + () => gl.deleteBuffer(vbuf), + () => gl.deleteBuffer(ibuf) + ); + + const gradient = new Float32Array(sky.gradient.flatMap((h) => [...hexToLinear(h)])); + const sun = [...sunDirection(sky)]; + const sunLight = [...sunColour(sky)]; + const ambient = [...ambientColour(sky)]; + const haze = [...hazeColour(sky)]; + const triangles = triangleCount(spec.grid); + + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + const sampler = new FrameSampler(); + let still = tier === 'still'; + let disposed = false; + let lost = false; + let raf = 0; + let readout: HTMLElement | undefined; + let resizeTimer = 0; + let turnsAtPause = 0; + let playedFrom: number | undefined; + + /** Size the canvas: low renders at 0.75 of the CSS size; full at device pixel ratio capped to 2. */ + const size = () => { + const dpr = Math.min(window.devicePixelRatio || 1, 2) * (tier === 'low' ? 0.75 : 1); + canvas.width = Math.max(1, Math.round((canvas.clientWidth || window.innerWidth) * dpr)); + canvas.height = Math.max(1, Math.round((canvas.clientHeight || window.innerHeight) * dpr)); + }; + + const draw = (turns: number) => { + if (lost) return; + gl.viewport(0, 0, canvas.width, canvas.height); + const v = view(canvas.width / canvas.height, turns % 1); + gl.disable(gl.DEPTH_TEST); + gl.useProgram(skyProg); + const us = (n: string) => gl.getUniformLocation(skyProg, n); + gl.uniform3fv(us('uGrad'), gradient); + gl.uniform3fv(us('uRight'), v.right); + gl.uniform3fv(us('uUp'), v.up); + gl.uniform3fv(us('uForward'), v.forward); + gl.uniform2f(us('uSize'), canvas.width, canvas.height); + gl.uniform1f(us('uTanHalf'), Math.tan(CAMERA.fov / 2)); + gl.drawArrays(gl.TRIANGLES, 0, 3); + + gl.enable(gl.DEPTH_TEST); + gl.clear(gl.DEPTH_BUFFER_BIT); + gl.useProgram(gridProg); + const u = (n: string) => gl.getUniformLocation(gridProg, n); + gl.uniformMatrix4fv(u('uViewProj'), false, v.viewProj); + gl.uniform3fv(u('uCamera'), v.eye); + gl.uniform1f(u('uSpan'), TERRAIN.span); + gl.uniform1i(u('uTex'), spec.tex); + gl.uniform3fv(u('uSun'), sun); + gl.uniform3fv(u('uSunColour'), sunLight); + gl.uniform3fv(u('uAmbient'), ambient); + gl.uniform3fv(u('uHaze'), haze); + gl.uniform1f(u('uHazeK'), HAZE_K); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, heightTex); + gl.uniform1i(u('uHeight'), 0); + gl.bindVertexArray(vao); + gl.drawElements(gl.TRIANGLES, g.indices.length, gl.UNSIGNED_INT, 0); + gl.bindVertexArray(null); + }; + + const moving = () => + !still && + !disposed && + !lost && + !(reducedMotion?.matches ?? false) && + document.visibilityState === 'visible'; + + const turnsNow = (now: number) => + turnsAtPause + (playedFrom === undefined ? 0 : (now - playedFrom) / (CAMERA.seconds * 1000)); + + const report = (stillNow: boolean) => { + const s = { ...sampler.stats(), still: stillNow, triangles }; + opts.onProbe?.(s); + if (readout) { + readout.textContent = `terrain · ${s.triangles} tris · n ${s.n} · median ${s.medianMs.toFixed( + 1 + )} ms · p95 ${s.p95Ms.toFixed(1)} ms${stillNow ? ' · STILL' : ''}`; + } + }; + + const settleToStill = () => { + if (playedFrom !== undefined) turnsAtPause = turnsNow(performance.now()); + playedFrom = undefined; + still = true; + cancelAnimationFrame(raf); + raf = 0; + draw(turnsAtPause); + report(true); + }; + + let sinceReport = 0; + // The first window is measured (guard against a slow device); after it the loop only draws. + let measuring = true; + const frame = (now: number) => { + raf = 0; + if (!moving()) return; + draw(turnsNow(now)); + if (!measuring) { + raf = requestAnimationFrame(frame); + return; + } + const full = sampler.push(now); + if (full) { + measuring = false; + if (sampler.slow()) { + settleToStill(); + return; + } + if (probe) report(false); + } else if (probe && now - sinceReport > 500) { + sinceReport = now; + report(false); + } + raf = requestAnimationFrame(frame); + }; + const wake = () => { + if (!moving()) { + if (playedFrom !== undefined) turnsAtPause = turnsNow(performance.now()); + playedFrom = undefined; + return; + } + if (raf) return; + playedFrom = performance.now(); + if (measuring) sampler.reset(); + raf = requestAnimationFrame(frame); + }; + + const onLost = (e: Event) => { + e.preventDefault(); + lost = true; + cancelAnimationFrame(raf); + raf = 0; + opts.onContextLost?.(); + }; + canvas.addEventListener('webglcontextlost', onLost); + + size(); + if (probe && canvas.parentElement) { + readout = document.createElement('div'); + Object.assign(readout.style, { + position: 'absolute', + left: '8px', + top: '8px', + font: '12px monospace', + color: '#fff', + background: 'rgba(0,0,0,.55)', + padding: '2px 6px', + borderRadius: '3px', + pointerEvents: 'none', + }); + canvas.parentElement.appendChild(readout); + } + if (still) settleToStill(); + else wake(); + if (!still && !moving()) draw(0); + + const onVisibility = () => { + sampler.reset(); + wake(); + }; + const onResize = () => { + clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + if (disposed || lost) return; + size(); + draw(turnsNow(performance.now())); + sampler.reset(); + }, 250); + }; + document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('resize', onResize); + reducedMotion?.addEventListener?.('change', onVisibility); + + return { + login() { + // The camera is the same before and after. + }, + dispose() { + if (disposed) return; + disposed = true; + cancelAnimationFrame(raf); + clearTimeout(resizeTimer); + document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('resize', onResize); + reducedMotion?.removeEventListener?.('change', onVisibility); + canvas.removeEventListener('webglcontextlost', onLost); + readout?.remove(); + release(); + }, + }; +} diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts new file mode 100644 index 00000000..0fe8adc5 --- /dev/null +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -0,0 +1,146 @@ +// GLSL ES 3.00. The height map is baked once by CultMath's Phacelle noise (vendored, unedited, MPL-2.0: Rune +// Skovbo Johansen's Phacelle noise, https://blog.runevision.com/2026/01/) composed into a slope-directed gully +// filter after the description in his March 2026 erosion-filter post. The composition is ours, written from +// that description; it is not his filter and has not been compared against it (see harness terrainDemo.ts on +// the terrain-tier-spike branch, from which this is promoted). The height is packed in two bytes of an RGBA8 +// target, so no float colour-buffer extension is needed. +import cultmath from '../shaders/CultMath.glsl?raw'; +import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; +import { trimGlsl } from '../cultmathTrim'; +import { TERRAIN } from './terrainWorld'; + +const HEADER = + '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; +const f1 = (x: number) => x.toFixed(1); + +export const FULLSCREEN_VERT = `${HEADER} +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +}`; + +const HEIGHT_STAGE = ` +uniform float uSpan; +uniform vec2 uSeed; +uniform float uTexels; +out vec4 o; + +const float BASE_AMP = 60.0; +const float BASE_FREQ = 1.0 / 520.0; +const float BASE_LIFT = 110.0; +const float CELL0 = 160.0; +const float SIDE_LEN = 3.0; +const float NORM = 0.5; +const float SLOPE_REF = 0.55; +const float DETAIL = 1.5; +const float AMP0 = 11.0; +const float HLO = 20.0, HHI = 200.0; + +float easeOut(float t) { t = clamp(t, 0.0, 1.0); return 1.0 - (1.0 - t) * (1.0 - t); } +float powInv(float t, float p) { return 1.0 - pow(1.0 - t, p); } + +float height(vec2 xz) { + vec4 fg = cultmath_fbm_grad(vec3(xz * BASE_FREQ, 0.0), 4, 2.0, 0.45); + float h = BASE_LIFT + BASE_AMP * fg.w; + vec2 g = BASE_AMP * BASE_FREQ * fg.xy; + float target = clamp((h - HLO) / (HHI - HLO) * 2.0 - 1.0, -1.0, 1.0); + float combi = 1.0; + float amp = AMP0; + float cell = CELL0; + for (int i = 0; i < OCTAVES; i++) { + float slope = length(g); + vec2 downhill = slope > 1e-4 ? -g / slope : vec2(1.0, 0.0); + vec2 across = vec2(-downhill.y, downhill.x); + CultPhasor ph = cultmath_phacelle(vec3(xz / cell, 0.5), vec3(across * SIDE_LEN, 0.0), 0.0, NORM); + float mask = easeOut(slope / SLOPE_REF) * combi; + float faded = mix(target, ph.cos.w, mask); + h += amp * faded; + g += amp * mask * ph.cos.xy / cell; + combi = powInv(combi, DETAIL) * (1.0 - abs(ph.sin.w)); + target = faded; + amp *= 0.5; + cell *= 0.5; + } + return h; +} + +void main() { + vec2 uv = gl_FragCoord.xy / vec2(uTexels); + float h = clamp(height((uv - 0.5) * uSpan + uSeed) / ${f1(TERRAIN.hmax)}, 0.0, 1.0); + float q = floor(h * 65535.0 + 0.5); + o = vec4(floor(q / 256.0) / 255.0, mod(q, 256.0) / 255.0, 0.0, 1.0); +}`; + +/** The height-bake fragment shader: header, the part of CultMath that Phacelle and the stage reach, Phacelle, the stage. */ +export function heightFragmentSource(): string { + const math = trimGlsl(cultmath, [phacelle, HEIGHT_STAGE]); + return `${HEADER}#define OCTAVES 4\n${math}\n${phacelle}\n${HEIGHT_STAGE}`; +} + +const FETCH = ` +uniform sampler2D uHeight; +uniform int uTex; +float hAt(ivec2 t) { + t = clamp(t, ivec2(0), ivec2(uTex - 1)); + vec4 s = texelFetch(uHeight, t, 0); + return (floor(s.r * 255.0 + 0.5) * 256.0 + floor(s.g * 255.0 + 0.5)) / 65535.0 * ${f1( + TERRAIN.hmax + )}; +}`; + +export const GRID_VERT = `${HEADER} +layout(location = 0) in vec2 a_uv; +uniform mat4 uViewProj; +uniform float uSpan; +out vec2 v_uv; +out vec3 v_world; +${FETCH} +void main() { + ivec2 t = ivec2(a_uv * float(uTex - 1) + 0.5); + // The patch's rim sinks to zero so the grid has no cliff edge to show through the haze. + float rim = 1.0 - smoothstep(0.78, 1.0, 2.0 * max(abs(a_uv.x - 0.5), abs(a_uv.y - 0.5))); + float h = hAt(t) * rim; + v_uv = a_uv; + v_world = vec3((a_uv.x - 0.5) * uSpan, h, (a_uv.y - 0.5) * uSpan); + gl_Position = uViewProj * vec4(v_world, 1.0); +}`; + +// Lit by the baked sky: uSun (direction), uSunColour (linear, zero when the sun is down), uAmbient, uHaze (linear). +// The same arithmetic as terrainSky.shade(); the normal comes from the height map so shading carries detail finer than the mesh. +export const GRID_FRAG = `${HEADER} +in vec2 v_uv; +in vec3 v_world; +uniform vec3 uCamera, uSun, uSunColour, uAmbient, uHaze; +uniform float uSpan, uHazeK; +out vec4 o; +${FETCH} +void main() { + ivec2 t = ivec2(v_uv * float(uTex - 1) + 0.5); + float texel = uSpan / float(uTex - 1); + float dx = hAt(t + ivec2(1, 0)) - hAt(t - ivec2(1, 0)); + float dz = hAt(t + ivec2(0, 1)) - hAt(t - ivec2(0, 1)); + vec3 n = normalize(vec3(-dx, 2.0 * texel, -dz)); + float alt = clamp(v_world.y / 200.0, 0.0, 1.0); + vec3 rock = mix(vec3(0.07, 0.06, 0.05), vec3(0.28, 0.26, 0.22), alt); + vec3 grass = mix(vec3(0.03, 0.07, 0.025), vec3(0.14, 0.15, 0.07), alt); + vec3 albedo = mix(rock, grass, smoothstep(0.55, 0.9, n.y)); + vec3 lit = albedo * (uSunColour * max(dot(n, uSun), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); + float T = exp(-uHazeK * length(v_world - uCamera)); + o = vec4(pow(mix(uHaze, lit, T), vec3(1.0 / 2.2)), 1.0); +}`; + +// The sky behind: the gradient's seven stops, zenith first, by the view ray's elevation (horizon stop at and below the horizon). +export const SKY_FRAG = `${HEADER} +uniform vec3 uGrad[7]; +uniform vec3 uRight, uUp, uForward; +uniform vec2 uSize; +uniform float uTanHalf; +out vec4 o; +void main() { + vec2 ndc = gl_FragCoord.xy / uSize * 2.0 - 1.0; + vec3 d = normalize(uForward + ndc.x * uTanHalf * uSize.x / uSize.y * uRight + ndc.y * uTanHalf * uUp); + float s = (1.0 - sqrt(clamp(d.y, 0.0, 1.0))) * 6.0; + int i = int(min(floor(s), 5.0)); + vec3 c = mix(uGrad[i], uGrad[i + 1], s - float(i)); + o = vec4(pow(c, vec3(1.0 / 2.2)), 1.0); +}`; diff --git a/src/app/features/scene/terrain/terrainWorld.test.ts b/src/app/features/scene/terrain/terrainWorld.test.ts new file mode 100644 index 00000000..5d21ba47 --- /dev/null +++ b/src/app/features/scene/terrain/terrainWorld.test.ts @@ -0,0 +1,81 @@ +/* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ +import { describe, expect, it } from 'vitest'; +import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; +import { heightFragmentSource } from './terrainShaders'; + +describe('seed', () => { + it('same seed, same offset, so the baked height map is the same; a different seed moves it', () => { + expect(seedOffset(7)).toEqual(seedOffset(7)); + expect(seedOffset(0)).toEqual([0, 0]); + expect(seedOffset(1)).not.toEqual(seedOffset(2)); + }); + + it('the height-bake source is a pure function of nothing but the world constants', () => { + expect(heightFragmentSource()).toBe(heightFragmentSource()); + const src = heightFragmentSource(); + expect(src.startsWith('#version 300 es')).toBe(true); + expect(src).toContain('cultmath_phacelle'); + expect(src).toContain(`/ ${TERRAIN.hmax.toFixed(1)}`); + expect(src).toContain('uSeed'); + }); +}); + +describe('grid', () => { + it('has (n+1)^2 vertices in 0..1 and 2n^2 triangles that index them all in range', () => { + const n = 8; + const g = buildGrid(n); + expect(g.uv.length).toBe((n + 1) * (n + 1) * 2); + expect(g.indices.length / 3).toBe(triangleCount(n)); + expect(Math.max(...g.indices)).toBe((n + 1) * (n + 1) - 1); + expect(Math.min(...g.uv)).toBe(0); + expect(Math.max(...g.uv)).toBe(1); + }); + + it('each tier fits a 32-bit index and the cheap tiers stay cheap', () => { + expect(triangleCount(GRID.low.grid)).toBeLessThan(40000); + expect(GRID.full.grid).toBeGreaterThan(GRID.low.grid); + expect(GRID.still.tex).toBeLessThanOrEqual(GRID.low.tex); + }); +}); + +describe('camera', () => { + const project = (m: Float32Array, p: number[]) => { + const x = m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12]; + const y = m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13]; + const z = m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]; + const w = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]; + return { x: x / w, y: y / w, z: z / w, w }; + }; + + it('a point straight ahead lands in the middle of the screen, in front, inside the depth range', () => { + for (const turns of [0, 0.25, 0.6]) { + const v = view(0.46, turns); + const p = v.eye.map((e, i) => e + 800 * v.forward[i]); + const s = project(v.viewProj, p); + expect(s.w).toBeGreaterThan(0); + expect(s.x).toBeCloseTo(0, 5); + expect(s.y).toBeCloseTo(0, 5); + expect(Math.abs(s.z)).toBeLessThan(1); + } + }); + + it('looks down by the pitch, and one full turn comes back to the same heading', () => { + const a = view(1, 0); + const b = view(1, 1); + expect(a.forward[1]).toBeCloseTo(-Math.sin(CAMERA.pitch), 9); + for (let i = 0; i < 3; i += 1) expect(a.forward[i]).toBeCloseTo(b.forward[i], 9); + }); + + it('sits above the highest possible terrain, so the eye is never inside a hill', () => { + expect(CAMERA.eye).toBeGreaterThan(TERRAIN.hmax); + }); + + it('the basis is orthonormal', () => { + const v = view(0.5, 0.37); + const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + expect(dot(v.right, v.up)).toBeCloseTo(0, 9); + expect(dot(v.right, v.forward)).toBeCloseTo(0, 9); + expect(dot(v.up, v.forward)).toBeCloseTo(0, 9); + expect(Math.hypot(...v.up)).toBeCloseTo(1, 9); + }); +}); diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts new file mode 100644 index 00000000..e21a229e --- /dev/null +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -0,0 +1,126 @@ +// The terrain scene's fixed numbers, grid and camera, as pure functions. No DOM, no GL. +// The height field itself is a function of world position written in GLSL (terrainShaders.ts), baked once +// into a texture; the CPU owns only where it is looked at from, and the seed offset it is sampled at. + +/** Metres across the patch, and the height in metres that normalised height 1.0 stands for. */ +export const TERRAIN = { span: 3000, hmax: 256 }; + +/** Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. */ +export const GRID = { + still: { grid: 128, tex: 512 }, + low: { grid: 128, tex: 512 }, + full: { grid: 192, tex: 1024 }, +} as const; + +/** The camera: at the patch centre, above the highest peak, turning slowly and looking a little down. */ +export const CAMERA = { + eye: 330, + pitch: (13 * Math.PI) / 180, + fov: (55 * Math.PI) / 180, + near: 10, + far: 6000, + /** Seconds for one full turn. */ + seconds: 420, +}; + +/** The height map's sample offset in metres for a seed: same seed, same terrain. Seed 0 is the origin. */ +export function seedOffset(seed: number): [number, number] { + const s = Math.trunc(seed); + return [(s * 7919) % 65536, (s * 104729) % 65536]; +} + +export const triangleCount = (grid: number): number => 2 * grid * grid; + +/** Vertex grid in unit uv: (grid + 1)^2 positions, then the two-triangle-per-quad index list. */ +export function buildGrid(grid: number): { uv: Float32Array; indices: Uint32Array } { + const uv = new Float32Array((grid + 1) * (grid + 1) * 2); + for (let j = 0, k = 0; j <= grid; j += 1) + for (let i = 0; i <= grid; i += 1, k += 2) { + uv[k] = i / grid; + uv[k + 1] = j / grid; + } + const indices = new Uint32Array(grid * grid * 6); + for (let j = 0, k = 0; j < grid; j += 1) + for (let i = 0; i < grid; i += 1, k += 6) { + const a = j * (grid + 1) + i; + indices.set([a, a + grid + 1, a + 1, a + 1, a + grid + 1, a + grid + 2], k); + } + return { uv, indices }; +} + +export interface View { + /** Column-major perspective * view. */ + viewProj: Float32Array; + eye: [number, number, number]; + /** Camera basis in world space, for the sky pass. */ + right: [number, number, number]; + up: [number, number, number]; + forward: [number, number, number]; +} + +const norm = (a: number[]): [number, number, number] => { + const l = Math.hypot(a[0], a[1], a[2]) || 1; + return [a[0] / l, a[1] / l, a[2] / l]; +}; +const cross = (a: number[], b: number[]): [number, number, number] => [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], +]; + +/** The camera `turns` full turns round (0..1 wraps), for a viewport aspect (width / height). */ +export function view(aspect: number, turns: number): View { + const yaw = 2 * Math.PI * turns; + const cp = Math.cos(CAMERA.pitch); + const forward = norm([Math.sin(yaw) * cp, -Math.sin(CAMERA.pitch), -Math.cos(yaw) * cp]); + const right = norm(cross(forward, [0, 1, 0])); + const up = cross(right, forward); + const eye: [number, number, number] = [0, CAMERA.eye, 0]; + const t = (a: number[]) => -(a[0] * eye[0] + a[1] * eye[1] + a[2] * eye[2]); + const v = [ + right[0], + up[0], + -forward[0], + 0, + right[1], + up[1], + -forward[1], + 0, + right[2], + up[2], + -forward[2], + 0, + t(right), + t(up), + -t(forward), + 1, + ]; + const f = 1 / Math.tan(CAMERA.fov / 2); + const { near: n, far } = CAMERA; + const p = [ + f / aspect, + 0, + 0, + 0, + 0, + f, + 0, + 0, + 0, + 0, + (far + n) / (n - far), + -1, + 0, + 0, + (2 * far * n) / (n - far), + 0, + ]; + const viewProj = new Float32Array(16); + for (let c = 0; c < 4; c += 1) + for (let r = 0; r < 4; r += 1) { + let s = 0; + for (let k = 0; k < 4; k += 1) s += p[k * 4 + r] * v[c * 4 + k]; + viewProj[c * 4 + r] = s; + } + return { viewProj, eye, right, up, forward }; +} diff --git a/src/app/features/scene/tier.ts b/src/app/features/scene/tier.ts index e2c82325..4a976081 100644 --- a/src/app/features/scene/tier.ts +++ b/src/app/features/scene/tier.ts @@ -15,8 +15,11 @@ export interface Capabilities { hardwareConcurrency?: number; } -/** 'ridge': baked parallax ridges, no WebGL (default). 'raymarch': the sphere-traced world with its tree and clouds. */ -export type SceneStyle = 'ridge' | 'raymarch'; +/** + * 'ridge': baked parallax ridges, no WebGL (default). 'raymarch': the sphere-traced world with its tree and + * clouds. 'terrain': a baked height map on a displaced grid, lit and hazed by the baked sky (WebGL2). + */ +export type SceneStyle = 'ridge' | 'raymarch' | 'terrain'; export interface BackgroundSceneConfig { tier?: SceneTier; From 9e34664550bd8dc369ef9edd6efa580f3cafaccb Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 18:43:33 +0000 Subject: [PATCH 005/111] scene: terrain shading blended across texels, smoother sky horizon, lighter haze Preview on Starfire showed one flat normal per height-map texel (blocky ground) and a hard step in the sky gradient at the geometric horizon. Normals now blend the four surrounding texels; the sky's elevation mapping eases through the horizon; haze k 0.0012 -> 0.0007 so relief reads. Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/terrainShaders.ts | 19 ++++++++++++++----- src/app/features/scene/terrain/terrainSky.ts | 2 +- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 0fe8adc5..1eff9c93 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -114,12 +114,21 @@ uniform vec3 uCamera, uSun, uSunColour, uAmbient, uHaze; uniform float uSpan, uHazeK; out vec4 o; ${FETCH} -void main() { - ivec2 t = ivec2(v_uv * float(uTex - 1) + 0.5); - float texel = uSpan / float(uTex - 1); +// Unnormalised normal at one texel, central differences over its neighbours. +vec3 nAt(ivec2 t, float texel) { float dx = hAt(t + ivec2(1, 0)) - hAt(t - ivec2(1, 0)); float dz = hAt(t + ivec2(0, 1)) - hAt(t - ivec2(0, 1)); - vec3 n = normalize(vec3(-dx, 2.0 * texel, -dz)); + return normalize(vec3(-dx, 2.0 * texel, -dz)); +} +void main() { + // The four texels around this fragment, blended, so the shading is continuous and not one flat square per texel. + vec2 p = v_uv * float(uTex - 1); + ivec2 t = ivec2(floor(p)); + vec2 f = fract(p); + float texel = uSpan / float(uTex - 1); + vec3 n = normalize(mix( + mix(nAt(t, texel), nAt(t + ivec2(1, 0), texel), f.x), + mix(nAt(t + ivec2(0, 1), texel), nAt(t + ivec2(1, 1), texel), f.x), f.y)); float alt = clamp(v_world.y / 200.0, 0.0, 1.0); vec3 rock = mix(vec3(0.07, 0.06, 0.05), vec3(0.28, 0.26, 0.22), alt); vec3 grass = mix(vec3(0.03, 0.07, 0.025), vec3(0.14, 0.15, 0.07), alt); @@ -139,7 +148,7 @@ out vec4 o; void main() { vec2 ndc = gl_FragCoord.xy / uSize * 2.0 - 1.0; vec3 d = normalize(uForward + ndc.x * uTanHalf * uSize.x / uSize.y * uRight + ndc.y * uTanHalf * uUp); - float s = (1.0 - sqrt(clamp(d.y, 0.0, 1.0))) * 6.0; + float s = (1.0 - sqrt(smoothstep(-0.15, 1.0, d.y))) * 6.0; int i = int(min(floor(s), 5.0)); vec3 c = mix(uGrad[i], uGrad[i + 1], s - float(i)); o = vec4(pow(c, vec3(1.0 / 2.2)), 1.0); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index 8f255206..d6c699c3 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -56,7 +56,7 @@ export function ambientColour(d: SkyData): Vec3 { export const hazeColour = (d: SkyData): Vec3 => hexToLinear(d.gradient[d.gradient.length - 1]); /** Haze: transmittance T = exp(-k d) at distance d metres. */ -export const HAZE_K = 0.0012; +export const HAZE_K = 0.0007; export const transmittance = (distance: number): number => Math.exp(-HAZE_K * Math.max(0, distance)); From 7216456a278828c0106a38990541d05b80c38e39 Mon Sep 17 00:00:00 2001 From: Void Date: Sat, 7 Nov 2026 18:46:39 +0000 Subject: [PATCH 006/111] sky: bake sun azimuth and colour, ambient with a night floor, horizon colour Co-Authored-By: Claude Sonnet 5.5 --- src/sky/baked.ts | 14 ++++++++++++++ src/sky/skyTokens.js | 13 ++++++++++++- src/sky/skyTokens.test.ts | 34 +++++++++++++++++++++++++++++++++- src/sky/sun.js | 22 ++++++++++++++++------ 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/sky/baked.ts b/src/sky/baked.ts index 15e72c8f..ecaffbe5 100644 --- a/src/sky/baked.ts +++ b/src/sky/baked.ts @@ -8,6 +8,13 @@ export const baked = { lon: 0, }, elevation: -44.579021600011224, + sun: { + elevation_deg: -44.579021600011224, + azimuth_deg: 2.4988773054640205, + color_linear: [0, 0, 0], + }, + ambient_linear: [0.018, 0.024, 0.04], + horizon_hex: '#484e64', gradient: ['#37446a', '#38456a', '#3b4669', '#3d4868', '#414a67', '#444c66', '#484e64'], tokens: { Background: { @@ -117,6 +124,13 @@ export const baked = { lon: 0, }, elevation: 45.16994813502928, + sun: { + elevation_deg: 45.16994813502928, + azimuth_deg: 182.58737070473103, + color_linear: [0.9189659216820631, 0.8444983652129638, 0.6933684802959983], + }, + ambient_linear: [0.07, 0.132, 0.27], + horizon_hex: '#e2f4ff', gradient: ['#95c6ff', '#9ac9ff', '#a5cfff', '#b3d6ff', '#c2dfff', '#d2e9ff', '#e2f4ff'], tokens: { Background: { diff --git a/src/sky/skyTokens.js b/src/sky/skyTokens.js index 0df39c33..c234de12 100644 --- a/src/sky/skyTokens.js +++ b/src/sky/skyTokens.js @@ -1,7 +1,8 @@ // Midnight Sky and Noonday Sky: theme tokens as albedo x irradiance. // Dependency-free apart from sun.js, so scripts/bake-sky.mjs and the tests run it under plain node. // -// bakeSky('midnight' | 'noonday') -> { moment, elevation, gradient: [hex x GRADIENT_STOPS], tokens } +// bakeSky('midnight' | 'noonday') -> { moment, elevation, sun, ambient_linear, horizon_hex, +// gradient: [hex x GRADIENT_STOPS], tokens, ... } // // The sky is lit by sun.js at a fixed moment (clear weather: a build-time bake has no feed). // A surface is `albedo * irradiance`, where irradiance is the sun and sky on an upward-facing @@ -24,6 +25,11 @@ export const MOMENTS = { midnight: { time: '2026-09-22T00:00:00Z', lat: 45, lon: 0 }, }; +// Lowest ambient a baked sky reports, linear RGB. sun.js's night sky is ~0.004, which is +// starlight-true but too dark to see by on a screen; this lifts it to a bluish moonlit-skyglow +// level. Daylight ambient is far above it, so the floor only ever touches the night. +export const AMBIENT_FLOOR = [0.018, 0.024, 0.04]; + // Linear-RGB albedos of the things Yggdrasil is made of. EYEBALLED around measured ranges // (dark bark 0.05-0.1, birch bark ~0.6, leaves ~0.05 red/0.15 green/0.03 blue, wet soil ~0.1). export const ALBEDO = { @@ -193,6 +199,11 @@ export function bakeSky(name) { return { moment, elevation: l.elevation, + // Sun for consumers that light a scene themselves: direction in degrees (azimuth clockwise + // from north) and the direct light's colour at normal incidence (black while it is below the horizon). + sun: { elevation_deg: l.elevation, azimuth_deg: l.azimuth, color_linear: l.direct }, + ambient_linear: l.diffuse.map((v, i) => Math.max(v, AMBIENT_FLOOR[i])), + horizon_hex: gradient[GRADIENT_STOPS - 1], // last stop: the horizon end of the gradient gradient, tokens, bubbles, diff --git a/src/sky/skyTokens.test.ts b/src/sky/skyTokens.test.ts index 0cd6694c..78a29b03 100644 --- a/src/sky/skyTokens.test.ts +++ b/src/sky/skyTokens.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { AA, bakeSky, contrast } from './skyTokens.js'; +import { AA, AMBIENT_FLOOR, bakeSky, contrast } from './skyTokens.js'; +import { light } from './sun.js'; import { baked } from './baked'; const names = ['midnight', 'noonday'] as const; @@ -18,6 +19,37 @@ describe.each(names)('%s sky', (name) => { expect(name === 'noonday' ? sky.elevation : -sky.elevation).toBeGreaterThan(40); }); + it('carries the sun, an ambient light and a horizon colour for consumers that light a scene', () => { + const { sun } = sky; + expect(Object.keys(sun).sort()).toEqual(['azimuth_deg', 'color_linear', 'elevation_deg']); + expect(sun.elevation_deg).toBe(sky.elevation); + expect(sun.color_linear).toHaveLength(3); + sun.color_linear.forEach((v: number) => { + expect(Number.isFinite(v)).toBe(true); + expect(v).toBeGreaterThanOrEqual(0); + }); + expect(sky.ambient_linear).toHaveLength(3); + expect(sky.horizon_hex).toMatch(/^#[0-9a-f]{6}$/); + expect(sky.horizon_hex).toBe(sky.gradient[sky.gradient.length - 1]); + }); + + it('puts the sun where Nibu expects it, azimuth clockwise from north', () => { + const azimuth: number = sky.sun.azimuth_deg; + expect(azimuth).toBeGreaterThanOrEqual(0); + expect(azimuth).toBeLessThan(360); + expect(Math.abs(azimuth - (name === 'noonday' ? 182.6 : 2.5))).toBeLessThan(1); + }); + + it('keeps ambient above the night floor and leaves daylight alone', () => { + sky.ambient_linear.forEach((v: number, i: number) => + expect(v).toBeGreaterThanOrEqual(AMBIENT_FLOOR[i]) + ); + if (name === 'noonday') { + sky.ambient_linear.forEach((v: number) => expect(v).toBeGreaterThan(0.05)); + expect(sky.ambient_linear).toEqual(light(sky.moment).diffuse); + } + }); + it('clears WCAG AA for text on every panel state', () => { ['Background', 'Surface', 'SurfaceVariant'].forEach((p) => states.forEach((s) => diff --git a/src/sky/sun.js b/src/sky/sun.js index d66f0f9a..55cad1ac 100644 --- a/src/sky/sun.js +++ b/src/sky/sun.js @@ -2,13 +2,14 @@ // no imports, so a build-time token script and a runtime shader can share it. // // light({ time, lat, lon, cloud_cover = 0, precipitation = 0 }) -// -> { elevation, direct: [r,g,b], diffuse: [r,g,b], wetness } +// -> { elevation, azimuth, direct: [r,g,b], diffuse: [r,g,b], wetness } // // time Date, epoch milliseconds, or an ISO string (read as UTC) // lat, lon degrees, north and east positive // cloud_cover 0..1 (Open-Meteo reports percent: divide by 100) // precipitation mm/h // elevation degrees above the horizon, no refraction +// azimuth degrees clockwise from north (0 N, 90 E, 180 S), 0..360, no refraction // direct sun colour at normal incidence, linear RGB. Clear zenith sun ~ 1. // The consumer multiplies by max(0, dot(normal, toSun)). // diffuse sky ambient on an upward surface, linear RGB @@ -28,8 +29,8 @@ const mix = (a, b, t) => a + (b - a) * t; const toMs = (time) => (time instanceof Date ? time.getTime() : typeof time === 'string' ? Date.parse(time) : Number(time)); -/** Sun elevation in degrees at a UTC moment and place. */ -export function sunElevation(time, lat, lon) { +/** Sun elevation and azimuth in degrees at a UTC moment and place. Azimuth runs clockwise from north. */ +export function sunPosition(time, lat, lon) { const d = toMs(time) / 86400000 - 10957.5; // days since J2000.0 (2000-01-01 12:00 UTC) const L = (280.46 + 0.9856474 * d) * RAD; // mean longitude const g = (357.528 + 0.9856003 * d) * RAD; // mean anomaly @@ -41,9 +42,18 @@ export function sunElevation(time, lat, lon) { const hourAngle = gmst + lon * RAD - ra; const phi = lat * RAD; const sinEl = Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(hourAngle); - return Math.asin(clamp(sinEl, -1, 1)) / RAD; + // Hour angle is westward-positive, so atan2 gives azimuth from south, westward; +180 turns it into + // clockwise from north. + const south = Math.atan2(Math.sin(hourAngle), Math.cos(hourAngle) * Math.sin(phi) - Math.tan(dec) * Math.cos(phi)); + return { + elevation: Math.asin(clamp(sinEl, -1, 1)) / RAD, + azimuth: (((south / RAD + 180) % 360) + 360) % 360, + }; } +/** Sun elevation in degrees at a UTC moment and place. */ +export const sunElevation = (time, lat, lon) => sunPosition(time, lat, lon).elevation; + // Optical depth of the clear atmosphere at the zenith, per channel (R, G, B). // Rayleigh at 650/550/450 nm plus a little ozone and haze (EYEBALLED to keep // low sun orange rather than brown). @@ -62,7 +72,7 @@ const GREY_TINT = [0.95, 1, 1.08]; // cool overcast. EYEBALLED const luma = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; export function light({ time, lat, lon, cloud_cover = 0, precipitation = 0 }) { - const elevation = sunElevation(time, lat, lon); + const { elevation, azimuth } = sunPosition(time, lat, lon); const cloud = clamp(cloud_cover, 0, 1); const rain = Math.max(0, precipitation); @@ -95,5 +105,5 @@ export function light({ time, lat, lon, cloud_cover = 0, precipitation = 0 }) { // 0 dry, ~0.63 at 1 mm/h, ~0.98 at 4 mm/h. EYEBALLED. const wetness = 1 - Math.exp(-rain); - return { elevation, direct, diffuse, wetness }; + return { elevation, azimuth, direct, diffuse, wetness }; } From 4afd37e7dde785e2a6a8663930a72c39d204cc2a Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 20:25:02 +0000 Subject: [PATCH 007/111] preview: hash router and base-relative manifest link Previews are static dirs with no SPA fallback; a reload or shared link on /login/matrix.org was an nginx 404. preview-config now enables the hash router, and index.html's manifest link follows the base path. Co-Authored-By: Claude Sonnet 5.5 --- index.html | 2 +- scripts/preview-config.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 7242f309..ec7f4e39 100644 --- a/index.html +++ b/index.html @@ -23,7 +23,7 @@ - + diff --git a/scripts/preview-config.js b/scripts/preview-config.js index 75746f98..26f55b06 100644 --- a/scripts/preview-config.js +++ b/scripts/preview-config.js @@ -1,5 +1,6 @@ // 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] [style] +// in the repo stays 'off'. Previews are static directories with no SPA fallback, so they also switch +// to the hash router: a reload on /login/matrix.org would otherwise be an nginx 404. Usage: node scripts/preview-config.js [tier] [style] import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -8,6 +9,7 @@ const tier = process.argv[2] ?? 'low'; const style = process.argv[3]; const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json'); const config = JSON.parse(fs.readFileSync(file, 'utf8')); +config.hashRouter = { enabled: true, basename: '/' }; config.backgroundScene = { ...config.backgroundScene, tier, ...(style ? { style } : {}) }; fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); console.log(`${file}: backgroundScene.tier = ${tier}${style ? `, style = ${style}` : ''}`); From 19c1f21610e659da126f34af22b7fbdf5a4b1f0f Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 20:43:34 +0000 Subject: [PATCH 008/111] scene: terrainNormal reads the world-z slope from .y, not .z The noise is evaluated at vec3(xz * f, c), so .z is the slope along the held constant axis. Confirmed numerically (harness/scene/gradAxis.cjs): analytic .xy matches central differences for fbm and phacelle, .z does not. Only the ray-march scene's normals were affected; the grid tier already used .xy. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/README.md | 1 + harness/scene/gradAxis.cjs | 30 +++++++++++++++++++++++++ src/app/features/scene/shaders/sdf.glsl | 4 ++-- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 harness/scene/gradAxis.cjs diff --git a/harness/scene/README.md b/harness/scene/README.md index ef735d2e..624d4b0b 100644 --- a/harness/scene/README.md +++ b/harness/scene/README.md @@ -6,6 +6,7 @@ Chromium. Not shipped, not run by CI. node bundle.cjs # esbuild bundle of entry.ts plus an index.html node drive.cjs compile # compile and link each stage, JSON report node drive.cjs shots # rest, mid pull-back and pulled-back screenshots + node gradAxis.cjs # numeric check that noise gradients are (d/dx, d/dz-as-.y, ...): terrainNormal axis node drive.cjs measure # GPU time per pass by timer query, two viewports, two levels node drive.cjs variants # the same with source patches (no erosion, no tree, value-only fbm) node drive.cjs trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms diff --git a/harness/scene/gradAxis.cjs b/harness/scene/gradAxis.cjs new file mode 100644 index 00000000..48b96fa6 --- /dev/null +++ b/harness/scene/gradAxis.cjs @@ -0,0 +1,30 @@ +// Probe: the gradient cultmath_fbm_grad / cultmath_phacelle return for vec3(xz * f, c) is (d/dx, d/dz, d/dc, value), +// so the world-z slope is .y, not .z. Compares analytic gradients with central differences at one point. +// Usage (playwright + swiftshader): node harness/scene/gradAxis.cjs /src/app/features/scene/shaders +const { chromium } = require('playwright'); const fs=require('fs'); +const S=process.argv[2]; +const cm=fs.readFileSync(S+'/CultMath.glsl','utf8'), ph=fs.readFileSync(S+'/CultMath.Phacelle.glsl','utf8'); +const fs_=`#version 300 es +precision highp float; precision highp int; +${cm} +${ph} +out vec4 o; +const float F=1.0/520.0; +float fb(vec2 p){return cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45).w;} +float pc(vec2 p){return cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos.w;} +void main(){ + vec2 p=vec2(1234.5,-777.7); float e=0.5; + int k=int(gl_FragCoord.x); + if(k==0){vec4 g=cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45); o=vec4(g.xyz*F,0.0);} // analytic per metre: x,y,z + else if(k==1){o=vec4((fb(p+vec2(e,0))-fb(p-vec2(e,0)))/(2.0*e),(fb(p+vec2(0,e))-fb(p-vec2(0,e)))/(2.0*e),0,0);} + else if(k==2){vec4 g=cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos; o=vec4(g.xyz/40.0,0.0);} + else {o=vec4((pc(p+vec2(e,0))-pc(p-vec2(e,0)))/(2.0*e),(pc(p+vec2(0,e))-pc(p-vec2(0,e)))/(2.0*e),0,0);} +}`; +(async()=>{const b=await chromium.launch({args:['--use-gl=angle','--use-angle=swiftshader','--enable-unsafe-swiftshader']}); +const pg=await b.newPage(); +const r=await pg.evaluate(([src])=>{const c=document.createElement('canvas');const g=c.getContext('webgl2');g.getExtension('EXT_color_buffer_float'); +const mk=(t,s)=>{const x=g.createShader(t);g.shaderSource(x,s);g.compileShader(x);if(!g.getShaderParameter(x,g.COMPILE_STATUS))throw g.getShaderInfoLog(x);return x}; +const p=g.createProgram();g.attachShader(p,mk(g.VERTEX_SHADER,'#version 300 es\nvoid main(){vec2 p=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));gl_Position=vec4(p*2.0-1.0,0.0,1.0);}'));g.attachShader(p,mk(g.FRAGMENT_SHADER,src));g.linkProgram(p);g.useProgram(p); +const t=g.createTexture();g.bindTexture(g.TEXTURE_2D,t);g.texImage2D(g.TEXTURE_2D,0,g.RGBA32F,4,1,0,g.RGBA,g.FLOAT,null);const f=g.createFramebuffer();g.bindFramebuffer(g.FRAMEBUFFER,f);g.framebufferTexture2D(g.FRAMEBUFFER,g.COLOR_ATTACHMENT0,g.TEXTURE_2D,t,0);g.viewport(0,0,4,1);g.drawArrays(g.TRIANGLES,0,3); +const o=new Float32Array(16);g.readPixels(0,0,4,1,g.RGBA,g.FLOAT,o);return Array.from(o)},[fs_]).catch(e=>'ERR '+String(e).slice(0,500)); +console.log(Array.isArray(r)?['fbm analytic x,y,z','fbm FD dx,dz','phac analytic','phac FD'].map((n,i)=>n+': '+r.slice(i*4,i*4+3).map(v=>v.toExponential(3)).join(' ')).join('\n'):r);await b.close()})(); diff --git a/src/app/features/scene/shaders/sdf.glsl b/src/app/features/scene/shaders/sdf.glsl index 95e96bc2..fd5e6363 100644 --- a/src/app/features/scene/shaders/sdf.glsl +++ b/src/app/features/scene/shaders/sdf.glsl @@ -44,10 +44,10 @@ vec3 terrainNormal(vec2 xz) vec2 dir = r > 0.0 ? xz / r : vec2(0.0); vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); float f = fg.w; - vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir; + vec2 g = m * W_AMP * W_FREQ * fg.xy + dm * W_AMP * f * dir; #if W_ERODE_ENABLED CultPhasor ph = terrainPhacelle(xz); - g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir; + g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xy + dm * W_ERODE_AMP * ph.cos.w * dir; #endif return normalize(vec3(-g.x, 1.0, -g.y)); } From 931f84fcd1aa7b82978394b2a725bf01fbdb5f3a Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 20:44:54 +0000 Subject: [PATCH 009/111] scene: procedural terrain albedo by normal and height, per baked sky Rock on steep faces, soil to scree to snow by height, snow only on gentle faces; no texture reads. Pure albedoAt()/albedoPalette() mirror the grid fragment shader; the palette is read from the baked sky (snow line and tint by sun elevation: cool haze at night, warm sun by day). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/terrain/Terrain.ts | 7 ++ .../scene/terrain/terrainAlbedo.test.ts | 103 ++++++++++++++++++ .../features/scene/terrain/terrainAlbedo.ts | 98 +++++++++++++++++ .../features/scene/terrain/terrainShaders.ts | 31 ++++-- 4 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 src/app/features/scene/terrain/terrainAlbedo.test.ts create mode 100644 src/app/features/scene/terrain/terrainAlbedo.ts diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 2a81aa56..6f725b95 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -2,6 +2,7 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from '../ridge/probe'; import { skyColours } from '../ridge/skyColour'; import { SkyName } from './skyData'; +import { albedoPalette } from './terrainAlbedo'; import { ambientColour, hazeColour, @@ -170,6 +171,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const sunLight = [...sunColour(sky)]; const ambient = [...ambientColour(sky)]; const haze = [...hazeColour(sky)]; + const palette = albedoPalette(sky); const triangles = triangleCount(spec.grid); const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); @@ -218,6 +220,11 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.uniform3fv(u('uAmbient'), ambient); gl.uniform3fv(u('uHaze'), haze); gl.uniform1f(u('uHazeK'), HAZE_K); + gl.uniform3fv(u('uSoil'), palette.soil); + gl.uniform3fv(u('uScree'), palette.scree); + gl.uniform3fv(u('uRock'), palette.rock); + gl.uniform3fv(u('uSnow'), palette.snow); + gl.uniform1f(u('uSnowLine'), palette.snowLine); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, heightTex); gl.uniform1i(u('uHeight'), 0); diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts new file mode 100644 index 00000000..c637770d --- /dev/null +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -0,0 +1,103 @@ +/* eslint-disable no-restricted-syntax -- tests loop over skies, heights and slopes */ +import { describe, expect, it } from 'vitest'; +import { SKY } from './skyData'; +import { ALBEDO, albedoAt, albedoPalette, wobble } from './terrainAlbedo'; +import { GRID_FRAG } from './terrainShaders'; + +const near = (a: readonly number[], b: readonly number[], eps = 1e-6) => + a.every((v, i) => Math.abs(v - b[i]) < eps); +const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +// A point where the wobble is zero, so band edges sit exactly where the palette says. +const FLAT_X = Math.PI / 0.043; +const at = (sky: keyof typeof SKY, h: number, ny: number) => + albedoAt(albedoPalette(SKY[sky]), h, ny, FLAT_X, 0); + +describe('albedo by slope and height', () => { + it('the test point really has no wobble', () => { + expect(Math.abs(wobble(FLAT_X, 0))).toBeLessThan(1e-9); + }); + + it('steep faces are rock at every height, gentle low ones are soil, gentle high ones are snow', () => { + const p = albedoPalette(SKY.noonday); + for (const h of [10, 90, 160, 220]) expect(near(at('noonday', h, 0.3), p.rock)).toBe(true); + expect(near(at('noonday', 10, 1), p.soil)).toBe(true); + expect(near(at('noonday', 220, 1), p.snow)).toBe(true); + }); + + it('scree sits between soil and the snow line on gentle ground', () => { + const p = albedoPalette(SKY.noonday); + expect(near(at('noonday', ALBEDO.screeTo + 1, 1), p.scree)).toBe(true); + }); + + it('snow is shed as the face steepens: the same height goes snow, then mixed, then rock', () => { + const l = [1, 0.8, 0.5].map((ny) => luma(at('noonday', 220, ny))); + expect(l[0]).toBeGreaterThan(l[1]); + expect(l[1]).toBeGreaterThan(l[2]); + }); + + it('is continuous: a small step in height or slope never jumps a channel by more than a tenth', () => { + for (const sky of ['midnight', 'noonday'] as const) + for (let h = 0; h < 250; h += 1) + for (let ny = 0.3; ny <= 1; ny += 0.05) { + const a = at(sky, h, ny); + const b = at(sky, h + 1, ny + 0.01); + a.forEach((v, i) => expect(Math.abs(v - b[i])).toBeLessThan(0.1)); + } + }); + + it('never returns a negative or a super-white channel', () => { + for (const sky of ['midnight', 'noonday'] as const) + for (let h = -20; h < 300; h += 7) + for (let ny = 0; ny <= 1; ny += 0.1) + for (const v of albedoAt(albedoPalette(SKY[sky]), h, ny, h * 3, ny * 91)) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(1); + } + }); + + it('the wobble moves band edges but stays inside its stated amplitude', () => { + for (let i = 0; i < 400; i += 1) + expect(Math.abs(wobble(i * 13.7, i * -9.1))).toBeLessThanOrEqual(1); + }); +}); + +describe('per-sky palette', () => { + const mid = albedoPalette(SKY.midnight); + const noon = albedoPalette(SKY.noonday); + + it('midnight snows lower than noonday', () => { + expect(mid.snowLine).toBeLessThan(noon.snowLine); + }); + + it('midnight is tinted toward the cool horizon haze, noonday toward the warm sun', () => { + // blue share of the same material is higher at night + const blueShare = (c: readonly number[]) => c[2] / (c[0] + c[1] + c[2]); + expect(blueShare(mid.scree)).toBeGreaterThan(blueShare(noon.scree)); + expect(blueShare(mid.snow)).toBeGreaterThan(blueShare(noon.snow)); + }); + + it('a sun at the horizon lands between the two palettes', () => { + const dusk = albedoPalette({ ...SKY.noonday, elevation: 2 }); + expect(dusk.snowLine).toBeLessThan(noon.snowLine); + expect(dusk.snowLine).toBeGreaterThan(mid.snowLine); + }); + + it('keeps every material in 0..1', () => { + for (const p of [mid, noon]) + for (const c of [p.soil, p.scree, p.rock, p.snow]) + c.forEach((v) => { + expect(v).toBeGreaterThan(0); + expect(v).toBeLessThanOrEqual(1); + }); + }); +}); + +describe('shader agrees with the pure function on its numbers', () => { + it('takes every band number from ALBEDO, and reads no texture for colour', () => { + expect(GRID_FRAG).toContain(`#define ALB_SNOW_BAND ${ALBEDO.snowBand.toFixed(1)}`); + expect(GRID_FRAG).toContain(`#define ALB_ROCK_LO ${ALBEDO.rockLo}`); + expect(GRID_FRAG).toContain(`#define ALB_WOBBLE_S ${ALBEDO.wobbleSlope}`); + // the only fetch in the fragment shader is the height map's + expect(GRID_FRAG.match(/texelFetch|texture\(/g)).toHaveLength(1); + }); +}); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts new file mode 100644 index 00000000..237f5338 --- /dev/null +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -0,0 +1,98 @@ +// Terrain surface colour as a pure function of height, slope and world position, no texture reads: the shader +// (terrainShaders.ts GRID_FRAG) does the same arithmetic with these numbers as uniforms and #defines. No DOM, no GL. +// Rock where the face is steep, then by height soil, scree and snow; snow only settles on gentle faces. The per-sky +// part reads the baked sky (./skyData.ts): the snow line drops and every material is tinted toward the sky's colour +// as the sun goes down, warm by the sun's own colour by day and cool by the horizon haze at night. +import { SkyData, Vec3 } from './skyData'; +import { hazeColour, sunColour } from './terrainSky'; + +const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); +const smooth = (a: number, b: number, x: number) => { + const t = clamp01((x - a) / (b - a)); + return t * t * (3 - 2 * t); +}; +const mix = (a: number, b: number, t: number) => a + (b - a) * t; + +/** Every number the shader shares: band edges in metres, slope edges as the normal's y (cosine of the slope angle). */ +export const ALBEDO = { + /** soil fades to scree across these heights. */ + screeFrom: 55, + screeTo: 115, + /** half-width in metres of the snow line's fade. */ + snowBand: 30, + /** n.y edges: faces with n.y below `rockLo` are all rock, above `rockHi` none. */ + rockLo: 0.62, + rockHi: 0.88, + /** n.y edges: snow is shed entirely below `snowLo` and settles fully above `snowHi`. */ + snowLo: 0.68, + snowHi: 0.9, + /** world-position wobble of the height edges (metres) and the slope edges, so bands are not contour lines. */ + wobbleHeight: 14, + wobbleSlope: 0.05, +} as const; + +export interface AlbedoPalette { + soil: Vec3; + scree: Vec3; + rock: Vec3; + snow: Vec3; + /** Metres where snow is half in. */ + snowLine: number; +} + +const BASE = { + soil: [0.055, 0.06, 0.03], + scree: [0.2, 0.18, 0.155], + rock: [0.1, 0.09, 0.085], + snow: [0.72, 0.75, 0.8], +} as const; + +const SNOW_LINE_DAY = 150; +const SNOW_LINE_NIGHT = 105; + +const unit = (c: Vec3): Vec3 => { + const m = Math.max(c[0], c[1], c[2], 1e-6); + return [c[0] / m, c[1] / m, c[2] / m]; +}; + +/** The palette one baked sky gives: base reflectances tinted toward the sky's colour, and its snow line. */ +export function albedoPalette(d: SkyData): AlbedoPalette { + const cold = 1 - smooth(-6, 25, d.elevation); + const sun = unit(sunColour(d)); + const haze = unit(hazeColour(d)); + const tint = [0, 1, 2].map((i) => mix(sun[i], haze[i], cold)); + const k = mix(0.15, 0.5, cold); + const tinted = (c: Vec3): Vec3 => + [0, 1, 2].map((i) => c[i] * mix(1, tint[i], k)) as unknown as Vec3; + return { + soil: tinted(BASE.soil), + scree: tinted(BASE.scree), + rock: tinted(BASE.rock), + snow: tinted(BASE.snow), + snowLine: mix(SNOW_LINE_DAY, SNOW_LINE_NIGHT, cold), + }; +} + +/** Cheap two-sine value in -1..1 from world position; stands in for noise so no texture or hash is read. */ +export const wobble = (x: number, z: number): number => + Math.sin(x * 0.043 + z * 0.017) * Math.sin(z * 0.051 - x * 0.023); + +/** + * Linear albedo at a surface point. `height` in metres, `ny` the unit normal's y, `x`/`z` world metres. + * Rock takes the steep faces at any height; on the rest, soil to scree by height, then snow above the snow + * line, thinning to nothing as the face steepens. + */ +export function albedoAt(p: AlbedoPalette, height: number, ny: number, x: number, z: number): Vec3 { + const w = wobble(x, z); + const h = height + ALBEDO.wobbleHeight * w; + const flat = ny + ALBEDO.wobbleSlope * w; + const ground = (i: number) => + mix(p.soil[i], p.scree[i], smooth(ALBEDO.screeFrom, ALBEDO.screeTo, h)); + const snow = + smooth(p.snowLine - ALBEDO.snowBand, p.snowLine + ALBEDO.snowBand, h) * + smooth(ALBEDO.snowLo, ALBEDO.snowHi, flat); + const rock = 1 - smooth(ALBEDO.rockLo, ALBEDO.rockHi, flat); + return [0, 1, 2].map((i) => + mix(mix(ground(i), p.snow[i], snow), p.rock[i], rock) + ) as unknown as Vec3; +} diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 1eff9c93..0e1783e7 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -7,11 +7,13 @@ import cultmath from '../shaders/CultMath.glsl?raw'; import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; +import { ALBEDO } from './terrainAlbedo'; import { TERRAIN } from './terrainWorld'; const HEADER = '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; const f1 = (x: number) => x.toFixed(1); +const lit = (x: number) => (Number.isInteger(x) ? x.toFixed(1) : String(x)); export const FULLSCREEN_VERT = `${HEADER} void main() { @@ -107,11 +109,22 @@ void main() { // Lit by the baked sky: uSun (direction), uSunColour (linear, zero when the sun is down), uAmbient, uHaze (linear). // The same arithmetic as terrainSky.shade(); the normal comes from the height map so shading carries detail finer than the mesh. -export const GRID_FRAG = `${HEADER} +const ALBEDO_DEFINES = ` +#define ALB_SCREE_FROM ${lit(ALBEDO.screeFrom)} +#define ALB_SCREE_TO ${lit(ALBEDO.screeTo)} +#define ALB_SNOW_BAND ${lit(ALBEDO.snowBand)} +#define ALB_ROCK_HI ${lit(ALBEDO.rockHi)} +#define ALB_ROCK_LO ${lit(ALBEDO.rockLo)} +#define ALB_SNOW_HI ${lit(ALBEDO.snowHi)} +#define ALB_SNOW_LO ${lit(ALBEDO.snowLo)} +#define ALB_WOBBLE_H ${lit(ALBEDO.wobbleHeight)} +#define ALB_WOBBLE_S ${lit(ALBEDO.wobbleSlope)} +`; +export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES} in vec2 v_uv; in vec3 v_world; -uniform vec3 uCamera, uSun, uSunColour, uAmbient, uHaze; -uniform float uSpan, uHazeK; +uniform vec3 uCamera, uSun, uSunColour, uAmbient, uHaze, uSoil, uScree, uRock, uSnow; +uniform float uSpan, uHazeK, uSnowLine; out vec4 o; ${FETCH} // Unnormalised normal at one texel, central differences over its neighbours. @@ -129,10 +142,14 @@ void main() { vec3 n = normalize(mix( mix(nAt(t, texel), nAt(t + ivec2(1, 0), texel), f.x), mix(nAt(t + ivec2(0, 1), texel), nAt(t + ivec2(1, 1), texel), f.x), f.y)); - float alt = clamp(v_world.y / 200.0, 0.0, 1.0); - vec3 rock = mix(vec3(0.07, 0.06, 0.05), vec3(0.28, 0.26, 0.22), alt); - vec3 grass = mix(vec3(0.03, 0.07, 0.025), vec3(0.14, 0.15, 0.07), alt); - vec3 albedo = mix(rock, grass, smoothstep(0.55, 0.9, n.y)); + // Procedural albedo, no texture reads; the same arithmetic as terrainAlbedo.albedoAt(). + float w = sin(v_world.x * 0.043 + v_world.z * 0.017) * sin(v_world.z * 0.051 - v_world.x * 0.023); + float h = v_world.y + ALB_WOBBLE_H * w; + float flat_ = n.y + ALB_WOBBLE_S * w; + vec3 ground = mix(uSoil, uScree, smoothstep(ALB_SCREE_FROM, ALB_SCREE_TO, h)); + float snow = smoothstep(uSnowLine - ALB_SNOW_BAND, uSnowLine + ALB_SNOW_BAND, h) * smoothstep(ALB_SNOW_LO, ALB_SNOW_HI, flat_); + float rockW = 1.0 - smoothstep(ALB_ROCK_LO, ALB_ROCK_HI, flat_); + vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); vec3 lit = albedo * (uSunColour * max(dot(n, uSun), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); float T = exp(-uHazeK * length(v_world - uCamera)); o = vec4(pow(mix(uHaze, lit, T), vec3(1.0 / 2.2)), 1.0); From da12cb87085f207ee81dc3b6f724792213de4a5e Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 20:54:25 +0000 Subject: [PATCH 010/111] scene: tune terrain albedo bands, lighter haze, night ambient lift Bands moved so snow caps only the peaks and soil/scree/rock read; haze 0.0007 -> 0.0004 so the albedo is not washed out; ambient x4 at night so a moonlit face is readable on a phone. Co-Authored-By: Claude Sonnet 5.5 --- .../scene/terrain/terrainAlbedo.test.ts | 5 ++-- .../features/scene/terrain/terrainAlbedo.ts | 28 +++++++++---------- .../features/scene/terrain/terrainSky.test.ts | 10 +++++++ src/app/features/scene/terrain/terrainSky.ts | 12 ++++++-- 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index c637770d..331b820a 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -24,9 +24,10 @@ describe('albedo by slope and height', () => { expect(near(at('noonday', 220, 1), p.snow)).toBe(true); }); - it('scree sits between soil and the snow line on gentle ground', () => { + it('soil grades to scree by height on gentle ground, halfway at the middle of the band', () => { const p = albedoPalette(SKY.noonday); - expect(near(at('noonday', ALBEDO.screeTo + 1, 1), p.scree)).toBe(true); + const half = p.soil.map((v, i) => (v + p.scree[i]) / 2); + expect(near(at('noonday', (ALBEDO.screeFrom + ALBEDO.screeTo) / 2, 1), half)).toBe(true); }); it('snow is shed as the face steepens: the same height goes snow, then mixed, then rock', () => { diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index 237f5338..703f9f0c 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -4,7 +4,7 @@ // part reads the baked sky (./skyData.ts): the snow line drops and every material is tinted toward the sky's colour // as the sun goes down, warm by the sun's own colour by day and cool by the horizon haze at night. import { SkyData, Vec3 } from './skyData'; -import { hazeColour, sunColour } from './terrainSky'; +import { coldness, hazeColour, sunColour } from './terrainSky'; const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); const smooth = (a: number, b: number, x: number) => { @@ -16,16 +16,16 @@ const mix = (a: number, b: number, t: number) => a + (b - a) * t; /** Every number the shader shares: band edges in metres, slope edges as the normal's y (cosine of the slope angle). */ export const ALBEDO = { /** soil fades to scree across these heights. */ - screeFrom: 55, - screeTo: 115, + screeFrom: 80, + screeTo: 160, /** half-width in metres of the snow line's fade. */ - snowBand: 30, + snowBand: 22, /** n.y edges: faces with n.y below `rockLo` are all rock, above `rockHi` none. */ - rockLo: 0.62, - rockHi: 0.88, + rockLo: 0.78, + rockHi: 0.95, /** n.y edges: snow is shed entirely below `snowLo` and settles fully above `snowHi`. */ - snowLo: 0.68, - snowHi: 0.9, + snowLo: 0.74, + snowHi: 0.93, /** world-position wobble of the height edges (metres) and the slope edges, so bands are not contour lines. */ wobbleHeight: 14, wobbleSlope: 0.05, @@ -41,14 +41,14 @@ export interface AlbedoPalette { } const BASE = { - soil: [0.055, 0.06, 0.03], - scree: [0.2, 0.18, 0.155], - rock: [0.1, 0.09, 0.085], + soil: [0.045, 0.08, 0.02], + scree: [0.3, 0.235, 0.16], + rock: [0.075, 0.062, 0.06], snow: [0.72, 0.75, 0.8], } as const; -const SNOW_LINE_DAY = 150; -const SNOW_LINE_NIGHT = 105; +const SNOW_LINE_DAY = 165; +const SNOW_LINE_NIGHT = 135; const unit = (c: Vec3): Vec3 => { const m = Math.max(c[0], c[1], c[2], 1e-6); @@ -57,7 +57,7 @@ const unit = (c: Vec3): Vec3 => { /** The palette one baked sky gives: base reflectances tinted toward the sky's colour, and its snow line. */ export function albedoPalette(d: SkyData): AlbedoPalette { - const cold = 1 - smooth(-6, 25, d.elevation); + const cold = coldness(d); const sun = unit(sunColour(d)); const haze = unit(hazeColour(d)); const tint = [0, 1, 2].map((i) => mix(sun[i], haze[i], cold)); diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index 44b77f36..b3b4d21e 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { SKY } from './skyData'; import { + AMBIENT_GAIN, + NIGHT_LIFT, ambientColour, hazeColour, hexToLinear, @@ -46,6 +48,14 @@ describe('baked sky numbers', () => { }); }); +describe('night fill', () => { + it('lifts ambient at night by NIGHT_LIFT and leaves a high sun at the plain gain', () => { + const z = (d: typeof SKY.midnight) => hexToLinear(d.gradient[0]); + ambientColour(SKY.midnight).forEach((v, i) => expect(v).toBeCloseTo(z(SKY.midnight)[i] * AMBIENT_GAIN * NIGHT_LIFT, 9)); + ambientColour(SKY.noonday).forEach((v, i) => expect(v).toBeCloseTo(z(SKY.noonday)[i] * AMBIENT_GAIN, 9)); + }); +}); + describe('lighting', () => { it('noonday lights an upward face with the sun; midnight has none', () => { expect(luma(sunColour(SKY.noonday))).toBeGreaterThan(0.5); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index d6c699c3..d0f41c40 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -47,16 +47,24 @@ export function sunColour(d: SkyData): Vec3 { * the same gain turns the gradient's glow into a faint moonlit fill, where `diffuse` alone would be black. */ export const AMBIENT_GAIN = 0.25; + +/** 0 with the sun well up, 1 once it is below -6 degrees (civil twilight over): the night notion the palette and fill share. */ +export const coldness = (d: SkyData): number => 1 - smooth(-6, 25, d.elevation); + +/** Extra ambient at night, so a moonlit face is readable and not black on a phone: x4 at full night, 1 by day. */ +export const NIGHT_LIFT = 4; + export function ambientColour(d: SkyData): Vec3 { const z = hexToLinear(d.gradient[0]); - return [z[0] * AMBIENT_GAIN, z[1] * AMBIENT_GAIN, z[2] * AMBIENT_GAIN]; + const k = AMBIENT_GAIN * (1 + (NIGHT_LIFT - 1) * coldness(d)); + return [z[0] * k, z[1] * k, z[2] * k]; } /** The colour distance fades to: the gradient's horizon stop, linear. */ export const hazeColour = (d: SkyData): Vec3 => hexToLinear(d.gradient[d.gradient.length - 1]); /** Haze: transmittance T = exp(-k d) at distance d metres. */ -export const HAZE_K = 0.0007; +export const HAZE_K = 0.0004; export const transmittance = (distance: number): number => Math.exp(-HAZE_K * Math.max(0, distance)); From 054c584ebc90337b6f771cda9a6af8f27eff81bc Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 20:55:44 +0000 Subject: [PATCH 011/111] scene: stars in the terrain scene, a 2D overlay above the horizon on dark skies Reuses ridge/stars.ts (placement, tint, rolling 32-star twinkle) projected with the terrain camera onto an overlay cut to the sky; stops with the terrain loop. Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/Terrain.test.ts | 84 +++++++ src/app/features/scene/terrain/Terrain.ts | 5 + .../scene/terrain/terrainStars.test.ts | 207 ++++++++++++++++++ .../features/scene/terrain/terrainStars.ts | 157 +++++++++++++ 4 files changed, 453 insertions(+) create mode 100644 src/app/features/scene/terrain/terrainStars.test.ts create mode 100644 src/app/features/scene/terrain/terrainStars.ts diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts index 8338721e..4578a58a 100644 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -233,3 +233,87 @@ describe("startBackgroundScene style 'terrain'", () => { expect(c2.getContext).not.toHaveBeenCalledWith('webgl2', expect.anything()); }); }); + +describe('stars in the terrain scene', () => { + let overlays: any[]; + let arcs: number; + const withOverlays = () => { + overlays = []; + arcs = 0; + (document as any).createElement = () => { + const el: any = { + style: {}, + remove: vi.fn(), + setAttribute() {}, + getContext: () => ({ + clearRect() {}, + beginPath() {}, + fill() {}, + arc: () => (arcs += 1), + }), + }; + overlays.push(el); + return el; + }; + }; + const scene = (sky: 'midnight' | 'noonday', tier: 'still' | 'low' = 'low') => { + withOverlays(); + const canvas = canvasWith(fakeGl().gl); + const s = createTerrainScene({ canvas, tier, sky })!; + return { s, canvas }; + }; + + it('a midnight sky gets one star overlay beside the canvas, painted with the terrain', () => { + const { s, canvas } = scene('midnight'); + expect(overlays).toHaveLength(1); + expect(canvas.parentElement.appendChild).toHaveBeenCalledWith(overlays[0]); + rafQueue.shift()!(16); + expect(arcs).toBeGreaterThan(0); + s.dispose(); + expect(overlays[0].remove).toHaveBeenCalledOnce(); + }); + + it('a noonday sky gets no overlay and paints no stars', () => { + const { s } = scene('noonday'); + expect(overlays).toHaveLength(0); + expect(arcs).toBe(0); + s.dispose(); + }); + + it("'still' paints the stars once at base and queues nothing", () => { + const { s } = scene('midnight', 'still'); + expect(arcs).toBeGreaterThan(0); + expect(rafQueue).toHaveLength(0); + s.dispose(); + }); + + it('a hidden tab or reduced motion paints no more stars than the first frame', () => { + visibility = 'hidden'; + const { s } = scene('midnight'); + const n = arcs; + expect(n).toBeGreaterThan(0); + expect(rafQueue).toHaveLength(0); // the terrain loop never starts, so nothing repaints the stars + s.dispose(); + visibility = 'visible'; + (window as any).matchMedia = () => ({ + matches: true, + addEventListener() {}, + removeEventListener() {}, + }); + const r = scene('midnight'); + expect(rafQueue).toHaveLength(0); + expect(arcs).toBe(n); + r.s.dispose(); + }); + + it('stars turn with the terrain: a later frame is painted at a later heading', () => { + let clock = 0; + vi.stubGlobal('performance', { now: () => (clock += 1000) }); + const { s } = scene('midnight'); + rafQueue.shift()!(16); + const first = arcs; + rafQueue.shift()!(1000); + expect(arcs).toBeGreaterThan(first); + s.dispose(); + }); +}); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 2a81aa56..94fd554a 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -21,6 +21,7 @@ import { heightFragmentSource, SKY_FRAG, } from './terrainShaders'; +import { createTerrainStars } from './terrainStars'; export type TerrainOptions = Pick< SceneOptions, @@ -172,6 +173,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const haze = [...hazeColour(sky)]; const triangles = triangleCount(spec.grid); + const stars = createTerrainStars(canvas, sky); const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); const sampler = new FrameSampler(); let still = tier === 'still'; @@ -224,6 +226,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.bindVertexArray(vao); gl.drawElements(gl.TRIANGLES, g.indices.length, gl.UNSIGNED_INT, 0); gl.bindVertexArray(null); + stars?.draw(turns % 1, moving() ? performance.now() / 1000 : undefined); }; const moving = () => @@ -331,6 +334,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { resizeTimer = window.setTimeout(() => { if (disposed || lost) return; size(); + stars?.resize(); draw(turnsNow(performance.now())); sampler.reset(); }, 250); @@ -353,6 +357,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { reducedMotion?.removeEventListener?.('change', onVisibility); canvas.removeEventListener('webglcontextlost', onLost); readout?.remove(); + stars?.dispose(); release(); }, }; diff --git a/src/app/features/scene/terrain/terrainStars.test.ts b/src/app/features/scene/terrain/terrainStars.test.ts new file mode 100644 index 00000000..40020186 --- /dev/null +++ b/src/app/features/scene/terrain/terrainStars.test.ts @@ -0,0 +1,207 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SKY } from './skyData'; +import { + createTerrainStars, + elevationOf, + ELEVATION_TOP, + FRAME_MS, + horizonY, + PERIOD, + project, + skyColoursOf, +} from './terrainStars'; +import { CAMERA, view } from './terrainWorld'; +import { isNight } from '../ridge/stars'; +import { skyNameFor } from './terrainSky'; + +/** A 2D context that only counts: arcs painted and clears since the last reset. */ +function fakeCtx() { + const c = { + arcs: [] as Array<[number, number, number]>, + clears: 0, + alpha: 1, + alphas: [] as number[], + }; + const ctx: any = { + clearRect: () => { + c.clears += 1; + c.arcs = []; + c.alphas = []; + }, + beginPath() {}, + fill() { + c.alphas.push(ctx.globalAlpha); + }, + arc: (x: number, y: number, r: number) => c.arcs.push([x, y, r]), + globalAlpha: 1, + fillStyle: '', + }; + return { c, ctx }; +} + +let overlay: any; +let ctx2d: ReturnType; +let parent: any; +beforeEach(() => { + ctx2d = fakeCtx(); + overlay = { + style: {}, + width: 0, + height: 0, + setAttribute: vi.fn(), + getContext: vi.fn(() => ctx2d.ctx), + remove: vi.fn(), + }; + parent = { appendChild: vi.fn() }; + vi.stubGlobal('document', { createElement: () => overlay }); + vi.stubGlobal('window', { devicePixelRatio: 1, innerWidth: 390, innerHeight: 844 }); +}); +afterEach(() => vi.unstubAllGlobals()); + +const glCanvas = (w = 390, h = 844) => + ({ clientWidth: w, clientHeight: h, parentElement: parent } as any); + +describe('which skies get stars', () => { + it("midnight is dark, noonday is not, by the ridge module's own test", () => { + expect(isNight(skyColoursOf(SKY.midnight))).toBe(true); + expect(isNight(skyColoursOf(SKY.noonday))).toBe(false); + expect(skyNameFor(skyColoursOf(SKY.noonday))).toBe('noonday'); + }); + + it('noonday gets no layer and no element', () => { + expect(createTerrainStars(glCanvas(), SKY.noonday)).toBeNull(); + expect(parent.appendChild).not.toHaveBeenCalled(); + }); + + it('midnight adds one overlay to the canvas parent; dispose removes it', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + expect(parent.appendChild).toHaveBeenCalledWith(overlay); + s.dispose(); + expect(overlay.remove).toHaveBeenCalledOnce(); + }); + + it('is absent without a parent or a 2D context', () => { + expect( + createTerrainStars({ ...glCanvas(), parentElement: null } as any, SKY.midnight) + ).toBeNull(); + overlay.getContext = () => null; + expect(createTerrainStars(glCanvas(), SKY.midnight)).toBeNull(); + expect(parent.appendChild).not.toHaveBeenCalled(); + }); +}); + +describe('sky only', () => { + it('the overlay is as tall as the horizon line is low, never the whole screen', () => { + createTerrainStars(glCanvas(), SKY.midnight); + expect(overlay.height).toBe(Math.ceil(horizonY(844))); + expect(overlay.height).toBeLessThan(844 / 2); + expect(overlay.style.pointerEvents).toBe('none'); + }); + + it('a star at the horizon lands on the horizon line, one above it lands above', () => { + for (const aspect of [0.46, 1.78]) { + const h = 800; + const w = h * aspect; + const onLine = project(0.2, 0, w, h)!; + expect(onLine.y).toBeCloseTo(horizonY(h), 5); + expect(project(0.2, 0.05, w, h)!.y).toBeLessThan(horizonY(h)); + } + }); + + it('the camera eye clears the peaks, so the horizon line bounds the terrain', () => { + expect(CAMERA.eye).toBeGreaterThan(256); + }); + + it('every painted star is above the horizon line, at any heading', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + for (let k = 0; k < 40; k += 1) { + s.draw(k / 40); + for (const [, y] of ctx2d.c.arcs) expect(y).toBeLessThanOrEqual(overlay.height); + } + }); + + it('agrees with the camera the terrain draws: the projection matches view()', () => { + const w = 390; + const h = 844; + const turns = 0.137; + const v = view(w / h, turns); + const yaw = 2 * Math.PI * turns; + const az = yaw + 0.1; + const e = 0.15; + const d = [Math.sin(az) * Math.cos(e), Math.sin(e), -Math.cos(az) * Math.cos(e)]; + const dot = (a: number[], b: readonly number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + const t = Math.tan(CAMERA.fov / 2); + const f = dot(d, v.forward); + const nx = dot(d, v.right) / f / (t * (w / h)); + const ny = dot(d, v.up) / f / t; + const at = project(0.1, e, w, h)!; + expect(at.x).toBeCloseTo(((nx + 1) / 2) * w, 6); + expect(at.y).toBeCloseTo(((1 - ny) / 2) * h, 6); + }); +}); + +describe('turning with the camera', () => { + it('stars are on screen, and slide sideways as the camera turns', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0); + const first = ctx2d.c.arcs.slice(); + expect(first.length).toBeGreaterThan(10); + s.draw(0.002); + const later = ctx2d.c.arcs; + expect(later).not.toEqual(first); + const m = (a: number[][]) => a.reduce((t, p) => t + p[0], 0) / a.length; + expect(m(later)).not.toBeCloseTo(m(first), 1); + }); + + it('repeats every PERIOD of heading', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0.1); + const a = ctx2d.c.arcs.map((p) => p.map((n) => Math.round(n * 1e3))); + s.draw(0.1 + PERIOD / (2 * Math.PI)); + expect(ctx2d.c.arcs.map((p) => p.map((n) => Math.round(n * 1e3)))).toEqual(a); + }); + + it('the field fills the screen top and thins toward the horizon, inside the elevation band', () => { + expect(elevationOf(0)).toBeCloseTo(ELEVATION_TOP, 9); + expect(elevationOf(0.78)).toBeCloseTo(0, 9); + }); +}); + +describe('motion', () => { + it('with no clock every star is at base and a repeat draw repaints; no twinkle', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0.3); + const alphas = ctx2d.c.alphas.slice(); + s.draw(0.3); + expect(ctx2d.c.alphas).toEqual(alphas); + }); + + it('with the clock the twinklers swing away from base, and the repaint is throttled', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0.3); + const still = ctx2d.c.alphas.slice(); + let moved = false; + for (let t = 1; t < 60 && !moved; t += 1.7) { + s.draw(0.3, t); + moved = ctx2d.c.alphas.some((a, i) => a !== still[i]); + } + expect(moved).toBe(true); + const clears = ctx2d.c.clears; + s.draw(0.3, 100); + s.draw(0.3, 100 + FRAME_MS / 4000); + expect(ctx2d.c.clears).toBe(clears + 1); + }); + + it('resize refits the canvas and the next draw repaints at once', () => { + const c = glCanvas(); + const s = createTerrainStars(c, SKY.midnight)!; + s.draw(0.1, 5); + c.clientHeight = 400; + s.resize(); + expect(overlay.height).toBe(Math.ceil(horizonY(400))); + const clears = ctx2d.c.clears; + s.draw(0.1, 5.001); + expect(ctx2d.c.clears).toBe(clears + 1); + }); +}); diff --git a/src/app/features/scene/terrain/terrainStars.ts b/src/app/features/scene/terrain/terrainStars.ts new file mode 100644 index 00000000..e568418f --- /dev/null +++ b/src/app/features/scene/terrain/terrainStars.ts @@ -0,0 +1,157 @@ +// The terrain scene's star layer: the ridge scene's starfield (ridge/stars.ts: placement, alpha, tint, the +// rolling twinkle schedule) on a 2D overlay canvas above the WebGL canvas, shown only when the baked sky is dark. +// +// The WebGL canvas is opaque, so the overlay sits on top and is cut to the sky: it is only as tall as the +// horizon line is low on screen. The camera has no roll and the eye (CAMERA.eye) is above the highest peak +// (TERRAIN.hmax), so terrain never rises above that line and a star above the horizon is never over terrain. +// +// Stars turn with the camera, as sky does: the ridge field's x becomes azimuth, repeated every PERIOD (so 400 +// stars fill a sky that only ever shows 55 deg to ~120 deg of it), and y becomes elevation from the horizon +// up to ELEVATION_TOP, the top of the screen. Placement is projected with the terrain's own camera (CAMERA, +// the yaw the terrain draws at), nothing is baked, and the whole overlay is redrawn at most every FRAME_MS: a +// visible star is a few arcs on a canvas a quarter of the screen tall. The stars stop when the terrain stops: +// the layer only paints when Terrain.ts draws, and a draw with no clock (reduced motion, still tier, resize) +// paints every star at base brightness. + +import type { SkyColours, Srgb } from '../ridge/skyColour'; +import { activeAt, isNight, makeStars, SKY_STOPS, Star } from '../ridge/stars'; +import { SkyData } from './skyData'; +import { CAMERA } from './terrainWorld'; + +/** Azimuth the star field repeats at (radians); wider than any horizontal field of view the layer is shown at. */ +export const PERIOD = (2 * Math.PI) / 3; +/** Elevation of the field's top, radians: a little above the top edge of the screen at the camera's pitch. */ +export const ELEVATION_TOP = (16 * Math.PI) / 180; +/** The overlay repaints at most this often while the terrain turns (12 px/s on a phone: under half a pixel a step). */ +export const FRAME_MS = 33; +/** The twinkle schedule is re-read this often; the swings are 2 to 7 s. */ +const TWINKLE_MS = 50; + +const hex = (h: string): Srgb => + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)) as unknown as Srgb; + +/** The ridge star module's view of a baked terrain sky: zenith, the middle stop, and the horizon stop. */ +export function skyColoursOf(d: SkyData): SkyColours { + const g = d.gradient; + return { + top: hex(g[0]), + mid: hex(g[Math.floor(g.length / 2)]), + horizon: hex(g[g.length - 1]), + ground: [0, 0, 0], + }; +} + +/** Screen height in px below which there is no sky: where the horizon plane meets the image, a straight line. */ +export const horizonY = (height: number): number => + height * (0.5 - Math.tan(CAMERA.pitch) / Math.tan(CAMERA.fov / 2) / 2); + +export interface Placed { + x: number; + y: number; +} + +/** + * Where a star at azimuth `da` from the camera's heading and elevation `e` (radians) lands in a `w` x `h` px + * viewport, or null if it is behind or off the screen. The same camera as `view()` in terrainWorld.ts + * (forward, right and up are its basis written out for a camera with yaw and pitch only). + */ +export function project(da: number, e: number, w: number, h: number): Placed | null { + const p = CAMERA.pitch; + const ce = Math.cos(e); + const front = ce * Math.cos(p) * Math.cos(da) - Math.sin(e) * Math.sin(p); + if (front <= 0.01) return null; + const tanHalf = Math.tan(CAMERA.fov / 2); + const nx = (ce * Math.sin(da)) / front / (tanHalf * (w / h)); + const ny = (ce * Math.sin(p) * Math.cos(da) + Math.sin(e) * Math.cos(p)) / front / tanHalf; + if (Math.abs(nx) > 1.05) return null; + return { x: ((nx + 1) / 2) * w, y: ((1 - ny) / 2) * h }; +} + +/** Elevation in radians for a ridge-field height fraction y (0 zenith side .. SKY_STOPS.horizon at the horizon). */ +export const elevationOf = (y: number): number => ELEVATION_TOP * (1 - y / SKY_STOPS.horizon); + +const wrap = (a: number): number => a - PERIOD * Math.round(a / PERIOD); + +export interface TerrainStars { + /** Paint for the camera `turns` into its turn (0..1). `seconds` is the clock while the terrain moves; absent: every star at base. */ + draw(turns: number, seconds?: number): void; + /** Re-fit the overlay to the canvas's current size; the next draw repaints. */ + resize(): void; + dispose(): void; +} + +/** The layer for `canvas`'s parent, or null: a day sky, no parent, no 2D context. */ +export function createTerrainStars(canvas: HTMLCanvasElement, sky: SkyData): TerrainStars | null { + const colours = skyColoursOf(sky); + const parent = canvas.parentElement; + if (!parent || !isNight(colours)) return null; + const el = document.createElement('canvas'); + const ctx = el.getContext?.('2d'); + if (!ctx) return null; + el.setAttribute?.('aria-hidden', 'true'); + Object.assign(el.style, { position: 'absolute', left: '0', top: '0', pointerEvents: 'none' }); + parent.appendChild(el); + + let w = 1; + let h = 1; + let scale = 1; + let field: Star[] = []; + let lit: Array<{ star: Star; az: number; e: number }> = []; + let gains = new Map(); + let gainsAt = -Infinity; + let last = -Infinity; + + const fit = () => { + w = canvas.clientWidth || window.innerWidth; + h = canvas.clientHeight || window.innerHeight; + scale = Math.min(window.devicePixelRatio || 1, 2); + const sky = Math.max(1, Math.ceil(horizonY(h))); + el.width = Math.max(1, Math.round(w * scale)); + el.height = Math.max(1, Math.round(sky * scale)); + el.style.width = `${w}px`; + el.style.height = `${sky}px`; + field = makeStars(colours, w, h); + lit = field.map((star) => ({ star, az: star.x * PERIOD, e: elevationOf(star.y) })); + gains = new Map(); + gainsAt = -Infinity; + }; + + const paint = (turns: number, seconds: number | undefined) => { + ctx.clearRect(0, 0, el.width, el.height); + if (seconds !== undefined && seconds * 1000 - gainsAt >= TWINKLE_MS) { + gains = new Map(activeAt(field.length, seconds).map((a) => [a.star, a.gain])); + gainsAt = seconds * 1000; + } + const yaw = 2 * Math.PI * turns; + lit.forEach(({ star, az, e }, i) => { + const da = wrap(az - yaw); + const gain = seconds === undefined ? 1 : gains.get(i) ?? 1; + ctx.globalAlpha = Math.min(1, star.alpha * gain); + ctx.fillStyle = star.fill; + [da, da > 0 ? da - PERIOD : da + PERIOD].forEach((d) => { + const at = project(d, e, w, h); + if (!at || at.y < 0 || at.y > el.height / scale) return; + ctx.beginPath(); + ctx.arc(at.x * scale, at.y * scale, star.r * scale, 0, 2 * Math.PI); + ctx.fill(); + }); + }); + ctx.globalAlpha = 1; + }; + + fit(); + return { + draw(turns, seconds) { + if (seconds !== undefined && seconds * 1000 - last < FRAME_MS) return; + last = seconds === undefined ? -Infinity : seconds * 1000; + paint(turns, seconds); + }, + resize() { + fit(); + last = -Infinity; + }, + dispose() { + el.remove(); + }, + }; +} From 0386065f2c87eeabdb6a929b7cc127ca058b89c3 Mon Sep 17 00:00:00 2001 From: Void Date: Sat, 7 Nov 2026 20:57:22 +0000 Subject: [PATCH 012/111] sky: bake an atmospheric-scattering table (half float) for a WebGL2 sky shader scatter.js ray-marches a single-scattering Rayleigh+Mie atmosphere whose vertical optical depth is sun.js's TAU, and bakes two tables into baked.scatter: transmittance to space by view elevation (32x1) and sky radiance by azimuth from the sun x view elevation x sun elevation (7x32x12), RGB half floats, little-endian base64, value x 64. Layout, exact decode and a GLSL sampler are in src/sky/README.md. Tests pin layout, encoding, checksums and physical anchors (zenith transmittance equals exp(-TAU); blue noon, red low sun toward the sun, black below the planet-shadow limit). The per-sky sun {elevation_deg, azimuth_deg, color_linear}, ambient_linear and horizon_hex were already baked in 7216456a; sun.js now exports TAU. Co-Authored-By: Claude Sonnet 5.5 --- scripts/bake-sky.mjs | 12 +- src/sky/README.md | 83 ++++++++++++++ src/sky/baked.ts | 28 +++++ src/sky/scatter.js | 249 ++++++++++++++++++++++++++++++++++++++++ src/sky/scatter.test.ts | 160 ++++++++++++++++++++++++++ src/sky/sun.js | 2 +- 6 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 src/sky/README.md create mode 100644 src/sky/scatter.js create mode 100644 src/sky/scatter.test.ts diff --git a/scripts/bake-sky.mjs b/scripts/bake-sky.mjs index 4e9d015a..827110c2 100644 --- a/scripts/bake-sky.mjs +++ b/scripts/bake-sky.mjs @@ -1,10 +1,16 @@ -// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts. -// Run `npm run bake:sky` after changing sun.js, skyTokens.js or a moment. +// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts, with the +// atmospheric-scattering tables (src/sky/scatter.js) as `scatter`. +// Run `npm run bake:sky` after changing sun.js, skyTokens.js, scatter.js or a moment. import { writeFileSync } from 'node:fs'; import { bakeSky } from '../src/sky/skyTokens.js'; +import { bakeScatter } from '../src/sky/scatter.js'; const out = new URL('../src/sky/baked.ts', import.meta.url); -const baked = { midnight: bakeSky('midnight'), noonday: bakeSky('noonday') }; +const baked = { + midnight: bakeSky('midnight'), + noonday: bakeSky('noonday'), + scatter: bakeScatter(), +}; writeFileSync( out, `// Generated by scripts/bake-sky.mjs from src/sky/sun.js. Do not edit.\n` + diff --git a/src/sky/README.md b/src/sky/README.md new file mode 100644 index 00000000..7afc80a7 --- /dev/null +++ b/src/sky/README.md @@ -0,0 +1,83 @@ +# Baked sky + +`npm run bake:sky` (`scripts/bake-sky.mjs`) lights two fixed moments with `sun.js`, the single source of +the sun and the air, and writes `baked.ts`. `baked.midnight`, `baked.noonday` and `baked.scatter` are its +three keys. Everything here is linear RGB unless a name says `hex`. + +## Per sky: `baked.midnight`, `baked.noonday` + +| field | meaning | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `moment` | `{ time, lat, lon }` fed to `sun.js` (equinox, 45N, prime meridian; 00:00 and 12:00 UTC) | +| `elevation` | sun elevation in degrees at that moment: -44.6 midnight, +45.2 noonday | +| `sun.elevation_deg` | the same, for scene code; fade stars by this (and see below for twilight) | +| `sun.azimuth_deg` | degrees clockwise from north, 0..360 | +| `sun.color_linear` | direct sun colour at normal incidence, clear zenith ~ 1; multiply by `max(0, dot(n, toSun))` | +| `ambient_linear` | sky ambient on an upward surface, with a night floor of `AMBIENT_FLOOR` (a moonlit skyglow, not black); daylight is `sun.js`'s own `diffuse` | +| `horizon_hex` | colour of the last gradient stop, the horizon | +| `gradient` | 7 sRGB hex stops, zenith to horizon | +| `tokens`, `bubbles`, ... | theme colours (see `skyTokens.js`) | + +The two skies are single moments. To fade stars through twilight, take the elevation from `sun.js` at the +viewer's real time (`sunPosition(time, lat, lon).elevation`) rather than from `baked..sun`, which only +ever says -44.6 or +45.2. The scattering table below goes dark at -12 degrees and below (the planet's shadow +covers every sunlit air path), so a star fade between about -6 and -12 degrees follows it. + +## `baked.scatter`: atmospheric scattering for a WebGL2 shader + +A Hillaire-style (2020) single-scattering atmosphere, reduced to two small tables. The air is `sun.js`'s: +`TAU` (its zenith optical depth per channel) is the vertical optical depth here, split into a grey aerosol +part (0.02) and a Rayleigh part, so the transmittance table equals `exp(-TAU)` at the zenith. Planet radius +6360 km, atmosphere 100 km, scale heights 8 km (Rayleigh) and 1.2 km (Mie, g = 0.8, albedo 0.9), observer +100 m above the ground. Single scattering only: no ground bounce, no multiple scattering, no sun disc +(the consumer draws it from `sun.color_linear`). That makes dusk a little darker than the real thing. + +Constants: `planet_radius_km`, `atmosphere_height_km`, `observer_altitude_km`, `extinction_per_km` (RGB at the +observer, for aerial perspective: `exp(-k * distance_km)`). + +### Encoding (both tables) + +IEEE 754 half floats (16 bit), little-endian, base64 of the raw bytes, no header. The stored value is +`value * store_scale` (`store_scale` = 64; it lifts the dark end of twilight clear of the half-float +subnormals). Exact decode: for a 16-bit word `h`, `e = (h >> 10) & 31`, `m = h & 1023`, +`half = e == 0 ? m * 2^-24 : (1 + m / 1024) * 2^(e - 15)` (no infinities or NaNs are stored), and +`value = half / store_scale`. In WebGL2 upload as `RGB16F` / `HALF_FLOAT` from a `Uint16Array` over the +decoded bytes, and the GPU does the decode: sample, then divide by `store_scale`. Precision is 11 bits +(about 0.05%): the horizon glow does not step. + +### `transmittance`: `width` 32, `height` 1, 3 channels + +Transmittance from the observer to space along a view elevation, per channel. Texel `i` is the elevation +`-4 + 94 * (i / 31)^2` degrees (`viewElevation(i)` in `scatter.js`; the inverse is `viewIndex(deg)`): +finest at the horizon. Elevations that look at the ground (-4 to about -0.3) are 0. Sample it for the sun's +direct colour: at the sun's elevation, `sun.color_linear`-like attenuation, redder as it sets. + +### `sky_view`: `width` 7, `height` 32, `depth` 12, 3 channels + +Sky radiance per steradian for a sun of unit irradiance (multiply by your own sun colour and exposure). +One 3D texture, `x` fastest: + +- `x`: azimuth of the view away from the sun, 0, 30, ... 180 degrees (`azimuth_step_deg`). The sky is + symmetric about the sun's vertical plane, so use `abs` of the relative azimuth. +- `y`: view elevation, same axis as the transmittance table (`viewElevation(i)`). +- `z`: sun elevation, `sun_elevations_deg` = -18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90. Between them + interpolate linearly; `sunIndex(deg)` in `scatter.js` gives the fractional index. + +Element `((z * 32 + y) * 7 + x) * 3 + c` of the decoded array. Texel centres, for linear filtering: + +```glsl +uniform highp sampler3D uSky; // RGB16F, 7 x 32 x 12, LINEAR, CLAMP_TO_EDGE +uniform float uSunIndex; // sunIndex(sunElevationDeg), 0..11, from the CPU +vec3 skyRadiance(float viewElDeg, float azFromSunDeg) { + float y = sqrt(clamp((viewElDeg + 4.0) / 94.0, 0.0, 1.0)) * 31.0; + float x = clamp(abs(azFromSunDeg) / 180.0, 0.0, 1.0) * 6.0; + return texture(uSky, vec3((x + 0.5) / 7.0, (y + 0.5) / 32.0, (uSunIndex + 0.5) / 12.0)).rgb / 64.0; +} +``` + +Row width is 21 bytes, so set `gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)` before `texImage3D`. + +Size: 16,128 bytes for `sky_view` (21.5 KB as base64) and 192 bytes for `transmittance`; about +twice the "few KB" of an 8-bit table, the price of not quantising the glow. `scatter.test.ts` pins the +layout, the encoding, the checksums and a set of physical anchors (blue noon sky, red sunset toward the sun, +black below the planet's shadow limit). diff --git a/src/sky/baked.ts b/src/sky/baked.ts index ecaffbe5..b732fbc1 100644 --- a/src/sky/baked.ts +++ b/src/sky/baked.ts @@ -233,4 +233,32 @@ export const baked = { ], link: '#2a4f27', }, + scatter: { + planet_radius_km: 6360, + atmosphere_height_km: 100, + observer_altitude_km: 0.1, + extinction_per_km: [0.020271980981493083, 0.02767884208799963, 0.044961518003181586], + format: 'half-float', + store_scale: 64, + view_elevation_deg: { + min: -4, + max: 90, + samples: 32, + }, + transmittance: { + width: 32, + height: 1, + channels: 3, + data: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAArkjBP0wqR02AR145qk9vS4BBtlCITWBGW1EcT3dJ1FE1UN5LL1K9UBdNdVIrUSdOq1KFURlP1lLOUe5P+VILUlRQFlM+UqVQLVNoUupQQFOLUidRUFOpUltRXlPCUohRaVPYUq5Rc1PqUs9Re1P4UupRgVMFUwFShlMPUxRSilMWUyJSjVMcUyxSj1MfUzNSj1MgUzVS', + }, + sky_view: { + sun_elevations_deg: [-18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90], + azimuth_step_deg: 30, + width: 7, + height: 32, + depth: 12, + channels: 3, + data: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyhRWDusADhBFCV8AUAFcAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABRKEGYE5g40C8wBGQGDAA0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAqQ++DQsGtQrbCHkCiABRAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVgq5CZkEmAXWBNoBMgAbAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwX3BIkCOAIMAvUACwAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/AH2ARkB1wC/AFcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwACvAFcASgA1ABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQAvAA4AFAAIAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEQAHAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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LithKgMmxya1JQYhpSDLHh0ZEBhtFJAMugRDARIADAABAAAA2CqjKp8nYykdKcQlUyXYJCAhQyDUHksa9xmgF0MRkhDfC+ACCAkeA0QAKik1KcAmGigRKC8lPSQOJN8glx+KHvgaRBsxGWsUuhVaEnILVBL6DUoF8CcbKMwlWyZ/JoMk2yLJIncgxR4dHj8b5RstGh4WkhjzFZkQoRbcEzsNNCaKJukkAiU8Jb4joiG2IQUgDx6lHUAbFBzDGoQX2xlCGO0T5RisFqUR6yRDJSIkAiRDJJsitCDZIC0fcB0wHRcbFBwMG0YYsRpEGZYVExqLGIQU5iNHJPQigiIEI6Eh9R8nIGQe5hzEHNYa5xsdG54YHRv1GfkWyBp3GRkWayIGI9shWyHUIc0g0R4sH7EdbxxiHIkaixsHG9EYVBtfGgYYJhsVGmgXRiHQIfEgdSDiIBwg5B1AHhQdChwLHDkaJxvaGukYUBuRGmcYURtxGjIYYCDYIC4ggR8gIAwfKB19HYwcZxt9G+oZxhqeGuwYHBuZGqQYQxuYGokYUx8QIBgfXx4DHxEejhzcHBcc0xr4Gp8ZahpcGuAY0BqBGsMYBhuVGrsYLh7bHggedR0AHj0dDxxWHGMbVhqFGloZExoYGssYehpUGsoYtBp0Gs8YQR3QHSYdtxwsHYscTBvNG7Ya7BkhGhwZwxnVGa8YIxoZGr8YWBo/Gs0YgBz2HGkcGBx8HOwbnRoSGyEakxnNGeQYeRmWGZEYzRnXGagY/Bn8GbkYwxtEHJkbMxvZG/QaDRp4GqQZSBmFGbQYNxlcGXMYfBmTGYkYohm0GZoYxxpmG5gaZBrvGicalxn5GToZChlJGYoY/RgpGVcYMBlSGWcYThlrGXUY/xmAGsoZvhkzGoAZOBmSGeQY2RgZGWgYzBj9GD8Y7hgXGUYYAhkoGU8YYxnOGSkZPhmhGf0Y7hhBGZ4YshjzGE0YphjcGCwYtxjmGCsYwxjvGC8Y8RhLGbAY3xg1GZoYuBgEGWgYlxjYGDoYixjFGCAYkBjFGBoYlBjHGBoYphjzGFwYoRjrGFQYkxjaGEEYhhjIGC0Yfhi8GB8YfBi3GBcYfBi2GBUYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoTzFMZAc7zXcLqga4jGFKqwV7ixVJAsOHCRXGLsBAAAAAAAAAAAAAAAAzTuBNX0oDzgONPUmmzRTML8i0zCDK8sc0Cz0JNET5h9hEUYAAAAAAAAAKTqbNsEtwTc0NZUsrTTzMdcomzEmLjwkLTDjKqceHy31JP0TUyrKH0cIyjiHNm4wrzZMNS4vLDRYMgYsZTFVLxAo2zBqLawkiTClK0sgKDDlKcgcbzfsNU4xkzXkNFswJjMQMhot2jCOL7MpzzCCLhkoQDEDLuklWTGYLcAk3jUvNZMxnzRWNJ8wEDKFMZwtRDBCL8AqdzDdLpEpQzE6L/8olDFCL58oujR6NHYxsjOPM5EwJjHsMLUtdy+/LkQrCzDFLo8q9DCpL6MqWzH+L5EqwjO0MyQxdzKOMlYwZjBcMI8tii4tLmcrQS9yLhorizCeL7kr+DAPMO8rdzKjMrwwezGwMQYwlS+1L0gtwS2fLU0rfS4HLlArHjBPLygsiDDkL14sdTG8MU0wrzDzMFwvli7VLvEsGi0dLRArzi2ULUkrai/fLkQsGDB6L4ospzD5MMEvCDBRMK4uxS0ULpYsjiyoLMIqNS0kLR0rqS5iLjwsXS/5Lo0s/S9TMPQu/S6QLwkuGC1uLT0sGSxCLGwqsSy7LNsq+i3iLR4smy5wLnIs5y6LLzYuFy6lLm8thyzgLNIrbSvRKxYqPixaLIwqXi1lLeMr6y3mLUMs/i2ZLoktVi3bLeIsDixmLDYryCo3K8MptCsCLDgq0izuLHgrTC1iLQksOS3ILewssSwtLWMsTiv5K6gqPCqxKnYpBytnK+MpViyALAQrvSzkLJErkiwTLWAsJSyXLOIroCpFKygqxik+Ki8pcCraKpEpzysaLI4qPixvLAorAyx3LMcrWysWLBYrDSqqKrYpYyncKfAo7yleKkQpCyt6KxkqmCsELIMqESvjK+sqkCpOK2IqkCkmKlIpDymIKbgofynwKf0oXyrTKqopzipEKwAqQyr+Kiwq5CmRKsYpKCm2KfsoyihCKYcoHymSKb0oyik/KkMpHiqVKoYplyk/KogpVSnyKUEp0ShZKbEokSgHKV4ozyhBKYQoSSm+KeYohin9KRcpCymiKQEp4ChwKdIojCgNKXMoYyjYKDwojCj+KFQo3ihRKZUoBil7KbYonignKZUohigLKXooVijSKEMoPyizKCEoWCjJKC4oiCj5KFMonygSKWYoTyjOKEUoRCjBKDkoLyinKB4oJSiYKA0oMSiiKBIoSCi4KCIoUyjDKCsoICiXKBIoHSiTKA8oGCiMKAcoFiiIKAEoGSiKKAEoHyiQKAQoIiiTKAYoECiDKPonECiDKPonECiDKPonECiDKPonECiDKPonECiDKPonECiDKPonsDGjJYIJViNRGGsBMRuhEZ4AyRcpD3AAgRfSD4IAphgnEa8AJxnNEcYA3jHQJa4JhyNuGHUBXxvFEaIA+hdWD3IAsBcBEIUAwxhHEbMARxnxEcsAdzJnJkAKFCTMGJQB+Rs9Eq8ATxjsD3sAJhhSEI8AJBmxEcEAshloEtoAsTOdJ20LvSSRGdQBnBw1E8oA+RiREI4AyRj5EKQA7BmMEt0AjxpdE/oAGTULKekMGSYpG1kC5x2cFAIBWBrREbQAFhpQEs8AhRslFBYBKhyoFDoBJTgZLPcP0SilHbADnyAyF5AB7hx/FBMBshzWFDgBxh1OFp4BYR4RF9IBvz+iM10XXjAUJYUKCCgoHh8FFyQ0Gy8DLCMMGzUDHyRFHOoDWySUHDcEkkq6PwYn1z0KNq4hnTjFMUYdfDUjLiQYvDT3KxgSEzRdKNEI0TLsJSoFFEk1QNYuNz5+OYcsADpBNgkpCDjVMzAlKjjXMl4izTg7Moge/DirMeEbMUeYPz4zeT2DOoIxvjn1N6AuDzhlNeErfThnNVwqpjkBNlcpKjonNqoo+ERfPkQ1kTx8Ojk0AjkdOFAxRzfWNd8uKThENmcubTmZN6AuCDoUOJMuxEIrPTk2hDv6OSE1QTjFN6UySjauNYgwUTdXNpUw2zgDOD4xcTlmOH0xl0AmPJA2MTpVOYE1MTcPN0ozZDVFNSgxVTYENnoxPTjMN50yxDhSOB0zTD61On82IjmwOIM1HTZONm8zojTJNGgxeDWLNewxWDdGN2EzIzgOOAc0bDx+OTM2SjgbOE81PTWbNUYzAzRQNGkxvjQJNQsyXjalNqszLTdvNzg0ZzqROMo1ODcvN/40hzT6NPIyATPEM0UxIzSNNPQxijUDNqEzPTa7Njc0zTi3N1U1HzZMNqE05DNvNIoyLDIBMwwxRTMaNL4x1jRpNWIzcDUONhc0cDeYNt80OTWJNUE07jLsMxsyfTFYMsswbTJpM3cxPjTcNAUzwjRuNcgz8DWxNWw0fTTgNMYzIjIcM6wx7DDFMYgwuTG1MikxejNcNJgyLTTcNE0z2jTyNP4zwTNPNBMzdjFoMkIxczBHMUYwIDEXMtkwnDLTMyQyWzNYNMgyCzRTNDMzvDKjM2wy5jDMMd8wDjDbMAgwnjCMMYow3TEHM68xfTLEM0Ay2DKXM3ky4THJMtExazBGMYQwcS9/MJ8vMDATMUAwNzFRMjwxvDHvMrox3DGvMs4xKjEMMkUxAjDSMDIw4i4vMDgvoy+pMPYvqDCvMdAwFDEyMjoxEDHnMTQxjjBnMcYwUi9uMM8vai7YL90uAi9OMHcvLDAgMWowgjCJMcIwaTA6MaowCzDZMFQwui4YMEsvBS5lL4wueS79LwYvgS+iMA0wAzD1MFMwvy+nMDEwOi9fMN8vOi6eL9gusS0FL0cuBi51L6Euyi42MHMvLS90MN0v3i4qMI8vgy7vLzIvzy0jL3YubC21Lgwupi0EL0suLy6yL+EudS4FMC0vKC6HL94u7S1EL6AueC2+LiMuNS11LtstWC2nLgMusS0YL2Uu3i1RL5cumS3kLk8ueC29LisuMy1vLuEtCi1DLrUtHC1eLsotTi2eLgEuZy2/Lh4uMS1rLuQtIS1ZLtMtAC01LrAt7CwfLpkt8iwpLqEtBi1GLrktEi1ULsUt7iwfLp8t6iwaLpot4CwQLpAt2SwJLogt2SwKLogt3SwQLo0t3ywULpAt0iwBLoIt0iwBLoIt0iwBLoIt0iwBLoIt0iwBLoIt0iwBLoIt0iwBLoItuz3qOHAtkTCoLOsioyhFJjMeFSUMJHQc8yR4JEEdKybuJSsf2iaxJhIg7j0VOZ4tsDDHLBgjwChtJloeNiUlJJAcEiWUJGEdUiYTJlcfBSfbJisglj6jOTguFjEtLa0jIynxJtseoSV6JOwceiXxJM0d1CaPJuoflSdmJ34g8z/MOngv6TECLnAk8CkCKOYffyYpJakdUiayJase4SePJ40gYChEKCohYkGXPAMxnTO3L6slmCsdKQQhJiiTJi0fCChAJzkgBinPKMIhlCltKYkifESbPxo0+zUHMmAo6i3tK7EjcioVKXshQiqYKW4iyytqK2AkUywuLPckCkylRrI6ED3xOLMu8jRqMsopWzESMA0oKzFwMKsoaTLXMUYqGzOTMhYr8FB4SpY7d0N/PhIxMz2tOfIssTqkN3UqOzsgOKAqpTwtOfkrKD2rOU4sSk/ESSA9hUL4P6I1fT0zPIcyojsMOoUwWzzkOtIwzT2APOgxhT4CPWQyJ02oSPs9QkEKQK843DyiPOI1+zriOkE0EjwHPL80gD1jPQ02Nz4NPqk2J0suR0I+MEB8Px86Fjx8PP83+DnGOvE1CTsFPNQ2yDxwPXg4aj0jPvo43EhaRQo+tj6nPuQ6zzoaPJs4BjlCOvc28jl6OxE4DDwVPWg5lzzAPQ86eEbPQ4o9cT3QPRw7szlRO9Q4PTihOWM3BTm9Ols41DqWPNU5vjsyPY86QUSsQfQ8dzwNPfg60Th6Oss4NjcDOWk3Qzj/OWI4yTkUPOQ5jjqfPKM6n0EsQGQ8aDtjPKI6HTi5OZ44MzZ1ODI3TTdROUI47zg1O7g5ljkUPHI6lD9MPss7MTqiOzI6GzcSOV04ZDXzN9w2UDa4OAw4PThcOms5yjgwOxs6Rj3oPPA6NzmoOrk5MTaCOBU4vDQfN3g2gTUzOJo3VTeeOQ85IjhVOrA5oDvhOzI6azjTOUA5cTUHOJc3NDRqNhM21zSANxU3Yjb4OKw4KzeVOT45uzl/Oow5iDcdOco40TQ7Nwk3hjPQNbA1SjS6NpM2lTVnOEo4PzbuOMs4djh3Ofo4dTaAOFw4TDSFNoQ2yzJNNVQ1qDMQNhc26DTRN9Y3djVbOFw4KTerOHc4kTX0N+w3tTPnNQg2MDLeNAA14TJ+NaQ1VTT1NiQ3yzS1N+Y34jUJOAI41DQNNzA39DJfNZc1rTF/NLQ0OTIANTw1rjM1Nn42NzTTNiI37jQJNzE3NjRENoU2TzLqNDA1QDEuNHA0qzGTNN001jKNNec1cTMONm42MTQrNnQ2YzOXNek1wjGDNNQ05DDTMzU0MjE2NIk0HDL8NF41ljJhNcs1NzNwNcs1hTICNV41STErNII0lzBfMwE0zTDOMz80fjF+NOQ02zHMNDk1RzLSNDU1yzGBNOM04jC/Mzo0VzD+MqkzeTBJM/8z+DASNHk0PDFMNLg0hDFNNLI0MTEUNHc0izA/M/gzIzCuMl8zNDDbMpMzijBxMx40uDDBM0o06DC8M0M0sjBxMxs0QzDUMo4z8S9tMiEz+y+DMjwzMTDgMqUzTjASM90zbDAKM88zTDDdMp8zCTB+MjgzsC87MvEyqS9AMvky3C9wMjAz+i+LMk8zDzCDMkIz/i9tMiozui88MvYygi8XMs8ycy8SMssygi8kMt8yjS8uMusyni8nMuIylC8gMtoyfC8QMskyZS8BMrkyWS/7MbMyVi/7MbQyVy/8MbYyWy/5MbIyWy/5MbIyWy/5MbIyWy/5MbIyWy/5MbIyWy/5MbIyWy/5MbIyej2nO5k2xTKgMW0ucisPLBEqJyhRKWooECjlKTspDynQKyErnSlnLAwsrD3tO9M29DLGMZcuoispLDcqQihyKYYoKigKKlspLykALE0rwCmDLCUsVT5rPJQ3kDNGMiQvISx/LLYqmijhKeEofyiEKsYpmSlRLN8rNireLHgsuT9fPZQ4azROMyQwyCwyLbwrUCnIKp0pMCmDK6MqdSr6LIYsKiucLSMtUkFePzg6ujW3NE0xHy6jLuosySpSLB4rnirHLDQsHSxULrotkSwiL4AujkRCQjE9iThoNxo0xzAmMYcvRy2uLnAtIi1fL2guYi7hMF0wFS+AMfMwQkygSUlEtz8QPi06/TcmOJk1YzRbNQQ0QTTiNbQ0SDXHN2M23DVhOD4341DNTHZD5kQYQho64T5YPTU2fjyFO4M09DxTPEE1iz7LPQw3XT+JPvI3u09xTJhEI0SwQrw83z7kPng5zDwYPRs4hD0HPtw4bj8YQJI6NECkQGw7FE7ASxJFmEJpQkE+8j0jP8E7QzxiPew5+TxuPhQ7vz5lQNE8pj/9QHQ91UyeSi9FPUHFQTU/8jyyPqM8MDsYPSU7NjwaPkk8uT0tQNo9fj6/QKM+mUt+SfNENkAQQZI/GjwFPvk8ADqZPLM7BzuAPZ48xjyHP08+aj1HQCg/wUleSHBE4T5mQIA/2zpTPQA9CTkUPMM74TncPKQ89zuiPlQ+hDyIPy4/IEiYRodDsT2iPy4/zTmyPNc8Rjg0O4g79jhEPHo8sTrLPRY+kzuSPuY+nUXGRCpCwjyiPro++DglPJY8VjdfOiY7Ojh7Ozg8qzkOPbQ9aDq5PXU+aUPOQgFBAjzGPTk+TThYO0s8YDaoObU6RjeVOtg71zhpPEM9dTn8PPI94kDpQB1AyToLPbU9hTeKOvs7mTUOOUI6UjbROT07KTi5O888rjhaPGw9lT5PP+Y+wzlpPDY9ojbaOWQ7+DSMONI5ijUrOac6NTfHOl88CjibO+g8nTynPek96ji+O7485TVEOdY6czQeOGs55TSdOBs6Rzb3Oek7ADeqOmw8wDqJPCU9NTjPOk48RjXCOFI6BjSCNw05XDQkOJs5fzVEOSQ7GDbaOfE7Jzl+O4g8PDcCOs87vzRSONo5VTPlNrk40jN7Nyg51TSqOHE6UzUmOR47FDhUOgU8QDZSORU7TDTiN2w5vTJfNm84EjPMNsI4RjQlONA5qzSKOF86pjZvOSo7bjW6OGs60DM5Nwk5PTLtNS04cTI4Nmg4mDNkN0A5HDQDOLM5izW4OGc6vzQ2ONI5JDOlNq840jGNNeg36jG6NRk4yTKfNsE4RTMdNxw5tTQjOLw5LDSIN0k5kDIkNl84eTE8NYU3ejFQNaw3GzL5NVI4dzJWNpc4DTRMNyU5YjPENs44DzK0NRg4LzH5NDE3HzH6NDs3jDFtNek3zDGwNSY4EDN7NqE4lDIbNmE4oTFTNbQ38jDBNOs21zC0NN82GDH8NE43QjEoNY83OTLMNS846jGMNQM4RDEBNUg3wjCUNLE2oDB+NJg2vzCkNNM21zC9NPk2jzE9NZ43XzEVNWc39jC8NO02nDBxNIU2ejBYNGU2gTBlNHs2izBwNIw2CjHLNAQ38TC2NOc2uDCGNKM2gTBYNGQ2ZDBCNEY2XTA/NEU2XjBCNEk2qTB4NJE2oDBwNIY2iTBdNGw2cTBJNFA2XzA7ND02VTA0NDQ2UzAyNDI2azBENEo2azBENEo2azBENEo2azBENEo2azBENEo2azBENEo2azBENEo27DkmOUI3djKfMvIxJixwLSsuyihTK5gstCgQLG8tzilWLVcvbCr/LScwITpTOX43pTLOMhoyQSyTLVIu6SiBK7Qs0SgpLJAt8yl3LYQvlCokLkAw0zrpOSM4PzNnM58ynCwILtQuTykNLBMtMyl9LP4tayrlLQwwGSugLpQwJDwkO/Q4QDRTNLEzWC37LuAvIiqtLNct/yksLeMuZivJLqYwFyygL0MxrT3cPKc6jDWcNfE03C54MAMx1yv2LWcvpSuSLlswtixQMOExNi3YMKcy10AaQHw9bjhwOK03XzHyMrEzGi6cMKgx7i0RMaIyTC+kMnk0CTB2Mw81hUhWR3hEnj9QP8s9gTibObc5FTVlNy446jQLOOA4CjZLOZA6rDbyOWw7QU1ISoRD2kQjQ049ez+5PsE59zzTPE44cT2MPRA5Ij9sP9Q6AkAwQLg7VkwXSr5EKkTAQ6M/Yj8qQI88ND07Pv067D1TPyc85T/zQKE9dUCaQWA+PkulSXNFvUJoQ8FAXj47QP09ljxrPqM8Rj2VP4Y9ET8jQYE//D/SQUBAV0pBSexFcEGuQkhBTD3cP9Y+tDv+PU89bjwQP08+8D3IQEhAtz5rQdpAzUn4SDZGc0DlQW9BZzwLPyU/azpfPYs9WTtLPow+6zxAQG5Ajj3QQAVBcUm+SFdGaj8rQVRBYzs7PhM/Yjm+PHs9HjqDPWo+FDxpP1NAmjwxQOVACklzSD9GRT6JQBVBRzqAPcw+kTgtPEA9JDnOPBU+zzprPhNArjtBP5tAZEjkR89FXT36P8RAZznfPGo+2TdhO/E8XTgxPKc9vTmOPYU/dDpEPj5A70ZoRgNFnjwIP2tAtThWPP891DaOOpw8fTdXOzQ93zjRPNo+eDlrPbY/70TFRA5E+js1PhJAJDjCO5Q9AjbcOUk8fTZ2OsI8LDgxPDI+qjiyPPM+jULGQmJC5Tp8PXU/Wzf6Oi09VzVHOfY7rTW4OVk8MTdQO5M9ATgVPDo+REDPQBRB8znZPM8+lTZQOs08zDTJOGc7ATUYOfA7PDZoOgI96DYcO5A9rj0QPyhAITlJPDQ+7jW9OXU8WTRgOOc6dDSROEM7cTWhOX48/TU2OvY86DtvPQU/aziWO6Q9YDU9OSQ89TMHOHY6/zMgOKs6xjT4OAg8NzVxOWs8yzlcPBE+oDe6OiE95TTOOLQ7VzN3NxI6PTOANyU6NzRpOEA7kDTJOOA7dDg+O1Q9mjb6Oak8ejRsOC470zL4Nrw5nTLgNrI5fzPdN4o6AzQ5OAc7ITcpOro8vjVUOT08HDQWOLQ6ZTKNNnE5GjJcNlE5uDIQN+05HTOAN0w64jVVOTg8BjXEOLU7lDOUN0Y6CTIzNjE5sDHwNQA5FTJnNmk5WzK2Nq059zSrOJA7bDRHOAQ7AzMNN+I5vjHpNfo4XTGaNb84kzHeNf04wDESNio5RTQgOMw61zO2N2Y6hDKXNok5gDGsNc04HjFYNYw4LzF1Nag4SDGSNcM4cTNZNyE6ADP7Nto5FjIwNjo5TzF7Nak48jApNWg46TApNWs48jA1NXc4kjKYNo45TjJdNmA5tzHXNfU4KTFVNYw42DANNVM4wDD6NEU4vjD7NEc44jH6NRM5vjHZNfk4aDGMNbo4DjE6NXg40TACNUs4tDDpNDc4rTDjNDM4WjF9NbA4TDFxNaU4KDFQNYs4/jApNWs42zAKNVI4xjD3NEI4vzDwND04+DAkNWc4+DAkNWc4+DAkNWc4+DAkNWc4+DAkNWc4+DAkNWc4+DAkNWc4QzV3NXI1/TDtMfIyHSz2LSkwFSk8LHgu+yipLJAvBCr6LfwwmCqoLpcxbjWjNZs1ITEWMiEzOCweLkQwNSlXLKAuGinGLL4vKSofLhoxwSrRLrkx/zUzNiI2mjGhMrwzkyyfLpwwoimwLCUvgikmLSswpSqZLn4xSStZLykyLjdiNzs3lTLBM380US2tL1MxgyppLR0wWirsLckwpyuWL00yMSw5MBEz1TjsOL84TzQKNcg12S7sMMkyKyzmLjcxCyyGLwwy3izQMPQzVS1bMXY0BjwKPJo76jb9N4A4YTGtMzg1fy5XMfgzRS7LMZk0hi9lMwo2HzAeNMU2TkP+QvZB+z2cPs0+iTg3OsY7azVJOOQ5MTWeOL86NzbiOWw8zTaMOvU8b0hURiBBBkSuQjY+iT9QP5I7Mz1tPdo5mD0gPsQ6Ij/6P3Q88T93QP88hUeCRtBCMEN3Q3JAdT96QMM9aT3ePog8Aj7hP0Q9yD8tQfU+WUDOQdA/RkYpRgpE+EFBQ3xBcz6GQFk/wTwAP849Tz0EQLo+5j5IQXFAuT/tQf5AaUW1RWxE7EChQg9CZT0zQCBA+jt/Pn8+bzxoP3o/wj3cQOxAcT50QYdB5URXRZ5EHUDtQTtCgjyLP0VApTrOPbE+VDuNPp0/vDxHQP1ATD3LQJlBr0QhRbpEAj9FQSNCnTuzPjdAlTkgPY8+FDq0PV0/zjtmP8tAXDwkQF9BvUQcRddEFz60QOVBhTryPQxAvziEPEE+FznyPOk+fDpdPnZAOjscP/1ACkVKRQJFYT04QJVBqTlNPak/Fzj9O+A9TzhNPGE+bjl5PRNACjoZPotAhEWgRT5F0DyePz9B+jjAPDI/IzcdO3s9YTeCO9Y9lzi3PFw/Fjk9PRdA8EXtRXFFVzzoPudAbThJPLs+TjZhOho9YzaZOlE90zcVPJs+UTiDPFA/10XMRVVF2DtFPpBA8zfJO0o+ozXFOcA8ljXWOdg8uDYXO+k9XzfNO4I+6UTzRLZEETuxPTxAMzccO+E9GDVDOW887zQ0OWw80DUxOkg9VjbEOsc9AkNjQ4xDUTolPdM/kTaHOoA9pjTWOCg8aDStOA08ETVvObo8ezXlOSI9i0AoQdZBljmhPDM/BjYGOig9STR7ONI78jM9OHY7dDTNOD48xTQpOZA80j1IP5xA5DgmPJs+jTWUOdc8+DMvOGY7PzPDN+o65TNGOKU7LzSLOBI8xjtqPZg/PzhpOws+IzUwOYw8eTPgNwk7rzItN3Y6EzOtN/A6aTMJOE07hzlDPHw+WjedOoY9xTTWOEg8ETN2N7k6PDK2Nhc6ajL5Nls6ozI8N5o6LjgFO6w9XzbpOQw9cDSGOAo8vDIfN3Y65TFZNs055zFqNuM5BzKSNgo6pTb5OQo9jDVNOZ48IzQ9OKA7djLYNj86pTEUNpY5hTEANog5kTERNps5fTUxOYU83jTFODk8uzP2NzY7PzKeNhE6ezHlNXA5RDG2NUo5QTG3NUw5qTSUOBM8TTRQOL47PDN/N9Q6EzJwNu05ZDHLNVw5IDGNNSc5EzGCNR45CjQVOGA7rDPVNx07yzIUN3w68jFONtE5YTHENVg5GzGCNR85CTFxNRA5HjNXN7U65zIoN446ZzK2Ni062zE1Nr05cTHQNWU5NDGXNTI5IjGFNSM5XTKqNiM6RzKXNhM6EDJlNuc5zTEmNrE5kzHvNYE5bTHMNWE5YDG/NVc5yTEhNq05yTEhNq05yTEhNq05yTEhNq05yTEhNq05yTEhNq05yTEhNq05Ri/uMBIzpi0OMB0yKSu3LagwPSmjLAcwECnpLHowqSnLLWwxBCpBLuUxey8RMUIzzy0qMEYyWSvdLccwXynALCAwMCkHLZUwzCnuLYwxKSpnLggyFzCFMeIzVC6HMM0y+itbLiox0CkiLXMwmSlrLe4wQCpjLvcxoyrkLnwy0TB3MpY0ay9IMeUzpCxfL/kxuirtLR8xdCo7LqcxMCtVL9QyoivpL2wzUzI1NOk11jDXMhE1/i29MKAzUCyQL34yHCznLyIzkSykMEw02CwBMas0GTWyNpw4uTNmNd03ujBoM+A1vC7cMfY0Xi4UMmo1DS8dM4I2eC+qMxA3cDyQPf0+pDpxPOw9ATgFOmM8ojW1OFY7RjXXOO870DWkOcA8JjYSOic9cULrQXg+HUHvQJM95D4lPzk8XD3cPSM7ez07Pr87YD5nP6c83D4CQA09CULbQqxA7EDbQRhA7z5kQFQ+jz1TP2A9zT3aP9Q90z6wQAE/YD8WQZw/KUHOQslBPkDeQRxBED5zQPc/4jxrP74+Fj3pP0E//T22QFhAeD4cQbdAXEBUQmxCJj93QbBBHj0nQHFAGTzgPnQ/Ozw+P+w/9TxKQLZAWj2lQBtBdz/BQadCED70QOVBUTyBP5hA2jomPqA/+TphPvo/DTx+P7RAXTwMQBZBiz45QaFCOz12QNtBXzu5PoxAxzlwPXU/xzmLPak/pTpwPndAIzvwPtFA5j3GQHhCmzwIQK9BZDoHPmZA8TjQPB8/2DjPPCo/fzmDPSBA4jnqPW5AfD1uQEJCIjxXP3RBoTlwPTNASzhJPLg+HTgwPJw+mTi6PH4/5jgNPQJASD0wQAxCkjvAPjRBCTnxPPk/kTezO08+FjdZOxE+xzcTPMA+HzhUPC8/TD0OQONBEztGPvZAkDiHPI8/xDb7Ouw9LzaBOo89pzYROxE+ADd2O2o+lT0QQM5BvTrnPb1AMDgwPCw/ITZkOpM9eTXROR09wTUuOnU9AjZ6Ork9Pz5CQNxBizqePYpAyDfOO9I+oTXpOUU96TRCObo8CjV1Oe08ODWrOSA9fT+5QCFCeTpqPV5ATTdUO4E+OzWFOQM9dzTQOGg8eTTfOHo8ljQDOZ08xkCSQbRCfjpEPThA6jbtOjk+6zQ1Ocs8HzR2OCQ8BzRnOBw8FjR7ODA8KULCQo9DjDonPRZAmjaWOvg9rDT0OJ08uDMvON47XTMKOJ87ZDMROLA7IUOTQxFEiToGPeQ/WTZMOr09fDTCOHg8VTP2N4472jKFNys7zjJ/Nyg7YELVQnVDVjrRPI4/IDYLOog9WDSaOFo8ETOsN1U7fTIgN9g6YzIJN8Q6YUD1QNtB3jl+PCA/7TXQOVY9PzR8OEM86DJ9NzM7RDLfNqM6HzK8NoQ6YD22PndALDkSPJ4+uTWYOSc9LjRmODI82DJnNyU7KzLANow6/jGVNmQ6yzrKPC0/Zjg+OxU+gjVgOfg8JTRXOCY83jJmNyk7MTLANpE6ADKQNmU6rjhsOxo+YTdoOpM9RDUnOco8ITRNOB08+zJ6Nz47VTLeNq86IzKuNoM6BzclOmM9OjazOSA9/zTtOJs8ITRHOBg8LjOiN2M7mDIYN+c6aTLsNr86pzVQOds8VzUeObo8tjSzOGw8JDREOBU8dzPcN5Y7/TJxNzg71jJNNxg7xDS4OG48qzSmOGE8bTR5OD88JjRDOBM81zMUONg7izPpN6I7cjPTN487KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8SSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4waynvLIowaynvLIowaynvLIowaynvLIowaynvLIowaynvLIowaynvLIow3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUwzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQz0y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY1ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w99TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/AjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJA9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJBGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtC', + }, + }, } as const; diff --git a/src/sky/scatter.js b/src/sky/scatter.js new file mode 100644 index 00000000..5781c089 --- /dev/null +++ b/src/sky/scatter.js @@ -0,0 +1,249 @@ +// Atmospheric scattering tables, baked for a WebGL2 shader (layout: src/sky/README.md). +// Dependency-free apart from sun.js, so scripts/bake-sky.mjs and the tests run it under plain node. +// +// bakeScatter() -> { transmittance, sky_view, ... } with base64 RGB16F tables (half floats). +// +// A Hillaire-style single-scattering model (Rayleigh + Mie, spherical planet, observer 100 m up), +// reduced to two small tables: the transmittance of the whole atmosphere along a view direction, and +// the sky's radiance by sun elevation, view elevation and azimuth from the sun. sun.js stays the +// single source of the air: its zenith optical depths (TAU, per channel) are the vertical optical +// depth of this atmosphere, split into a grey aerosol part and a Rayleigh part. So the direct sun +// sun.js returns and the transmittance here agree at the zenith; only the air-mass law differs +// (Kasten-Young there, the spherical integral here). + +import { TAU } from './sun.js'; + +const RAD = Math.PI / 180; +const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x)); + +// Geometry (km) and scale heights. +export const PLANET_RADIUS_KM = 6360; +export const ATMOSPHERE_HEIGHT_KM = 100; +export const OBSERVER_ALTITUDE_KM = 0.1; +const H_RAYLEIGH = 8; +const H_MIE = 1.2; +const MIE_ALBEDO = 0.9; // single-scattering albedo of the aerosol (EYEBALLED) +const MIE_G = 0.8; // Henyey-Greenstein asymmetry (Hillaire 2020) +const MIE_TAU = 0.02; // grey aerosol vertical optical depth (EYEBALLED); the rest of TAU is Rayleigh + +// Vertical optical depth of an exponential layer of scale height H over the atmosphere. +const column = (H) => H * (1 - Math.exp(-ATMOSPHERE_HEIGHT_KM / H)); +const BETA_RAYLEIGH = TAU.map((tau) => (tau - MIE_TAU) / column(H_RAYLEIGH)); // per km at sea level +const BETA_MIE_EXT = MIE_TAU / column(H_MIE); +const BETA_MIE_SCA = BETA_MIE_EXT * MIE_ALBEDO; + +// Table axes. +export const SUN_ELEVATIONS_DEG = [-18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90]; +export const VIEW_SAMPLES = 32; +export const AZIMUTH_SAMPLES = 7; // 0, 30, ... 180 degrees from the sun +export const AZIMUTH_STEP_DEG = 30; +export const VIEW_MIN_DEG = -4; +export const VIEW_MAX_DEG = 90; +// Tables hold IEEE 754 half floats, little-endian, holding value * STORE_SCALE. The scale lifts the +// dark end (twilight sky radiance is ~1e-4) clear of the half-float subnormals; the brightest +// stored value, a Mie peak near 0.3 x 64, is far below the format's 65504. +export const STORE_SCALE = 64; + +/** View elevation in degrees of sample index i of VIEW_SAMPLES: finest at the horizon. */ +export const viewElevation = (i) => + VIEW_MIN_DEG + (VIEW_MAX_DEG - VIEW_MIN_DEG) * (i / (VIEW_SAMPLES - 1)) ** 2; + +/** Fractional index into SUN_ELEVATIONS_DEG of a sun elevation (clamped to the table): the z texel coordinate. */ +export const sunIndex = (elevationDeg) => { + const e = clamp( + elevationDeg, + SUN_ELEVATIONS_DEG[0], + SUN_ELEVATIONS_DEG[SUN_ELEVATIONS_DEG.length - 1] + ); + const k = Math.min(SUN_ELEVATIONS_DEG.findIndex((x) => x > e) - 1, SUN_ELEVATIONS_DEG.length - 2); + const i = k < 0 ? SUN_ELEVATIONS_DEG.length - 2 : k; + return i + (e - SUN_ELEVATIONS_DEG[i]) / (SUN_ELEVATIONS_DEG[i + 1] - SUN_ELEVATIONS_DEG[i]); +}; + +/** Inverse of viewElevation: the fractional sample index of an elevation. */ +export const viewIndex = (elevationDeg) => + Math.sqrt(clamp((elevationDeg - VIEW_MIN_DEG) / (VIEW_MAX_DEG - VIEW_MIN_DEG), 0, 1)) * + (VIEW_SAMPLES - 1); + +/** Round a non-negative number to the nearest IEEE 754 half float (ties to even); returns its 16 bits. */ +export function toHalf(x) { + if (!(x > 0)) return 0; + const u = new Uint32Array(new Float32Array([x]).buffer)[0]; + const exp = ((u >> 23) & 0xff) - 127 + 15; + const mant = u & 0x7fffff; + if (exp >= 31) return 0x7bff; + let half; + let rem; + let halfway; + if (exp <= 0) { + if (exp < -10) return 0; + const m = mant | 0x800000; + const shift = 14 - exp; + half = m >> shift; + rem = m & ((1 << shift) - 1); + halfway = 1 << (shift - 1); + } else { + half = (exp << 10) | (mant >> 13); + rem = mant & 0x1fff; + halfway = 0x1000; + } + return rem > halfway || (rem === halfway && half & 1) ? half + 1 : half; +} + +/** The exact value of 16 half-float bits (finite values only). */ +export const fromHalf = (h) => { + const e = (h >> 10) & 31; + const m = h & 1023; + return e === 0 ? m * 2 ** -24 : (1 + m / 1024) * 2 ** (e - 15); +}; + +/** Stored 16 bits of a value, and the value back out of them. */ +export const encode = (v) => toHalf(v * STORE_SCALE); +export const decode = (h) => fromHalf(h) / STORE_SCALE; + +const extinction = (h) => { + const r = Math.exp(-h / H_RAYLEIGH); + const m = Math.exp(-h / H_MIE); + return BETA_RAYLEIGH.map((b) => b * r + BETA_MIE_EXT * m); +}; + +// Distance along ray (origin o, unit direction d) to the sphere of radius r centred on the origin +// of the planet: the far root, or the near root if `near`. Infinity when it misses or is behind. +function sphereHit(o, d, r, near) { + const b = o[0] * d[0] + o[1] * d[1] + o[2] * d[2]; + const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - r * r; + const disc = b * b - c; + if (disc < 0) return Infinity; + const sq = Math.sqrt(disc); + const t = near ? -b - sq : -b + sq; + return t > 0 ? t : Infinity; +} + +// Optical depth per channel from p along d to the top of the atmosphere; Infinity if the planet is in the way. +function opticalDepthToSpace(p, d, steps) { + if (sphereHit(p, d, PLANET_RADIUS_KM, true) !== Infinity) return null; + const top = sphereHit(p, d, PLANET_RADIUS_KM + ATMOSPHERE_HEIGHT_KM, false); + const dt = top / steps; + const depth = [0, 0, 0]; + for (let k = 0; k < steps; k += 1) { + const t = (k + 0.5) * dt; + const q = [p[0] + d[0] * t, p[1] + d[1] * t, p[2] + d[2] * t]; + const e = extinction(Math.hypot(...q) - PLANET_RADIUS_KM); + for (let c = 0; c < 3; c += 1) depth[c] += e[c] * dt; + } + return depth; +} + +const observer = [0, PLANET_RADIUS_KM + OBSERVER_ALTITUDE_KM, 0]; +const viewDirection = (elevationDeg, azimuthDeg) => { + const e = elevationDeg * RAD; + const a = azimuthDeg * RAD; + return [Math.cos(e) * Math.cos(a), Math.sin(e), Math.cos(e) * Math.sin(a)]; +}; +const sunDirection = (elevationDeg) => [ + Math.cos(elevationDeg * RAD), + Math.sin(elevationDeg * RAD), + 0, +]; + +/** Transmittance per channel from the observer along an elevation to space; zeros where the ground is in the way. */ +export function transmittance(elevationDeg) { + const depth = opticalDepthToSpace(observer, viewDirection(elevationDeg, 0), 64); + return depth ? depth.map((x) => Math.exp(-x)) : [0, 0, 0]; +} + +const rayleighPhase = (c) => (3 / (16 * Math.PI)) * (1 + c * c); +const miePhase = (c) => + (1 - MIE_G * MIE_G) / (4 * Math.PI * (1 + MIE_G * MIE_G - 2 * MIE_G * c) ** 1.5); + +/** + * Sky radiance per channel, per steradian, for a sun of unit irradiance (so the consumer multiplies by + * its own sun colour), in a view direction at an elevation and an azimuth measured from the sun. + * Single scattering, no ground bounce, no sun disc. + */ +export function skyRadiance(sunElevationDeg, viewElevationDeg, azimuthDeg) { + const v = viewDirection(viewElevationDeg, azimuthDeg); + const s = sunDirection(sunElevationDeg); + const cosTheta = v[0] * s[0] + v[1] * s[1] + v[2] * s[2]; + const pr = rayleighPhase(cosTheta); + const pm = miePhase(cosTheta); + const ground = sphereHit(observer, v, PLANET_RADIUS_KM, true); + const top = sphereHit(observer, v, PLANET_RADIUS_KM + ATMOSPHERE_HEIGHT_KM, false); + const end = Math.min(ground, top); + const steps = 24; + const dt = end / steps; + const along = [0, 0, 0]; + const radiance = [0, 0, 0]; + for (let k = 0; k < steps; k += 1) { + const t = (k + 0.5) * dt; + const q = [observer[0] + v[0] * t, observer[1] + v[1] * t, observer[2] + v[2] * t]; + const h = Math.hypot(...q) - PLANET_RADIUS_KM; + const e = extinction(h); + for (let c = 0; c < 3; c += 1) along[c] += e[c] * dt; + const toSun = opticalDepthToSpace(q, s, 12); + if (toSun) { + const r = Math.exp(-h / H_RAYLEIGH); + const m = Math.exp(-h / H_MIE); + for (let c = 0; c < 3; c += 1) { + const scatter = BETA_RAYLEIGH[c] * r * pr + BETA_MIE_SCA * m * pm; + radiance[c] += Math.exp(-along[c] - toSun[c]) * scatter * dt; + } + } + } + return radiance; +} + +const base64 = (halves) => { + const bytes = new Uint8Array(halves.length * 2); + halves.forEach((h, i) => { + bytes[i * 2] = h & 0xff; // little-endian + bytes[i * 2 + 1] = h >> 8; + }); + let s = ''; + for (let i = 0; i < bytes.length; i += 0x8000) { + s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(s); +}; + +export function bakeScatter() { + const view = Array.from({ length: VIEW_SAMPLES }, (_, i) => viewElevation(i)); + + const t = new Uint16Array(VIEW_SAMPLES * 3); + view.forEach((e, i) => transmittance(e).forEach((v, c) => (t[i * 3 + c] = encode(v)))); + + // x = azimuth, y = view elevation, z = sun elevation, three half floats per texel: a 3D texture. + const sky = new Uint16Array(SUN_ELEVATIONS_DEG.length * VIEW_SAMPLES * AZIMUTH_SAMPLES * 3); + let at = 0; + SUN_ELEVATIONS_DEG.forEach((sun) => + view.forEach((e) => { + for (let a = 0; a < AZIMUTH_SAMPLES; a += 1) { + skyRadiance(sun, e, a * AZIMUTH_STEP_DEG).forEach((v) => { + sky[at] = encode(v); + at += 1; + }); + } + }) + ); + + return { + planet_radius_km: PLANET_RADIUS_KM, + atmosphere_height_km: ATMOSPHERE_HEIGHT_KM, + observer_altitude_km: OBSERVER_ALTITUDE_KM, + // Extinction per km at the observer, per channel, for aerial perspective: exp(-k * distance_km). + extinction_per_km: extinction(OBSERVER_ALTITUDE_KM), + format: 'half-float', + store_scale: STORE_SCALE, + view_elevation_deg: { min: VIEW_MIN_DEG, max: VIEW_MAX_DEG, samples: VIEW_SAMPLES }, + transmittance: { width: VIEW_SAMPLES, height: 1, channels: 3, data: base64(t) }, + sky_view: { + sun_elevations_deg: SUN_ELEVATIONS_DEG, + azimuth_step_deg: AZIMUTH_STEP_DEG, + width: AZIMUTH_SAMPLES, + height: VIEW_SAMPLES, + depth: SUN_ELEVATIONS_DEG.length, + channels: 3, + data: base64(sky), + }, + }; +} diff --git a/src/sky/scatter.test.ts b/src/sky/scatter.test.ts new file mode 100644 index 00000000..9ebcb014 --- /dev/null +++ b/src/sky/scatter.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; +import { + AZIMUTH_SAMPLES, + AZIMUTH_STEP_DEG, + STORE_SCALE, + SUN_ELEVATIONS_DEG, + VIEW_SAMPLES, + bakeScatter, + decode, + fromHalf, + skyRadiance, + sunIndex, + toHalf, + transmittance, + viewElevation, + viewIndex, +} from './scatter.js'; +import { TAU } from './sun.js'; +import { baked } from './baked'; + +const table = (b64: string): Uint16Array => { + const bin = atob(b64); + const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0)); + return new Uint16Array(bytes.buffer); +}; +// Checksums of the baked halves, so a change to the model or the encoding is a deliberate re-pin. +const SKY_CHECKSUM = 622927107; +const TRANS_CHECKSUM = 1498461058; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const scatter = baked.scatter as any; +const sky = table(scatter.sky_view.data); +const trans = table(scatter.transmittance.data); +const sunIndex_ = (deg: number) => SUN_ELEVATIONS_DEG.indexOf(deg); +// Texel of the sky table: x = azimuth, y = view elevation, z = sun elevation; value = three halves. +const texel = (s: number, v: number, a: number) => + [0, 1, 2].map((c) => decode(sky[((s * VIEW_SAMPLES + v) * AZIMUTH_SAMPLES + a) * 3 + c])); + +describe('half floats', () => { + it('round-trip within half precision across the table range, ties to even', () => { + [6.1e-5, 1e-4, 0.0123, 0.3, 1, 20, 1000].forEach((x) => + expect(Math.abs(fromHalf(toHalf(x)) - x) / x).toBeLessThan(5.1e-4) + ); + expect(toHalf(0)).toBe(0); + expect(toHalf(1)).toBe(0x3c00); + expect(toHalf(2 ** -24)).toBe(1); // smallest subnormal + expect(toHalf(1 + 2 ** -11)).toBe(0x3c00); // exact tie goes to the even neighbour + expect(toHalf(1 + 3 * 2 ** -11)).toBe(0x3c02); + expect(fromHalf(0x7bff)).toBe(65504); + }); + + it('keeps twilight radiance, ~1e-4, within 0.1% thanks to the store scale', () => { + const x = 1.234e-4; + expect(Math.abs(decode(toHalf(x * STORE_SCALE)) - x) / x).toBeLessThan(1e-3); + }); +}); + +describe('sunIndex', () => { + it('is the fractional position of an elevation on the sun axis, clamped to the table', () => { + expect(sunIndex(-18)).toBe(0); + expect(sunIndex(90)).toBe(SUN_ELEVATIONS_DEG.length - 1); + expect(sunIndex(-100)).toBe(0); + expect(sunIndex(100)).toBe(SUN_ELEVATIONS_DEG.length - 1); + expect(sunIndex(0)).toBe(sunIndex_(0)); + expect(sunIndex(1.5)).toBeCloseTo(sunIndex_(0) + 0.5, 12); // halfway between 0 and 3 + expect(sunIndex(35)).toBeCloseTo(sunIndex_(25) + 0.5, 12); // halfway between 25 and 45 + }); +}); + +describe('scattering tables', () => { + it('are what scripts/bake-sky.mjs would write now (run npm run bake:sky if not)', () => { + expect(JSON.parse(JSON.stringify(bakeScatter()))).toEqual(JSON.parse(JSON.stringify(scatter))); + }); + + it('have the documented layout and size', () => { + expect(scatter.format).toBe('half-float'); + expect(scatter.sky_view).toMatchObject({ + width: AZIMUTH_SAMPLES, + height: VIEW_SAMPLES, + depth: SUN_ELEVATIONS_DEG.length, + channels: 3, + azimuth_step_deg: AZIMUTH_STEP_DEG, + }); + expect(sky.length).toBe(AZIMUTH_SAMPLES * VIEW_SAMPLES * SUN_ELEVATIONS_DEG.length * 3); + expect(trans.length).toBe(VIEW_SAMPLES * 3); + expect(sky.length * 2).toBe(16128); // bytes: the budget the README states + }); + + it('pin the view-elevation axis and its inverse', () => { + expect(viewElevation(0)).toBe(-4); + expect(viewElevation(VIEW_SAMPLES - 1)).toBe(90); + expect(viewElevation(6)).toBeCloseTo(-4 + 94 * (6 / 31) ** 2, 10); + [0, 5, 6, 17, 31].forEach((i) => expect(viewIndex(viewElevation(i))).toBeCloseTo(i, 9)); + }); + + it('transmittance to space agrees with sun.js at the zenith and falls toward the horizon', () => { + const top = VIEW_SAMPLES - 1; + [0, 1, 2].forEach((c) => + expect(Math.abs(decode(trans[top * 3 + c]) / Math.exp(-TAU[c]) - 1)).toBeLessThan(0.01) + ); + expect(transmittance(90).map((v, c) => v / Math.exp(-TAU[c]))).toSatisfy((r: number[]) => + r.every((x) => Math.abs(x - 1) < 0.01) + ); + // blue goes first: lower view, lower blue, and redder light at the horizon + let prev = Infinity; + for (let i = top; i >= 8; i -= 1) { + const blue = decode(trans[i * 3 + 2]); + expect(blue).toBeLessThanOrEqual(prev); + prev = blue; + } + expect(decode(trans[8 * 3]) / decode(trans[8 * 3 + 2])).toBeGreaterThan(10); // ~2 degrees up: sunset red + expect(decode(trans[0])).toBe(0); // -4 degrees looks at the ground + }); + + it('colour the daytime sky blue, and a low sun redder toward it than away from it', () => { + const noon = texel(sunIndex_(45), VIEW_SAMPLES - 1, 0); + expect(noon[2]).toBeGreaterThan(noon[1]); + expect(noon[1]).toBeGreaterThan(noon[0]); + const horizon = Math.round(viewIndex(5)); + const toward = texel(sunIndex_(3), horizon, 0); + const away = texel(sunIndex_(3), horizon, AZIMUTH_SAMPLES - 1); + expect(toward[0] / toward[2]).toBeGreaterThan(5); + expect(toward[0] / toward[2]).toBeGreaterThan(2 * (away[0] / away[2])); + expect(toward[0]).toBeGreaterThan(5 * away[0]); // the glow is around the sun + }); + + it('fade through twilight to black below the planet-shadow limit', () => { + const mid = Math.round(viewIndex(30)); + const lum = (s: number) => texel(sunIndex_(s), mid, 3).reduce((x, y) => x + y, 0); + expect(lum(8)).toBeGreaterThan(lum(3)); + expect(lum(3)).toBeGreaterThan(lum(-2)); + expect(lum(-2)).toBeGreaterThan(lum(-5)); + expect(lum(-5)).toBeGreaterThan(0); + expect(lum(-12)).toBe(0); + expect(lum(-18)).toBe(0); + }); + + it('hold the model to half-float precision, with no quantised steps along the horizon glow', () => { + // Every table texel off a handful of sampled ones is the model's own value to < 0.2%. + [ + [sunIndex_(3), 6, 0], + [sunIndex_(3), 9, 2], + [sunIndex_(0), 8, 0], + [sunIndex_(45), 20, 3], + [sunIndex_(-2), 12, 1], + ].forEach(([s, v, a]) => { + const direct = skyRadiance(SUN_ELEVATIONS_DEG[s], viewElevation(v), a * AZIMUTH_STEP_DEG); + texel(s, v, a).forEach((got, c) => expect(Math.abs(got / direct[c] - 1)).toBeLessThan(2e-3)); + }); + // Moving up from the horizon toward the sun, red drops smoothly: distinct, strictly decreasing. + const s = sunIndex_(3); + const reds = Array.from({ length: 8 }, (_, k) => texel(s, 7 + k, 0)[0]); + reds.slice(1).forEach((r, k) => expect(r).toBeLessThan(reds[k])); + expect(new Set(reds).size).toBe(reds.length); + }); + + it('are the same bytes as when pinned', () => { + const sum = (a: Uint16Array) => a.reduce((x, y) => (x * 31 + y) % 2147483647, 7); + expect([sum(sky), sum(trans)]).toEqual([SKY_CHECKSUM, TRANS_CHECKSUM]); + }); +}); diff --git a/src/sky/sun.js b/src/sky/sun.js index 55cad1ac..3173eb4d 100644 --- a/src/sky/sun.js +++ b/src/sky/sun.js @@ -57,7 +57,7 @@ export const sunElevation = (time, lat, lon) => sunPosition(time, lat, lon).elev // Optical depth of the clear atmosphere at the zenith, per channel (R, G, B). // Rayleigh at 650/550/450 nm plus a little ozone and haze (EYEBALLED to keep // low sun orange rather than brown). -const TAU = [0.06, 0.12, 0.26]; +export const TAU = [0.06, 0.12, 0.26]; // Relative air mass, Kasten & Young 1989. Elevation in degrees, >= 0. const airMass = (el) => 1 / (Math.sin(el * RAD) + 0.50572 * Math.pow(el + 6.07995, -1.6364)); From d2c2c279e2a2a1f06b9103e9ce67747f580c88af Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 21:00:56 +0000 Subject: [PATCH 013/111] scene: brighter, slightly larger terrain stars so they read on a phone Terrain overlay draws the ridge field at 2.4x alpha (capped at 1) and 1.3x radius; the dark-sky gate is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/terrainStars.test.ts | 16 +++++++++++++++- src/app/features/scene/terrain/terrainStars.ts | 8 ++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/app/features/scene/terrain/terrainStars.test.ts b/src/app/features/scene/terrain/terrainStars.test.ts index 40020186..370b6843 100644 --- a/src/app/features/scene/terrain/terrainStars.test.ts +++ b/src/app/features/scene/terrain/terrainStars.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SKY } from './skyData'; import { + BRIGHTEN, createTerrainStars, elevationOf, ELEVATION_TOP, @@ -12,7 +13,7 @@ import { skyColoursOf, } from './terrainStars'; import { CAMERA, view } from './terrainWorld'; -import { isNight } from '../ridge/stars'; +import { isNight, makeStars } from '../ridge/stars'; import { skyNameFor } from './terrainSky'; /** A 2D context that only counts: arcs painted and clears since the last reset. */ @@ -168,6 +169,19 @@ describe('turning with the camera', () => { }); }); +describe('brightness', () => { + it('draws every star at BRIGHTEN times its ridge-field alpha, capped at 1', () => { + createTerrainStars(glCanvas(), SKY.midnight)?.draw(0); + const want = makeStars(skyColoursOf(SKY.midnight), 390, 844).map((st) => + Math.min(1, st.alpha * BRIGHTEN) + ); + ctx2d.c.alphas.forEach((a) => expect(want.some((w) => Math.abs(w - a) < 1e-9)).toBe(true)); + // and it is brighter than the ridge field's own top alpha + const top = Math.max(...makeStars(skyColoursOf(SKY.midnight), 390, 844).map((st) => st.alpha)); + expect(Math.max(...ctx2d.c.alphas)).toBeGreaterThan(top * 1.5); + }); +}); + describe('motion', () => { it('with no clock every star is at base and a repeat draw repaints; no twinkle', () => { const s = createTerrainStars(glCanvas(), SKY.midnight)!; diff --git a/src/app/features/scene/terrain/terrainStars.ts b/src/app/features/scene/terrain/terrainStars.ts index e568418f..4fca72c9 100644 --- a/src/app/features/scene/terrain/terrainStars.ts +++ b/src/app/features/scene/terrain/terrainStars.ts @@ -72,6 +72,10 @@ export const elevationOf = (y: number): number => ELEVATION_TOP * (1 - y / SKY_S const wrap = (a: number): number => a - PERIOD * Math.round(a / PERIOD); +/** The terrain overlay draws the ridge field's stars brighter and a little larger: a phone's small, hazy sky needs it to read. */ +export const BRIGHTEN = 2.4; +export const GROW = 1.3; + export interface TerrainStars { /** Paint for the camera `turns` into its turn (0..1). `seconds` is the clock while the terrain moves; absent: every star at base. */ draw(turns: number, seconds?: number): void; @@ -126,13 +130,13 @@ export function createTerrainStars(canvas: HTMLCanvasElement, sky: SkyData): Ter lit.forEach(({ star, az, e }, i) => { const da = wrap(az - yaw); const gain = seconds === undefined ? 1 : gains.get(i) ?? 1; - ctx.globalAlpha = Math.min(1, star.alpha * gain); + ctx.globalAlpha = Math.min(1, star.alpha * gain * BRIGHTEN); ctx.fillStyle = star.fill; [da, da > 0 ? da - PERIOD : da + PERIOD].forEach((d) => { const at = project(d, e, w, h); if (!at || at.y < 0 || at.y > el.height / scale) return; ctx.beginPath(); - ctx.arc(at.x * scale, at.y * scale, star.r * scale, 0, 2 * Math.PI); + ctx.arc(at.x * scale, at.y * scale, star.r * GROW * scale, 0, 2 * Math.PI); ctx.fill(); }); }); From c2858452e7a3f44b0d6f400ddd5e896011b6da96 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 23:40:43 +0000 Subject: [PATCH 014/111] scene: terrain sky and haze from Void's baked scattering table Drops the vendored terrain/skyData.ts and the computed azimuth. terrainSky.ts reads src/sky/baked.ts: sun {elevation_deg, azimuth_deg, color_linear}, ambient_linear, horizon_hex. terrainScatter.ts decodes baked.scatter and uploads sky_view as an RGB16F 3D texture; SKY_FRAG and GRID_FRAG sample it (README sampler), the sky for the view ray, the haze for the horizon in the fragment's azimuth. - Sky: 1 - exp(-exposure * radiance) + night glow (midnight horizon colour, off as the sun nears the horizon) + sun disc; +-0.5/255 dither on the 8-bit output. - Stars fade by sun elevation (starGain: 1 below -12, 0 above -6) instead of a dark-sky gate; the field is placed against the midnight gradient. - ?sun=&az= (or the sun option) lights between the two bakes for dusk (skyAtSun). - Changed numbers, noted in scene/README.md: haze colour is the scattered sky (was the gradient's horizon stop), ambient is ambient_linear as baked (was gradient zenith x 0.25 x night lift up to 4), HAZE_K unchanged at 0.0004. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 25 +++ .../features/scene/terrain/Terrain.test.ts | 59 ++++-- src/app/features/scene/terrain/Terrain.ts | 82 ++++++-- src/app/features/scene/terrain/skyData.ts | 44 ---- .../scene/terrain/terrainAlbedo.test.ts | 6 +- .../features/scene/terrain/terrainAlbedo.ts | 7 +- .../scene/terrain/terrainScatter.test.ts | 88 ++++++++ .../features/scene/terrain/terrainScatter.ts | 94 +++++++++ .../features/scene/terrain/terrainShaders.ts | 46 +++-- .../features/scene/terrain/terrainSky.test.ts | 190 ++++++++++++------ src/app/features/scene/terrain/terrainSky.ts | 139 +++++++++---- .../scene/terrain/terrainStars.test.ts | 42 +++- .../features/scene/terrain/terrainStars.ts | 34 +++- 13 files changed, 644 insertions(+), 212 deletions(-) delete mode 100644 src/app/features/scene/terrain/skyData.ts create mode 100644 src/app/features/scene/terrain/terrainScatter.test.ts create mode 100644 src/app/features/scene/terrain/terrainScatter.ts diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 473a92a1..5bf2ef59 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -38,6 +38,31 @@ Integration (a canvas under the app, `startBackgroundScene`, `login()` when the are later cuts. This PR is the render core plus the contract the host needs: flag, tier, failure callbacks and the creature sizing rule. Nothing has drawn a creature. +## Terrain scene (`style: 'terrain'`): the baked sky + +`terrain/` draws a Phacelle-displaced grid under a sky read from Void's bake (`src/sky/baked.ts`, layout in +`src/sky/README.md`); nothing is vendored. `terrainSky.ts` reads each moment's `sun.{elevation_deg, azimuth_deg, +color_linear}`, `ambient_linear` and `horizon_hex`; `terrainScatter.ts` decodes `baked.scatter` and uploads `sky_view` as an +RGB16F 3D texture (the transmittance table stays on the CPU, for the sun's colour at an elevation the bake does not hold). +Both fragment shaders sample it: `SKY_FRAG` for the sky, `GRID_FRAG` for the haze, which is the sky's colour at the horizon in the +fragment's azimuth, so far ground fades into the sky behind it at dusk too. + +Page URL: `?sky=midnight|noonday` picks a bake; `?sun=&az=` puts the sun anywhere between them +(`skyAtSun`: baked transmittance for the sun's colour, ambient and horizon blended from the midnight bake to the noonday one as +the sun climbs from -8 to 25 degrees). `?sun=` wins over `?sky=`. + +- Sky: `1 - exp(-SKY_EXPOSURE * radiance)` plus a night glow (the midnight horizon colour, `nightness` 1 below -4 degrees, 0 above 4), a + sun disc from the sun's colour, and a +-0.5/255 dither on the 8-bit output. The table is single scattering only (no ground bounce, + no multiple scattering), so the noon horizon is pale yellow-green and a sun just under the horizon colours the whole sky orange, + where the real one keeps a blue-violet zenith. Within `HORIZON_FLOOR` (1.5 degrees) of the horizon the sky takes one flat colour, + because the table dips toward the ground there and would draw a seam against the haze. +- Stars: faded by the sun's elevation, `starGain` 1 below -12 degrees (where the table goes dark), 0 above -6, smooth between; + the field is placed against the midnight gradient and no longer gated on the sky being dark. At a baked sky that is the same as + before (midnight shows, noonday does not). +- Changed numbers: haze colour (was the gradient's horizon stop; now the scattered sky), ambient (was the gradient's zenith stop + x `AMBIENT_GAIN` 0.25 x `NIGHT_LIFT` up to 4; now `ambient_linear` as baked, so midnight's fill is about 2.5 to 3.5 times darker than + the lifted one). `HAZE_K` (0.0004 per metre) is unchanged. + ## Entry points - `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts index 4578a58a..446ab9cf 100644 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTerrainScene } from './Terrain'; import { GRID, triangleCount } from './terrainWorld'; -import { SKY } from './skyData'; -import { ambientColour } from './terrainSky'; +import { SKY, skyAtSun } from './terrainSky'; +import { SKY_VIEW, skyViewWords } from './terrainScatter'; +import { SKY_FRAG } from './terrainShaders'; import { startBackgroundScene } from '../index'; /** A GL context that accepts every call, remembers them, reports success for compile, link and framebuffer. */ @@ -134,26 +135,58 @@ describe('createTerrainScene', () => { }); it('lights with the sky chosen: option, then ?sky=, then the night default', () => { - const ambientUploaded = (opts: { sky?: 'midnight' | 'noonday' }) => { + const uploaded = (opts: { sky?: 'midnight' | 'noonday' }) => { const { gl, calls } = fakeGl(); createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); - const hit = calls.uniform3fv.filter((a) => (a[1] as ArrayLike).length === 3); - // uniform3fv order in draw(): right, up, forward, then sun, sunColour, ambient, haze (+ camera) - return hit.map((a) => Array.from(a[1] as ArrayLike)); + // every vec3 uniform draw() sets: right, up, forward, sun, sunColour, glow, ambient, camera, soil... + return calls.uniform3fv + .filter((a) => (a[1] as ArrayLike).length === 3) + .map((a) => Array.from(a[1] as ArrayLike)); }; - const night = ambientUploaded({}); - const day = ambientUploaded({ sky: 'noonday' }); - const nightA = Array.from(ambientColour(SKY.midnight)); - const dayA = Array.from(ambientColour(SKY.noonday)); + const night = uploaded({}); + const day = uploaded({ sky: 'noonday' }); + const nightA = Array.from(SKY.midnight.ambient); + const dayA = Array.from(SKY.noonday.ambient); const has = (all: number[][], v: number[]) => all.some((a) => a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); expect(has(night, nightA)).toBe(true); expect(has(night, dayA)).toBe(false); expect(has(day, dayA)).toBe(true); search = '?sky=noonday'; - expect(has(ambientUploaded({}), dayA)).toBe(true); + expect(has(uploaded({}), dayA)).toBe(true); search = '?sky=garbage'; - expect(has(ambientUploaded({}), nightA)).toBe(true); + expect(has(uploaded({}), nightA)).toBe(true); + }); + + it('a sun elevation (option or ?sun=&az=) lights between the bakes, and wins over ?sky=', () => { + const ambients = (opts: Record) => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); + return calls.uniform3fv.map((a) => Array.from(a[1] as ArrayLike)); + }; + const dusk = skyAtSun(-1, 300); + const has = (all: number[][], v: readonly number[]) => + all.some((a) => a.length === 3 && a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); + expect(has(ambients({ sun: { elevation: -1, azimuth: 300 } }), dusk.ambient)).toBe(true); + search = '?sky=noonday&sun=-1&az=300'; + expect(has(ambients({}), dusk.ambient)).toBe(true); + search = '?sun=garbage'; + expect(has(ambients({}), SKY.midnight.ambient)).toBe(true); + }); + + it('uploads the baked scattering table once, as half floats, to a 3D texture', () => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); + expect(calls.texImage3D).toHaveLength(1); + const a = calls.texImage3D[0]; + expect([a[3], a[4], a[5]]).toEqual([SKY_VIEW.width, SKY_VIEW.height, SKY_VIEW.depth]); + expect(a[9]).toEqual(skyViewWords()); + expect(calls.deleteTexture).toHaveLength(2); + }); + + it('dithers the sky output by half an 8-bit step either way, and the terrain not at all', () => { + expect(SKY_FRAG).toContain('- 0.5'); + expect(SKY_FRAG).toContain('dither / 255.0'); }); it('falls back to a still frame when the measured p95 is over 33 ms', () => { @@ -208,7 +241,7 @@ describe('createTerrainScene', () => { createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); expect(calls.deleteProgram).toHaveLength(3); expect(calls.deleteBuffer).toHaveLength(2); - expect(calls.deleteTexture).toHaveLength(1); + expect(calls.deleteTexture).toHaveLength(2); expect(rafQueue).toHaveLength(0); }); }); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 27718624..90d90ca2 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -1,17 +1,18 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from '../ridge/probe'; import { skyColours } from '../ridge/skyColour'; -import { SkyName } from './skyData'; import { albedoPalette } from './terrainAlbedo'; +import { sunIndex } from '../../../../sky/scatter.js'; +import { SKY_VIEW, skyViewWords } from './terrainScatter'; import { - ambientColour, - hazeColour, + glowColour, HAZE_K, - hexToLinear, isSkyName, + skyAtSun, + SkyData, skyData, + SkyName, skyNameFor, - sunColour, sunDirection, } from './terrainSky'; import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; @@ -30,6 +31,8 @@ export type TerrainOptions = Pick< > & { /** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else the night notion of `skyColours()`. */ sky?: SkyName; + /** Put the sun at this elevation (degrees) and azimuth (clockwise from north; default: noonday's) instead of at the baked sky's: `?sun=` and `?az=` in the page URL. Lights between the two bakes, for dusk. */ + sun?: { elevation: number; azimuth?: number }; /** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */ seed?: number; /** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`. */ @@ -74,18 +77,25 @@ function program(gl: WebGL2RenderingContext, name: string, vs: string, fs: strin return p; } -/** The sky to use: the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ -function chooseSky(opt: SkyName | undefined): SkyName { - if (opt) return opt; - const q = new URLSearchParams(window.location.search).get('sky'); - return isSkyName(q) ? q : skyNameFor(skyColours()); +/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ +function chooseSky(opts: Pick): SkyData { + const q = new URLSearchParams(window.location.search); + const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); + const elevation = opts.sun?.elevation ?? number('sun'); + if (Number.isFinite(elevation)) { + const azimuth = opts.sun?.azimuth ?? number('az'); + return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); + } + if (opts.sky) return skyData(opts.sky); + const name = q.get('sky'); + return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); } /** * The terrain scene: same contract as `createRidgeScene`/`createScene` ({ login, dispose }, null for "keep the * CSS ground"). WebGL2 only. The height map is baked once on the GPU (Phacelle, see terrainShaders.ts) into an - * RGBA8 texture; each frame is two draws: the baked gradient sky, then a displaced grid lit by the baked sun and - * ambient and hazed toward the baked horizon colour. Cadence guard, pauses and the `?probe=1` readout match the + * RGBA8 texture; each frame is two draws: the sky from the baked scattering table, then a displaced grid lit by the + * baked sun and ambient and hazed toward that sky at the horizon. Cadence guard, pauses and the `?probe=1` readout match the * ridge scene. */ export function createTerrainScene(opts: TerrainOptions): Scene | null { @@ -101,7 +111,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const units = gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS); if (typeof units === 'number' && units < 1) return null; - const sky = skyData(chooseSky(opts.sky)); + const sky = chooseSky(opts); const spec = GRID[tier]; let skyProg: WebGLProgram; @@ -123,6 +133,29 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, spec.tex, spec.tex, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + // The baked scattering table, RGB16F, uploaded as it is stored (half floats); the shader divides by the store scale. + const skyTex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage3D( + gl.TEXTURE_3D, + 0, + gl.RGB16F, + SKY_VIEW.width, + SKY_VIEW.height, + SKY_VIEW.depth, + 0, + gl.RGB, + gl.HALF_FLOAT, + skyViewWords() + ); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => + gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) + ); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4); + gl.bindTexture(gl.TEXTURE_2D, heightTex); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, heightTex, 0); const complete = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE; @@ -130,6 +163,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const release = () => { free.forEach((f) => f()); gl.deleteTexture(heightTex); + gl.deleteTexture(skyTex); gl.deleteFramebuffer(fbo); gl.deleteProgram(bakeProg); gl.deleteProgram(skyProg); @@ -167,11 +201,11 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { () => gl.deleteBuffer(ibuf) ); - const gradient = new Float32Array(sky.gradient.flatMap((h) => [...hexToLinear(h)])); const sun = [...sunDirection(sky)]; - const sunLight = [...sunColour(sky)]; - const ambient = [...ambientColour(sky)]; - const haze = [...hazeColour(sky)]; + const sunLight = [...sky.direct]; + const ambient = [...sky.ambient]; + const glow = [...glowColour(sky)]; + const sunTexel = sunIndex(sky.elevation); const palette = albedoPalette(sky); const triangles = triangleCount(spec.grid); @@ -201,7 +235,13 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.disable(gl.DEPTH_TEST); gl.useProgram(skyProg); const us = (n: string) => gl.getUniformLocation(skyProg, n); - gl.uniform3fv(us('uGrad'), gradient); + gl.uniform3fv(us('uSun'), sun); + gl.uniform3fv(us('uSunColour'), sunLight); + gl.uniform3fv(us('uGlow'), glow); + gl.uniform1f(us('uSunIndex'), sunTexel); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.uniform1i(us('uSky'), 1); gl.uniform3fv(us('uRight'), v.right); gl.uniform3fv(us('uUp'), v.up); gl.uniform3fv(us('uForward'), v.forward); @@ -220,7 +260,11 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.uniform3fv(u('uSun'), sun); gl.uniform3fv(u('uSunColour'), sunLight); gl.uniform3fv(u('uAmbient'), ambient); - gl.uniform3fv(u('uHaze'), haze); + gl.uniform3fv(u('uGlow'), glow); + gl.uniform1f(u('uSunIndex'), sunTexel); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.uniform1i(u('uSky'), 1); gl.uniform1f(u('uHazeK'), HAZE_K); gl.uniform3fv(u('uSoil'), palette.soil); gl.uniform3fv(u('uScree'), palette.scree); diff --git a/src/app/features/scene/terrain/skyData.ts b/src/app/features/scene/terrain/skyData.ts deleted file mode 100644 index 2b75f405..00000000 --- a/src/app/features/scene/terrain/skyData.ts +++ /dev/null @@ -1,44 +0,0 @@ -// The sky numbers the terrain scene lights and hazes with, vendored from Void's baked sky (GameCult/Void -// PR #22, branch void/sky-tokens, revision 7b4aa2f): `src/sky/baked.ts` (generated by scripts/bake-sky.mjs -// from src/sky/sun.js and src/sky/skyTokens.js) and `light()` in `src/sky/sun.js`. Both moments are -// 2026-09-22 at lat 45, lon 0. -// -// elevation, gradient copied from baked.ts (`baked.midnight` / `baked.noonday`). -// direct `light({time, lat, lon}).direct` from sun.js at that moment: sun colour at normal -// incidence, linear RGB, clear zenith sun ~ 1. baked.ts does not carry it. -// azimuth NOT in #22. Computed here from the same NOAA/Meeus series sun.js uses for the -// elevation (hour angle and declination), degrees clockwise from north. It is the -// one number in this file Void's bake does not produce; see the report on the table shape. -// -// These are copies, not a link: #22 is not in this tree, so nothing fails if Void re-bakes. When #22 lands, -// replace this module's literals with an import of `baked` (plus the fields above, if the bake adds them) and -// keep the exports. - -export type SkyName = 'midnight' | 'noonday'; -export type Vec3 = readonly [number, number, number]; - -export interface SkyData { - /** Degrees above the horizon, no refraction. Negative: the sun is down. */ - elevation: number; - /** Degrees clockwise from north. */ - azimuth: number; - /** Sun colour at normal incidence, linear RGB. Black when the sun is down. */ - direct: Vec3; - /** Seven sRGB hex stops, zenith first, horizon last. */ - gradient: readonly string[]; -} - -export const SKY: Record = { - midnight: { - elevation: -44.579021600011224, - azimuth: 2.4988773054640205, - direct: [0, 0, 0], - gradient: ['#37446a', '#38456a', '#3b4669', '#3d4868', '#414a67', '#444c66', '#484e64'], - }, - noonday: { - elevation: 45.16994813502928, - azimuth: 182.58737070473097, - direct: [0.9189659216820631, 0.8444983652129638, 0.6933684802959983], - gradient: ['#95c6ff', '#9ac9ff', '#a5cfff', '#b3d6ff', '#c2dfff', '#d2e9ff', '#e2f4ff'], - }, -}; diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 331b820a..0136b5f3 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -1,6 +1,6 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, heights and slopes */ import { describe, expect, it } from 'vitest'; -import { SKY } from './skyData'; +import { SKY } from './terrainSky'; import { ALBEDO, albedoAt, albedoPalette, wobble } from './terrainAlbedo'; import { GRID_FRAG } from './terrainShaders'; @@ -98,7 +98,7 @@ describe('shader agrees with the pure function on its numbers', () => { expect(GRID_FRAG).toContain(`#define ALB_SNOW_BAND ${ALBEDO.snowBand.toFixed(1)}`); expect(GRID_FRAG).toContain(`#define ALB_ROCK_LO ${ALBEDO.rockLo}`); expect(GRID_FRAG).toContain(`#define ALB_WOBBLE_S ${ALBEDO.wobbleSlope}`); - // the only fetch in the fragment shader is the height map's - expect(GRID_FRAG.match(/texelFetch|texture\(/g)).toHaveLength(1); + // the only fetch for colour-by-material would be a texture; the height map's texelFetch is the only other read, and the sky lookup is the baked scattering table's + expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky)/g)).toHaveLength(1); }); }); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index 703f9f0c..e3230a8b 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -1,10 +1,9 @@ // Terrain surface colour as a pure function of height, slope and world position, no texture reads: the shader // (terrainShaders.ts GRID_FRAG) does the same arithmetic with these numbers as uniforms and #defines. No DOM, no GL. // Rock where the face is steep, then by height soil, scree and snow; snow only settles on gentle faces. The per-sky -// part reads the baked sky (./skyData.ts): the snow line drops and every material is tinted toward the sky's colour +// part reads the baked sky (./terrainSky.ts): the snow line drops and every material is tinted toward the sky's colour // as the sun goes down, warm by the sun's own colour by day and cool by the horizon haze at night. -import { SkyData, Vec3 } from './skyData'; -import { coldness, hazeColour, sunColour } from './terrainSky'; +import { coldness, hazeColour, SkyData, Vec3 } from './terrainSky'; const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); const smooth = (a: number, b: number, x: number) => { @@ -58,7 +57,7 @@ const unit = (c: Vec3): Vec3 => { /** The palette one baked sky gives: base reflectances tinted toward the sky's colour, and its snow line. */ export function albedoPalette(d: SkyData): AlbedoPalette { const cold = coldness(d); - const sun = unit(sunColour(d)); + const sun = unit(d.direct); const haze = unit(hazeColour(d)); const tint = [0, 1, 2].map((i) => mix(sun[i], haze[i], cold)); const k = mix(0.15, 0.5, cold); diff --git a/src/app/features/scene/terrain/terrainScatter.test.ts b/src/app/features/scene/terrain/terrainScatter.test.ts new file mode 100644 index 00000000..782d980e --- /dev/null +++ b/src/app/features/scene/terrain/terrainScatter.test.ts @@ -0,0 +1,88 @@ +/* eslint-disable no-restricted-syntax -- tests walk table texels */ +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../../sky/baked'; +import { decode, viewElevation } from '../../../../sky/scatter.js'; +import { + SCATTER_GLSL, + scatterRadiance, + skyViewWords, + SKY_VIEW, + tableWords, + transmittanceAt, +} from './terrainScatter'; + +const S = baked.scatter.sky_view; + +describe('the table upload', () => { + it('decodes the baked base64 to one half-float word per channel per texel', () => { + const w = skyViewWords(); + expect(w.length).toBe(S.width * S.height * S.depth * S.channels); + expect(SKY_VIEW).toEqual({ width: S.width, height: S.height, depth: S.depth }); + expect(tableWords(baked.scatter.transmittance.data)).toHaveLength(32 * 3); + }); + + it('words are little-endian: the base64 bytes lo, hi make a word hi << 8 | lo', () => { + expect(Array.from(tableWords(btoa(String.fromCharCode(0x34, 0x12, 0xff, 0x7b))))).toEqual([ + 0x1234, 0x7bff, + ]); + }); +}); + +describe('scatterRadiance (the CPU twin of the sampler)', () => { + it('returns the stored texel exactly at a texel centre: layout x azimuth, y view, z sun', () => { + const w = skyViewWords(); + for (const [z, y, x] of [ + [10, 31, 0], + [8, 5, 3], + [5, 20, 6], + [11, 0, 2], + ]) { + const base = ((z * S.height + y) * S.width + x) * 3; + const got = scatterRadiance( + S.sun_elevations_deg[z], + viewElevation(y), + x * S.azimuth_step_deg + ); + got.forEach((v, c) => expect(v).toBeCloseTo(decode(w[base + c]), 9)); + } + }); + + it('interpolates between texels and clamps beyond the table', () => { + const a = scatterRadiance(25, 10, 0)[2]; + const b = scatterRadiance(45, 10, 0)[2]; + const mid = scatterRadiance(35, 10, 0)[2]; // 35 is halfway between 25 and 45 on the sun axis + expect(mid).toBeCloseTo((a + b) / 2, 9); + expect(scatterRadiance(500, 10, 400)).toEqual(scatterRadiance(90, 10, 180)); + expect(scatterRadiance(30, 10, -90)).toEqual(scatterRadiance(30, 10, 90)); + }); + + it('is black under the planet’s shadow limit and blue at a high noon zenith', () => { + scatterRadiance(-18, 30, 0).forEach((v) => expect(v).toBeLessThan(1e-6)); + const noon = scatterRadiance(45, 85, 90); + expect(noon[2]).toBeGreaterThan(1.5 * noon[0]); + }); +}); + +describe('transmittanceAt', () => { + it('is the zenith transmittance exp(-TAU) overhead and redder at the horizon', () => { + const z = transmittanceAt(90); + [0.06, 0.12, 0.26].forEach((tau, i) => expect(z[i]).toBeCloseTo(Math.exp(-tau), 2)); + const h = transmittanceAt(2); + expect(h[2] / h[0]).toBeLessThan(0.5 * (z[2] / z[0])); + }); + + it('is zero where the ground is in the way', () => { + expect(transmittanceAt(-4)).toEqual([0, 0, 0]); + }); +}); + +describe('the GLSL sampler', () => { + it('uses the layout the README documents: texel-centre coordinates over a 7 x 32 x 12 texture, decoded by the store scale', () => { + expect(SCATTER_GLSL).toContain('(x + 0.5) / 7.0'); + expect(SCATTER_GLSL).toContain('(y + 0.5) / 32.0'); + expect(SCATTER_GLSL).toContain('(uSunIndex + 0.5) / 12.0'); + expect(SCATTER_GLSL).toContain('/ 64.0'); + expect(SCATTER_GLSL).toContain('sqrt(clamp((el - (-4.0)) / 94.0, 0.0, 1.0)) * 31.0'); + expect(SCATTER_GLSL).toContain('* 6.0'); + }); +}); diff --git a/src/app/features/scene/terrain/terrainScatter.ts b/src/app/features/scene/terrain/terrainScatter.ts new file mode 100644 index 00000000..9149ac47 --- /dev/null +++ b/src/app/features/scene/terrain/terrainScatter.ts @@ -0,0 +1,94 @@ +// The baked scattering tables (src/sky/baked.ts `scatter`, layout in src/sky/README.md) as the terrain scene +// uses them: decoded to half-float words for the GPU, a CPU twin of the shader's sky lookup for tests and +// for the sun's colour at an arbitrary elevation, and the GLSL both shaders share. No DOM, no GL. +import { baked } from '../../../../sky/baked'; +import { decode, sunIndex, viewIndex } from '../../../../sky/scatter.js'; +import type { Vec3 } from './terrainSky'; + +const S = baked.scatter; +const SKY = S.sky_view; +const VIEW = S.view_elevation_deg; + +/** Raw little-endian half-float words of a base64 table, ready for `texImage3D(..., HALF_FLOAT, words)`. */ +export function tableWords(b64: string): Uint16Array { + const bytes = atob(b64); + return Uint16Array.from( + { length: bytes.length / 2 }, + (_, i) => bytes.charCodeAt(2 * i) + 256 * bytes.charCodeAt(2 * i + 1) + ); +} + +export const SKY_VIEW = { width: SKY.width, height: SKY.height, depth: SKY.depth } as const; +export const STORE_SCALE = S.store_scale; +export const skyViewWords = (): Uint16Array => tableWords(SKY.data); + +/** Decode a stored table to values, once. */ +const decoded = (data: string) => { + let values: Float32Array | undefined; + return (): Float32Array => { + if (!values) values = Float32Array.from(tableWords(data), decode); + return values; + }; +}; +const transmittanceTable = decoded(S.transmittance.data); +const skyTable = decoded(SKY.data); + +/** Sun colour at normal incidence for a sun at `elevationDeg`: the baked transmittance, per channel. Zero under the horizon. */ +export function transmittanceAt(elevationDeg: number): Vec3 { + const t = transmittanceTable(); + const f = Math.min(viewIndex(elevationDeg), VIEW.samples - 1); + const i = Math.min(Math.floor(f), VIEW.samples - 2); + const k = f - i; + return [0, 1, 2].map((c) => t[i * 3 + c] * (1 - k) + t[(i + 1) * 3 + c] * k) as unknown as Vec3; +} + +/** + * Sky radiance per steradian for a sun of unit irradiance, trilinear in (azimuth from the sun, view + * elevation, sun elevation) on texel centres, clamped at the edges: what the GPU's LINEAR sampler returns. + */ +export function scatterRadiance(sunElDeg: number, viewElDeg: number, azFromSunDeg: number): Vec3 { + const t = skyTable(); + const at = [ + Math.min(Math.abs(azFromSunDeg) / 180, 1) * (SKY.width - 1), + viewIndex(viewElDeg), + sunIndex(sunElDeg), + ]; + const lo = at.map((v, a) => Math.min(Math.floor(v), [SKY.width, SKY.height, SKY.depth][a] - 2)); + const f = at.map((v, a) => v - lo[a]); + const out = [0, 0, 0]; + for (let corner = 0; corner < 8; corner += 1) { + const dx = corner % 2; + const dy = Math.floor(corner / 2) % 2; + const dz = Math.floor(corner / 4); + const w = (dx ? f[0] : 1 - f[0]) * (dy ? f[1] : 1 - f[1]) * (dz ? f[2] : 1 - f[2]); + const base = (((lo[2] + dz) * SKY.height + lo[1] + dy) * SKY.width + lo[0] + dx) * 3; + for (let c = 0; c < 3; c += 1) out[c] += w * t[base + c]; + } + return [out[0], out[1], out[2]]; +} + +/** + * The lowest view elevation the sky is sampled at, degrees. The table dips toward the ground over its last degree + * (its rows below the horizon are for an observer who would hit the ground), so at and below this the sky and the + * terrain's haze both take the colour here, flat to the horizon, and meet without a seam. + */ +export const HORIZON_FLOOR = 1.5; + +/** The sky shader's lookup, in GLSL: `skyScatter(viewDirection)` is unit-sun radiance, needs uSky, uSunIndex, uSun. */ +export const SCATTER_GLSL = ` +uniform highp sampler3D uSky; +uniform float uSunIndex; +uniform vec3 uSun; +vec3 skyScatter(vec3 d) { + float el = max(degrees(asin(clamp(d.y, -1.0, 1.0))), ${HORIZON_FLOOR.toFixed(1)}); + vec2 h = d.xz, s = uSun.xz; + float az = (length(h) < 1e-4 || length(s) < 1e-4) ? 0.0 + : degrees(acos(clamp(dot(normalize(h), normalize(s)), -1.0, 1.0))); + float y = sqrt(clamp((el - (${VIEW.min.toFixed(1)})) / ${(VIEW.max - VIEW.min).toFixed( + 1 +)}, 0.0, 1.0)) * ${(VIEW.samples - 1).toFixed(1)}; + float x = clamp(az / 180.0, 0.0, 1.0) * ${(SKY.width - 1).toFixed(1)}; + return texture(uSky, vec3((x + 0.5) / ${SKY.width}.0, (y + 0.5) / ${ + SKY.height +}.0, (uSunIndex + 0.5) / ${SKY.depth}.0)).rgb / ${S.store_scale.toFixed(1)}; +}`; diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 0e1783e7..8418e91a 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -8,6 +8,8 @@ import cultmath from '../shaders/CultMath.glsl?raw'; import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; import { ALBEDO } from './terrainAlbedo'; +import { SCATTER_GLSL } from './terrainScatter'; +import { GLOW_ZENITH, SKY_EXPOSURE } from './terrainSky'; import { TERRAIN } from './terrainWorld'; const HEADER = @@ -107,7 +109,8 @@ void main() { gl_Position = uViewProj * vec4(v_world, 1.0); }`; -// Lit by the baked sky: uSun (direction), uSunColour (linear, zero when the sun is down), uAmbient, uHaze (linear). +// Lit by the baked sky: uSun (direction), uSunColour (linear, zero when the sun is down), uAmbient; hazed toward the +// scattered sky at the horizon in the ray's azimuth (SKY_COLOUR_GLSL). // The same arithmetic as terrainSky.shade(); the normal comes from the height map so shading carries detail finer than the mesh. const ALBEDO_DEFINES = ` #define ALB_SCREE_FROM ${lit(ALBEDO.screeFrom)} @@ -120,10 +123,20 @@ const ALBEDO_DEFINES = ` #define ALB_WOBBLE_H ${lit(ALBEDO.wobbleHeight)} #define ALB_WOBBLE_S ${lit(ALBEDO.wobbleSlope)} `; -export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES} +// The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow. The +// same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already scaled by how dark the sun is. +const SKY_COLOUR_GLSL = `${SCATTER_GLSL} +uniform vec3 uGlow; +vec3 skyColour(vec3 d) { + return 1.0 - exp(-${lit(SKY_EXPOSURE)} * skyScatter(d)) + uGlow * mix(1.0, ${lit( + GLOW_ZENITH +)}, sqrt(clamp(d.y, 0.0, 1.0))); +}`; + +export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL} in vec2 v_uv; in vec3 v_world; -uniform vec3 uCamera, uSun, uSunColour, uAmbient, uHaze, uSoil, uScree, uRock, uSnow; +uniform vec3 uCamera, uSunColour, uAmbient, uSoil, uScree, uRock, uSnow; uniform float uSpan, uHazeK, uSnowLine; out vec4 o; ${FETCH} @@ -151,13 +164,21 @@ void main() { float rockW = 1.0 - smoothstep(ALB_ROCK_LO, ALB_ROCK_HI, flat_); vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); vec3 lit = albedo * (uSunColour * max(dot(n, uSun), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); - float T = exp(-uHazeK * length(v_world - uCamera)); - o = vec4(pow(mix(uHaze, lit, T), vec3(1.0 / 2.2)), 1.0); + vec3 toSurface = v_world - uCamera; + float T = exp(-uHazeK * length(toSurface)); + vec3 haze = skyColour(normalize(vec3(toSurface.x, 0.0, toSurface.z))); + o = vec4(pow(mix(haze, lit, T), vec3(1.0 / 2.2)), 1.0); }`; -// The sky behind: the gradient's seven stops, zenith first, by the view ray's elevation (horizon stop at and below the horizon). -export const SKY_FRAG = `${HEADER} -uniform vec3 uGrad[7]; +// The sky behind: the baked scattering tables by the view ray's elevation and azimuth from the sun, a sun disc, and a +// dither of +-0.5/255 on the 8-bit output so the dusk glow does not band. +const DISC = { + inner: Math.cos((0.5 * Math.PI) / 180), + outer: Math.cos((1.1 * Math.PI) / 180), + gain: 6.0, +}; +export const SKY_FRAG = `${HEADER}${SKY_COLOUR_GLSL} +uniform vec3 uSunColour; uniform vec3 uRight, uUp, uForward; uniform vec2 uSize; uniform float uTanHalf; @@ -165,8 +186,9 @@ out vec4 o; void main() { vec2 ndc = gl_FragCoord.xy / uSize * 2.0 - 1.0; vec3 d = normalize(uForward + ndc.x * uTanHalf * uSize.x / uSize.y * uRight + ndc.y * uTanHalf * uUp); - float s = (1.0 - sqrt(smoothstep(-0.15, 1.0, d.y))) * 6.0; - int i = int(min(floor(s), 5.0)); - vec3 c = mix(uGrad[i], uGrad[i + 1], s - float(i)); - o = vec4(pow(c, vec3(1.0 / 2.2)), 1.0); + vec3 c = skyColour(d) + uSunColour * ${lit(DISC.gain)} * smoothstep(${DISC.outer}, ${ + DISC.inner +}, dot(d, uSun)); + float dither = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))) - 0.5; + o = vec4(pow(c, vec3(1.0 / 2.2)) + dither / 255.0, 1.0); }`; diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index b3b4d21e..d8cb0129 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -1,29 +1,47 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ import { describe, expect, it } from 'vitest'; -import { SKY } from './skyData'; +import { baked } from '../../../../sky/baked'; import { - AMBIENT_GAIN, - NIGHT_LIFT, - ambientColour, + coldness, + glowColour, hazeColour, hexToLinear, + nightness, shade, + SKY, + skyAtSun, + skyColour, skyNameFor, - sunColour, sunDirection, transmittance, + Vec3, } from './terrainSky'; import { skyColours } from '../ridge/skyColour'; const UP = [0, 1, 0] as const; const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +/** Unit view direction at an elevation (degrees) and an azimuth clockwise from north. */ +const look = (el: number, az: number): Vec3 => { + const e = (el * Math.PI) / 180; + const a = (az * Math.PI) / 180; + return [Math.sin(a) * Math.cos(e), Math.sin(e), -Math.cos(a) * Math.cos(e)]; +}; -describe('baked sky numbers', () => { - it('keep the two moments apart: night sun below the horizon, noon sun well above', () => { +describe('the baked sky is read, not copied', () => { + it('SKY carries Void’s numbers: sun, ambient, horizon', () => { + for (const name of ['midnight', 'noonday'] as const) { + const b = baked[name]; + expect(SKY[name].elevation).toBe(b.sun.elevation_deg); + expect(SKY[name].azimuth).toBe(b.sun.azimuth_deg); + expect(SKY[name].direct).toEqual(b.sun.color_linear); + expect(SKY[name].ambient).toEqual(b.ambient_linear); + expect(SKY[name].horizon).toEqual(hexToLinear(b.horizon_hex)); + } + }); + + it('keeps the two moments apart: night sun below the horizon, noon sun well above', () => { expect(SKY.midnight.elevation).toBeLessThan(0); expect(SKY.noonday.elevation).toBeGreaterThan(30); - expect(SKY.midnight.gradient).toHaveLength(7); - expect(SKY.noonday.gradient).toHaveLength(7); }); it('sunDirection is a unit vector with y = sin(elevation), north at -z, south at +z', () => { @@ -48,67 +66,118 @@ describe('baked sky numbers', () => { }); }); -describe('night fill', () => { - it('lifts ambient at night by NIGHT_LIFT and leaves a high sun at the plain gain', () => { - const z = (d: typeof SKY.midnight) => hexToLinear(d.gradient[0]); - ambientColour(SKY.midnight).forEach((v, i) => expect(v).toBeCloseTo(z(SKY.midnight)[i] * AMBIENT_GAIN * NIGHT_LIFT, 9)); - ambientColour(SKY.noonday).forEach((v, i) => expect(v).toBeCloseTo(z(SKY.noonday)[i] * AMBIENT_GAIN, 9)); +describe('a sun at any elevation (skyAtSun)', () => { + it('lights nothing under the horizon and fades in across the last degrees, as sun.js does', () => { + expect(skyAtSun(-2).direct).toEqual([0, 0, 0]); + expect(skyAtSun(-30).direct).toEqual([0, 0, 0]); + const at = (e: number) => luma(skyAtSun(e).direct); + expect(at(0.3)).toBeGreaterThan(0); + expect(at(0.3)).toBeLessThan(at(1.5)); + expect(at(1.5)).toBeLessThan(at(60)); + }); + + it('agrees with the baked noon sun colour where they overlap, to a couple of percent', () => { + const want = SKY.noonday.direct; + const got = skyAtSun(SKY.noonday.elevation).direct; + got.forEach((v, i) => expect(Math.abs(v / want[i] - 1)).toBeLessThan(0.02)); + }); + + it('the sun goes red as it sets: less blue left in the direct light at 2 degrees than at 45', () => { + const lo = skyAtSun(2).direct; + const hi = skyAtSun(45).direct; + expect(lo[2] / lo[0]).toBeLessThan(0.6 * (hi[2] / hi[0])); + }); + + it('ambient and horizon run from the midnight bake to the noonday one as the sun climbs', () => { + expect(luma(skyAtSun(-30).ambient)).toBeCloseTo(luma(SKY.midnight.ambient), 9); + expect(luma(skyAtSun(60).ambient)).toBeCloseTo(luma(SKY.noonday.ambient), 9); + expect(luma(skyAtSun(5).ambient)).toBeGreaterThan(luma(SKY.midnight.ambient)); + expect(luma(skyAtSun(5).ambient)).toBeLessThan(luma(SKY.noonday.ambient)); + expect(skyAtSun(10, 270).azimuth).toBe(270); }); }); describe('lighting', () => { it('noonday lights an upward face with the sun; midnight has none', () => { - expect(luma(sunColour(SKY.noonday))).toBeGreaterThan(0.5); - expect(sunColour(SKY.midnight)).toEqual([0, 0, 0]); - }); - - it('a sun below the horizon gives no negative or sun light on any normal, even with a lit colour', () => { - const forcedDown = { ...SKY.noonday, elevation: -30 }; - expect(sunColour(forcedDown)).toEqual([0, 0, 0]); - for (const n of [UP, [1, 0, 0], [0, -1, 0], [0, 0.6, -0.8]] as const) { - const c = shade(forcedDown, [1, 1, 1], n, 0); - for (const ch of c) expect(ch).toBeGreaterThanOrEqual(0); - } - }); - - it('the sun fades out through the horizon instead of switching', () => { - const at = (elevation: number) => luma(sunColour({ ...SKY.noonday, elevation })); - expect(at(-2)).toBe(0); - expect(at(0.3)).toBeGreaterThan(0); - expect(at(0.3)).toBeLessThan(at(1.5)); - expect(at(1.5)).toBeCloseTo(at(60), 9); + expect(luma(SKY.noonday.direct)).toBeGreaterThan(0.5); + expect(SKY.midnight.direct).toEqual([0, 0, 0]); }); it('shade never goes negative on any normal at either sky, at any distance', () => { - for (const d of Object.values(SKY)) + for (const d of [...Object.values(SKY), skyAtSun(-3), skyAtSun(1)]) for (const n of [UP, [0, -1, 0], [1, 0, 0], [-0.6, 0.0, 0.8]] as const) for (const dist of [0, 100, 5000]) - for (const ch of shade(d, [0.3, 0.3, 0.3], n, dist)) expect(ch).toBeGreaterThanOrEqual(0); + for (const ch of shade(d, [0.3, 0.3, 0.3], n, dist, look(-5, 40))) + expect(ch).toBeGreaterThanOrEqual(0); }); it('a face turned to the noon sun is brighter than one turned away; at midnight the two match to ambient', () => { const toSun = sunDirection(SKY.noonday); const away = [-toSun[0], 0, -toSun[2]] as const; - const lit = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], toSun, 0)); - const dark = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], away, 0)); + const view = look(-5, 0); + const lit = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], toSun, 0, view)); + const dark = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], away, 0, view)); expect(lit).toBeGreaterThan(3 * dark); - const m = (n: readonly [number, number, number]) => shade(SKY.midnight, [0.5, 0.5, 0.5], n, 0); + const m = (n: readonly [number, number, number]) => + shade(SKY.midnight, [0.5, 0.5, 0.5], n, 0, view); expect(luma(m(UP))).toBeGreaterThan(0); // moonlit fill, not black expect(luma(m(UP))).toBeLessThan(0.05); }); it('noon is much brighter than midnight, ambient included', () => { - expect(luma(ambientColour(SKY.noonday))).toBeGreaterThan(2 * luma(ambientColour(SKY.midnight))); - expect(luma(shade(SKY.noonday, [0.4, 0.4, 0.4], UP, 0))).toBeGreaterThan( - 10 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0)) + expect(luma(SKY.noonday.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); + const view = look(-5, 0); + expect(luma(shade(SKY.noonday, [0.4, 0.4, 0.4], UP, 0, view))).toBeGreaterThan( + 10 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0, view)) ); }); - it('noon ambient matches sun.js diffuse at that moment to within a few percent (the gain)', () => { - const a = ambientColour(SKY.noonday); - const diffuse = [0.07, 0.132, 0.27]; // light() at 2026-09-22T12:00Z, 45N 0E, revision 7b4aa2f - for (let i = 0; i < 3; i += 1) expect(a[i] / diffuse[i]).toBeGreaterThan(0.85); - for (let i = 0; i < 3; i += 1) expect(a[i] / diffuse[i]).toBeLessThan(1.2); + it('the cold tint (albedo) is 0 with the sun up and 1 once it is below -6', () => { + expect(coldness(SKY.noonday)).toBe(0); + expect(coldness(SKY.midnight)).toBe(1); + expect(hazeColour(SKY.noonday)).toEqual(SKY.noonday.horizon); + }); +}); + +describe('the scattered sky', () => { + it('noon is blue overhead: more blue than red, much brighter than the night glow', () => { + const zenith = skyColour(SKY.noonday, [0, 1, 0]); + expect(zenith[2]).toBeGreaterThan(1.5 * zenith[0]); + expect(luma(zenith)).toBeGreaterThan(5 * luma(skyColour(SKY.midnight, [0, 1, 0]))); + zenith.forEach((v) => expect(v).toBeLessThan(1)); + }); + + it('a sun at the horizon reddens the sky toward it and darkens it away', () => { + const dusk = skyAtSun(-1, 270); + const toward = skyColour(dusk, look(3, 270)); + const away = skyColour(dusk, look(3, 90)); + const blueShare = (c: Vec3) => c[2] / (c[0] + c[1] + c[2]); + expect(blueShare(toward)).toBeLessThan(blueShare(away)); + expect(luma(toward)).toBeGreaterThan(3 * luma(away)); + }); + + it('under a deep night sun the tables are black and only the glow remains, brightest at the horizon', () => { + const d = SKY.midnight; + const horizon = skyColour(d, look(0, 180)); + const zenith = skyColour(d, [0, 1, 0]); + horizon.forEach((v, i) => expect(v).toBeCloseTo(d.horizon[i], 6)); + expect(luma(zenith)).toBeLessThan(luma(horizon)); + expect(luma(zenith)).toBeGreaterThan(0.5 * luma(horizon)); + }); + + it('the night glow fades out as the sun reaches the horizon', () => { + expect(nightness(-10)).toBe(1); + expect(nightness(5)).toBe(0); + expect(glowColour(SKY.noonday)).toEqual([0, 0, 0]); + expect(glowColour(SKY.midnight)).toEqual(SKY.midnight.horizon); + expect(luma(glowColour(skyAtSun(-2)))).toBeLessThan(luma(glowColour(skyAtSun(-6)))); + }); + + it('is continuous in the sun’s elevation through dusk: no step between neighbours', () => { + const at = (e: number) => luma(skyColour(skyAtSun(e, 270), look(10, 200))); + for (let e = -20; e < 30; e += 0.5) { + expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.05); + } }); }); @@ -124,24 +193,33 @@ describe('haze', () => { } }); - it('far surfaces take the horizon colour of the baked gradient, at both skies', () => { - for (const d of Object.values(SKY)) { - const far = shade(d, [0.9, 0.2, 0.1], UP, 100000); - const h = hazeColour(d); - for (let i = 0; i < 3; i += 1) expect(far[i]).toBeCloseTo(h[i], 6); + it('far surfaces take the scattered sky at the horizon in their azimuth, at both skies', () => { + for (const d of [...Object.values(SKY), skyAtSun(2, 270)]) { + const far = shade(d, [0.9, 0.2, 0.1], UP, 100000, look(-3, 200)); + const sky = skyColour(d, look(0, 200)); + for (let i = 0; i < 3; i += 1) expect(far[i]).toBeCloseTo(sky[i], 6); } - expect(hazeColour(SKY.noonday)).toEqual(hexToLinear('#e2f4ff')); - expect(hazeColour(SKY.midnight)).toEqual(hexToLinear('#484e64')); + }); + + it('haze toward the dusk sun is warmer and brighter than haze away from it', () => { + const dusk = skyAtSun(-1, 270); + const toward = shade(dusk, [0, 0, 0], UP, 100000, look(-3, 270)); + const away = shade(dusk, [0, 0, 0], UP, 100000, look(-3, 90)); + const blueShare = (c: Vec3) => c[2] / (c[0] + c[1] + c[2]); + expect(luma(toward)).toBeGreaterThan(3 * luma(away)); + expect(blueShare(toward)).toBeLessThan(blueShare(away)); }); it('haze colour differs between the two skies: pale day horizon, dim night one', () => { - expect(luma(hazeColour(SKY.noonday))).toBeGreaterThan(5 * luma(hazeColour(SKY.midnight))); + const far = (d: typeof SKY.midnight) => luma(shade(d, [0, 0, 0], UP, 1e6, look(-3, 180))); + expect(far(SKY.noonday)).toBeGreaterThan(5 * far(SKY.midnight)); }); it('with distance a dark surface rises toward the haze and a bright one falls toward it', () => { const d = SKY.noonday; - const h = luma(hazeColour(d)); - const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x)); + const view = look(-4, 200); + const h = luma(skyColour(d, look(0, 200))); + const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x, view)); expect(dark(2000)).toBeGreaterThan(dark(200)); expect(dark(2000)).toBeLessThan(h); }); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index d0f41c40..a7e38a39 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -1,8 +1,25 @@ -// Lighting and haze for the terrain scene, as pure functions of one sky's baked numbers (./skyData.ts). -// No DOM, no GL. The fragment shader does the same arithmetic with these results as uniforms. +// Lighting, haze and sky for the terrain scene, as pure functions of one sky's baked numbers (src/sky/baked.ts, Void's +// bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms. +import { baked } from '../../../../sky/baked'; import { isNight } from '../ridge/stars'; import type { SkyColours } from '../ridge/skyColour'; -import { SKY, SkyData, SkyName, Vec3 } from './skyData'; +import { HORIZON_FLOOR, scatterRadiance, transmittanceAt } from './terrainScatter'; + +export type SkyName = 'midnight' | 'noonday'; +export type Vec3 = readonly [number, number, number]; + +export interface SkyData { + /** Degrees above the horizon, no refraction. Negative: the sun is down. */ + elevation: number; + /** Degrees clockwise from north. */ + azimuth: number; + /** Sun colour at normal incidence, linear RGB. Black when the sun is down. */ + direct: Vec3; + /** Sky ambient on an upward surface, linear RGB; at night a moonlit floor, not black. */ + ambient: Vec3; + /** The horizon, linear RGB: the baked `horizon_hex`. */ + horizon: Vec3; +} const RAD = Math.PI / 180; const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); @@ -10,6 +27,8 @@ const smooth = (a: number, b: number, x: number) => { const t = clamp01((x - a) / (b - a)); return t * t * (3 - 2 * t); }; +const mixVec = (a: Vec3, b: Vec3, t: number): Vec3 => + [0, 1, 2].map((i) => a[i] + (b[i] - a[i]) * t) as unknown as Vec3; /** Which baked sky a ridge-style `SkyColours` means: the night notion the stars use (`isNight`). */ export const skyNameFor = (sky: SkyColours): SkyName => (isNight(sky) ? 'midnight' : 'noonday'); @@ -25,6 +44,39 @@ export function hexToLinear(hex: string): Vec3 { return [ch(1), ch(3), ch(5)]; } +const fromBaked = (b: typeof baked.midnight | typeof baked.noonday): SkyData => ({ + elevation: b.sun.elevation_deg, + azimuth: b.sun.azimuth_deg, + direct: b.sun.color_linear, + ambient: b.ambient_linear, + horizon: hexToLinear(b.horizon_hex), +}); + +export const SKY: Record = { + midnight: fromBaked(baked.midnight), + noonday: fromBaked(baked.noonday), +}; + +export const skyData = (name: SkyName): SkyData => SKY[name]; + +/** + * A sky for a sun at any elevation, between the two baked ones. The sun's colour is the baked transmittance at + * that elevation, faded out across the disc's last degrees the way sun.js does (gone at -0.83, whole by 1.5); + * ambient and horizon blend from the midnight bake to the noonday one as the sun climbs from -8 to 25 degrees. + */ +export function skyAtSun(elevation: number, azimuth: number = SKY.noonday.azimuth): SkyData { + const day = smooth(-8, 25, elevation); + const fade = smooth(-0.83, 1.5, elevation); + const t = transmittanceAt(Math.max(elevation, -4)); + return { + elevation, + azimuth, + direct: [t[0] * fade, t[1] * fade, t[2] * fade], + ambient: mixVec(SKY.midnight.ambient, SKY.noonday.ambient, day), + horizon: mixVec(SKY.midnight.horizon, SKY.noonday.horizon, day), + }; +} + /** Unit vector toward the sun: x east, y up, z south (so azimuth 0, north, is -z). */ export function sunDirection(d: SkyData): Vec3 { const el = d.elevation * RAD; @@ -32,59 +84,68 @@ export function sunDirection(d: SkyData): Vec3 { return [Math.sin(az) * Math.cos(el), Math.sin(el), -Math.cos(az) * Math.cos(el)]; } -/** - * The sun's colour as the ground sees it: baked `direct`, off at the horizon the way sun.js fades the disc - * (gone at -0.83 degrees, soft to 1.5). A sun below the horizon lights nothing. - */ -export function sunColour(d: SkyData): Vec3 { - const k = smooth(-0.83, 1.5, d.elevation); - return [d.direct[0] * k, d.direct[1] * k, d.direct[2] * k]; -} - -/** - * Ambient light on an upward surface, from the baked gradient's zenith stop. The gain is chosen so the - * noonday value (0.075, 0.14, 0.25) matches sun.js's own `diffuse` there (0.07, 0.13, 0.27); at midnight - * the same gain turns the gradient's glow into a faint moonlit fill, where `diffuse` alone would be black. - */ -export const AMBIENT_GAIN = 0.25; - -/** 0 with the sun well up, 1 once it is below -6 degrees (civil twilight over): the night notion the palette and fill share. */ +/** 0 with the sun well up, 1 once it is below -6 degrees (civil twilight over): the night notion the palette shares. */ export const coldness = (d: SkyData): number => 1 - smooth(-6, 25, d.elevation); -/** Extra ambient at night, so a moonlit face is readable and not black on a phone: x4 at full night, 1 by day. */ -export const NIGHT_LIFT = 4; +/** The colour distance fades to when the air has nothing to scatter: the baked horizon. Albedo tints toward it at night. */ +export const hazeColour = (d: SkyData): Vec3 => d.horizon; -export function ambientColour(d: SkyData): Vec3 { - const z = hexToLinear(d.gradient[0]); - const k = AMBIENT_GAIN * (1 + (NIGHT_LIFT - 1) * coldness(d)); - return [z[0] * k, z[1] * k, z[2] * k]; +/** Sky radiance (see the sun's `skyScatter` in terrainScatter.ts) is for unit sun irradiance; this lifts it to the image. EYEBALLED. */ +export const SKY_EXPOSURE = 45; +/** How far the night glow dims from the horizon to the zenith (1 at the horizon). EYEBALLED. */ +export const GLOW_ZENITH = 0.75; + +/** 1 with the sun below -4 degrees, 0 above 4: where the midnight horizon colour stands in for light the single-scattering tables lack. */ +export const nightness = (elevation: number): number => 1 - smooth(-4, 4, elevation); + +/** The night glow colour: the midnight bake's horizon, fading out as the sun nears the horizon (whatever horizon the day blend gives `d`). */ +export const glowColour = (d: SkyData): Vec3 => { + const k = nightness(d.elevation); + const h = SKY.midnight.horizon; + return [h[0] * k, h[1] * k, h[2] * k]; +}; + +/** + * Linear colour of the sky along a unit view direction (x east, y up, z south): the baked single-scattering + * radiance for this sun, lifted by SKY_EXPOSURE and tone-mapped `1 - exp(-x)`, plus the night glow, which sits + * brightest at the horizon; at and below the horizon, the horizon's colour. The fragment shader does the same. + */ +export function skyColour(d: SkyData, dir: Vec3): Vec3 { + const el = Math.max(HORIZON_FLOOR, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD); + const sun = sunDirection(d); + const h = Math.hypot(dir[0], dir[2]); + const s = Math.hypot(sun[0], sun[2]); + const az = + h < 1e-4 || s < 1e-4 + ? 0 + : Math.acos(Math.min(1, Math.max(-1, (dir[0] * sun[0] + dir[2] * sun[2]) / (h * s)))) / RAD; + const r = scatterRadiance(d.elevation, el, az); + const glow = glowColour(d); + const shape = 1 + (GLOW_ZENITH - 1) * Math.sqrt(clamp01(dir[1])); + return [0, 1, 2].map( + (i) => 1 - Math.exp(-SKY_EXPOSURE * r[i]) + glow[i] * shape + ) as unknown as Vec3; } -/** The colour distance fades to: the gradient's horizon stop, linear. */ -export const hazeColour = (d: SkyData): Vec3 => hexToLinear(d.gradient[d.gradient.length - 1]); - /** Haze: transmittance T = exp(-k d) at distance d metres. */ export const HAZE_K = 0.0004; export const transmittance = (distance: number): number => Math.exp(-HAZE_K * Math.max(0, distance)); /** - * Linear colour of a surface: lambert from the sun plus ambient (full on an upward face, half on a - * vertical one, none on a downward one: `0.5 + 0.5 n.y`), then hazed toward the horizon colour by distance. - * `normal` is a unit vector. Never negative, whatever the sun does. + * Linear colour of a surface seen along the unit direction `view` (from the eye to the surface): lambert from + * the sun plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), + * then hazed by distance toward the sky at the horizon in that direction. `normal` is a unit vector. Never negative. */ -export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number): Vec3 { +export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, view: Vec3): Vec3 { const sun = sunDirection(d); const lambert = Math.max(0, normal[0] * sun[0] + normal[1] * sun[1] + normal[2] * sun[2]); - const direct = sunColour(d); - const amb = ambientColour(d); const hemi = 0.5 + 0.5 * normal[1]; - const haze = hazeColour(d); + const flat = Math.hypot(view[0], view[2]) || 1; + const haze = skyColour(d, [view[0] / flat, 0, view[2] / flat]); const t = transmittance(distance); return [0, 1, 2].map((i) => { - const lit = albedo[i] * (direct[i] * lambert + amb[i] * Math.max(0, hemi)); + const lit = albedo[i] * (d.direct[i] * lambert + d.ambient[i] * Math.max(0, hemi)); return lit * t + haze[i] * (1 - t); }) as unknown as Vec3; } - -export const skyData = (name: SkyName): SkyData => SKY[name]; diff --git a/src/app/features/scene/terrain/terrainStars.test.ts b/src/app/features/scene/terrain/terrainStars.test.ts index 370b6843..c1231bd8 100644 --- a/src/app/features/scene/terrain/terrainStars.test.ts +++ b/src/app/features/scene/terrain/terrainStars.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SKY } from './skyData'; +import { SKY, skyAtSun } from './terrainSky'; import { BRIGHTEN, createTerrainStars, @@ -9,12 +9,12 @@ import { FRAME_MS, horizonY, PERIOD, + nightColours, project, - skyColoursOf, + starGain, } from './terrainStars'; import { CAMERA, view } from './terrainWorld'; import { isNight, makeStars } from '../ridge/stars'; -import { skyNameFor } from './terrainSky'; /** A 2D context that only counts: arcs painted and clears since the last reset. */ function fakeCtx() { @@ -64,10 +64,21 @@ const glCanvas = (w = 390, h = 844) => ({ clientWidth: w, clientHeight: h, parentElement: parent } as any); describe('which skies get stars', () => { - it("midnight is dark, noonday is not, by the ridge module's own test", () => { - expect(isNight(skyColoursOf(SKY.midnight))).toBe(true); - expect(isNight(skyColoursOf(SKY.noonday))).toBe(false); - expect(skyNameFor(skyColoursOf(SKY.noonday))).toBe('noonday'); + it('the field is placed against the baked night gradient, which the ridge module calls dark', () => { + expect(isNight(nightColours())).toBe(true); + }); + + it('starGain: full below -12 degrees, none above -6, smooth and monotone between', () => { + expect(starGain(-44.6)).toBe(1); + expect(starGain(-12)).toBe(1); + expect(starGain(-6)).toBe(0); + expect(starGain(45.2)).toBe(0); + expect(starGain(-9)).toBeCloseTo(0.5, 9); + let prev = 1; + for (let e = -12; e <= -6; e += 0.25) { + expect(starGain(e)).toBeLessThanOrEqual(prev); + prev = starGain(e); + } }); it('noonday gets no layer and no element', () => { @@ -75,6 +86,17 @@ describe('which skies get stars', () => { expect(parent.appendChild).not.toHaveBeenCalled(); }); + it('twilight is a fade, not a gate: a sun at -9 degrees paints the stars at half alpha', () => { + createTerrainStars(glCanvas(), skyAtSun(-9))?.draw(0); + const half = Math.max(...ctx2d.c.alphas); + ctx2d.c.alphas = []; + createTerrainStars(glCanvas(), SKY.midnight)?.draw(0); + expect(half).toBeGreaterThan(0); + expect(half / Math.max(...ctx2d.c.alphas)).toBeCloseTo(0.5, 6); + expect(createTerrainStars(glCanvas(), skyAtSun(-6.5))).not.toBeNull(); + expect(createTerrainStars(glCanvas(), skyAtSun(-6))).toBeNull(); + }); + it('midnight adds one overlay to the canvas parent; dispose removes it', () => { const s = createTerrainStars(glCanvas(), SKY.midnight)!; expect(parent.appendChild).toHaveBeenCalledWith(overlay); @@ -172,12 +194,10 @@ describe('turning with the camera', () => { describe('brightness', () => { it('draws every star at BRIGHTEN times its ridge-field alpha, capped at 1', () => { createTerrainStars(glCanvas(), SKY.midnight)?.draw(0); - const want = makeStars(skyColoursOf(SKY.midnight), 390, 844).map((st) => - Math.min(1, st.alpha * BRIGHTEN) - ); + const want = makeStars(nightColours(), 390, 844).map((st) => Math.min(1, st.alpha * BRIGHTEN)); ctx2d.c.alphas.forEach((a) => expect(want.some((w) => Math.abs(w - a) < 1e-9)).toBe(true)); // and it is brighter than the ridge field's own top alpha - const top = Math.max(...makeStars(skyColoursOf(SKY.midnight), 390, 844).map((st) => st.alpha)); + const top = Math.max(...makeStars(nightColours(), 390, 844).map((st) => st.alpha)); expect(Math.max(...ctx2d.c.alphas)).toBeGreaterThan(top * 1.5); }); }); diff --git a/src/app/features/scene/terrain/terrainStars.ts b/src/app/features/scene/terrain/terrainStars.ts index 4fca72c9..a7275062 100644 --- a/src/app/features/scene/terrain/terrainStars.ts +++ b/src/app/features/scene/terrain/terrainStars.ts @@ -1,5 +1,5 @@ // The terrain scene's star layer: the ridge scene's starfield (ridge/stars.ts: placement, alpha, tint, the -// rolling twinkle schedule) on a 2D overlay canvas above the WebGL canvas, shown only when the baked sky is dark. +// rolling twinkle schedule) on a 2D overlay canvas above the WebGL canvas, faded by the sun's elevation (starGain). // // The WebGL canvas is opaque, so the overlay sits on top and is cut to the sky: it is only as tall as the // horizon line is low on screen. The camera has no roll and the eye (CAMERA.eye) is above the highest peak @@ -13,9 +13,10 @@ // the layer only paints when Terrain.ts draws, and a draw with no clock (reduced motion, still tier, resize) // paints every star at base brightness. +import { baked } from '../../../../sky/baked'; import type { SkyColours, Srgb } from '../ridge/skyColour'; -import { activeAt, isNight, makeStars, SKY_STOPS, Star } from '../ridge/stars'; -import { SkyData } from './skyData'; +import { activeAt, makeStars, SKY_STOPS, Star } from '../ridge/stars'; +import { SkyData } from './terrainSky'; import { CAMERA } from './terrainWorld'; /** Azimuth the star field repeats at (radians); wider than any horizontal field of view the layer is shown at. */ @@ -30,9 +31,19 @@ const TWINKLE_MS = 50; const hex = (h: string): Srgb => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)) as unknown as Srgb; -/** The ridge star module's view of a baked terrain sky: zenith, the middle stop, and the horizon stop. */ -export function skyColoursOf(d: SkyData): SkyColours { - const g = d.gradient; +/** + * How much of the starfield shows with the sun at `elevation` degrees: all of it below -12 (where the baked + * scattering goes dark: the planet's shadow covers every sunlit air path), none above -6 (civil twilight), a smooth + * fade between. Not a dark/day gate: a sun at -9 shows the stars at half. + */ +export function starGain(elevation: number): number { + const t = Math.min(1, Math.max(0, (elevation + 12) / 6)); + return 1 - t * t * (3 - 2 * t); +} + +/** The ridge star module's view of the night sky it places stars against: the baked midnight gradient's zenith, middle and horizon stops. */ +export function nightColours(): SkyColours { + const g = baked.midnight.gradient; return { top: hex(g[0]), mid: hex(g[Math.floor(g.length / 2)]), @@ -84,11 +95,12 @@ export interface TerrainStars { dispose(): void; } -/** The layer for `canvas`'s parent, or null: a day sky, no parent, no 2D context. */ +/** The layer for `canvas`'s parent, or null: the sun is too high for any star, no parent, no 2D context. */ export function createTerrainStars(canvas: HTMLCanvasElement, sky: SkyData): TerrainStars | null { - const colours = skyColoursOf(sky); + const colours = nightColours(); + const gain = starGain(sky.elevation); const parent = canvas.parentElement; - if (!parent || !isNight(colours)) return null; + if (!parent || gain <= 0) return null; const el = document.createElement('canvas'); const ctx = el.getContext?.('2d'); if (!ctx) return null; @@ -129,8 +141,8 @@ export function createTerrainStars(canvas: HTMLCanvasElement, sky: SkyData): Ter const yaw = 2 * Math.PI * turns; lit.forEach(({ star, az, e }, i) => { const da = wrap(az - yaw); - const gain = seconds === undefined ? 1 : gains.get(i) ?? 1; - ctx.globalAlpha = Math.min(1, star.alpha * gain * BRIGHTEN); + const twinkle = seconds === undefined ? 1 : gains.get(i) ?? 1; + ctx.globalAlpha = Math.min(1, star.alpha * twinkle * BRIGHTEN) * gain; ctx.fillStyle = star.fill; [da, da > 0 ? da - PERIOD : da + PERIOD].forEach((d) => { const at = project(d, e, w, h); From e007514af3b8aac104f76995c13df06cc2f59d18 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 08:10:28 +0000 Subject: [PATCH 015/111] scene: terrain falls back to its sky alone on a 2D canvas where WebGL2 is missing createTerrainScene returned null without WebGL2, leaving plain dark ground. The door now mounts terrain/TerrainSky2D.ts on the same canvas: skyColour (the CPU twin of SKY_FRAG) painted once, the star layer on top when dark, same ?sky= ?sun= ?az= choice. chooseSky moves from Terrain.ts to terrainSky.ts so both scenes share one owner. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 5 + src/app/features/scene/index.ts | 14 +- src/app/features/scene/terrain/Terrain.ts | 27 +-- .../scene/terrain/TerrainSky2D.test.ts | 220 ++++++++++++++++++ .../features/scene/terrain/TerrainSky2D.ts | 83 +++++++ src/app/features/scene/terrain/terrainSky.ts | 18 ++ 6 files changed, 337 insertions(+), 30 deletions(-) create mode 100644 src/app/features/scene/terrain/TerrainSky2D.test.ts create mode 100644 src/app/features/scene/terrain/TerrainSky2D.ts diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 5bf2ef59..35075536 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -51,6 +51,11 @@ Page URL: `?sky=midnight|noonday` picks a bake; `?sun=&az= Scene | null; try { if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; - else if (config?.style === 'terrain') - createScene = (await import('./terrain/Terrain')).createTerrainScene; - else createScene = (await import('./ridge/Ridge')).createRidgeScene; + else if (config?.style === 'terrain') { + const { createTerrainScene } = await import('./terrain/Terrain'); + const scene = createTerrainScene({ ...options, tier }); + // No WebGL2 (or no usable GL): the terrain's sky alone on the same canvas, not bare ground. + if (scene) return scene; + const { createTerrainSkyScene } = await import('./terrain/TerrainSky2D'); + return createTerrainSkyScene({ ...options, tier }); + } else createScene = (await import('./ridge/Ridge')).createRidgeScene; } catch (e) { options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); return null; diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 90d90ca2..3db90d3c 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -1,20 +1,9 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from '../ridge/probe'; -import { skyColours } from '../ridge/skyColour'; import { albedoPalette } from './terrainAlbedo'; import { sunIndex } from '../../../../sky/scatter.js'; import { SKY_VIEW, skyViewWords } from './terrainScatter'; -import { - glowColour, - HAZE_K, - isSkyName, - skyAtSun, - SkyData, - skyData, - SkyName, - skyNameFor, - sunDirection, -} from './terrainSky'; +import { glowColour, HAZE_K, chooseSky, SkyName, sunDirection } from './terrainSky'; import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; import { FULLSCREEN_VERT, @@ -77,20 +66,6 @@ function program(gl: WebGL2RenderingContext, name: string, vs: string, fs: strin return p; } -/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ -function chooseSky(opts: Pick): SkyData { - const q = new URLSearchParams(window.location.search); - const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); - const elevation = opts.sun?.elevation ?? number('sun'); - if (Number.isFinite(elevation)) { - const azimuth = opts.sun?.azimuth ?? number('az'); - return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); - } - if (opts.sky) return skyData(opts.sky); - const name = q.get('sky'); - return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); -} - /** * The terrain scene: same contract as `createRidgeScene`/`createScene` ({ login, dispose }, null for "keep the * CSS ground"). WebGL2 only. The height map is baked once on the GPU (Phacelle, see terrainShaders.ts) into an diff --git a/src/app/features/scene/terrain/TerrainSky2D.test.ts b/src/app/features/scene/terrain/TerrainSky2D.test.ts new file mode 100644 index 00000000..c3eb1e3d --- /dev/null +++ b/src/app/features/scene/terrain/TerrainSky2D.test.ts @@ -0,0 +1,220 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTerrainSkyScene, SKY_WIDTH } from './TerrainSky2D'; +import { startBackgroundScene } from '../index'; + +let search: string; +let arcs: number; +let overlay: { remove: ReturnType }; +let windowListeners: Record void>; +beforeEach(() => { + search = ''; + arcs = 0; + windowListeners = {}; + vi.stubGlobal('document', { + createElement: () => { + overlay = { + remove: vi.fn(), + style: {}, + setAttribute() {}, + getContext: () => ({ + clearRect() {}, + beginPath() {}, + fill() {}, + arc: () => { + arcs += 1; + }, + }), + } as any; + return overlay; + }, + }); + vi.stubGlobal('window', { + devicePixelRatio: 1, + innerWidth: 400, + innerHeight: 800, + addEventListener: (n: string, f: () => void) => { + windowListeners[n] = f; + }, + removeEventListener: (n: string) => { + delete windowListeners[n]; + }, + get location() { + return { search }; + }, + setTimeout, + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** A canvas with a recording 2D context; `gl` is what 'webgl2' answers. */ +function canvasWith(opts: { twoD?: boolean; gl?: unknown } = {}) { + const painted: { data: Uint8ClampedArray; width: number; height: number }[] = []; + const ctx = { + createImageData: (width: number, height: number) => ({ + width, + height, + data: new Uint8ClampedArray(width * height * 4), + }), + putImageData: (image: any) => painted.push(image), + }; + const parentAppend = vi.fn(); + const canvas: any = { + width: 0, + height: 0, + clientWidth: 400, + clientHeight: 800, + parentElement: { appendChild: parentAppend }, + getContext: vi.fn((kind: string) => { + if (kind !== '2d') return opts.gl ?? null; + return opts.twoD === false ? null : ctx; + }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + return { canvas, painted, parentAppend }; +} + +const pixel = (img: { data: Uint8ClampedArray; width: number }, x: number, y: number) => + Array.from(img.data.slice((y * img.width + x) * 4, (y * img.width + x) * 4 + 3)); +const mean = (img: { data: Uint8ClampedArray }) => { + let s = 0; + for (let i = 0; i < img.data.length; i += 4) s += img.data[i] + img.data[i + 1] + img.data[i + 2]; + return s / (img.data.length / 4) / 3; +}; + +describe('createTerrainSkyScene', () => { + it("returns null at tier 'off' and touches nothing", () => { + const { canvas } = canvasWith(); + expect(createTerrainSkyScene({ canvas, tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('returns null when the canvas gives no 2D context (the CSS ground stays)', () => { + const { canvas } = canvasWith({ twoD: false }); + expect(createTerrainSkyScene({ canvas, tier: 'low' })).toBeNull(); + }); + + it('paints one opaque, graded sky: the horizon end of the image is not the zenith end', () => { + const { canvas, painted } = canvasWith(); + const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!; + expect(painted).toHaveLength(1); + const img = painted[0]; + expect(img.width).toBe(SKY_WIDTH); + expect(canvas.width).toBe(SKY_WIDTH); + expect(canvas.height).toBe(img.height); + for (let i = 3; i < img.data.length; i += 4) expect(img.data[i]).toBe(255); + const x = Math.floor(SKY_WIDTH / 2); + expect(pixel(img, x, 0)).not.toEqual(pixel(img, x, img.height - 1)); + expect(mean(img)).toBeGreaterThan(20); + s.dispose(); + }); + + it('follows the baked sky: noon is brighter than midnight, and ?sun= / ?az= move it', () => { + const noon = canvasWith(); + createTerrainSkyScene({ canvas: noon.canvas, tier: 'low', sky: 'noonday' })!.dispose(); + const night = canvasWith(); + createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!.dispose(); + expect(mean(noon.painted[0])).toBeGreaterThan(mean(night.painted[0]) * 1.5); + + const low = canvasWith(); + createTerrainSkyScene({ canvas: low.canvas, tier: 'low', sun: { elevation: 30 } })!.dispose(); + const down = canvasWith(); + createTerrainSkyScene({ canvas: down.canvas, tier: 'low', sun: { elevation: -20 } })!.dispose(); + expect(mean(low.painted[0])).toBeGreaterThan(mean(down.painted[0])); + + search = '?sun=30&az=0'; + const q0 = canvasWith(); + createTerrainSkyScene({ canvas: q0.canvas, tier: 'low' })!.dispose(); + search = '?sun=30&az=180'; + const q1 = canvasWith(); + createTerrainSkyScene({ canvas: q1.canvas, tier: 'low' })!.dispose(); + expect(Array.from(q0.painted[0].data)).not.toEqual(Array.from(q1.painted[0].data)); + }); + + it('stars only on a dark sky', () => { + const night = canvasWith(); + const a = createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!; + expect(night.parentAppend).toHaveBeenCalledTimes(1); + expect(arcs).toBeGreaterThan(0); + a.dispose(); + expect(overlay.remove).toHaveBeenCalled(); + arcs = 0; + const day = canvasWith(); + createTerrainSkyScene({ canvas: day.canvas, tier: 'low', sky: 'noonday' })!.dispose(); + expect(day.parentAppend).not.toHaveBeenCalled(); + expect(arcs).toBe(0); + }); + + it('repaints on resize, and dispose stops listening', () => { + vi.useFakeTimers(); + vi.stubGlobal('window', { + ...(window as any), + setTimeout, + addEventListener: (window as any).addEventListener, + }); + const { canvas, painted } = canvasWith(); + const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!; + expect(windowListeners.resize).toBeDefined(); + windowListeners.resize(); + vi.advanceTimersByTime(300); + expect(painted).toHaveLength(2); + s.dispose(); + expect(windowListeners.resize).toBeUndefined(); + vi.useRealTimers(); + }); +}); + +describe('startBackgroundScene, terrain style', () => { + it('without WebGL2 mounts the sky on the same canvas instead of returning null', async () => { + const { canvas, painted } = canvasWith({ gl: null }); + const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2', '2d']); + expect(painted).toHaveLength(1); + scene!.dispose(); + }); + + it('with WebGL2 the terrain runs and the 2D sky is never requested', async () => { + const gl: any = new Proxy( + {}, + { + get(_t, name: string) { + if (name === 'getShaderParameter' || name === 'getProgramParameter') return () => true; + if (name === 'checkFramebufferStatus') return () => 1; + if (name === 'getParameter') return () => 16; + if (/^[A-Z_0-9]+$/.test(name)) return 1; + return () => ({}); + }, + } + ); + Object.assign(document, { + addEventListener() {}, + removeEventListener() {}, + visibilityState: 'visible', + }); + vi.stubGlobal('requestAnimationFrame', () => 1); + vi.stubGlobal('cancelAnimationFrame', () => {}); + vi.stubGlobal('performance', { now: () => 0 }); + (window as any).matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + const { canvas, painted } = canvasWith({ gl }); + const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2']); + expect(painted).toHaveLength(0); + scene!.dispose(); + }); + + it('a canvas that took a GL context but gives no scene still ends as null: no 2D context to paint on', async () => { + const { canvas } = canvasWith({ gl: null, twoD: false }); + expect( + await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }) + ).toBeNull(); + }); +}); diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts new file mode 100644 index 00000000..e61c6fee --- /dev/null +++ b/src/app/features/scene/terrain/TerrainSky2D.ts @@ -0,0 +1,83 @@ +import type { Scene } from '../Scene'; +import type { TerrainOptions } from './Terrain'; +import { chooseSky, skyColour, Vec3 } from './terrainSky'; +import { createTerrainStars } from './terrainStars'; +import { CAMERA, view } from './terrainWorld'; + +/** The sky is smooth, so it is painted this many px wide and stretched by the canvas's CSS size. */ +export const SKY_WIDTH = 96; + +/** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */ +export type TerrainSkyOptions = Pick; + +/** + * Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the + * same baked tables and the same `?sun=` `?az=` `?sky=` choice) for the camera at its start, still: nothing moves, so + * a device that cannot run WebGL2 pays one paint and one resize repaint. Below the horizon there is no terrain, so + * the image continues with the horizon's colour. Null for "keep the CSS ground": tier off, or the canvas already + * holds a WebGL context and gives no 2D one. + */ +export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { + if ((opts.tier ?? 'full') === 'off') return null; + const { canvas } = opts; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + const sky = chooseSky(opts); + const stars = createTerrainStars(canvas, sky); + const tanHalf = Math.tan(CAMERA.fov / 2); + let disposed = false; + let resizeTimer = 0; + + const paint = () => { + const w = SKY_WIDTH; + const cw = canvas.clientWidth || window.innerWidth; + const ch = canvas.clientHeight || window.innerHeight; + const h = Math.max(1, Math.round((w * ch) / cw)); + canvas.width = w; + canvas.height = h; + const v = view(cw / ch, 0); + const image = ctx.createImageData(w, h); + for (let y = 0; y < h; y += 1) { + const ny = 1 - ((y + 0.5) / h) * 2; + for (let x = 0; x < w; x += 1) { + const nx = ((x + 0.5) / w) * 2 - 1; + const d = [0, 1, 2].map( + (i) => v.forward[i] + nx * tanHalf * (cw / ch) * v.right[i] + ny * tanHalf * v.up[i] + ); + const len = Math.hypot(d[0], d[1], d[2]); + const c: Vec3 = skyColour(sky, [d[0] / len, d[1] / len, d[2] / len]); + const at = (y * w + x) * 4; + for (let i = 0; i < 3; i += 1) { + image.data[at + i] = Math.round(255 * Math.min(1, Math.max(0, c[i])) ** (1 / 2.2)); + } + image.data[at + 3] = 255; + } + } + ctx.putImageData(image, 0, 0); + stars?.draw(0); + }; + + const onResize = () => { + clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + if (disposed) return; + stars?.resize(); + paint(); + }, 250); + }; + window.addEventListener('resize', onResize); + paint(); + + return { + login() { + // Nothing moves, so there is no pull-back to start. + }, + dispose() { + if (disposed) return; + disposed = true; + clearTimeout(resizeTimer); + window.removeEventListener('resize', onResize); + stars?.dispose(); + }, + }; +} diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index a7e38a39..b77ddbf2 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -2,6 +2,7 @@ // bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms. import { baked } from '../../../../sky/baked'; import { isNight } from '../ridge/stars'; +import { skyColours } from '../ridge/skyColour'; import type { SkyColours } from '../ridge/skyColour'; import { HORIZON_FLOOR, scatterRadiance, transmittanceAt } from './terrainScatter'; @@ -149,3 +150,20 @@ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, return lit * t + haze[i] * (1 - t); }) as unknown as Vec3; } + +/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ +export function chooseSky(opts: { + sky?: SkyName; + sun?: { elevation: number; azimuth?: number }; +}): SkyData { + const q = new URLSearchParams(window.location.search); + const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); + const elevation = opts.sun?.elevation ?? number('sun'); + if (Number.isFinite(elevation)) { + const azimuth = opts.sun?.azimuth ?? number('az'); + return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); + } + if (opts.sky) return skyData(opts.sky); + const name = q.get('sky'); + return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); +} From 1296f1833820f123ecdf8f0cc94c39edb87226f9 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 17:38:27 +0000 Subject: [PATCH 016/111] scene: terrain relief and contrast on a phone, a tier label Why it read hazy and flat at 390x844: the camera started with the noon sun straight behind it (no shading), the low tier sampled a 512 height map (6 m texels), plain exp(-k d) haze greyed the near ground, albedo had no grain, and the night ground sat at ambient 0.018 (near black). - camera starts with the noon sun 100 degrees off axis (START_TURNS; ?az= still moves the sun) - haze T = exp(-(k d)^1.6), k 0.0007: near ground clear, far ridges melt - low tier bakes 1024 texels - albedo grain (three-wave octaves, fine one fades with distance) and rock strata; shader-only cavity shading - night: moon from the sun's mirrored position, ambient lifted by nightness - tier label (full / low / still / sky-only) in preview builds or ?tierlabel=1 Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/Terrain.test.ts | 14 +++--- src/app/features/scene/terrain/Terrain.ts | 24 +++++++-- .../features/scene/terrain/TerrainSky2D.ts | 10 ++-- .../scene/terrain/terrainAlbedo.test.ts | 30 +++++++++++- .../features/scene/terrain/terrainAlbedo.ts | 40 +++++++++++++++ .../features/scene/terrain/terrainShaders.ts | 25 ++++++++-- .../features/scene/terrain/terrainSky.test.ts | 41 +++++++++++++++- src/app/features/scene/terrain/terrainSky.ts | 39 ++++++++++++--- .../features/scene/terrain/terrainWorld.ts | 2 +- .../features/scene/terrain/tierLabel.test.ts | 49 +++++++++++++++++++ src/app/features/scene/terrain/tierLabel.ts | 26 ++++++++++ 11 files changed, 272 insertions(+), 28 deletions(-) create mode 100644 src/app/features/scene/terrain/tierLabel.test.ts create mode 100644 src/app/features/scene/terrain/tierLabel.ts diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts index 446ab9cf..b61b632b 100644 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTerrainScene } from './Terrain'; import { GRID, triangleCount } from './terrainWorld'; -import { SKY, skyAtSun } from './terrainSky'; +import { groundLight, SKY, skyAtSun } from './terrainSky'; import { SKY_VIEW, skyViewWords } from './terrainScatter'; import { SKY_FRAG } from './terrainShaders'; import { startBackgroundScene } from '../index'; @@ -145,8 +145,8 @@ describe('createTerrainScene', () => { }; const night = uploaded({}); const day = uploaded({ sky: 'noonday' }); - const nightA = Array.from(SKY.midnight.ambient); - const dayA = Array.from(SKY.noonday.ambient); + const nightA = Array.from(groundLight(SKY.midnight).ambient); + const dayA = Array.from(groundLight(SKY.noonday).ambient); const has = (all: number[][], v: number[]) => all.some((a) => a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); expect(has(night, nightA)).toBe(true); @@ -167,11 +167,13 @@ describe('createTerrainScene', () => { const dusk = skyAtSun(-1, 300); const has = (all: number[][], v: readonly number[]) => all.some((a) => a.length === 3 && a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); - expect(has(ambients({ sun: { elevation: -1, azimuth: 300 } }), dusk.ambient)).toBe(true); + expect(has(ambients({ sun: { elevation: -1, azimuth: 300 } }), groundLight(dusk).ambient)).toBe( + true + ); search = '?sky=noonday&sun=-1&az=300'; - expect(has(ambients({}), dusk.ambient)).toBe(true); + expect(has(ambients({}), groundLight(dusk).ambient)).toBe(true); search = '?sun=garbage'; - expect(has(ambients({}), SKY.midnight.ambient)).toBe(true); + expect(has(ambients({}), groundLight(SKY.midnight).ambient)).toBe(true); }); it('uploads the baked scattering table once, as half floats, to a 3D texture', () => { diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 3db90d3c..d3c95ff3 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -3,7 +3,15 @@ import { FrameSampler, FrameStats } from '../ridge/probe'; import { albedoPalette } from './terrainAlbedo'; import { sunIndex } from '../../../../sky/scatter.js'; import { SKY_VIEW, skyViewWords } from './terrainScatter'; -import { glowColour, HAZE_K, chooseSky, SkyName, sunDirection } from './terrainSky'; +import { + glowColour, + groundLight, + HAZE_K, + chooseSky, + SkyName, + START_TURNS, + sunDirection, +} from './terrainSky'; import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; import { FULLSCREEN_VERT, @@ -13,6 +21,7 @@ import { SKY_FRAG, } from './terrainShaders'; import { createTerrainStars } from './terrainStars'; +import { showTierLabel } from './tierLabel'; export type TerrainOptions = Pick< SceneOptions, @@ -178,7 +187,9 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const sun = [...sunDirection(sky)]; const sunLight = [...sky.direct]; - const ambient = [...sky.ambient]; + const ground = groundLight(sky); + const ambient = [...ground.ambient]; + const heading = START_TURNS; const glow = [...glowColour(sky)]; const sunTexel = sunIndex(sky.elevation); const palette = albedoPalette(sky); @@ -206,7 +217,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const draw = (turns: number) => { if (lost) return; gl.viewport(0, 0, canvas.width, canvas.height); - const v = view(canvas.width / canvas.height, turns % 1); + const v = view(canvas.width / canvas.height, (turns + heading) % 1); gl.disable(gl.DEPTH_TEST); gl.useProgram(skyProg); const us = (n: string) => gl.getUniformLocation(skyProg, n); @@ -233,7 +244,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.uniform1f(u('uSpan'), TERRAIN.span); gl.uniform1i(u('uTex'), spec.tex); gl.uniform3fv(u('uSun'), sun); - gl.uniform3fv(u('uSunColour'), sunLight); + gl.uniform3fv(u('uLightDir'), ground.dir); + gl.uniform3fv(u('uLight'), ground.colour); gl.uniform3fv(u('uAmbient'), ambient); gl.uniform3fv(u('uGlow'), glow); gl.uniform1f(u('uSunIndex'), sunTexel); @@ -252,7 +264,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.bindVertexArray(vao); gl.drawElements(gl.TRIANGLES, g.indices.length, gl.UNSIGNED_INT, 0); gl.bindVertexArray(null); - stars?.draw(turns % 1, moving() ? performance.now() / 1000 : undefined); + stars?.draw((turns + heading) % 1, moving() ? performance.now() / 1000 : undefined); }; const moving = () => @@ -347,6 +359,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { }); canvas.parentElement.appendChild(readout); } + const removeLabel = showTierLabel(canvas, tier); if (still) settleToStill(); else wake(); if (!still && !moving()) draw(0); @@ -383,6 +396,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { reducedMotion?.removeEventListener?.('change', onVisibility); canvas.removeEventListener('webglcontextlost', onLost); readout?.remove(); + removeLabel?.(); stars?.dispose(); release(); }, diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts index e61c6fee..0c19ec97 100644 --- a/src/app/features/scene/terrain/TerrainSky2D.ts +++ b/src/app/features/scene/terrain/TerrainSky2D.ts @@ -1,7 +1,8 @@ import type { Scene } from '../Scene'; import type { TerrainOptions } from './Terrain'; -import { chooseSky, skyColour, Vec3 } from './terrainSky'; +import { chooseSky, skyColour, START_TURNS, Vec3 } from './terrainSky'; import { createTerrainStars } from './terrainStars'; +import { showTierLabel } from './tierLabel'; import { CAMERA, view } from './terrainWorld'; /** The sky is smooth, so it is painted this many px wide and stretched by the canvas's CSS size. */ @@ -24,6 +25,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { if (!ctx) return null; const sky = chooseSky(opts); const stars = createTerrainStars(canvas, sky); + const heading = START_TURNS; const tanHalf = Math.tan(CAMERA.fov / 2); let disposed = false; let resizeTimer = 0; @@ -35,7 +37,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { const h = Math.max(1, Math.round((w * ch) / cw)); canvas.width = w; canvas.height = h; - const v = view(cw / ch, 0); + const v = view(cw / ch, heading); const image = ctx.createImageData(w, h); for (let y = 0; y < h; y += 1) { const ny = 1 - ((y + 0.5) / h) * 2; @@ -54,7 +56,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { } } ctx.putImageData(image, 0, 0); - stars?.draw(0); + stars?.draw(heading); }; const onResize = () => { @@ -67,6 +69,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { }; window.addEventListener('resize', onResize); paint(); + const removeLabel = showTierLabel(canvas, 'sky-only'); return { login() { @@ -77,6 +80,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { disposed = true; clearTimeout(resizeTimer); window.removeEventListener('resize', onResize); + removeLabel?.(); stars?.dispose(); }, }; diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 0136b5f3..385344d3 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -1,7 +1,7 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, heights and slopes */ import { describe, expect, it } from 'vitest'; import { SKY } from './terrainSky'; -import { ALBEDO, albedoAt, albedoPalette, wobble } from './terrainAlbedo'; +import { ALBEDO, albedoAt, albedoPalette, grainAt, wobble } from './terrainAlbedo'; import { GRID_FRAG } from './terrainShaders'; const near = (a: readonly number[], b: readonly number[], eps = 1e-6) => @@ -98,7 +98,35 @@ describe('shader agrees with the pure function on its numbers', () => { expect(GRID_FRAG).toContain(`#define ALB_SNOW_BAND ${ALBEDO.snowBand.toFixed(1)}`); expect(GRID_FRAG).toContain(`#define ALB_ROCK_LO ${ALBEDO.rockLo}`); expect(GRID_FRAG).toContain(`#define ALB_WOBBLE_S ${ALBEDO.wobbleSlope}`); + expect(GRID_FRAG).toContain(`#define ALB_GRAIN_COARSE ${ALBEDO.grainCoarse}`); + expect(GRID_FRAG).toContain(`#define ALB_STRATA ${ALBEDO.strata}`); // the only fetch for colour-by-material would be a texture; the height map's texelFetch is the only other read, and the sky lookup is the baked scattering table's expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky)/g)).toHaveLength(1); }); }); + +describe('grain', () => { + const samples = (rock: number, distance: number) => + Array.from({ length: 400 }, (_, i) => grainAt(i * 1.7, i * 0.9, 100 + i * 0.3, rock, distance)); + const spread = (v: number[]) => Math.max(...v) - Math.min(...v); + + it('varies the surface near the eye, never goes negative, and averages about 1', () => { + const v = samples(0, 50); + expect(spread(v)).toBeGreaterThan(0.4); + expect(Math.min(...v)).toBeGreaterThanOrEqual(0); + expect(v.reduce((a, b) => a + b, 0) / v.length).toBeGreaterThan(0.85); + }); + + it('drops its fine octave with distance, so far ground does not shimmer', () => { + const rangeOf = (d: number) => + spread(Array.from({ length: 400 }, (_, i) => grainAt(i * 0.37, i * 0.21, 100, 0, d))); + expect(rangeOf(900)).toBeLessThan(rangeOf(50)); + }); + + it('layers the rock: strata show only where the face is rock', () => { + const height = (rock: number) => + spread(Array.from({ length: 400 }, (_, i) => grainAt(0, 0, 100 + i * 0.05, rock, 2000))); + expect(height(1)).toBeGreaterThan(0.2); + expect(height(0)).toBeLessThan(1e-9); + }); +}); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index e3230a8b..d452dec8 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -28,6 +28,13 @@ export const ALBEDO = { /** world-position wobble of the height edges (metres) and the slope edges, so bands are not contour lines. */ wobbleHeight: 14, wobbleSlope: 0.05, + /** Albedo grain: relative swing of the two sine-product octaves (about 20 m and 5 m periods), and of the rock strata. */ + grainCoarse: 0.35, + grainFine: 0.3, + /** The fine octave fades out between these distances (metres): beyond them it is under a pixel and only shimmers. */ + fineNear: 250, + fineFar: 700, + strata: 0.22, } as const; export interface AlbedoPalette { @@ -95,3 +102,36 @@ export function albedoAt(p: AlbedoPalette, height: number, ny: number, x: number mix(mix(ground(i), p.snow[i], snow), p.rock[i], rock) ) as unknown as Vec3; } + +/** + * The multiplier on albedo that gives the surface grain and the rock its layers, from world position (metres), + * height, `rock` (0..1, how much of the face is rock) and the distance from the eye. The shader's `ALB_GRAIN_*` block does the same arithmetic. + * The cavity term beside it in the shader reads the height map and has no twin here. + */ +export function grainAt( + x: number, + z: number, + height: number, + rock: number, + distance: number +): number { + const waves = (k: ReadonlyArray) => + k.reduce((sum, [kx, kz]) => sum + Math.sin(x * kx + z * kz), 0) / 3; + const coarse = waves([ + [0.31, 0.27], + [-0.22, 0.35], + [0.38, -0.12], + ]); + const fine = + waves([ + [1.1, 0.7], + [-0.8, 1.2], + [1.3, -0.6], + ]) * + (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); + const layers = Math.sin(height * 0.9 + wobble(x, z) * 3); + return Math.max( + 0, + 1 + ALBEDO.grainCoarse * coarse + ALBEDO.grainFine * fine + ALBEDO.strata * rock * layers + ); +} diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 8418e91a..04d0425b 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -9,7 +9,7 @@ import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; import { ALBEDO } from './terrainAlbedo'; import { SCATTER_GLSL } from './terrainScatter'; -import { GLOW_ZENITH, SKY_EXPOSURE } from './terrainSky'; +import { GLOW_ZENITH, HAZE_POWER, SKY_EXPOSURE } from './terrainSky'; import { TERRAIN } from './terrainWorld'; const HEADER = @@ -122,6 +122,11 @@ const ALBEDO_DEFINES = ` #define ALB_SNOW_LO ${lit(ALBEDO.snowLo)} #define ALB_WOBBLE_H ${lit(ALBEDO.wobbleHeight)} #define ALB_WOBBLE_S ${lit(ALBEDO.wobbleSlope)} +#define ALB_GRAIN_COARSE ${lit(ALBEDO.grainCoarse)} +#define ALB_GRAIN_FINE ${lit(ALBEDO.grainFine)} +#define ALB_STRATA ${lit(ALBEDO.strata)} +#define ALB_FINE_NEAR ${lit(ALBEDO.fineNear)} +#define ALB_FINE_FAR ${lit(ALBEDO.fineFar)} `; // The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow. The // same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already scaled by how dark the sun is. @@ -136,7 +141,7 @@ vec3 skyColour(vec3 d) { export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL} in vec2 v_uv; in vec3 v_world; -uniform vec3 uCamera, uSunColour, uAmbient, uSoil, uScree, uRock, uSnow; +uniform vec3 uCamera, uLightDir, uLight, uAmbient, uSoil, uScree, uRock, uSnow; uniform float uSpan, uHazeK, uSnowLine; out vec4 o; ${FETCH} @@ -163,9 +168,21 @@ void main() { float snow = smoothstep(uSnowLine - ALB_SNOW_BAND, uSnowLine + ALB_SNOW_BAND, h) * smoothstep(ALB_SNOW_LO, ALB_SNOW_HI, flat_); float rockW = 1.0 - smoothstep(ALB_ROCK_LO, ALB_ROCK_HI, flat_); vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); - vec3 lit = albedo * (uSunColour * max(dot(n, uSun), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); + // Surface grain and rock strata, the same arithmetic as terrainAlbedo.grainAt(). + vec2 q = v_world.xz; + float coarse = (sin(dot(q, vec2(0.31, 0.27))) + sin(dot(q, vec2(-0.22, 0.35))) + sin(dot(q, vec2(0.38, -0.12)))) / 3.0; + float fine = (sin(dot(q, vec2(1.1, 0.7))) + sin(dot(q, vec2(-0.8, 1.2))) + sin(dot(q, vec2(1.3, -0.6)))) / 3.0; + float near = 1.0 - smoothstep(ALB_FINE_NEAR, ALB_FINE_FAR, length(v_world - uCamera)); + float layers = sin(v_world.y * 0.9 + w * 3.0); + albedo *= max(0.0, 1.0 + ALB_GRAIN_COARSE * coarse + ALB_GRAIN_FINE * near * fine + ALB_STRATA * rockW * layers); + // Cavity: a point lower than its surroundings sits in shade, one higher catches light. Shader-only, from the height map. + int r = max(1, int(12.0 / texel)); + ivec2 c = ivec2(p + 0.5); + float curv = 0.25 * (hAt(c + ivec2(r, 0)) + hAt(c - ivec2(r, 0)) + hAt(c + ivec2(0, r)) + hAt(c - ivec2(0, r))) - hAt(c); + float cavity = 1.0 - 0.55 * clamp(curv / 12.0, 0.0, 1.0) + 0.3 * clamp(-curv / 12.0, 0.0, 1.0); + vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); vec3 toSurface = v_world - uCamera; - float T = exp(-uHazeK * length(toSurface)); + float T = exp(-pow(uHazeK * length(toSurface), ${lit(HAZE_POWER)})); vec3 haze = skyColour(normalize(vec3(toSurface.x, 0.0, toSurface.z))); o = vec4(pow(mix(haze, lit, T), vec3(1.0 / 2.2)), 1.0); }`; diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index d8cb0129..9e5bc91e 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -14,6 +14,10 @@ import { skyNameFor, sunDirection, transmittance, + groundLight, + HAZE_K, + HAZE_POWER, + START_TURNS, Vec3, } from './terrainSky'; import { skyColours } from '../ridge/skyColour'; @@ -121,14 +125,14 @@ describe('lighting', () => { const m = (n: readonly [number, number, number]) => shade(SKY.midnight, [0.5, 0.5, 0.5], n, 0, view); expect(luma(m(UP))).toBeGreaterThan(0); // moonlit fill, not black - expect(luma(m(UP))).toBeLessThan(0.05); + expect(luma(m(UP))).toBeLessThan(0.12); }); it('noon is much brighter than midnight, ambient included', () => { expect(luma(SKY.noonday.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); const view = look(-5, 0); expect(luma(shade(SKY.noonday, [0.4, 0.4, 0.4], UP, 0, view))).toBeGreaterThan( - 10 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0, view)) + 3 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0, view)) ); }); @@ -224,3 +228,36 @@ describe('haze', () => { expect(dark(2000)).toBeLessThan(h); }); }); + +describe('ground light and haze', () => { + it("at night the ground has a moon above the horizon on the sun's mirrored side, and a lifted ambient; by day it is the sun", () => { + const night = groundLight(SKY.midnight); + expect(night.dir[1]).toBeGreaterThan(0.5); + expect(luma(night.colour)).toBeGreaterThan(0.03); + expect(luma(night.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); + const day = groundLight(SKY.noonday); + expect(Array.from(day.dir)).toEqual(Array.from(sunDirection(SKY.noonday))); + expect(Array.from(day.colour)).toEqual(Array.from(SKY.noonday.direct)); + expect(Array.from(day.ambient)).toEqual(Array.from(SKY.noonday.ambient)); + }); + + it('the ground light is continuous through sunset', () => { + const at = (e: number) => luma(groundLight(skyAtSun(e)).colour); + for (let e = -10; e < 10; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.35); + }); + + it('haze leaves the near ground nearly clear and takes the far ridges: a power law, not plain exp(-k d)', () => { + expect(transmittance(300)).toBeGreaterThan(0.85); + expect(transmittance(300)).toBeGreaterThan(Math.exp(-HAZE_K * 300)); + expect(transmittance(2500)).toBeLessThan(0.15); + expect(HAZE_POWER).toBeGreaterThan(1); + }); + + it('the camera starts with the noon sun off its axis, so the light rakes the relief', () => { + const yaw = 2 * Math.PI * START_TURNS; + const forward = [Math.sin(yaw), 0, -Math.cos(yaw)]; + const sun = sunDirection(SKY.noonday); + const along = (forward[0] * sun[0] + forward[2] * sun[2]) / Math.hypot(sun[0], sun[2]); + expect(Math.abs(along)).toBeLessThan(0.4); + }); +}); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index b77ddbf2..697896a3 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -128,25 +128,52 @@ export function skyColour(d: SkyData, dir: Vec3): Vec3 { ) as unknown as Vec3; } -/** Haze: transmittance T = exp(-k d) at distance d metres. */ -export const HAZE_K = 0.0004; +/** + * Haze: transmittance T = exp(-(k d)^HAZE_POWER) at distance d metres. The power above 1 keeps the near and middle + * ground at its own colour and lets only the far ridges melt toward the sky; a plain exp(-k d) greyed everything. + */ +export const HAZE_K = 0.0007; +export const HAZE_POWER = 1.6; export const transmittance = (distance: number): number => - Math.exp(-HAZE_K * Math.max(0, distance)); + Math.exp(-((HAZE_K * Math.max(0, distance)) ** HAZE_POWER)); + +/** Added to the ambient on a dark sky, scaled by `nightness`: the baked night floor is too dark to show any form on a phone. */ +export const NIGHT_AMBIENT_LIFT = 3; +/** Cool light from the mirror of the sun's below-horizon position, linear, scaled by `nightness`: gives the terrain a lit side at night. */ +export const MOON_COLOUR: Vec3 = [0.05, 0.07, 0.13]; + +/** The light on the ground: the sun, or at night the moon standing where the sun is mirrored above the horizon. Continuous through sunset. */ +export function groundLight(d: SkyData): { dir: Vec3; colour: Vec3; ambient: Vec3 } { + const sun = sunDirection(d); + const k = nightness(d.elevation); + return { + dir: [sun[0], Math.abs(sun[1]), sun[2]], + colour: [0, 1, 2].map((i) => d.direct[i] + MOON_COLOUR[i] * k) as unknown as Vec3, + ambient: [0, 1, 2].map((i) => d.ambient[i] * (1 + NIGHT_AMBIENT_LIFT * k)) as unknown as Vec3, + }; +} + +/** + * The heading the camera starts at, in turns from north: the noonday sun a hundred degrees to its right, so the light + * rakes across the relief instead of flattening it from behind. Fixed, so `?az=` still moves the sun in the picture. + */ +export const START_TURNS = ((SKY.noonday.azimuth - 100) / 360 + 1) % 1; /** * Linear colour of a surface seen along the unit direction `view` (from the eye to the surface): lambert from - * the sun plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), + * the ground light plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), * then hazed by distance toward the sky at the horizon in that direction. `normal` is a unit vector. Never negative. */ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, view: Vec3): Vec3 { - const sun = sunDirection(d); + const light = groundLight(d); + const sun = light.dir; const lambert = Math.max(0, normal[0] * sun[0] + normal[1] * sun[1] + normal[2] * sun[2]); const hemi = 0.5 + 0.5 * normal[1]; const flat = Math.hypot(view[0], view[2]) || 1; const haze = skyColour(d, [view[0] / flat, 0, view[2] / flat]); const t = transmittance(distance); return [0, 1, 2].map((i) => { - const lit = albedo[i] * (d.direct[i] * lambert + d.ambient[i] * Math.max(0, hemi)); + const lit = albedo[i] * (light.colour[i] * lambert + light.ambient[i] * Math.max(0, hemi)); return lit * t + haze[i] * (1 - t); }) as unknown as Vec3; } diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts index e21a229e..97577054 100644 --- a/src/app/features/scene/terrain/terrainWorld.ts +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -8,7 +8,7 @@ export const TERRAIN = { span: 3000, hmax: 256 }; /** Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. */ export const GRID = { still: { grid: 128, tex: 512 }, - low: { grid: 128, tex: 512 }, + low: { grid: 128, tex: 1024 }, full: { grid: 192, tex: 1024 }, } as const; diff --git a/src/app/features/scene/terrain/tierLabel.test.ts b/src/app/features/scene/terrain/tierLabel.test.ts new file mode 100644 index 00000000..3e9f3a48 --- /dev/null +++ b/src/app/features/scene/terrain/tierLabel.test.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { showTierLabel } from './tierLabel'; + +let search: string; +let appended: any[]; +const canvas = (parent: boolean) => + ({ + parentElement: parent + ? { + appendChild: (e: any) => appended.push(e), + } + : null, + } as any); + +beforeEach(() => { + search = ''; + appended = []; + vi.stubGlobal('window', { + get location() { + return { search }; + }, + }); + vi.stubGlobal('document', { + createElement: () => ({ style: {}, remove: vi.fn(), textContent: '' }), + }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('showTierLabel', () => { + it('adds nothing in a normal build', () => { + expect(showTierLabel(canvas(true), 'full')).toBeNull(); + expect(appended).toHaveLength(0); + }); + + it('names the path on screen when asked with ?tierlabel=1, and removes itself', () => { + search = '?tierlabel=1'; + const remove = showTierLabel(canvas(true), 'sky-only'); + expect(appended).toHaveLength(1); + expect(appended[0].textContent).toBe('terrain · sky-only'); + remove!(); + expect(appended[0].remove).toHaveBeenCalled(); + }); + + it('has nowhere to go without a parent', () => { + search = '?tierlabel=1'; + expect(showTierLabel(canvas(false), 'full')).toBeNull(); + }); +}); diff --git a/src/app/features/scene/terrain/tierLabel.ts b/src/app/features/scene/terrain/tierLabel.ts new file mode 100644 index 00000000..6e3a9236 --- /dev/null +++ b/src/app/features/scene/terrain/tierLabel.ts @@ -0,0 +1,26 @@ +// A tiny label naming which terrain path is on screen ('full', 'low', 'still', or 'sky-only' where WebGL2 is missing), +// so a screenshot of a preview says what it shows. Built into preview builds only (VITE_PREVIEW_DEMO=1), or by `?tierlabel=1`. +const wanted = (): boolean => + import.meta.env?.VITE_PREVIEW_DEMO === '1' || + new URLSearchParams(window.location.search).get('tierlabel') === '1'; + +/** Adds the label to the canvas's parent; returns its remover, or null when no label is wanted. */ +export function showTierLabel(canvas: HTMLCanvasElement, text: string): (() => void) | null { + const parent = canvas.parentElement; + if (!parent || !wanted()) return null; + const el = document.createElement('div'); + el.textContent = `terrain · ${text}`; + Object.assign(el.style, { + position: 'absolute', + right: '6px', + bottom: '6px', + font: '10px monospace', + color: 'rgba(255,255,255,.75)', + background: 'rgba(0,0,0,.4)', + padding: '1px 5px', + borderRadius: '3px', + pointerEvents: 'none', + }); + parent.appendChild(el); + return () => el.remove(); +} From 685abbb19b341062c78702065ba91c8f3877468b Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 18:10:21 +0000 Subject: [PATCH 017/111] scene: terrain steep faces without streaks, smoother normals and silhouettes - albedo wobble, grain and strata are triplanar (weights |n|^4 over yz, xz, xy), so a cliff is not the floor pattern smeared down it - normals from bilinear height at a continuous position, stencil 1.5 texels each side - the grid is packed toward the camera at the patch centre (warpUv), 192 low / 256 full, vertex heights bilinear: near ridge silhouettes no longer step in 23 m blocks Co-Authored-By: Claude Sonnet 5.5 --- .../scene/terrain/terrainAlbedo.test.ts | 54 ++++++++++++-- .../features/scene/terrain/terrainAlbedo.ts | 73 ++++++++++++------- .../features/scene/terrain/terrainShaders.ts | 49 +++++++------ .../scene/terrain/terrainWorld.test.ts | 29 +++++++- .../features/scene/terrain/terrainWorld.ts | 14 +++- 5 files changed, 162 insertions(+), 57 deletions(-) diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 385344d3..33855158 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -1,7 +1,15 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, heights and slopes */ import { describe, expect, it } from 'vitest'; import { SKY } from './terrainSky'; -import { ALBEDO, albedoAt, albedoPalette, grainAt, wobble } from './terrainAlbedo'; +import { + ALBEDO, + albedoAt, + albedoPalette, + grainAt, + triWeights, + wobble, + wobbleAt, +} from './terrainAlbedo'; import { GRID_FRAG } from './terrainShaders'; const near = (a: readonly number[], b: readonly number[], eps = 1e-6) => @@ -9,8 +17,10 @@ const near = (a: readonly number[], b: readonly number[], eps = 1e-6) => const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; // A point where the wobble is zero, so band edges sit exactly where the palette says. const FLAT_X = Math.PI / 0.043; +// A normal for the xz plane alone (all the weight on it), carrying `ny` for the slope bands. +const facing = (ny: number) => [0, ny, 0] as const; const at = (sky: keyof typeof SKY, h: number, ny: number) => - albedoAt(albedoPalette(SKY[sky]), h, ny, FLAT_X, 0); + albedoAt(albedoPalette(SKY[sky]), h, facing(ny), FLAT_X, 0); describe('albedo by slope and height', () => { it('the test point really has no wobble', () => { @@ -50,7 +60,7 @@ describe('albedo by slope and height', () => { for (const sky of ['midnight', 'noonday'] as const) for (let h = -20; h < 300; h += 7) for (let ny = 0; ny <= 1; ny += 0.1) - for (const v of albedoAt(albedoPalette(SKY[sky]), h, ny, h * 3, ny * 91)) { + for (const v of albedoAt(albedoPalette(SKY[sky]), h, facing(ny), h * 3, ny * 91)) { expect(v).toBeGreaterThanOrEqual(0); expect(v).toBeLessThanOrEqual(1); } @@ -105,9 +115,14 @@ describe('shader agrees with the pure function on its numbers', () => { }); }); +const UP = [0, 1, 0] as const; +const EAST = [1, 0, 0] as const; + describe('grain', () => { const samples = (rock: number, distance: number) => - Array.from({ length: 400 }, (_, i) => grainAt(i * 1.7, i * 0.9, 100 + i * 0.3, rock, distance)); + Array.from({ length: 400 }, (_, i) => + grainAt(i * 1.7, 100 + i * 0.3, i * 0.9, UP, rock, distance) + ); const spread = (v: number[]) => Math.max(...v) - Math.min(...v); it('varies the surface near the eye, never goes negative, and averages about 1', () => { @@ -119,14 +134,41 @@ describe('grain', () => { it('drops its fine octave with distance, so far ground does not shimmer', () => { const rangeOf = (d: number) => - spread(Array.from({ length: 400 }, (_, i) => grainAt(i * 0.37, i * 0.21, 100, 0, d))); + spread(Array.from({ length: 400 }, (_, i) => grainAt(i * 0.37, 100, i * 0.21, UP, 0, d))); expect(rangeOf(900)).toBeLessThan(rangeOf(50)); }); it('layers the rock: strata show only where the face is rock', () => { const height = (rock: number) => - spread(Array.from({ length: 400 }, (_, i) => grainAt(0, 0, 100 + i * 0.05, rock, 2000))); + spread(Array.from({ length: 400 }, (_, i) => grainAt(0, 100 + i * 0.05, 0, UP, rock, 2000))); expect(height(1)).toBeGreaterThan(0.2); expect(height(0)).toBeLessThan(1e-9); }); }); + +describe('triplanar projection', () => { + it('weights sum to 1 and go to the plane the face looks along', () => { + for (const n of [UP, EAST, [0.6, 0.6, 0.529], [0, 0, 1]] as const) { + const w = triWeights(n); + expect(w[0] + w[1] + w[2]).toBeCloseTo(1, 9); + } + expect(triWeights(UP)).toEqual([0, 1, 0]); + expect(triWeights(EAST)).toEqual([1, 0, 0]); + expect(triWeights([0.8, 0.5, 0.33])[0]).toBeGreaterThan(0.7); + }); + + it('a cliff facing east takes its pattern over (z, y), so it varies with height and not with x: no vertical smear', () => { + const col = (x: number, y: number) => grainAt(x, y, 40, EAST, 0, 50); + const alongY = Array.from({ length: 200 }, (_, i) => col(10, 50 + i * 0.7)); + const alongX = Array.from({ length: 200 }, (_, i) => col(i * 0.7, 50)); + expect(Math.max(...alongY) - Math.min(...alongY)).toBeGreaterThan(0.3); + expect(Math.max(...alongX) - Math.min(...alongX)).toBeLessThan(1e-9); + expect(wobbleAt(5, 80, 40, EAST)).toBeCloseTo(wobbleAt(500, 80, 40, EAST), 12); + expect(wobbleAt(5, 80, 40, EAST)).not.toBeCloseTo(wobbleAt(5, 120, 40, EAST), 3); + }); + + it('level ground takes its pattern over (x, z) alone, as before', () => { + expect(grainAt(12, 10, 30, UP, 0, 50)).toBeCloseTo(grainAt(12, 900, 30, UP, 0, 50), 12); + expect(wobbleAt(12, 10, 30, UP)).toBeCloseTo(wobble(12, 30), 12); + }); +}); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index d452dec8..c9f11ab4 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -79,19 +79,36 @@ export function albedoPalette(d: SkyData): AlbedoPalette { }; } -/** Cheap two-sine value in -1..1 from world position; stands in for noise so no texture or hash is read. */ +/** Cheap two-sine value in -1..1 from a 2D world position; stands in for noise so no texture or hash is read. */ export const wobble = (x: number, z: number): number => Math.sin(x * 0.043 + z * 0.017) * Math.sin(z * 0.051 - x * 0.023); /** - * Linear albedo at a surface point. `height` in metres, `ny` the unit normal's y, `x`/`z` world metres. + * How much each axis-aligned projection plane counts at a surface with unit normal `n`: weights for (yz, xz, xy), summing + * to 1, sharply favouring the plane the face looks along. Procedural patterns are evaluated on all three and blended by + * these (triplanar), so a cliff is not the floor pattern smeared down it. + */ +export function triWeights(n: Vec3): Vec3 { + const w = n.map((c) => Math.abs(c) ** 4); + const sum = w[0] + w[1] + w[2] || 1; + return [w[0] / sum, w[1] / sum, w[2] / sum]; +} + +/** `wobble` blended over the three projection planes, at a surface point with unit normal `n`. */ +export function wobbleAt(x: number, y: number, z: number, n: Vec3): number { + const w = triWeights(n); + return w[0] * wobble(z, y) + w[1] * wobble(x, z) + w[2] * wobble(x, y); +} + +/** + * Linear albedo at a surface point. `y` the height in metres, `n` the unit normal, `x`/`z` world metres. * Rock takes the steep faces at any height; on the rest, soil to scree by height, then snow above the snow * line, thinning to nothing as the face steepens. */ -export function albedoAt(p: AlbedoPalette, height: number, ny: number, x: number, z: number): Vec3 { - const w = wobble(x, z); - const h = height + ALBEDO.wobbleHeight * w; - const flat = ny + ALBEDO.wobbleSlope * w; +export function albedoAt(p: AlbedoPalette, y: number, n: Vec3, x: number, z: number): Vec3 { + const w = wobbleAt(x, y, z, n); + const h = y + ALBEDO.wobbleHeight * w; + const flat = n[1] + ALBEDO.wobbleSlope * w; const ground = (i: number) => mix(p.soil[i], p.scree[i], smooth(ALBEDO.screeFrom, ALBEDO.screeTo, h)); const snow = @@ -103,33 +120,39 @@ export function albedoAt(p: AlbedoPalette, height: number, ny: number, x: number ) as unknown as Vec3; } +const COARSE = [ + [0.31, 0.27], + [-0.22, 0.35], + [0.38, -0.12], +] as const; +const FINE = [ + [1.1, 0.7], + [-0.8, 1.2], + [1.3, -0.6], +] as const; +const waves = (k: ReadonlyArray, a: number, b: number) => + k.reduce((sum, [ka, kb]) => sum + Math.sin(a * ka + b * kb), 0) / 3; + /** - * The multiplier on albedo that gives the surface grain and the rock its layers, from world position (metres), - * height, `rock` (0..1, how much of the face is rock) and the distance from the eye. The shader's `ALB_GRAIN_*` block does the same arithmetic. - * The cavity term beside it in the shader reads the height map and has no twin here. + * The multiplier on albedo that gives the surface grain and the rock its layers, at a surface point (metres) with unit + * normal `n`, `rock` (0..1, how much of the face is rock) and the distance from the eye. Grain is triplanar like the + * wobble. The shader's `ALB_GRAIN_*` block does the same arithmetic. The cavity term beside it in the shader reads the + * height map and has no twin here. */ export function grainAt( x: number, + y: number, z: number, - height: number, + n: Vec3, rock: number, distance: number ): number { - const waves = (k: ReadonlyArray) => - k.reduce((sum, [kx, kz]) => sum + Math.sin(x * kx + z * kz), 0) / 3; - const coarse = waves([ - [0.31, 0.27], - [-0.22, 0.35], - [0.38, -0.12], - ]); - const fine = - waves([ - [1.1, 0.7], - [-0.8, 1.2], - [1.3, -0.6], - ]) * - (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); - const layers = Math.sin(height * 0.9 + wobble(x, z) * 3); + const w = triWeights(n); + const over = (k: ReadonlyArray) => + w[0] * waves(k, z, y) + w[1] * waves(k, x, z) + w[2] * waves(k, x, y); + const coarse = over(COARSE); + const fine = over(FINE) * (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); + const layers = Math.sin(y * 0.9 + wobbleAt(x, y, z, n) * 3); return Math.max( 0, 1 + ALBEDO.grainCoarse * coarse + ALBEDO.grainFine * fine + ALBEDO.strata * rock * layers diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 04d0425b..93670449 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -90,6 +90,12 @@ float hAt(ivec2 t) { return (floor(s.r * 255.0 + 0.5) * 256.0 + floor(s.g * 255.0 + 0.5)) / 65535.0 * ${f1( TERRAIN.hmax )}; +} +// The height at a point in texel units, bilinear over the four texels around it (the packed texture cannot be filtered by the GPU). +float hBil(vec2 p) { + ivec2 i = ivec2(floor(p)); + vec2 f = fract(p); + return mix(mix(hAt(i), hAt(i + ivec2(1, 0)), f.x), mix(hAt(i + ivec2(0, 1)), hAt(i + ivec2(1, 1)), f.x), f.y); }`; export const GRID_VERT = `${HEADER} @@ -100,12 +106,14 @@ out vec2 v_uv; out vec3 v_world; ${FETCH} void main() { - ivec2 t = ivec2(a_uv * float(uTex - 1) + 0.5); + // The grid is packed toward the centre, where the camera stands (terrainWorld.warpUv): s = 2u - 1, 0.5 + 0.5 (0.5 s + 0.5 s |s|). + vec2 s = a_uv * 2.0 - 1.0; + vec2 uv = 0.5 + 0.5 * (0.5 * s + 0.5 * s * abs(s)); // The patch's rim sinks to zero so the grid has no cliff edge to show through the haze. - float rim = 1.0 - smoothstep(0.78, 1.0, 2.0 * max(abs(a_uv.x - 0.5), abs(a_uv.y - 0.5))); - float h = hAt(t) * rim; - v_uv = a_uv; - v_world = vec3((a_uv.x - 0.5) * uSpan, h, (a_uv.y - 0.5) * uSpan); + float rim = 1.0 - smoothstep(0.78, 1.0, 2.0 * max(abs(uv.x - 0.5), abs(uv.y - 0.5))); + float h = hBil(uv * float(uTex - 1)) * rim; + v_uv = uv; + v_world = vec3((uv.x - 0.5) * uSpan, h, (uv.y - 0.5) * uSpan); gl_Position = uViewProj * vec4(v_world, 1.0); }`; @@ -145,23 +153,21 @@ uniform vec3 uCamera, uLightDir, uLight, uAmbient, uSoil, uScree, uRock, uSnow; uniform float uSpan, uHazeK, uSnowLine; out vec4 o; ${FETCH} -// Unnormalised normal at one texel, central differences over its neighbours. -vec3 nAt(ivec2 t, float texel) { - float dx = hAt(t + ivec2(1, 0)) - hAt(t - ivec2(1, 0)); - float dz = hAt(t + ivec2(0, 1)) - hAt(t - ivec2(0, 1)); - return normalize(vec3(-dx, 2.0 * texel, -dz)); -} +// Triplanar patterns: each projection plane's value, blended by the weights of the face's normal (terrainAlbedo.triWeights). +float wob2(vec2 q) { return sin(q.x * 0.043 + q.y * 0.017) * sin(q.y * 0.051 - q.x * 0.023); } +float waves3(vec2 q, vec2 a, vec2 b, vec2 c) { return (sin(dot(q, a)) + sin(dot(q, b)) + sin(dot(q, c))) / 3.0; } void main() { - // The four texels around this fragment, blended, so the shading is continuous and not one flat square per texel. + // Normal from the bilinear height at a continuous position, a stencil 1.5 texels each side: smooth across texel borders. vec2 p = v_uv * float(uTex - 1); - ivec2 t = ivec2(floor(p)); - vec2 f = fract(p); float texel = uSpan / float(uTex - 1); - vec3 n = normalize(mix( - mix(nAt(t, texel), nAt(t + ivec2(1, 0), texel), f.x), - mix(nAt(t + ivec2(0, 1), texel), nAt(t + ivec2(1, 1), texel), f.x), f.y)); + float dx = hBil(p + vec2(1.5, 0.0)) - hBil(p - vec2(1.5, 0.0)); + float dz = hBil(p + vec2(0.0, 1.5)) - hBil(p - vec2(0.0, 1.5)); + vec3 n = normalize(vec3(-dx, 3.0 * texel, -dz)); + vec3 tw = pow(abs(n), vec3(4.0)); + tw /= tw.x + tw.y + tw.z; + vec2 qx = v_world.zy, qy = v_world.xz, qz = v_world.xy; // Procedural albedo, no texture reads; the same arithmetic as terrainAlbedo.albedoAt(). - float w = sin(v_world.x * 0.043 + v_world.z * 0.017) * sin(v_world.z * 0.051 - v_world.x * 0.023); + float w = tw.x * wob2(qx) + tw.y * wob2(qy) + tw.z * wob2(qz); float h = v_world.y + ALB_WOBBLE_H * w; float flat_ = n.y + ALB_WOBBLE_S * w; vec3 ground = mix(uSoil, uScree, smoothstep(ALB_SCREE_FROM, ALB_SCREE_TO, h)); @@ -169,15 +175,14 @@ void main() { float rockW = 1.0 - smoothstep(ALB_ROCK_LO, ALB_ROCK_HI, flat_); vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); // Surface grain and rock strata, the same arithmetic as terrainAlbedo.grainAt(). - vec2 q = v_world.xz; - float coarse = (sin(dot(q, vec2(0.31, 0.27))) + sin(dot(q, vec2(-0.22, 0.35))) + sin(dot(q, vec2(0.38, -0.12)))) / 3.0; - float fine = (sin(dot(q, vec2(1.1, 0.7))) + sin(dot(q, vec2(-0.8, 1.2))) + sin(dot(q, vec2(1.3, -0.6)))) / 3.0; + float coarse = tw.x * waves3(qx, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.y * waves3(qy, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.z * waves3(qz, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)); + float fine = tw.x * waves3(qx, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.y * waves3(qy, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.z * waves3(qz, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)); float near = 1.0 - smoothstep(ALB_FINE_NEAR, ALB_FINE_FAR, length(v_world - uCamera)); float layers = sin(v_world.y * 0.9 + w * 3.0); albedo *= max(0.0, 1.0 + ALB_GRAIN_COARSE * coarse + ALB_GRAIN_FINE * near * fine + ALB_STRATA * rockW * layers); // Cavity: a point lower than its surroundings sits in shade, one higher catches light. Shader-only, from the height map. int r = max(1, int(12.0 / texel)); - ivec2 c = ivec2(p + 0.5); + ivec2 c = ivec2(floor(p + 0.5)); float curv = 0.25 * (hAt(c + ivec2(r, 0)) + hAt(c - ivec2(r, 0)) + hAt(c + ivec2(0, r)) + hAt(c - ivec2(0, r))) - hAt(c); float cavity = 1.0 - 0.55 * clamp(curv / 12.0, 0.0, 1.0) + 0.3 * clamp(-curv / 12.0, 0.0, 1.0); vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); diff --git a/src/app/features/scene/terrain/terrainWorld.test.ts b/src/app/features/scene/terrain/terrainWorld.test.ts index 5d21ba47..17935382 100644 --- a/src/app/features/scene/terrain/terrainWorld.test.ts +++ b/src/app/features/scene/terrain/terrainWorld.test.ts @@ -1,6 +1,15 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ import { describe, expect, it } from 'vitest'; -import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; +import { + buildGrid, + CAMERA, + GRID, + seedOffset, + TERRAIN, + triangleCount, + view, + warpUv, +} from './terrainWorld'; import { heightFragmentSource } from './terrainShaders'; describe('seed', () => { @@ -32,7 +41,7 @@ describe('grid', () => { }); it('each tier fits a 32-bit index and the cheap tiers stay cheap', () => { - expect(triangleCount(GRID.low.grid)).toBeLessThan(40000); + expect(triangleCount(GRID.low.grid)).toBeLessThan(80000); expect(GRID.full.grid).toBeGreaterThan(GRID.low.grid); expect(GRID.still.tex).toBeLessThanOrEqual(GRID.low.tex); }); @@ -79,3 +88,19 @@ describe('camera', () => { expect(Math.hypot(...v.up)).toBeCloseTo(1, 9); }); }); + +describe('warpUv', () => { + it('maps the patch onto itself, monotonic, centre fixed', () => { + expect(warpUv(0)).toBe(0); + expect(warpUv(1)).toBe(1); + expect(warpUv(0.5)).toBe(0.5); + for (let i = 0; i < 100; i += 1) expect(warpUv((i + 1) / 100)).toBeGreaterThan(warpUv(i / 100)); + }); + + it('packs the vertices toward the camera at the centre: finer there, coarser at the rim, than a plain grid', () => { + const plain = 1 / 192; + const step = (u: number) => warpUv(u + 1 / 192) - warpUv(u); + expect(step(0.5)).toBeLessThan(0.6 * plain); + expect(step(0.99)).toBeGreaterThan(1.3 * plain); + }); +}); diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts index 97577054..fcc02390 100644 --- a/src/app/features/scene/terrain/terrainWorld.ts +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -8,8 +8,8 @@ export const TERRAIN = { span: 3000, hmax: 256 }; /** Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. */ export const GRID = { still: { grid: 128, tex: 512 }, - low: { grid: 128, tex: 1024 }, - full: { grid: 192, tex: 1024 }, + low: { grid: 192, tex: 1024 }, + full: { grid: 256, tex: 1024 }, } as const; /** The camera: at the patch centre, above the highest peak, turning slowly and looking a little down. */ @@ -29,6 +29,16 @@ export function seedOffset(seed: number): [number, number] { return [(s * 7919) % 65536, (s * 104729) % 65536]; } +/** + * Where grid coordinate `u` (0..1 across the patch) lands in the patch, 0..1. The camera stands at the centre, so the + * grid is packed toward it: with s = 2u - 1 the spacing grows as 0.5 + |s| of the plain grid's, about half of it at the + * centre and half again as much at the rim. The vertex shader does the same arithmetic (terrainShaders.ts GRID_VERT). + */ +export const warpUv = (u: number): number => { + const s = 2 * u - 1; + return 0.5 + 0.5 * (0.5 * s + 0.5 * s * Math.abs(s)); +}; + export const triangleCount = (grid: number): number => 2 * grid * grid; /** Vertex grid in unit uv: (grid + 1)^2 positions, then the two-triangle-per-quad index list. */ From c35ac7e5cd7b0660302ee47534ccc7680f4bce8c Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 18:16:47 +0000 Subject: [PATCH 018/111] scene: terrain haze is aerial perspective from a baked Hillaire camera volume Replaces the exp(-(k d)^1.6) haze with the camera volume of Hillaire, EGSR 2020 section 5.4 (and sebh/UnrealEngineSkyAtmosphere RenderCameraVolumePS): slices on the squared distribution, in-scatter integrated with each step's extinction analytic, alpha the grey opacity 1 - mean(T). Baked into baked.scatter.aerial_perspective by src/sky/scatter.js (RGBA16F 7 x 96 x 12, 86 KB), looked up in the terrain fragment shader (two fetches). Night: the moon lights the haze through the same volume, plus a deep blue starlight floor. Shortcuts, with units and constants, in src/sky/README.md: sun-relative indexing, 12 x 8 x 7 texels, path = scene metres x 8, ground ignored, single scattering, no multi-scattering LUT. Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/Terrain.test.ts | 13 +- src/app/features/scene/terrain/Terrain.ts | 33 ++++- .../scene/terrain/terrainAlbedo.test.ts | 2 +- .../scene/terrain/terrainScatter.test.ts | 55 ++++++++- .../features/scene/terrain/terrainScatter.ts | 87 +++++++++++++- .../features/scene/terrain/terrainShaders.ts | 28 +++-- .../features/scene/terrain/terrainSky.test.ts | 94 +++++++++------ src/app/features/scene/terrain/terrainSky.ts | 63 ++++++++-- src/sky/README.md | 22 ++++ src/sky/baked.ts | 12 ++ src/sky/scatter.js | 113 ++++++++++++++++++ src/sky/scatter.test.ts | 66 ++++++++++ 12 files changed, 521 insertions(+), 67 deletions(-) diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts index b61b632b..01f2df11 100644 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTerrainScene } from './Terrain'; import { GRID, triangleCount } from './terrainWorld'; import { groundLight, SKY, skyAtSun } from './terrainSky'; -import { SKY_VIEW, skyViewWords } from './terrainScatter'; +import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter'; import { SKY_FRAG } from './terrainShaders'; import { startBackgroundScene } from '../index'; @@ -176,14 +176,17 @@ describe('createTerrainScene', () => { expect(has(ambients({}), groundLight(SKY.midnight).ambient)).toBe(true); }); - it('uploads the baked scattering table once, as half floats, to a 3D texture', () => { + it('uploads the baked sky table and the aerial perspective volume once each, as half floats, to 3D textures', () => { const { gl, calls } = fakeGl(); createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); - expect(calls.texImage3D).toHaveLength(1); + expect(calls.texImage3D).toHaveLength(2); const a = calls.texImage3D[0]; expect([a[3], a[4], a[5]]).toEqual([SKY_VIEW.width, SKY_VIEW.height, SKY_VIEW.depth]); expect(a[9]).toEqual(skyViewWords()); - expect(calls.deleteTexture).toHaveLength(2); + const ap = calls.texImage3D[1]; + expect([ap[3], ap[4], ap[5]]).toEqual([AP_VOLUME.width, AP_VOLUME.height, AP_VOLUME.depth]); + expect(ap[9]).toEqual(apVolumeWords()); + expect(calls.deleteTexture).toHaveLength(3); }); it('dithers the sky output by half an 8-bit step either way, and the terrain not at all', () => { @@ -243,7 +246,7 @@ describe('createTerrainScene', () => { createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); expect(calls.deleteProgram).toHaveLength(3); expect(calls.deleteBuffer).toHaveLength(2); - expect(calls.deleteTexture).toHaveLength(2); + expect(calls.deleteTexture).toHaveLength(3); expect(rafQueue).toHaveLength(0); }); }); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index d3c95ff3..fdc53926 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -2,11 +2,11 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from '../ridge/probe'; import { albedoPalette } from './terrainAlbedo'; import { sunIndex } from '../../../../sky/scatter.js'; -import { SKY_VIEW, skyViewWords } from './terrainScatter'; +import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter'; import { + nightness, glowColour, groundLight, - HAZE_K, chooseSky, SkyName, START_TURNS, @@ -138,6 +138,26 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) ); + // The aerial perspective volume, RGBA16F, half floats as stored (src/sky/scatter.js). + const apTex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_3D, apTex); + gl.texImage3D( + gl.TEXTURE_3D, + 0, + gl.RGBA16F, + AP_VOLUME.width, + AP_VOLUME.height, + AP_VOLUME.depth, + 0, + gl.RGBA, + gl.HALF_FLOAT, + apVolumeWords() + ); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => + gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) + ); gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4); gl.bindTexture(gl.TEXTURE_2D, heightTex); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); @@ -148,6 +168,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { free.forEach((f) => f()); gl.deleteTexture(heightTex); gl.deleteTexture(skyTex); + gl.deleteTexture(apTex); gl.deleteFramebuffer(fbo); gl.deleteProgram(bakeProg); gl.deleteProgram(skyProg); @@ -192,6 +213,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const heading = START_TURNS; const glow = [...glowColour(sky)]; const sunTexel = sunIndex(sky.elevation); + const night = nightness(sky.elevation); + const moonTexel = sunIndex(Math.abs(sky.elevation)); const palette = albedoPalette(sky); const triangles = triangleCount(spec.grid); @@ -252,7 +275,11 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_3D, skyTex); gl.uniform1i(u('uSky'), 1); - gl.uniform1f(u('uHazeK'), HAZE_K); + gl.uniform1f(u('uNight'), night); + gl.uniform1f(u('uMoonIndex'), moonTexel); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_3D, apTex); + gl.uniform1i(u('uAP'), 2); gl.uniform3fv(u('uSoil'), palette.soil); gl.uniform3fv(u('uScree'), palette.scree); gl.uniform3fv(u('uRock'), palette.rock); diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 33855158..2314f758 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -111,7 +111,7 @@ describe('shader agrees with the pure function on its numbers', () => { expect(GRID_FRAG).toContain(`#define ALB_GRAIN_COARSE ${ALBEDO.grainCoarse}`); expect(GRID_FRAG).toContain(`#define ALB_STRATA ${ALBEDO.strata}`); // the only fetch for colour-by-material would be a texture; the height map's texelFetch is the only other read, and the sky lookup is the baked scattering table's - expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky)/g)).toHaveLength(1); + expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky|uAP)/g)).toHaveLength(1); }); }); diff --git a/src/app/features/scene/terrain/terrainScatter.test.ts b/src/app/features/scene/terrain/terrainScatter.test.ts index 782d980e..c153eb5f 100644 --- a/src/app/features/scene/terrain/terrainScatter.test.ts +++ b/src/app/features/scene/terrain/terrainScatter.test.ts @@ -1,8 +1,21 @@ /* eslint-disable no-restricted-syntax -- tests walk table texels */ import { describe, expect, it } from 'vitest'; import { baked } from '../../../../sky/baked'; -import { decode, viewElevation } from '../../../../sky/scatter.js'; import { + aerialRay, + AP_ELEVATIONS, + AP_SLICES, + apElevation, + apSliceKm, + decode, + SUN_ELEVATIONS_DEG, + viewElevation, +} from '../../../../sky/scatter.js'; +import { + AERIAL_GLSL, + aerialAt, + AP_VOLUME, + apVolumeWords, SCATTER_GLSL, scatterRadiance, skyViewWords, @@ -86,3 +99,43 @@ describe('the GLSL sampler', () => { expect(SCATTER_GLSL).toContain('* 6.0'); }); }); + +describe('the aerial perspective volume as the terrain reads it', () => { + it('uploads RGBA half floats in the baked layout', () => { + const a = baked.scatter.aerial_perspective; + expect(apVolumeWords()).toHaveLength(a.width * a.height * a.depth * 4); + expect(AP_VOLUME).toEqual({ width: a.width, height: a.height, depth: a.depth }); + }); + + it('on a texel centre the CPU twin returns the baked ray exactly (to half-float precision)', () => { + const sunI = 8; + const ei = 5; + const slice = 6; + const want = aerialRay(SUN_ELEVATIONS_DEG[sunI], apElevation(ei), 90)[slice]; + const got = aerialAt(SUN_ELEVATIONS_DEG[sunI], apElevation(ei), 90, apSliceKm(slice)); + for (let c = 0; c < 3; c += 1) expect(got.L[c]).toBeCloseTo(want[c], 3); + expect(got.alpha).toBeCloseTo(want[3], 3); + }); + + it('between texels it is near the model: the filtering error is small against the signal', () => { + const want = aerialRay(10, -1.3, 75)[AP_SLICES - 2]; + const got = aerialAt(10, -1.3, 75, apSliceKm(AP_SLICES - 2)); + expect(Math.abs(got.alpha - want[3])).toBeLessThan(0.03); + expect(got.L[0] / want[0]).toBeGreaterThan(0.6); + expect(got.L[0] / want[0]).toBeLessThan(1.6); + }); + + it('fades to nothing at the observer, and clamps past the last slice', () => { + expect(aerialAt(45, -2, 90, 0)).toEqual({ L: [0, 0, 0], alpha: 0 }); + const last = aerialAt(45, -2, 90, apSliceKm(AP_SLICES - 1)); + const beyond = aerialAt(45, -2, 90, 1e6); + expect(beyond.alpha).toBeCloseTo(last.alpha, 9); + }); + + it('the shader lookup carries the same constants as the table', () => { + const a = baked.scatter.aerial_perspective; + expect(AERIAL_GLSL).toContain(`/ ${a.height}.0`); + expect(AERIAL_GLSL).toContain(`${a.max_km.toFixed(1)}`); + expect(AERIAL_GLSL).toContain(`${AP_ELEVATIONS}.0 +`); + }); +}); diff --git a/src/app/features/scene/terrain/terrainScatter.ts b/src/app/features/scene/terrain/terrainScatter.ts index 9149ac47..72ebfb5c 100644 --- a/src/app/features/scene/terrain/terrainScatter.ts +++ b/src/app/features/scene/terrain/terrainScatter.ts @@ -2,10 +2,20 @@ // uses them: decoded to half-float words for the GPU, a CPU twin of the shader's sky lookup for tests and // for the sun's colour at an arbitrary elevation, and the GLSL both shaders share. No DOM, no GL. import { baked } from '../../../../sky/baked'; -import { decode, sunIndex, viewIndex } from '../../../../sky/scatter.js'; +import { + AP_DISTANCE_SCALE, + AP_ELEVATIONS, + AP_SLICES, + apElevationIndex, + apSliceIndex, + decode, + sunIndex, + viewIndex, +} from '../../../../sky/scatter.js'; import type { Vec3 } from './terrainSky'; const S = baked.scatter; +const AP = S.aerial_perspective; const SKY = S.sky_view; const VIEW = S.view_elevation_deg; @@ -22,6 +32,11 @@ export const SKY_VIEW = { width: SKY.width, height: SKY.height, depth: SKY.depth export const STORE_SCALE = S.store_scale; export const skyViewWords = (): Uint16Array => tableWords(SKY.data); +/** The aerial perspective volume (layout in src/sky/scatter.js): RGBA16F, x azimuth from the light, y slice-major block of elevations, z light elevation. */ +export const AP_VOLUME = { width: AP.width, height: AP.height, depth: AP.depth } as const; +export const apVolumeWords = (): Uint16Array => tableWords(AP.data); +export { AP_DISTANCE_SCALE }; + /** Decode a stored table to values, once. */ const decoded = (data: string) => { let values: Float32Array | undefined; @@ -32,6 +47,7 @@ const decoded = (data: string) => { }; const transmittanceTable = decoded(S.transmittance.data); const skyTable = decoded(SKY.data); +const apTable = decoded(AP.data); /** Sun colour at normal incidence for a sun at `elevationDeg`: the baked transmittance, per channel. Zero under the horizon. */ export function transmittanceAt(elevationDeg: number): Vec3 { @@ -67,6 +83,52 @@ export function scatterRadiance(sunElDeg: number, viewElDeg: number, azFromSunDe return [out[0], out[1], out[2]]; } +/** + * Aerial perspective along a view ray: in-scatter luminance per channel (for a light of unit irradiance) and opacity + * (1 - mean transmittance), for a light at `lightElDeg`, a ray `viewElDeg` (0 and below) and `azDeg` from the light, + * `km` of baked path along it. Trilinear in (azimuth, elevation, light elevation) and linear between slices, the arithmetic the + * shader's `aerial()` does on the GPU; below the first slice it fades to nothing at the observer (Hillaire's near-slice fade). + */ +export function aerialAt( + lightElDeg: number, + viewElDeg: number, + azDeg: number, + km: number +): { L: Vec3; alpha: number } { + const t = apTable(); + const x = Math.min(Math.abs(azDeg) / 180, 1) * (AP.width - 1); + const e = apElevationIndex(viewElDeg); + const z = sunIndex(lightElDeg); + const texel = (slice: number): number[] => { + const lo = [ + Math.min(Math.floor(x), AP.width - 2), + Math.min(Math.floor(e), AP_ELEVATIONS - 2), + Math.min(Math.floor(z), AP.depth - 2), + ]; + const f = [x - lo[0], e - lo[1], z - lo[2]]; + const out = [0, 0, 0, 0]; + for (let corner = 0; corner < 8; corner += 1) { + const dx = corner % 2; + const dy = Math.floor(corner / 2) % 2; + const dz = Math.floor(corner / 4); + const w = (dx ? f[0] : 1 - f[0]) * (dy ? f[1] : 1 - f[1]) * (dz ? f[2] : 1 - f[2]); + const base = + (((lo[2] + dz) * AP.height + slice * AP_ELEVATIONS + lo[1] + dy) * AP.width + lo[0] + dx) * + 4; + for (let c = 0; c < 4; c += 1) out[c] += w * t[base + c]; + } + return out; + }; + const f = apSliceIndex(km); + const fs = Math.min(Math.max(f, 0), AP_SLICES - 1); + const k = Math.min(Math.floor(fs), AP_SLICES - 2); + const a = texel(k); + const b = texel(k + 1); + const fade = Math.min(Math.max(f + 1, 0), 1); + const v = [0, 1, 2, 3].map((c) => (a[c] + (b[c] - a[c]) * (fs - k)) * fade); + return { L: [v[0], v[1], v[2]], alpha: v[3] }; +} + /** * The lowest view elevation the sky is sampled at, degrees. The table dips toward the ground over its last degree * (its rows below the horizon are for an observer who would hit the ground), so at and below this the sky and the @@ -92,3 +154,26 @@ vec3 skyScatter(vec3 d) { SKY.height }.0, (uSunIndex + 0.5) / ${SKY.depth}.0)).rgb / ${S.store_scale.toFixed(1)}; }`; + +/** + * The aerial perspective lookup, in GLSL: `aerial(lightIndex, viewElDeg, azDeg, km)` is `aerialAt` on the GPU. Needs uAP. + * Each slice is one hardware-filtered fetch, kept inside its block of elevation rows so the filter never reads a + * neighbouring slice; two fetches and a lerp per call. + */ +export const AERIAL_GLSL = ` +uniform highp sampler3D uAP; +vec4 apSlice(float k, float e, float x, float z) { + return texture(uAP, vec3((x + 0.5) / ${AP.width}.0, (k * ${AP.elevations}.0 + e + 0.5) / ${ + AP.height +}.0, (z + 0.5) / ${AP.depth}.0)) / ${S.store_scale.toFixed(1)}; +} +vec4 aerial(float lightIndex, float viewElDeg, float azDeg, float km) { + float e = ${(AP.elevations - 1).toFixed( + 1 + )} * (1.0 - sqrt(clamp(-viewElDeg / ${(-AP.elevation_min_deg).toFixed(1)}, 0.0, 1.0))); + float x = clamp(abs(azDeg) / 180.0, 0.0, 1.0) * ${(AP.width - 1).toFixed(1)}; + float f = ${AP.slices}.0 * sqrt(max(km, 0.0) / ${AP.max_km.toFixed(1)}) - 1.0; + float fs = clamp(f, 0.0, ${(AP.slices - 1).toFixed(1)}); + float k = min(floor(fs), ${(AP.slices - 2).toFixed(1)}); + return mix(apSlice(k, e, x, lightIndex), apSlice(k + 1.0, e, x, lightIndex), fs - k) * clamp(f + 1.0, 0.0, 1.0); +}`; diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 93670449..12428d70 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -8,8 +8,8 @@ import cultmath from '../shaders/CultMath.glsl?raw'; import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; import { ALBEDO } from './terrainAlbedo'; -import { SCATTER_GLSL } from './terrainScatter'; -import { GLOW_ZENITH, HAZE_POWER, SKY_EXPOSURE } from './terrainSky'; +import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter'; +import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, STAR_FLOOR } from './terrainSky'; import { TERRAIN } from './terrainWorld'; const HEADER = @@ -146,11 +146,11 @@ vec3 skyColour(vec3 d) { )}, sqrt(clamp(d.y, 0.0, 1.0))); }`; -export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL} +export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL}${AERIAL_GLSL} in vec2 v_uv; in vec3 v_world; uniform vec3 uCamera, uLightDir, uLight, uAmbient, uSoil, uScree, uRock, uSnow; -uniform float uSpan, uHazeK, uSnowLine; +uniform float uSpan, uSnowLine, uNight, uMoonIndex; out vec4 o; ${FETCH} // Triplanar patterns: each projection plane's value, blended by the weights of the face's normal (terrainAlbedo.triWeights). @@ -187,9 +187,23 @@ void main() { float cavity = 1.0 - 0.55 * clamp(curv / 12.0, 0.0, 1.0) + 0.3 * clamp(-curv / 12.0, 0.0, 1.0); vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); vec3 toSurface = v_world - uCamera; - float T = exp(-pow(uHazeK * length(toSurface), ${lit(HAZE_POWER)})); - vec3 haze = skyColour(normalize(vec3(toSurface.x, 0.0, toSurface.z))); - o = vec4(pow(mix(haze, lit, T), vec3(1.0 / 2.2)), 1.0); + float dist = length(toSurface); + // Aerial perspective (terrainSky.aerialPerspective): the baked volume by this ray's elevation, azimuth from the light and path. + vec3 ray = toSurface / dist; + float el = min(degrees(asin(clamp(ray.y, -1.0, 1.0))), 0.0); + vec2 hz = ray.xz, sz = uSun.xz; + float az = (length(hz) < 1e-4 || length(sz) < 1e-4) ? 0.0 : degrees(acos(clamp(dot(normalize(hz), normalize(sz)), -1.0, 1.0))); + float km = dist * ${lit(AP_DISTANCE_SCALE / 1000)}; + vec4 ap = aerial(uSunIndex, el, az, km); + vec3 L = ap.rgb; + // The moon lights the haze from where the sun is mirrored above the horizon, sharing its azimuth; a starlight floor stays. + if (uNight > 0.0) L += uNight * vec3(${MOON_HAZE.map(lit).join( + ', ' + )}) * aerial(uMoonIndex, el, az, km).rgb; + vec3 haze = 1.0 - exp(-${lit(SKY_EXPOSURE)} * L) + uNight * ap.a * vec3(${STAR_FLOOR.map( + lit +).join(', ')}); + o = vec4(pow(lit * (1.0 - ap.a) + haze, vec3(1.0 / 2.2)), 1.0); }`; // The sky behind: the baked scattering tables by the view ray's elevation and azimuth from the sun, a sun disc, and a diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index 9e5bc91e..5a8e0502 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -1,4 +1,5 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ +import { AP_DISTANCE_SCALE } from './terrainScatter'; import { describe, expect, it } from 'vitest'; import { baked } from '../../../../sky/baked'; import { @@ -13,7 +14,8 @@ import { skyColour, skyNameFor, sunDirection, - transmittance, + aerialPerspective, + metresToKm, groundLight, HAZE_K, HAZE_POWER, @@ -185,47 +187,72 @@ describe('the scattered sky', () => { }); }); -describe('haze', () => { - it('transmittance falls monotonically from 1 toward 0', () => { - let prev = transmittance(0); - expect(prev).toBe(1); - for (const d of [10, 100, 400, 1000, 3000, 8000]) { - const t = transmittance(d); - expect(t).toBeLessThan(prev); - expect(t).toBeGreaterThan(0); - prev = t; +describe('aerial perspective', () => { + const ray = (el: number, az: number): Vec3 => look(el, az); + const along = (d: typeof SKY.midnight, el: number, az: number, m: number) => + aerialPerspective(d, ray(el, az), m); + + it('opacity grows with distance from nothing at the eye toward the far value, never past 1', () => { + let prev = along(SKY.noonday, -2, 200, 0).alpha; + expect(prev).toBe(0); + for (const m of [10, 100, 400, 1000, 2000, 2500]) { + const a = along(SKY.noonday, -2, 200, m).alpha; + expect(a).toBeGreaterThan(prev); + expect(a).toBeLessThanOrEqual(1); + prev = a; } }); - it('far surfaces take the scattered sky at the horizon in their azimuth, at both skies', () => { - for (const d of [...Object.values(SKY), skyAtSun(2, 270)]) { - const far = shade(d, [0.9, 0.2, 0.1], UP, 100000, look(-3, 200)); - const sky = skyColour(d, look(0, 200)); - for (let i = 0; i < 3; i += 1) expect(far[i]).toBeCloseTo(sky[i], 6); - } + it('leaves the near ground its own colour and takes the far ridges', () => { + expect(along(SKY.noonday, -2, 200, 100).alpha).toBeLessThan(0.15); + expect(along(SKY.noonday, -2, 200, 2000).alpha).toBeGreaterThan(0.3); }); - it('haze toward the dusk sun is warmer and brighter than haze away from it', () => { - const dusk = skyAtSun(-1, 270); - const toward = shade(dusk, [0, 0, 0], UP, 100000, look(-3, 270)); - const away = shade(dusk, [0, 0, 0], UP, 100000, look(-3, 90)); - const blueShare = (c: Vec3) => c[2] / (c[0] + c[1] + c[2]); + it('the in-scatter is a function of the baked volume: its colour is bluer than the sun far by day', () => { + const far = along(SKY.noonday, -2, 90, 2400).colour; + expect(far[2]).toBeGreaterThan(far[0]); + }); + + it('haze toward a setting sun is brighter than away from it, and red, not blue (the sun is reddened on its way)', () => { + const dusk = skyAtSun(2, 270); + const toward = along(dusk, -1, 270, 2000).colour; + const away = along(dusk, -1, 90, 2000).colour; expect(luma(toward)).toBeGreaterThan(3 * luma(away)); - expect(blueShare(toward)).toBeLessThan(blueShare(away)); + expect(toward[0]).toBeGreaterThan(toward[2]); }); - it('haze colour differs between the two skies: pale day horizon, dim night one', () => { - const far = (d: typeof SKY.midnight) => luma(shade(d, [0, 0, 0], UP, 1e6, look(-3, 180))); - expect(far(SKY.noonday)).toBeGreaterThan(5 * far(SKY.midnight)); + it('night haze is a deep blue floor, not grey: dark, blue over red by a wide margin, brighter than black', () => { + const c = along(SKY.midnight, -2, 0, 2400).colour; + expect(luma(c)).toBeGreaterThan(0.001); + expect(luma(c)).toBeLessThan(0.08); + expect(c[2]).toBeGreaterThan(2.5 * c[0]); + expect(luma(along(SKY.noonday, -2, 0, 2400).colour)).toBeGreaterThan(5 * luma(c)); }); - it('with distance a dark surface rises toward the haze and a bright one falls toward it', () => { + it('moonlight lights the night haze toward the moon more than away from it', () => { + const moon = groundLight(SKY.midnight).dir; + const toward = aerialPerspective(SKY.midnight, [moon[0], -0.02, moon[2]], 2400).colour; + const away = aerialPerspective(SKY.midnight, [-moon[0], -0.02, -moon[2]], 2400).colour; + expect(luma(toward)).toBeGreaterThan(luma(away)); + }); + + it('with distance a dark surface rises toward the haze and its own colour is lost', () => { const d = SKY.noonday; - const view = look(-4, 200); - const h = luma(skyColour(d, look(0, 200))); + const view = ray(-4, 200); const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x, view)); - expect(dark(2000)).toBeGreaterThan(dark(200)); - expect(dark(2000)).toBeLessThan(h); + expect(dark(2000)).toBeGreaterThan(dark(100)); + const own = (x: number) => 1 - aerialPerspective(d, view, x).alpha; + expect(own(2000)).toBeLessThan(0.7 * own(100)); + }); + + it('is continuous in the sun elevation through dusk: no step between neighbours', () => { + const at = (e: number) => luma(along(skyAtSun(e, 270), -1, 270, 2000).colour); + for (let e = -12; e < 12; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.45); + }); + + it('the metres-to-km law is the scene metres times the baked distance scale', () => { + expect(metresToKm(1000)).toBeCloseTo(AP_DISTANCE_SCALE, 9); + expect(metresToKm(-5)).toBe(0); }); }); @@ -246,13 +273,6 @@ describe('ground light and haze', () => { for (let e = -10; e < 10; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.35); }); - it('haze leaves the near ground nearly clear and takes the far ridges: a power law, not plain exp(-k d)', () => { - expect(transmittance(300)).toBeGreaterThan(0.85); - expect(transmittance(300)).toBeGreaterThan(Math.exp(-HAZE_K * 300)); - expect(transmittance(2500)).toBeLessThan(0.15); - expect(HAZE_POWER).toBeGreaterThan(1); - }); - it('the camera starts with the noon sun off its axis, so the light rakes the relief', () => { const yaw = 2 * Math.PI * START_TURNS; const forward = [Math.sin(yaw), 0, -Math.cos(yaw)]; diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index 697896a3..2efef429 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -4,7 +4,13 @@ import { baked } from '../../../../sky/baked'; import { isNight } from '../ridge/stars'; import { skyColours } from '../ridge/skyColour'; import type { SkyColours } from '../ridge/skyColour'; -import { HORIZON_FLOOR, scatterRadiance, transmittanceAt } from './terrainScatter'; +import { + AP_DISTANCE_SCALE, + aerialAt, + HORIZON_FLOOR, + scatterRadiance, + transmittanceAt, +} from './terrainScatter'; export type SkyName = 'midnight' | 'noonday'; export type Vec3 = readonly [number, number, number]; @@ -129,13 +135,48 @@ export function skyColour(d: SkyData, dir: Vec3): Vec3 { } /** - * Haze: transmittance T = exp(-(k d)^HAZE_POWER) at distance d metres. The power above 1 keeps the near and middle - * ground at its own colour and lets only the far ridges melt toward the sky; a plain exp(-k d) greyed everything. + * Aerial perspective: the baked volume of src/sky/scatter.js (after Hillaire 2020, see there for what follows the paper + * and what does not), looked up by the view ray relative to the light and the metres to the surface. The surface keeps + * (1 - alpha) of its own colour (alpha = 1 - mean transmittance, a grey transmittance as the paper) and gains the in-scatter L, + * lifted by the sky's exposure and tone-mapped the way the sky is, so far ground meets the sky's colour. SHORTCUT: the path is + * the scene's metres times AP_DISTANCE_SCALE (a real atmosphere is nearly clear over 3 km). */ -export const HAZE_K = 0.0007; -export const HAZE_POWER = 1.6; -export const transmittance = (distance: number): number => - Math.exp(-((HAZE_K * Math.max(0, distance)) ** HAZE_POWER)); +export const metresToKm = (metres: number): number => + (Math.max(0, metres) / 1000) * AP_DISTANCE_SCALE; + +/** Moonlight's irradiance on the haze, relative to the sun's 1, at full night. EYEBALLED, far above a real moon (about 3e-6) as the sky exposure is. */ +export const MOON_HAZE: Vec3 = [0.0004, 0.0007, 0.0018]; +/** Starlight and airglow scattered by the air, added to the in-scatter in proportion to alpha, at full night: a deep blue floor, not grey. EYEBALLED. */ +export const STAR_FLOOR: Vec3 = [0.0006, 0.0012, 0.0045]; + +/** Haze in front of a surface seen along `dir` (unit, eye to surface) `distance` metres away: the colour it adds and the surface's loss. */ +export function aerialPerspective( + d: SkyData, + dir: Vec3, + distance: number +): { colour: Vec3; alpha: number } { + const el = Math.min(0, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD); + const sun = sunDirection(d); + const h = Math.hypot(dir[0], dir[2]); + const s = Math.hypot(sun[0], sun[2]); + const az = + h < 1e-4 || s < 1e-4 + ? 0 + : Math.acos(Math.min(1, Math.max(-1, (dir[0] * sun[0] + dir[2] * sun[2]) / (h * s)))) / RAD; + const km = metresToKm(distance); + const night = nightness(d.elevation); + const day = aerialAt(d.elevation, el, az, km); + const L = [...day.L]; + if (night > 0) { + // The moon stands where the sun is mirrored above the horizon (groundLight), so it shares the sun's azimuth. + const moon = aerialAt(Math.abs(d.elevation), el, az, km); + for (let i = 0; i < 3; i += 1) L[i] += MOON_HAZE[i] * night * moon.L[i]; + } + const colour = [0, 1, 2].map( + (i) => 1 - Math.exp(-SKY_EXPOSURE * L[i]) + STAR_FLOOR[i] * night * day.alpha + ) as unknown as Vec3; + return { colour, alpha: day.alpha }; +} /** Added to the ambient on a dark sky, scaled by `nightness`: the baked night floor is too dark to show any form on a phone. */ export const NIGHT_AMBIENT_LIFT = 3; @@ -162,19 +203,17 @@ export const START_TURNS = ((SKY.noonday.azimuth - 100) / 360 + 1) % 1; /** * Linear colour of a surface seen along the unit direction `view` (from the eye to the surface): lambert from * the ground light plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), - * then hazed by distance toward the sky at the horizon in that direction. `normal` is a unit vector. Never negative. + * then aerial perspective (`aerialPerspective`). `normal` is a unit vector. Never negative. */ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, view: Vec3): Vec3 { const light = groundLight(d); const sun = light.dir; const lambert = Math.max(0, normal[0] * sun[0] + normal[1] * sun[1] + normal[2] * sun[2]); const hemi = 0.5 + 0.5 * normal[1]; - const flat = Math.hypot(view[0], view[2]) || 1; - const haze = skyColour(d, [view[0] / flat, 0, view[2] / flat]); - const t = transmittance(distance); + const haze = aerialPerspective(d, view, distance); return [0, 1, 2].map((i) => { const lit = albedo[i] * (light.colour[i] * lambert + light.ambient[i] * Math.max(0, hemi)); - return lit * t + haze[i] * (1 - t); + return lit * (1 - haze.alpha) + haze.colour[i]; }) as unknown as Vec3; } diff --git a/src/sky/README.md b/src/sky/README.md index 7afc80a7..1f816a15 100644 --- a/src/sky/README.md +++ b/src/sky/README.md @@ -81,3 +81,25 @@ Size: 16,128 bytes for `sky_view` (21.5 KB as base64) and 192 bytes for `transmi twice the "few KB" of an 8-bit table, the price of not quantising the glow. `scatter.test.ts` pins the layout, the encoding, the checksums and a set of physical anchors (blue noon sky, red sunset toward the sun, black below the planet's shadow limit). + +### `aerial_perspective`: the camera volume for ground seen through the air + +After Hillaire, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique", EGSR 2020, section 5.4, and its +reference code (sebh/UnrealEngineSkyAtmosphere, `RenderCameraVolumePS` in `RenderSkyRayMarching.hlsl`). RGBA half floats +(same encoding, `store_scale` 64), `width` 7, `height` 96, `depth` 12, 4 channels; one 3D texture, `x` fastest: + +- `x`: azimuth of the view ray from the light, 0, 30, ... 180 degrees (as `sky_view`). +- `y`: `slice * 8 + elevation`: 12 slices, each a block of 8 view elevations, `-50 * (1 - i / 7)^2` degrees (finest at 0, + rays at or above the horizon are clamped to 0). Keep the filter inside a block: sample at `(slice * 8 + e + 0.5) / 96`, `e` in 0..7. +- `z`: light elevation, `sun_elevations_deg`, as `sky_view`. The same volume serves the moon: look up at the moon's elevation. +- Slice `k` is the far side of `max_km * ((k + 1) / 12)^2` km (the paper's squared distribution; `max_km` 20). Between slices interpolate + linearly; below slice 0 fade to zero at the observer, as the reference does for `Slice < 0.5`. +- RGB: in-scatter luminance per steradian for a light of unit irradiance. A: opacity `1 - mean(rgb transmittance)`, the paper's grey + transmittance. Apply as `surface * (1 - A) + L * exposure`. + +Follows the paper: the squared slice distribution, the in-scatter integral with each step's extinction integrated analytically +(`S (1 - exp(-e dt)) / e`), single scattering with Rayleigh and Mie (g 0.8) phase, and the grey opacity in alpha. +Shortcuts: sun-relative azimuth and elevation instead of screen position; 12 x 8 x 7 texels, not 32 x 32 x 32; path lengths are the +scene's metres times `distance_scale` (8), because a real atmosphere is nearly clear across a 3 km patch; the ray ignores the ground +(density held at its ground value, sun visibility tested from the ground sphere); no multi-scattering LUT (the paper's dusk fix); +observer at 100 m. Units: distances in km, radiance per unit sun irradiance, as `sky_view`. 86 KB as base64. diff --git a/src/sky/baked.ts b/src/sky/baked.ts index b732fbc1..0393fdbf 100644 --- a/src/sky/baked.ts +++ b/src/sky/baked.ts @@ -260,5 +260,17 @@ export const baked = { channels: 3, data: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyhRWDusADhBFCV8AUAFcAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABRKEGYE5g40C8wBGQGDAA0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAqQ++DQsGtQrbCHkCiABRAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVgq5CZkEmAXWBNoBMgAbAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwX3BIkCOAIMAvUACwAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/AH2ARkB1wC/AFcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwACvAFcASgA1ABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQAvAA4AFAAIAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEQAHAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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LithKgMmxya1JQYhpSDLHh0ZEBhtFJAMugRDARIADAABAAAA2CqjKp8nYykdKcQlUyXYJCAhQyDUHksa9xmgF0MRkhDfC+ACCAkeA0QAKik1KcAmGigRKC8lPSQOJN8glx+KHvgaRBsxGWsUuhVaEnILVBL6DUoF8CcbKMwlWyZ/JoMk2yLJIncgxR4dHj8b5RstGh4WkhjzFZkQoRbcEzsNNCaKJukkAiU8Jb4joiG2IQUgDx6lHUAbFBzDGoQX2xlCGO0T5RisFqUR6yRDJSIkAiRDJJsitCDZIC0fcB0wHRcbFBwMG0YYsRpEGZYVExqLGIQU5iNHJPQigiIEI6Eh9R8nIGQe5hzEHNYa5xsdG54YHRv1GfkWyBp3GRkWayIGI9shWyHUIc0g0R4sH7EdbxxiHIkaixsHG9EYVBtfGgYYJhsVGmgXRiHQIfEgdSDiIBwg5B1AHhQdChwLHDkaJxvaGukYUBuRGmcYURtxGjIYYCDYIC4ggR8gIAwfKB19HYwcZxt9G+oZxhqeGuwYHBuZGqQYQxuYGokYUx8QIBgfXx4DHxEejhzcHBcc0xr4Gp8ZahpcGuAY0BqBGsMYBhuVGrsYLh7bHggedR0AHj0dDxxWHGMbVhqFGloZExoYGssYehpUGsoYtBp0Gs8YQR3QHSYdtxwsHYscTBvNG7Ya7BkhGhwZwxnVGa8YIxoZGr8YWBo/Gs0YgBz2HGkcGBx8HOwbnRoSGyEakxnNGeQYeRmWGZEYzRnXGagY/Bn8GbkYwxtEHJkbMxvZG/QaDRp4GqQZSBmFGbQYNxlcGXMYfBmTGYkYohm0GZoYxxpmG5gaZBrvGicalxn5GToZChlJGYoY/RgpGVcYMBlSGWcYThlrGXUY/xmAGsoZvhkzGoAZOBmSGeQY2RgZGWgYzBj9GD8Y7hgXGUYYAhkoGU8YYxnOGSkZPhmhGf0Y7hhBGZ4YshjzGE0YphjcGCwYtxjmGCsYwxjvGC8Y8RhLGbAY3xg1GZoYuBgEGWgYlxjYGDoYixjFGCAYkBjFGBoYlBjHGBoYphjzGFwYoRjrGFQYkxjaGEEYhhjIGC0Yfhi8GB8YfBi3GBcYfBi2GBUYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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zjJ/Nyg7YELVQnVDVjrRPI4/IDYLOog9WDSaOFo8ETOsN1U7fTIgN9g6YzIJN8Q6YUD1QNtB3jl+PCA/7TXQOVY9PzR8OEM86DJ9NzM7RDLfNqM6HzK8NoQ6YD22PndALDkSPJ4+uTWYOSc9LjRmODI82DJnNyU7KzLANow6/jGVNmQ6yzrKPC0/Zjg+OxU+gjVgOfg8JTRXOCY83jJmNyk7MTLANpE6ADKQNmU6rjhsOxo+YTdoOpM9RDUnOco8ITRNOB08+zJ6Nz47VTLeNq86IzKuNoM6BzclOmM9OjazOSA9/zTtOJs8ITRHOBg8LjOiN2M7mDIYN+c6aTLsNr86pzVQOds8VzUeObo8tjSzOGw8JDREOBU8dzPcN5Y7/TJxNzg71jJNNxg7xDS4OG48qzSmOGE8bTR5OD88JjRDOBM81zMUONg7izPpN6I7cjPTN487KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8SSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4waynvLIowaynvLIowaynvLIowaynvLIowaynvLIowaynvLIowaynvLIow3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUwzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQz0y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY1ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w99TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/AjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJA9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJBGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtC', }, + aerial_perspective: { + slices: 12, + elevations: 8, + elevation_min_deg: -50, + max_km: 20, + distance_scale: 8, + width: 7, + height: 96, + depth: 12, + channels: 4, + data: 'AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlG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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAA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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAA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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAA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AAASQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlG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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAA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AAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlG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AAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAA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', + }, }, } as const; diff --git a/src/sky/scatter.js b/src/sky/scatter.js index 5781c089..bd12b7e0 100644 --- a/src/sky/scatter.js +++ b/src/sky/scatter.js @@ -193,6 +193,87 @@ export function skyRadiance(sunElevationDeg, viewElevationDeg, azimuthDeg) { return radiance; } +// ---- Aerial perspective ------------------------------------------------------------------------------------------ +// After Hillaire, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique", EGSR 2020 (section 5.4, the +// aerial perspective "camera volume"), and its reference implementation, sebh/UnrealEngineSkyAtmosphere +// Resources/RenderSkyRayMarching.hlsl `RenderCameraVolumePS`: a volume of AP_SLICES slices whose depth grows with the +// square of the slice index (`Slice *= Slice`), each texel holding the single-scattered luminance L accumulated along +// the view ray to that depth, and in alpha the opacity 1 - mean(rgb transmittance) (a grey transmittance, as the paper). +// Applied to a surface as out = surface * (1 - alpha) + L, with L the in-scatter along that ray. +// +// What differs from the paper, and why: +// - the volume is indexed by the view ray relative to the sun (azimuth from the sun, elevation) and sun elevation, not by +// screen position, because the terrain camera turns and the sky tables here are sun-relative too; +// - the path lengths are the scene's length times AP_DISTANCE_SCALE: a real atmosphere is nearly clear over the 3 km patch, +// so the baked path is that many times longer (EYEBALLED); it sets how fast far ridges go to the sky; +// - the ray ignores the ground: below the observer's altitude the density is held at its ground value, and the sun's +// visibility is tested from the ground sphere; the terrain is the ground; +// - single scattering with the sky tables' own air (no multi-scattering LUT, which the paper adds for dusk); +// - AP_SLICES = 12 slices, 8 view elevations and 7 azimuths, not 32 x 32 x 32 screen froxels, to stay phone-sized. +export const AP_SLICES = 12; +export const AP_ELEVATIONS = 8; +/** The view elevations the volume holds run from this down to 0 degrees, finest at 0: -AP_ELEVATION_MIN_DEG * (1 - i / 7)^2. */ +export const AP_ELEVATION_MIN_DEG = 50; +/** Scene metres a slice volume covers: the patch's far corner (about 2.1 km) with margin. */ +export const AP_SCENE_MAX_M = 2500; +export const AP_DISTANCE_SCALE = 8; +/** Physical kilometres the last slice stands for. */ +export const AP_MAX_KM = (AP_SCENE_MAX_M * AP_DISTANCE_SCALE) / 1000; +const AP_SUBSTEPS = 4; + +export const apElevation = (i) => -AP_ELEVATION_MIN_DEG * (1 - i / (AP_ELEVATIONS - 1)) ** 2; +/** Fractional elevation index of a view elevation in degrees (clamped to 0 degrees and below). */ +export const apElevationIndex = (deg) => + (AP_ELEVATIONS - 1) * (1 - Math.sqrt(clamp(-deg / AP_ELEVATION_MIN_DEG, 0, 1))); +/** Distance in km at the far side of slice k (k = 0 .. AP_SLICES - 1): the squared distribution. */ +export const apSliceKm = (k) => AP_MAX_KM * ((k + 1) / AP_SLICES) ** 2; +/** Fractional slice index of a distance in km: below 0 the volume fades to nothing at the observer. */ +export const apSliceIndex = (km) => AP_SLICES * Math.sqrt(Math.max(km, 0) / AP_MAX_KM) - 1; + +/** + * In-scatter luminance per channel (for a sun of unit irradiance) and opacity along a view ray from the observer, + * at the far side of each of AP_SLICES slices: [[r, g, b, alpha] x AP_SLICES]. + */ +export function aerialRay(sunElevationDeg, viewElevationDeg, azimuthDeg) { + const v = viewDirection(viewElevationDeg, azimuthDeg); + const s = sunDirection(sunElevationDeg); + const cosTheta = v[0] * s[0] + v[1] * s[1] + v[2] * s[2]; + const pr = rayleighPhase(cosTheta); + const pm = miePhase(cosTheta); + const out = []; + const along = [0, 0, 0]; + const radiance = [0, 0, 0]; + let from = 0; + for (let k = 0; k < AP_SLICES; k += 1) { + const to = apSliceKm(k); + const dt = (to - from) / AP_SUBSTEPS; + for (let j = 0; j < AP_SUBSTEPS; j += 1) { + const t = from + (j + 0.5) * dt; + let q = [observer[0] + v[0] * t, observer[1] + v[1] * t, observer[2] + v[2] * t]; + const r = Math.hypot(...q); + if (r < PLANET_RADIUS_KM) q = q.map((x) => (x * PLANET_RADIUS_KM) / r); + const h = Math.max(r - PLANET_RADIUS_KM, 0); + const e = extinction(h); + const toSun = opticalDepthToSpace(q, s, 12); + const rm = Math.exp(-h / H_RAYLEIGH); + const mm = Math.exp(-h / H_MIE); + for (let c = 0; c < 3; c += 1) { + // The step's own extinction is integrated analytically (Hillaire eq. 6): S (1 - exp(-e dt)) / e. + const step = Math.exp(-e[c] * dt); + if (toSun) { + const scatter = BETA_RAYLEIGH[c] * rm * pr + BETA_MIE_SCA * mm * pm; + radiance[c] += Math.exp(-along[c] - toSun[c]) * ((scatter * (1 - step)) / e[c]); + } + along[c] += e[c] * dt; + } + } + from = to; + const mean = along.reduce((a, x) => a + Math.exp(-x), 0) / 3; + out.push([...radiance, 1 - mean]); + } + return out; +} + const base64 = (halves) => { const bytes = new Uint8Array(halves.length * 2); halves.forEach((h, i) => { @@ -226,6 +307,26 @@ export function bakeScatter() { }) ); + // x = azimuth from the sun, y = elevation block k * AP_ELEVATIONS + i (slice-major), z = sun elevation: four half floats per texel (L rgb, alpha). + const aerial = new Uint16Array( + SUN_ELEVATIONS_DEG.length * AP_SLICES * AP_ELEVATIONS * AZIMUTH_SAMPLES * 4 + ); + let ap = 0; + SUN_ELEVATIONS_DEG.forEach((sun) => { + const rays = Array.from({ length: AP_ELEVATIONS }, (_, i) => + Array.from({ length: AZIMUTH_SAMPLES }, (_, a) => + aerialRay(sun, apElevation(i), a * AZIMUTH_STEP_DEG) + ) + ); + for (let k = 0; k < AP_SLICES; k += 1) + for (let i = 0; i < AP_ELEVATIONS; i += 1) + for (let a = 0; a < AZIMUTH_SAMPLES; a += 1) + rays[i][a][k].forEach((x) => { + aerial[ap] = encode(x); + ap += 1; + }); + }); + return { planet_radius_km: PLANET_RADIUS_KM, atmosphere_height_km: ATMOSPHERE_HEIGHT_KM, @@ -245,5 +346,17 @@ export function bakeScatter() { channels: 3, data: base64(sky), }, + aerial_perspective: { + slices: AP_SLICES, + elevations: AP_ELEVATIONS, + elevation_min_deg: -AP_ELEVATION_MIN_DEG, + max_km: AP_MAX_KM, + distance_scale: AP_DISTANCE_SCALE, + width: AZIMUTH_SAMPLES, + height: AP_SLICES * AP_ELEVATIONS, + depth: SUN_ELEVATIONS_DEG.length, + channels: 4, + data: base64(aerial), + }, }; } diff --git a/src/sky/scatter.test.ts b/src/sky/scatter.test.ts index 9ebcb014..e0ea9023 100644 --- a/src/sky/scatter.test.ts +++ b/src/sky/scatter.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; import { + AP_ELEVATIONS, + AP_MAX_KM, + AP_SLICES, + apElevation, + apElevationIndex, + apSliceIndex, + apSliceKm, + aerialRay, AZIMUTH_SAMPLES, AZIMUTH_STEP_DEG, STORE_SCALE, @@ -158,3 +166,61 @@ describe('scattering tables', () => { expect([sum(sky), sum(trans)]).toEqual([SKY_CHECKSUM, TRANS_CHECKSUM]); }); }); + +describe('aerial perspective volume', () => { + const ap = scatter.aerial_perspective; + + it('has the documented layout and size: RGBA half floats, x azimuth, y slice-major elevations, z sun elevation', () => { + expect([ap.width, ap.height, ap.depth, ap.channels]).toEqual([ + AZIMUTH_SAMPLES, + AP_SLICES * AP_ELEVATIONS, + SUN_ELEVATIONS_DEG.length, + 4, + ]); + expect(table(ap.data)).toHaveLength(ap.width * ap.height * ap.depth * 4); + }); + + it("slices follow the paper's squared distribution, ending at the covered distance", () => { + expect(apSliceKm(AP_SLICES - 1)).toBeCloseTo(AP_MAX_KM, 9); + expect(apSliceKm(0)).toBeCloseTo(AP_MAX_KM / AP_SLICES ** 2, 9); + for (let k = 0; k < AP_SLICES; k += 1) expect(apSliceIndex(apSliceKm(k))).toBeCloseTo(k, 9); + expect(apSliceIndex(0)).toBeCloseTo(-1, 9); + }); + + it('elevations are finest at the horizon and the index inverts them', () => { + expect(apElevation(AP_ELEVATIONS - 1)).toBeCloseTo(0, 12); + for (let i = 0; i < AP_ELEVATIONS; i += 1) + expect(apElevationIndex(apElevation(i))).toBeCloseTo(i, 9); + expect(apElevation(AP_ELEVATIONS - 1) - apElevation(AP_ELEVATIONS - 2)).toBeLessThan( + apElevation(1) - apElevation(0) + ); + }); + + it("opacity grows with path, in-scatter grows with path, and a sun under the planet's shadow scatters nothing", () => { + const r = aerialRay(45, -2, 90); + for (let k = 1; k < AP_SLICES; k += 1) { + expect(r[k][3]).toBeGreaterThan(r[k - 1][3]); + expect(r[k][0]).toBeGreaterThan(r[k - 1][0]); + } + for (const slice of aerialRay(-18, -2, 0)) expect(slice.slice(0, 3)).toEqual([0, 0, 0]); + }); + + it('is brighter toward a low sun than away, and redder toward it (Rayleigh and Mie phase)', () => { + const toward = aerialRay(2, -2, 0)[AP_SLICES - 1]; + const away = aerialRay(2, -2, 180)[AP_SLICES - 1]; + expect(toward[0]).toBeGreaterThan(20 * away[0]); + expect(toward[0] / toward[2]).toBeGreaterThan(away[0] / away[2]); + }); + + it('opacity does not depend on the sun, only on the path', () => { + expect(aerialRay(45, -2, 0)[5][3]).toBeCloseTo(aerialRay(-18, -2, 90)[5][3], 12); + }); + + it("the baked table is the model's output: spot texels equal aerialRay, to half-float precision", () => { + const t = table(ap.data); + const at = (sun: number, slice: number, el: number, az: number, c: number) => + decode(t[((sun * ap.height + slice * AP_ELEVATIONS + el) * ap.width + az) * 4 + c]); + const r = aerialRay(SUN_ELEVATIONS_DEG[8], apElevation(6), 60)[7]; + for (let c = 0; c < 4; c += 1) expect(at(8, 7, 6, 2, c)).toBeCloseTo(r[c], 3); + }); +}); From f7725545a53166b0c8aacb9ea388e716f781cf49 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 19:30:04 +0000 Subject: [PATCH 019/111] Move the terrain tier label to top-left under the preview banner It sat bottom right and overlapped the chat status line. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/terrain/tierLabel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/terrain/tierLabel.ts b/src/app/features/scene/terrain/tierLabel.ts index 6e3a9236..0b2c79ec 100644 --- a/src/app/features/scene/terrain/tierLabel.ts +++ b/src/app/features/scene/terrain/tierLabel.ts @@ -12,8 +12,8 @@ export function showTierLabel(canvas: HTMLCanvasElement, text: string): (() => v el.textContent = `terrain · ${text}`; Object.assign(el.style, { position: 'absolute', - right: '6px', - bottom: '6px', + left: '2px', + top: '16px', font: '10px monospace', color: 'rgba(255,255,255,.75)', background: 'rgba(0,0,0,.4)', From 20cc2c682d10d2d6f8e2a0c1316bd13c50437a47 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 21:12:07 +0000 Subject: [PATCH 020/111] scene: far terrain fades into the sky's own horizon colour (night glow included) Haze was the Hillaire volume alone, ~40% opaque by 2 km and without the night glow the sky adds, so the far ground sat well off the sky at the horizon (night: sky 73,79,99 vs far ground 20,24,31). Past SKY_FADE the surface is now skyColour flat along the horizon in its ray's azimuth, in the CPU twin and the GLSL alike. Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/terrainShaders.ts | 7 ++- .../features/scene/terrain/terrainSky.test.ts | 43 +++++++++++++------ src/app/features/scene/terrain/terrainSky.ts | 15 ++++++- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 12428d70..5fd01d7b 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -9,7 +9,7 @@ import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; import { ALBEDO } from './terrainAlbedo'; import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter'; -import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, STAR_FLOOR } from './terrainSky'; +import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, SKY_FADE, STAR_FLOOR } from './terrainSky'; import { TERRAIN } from './terrainWorld'; const HEADER = @@ -203,7 +203,10 @@ void main() { vec3 haze = 1.0 - exp(-${lit(SKY_EXPOSURE)} * L) + uNight * ap.a * vec3(${STAR_FLOOR.map( lit ).join(', ')}); - o = vec4(pow(lit * (1.0 - ap.a) + haze, vec3(1.0 / 2.2)), 1.0); + // Past SKY_FADE the surface is the sky at the horizon in this ray's azimuth, the colour the sky pass draws there (terrainSky.aerialPerspective). + float toSky = smoothstep(${lit(SKY_FADE[0])}, ${lit(SKY_FADE[1])}, dist); + vec3 horizon = skyColour(vec3(ray.x, 0.0, ray.z)); + o = vec4(pow(mix(lit * (1.0 - ap.a) + haze, horizon, toSky), vec3(1.0 / 2.2)), 1.0); }`; // The sky behind: the baked scattering tables by the view ray's elevation and azimuth from the sun, a sun disc, and a diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index 5a8e0502..016ee1ce 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -15,6 +15,7 @@ import { skyNameFor, sunDirection, aerialPerspective, + SKY_FADE, metresToKm, groundLight, HAZE_K, @@ -195,7 +196,7 @@ describe('aerial perspective', () => { it('opacity grows with distance from nothing at the eye toward the far value, never past 1', () => { let prev = along(SKY.noonday, -2, 200, 0).alpha; expect(prev).toBe(0); - for (const m of [10, 100, 400, 1000, 2000, 2500]) { + for (const m of [10, 100, 400, 800, 1000, 1300]) { const a = along(SKY.noonday, -2, 200, m).alpha; expect(a).toBeGreaterThan(prev); expect(a).toBeLessThanOrEqual(1); @@ -205,34 +206,34 @@ describe('aerial perspective', () => { it('leaves the near ground its own colour and takes the far ridges', () => { expect(along(SKY.noonday, -2, 200, 100).alpha).toBeLessThan(0.15); - expect(along(SKY.noonday, -2, 200, 2000).alpha).toBeGreaterThan(0.3); + expect(along(SKY.noonday, -2, 200, SKY_FADE[0]).alpha).toBeGreaterThan(0.1); }); it('the in-scatter is a function of the baked volume: its colour is bluer than the sun far by day', () => { - const far = along(SKY.noonday, -2, 90, 2400).colour; + const far = along(SKY.noonday, -2, 90, SKY_FADE[0]).colour; expect(far[2]).toBeGreaterThan(far[0]); }); it('haze toward a setting sun is brighter than away from it, and red, not blue (the sun is reddened on its way)', () => { const dusk = skyAtSun(2, 270); - const toward = along(dusk, -1, 270, 2000).colour; - const away = along(dusk, -1, 90, 2000).colour; + const toward = along(dusk, -1, 270, SKY_FADE[0]).colour; + const away = along(dusk, -1, 90, SKY_FADE[0]).colour; expect(luma(toward)).toBeGreaterThan(3 * luma(away)); expect(toward[0]).toBeGreaterThan(toward[2]); }); it('night haze is a deep blue floor, not grey: dark, blue over red by a wide margin, brighter than black', () => { - const c = along(SKY.midnight, -2, 0, 2400).colour; - expect(luma(c)).toBeGreaterThan(0.001); + const c = along(SKY.midnight, -2, 0, SKY_FADE[0]).colour; + expect(luma(c)).toBeGreaterThan(0.0003); expect(luma(c)).toBeLessThan(0.08); expect(c[2]).toBeGreaterThan(2.5 * c[0]); - expect(luma(along(SKY.noonday, -2, 0, 2400).colour)).toBeGreaterThan(5 * luma(c)); + expect(luma(along(SKY.noonday, -2, 0, SKY_FADE[0]).colour)).toBeGreaterThan(5 * luma(c)); }); it('moonlight lights the night haze toward the moon more than away from it', () => { const moon = groundLight(SKY.midnight).dir; - const toward = aerialPerspective(SKY.midnight, [moon[0], -0.02, moon[2]], 2400).colour; - const away = aerialPerspective(SKY.midnight, [-moon[0], -0.02, -moon[2]], 2400).colour; + const toward = aerialPerspective(SKY.midnight, [moon[0], -0.02, moon[2]], SKY_FADE[0]).colour; + const away = aerialPerspective(SKY.midnight, [-moon[0], -0.02, -moon[2]], SKY_FADE[0]).colour; expect(luma(toward)).toBeGreaterThan(luma(away)); }); @@ -240,16 +241,32 @@ describe('aerial perspective', () => { const d = SKY.noonday; const view = ray(-4, 200); const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x, view)); - expect(dark(2000)).toBeGreaterThan(dark(100)); + expect(dark(SKY_FADE[0])).toBeGreaterThan(dark(100)); const own = (x: number) => 1 - aerialPerspective(d, view, x).alpha; - expect(own(2000)).toBeLessThan(0.7 * own(100)); + expect(own(SKY_FADE[0])).toBeLessThan(0.9 * own(100)); }); it('is continuous in the sun elevation through dusk: no step between neighbours', () => { - const at = (e: number) => luma(along(skyAtSun(e, 270), -1, 270, 2000).colour); + const at = (e: number) => luma(along(skyAtSun(e, 270), -1, 270, SKY_FADE[0]).colour); for (let e = -12; e < 12; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.45); }); + it('far terrain is the sky: past the fade it is the horizon sky colour in its azimuth, by day, dusk and night, whatever it was', () => { + for (const [d, az] of [ + [SKY.midnight, 0], + [skyAtSun(-1, 90), 90], + [skyAtSun(2, 270), 200], + [SKY.noonday, 120], + ] as const) { + const view = ray(-0.5, az); + const hz = skyColour(d, [view[0], 0, view[2]]); + const far = aerialPerspective(d, view, SKY_FADE[1]); + expect(far.alpha).toBe(1); + for (let i = 0; i < 3; i += 1) expect(far.colour[i]).toBeCloseTo(hz[i], 9); + expect(shade(d, [0.5, 0.5, 0.5], UP, SKY_FADE[1] + 500, view)).toEqual(far.colour); + } + }); + it('the metres-to-km law is the scene metres times the baked distance scale', () => { expect(metresToKm(1000)).toBeCloseTo(AP_DISTANCE_SCALE, 9); expect(metresToKm(-5)).toBe(0); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index 2efef429..4034fd93 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -149,6 +149,13 @@ export const MOON_HAZE: Vec3 = [0.0004, 0.0007, 0.0018]; /** Starlight and airglow scattered by the air, added to the in-scatter in proportion to alpha, at full night: a deep blue floor, not grey. EYEBALLED. */ export const STAR_FLOOR: Vec3 = [0.0006, 0.0012, 0.0045]; +/** + * Metres from the eye over which the terrain gives itself up to the sky: past the far end a surface is the sky's own + * colour at the horizon in its azimuth (`skyColour` flat along the horizon, night glow included), so the patch's rim + * meets the sky above and beyond it with no seam. The volume alone is only ~40% opaque by then. EYEBALLED. + */ +export const SKY_FADE: readonly [number, number] = [600, 1350]; + /** Haze in front of a surface seen along `dir` (unit, eye to surface) `distance` metres away: the colour it adds and the surface's loss. */ export function aerialPerspective( d: SkyData, @@ -172,10 +179,14 @@ export function aerialPerspective( const moon = aerialAt(Math.abs(d.elevation), el, az, km); for (let i = 0; i < 3; i += 1) L[i] += MOON_HAZE[i] * night * moon.L[i]; } + const horizon = skyColour(d, [dir[0], 0, dir[2]]); + const f = smooth(SKY_FADE[0], SKY_FADE[1], distance); const colour = [0, 1, 2].map( - (i) => 1 - Math.exp(-SKY_EXPOSURE * L[i]) + STAR_FLOOR[i] * night * day.alpha + (i) => + (1 - f) * (1 - Math.exp(-SKY_EXPOSURE * L[i]) + STAR_FLOOR[i] * night * day.alpha) + + f * horizon[i] ) as unknown as Vec3; - return { colour, alpha: day.alpha }; + return { colour, alpha: (1 - f) * day.alpha + f }; } /** Added to the ambient on a dark sky, scaled by `nightness`: the baked night floor is too dark to show any form on a phone. */ From 245ad875593346fc3fe85def1314d98193b97a51 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 11:30:44 +0000 Subject: [PATCH 021/111] CI: run on the forge's docker runner label The forge has no runner labelled ubuntu-latest; the label that works is docker. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/build-pull-request.yml | 2 +- .github/workflows/docker-pr.yml | 2 +- .github/workflows/lockfile.yml | 2 +- .github/workflows/rat-dev-preview.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 11b5056c..d4da18c3 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -7,7 +7,7 @@ on: jobs: build-pull-request: name: Build pull request - runs-on: ubuntu-latest + runs-on: docker env: PR_NUMBER: ${{github.event.number}} steps: diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 997aba6d..63b48721 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -8,7 +8,7 @@ on: jobs: docker-build: - runs-on: ubuntu-latest + runs-on: docker permissions: contents: read packages: write diff --git a/.github/workflows/lockfile.yml b/.github/workflows/lockfile.yml index a9cc1a34..fe8aba64 100644 --- a/.github/workflows/lockfile.yml +++ b/.github/workflows/lockfile.yml @@ -7,7 +7,7 @@ on: jobs: lockfile_changes: - runs-on: ubuntu-latest + runs-on: docker # Permission overwrite is required for Dependabot PRs, see "Common issues" below. permissions: contents: read diff --git a/.github/workflows/rat-dev-preview.yml b/.github/workflows/rat-dev-preview.yml index 45da8bc4..158afc40 100644 --- a/.github/workflows/rat-dev-preview.yml +++ b/.github/workflows/rat-dev-preview.yml @@ -7,7 +7,7 @@ on: jobs: build: name: Build dev preview - runs-on: ubuntu-latest + runs-on: docker timeout-minutes: 30 steps: - name: Checkout repository From 701025cc4fa4c52e6b95af2749cb950bc78379aa Mon Sep 17 00:00:00 2001 From: "Void (mind)" Date: Mon, 9 Nov 2026 11:40:23 +0000 Subject: [PATCH 022/111] CI: delete docker-pr.yml It needs QEMU, buildx and a ghcr.io login, none of which this forge provides. Eureka's ruling, relayed in brief ratatoskr:follow_up:chat-s4646-4647: nothing here pulls container images (forge registry only, if ever). Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/docker-pr.yml | 62 --------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 .github/workflows/docker-pr.yml diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml deleted file mode 100644 index 63b48721..00000000 --- a/.github/workflows/docker-pr.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: 'Docker check' - -on: - pull_request: - paths: - - 'Dockerfile' - - '.github/workflows/docker-pr.yml' - -jobs: - docker-build: - runs-on: docker - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up QEMU - uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - - - name: Login to Docker Hub #Do not update this action from a outside PR - if: github.event.pull_request.head.repo.fork == false - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - continue-on-error: true - - - name: Login to the Github Container registry #Do not update this action from a outside PR - if: github.event.pull_request.head.repo.fork == false - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - continue-on-error: true - - - name: Extract metadata (tags, labels) for Docker, GHCR - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - with: - images: | - ajbura/cinny - ghcr.io/${{ github.repository }} - - - name: Build Docker image (no push) - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - platforms: linux/amd64 - push: false - load: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - - name: Show Docker images - run: docker images From 9794714f9760d8ffd7925e5f5ec04803a13cab52 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 14:57:08 +0000 Subject: [PATCH 023/111] Room list wash and name line: undo the solid slabs and the boxed pill (#39) Co-Authored-By: Claude Sonnet 5.5 --- .../components/message/layout/layout.css.ts | 51 +++++++++---------- src/app/components/nav/styles.css.ts | 26 ++++++---- 2 files changed, 40 insertions(+), 37 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 731fa0f5..815c579f 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -198,25 +198,19 @@ export const BubbleHeaderOwn = style({ alignSelf: 'flex-end', }); -// Phone: the name is a pill straddling the bubble's top edge, on the side opposite the portrait. +// Phone: name and time are one plain line in flow above the bubble, on the side opposite the +// portrait. It is as wide as the full name needs, up to the room beside the portrait; only a name +// longer than that wraps (and then the time drops below it). It is capped by the viewport, not by +// the column: a short bubble's column is narrower than its own name, and a percentage cap there +// truncated names to one letter. export const BubbleHeaderPose = style({ '@media': { [PHONE]: { - position: 'absolute', - zIndex: 2, - // Anchored by its bottom edge, 13px inside the bubble: a name that wraps grows upward, out of - // the bubble, instead of down over the first line of text. - bottom: `calc(100% - ${toRem(13)})`, - right: toRem(14), - padding: `0 ${toRem(13)}`, - background: color.Secondary.Container, - border: `1px solid ${color.Secondary.ContainerLine}`, - // The pill is as wide as the full name needs, up to the room beside the portrait, then the - // name wraps. It is capped by the viewport, not by the column: a short bubble's column is - // narrower than its own name, and a percentage cap there truncated names to one letter. + alignSelf: 'flex-end', + margin: `0 ${toRem(14)} ${toRem(2)}`, width: 'max-content', maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`, - borderRadius: toRem(14), + textAlign: 'right', }, }, }); @@ -224,8 +218,8 @@ export const BubbleHeaderPose = style({ export const BubbleHeaderPoseAlt = style({ '@media': { [PHONE]: { - right: 'auto', - left: toRem(14), + alignSelf: 'flex-start', + textAlign: 'left', }, }, }); @@ -398,9 +392,8 @@ globalStyle(`${BubbleContentPose} > div`, { }, }); -// The name inside the pill wraps instead of ellipsising: the pill already caps its own width, and -// Username's own nowrap + ellipsis, and the truncate on the Text inside it, clipped long names to -// "Heimdall o…" inside it. +// The name wraps instead of ellipsising: the header already caps its own width, and Username's own +// nowrap + ellipsis, and the truncate on the Text inside it, clipped long names to "Heimdall o…". globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *`, { '@media': { [PHONE]: { @@ -413,11 +406,21 @@ globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *` }); // A name too long for one line takes its own lines and the time drops below it, instead of the -// name shrinking to a sliver beside the time (the pill's one child is the flex row of both). +// name shrinking to a sliver beside the time (the header's one child is the flex row of both). The +// row hugs the side the header sits on, so a dropped time stays under the name's edge. globalStyle(`${BubbleHeaderPose} > div`, { '@media': { [PHONE]: { flexWrap: 'wrap', + justifyContent: 'flex-end', + }, + }, +}); + +globalStyle(`${BubbleHeaderPoseAlt} > div`, { + '@media': { + [PHONE]: { + justifyContent: 'flex-start', }, }, }); @@ -446,14 +449,6 @@ globalStyle(`${scene} ${BubbleContent}`, { globalStyle(`${scene} ${BubbleContentOwn}`, { backgroundColor: [color.Primary.Container, half(color.Primary.Container)], }); -globalStyle(`${scene} ${BubbleHeaderPose}`, { - '@media': { - [PHONE]: { - backgroundColor: [color.Secondary.Container, half(color.Secondary.Container)], - backdropFilter: 'blur(12px)', - }, - }, -}); globalStyle(`${scene} ${BubbleLeftArrow}`, { display: 'none', }); diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 927fee0f..b123f9bc 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -3,6 +3,14 @@ import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; import { surfaceInk } from '../../features/scene/sceneShell.css'; +// Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows +// are three distinct steps above them. +const SCRIM_LABEL = '12%'; +const SCRIM_ROW = '16%'; +const SCRIM_HOVER = '26%'; +const SCRIM_SELECTED = '36%'; +const SCRIM_PRESSED = '48%'; + export const NavCategory = style([ DefaultReset, { @@ -13,9 +21,9 @@ export const NavCategory = style([ export const NavCategoryHeader = style({ gap: config.space.S100, selectors: { - // Category labels sit on the scene like the rows do, and need the same scrim to be read. + // Category labels sit on the scene like the rows do, and need a wash to be read. ':root[data-scene] &': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} 62%, transparent)`, + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_LABEL}, transparent)`, borderRadius: config.radii.R400, }, }, @@ -69,21 +77,21 @@ const NavItemBase = style({ '&[data-hover=true]': { backgroundColor: ContainerHover, }, - // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a steady - // scrim of the theme's own surface, so ridges never show through its text. Hover, the selected row and a - // press each strengthen it a step. + // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a faint + // wash of the theme's own surface: the terrain stays visible, the text stays readable. Hover, + // the selected row and a press each strengthen it a step. ':root[data-scene] &': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} 62%, transparent)`, + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_ROW}, transparent)`, }, ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true]': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} 74%, transparent)`, + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_HOVER}, transparent)`, }, ':root[data-scene] &[aria-selected=true]': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} 84%, transparent)`, + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_SELECTED}, transparent)`, }, [`:root[data-scene] &:has(.${NavLink}:active)`]: { - backgroundColor: `color-mix(in srgb, ${surfaceInk} 92%, transparent)`, + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_PRESSED}, transparent)`, }, [`&:has(.${NavLink}:active)`]: { backgroundColor: ContainerActive, From 9b4d92c9de81638f31cc79660c150f9f100099a4 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 15:19:14 +0000 Subject: [PATCH 024/111] scene: terrain mesh as fine as the height map it is shaded from, cavity read bilinear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phone shots of 'terrain · low' showed hard-edged facets on the near ground and stepped crests. Cause: the grid (192 quads low, 256 full, 7 m wide at the nearest ground) was coarser than the 2.9 m height texels the fragment shader reads its normals from, so gullies the polygons could not carry showed as hard shading edges. Measured by rendering the low tier at 192..384 quads against a 768-quad reference: mean abs error 1.7 / 1.1 / 0.7 / 0.5 on the day frame at 192 / 256 / 320 / 384; the warp's shape barely moved it. - GRID: still 192, low 320, full 384; a test holds every tier to a quad of at most two texels at the nearest ground the camera sees, and the tiers ordered by cost. The low triangle ceiling goes from 80k to 210k (205k used), unmeasured on a phone. - cavity: the shader read it from the nearest texel (blocks of one texel); it is now bilinear (hBil) at a continuous position, with a CPU twin (bilinearHeight, curvatureAt, cavityAt, CAVITY) and a test that shading is continuous across texel borders where the nearest-texel read jumps. Co-Authored-By: Claude Sonnet 5.5 --- .../scene/terrain/terrainAlbedo.test.ts | 50 +++++++++++++++++++ .../features/scene/terrain/terrainAlbedo.ts | 37 ++++++++++++++ .../features/scene/terrain/terrainShaders.ts | 16 +++--- .../scene/terrain/terrainWorld.test.ts | 31 +++++++++++- .../features/scene/terrain/terrainWorld.ts | 12 +++-- 5 files changed, 135 insertions(+), 11 deletions(-) diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 2314f758..17363282 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -5,6 +5,10 @@ import { ALBEDO, albedoAt, albedoPalette, + bilinearHeight, + CAVITY, + cavityAt, + curvatureAt, grainAt, triWeights, wobble, @@ -172,3 +176,49 @@ describe('triplanar projection', () => { expect(wobbleAt(12, 10, 30, UP)).toBeCloseTo(wobble(12, 30), 12); }); }); + +describe('cavity shading', () => { + // A texel grid of heights with one bump; `hAt` is the nearest texel, `bilinearAt` the continuous reading. + const bump = (i: number, j: number) => 6 * Math.exp(-((i - 20) ** 2 + (j - 20) ** 2) / 40); + const nearestAt = (px: number, pz: number) => bump(Math.floor(px + 0.5), Math.floor(pz + 0.5)); + // Largest change in curvature between neighbouring sample positions a hundredth of a texel apart, over a few texels. + const worstStep = (read: (px: number, pz: number) => number) => { + let worst = 0; + for (let px = 14; px < 26; px += 0.01) + worst = Math.max( + worst, + Math.abs(curvatureAt(read, px + 0.01, 20.3, 4) - curvatureAt(read, px, 20.3, 4)) + ); + return worst; + }; + + it('curvature is the mean of four neighbours at the radius minus the centre', () => { + expect(curvatureAt((px, pz) => px * 2 + pz, 7, 9, 3)).toBeCloseTo(0, 12); + expect(curvatureAt((px, pz) => (px - 5) ** 2 + (pz - 5) ** 2, 5, 5, 2)).toBeCloseTo(4, 12); + }); + + it('bilinear height reads the texel exactly at a texel centre and the mean at the middle of four', () => { + const t = (i: number, j: number) => i * 10 + j * 100; + expect(bilinearHeight(t, 3, 4)).toBe(430); + expect(bilinearHeight(t, 3.5, 4.5)).toBeCloseTo(485, 9); + }); + + it('shading from a bilinear read changes smoothly across texel borders; from the nearest texel it jumps', () => { + const smooth = worstStep((px, pz) => bilinearHeight(bump, px, pz)); + const blocky = worstStep(nearestAt); + expect(blocky).toBeGreaterThan(0.1); + expect(smooth).toBeLessThan(0.02); + }); + + it('the cavity factor darkens a hollow, lights a rise and leaves flat ground alone', () => { + expect(cavityAt(0)).toBe(1); + expect(cavityAt(CAVITY.range)).toBeCloseTo(1 - CAVITY.dark, 12); + expect(cavityAt(-CAVITY.range)).toBeCloseTo(1 + CAVITY.light, 12); + expect(cavityAt(10 * CAVITY.range)).toBeCloseTo(1 - CAVITY.dark, 12); + }); + + it('the grid shader reads the cavity through the bilinear height, the same constants as CAVITY', () => { + expect(GRID_FRAG).toContain(`#define CAV_RANGE ${CAVITY.range.toFixed(1)}`); + expect(GRID_FRAG).not.toMatch(/curv[^;]*hAt\(/); + }); +}); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index c9f11ab4..fef204ff 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -158,3 +158,40 @@ export function grainAt( 1 + ALBEDO.grainCoarse * coarse + ALBEDO.grainFine * fine + ALBEDO.strata * rock * layers ); } + +/** The cavity term's numbers: curvature (metres) at which a hollow is fully shaded / a rise fully lit, and by how much. */ +export const CAVITY = { radius: 12, range: 12, dark: 0.55, light: 0.3 } as const; + +/** Height at a continuous texel position, bilinear over the four texels around it (the shader's `hBil`). */ +export function bilinearHeight( + texel: (i: number, j: number) => number, + px: number, + pz: number +): number { + const i = Math.floor(px); + const j = Math.floor(pz); + const fx = px - i; + const fz = pz - j; + return mix( + mix(texel(i, j), texel(i + 1, j), fx), + mix(texel(i, j + 1), texel(i + 1, j + 1), fx), + fz + ); +} + +/** Mean of the four neighbours `r` texels away minus the centre: positive in a hollow, negative on a rise. */ +export function curvatureAt( + height: (px: number, pz: number) => number, + px: number, + pz: number, + r: number +): number { + return ( + 0.25 * (height(px + r, pz) + height(px - r, pz) + height(px, pz + r) + height(px, pz - r)) - + height(px, pz) + ); +} + +/** The multiplier on lit colour for a curvature: a hollow sits in shade, a rise catches light (the shader's `cavity`). */ +export const cavityAt = (curv: number): number => + 1 - CAVITY.dark * clamp01(curv / CAVITY.range) + CAVITY.light * clamp01(-curv / CAVITY.range); diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 5fd01d7b..8e270d83 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -7,7 +7,7 @@ import cultmath from '../shaders/CultMath.glsl?raw'; import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; -import { ALBEDO } from './terrainAlbedo'; +import { ALBEDO, CAVITY } from './terrainAlbedo'; import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter'; import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, SKY_FADE, STAR_FLOOR } from './terrainSky'; import { TERRAIN } from './terrainWorld'; @@ -135,6 +135,10 @@ const ALBEDO_DEFINES = ` #define ALB_STRATA ${lit(ALBEDO.strata)} #define ALB_FINE_NEAR ${lit(ALBEDO.fineNear)} #define ALB_FINE_FAR ${lit(ALBEDO.fineFar)} +#define CAV_RADIUS ${lit(CAVITY.radius)} +#define CAV_RANGE ${lit(CAVITY.range)} +#define CAV_DARK ${lit(CAVITY.dark)} +#define CAV_LIGHT ${lit(CAVITY.light)} `; // The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow. The // same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already scaled by how dark the sun is. @@ -180,11 +184,11 @@ void main() { float near = 1.0 - smoothstep(ALB_FINE_NEAR, ALB_FINE_FAR, length(v_world - uCamera)); float layers = sin(v_world.y * 0.9 + w * 3.0); albedo *= max(0.0, 1.0 + ALB_GRAIN_COARSE * coarse + ALB_GRAIN_FINE * near * fine + ALB_STRATA * rockW * layers); - // Cavity: a point lower than its surroundings sits in shade, one higher catches light. Shader-only, from the height map. - int r = max(1, int(12.0 / texel)); - ivec2 c = ivec2(floor(p + 0.5)); - float curv = 0.25 * (hAt(c + ivec2(r, 0)) + hAt(c - ivec2(r, 0)) + hAt(c + ivec2(0, r)) + hAt(c - ivec2(0, r))) - hAt(c); - float cavity = 1.0 - 0.55 * clamp(curv / 12.0, 0.0, 1.0) + 0.3 * clamp(-curv / 12.0, 0.0, 1.0); + // Cavity: a point lower than its surroundings sits in shade, one higher catches light (terrainAlbedo.cavityAt). Shader-only, from the height map, + // read bilinear at a continuous position so it does not snap to a texel (that made the shading blocks of one texel, 3 m). + float cr = CAV_RADIUS / texel; + float curv = 0.25 * (hBil(p + vec2(cr, 0.0)) + hBil(p - vec2(cr, 0.0)) + hBil(p + vec2(0.0, cr)) + hBil(p - vec2(0.0, cr))) - hBil(p); + float cavity = 1.0 - CAV_DARK * clamp(curv / CAV_RANGE, 0.0, 1.0) + CAV_LIGHT * clamp(-curv / CAV_RANGE, 0.0, 1.0); vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); vec3 toSurface = v_world - uCamera; float dist = length(toSurface); diff --git a/src/app/features/scene/terrain/terrainWorld.test.ts b/src/app/features/scene/terrain/terrainWorld.test.ts index 17935382..78ab2700 100644 --- a/src/app/features/scene/terrain/terrainWorld.test.ts +++ b/src/app/features/scene/terrain/terrainWorld.test.ts @@ -41,7 +41,8 @@ describe('grid', () => { }); it('each tier fits a 32-bit index and the cheap tiers stay cheap', () => { - expect(triangleCount(GRID.low.grid)).toBeLessThan(80000); + // 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); expect(GRID.full.grid).toBeGreaterThan(GRID.low.grid); expect(GRID.still.tex).toBeLessThanOrEqual(GRID.low.tex); }); @@ -89,6 +90,34 @@ describe('camera', () => { }); }); +describe('grid resolution', () => { + // The mesh must carry what the height map shows: where a quad is wider than a couple of texels the shading (read from the + // height map) has gullies the polygons cannot, and the near ground reads as hard-edged facets (the 'terrain · low' phone + // shots, 2026-11-09). Metres from the patch centre to the nearest ground the camera can see: the bottom view ray meets + // the highest terrain, hmax below the eye, at (eye - hmax) / tan(pitch + fov / 2). + const nearest = (CAMERA.eye - TERRAIN.hmax) / Math.tan(CAMERA.pitch + CAMERA.fov / 2); + const spacingAt = (grid: number, metres: number) => { + for (let i = 0; i < grid; i += 1) { + const x = (warpUv((i + 1) / grid) - 0.5) * TERRAIN.span; + if (x >= metres) return (warpUv((i + 1) / grid) - warpUv(i / grid)) * TERRAIN.span; + } + throw new Error('beyond the patch'); + }; + + it.each(['still', 'low', 'full'] as const)( + 'the %s tier has a quad no wider than two height texels at the nearest ground the camera can see', + (tier) => { + const { grid, tex } = GRID[tier]; + expect(spacingAt(grid, nearest)).toBeLessThanOrEqual((2 * TERRAIN.span) / (tex - 1)); + } + ); + + it('a dearer tier never has a coarser mesh', () => { + expect(GRID.low.grid).toBeGreaterThanOrEqual(GRID.still.grid); + expect(GRID.full.grid).toBeGreaterThanOrEqual(GRID.low.grid); + }); +}); + describe('warpUv', () => { it('maps the patch onto itself, monotonic, centre fixed', () => { expect(warpUv(0)).toBe(0); diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts index fcc02390..7296025b 100644 --- a/src/app/features/scene/terrain/terrainWorld.ts +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -5,11 +5,15 @@ /** Metres across the patch, and the height in metres that normalised height 1.0 stands for. */ export const TERRAIN = { span: 3000, hmax: 256 }; -/** Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. */ +/** + * Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. The + * mesh is dense enough that a quad is at most two texels wide at the nearest ground (terrainWorld.test.ts); a coarser mesh + * under shading read from the height map shows as hard-edged facets and stepped crests. + */ export const GRID = { - still: { grid: 128, tex: 512 }, - low: { grid: 192, tex: 1024 }, - full: { grid: 256, tex: 1024 }, + still: { grid: 192, tex: 512 }, + low: { grid: 320, tex: 1024 }, + full: { grid: 384, tex: 1024 }, } as const; /** The camera: at the patch centre, above the highest peak, turning slowly and looking a little down. */ From deb4e0dfe6677de394b07bd0ff990cac5c8b238e Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 15:39:38 +0000 Subject: [PATCH 025/111] Room list on a phone: plain text rows in the heading type; no Create Room or Join with Address (#40) --- .../components/message/layout/layout.css.ts | 17 +- src/app/components/nav/styles.css.ts | 158 ++++++++++++++---- src/app/features/estate/EstateNavigator.tsx | 8 +- src/app/features/room-nav/RoomNavItem.tsx | 9 +- src/app/features/room-nav/styles.css.ts | 18 ++ 5 files changed, 174 insertions(+), 36 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 815c579f..a724241c 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -158,6 +158,7 @@ export const BubbleContentArrowLeft = style({ const PHONE = 'screen and (max-width: 700px)'; const PHONE_PORTRAIT = 'min(38vw, 160px)'; const PHONE_TEXT_FLOOR = toRem(170); +const PORTRAIT_OVERHANG = 58; const PORTRAIT = toRem(124); const POSE_GUTTER = toRem(14); const BUBBLE_CORNER = toRem(6); @@ -180,7 +181,6 @@ export const BubbleRowPortrait = style({ '@media': { [PHONE]: { minHeight: 0, - marginTop: toRem(58), }, }, }); @@ -207,6 +207,12 @@ export const BubbleHeaderPose = style({ '@media': { [PHONE]: { alignSelf: 'flex-end', + // The band above the bubble where the portrait overhangs: the header sits at its foot, beside + // the portrait, so the overhang needs no margin of its own. + minHeight: toRem(PORTRAIT_OVERHANG), + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-end', margin: `0 ${toRem(14)} ${toRem(2)}`, width: 'max-content', maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`, @@ -224,6 +230,15 @@ export const BubbleHeaderPoseAlt = style({ }, }); +// A portrait row with no header (nothing to fill the overhang band) keeps the band as a margin. +globalStyle(`${BubbleRowPortrait}:not(:has(.${BubbleHeaderPose}))`, { + '@media': { + [PHONE]: { + marginTop: toRem(PORTRAIT_OVERHANG), + }, + }, +}); + export const BubbleColumnPose = style({ position: 'relative', }); diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index b123f9bc..feee3276 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -1,4 +1,4 @@ -import { ComplexStyleRule, createVar, style } from '@vanilla-extract/css'; +import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; import { surfaceInk } from '../../features/scene/sceneShell.css'; @@ -20,11 +20,16 @@ export const NavCategory = style([ export const NavCategoryHeader = style({ gap: config.space.S100, - selectors: { - // Category labels sit on the scene like the rows do, and need a wash to be read. - ':root[data-scene] &': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_LABEL}, transparent)`, - borderRadius: config.radii.R400, + '@media': { + // Category labels sit on the scene like the rows do, and need a wash to be read (wide screens; + // a phone's list is plain text). + 'screen and (min-width: 751px)': { + selectors: { + ':root[data-scene] &': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_LABEL}, transparent)`, + borderRadius: config.radii.R400, + }, + }, }, }, }); @@ -60,6 +65,12 @@ const getVariant = (variant: ContainerColor): ComplexStyleRule => ({ }, }); +// The room list is plain text on a phone: no pill, slab or border behind any row, and hover, +// selected and pressed show in the label (NavLabel), never in a box. Wider screens keep the rows +// and the wash. +const WIDE = 'screen and (min-width: 751px)'; +const PHONE = 'screen and (max-width: 750px)'; + const NavItemBase = style({ width: '100%', display: 'flex', @@ -70,35 +81,45 @@ const NavItemBase = style({ outline: 'none', minHeight: toRem(36), - selectors: { - '&:hover, &:focus-visible': { - backgroundColor: ContainerHover, + '@media': { + [PHONE]: { + backgroundColor: 'transparent', + borderRadius: 0, }, - '&[data-hover=true]': { - backgroundColor: ContainerHover, - }, - // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a faint - // wash of the theme's own surface: the terrain stays visible, the text stays readable. Hover, - // the selected row and a press each strengthen it a step. - ':root[data-scene] &': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_ROW}, transparent)`, - }, - ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true]': - { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_HOVER}, transparent)`, + [WIDE]: { + selectors: { + '&:hover, &:focus-visible': { + backgroundColor: ContainerHover, + }, + '&[data-hover=true]': { + backgroundColor: ContainerHover, + }, + // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a faint + // wash of the theme's own surface: the terrain stays visible, the text stays readable. + // Hover, the selected row and a press each strengthen it a step. + ':root[data-scene] &': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_ROW}, transparent)`, + }, + ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true]': + { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_HOVER}, transparent)`, + }, + ':root[data-scene] &[aria-selected=true]': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_SELECTED}, transparent)`, + }, + [`:root[data-scene] &:has(.${NavLink}:active)`]: { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_PRESSED}, transparent)`, + }, + [`&:has(.${NavLink}:active)`]: { + backgroundColor: ContainerActive, + }, + '&[aria-selected=true]': { + backgroundColor: ContainerActive, + }, }, - ':root[data-scene] &[aria-selected=true]': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_SELECTED}, transparent)`, - }, - [`:root[data-scene] &:has(.${NavLink}:active)`]: { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_PRESSED}, transparent)`, - }, - [`&:has(.${NavLink}:active)`]: { - backgroundColor: ContainerActive, - }, - '&[aria-selected=true]': { - backgroundColor: ContainerActive, }, + }, + selectors: { [`&:has(.${NavLink}:focus-visible)`]: { outline: `${config.borderWidth.B600} solid ${ContainerLine}`, outlineOffset: `calc(-1 * ${config.borderWidth.B600})`, @@ -158,3 +179,76 @@ export const NavItemContent = style({ export const NavItemOptions = style({ paddingRight: config.space.S200, }); + +// A room's name on a phone wears the section headings' type (small caps-height, uppercase, 600, +// at heading strength) and states itself in the text: hover and focus underline it, the selected +// row is bolder and underlined, a press dims it, an unread room is at full strength. Over the scene +// a halo of the theme's own surface keeps it readable without a box. +export const NavLabel = style({}); +const label = `.${NavLabel}.${NavLabel}`; +const phoneLabel = (selector: string, rule: StyleRule) => + globalStyle(selector, { '@media': { [PHONE]: rule } }); + +phoneLabel(label, { + fontSize: toRem(12), + lineHeight: toRem(16), + fontWeight: config.fontWeight.W600, + textTransform: 'uppercase', + opacity: 0.75, + textDecorationThickness: '1px', + textUnderlineOffset: toRem(4), +}); +phoneLabel( + [ + `.${NavItemBase}:hover ${label}`, + `.${NavItemBase}:focus-visible ${label}`, + `.${NavItemBase}[data-hover=true] ${label}`, + `.${NavItemBase}[data-highlight=true] ${label}`, + ].join(', '), + { opacity: 1 } +); +phoneLabel(`.${NavItemBase}:hover ${label}, .${NavItemBase}:focus-visible ${label}`, { + textDecorationLine: 'underline', +}); +phoneLabel(`.${NavItemBase}[aria-selected=true] ${label}`, { + opacity: 1, + fontWeight: config.fontWeight.W700, + textDecorationLine: 'underline', + textDecorationThickness: '2px', +}); +phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, { + opacity: 0.6, + textDecorationLine: 'underline', +}); +phoneLabel(`:root[data-scene] ${label}`, { + textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, +}); + +// Icons, dots and the Join action share the label's halo over the scene. +globalStyle(`:root[data-scene] ${NavItemBase} svg, :root[data-scene] ${NavItemBase} button`, { + '@media': { + [PHONE]: { + filter: `drop-shadow(0 0 3px ${surfaceInk})`, + }, + }, +}); + +// The Join action on a phone is a plain underlined word, not a pill. +export const NavAction = style({ + '@media': { + [PHONE]: { + selectors: { + '&&': { + backgroundColor: 'transparent', + boxShadow: 'none', + border: 'none', + borderRadius: 0, + minWidth: 0, + textDecoration: 'underline', + textUnderlineOffset: toRem(4), + flexShrink: 0, + }, + }, + }, + }, +}); diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 29451c92..1a9ca8ee 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -10,6 +10,7 @@ import { NavItem, NavItemContent, } from '../../components/nav'; +import { NavAction, NavLabel } from '../../components/nav/styles.css'; import { UseStateProvider } from '../../components/UseStateProvider'; import { JoinAddressPrompt } from '../../components/join-address-prompt'; import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; @@ -80,7 +81,7 @@ function JoinRow({ room }: { room: EstateRoom }) { - + {room.name} @@ -95,6 +96,7 @@ function JoinRow({ room }: { room: EstateRoom }) { )} (null); const mx = useMatrixClient(); const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; + // A phone's list is the estate and nothing else: no Create Room, no Join with Address. + const mobile = useScreenSizeContext() === ScreenSize.Mobile; return ( @@ -302,7 +306,7 @@ export function EstateNavigator() { - + {!mobile && } {rootId && } {!rootId && error && ( diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 08341113..ff23930e 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -21,6 +21,7 @@ import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; import { useAtom, useAtomValue } from 'jotai'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; +import { NavLabel } from '../../components/nav/styles.css'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room'; @@ -358,7 +359,13 @@ export function RoomNavItem({ )} - + {roomName} diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index b5701a6f..b9655923 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -1,8 +1,26 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; +import { surfaceInk } from '../scene/sceneShell.css'; export const CategoryButton = style({ flexGrow: 1, + '@media': { + // A section heading on a phone is plain text: no pill behind it. + 'screen and (max-width: 750px)': { + selectors: { + '&&': { + backgroundColor: 'transparent', + boxShadow: 'none', + border: 'none', + borderRadius: 0, + }, + ':root[data-scene] &&': { + textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, + filter: `drop-shadow(0 0 3px ${surfaceInk})`, + }, + }, + }, + }, }); export const CategoryButtonIcon = style({ opacity: config.opacity.P400, From 030417f27acfd74374a5534b2c0f492e00e0f491 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 16:01:37 +0000 Subject: [PATCH 026/111] CI: drop artifact upload from the build check (#41) --- .github/workflows/build-pull-request.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 11b5056c..4ff842ed 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -8,8 +8,6 @@ jobs: build-pull-request: name: Build pull request runs-on: ubuntu-latest - env: - PR_NUMBER: ${{github.event.number}} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -24,17 +22,3 @@ jobs: env: NODE_OPTIONS: '--max_old_space_size=4096' run: npm run build - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: preview - path: dist - retention-days: 1 - - name: Save pr number - run: echo ${PR_NUMBER} > ./pr.txt - - name: Upload pr number - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: pr - path: ./pr.txt - retention-days: 1 From 78a7dbe9231adecabb6df2af15f362f310a2230d Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 20:17:35 +0000 Subject: [PATCH 027/111] auth: login card fill 50% -> 54% so 4.5:1 holds without backdrop-filter Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/styles.css.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index b45539a6..9a4240f0 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -56,9 +56,9 @@ export const AuthFooter = style({ // 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. The fill is 50%: the 18px blur carries the readability, flattening the sky and -// ridges behind the text. Without the blur, text is 4.15:1 over the brightest backdrop (the horizon -// glow; 4.5:1 needs 54%) and >= 7.3:1 elsewhere in the scene. +// until it lands. The fill is 54%, the least that gives 4.5:1 text over the brightest backdrop (the +// horizon glow) without depending on the blur; it is >= 7.3:1 elsewhere in the scene. The 18px blur +// only softens the sky and ridges behind the text. // 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}`, { @@ -67,8 +67,8 @@ globalStyle(`${scene} ${AuthLayout}`, { }); globalStyle(`${scene} ${AuthCard}`, { backgroundColor: [ - 'rgba(13, 26, 38, 0.5)', - 'color-mix(in srgb, var(--sky-bubble, #0d1a26) 50%, transparent)', + 'rgba(13, 26, 38, 0.54)', + 'color-mix(in srgb, var(--sky-bubble, #0d1a26) 54%, transparent)', ], color: 'var(--sky-bubble-text, #b7c7d9)', borderRadius: config.radii.R500, From a773bc4932e081f3de6b763f8d9467cd1eef989b Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 20:37:16 +0000 Subject: [PATCH 028/111] auth: Noonday Sky login card opaque, so text and link pass AA over any terrain Libby's read: at 54% fill Noonday text was 2.99:1 and the Register link 2.06:1 over shaded terrain. Dark text on a light bubble fails when a dark ground shows through, so Noonday's card now takes the bubble colour with no transparency. Midnight is unchanged (54%). WCAG 2.x ratios, computed (relative luminance, sRGB): Noonday, bubble #b9b6af, text #342d25, link #2a4f27, card over black / white: 54% fill: text 2.23 / 9.51, link 1.54 / 6.55 (fails over dark) 80% fill: text 4.36 / 7.86, link 3.00 / 5.42 (fails over dark) 90% fill: text 5.44 / 7.24, link 3.75 / 4.99 (link fails over dark) 100% fill: text 6.70, link 4.62, independent of backdrop and blur Midnight, bubble #0a090e, text #aeccff, link #609385, 54% fill: over black text 12.49, link 5.83; over white text 2.62, link 1.22 (white is not in the night scene; 4.5:1 over the horizon glow is an estimate, not measured) Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/styles.css.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 9a4240f0..06339710 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -56,9 +56,12 @@ export const AuthFooter = style({ // 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. The fill is 54%, the least that gives 4.5:1 text over the brightest backdrop (the -// horizon glow) without depending on the blur; it is >= 7.3:1 elsewhere in the scene. The 18px blur +// until it lands. The fill is 54% in Midnight Sky (light text on a near-black bubble: 12.5:1 over a +// black backdrop; 54% was estimated as the least for 4.5:1 over the horizon glow). The 18px blur // only softens the sky and ridges behind the text. +// Noonday Sky is dark text on a light bubble, so the dark ground behind a translucent card is what +// fails it (Libby: 2.99:1 text, 2.06:1 Register link at 54%). Its card is opaque: bubble #b9b6af +// holds text #342d25 at 6.70:1 and the link #2a4f27 at 4.62:1 whatever is behind it, blur or not. // 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}`, { @@ -76,6 +79,9 @@ globalStyle(`${scene} ${AuthCard}`, { borderColor: 'rgba(183, 199, 217, 0.14)', backdropFilter: 'blur(18px)', }); +globalStyle(`${scene} body.noonday-sky ${AuthCard}`, { + backgroundColor: 'var(--sky-bubble, #b9b6af)', +}); globalStyle(`${scene} ${AuthHeader}`, { backgroundColor: 'transparent', }); From ef961636c3ab345648ae917d284856a58574ba26 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 20:40:47 +0000 Subject: [PATCH 029/111] auth: opaque login card for every theme, own link colour off the sky themes The scene sky is one fixed dusk for every theme, so a translucent card put Midnight's link at 4.12/3.37 and Light's and Silver's at 2.49 (Libby). The card is now the theme's bubble colour with no transparency and no backdrop-filter, so text and link contrast no longer depend on the scene or a blur. Themes without --sky-bubble (Light, Silver, Dark, Butter) get link #99c7ff on the card (Dark and Butter already used that hue); Midnight and Noonday keep their own. WCAG 2.x ratios, computed, text / Register link on the card: Light, Silver, Dark, Butter #b7c7d9 / #99c7ff on #0d1a26 10.21 / 10.03 Midnight Sky #aeccff / #609385 on #0a090e 12.17 / 5.68 Noonday Sky #342d25 / #2a4f27 on #b9b6af 6.70 / 4.62 Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/styles.css.ts | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 06339710..4fa42d74 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -53,15 +53,17 @@ export const AuthFooter = style({ }); // 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. The fill is 54% in Midnight Sky (light text on a near-black bubble: 12.5:1 over a -// black backdrop; 54% was estimated as the least for 4.5:1 over the horizon glow). The 18px blur -// only softens the sky and ridges behind the text. -// Noonday Sky is dark text on a light bubble, so the dark ground behind a translucent card is what -// fails it (Libby: 2.99:1 text, 2.06:1 Register link at 54%). Its card is opaque: bubble #b9b6af -// holds text #342d25 at 6.70:1 and the link #2a4f27 at 4.62:1 whatever is behind it, blur or not. +// signed-out ground is clear and the card is a pane shaped like a message bubble (BubbleContent: +// radius R500, no border), filled with the sky's bubble colour. The scene is one fixed dusk for +// every theme, so the card is opaque: its text and link contrast cannot depend on what the scene +// shows behind it, nor on a blur. The --sky-bubble variables come from the sky themes (Midnight and +// Noonday); the other themes (Light, Silver, Dark, Butter) take the fallback colours. +// WCAG ratios of text / Register link on the card, per theme: +// Light, Silver, Dark, Butter #b7c7d9 / #99c7ff on #0d1a26 10.21 / 10.03 +// Midnight Sky #aeccff / #609385 on #0a090e 12.17 / 5.68 +// Noonday Sky #342d25 / #2a4f27 on #b9b6af 6.70 / 4.62 +// The fallback link is set here because those themes' own links (Light 2.49:1, Silver 2.49:1) are +// made for their own grounds, not this card; Dark and Butter already use the same hsl(213 100% 80%). // 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}`, { @@ -69,18 +71,14 @@ globalStyle(`${scene} ${AuthLayout}`, { backgroundImage: 'none', }); globalStyle(`${scene} ${AuthCard}`, { - backgroundColor: [ - 'rgba(13, 26, 38, 0.54)', - 'color-mix(in srgb, var(--sky-bubble, #0d1a26) 54%, transparent)', - ], + backgroundColor: 'var(--sky-bubble, #0d1a26)', color: 'var(--sky-bubble-text, #b7c7d9)', borderRadius: config.radii.R500, // A faint inner edge, so a pane this clear still reads as a surface. borderColor: 'rgba(183, 199, 217, 0.14)', - backdropFilter: 'blur(18px)', }); -globalStyle(`${scene} body.noonday-sky ${AuthCard}`, { - backgroundColor: 'var(--sky-bubble, #b9b6af)', +globalStyle(`${scene} body:not(.gamecult-theme) ${AuthCard}`, { + vars: { '--tc-link': '#99c7ff' }, }); globalStyle(`${scene} ${AuthHeader}`, { backgroundColor: 'transparent', From ecdf5e9d29965911463d975d51d809439fadd46b Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:02:01 +0000 Subject: [PATCH 030/111] scene: sky-state seam (auto/day/dusk/night) with a luminance bound per state getSkyState/setSkyState/subscribe over one localStorage key, default auto, tolerant of missing, invalid and refused storage. skyBackdrop(state) returns the darkest and lightest sky luminance the state can show. Tests measure login text and link contrast against it per state and theme. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyState.test.ts | 185 ++++++++++++++++++++++++ src/app/features/scene/skyState.ts | 84 +++++++++++ 2 files changed, 269 insertions(+) create mode 100644 src/app/features/scene/skyState.test.ts create mode 100644 src/app/features/scene/skyState.ts diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts new file mode 100644 index 00000000..71d35fd0 --- /dev/null +++ b/src/app/features/scene/skyState.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { baked } from '../../../sky/baked'; +import { AA, luminance } from '../../../sky/skyTokens.js'; +import { + SKY_STATES, + SKY_STATE_KEY, + skyBackdrop, + type SkyBackdrop, + type SkyState, +} from './skyState'; + +type Mod = typeof import('./skyState'); + +function fakeStorage(initial: Record = {}) { + const data = new Map(Object.entries(initial)); + return { + data, + getItem: (k: string) => data.get(k) ?? null, + setItem: (k: string, v: string) => { + data.set(k, v); + }, + }; +} +const fresh = (): Promise => { + vi.resetModules(); + return import('./skyState'); +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('sky state', () => { + let storage: ReturnType; + beforeEach(() => { + storage = fakeStorage(); + vi.stubGlobal('localStorage', storage); + }); + + it('defaults to auto when nothing is stored', async () => { + expect((await fresh()).getSkyState()).toBe('auto'); + }); + + it.each(['night', 'day', 'dusk', 'auto'] as const)('restores a stored %s', async (s) => { + storage.data.set(SKY_STATE_KEY, s); + expect((await fresh()).getSkyState()).toBe(s); + }); + + it.each(['', 'noon', 'NIGHT', '"night"', 'null'])( + 'falls back to auto on stored %j', + async (v) => { + storage.data.set(SKY_STATE_KEY, v); + expect((await fresh()).getSkyState()).toBe('auto'); + } + ); + + it('falls back to auto when storage throws', async () => { + vi.stubGlobal('localStorage', { + getItem: () => { + throw new Error('denied'); + }, + setItem: () => { + throw new Error('denied'); + }, + }); + const m = await fresh(); + expect(m.getSkyState()).toBe('auto'); + m.setSkyState('night'); + expect(m.getSkyState()).toBe('night'); + }); + + it('persists a set state under one key and reads it back after a reload', async () => { + (await fresh()).setSkyState('dusk'); + expect([...storage.data.entries()]).toEqual([[SKY_STATE_KEY, 'dusk']]); + expect((await fresh()).getSkyState()).toBe('dusk'); + }); + + it('ignores an invalid state', async () => { + const m = await fresh(); + m.setSkyState('night'); + m.setSkyState('noon' as SkyState); + expect(m.getSkyState()).toBe('night'); + expect(storage.data.get(SKY_STATE_KEY)).toBe('night'); + }); + + it('notifies subscribers on change only, and stops after unsubscribe', async () => { + const m = await fresh(); + const cb = vi.fn(); + const off = m.subscribe(cb); + m.setSkyState('day'); + m.setSkyState('day'); + expect(cb.mock.calls).toEqual([['day']]); + off(); + m.setSkyState('night'); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('tells a late subscriber nothing until the next change', async () => { + const m = await fresh(); + m.setSkyState('day'); + const cb = vi.fn(); + m.subscribe(cb); + expect(cb).not.toHaveBeenCalled(); + }); +}); + +describe('skyBackdrop', () => { + it('orders the fixed skies night < dusk < day by their lightest sky', () => { + expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest); + expect(skyBackdrop('dusk').lightest).toBeLessThan(skyBackdrop('day').lightest); + }); + + it('is the luminance of the baked gradients for day and night', () => { + const lum = (g: readonly string[]) => g.map(luminance); + expect(skyBackdrop('day').lightest).toBe(Math.max(...lum(baked.noonday.gradient))); + expect(skyBackdrop('night').darkest).toBe(Math.min(...lum(baked.midnight.gradient))); + }); + + it('bounds every fixed sky when auto', () => { + const a = skyBackdrop('auto'); + (['day', 'dusk', 'night'] as const).forEach((s) => { + expect(a.lightest).toBeGreaterThanOrEqual(skyBackdrop(s).lightest); + expect(a.darkest).toBeLessThanOrEqual(skyBackdrop(s).darkest); + }); + }); + + it('never has darkest above lightest', () => { + SKY_STATES.forEach((s) => + expect(skyBackdrop(s).darkest).toBeLessThanOrEqual(skyBackdrop(s).lightest) + ); + }); +}); + +// Login text and link, per theme, from the card variables in pages/auth/styles.css.ts (the +// fallback pair serves Light, Silver, Dark and Butter) and the sky themes' baked bubble text and +// link. Light ink is measured against the lightest sky it can sit on, dark ink against the darkest. +const inks = [ + { theme: 'Light/Silver/Dark/Butter', text: '#b7c7d9', link: '#99c7ff' }, + { theme: 'Midnight Sky', text: baked.midnight.bubbles.OtherText, link: baked.midnight.link }, + { theme: 'Noonday Sky', text: baked.noonday.bubbles.OtherText, link: baked.noonday.link }, +]; +// WCAG contrast of an ink against a backdrop of the given luminance, with the ink's polarity +// deciding which end of the sky is its worst case. +const ratio = (ink: string, b: SkyBackdrop) => { + const li = luminance(ink); + const bg = li > 0.18 ? b.lightest : b.darkest; + const [hi, lo] = li > bg ? [li, bg] : [bg, li]; + return (hi + 0.05) / (lo + 0.05); +}; + +describe('login ink against the sky backdrop', () => { + const rows = SKY_STATES.flatMap((state) => + inks.flatMap(({ theme, text, link }) => + ( + [ + ['text', text], + ['link', link], + ] as const + ).map(([role, ink]) => ({ state, theme, role, ratio: ratio(ink, skyBackdrop(state)) })) + ) + ); + + it('prints the measured ratios', () => { + // eslint-disable-next-line no-console + console.table(rows.map((r) => ({ ...r, ratio: r.ratio.toFixed(2), ok: r.ratio >= AA }))); + expect(rows).toHaveLength(SKY_STATES.length * inks.length * 2); + }); + + // Bare sky does not hold either ink in most states: the card behind the login form stays opaque + // (pages/auth/styles.css.ts) for that reason. These are the cells that clear AA; every other + // cell is asserted to fail, so a sky or theme change that fixes one turns it red until listed. + const clears = new Set([ + 'day|Noonday Sky|text', + 'day|Noonday Sky|link', + 'night|Light/Silver/Dark/Butter|text', + 'night|Light/Silver/Dark/Butter|link', + 'night|Midnight Sky|text', + ]); + describe.each(rows)('$theme $role in $state', (r) => { + const holds = clears.has(`${r.state}|${r.theme}|${r.role}`); + (holds ? it : it.fails)(holds ? 'is at least AA' : 'is below AA (known)', () => { + expect(r.ratio).toBeGreaterThanOrEqual(AA); + }); + }); +}); diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts new file mode 100644 index 00000000..60e0471f --- /dev/null +++ b/src/app/features/scene/skyState.ts @@ -0,0 +1,84 @@ +// Which sky the signed-out page is drawn against. The state is one value, kept in one +// localStorage key; whoever draws the sky (the scene, the title bar's control) reads it here, and +// `skyBackdrop` says how light or dark the sky behind the login text can be in each state, so the +// text colours can be checked against it. +import { baked } from '../../../sky/baked'; +import { luminance } from '../../../sky/skyTokens.js'; +import { skyColours } from './ridge/skyColour'; + +export type SkyState = 'auto' | 'day' | 'dusk' | 'night'; + +export const SKY_STATES: readonly SkyState[] = ['auto', 'day', 'dusk', 'night']; +export const SKY_STATE_KEY = 'ratatoskr.skyState'; + +const isSkyState = (v: unknown): v is SkyState => SKY_STATES.includes(v as SkyState); + +function load(): SkyState { + try { + const stored = localStorage.getItem(SKY_STATE_KEY); + return isSkyState(stored) ? stored : 'auto'; + } catch { + return 'auto'; + } +} + +let current: SkyState | undefined; +const listeners = new Set<(s: SkyState) => void>(); + +export const getSkyState = (): SkyState => { + current ??= load(); + return current; +}; + +export function setSkyState(s: SkyState): void { + if (!isSkyState(s)) return; + const changed = s !== getSkyState(); + current = s; + try { + localStorage.setItem(SKY_STATE_KEY, s); + } catch { + // Storage is refused (private mode, quota): the choice holds for this page load only. + } + if (changed) [...listeners].forEach((cb) => cb(s)); +} + +export function subscribe(cb: (s: SkyState) => void): () => void { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; +} + +/** + * Relative luminance (WCAG, 0..1) of the lightest and darkest sky the state can show behind the + * page. Light ink is worst against `lightest`, dark ink against `darkest`. 'auto' follows the + * clock, so it can show any of the three fixed skies and bounds all of them. + */ +export interface SkyBackdrop { + readonly darkest: number; + readonly lightest: number; +} + +const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; +const bound = (colours: readonly string[]): SkyBackdrop => { + const l = colours.map(luminance); + return { darkest: Math.min(...l), lightest: Math.max(...l) }; +}; + +const FIXED: Record, SkyBackdrop> = (() => { + const dusk = skyColours(); + return { + day: bound(baked.noonday.gradient), + dusk: bound([dusk.top, dusk.mid, dusk.horizon].map(hex)), + night: bound(baked.midnight.gradient), + }; +})(); + +export function skyBackdrop(state: SkyState): SkyBackdrop { + if (state !== 'auto') return FIXED[state]; + const all = Object.values(FIXED); + return { + darkest: Math.min(...all.map((b) => b.darkest)), + lightest: Math.max(...all.map((b) => b.lightest)), + }; +} From 3b61f2babfc635d7b148708d37f7a3895211b67b Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:05:30 +0000 Subject: [PATCH 031/111] scene: host handles a lost GL context (drop to ground, rebuild on restore) host.ts passes onContextLost to the scene; on loss it disposes the dead scene and calls onInactive (BackgroundScene removes data-scene so the CSS ground shows). A webglcontextrestored listener on the canvas starts a fresh scene; it is removed on dispose, and a stale start is dropped by generation. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/BackgroundScene.tsx | 1 + src/app/features/scene/README.md | 4 +- src/app/features/scene/host.test.ts | 106 ++++++++++++++++++++- src/app/features/scene/host.ts | 60 +++++++++--- 4 files changed, 152 insertions(+), 19 deletions(-) diff --git a/src/app/features/scene/BackgroundScene.tsx b/src/app/features/scene/BackgroundScene.tsx index 643fa06d..10785a5f 100644 --- a/src/app/features/scene/BackgroundScene.tsx +++ b/src/app/features/scene/BackgroundScene.tsx @@ -27,6 +27,7 @@ export function BackgroundScene() { const dispose = mountBackgroundScene(config, readCapabilities(navigator), canvas, { loggedIn: !!getFallbackSession(), onActive: () => root.setAttribute(SCENE_ACTIVE_ATTRIBUTE, ''), + onInactive: () => root.removeAttribute(SCENE_ACTIVE_ATTRIBUTE), // eslint-disable-next-line no-console onFail: (e) => console.warn('Background scene unavailable:', e), }); diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 35075536..fa7565b0 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -82,8 +82,8 @@ holds a GL context gives no 2D one, so a GL failure after context creation (comp only the half extension the prepass start target is RGBA16F, written rounded down), or a shader fails to compile (`onCompileFail` gets the `ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a baked image: it runs the full pipeline once at the top level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context - stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to - show. It caps at 30 fps, pauses when + stops the scene for good and calls `onContextLost`; the scene never rebuilds itself. `host.ts` disposes it, drops + `data-scene` so the CSS ground shows, and starts a fresh scene on `webglcontextrestored`. It caps at 30 fps, pauses when the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion` (a change of the preference while it runs freezes or thaws it), and steps down a quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist. diff --git a/src/app/features/scene/host.test.ts b/src/app/features/scene/host.test.ts index 920e98e4..7d6d3cfe 100644 --- a/src/app/features/scene/host.test.ts +++ b/src/app/features/scene/host.test.ts @@ -1,11 +1,11 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, 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; +let canvas: HTMLCanvasElement; const config = { tier: 'low' as const }; const caps = { hardwareConcurrency: 8 }; @@ -20,6 +20,10 @@ const flush = () => setTimeout(r, 0); }); +beforeEach(() => { + canvas = new EventTarget() as HTMLCanvasElement; +}); + afterEach(() => { startBackgroundScene.mockReset(); }); @@ -103,4 +107,102 @@ describe('mountBackgroundScene', () => { await flush(); expect(scene.login).toHaveBeenCalledOnce(); }); + + describe('a lost WebGL context', () => { + const lose = () => + startBackgroundScene.mock.calls[ + startBackgroundScene.mock.calls.length - 1 + ][2].onContextLost(); + const restore = () => canvas.dispatchEvent(new Event('webglcontextrestored')); + + it('disposes the dead scene and puts the ground back, without rebuilding yet', async () => { + const scene = fakeScene(); + startBackgroundScene.mockResolvedValue(scene); + const onInactive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); + await flush(); + lose(); + expect(scene.dispose).toHaveBeenCalledOnce(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(startBackgroundScene).toHaveBeenCalledOnce(); + }); + + it('starts a fresh scene on restore, activates it and logs in when a session exists', async () => { + const first = fakeScene(); + const second = fakeScene(); + startBackgroundScene.mockResolvedValueOnce(first).mockResolvedValueOnce(second); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: true, onActive }); + await flush(); + lose(); + restore(); + await flush(); + expect(startBackgroundScene).toHaveBeenCalledTimes(2); + expect(onActive).toHaveBeenCalledTimes(2); + expect(second.login).toHaveBeenCalledOnce(); + expect(first.dispose).toHaveBeenCalledOnce(); + }); + + it('rebuilds on every loss and restore, disposing each dead scene once', async () => { + const scenes = [fakeScene(), fakeScene(), fakeScene()]; + scenes.forEach((s) => startBackgroundScene.mockResolvedValueOnce(s)); + mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + lose(); + restore(); + await flush(); + lose(); + restore(); + await flush(); + expect(scenes.map((s) => s.dispose.mock.calls.length)).toEqual([1, 1, 0]); + }); + + it('ignores a restore while a scene is running', async () => { + startBackgroundScene.mockResolvedValue(fakeScene()); + mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + restore(); + await flush(); + expect(startBackgroundScene).toHaveBeenCalledOnce(); + }); + + it('leaves the ground when the rebuild yields no scene', async () => { + startBackgroundScene.mockResolvedValueOnce(fakeScene()).mockResolvedValueOnce(null); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onActive }); + await flush(); + lose(); + restore(); + await flush(); + expect(onActive).toHaveBeenCalledOnce(); + }); + + it('does not rebuild after dispose, and removes its restore listener', async () => { + startBackgroundScene.mockResolvedValue(fakeScene()); + const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + lose(); + dispose(); + restore(); + await flush(); + expect(startBackgroundScene).toHaveBeenCalledOnce(); + }); + + it('drops a scene that resolves after a loss that arrived while it started', async () => { + const late = fakeScene(); + let resolve!: (s: Scene) => void; + startBackgroundScene.mockReturnValueOnce( + new Promise((r) => { + resolve = r; + }) + ); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onActive }); + lose(); + resolve(late); + await flush(); + expect(late.dispose).toHaveBeenCalledOnce(); + expect(onActive).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/features/scene/host.ts b/src/app/features/scene/host.ts index bd28f45c..e771f696 100644 --- a/src/app/features/scene/host.ts +++ b/src/app/features/scene/host.ts @@ -7,6 +7,8 @@ export type HostOptions = { loggedIn: boolean; /** The scene is running: the host may now let it show through. Not called when there is none. */ onActive?: () => void; + /** The scene stopped because its GL context was lost: put the CSS ground back until it restores. */ + onInactive?: () => void; /** A shader failed to compile, the chunk failed to load, or starting threw. The CSS ground stays. */ onFail?: (e: Error) => void; }; @@ -15,34 +17,62 @@ export type HostOptions = { * 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. + * + * A lost WebGL context ends the scene it killed: the host disposes it, calls `onInactive` so the + * CSS ground shows, and starts a fresh scene when the canvas's context is restored. The 2D sky + * cannot take over on this canvas (a canvas that has held a WebGL context gives no 2D one), so the + * ground is the fallback while the context is gone; a restore that cannot rebuild leaves it there. */ export function mountBackgroundScene( config: BackgroundSceneConfig | undefined, capabilities: Capabilities, canvas: HTMLCanvasElement, - { loggedIn, onActive, onFail }: HostOptions + { loggedIn, onActive, onInactive, onFail }: HostOptions ): () => void { let cancelled = false; let scene: Scene | null = null; + let generation = 0; - startBackgroundScene(config, capabilities, { canvas, onCompileFail: onFail }).then( - (started) => { - if (cancelled) { - started?.dispose(); - return; + const start = () => { + generation += 1; + const mine = generation; + const stale = () => cancelled || mine !== generation; + startBackgroundScene(config, capabilities, { + canvas, + onCompileFail: onFail, + onContextLost: () => { + if (stale()) return; + generation += 1; + scene?.dispose(); + scene = null; + onInactive?.(); + }, + }).then( + (started) => { + if (stale()) { + started?.dispose(); + return; + } + scene = started; + if (!scene) return; + onActive?.(); + if (loggedIn) scene.login(); + }, + (e) => { + if (!stale()) onFail?.(e instanceof Error ? e : new Error(String(e))); } - scene = started; - if (!scene) return; - onActive?.(); - if (loggedIn) scene.login(); - }, - (e) => { - if (!cancelled) onFail?.(e instanceof Error ? e : new Error(String(e))); - } - ); + ); + }; + + const onRestored = () => { + if (!cancelled && !scene) start(); + }; + canvas.addEventListener('webglcontextrestored', onRestored); + start(); return () => { cancelled = true; + canvas.removeEventListener('webglcontextrestored', onRestored); scene?.dispose(); scene = null; }; From 571a2de3fab9b204c3e9567b15e334ae41116d37 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:10:36 +0000 Subject: [PATCH 032/111] scene: pin the host's restore-listener removal and stale-loss guard; fix preventDefault comment Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/Scene.ts | 5 ++-- src/app/features/scene/host.test.ts | 42 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index de80583c..c2f0423a 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -599,8 +599,9 @@ export function createScene(opts: SceneOptions): Scene | null { } listen(window, 'resize', () => wake()); // A lost context takes every texture and program with it and nothing here rebuilds them, so the - // scene ends and the host decides what to show. preventDefault: otherwise the browser never - // restores the context, which is what we want, and a restore would find a dead scene. + // scene ends and the host decides what to show. preventDefault is what lets the browser restore + // the context later (without it a lost context stays lost); the host then builds a new scene on + // `webglcontextrestored`, and this one stays dead. listen(canvas, 'webglcontextlost', (e) => { e.preventDefault(); stop(); diff --git a/src/app/features/scene/host.test.ts b/src/app/features/scene/host.test.ts index 7d6d3cfe..272f502f 100644 --- a/src/app/features/scene/host.test.ts +++ b/src/app/features/scene/host.test.ts @@ -188,6 +188,48 @@ describe('mountBackgroundScene', () => { expect(startBackgroundScene).toHaveBeenCalledOnce(); }); + it('removes the exact restore listener it added when it is disposed', async () => { + const add = vi.spyOn(canvas, 'addEventListener'); + const remove = vi.spyOn(canvas, 'removeEventListener'); + startBackgroundScene.mockResolvedValue(fakeScene()); + const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + const added = add.mock.calls.find(([type]) => type === 'webglcontextrestored'); + expect(added).toBeDefined(); + expect(remove).not.toHaveBeenCalled(); + dispose(); + expect(remove).toHaveBeenCalledWith('webglcontextrestored', added?.[1]); + }); + + it('treats a second loss from the same scene as nothing', async () => { + const scene = fakeScene(); + startBackgroundScene.mockResolvedValue(scene); + const onInactive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); + await flush(); + lose(); + lose(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(scene.dispose).toHaveBeenCalledOnce(); + }); + + it('ignores the old scene loss callback once a new scene has started', async () => { + const first = fakeScene(); + const second = fakeScene(); + startBackgroundScene.mockResolvedValueOnce(first).mockResolvedValueOnce(second); + const onInactive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); + await flush(); + const oldLost = startBackgroundScene.mock.calls[0][2].onContextLost; + lose(); + restore(); + await flush(); + onInactive.mockClear(); + oldLost(); + expect(second.dispose).not.toHaveBeenCalled(); + expect(onInactive).not.toHaveBeenCalled(); + }); + it('drops a scene that resolves after a loss that arrived while it started', async () => { const late = fakeScene(); let resolve!: (s: Scene) => void; From a3a500a3d28b1a0830dd40b580515b1a38ad9e5f Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:13:36 +0000 Subject: [PATCH 033/111] scene: skyState test measures ink against both sky ends and takes the worse Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyState.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index 71d35fd0..65e0872d 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -131,22 +131,24 @@ describe('skyBackdrop', () => { }); }); -// Login text and link, per theme, from the card variables in pages/auth/styles.css.ts (the -// fallback pair serves Light, Silver, Dark and Butter) and the sky themes' baked bubble text and -// link. Light ink is measured against the lightest sky it can sit on, dark ink against the darkest. +// Login text and link, per theme, from the card variables in pages/auth/styles.css.ts and the sky +// themes' baked bubble text and link. The Light/Silver/Dark/Butter row uses the ink those themes +// actually render: under data-scene the auth card text is var(--sky-bubble-text, #b7c7d9), not each +// theme's own OnContainer. const inks = [ { theme: 'Light/Silver/Dark/Butter', text: '#b7c7d9', link: '#99c7ff' }, { theme: 'Midnight Sky', text: baked.midnight.bubbles.OtherText, link: baked.midnight.link }, { theme: 'Noonday Sky', text: baked.noonday.bubbles.OtherText, link: baked.noonday.link }, ]; -// WCAG contrast of an ink against a backdrop of the given luminance, with the ink's polarity -// deciding which end of the sky is its worst case. -const ratio = (ink: string, b: SkyBackdrop) => { +// WCAG contrast of an ink against the sky: the worse of its two ends, so a mid-luminance ink is +// measured against whichever end it is closer to. +const against = (ink: string, bg: number) => { const li = luminance(ink); - const bg = li > 0.18 ? b.lightest : b.darkest; const [hi, lo] = li > bg ? [li, bg] : [bg, li]; return (hi + 0.05) / (lo + 0.05); }; +const ratio = (ink: string, b: SkyBackdrop) => + Math.min(against(ink, b.lightest), against(ink, b.darkest)); describe('login ink against the sky backdrop', () => { const rows = SKY_STATES.flatMap((state) => From 32cc8d6ad6226eee879b8c8c3ebfc3e9007e7c9b Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:16:39 +0000 Subject: [PATCH 034/111] scene: skyState test clamps the ink into the sky range for its worst case Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyState.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index 65e0872d..4885abcd 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -140,15 +140,17 @@ const inks = [ { theme: 'Midnight Sky', text: baked.midnight.bubbles.OtherText, link: baked.midnight.link }, { theme: 'Noonday Sky', text: baked.noonday.bubbles.OtherText, link: baked.noonday.link }, ]; -// WCAG contrast of an ink against the sky: the worse of its two ends, so a mid-luminance ink is -// measured against whichever end it is closer to. +// WCAG contrast of an ink against the sky it is worst on. The sky can show any luminance between +// its darkest and lightest, and contrast is lowest where the backdrop is closest to the ink: the +// ink's own luminance clamped into that range. An ink inside the range can land on a backdrop of +// exactly its own luminance (1.00); an ink outside it is worst on the nearer end. const against = (ink: string, bg: number) => { const li = luminance(ink); const [hi, lo] = li > bg ? [li, bg] : [bg, li]; return (hi + 0.05) / (lo + 0.05); }; const ratio = (ink: string, b: SkyBackdrop) => - Math.min(against(ink, b.lightest), against(ink, b.darkest)); + against(ink, Math.min(b.lightest, Math.max(b.darkest, luminance(ink)))); describe('login ink against the sky backdrop', () => { const rows = SKY_STATES.flatMap((state) => From 0e0cff66ae3752cf521272485543a73c472da021 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:20:49 +0000 Subject: [PATCH 035/111] scene: the ridge and terrain scenes follow the sky state The door (index.ts) reads getSkyState, subscribes on start and rebuilds the scene on the same canvas when it changes; the returned scene's dispose unsubscribes. skyForState.ts maps a state to the scene's sky option: auto leaves it unset (today's behaviour), dusk is the ridge dusk, day and night are the baked gradients skyBackdrop bounds. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 123 +++++++++++++++++++++ src/app/features/scene/index.ts | 87 ++++++++++++--- src/app/features/scene/skyForState.test.ts | 65 +++++++++++ src/app/features/scene/skyForState.ts | 52 +++++++++ 4 files changed, 310 insertions(+), 17 deletions(-) create mode 100644 src/app/features/scene/index.test.ts create mode 100644 src/app/features/scene/skyForState.test.ts create mode 100644 src/app/features/scene/skyForState.ts diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts new file mode 100644 index 00000000..5ea2cdab --- /dev/null +++ b/src/app/features/scene/index.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { baked } from '../../../sky/baked'; +import type { Scene } from './Scene'; +import { startBackgroundScene } from './index'; +import { setSkyState } from './skyState'; + +const { createRidgeScene, createScene } = vi.hoisted(() => ({ + createRidgeScene: vi.fn(), + createScene: vi.fn(), +})); +vi.mock('./ridge/Ridge', () => ({ createRidgeScene })); +vi.mock('./Scene', () => ({ createScene })); + +const canvas = {} as HTMLCanvasElement; +const config = { tier: 'low' as const }; +const caps = { hardwareConcurrency: 8 }; +const flush = () => + new Promise((r) => { + setTimeout(r, 0); + }); +const fakeScene = () => ({ login: vi.fn(), dispose: vi.fn() }); +const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; + +beforeEach(() => { + vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }); + setSkyState('auto'); + createRidgeScene.mockReset(); + createScene.mockReset(); +}); +afterEach(() => { + setSkyState('auto'); + vi.unstubAllGlobals(); +}); + +describe('startBackgroundScene follows the sky state', () => { + it('hands the ridge scene no sky for auto, as before there was a state', async () => { + createRidgeScene.mockReturnValue(fakeScene()); + const scene = await startBackgroundScene(config, caps, { canvas }); + expect(createRidgeScene).toHaveBeenCalledOnce(); + expect(createRidgeScene.mock.calls[0][0].sky).toBeUndefined(); + scene?.dispose(); + }); + + it('starts on the stored state', async () => { + setSkyState('night'); + createRidgeScene.mockReturnValue(fakeScene()); + const scene = await startBackgroundScene(config, caps, { canvas }); + expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]); + scene?.dispose(); + }); + + it('rebuilds on a change: the old scene is disposed, the new one draws the new sky', async () => { + const first = fakeScene(); + const second = fakeScene(); + createRidgeScene.mockReturnValueOnce(first).mockReturnValueOnce(second); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + await flush(); + expect(first.dispose).toHaveBeenCalledOnce(); + expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]); + scene?.dispose(); + expect(second.dispose).toHaveBeenCalledOnce(); + }); + + it('replays a login onto the rebuilt scene, but only if one happened', async () => { + const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + await flush(); + expect(b.login).not.toHaveBeenCalled(); + scene?.login(); + expect(b.login).toHaveBeenCalledOnce(); + setSkyState('dusk'); + await flush(); + expect(c.login).toHaveBeenCalledOnce(); + scene?.dispose(); + }); + + it('removes the subscription on dispose', async () => { + const first = fakeScene(); + createRidgeScene.mockReturnValue(first); + const scene = await startBackgroundScene(config, caps, { canvas }); + scene?.dispose(); + scene?.dispose(); + setSkyState('day'); + await flush(); + expect(createRidgeScene).toHaveBeenCalledOnce(); + expect(first.dispose).toHaveBeenCalledOnce(); + }); + + it('drops a scene whose rebuild finishes after dispose', async () => { + const second = fakeScene(); + createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(second); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + scene?.dispose(); + await flush(); + expect(second.dispose).toHaveBeenCalledOnce(); + }); + + it('keeps only the latest of two quick changes', async () => { + const scenes = [fakeScene(), fakeScene(), fakeScene()]; + scenes.forEach((s) => createRidgeScene.mockReturnValueOnce(s)); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + setSkyState('night'); + await flush(); + expect(scenes[1].dispose).toHaveBeenCalledOnce(); + expect(scenes[2].dispose).not.toHaveBeenCalled(); + scene?.dispose(); + }); + + it('leaves the ray-march scene alone: it has one fixed dusk', async () => { + const inner = fakeScene() as unknown as Scene; + createScene.mockReturnValue(inner); + const scene = await startBackgroundScene({ ...config, style: 'raymarch' }, caps, { canvas }); + expect(scene).toBe(inner); + setSkyState('day'); + await flush(); + expect(createScene).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 794ddc11..7dc5b4f0 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -1,5 +1,8 @@ import type { Scene, SceneOptions } from './Scene'; -import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier'; +import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; +import { getSkyState, subscribe } from './skyState'; +import type { SkyState } from './skyState'; +import { ridgeSkyFor, terrainSkyFor } from './skyForState'; export type { Scene, SceneStats } from './Scene'; export type { SceneStyle, SceneTier } from './tier'; @@ -9,6 +12,33 @@ export type StartOptions = Omit void; }; +async function create( + config: BackgroundSceneConfig | undefined, + options: StartOptions & { tier: SceneTier }, + state: SkyState +): Promise { + let createScene: (o: SceneOptions) => Scene | null; + try { + if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; + else if (config?.style === 'terrain') { + const { createTerrainScene } = await import('./terrain/Terrain'); + const sky = terrainSkyFor(state); + const scene = createTerrainScene({ ...options, ...sky }); + // No WebGL2 (or no usable GL): the terrain's sky alone on the same canvas, not bare ground. + if (scene) return scene; + const { createTerrainSkyScene } = await import('./terrain/TerrainSky2D'); + return createTerrainSkyScene({ ...options, ...sky }); + } else { + const { createRidgeScene } = await import('./ridge/Ridge'); + return createRidgeScene({ ...options, sky: ridgeSkyFor(state) }); + } + } catch (e) { + options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); + return null; + } + return createScene(options); +} + /** * The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no * canvas context is requested; otherwise the scene code loads on demand, so it stays out of the @@ -16,6 +46,11 @@ export type StartOptions = Omit { const tier = resolveTier(config, capabilities); if (tier === 'off') return null; - let createScene: (o: SceneOptions) => Scene | null; - try { - if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; - else if (config?.style === 'terrain') { - const { createTerrainScene } = await import('./terrain/Terrain'); - const scene = createTerrainScene({ ...options, tier }); - // No WebGL2 (or no usable GL): the terrain's sky alone on the same canvas, not bare ground. - if (scene) return scene; - const { createTerrainSkyScene } = await import('./terrain/TerrainSky2D'); - return createTerrainSkyScene({ ...options, tier }); - } else createScene = (await import('./ridge/Ridge')).createRidgeScene; - } catch (e) { - options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); - return null; - } - return createScene({ ...options, tier }); + const build = (state: SkyState) => create(config, { ...options, tier }, state); + const first = await build(getSkyState()); + if (!first || config?.style === 'raymarch') return first; + + let inner: Scene | null = first; + let loggedIn = false; + let disposed = false; + let generation = 0; + const off = subscribe(async (state) => { + generation += 1; + const mine = generation; + inner?.dispose(); + inner = null; + const next = await build(state); + if (disposed || mine !== generation) { + next?.dispose(); + return; + } + inner = next; + if (loggedIn) inner?.login(); + }); + return { + login() { + loggedIn = true; + inner?.login(); + }, + dispose() { + if (disposed) return; + disposed = true; + off(); + inner?.dispose(); + inner = null; + }, + }; } diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts new file mode 100644 index 00000000..443e8fdb --- /dev/null +++ b/src/app/features/scene/skyForState.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../sky/baked'; +import { luminance } from '../../../sky/skyTokens.js'; +import { skyColours } from './ridge/skyColour'; +import { DUSK_SUN_ELEVATION, ridgeSkyFor, terrainSkyFor } from './skyForState'; +import { skyBackdrop } from './skyState'; + +const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; +const EPS = 1e-9; + +describe('ridgeSkyFor', () => { + it('leaves auto to the scene, so it draws what it drew before there was a state', () => { + expect(ridgeSkyFor('auto')).toBeUndefined(); + }); + + it('draws the current ridge dusk for dusk', () => { + expect(ridgeSkyFor('dusk')).toEqual(skyColours()); + }); + + it('draws the baked gradients for day and night', () => { + const day = ridgeSkyFor('day'); + const night = ridgeSkyFor('night'); + expect(hex(day?.top ?? [])).toBe(baked.noonday.gradient[0]); + expect(hex(day?.horizon ?? [])).toBe(baked.noonday.gradient[6]); + expect(hex(night?.top ?? [])).toBe(baked.midnight.gradient[0]); + expect(hex(night?.horizon ?? [])).toBe(baked.midnight.gradient[6]); + }); + + it.each(['day', 'dusk', 'night'] as const)( + 'paints a %s sky whose colours lie inside skyBackdrop', + (state) => { + const sky = ridgeSkyFor(state); + const { darkest, lightest } = skyBackdrop(state); + [sky?.top, sky?.mid, sky?.horizon].forEach((c) => { + const l = luminance(hex(c ?? [])); + expect(l).toBeGreaterThanOrEqual(darkest - EPS); + expect(l).toBeLessThanOrEqual(lightest + EPS); + }); + } + ); +}); + +describe('terrainSkyFor', () => { + it('leaves auto to the clock and the URL', () => { + expect(terrainSkyFor('auto')).toEqual({}); + }); + + it('names the baked sky for day and night, and a low sun for dusk', () => { + expect(terrainSkyFor('day')).toEqual({ sky: 'noonday' }); + expect(terrainSkyFor('night')).toEqual({ sky: 'midnight' }); + expect(terrainSkyFor('dusk')).toEqual({ sun: { elevation: DUSK_SUN_ELEVATION } }); + }); + + it.each([ + ['day', 'noonday'], + ['night', 'midnight'], + ] as const)('lights %s from a gradient inside skyBackdrop', (state, name) => { + const { darkest, lightest } = skyBackdrop(state); + expect(terrainSkyFor(state).sky).toBe(name); + baked[name].gradient.forEach((c) => { + expect(luminance(c)).toBeGreaterThanOrEqual(darkest - EPS); + expect(luminance(c)).toBeLessThanOrEqual(lightest + EPS); + }); + }); +}); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts new file mode 100644 index 00000000..64869440 --- /dev/null +++ b/src/app/features/scene/skyForState.ts @@ -0,0 +1,52 @@ +// What each scene is told for a sky state. 'auto' returns nothing: the scene chooses for itself +// (the ridges' fixed dusk, the terrain's clock with `?sky=` `?sun=` `?az=`), exactly as before +// there was a state. The other states name a sky explicitly, drawn from the same gradients +// `skyBackdrop` bounds, so the colours a scene paints lie inside the luminance range the login +// text was checked against. +import { baked } from '../../../sky/baked'; +import type { SkyName } from './terrain/terrainSky'; +import type { SkyColours, Srgb } from './ridge/skyColour'; +import { skyColours } from './ridge/skyColour'; +import type { SkyState } from './skyState'; + +const rgb = (hex: string): Srgb => + [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb; + +const fromGradient = (g: readonly string[], ground: Srgb): SkyColours => ({ + top: rgb(g[0]), + mid: rgb(g[Math.floor(g.length / 2)]), + horizon: rgb(g[g.length - 1]), + ground, +}); + +/** Sun elevation in degrees for the terrain's dusk: just above the horizon, between the two bakes (as `?sun=` puts it). */ +export const DUSK_SUN_ELEVATION = 2; + +/** The ridge scene's `sky` option; undefined leaves the scene on `skyColours()`. */ +export function ridgeSkyFor(state: SkyState): SkyColours | undefined { + const { ground } = skyColours(); + switch (state) { + case 'dusk': + return skyColours(); + case 'day': + return fromGradient(baked.noonday.gradient, ground); + case 'night': + return fromGradient(baked.midnight.gradient, ground); + default: + return undefined; + } +} + +/** The terrain scene's `sky` and `sun` options; `{}` leaves the scene on its clock and URL choice. */ +export function terrainSkyFor(state: SkyState): { sky?: SkyName; sun?: { elevation: number } } { + switch (state) { + case 'day': + return { sky: 'noonday' }; + case 'night': + return { sky: 'midnight' }; + case 'dusk': + return { sun: { elevation: DUSK_SUN_ELEVATION } }; + default: + return {}; + } +} From 6c22d4f1dc197e2017c664bf91a917e9abc623a3 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:23:47 +0000 Subject: [PATCH 036/111] scene: a failed sky-state rebuild restores the ground; an explicit sky beats the URL The door tells the host (onInactive/onActive) when a rebuild makes no scene or makes one again. chooseSky takes an explicit sun or sky option before ?sun=, ?az= and ?sky=; only auto, which passes none, is steered by the URL. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/host.test.ts | 14 +++++++++ src/app/features/scene/host.ts | 2 ++ src/app/features/scene/index.test.ts | 28 +++++++++++++++++ src/app/features/scene/index.ts | 10 +++++- .../features/scene/terrain/terrainSky.test.ts | 31 ++++++++++++++++++- src/app/features/scene/terrain/terrainSky.ts | 13 +++++--- 6 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/app/features/scene/host.test.ts b/src/app/features/scene/host.test.ts index 272f502f..b987d0cc 100644 --- a/src/app/features/scene/host.test.ts +++ b/src/app/features/scene/host.test.ts @@ -108,6 +108,20 @@ describe('mountBackgroundScene', () => { expect(scene.login).toHaveBeenCalledOnce(); }); + it('hands the door its onInactive and onActive, for a sky-state rebuild that makes no scene', async () => { + startBackgroundScene.mockResolvedValue(fakeScene()); + const onInactive = vi.fn(); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive, onActive }); + await flush(); + const given = startBackgroundScene.mock.calls[0][2]; + onActive.mockClear(); + given.onInactive(); + given.onActive(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(onActive).toHaveBeenCalledOnce(); + }); + describe('a lost WebGL context', () => { const lose = () => startBackgroundScene.mock.calls[ diff --git a/src/app/features/scene/host.ts b/src/app/features/scene/host.ts index e771f696..9a31c812 100644 --- a/src/app/features/scene/host.ts +++ b/src/app/features/scene/host.ts @@ -40,6 +40,8 @@ export function mountBackgroundScene( startBackgroundScene(config, capabilities, { canvas, onCompileFail: onFail, + onInactive, + onActive, onContextLost: () => { if (stale()) return; generation += 1; diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 5ea2cdab..684e0c67 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -77,6 +77,34 @@ describe('startBackgroundScene follows the sky state', () => { scene?.dispose(); }); + it('puts the ground back when a rebuild makes no scene, and lets it show when one returns', async () => { + const [a, c] = [fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(null).mockReturnValueOnce(c); + const onInactive = vi.fn(); + const onActive = vi.fn(); + const scene = await startBackgroundScene(config, caps, { canvas, onInactive, onActive }); + expect(onInactive).not.toHaveBeenCalled(); + setSkyState('day'); + await flush(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(onActive).not.toHaveBeenCalled(); + setSkyState('dusk'); + await flush(); + expect(onActive).toHaveBeenCalledOnce(); + expect(onInactive).toHaveBeenCalledOnce(); + scene?.dispose(); + }); + + it('does not report a dropped rebuild as inactive', async () => { + createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(null); + const onInactive = vi.fn(); + const scene = await startBackgroundScene(config, caps, { canvas, onInactive }); + setSkyState('day'); + scene?.dispose(); + await flush(); + expect(onInactive).not.toHaveBeenCalled(); + }); + it('removes the subscription on dispose', async () => { const first = fakeScene(); createRidgeScene.mockReturnValue(first); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 7dc5b4f0..2319b328 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -10,6 +10,10 @@ export type { SceneStyle, SceneTier } from './tier'; export type StartOptions = Omit & { /** A shader failed to compile, or the scene chunk failed to load. The host keeps its ground. */ onCompileFail?: (e: Error) => void; + /** A rebuild for a new sky state made no scene: the host puts the CSS ground back (as on a lost context). */ + onInactive?: () => void; + /** A rebuild made a scene again: the host lets it show. */ + onActive?: () => void; }; async function create( @@ -78,7 +82,11 @@ export async function startBackgroundScene( return; } inner = next; - if (loggedIn) inner?.login(); + if (!next) options.onInactive?.(); + else { + options.onActive?.(); + if (loggedIn) next.login(); + } }); return { login() { diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index 016ee1ce..a6dc7a7e 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -1,8 +1,9 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ import { AP_DISTANCE_SCALE } from './terrainScatter'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { baked } from '../../../../sky/baked'; import { + chooseSky, coldness, glowColour, hazeColour, @@ -11,6 +12,7 @@ import { shade, SKY, skyAtSun, + skyData, skyColour, skyNameFor, sunDirection, @@ -298,3 +300,30 @@ describe('ground light and haze', () => { expect(Math.abs(along)).toBeLessThan(0.4); }); }); + +describe('chooseSky: an explicit sky wins over the page URL', () => { + const at = (search: string) => vi.stubGlobal('window', { location: { search } }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('lets the URL steer when no option is given', () => { + at('?sun=40&az=90'); + expect(chooseSky({})).toEqual(skyAtSun(40, 90)); + at('?sky=noonday'); + expect(chooseSky({})).toEqual(skyData('noonday')); + }); + + it('prefers an explicit baked sky to ?sun= and ?sky=', () => { + at('?sun=40&az=90&sky=noonday'); + expect(chooseSky({ sky: 'midnight' })).toEqual(skyData('midnight')); + at('?sky=noonday'); + expect(chooseSky({ sky: 'midnight' })).toEqual(skyData('midnight')); + }); + + it('prefers an explicit sun to ?sun=, and takes no azimuth from ?az=', () => { + at('?sun=40&az=90&sky=noonday'); + expect(chooseSky({ sun: { elevation: 2 } })).toEqual(skyAtSun(2, undefined)); + expect(chooseSky({ sun: { elevation: 2, azimuth: 10 } })).toEqual(skyAtSun(2, 10)); + }); +}); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index 4034fd93..f6647407 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -228,19 +228,24 @@ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, }) as unknown as Vec3; } -/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ +/** + * The sky to use. An explicit option wins over the page URL, so a person who picked a sky sees it: a sun + * elevation (with its azimuth, default noonday's), else a baked sky. Only without either does the URL steer: + * `?sun=` (with `?az=`), then `?sky=midnight|noonday`, then the night notion of `skyColours()`. + */ export function chooseSky(opts: { sky?: SkyName; sun?: { elevation: number; azimuth?: number }; }): SkyData { + if (opts.sun) return skyAtSun(opts.sun.elevation, opts.sun.azimuth); + if (opts.sky) return skyData(opts.sky); const q = new URLSearchParams(window.location.search); const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); - const elevation = opts.sun?.elevation ?? number('sun'); + const elevation = number('sun'); if (Number.isFinite(elevation)) { - const azimuth = opts.sun?.azimuth ?? number('az'); + const azimuth = number('az'); return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); } - if (opts.sky) return skyData(opts.sky); const name = q.get('sky'); return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); } From edc6a9a8f15691968aa3c2fc9fb4fb659ad0e794 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:31:00 +0000 Subject: [PATCH 037/111] scene: catch a state change during the first build; skyBackdrop is the union of what the scenes draw index.ts compares the current state with the built one after subscribing and rebuilds if they differ. skyBackdrop unions the ridge sky and the terrain sky (skyColour sampled over the sphere, from chooseSky(terrainSkyFor(state))), so terrain dusk's sun glow and terrain day's darker zenith are inside the range. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 14 ++++++ src/app/features/scene/index.ts | 22 +++++---- src/app/features/scene/skyForState.test.ts | 55 +++++++++++++++++++++- src/app/features/scene/skyForState.ts | 48 ++++++++++++++++++- src/app/features/scene/skyState.test.ts | 19 ++++---- src/app/features/scene/skyState.ts | 42 ++++++++--------- 6 files changed, 161 insertions(+), 39 deletions(-) diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 684e0c67..6c3d034a 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -62,6 +62,20 @@ describe('startBackgroundScene follows the sky state', () => { expect(second.dispose).toHaveBeenCalledOnce(); }); + it('rebuilds for a change that landed while the first build was awaited', async () => { + const [a, b] = [fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b); + const started = startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + const scene = await started; + await flush(); + expect(createRidgeScene).toHaveBeenCalledTimes(2); + expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]); + expect(a.dispose).toHaveBeenCalledOnce(); + scene?.dispose(); + expect(b.dispose).toHaveBeenCalledOnce(); + }); + it('replays a login onto the rebuilt scene, but only if one happened', async () => { const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 2319b328..3cc42486 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -64,30 +64,36 @@ export async function startBackgroundScene( const tier = resolveTier(config, capabilities); if (tier === 'off') return null; const build = (state: SkyState) => create(config, { ...options, tier }, state); - const first = await build(getSkyState()); + const state = getSkyState(); + const first = await build(state); if (!first || config?.style === 'raymarch') return first; let inner: Scene | null = first; + let built = state; let loggedIn = false; let disposed = false; let generation = 0; - const off = subscribe(async (state) => { + const rebuild = async (next: SkyState) => { generation += 1; const mine = generation; + built = next; inner?.dispose(); inner = null; - const next = await build(state); + const scene = await build(next); if (disposed || mine !== generation) { - next?.dispose(); + scene?.dispose(); return; } - inner = next; - if (!next) options.onInactive?.(); + inner = scene; + if (!scene) options.onInactive?.(); else { options.onActive?.(); - if (loggedIn) next.login(); + if (loggedIn) scene.login(); } - }); + }; + const off = subscribe(rebuild); + // A change that landed while the first build was awaited was missed by the subscription. + if (getSkyState() !== built) rebuild(getSkyState()); return { login() { loggedIn = true; diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 443e8fdb..1e09021d 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -2,7 +2,14 @@ import { describe, expect, it } from 'vitest'; import { baked } from '../../../sky/baked'; import { luminance } from '../../../sky/skyTokens.js'; import { skyColours } from './ridge/skyColour'; -import { DUSK_SUN_ELEVATION, ridgeSkyFor, terrainSkyFor } from './skyForState'; +import { chooseSky, skyColour } from './terrain/terrainSky'; +import { + DUSK_SUN_ELEVATION, + ridgeDrawn, + ridgeSkyFor, + terrainDrawn, + terrainSkyFor, +} from './skyForState'; import { skyBackdrop } from './skyState'; const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; @@ -63,3 +70,49 @@ describe('terrainSkyFor', () => { }); }); }); + +describe('the terrain sky lies inside skyBackdrop', () => { + // A grid offset from the one skyBackdrop samples, so a miss between its samples shows. + const sweep = (state: 'day' | 'dusk' | 'night') => { + const sky = chooseSky(terrainSkyFor(state)); + const out: number[] = []; + for (let el = 1.3; el <= 90; el += 4.1) { + for (let az = 0.7; az < 360; az += 4.3) { + const e = (el * Math.PI) / 180; + const a = (az * Math.PI) / 180; + const c = skyColour(sky, [ + Math.cos(e) * Math.sin(a), + Math.sin(e), + -Math.cos(e) * Math.cos(a), + ]); + const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); + out.push(0.2126 * r + 0.7152 * g + 0.0722 * b); + } + } + return out; + }; + + it.each(['day', 'dusk', 'night'] as const)('for %s', (state) => { + const { darkest, lightest } = skyBackdrop(state); + const l = sweep(state); + expect(Math.min(...l)).toBeGreaterThanOrEqual(darkest - EPS); + expect(Math.max(...l)).toBeLessThanOrEqual(lightest + EPS); + }); + + it('and the terrain dusk reaches the sun glow the ridge dusk does not', () => { + expect(skyBackdrop('dusk').lightest).toBeGreaterThan(0.9); + expect(skyBackdrop('dusk').lightest).toBeGreaterThan(Math.max(...sweep('night'))); + }); +}); + +describe('both scenes of a state lie inside skyBackdrop', () => { + it.each(['day', 'dusk', 'night'] as const)('%s', (state) => { + const b = skyBackdrop(state); + const r = ridgeDrawn(state); + const t = terrainDrawn(state); + [r, t].forEach((x) => { + expect(x?.darkest).toBeGreaterThanOrEqual(b.darkest - EPS); + expect(x?.lightest).toBeLessThanOrEqual(b.lightest + EPS); + }); + }); +}); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index 64869440..aa3a7803 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -4,10 +4,12 @@ // `skyBackdrop` bounds, so the colours a scene paints lie inside the luminance range the login // text was checked against. import { baked } from '../../../sky/baked'; -import type { SkyName } from './terrain/terrainSky'; +import { chooseSky, skyColour } from './terrain/terrainSky'; +import type { SkyName, Vec3 } from './terrain/terrainSky'; import type { SkyColours, Srgb } from './ridge/skyColour'; import { skyColours } from './ridge/skyColour'; import type { SkyState } from './skyState'; +import { luminance } from '../../../sky/skyTokens.js'; const rgb = (hex: string): Srgb => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb; @@ -50,3 +52,47 @@ export function terrainSkyFor(state: SkyState): { sky?: SkyName; sun?: { elevati return {}; } } + +const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; + +/** Relative luminance (WCAG) of the darkest and lightest colour a scene can draw. */ +export interface Range { + readonly darkest: number; + readonly lightest: number; +} + +/** The ridge sky's range: its top, middle and horizon stops (the canvas blends between them). */ +export function ridgeDrawn(state: SkyState): Range | undefined { + const sky = ridgeSkyFor(state === 'auto' ? 'dusk' : state); + if (!sky) return undefined; + const l = [sky.top, sky.mid, sky.horizon].map((c) => luminance(rgbHex(c))); + return { darkest: Math.min(...l), lightest: Math.max(...l) }; +} + +const SAMPLE_DEG = 3; +const linearLuminance = (c: Vec3) => { + const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; + +/** + * The terrain sky's range: `skyColour` (the CPU twin of its sky shader) over the whole upper sphere, which the camera + * sweeps as it turns, for the sky the scene picks for this state. Sampled every 3 degrees. + */ +export function terrainDrawn(state: SkyState): Range { + const sky = chooseSky(terrainSkyFor(state)); + let darkest = Infinity; + let lightest = -Infinity; + for (let el = 0; el <= 90; el += SAMPLE_DEG) { + for (let az = 0; az < 360; az += SAMPLE_DEG) { + const e = (el * Math.PI) / 180; + const a = (az * Math.PI) / 180; + const l = linearLuminance( + skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]) + ); + darkest = Math.min(darkest, l); + lightest = Math.max(lightest, l); + } + } + return { darkest, lightest }; +} diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index 4885abcd..7b33a1ba 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -105,15 +105,20 @@ describe('sky state', () => { }); describe('skyBackdrop', () => { - it('orders the fixed skies night < dusk < day by their lightest sky', () => { + it('puts night below day and dusk, which spans from dark to the sun glow', () => { + expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('day').darkest); expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest); - expect(skyBackdrop('dusk').lightest).toBeLessThan(skyBackdrop('day').lightest); + expect(skyBackdrop('dusk').darkest).toBeLessThan(skyBackdrop('day').darkest); }); - it('is the luminance of the baked gradients for day and night', () => { + it('holds the baked gradients for day and night', () => { const lum = (g: readonly string[]) => g.map(luminance); - expect(skyBackdrop('day').lightest).toBe(Math.max(...lum(baked.noonday.gradient))); - expect(skyBackdrop('night').darkest).toBe(Math.min(...lum(baked.midnight.gradient))); + expect(skyBackdrop('day').lightest).toBeGreaterThanOrEqual( + Math.max(...lum(baked.noonday.gradient)) - 1e-9 + ); + expect(skyBackdrop('night').darkest).toBeLessThanOrEqual( + Math.min(...lum(baked.midnight.gradient)) + 1e-9 + ); }); it('bounds every fixed sky when auto', () => { @@ -171,11 +176,9 @@ describe('login ink against the sky backdrop', () => { }); // Bare sky does not hold either ink in most states: the card behind the login form stays opaque - // (pages/auth/styles.css.ts) for that reason. These are the cells that clear AA; every other + // (pages/auth/styles.css.ts) for that reason. These are the cells that clear AA, with the sky range the scenes can draw; every other // cell is asserted to fail, so a sky or theme change that fixes one turns it red until listed. const clears = new Set([ - 'day|Noonday Sky|text', - 'day|Noonday Sky|link', 'night|Light/Silver/Dark/Butter|text', 'night|Light/Silver/Dark/Butter|link', 'night|Midnight Sky|text', diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index 60e0471f..bc164d7a 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -2,9 +2,7 @@ // localStorage key; whoever draws the sky (the scene, the title bar's control) reads it here, and // `skyBackdrop` says how light or dark the sky behind the login text can be in each state, so the // text colours can be checked against it. -import { baked } from '../../../sky/baked'; -import { luminance } from '../../../sky/skyTokens.js'; -import { skyColours } from './ridge/skyColour'; +import { ridgeDrawn, terrainDrawn } from './skyForState'; export type SkyState = 'auto' | 'day' | 'dusk' | 'night'; @@ -59,26 +57,28 @@ export interface SkyBackdrop { readonly lightest: number; } -const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; -const bound = (colours: readonly string[]): SkyBackdrop => { - const l = colours.map(luminance); - return { darkest: Math.min(...l), lightest: Math.max(...l) }; +// The union of every sky a scene draws for the state: the ridge sky and the terrain sky, each from +// the function its scene uses. 'auto' follows the clock (and a URL), so it bounds all three fixed +// states. Computed on first use: the terrain range samples its sky. +const union = (ranges: readonly (SkyBackdrop | undefined)[]): SkyBackdrop => { + const r = ranges.filter((x): x is SkyBackdrop => !!x); + return { + darkest: Math.min(...r.map((x) => x.darkest)), + lightest: Math.max(...r.map((x) => x.lightest)), + }; }; -const FIXED: Record, SkyBackdrop> = (() => { - const dusk = skyColours(); - return { - day: bound(baked.noonday.gradient), - dusk: bound([dusk.top, dusk.mid, dusk.horizon].map(hex)), - night: bound(baked.midnight.gradient), - }; -})(); +const FIXED_STATES = ['day', 'dusk', 'night'] as const; +const cache = new Map(); export function skyBackdrop(state: SkyState): SkyBackdrop { - if (state !== 'auto') return FIXED[state]; - const all = Object.values(FIXED); - return { - darkest: Math.min(...all.map((b) => b.darkest)), - lightest: Math.max(...all.map((b) => b.lightest)), - }; + let b = cache.get(state); + if (!b) { + b = + state === 'auto' + ? union(FIXED_STATES.map(skyBackdrop)) + : union([ridgeDrawn(state), terrainDrawn(state)]); + cache.set(state, b); + } + return b; } From c1803854767b7ab2da40353512ff80002dab75a8 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:10:11 +0000 Subject: [PATCH 038/111] Sky-state control in the estate title bar; the state picks the theme One button beside Find a room opens Auto / Day / Dusk / Night (fits a 320 px bar). Day and Dusk take Noonday Sky's dark ink, Night Midnight Sky's light ink, Auto keeps the theme the page would have had; signed-out pages follow the state too. skyTheme.test.ts checks contrast from the baked tokens: day and night ink clear AA on bare sky, every container carries its text, links clear AA on the surface, and dusk text on bare sky does not, so it stays on surfaces. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 2 + src/app/features/scene/SkyStateControl.tsx | 80 +++++++++++++++++++++ src/app/features/scene/skyTheme.test.ts | 59 +++++++++++++++ src/app/features/scene/skyTheme.ts | 18 +++++ src/app/hooks/useSkyState.ts | 4 ++ src/app/hooks/useTheme.ts | 11 +++ src/app/pages/ThemeManager.tsx | 10 ++- 7 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 src/app/features/scene/SkyStateControl.tsx create mode 100644 src/app/features/scene/skyTheme.test.ts create mode 100644 src/app/features/scene/skyTheme.ts create mode 100644 src/app/hooks/useSkyState.ts diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 1a9ca8ee..2cbbfe4a 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -42,6 +42,7 @@ import { EstateRoom, EstateTier } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; import * as css from './EstateNavigator.css'; +import { SkyStateControl } from '../scene/SkyStateControl'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; @@ -295,6 +296,7 @@ export function EstateNavigator() { {title} + = { + auto: 'Auto', + day: 'Day', + dusk: 'Dusk', + night: 'Night', +}; + +/** + * One title-bar button that opens the four sky states. A single button, not a row, so the bar + * still fits a 320 px phone beside the search button; the menu items carry the state names. + */ +export function SkyStateControl() { + const state = useSkyState(); + const [cords, setCords] = useState(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + {SKY_STATES.map((s) => ( + { + setSkyState(s); + setCords(undefined); + }} + after={state === s ? : undefined} + > + + {LABEL[s]} + + + ))} + + + + } + > + + + + + ); +} diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts new file mode 100644 index 00000000..74b3280b --- /dev/null +++ b/src/app/features/scene/skyTheme.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../sky/baked'; +import { AA, contrast, luminance } from '../../../sky/skyTokens.js'; +import { SKY_STATES, skyBackdrop, type SkyState } from './skyState'; +import { skyThemeId } from './skyTheme'; + +const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; +const ratio = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + +describe('skyThemeId', () => { + it('leaves the theme alone in auto and picks one for every fixed state', () => { + expect(skyThemeId('auto')).toBeUndefined(); + SKY_STATES.filter((s) => s !== 'auto').forEach((s) => { + expect(skyThemeId(s)).toBeDefined(); + }); + }); + + it('lights day and dusk with dark ink and night with light ink', () => { + expect(skyThemeId('day')).toBe('noonday-sky'); + expect(skyThemeId('dusk')).toBe('noonday-sky'); + expect(skyThemeId('night')).toBe('midnight-sky'); + }); +}); + +describe('contrast of the theme a state picks', () => { + const picked = SKY_STATES.flatMap((s) => { + const id = skyThemeId(s); + return id ? [[s, SKY[id]] as const] : []; + }); + + it.each(picked)('%s: every container carries its text at AA', (_state, sky) => { + Object.values(sky.tokens).forEach((group) => { + expect(contrast(group.OnContainer, group.Container)).toBeGreaterThanOrEqual(AA); + }); + }); + + it.each(picked)('%s: links read at AA on the background surface', (_state, sky) => { + expect(contrast(sky.link, sky.tokens.Background.Container)).toBeGreaterThanOrEqual(AA); + }); + + // Bare sky has no surface under it. Text may sit there only where the ink clears AA against + // both the darkest and the lightest sky the state can show; anywhere else it needs a surface. + const onBareSky = (state: SkyState): number => { + const id = skyThemeId(state); + if (!id) return 0; + const ink = luminance(SKY[id].tokens.Background.OnContainer); + const { darkest, lightest } = skyBackdrop(state); + return Math.min(ratio(ink, darkest), ratio(ink, lightest)); + }; + + it('day and night text on bare sky clears AA', () => { + expect(onBareSky('day')).toBeGreaterThanOrEqual(AA); + expect(onBareSky('night')).toBeGreaterThanOrEqual(AA); + }); + + it('dusk text on bare sky does not, so dusk text stays on surfaces', () => { + expect(onBareSky('dusk')).toBeLessThan(AA); + }); +}); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts new file mode 100644 index 00000000..ebd4504f --- /dev/null +++ b/src/app/features/scene/skyTheme.ts @@ -0,0 +1,18 @@ +// Which theme a sky state lights the page with. 'auto' picks none: the page keeps the theme it +// would have had (the user's choice when signed in, Midnight Sky when signed out). +// +// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Dusk's +// gradient runs from near-black to bright orange, so no single ink reads on all of it; Dusk +// text therefore only ever sits on a theme surface, never on bare sky (skyTheme.test.ts). +import type { SkyState } from './skyState'; + +export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; + +const BY_STATE: Record, SkyThemeId> = { + day: 'noonday-sky', + dusk: 'noonday-sky', + night: 'midnight-sky', +}; + +export const skyThemeId = (state: SkyState): SkyThemeId | undefined => + state === 'auto' ? undefined : BY_STATE[state]; diff --git a/src/app/hooks/useSkyState.ts b/src/app/hooks/useSkyState.ts new file mode 100644 index 00000000..985e2da2 --- /dev/null +++ b/src/app/hooks/useSkyState.ts @@ -0,0 +1,4 @@ +import { useSyncExternalStore } from 'react'; +import { getSkyState, subscribe, type SkyState } from '../features/scene/skyState'; + +export const useSkyState = (): SkyState => useSyncExternalStore(subscribe, getSkyState); diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index d3663283..bf556dd0 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -12,6 +12,8 @@ import '../../gamecult.css'; import '../../sky.css'; import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; +import { skyThemeId } from '../features/scene/skyTheme'; +import { useSkyState } from './useSkyState'; export enum ThemeKind { Light = 'light', @@ -100,7 +102,14 @@ export const useSystemThemeKind = (): ThemeKind => { return themeKind; }; +/** The theme the sky state asks for, or undefined while it is 'auto'. */ +export const useSkyTheme = (): Theme | undefined => { + const id = skyThemeId(useSkyState()); + return id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme; +}; + export const useActiveTheme = (): Theme => { + const skyTheme = useSkyTheme(); const systemThemeKind = useSystemThemeKind(); const themes = useThemes(); const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); @@ -108,6 +117,8 @@ export const useActiveTheme = (): Theme => { const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId'); const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId'); + if (skyTheme) return skyTheme; + if (!systemTheme) { const selectedTheme = themes.find((theme) => theme.id === themeId) ?? MidnightSkyTheme; diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index e730b00d..b7c50d93 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -4,17 +4,21 @@ import { MidnightSkyTheme, ThemeContextProvider, useActiveTheme, + useSkyTheme, } from '../hooks/useTheme'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; export function UnAuthRouteThemeManager() { + // Signed-out pages are Midnight Sky unless the sky state picks otherwise; they ignore the + // system scheme. + const theme = useSkyTheme() ?? MidnightSkyTheme; + useEffect(() => { document.body.className = ''; document.body.classList.add(configClass, varsClass); - // Signed-out pages are always Midnight Sky and ignore the system scheme. - document.body.classList.add(...MidnightSkyTheme.classNames); - }, []); + document.body.classList.add(...theme.classNames); + }, [theme]); return null; } From 3be90875ec37b23ad1abcc4aae8b2f819500691b Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:19:58 +0000 Subject: [PATCH 039/111] Sky control: menu semantics, plates under bare-sky text, pinned colours The menu is role=menu with menuitemradio items (aria-checked), the button carries aria-expanded, focus goes to the checked item on open and back to the button on close. Over the scene the estate title bar and any non-bubble message sit on the sign-in card's plate (--sky-bubble) instead of bare sky. skyTheme.test.ts pins the ink colours and WCAG ratios of the picked themes, measured with the ink clamped into the sky range as skyState.test.ts does; the pass/fail set is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 4 +- src/app/features/scene/SkyStateControl.tsx | 10 +- src/app/features/scene/skyPlate.css.ts | 32 ++++++ src/app/features/scene/skyPlate.ts | 9 ++ src/app/features/scene/skyTheme.test.ts | 105 +++++++++++++------- 5 files changed, 119 insertions(+), 41 deletions(-) create mode 100644 src/app/features/scene/skyPlate.css.ts create mode 100644 src/app/features/scene/skyPlate.ts diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 2cbbfe4a..8e5cc50c 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -2,6 +2,7 @@ import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } fro import { useNavigate } from 'react-router-dom'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color, config } from 'folds'; +import classNames from 'classnames'; import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; import { NavButton, @@ -42,6 +43,7 @@ import { EstateRoom, EstateTier } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; import * as css from './EstateNavigator.css'; +import { SkyPlate } from '../scene/skyPlate.css'; import { SkyStateControl } from '../scene/SkyStateControl'; /** How long a tap waits for the client store to hold the room it just joined. */ @@ -290,7 +292,7 @@ export function EstateNavigator() { return ( - + {title} diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx index 64a747fb..5a9d84d6 100644 --- a/src/app/features/scene/SkyStateControl.tsx +++ b/src/app/features/scene/SkyStateControl.tsx @@ -32,7 +32,8 @@ export function SkyStateControl() { content={ setCords(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', @@ -40,7 +41,7 @@ export function SkyStateControl() { escapeDeactivates: stopPropagation, }} > - + {SKY_STATES.map((s) => ( { setSkyState(s); setCords(undefined); @@ -70,7 +72,7 @@ export function SkyStateControl() { aria-label={`Sky: ${LABEL[state]}`} title={`Sky: ${LABEL[state]}`} aria-haspopup="menu" - aria-pressed={!!cords} + aria-expanded={!!cords} onClick={open} > diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts new file mode 100644 index 00000000..cb56a75b --- /dev/null +++ b/src/app/features/scene/skyPlate.css.ts @@ -0,0 +1,32 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { config } from 'folds'; +import { MessageBase, BubbleContent } from '../../components/message/layout/layout.css'; +import { SKY_PLATE_FALLBACK } from './skyPlate'; + +// Over the scene (html[data-scene]) the signed-in shell is clear, so text outside a chat bubble +// would sit on bare sky, which a sky state can make light, dark or both (dusk): no ink clears +// 4.5:1 on all of that (skyTheme.test.ts). Such text gets a plate instead, never a halo. +const scene = ':root[data-scene]'; +const fill = `var(--sky-bubble, ${SKY_PLATE_FALLBACK.fill})`; +const text = `var(--sky-bubble-text, ${SKY_PLATE_FALLBACK.text})`; + +const plate = { + backgroundColor: fill, + color: text, + borderRadius: config.radii.R400, +} as const; + +/** A title-bar group whose controls and title sit on the plate. */ +export const SkyPlate = style({}); +globalStyle(`${scene} ${SkyPlate}`, { ...plate, padding: `0 ${config.space.S100}` }); +// Controls in the plate take its ink; their themed ink was chosen for the clear shell. +globalStyle(`${scene} ${SkyPlate} button`, { color: 'inherit' }); + +// A message that is not a bubble (Modern, Compact) is plain text on the scene: plate the row. +globalStyle(`${scene} ${MessageBase}:not(:has(.${BubbleContent}))`, plate); + +// Links on a plate read against the plate. The Midnight and Noonday themes set their own +// --tc-link for it; the rest take the fallback, as the sign-in card does. +globalStyle(`${scene} body:not(.gamecult-theme) ${SkyPlate}, ${scene} body:not(.gamecult-theme) ${MessageBase}`, { + vars: { '--tc-link': SKY_PLATE_FALLBACK.link }, +}); diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts new file mode 100644 index 00000000..872e2fd6 --- /dev/null +++ b/src/app/features/scene/skyPlate.ts @@ -0,0 +1,9 @@ +// The plate: the opaque surface text sits on wherever the shell is clear over the background +// scene and a sky state can make the sky behind it anything. It is the sign-in card's colour +// (--sky-bubble, set by the Midnight and Noonday themes in sky.css.ts), so what was checked for +// the card holds here. The other themes take these fallbacks. +export const SKY_PLATE_FALLBACK = { + fill: '#0d1a26', + text: '#b7c7d9', + link: '#99c7ff', +} as const; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 74b3280b..3fad1f3f 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -1,18 +1,34 @@ import { describe, expect, it } from 'vitest'; import { baked } from '../../../sky/baked'; import { AA, contrast, luminance } from '../../../sky/skyTokens.js'; -import { SKY_STATES, skyBackdrop, type SkyState } from './skyState'; +import { SKY_PLATE_FALLBACK } from './skyPlate'; +import { SKY_STATES, skyBackdrop, type SkyBackdrop, type SkyState } from './skyState'; import { skyThemeId } from './skyTheme'; const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; -const ratio = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +const fixed = (['day', 'dusk', 'night'] as const).flatMap((s) => { + const id = skyThemeId(s); + return id ? [[s, id] as const] : []; +}); + +// WCAG contrast of an ink against the sky it is worst on, measured as skyState.test.ts does: the +// sky can show any luminance between its darkest and lightest, and contrast is lowest where the +// backdrop is closest to the ink, which is the ink's own luminance clamped into that range. +const against = (ink: string, bg: number) => { + const li = luminance(ink); + const [hi, lo] = li > bg ? [li, bg] : [bg, li]; + return (hi + 0.05) / (lo + 0.05); +}; +const ratio = (ink: string, b: SkyBackdrop) => + against(ink, Math.min(b.lightest, Math.max(b.darkest, luminance(ink)))); +const r2 = (n: number) => n.toFixed(2); describe('skyThemeId', () => { it('leaves the theme alone in auto and picks one for every fixed state', () => { expect(skyThemeId('auto')).toBeUndefined(); - SKY_STATES.filter((s) => s !== 'auto').forEach((s) => { - expect(skyThemeId(s)).toBeDefined(); - }); + expect(SKY_STATES.filter((s) => s !== 'auto').map((s) => skyThemeId(s))).not.toContain( + undefined + ); }); it('lights day and dusk with dark ink and night with light ink', () => { @@ -22,38 +38,55 @@ describe('skyThemeId', () => { }); }); -describe('contrast of the theme a state picks', () => { - const picked = SKY_STATES.flatMap((s) => { - const id = skyThemeId(s); - return id ? [[s, SKY[id]] as const] : []; +describe('ink of the theme a state picks, on bare sky', () => { + // [state, text ratio, link ratio]; a theme's own ink is not chosen for bare sky, so only day + // and night text pass (Nibu's measurement, skyState.test.ts). Links never rest on bare sky. + const cells: [SkyState, string, string, string, string][] = [ + ['day', 'noonday-sky', '#342d25', '7.62', '#2a4f27'], + ['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27'], + ['night', 'midnight-sky', '#aeccff', '5.05', '#609385'], + ]; + + it.each(cells)('%s with %s: ink %s measures %s', (state, id, ink, want, link) => { + const sky = SKY[id as keyof typeof SKY]; + expect(sky.tokens.Background.OnContainer).toBe(ink); + expect(sky.link).toBe(link); + expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want); }); - it.each(picked)('%s: every container carries its text at AA', (_state, sky) => { - Object.values(sky.tokens).forEach((group) => { - expect(contrast(group.OnContainer, group.Container)).toBeGreaterThanOrEqual(AA); - }); - }); - - it.each(picked)('%s: links read at AA on the background surface', (_state, sky) => { - expect(contrast(sky.link, sky.tokens.Background.Container)).toBeGreaterThanOrEqual(AA); - }); - - // Bare sky has no surface under it. Text may sit there only where the ink clears AA against - // both the darkest and the lightest sky the state can show; anywhere else it needs a surface. - const onBareSky = (state: SkyState): number => { - const id = skyThemeId(state); - if (!id) return 0; - const ink = luminance(SKY[id].tokens.Background.OnContainer); - const { darkest, lightest } = skyBackdrop(state); - return Math.min(ratio(ink, darkest), ratio(ink, lightest)); - }; - - it('day and night text on bare sky clears AA', () => { - expect(onBareSky('day')).toBeGreaterThanOrEqual(AA); - expect(onBareSky('night')).toBeGreaterThanOrEqual(AA); - }); - - it('dusk text on bare sky does not, so dusk text stays on surfaces', () => { - expect(onBareSky('dusk')).toBeLessThan(AA); + it('only the cells that clear AA on bare sky carry text there', () => { + const passing = fixed.filter( + ([s, id]) => ratio(SKY[id].tokens.Background.OnContainer, skyBackdrop(s)) >= AA + ); + expect(passing.map(([s]) => s)).toEqual(['day', 'night']); + }); +}); + +describe('the plate, where text cannot rest on bare sky', () => { + // Each theme's plate: fill, ink, link and the WCAG ratios of ink and link on the fill. + const plates = [ + ['midnight-sky', baked.midnight, '12.17', '5.68'], + ['noonday-sky', baked.noonday, '6.70', '4.62'], + ] as const; + + it.each(plates)('%s: ink and link on the fill', (_id, sky, inkRatio, linkRatio) => { + expect(r2(contrast(sky.bubbles.OtherText, sky.bubbles.Other))).toBe(inkRatio); + expect(r2(contrast(sky.link, sky.bubbles.Other))).toBe(linkRatio); + expect(contrast(sky.bubbles.OtherText, sky.bubbles.Other)).toBeGreaterThanOrEqual(AA); + expect(contrast(sky.link, sky.bubbles.Other)).toBeGreaterThanOrEqual(AA); + }); + + it('the other themes take fallback colours that clear AA', () => { + const { fill, text, link } = SKY_PLATE_FALLBACK; + expect(r2(contrast(text, fill))).toBe('10.21'); + expect(r2(contrast(link, fill))).toBe('10.03'); + }); + + it('every container of the picked themes carries its text at AA', () => { + fixed.forEach(([, id]) => { + Object.values(SKY[id].tokens).forEach((g) => { + expect(contrast(g.OnContainer, g.Container)).toBeGreaterThanOrEqual(AA); + }); + }); }); }); From ce16609c768ad41bd129df1f6228941299ffbcc8 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:25:59 +0000 Subject: [PATCH 040/111] Plate every region of bare-sky text under the scene with one rule skyPlate.css.ts joins the room and page headers, the room list, list pages, the typing and Following bars, non-bubble messages and the room intro to the sign-in card's opaque --sky-bubble plate, with the plate's ink and link tokens, at all widths. The estate title bar's own plate goes: the shared header rule covers it. skyTheme.test.ts pins the link ratios on bare sky as well (day 5.25, night 2.36). Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/room-intro/RoomIntro.tsx | 6 +- src/app/features/estate/EstateNavigator.tsx | 4 +- src/app/features/scene/skyPlate.css.ts | 69 +++++++++++++++------ src/app/features/scene/skyTheme.test.ts | 16 ++--- 4 files changed, 64 insertions(+), 31 deletions(-) diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index 2e4e3ebc..0c6b10df 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -1,7 +1,9 @@ +import classNames from 'classnames'; import React, { useCallback, useState } from 'react'; import { Avatar, Box, Button, Spinner, Text, as } from 'folds'; import { Room } from 'matrix-js-sdk'; import { useAtomValue } from 'jotai'; +import { SkyPlate } from '../../features/scene/skyPlate.css'; import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room'; import { getMemberDisplayName, getStateEvent } from '../../utils/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -23,7 +25,7 @@ export type RoomIntroProps = { room: Room; }; -export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => { +export const RoomIntro = as<'div', RoomIntroProps>(({ room, className, ...props }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); @@ -50,7 +52,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); return ( - + - + {title} diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index cb56a75b..102eefd0 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -1,32 +1,63 @@ import { globalStyle, style } from '@vanilla-extract/css'; -import { config } from 'folds'; +import { color, config } from 'folds'; import { MessageBase, BubbleContent } from '../../components/message/layout/layout.css'; +import { PageContent, PageNavContent } from '../../components/page/style.css'; +import { RoomViewFollowing } from '../room/RoomViewFollowing.css'; +import { RoomViewTyping } from '../room/RoomViewTyping.css'; import { SKY_PLATE_FALLBACK } from './skyPlate'; -// Over the scene (html[data-scene]) the signed-in shell is clear, so text outside a chat bubble -// would sit on bare sky, which a sky state can make light, dark or both (dusk): no ink clears -// 4.5:1 on all of that (skyTheme.test.ts). Such text gets a plate instead, never a halo. +// Over the scene (html[data-scene]) the signed-in shell is clear (sceneShell.css.ts), so text +// outside a chat bubble would sit on bare sky, which a sky state can make light, dark or both +// (dusk): no ink clears 4.5:1 on all of that (skyTheme.test.ts). Every such region is a plate +// instead, the sign-in card's opaque --sky-bubble fill, with its own ink and link, so what was +// checked for the card holds everywhere. One rule, below; a region joins it by class or by tag. const scene = ':root[data-scene]'; const fill = `var(--sky-bubble, ${SKY_PLATE_FALLBACK.fill})`; const text = `var(--sky-bubble-text, ${SKY_PLATE_FALLBACK.text})`; -const plate = { +/** A region with no component class of its own (the room intro) joins the plate with this. */ +export const SkyPlate = style({}); + +// A recipe's first class is its base style. +const base = (recipe: () => string) => `.${recipe().split(' ')[0]}`; + +const regions = [ + `.${SkyPlate}`, + // Title bars: the room header, the room list's header, every page header. + '#root header', + // The room list, and the pages that are lists (explore, inbox, search). + `.${PageNavContent}`, + `.${PageContent}`, + // The bars over the composer. + `.${RoomViewTyping}`, + base(RoomViewFollowing), + // A message that is not a bubble (Modern, Compact) is plain text on the scene. + `${base(MessageBase)}:not(:has(.${BubbleContent}))`, +]; + +// Only inside #root: menus and dialogs render in #portalContainer with their own themed fill. +const within = (ancestor: string) => + regions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', '); + +globalStyle(within(scene), { backgroundColor: fill, color: text, - borderRadius: config.radii.R400, -} as const; + // Themed ink was chosen for the clear shell; inside the plate every token reads the plate's. + vars: { + [color.Background.OnContainer]: text, + [color.Surface.OnContainer]: text, + [color.SurfaceVariant.OnContainer]: text, + }, +}); +globalStyle(`${scene} #root header`, { borderRadius: config.radii.R400 }); -/** A title-bar group whose controls and title sit on the plate. */ -export const SkyPlate = style({}); -globalStyle(`${scene} ${SkyPlate}`, { ...plate, padding: `0 ${config.space.S100}` }); -// Controls in the plate take its ink; their themed ink was chosen for the clear shell. -globalStyle(`${scene} ${SkyPlate} button`, { color: 'inherit' }); - -// A message that is not a bubble (Modern, Compact) is plain text on the scene: plate the row. -globalStyle(`${scene} ${MessageBase}:not(:has(.${BubbleContent}))`, plate); - -// Links on a plate read against the plate. The Midnight and Noonday themes set their own -// --tc-link for it; the rest take the fallback, as the sign-in card does. -globalStyle(`${scene} body:not(.gamecult-theme) ${SkyPlate}, ${scene} body:not(.gamecult-theme) ${MessageBase}`, { +// Links read against the plate. The Midnight and Noonday themes set their own --tc-link for it; +// the other themes take the fallback, as the sign-in card does. +globalStyle(within(`${scene} body:not(.gamecult-theme)`), { vars: { '--tc-link': SKY_PLATE_FALLBACK.link }, }); + +globalStyle(`${scene} #root .${SkyPlate}`, { + padding: config.space.S400, + borderRadius: config.radii.R400, +}); diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 3fad1f3f..60a3ae64 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -39,19 +39,21 @@ describe('skyThemeId', () => { }); describe('ink of the theme a state picks, on bare sky', () => { - // [state, text ratio, link ratio]; a theme's own ink is not chosen for bare sky, so only day - // and night text pass (Nibu's measurement, skyState.test.ts). Links never rest on bare sky. - const cells: [SkyState, string, string, string, string][] = [ - ['day', 'noonday-sky', '#342d25', '7.62', '#2a4f27'], - ['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27'], - ['night', 'midnight-sky', '#aeccff', '5.05', '#609385'], + // Per state: the picked theme, its ink and link, and their ratios on bare sky. A theme's own + // ink is not chosen for bare sky, so only day and night text pass (skyState.test.ts), and no + // link does (night 2.36): links never rest on bare sky, they sit on the plate below. + const cells: [SkyState, string, string, string, string, string][] = [ + ['day', 'noonday-sky', '#342d25', '7.62', '#2a4f27', '5.25'], + ['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27', '1.00'], + ['night', 'midnight-sky', '#aeccff', '5.05', '#609385', '2.36'], ]; - it.each(cells)('%s with %s: ink %s measures %s', (state, id, ink, want, link) => { + it.each(cells)('%s with %s: ink %s is %s, link %s is %s', (state, id, ink, want, link, wantLink) => { const sky = SKY[id as keyof typeof SKY]; expect(sky.tokens.Background.OnContainer).toBe(ink); expect(sky.link).toBe(link); expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want); + expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink); }); it('only the cells that clear AA on bare sky carry text there', () => { From 5862bbdba210a3a51a60240c1d9486ef0b16c16c Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:31:43 +0000 Subject: [PATCH 041/111] Plate the composer, members drawer, call view and call status bar They join the shared plate rule under the scene. The composer's own half-strength blurred fill goes (its text fell below 4.5:1 in dusk and some auto themes); the plate has the checked ink. skyTheme.test.ts pins which regions are on the plate. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/editor/Editor.css.ts | 11 +--------- src/app/features/call/CallView.tsx | 4 +++- src/app/features/scene/skyPlate.css.ts | 14 +++++++++---- src/app/features/scene/skyTheme.test.ts | 27 +++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 82adb6f4..d128ed07 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -1,4 +1,4 @@ -import { globalStyle, style } from '@vanilla-extract/css'; +import { style } from '@vanilla-extract/css'; import { color, config, DefaultReset, toRem } from 'folds'; export const Editor = style([ @@ -12,15 +12,6 @@ export const Editor = style([ }, ]); -// Over the background scene (html[data-scene]) the composer is a bubble too: half strength, blurred. -globalStyle(`:root[data-scene] ${Editor}`, { - backgroundColor: [ - color.SurfaceVariant.Container, - `color-mix(in srgb, ${color.SurfaceVariant.Container} 50%, transparent)`, - ], - backdropFilter: 'blur(12px)', -}); - export const EditorOptions = style([ DefaultReset, { diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 1fb97650..430219c6 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -1,5 +1,7 @@ import React, { RefObject, useRef } from 'react'; +import classNames from 'classnames'; import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; +import { SkyPlate } from '../scene/skyPlate.css'; import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { PrescreenControls } from './PrescreenControls'; @@ -162,7 +164,7 @@ export function CallView() { return ( diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index 102eefd0..a3c0c567 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -2,6 +2,9 @@ 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 { SKY_PLATE_FALLBACK } from './skyPlate'; @@ -15,19 +18,23 @@ const scene = ':root[data-scene]'; const fill = `var(--sky-bubble, ${SKY_PLATE_FALLBACK.fill})`; const text = `var(--sky-bubble-text, ${SKY_PLATE_FALLBACK.text})`; -/** A region with no component class of its own (the room intro) joins the plate with this. */ +/** A region with no class of its own (the room intro, the call view) joins the plate with this. */ export const SkyPlate = style({}); // A recipe's first class is its base style. const base = (recipe: () => string) => `.${recipe().split(' ')[0]}`; -const regions = [ +export const plateRegions = [ `.${SkyPlate}`, // Title bars: the room header, the room list's header, every page header. '#root header', // The room list, and the pages that are lists (explore, inbox, search). `.${PageNavContent}`, `.${PageContent}`, + // The composer, the members drawer, and the call status bar. + `.${Editor}`, + `.${MembersDrawer}`, + `.${CallStatus}`, // The bars over the composer. `.${RoomViewTyping}`, base(RoomViewFollowing), @@ -37,7 +44,7 @@ const regions = [ // Only inside #root: menus and dialogs render in #portalContainer with their own themed fill. const within = (ancestor: string) => - regions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', '); + plateRegions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', '); globalStyle(within(scene), { backgroundColor: fill, @@ -58,6 +65,5 @@ globalStyle(within(`${scene} body:not(.gamecult-theme)`), { }); globalStyle(`${scene} #root .${SkyPlate}`, { - padding: config.space.S400, borderRadius: config.radii.R400, }); diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 60a3ae64..53a68c5c 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from 'vitest'; import { baked } from '../../../sky/baked'; import { AA, contrast, luminance } from '../../../sky/skyTokens.js'; +import { Editor } from '../../components/editor/Editor.css'; +import { PageContent, PageNavContent } from '../../components/page/style.css'; +import { CallStatus } from '../call-status/styles.css'; +import { MembersDrawer } from '../room/MembersDrawer.css'; +import { RoomViewTyping } from '../room/RoomViewTyping.css'; import { SKY_PLATE_FALLBACK } from './skyPlate'; +import { SkyPlate, plateRegions } from './skyPlate.css'; import { SKY_STATES, skyBackdrop, type SkyBackdrop, type SkyState } from './skyState'; import { skyThemeId } from './skyTheme'; @@ -92,3 +98,24 @@ describe('the plate, where text cannot rest on bare sky', () => { }); }); }); + +describe('regions that sit on the plate', () => { + // Every region whose text would otherwise rest on bare sky joins the one plate rule. The ink + // 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', () => { + 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'); + expect(plateRegions.some((r) => r.includes(':not(:has('))).toBe(true); + }); +}); From 76daf7cdca2cb26262ad56af858c4ef52a4b5ddf Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:32:56 +0000 Subject: [PATCH 042/111] Re-pin the bare-sky ratios on skyBackdrop's union ranges #47 makes skyBackdrop the union of the skies the scenes draw: day 0.238-0.919, dusk 0.017-0.965, night 0.058-0.077. Only night text still clears 4.5:1 on bare sky (5.05); day falls to 3.71 text / 2.56 link. Everything outside a bubble stays on the plate. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyTheme.test.ts | 13 +++++++------ src/app/features/scene/skyTheme.ts | 7 ++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 53a68c5c..3fd06875 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -45,11 +45,12 @@ describe('skyThemeId', () => { }); describe('ink of the theme a state picks, on bare sky', () => { - // Per state: the picked theme, its ink and link, and their ratios on bare sky. A theme's own - // ink is not chosen for bare sky, so only day and night text pass (skyState.test.ts), and no - // link does (night 2.36): links never rest on bare sky, they sit on the plate below. + // Per state: the picked theme, its ink and link, and their ratios on bare sky, over the union + // of the skies the scenes draw (skyBackdrop). Only night text passes (5.05); day's sky reaches + // 0.919 and dusk's 0.017 to 0.965, so no theme's ink holds there. No link passes anywhere + // (night 2.36): nothing rests on bare sky, everything sits on the plate below. const cells: [SkyState, string, string, string, string, string][] = [ - ['day', 'noonday-sky', '#342d25', '7.62', '#2a4f27', '5.25'], + ['day', 'noonday-sky', '#342d25', '3.71', '#2a4f27', '2.56'], ['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27', '1.00'], ['night', 'midnight-sky', '#aeccff', '5.05', '#609385', '2.36'], ]; @@ -62,11 +63,11 @@ describe('ink of the theme a state picks, on bare sky', () => { expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink); }); - it('only the cells that clear AA on bare sky carry text there', () => { + it('night is the only state whose text clears AA on bare sky', () => { const passing = fixed.filter( ([s, id]) => ratio(SKY[id].tokens.Background.OnContainer, skyBackdrop(s)) >= AA ); - expect(passing.map(([s]) => s)).toEqual(['day', 'night']); + expect(passing.map(([s]) => s)).toEqual(['night']); }); }); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index ebd4504f..8854fcd4 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -1,9 +1,10 @@ // Which theme a sky state lights the page with. 'auto' picks none: the page keeps the theme it // would have had (the user's choice when signed in, Midnight Sky when signed out). // -// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Dusk's -// gradient runs from near-black to bright orange, so no single ink reads on all of it; Dusk -// text therefore only ever sits on a theme surface, never on bare sky (skyTheme.test.ts). +// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. On bare sky +// only Night's text clears 4.5:1 (5.05); Day's sky reaches 0.919 luminance and Dusk's spans +// 0.017 to 0.965, so no ink holds there. Text therefore never rests on bare sky: every region +// that would is a plate (skyPlate.css.ts), and skyTheme.test.ts pins the numbers. import type { SkyState } from './skyState'; export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; From 0d26b7186210e38208fc4bcbc79c0df97e8099fa Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:37:06 +0000 Subject: [PATCH 043/111] scene: keep the terrain sky and baked tables out of the main chunk skyBackdrop reads constants (the union of the ridge and terrain skies, rounded outward) instead of computing it, and the door imports skyForState lazily with the scenes. A test fails when the constants drift from the computed union. Main entry chunk vs dev: +0.4 KB gzip, down from +51.5 KB. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.ts | 3 +- src/app/features/scene/skyForState.test.ts | 33 ++++++++++++++++++++ src/app/features/scene/skyState.ts | 35 +++++++--------------- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 3cc42486..55b00e86 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -2,7 +2,6 @@ import type { Scene, SceneOptions } from './Scene'; import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; import { getSkyState, subscribe } from './skyState'; import type { SkyState } from './skyState'; -import { ridgeSkyFor, terrainSkyFor } from './skyForState'; export type { Scene, SceneStats } from './Scene'; export type { SceneStyle, SceneTier } from './tier'; @@ -23,6 +22,8 @@ async function create( ): Promise { let createScene: (o: SceneOptions) => Scene | null; try { + // Loaded here, with the scenes, so the main chunk does not carry the terrain sky or the baked tables. + const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState'); if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; else if (config?.style === 'terrain') { const { createTerrainScene } = await import('./terrain/Terrain'); diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 1e09021d..aff09b1e 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -116,3 +116,36 @@ describe('both scenes of a state lie inside skyBackdrop', () => { }); }); }); + +describe('skyBackdrop holds the union of what the scenes draw', () => { + const computed = (state: 'day' | 'dusk' | 'night') => { + const r = [ridgeDrawn(state), terrainDrawn(state)].filter((x) => x !== undefined) as { + darkest: number; + lightest: number; + }[]; + return { + darkest: Math.min(...r.map((x) => x.darkest)), + lightest: Math.max(...r.map((x) => x.lightest)), + }; + }; + + it.each(['day', 'dusk', 'night'] as const)( + '%s: the constant bounds the computed range and sits within 1e-5 of it', + (state) => { + const c = computed(state); + const b = skyBackdrop(state); + expect(b.darkest).toBeLessThanOrEqual(c.darkest); + expect(b.lightest).toBeGreaterThanOrEqual(c.lightest); + expect(c.darkest - b.darkest).toBeLessThan(1e-5); + expect(b.lightest - c.lightest).toBeLessThan(1e-5); + } + ); + + it('auto is the union of the three fixed states', () => { + const all = (['day', 'dusk', 'night'] as const).map(skyBackdrop); + expect(skyBackdrop('auto')).toEqual({ + darkest: Math.min(...all.map((x) => x.darkest)), + lightest: Math.max(...all.map((x) => x.lightest)), + }); + }); +}); diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index bc164d7a..a6d1ce8e 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -2,8 +2,6 @@ // localStorage key; whoever draws the sky (the scene, the title bar's control) reads it here, and // `skyBackdrop` says how light or dark the sky behind the login text can be in each state, so the // text colours can be checked against it. -import { ridgeDrawn, terrainDrawn } from './skyForState'; - export type SkyState = 'auto' | 'day' | 'dusk' | 'night'; export const SKY_STATES: readonly SkyState[] = ['auto', 'day', 'dusk', 'night']; @@ -58,27 +56,16 @@ export interface SkyBackdrop { } // The union of every sky a scene draws for the state: the ridge sky and the terrain sky, each from -// the function its scene uses. 'auto' follows the clock (and a URL), so it bounds all three fixed -// states. Computed on first use: the terrain range samples its sky. -const union = (ranges: readonly (SkyBackdrop | undefined)[]): SkyBackdrop => { - const r = ranges.filter((x): x is SkyBackdrop => !!x); - return { - darkest: Math.min(...r.map((x) => x.darkest)), - lightest: Math.max(...r.map((x) => x.lightest)), - }; +// the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). Held as constants so +// this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering +// tables with it; skyForState.test.ts fails when they drift from the computed union. +// Rounded outward to six places. 'auto' follows the clock (and a URL), so it +// bounds all three fixed states. +const BACKDROPS: Record = { + auto: { darkest: 0.016727, lightest: 0.964723 }, + day: { darkest: 0.237514, lightest: 0.919226 }, + dusk: { darkest: 0.016727, lightest: 0.964723 }, + night: { darkest: 0.058099, lightest: 0.077467 }, }; -const FIXED_STATES = ['day', 'dusk', 'night'] as const; -const cache = new Map(); - -export function skyBackdrop(state: SkyState): SkyBackdrop { - let b = cache.get(state); - if (!b) { - b = - state === 'auto' - ? union(FIXED_STATES.map(skyBackdrop)) - : union([ridgeDrawn(state), terrainDrawn(state)]); - cache.set(state, b); - } - return b; -} +export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[state]; From 8a05668d8483e83bbf1e83036864533e967c76f2 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:41:27 +0000 Subject: [PATCH 044/111] scene: terrain backdrop range found by refinement, not a grid terrainDrawn finds the sun glow peak and the darkest patch with a compass search from the best coarse samples; constants regenerated (Day 0.237309-0.92805, Dusk 0.016727-0.967912). The drift test sweeps its own grid (golden-angle spiral plus a 0.05 degree patch around the sun). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyForState.test.ts | 38 +++++++----- src/app/features/scene/skyForState.ts | 68 ++++++++++++++++++---- src/app/features/scene/skyState.ts | 6 +- 3 files changed, 85 insertions(+), 27 deletions(-) diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index aff09b1e..44ca5899 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -72,31 +72,43 @@ describe('terrainSkyFor', () => { }); describe('the terrain sky lies inside skyBackdrop', () => { - // A grid offset from the one skyBackdrop samples, so a miss between its samples shows. + // Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns) + // plus a 0.05 degree grid across the sun, five times finer than the generator's. + const luma = (c: readonly number[]) => { + const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; const sweep = (state: 'day' | 'dusk' | 'night') => { const sky = chooseSky(terrainSkyFor(state)); + const rad = Math.PI / 180; + const dirAt = (el: number, az: number): [number, number, number] => [ + Math.cos(el * rad) * Math.sin(az * rad), + Math.sin(el * rad), + -Math.cos(el * rad) * Math.cos(az * rad), + ]; const out: number[] = []; - for (let el = 1.3; el <= 90; el += 4.1) { - for (let az = 0.7; az < 360; az += 4.3) { - const e = (el * Math.PI) / 180; - const a = (az * Math.PI) / 180; - const c = skyColour(sky, [ - Math.cos(e) * Math.sin(a), - Math.sin(e), - -Math.cos(e) * Math.cos(a), - ]); - const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); - out.push(0.2126 * r + 0.7152 * g + 0.0722 * b); + const N = 20000; + for (let i = 0; i < N; i += 1) { + const y = (i + 0.5) / N; // sin(elevation): uniform over the upper hemisphere + out.push(luma(skyColour(sky, dirAt(Math.asin(y) / rad, (i * 137.50776405) % 360)))); + } + for (let dEl = -3; dEl <= 3; dEl += 0.05) { + for (let dAz = -3; dAz <= 3; dAz += 0.05) { + out.push(luma(skyColour(sky, dirAt(Math.max(0, sky.elevation + dEl), sky.azimuth + dAz)))); } } return out; }; - it.each(['day', 'dusk', 'night'] as const)('for %s', (state) => { + it.each(['day', 'dusk', 'night'] as const)('for %s, and no looser than 5e-4 from it', (state) => { const { darkest, lightest } = skyBackdrop(state); const l = sweep(state); expect(Math.min(...l)).toBeGreaterThanOrEqual(darkest - EPS); expect(Math.max(...l)).toBeLessThanOrEqual(lightest + EPS); + const top = Math.max(Math.max(...l), ridgeDrawn(state)?.lightest ?? 0); + expect(lightest - top).toBeLessThan(5e-4); + const bottom = Math.min(Math.min(...l), ridgeDrawn(state)?.darkest ?? 1); + expect(bottom - darkest).toBeLessThan(5e-4); }); it('and the terrain dusk reaches the sun glow the ridge dusk does not', () => { diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index aa3a7803..a6d78e4a 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -70,6 +70,10 @@ export function ridgeDrawn(state: SkyState): Range | undefined { } const SAMPLE_DEG = 3; +const SUN_DEG = 0.25; +const SUN_SPAN_DEG = 8; +const HORIZON_DEG = 0.5; +const REFINE_FROM = 6; const linearLuminance = (c: Vec3) => { const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); return 0.2126 * r + 0.7152 * g + 0.0722 * b; @@ -77,22 +81,64 @@ const linearLuminance = (c: Vec3) => { /** * The terrain sky's range: `skyColour` (the CPU twin of its sky shader) over the whole upper sphere, which the camera - * sweeps as it turns, for the sky the scene picks for this state. Sampled every 3 degrees. + * sweeps as it turns, for the sky the scene picks for this state. A 3 degree grid, a 0.5 degree ring of the horizon, the + * zenith and a 0.25 degree patch around the sun find where the extremes are; each of the best few is then refined by a + * compass search whose step halves from 1 degree to under 0.001, because the sun's glow peaks (and the sky's darkest + * patch sits) between any fixed grid's samples. */ export function terrainDrawn(state: SkyState): Range { const sky = chooseSky(terrainSkyFor(state)); - let darkest = Infinity; - let lightest = -Infinity; + const rad = Math.PI / 180; + const lum = (el: number, az: number) => { + const e = Math.min(90, Math.max(0, el)) * rad; + const a = az * rad; + return linearLuminance( + skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]) + ); + }; + const seen: { el: number; az: number; l: number }[] = []; + const at = (el: number, az: number) => seen.push({ el, az, l: lum(el, az) }); for (let el = 0; el <= 90; el += SAMPLE_DEG) { - for (let az = 0; az < 360; az += SAMPLE_DEG) { - const e = (el * Math.PI) / 180; - const a = (az * Math.PI) / 180; - const l = linearLuminance( - skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]) - ); - darkest = Math.min(darkest, l); - lightest = Math.max(lightest, l); + for (let az = 0; az < 360; az += SAMPLE_DEG) at(el, az); + } + for (let az = 0; az < 360; az += HORIZON_DEG) at(0, az); + at(90, 0); + for (let dEl = -SUN_SPAN_DEG; dEl <= SUN_SPAN_DEG; dEl += SUN_DEG) { + for (let dAz = -SUN_SPAN_DEG; dAz <= SUN_SPAN_DEG; dAz += SUN_DEG) { + at(sky.elevation + dEl, sky.azimuth + dAz); } } + type Point = { el: number; az: number; l: number }; + const around = (p: Point, step: number): Point[] => + [ + [step, 0], + [-step, 0], + [0, step], + [0, -step], + ].map(([dEl, dAz]) => { + const el = Math.min(90, Math.max(0, p.el + dEl)); + return { el, az: p.az + dAz, l: lum(el, p.az + dAz) }; + }); + const refine = (from: Point, sign: 1 | -1) => { + let best = from; + for (let step = 1; step > 0.0005; step /= 2) { + let improved = true; + while (improved) { + const cur = best; + best = around(cur, step).reduce((b, c) => (sign * c.l > sign * b.l ? c : b), cur); + improved = best !== cur; + } + } + return best.l; + }; + const byLight = [...seen].sort((p, q) => q.l - p.l); + const lightest = Math.max( + byLight[0].l, + ...byLight.slice(0, REFINE_FROM).map((p) => refine(p, 1)) + ); + const darkest = Math.min( + byLight[byLight.length - 1].l, + ...byLight.slice(-REFINE_FROM).map((p) => refine(p, -1)) + ); return { darkest, lightest }; } diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index a6d1ce8e..e06e9845 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -62,9 +62,9 @@ export interface SkyBackdrop { // Rounded outward to six places. 'auto' follows the clock (and a URL), so it // bounds all three fixed states. const BACKDROPS: Record = { - auto: { darkest: 0.016727, lightest: 0.964723 }, - day: { darkest: 0.237514, lightest: 0.919226 }, - dusk: { darkest: 0.016727, lightest: 0.964723 }, + auto: { darkest: 0.016727, lightest: 0.967912 }, + day: { darkest: 0.237309, lightest: 0.92805 }, + dusk: { darkest: 0.016727, lightest: 0.967912 }, night: { darkest: 0.058099, lightest: 0.077467 }, }; From 8f8536a805c0c17cae506bc4d1ca1cb61216fb70 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:50:10 +0000 Subject: [PATCH 045/111] CI: cancel superseded runs with workflow concurrency groups The forge runner takes one job at a time, so every superseded push or PR sync piled up behind the one before it and the dev preview run waited behind runs for merged heads. Pull-request workflows get a group per workflow and PR number; the dev preview gets its own group, so a newer dev push cancels an older one and PR runs never cancel it. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/build-pull-request.yml | 4 ++++ .github/workflows/docker-pr.yml | 4 ++++ .github/workflows/lockfile.yml | 4 ++++ .github/workflows/rat-dev-preview.yml | 7 +++++++ 4 files changed, 19 insertions(+) diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 4ff842ed..964add54 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -4,6 +4,10 @@ on: pull_request: types: ['opened', 'synchronize'] +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: build-pull-request: name: Build pull request diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 997aba6d..24b9389c 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -6,6 +6,10 @@ on: - 'Dockerfile' - '.github/workflows/docker-pr.yml' +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: docker-build: runs-on: ubuntu-latest diff --git a/.github/workflows/lockfile.yml b/.github/workflows/lockfile.yml index a9cc1a34..e96d2818 100644 --- a/.github/workflows/lockfile.yml +++ b/.github/workflows/lockfile.yml @@ -5,6 +5,10 @@ on: paths: - 'package-lock.json' +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: lockfile_changes: runs-on: ubuntu-latest diff --git a/.github/workflows/rat-dev-preview.yml b/.github/workflows/rat-dev-preview.yml index 45da8bc4..47f22b7e 100644 --- a/.github/workflows/rat-dev-preview.yml +++ b/.github/workflows/rat-dev-preview.yml @@ -4,6 +4,13 @@ on: push: branches: ['dev'] +# One run per ref wins: a newer push to dev cancels the older queued or running one, so the run +# that builds rat-dev-dist is always the newest. Pull-request runs are other workflows and other +# groups; they never cancel it. +concurrency: + group: rat-dev-preview + cancel-in-progress: true + jobs: build: name: Build dev preview From 0c6d4476bece39fcb81ef7c78a0ae9a084459771 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 23:15:13 +0000 Subject: [PATCH 046/111] trainingNotice: say whose ruling the wording is The comment said the wording awaits Meta's legal read. The notice, its wording and the registration acknowledgement are Meta's ruling of 2026-11-06; the comment now says so. Comment only. Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/trainingNotice.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/utils/trainingNotice.ts b/src/app/utils/trainingNotice.ts index e2c1de00..16215249 100644 --- a/src/app/utils/trainingNotice.ts +++ b/src/app/utils/trainingNotice.ts @@ -3,8 +3,11 @@ import { StateEvent } from '../../types/matrix/room'; export const TRAINING_NOTICE_TEXT = "Everything said here may be used to train GameCult's AI stewards."; +// The notice, its wording and the registration acknowledgement are Meta's ruling of 2026-11-06: +// everything said on the server may be used to train GameCult's AI stewards; shown at registration +// only; no mention of Discord; existing members are not re-asked. The wording is hers to change. // The preview build runs against a pretend server where nothing is trained, so the real sentence -// would be false there. The real wording awaits Meta's legal read (Heimdall's consent text, open). +// would be false there. export const PREVIEW_NOTICE_TEXT = 'Pretend server: nothing you type here is stored or used to train anything.'; From 5d3db9e1f26d32871def08180a2785e6d8ed6ae6 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 23:18:08 +0000 Subject: [PATCH 047/111] scene: shape each sky state's drawn sky into a luminance band its text ink clears Day and dusk: floor 0.30 (dark ink needs 0.2983). Night: ceiling 0.0931 for the light ink; the baked night sky is kept. One shaping function (skyBand.shapeToBand) with a GLSL twin lifts the terrain sky toward a tint (hue kept) or scales it down. The ridge dusk becomes lavender, rose and orange. 'auto' resolves from the clock (sun.js elevation) to day, dusk or night; subscribers hear the resolved sky; skyBackdrop('auto') is the resolved state's band. Link ink is not in the band (Void's wiring). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 9 +- src/app/features/scene/index.ts | 17 +- src/app/features/scene/ridge/skyColour.ts | 12 +- src/app/features/scene/ridge/stars.test.ts | 5 +- src/app/features/scene/skyBand.test.ts | 83 +++++++ src/app/features/scene/skyBand.ts | 76 ++++++ src/app/features/scene/skyForState.test.ts | 230 ++++++++++-------- src/app/features/scene/skyForState.ts | 78 +++--- src/app/features/scene/skyState.test.ts | 126 +++++----- src/app/features/scene/skyState.ts | 89 +++++-- src/app/features/scene/skyTheme.test.ts | 76 +++--- src/app/features/scene/skyTheme.ts | 24 +- src/app/features/scene/terrain/Terrain.ts | 9 + .../scene/terrain/TerrainSky2D.test.ts | 30 +++ .../features/scene/terrain/TerrainSky2D.ts | 2 +- .../features/scene/terrain/terrainShaders.ts | 25 +- .../features/scene/terrain/terrainSky.test.ts | 7 - src/app/features/scene/terrain/terrainSky.ts | 37 ++- src/app/hooks/useSkyState.ts | 13 +- src/app/hooks/useTheme.ts | 6 +- 20 files changed, 650 insertions(+), 304 deletions(-) create mode 100644 src/app/features/scene/skyBand.test.ts create mode 100644 src/app/features/scene/skyBand.ts diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 6c3d034a..645b093c 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { baked } from '../../../sky/baked'; import type { Scene } from './Scene'; import { startBackgroundScene } from './index'; -import { setSkyState } from './skyState'; +import { setSkyClock, setSkyState } from './skyState'; const { createRidgeScene, createScene } = vi.hoisted(() => ({ createRidgeScene: vi.fn(), @@ -23,21 +23,24 @@ const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2 beforeEach(() => { vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }); + // Local midnight: 'auto' resolves to night. + setSkyClock(() => new Date(2026, 8, 22, 0, 0)); setSkyState('auto'); createRidgeScene.mockReset(); createScene.mockReset(); }); afterEach(() => { + setSkyClock(); setSkyState('auto'); vi.unstubAllGlobals(); }); describe('startBackgroundScene follows the sky state', () => { - it('hands the ridge scene no sky for auto, as before there was a state', async () => { + it('hands the ridge scene the sky auto resolves to (night at midnight)', async () => { createRidgeScene.mockReturnValue(fakeScene()); const scene = await startBackgroundScene(config, caps, { canvas }); expect(createRidgeScene).toHaveBeenCalledOnce(); - expect(createRidgeScene.mock.calls[0][0].sky).toBeUndefined(); + expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]); scene?.dispose(); }); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 55b00e86..4fe33934 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -1,7 +1,7 @@ import type { Scene, SceneOptions } from './Scene'; import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; -import { getSkyState, subscribe } from './skyState'; -import type { SkyState } from './skyState'; +import { getResolvedSkyState, subscribe } from './skyState'; +import type { FixedSkyState } from './skyState'; export type { Scene, SceneStats } from './Scene'; export type { SceneStyle, SceneTier } from './tier'; @@ -18,7 +18,7 @@ export type StartOptions = Omit { let createScene: (o: SceneOptions) => Scene | null; try { @@ -52,7 +52,7 @@ async function create( * "keep the CSS ground", including when that chunk fails to load (offline, stale deploy): the * failure goes to onCompileFail and nothing throws. * - * The ridge and terrain scenes bake their sky once, so they follow the sky state (`skyState.ts`) by + * The ridge and terrain scenes bake their sky once, so they follow the sky (`skyState.ts`: the choice, resolved, so 'auto' follows the clock) by * being rebuilt on the same canvas when it changes: the door subscribes on start and the returned * scene's `dispose` unsubscribes. The pull-back restarts from the tree; a later `login()` is * replayed on the new scene. The ray-march scene has one fixed dusk and ignores the state. @@ -64,8 +64,8 @@ export async function startBackgroundScene( ): Promise { const tier = resolveTier(config, capabilities); if (tier === 'off') return null; - const build = (state: SkyState) => create(config, { ...options, tier }, state); - const state = getSkyState(); + const build = (state: FixedSkyState) => create(config, { ...options, tier }, state); + const state = getResolvedSkyState(); const first = await build(state); if (!first || config?.style === 'raymarch') return first; @@ -74,7 +74,8 @@ export async function startBackgroundScene( let loggedIn = false; let disposed = false; let generation = 0; - const rebuild = async (next: SkyState) => { + const rebuild = async (next: FixedSkyState) => { + if (next === built) return; generation += 1; const mine = generation; built = next; @@ -94,7 +95,7 @@ export async function startBackgroundScene( }; const off = subscribe(rebuild); // A change that landed while the first build was awaited was missed by the subscription. - if (getSkyState() !== built) rebuild(getSkyState()); + rebuild(getResolvedSkyState()); return { login() { loggedIn = true; diff --git a/src/app/features/scene/ridge/skyColour.ts b/src/app/features/scene/ridge/skyColour.ts index 326fe66d..3a5147a4 100644 --- a/src/app/features/scene/ridge/skyColour.ts +++ b/src/app/features/scene/ridge/skyColour.ts @@ -1,7 +1,9 @@ // The one place the sky's colours come from. Void's `src/sky/sun.js` (`light({ time, lat, lon, // cloud_cover, precipitation })`, PR #20) is the intended source; until it lands this returns a -// fixed dusk drawn from GameCult's nebula inputs (see ../palette.ts). Replace the body, keep the -// signature: the scene calls it once, at startup, and bakes the result. +// fixed twilight: lavender zenith, rose mid-sky, orange horizon. Its darkest blend stays above the dusk +// floor of ../skyBand.ts (the dark ink of Noonday Sky needs a backdrop of at least 0.2983 luminance), which +// skyForState.test.ts sweeps. Replace the body, keep the signature: the scene calls it once, at startup, +// and bakes the result. export type Srgb = readonly [number, number, number]; @@ -20,9 +22,9 @@ export interface SkyColours { export function skyColours(_now: Date = new Date()): SkyColours { return { - top: [24, 30, 78], - mid: [78, 66, 150], - horizon: [226, 130, 74], + top: [170, 145, 220], + mid: [225, 130, 165], + horizon: [240, 150, 80], ground: [7, 17, 26], sunAzimuth: 0.3, }; diff --git a/src/app/features/scene/ridge/stars.test.ts b/src/app/features/scene/ridge/stars.test.ts index 38b4e7e8..e14f2a7c 100644 --- a/src/app/features/scene/ridge/stars.test.ts +++ b/src/app/features/scene/ridge/stars.test.ts @@ -41,9 +41,10 @@ describe('star placement', () => { expect(makeStars(skyColours(), 390, 844)).toEqual(makeStars(skyColours(), 390, 844)); }); - it('draws none in a day sky, and the dusk default is dark enough for some', () => { + it('draws none in a day sky or the lavender dusk, and some in a night one', () => { expect(makeStars(DAY, 390, 844)).toHaveLength(0); - expect(makeStars(skyColours(), 390, 844).length).toBeGreaterThan(50); + expect(makeStars(skyColours(), 390, 844)).toHaveLength(0); + expect(makeStars(NIGHT, 390, 844).length).toBeGreaterThan(50); }); it('is a few hundred at most: bounded by MAX_STARS and by the viewport', () => { diff --git a/src/app/features/scene/skyBand.test.ts b/src/app/features/scene/skyBand.test.ts new file mode 100644 index 00000000..23c7c18e --- /dev/null +++ b/src/app/features/scene/skyBand.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { displayedLuminance, hexToLinear, luma, shapeToBand, SKY_BAND } from './skyBand'; +import type { Linear } from './skyBand'; +import { GRID_FRAG, SHAPE_SKY_GLSL, SKY_FRAG } from './terrain/terrainShaders'; + +const near = (a: number, b: number) => Math.abs(a - b) < 1e-9; + +describe('shapeToBand', () => { + const dark: Linear = [0.02, 0.05, 0.2]; // a deep blue + const { day } = SKY_BAND; + + it('lifts a sky below the floor onto the floor, mixing toward the tint so the hue is kept', () => { + const c = shapeToBand(dark, day); + expect(near(luma(c), day.floor)).toBe(true); + // Between the sky and the tint on every channel: a mix, not a grey clamp. + [0, 1, 2].forEach((i) => { + expect(c[i]).toBeGreaterThanOrEqual(Math.min(dark[i], day.lift[i])); + expect(c[i]).toBeLessThanOrEqual(Math.max(dark[i], day.lift[i])); + }); + expect(c[2]).toBeGreaterThan(c[0]); // still blue + }); + + it('lifts black to the tint scaled in, not to grey', () => { + const c = shapeToBand([0, 0, 0], SKY_BAND.dusk); + expect(near(luma(c), SKY_BAND.dusk.floor)).toBe(true); + expect(c[0]).toBeGreaterThan(c[2]); // the dusk tint is rose + }); + + it('leaves a sky already in the band as it is, and is continuous at the floor', () => { + const ok: Linear = [0.5, 0.6, 0.7]; + expect(shapeToBand(ok, day)).toEqual(ok); + const lo = shapeToBand([0.31, 0.31, 0.31], day); + const below = shapeToBand([0.3099, 0.3099, 0.3099], day); + expect(Math.abs(lo[0] - below[0])).toBeLessThan(1e-3); + }); + + it('scales a sky above the ceiling down to it, keeping its hue', () => { + const c = shapeToBand([0.9, 0.8, 1], SKY_BAND.night); + expect(near(luma(c), SKY_BAND.night.ceiling)).toBe(true); + expect(near(c[0] / c[2], 0.9)).toBe(true); + }); + + it('clamps to 0..1 as the screen does, and never leaves the band when shaped twice', () => { + expect(shapeToBand([4, 4, 4], day)).toEqual([1, 1, 1]); + const once = shapeToBand(dark, day); + shapeToBand(once, day).forEach((v, i) => expect(v).toBeCloseTo(once[i], 12)); + }); +}); + +describe('the bands', () => { + it('have a tint above the floor to lift toward', () => { + Object.values(SKY_BAND).forEach((b) => expect(luma(b.lift)).toBeGreaterThan(b.floor)); + }); + + it('keep the floor 0.01 above the 0.2983 text bound: gamma 2.2 reads darker than sRGB by less', () => { + const grey = shapeToBand([0, 0, 0], SKY_BAND.day); + const shown = displayedLuminance(grey); + expect(shown).toBeGreaterThanOrEqual(0.2983); + expect(shown).toBeLessThan(SKY_BAND.day.floor); + }); + + it('decode hex as linear light', () => { + expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]); + }); +}); + +describe('the GLSL twin', () => { + it('shapes with the same arithmetic: clamp, luma weights, mix to the floor, scale to the ceiling', () => { + expect(SHAPE_SKY_GLSL).toContain('c = clamp(c, 0.0, 1.0);'); + expect(SHAPE_SKY_GLSL).toContain('const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);'); + expect(SHAPE_SKY_GLSL).toContain( + 'if (l < uBand.x) return mix(c, uLift, (uBand.x - l) / (dot(uLift, LUMA) - l));' + ); + expect(SHAPE_SKY_GLSL).toContain('if (l > uBand.y) return c * (uBand.y / l);'); + }); + + it('is applied to the sky colour in both shaders that draw it, the sky pass and the haze that fades into it', () => { + [SKY_FRAG, GRID_FRAG].forEach((src) => { + expect(src).toContain(SHAPE_SKY_GLSL); + expect(src).toMatch(/return shapeSky\(1\.0 - exp\(-45\.0 \* skyScatter\(d\)\)/); + }); + }); +}); diff --git a/src/app/features/scene/skyBand.ts b/src/app/features/scene/skyBand.ts new file mode 100644 index 00000000..470393ee --- /dev/null +++ b/src/app/features/scene/skyBand.ts @@ -0,0 +1,76 @@ +// The luminance band each fixed sky state's drawn sky is shaped into, so the state's theme ink +// clears 4.5:1 on bare sky (WCAG relative luminance, the `luminance` of src/sky/skyTokens.js). +// +// day, dusk Noonday Sky's dark text ink #342d25 (L 0.0274) needs a backdrop of at least 0.2983. +// The floor is that text bound (0.30 as drawn); there is no ceiling, dark ink on light is fine. +// night Midnight Sky's light text ink #aeccff (L 0.594) needs a backdrop of at most 0.0931. +// The baked night sky (0.058 to 0.077) is kept; the ceiling is the bound. +// +// Link ink is not part of the band: see skyTheme.ts. +// +// The terrain sky is a physical scatter function, so it is shaped downstream, on its final colour: +// `shapeToBand` here, and its GLSL twin (`SHAPE_SKY_GLSL`) in terrain/terrainShaders.ts, built from the +// same constants. The ridge sky is a three-stop gradient whose stops are chosen inside the band +// (ridge/skyColour.ts, skyForState.ts), and its test sweeps the blends. +export type FixedSkyState = 'day' | 'dusk' | 'night'; +export type Linear = readonly [number, number, number]; + +/** WCAG luma weights, applied to linear RGB. */ +export const LUMA: Linear = [0.2126, 0.7152, 0.0722]; +export const luma = (c: Linear): number => LUMA[0] * c[0] + LUMA[1] * c[1] + LUMA[2] * c[2]; + +/** sRGB hex `#rrggbb` to linear RGB 0..1. */ +export function hexToLinear(hex: string): Linear { + const ch = (at: number) => { + const s = parseInt(hex.slice(at, at + 2), 16) / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return [ch(1), ch(3), ch(5)]; +} + +/** + * Relative luminance of what the terrain shaders put on screen for a linear colour: they encode with + * pow(1/2.2), not the sRGB curve, so the screen colour is read back through the sRGB curve. + */ +export function displayedLuminance(c: Linear): number { + const ch = (v: number) => { + const e = Math.min(1, Math.max(0, v)) ** (1 / 2.2); + return e <= 0.04045 ? e / 12.92 : ((e + 0.055) / 1.055) ** 2.4; + }; + return luma([ch(c[0]), ch(c[1]), ch(c[2])]); +} + +export interface SkyBand { + /** Linear luma the shaped colour is at least (0: no floor). */ + floor: number; + /** Linear luma the shaped colour is at most (1: no ceiling). */ + ceiling: number; + /** The colour a too-dark sky is mixed toward: the state's own tint, so the hue survives the lift. Its luma is above `floor`. */ + lift: Linear; +} + +/** + * The shaping targets, in linear luma. The floor sits 0.01 above the text bound (0.2983) because the + * screen encoding is gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level. + */ +export const SKY_BAND: Record = { + day: { floor: 0.31, ceiling: 1, lift: hexToLinear('#e2f4ff') }, + dusk: { floor: 0.31, ceiling: 1, lift: hexToLinear('#eba0b9') }, + night: { floor: 0, ceiling: 0.09, lift: hexToLinear('#484e64') }, +}; + +/** + * Shape a linear sky colour into the band. Below the floor it mixes toward `lift` by exactly the amount + * that lands on the floor (continuous at the floor, hue kept); above the ceiling it scales down to the + * ceiling. The colour is clamped to 0..1 first, as the screen does. + */ +export function shapeToBand(c: Linear, band: SkyBand): Linear { + const k = [0, 1, 2].map((i) => Math.min(1, Math.max(0, c[i]))) as unknown as Linear; + const l = luma(k); + if (l < band.floor) { + const t = (band.floor - l) / (luma(band.lift) - l); + return [0, 1, 2].map((i) => k[i] + (band.lift[i] - k[i]) * t) as unknown as Linear; + } + if (l > band.ceiling) return [0, 1, 2].map((i) => (k[i] * band.ceiling) / l) as unknown as Linear; + return k; +} diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 44ca5899..fb95dc0a 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { baked } from '../../../sky/baked'; -import { luminance } from '../../../sky/skyTokens.js'; +import { AA, luminance } from '../../../sky/skyTokens.js'; import { skyColours } from './ridge/skyColour'; import { chooseSky, skyColour } from './terrain/terrainSky'; import { @@ -10,154 +10,178 @@ import { terrainDrawn, terrainSkyFor, } from './skyForState'; -import { skyBackdrop } from './skyState'; +import { displayedLuminance, SKY_BAND } from './skyBand'; +import type { FixedSkyState } from './skyBand'; +import { setSkyClock, skyBackdrop } from './skyState'; +import { skyThemeId } from './skyTheme'; const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; const EPS = 1e-9; +const STATES = ['day', 'dusk', 'night'] as const; +const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; + +/** + * The band each state's text ink needs, from the ink itself: dark ink needs a backdrop of at least + * AA * (L + 0.05) - 0.05, light ink at most (L + 0.05) / AA - 0.05. Day 0.2983, dusk 0.2983, night 0.0931. + */ +const needs = (state: FixedSkyState) => { + const l = luminance(SKY[skyThemeId(state)].tokens.Background.OnContainer); + return state === 'night' + ? { floor: 0, ceiling: (l + 0.05) / AA - 0.05 } + : { floor: AA * (l + 0.05) - 0.05, ceiling: 1 }; +}; + +afterEach(() => setSkyClock()); + +describe('the bands the text ink needs', () => { + it('are the numbers the shaping was set from', () => { + expect(needs('day').floor).toBeCloseTo(0.2983, 3); + expect(needs('dusk').floor).toBeCloseTo(0.2983, 3); + expect(needs('night').ceiling).toBeCloseTo(0.0931, 3); + }); +}); describe('ridgeSkyFor', () => { - it('leaves auto to the scene, so it draws what it drew before there was a state', () => { - expect(ridgeSkyFor('auto')).toBeUndefined(); + it('draws the gradient of the sky auto resolves to, never nothing', () => { + setSkyClock(() => new Date(2026, 8, 22, 12, 0)); + expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('day')); + setSkyClock(() => new Date(2026, 8, 22, 0, 0)); + expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('night')); + setSkyClock(() => new Date(2026, 8, 22, 18, 10)); + expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('dusk')); }); - it('draws the current ridge dusk for dusk', () => { - expect(ridgeSkyFor('dusk')).toEqual(skyColours()); + it('draws a lavender, rose and orange twilight for dusk', () => { + const d = ridgeSkyFor('dusk'); + expect(d).toEqual(skyColours()); + const [t, m, h] = [d.top, d.mid, d.horizon]; + expect(t[2]).toBeGreaterThan(t[1]); // lavender: blue over green + expect(t[0]).toBeGreaterThan(t[1]); + expect(m[0]).toBeGreaterThan(m[2]); // rose: red over blue + expect(m[2]).toBeGreaterThan(m[1]); + expect(h[0]).toBeGreaterThan(h[1]); // orange: red over green over blue + expect(h[1]).toBeGreaterThan(h[2]); }); it('draws the baked gradients for day and night', () => { const day = ridgeSkyFor('day'); const night = ridgeSkyFor('night'); - expect(hex(day?.top ?? [])).toBe(baked.noonday.gradient[0]); - expect(hex(day?.horizon ?? [])).toBe(baked.noonday.gradient[6]); - expect(hex(night?.top ?? [])).toBe(baked.midnight.gradient[0]); - expect(hex(night?.horizon ?? [])).toBe(baked.midnight.gradient[6]); + expect(hex(day.top)).toBe(baked.noonday.gradient[0]); + expect(hex(day.horizon)).toBe(baked.noonday.gradient[6]); + expect(hex(night.top)).toBe(baked.midnight.gradient[0]); + expect(hex(night.horizon)).toBe(baked.midnight.gradient[6]); }); - it.each(['day', 'dusk', 'night'] as const)( - 'paints a %s sky whose colours lie inside skyBackdrop', - (state) => { - const sky = ridgeSkyFor(state); - const { darkest, lightest } = skyBackdrop(state); - [sky?.top, sky?.mid, sky?.horizon].forEach((c) => { - const l = luminance(hex(c ?? [])); - expect(l).toBeGreaterThanOrEqual(darkest - EPS); - expect(l).toBeLessThanOrEqual(lightest + EPS); - }); - } - ); + it.each(STATES)('%s: every stop and blend lies in the band its text ink needs', (state) => { + const { floor, ceiling } = needs(state); + const r = ridgeDrawn(state); + expect(r.darkest).toBeGreaterThanOrEqual(floor); + expect(r.lightest).toBeLessThanOrEqual(ceiling); + }); }); describe('terrainSkyFor', () => { - it('leaves auto to the clock and the URL', () => { - expect(terrainSkyFor('auto')).toEqual({}); + it('draws the sky auto resolves to, with that state band', () => { + setSkyClock(() => new Date(2026, 8, 22, 12, 0)); + expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('day')); + setSkyClock(() => new Date(2026, 8, 22, 0, 0)); + expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('night')); }); - it('names the baked sky for day and night, and a low sun for dusk', () => { - expect(terrainSkyFor('day')).toEqual({ sky: 'noonday' }); - expect(terrainSkyFor('night')).toEqual({ sky: 'midnight' }); - expect(terrainSkyFor('dusk')).toEqual({ sun: { elevation: DUSK_SUN_ELEVATION } }); - }); - - it.each([ - ['day', 'noonday'], - ['night', 'midnight'], - ] as const)('lights %s from a gradient inside skyBackdrop', (state, name) => { - const { darkest, lightest } = skyBackdrop(state); - expect(terrainSkyFor(state).sky).toBe(name); - baked[name].gradient.forEach((c) => { - expect(luminance(c)).toBeGreaterThanOrEqual(darkest - EPS); - expect(luminance(c)).toBeLessThanOrEqual(lightest + EPS); + it('names the baked sky for day and night, and a low sun for dusk, each with its band', () => { + expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day }); + expect(terrainSkyFor('night')).toEqual({ sky: 'midnight', band: SKY_BAND.night }); + expect(terrainSkyFor('dusk')).toEqual({ + sun: { elevation: DUSK_SUN_ELEVATION }, + band: SKY_BAND.dusk, }); }); }); -describe('the terrain sky lies inside skyBackdrop', () => { - // Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns) - // plus a 0.05 degree grid across the sun, five times finer than the generator's. - const luma = (c: readonly number[]) => { - const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); - return 0.2126 * r + 0.7152 * g + 0.0722 * b; - }; - const sweep = (state: 'day' | 'dusk' | 'night') => { - const sky = chooseSky(terrainSkyFor(state)); - const rad = Math.PI / 180; - const dirAt = (el: number, az: number): [number, number, number] => [ - Math.cos(el * rad) * Math.sin(az * rad), - Math.sin(el * rad), - -Math.cos(el * rad) * Math.cos(az * rad), - ]; - const out: number[] = []; - const N = 20000; - for (let i = 0; i < N; i += 1) { - const y = (i + 0.5) / N; // sin(elevation): uniform over the upper hemisphere - out.push(luma(skyColour(sky, dirAt(Math.asin(y) / rad, (i * 137.50776405) % 360)))); +// Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns) +// plus a 0.05 degree grid across the sun, five times finer than the generator's. +const dirAt = (el: number, az: number): [number, number, number] => { + const rad = Math.PI / 180; + return [ + Math.cos(el * rad) * Math.sin(az * rad), + Math.sin(el * rad), + -Math.cos(el * rad) * Math.cos(az * rad), + ]; +}; +const sweepOf = (sky: ReturnType, N = 4000) => { + const rad = Math.PI / 180; + const out: number[] = []; + for (let i = 0; i < N; i += 1) { + const y = (i + 0.5) / N; // sin(elevation): uniform over the upper hemisphere + out.push( + displayedLuminance(skyColour(sky, dirAt(Math.asin(y) / rad, (i * 137.50776405) % 360))) + ); + } + for (let dEl = -3; dEl <= 3; dEl += 0.05) { + for (let dAz = -3; dAz <= 3; dAz += 0.05) { + out.push( + displayedLuminance( + skyColour(sky, dirAt(Math.max(0, sky.elevation + dEl), sky.azimuth + dAz)) + ) + ); } - for (let dEl = -3; dEl <= 3; dEl += 0.05) { - for (let dAz = -3; dAz <= 3; dAz += 0.05) { - out.push(luma(skyColour(sky, dirAt(Math.max(0, sky.elevation + dEl), sky.azimuth + dAz)))); - } - } - return out; - }; + } + return out; +}; - it.each(['day', 'dusk', 'night'] as const)('for %s, and no looser than 5e-4 from it', (state) => { - const { darkest, lightest } = skyBackdrop(state); +describe('the terrain sky lies in the band its text ink needs', () => { + const sweep = (state: FixedSkyState) => sweepOf(chooseSky(terrainSkyFor(state))); + + it.each(STATES)('%s', (state) => { + const { floor, ceiling } = needs(state); const l = sweep(state); - expect(Math.min(...l)).toBeGreaterThanOrEqual(darkest - EPS); - expect(Math.max(...l)).toBeLessThanOrEqual(lightest + EPS); - const top = Math.max(Math.max(...l), ridgeDrawn(state)?.lightest ?? 0); - expect(lightest - top).toBeLessThan(5e-4); - const bottom = Math.min(Math.min(...l), ridgeDrawn(state)?.darkest ?? 1); - expect(bottom - darkest).toBeLessThan(5e-4); + expect(Math.min(...l)).toBeGreaterThanOrEqual(floor); + expect(Math.max(...l)).toBeLessThanOrEqual(ceiling); }); - it('and the terrain dusk reaches the sun glow the ridge dusk does not', () => { + it.each(STATES)('%s holds for a sun at any elevation, as ?sun= can put it', (state) => { + const { floor, ceiling } = needs(state); + for (let e = -90; e <= 90; e += 10) { + const l = sweepOf(chooseSky({ sun: { elevation: e }, band: SKY_BAND[state] })); + expect(Math.min(...l)).toBeGreaterThanOrEqual(floor); + expect(Math.max(...l)).toBeLessThanOrEqual(ceiling); + } + }); + + it('and the terrain dusk still reaches the sun glow the ridge dusk does not', () => { expect(skyBackdrop('dusk').lightest).toBeGreaterThan(0.9); - expect(skyBackdrop('dusk').lightest).toBeGreaterThan(Math.max(...sweep('night'))); - }); -}); - -describe('both scenes of a state lie inside skyBackdrop', () => { - it.each(['day', 'dusk', 'night'] as const)('%s', (state) => { - const b = skyBackdrop(state); - const r = ridgeDrawn(state); - const t = terrainDrawn(state); - [r, t].forEach((x) => { - expect(x?.darkest).toBeGreaterThanOrEqual(b.darkest - EPS); - expect(x?.lightest).toBeLessThanOrEqual(b.lightest + EPS); - }); }); }); describe('skyBackdrop holds the union of what the scenes draw', () => { - const computed = (state: 'day' | 'dusk' | 'night') => { - const r = [ridgeDrawn(state), terrainDrawn(state)].filter((x) => x !== undefined) as { - darkest: number; - lightest: number; - }[]; + const computed = (state: FixedSkyState) => { + const r = [ridgeDrawn(state), terrainDrawn(state)]; return { darkest: Math.min(...r.map((x) => x.darkest)), lightest: Math.max(...r.map((x) => x.lightest)), }; }; - it.each(['day', 'dusk', 'night'] as const)( + it.each(STATES)( '%s: the constant bounds the computed range and sits within 1e-5 of it', (state) => { const c = computed(state); const b = skyBackdrop(state); - expect(b.darkest).toBeLessThanOrEqual(c.darkest); - expect(b.lightest).toBeGreaterThanOrEqual(c.lightest); + expect(b.darkest).toBeLessThanOrEqual(c.darkest + EPS); + expect(b.lightest).toBeGreaterThanOrEqual(c.lightest - EPS); expect(c.darkest - b.darkest).toBeLessThan(1e-5); expect(b.lightest - c.lightest).toBeLessThan(1e-5); } ); - it('auto is the union of the three fixed states', () => { - const all = (['day', 'dusk', 'night'] as const).map(skyBackdrop); - expect(skyBackdrop('auto')).toEqual({ - darkest: Math.min(...all.map((x) => x.darkest)), - lightest: Math.max(...all.map((x) => x.lightest)), - }); + it.each(STATES)('%s: an independent sweep, no looser than 5e-4 from the constant', (state) => { + // The sweep is not the generator's grid (see above), so it cannot share its blind spots. + const l = sweepOf(chooseSky(terrainSkyFor(state)), 20000); + const b = skyBackdrop(state); + const r = ridgeDrawn(state); + expect(Math.min(...l)).toBeGreaterThanOrEqual(b.darkest - EPS); + expect(Math.max(...l)).toBeLessThanOrEqual(b.lightest + EPS); + expect(b.lightest - Math.max(Math.max(...l), r.lightest)).toBeLessThan(5e-4); + expect(Math.min(Math.min(...l), r.darkest) - b.darkest).toBeLessThan(5e-4); }); }); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index a6d78e4a..0de0dbe0 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -1,14 +1,17 @@ -// What each scene is told for a sky state. 'auto' returns nothing: the scene chooses for itself -// (the ridges' fixed dusk, the terrain's clock with `?sky=` `?sun=` `?az=`), exactly as before -// there was a state. The other states name a sky explicitly, drawn from the same gradients -// `skyBackdrop` bounds, so the colours a scene paints lie inside the luminance range the login -// text was checked against. +// What each scene is told for a sky state. 'auto' is a choice, not a sky: both functions draw the sky it +// resolves to (`resolveSkyState`, the clock), so every scene paints one of day, dusk or night. Each is drawn +// inside that state's luminance band (skyBand.ts): the ridge sky from gradients whose stops and blends lie in +// it, the terrain sky shaped into it downstream of the scatter function. `skyBackdrop` bounds what comes out, +// and the login text was checked against that bound. import { baked } from '../../../sky/baked'; import { chooseSky, skyColour } from './terrain/terrainSky'; -import type { SkyName, Vec3 } from './terrain/terrainSky'; +import type { SkyName } from './terrain/terrainSky'; import type { SkyColours, Srgb } from './ridge/skyColour'; import { skyColours } from './ridge/skyColour'; +import { resolveSkyState } from './skyState'; import type { SkyState } from './skyState'; +import { displayedLuminance, SKY_BAND } from './skyBand'; +import type { SkyBand } from './skyBand'; import { luminance } from '../../../sky/skyTokens.js'; const rgb = (hex: string): Srgb => @@ -24,32 +27,34 @@ const fromGradient = (g: readonly string[], ground: Srgb): SkyColours => ({ /** Sun elevation in degrees for the terrain's dusk: just above the horizon, between the two bakes (as `?sun=` puts it). */ export const DUSK_SUN_ELEVATION = 2; -/** The ridge scene's `sky` option; undefined leaves the scene on `skyColours()`. */ -export function ridgeSkyFor(state: SkyState): SkyColours | undefined { +/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). */ +export function ridgeSkyFor(state: SkyState): SkyColours { const { ground } = skyColours(); - switch (state) { + switch (resolveSkyState(state)) { case 'dusk': return skyColours(); case 'day': return fromGradient(baked.noonday.gradient, ground); - case 'night': - return fromGradient(baked.midnight.gradient, ground); default: - return undefined; + return fromGradient(baked.midnight.gradient, ground); } } -/** The terrain scene's `sky` and `sun` options; `{}` leaves the scene on its clock and URL choice. */ -export function terrainSkyFor(state: SkyState): { sky?: SkyName; sun?: { elevation: number } } { - switch (state) { +/** The terrain scene's `sky`, `sun` and `band` options: the state's sky ('auto': the sky it resolves to), shaped into the state's band. */ +export function terrainSkyFor(state: SkyState): { + sky?: SkyName; + sun?: { elevation: number }; + band: SkyBand; +} { + const fixed = resolveSkyState(state); + const band = SKY_BAND[fixed]; + switch (fixed) { case 'day': - return { sky: 'noonday' }; + return { sky: 'noonday', band }; case 'night': - return { sky: 'midnight' }; - case 'dusk': - return { sun: { elevation: DUSK_SUN_ELEVATION } }; + return { sky: 'midnight', band }; default: - return {}; + return { sun: { elevation: DUSK_SUN_ELEVATION }, band }; } } @@ -61,11 +66,26 @@ export interface Range { readonly lightest: number; } -/** The ridge sky's range: its top, middle and horizon stops (the canvas blends between them). */ -export function ridgeDrawn(state: SkyState): Range | undefined { - const sky = ridgeSkyFor(state === 'auto' ? 'dusk' : state); - if (!sky) return undefined; - const l = [sky.top, sky.mid, sky.horizon].map((c) => luminance(rgbHex(c))); +/** + * The ridge sky's range: its top, middle and horizon stops and the blends between them (the canvas interpolates + * encoded sRGB, and luminance is convex in it, so a blend can be darker than both ends: each stop pair is sampled). + */ +export function ridgeDrawn(state: SkyState): Range { + const { top, mid, horizon } = ridgeSkyFor(state); + const l: number[] = []; + [ + [top, mid], + [mid, horizon], + [top, horizon], + ].forEach(([a, b]) => { + for (let k = 0; k <= 100; k += 1) { + l.push( + luminance( + rgbHex(a.map((v, i) => Math.round(v + ((b[i] - v) * k) / 100)) as unknown as Srgb) + ) + ); + } + }); return { darkest: Math.min(...l), lightest: Math.max(...l) }; } @@ -74,13 +94,9 @@ const SUN_DEG = 0.25; const SUN_SPAN_DEG = 8; const HORIZON_DEG = 0.5; const REFINE_FROM = 6; -const linearLuminance = (c: Vec3) => { - const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); - return 0.2126 * r + 0.7152 * g + 0.0722 * b; -}; /** - * The terrain sky's range: `skyColour` (the CPU twin of its sky shader) over the whole upper sphere, which the camera + * The terrain sky's range, as displayed (`displayedLuminance`): `skyColour` (the CPU twin of its sky shader) over the whole upper sphere, which the camera * sweeps as it turns, for the sky the scene picks for this state. A 3 degree grid, a 0.5 degree ring of the horizon, the * zenith and a 0.25 degree patch around the sun find where the extremes are; each of the best few is then refined by a * compass search whose step halves from 1 degree to under 0.001, because the sun's glow peaks (and the sky's darkest @@ -92,7 +108,7 @@ export function terrainDrawn(state: SkyState): Range { const lum = (el: number, az: number) => { const e = Math.min(90, Math.max(0, el)) * rad; const a = az * rad; - return linearLuminance( + return displayedLuminance( skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]) ); }; diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index 7b33a1ba..f8c22178 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { baked } from '../../../sky/baked'; -import { AA, luminance } from '../../../sky/skyTokens.js'; +import { luminance } from '../../../sky/skyTokens.js'; import { SKY_STATES, SKY_STATE_KEY, + resolveSkyState, + setSkyClock, skyBackdrop, - type SkyBackdrop, type SkyState, } from './skyState'; @@ -105,10 +106,10 @@ describe('sky state', () => { }); describe('skyBackdrop', () => { - it('puts night below day and dusk, which spans from dark to the sun glow', () => { + it('puts night below day and dusk, and dusk reaches the sun glow', () => { expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('day').darkest); expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest); - expect(skyBackdrop('dusk').darkest).toBeLessThan(skyBackdrop('day').darkest); + expect(skyBackdrop('dusk').lightest).toBeGreaterThan(skyBackdrop('day').lightest); }); it('holds the baked gradients for day and night', () => { @@ -121,12 +122,13 @@ describe('skyBackdrop', () => { ); }); - it('bounds every fixed sky when auto', () => { - const a = skyBackdrop('auto'); - (['day', 'dusk', 'night'] as const).forEach((s) => { - expect(a.lightest).toBeGreaterThanOrEqual(skyBackdrop(s).lightest); - expect(a.darkest).toBeLessThanOrEqual(skyBackdrop(s).darkest); - }); + it('is the band of the sky auto resolves to, not the union of all three', () => { + const at = (h: number) => setSkyClock(() => new Date(2026, 8, 22, h, 0)); + at(12); + expect(skyBackdrop('auto')).toEqual(skyBackdrop('day')); + at(0); + expect(skyBackdrop('auto')).toEqual(skyBackdrop('night')); + setSkyClock(); }); it('never has darkest above lightest', () => { @@ -136,57 +138,61 @@ describe('skyBackdrop', () => { }); }); -// Login text and link, per theme, from the card variables in pages/auth/styles.css.ts and the sky -// themes' baked bubble text and link. The Light/Silver/Dark/Butter row uses the ink those themes -// actually render: under data-scene the auth card text is var(--sky-bubble-text, #b7c7d9), not each -// theme's own OnContainer. -const inks = [ - { theme: 'Light/Silver/Dark/Butter', text: '#b7c7d9', link: '#99c7ff' }, - { theme: 'Midnight Sky', text: baked.midnight.bubbles.OtherText, link: baked.midnight.link }, - { theme: 'Noonday Sky', text: baked.noonday.bubbles.OtherText, link: baked.noonday.link }, -]; -// WCAG contrast of an ink against the sky it is worst on. The sky can show any luminance between -// its darkest and lightest, and contrast is lowest where the backdrop is closest to the ink: the -// ink's own luminance clamped into that range. An ink inside the range can land on a backdrop of -// exactly its own luminance (1.00); an ink outside it is worst on the nearer end. -const against = (ink: string, bg: number) => { - const li = luminance(ink); - const [hi, lo] = li > bg ? [li, bg] : [bg, li]; - return (hi + 0.05) / (lo + 0.05); -}; -const ratio = (ink: string, b: SkyBackdrop) => - against(ink, Math.min(b.lightest, Math.max(b.darkest, luminance(ink)))); - -describe('login ink against the sky backdrop', () => { - const rows = SKY_STATES.flatMap((state) => - inks.flatMap(({ theme, text, link }) => - ( - [ - ['text', text], - ['link', link], - ] as const - ).map(([role, ink]) => ({ state, theme, role, ratio: ratio(ink, skyBackdrop(state)) })) - ) - ); - - it('prints the measured ratios', () => { - // eslint-disable-next-line no-console - console.table(rows.map((r) => ({ ...r, ratio: r.ratio.toFixed(2), ok: r.ratio >= AA }))); - expect(rows).toHaveLength(SKY_STATES.length * inks.length * 2); +describe('auto follows the clock', () => { + const local = (h: number, m = 0) => new Date(2026, 8, 22, h, m); // local solar time ~ local clock + let storage: ReturnType; + beforeEach(() => { + vi.useFakeTimers(); + storage = fakeStorage(); + vi.stubGlobal('localStorage', storage); + }); + afterEach(() => { + vi.useRealTimers(); }); - // Bare sky does not hold either ink in most states: the card behind the login form stays opaque - // (pages/auth/styles.css.ts) for that reason. These are the cells that clear AA, with the sky range the scenes can draw; every other - // cell is asserted to fail, so a sky or theme change that fixes one turns it red until listed. - const clears = new Set([ - 'night|Light/Silver/Dark/Butter|text', - 'night|Light/Silver/Dark/Butter|link', - 'night|Midnight Sky|text', - ]); - describe.each(rows)('$theme $role in $state', (r) => { - const holds = clears.has(`${r.state}|${r.theme}|${r.role}`); - (holds ? it : it.fails)(holds ? 'is at least AA' : 'is below AA (known)', () => { - expect(r.ratio).toBeGreaterThanOrEqual(AA); - }); + it('resolves noon to day, midnight to night and the twilight after sunset to dusk', () => { + expect(resolveSkyState('auto', local(12))).toBe('day'); + expect(resolveSkyState('auto', local(0))).toBe('night'); + expect(resolveSkyState('auto', local(18, 10))).toBe('dusk'); + expect(resolveSkyState('auto', local(5, 50))).toBe('dusk'); + expect(resolveSkyState('day', local(0))).toBe('day'); + }); + + it('tells listeners the resolved sky when the clock crosses a boundary, and only then', async () => { + const m = await fresh(); + let now = local(17, 0); + m.setSkyClock(() => now); + const cb = vi.fn(); + const off = m.subscribe(cb); + vi.advanceTimersByTime(5 * 60_000); + expect(cb).not.toHaveBeenCalled(); + now = local(18, 10); + vi.advanceTimersByTime(60_000); + expect(cb.mock.calls).toEqual([['dusk']]); + vi.advanceTimersByTime(5 * 60_000); + expect(cb).toHaveBeenCalledTimes(1); + off(); + }); + + it('keeps no timer without listeners or when a fixed state is chosen', async () => { + const m = await fresh(); + const off = m.subscribe(() => undefined); + expect(vi.getTimerCount()).toBe(1); + m.setSkyState('night'); + expect(vi.getTimerCount()).toBe(0); + m.setSkyState('auto'); + expect(vi.getTimerCount()).toBe(1); + off(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('notifies a change of choice with the resolved sky', async () => { + const m = await fresh(); + m.setSkyClock(() => local(12)); + const cb = vi.fn(); + m.subscribe(cb); + m.setSkyState('night'); + m.setSkyState('auto'); + expect(cb.mock.calls).toEqual([['night'], ['day']]); }); }); diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index e06e9845..ce31fb23 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -1,7 +1,11 @@ -// Which sky the signed-out page is drawn against. The state is one value, kept in one +// Which sky the signed-out page is drawn against. The choice is one value, kept in one // localStorage key; whoever draws the sky (the scene, the title bar's control) reads it here, and // `skyBackdrop` says how light or dark the sky behind the login text can be in each state, so the -// text colours can be checked against it. +// text colours can be checked against it. 'auto' is a choice, not a sky: it resolves from the +// clock to day, dusk or night (`resolveSkyState`), and every sky drawn is one of those three. +import { sunElevation } from '../../../sky/sun.js'; +import type { FixedSkyState } from './skyBand'; + export type SkyState = 'auto' | 'day' | 'dusk' | 'night'; export const SKY_STATES: readonly SkyState[] = ['auto', 'day', 'dusk', 'night']; @@ -18,14 +22,63 @@ function load(): SkyState { } } -let current: SkyState | undefined; -const listeners = new Set<(s: SkyState) => void>(); +export type { FixedSkyState }; +/** Where the clock puts the sun: the latitude the skies are baked for (src/sky/skyTokens.js MOMENTS), the longitude of this timezone. */ +const LAT = 45; +/** Sun elevation (degrees) from which it is day, and below the negative of which it is night; between, dusk (dawn included). */ +export const DAY_FROM = 6; + +let clock: () => Date = () => new Date(); +/** The fixed sky a choice draws now: itself, or for 'auto' the sky the sun's elevation here calls for. */ +export function resolveSkyState(s: SkyState, now: Date = clock()): FixedSkyState { + if (s !== 'auto') return s; + const lon = -now.getTimezoneOffset() / 4; + const el = sunElevation(now, LAT, lon) as number; + if (el >= DAY_FROM) return 'day'; + return el <= -DAY_FROM ? 'night' : 'dusk'; +} + +let current: SkyState | undefined; +const listeners = new Set<(s: FixedSkyState) => void>(); +let timer: ReturnType | undefined; +let shown: FixedSkyState | undefined; +const TICK_MS = 60_000; + +/** The choice (what the control shows). */ export const getSkyState = (): SkyState => { current ??= load(); return current; }; +/** The sky being drawn: the choice, resolved. */ +export const getResolvedSkyState = (): FixedSkyState => resolveSkyState(getSkyState()); + +const emit = () => { + shown = getResolvedSkyState(); + [...listeners].forEach((cb) => cb(shown as FixedSkyState)); +}; + +// While anyone listens and the choice is 'auto', one timer checks the clock each minute and tells +// listeners when the resolved sky changes. Otherwise there is no timer. +function sync(): void { + clearTimeout(timer); + timer = undefined; + if (listeners.size === 0 || getSkyState() !== 'auto') return; + shown = getResolvedSkyState(); + const tick = () => { + if (getResolvedSkyState() !== shown) emit(); + timer = setTimeout(tick, TICK_MS); + }; + timer = setTimeout(tick, TICK_MS); +} + +/** Replace the clock (tests); no argument restores the real one. */ +export function setSkyClock(now?: () => Date): void { + clock = now ?? (() => new Date()); + sync(); +} + export function setSkyState(s: SkyState): void { if (!isSkyState(s)) return; const changed = s !== getSkyState(); @@ -35,20 +88,26 @@ export function setSkyState(s: SkyState): void { } catch { // Storage is refused (private mode, quota): the choice holds for this page load only. } - if (changed) [...listeners].forEach((cb) => cb(s)); + if (changed) { + sync(); + emit(); + } } -export function subscribe(cb: (s: SkyState) => void): () => void { +/** Listeners hear the resolved sky: on a change of choice, and while 'auto', when the clock crosses a boundary. */ +export function subscribe(cb: (s: FixedSkyState) => void): () => void { listeners.add(cb); + sync(); return () => { listeners.delete(cb); + sync(); }; } /** * Relative luminance (WCAG, 0..1) of the lightest and darkest sky the state can show behind the - * page. Light ink is worst against `lightest`, dark ink against `darkest`. 'auto' follows the - * clock, so it can show any of the three fixed skies and bounds all of them. + * page. Light ink is worst against `lightest`, dark ink against `darkest`. 'auto' is the band of + * the sky it resolves to now. */ export interface SkyBackdrop { readonly darkest: number; @@ -59,13 +118,11 @@ export interface SkyBackdrop { // the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). Held as constants so // this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering // tables with it; skyForState.test.ts fails when they drift from the computed union. -// Rounded outward to six places. 'auto' follows the clock (and a URL), so it -// bounds all three fixed states. -const BACKDROPS: Record = { - auto: { darkest: 0.016727, lightest: 0.967912 }, - day: { darkest: 0.237309, lightest: 0.92805 }, - dusk: { darkest: 0.016727, lightest: 0.967912 }, - night: { darkest: 0.058099, lightest: 0.077467 }, +// Rounded outward to six places. +const BACKDROPS: Record = { + day: { darkest: 0.304139, lightest: 0.925759 }, + dusk: { darkest: 0.303833, lightest: 0.967298 }, + night: { darkest: 0.05987, lightest: 0.079657 }, }; -export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[state]; +export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)]; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 3fd06875..027647da 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -8,7 +8,9 @@ import { MembersDrawer } from '../room/MembersDrawer.css'; import { RoomViewTyping } from '../room/RoomViewTyping.css'; import { SKY_PLATE_FALLBACK } from './skyPlate'; import { SkyPlate, plateRegions } from './skyPlate.css'; -import { SKY_STATES, skyBackdrop, type SkyBackdrop, type SkyState } from './skyState'; +import { setSkyClock, skyBackdrop } from './skyState'; +import type { SkyBackdrop } from './skyState'; +import type { FixedSkyState } from './skyBand'; import { skyThemeId } from './skyTheme'; const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; @@ -30,44 +32,62 @@ const ratio = (ink: string, b: SkyBackdrop) => const r2 = (n: number) => n.toFixed(2); describe('skyThemeId', () => { - it('leaves the theme alone in auto and picks one for every fixed state', () => { - expect(skyThemeId('auto')).toBeUndefined(); - expect(SKY_STATES.filter((s) => s !== 'auto').map((s) => skyThemeId(s))).not.toContain( - undefined - ); - }); - - it('lights day and dusk with dark ink and night with light ink', () => { + it('picks a theme for every fixed state, and for auto the theme of the sky it resolves to', () => { expect(skyThemeId('day')).toBe('noonday-sky'); expect(skyThemeId('dusk')).toBe('noonday-sky'); expect(skyThemeId('night')).toBe('midnight-sky'); + const at = (h: number, m = 0) => setSkyClock(() => new Date(2026, 8, 22, h, m)); + at(12); + expect(skyThemeId('auto')).toBe('noonday-sky'); + at(0); + expect(skyThemeId('auto')).toBe('midnight-sky'); + at(18, 10); + expect(skyThemeId('auto')).toBe('noonday-sky'); + setSkyClock(); }); }); describe('ink of the theme a state picks, on bare sky', () => { - // Per state: the picked theme, its ink and link, and their ratios on bare sky, over the union - // of the skies the scenes draw (skyBackdrop). Only night text passes (5.05); day's sky reaches - // 0.919 and dusk's 0.017 to 0.965, so no theme's ink holds there. No link passes anywhere - // (night 2.36): nothing rests on bare sky, everything sits on the plate below. - const cells: [SkyState, string, string, string, string, string][] = [ - ['day', 'noonday-sky', '#342d25', '3.71', '#2a4f27', '2.56'], - ['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27', '1.00'], - ['night', 'midnight-sky', '#aeccff', '5.05', '#609385', '2.36'], + // Per state: the picked theme, its text ink and link, and their WCAG ratios on the sky the scenes + // draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link does not: + // the links are derived in src/sky/skyTokens.js, outside the band, and these numbers are recorded + // as they are measured (Night's #609385 would clear 4.5 at a luminance of about 0.53 or more). + // 'auto' resolves from the clock to one of these three and has the same numbers. + const cells: [FixedSkyState, string, string, string, string, string][] = [ + ['day', 'noonday-sky', '#342d25', '4.58', '#2a4f27', '3.15'], + ['dusk', 'noonday-sky', '#342d25', '4.57', '#2a4f27', '3.15'], + ['night', 'midnight-sky', '#aeccff', '4.97', '#609385', '2.32'], ]; - it.each(cells)('%s with %s: ink %s is %s, link %s is %s', (state, id, ink, want, link, wantLink) => { - const sky = SKY[id as keyof typeof SKY]; - expect(sky.tokens.Background.OnContainer).toBe(ink); - expect(sky.link).toBe(link); - expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want); - expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink); + it.each(cells)( + '%s with %s: text %s is %s, link %s is %s', + (state, id, ink, want, link, wantLink) => { + const sky = SKY[id as keyof typeof SKY]; + expect(sky.tokens.Background.OnContainer).toBe(ink); + expect(sky.link).toBe(link); + expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want); + expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink); + } + ); + + it.each(cells)('%s: text clears AA, link is the known shortfall', (state, id, ink, _w, link) => { + expect(ratio(ink, skyBackdrop(state))).toBeGreaterThanOrEqual(AA); + expect(ratio(link, skyBackdrop(state))).toBeLessThan(AA); }); - it('night is the only state whose text clears AA on bare sky', () => { - const passing = fixed.filter( - ([s, id]) => ratio(SKY[id].tokens.Background.OnContainer, skyBackdrop(s)) >= AA - ); - expect(passing.map(([s]) => s)).toEqual(['night']); + it.each([ + [12, 'day'], + [18, 'dusk'], + [0, 'night'], + ] as const)('auto at %s:00 has the numbers of %s', (hour, state) => { + setSkyClock(() => new Date(2026, 8, 22, hour, hour === 18 ? 10 : 0)); + const id = skyThemeId('auto'); + expect(id).toBe(skyThemeId(state)); + const { tokens, link } = SKY[id]; + const text = tokens.Background.OnContainer; + expect(r2(ratio(text, skyBackdrop('auto')))).toBe(r2(ratio(text, skyBackdrop(state)))); + expect(r2(ratio(link, skyBackdrop('auto')))).toBe(r2(ratio(link, skyBackdrop(state)))); + setSkyClock(); }); }); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 8854fcd4..2e8c1b2e 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -1,19 +1,23 @@ -// Which theme a sky state lights the page with. 'auto' picks none: the page keeps the theme it -// would have had (the user's choice when signed in, Midnight Sky when signed out). +// Which theme a sky state lights the page with. 'auto' takes the theme of the sky it resolves to +// (skyState.resolveSkyState), since that is the sky behind the text. // -// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. On bare sky -// only Night's text clears 4.5:1 (5.05); Day's sky reaches 0.919 luminance and Dusk's spans -// 0.017 to 0.965, so no ink holds there. Text therefore never rests on bare sky: every region -// that would is a plate (skyPlate.css.ts), and skyTheme.test.ts pins the numbers. -import type { SkyState } from './skyState'; +// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Each state's +// drawn sky is shaped into a luminance band (skyBand.ts) in which that ink's TEXT clears 4.5:1 on +// bare sky: Day and Dusk at least 0.2983 (dark ink), Night at most 0.0931 (light ink). skyTheme.test.ts +// pins the measured text and link ratios. Link ink is not in the band: the Night link (#609385, +// 2.36:1) and the dark links on Day and Dusk fall short, because the themes' link colours are +// derived outside this code (src/sky/skyTokens.js); lightening the Night link to a luminance of +// about 0.53 or more would clear it. The plate (skyPlate.css.ts) is still in the shell, pending +// its removal. +import { resolveSkyState } from './skyState'; +import type { FixedSkyState, SkyState } from './skyState'; export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; -const BY_STATE: Record, SkyThemeId> = { +const BY_STATE: Record = { day: 'noonday-sky', dusk: 'noonday-sky', night: 'midnight-sky', }; -export const skyThemeId = (state: SkyState): SkyThemeId | undefined => - state === 'auto' ? undefined : BY_STATE[state]; +export const skyThemeId = (state: SkyState): SkyThemeId => BY_STATE[resolveSkyState(state)]; diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index fdc53926..60ab33eb 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -1,4 +1,5 @@ import type { Scene, SceneOptions } from '../Scene'; +import type { SkyBand } from '../skyBand'; import { FrameSampler, FrameStats } from '../ridge/probe'; import { albedoPalette } from './terrainAlbedo'; import { sunIndex } from '../../../../sky/scatter.js'; @@ -31,6 +32,8 @@ export type TerrainOptions = Pick< sky?: SkyName; /** Put the sun at this elevation (degrees) and azimuth (clockwise from north; default: noonday's) instead of at the baked sky's: `?sun=` and `?az=` in the page URL. Lights between the two bakes, for dusk. */ sun?: { elevation: number; azimuth?: number }; + /** The luminance band the sky is shaped into (skyBand.ts), in the sky pass and in the haze that fades into it. Default: none. */ + band?: SkyBand; /** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */ seed?: number; /** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`. */ @@ -212,6 +215,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const ambient = [...ground.ambient]; const heading = START_TURNS; const glow = [...glowColour(sky)]; + const band: [number, number] = sky.band ? [sky.band.floor, sky.band.ceiling] : [0, 1]; + const lift = [...(sky.band?.lift ?? [0, 0, 0])]; const sunTexel = sunIndex(sky.elevation); const night = nightness(sky.elevation); const moonTexel = sunIndex(Math.abs(sky.elevation)); @@ -247,6 +252,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.uniform3fv(us('uSun'), sun); gl.uniform3fv(us('uSunColour'), sunLight); gl.uniform3fv(us('uGlow'), glow); + gl.uniform2fv(us('uBand'), band); + gl.uniform3fv(us('uLift'), lift); gl.uniform1f(us('uSunIndex'), sunTexel); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_3D, skyTex); @@ -271,6 +278,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.uniform3fv(u('uLight'), ground.colour); gl.uniform3fv(u('uAmbient'), ambient); gl.uniform3fv(u('uGlow'), glow); + gl.uniform2fv(u('uBand'), band); + gl.uniform3fv(u('uLift'), lift); gl.uniform1f(u('uSunIndex'), sunTexel); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_3D, skyTex); diff --git a/src/app/features/scene/terrain/TerrainSky2D.test.ts b/src/app/features/scene/terrain/TerrainSky2D.test.ts index c3eb1e3d..c3a95d69 100644 --- a/src/app/features/scene/terrain/TerrainSky2D.test.ts +++ b/src/app/features/scene/terrain/TerrainSky2D.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTerrainSkyScene, SKY_WIDTH } from './TerrainSky2D'; import { startBackgroundScene } from '../index'; +import { terrainSkyFor } from '../skyForState'; let search: string; let arcs: number; @@ -218,3 +219,32 @@ describe('startBackgroundScene, terrain style', () => { ).toBeNull(); }); }); + +describe('the 2D sky lies in the band of its state', () => { + // The painted pixels, read back through the sRGB curve as a screen shows them. + const lumOf = (rgb: number[]) => { + const [r, g, b] = rgb.map((v) => { + const e = v / 255; + return e <= 0.04045 ? e / 12.92 : ((e + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const painted = (state: 'day' | 'dusk' | 'night') => { + const c = canvasWith(); + createTerrainSkyScene({ canvas: c.canvas, tier: 'low', ...terrainSkyFor(state) })!.dispose(); + const img = c.painted[0]; + const out: number[] = []; + for (let y = 0; y < img.height; y += 1) { + for (let x = 0; x < img.width; x += 1) out.push(lumOf(pixel(img, x, y))); + } + return out; + }; + + it.each([['day'], ['dusk']] as const)('%s: no pixel below the text bound 0.2983', (state) => { + expect(Math.min(...painted(state))).toBeGreaterThanOrEqual(0.2983); + }); + + it('night: no pixel above the text bound 0.0931', () => { + expect(Math.max(...painted('night'))).toBeLessThanOrEqual(0.0931); + }); +}); diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts index 0c19ec97..71bc7a78 100644 --- a/src/app/features/scene/terrain/TerrainSky2D.ts +++ b/src/app/features/scene/terrain/TerrainSky2D.ts @@ -9,7 +9,7 @@ import { CAMERA, view } from './terrainWorld'; export const SKY_WIDTH = 96; /** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */ -export type TerrainSkyOptions = Pick; +export type TerrainSkyOptions = Pick; /** * Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 8e270d83..b79e2873 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -7,6 +7,7 @@ import cultmath from '../shaders/CultMath.glsl?raw'; import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; +import { LUMA } from '../skyBand'; import { ALBEDO, CAVITY } from './terrainAlbedo'; import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter'; import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, SKY_FADE, STAR_FLOOR } from './terrainSky'; @@ -140,14 +141,28 @@ const ALBEDO_DEFINES = ` #define CAV_DARK ${lit(CAVITY.dark)} #define CAV_LIGHT ${lit(CAVITY.light)} `; -// The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow. The -// same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already scaled by how dark the sun is. -const SKY_COLOUR_GLSL = `${SCATTER_GLSL} +// The GLSL twin of skyBand.shapeToBand: clamp to 0..1, mix toward uLift to land on the floor uBand.x, or scale down to the ceiling uBand.y. +// No band is uBand = (0, 1). +export const SHAPE_SKY_GLSL = ` +uniform vec2 uBand; +uniform vec3 uLift; +const vec3 LUMA = vec3(${LUMA.map(lit).join(', ')}); +vec3 shapeSky(vec3 c) { + c = clamp(c, 0.0, 1.0); + float l = dot(c, LUMA); + if (l < uBand.x) return mix(c, uLift, (uBand.x - l) / (dot(uLift, LUMA) - l)); + if (l > uBand.y) return c * (uBand.y / l); + return c; +}`; +// The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow, shaped into +// the state's luminance band. The same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already +// scaled by how dark the sun is. +const SKY_COLOUR_GLSL = `${SCATTER_GLSL}${SHAPE_SKY_GLSL} uniform vec3 uGlow; vec3 skyColour(vec3 d) { - return 1.0 - exp(-${lit(SKY_EXPOSURE)} * skyScatter(d)) + uGlow * mix(1.0, ${lit( + return shapeSky(1.0 - exp(-${lit(SKY_EXPOSURE)} * skyScatter(d)) + uGlow * mix(1.0, ${lit( GLOW_ZENITH -)}, sqrt(clamp(d.y, 0.0, 1.0))); +)}, sqrt(clamp(d.y, 0.0, 1.0)))); }`; export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL}${AERIAL_GLSL} diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index a6dc7a7e..f409471a 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -14,7 +14,6 @@ import { skyAtSun, skyData, skyColour, - skyNameFor, sunDirection, aerialPerspective, SKY_FADE, @@ -25,7 +24,6 @@ import { START_TURNS, Vec3, } from './terrainSky'; -import { skyColours } from '../ridge/skyColour'; const UP = [0, 1, 0] as const; const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; @@ -68,11 +66,6 @@ describe('the baked sky is read, not copied', () => { expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]); expect(hexToLinear('#808080')[0]).toBeCloseTo(0.2158, 3); }); - - it('the default sky from skyColours() is a night one, as the stars see it', () => { - expect(skyNameFor(skyColours())).toBe('midnight'); - expect(skyNameFor({ ...skyColours(), top: [150, 198, 255] })).toBe('noonday'); - }); }); describe('a sun at any elevation (skyAtSun)', () => { diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index f6647407..3470249c 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -1,9 +1,8 @@ // Lighting, haze and sky for the terrain scene, as pure functions of one sky's baked numbers (src/sky/baked.ts, Void's // bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms. import { baked } from '../../../../sky/baked'; -import { isNight } from '../ridge/stars'; -import { skyColours } from '../ridge/skyColour'; -import type { SkyColours } from '../ridge/skyColour'; +import { hexToLinear, shapeToBand } from '../skyBand'; +import type { SkyBand } from '../skyBand'; import { AP_DISTANCE_SCALE, aerialAt, @@ -26,6 +25,8 @@ export interface SkyData { ambient: Vec3; /** The horizon, linear RGB: the baked `horizon_hex`. */ horizon: Vec3; + /** The luminance band `skyColour` shapes the sky into (skyBand.ts); absent: the physical sky as it is. */ + band?: SkyBand; } const RAD = Math.PI / 180; @@ -37,19 +38,9 @@ const smooth = (a: number, b: number, x: number) => { const mixVec = (a: Vec3, b: Vec3, t: number): Vec3 => [0, 1, 2].map((i) => a[i] + (b[i] - a[i]) * t) as unknown as Vec3; -/** Which baked sky a ridge-style `SkyColours` means: the night notion the stars use (`isNight`). */ -export const skyNameFor = (sky: SkyColours): SkyName => (isNight(sky) ? 'midnight' : 'noonday'); - export const isSkyName = (s: unknown): s is SkyName => s === 'midnight' || s === 'noonday'; -/** sRGB hex `#rrggbb` to linear RGB 0..1. */ -export function hexToLinear(hex: string): Vec3 { - const ch = (at: number) => { - const s = parseInt(hex.slice(at, at + 2), 16) / 255; - return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; - }; - return [ch(1), ch(3), ch(5)]; -} +export { hexToLinear }; const fromBaked = (b: typeof baked.midnight | typeof baked.noonday): SkyData => ({ elevation: b.sun.elevation_deg, @@ -115,7 +106,8 @@ export const glowColour = (d: SkyData): Vec3 => { /** * Linear colour of the sky along a unit view direction (x east, y up, z south): the baked single-scattering * radiance for this sun, lifted by SKY_EXPOSURE and tone-mapped `1 - exp(-x)`, plus the night glow, which sits - * brightest at the horizon; at and below the horizon, the horizon's colour. The fragment shader does the same. + * brightest at the horizon; at and below the horizon, the horizon's colour; then shaped into `d.band` when it has one. + * The fragment shader does the same. */ export function skyColour(d: SkyData, dir: Vec3): Vec3 { const el = Math.max(HORIZON_FLOOR, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD); @@ -129,9 +121,10 @@ export function skyColour(d: SkyData, dir: Vec3): Vec3 { const r = scatterRadiance(d.elevation, el, az); const glow = glowColour(d); const shape = 1 + (GLOW_ZENITH - 1) * Math.sqrt(clamp01(dir[1])); - return [0, 1, 2].map( + const c = [0, 1, 2].map( (i) => 1 - Math.exp(-SKY_EXPOSURE * r[i]) + glow[i] * shape ) as unknown as Vec3; + return d.band ? shapeToBand(c, d.band) : c; } /** @@ -231,21 +224,23 @@ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, /** * The sky to use. An explicit option wins over the page URL, so a person who picked a sky sees it: a sun * elevation (with its azimuth, default noonday's), else a baked sky. Only without either does the URL steer: - * `?sun=` (with `?az=`), then `?sky=midnight|noonday`, then the night notion of `skyColours()`. + * `?sun=` (with `?az=`), then `?sky=midnight|noonday`, then midnight. (The door always names a sky and a band; these are for callers that do not.) */ export function chooseSky(opts: { sky?: SkyName; sun?: { elevation: number; azimuth?: number }; + band?: SkyBand; }): SkyData { - if (opts.sun) return skyAtSun(opts.sun.elevation, opts.sun.azimuth); - if (opts.sky) return skyData(opts.sky); + const banded = (d: SkyData): SkyData => (opts.band ? { ...d, band: opts.band } : d); + if (opts.sun) return banded(skyAtSun(opts.sun.elevation, opts.sun.azimuth)); + if (opts.sky) return banded(skyData(opts.sky)); const q = new URLSearchParams(window.location.search); const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); const elevation = number('sun'); if (Number.isFinite(elevation)) { const azimuth = number('az'); - return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); + return banded(skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined)); } const name = q.get('sky'); - return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); + return banded(skyData(isSkyName(name) ? name : 'midnight')); } diff --git a/src/app/hooks/useSkyState.ts b/src/app/hooks/useSkyState.ts index 985e2da2..4a2448b2 100644 --- a/src/app/hooks/useSkyState.ts +++ b/src/app/hooks/useSkyState.ts @@ -1,4 +1,15 @@ import { useSyncExternalStore } from 'react'; -import { getSkyState, subscribe, type SkyState } from '../features/scene/skyState'; +import { + getResolvedSkyState, + getSkyState, + subscribe, + type FixedSkyState, + type SkyState, +} from '../features/scene/skyState'; +/** The choice, as the control shows it ('auto' stays 'auto'). */ export const useSkyState = (): SkyState => useSyncExternalStore(subscribe, getSkyState); + +/** The sky being drawn: the choice, resolved from the clock for 'auto'. */ +export const useResolvedSkyState = (): FixedSkyState => + useSyncExternalStore(subscribe, getResolvedSkyState); diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index bf556dd0..6f1b914f 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -13,7 +13,7 @@ import '../../sky.css'; import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; import { skyThemeId } from '../features/scene/skyTheme'; -import { useSkyState } from './useSkyState'; +import { useResolvedSkyState } from './useSkyState'; export enum ThemeKind { Light = 'light', @@ -102,9 +102,9 @@ export const useSystemThemeKind = (): ThemeKind => { return themeKind; }; -/** The theme the sky state asks for, or undefined while it is 'auto'. */ +/** The theme the sky being drawn asks for ('auto' asks for the one of the sky it resolves to). */ export const useSkyTheme = (): Theme | undefined => { - const id = skyThemeId(useSkyState()); + const id = skyThemeId(useResolvedSkyState()); return id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme; }; From 4177bdcccbf73aac742aa4d85232569755d34bfc Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 23:19:35 +0000 Subject: [PATCH 048/111] scene: bound the terrain sky over what its frozen camera reaches (0 to 16 degrees), ridge stays whole-range Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyForState.test.ts | 41 +++++++++++++++++++--- src/app/features/scene/skyForState.ts | 22 ++++++++---- src/app/features/scene/skyState.ts | 7 ++-- src/app/features/scene/skyTheme.test.ts | 18 ++++++---- 4 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index fb95dc0a..aec50c12 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -5,6 +5,7 @@ import { skyColours } from './ridge/skyColour'; import { chooseSky, skyColour } from './terrain/terrainSky'; import { DUSK_SUN_ELEVATION, + TERRAIN_REACH_DEG, ridgeDrawn, ridgeSkyFor, terrainDrawn, @@ -13,6 +14,7 @@ import { import { displayedLuminance, SKY_BAND } from './skyBand'; import type { FixedSkyState } from './skyBand'; import { setSkyClock, skyBackdrop } from './skyState'; +import { view } from './terrain/terrainWorld'; import { skyThemeId } from './skyTheme'; const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; @@ -80,6 +82,27 @@ describe('ridgeSkyFor', () => { }); }); +describe('the terrain camera reach', () => { + it('covers the highest ray the frozen camera sees, at any aspect, with a small margin', () => { + const tanHalf = Math.tan((55 * Math.PI) / 360); + const tops = [0.4, 0.5, 1, 16 / 9, 2.4].flatMap((aspect) => + [0, 0.125, 0.25, 0.5, 0.75].flatMap((turns) => { + const v = view(aspect, turns); + return [-1, 0, 1].map((nx) => { + const d = [0, 1, 2].map( + (i) => v.forward[i] + nx * tanHalf * aspect * v.right[i] + tanHalf * v.up[i] + ); + return (Math.asin(d[1] / Math.hypot(d[0], d[1], d[2])) * 180) / Math.PI; + }); + }) + ); + const top = Math.max(...tops); + expect(top).toBeCloseTo(14.5, 1); + expect(TERRAIN_REACH_DEG).toBeGreaterThanOrEqual(top); + expect(TERRAIN_REACH_DEG - top).toBeLessThan(2); + }); +}); + describe('terrainSkyFor', () => { it('draws the sky auto resolves to, with that state band', () => { setSkyClock(() => new Date(2026, 8, 22, 12, 0)); @@ -108,11 +131,11 @@ const dirAt = (el: number, az: number): [number, number, number] => { -Math.cos(el * rad) * Math.cos(az * rad), ]; }; -const sweepOf = (sky: ReturnType, N = 4000) => { +const sweepOf = (sky: ReturnType, N = 4000, maxEl = TERRAIN_REACH_DEG) => { const rad = Math.PI / 180; const out: number[] = []; for (let i = 0; i < N; i += 1) { - const y = (i + 0.5) / N; // sin(elevation): uniform over the upper hemisphere + const y = ((i + 0.5) / N) * Math.sin(maxEl * rad); // sin(elevation): uniform from the horizon to maxEl out.push( displayedLuminance(skyColour(sky, dirAt(Math.asin(y) / rad, (i * 137.50776405) % 360))) ); @@ -121,7 +144,10 @@ const sweepOf = (sky: ReturnType, N = 4000) => { for (let dAz = -3; dAz <= 3; dAz += 0.05) { out.push( displayedLuminance( - skyColour(sky, dirAt(Math.max(0, sky.elevation + dEl), sky.azimuth + dAz)) + skyColour( + sky, + dirAt(Math.min(maxEl, Math.max(0, sky.elevation + dEl)), sky.azimuth + dAz) + ) ) ); } @@ -132,6 +158,13 @@ const sweepOf = (sky: ReturnType, N = 4000) => { describe('the terrain sky lies in the band its text ink needs', () => { const sweep = (state: FixedSkyState) => sweepOf(chooseSky(terrainSkyFor(state))); + it('dusk needs its lift: the scatter sky alone dips below the text bound within the reach', () => { + const { sun } = terrainSkyFor('dusk'); + const l = sweepOf(chooseSky({ sun })); + expect(Math.min(...l)).toBeLessThan(needs('dusk').floor - 0.02); + expect(Math.min(...sweep('dusk'))).toBeGreaterThanOrEqual(needs('dusk').floor); + }); + it.each(STATES)('%s', (state) => { const { floor, ceiling } = needs(state); const l = sweep(state); @@ -142,7 +175,7 @@ describe('the terrain sky lies in the band its text ink needs', () => { it.each(STATES)('%s holds for a sun at any elevation, as ?sun= can put it', (state) => { const { floor, ceiling } = needs(state); for (let e = -90; e <= 90; e += 10) { - const l = sweepOf(chooseSky({ sun: { elevation: e }, band: SKY_BAND[state] })); + const l = sweepOf(chooseSky({ sun: { elevation: e }, band: SKY_BAND[state] }), 4000, 90); expect(Math.min(...l)).toBeGreaterThanOrEqual(floor); expect(Math.max(...l)).toBeLessThanOrEqual(ceiling); } diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index 0de0dbe0..e7abcd73 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -13,6 +13,7 @@ import type { SkyState } from './skyState'; import { displayedLuminance, SKY_BAND } from './skyBand'; import type { SkyBand } from './skyBand'; import { luminance } from '../../../sky/skyTokens.js'; +import { CAMERA } from './terrain/terrainWorld'; const rgb = (hex: string): Srgb => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb; @@ -89,6 +90,14 @@ export function ridgeDrawn(state: SkyState): Range { return { darkest: Math.min(...l), lightest: Math.max(...l) }; } +/** + * How high the terrain camera can see, in degrees of elevation: it is frozen at a downward pitch and only turns + * (no pull-back), so the top edge of its view, at the centre column, is the pitch down subtracted from half the + * field of view (14.5 degrees; off-centre columns look lower). Plus a 1.5 degree margin. The terrain's range is + * over 0 to this, all azimuths, not the whole upper sphere; below the horizon the sky is the horizon's colour. + */ +export const TERRAIN_REACH_DEG = Math.ceil((CAMERA.fov / 2 - CAMERA.pitch) * (180 / Math.PI) + 1.5); + const SAMPLE_DEG = 3; const SUN_DEG = 0.25; const SUN_SPAN_DEG = 8; @@ -96,9 +105,8 @@ const HORIZON_DEG = 0.5; const REFINE_FROM = 6; /** - * The terrain sky's range, as displayed (`displayedLuminance`): `skyColour` (the CPU twin of its sky shader) over the whole upper sphere, which the camera - * sweeps as it turns, for the sky the scene picks for this state. A 3 degree grid, a 0.5 degree ring of the horizon, the - * zenith and a 0.25 degree patch around the sun find where the extremes are; each of the best few is then refined by a + * The terrain sky's range, as displayed (`displayedLuminance`): `skyColour` (the CPU twin of its sky shader) over the part of the sky the camera reaches (0 to TERRAIN_REACH_DEG, every azimuth: it turns), for the sky the scene picks for this state. A 3 degree grid, a 0.5 degree ring of the horizon, the + * top of the reach and a 0.25 degree patch around the sun find where the extremes are; each of the best few is then refined by a * compass search whose step halves from 1 degree to under 0.001, because the sun's glow peaks (and the sky's darkest * patch sits) between any fixed grid's samples. */ @@ -106,7 +114,7 @@ export function terrainDrawn(state: SkyState): Range { const sky = chooseSky(terrainSkyFor(state)); const rad = Math.PI / 180; const lum = (el: number, az: number) => { - const e = Math.min(90, Math.max(0, el)) * rad; + const e = Math.min(TERRAIN_REACH_DEG, Math.max(0, el)) * rad; const a = az * rad; return displayedLuminance( skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]) @@ -114,11 +122,11 @@ export function terrainDrawn(state: SkyState): Range { }; const seen: { el: number; az: number; l: number }[] = []; const at = (el: number, az: number) => seen.push({ el, az, l: lum(el, az) }); - for (let el = 0; el <= 90; el += SAMPLE_DEG) { + for (let el = 0; el <= TERRAIN_REACH_DEG; el += SAMPLE_DEG) { for (let az = 0; az < 360; az += SAMPLE_DEG) at(el, az); } for (let az = 0; az < 360; az += HORIZON_DEG) at(0, az); - at(90, 0); + at(TERRAIN_REACH_DEG, 0); for (let dEl = -SUN_SPAN_DEG; dEl <= SUN_SPAN_DEG; dEl += SUN_DEG) { for (let dAz = -SUN_SPAN_DEG; dAz <= SUN_SPAN_DEG; dAz += SUN_DEG) { at(sky.elevation + dEl, sky.azimuth + dAz); @@ -132,7 +140,7 @@ export function terrainDrawn(state: SkyState): Range { [0, step], [0, -step], ].map(([dEl, dAz]) => { - const el = Math.min(90, Math.max(0, p.el + dEl)); + const el = Math.min(TERRAIN_REACH_DEG, Math.max(0, p.el + dEl)); return { el, az: p.az + dAz, l: lum(el, p.az + dAz) }; }); const refine = (from: Point, sign: 1 | -1) => { diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index ce31fb23..c27c10a4 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -117,11 +117,12 @@ export interface SkyBackdrop { // The union of every sky a scene draws for the state: the ridge sky and the terrain sky, each from // the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). Held as constants so // this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering -// tables with it; skyForState.test.ts fails when they drift from the computed union. +// tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts +// over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts). // Rounded outward to six places. const BACKDROPS: Record = { - day: { darkest: 0.304139, lightest: 0.925759 }, - dusk: { darkest: 0.303833, lightest: 0.967298 }, + day: { darkest: 0.466725, lightest: 0.892734 }, + dusk: { darkest: 0.303835, lightest: 0.967298 }, night: { darkest: 0.05987, lightest: 0.079657 }, }; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 027647da..4f237fc6 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -49,12 +49,12 @@ describe('skyThemeId', () => { describe('ink of the theme a state picks, on bare sky', () => { // Per state: the picked theme, its text ink and link, and their WCAG ratios on the sky the scenes - // draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link does not: - // the links are derived in src/sky/skyTokens.js, outside the band, and these numbers are recorded - // as they are measured (Night's #609385 would clear 4.5 at a luminance of about 0.53 or more). + // draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link clears + // on day and not on dusk or night: the links are derived in src/sky/skyTokens.js, outside the band, and these + // numbers are recorded as they are measured (Night's #609385 would clear 4.5 at a luminance of about 0.53 or more). // 'auto' resolves from the clock to one of these three and has the same numbers. const cells: [FixedSkyState, string, string, string, string, string][] = [ - ['day', 'noonday-sky', '#342d25', '4.58', '#2a4f27', '3.15'], + ['day', 'noonday-sky', '#342d25', '6.68', '#2a4f27', '4.60'], ['dusk', 'noonday-sky', '#342d25', '4.57', '#2a4f27', '3.15'], ['night', 'midnight-sky', '#aeccff', '4.97', '#609385', '2.32'], ]; @@ -70,9 +70,15 @@ describe('ink of the theme a state picks, on bare sky', () => { } ); - it.each(cells)('%s: text clears AA, link is the known shortfall', (state, id, ink, _w, link) => { + it.each(cells)('%s: text clears AA', (state, _id, ink) => { expect(ratio(ink, skyBackdrop(state))).toBeGreaterThanOrEqual(AA); - expect(ratio(link, skyBackdrop(state))).toBeLessThan(AA); + }); + + it('link clears AA on day (the terrain camera sees only the lighter sky) and falls short on dusk and night', () => { + const link = (s: FixedSkyState, id: keyof typeof SKY) => ratio(SKY[id].link, skyBackdrop(s)); + expect(link('day', 'noonday-sky')).toBeGreaterThanOrEqual(AA); + expect(link('dusk', 'noonday-sky')).toBeLessThan(AA); + expect(link('night', 'midnight-sky')).toBeLessThan(AA); }); it.each([ From eafa3eda798f4dcfb2aa0d49c686b763902c9aed Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 23:28:55 +0000 Subject: [PATCH 049/111] scene: Day sky bound was 1.2e-6 low; refine to 1e-6 degrees and check both directions Day lightest 0.92805 -> 0.928052 (true terrain peak 0.92805117...). The compass refinement now halves to 5e-7 degrees; the drift test checks the constant against the refined computation and an independent golden-section sweep, exact to [extreme, extreme + 1e-6). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyForState.test.ts | 66 ++++++++++++++++++++-- src/app/features/scene/skyForState.ts | 2 +- src/app/features/scene/skyState.ts | 2 +- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 44ca5899..20bc023a 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -130,26 +130,82 @@ describe('both scenes of a state lie inside skyBackdrop', () => { }); describe('skyBackdrop holds the union of what the scenes draw', () => { + // An independent extremum: not terrainDrawn's grid and compass search but alternating golden-section + // line searches in elevation and azimuth from 60 starts (a golden-angle spiral and scatter about the sun), + // the bracket shrinking to 1e-3 degrees. A plateau (the shader clamps) cannot stall it: each line search + // always moves to the better end of its bracket. + const extremes = (state: 'day' | 'dusk' | 'night') => { + const sky = chooseSky(terrainSkyFor(state)); + const rad = Math.PI / 180; + const f = (el: number, az: number) => { + const e = Math.min(90, Math.max(0, el)) * rad; + const a = az * rad; + const [r, g, b] = skyColour(sky, [ + Math.cos(e) * Math.sin(a), + Math.sin(e), + -Math.cos(e) * Math.cos(a), + ]).map((v) => Math.min(1, Math.max(0, v))); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const P = 0.6180339887; + const line = (g: (x: number) => number, mid: number, w: number) => { + let a = mid - w; + let b = mid + w; + for (let k = 0; k < 40; k += 1) { + const c = b - P * (b - a); + const d = a + P * (b - a); + if (g(c) > g(d)) b = d; + else a = c; + } + return (a + b) / 2; + }; + const search = (sign: 1 | -1) => { + let best = -Infinity; + for (let i = 0; i < 60; i += 1) { + let el = + i % 2 + ? sky.elevation + (((i * 7919) % 1600) - 800) / 100 + : Math.asin((i + 0.5) / 60) / rad; + let az = + i % 2 ? sky.azimuth + (((i * 104729) % 1600) - 800) / 100 : (i * 137.50776405) % 360; + for (let w = 8; w > 1e-3; w /= 2) { + const [el0, az0] = [el, az]; + el = line((x) => sign * f(Math.max(0, x), az0), el0, w); + const el1 = el; + az = line((x) => sign * f(el1, x), az0, w); + best = Math.max(best, sign * f(el, az)); + } + } + return sign * best; + }; + return { darkest: search(-1), lightest: search(1) }; + }; + const computed = (state: 'day' | 'dusk' | 'night') => { const r = [ridgeDrawn(state), terrainDrawn(state)].filter((x) => x !== undefined) as { darkest: number; lightest: number; }[]; + const ind = extremes(state); + const ridge = ridgeDrawn(state); return { - darkest: Math.min(...r.map((x) => x.darkest)), - lightest: Math.max(...r.map((x) => x.lightest)), + darkest: Math.min(...r.map((x) => x.darkest), ind.darkest, ridge?.darkest ?? 1), + lightest: Math.max(...r.map((x) => x.lightest), ind.lightest, ridge?.lightest ?? 0), }; }; + // The constants are the swept extremes rounded outward to six places, so each lies in [extreme, extreme + 1e-6): + // a constant 1e-6 low is under the extreme, one 2e-5 high is over the bound. SEARCH is the searches' own error. + const SEARCH = 1e-8; it.each(['day', 'dusk', 'night'] as const)( - '%s: the constant bounds the computed range and sits within 1e-5 of it', + '%s: the constant is the swept extreme rounded outward, within 1e-6 above it', (state) => { const c = computed(state); const b = skyBackdrop(state); expect(b.darkest).toBeLessThanOrEqual(c.darkest); expect(b.lightest).toBeGreaterThanOrEqual(c.lightest); - expect(c.darkest - b.darkest).toBeLessThan(1e-5); - expect(b.lightest - c.lightest).toBeLessThan(1e-5); + expect(c.darkest - b.darkest).toBeLessThan(1e-6 + SEARCH); + expect(b.lightest - c.lightest).toBeLessThan(1e-6 + SEARCH); } ); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index a6d78e4a..aedc6861 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -121,7 +121,7 @@ export function terrainDrawn(state: SkyState): Range { }); const refine = (from: Point, sign: 1 | -1) => { let best = from; - for (let step = 1; step > 0.0005; step /= 2) { + for (let step = 1; step > 5e-7; step /= 2) { let improved = true; while (improved) { const cur = best; diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index e06e9845..dc2b6199 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -63,7 +63,7 @@ export interface SkyBackdrop { // bounds all three fixed states. const BACKDROPS: Record = { auto: { darkest: 0.016727, lightest: 0.967912 }, - day: { darkest: 0.237309, lightest: 0.92805 }, + day: { darkest: 0.237309, lightest: 0.928052 }, dusk: { darkest: 0.016727, lightest: 0.967912 }, night: { darkest: 0.058099, lightest: 0.077467 }, }; From 577d795189c1cc7adec6fd3229ab243e180a47d3 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 23:29:02 +0000 Subject: [PATCH 050/111] scene: auto never picks a theme; URL steers auto; quantised bounds; listener-window fix; DST - skyThemeId('auto') is undefined again (auto must not override a signed-in theme); the resolved state stays readable by getResolvedSkyState and subscribe. - In auto, ?sun= ?az= ?sky= reach the terrain again, shaped into the band of the clock-resolved state; an explicit state beats the URL. - The sky-change notice is no longer swallowed by a listener arriving or leaving between the clock crossing and the tick. - BACKDROPS bound what the 8-bit screen shows with the dither at its worst; shaping floor 0.317, night ceiling 0.085. - The clock uses standard time, so daylight saving does not move the sun. - A Terrain GL fake test pins uBand and uLift for each state. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 14 +- src/app/features/scene/index.ts | 24 +-- src/app/features/scene/skyBand.test.ts | 4 +- src/app/features/scene/skyBand.ts | 24 ++- src/app/features/scene/skyForState.test.ts | 147 +++++++++++------- src/app/features/scene/skyForState.ts | 29 +++- src/app/features/scene/skyState.test.ts | 23 ++- src/app/features/scene/skyState.ts | 22 ++- src/app/features/scene/skyTheme.test.ts | 37 +++-- src/app/features/scene/skyTheme.ts | 9 +- .../features/scene/standardTime.testkit.ts | 7 + .../features/scene/terrain/Terrain.test.ts | 28 ++++ src/app/features/scene/terrain/Terrain.ts | 2 +- src/app/features/scene/terrain/terrainSky.ts | 33 ++-- src/app/hooks/useSkyState.ts | 14 +- src/app/hooks/useTheme.ts | 6 +- 16 files changed, 287 insertions(+), 136 deletions(-) create mode 100644 src/app/features/scene/standardTime.testkit.ts diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 645b093c..7971508b 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { atStandardTime } from './standardTime.testkit'; import { baked } from '../../../sky/baked'; import type { Scene } from './Scene'; import { startBackgroundScene } from './index'; @@ -24,7 +25,7 @@ const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2 beforeEach(() => { vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }); // Local midnight: 'auto' resolves to night. - setSkyClock(() => new Date(2026, 8, 22, 0, 0)); + setSkyClock(() => atStandardTime(0, 0)); setSkyState('auto'); createRidgeScene.mockReset(); createScene.mockReset(); @@ -44,6 +45,17 @@ describe('startBackgroundScene follows the sky state', () => { scene?.dispose(); }); + it('rebuilds when auto is replaced by the explicit state it already resolves to (a URL may steer only auto)', async () => { + const [a, b] = [fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('night'); + await flush(); + expect(a.dispose).toHaveBeenCalledOnce(); + expect(createRidgeScene).toHaveBeenCalledTimes(2); + scene?.dispose(); + }); + it('starts on the stored state', async () => { setSkyState('night'); createRidgeScene.mockReturnValue(fakeScene()); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 4fe33934..73ab8d5a 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -1,7 +1,7 @@ import type { Scene, SceneOptions } from './Scene'; import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; -import { getResolvedSkyState, subscribe } from './skyState'; -import type { FixedSkyState } from './skyState'; +import { getResolvedSkyState, getSkyState, subscribe } from './skyState'; +import type { SkyState } from './skyState'; export type { Scene, SceneStats } from './Scene'; export type { SceneStyle, SceneTier } from './tier'; @@ -18,7 +18,7 @@ export type StartOptions = Omit { let createScene: (o: SceneOptions) => Scene | null; try { @@ -64,21 +64,25 @@ export async function startBackgroundScene( ): Promise { const tier = resolveTier(config, capabilities); if (tier === 'off') return null; - const build = (state: FixedSkyState) => create(config, { ...options, tier }, state); - const state = getResolvedSkyState(); + const build = (state: SkyState) => create(config, { ...options, tier }, state); + const state = getSkyState(); + const key0 = `${state}:${getResolvedSkyState()}`; const first = await build(state); if (!first || config?.style === 'raymarch') return first; let inner: Scene | null = first; - let built = state; + let built = key0; let loggedIn = false; let disposed = false; let generation = 0; - const rebuild = async (next: FixedSkyState) => { - if (next === built) return; + // A rebuild is for a new choice or a new resolved sky ('auto' crossing a boundary), not for a repeat of either. + const rebuild = async () => { + const next = getSkyState(); + const key = `${next}:${getResolvedSkyState()}`; + if (key === built) return; generation += 1; const mine = generation; - built = next; + built = key; inner?.dispose(); inner = null; const scene = await build(next); @@ -95,7 +99,7 @@ export async function startBackgroundScene( }; const off = subscribe(rebuild); // A change that landed while the first build was awaited was missed by the subscription. - rebuild(getResolvedSkyState()); + rebuild(); return { login() { loggedIn = true; diff --git a/src/app/features/scene/skyBand.test.ts b/src/app/features/scene/skyBand.test.ts index 23c7c18e..5963fb79 100644 --- a/src/app/features/scene/skyBand.test.ts +++ b/src/app/features/scene/skyBand.test.ts @@ -52,9 +52,9 @@ describe('the bands', () => { Object.values(SKY_BAND).forEach((b) => expect(luma(b.lift)).toBeGreaterThan(b.floor)); }); - it('keep the floor 0.01 above the 0.2983 text bound: gamma 2.2 reads darker than sRGB by less', () => { + it('keep the floor above the 0.2983 text bound by what gamma 2.2, 8-bit rounding and dither can take', () => { const grey = shapeToBand([0, 0, 0], SKY_BAND.day); - const shown = displayedLuminance(grey); + const shown = displayedLuminance(grey, 'low'); expect(shown).toBeGreaterThanOrEqual(0.2983); expect(shown).toBeLessThan(SKY_BAND.day.floor); }); diff --git a/src/app/features/scene/skyBand.ts b/src/app/features/scene/skyBand.ts index 470393ee..ae6d5791 100644 --- a/src/app/features/scene/skyBand.ts +++ b/src/app/features/scene/skyBand.ts @@ -28,13 +28,20 @@ export function hexToLinear(hex: string): Linear { return [ch(1), ch(3), ch(5)]; } +/** How a displayed colour is read: as encoded, or as the 8-bit screen can show it, the sky pass dithers by half a step either way ('low', 'high'). */ +export type Reading = 'exact' | 'low' | 'high'; + /** * Relative luminance of what the terrain shaders put on screen for a linear colour: they encode with - * pow(1/2.2), not the sRGB curve, so the screen colour is read back through the sRGB curve. + * pow(1/2.2), not the sRGB curve, so the screen colour is read back through the sRGB curve. 'low' and 'high' read + * it after 8-bit quantisation with the dither at its worst, so the bounds they give hold on the screen. */ -export function displayedLuminance(c: Linear): number { +export function displayedLuminance(c: Linear, reading: Reading = 'exact'): number { const ch = (v: number) => { - const e = Math.min(1, Math.max(0, v)) ** (1 / 2.2); + let e = Math.min(1, Math.max(0, v)) ** (1 / 2.2); + if (reading !== 'exact') { + e = Math.min(255, Math.max(0, Math.round(e * 255 + (reading === 'low' ? -0.5 : 0.5)))) / 255; + } return e <= 0.04045 ? e / 12.92 : ((e + 0.055) / 1.055) ** 2.4; }; return luma([ch(c[0]), ch(c[1]), ch(c[2])]); @@ -50,13 +57,14 @@ export interface SkyBand { } /** - * The shaping targets, in linear luma. The floor sits 0.01 above the text bound (0.2983) because the - * screen encoding is gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level. + * The shaping targets, in linear luma. The floor sits 0.019 above the text bound (0.2983): the screen encoding is + * gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level, and 8-bit rounding with the sky + * pass's dither at its worst takes about 0.006 more. */ export const SKY_BAND: Record = { - day: { floor: 0.31, ceiling: 1, lift: hexToLinear('#e2f4ff') }, - dusk: { floor: 0.31, ceiling: 1, lift: hexToLinear('#eba0b9') }, - night: { floor: 0, ceiling: 0.09, lift: hexToLinear('#484e64') }, + day: { floor: 0.317, ceiling: 1, lift: hexToLinear('#e2f4ff') }, + dusk: { floor: 0.317, ceiling: 1, lift: hexToLinear('#eba0b9') }, + night: { floor: 0, ceiling: 0.085, lift: hexToLinear('#5a6a9a') }, }; /** diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index aec50c12..116e7f9f 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { atStandardTime } from './standardTime.testkit'; import { baked } from '../../../sky/baked'; import { AA, luminance } from '../../../sky/skyTokens.js'; import { skyColours } from './ridge/skyColour'; @@ -33,7 +34,19 @@ const needs = (state: FixedSkyState) => { : { floor: AA * (l + 0.05) - 0.05, ceiling: 1 }; }; -afterEach(() => setSkyClock()); +let search = ''; +beforeEach(() => { + search = ''; + vi.stubGlobal('window', { + get location() { + return { search }; + }, + }); +}); +afterEach(() => { + setSkyClock(); + vi.unstubAllGlobals(); +}); describe('the bands the text ink needs', () => { it('are the numbers the shaping was set from', () => { @@ -45,11 +58,11 @@ describe('the bands the text ink needs', () => { describe('ridgeSkyFor', () => { it('draws the gradient of the sky auto resolves to, never nothing', () => { - setSkyClock(() => new Date(2026, 8, 22, 12, 0)); + setSkyClock(() => atStandardTime(12, 0)); expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('day')); - setSkyClock(() => new Date(2026, 8, 22, 0, 0)); + setSkyClock(() => atStandardTime(0, 0)); expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('night')); - setSkyClock(() => new Date(2026, 8, 22, 18, 10)); + setSkyClock(() => atStandardTime(18, 10)); expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('dusk')); }); @@ -82,6 +95,38 @@ describe('ridgeSkyFor', () => { }); }); +// Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns) +// plus a 0.05 degree grid across the sun, five times finer than the generator's. +const dirAt = (el: number, az: number): [number, number, number] => { + const rad = Math.PI / 180; + return [ + Math.cos(el * rad) * Math.sin(az * rad), + Math.sin(el * rad), + -Math.cos(el * rad) * Math.cos(az * rad), + ]; +}; +/** Over the sweep: the darkest the 8-bit screen can show (dither at its worst), the lightest, and the darkest as encoded. */ +const sweepOf = (sky: ReturnType, N = 4000, maxEl = TERRAIN_REACH_DEG) => { + const rad = Math.PI / 180; + const out = { low: Infinity, high: -Infinity, exactMin: Infinity }; + const see = (el: number, az: number) => { + const c = skyColour(sky, dirAt(el, az)); + out.low = Math.min(out.low, displayedLuminance(c, 'low')); + out.high = Math.max(out.high, displayedLuminance(c, 'high')); + out.exactMin = Math.min(out.exactMin, displayedLuminance(c)); + }; + for (let i = 0; i < N; i += 1) { + const y = ((i + 0.5) / N) * Math.sin(maxEl * rad); // sin(elevation): uniform from the horizon to maxEl + see(Math.asin(y) / rad, (i * 137.50776405) % 360); + } + for (let dEl = -3; dEl <= 3; dEl += 0.05) { + for (let dAz = -3; dAz <= 3; dAz += 0.05) { + see(Math.min(maxEl, Math.max(0, sky.elevation + dEl)), sky.azimuth + dAz); + } + } + return out; +}; + describe('the terrain camera reach', () => { it('covers the highest ray the frozen camera sees, at any aspect, with a small margin', () => { const tanHalf = Math.tan((55 * Math.PI) / 360); @@ -105,12 +150,45 @@ describe('the terrain camera reach', () => { describe('terrainSkyFor', () => { it('draws the sky auto resolves to, with that state band', () => { - setSkyClock(() => new Date(2026, 8, 22, 12, 0)); + setSkyClock(() => atStandardTime(12, 0)); expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('day')); - setSkyClock(() => new Date(2026, 8, 22, 0, 0)); + setSkyClock(() => atStandardTime(0, 0)); expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('night')); }); + it('lets the page URL steer auto, shaped into the band of the state the clock resolves to', () => { + setSkyClock(() => atStandardTime(0, 0)); // night + search = '?sun=30&az=120'; + expect(terrainSkyFor('auto')).toEqual({ + sun: { elevation: 30, azimuth: 120 }, + band: SKY_BAND.night, + }); + search = '?sky=noonday'; + expect(terrainSkyFor('auto')).toEqual({ sky: 'noonday', band: SKY_BAND.night }); + search = '?sky=garbage'; + expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('night')); + setSkyClock(() => atStandardTime(18, 10)); // dusk, URL azimuth honoured + search = '?sun=2&az=250'; + expect(terrainSkyFor('auto').sun).toEqual({ elevation: 2, azimuth: 250 }); + }); + + it('keeps a URL-chosen sun inside the band of the resolved state', () => { + setSkyClock(() => atStandardTime(12, 0)); // day: floor + search = '?sun=-30'; + const day = sweepOf(chooseSky(terrainSkyFor('auto'))); + expect(day.low).toBeGreaterThanOrEqual(needs('day').floor); + setSkyClock(() => atStandardTime(0, 0)); // night: ceiling + search = '?sun=60'; + const night = sweepOf(chooseSky(terrainSkyFor('auto'))); + expect(night.high).toBeLessThanOrEqual(needs('night').ceiling); + }); + + it('lets an explicit state beat the URL, as before auto had one', () => { + search = '?sun=30&az=120&sky=midnight'; + expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day }); + expect(terrainSkyFor('dusk').sun).toEqual({ elevation: DUSK_SUN_ELEVATION }); + }); + it('names the baked sky for day and night, and a low sun for dusk, each with its band', () => { expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day }); expect(terrainSkyFor('night')).toEqual({ sky: 'midnight', band: SKY_BAND.night }); @@ -121,63 +199,28 @@ describe('terrainSkyFor', () => { }); }); -// Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns) -// plus a 0.05 degree grid across the sun, five times finer than the generator's. -const dirAt = (el: number, az: number): [number, number, number] => { - const rad = Math.PI / 180; - return [ - Math.cos(el * rad) * Math.sin(az * rad), - Math.sin(el * rad), - -Math.cos(el * rad) * Math.cos(az * rad), - ]; -}; -const sweepOf = (sky: ReturnType, N = 4000, maxEl = TERRAIN_REACH_DEG) => { - const rad = Math.PI / 180; - const out: number[] = []; - for (let i = 0; i < N; i += 1) { - const y = ((i + 0.5) / N) * Math.sin(maxEl * rad); // sin(elevation): uniform from the horizon to maxEl - out.push( - displayedLuminance(skyColour(sky, dirAt(Math.asin(y) / rad, (i * 137.50776405) % 360))) - ); - } - for (let dEl = -3; dEl <= 3; dEl += 0.05) { - for (let dAz = -3; dAz <= 3; dAz += 0.05) { - out.push( - displayedLuminance( - skyColour( - sky, - dirAt(Math.min(maxEl, Math.max(0, sky.elevation + dEl)), sky.azimuth + dAz) - ) - ) - ); - } - } - return out; -}; - describe('the terrain sky lies in the band its text ink needs', () => { const sweep = (state: FixedSkyState) => sweepOf(chooseSky(terrainSkyFor(state))); it('dusk needs its lift: the scatter sky alone dips below the text bound within the reach', () => { const { sun } = terrainSkyFor('dusk'); - const l = sweepOf(chooseSky({ sun })); - expect(Math.min(...l)).toBeLessThan(needs('dusk').floor - 0.02); - expect(Math.min(...sweep('dusk'))).toBeGreaterThanOrEqual(needs('dusk').floor); + expect(sweepOf(chooseSky({ sun })).exactMin).toBeLessThan(needs('dusk').floor - 0.02); + expect(sweep('dusk').low).toBeGreaterThanOrEqual(needs('dusk').floor); }); it.each(STATES)('%s', (state) => { const { floor, ceiling } = needs(state); const l = sweep(state); - expect(Math.min(...l)).toBeGreaterThanOrEqual(floor); - expect(Math.max(...l)).toBeLessThanOrEqual(ceiling); + expect(l.low).toBeGreaterThanOrEqual(floor); + expect(l.high).toBeLessThanOrEqual(ceiling); }); it.each(STATES)('%s holds for a sun at any elevation, as ?sun= can put it', (state) => { const { floor, ceiling } = needs(state); for (let e = -90; e <= 90; e += 10) { const l = sweepOf(chooseSky({ sun: { elevation: e }, band: SKY_BAND[state] }), 4000, 90); - expect(Math.min(...l)).toBeGreaterThanOrEqual(floor); - expect(Math.max(...l)).toBeLessThanOrEqual(ceiling); + expect(l.low).toBeGreaterThanOrEqual(floor); + expect(l.high).toBeLessThanOrEqual(ceiling); } }); @@ -212,9 +255,9 @@ describe('skyBackdrop holds the union of what the scenes draw', () => { const l = sweepOf(chooseSky(terrainSkyFor(state)), 20000); const b = skyBackdrop(state); const r = ridgeDrawn(state); - expect(Math.min(...l)).toBeGreaterThanOrEqual(b.darkest - EPS); - expect(Math.max(...l)).toBeLessThanOrEqual(b.lightest + EPS); - expect(b.lightest - Math.max(Math.max(...l), r.lightest)).toBeLessThan(5e-4); - expect(Math.min(Math.min(...l), r.darkest) - b.darkest).toBeLessThan(5e-4); + expect(l.low).toBeGreaterThanOrEqual(b.darkest - EPS); + expect(l.high).toBeLessThanOrEqual(b.lightest + EPS); + expect(b.lightest - Math.max(l.high, r.lightest)).toBeLessThan(5e-4); + expect(Math.min(l.low, r.darkest) - b.darkest).toBeLessThan(5e-4); }); }); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index e7abcd73..a40317a2 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -4,7 +4,7 @@ // it, the terrain sky shaped into it downstream of the scatter function. `skyBackdrop` bounds what comes out, // and the login text was checked against that bound. import { baked } from '../../../sky/baked'; -import { chooseSky, skyColour } from './terrain/terrainSky'; +import { chooseSky, skyColour, urlSky } from './terrain/terrainSky'; import type { SkyName } from './terrain/terrainSky'; import type { SkyColours, Srgb } from './ridge/skyColour'; import { skyColours } from './ridge/skyColour'; @@ -41,14 +41,22 @@ export function ridgeSkyFor(state: SkyState): SkyColours { } } -/** The terrain scene's `sky`, `sun` and `band` options: the state's sky ('auto': the sky it resolves to), shaped into the state's band. */ +/** + * The terrain scene's `sky`, `sun` and `band` options. An explicit state draws its own sky and ignores the URL. 'auto' + * draws the sky it resolves to, unless the page URL steers (`?sun=` `?az=` `?sky=`, see `urlSky`): then the URL's sky, + * shaped into the band of the state the clock resolves to, which keeps any sun the URL picks inside that band. + */ export function terrainSkyFor(state: SkyState): { sky?: SkyName; - sun?: { elevation: number }; + sun?: { elevation: number; azimuth?: number }; band: SkyBand; } { const fixed = resolveSkyState(state); const band = SKY_BAND[fixed]; + if (state === 'auto') { + const url = urlSky(); + if (url) return { ...url, band }; + } switch (fixed) { case 'day': return { sky: 'noonday', band }; @@ -113,12 +121,16 @@ const REFINE_FROM = 6; export function terrainDrawn(state: SkyState): Range { const sky = chooseSky(terrainSkyFor(state)); const rad = Math.PI / 180; + // Every point looked at is also read as the 8-bit screen would show it, dither at its worst, so the range bounds that too. + let screenDarkest = Infinity; + let screenLightest = -Infinity; const lum = (el: number, az: number) => { const e = Math.min(TERRAIN_REACH_DEG, Math.max(0, el)) * rad; const a = az * rad; - return displayedLuminance( - skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]) - ); + const c = skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]); + screenDarkest = Math.min(screenDarkest, displayedLuminance(c, 'low')); + screenLightest = Math.max(screenLightest, displayedLuminance(c, 'high')); + return displayedLuminance(c); }; const seen: { el: number; az: number; l: number }[] = []; const at = (el: number, az: number) => seen.push({ el, az, l: lum(el, az) }); @@ -164,5 +176,8 @@ export function terrainDrawn(state: SkyState): Range { byLight[byLight.length - 1].l, ...byLight.slice(-REFINE_FROM).map((p) => refine(p, -1)) ); - return { darkest, lightest }; + return { + darkest: Math.min(darkest, screenDarkest), + lightest: Math.max(lightest, screenLightest), + }; } diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index f8c22178..ae4d9e83 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { atStandardTime } from './standardTime.testkit'; import { baked } from '../../../sky/baked'; import { luminance } from '../../../sky/skyTokens.js'; import { @@ -123,7 +124,7 @@ describe('skyBackdrop', () => { }); it('is the band of the sky auto resolves to, not the union of all three', () => { - const at = (h: number) => setSkyClock(() => new Date(2026, 8, 22, h, 0)); + const at = (h: number) => setSkyClock(() => atStandardTime(h, 0)); at(12); expect(skyBackdrop('auto')).toEqual(skyBackdrop('day')); at(0); @@ -139,7 +140,7 @@ describe('skyBackdrop', () => { }); describe('auto follows the clock', () => { - const local = (h: number, m = 0) => new Date(2026, 8, 22, h, m); // local solar time ~ local clock + const local = (h: number, m = 0) => atStandardTime(h, m); // local solar time ~ local clock let storage: ReturnType; beforeEach(() => { vi.useFakeTimers(); @@ -174,6 +175,24 @@ describe('auto follows the clock', () => { off(); }); + it('still tells everyone when a listener comes or goes inside the window before the clock crosses', async () => { + const m = await fresh(); + let now = local(17, 0); + m.setSkyClock(() => now); + const [a, b, late] = [vi.fn(), vi.fn(), vi.fn()]; + m.subscribe(a); + const offB = m.subscribe(b); + vi.advanceTimersByTime(30_000); + now = local(18, 10); // the clock has crossed; no tick has seen it yet + const offLate = m.subscribe(late); + offB(); + vi.advanceTimersByTime(60_000); + expect(a.mock.calls).toEqual([['dusk']]); + expect(b).not.toHaveBeenCalled(); + expect(late.mock.calls).toEqual([['dusk']]); + offLate(); + }); + it('keeps no timer without listeners or when a fixed state is chosen', async () => { const m = await fresh(); const off = m.subscribe(() => undefined); diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index c27c10a4..a79ed9c6 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -30,10 +30,17 @@ const LAT = 45; export const DAY_FROM = 6; let clock: () => Date = () => new Date(); +/** Minutes west of UTC of this timezone's standard time (the larger of its January and July offsets), so daylight saving does not move the sun. */ +export const standardOffsetMinutes = (now: Date): number => + Math.max( + new Date(now.getFullYear(), 0, 1).getTimezoneOffset(), + new Date(now.getFullYear(), 6, 1).getTimezoneOffset() + ); + /** The fixed sky a choice draws now: itself, or for 'auto' the sky the sun's elevation here calls for. */ export function resolveSkyState(s: SkyState, now: Date = clock()): FixedSkyState { if (s !== 'auto') return s; - const lon = -now.getTimezoneOffset() / 4; + const lon = -standardOffsetMinutes(now) / 4; const el = sunElevation(now, LAT, lon) as number; if (el >= DAY_FROM) return 'day'; return el <= -DAY_FROM ? 'night' : 'dusk'; @@ -60,12 +67,12 @@ const emit = () => { }; // While anyone listens and the choice is 'auto', one timer checks the clock each minute and tells -// listeners when the resolved sky changes. Otherwise there is no timer. +// listeners when the resolved sky changes from the one last told (`shown`, set when telling and when the +// first listener arrives, never by a later arrival or departure). Otherwise there is no timer. function sync(): void { clearTimeout(timer); timer = undefined; if (listeners.size === 0 || getSkyState() !== 'auto') return; - shown = getResolvedSkyState(); const tick = () => { if (getResolvedSkyState() !== shown) emit(); timer = setTimeout(tick, TICK_MS); @@ -96,6 +103,7 @@ export function setSkyState(s: SkyState): void { /** Listeners hear the resolved sky: on a change of choice, and while 'auto', when the clock crosses a boundary. */ export function subscribe(cb: (s: FixedSkyState) => void): () => void { + if (listeners.size === 0) shown = getResolvedSkyState(); listeners.add(cb); sync(); return () => { @@ -119,11 +127,11 @@ export interface SkyBackdrop { // this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering // tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts // over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts). -// Rounded outward to six places. +// Bounds what the 8-bit screen shows too (dither at its worst), rounded outward to six places. const BACKDROPS: Record = { - day: { darkest: 0.466725, lightest: 0.892734 }, - dusk: { darkest: 0.303835, lightest: 0.967298 }, - night: { darkest: 0.05987, lightest: 0.079657 }, + day: { darkest: 0.463644, lightest: 0.898318 }, + dusk: { darkest: 0.306293, lightest: 0.968113 }, + night: { darkest: 0.05987, lightest: 0.081133 }, }; export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)]; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 4f237fc6..704ff15d 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { atStandardTime } from './standardTime.testkit'; import { baked } from '../../../sky/baked'; import { AA, contrast, luminance } from '../../../sky/skyTokens.js'; import { Editor } from '../../components/editor/Editor.css'; @@ -8,7 +9,7 @@ import { MembersDrawer } from '../room/MembersDrawer.css'; import { RoomViewTyping } from '../room/RoomViewTyping.css'; import { SKY_PLATE_FALLBACK } from './skyPlate'; import { SkyPlate, plateRegions } from './skyPlate.css'; -import { setSkyClock, skyBackdrop } from './skyState'; +import { getResolvedSkyState, setSkyClock, skyBackdrop } from './skyState'; import type { SkyBackdrop } from './skyState'; import type { FixedSkyState } from './skyBand'; import { skyThemeId } from './skyTheme'; @@ -32,17 +33,25 @@ const ratio = (ink: string, b: SkyBackdrop) => const r2 = (n: number) => n.toFixed(2); describe('skyThemeId', () => { - it('picks a theme for every fixed state, and for auto the theme of the sky it resolves to', () => { + it('picks a theme for each explicit state', () => { expect(skyThemeId('day')).toBe('noonday-sky'); expect(skyThemeId('dusk')).toBe('noonday-sky'); expect(skyThemeId('night')).toBe('midnight-sky'); - const at = (h: number, m = 0) => setSkyClock(() => new Date(2026, 8, 22, h, m)); - at(12); - expect(skyThemeId('auto')).toBe('noonday-sky'); - at(0); - expect(skyThemeId('auto')).toBe('midnight-sky'); - at(18, 10); - expect(skyThemeId('auto')).toBe('noonday-sky'); + }); + + it('picks none for auto, at any hour: auto never overrides a signed-in theme', () => { + [0, 6, 12, 18, 23].forEach((h) => { + setSkyClock(() => atStandardTime(h, 10)); + expect(skyThemeId('auto')).toBeUndefined(); + }); + setSkyClock(); + }); + + it('is what a wiring that follows the sky reads: the resolved state names a theme', () => { + setSkyClock(() => atStandardTime(12)); + expect(skyThemeId(getResolvedSkyState())).toBe('noonday-sky'); + setSkyClock(() => atStandardTime(0)); + expect(skyThemeId(getResolvedSkyState())).toBe('midnight-sky'); setSkyClock(); }); }); @@ -54,9 +63,9 @@ describe('ink of the theme a state picks, on bare sky', () => { // numbers are recorded as they are measured (Night's #609385 would clear 4.5 at a luminance of about 0.53 or more). // 'auto' resolves from the clock to one of these three and has the same numbers. const cells: [FixedSkyState, string, string, string, string, string][] = [ - ['day', 'noonday-sky', '#342d25', '6.68', '#2a4f27', '4.60'], - ['dusk', 'noonday-sky', '#342d25', '4.57', '#2a4f27', '3.15'], - ['night', 'midnight-sky', '#aeccff', '4.97', '#609385', '2.32'], + ['day', 'noonday-sky', '#342d25', '6.64', '#2a4f27', '4.57'], + ['dusk', 'noonday-sky', '#342d25', '4.60', '#2a4f27', '3.17'], + ['night', 'midnight-sky', '#aeccff', '4.91', '#609385', '2.29'], ]; it.each(cells)( @@ -86,8 +95,8 @@ describe('ink of the theme a state picks, on bare sky', () => { [18, 'dusk'], [0, 'night'], ] as const)('auto at %s:00 has the numbers of %s', (hour, state) => { - setSkyClock(() => new Date(2026, 8, 22, hour, hour === 18 ? 10 : 0)); - const id = skyThemeId('auto'); + setSkyClock(() => atStandardTime(hour, hour === 18 ? 10 : 0)); + const id = skyThemeId(getResolvedSkyState()) as keyof typeof SKY; expect(id).toBe(skyThemeId(state)); const { tokens, link } = SKY[id]; const text = tokens.Background.OnContainer; diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 2e8c1b2e..52aff750 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -1,5 +1,6 @@ -// Which theme a sky state lights the page with. 'auto' takes the theme of the sky it resolves to -// (skyState.resolveSkyState), since that is the sky behind the text. +// Which theme a sky state lights the page with. 'auto' picks none: it never overrides a person's theme +// (the stored one when signed in, Midnight Sky when signed out). A wiring that follows the sky when no theme +// is stored reads `getResolvedSkyState` (skyState.ts) and passes that fixed state here. // // Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Each state's // drawn sky is shaped into a luminance band (skyBand.ts) in which that ink's TEXT clears 4.5:1 on @@ -9,7 +10,6 @@ // derived outside this code (src/sky/skyTokens.js); lightening the Night link to a luminance of // about 0.53 or more would clear it. The plate (skyPlate.css.ts) is still in the shell, pending // its removal. -import { resolveSkyState } from './skyState'; import type { FixedSkyState, SkyState } from './skyState'; export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; @@ -20,4 +20,5 @@ const BY_STATE: Record = { night: 'midnight-sky', }; -export const skyThemeId = (state: SkyState): SkyThemeId => BY_STATE[resolveSkyState(state)]; +export const skyThemeId = (state: SkyState): SkyThemeId | undefined => + state === 'auto' ? undefined : BY_STATE[state]; diff --git a/src/app/features/scene/standardTime.testkit.ts b/src/app/features/scene/standardTime.testkit.ts new file mode 100644 index 00000000..a6966238 --- /dev/null +++ b/src/app/features/scene/standardTime.testkit.ts @@ -0,0 +1,7 @@ +import { standardOffsetMinutes } from './skyState'; + +/** 22 September 2026 at h:m of local solar time as the sky clock reads it (standard time, whatever daylight saving says). */ +export const atStandardTime = (h: number, m = 0): Date => { + const probe = new Date(2026, 8, 22); + return new Date(Date.UTC(2026, 8, 22, h, m) + standardOffsetMinutes(probe) * 60000); +}; diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts index 01f2df11..ef1623c4 100644 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -5,6 +5,8 @@ import { GRID, triangleCount } from './terrainWorld'; import { groundLight, SKY, skyAtSun } from './terrainSky'; import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter'; import { SKY_FRAG } from './terrainShaders'; +import { SKY_BAND } from '../skyBand'; +import { terrainSkyFor } from '../skyForState'; import { startBackgroundScene } from '../index'; /** A GL context that accepts every call, remembers them, reports success for compile, link and framebuffer. */ @@ -158,6 +160,32 @@ describe('createTerrainScene', () => { expect(has(uploaded({}), nightA)).toBe(true); }); + it.each(['day', 'dusk', 'night'] as const)( + 'uploads the %s band as uBand (floor, ceiling) and uLift, in the sky pass and the grid pass', + (state) => { + const { gl, calls } = fakeGl(); + const opts = terrainSkyFor(state); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); + const { floor, ceiling, lift } = SKY_BAND[state]; + const pairs = calls.uniform2fv.filter( + (a) => + Math.abs((a[1] as number[])[0] - floor) < 1e-6 && + Math.abs((a[1] as number[])[1] - ceiling) < 1e-6 + ); + expect(pairs).toHaveLength(2); + const lifts = calls.uniform3fv.filter((a) => + Array.from(a[1] as ArrayLike).every((x, i) => Math.abs(x - lift[i]) < 1e-6) + ); + expect(lifts).toHaveLength(2); + } + ); + + it('uploads no band (0, 1) and a zero lift without one', () => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still' })!.dispose(); + expect(calls.uniform2fv.filter((a) => (a[1] as number[])[1] === 1)).toHaveLength(2); + }); + it('a sun elevation (option or ?sun=&az=) lights between the bakes, and wins over ?sky=', () => { const ambients = (opts: Record) => { const { gl, calls } = fakeGl(); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 60ab33eb..b697927b 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -28,7 +28,7 @@ export type TerrainOptions = Pick< SceneOptions, 'canvas' | 'tier' | 'onCompileFail' | 'onContextLost' > & { - /** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else the night notion of `skyColours()`. */ + /** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else midnight. */ sky?: SkyName; /** Put the sun at this elevation (degrees) and azimuth (clockwise from north; default: noonday's) instead of at the baked sky's: `?sun=` and `?az=` in the page URL. Lights between the two bakes, for dusk. */ sun?: { elevation: number; azimuth?: number }; diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index 3470249c..92a2ab22 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -221,10 +221,25 @@ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, }) as unknown as Vec3; } +/** What the page URL asks of the sky, if anything: `?sun=` (with `?az=`) wins over `?sky=midnight|noonday`; a bad value asks nothing. */ +export function urlSky(): + | { sky?: SkyName; sun?: { elevation: number; azimuth?: number } } + | undefined { + const q = new URLSearchParams(window.location.search); + const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); + const elevation = number('sun'); + if (Number.isFinite(elevation)) { + const azimuth = number('az'); + return { sun: { elevation, azimuth: Number.isFinite(azimuth) ? azimuth : undefined } }; + } + const name = q.get('sky'); + return isSkyName(name) ? { sky: name } : undefined; +} + /** * The sky to use. An explicit option wins over the page URL, so a person who picked a sky sees it: a sun - * elevation (with its azimuth, default noonday's), else a baked sky. Only without either does the URL steer: - * `?sun=` (with `?az=`), then `?sky=midnight|noonday`, then midnight. (The door always names a sky and a band; these are for callers that do not.) + * elevation (with its azimuth, default noonday's), else a baked sky. Only without either does the URL steer + * (`urlSky`), else midnight. A band, when given, shapes whichever sky that is. */ export function chooseSky(opts: { sky?: SkyName; @@ -232,15 +247,7 @@ export function chooseSky(opts: { band?: SkyBand; }): SkyData { const banded = (d: SkyData): SkyData => (opts.band ? { ...d, band: opts.band } : d); - if (opts.sun) return banded(skyAtSun(opts.sun.elevation, opts.sun.azimuth)); - if (opts.sky) return banded(skyData(opts.sky)); - const q = new URLSearchParams(window.location.search); - const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); - const elevation = number('sun'); - if (Number.isFinite(elevation)) { - const azimuth = number('az'); - return banded(skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined)); - } - const name = q.get('sky'); - return banded(skyData(isSkyName(name) ? name : 'midnight')); + const want = opts.sun || opts.sky ? opts : urlSky() ?? { sky: 'midnight' as const }; + if (want.sun) return banded(skyAtSun(want.sun.elevation, want.sun.azimuth)); + return banded(skyData(want.sky ?? 'midnight')); } diff --git a/src/app/hooks/useSkyState.ts b/src/app/hooks/useSkyState.ts index 4a2448b2..d49bf267 100644 --- a/src/app/hooks/useSkyState.ts +++ b/src/app/hooks/useSkyState.ts @@ -1,15 +1,5 @@ import { useSyncExternalStore } from 'react'; -import { - getResolvedSkyState, - getSkyState, - subscribe, - type FixedSkyState, - type SkyState, -} from '../features/scene/skyState'; +import { getSkyState, subscribe, type SkyState } from '../features/scene/skyState'; -/** The choice, as the control shows it ('auto' stays 'auto'). */ +/** The choice, as the control shows it ('auto' stays 'auto'). The sky being drawn is `getResolvedSkyState`. */ export const useSkyState = (): SkyState => useSyncExternalStore(subscribe, getSkyState); - -/** The sky being drawn: the choice, resolved from the clock for 'auto'. */ -export const useResolvedSkyState = (): FixedSkyState => - useSyncExternalStore(subscribe, getResolvedSkyState); diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 6f1b914f..00ac1ee9 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -13,7 +13,7 @@ import '../../sky.css'; import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; import { skyThemeId } from '../features/scene/skyTheme'; -import { useResolvedSkyState } from './useSkyState'; +import { useSkyState } from './useSkyState'; export enum ThemeKind { Light = 'light', @@ -102,9 +102,9 @@ export const useSystemThemeKind = (): ThemeKind => { return themeKind; }; -/** The theme the sky being drawn asks for ('auto' asks for the one of the sky it resolves to). */ +/** The theme the sky state asks for, or undefined while it is 'auto': auto never overrides a person's theme. */ export const useSkyTheme = (): Theme | undefined => { - const id = skyThemeId(useResolvedSkyState()); + const id = skyThemeId(useSkyState()); return id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme; }; From c13e90539f2beeb3b8f9a04c4eb2136f58900c93 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 23:48:07 +0000 Subject: [PATCH 051/111] trainingNotice: correct the comment per review Both render sites named (registration, and rooms whose notice event has scope train). The rule is an acknowledgment that Minds read and learn from what is said, not consent to training; the wording is Meta's to settle. Drops the unsourced ruling date and the claim about existing members. Comment only. Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/trainingNotice.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/utils/trainingNotice.ts b/src/app/utils/trainingNotice.ts index 16215249..4b416359 100644 --- a/src/app/utils/trainingNotice.ts +++ b/src/app/utils/trainingNotice.ts @@ -3,9 +3,12 @@ import { StateEvent } from '../../types/matrix/room'; export const TRAINING_NOTICE_TEXT = "Everything said here may be used to train GameCult's AI stewards."; -// The notice, its wording and the registration acknowledgement are Meta's ruling of 2026-11-06: -// everything said on the server may be used to train GameCult's AI stewards; shown at registration -// only; no mention of Discord; existing members are not re-asked. The wording is hers to change. +// The agreed rule is an acknowledgment that Minds on the server read what is said and learn from it; +// it is not consent to training. TRAINING_NOTICE_TEXT still reads 'may be used to train', and that +// wording is Meta's to settle. It never mentions Discord (the test pins this). It renders in two +// places: the registration acknowledgement (PasswordRegisterForm.tsx, via TrainingNotice.tsx) and +// the line in any room whose org.gamecult.notice state event has scope 'train' (roomTrainingLine, +// shown by RoomTrainingLine.tsx from RoomView.tsx). // The preview build runs against a pretend server where nothing is trained, so the real sentence // would be false there. export const PREVIEW_NOTICE_TEXT = From 80d3ae1c9b25aedf2a4418ee1d71fbc8de839ecc Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 23:48:16 +0000 Subject: [PATCH 052/111] trainingNotice: rewrap comment to the print width Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/trainingNotice.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/utils/trainingNotice.ts b/src/app/utils/trainingNotice.ts index 4b416359..ecc57809 100644 --- a/src/app/utils/trainingNotice.ts +++ b/src/app/utils/trainingNotice.ts @@ -3,12 +3,12 @@ import { StateEvent } from '../../types/matrix/room'; export const TRAINING_NOTICE_TEXT = "Everything said here may be used to train GameCult's AI stewards."; -// The agreed rule is an acknowledgment that Minds on the server read what is said and learn from it; -// it is not consent to training. TRAINING_NOTICE_TEXT still reads 'may be used to train', and that -// wording is Meta's to settle. It never mentions Discord (the test pins this). It renders in two -// places: the registration acknowledgement (PasswordRegisterForm.tsx, via TrainingNotice.tsx) and -// the line in any room whose org.gamecult.notice state event has scope 'train' (roomTrainingLine, -// shown by RoomTrainingLine.tsx from RoomView.tsx). +// The agreed rule is an acknowledgment that Minds on the server read what is said and learn from +// it; it is not consent to training. TRAINING_NOTICE_TEXT still reads 'may be used to train', and +// that wording is Meta's to settle. It never mentions Discord (the test pins this). It renders in +// two places: the registration acknowledgement (PasswordRegisterForm.tsx, via TrainingNotice.tsx) +// and the line in any room whose org.gamecult.notice state event has scope 'train' +// (roomTrainingLine, shown by RoomTrainingLine.tsx from RoomView.tsx). // The preview build runs against a pretend server where nothing is trained, so the real sentence // would be false there. export const PREVIEW_NOTICE_TEXT = From e1ebf7ec914d6f265b1461a11067f7d314eee7cf Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 23:49:09 +0000 Subject: [PATCH 053/111] trainingNotice: cite Meta's 2026-11-09 scope; neither exception is implemented Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/trainingNotice.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/utils/trainingNotice.ts b/src/app/utils/trainingNotice.ts index ecc57809..c3d6e42a 100644 --- a/src/app/utils/trainingNotice.ts +++ b/src/app/utils/trainingNotice.ts @@ -3,6 +3,9 @@ import { StateEvent } from '../../types/matrix/room'; export const TRAINING_NOTICE_TEXT = "Everything said here may be used to train GameCult's AI stewards."; +// Source: Meta, 2026-11-09: server-wide, human-to-human DMs excepted, estate-room exception open. +// Neither exception is implemented here: the registration notice is unconditional, and the room +// line follows only the room's notice event, with no check for direct messages or estate rooms. // The agreed rule is an acknowledgment that Minds on the server read what is said and learn from // it; it is not consent to training. TRAINING_NOTICE_TEXT still reads 'may be used to train', and // that wording is Meta's to settle. It never mentions Discord (the test pins this). It renders in From 7bc28f9256c38d30a7cd516ce7e377ef3cd073f6 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 01:09:46 +0000 Subject: [PATCH 054/111] scene: a rebuild is for a different drawn sky; a URL-steered night sky stays within the Night backdrop Rebuild key: the sky options the scene is built from (terrainSkyFor / ridgeSkyFor), not the pair of choice and resolved state, so picking Day while the clock says day, or Auto to the state it resolves to, no longer tears down and rebuilds the scene. A URL steers only 'auto', so there the options differ and it still rebuilds. Night: the band ceiling 0.085 let a sky the page URL steers (?sun=, ?sky=) show up to 0.0890, lighter than the Night constant 0.081705 the text was checked against. The ceiling is now 0.077394, the luma at which one 8-bit step up reads the baked night's own lightest, and the constant (0.081642) covers the steered case; a test pins it. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 17 +++++++------ src/app/features/scene/index.ts | 28 ++++++++++++++++++---- src/app/features/scene/skyBand.ts | 6 +++-- src/app/features/scene/skyForState.test.ts | 22 ++++++++++++++++- src/app/features/scene/skyState.ts | 3 ++- 5 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 7971508b..5d0e6410 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -45,14 +45,14 @@ describe('startBackgroundScene follows the sky state', () => { scene?.dispose(); }); - it('rebuilds when auto is replaced by the explicit state it already resolves to (a URL may steer only auto)', async () => { - const [a, b] = [fakeScene(), fakeScene()]; - createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b); + it('does not rebuild when auto is replaced by the explicit state it already resolves to', async () => { + const a = fakeScene(); + createRidgeScene.mockReturnValueOnce(a); const scene = await startBackgroundScene(config, caps, { canvas }); setSkyState('night'); await flush(); - expect(a.dispose).toHaveBeenCalledOnce(); - expect(createRidgeScene).toHaveBeenCalledTimes(2); + expect(a.dispose).not.toHaveBeenCalled(); + expect(createRidgeScene).toHaveBeenCalledOnce(); scene?.dispose(); }); @@ -146,14 +146,13 @@ describe('startBackgroundScene follows the sky state', () => { expect(first.dispose).toHaveBeenCalledOnce(); }); - it('drops a scene whose rebuild finishes after dispose', async () => { - const second = fakeScene(); - createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(second); + it('builds nothing for a rebuild that is disposed before it starts', async () => { + createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(fakeScene()); const scene = await startBackgroundScene(config, caps, { canvas }); setSkyState('day'); scene?.dispose(); await flush(); - expect(second.dispose).toHaveBeenCalledOnce(); + expect(createRidgeScene).toHaveBeenCalledOnce(); }); it('keeps only the latest of two quick changes', async () => { diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 73ab8d5a..55966188 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -1,6 +1,6 @@ import type { Scene, SceneOptions } from './Scene'; import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; -import { getResolvedSkyState, getSkyState, subscribe } from './skyState'; +import { getSkyState, subscribe } from './skyState'; import type { SkyState } from './skyState'; export type { Scene, SceneStats } from './Scene'; @@ -44,6 +44,24 @@ async function create( return createScene(options); } +/** + * What a rebuild is for: the sky options the scene would be built from. A change of choice that draws the same sky + * (Day picked while the clock says day, 'auto' to the fixed state it resolves to) is not a rebuild; the page URL steers + * only 'auto' (`terrainSkyFor`), so there the two differ and the key says so. + */ +async function skyKey( + config: BackgroundSceneConfig | undefined, + state: SkyState +): Promise { + try { + const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState'); + return JSON.stringify(config?.style === 'terrain' ? terrainSkyFor(state) : ridgeSkyFor(state)); + } catch { + // The chunk failed to load: no key, so every change rebuilds (and `create` reports the failure). + return null; + } +} + /** * The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no * canvas context is requested; otherwise the scene code loads on demand, so it stays out of the @@ -66,7 +84,7 @@ export async function startBackgroundScene( if (tier === 'off') return null; const build = (state: SkyState) => create(config, { ...options, tier }, state); const state = getSkyState(); - const key0 = `${state}:${getResolvedSkyState()}`; + const key0 = config?.style === 'raymarch' ? null : await skyKey(config, state); const first = await build(state); if (!first || config?.style === 'raymarch') return first; @@ -75,11 +93,11 @@ export async function startBackgroundScene( let loggedIn = false; let disposed = false; let generation = 0; - // A rebuild is for a new choice or a new resolved sky ('auto' crossing a boundary), not for a repeat of either. + // A rebuild is for a different sky ('auto' crossing a boundary, a new choice), not for a repeat of the one drawn. const rebuild = async () => { const next = getSkyState(); - const key = `${next}:${getResolvedSkyState()}`; - if (key === built) return; + const key = await skyKey(config, next); + if ((key !== null && key === built) || disposed) return; generation += 1; const mine = generation; built = key; diff --git a/src/app/features/scene/skyBand.ts b/src/app/features/scene/skyBand.ts index b6b39315..ac311081 100644 --- a/src/app/features/scene/skyBand.ts +++ b/src/app/features/scene/skyBand.ts @@ -58,12 +58,14 @@ export interface SkyBand { /** * The shaping targets, in linear luma. The floor sits 0.019 above the text bound (0.2983): the screen encoding is * gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level, and 8-bit rounding with the sky - * pass's dither at its worst (one step in every channel) takes about 0.007 more. + * pass's dither at its worst (one step in every channel) takes about 0.007 more. The night ceiling is lower than the text bound + * (0.0931) by more than that: 0.077394 is the luma at which one step up reads 0.08164, the lightest the baked night sky shows + * (skyState.ts), so a sky the page URL steers at night, scaled down to the ceiling, cannot show lighter than the baked one. */ export const SKY_BAND: Record = { day: { floor: 0.317, ceiling: 1, lift: hexToLinear('#e2f4ff') }, dusk: { floor: 0.317, ceiling: 1, lift: hexToLinear('#eba0b9') }, - night: { floor: 0, ceiling: 0.085, lift: hexToLinear('#5a6a9a') }, + night: { floor: 0, ceiling: 0.077394, lift: hexToLinear('#5a6a9a') }, }; /** diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 2ad3b5f6..9d1dfd2d 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -183,6 +183,14 @@ describe('terrainSkyFor', () => { expect(night.high).toBeLessThanOrEqual(needs('night').ceiling); }); + it('keeps a URL-steered sky at night no lighter than the Night backdrop the text was checked against', () => { + setSkyClock(() => atStandardTime(0, 0)); + ['?sun=60', '?sun=10&az=40', '?sun=2&az=250', '?sky=noonday'].forEach((q) => { + search = q; + expect(terrainDrawn('auto').lightest).toBeLessThanOrEqual(skyBackdrop('night').lightest); + }); + }); + it('lets an explicit state beat the URL, as before auto had one', () => { search = '?sun=30&az=120&sky=midnight'; expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day }); @@ -280,12 +288,24 @@ describe('skyBackdrop holds the union of what the scenes draw', () => { return { darkest: hunt(-1), lightest: hunt(1) }; }; + // At night a sky the page URL steers (?sun= ?sky=) is shaped to the band's ceiling, so the lightest it can show is one + // 8-bit step above the ceiling's own grey: the Night constant must cover it too, or text is checked against a sky + // lighter than the one it sits on. + const steeredNight = displayedLuminance( + [SKY_BAND.night.ceiling, SKY_BAND.night.ceiling, SKY_BAND.night.ceiling], + 'high' + ); + const computed = (state: FixedSkyState) => { const r = [ridgeDrawn(state), terrainDrawn(state)]; const ind = extremes(state); return { darkest: Math.min(...r.map((x) => x.darkest), ind.darkest), - lightest: Math.max(...r.map((x) => x.lightest), ind.lightest), + lightest: Math.max( + ...r.map((x) => x.lightest), + ind.lightest, + ...(state === 'night' ? [steeredNight] : []) + ), }; }; diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index 666e83d6..21117039 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -127,11 +127,12 @@ export interface SkyBackdrop { // this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering // tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts // over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts). +// Night's lightest also covers a sky the page URL steers (shaped to the band ceiling, skyBand.ts). // Bounds what the 8-bit screen shows too (one step each way for rounding and dither), rounded outward to six places. const BACKDROPS: Record = { day: { darkest: 0.461063, lightest: 0.901108 }, dusk: { darkest: 0.30619, lightest: 0.968744 }, - night: { darkest: 0.05987, lightest: 0.081705 }, + night: { darkest: 0.05987, lightest: 0.081642 }, }; export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)]; From 2c271c6ded945ba0395256ef3dc643185c76c65c Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 01:13:29 +0000 Subject: [PATCH 055/111] sky: Midnight Sky's link is fitted to clear AA on the lightest Night sky The dark theme's link was #609385 (luminance 0.2505), 2.28:1 on the lightest Night sky (0.081642). It is now brightened in its own hue until its luminance is at least AA * (0.081642 + 0.05) - 0.05 = 0.5424: #8ad1bd (0.5468, hue 163 degrees as before), 4.53:1 on the lightest Night sky, 5.43:1 on the darkest, 11.28:1 on the sign-in card (was 5.68). Only the link changes in baked.ts; the user colours are unchanged. NIGHT_SKY_LIGHTEST is the Night backdrop constant, and a test fails if the two drift. Dusk is untouched. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyTheme.test.ts | 19 ++++++++++++------- src/app/features/scene/skyTheme.ts | 8 ++++---- src/app/pages/auth/styles.css.ts | 2 +- src/sky/baked.ts | 2 +- src/sky/skyTokens.js | 15 +++++++++++---- 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index f6873466..39b74fdc 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { atStandardTime } from './standardTime.testkit'; import { baked } from '../../../sky/baked'; -import { AA, contrast, luminance } from '../../../sky/skyTokens.js'; +import { AA, NIGHT_SKY_LIGHTEST, contrast, luminance } from '../../../sky/skyTokens.js'; import { Editor } from '../../components/editor/Editor.css'; import { PageContent, PageNavContent } from '../../components/page/style.css'; import { CallStatus } from '../call-status/styles.css'; @@ -59,13 +59,14 @@ describe('skyThemeId', () => { describe('ink of the theme a state picks, on bare sky', () => { // Per state: the picked theme, its text ink and link, and their WCAG ratios on the sky the scenes // draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link clears - // on day and not on dusk or night: the links are derived in src/sky/skyTokens.js, outside the band, and these - // numbers are recorded as they are measured (Night's #609385 would clear 4.5 at a luminance of about 0.53 or more). + // on day and night and not on dusk: the links are derived in src/sky/skyTokens.js, outside the band, and these + // numbers are recorded as they are measured. Midnight's link is brightened in its own hue until it clears the + // lightest Night sky (NIGHT_SKY_LIGHTEST); Dusk's dark link would need a backdrop of about 0.455, and the dusk floor is lower. // 'auto' resolves from the clock to one of these three and has the same numbers. const cells: [FixedSkyState, string, string, string, string, string][] = [ ['day', 'noonday-sky', '#342d25', '6.60', '#2a4f27', '4.55'], ['dusk', 'noonday-sky', '#342d25', '4.60', '#2a4f27', '3.17'], - ['night', 'midnight-sky', '#aeccff', '4.89', '#609385', '2.28'], + ['night', 'midnight-sky', '#aeccff', '4.89', '#8ad1bd', '4.53'], ]; it.each(cells)( @@ -83,11 +84,15 @@ describe('ink of the theme a state picks, on bare sky', () => { expect(ratio(ink, skyBackdrop(state))).toBeGreaterThanOrEqual(AA); }); - it('link clears AA on day (the terrain camera sees only the lighter sky) and falls short on dusk and night', () => { + it('link clears AA on day and night and falls short on dusk', () => { const link = (s: FixedSkyState, id: keyof typeof SKY) => ratio(SKY[id].link, skyBackdrop(s)); expect(link('day', 'noonday-sky')).toBeGreaterThanOrEqual(AA); expect(link('dusk', 'noonday-sky')).toBeLessThan(AA); - expect(link('night', 'midnight-sky')).toBeLessThan(AA); + expect(link('night', 'midnight-sky')).toBeGreaterThanOrEqual(AA); + }); + + it('keeps the lightest Night sky the link was fitted to equal to the Night backdrop (no drift)', () => { + expect(Math.abs(NIGHT_SKY_LIGHTEST - skyBackdrop('night').lightest)).toBeLessThan(1e-6); }); it.each([ @@ -109,7 +114,7 @@ describe('ink of the theme a state picks, on bare sky', () => { describe('the plate, where text cannot rest on bare sky', () => { // Each theme's plate: fill, ink, link and the WCAG ratios of ink and link on the fill. const plates = [ - ['midnight-sky', baked.midnight, '12.17', '5.68'], + ['midnight-sky', baked.midnight, '12.17', '11.28'], ['noonday-sky', baked.noonday, '6.70', '4.62'], ] as const; diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 52aff750..4ab3f43a 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -5,10 +5,10 @@ // Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Each state's // drawn sky is shaped into a luminance band (skyBand.ts) in which that ink's TEXT clears 4.5:1 on // bare sky: Day and Dusk at least 0.2983 (dark ink), Night at most 0.0931 (light ink). skyTheme.test.ts -// pins the measured text and link ratios. Link ink is not in the band: the Night link (#609385, -// 2.36:1) and the dark links on Day and Dusk fall short, because the themes' link colours are -// derived outside this code (src/sky/skyTokens.js); lightening the Night link to a luminance of -// about 0.53 or more would clear it. The plate (skyPlate.css.ts) is still in the shell, pending +// pins the measured text and link ratios. Link ink is not in the band: the Day and Dusk links are dark +// and fall short of 4.5 where the sky is below about 0.455 (Dusk: 3.17), because the themes' link colours are +// derived outside this code (src/sky/skyTokens.js). Midnight Sky's link is derived there against the lightest Night sky +// (4.53 on it, 5.43 on the darkest). The plate (skyPlate.css.ts) is still in the shell, pending // its removal. import type { FixedSkyState, SkyState } from './skyState'; diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 4fa42d74..b13bf868 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -60,7 +60,7 @@ export const AuthFooter = style({ // Noonday); the other themes (Light, Silver, Dark, Butter) take the fallback colours. // WCAG ratios of text / Register link on the card, per theme: // Light, Silver, Dark, Butter #b7c7d9 / #99c7ff on #0d1a26 10.21 / 10.03 -// Midnight Sky #aeccff / #609385 on #0a090e 12.17 / 5.68 +// Midnight Sky #aeccff / #8ad1bd on #0a090e 12.17 / 11.28 // Noonday Sky #342d25 / #2a4f27 on #b9b6af 6.70 / 4.62 // The fallback link is set here because those themes' own links (Light 2.49:1, Silver 2.49:1) are // made for their own grounds, not this card; Dark and Butter already use the same hsl(213 100% 80%). diff --git a/src/sky/baked.ts b/src/sky/baked.ts index 0393fdbf..73c93bd5 100644 --- a/src/sky/baked.ts +++ b/src/sky/baked.ts @@ -115,7 +115,7 @@ export const baked = { '#677dff', '#6a926a', ], - link: '#609385', + link: '#8ad1bd', }, noonday: { moment: { diff --git a/src/sky/skyTokens.js b/src/sky/skyTokens.js index c234de12..5152a4fb 100644 --- a/src/sky/skyTokens.js +++ b/src/sky/skyTokens.js @@ -19,6 +19,10 @@ const luma = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; export const GRADIENT_STOPS = 7; export const AA = 4.5; // WCAG 2.x AA, normal text +// The lightest the Night sky draws behind the page (src/app/features/scene/skyState.ts, `skyBackdrop('night')`; a test in +// skyTheme.test.ts fails if the two drift). Midnight Sky's link also sits on bare sky, so it must clear AA against this. +export const NIGHT_SKY_LIGHTEST = 0.081642; + // Where and when the theme is lit. Equinox, 45N on the prime meridian: noon sun at 45 degrees. export const MOMENTS = { noonday: { time: '2026-09-22T12:00:00Z', lat: 45, lon: 0 }, @@ -186,10 +190,10 @@ export function bakeSky(name) { bubbles.Own, ]; const readable = (h) => grounds.every((g) => contrast(h, g) >= AA); - const fit = (albedo) => { + const fit = (albedo, ok = () => true) => { let key = 0.5; let h = hex(albedo, key); - for (let n = 0; n < 80 && !readable(h); n += 1) { + for (let n = 0; n < 80 && !(readable(h) && ok(h)); n += 1) { key *= dark ? 1.05 : 0.95; h = hex(albedo, key); } @@ -207,7 +211,10 @@ export function bakeSky(name) { gradient, tokens, bubbles, - userColors: PIGMENTS.map(fit), - link: fit(dark ? ALBEDO.leafPale : ALBEDO.leaf), + userColors: PIGMENTS.map((p) => fit(p)), + // The dark link also has to clear AA on the bare Night sky: brightened, in its own hue, until it does. + link: dark + ? fit(ALBEDO.leafPale, (h) => luminance(h) >= AA * (NIGHT_SKY_LIGHTEST + 0.05) - 0.05) + : fit(ALBEDO.leaf), }; } From 61f434a32db89bdc1b4cac411730364a5eaa73be Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 01:34:07 +0000 Subject: [PATCH 056/111] scene: two quick sky changes build the last sky; the Night drift test is exact A rebuild read the sky choice before awaiting its key and acted on it after, so Day then Dusk in quick succession left the Day scene live (the old test only counted disposals and passed with the Dusk scene never built). A rebuild now stands down if the choice moved while it awaited; that change has its own rebuild. The test asserts the last sky built is the last one chosen, is the live scene (a login reaches it), and that every other scene was disposed. The NIGHT_SKY_LIGHTEST drift test compared with a 1e-6 tolerance, so 0.081641 passed; it is now exact equality. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 21 +++++++++++++++++---- src/app/features/scene/index.ts | 2 ++ src/app/features/scene/skyTheme.test.ts | 2 +- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 5d0e6410..6a29b33f 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -4,6 +4,7 @@ import { baked } from '../../../sky/baked'; import type { Scene } from './Scene'; import { startBackgroundScene } from './index'; import { setSkyClock, setSkyState } from './skyState'; +import { ridgeSkyFor } from './skyForState'; const { createRidgeScene, createScene } = vi.hoisted(() => ({ createRidgeScene: vi.fn(), @@ -155,16 +156,28 @@ describe('startBackgroundScene follows the sky state', () => { expect(createRidgeScene).toHaveBeenCalledOnce(); }); - it('keeps only the latest of two quick changes', async () => { + it('keeps only the latest of two quick changes: the last sky is built, the others are not left running', async () => { const scenes = [fakeScene(), fakeScene(), fakeScene()]; scenes.forEach((s) => createRidgeScene.mockReturnValueOnce(s)); const scene = await startBackgroundScene(config, caps, { canvas }); setSkyState('day'); - setSkyState('night'); + setSkyState('dusk'); await flush(); - expect(scenes[1].dispose).toHaveBeenCalledOnce(); - expect(scenes[2].dispose).not.toHaveBeenCalled(); + // The sky last built is dusk's, and it is the live scene: a login reaches it and nothing else. + const built = createRidgeScene.mock.calls.length; + expect(hex(createRidgeScene.mock.calls[built - 1][0].sky.top)).toBe( + hex(ridgeSkyFor('dusk').top) + ); + const live = scenes[built - 1]; + scene?.login(); + expect(live.login).toHaveBeenCalledOnce(); + scenes.filter((s) => s !== live).forEach((s) => expect(s.login).not.toHaveBeenCalled()); + // Every scene but the live one was disposed, the live one not yet. + scenes.slice(0, built).forEach((s) => { + expect(s.dispose).toHaveBeenCalledTimes(s === live ? 0 : 1); + }); scene?.dispose(); + expect(live.dispose).toHaveBeenCalledOnce(); }); it('leaves the ray-march scene alone: it has one fixed dusk', async () => { diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 55966188..73377440 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -95,8 +95,10 @@ export async function startBackgroundScene( let generation = 0; // A rebuild is for a different sky ('auto' crossing a boundary, a new choice), not for a repeat of the one drawn. const rebuild = async () => { + // The choice can move while the key is awaited; that change has its own rebuild, so this one stands down. const next = getSkyState(); const key = await skyKey(config, next); + if (getSkyState() !== next) return; if ((key !== null && key === built) || disposed) return; generation += 1; const mine = generation; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 39b74fdc..53cf2873 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -92,7 +92,7 @@ describe('ink of the theme a state picks, on bare sky', () => { }); it('keeps the lightest Night sky the link was fitted to equal to the Night backdrop (no drift)', () => { - expect(Math.abs(NIGHT_SKY_LIGHTEST - skyBackdrop('night').lightest)).toBeLessThan(1e-6); + expect(NIGHT_SKY_LIGHTEST).toBe(skyBackdrop('night').lightest); }); it.each([ From 098e45f122d57b7f5041994b650e5a313d6baa35 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 01:56:34 +0000 Subject: [PATCH 057/111] scene: test a sky change landing 10-12 microtasks into a build The stale build must be disposed and must not become live: removing the stale-build scene?.dispose() or the generation check now fails the test. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 6a29b33f..75e7b289 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -20,6 +20,12 @@ const flush = () => new Promise((r) => { setTimeout(r, 0); }); +// Resolves after `n` further turns of the microtask queue. +const microtasks = (n: number) => + Array.from({ length: n }).reduce>( + (p) => p.then(() => undefined), + Promise.resolve() + ); const fakeScene = () => ({ login: vi.fn(), dispose: vi.fn() }); const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; @@ -180,6 +186,35 @@ describe('startBackgroundScene follows the sky state', () => { expect(live.dispose).toHaveBeenCalledOnce(); }); + it.each([10, 11, 12])( + 'a sky change %i microtasks into a build leaves only the newer build live; the stale one is disposed', + async (ticks) => { + const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); + const onActive = vi.fn(); + const scene = await startBackgroundScene(config, caps, { canvas, onActive }); + setSkyState('day'); + await microtasks(ticks); + setSkyState('dusk'); + await flush(); + const built = createRidgeScene.mock.calls.length; + expect(hex(createRidgeScene.mock.calls[built - 1][0].sky.top)).toBe( + hex(ridgeSkyFor('dusk').top) + ); + // The newest build is live, every earlier one disposed exactly once, and only the live one is shown. + const live = [a, b, c][built - 1]; + scene?.login(); + expect(live.login).toHaveBeenCalledOnce(); + [a, b, c].slice(0, built).forEach((s) => { + expect(s.dispose).toHaveBeenCalledTimes(s === live ? 0 : 1); + if (s !== live) expect(s.login).not.toHaveBeenCalled(); + }); + expect(onActive).toHaveBeenCalledOnce(); + scene?.dispose(); + expect(live.dispose).toHaveBeenCalledOnce(); + } + ); + it('leaves the ray-march scene alone: it has one fixed dusk', async () => { const inner = fakeScene() as unknown as Scene; createScene.mockReturnValue(inner); From 6a93664813b61cc1f20efd1688c7a84cc7a1f02c Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 02:07:11 +0000 Subject: [PATCH 058/111] scene: sweep the mid-build test over microtask offsets 0..20 The change is no longer pinned to 10-12 microtasks; the test requires at least one offset to land on a build that is found stale and dropped, so a shift in the awaits inside create/skyKey cannot make it vacuous. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 69 +++++++++++++++++----------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 75e7b289..f54e456f 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -186,34 +186,47 @@ describe('startBackgroundScene follows the sky state', () => { expect(live.dispose).toHaveBeenCalledOnce(); }); - it.each([10, 11, 12])( - 'a sky change %i microtasks into a build leaves only the newer build live; the stale one is disposed', - async (ticks) => { - const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; - createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); - const onActive = vi.fn(); - const scene = await startBackgroundScene(config, caps, { canvas, onActive }); - setSkyState('day'); - await microtasks(ticks); - setSkyState('dusk'); - await flush(); - const built = createRidgeScene.mock.calls.length; - expect(hex(createRidgeScene.mock.calls[built - 1][0].sky.top)).toBe( - hex(ridgeSkyFor('dusk').top) - ); - // The newest build is live, every earlier one disposed exactly once, and only the live one is shown. - const live = [a, b, c][built - 1]; - scene?.login(); - expect(live.login).toHaveBeenCalledOnce(); - [a, b, c].slice(0, built).forEach((s) => { - expect(s.dispose).toHaveBeenCalledTimes(s === live ? 0 : 1); - if (s !== live) expect(s.login).not.toHaveBeenCalled(); - }); - expect(onActive).toHaveBeenCalledOnce(); - scene?.dispose(); - expect(live.dispose).toHaveBeenCalledOnce(); - } - ); + // A second sky change `ticks` microtasks after the first. True when it landed mid-build: the build for the first + // change finished, was found stale and dropped (never shown), and only the newest sky was announced. + const changeAfter = async (ticks: number) => { + createRidgeScene.mockReset(); + setSkyState('auto'); + const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); + const onActive = vi.fn(); + const scene = await startBackgroundScene(config, caps, { canvas, onActive }); + setSkyState('day'); + await microtasks(ticks); + setSkyState('dusk'); + await flush(); + const at = `at ${ticks} microtasks`; + const built = createRidgeScene.mock.calls.length; + expect(hex(createRidgeScene.mock.calls[built - 1][0].sky.top), at).toBe( + hex(ridgeSkyFor('dusk').top) + ); + // The newest build is live, every earlier one disposed exactly once, and only the live one is shown. + const live = [a, b, c][built - 1]; + scene?.login(); + expect(live.login, at).toHaveBeenCalledOnce(); + [a, b, c].slice(0, built).forEach((x) => { + expect(x.dispose, at).toHaveBeenCalledTimes(x === live ? 0 : 1); + if (x !== live) expect(x.login, at).not.toHaveBeenCalled(); + }); + scene?.dispose(); + expect(live.dispose, at).toHaveBeenCalledOnce(); + return built === 3 && onActive.mock.calls.length === 1; + }; + + it('a sky change at any point of a build leaves only the newer build live; the stale one is disposed', async () => { + // The offsets are swept, not pinned, so a shift in the awaits inside the build cannot move the change off the + // build: at least one offset must land mid-build, or the sweep proves nothing. + const offsets = Array.from({ length: 21 }, (_, i) => i); + const landed = await offsets.reduce>( + (p, k) => p.then(async (acc) => ((await changeAfter(k)) ? [...acc, k] : acc)), + Promise.resolve([]) + ); + expect(landed.length).toBeGreaterThan(0); + }); it('leaves the ray-march scene alone: it has one fixed dusk', async () => { const inner = fakeScene() as unknown as Scene; From de504f82863fbc9172d10b499b883185bbebd263 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 02:24:13 +0000 Subject: [PATCH 059/111] scene: say what the sweep's mid-build assertion means when it fails Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index f54e456f..23c6b297 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -225,7 +225,9 @@ describe('startBackgroundScene follows the sky state', () => { (p, k) => p.then(async (acc) => ((await changeAfter(k)) ? [...acc, k] : acc)), Promise.resolve([]) ); - expect(landed.length).toBeGreaterThan(0); + // This can fail after a correct refactor that changes the await count in create/skyKey and moves every offset + // off the build. Then widen the range of offsets until some land again; do not weaken the assertion. + expect(landed.length, 'at least one offset must land mid-build').toBeGreaterThan(0); }); it('leaves the ray-march scene alone: it has one fixed dusk', async () => { From 91e93e6887ecd777f0ac5fe3cd0dff4540be286b Mon Sep 17 00:00:00 2001 From: Nibu Date: Tue, 10 Nov 2026 01:18:45 +0000 Subject: [PATCH 060/111] scene: Dusk floor at the link bound with Day's pale-blue lift, so the dark link clears AA Dusk's floor moves from 0.317 to 0.4744 (the dark link's bound 0.4554 plus the 0.019 margin Day keeps over its text bound), and its lift from rose #eba0b9 (luma 0.463, below the new floor) to Day's #e2f4ff. The ridge dusk stops are shaped into the same band, since the unshaped twilight dipped to 0.336. skyBackdrop('dusk').darkest becomes 0.460352; Dusk text 4.60 -> 6.59, link 3.17 -> 4.54. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/ridge/skyColour.ts | 5 ++-- src/app/features/scene/skyBand.test.ts | 2 +- src/app/features/scene/skyBand.ts | 13 ++++++--- src/app/features/scene/skyForState.test.ts | 34 +++++++++++++++++----- src/app/features/scene/skyForState.ts | 27 +++++++++++++---- src/app/features/scene/skyState.ts | 2 +- src/app/features/scene/skyTheme.test.ts | 12 ++++---- src/app/features/scene/skyTheme.ts | 8 ++--- 8 files changed, 71 insertions(+), 32 deletions(-) diff --git a/src/app/features/scene/ridge/skyColour.ts b/src/app/features/scene/ridge/skyColour.ts index 3a5147a4..7917d89e 100644 --- a/src/app/features/scene/ridge/skyColour.ts +++ b/src/app/features/scene/ridge/skyColour.ts @@ -1,8 +1,7 @@ // The one place the sky's colours come from. Void's `src/sky/sun.js` (`light({ time, lat, lon, // cloud_cover, precipitation })`, PR #20) is the intended source; until it lands this returns a -// fixed twilight: lavender zenith, rose mid-sky, orange horizon. Its darkest blend stays above the dusk -// floor of ../skyBand.ts (the dark ink of Noonday Sky needs a backdrop of at least 0.2983 luminance), which -// skyForState.test.ts sweeps. Replace the body, keep the signature: the scene calls it once, at startup, +// fixed twilight: lavender zenith, rose mid-sky, orange horizon. These are the raw stops: the scene draws them +// shaped into the dusk band of ../skyBand.ts (`ridgeSkyFor`, skyForState.ts), whose floor the link of Noonday Sky needs. Replace the body, keep the signature: the scene calls it once, at startup, // and bakes the result. export type Srgb = readonly [number, number, number]; diff --git a/src/app/features/scene/skyBand.test.ts b/src/app/features/scene/skyBand.test.ts index 5963fb79..e38f676a 100644 --- a/src/app/features/scene/skyBand.test.ts +++ b/src/app/features/scene/skyBand.test.ts @@ -23,7 +23,7 @@ describe('shapeToBand', () => { it('lifts black to the tint scaled in, not to grey', () => { const c = shapeToBand([0, 0, 0], SKY_BAND.dusk); expect(near(luma(c), SKY_BAND.dusk.floor)).toBe(true); - expect(c[0]).toBeGreaterThan(c[2]); // the dusk tint is rose + expect(c[2]).toBeGreaterThan(c[0]); // the dusk tint is Day's pale blue }); it('leaves a sky already in the band as it is, and is continuous at the floor', () => { diff --git a/src/app/features/scene/skyBand.ts b/src/app/features/scene/skyBand.ts index ac311081..a74784b3 100644 --- a/src/app/features/scene/skyBand.ts +++ b/src/app/features/scene/skyBand.ts @@ -1,12 +1,14 @@ // The luminance band each fixed sky state's drawn sky is shaped into, so the state's theme ink // clears 4.5:1 on bare sky (WCAG relative luminance, the `luminance` of src/sky/skyTokens.js). // -// day, dusk Noonday Sky's dark text ink #342d25 (L 0.0274) needs a backdrop of at least 0.2983. +// day Noonday Sky's dark text ink #342d25 (L 0.0274) needs a backdrop of at least 0.2983. // The floor is that text bound (0.30 as drawn); there is no ceiling, dark ink on light is fine. +// dusk The same ink, and its link #2a4f27 (L 0.0623), which needs a backdrop of at least 0.4554. The floor is +// the link bound (0.4744 as drawn), so text clears 4.5:1 with room (6.6) and the link clears too. // night Midnight Sky's light text ink #aeccff (L 0.594) needs a backdrop of at most 0.0931. // The baked night sky (0.058 to 0.077) is kept; the ceiling is the bound. // -// Link ink is not part of the band: see skyTheme.ts. +// Night's link ink is not part of the band: see skyTheme.ts. // // The terrain sky is a physical scatter function, so it is shaped downstream, on its final colour: // `shapeToBand` here, and its GLSL twin (`SHAPE_SKY_GLSL`) in terrain/terrainShaders.ts, built from the @@ -56,7 +58,8 @@ export interface SkyBand { } /** - * The shaping targets, in linear luma. The floor sits 0.019 above the text bound (0.2983): the screen encoding is + * The shaping targets, in linear luma. Day's floor sits 0.019 above the text bound (0.2983), Dusk's 0.019 above the link + * bound (0.4554), which skyForState.test.ts derives from the ink and ties to these: the screen encoding is * gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level, and 8-bit rounding with the sky * pass's dither at its worst (one step in every channel) takes about 0.007 more. The night ceiling is lower than the text bound * (0.0931) by more than that: 0.077394 is the luma at which one step up reads 0.08164, the lightest the baked night sky shows @@ -64,7 +67,9 @@ export interface SkyBand { */ export const SKY_BAND: Record = { day: { floor: 0.317, ceiling: 1, lift: hexToLinear('#e2f4ff') }, - dusk: { floor: 0.317, ceiling: 1, lift: hexToLinear('#eba0b9') }, + // Dusk lifts toward Day's pale blue rather than a rose: mixing a third of the sky toward rose washed the twilight pink, and + // a rose lighter than the floor shifts hues by 47 to 72 degrees where Day's tint shifts them by 18 (measured, PR body). + dusk: { floor: 0.4744, ceiling: 1, lift: hexToLinear('#e2f4ff') }, night: { floor: 0, ceiling: 0.077394, lift: hexToLinear('#5a6a9a') }, }; diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 9d1dfd2d..a8199b1b 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -12,7 +12,7 @@ import { terrainDrawn, terrainSkyFor, } from './skyForState'; -import { displayedLuminance, SKY_BAND } from './skyBand'; +import { displayedLuminance, luma, shapeToBand, SKY_BAND } from './skyBand'; import type { FixedSkyState } from './skyBand'; import { setSkyClock, skyBackdrop } from './skyState'; import { view } from './terrain/terrainWorld'; @@ -24,14 +24,17 @@ const STATES = ['day', 'dusk', 'night'] as const; const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; /** - * The band each state's text ink needs, from the ink itself: dark ink needs a backdrop of at least - * AA * (L + 0.05) - 0.05, light ink at most (L + 0.05) / AA - 0.05. Day 0.2983, dusk 0.2983, night 0.0931. + * The band each state needs, from the ink itself: dark ink needs a backdrop of at least + * AA * (L + 0.05) - 0.05, light ink at most (L + 0.05) / AA - 0.05. Day 0.2983, night 0.0931; dusk is bound by its + * link (0.4554), the darker ink to clear, whose floor also holds the text. */ const needs = (state: FixedSkyState) => { - const l = luminance(SKY[skyThemeId(state)].tokens.Background.OnContainer); + const sky = SKY[skyThemeId(state)]; + const l = luminance(sky.tokens.Background.OnContainer); + const link = luminance(sky.link); return state === 'night' ? { floor: 0, ceiling: (l + 0.05) / AA - 0.05 } - : { floor: AA * (l + 0.05) - 0.05, ceiling: 1 }; + : { floor: AA * ((state === 'dusk' ? link : l) + 0.05) - 0.05, ceiling: 1 }; }; let search = ''; @@ -51,9 +54,26 @@ afterEach(() => { describe('the bands the text ink needs', () => { it('are the numbers the shaping was set from', () => { expect(needs('day').floor).toBeCloseTo(0.2983, 3); - expect(needs('dusk').floor).toBeCloseTo(0.2983, 3); + expect(needs('dusk').floor).toBeCloseTo(0.4554, 3); expect(needs('night').ceiling).toBeCloseTo(0.0931, 3); }); + + // The drawn floor and the lift are tied to those bounds, not to themselves: nudge either constant and this fails. + it('give Dusk a floor 0.019 above the link bound (as Day has above the text bound) and a lift Day shares', () => { + expect(SKY_BAND.dusk.floor).toBeCloseTo(needs('dusk').floor + 0.019, 4); + expect(SKY_BAND.day.floor).toBeCloseTo(needs('day').floor + 0.019, 3); + expect(SKY_BAND.dusk.lift).toEqual(SKY_BAND.day.lift); + expect(luma(SKY_BAND.dusk.lift)).toBeGreaterThan(SKY_BAND.dusk.floor); + }); + + it.each(['day', 'dusk'] as const)( + '%s: the darkest the shaping can show reads at the bound and under the floor', + (state) => { + const shown = displayedLuminance(shapeToBand([0, 0, 0], SKY_BAND[state]), 'low'); + expect(shown).toBeGreaterThanOrEqual(needs(state).floor); + expect(shown).toBeLessThan(SKY_BAND[state].floor); + } + ); }); describe('ridgeSkyFor', () => { @@ -68,7 +88,7 @@ describe('ridgeSkyFor', () => { it('draws a lavender, rose and orange twilight for dusk', () => { const d = ridgeSkyFor('dusk'); - expect(d).toEqual(skyColours()); + expect(d.ground).toEqual(skyColours().ground); const [t, m, h] = [d.top, d.mid, d.horizon]; expect(t[2]).toBeGreaterThan(t[1]); // lavender: blue over green expect(t[0]).toBeGreaterThan(t[1]); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index 7da17476..71e07ca6 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -10,11 +10,13 @@ import type { SkyColours, Srgb } from './ridge/skyColour'; import { skyColours } from './ridge/skyColour'; import { resolveSkyState } from './skyState'; import type { SkyState } from './skyState'; -import { displayedLuminance, SKY_BAND } from './skyBand'; +import { displayedLuminance, hexToLinear, shapeToBand, SKY_BAND } from './skyBand'; import type { SkyBand } from './skyBand'; import { luminance } from '../../../sky/skyTokens.js'; import { CAMERA } from './terrain/terrainWorld'; +const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; + const rgb = (hex: string): Srgb => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb; @@ -28,12 +30,27 @@ const fromGradient = (g: readonly string[], ground: Srgb): SkyColours => ({ /** Sun elevation in degrees for the terrain's dusk: just above the horizon, between the two bakes (as `?sun=` puts it). */ export const DUSK_SUN_ELEVATION = 2; -/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). */ +const toByte = (v: number): number => + Math.round(255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055)); + +/** A stop moved into the state's band, the way the terrain sky is (`shapeToBand`), through the sRGB curve the canvas draws with. */ +const inBand = (c: Srgb, band: SkyBand): Srgb => + shapeToBand(hexToLinear(rgbHex(c)), band).map(toByte) as unknown as Srgb; + +/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). Dusk's twilight stops are shaped into its band. */ export function ridgeSkyFor(state: SkyState): SkyColours { const { ground } = skyColours(); switch (resolveSkyState(state)) { - case 'dusk': - return skyColours(); + case 'dusk': { + const twilight = skyColours(); + const band = SKY_BAND.dusk; + return { + ...twilight, + top: inBand(twilight.top, band), + mid: inBand(twilight.mid, band), + horizon: inBand(twilight.horizon, band), + }; + } case 'day': return fromGradient(baked.noonday.gradient, ground); default: @@ -67,8 +84,6 @@ export function terrainSkyFor(state: SkyState): { } } -const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; - /** Relative luminance (WCAG) of the darkest and lightest colour a scene can draw. */ export interface Range { readonly darkest: number; diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index 21117039..0b60d99b 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -131,7 +131,7 @@ export interface SkyBackdrop { // Bounds what the 8-bit screen shows too (one step each way for rounding and dither), rounded outward to six places. const BACKDROPS: Record = { day: { darkest: 0.461063, lightest: 0.901108 }, - dusk: { darkest: 0.30619, lightest: 0.968744 }, + dusk: { darkest: 0.460352, lightest: 0.968744 }, night: { darkest: 0.05987, lightest: 0.081642 }, }; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 53cf2873..fd9f32dd 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -59,13 +59,13 @@ describe('skyThemeId', () => { describe('ink of the theme a state picks, on bare sky', () => { // Per state: the picked theme, its text ink and link, and their WCAG ratios on the sky the scenes // draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link clears - // on day and night and not on dusk: the links are derived in src/sky/skyTokens.js, outside the band, and these - // numbers are recorded as they are measured. Midnight's link is brightened in its own hue until it clears the - // lightest Night sky (NIGHT_SKY_LIGHTEST); Dusk's dark link would need a backdrop of about 0.455, and the dusk floor is lower. + // in every state: the links are derived in src/sky/skyTokens.js, outside the band, and these numbers are recorded as + // they are measured. Midnight's link is brightened in its own hue until it clears the lightest Night sky + // (NIGHT_SKY_LIGHTEST); Dusk's dark link needs a backdrop of about 0.4554, which the dusk floor is set above. // 'auto' resolves from the clock to one of these three and has the same numbers. const cells: [FixedSkyState, string, string, string, string, string][] = [ ['day', 'noonday-sky', '#342d25', '6.60', '#2a4f27', '4.55'], - ['dusk', 'noonday-sky', '#342d25', '4.60', '#2a4f27', '3.17'], + ['dusk', 'noonday-sky', '#342d25', '6.59', '#2a4f27', '4.54'], ['night', 'midnight-sky', '#aeccff', '4.89', '#8ad1bd', '4.53'], ]; @@ -84,10 +84,10 @@ describe('ink of the theme a state picks, on bare sky', () => { expect(ratio(ink, skyBackdrop(state))).toBeGreaterThanOrEqual(AA); }); - it('link clears AA on day and night and falls short on dusk', () => { + it('link clears AA in every state', () => { const link = (s: FixedSkyState, id: keyof typeof SKY) => ratio(SKY[id].link, skyBackdrop(s)); expect(link('day', 'noonday-sky')).toBeGreaterThanOrEqual(AA); - expect(link('dusk', 'noonday-sky')).toBeLessThan(AA); + expect(link('dusk', 'noonday-sky')).toBeGreaterThanOrEqual(AA); expect(link('night', 'midnight-sky')).toBeGreaterThanOrEqual(AA); }); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 4ab3f43a..f8ab8b12 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -4,10 +4,10 @@ // // Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Each state's // drawn sky is shaped into a luminance band (skyBand.ts) in which that ink's TEXT clears 4.5:1 on -// bare sky: Day and Dusk at least 0.2983 (dark ink), Night at most 0.0931 (light ink). skyTheme.test.ts -// pins the measured text and link ratios. Link ink is not in the band: the Day and Dusk links are dark -// and fall short of 4.5 where the sky is below about 0.455 (Dusk: 3.17), because the themes' link colours are -// derived outside this code (src/sky/skyTokens.js). Midnight Sky's link is derived there against the lightest Night sky +// bare sky: Day at least 0.2983 (dark ink), Night at most 0.0931 (light ink). Dusk's floor is higher, the bound of +// the dark link (0.4554), so its link clears as well. skyTheme.test.ts pins the measured text and link ratios. Night's +// link ink is not in the band, and the themes' link colours are derived outside this code (src/sky/skyTokens.js). +// Midnight Sky's link is derived there against the lightest Night sky // (4.53 on it, 5.43 on the darkest). The plate (skyPlate.css.ts) is still in the shell, pending // its removal. import type { FixedSkyState, SkyState } from './skyState'; From ea238cf77d4af5c79113982ccccabd08bf3b8d34 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 03:18:49 +0000 Subject: [PATCH 061/111] auth: a blurred, tinted, soft-edged band behind the sign-in footer line The footer links sat on dark terrain in the sky's dark ink (contrast 1.0 to 2.9 at Dusk and Day). The footer now carries the plate's ink over a backdrop-blur band tinted with the plate's fill (FOOTER_TINT), faded at its edges; skyTheme.test.ts checks the ink over the darkest and lightest scene behind it. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyPlate.ts | 8 ++++++++ src/app/features/scene/skyTheme.test.ts | 19 ++++++++++++++++++- src/app/pages/auth/styles.css.ts | 23 +++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts index 872e2fd6..54ff13f2 100644 --- a/src/app/features/scene/skyPlate.ts +++ b/src/app/features/scene/skyPlate.ts @@ -7,3 +7,11 @@ export const SKY_PLATE_FALLBACK = { text: '#b7c7d9', link: '#99c7ff', } as const; + +/** + * The sign-in footer's tint: how much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind the + * footer line. The footer is a blurred, tinted band with soft edges, not a plate: the scene shows through it. The + * footer ink is the plate's, so the tint has to bring the scene behind it to where that ink clears 4.5:1 + * (skyTheme.test.ts). + */ +export const FOOTER_TINT = 0.75; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 53cf2873..dd46117b 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -7,7 +7,7 @@ import { PageContent, PageNavContent } from '../../components/page/style.css'; import { CallStatus } from '../call-status/styles.css'; import { MembersDrawer } from '../room/MembersDrawer.css'; import { RoomViewTyping } from '../room/RoomViewTyping.css'; -import { SKY_PLATE_FALLBACK } from './skyPlate'; +import { FOOTER_TINT, SKY_PLATE_FALLBACK } from './skyPlate'; import { SkyPlate, plateRegions } from './skyPlate.css'; import { getResolvedSkyState, setSkyClock, skyBackdrop } from './skyState'; import type { SkyBackdrop } from './skyState'; @@ -160,3 +160,20 @@ describe('regions that sit on the plate', () => { expect(plateRegions.some((r) => r.includes(':not(:has('))).toBe(true); }); }); + +describe('the sign-in footer band', () => { + // The footer line is the plate's ink on a band that mixes FOOTER_TINT of the plate's fill over the blurred scene. + // The scene behind it is the terrain: as dark as 0, as light as the sky state's lightest backdrop. Ink contrast is + // lowest at one of those two ends, so both are checked. + const tinted = (fill: string, behind: number) => + FOOTER_TINT * luminance(fill) + (1 - FOOTER_TINT) * behind; + it.each(fixed)( + '%s: the ink clears AA over the darkest and the lightest scene behind it', + (state, id) => { + const { Other, OtherText } = SKY[id].bubbles; + [0, skyBackdrop(state).lightest].forEach((behind) => { + expect(against(OtherText, tinted(Other, behind))).toBeGreaterThanOrEqual(AA); + }); + } + ); +}); diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index b13bf868..c1614a79 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -1,5 +1,6 @@ import { globalStyle, style } from '@vanilla-extract/css'; import { DefaultReset, color, config, toRem } from 'folds'; +import { FOOTER_TINT } from '../../features/scene/skyPlate'; export const AuthLayout = style({ minHeight: '100%', @@ -88,3 +89,25 @@ globalStyle(`${scene} ${AuthCardContent}`, { paddingBottom: config.space.S400, gap: config.space.S500, }); + +// The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 to 0.04 at dusk), where +// the sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's +// ink and link, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. +// The tint (FOOTER_TINT) is checked against the ink in skyTheme.test.ts. +globalStyle(`${scene} ${AuthFooter}`, { + paddingTop: config.space.S500, + paddingBottom: config.space.S500, + backgroundColor: `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${ + FOOTER_TINT * 100 + }%, transparent)`, + backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', + maskImage: + 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)', + WebkitMaskImage: + 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)', + color: 'var(--sky-bubble-text, #b7c7d9)', +}); +globalStyle(`${scene} ${AuthFooter} a`, { + color: 'var(--sky-bubble-text, #b7c7d9)', +}); From eb6efeee91f564b9a3521078197ab0f95a3ece7a Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 03:20:06 +0000 Subject: [PATCH 062/111] auth: footer tint 0.82; 0.75 measured 4.49 at Dusk on the drawn pixels Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyPlate.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts index 54ff13f2..2b9f4484 100644 --- a/src/app/features/scene/skyPlate.ts +++ b/src/app/features/scene/skyPlate.ts @@ -12,6 +12,7 @@ export const SKY_PLATE_FALLBACK = { * The sign-in footer's tint: how much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind the * footer line. The footer is a blurred, tinted band with soft edges, not a plate: the scene shows through it. The * footer ink is the plate's, so the tint has to bring the scene behind it to where that ink clears 4.5:1 - * (skyTheme.test.ts). + * (skyTheme.test.ts). The browser mixes in sRGB, which gives less contrast over dark terrain than the linear mix the test + * computes, so the value carries margin: measured on the drawn pixels (Chrome, GTX 1070), 0.75 gave 4.49 at Dusk. */ -export const FOOTER_TINT = 0.75; +export const FOOTER_TINT = 0.82; From 56bc40abe600cc92612cd159c79bce6aaa6d7f7d Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 03:21:47 +0000 Subject: [PATCH 063/111] auth: fade the footer band at its sides too Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/styles.css.ts | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index c1614a79..d3e60dca 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -90,6 +90,43 @@ globalStyle(`${scene} ${AuthCardContent}`, { gap: config.space.S500, }); +// The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 to 0.04 at dusk), where +// the sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's +// ink and link, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. +// The tint (FOOTER_TINT) is checked against the ink in skyTheme.test.ts. +const FADE_Y = + 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)'; +const FADE_X = + 'linear-gradient(to right, transparent, #000 24px, #000 calc(100% - 24px), transparent)'; +globalStyle(`${scene} ${AuthFooter}`, { + paddingTop: config.space.S500, + paddingBottom: config.space.S500, + backgroundColor: `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${ + FOOTER_TINT * 100 + }%, transparent)`, + backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', + maskImage: `${FADE_Y}, ${FADE_X}`, + WebkitMaskImage: `${FADE_Y}, ${FADE_X}`, + maskComposite: 'intersect', + WebkitMaskComposite: 'source-in', + color: 'var(--sky-bubble-text, #b7c7d9)', + borderRadius: config.radii.R500, + // A faint inner edge, so a pane this clear still reads as a surface. + borderColor: 'rgba(183, 199, 217, 0.14)', +}); +globalStyle(`${scene} body:not(.gamecult-theme) ${AuthCard}`, { + vars: { '--tc-link': '#99c7ff' }, +}); +globalStyle(`${scene} ${AuthHeader}`, { + backgroundColor: 'transparent', +}); +globalStyle(`${scene} ${AuthCardContent}`, { + paddingTop: config.space.S500, + paddingBottom: config.space.S400, + gap: config.space.S500, +}); + // The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 to 0.04 at dusk), where // the sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's // ink and link, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. From 2e6d4d042a326e38e793ae043832376dcf3ae78b Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 03:31:52 +0000 Subject: [PATCH 064/111] auth: footer band review fixes: one block, no pill or border, color-mix fallback, sRGB test The first footer block had spliced into the sign-in card's rule (a Y-only mask duplicate that overrode the side fade, a pill radius and border on the footer); the footer is now one rule. The test mixes in encoded sRGB as Chrome does. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyPlate.ts | 4 +-- src/app/features/scene/skyTheme.test.ts | 23 +++++++++---- src/app/pages/auth/styles.css.ts | 45 +++---------------------- 3 files changed, 23 insertions(+), 49 deletions(-) diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts index 2b9f4484..86429ba5 100644 --- a/src/app/features/scene/skyPlate.ts +++ b/src/app/features/scene/skyPlate.ts @@ -12,7 +12,7 @@ export const SKY_PLATE_FALLBACK = { * The sign-in footer's tint: how much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind the * footer line. The footer is a blurred, tinted band with soft edges, not a plate: the scene shows through it. The * footer ink is the plate's, so the tint has to bring the scene behind it to where that ink clears 4.5:1 - * (skyTheme.test.ts). The browser mixes in sRGB, which gives less contrast over dark terrain than the linear mix the test - * computes, so the value carries margin: measured on the drawn pixels (Chrome, GTX 1070), 0.75 gave 4.49 at Dusk. + * (skyTheme.test.ts). The browser mixes in sRGB, so the test does too (skyTheme.test.ts): over pure black 0.80 gives 4.36 and 0.82 gives 4.55, and + * the darkest terrain drawn is 0.0045, not black; measured on the drawn pixels the footer clears 6.2. */ export const FOOTER_TINT = 0.82; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index dd46117b..3229cf36 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -162,17 +162,28 @@ describe('regions that sit on the plate', () => { }); describe('the sign-in footer band', () => { - // The footer line is the plate's ink on a band that mixes FOOTER_TINT of the plate's fill over the blurred scene. - // The scene behind it is the terrain: as dark as 0, as light as the sky state's lightest backdrop. Ink contrast is - // lowest at one of those two ends, so both are checked. - const tinted = (fill: string, behind: number) => - FOOTER_TINT * luminance(fill) + (1 - FOOTER_TINT) * behind; + // The footer line is the plate's ink on a band that mixes FOOTER_TINT of the plate's fill over the blurred scene, + // as the browser does: per channel, in encoded sRGB (not in linear light, which flatters dark scenes). The scene + // behind it is the terrain, taken as grey: as dark as 0, as light as the sky state's lightest backdrop. Ink + // contrast is lowest at one of those two ends, so both are checked. + const encode = (l: number) => (l <= 0.0031308 ? 12.92 * l : 1.055 * l ** (1 / 2.4) - 0.055); + const channels = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + const hexOf = (c: number[]) => + `#${c + .map((v) => + Math.round(v * 255) + .toString(16) + .padStart(2, '0') + ) + .join('')}`; + const bandOver = (fill: string, behind: number) => + hexOf(channels(fill).map((f) => FOOTER_TINT * f + (1 - FOOTER_TINT) * encode(behind))); it.each(fixed)( '%s: the ink clears AA over the darkest and the lightest scene behind it', (state, id) => { const { Other, OtherText } = SKY[id].bubbles; [0, skyBackdrop(state).lightest].forEach((behind) => { - expect(against(OtherText, tinted(Other, behind))).toBeGreaterThanOrEqual(AA); + expect(contrast(OtherText, bandOver(Other, behind))).toBeGreaterThanOrEqual(AA); }); } ); diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index d3e60dca..772f02a2 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -92,57 +92,20 @@ globalStyle(`${scene} ${AuthCardContent}`, { // The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 to 0.04 at dusk), where // the sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's -// ink and link, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. -// The tint (FOOTER_TINT) is checked against the ink in skyTheme.test.ts. +// ink, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. A browser +// without color-mix gets the plate's opaque fill, as the chat bubbles do (message/layout/layout.css.ts). The tint (FOOTER_TINT) is checked against the ink in skyTheme.test.ts. const FADE_Y = 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)'; const FADE_X = 'linear-gradient(to right, transparent, #000 24px, #000 calc(100% - 24px), transparent)'; +const tint = `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${FOOTER_TINT * 100}%, transparent)`; globalStyle(`${scene} ${AuthFooter}`, { paddingTop: config.space.S500, paddingBottom: config.space.S500, - backgroundColor: `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${ - FOOTER_TINT * 100 - }%, transparent)`, + backgroundColor: ['var(--sky-bubble, #0d1a26)', tint], backdropFilter: 'blur(12px)', - WebkitBackdropFilter: 'blur(12px)', maskImage: `${FADE_Y}, ${FADE_X}`, - WebkitMaskImage: `${FADE_Y}, ${FADE_X}`, maskComposite: 'intersect', - WebkitMaskComposite: 'source-in', - color: 'var(--sky-bubble-text, #b7c7d9)', - borderRadius: config.radii.R500, - // A faint inner edge, so a pane this clear still reads as a surface. - borderColor: 'rgba(183, 199, 217, 0.14)', -}); -globalStyle(`${scene} body:not(.gamecult-theme) ${AuthCard}`, { - vars: { '--tc-link': '#99c7ff' }, -}); -globalStyle(`${scene} ${AuthHeader}`, { - backgroundColor: 'transparent', -}); -globalStyle(`${scene} ${AuthCardContent}`, { - paddingTop: config.space.S500, - paddingBottom: config.space.S400, - gap: config.space.S500, -}); - -// The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 to 0.04 at dusk), where -// the sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's -// ink and link, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. -// The tint (FOOTER_TINT) is checked against the ink in skyTheme.test.ts. -globalStyle(`${scene} ${AuthFooter}`, { - paddingTop: config.space.S500, - paddingBottom: config.space.S500, - backgroundColor: `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${ - FOOTER_TINT * 100 - }%, transparent)`, - backdropFilter: 'blur(12px)', - WebkitBackdropFilter: 'blur(12px)', - maskImage: - 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)', - WebkitMaskImage: - 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)', color: 'var(--sky-bubble-text, #b7c7d9)', }); globalStyle(`${scene} ${AuthFooter} a`, { From 271729a5c03c933def22745deb0fe2156a656ea7 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 03:48:36 +0000 Subject: [PATCH 065/111] auth: test the emitted footer rule; wrap its comment; correct the terrain luminance figure footerBand.test.ts reads the CSS vanilla-extract emits for styles.css.ts (so @vanilla-extract/integration becomes a devDependency): one footer rule, the plate fill before the color-mix fill, a mask faded on both axes, no border or radius. skyPlate.ts no longer claims the darkest terrain is 0.0045 (that was Night's whole frame); it states the footer region's minima per state and the measured link contrast. Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 2 ++ package.json | 1 + src/app/features/scene/skyPlate.ts | 8 +++-- src/app/pages/auth/footerBand.test.ts | 48 +++++++++++++++++++++++++++ src/app/pages/auth/styles.css.ts | 7 ++-- 5 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 src/app/pages/auth/footerBand.test.ts diff --git a/package-lock.json b/package-lock.json index 947eef2b..1915ec32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,6 +86,7 @@ "@types/ua-parser-js": "0.7.36", "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", + "@vanilla-extract/integration": "6.5.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", "cz-conventional-changelog": "3.3.0", @@ -5095,6 +5096,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.5.0.tgz", "integrity": "sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==", + "license": "MIT", "dependencies": { "@babel/core": "^7.20.7", "@babel/plugin-syntax-typescript": "^7.20.0", diff --git a/package.json b/package.json index 1aebfd94..69f589c6 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "@types/ua-parser-js": "0.7.36", "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", + "@vanilla-extract/integration": "6.5.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", "cz-conventional-changelog": "3.3.0", diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts index 86429ba5..353134ab 100644 --- a/src/app/features/scene/skyPlate.ts +++ b/src/app/features/scene/skyPlate.ts @@ -11,8 +11,10 @@ export const SKY_PLATE_FALLBACK = { /** * The sign-in footer's tint: how much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind the * footer line. The footer is a blurred, tinted band with soft edges, not a plate: the scene shows through it. The - * footer ink is the plate's, so the tint has to bring the scene behind it to where that ink clears 4.5:1 - * (skyTheme.test.ts). The browser mixes in sRGB, so the test does too (skyTheme.test.ts): over pure black 0.80 gives 4.36 and 0.82 gives 4.55, and - * the darkest terrain drawn is 0.0045, not black; measured on the drawn pixels the footer clears 6.2. + * footer ink is the plate's, so the tint has to bring the scene behind it to where that ink clears 4.5:1. The browser + * mixes in sRGB, so the test does too (skyTheme.test.ts): over pure black 0.80 gives 4.36 and 0.82 gives 4.55. The + * terrain is not black: in the shots at 390x844 (terrain scene, tier low) the darkest pixel in the footer's region is + * 0.0203 at Dusk, 0.0936 at Day (dark ink) and 0.0052 at Night (light ink). Measured on the drawn pixels with the band + * on, the worst link contrast is 5.0 at Dusk, 5.7 at Day and 11.2 at Night. */ export const FOOTER_TINT = 0.82; diff --git a/src/app/pages/auth/footerBand.test.ts b/src/app/pages/auth/footerBand.test.ts new file mode 100644 index 00000000..26078d95 --- /dev/null +++ b/src/app/pages/auth/footerBand.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import zlib from 'zlib'; +import { compile, processVanillaFile } from '@vanilla-extract/integration'; +import { AuthFooter } from './styles.css'; + +// The CSS styles.css.ts emits for the sign-in footer, read back from vanilla-extract's own compile. +const emitted = async () => { + const filePath = `${process.cwd()}/src/app/pages/auth/styles.css.ts`; + const { source } = await compile({ filePath, cwd: process.cwd() }); + const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); + const encoded = (code.match(/source=#([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1]; + return zlib.gunzipSync(Buffer.from(encoded, 'base64')).toString(); +}; +const declarations = (css: string, selector: string) => + [...css.matchAll(/^([^{}\n]+) \{\n([^}]*)\}/gm)] + .filter((m) => m[1] === selector) + .map((m) => + m[2] + .split('\n') + .map((l) => l.trim().replace(/;$/, '')) + .filter(Boolean) + ); + +describe('the sign-in footer band, as emitted', () => { + const over = `:root[data-scene] .${AuthFooter}`; + + it('is one rule over the scene, so no later rule can override its mask', async () => { + expect(declarations(await emitted(), over)).toHaveLength(1); + }); + + it('puts the plate fill before the color-mix tint, so a browser without color-mix keeps the fill', async () => { + const [rule] = declarations(await emitted(), over); + const fills = rule.filter((d) => d.startsWith('background-color:')); + expect(fills).toHaveLength(2); + expect(fills[0]).toBe('background-color: var(--sky-bubble, #0d1a26)'); + expect(fills[1]).toContain('color-mix('); + }); + + it('is a blurred band faded at all four edges, with no border or radius', async () => { + const [rule] = declarations(await emitted(), over); + const mask = rule.find((d) => d.startsWith('mask-image:')) ?? ''; + expect(mask).toContain('to bottom'); + expect(mask).toContain('to right'); + expect(rule).toContain('mask-composite: intersect'); + expect(rule.some((d) => d.startsWith('backdrop-filter: blur('))).toBe(true); + expect(rule.filter((d) => /^(border|border-radius)/.test(d))).toEqual([]); + }); +}); diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 772f02a2..fc47dd79 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -90,10 +90,11 @@ globalStyle(`${scene} ${AuthCardContent}`, { gap: config.space.S500, }); -// The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 to 0.04 at dusk), where -// the sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's +// The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 at dusk), where the +// sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's // ink, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. A browser -// without color-mix gets the plate's opaque fill, as the chat bubbles do (message/layout/layout.css.ts). The tint (FOOTER_TINT) is checked against the ink in skyTheme.test.ts. +// without color-mix gets the plate's opaque fill, as the chat bubbles do (message/layout/layout.css.ts). The tint +// (FOOTER_TINT) is checked against the ink in skyTheme.test.ts, the emitted rule in footerBand.test.ts. const FADE_Y = 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)'; const FADE_X = From b5e93510e86b424f4312838daa9a197679579480 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 03:58:04 +0000 Subject: [PATCH 066/111] auth: footerBand test: identOption for compile; kill blur(0), base-rule border, hard-stop survivors Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/footerBand.test.ts | 36 ++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/app/pages/auth/footerBand.test.ts b/src/app/pages/auth/footerBand.test.ts index 26078d95..c3293c4d 100644 --- a/src/app/pages/auth/footerBand.test.ts +++ b/src/app/pages/auth/footerBand.test.ts @@ -6,7 +6,7 @@ import { AuthFooter } from './styles.css'; // The CSS styles.css.ts emits for the sign-in footer, read back from vanilla-extract's own compile. const emitted = async () => { const filePath = `${process.cwd()}/src/app/pages/auth/styles.css.ts`; - const { source } = await compile({ filePath, cwd: process.cwd() }); + const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); const encoded = (code.match(/source=#([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1]; return zlib.gunzipSync(Buffer.from(encoded, 'base64')).toString(); @@ -36,13 +36,37 @@ describe('the sign-in footer band, as emitted', () => { expect(fills[1]).toContain('color-mix('); }); - it('is a blurred band faded at all four edges, with no border or radius', async () => { + it('blurs what is behind it by a real radius', async () => { + const [rule] = declarations(await emitted(), over); + const blur = rule.find((d) => d.startsWith('backdrop-filter:')) ?? ''; + const radius = Number(blur.match(/^backdrop-filter: blur\((\d+(?:\.\d+)?)px\)$/)?.[1]); + expect(radius).toBeGreaterThan(0); + }); + + it('fades along both axes over a soft ramp, not a hard edge', async () => { const [rule] = declarations(await emitted(), over); const mask = rule.find((d) => d.startsWith('mask-image:')) ?? ''; - expect(mask).toContain('to bottom'); - expect(mask).toContain('to right'); expect(rule).toContain('mask-composite: intersect'); - expect(rule.some((d) => d.startsWith('backdrop-filter: blur('))).toBe(true); - expect(rule.filter((d) => /^(border|border-radius)/.test(d))).toEqual([]); + ['bottom', 'right'].forEach((axis) => { + const ramp = mask.match( + new RegExp( + `linear-gradient\\(to ${axis}, transparent, #000 (\\d+)px, #000 calc\\(100% - (\\d+)px\\), transparent\\)` + ) + ); + expect(ramp).not.toBeNull(); + // Each end ramps from clear to opaque over at least 8px. + expect(Number(ramp?.[1])).toBeGreaterThanOrEqual(8); + expect(Number(ramp?.[2])).toBeGreaterThanOrEqual(8); + }); + }); + + it('has no border or radius, in the scene rule or in the base footer rule', async () => { + const css = await emitted(); + const [rule] = declarations(css, over); + const base = declarations(css, `.${AuthFooter}`); + expect(base).toHaveLength(1); + [rule, base[0]].forEach((r) => { + expect(r.filter((d) => /^(border|outline)/.test(d))).toEqual([]); + }); }); }); From 320aeecc2c076453e72f843c5fd025a4d2c12594 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 04:09:31 +0000 Subject: [PATCH 067/111] auth: footerBand test pins blur to 12px and the footer rule's exact property set Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/footerBand.test.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/app/pages/auth/footerBand.test.ts b/src/app/pages/auth/footerBand.test.ts index c3293c4d..9d196200 100644 --- a/src/app/pages/auth/footerBand.test.ts +++ b/src/app/pages/auth/footerBand.test.ts @@ -36,11 +36,25 @@ describe('the sign-in footer band, as emitted', () => { expect(fills[1]).toContain('color-mix('); }); - it('blurs what is behind it by a real radius', async () => { + it('blurs what is behind it by 12px, the radius of the chat bubbles', async () => { const [rule] = declarations(await emitted(), over); - const blur = rule.find((d) => d.startsWith('backdrop-filter:')) ?? ''; - const radius = Number(blur.match(/^backdrop-filter: blur\((\d+(?:\.\d+)?)px\)$/)?.[1]); - expect(radius).toBeGreaterThan(0); + expect(rule.filter((d) => d.startsWith('backdrop-filter'))).toEqual([ + 'backdrop-filter: blur(12px)', + ]); + }); + + it('declares exactly these properties, so nothing else (a shadow, a prefixed override) rides along', async () => { + const [rule] = declarations(await emitted(), over); + expect(rule.map((d) => d.slice(0, d.indexOf(':'))).sort()).toEqual([ + 'backdrop-filter', + 'background-color', + 'background-color', + 'color', + 'mask-composite', + 'mask-image', + 'padding-bottom', + 'padding-top', + ]); }); it('fades along both axes over a soft ramp, not a hard edge', async () => { @@ -68,5 +82,7 @@ describe('the sign-in footer band, as emitted', () => { [rule, base[0]].forEach((r) => { expect(r.filter((d) => /^(border|outline)/.test(d))).toEqual([]); }); + // The base rule is only the footer's padding: nothing visible (a shadow, a fill) is hidden outside the scene rule. + expect(base[0].map((d) => d.slice(0, d.indexOf(':')))).toEqual(['padding']); }); }); From 5712068902f5c73a0bc5eab63a9f073a6f91f079 Mon Sep 17 00:00:00 2001 From: Eureka Self Date: Tue, 10 Nov 2026 13:18:59 +0000 Subject: [PATCH 068/111] nemesis: Meta's logo, chromeless plates and card, sky-led theme, denser terrain, scene style picker - Logo: Meta's white matte (2026-11-09) as the UI mark; icons are the mark on a night-sky tile. Dark ink on Noonday Sky. - Plates (title bars, lists, composer) and the sign-in card: blur with a light sky tint (PLATE_TINT 0.45), never opaque. - auto sky picks the theme from the resolved sky: day light mode, night dark mode, dusk the dark-ink crossover. - Terrain grid 768/2048 full, 512/1024 low, 256/1024 still. - Temporary Background group (Terrain, Ridges) in the sky menu, also on the sign-in card. Co-Authored-By: Claude Opus 5.5 --- public/favicon.ico | Bin 2338 -> 9308 bytes public/res/android/android-chrome-144x144.png | Bin 3096 -> 9669 bytes public/res/android/android-chrome-192x192.png | Bin 4140 -> 13156 bytes public/res/android/android-chrome-256x256.png | Bin 5684 -> 17958 bytes public/res/android/android-chrome-36x36.png | Bin 817 -> 2038 bytes public/res/android/android-chrome-384x384.png | Bin 9055 -> 27702 bytes public/res/android/android-chrome-48x48.png | Bin 1039 -> 2914 bytes public/res/android/android-chrome-512x512.png | Bin 12620 -> 37581 bytes public/res/android/android-chrome-72x72.png | Bin 1528 -> 4503 bytes public/res/android/android-chrome-96x96.png | Bin 2022 -> 6234 bytes public/res/apple/apple-touch-icon-114x114.png | Bin 2340 -> 7505 bytes public/res/apple/apple-touch-icon-120x120.png | Bin 2472 -> 7923 bytes public/res/apple/apple-touch-icon-144x144.png | Bin 3096 -> 9669 bytes public/res/apple/apple-touch-icon-152x152.png | Bin 3212 -> 10236 bytes public/res/apple/apple-touch-icon-167x167.png | Bin 3555 -> 11346 bytes public/res/apple/apple-touch-icon-180x180.png | Bin 3884 -> 12230 bytes public/res/apple/apple-touch-icon-57x57.png | Bin 1188 -> 3474 bytes public/res/apple/apple-touch-icon-60x60.png | Bin 1261 -> 3629 bytes public/res/apple/apple-touch-icon-72x72.png | Bin 1528 -> 4503 bytes public/res/apple/apple-touch-icon-76x76.png | Bin 1606 -> 4748 bytes public/res/svg/ratatoskr.svg | 16 +----- src/app/features/scene/BackgroundScene.tsx | 9 +++- src/app/features/scene/SkyStateControl.tsx | 51 +++++++++++++++++- src/app/features/scene/sceneStyle.ts | 28 ++++++++++ src/app/features/scene/skyPlate.css.ts | 6 ++- src/app/features/scene/skyPlate.ts | 8 +++ src/app/features/scene/skyTheme.ts | 4 +- .../features/scene/terrain/terrainWorld.ts | 6 +-- src/app/pages/auth/AuthLayout.tsx | 2 + src/app/pages/auth/styles.css.ts | 29 +++++++--- 30 files changed, 127 insertions(+), 32 deletions(-) create mode 100644 src/app/features/scene/sceneStyle.ts diff --git a/public/favicon.ico b/public/favicon.ico index cfce994377be2daa71cd95204142fc269980eb34..bda9855c959e04e31aef615055c68fa7df9e1805 100644 GIT binary patch literal 9308 zcmaiYV{{!*v-SxZnU%gRWo{oDUz5e*LJ-%4`#X#xO1j>}4jYIx?@CmUE1YGQ!{ zj&lSAvSruoIvr%&Mw*w~(hzzpDhjMb(Iix`B+c@%Uf0C(<*>0r$&2QL$;q|Q{3;#` zqqdb>qQbbPpp&^<9lYlX|khk}8y4~lmd`58PBc_4sd5wNsLEnqCE%vOPW^sU+0C+T zq+GD3aVy!CyT0KYEk{K*{nt)4XofL>BgNV*ygp;h4Ygiu;=F02`9;dm&sjixsQAL?6*KPb1~JwZWj}cmH{G ztfHsahU&qagd88jc7@aPqR^+~eVOSX6x;zF4%AJXYp?sYuiQ5b4v(K*Ss$8XF|;h~ z8Kpqo8i~6^1xI}3**iZ^kc{C99wlWsmD^CJBGmVIMHoqc8Y>1+c#{bKfZ?*ZnXBz? z{3I==q2}dw2?pKDHrAzZ)(+EGR;VhfhlPP}OB)+bR#|7w9sJ4U&Z-}VlX~t3v=tO* z8aqKzjOXOM+=!|n;e7L)r4kid>dZ=0F8Afz?Is2J^6DJEAD65v_2)FQ!P=sWpb*!f z$|mT-QH6^XfUuXb5D~D_}``wuhXB) zhm)L_4+Kd6sY(AA z0wh6otL@{IvV{Ga>vP?fZJwMEo24R~4viMXtw02JPf;Rjm^8m&O-aK( zcbm12Zi%z5Wr|b(VOL)Erd|<`p3`f|Uz-+U+OJe{sEJntC#g!0fFs736`lY-0(Grk zZ%y-UJ2cAMcUpG{@N{%-_Z&Y7#QRLYz_IV)TRl{trB8=(O_8=iPA@^hL7)J30|+oJ z7f8=SigQ&mX<+%Ea;Hqx9MEe&FPH-TgClHq83{?;8Fw&{$Zudvc5==k-W(r2uC~U1 zi`93Uyx6D}YUz+nzX(O73pfEP`pKXXMwMGpJ-kS?t43*9xc z6}GaNHSD=9X53%@BcWoMT5~XMDU=2TYB1O;^!64h45N6|{0*LrY8Q<>5krVt;Ou6J``TI}4ZM7glcr1>Rlrc-76%=I{*y)&v;r7yT zSd}DOw*bQS>Gjg*b~C&Uj`Sxol@x2hm9(m>Bv!f7C+6|iHRcnW^^_U|&}W9-GE<}LNPvD#rj%PE!GuZO)OKkh@@A))kxJ_&2G8j|x+v6G zbwbDsEk*3^roi=DEU`1T(}%4w6h$meNlb_v4NU~B#Z*-xcsVqWnSNmh3doCyT0fe= zxSm^@_}$?{z=ePH%nMqEXi%fngAONycm{nzh6@$U;av^A{@N}1c&9Vx)-yahjr$R` zg$k1>6JyHAhmxyF8g9$$;&tldaQ-#du%cp7%qm9JYYg_w*J?Z{0`&yi8(ZIK;?$otQ|;|42AM)W)?7 zi&`UDQzyj;{^-4oo?s0yBwBMP?Ef{juA*v4FV{%mn(+ntH{&E-D!%mgi|EGd&cdu* zVQZP5!qlyE-7D1BzH_vz9^2H50Jnwf+3u@5E5kMywSu{dx~hbZk5^$Zrm!E%VIqdk zHW}fgiXKK7HV;gDSR-=-sf3(7=(wNF*Vm*+r?{kqQbt7L#;^Arrk9d$=>zb0DzWe)@<9Ugr zi_LL5_2lAAlGYp&LITc<*rB@+{rX2fxUkr9ACy+VKRo8)e9)qP^4kyC_4uwhIT;!G zsV&X$QzB(5AnB+HL3uHqZ^eHE+m1Bp92_(lOlgEZjDd}9;rY&@TzE(`V>(8IYjRqS zTijJn!eLuXzI*hIWZ)LlQdJ8l?7<%AZD_&SP)T(lb?0kcbBW0#W_EPnM5-$OGK6O8 zA&^LM?|zk7e}PvTnw1yRqQRV=;xN+L{;C_nWfFQ?azP=>d>%n$W-4fhJ|1yG#9>h1Zg{NL zZ!zc@CK~I$S7UEeWW0T2r3$q(8~m`>*Fc@Is?lvj4>$6IO*hh_Tj4xzJmqXG(}Cm* z$~)fu@X^}!Vd@(xEWX|uU#l>eb=DLu9dd)44dBAF%?IKQC7t6K8(U$m3!v;%v@8K< zoKUk@Cb5u-jqy9YB?%8I+~X+Auh)DS*LnUdP0=de2?GL zIVp3vLR+nbHSm;!>Bc}bJGF{#{}8b81DA&dR;h2E2J+ zHP#=0&KSeVK`qLbc>JwQq%qoDap4YsVF5SHKc(h9zyBXO^FKtB^?!4wHsnh<0DzG4 z-<&z6hv=lSg!eJs@pN_6Aqy9DGb;ietwORyjv79oM=w-WVP8Y5CM6AfZdFb$G?1o- zW}B%ecb<$_A~Ya9993q_N$yBTs!~J>9|8^xvP;@jZ1lP2%l+*0NoO1*2SUDO3S__X zAHRNnzLGp`FeFe?!8D8(e*TG)LREMAB^R5B-wB-6y+;<4lehh1g&f$bUIie94oR4m8wtTG1yy)=J6|_m)}}@<>=2J$ za(KsJWw3-KR%^rr3ZQ*W8n#BSEhr=lAuE-13YIeyYv{ZXL~XkH7`x^;^OiX`u%c>N z=7v_tku5SoBvXK^EHLFEt}<-RXh?>Ym|k6ZU{ml(1~e96TQeH0<%p6)vx$ky6b2=j zW#cYi`}bwl)F@8mF_os)=4K$1O${SkX2F#;G9fd3i}-B^;E7~@ukX>YsR>yQdFFI} z50MmwtA!5BR-3q0!w=I^rJyw$S^cVl6tW2i8QFx{-YWbDT&p4Lx7ZwdhTVE6g+q z`$QhWyri90s5s05KSCm_u=&@6wo#ZfmA2{0j_Bw$hD_)F>@G}7Kr6AUkvH!7$G6Dt z4MtvBz!xO4SzuIuE72p4S1cBFJlhw{`#X)VVfbixMNa*(mG$$1*7$JC;j^DPThDjR zkwC6LNfhOg^tWyd-$ukaZOq1F(r;$GN=K*0g7;M>gG95_BELDj!4D>W_iZVL(d>HM zH?~K!u8BdVR{z!kawA0I?3gX%x5lfeMl~63Ky27q1tbIh+g zfz_iWX72pwt~7_eAX%+MNEmf_5m$Bb>b_X-T%(na?m-TOv4+L>;{F&wWAMG|Mc6hu~>*q3Wk`HzdvZ!hL;TCNaiyJos-535z zpF?r1gX5jZv3<6~fmes(Q>gc)&^eE#!*=V z?GhfH*tJ-0^gvM@(xeQSZRu|@G=;o{Mng^uB)6+g^cEN^3^~h5(ip)_NV;>Z$p7Gf zWc+yV!&>mUTJtqlcFXW?gu6zk37aIfe!m^ab`MF&s}p{f1vM)35$&iK) z?(=K<=M)J#CR(w*pXgSN|?A*fs+WRxXe2}AThDet2#8JK#*=vbAc>M&jwEWy>(zvQHxt?<_ z(qaV$Q&FBk>}FUmBI3t$YtUW$crtG>){4Ymz35w=0K3!yJx%N&dmm&&{RU`d1!7#~ zCJAJyYySqw{Q9jZntyg*uX}?8MUUye_%xsEX3P+b{n5;JUb)U2*z#*}a4v4P6M%ze z*X4_!2A80n9^dI!)bWkt{Iy$3r!{Cq=<#-woOIXgNKhk0Yr;OhjQv)HVTSfB_-eyu zaWvt1`6A7Fl(mbL6P(n(!F?5YSlzbsxcznNw57pE$dbkpzrLZx!)Tp!+1Mdjlc8;; zfeXlMpv=U-h2U}4Y^*w%h=TfOn^ZDQ;{tL&{2R+uQ$WEyd~(+L*r6>WPA!PG`oY; z;nf?JCrl`ROgs^SRtCOZ9usxuwq@^f5kk8HM`d@l;f0{&#ZNzVy+oFb*G!eV<9X2E z_vEz0(M*2~dt(6Re5ht4w=Gs_*7A8LsQopF@8Baq z?=1U5Z!MmmyKPCZ^M~kEuiiz)R{VJ#KE{(NjEgHfCTG#2p8g_H+M&?BXVQ2Cn4(!B z@egJByr;btm%;pS3A9(V+WtR2))D-4l9q18qqp)!u9#s(v#649JaRD3qm?Kz1X-%tp{B1gvCDh z@|=Z8Af&aZKh%AEXAUeu*CIsVly9(-;h$?$Vl?Df#3_=7IyWTlvL;W=y!tB<%wDU6 zJ7shoB%Pn2^al5#l~H8#Mm3_`r;Hyv5}{$F)x9_Mr6*Y=I~z&}XwRr=b>I{lqr=`v ztWxSLbCu%|Gt!p!bK&`fo0koWAV!Z|@=W^ojf&E{eN@_W9b; z*g{r+zX|Ldev;f<`lFssq%Q`pv?Q)}lrvP*Sg7p~>+`0?j;bDC-L8R?0e6`H0-_m~ z`^-M?4*Qa%RPc9Exz5*-+_9{$#Q`}T>uJX9#)(S?5SJNy*`&F)%^)n^ALO_D%N)MBTmtP*b9%lBNeCegKB3Xlot!2?o8UjCX zvF<@&^@|u>Em3&GgyJE`ij6VI+Cm1|KlQYT7m8=#+QJwNVy3=mUZf&jpr*6n!4H$H zZi`}9uWA`xXp?R1A&&`3WyN&YRdZ>2b2(k4x3bJhDN75Jlv}OCu$+dm^6sIBr!b+K z49OkSQ)`^nX+HUWTr{T+1`hHV$#JYia;b{&quq;L-uv1Vyr@IL7z4HVQyPE9#VP&P z;eHoHbtPZJkc;_i7O=qnU8I^Igp;wB@WXu24^CKz} znpPj3({W0PT4kh$fj=xiC|G*8uj^ZX)4Oql@gdAblh|C|xzLHz^Skud*R*L1a?<2U{B@%}#JS|<{|mnsqmBa$6K zlEJ1bVhQO@7$^!zI`Oz?F6_{PqJ1O=rOJ&n?@OO*oDkI zs_{)rrL?h){7S(t7z4SWbeFwZ%Mu`1-03E6XiEk6>hv0ZAc}X2U#DOABV?H#0Kwtb zDlCo6>$(gFN2bUmksK)8RCTc63)=+8|8?m&jqYcv7z8OsAybjS?J+)vy(kc-z_w(# zVtnr*9KNgB!@IO}cwHK@aQsi(?1vt9 zY#^t8StBIJbc*krPtdI)$BXAcehI6T6}HL>@xyvFHI7Va?O@eMu~hWI0IEWQ*dgru zQGRxHmqsBge5pkzU1%P430?CaBOI`0wth4*9r?n;#nTqK9=tWan(l%E8LL)B4(NBD|X5mb`~1<_ZTh+wg@d5I2!VM1-LLQNHqjb zPVQn52(QeVluQW5PdyoJ_qKuk>};mEsw^%(@}?af6MJBsOtP6=4wvjJq5GJZU=J#L zC>lJjFtHOJ%rp;YVaICtdiX}f7^c*&<1b|CwOXizH5z#41>=N=V|*3w1IkuAmcek_ zw!I0#a5U`GAn;|dMdh>-YAqMiknxuV%QYG2^d=Z|Ncg?Z^|I4wgC9k{KNN|so|lck z-VG0VI-Pl-6JgI?da}o2e|DiG2K4-EkS&pNb0a`RH(t80%;7VqM`o>gKfmK_I3%x1 zB9SMbDLAK-?{zj`KPf=qcjLP`7v0T#FeBat2ysB?oJ=nP8BWARn!Ty1OaXC(wf&R% zy)lBXs1)4P<1ko+;`_CMp`ttn@qF=$L%sEKs`@U3G)kJgPGinF0^1So?N%~$UTi69 z+#JlB5byJi-ke!9Wmr&tW^?*)H?Npt{nqTYf&tols5*AU<@C0!eCJ+-!d@`w#M;rW zSRw{~P_HSI*PF2c{-@4JJ(p6?Zx9!sRalO_O)vpEP4Gf}`PjJFcmvdu3Y}PYXzvaI z73(G_&A+~rq%r4(zm~^Hk{YuS`5<(+%<2@meH{u-j-vWYF>pgEC4d6Jmx56aCmD$98(34eufn z6REa61D%kNfYqDIZzaa0`XbS?Y2Ac)T_L#bnM?yjTdyWry(aqks53{;> z-UDa_bR+Lep$aW{p-m`GWkNFl#5x@R3{lh=){TnQJ;cV8KtTsuniwzm9iz45Z*&`5J zzJ0>!r;Z8odL>q9|KzLDdt}()xKgq*4MYQS*w8xGTI?T*5q{N}o=%KYA2d;TGIi%t zo#$1xv-#!Wblfm-;T+jYHd9BdB@eS9WIz@rK-MJw1$x`ejC|Ln3t-O>f+bR8>+rZg zCVg%BuZYP)G??j4V^W;*_igqkIGQ#JUTZq$D90E5T-D5@3d!9{s-5dN z;TZ6smQV^WX}^aakIy|SpXylGmZPbpw?x)kPd1M3x+-x}m)7&w6>?YzhO^@q-{_bf5vdtxm;(HVV z=0*;zu*bUM+V^n`i5i8$UaDUW9}S7MYTpUNqvCHp!qE>pNHSb_aJ~v-!b?n`r1ZB0 zFY{{n680VeF!oG!@Rm{^+8zG@yI%u+cfC_jW@pFNOkTVE z-84+=O%FHn+-{tzMnulYeF0=S^^=I?A9(7g%e6t|+-sQn3}}104>&sQ?4V0Xw7-x$ zgXRiBv9UsoFPqZ8J#OH(0(>AAx_dAP??_*N_~=*E35E;N-Rj(IP#o~!eK;CxpBN1}HUAMg%+#q3_B1(4Faoahk7+u$Z zleadvRP1NTe@FlOBoBZ1ajDC3kVYVuZ!P=+e_dpu(@GwHjse!NBFU8Zwc|u`i1lzd z`ChIXNG~`fB3_L8@DLjjbJOrH_&$iClXi44GIQ#9b6=|@o?Jkfr?s=+o$Kj8Jw(Va zo@$V=%);mkE0j;-*!!2$pVhv*mQYIr97+d)2#uwn?(&`YHFKrGd(-{9HD&2R=Ow7* z_yy9GihWD_xrfMkjjQ?LK81DCaK!*BkeTL#L zZDII;FTR#D+i?>r-y-d@A5vNdT~b;fTF$(C)ND2+b4Uk}0;0RTF2DDil%9L*xu-(Q zS{JP1_OEs4`m#4#+c-2pQ4n0wMNqBJozQ^F*SJv&%>Dte_A<42DA{Ri#5{*12qjPYSkX(bH}lj|UMLVjN) z>~h<=+1X(naXeaVrBEN#V$fLl@UV{1XXzrjAUA}S6T`W$ILCLv2YP?5Yp@IfJD{Aw zc9pLcMfEbYU(M$4Wg!m;lM2vWr7ZLb^WiA$^pqX%9aNks3$dk6C2}hw?y3Pq4 zPe`!e$OJ^M4-9l@w@_#bg*1cng#`8nb64)tk_vt@kR&>B$no%6;z8Tc2bF_3tG`1C z6iz>Rw&wkIkSJ@v_jfTSv7JQ#)(0i!CaR6A4UvF(Tf$t~fnl!4Bx)(;UZrS&fmA>^ zkC0@p(=k6pMXvh?*dHrhlT^Ugmv%Zi;AJ|33@Hywu3GWC`2IC1hi_axuRkUaTJFk%g~ebtO&=_O<)mACg3%(;J7WIX+~A|$Ff6vNRgW7 zaJPQzqLv+w%CJ*XVtrJ+$xdMJz*}!=je`&7h!%{d`#J)OOCRU~w2Ov^qKB?paq0I* z1Nq+vbFhw6SE?aFT#C@vB(4~}b+zz%7a~HMGB1baOPX?9^TZnS6~m9b>%KVq@bp z?V#%KNb0!YFq!#;a}4yoqmE(N9t7;(|6#e7A|j4@Pbr?sy5slo`%28?ks;Qfnmd`$ z^jdF0aP#ac?@7Wo&|!dgHHEZJbZd+usi>s)vf|$a;|xlysNw_f#$V(Hs)4vZQxA1| zK9nQkWA|>xorPoKt`Q`HaL8z+UZCc3yge7|Deg#b1Q0(_XL{~Y1vBl zHY6$W@@VOMK9*-*JQ%&LVCw@_i<^JQ`m_)$u@7Z^mmBB_5eVCgFtLULdHX@QW2chy z(Q%vKX@8q^$^vM*d<2TE)xN*Hb_M)~OwZP{(OzCK;=)9`r_Gwbc&Y4ZITqn7qM&&G z*i3zT(jJ0G=<1NGt-mxP6nlT6E};g4R<4;$)dqhB@yE!{UinF@xHn^?EwTg6u}7vo|Wt3sQ`xRK27!!kua2r~V8xl{6ObK!m;MaS8H&m74w z4C}Rz4gely-v|^J1ci1Sn?v3MnrxMtwbD+bI3Xdn%Zhx>idGOu3Aa*<71AwR)wE(c zH=(y0^Yc&xqfIV-*e z$k-Ak%yW{NC%3+3hUPO*daxoM7o09^^Jvw*1^rk#z@-swPe-IvXADTbgLczlA8A0` zl>O4Y8^zkJW?A6J(Yw*WIx`UU=oQ&yrqSsU;nI}`oLA7M2UxuP_Gv@}y)1s?fayJz z;YoZ^W(*@x%WBXdHTwIejD2uSXoE~l1mRpEnlG*7WdHyG delta 2245 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 diff --git a/public/res/android/android-chrome-144x144.png b/public/res/android/android-chrome-144x144.png index d76ed356c9aeb674b40950163dbd422d61b84ade..2dfde532d8801012b8a38a176b80aef5cc5239e7 100644 GIT binary patch literal 9669 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 Hu0mjfyJ31P literal 3096 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 Hu0mjfJf7~C delta 795 zcmV+$1LXYn53vT2BYy(;NklZL|2h`x@@(JZ^0 zliOyieaOR)-7_c4`7$$S&YX(MWmT}0RPnz7%0OkHB@MJ%rGEhR3I%m8n|)D%HW-u; zbuk%sucMks79eJ&0y^qcaNMZ|w^fxF==lLHgqzekZIXf3>6I|vrG=@ZI!;eKT4JaD z9=(8zqwpQW^r-#MnC7{nfY38y7{1VJG@y8=9wz)OOb0CR)N6*x9uu)4R7pS>B0j*h z-vG&zHu6l{#DAXhH)!)8 zy@@oyx6ugvN{qnIi5Lu@Y6H7LVp5BNK3{C60-+t}qdOVcRi_mIeY~-cz@#-kauqDB zaq&Wy)|5{VuY1iI;X)7nczK%xir?#(zU@ubikwN!*SoMi>&`-bxY7!@yqm}_EU6Ch zi(=xKk$(y^Kd>z)464~&ScT3DbbbdztwRFuL>yF)EId93Ut&FEd}QM)d4TXB%~^{E zcT-{biJZD0Drl=01XM|>)?cG;4k&fql>vmF;RB&2!%q2tu)gqtuzs5-ZrK=K5&VnJpUVv@-(RKLx7%dmqEw1;0hb1|MyO-p6DGl2c`%GEk}d Z4HWs9wKCfirC)VK(#! z1kv_+V~JCu7vNcJJi3m6Y9XdoFybt}m?%Y?d3GkF(Z7RaTW%cA;8zT6S`hmDr|-$k z+iDpNA#7WxJEOrLa73_~-IF!RrJ^_=`_9UrPfUuD6h{)xNf=-SNW>{e!qMXO^ShVk z)aJ8MI(ka2r#i&I$7U+2RJa(&<_JU6;W%5$0Y-u7>g-@3+SA2YA zAB9{{txq&S z)Vkvc(1#p*u`EIB+XJ=g{n%2-#Gi;?U#$h+E4#7czrvdFKqU1l$tz*#VUXY7!%OhY zP>aJFaMFXpQ92s1_b_A}THuj3J#N8N)Wl%wag5&b^Fwye@w#7gx?GOvmCEOR@O{NZ zfOlxB%}+*`#x%drcwg@|AlYr$^cqDzeu51C$}!4e5xLDdd=tMr8z09$hijlHKWrFE zWzdv4m5B|keVp6?V!4>i@mna=KShZ*^c~mNhoZb)-92B#$qRc+nyj4zvv`#!c!}ml zmN^}^6s|CrL09;`N&4+UoR6-c=`c71&e5?oU3iHCyGm;{{L{_DCBxm{MiV z_s{SW@1kkvl6R)6@ECt;g>CBhxJ>YOuD^E$*-T**f#P+428@ywHG{G06>H`lnV5Q2 zv88F2QNVaKLf@aMtsBjjA1kZ>t?{~oR)T(rHO(IJyCn36U7^>~RH?XRP$AQ#yaa2r z{^i3gWhNMeBOvX0(xq8K1mo5WGPIp%N~Pk?hQ&1i$EtxFkJ3?z9Qv{GQv>`#C>NaC ze}*&rrnNRyE1K@YPD1d~1*BPC zg{@_`jr%9+{{C(V=zx<0>A1YJ-f=M@SMnptF|AI6FL)7uU!7# z^w_$Pwis|~Oy$_S=#h6b!0nqrQ7CB}v9o7TOY@$2L3$*gDq0H_3BI632M2}yhjwA} zK)gw@94Mm95=~eI9((rA`cCxaE~~+s*utc?1O<_H%UCf$6keiaVF(llZ5warY+e#A zyJ52=?e%Nw{s&t_PJ@X6(hpGai*#{`TSA>n{f`>CD6BLbOf-d?<=5&zm}={ROCSJC~A3faBcI9(@1k84+8=KFkIM!8}JC0uE?l)7Q1 zCL?UUuPL{0Zu0QYCqwpl^@(`tKC@yaED3CKL2md2WPS%ex@kd$hfy8J$I`CazX*DU zBtiX~+wa+BZHZ%oxkv*5r@#g^CvuuM+0q@1@wvlxDaQw$a}7pZD7D1*+J3!cKn|_o z=Qdkh;2|a|4!=;EJ`}NPw|u^m`6>%aznnedvPx|w-hMyQ)XZaB1vtSc%Edt?u%x~A z49(w_1vbrtW~+C728;LBH^Lr_i?Y;kjm=*_Kcj?FkwD0m;?_Rzxxx#jN zbOg{(#G6=KbJ1;C$EUpA+s0q$qYWLM1#2ABWuRBI%e&tM3?WIjY!HJh?(@%^6-Y%n9!Q;;oM z)g`RGVL46oTU#wL&ui$kMVcNH10vC$*bE|RKrOzwKt!Vp4TKMteZF7)?`C^zO@q0p z$Wkbfe*9#s+;}?T)TNe&o;WC!3c0x^t0JBLV8rFewJ0fEn%I<6OaN@n1GyMPTc?*h z%JuL&C}%E9{U>14B7%v*cw3MJf~V3$hg15HgR`GB2>l_~Cu1ROV=Yl0y$C5)tGPc8 z_+}cSSBp+O3R0L5_2$<3Hm~)=yV%${OiZd-@%eX0Cp?(iWlTeZee!`W*6l_H8bI#bYK>-QbRU5vCt=AA+4VV$gB^_y7 zdhhksiyp{H$>QSg9q}f3M$^A!J{w!yA%PRzaKm{2`wzY6y9kyI2B&NkaC*_w%&7L$ zf_@Y}K2mA|dc)GC*~BnIsJ+%(V;Jjb29ZbC!1`07)r0HNEM;FcI$pDhNbwL(W??u% z7nnj~>o}OTh!)@HNK&5wAOAQO%30tcGj;~vF}&2*&n4itk;DOR;}iRf%JIAJwb3e2z<%RI2*~8bT^5_Exqfl5B)8~G zGxA^FLloFg1l9h3g?WtPioiR1?jP_4#eCiBI3PG4-|O}jVt1`s3nlaggCjyCDqzbJ z&FryUGtKENIfczBhOPei@mYS|`N&lHa7sw0ubS=klvOQS{|o=Gs6Se?j8?EP)?Ug% zHT?$SaFGG^`_!2o%J#KK0q+2-s5CE;C~fM@wpyGsQDKT(l0Rn;qL&fb)xz6~0(W|6 z&kN4tVppDyOsjiw)t?&SCxiL3?*ih??22^D?&tZI{!$*t-f>vOvdUie{m+IKSjj|l zdE}f8)=WI_@Yi+|z7l%4PAc^608H?~2Xn6bGrYV~!IJuI*i*-GJrW1kZuvv)Tp+Dy zI^UDWULrTsa>lNfih<> zP3MT?Z<^`DiF2Vq?$>A_|J8UY(llsw@V=gn3`7=mUFnYuf}pn@ zalqS;g!3O}grQIG?B}f7!h8TMB^LviX>98)c;FxIal!Dvi$B_6jPwaon%hjjZ`XdJ zLz*j80qO97p?DsKpcPRs#ey>z3HxKGPeHiwDfiO_W@=fM;NUZ&fGj3xepVqx5s7L8 zow%BU6cYjyU5VCG5X5I*!{YT%p%V&C)9c(OWnat-3P(4G`igtI`X8Y67wyAw~+#`j$U z6{c9%N52VP9?8OCUi=EYlGqQIc#*yz*^W(p4BK?HUzpWVpar8A*3_Wx6Z~Fjw-n}c zV(ZVkF>LUiCahW4izlbEthihTfygk0$vjEJvF8uP)%Rnl)+V3B9`p}j4r+IDu;ST6 zsj-r^%KfQuxp0sp&9mr@*cp5&ei2Jyp2qD)VfF1sk;jL;_!apr8?87X-Wn6e=FNCO z&d6u@ zQPfJ!y?n&}f`d@c9+e}fY6xpVphFCttKG8%NIdB**ZFx~ipz6KUEr*O`p`%105QHh z*7Kp>o4WmUmbgQOI#VM0(Vl*(W;?br;_71m&rv3qHSzN+=z}8>V9I74wOB{xP#bNc zIZGjPY z8OHxa)7|TZX2){$JP>5JruPBfbn2xAI3ZYu(n#bY53;_8Yc0Cy z-6UzH`>L0*38g_>2FjSpE7ifwZ?Yqr3|z(AYJnoFG2N{z_O+Ktm-E(Qd%wMUhKfq< zcU*;lKdQ8n8R}3QruD@%fCrXH6-AmlC#ngAW@Ukd_O13p-Bbozbn_CZqiz$RhD_QD zBrn~bv`h%p@E|H|sA=t1Q%yZW?63K8M6~cEol~M9%lg>6> zT^@$ZcfEQ=;9efxtAo~vx}-=1CvgtFrGe0iIWT>JuY*3Z-220nly@Gx^WnftV1mZy zf5X+PA5}}}lo9Eg_zS&0KV8jNNBthBO=~P66v+KT>Ix6oVK^+QCxmJ7??Cq7Nb_H^ zaPV)pUir|wL-zX>1llQbyK;U?$S|9u?%uP_`^+x*5@A)S(^6mv5%^QrgfdzK|gO{+l>+{ zu3<`jvtmw7TksX1 zXRN>ir+;YYuL@M|Z$|~ArIl955t|p}{h-eMyLU(~|Dg%VoNYb2E5Bz=%oGpmc<1^IfHaSrZFKx zZ-&yW_hsjRIL_sC8akAiYk5)j^IHddpP-yi1lD=4s~Ee$fi$Qua(eypc#7`ca!G^L zpab!kpRjo1AZm#ua;JV39UN&ch~Ri6a^#EVf9)8An+i*VG`F`fRl2VY=KX}7_xO|m z=JluI0o7`05#`%pC}|FG!t2znhbq2asQFt_>F0th&@~jmks%yn-srTLlxpWt%9u>O zZbueE4#PMZ#bvYKYiuj^KEeu$Eb7{)asp%_TFgJzCIq9X;RUQY(P3tMKD>N9dgls~ zc5}1Mq5YI~fh5^}JJED`=LZeuNkS`24*8ZJ2}Pciu7zCEz~t;kB71HlrICIldUXNgO6Aw+6_}J+k3KgQ#UyaTRx672PbPRB&2;p zb~-DB9DcD`({j>}zzS;LW9@pa>r6iLkW!Etp^RCiw^N9j`S~5p*DOXK$Tuy7!Em$@ zu|T2{MGjPT(T5^zzU&YSS#rdYn&olu_W447#}kcrGv^d>+_{aK!k`uRTPz7cYw$Qd zuhlux6SeL6ifg)1F{4CG7~V3y9s~2N<;d&>j zkjKPOxhWLAZ8D`9QOTZs`}BkDgc1-egoBZ{a^*QnoI1kVTd96Rt**_j$Tjn&nuIEp zBB<8+fvl-IfQdz_>|vdMlDWI3z#V3mPpqW>7)5)H?%i~k*UAF%%xCYYm<{1XnaRlu z2IjWl+fwK*cy`#-_JhMlAp>9ck1xkF#QL#e<@S)W4-4~cZ0msQZOmApSq%eH{h%HG)7E{n66r3`M*+0E(9F8_4N-gU2 zNig*9WWvzrHVd4yCrZ`+dxk9u7kT0?3f&DR7pu~h8kpw8zV8cjJl^SJm^+e>HY8U& z%n4?U@kCG`ZuV|xbOOJW9GP9gE8U$O#dDwM>>nzHL$7?_DKSy^dxoKNgxoizu z3P2vTBoGCM4m!5=TY)*zP2?Vb+J41o_}JV*53*%OC*B|;$_apdgAnK^+f5PMqbp>g zBUv}=eVM%Bf3F@I0_2Eey;46dF5xnJnbUPZgtL)Y$&nAwX7&~G2CqE;KAL^mI zA>g>J9($ABYVkX3TCc@Z(P*G>LN^TfhBOWCt}{H2=18a8KQy_~p5D?+3uKleU@>D8 z*8`5+1kxdT*B_`_jf=~_(%S!ao2wjnA%D^lv zC-=zWo2Z`xp6NP!8{CPb*&G`vN$aaSy>Xeizkdh5)l_Wc!mc6`00|&R(x@ruN8*yu zFdvQxMosPqFGD1=MiDY5VUpHk;;(tyjXYP-i#O3OxCTwyv^zTJgJ*jm2l5+ zpp*|{W-P)m(Ty>-0P(GYSzrz^)rnwX7scQZ3m}yjE-}Ez{%!MU`4=}QX!ylh@Z?K+ z93Og!v8A@a)3wFLZJgKOQL~gxrw2*((c&+O_W=9y>GX6k=#8Xf!*N3dObLDPX3q25 z$Ez{1uWU!{w_WC5`-EArXb!RAmOP~k7f+i_vPdM0Z!_&8jikPnc=)u)NgWo4Rn}YoyoynJv9;Bl>FQ1EyyuTcA*@fs8#`-=%i){Pmb< zT}4+I-Hlo{eN{EWkikag@%=e+!b@5Dr(VU)LhkHo01dymTs|x9~t$1KD?93fykl`&6OHvY--z?zsUgwP*e1nsh>L=lmz&sDvBSGT-!PVlzxKk*xP!ktnp@wOpLIaOX$hiLO zc=soN+^js?66yT3R)^MLzUV1H=6Q_2iFlX2kdCKU|Cv{(W-Yu)RTz;k8^_c)zXIVp zbN(QQ8keiV#~uYhG_h~c0sxfRe-q`Vb<~LxoZ%~oaIOU^@ZNG8P+()etWVZ^4)SG| zp+pU0jzB^XjoHlaIY|QVfdHfPPJ1kNqda8$ry@Fw-m1poc8Mh zE`d*D{Wn_&a50>p>P(p;IC{P(E^ob%ELyWgaKZzD-I7pL8r$~=LE+yN+u$uz{DLr># z*S$Qku*m%|%$|_k10Wlnk=afGUT6)^UVOwlE~oyh%RFzdbuLsM;Nu zX3zHx&*mqM^XLy4=Miko?86N~G^o!_;O7#*JtkP18>AP&8Owfh1zp;)nzUlFXLL`K z2WybnQV^xrt%d8m#X0}2lp7^lX+8)5+wbPI*%18beIf3;f3#*~X9`<93SK{rSLHNB zusPOm1agC;eeue(O!eiFuGrvNz4--oxB1Et%rxbYi61Mo@ShzDfvaD|0K;0`!3t*- zS;xIi`f5hjuLhqab|FXhi>+;wQmE)Ofee%ru*P-jOPN(B>8T@BHp?^+p_??gv}b&} zhDz*b%3M`-Ibff^TYjWifNzF;Jmj>0tX?N27banA&(CcE3$RRegR?<$tgiTcE7rs_ z3N_`}Ig2Oq4zKC{0+?=WbeTRd65W5vcuC~3 zKXRLJFtbl}!g-ZrYLqw#*-gT(>ij`0I$uJuyNqv%M?3r)xfkCOhxyE}4Cn3BjAGM# z$BK#?i3Bm^5paB_JHi2W=peQZ%nBv)r-L5~WB^j{fnLk!`216!KC8e}AI0OMYq4xI z!k{ws`gI}pDGN|j_M&F0uK?b2swzaNRsOJa!0+20a%^LjAK zb2wSIm{+%uQUO^dKkx=^>vhNQjW__#5$eV{vv*oZ(S{d>bUVVEksG4OL9$8nZLDFo zqOl5*A?HT-WJMp)UkaAy{%+Zm&u<(Zj&tl_?fsO4hMpO_9bP9TvSCn%_i@o;4YzI2 zwjvJC1q!PuF{S)yi`Z}8CSm{kM`Q$yn#SAzObJMoerVr< zG85Jv$3_84uU{wf(-T)j-$wB|Q(A!5&jgYUPWGNYL9(M0bK1OzYQT+g^#NYe`2(+0K?QzLM=h0Vy3F602F$ z5XHIxFtGliN}#weG(=edoEc!p&9a#C)3ed1()T{VoXFj%wG3?FZCGaBHy5h zDW83~nJfaf)CKaU^eF6gis1B|1{CjbtYgMt3|MkNg{j%$kVX_x3c8_a`+^R|5{h&Vi@_*cKHXL6D&h zW&wmsKn|#QVn!~69UjxR*E+OCa00=HF1^rxBz@lxD88I(3jL|L2KFmo_yEmfw|@OC zRQ6WX!|+@~+gA~MXBzBI3H`Zzgt}}UzsArb&0V+n2l#mn0!j+OsXRSIbx7tL45%CP zp96*vUB3b@wSZ@u&Yr4nZURuFC^RNgHGd>jiU1l#&`$O3ASa`-B4&Ync~B$}54y&# zLavRAkcCco^)|Eu#RCU0qU!ytYrmkss8}M24YZ zQ}>NuhlF3yw6vCXVnW6CE(RY4|UNu`M`CS zgdHkpuRrc|U)qS?OAQWu_Mvv{xs!2V*^0P!SJiq9JyXttv)QA z-R%WOXUCrF_J&M9s5^6Go-K-0YKnQ#v3_DM>YQM{{L?EsUt8T=}9m8HPD)0zUrY?s65NGmO67o5$!6c2woYc5~WUADWGZwcA zVUq0Ici-!3O04@GLLc2-h8kkViN2UR|3&kZ1J9<#-wwr3~KVRvK*dL5K?tzc8Fe<%{Q|-u8k4%Xr`IbGguMrd+P- zvucr)3@C^D9?l${_!p@hrtr{XCw_*1;^xJr1D|Clrn4fvl#DjUbHDQdE9q`daaR#8 z)OU}bOwiw~Suv6!eqML2vE4&c2&A@uWw8&?fM$0#LU!Z0A_CHez(vngT`ehlKqNqX zgUFLGr94yJe;x%bTv~KWc}lc6r;$rE)HI}QEd_Hcyv*ip!rTyXHg!gdp`C?V_=2Dk z&sS&(1Q1@V-UZaTpW4bUb6wAWQ}Y)b%VW?fZBAQWYGJ5oHG22e=$u1MT|L2B0Cy9u zU|r?DdQzzeQ)h zPhXQ9v>mz5Lzk*CS8-ailXJ>jl1Q-XiM1^32oFjh3jX>Ve! zS{762==N8-z7Q=rvC5ABKy_F%3I)U`K=pV7B~;jHvmNqSZtez}$*6r@pgvj9^Wf)CEZl9yDKvIEU&z;;x*l{OLg0>0nZ$7U=L8mj$38*lodi!|k{< zQ_xHBjfp5_y+`Yus7g+8{GzX^QTjZFeav;Oky~ZhJF@*EyzzK|hW8#y*;#q~YGN3t zczIk?dViS_k=bcRz~{7%5<~?*0zUDC^UQ|9Q9k}Y&AzA|We_il%I+KcHi`5ls#< zu$>d#)7`lw9Nw(cUFEpdd-l?8^}^S6@py2*6C@_i(s$}v;?L9PTUG$K6W>pXybEbV zb6#+f!kb7w4eVfNs~)(ExZ<*%aCqZqzd6vW(aRK*- zJs^w1I*XcQCrOxO|A0V|WRGQ$Ty;Y;0#@+Up*rVpYnfEp(d)l!{wBAVaBwbe6Ybiz;`qcbpH|Z|fOI%-tXRcsI1qK=QOze`rgI4)hIv-Pe-zH^^a*SP~_S00c5( zD!$oMQEJ4CRPKNc1PK3%&dcq~^{%IcpS?FZHCIqda^TG3B@|)+)m$v_5+69_$OR#a zy*AxI{nNkUZ&Cr4SZ?IF=7HH{Yc2@x6IJ#E_S?E5E32l85d&1Upq@YZ~t1t+nQ z!*@Le|K$X`cW&5ps0in+?~7-xN8A7cA|!Vf0^tZ8kn%E(7{of(ofR+ngyOw5pp6_s zbK$$8mi&cA3yhhKj40Lql&Q5CK3#0;e7^;--VT;H5Wa zoP+7tN&FI!8;bufN5%f*!P^ELxYyoon;r2a-P#ZIS@| zU?fGnqjBRMV~6X^*S~pO;lD9`pXZ}?r!WVlg9|SWTE{u(myp# zx3GCC3K%*7U@W|(IVT7p1OTW6MDF(sB5z%*4$m3M^E_i`wKgN5C7)pD)7>I z^~>Kz9eT^4BclQCV;I~7&>NS&i)FBHVrti{~)sa&HDhp z?`ubNNlT=y!XK-0i`(b4G3d+S{l(4GT(O_yLV4JyyU|qJE5p-29Pm-L3aPp}yDPL< zGX1%_d+o)Dqvy-Fe6JutV3q@fqC}gNr3t+ZLwj&QwOX}ZIpmHe#I{HXuIw!)M>F;7jz3o3Y$?(eD2!1(zUzkpPIA!lS+@=f2kXsXGB8-ECIxm zkvgb*DhFU6K>UJ--Sx%0WG{y!K63Q#c3i=L+v4GGZ0d3r0tQcV{n8X;ep)7tqI3=h z+bTGZ6fnVl+<@eKMP<(*Csv=YTxwQqP2RdmOZ-o+5;Exl;w@>L zaFZQNT^c)4X5*aL{;6X5SXL#S=!3!NM;R2`u+m@foCRrsT#wSZxn##wpf@3<5Ft$A zPwdnsMy&sRDdAIAqm%<#x(1E#V%&krE53yaS)xVa2cF8ikY>d;L` zAcf1R=se~vUUHeY%gPFKF--jv#31CLXDL-Xy(%smKmw)nrIaO@#hk$w5CAhBdsv2w z<#eB9RD-C(Nzc3~pilpF-6y7lQ|Es~B{n*n3<9GIWI(MX?0KG*0-7c$Y+cKJ$aq^1 zW~iYOiqnluNBMdgirQ(EfE3%?dxE~en%>L@mtR7|LTT!Inle6q|PaV;J6(bbO6 z3}u8fa^)NGsj3er$A!vQ0$fhJ%mBjFZGVZ3E80|J-3@XY0WW=F94R@srk#c~rc!37 zo}tiUBSWs|X)oJ)|5yy+z%gzv4q1cffYzF;U!Rsn5YActZoRC&%rY#pRi zHst=|m+-3W>cnNXOg*k;=a2l#{|oqR_aF(hqq4xgj5c)E9MF3dvjUlrhk-C@ zpF!;Lnc>6B0)x&PgQrkom`NAkp6hCZ!@jH%n^Caa?1v@hK>8hAiW$MOmk#87)x5-b z_qNj0f!hF0mwsyHn5utNf!w!XK(8@yKg~fPuBB7+nhmLjq^CVyl&|Lv1EnMRTP_S; zu^acbk~)!^TO-s6S!)@77c25#ZJPaK0k+)ygmSyv#_@%BPW^j{c*kFx*_>cF2)bz? zKiWZ_kHf%zuZMfN87Fu|3hGZefBE|*GCGnPAlU#Bhm1$PhHsnlwMscE+K+!a2Xy)N z{~m+P`oFdS$f`!URO4T~hWNCMGiI+?_{2n(RcSD5SRtUMx4-o3s%hc{PjfPU*oZ#cbTR{W_;3 z0cgPKwOb|hbs;9@5hv7QH8#Qfc3(B6Cx7-m-;KVGNkZJL7>a4og%_ENUa!cK4=$3; zGe03M6f4Q>H1ZR0t+D%Wa5lM@uWBnpOM46S>(CWjm#d}Rw;Y-gD{5v*c*ZFSq{ z&KHDjfjs|9i>XZG@Qs}K?g&-pJ8}Pj>$o)SbrBBq`||7F zZJ%T=DeH__krone}iT)HW(Id9Tq?3R$=+%wKSlkCX!mit}j#0Czh7PdBw(5XV z>Q9yVt5>>vKQqK+fSTpX!Js;b%~)44k}UPKvtRxiD}{9=6R}0e`AW)v-bWYYk59_K zIG*1Tyk^HaZUuA(4b2o#q@v+oeL>y_gG_)P5tIK~d0L*rpK4yv{446qy5fAV)7@Xs zT|Ot*Y%V~tf~5PIMlR@>w-q*qQ^QY8R6DPY({k;OXD8#mOpV|3&)z$&wxVUcUb+Cb zqV@f+{2j2LFmGN%to&Ld%*GJk(RdTUR_cp$?&`m+2A}9{F=2XTL_R=mGmTkzk3Ez6L zVHUmLZeH`0(vcfa)o<+EClEFAx>@x;>EAXa-A^s^4|0p_f*%FgW&3l-XoLUnvNcCa z-$+JSo9e=MBOpzMshrLt`6u(Bdmr;tJMj(|toxS?g~H~dD&wnHw2YczxTNl|W}b4o z{mFkqOq2H++V{@u7({!QNI_WSr{}7FUq(&?|2s&(?ERCJWIp~M#@N_}p61ncIuJa^ zjD5THyUY5M*fE(2s0S^r@sgbQBY=Rp14t`OFN>dZUz}GQuJF}Z&rQ|!!?Mr5o3;3p z6m5xT@q!$Pp`A{zY-hbMjTI$u6}`RRah7iZll@s6tuL{GyLtEqC*Fzq3aCPWJWc=z zF+ZA2-Jvgg*{!K&_LuDT-|&=Q_{N{@K`GG=ZCy|itlk)4*n(ynvSzUp$DJ4(Yq#My zN!NZ{M`So?9FPAYJC>OD^-UCuSbio~)O8TILL?4SBT{>!0XnN`Sli0kNrhogXI@mS zIt+SEfu#ODR6uL{$%ACb#3AJ;>0Wt_BpkbKolv|F$PDftdcAOXmUP&BZ zsMoi(*n%*moR#u5!^Jiq$$+^98d?!HyXPb2QsjbMCp7@g$!q?l!bde+<4L&h00aj! z1$o&*;@BFq3ghwB$dMob$%COucdV<4n>lUf$f@btyN~uW{oQTP>G46_aot?CC3j-4 zI2lGy0uwA%Ku2wUJf94N`!Q!K5!m~u#~cDP^aO*aw&IeUvskMR&t z)503TvFmEsn@Dvtn#1b3Zj3Ta?z~q>=wx*4o_|-3faO+a8Qysvu5RzIpDC|wzP&ExmB6^)} z1oEZ8tzbPFN_>p&1J_^yu#Gtwc^#)Ha)OaMJ=rj)E{sil ztTs-RPKFZ?B@C;!hxNX^;ka09YCE~x_L`V3>4a6@9%W4n1(=MZiflFeL$U@iwPmW5 z7jt0pg1)MMlYnzT)^7$j?)nd1aA;z>eTbGJR1j`}vpnH9`D#+9l@CmWw zHGjzgq55iOsaILQIEte7WAt#YmoY$&%tvwv?(HTVlwNG9f#wy^VQnlDlJDr2&O?p` zWx2lpZ2(EJ79HY}!;E#ht@2uwgEr|Vsqnxrxj!XEGtC1C8UWRkYAy9C(bM(-0JzBL zWa!hyTPfbocZ)>Lr<@gaB~K*vU{tCk>3;6+M(_53U3P)Y2*fe3j>P`1(eCg8=DVuw zLxJJQM2u`NGoWk&?FI6;^amtI(A(&z{&!n71 zNhAn5PDvop_0cKa`&;U(869Q1?$#Vp**B-u`mSk8*jfJ01#J+>{ILCv}}&Dche|mh;uXgsd30blNPI zsRxMnl}brdQsK+=k9vdd;!Ui725<`Y;gaTBkIxqm&%mgty8gZ#6*$zE8H^aUXf7AI zjOT^_`FGBj+VpY>N%MAvi3L7t_F%LE2kvriQ+H%t1kaq0ri^zeQ(KF` zqsH~!r%#G5OnhIM`m;RN>wPzM95ysU_sI7d>n;jMD8siY8{@k)T(lSUNEiq$c|}(m zi%_Wr1P-usq>wS4vV_lgnwR{hG~TAKrx5Bgmu63~x*|EYD3OvnUk7mMY8Uq#iZO?$ zezGtQ`xKPB)ta@D0*?)x6V5_MAD?vVHe>x>UxCLM{6u`LSss%=D!T2iM%9H)?ngUa z+0Bk=+E32CH}M;&tEiEkubKviUoi;T43o6&Tg`w)`~MDDZ5wN z)>CE-LdtyQQjXa;aYq*|Ap$k89Ia10*@q9%JNoTtYw0}fDz9^&o9?6SMKCz_6-He< zv=uqk1ibg`NZbUT#dhegh0&i5O^`k94=a5^5+ovs1wXGu5<7he^e9m;-f9vB2{YUukVp z;dYQCCBZJ`iG{XfYaRN;oXgfjM50j{Tw?r;N5E%ulsAb=*TLPXIX^_*3b2`P-|h!FgQh zz6#D+03BG%XX~=x$M@=;r&;ves%oee7*aQY^ovRnghi2or)%_K?Q844&g7wwHAS56 zR$h1uY3#1+8WZZu`BUXiJt;6~P>6Ax#xHU+sCIM($?NSUpq#GAM%m4w#zu!k_|-`- zv+I9xeO#`0FJ-lbKenFEYJ;T=e8lT{a-jf(^*&$%iXEx3X<^S;?3cXMoWfXmN=<`G ztYaHo5b2!|O<9aRyRgV>a+yy~`|)3YqnC{GT~A?yo>IEu|DeEVwxh9|^ZicGWpqKQ zhFFr=ooS6;_s`b-Z@)cX-cVxEb`HRn1j>L1iXEcWkQ1YsxQ;qkz0V<(TyAHs|KE7~ zEUWscQ2Ch4<37Z|2>Y>yF_;VJ%Y<}7UkR&Ts5jhCoeEL`84j2@Qg44Q(!4l_s%%PG zM^*%(Vnq&s`AR6?bAMp_8d<6Q!rH%1snp9R`SLe)sOi_6Rn$+*yU`>+HAka$+0Dbn|cm4o+iPGgV)l@FIC_HnkNb$q@2q&Z3~a`02Vm!o!iMbd9XV7 z)q>$+we5wjG#(5rHFhIA5 zJV;vs=JrEoc}9P1T4nSq30P8{54?i@Ci^wEMcWK>0x;*4DE6t@dq*?Xl3MP$z_2_cf~ zk-hi4zjMF8KfT^@-t&IW=X}QVJhxP41-MXAuL+KZ{RPoVsa-mnBpOta)7VYzh$G*TZw zEbU|QEBi+63-AVQG*k@G+EH+T2^py>(mOJg~>u^-m+|Q1d>#amgW>)|~~~;4$$Ovy@%0 zc}v|TAu8{h@*2SaU)z2`E?v5(c;3zB@pPv8G~DBUDc z0h-Lp?!IyWM~PPy$%f)0--rWM$9%Ii!3o!kkZi5~U}F%ClwQ{oVM~=8Qm#pZcoVC7 zSQ-xlFFOygoZ4~!7oBD41`Uw<+1*eEzu(J0fBGM%4!zFG(dDgoYD8~~cr1uU)lHON zWC0fuJl}aYK6G>C4-8k@BG5gjRC~OTMFG|>uoLu>*Xj~4^^xRUXELGU@i!IEZMeo_ z@nU4*gQ3B|>FlvrZCj;7b7`$8Vaz*-rciexc}ZjRZYmEFX-NXBc@EjQN|HY z*}G7Ar8R%}Mq{qkEHo~u?Gm46<{a<^T2$_Y`CmAu32L7$>4Tn_&Ne!xB?({v+rEMd zVbx@F)Jzg3;08&`T+|lrj<>njNnp(QPT0H^KqBn9!?gp-H6f8UM2DNh#Gs5ADX{;b zqnhLD_*GCeF|pWYXs?F_-B06`ho3&+V5d3g{Ky9k>s=o*p9n(y#b{*t6R!2C!@BvU zybC)11Pwj&$mMfs(!2KBArzDiISUL4;@M?envFa*N40x54kC;}H!TArzAe>JEAU#6U``-f&T*zvktgu~I z0V>r654Jvhiu-%+dz?m*p-)ZALlT%i+_A%Svqd~SS{;uO6of@n#{IX)hF)jruCb#y zk>T+K;ntcbFF+c|67sbng#>O%ktlDA_{>}o%XolISvI7%oOTb{`j4$bhWUkLT&$P> zu?3vBovx6wEoS!X_HId=BLOOQu`0=Xe1S#1$%Suib*S;=?X2zIrz5`XR@yaZ;gLmN zAjb0VTw!cJLD3NgK04vaRxIcMOZARL4b1*1VYNG5dXZrZvyZ0T7 zLl3&wcqw|tDKNyW^`F1s*XD5Dsgw)}yuRllqZ#%V+>o2k)EVGO$nO^OW*Vwk!$2Fq z$GF*5#V29WeT}UH;40^r7Rfch2D~SnUHY{B?PDXmkrdg}tutN@OfwBf$vO-1IZGA88$&A4~(sqr*PqY@0`ku0;gq%26J+x9E#`` z4h^y0yLRi$n}8LG?FygH&O`2m$~XcB?48mf6Y`|LH5wZLZqe~7M&a(AMSultH?|EzlOI8%a`4_}qrfyg?O8hGuFwOS3*4}`hOI7pCt7Xo}(BnKn2 z1Sy`1%-%$Y<^ylDtJ4AP7}dYqGzxVifc4{~9+jV94N_4Ev^06u@(5-bLwL6cPYt44I9z9F^al2M?7XkqYv6{U zNh?&iEI3J+(`E579vF_@uWG1g+3@QTS6V1#)Q1m@OYOw6I+Ag*x#%4kz_S(D#P1f)Q0J zSqNWt4Q-^&%jc(#WWV6FiLA2KUmSjh5fp)#A^xeJfQnKqiJ-$ls%XuDOdQvkvwm4*~U;K@QbsVpEej-ZV}XkHvo z_Cn~^Br>PzmW?=Ub7sqF8KVQj-Wcc=`MO^0hisFirBWdtk#NX!6!j7$8czsMLg`ZI ze5E-ic-QC>k9Z%wiHR%*ltHQ;f<-rvBa67OE1@=!QBzXsqp|7% z=6>2UfF2=Wie|v&q>51~EW;!g#?S=jfa9tFqcnk?MC?Q4uLVk|U=bAW$RvD|PY9K< zCIjFJ3TjIP$BOStCNXbf^Bjd$xH-$oQV>iq)Ub{^b3G4a#v_P1^&eEgND z0S9AraDAX2E&!7OIFDIW#>-f;7JQVAc2+Abz)Z?rjUL?7!$WYJUR$XYy_fr0EM#sL z=Lh^um~?(mS9%Hcx{i0RwDQ_ZDV%c=1Eclg3wXmbdiu>FnCE8AoYxf;XNMb-Lyh0s9~WXkti~YlLdti;!HN zM7)R+M`*C?8c|3z2%yNZf1j@26OZ%|6&sVa8{oF1O#cl-G3 z%O_0Y|4c1dstuSMZB*XXsjD|$Kw6)9)rW{VY^j|t^;;FiJ>@%jfUHGEcPBtxBg1l< z-d8*K>w;GO1v0YksTnhq7LF(%{*rxJZ>Z7xJBEX@xuHk*Qy!ncPMwbtMi;Ck=My0{ z^#Hf?BeCsTELnoouNrsOP!Sk+WpOkSAIXBQ007W<@ z|Ea;#e0oO1y?AG0(dm5jt7-S*plvIn#I%`f5^wsC>L9NwE$2H>!OmYV@%Vf-U}94L zuxh@+c=4Mcmd18G3{Q0%!;cEw;V{AEJ294fA1RbQFlli zu1IS!OBnG2%>$#8euc;%#VP#R=QczUTn#Jpp#YJH^wuqWhW8FQQm+zGNd0I`xuTGA zj}9j4$KlmIX%`60J?(Si;6Sbg0y)N?^1VC{_*Nn?MkQYdM(y=;4n7?AWHB=PdYi?! zIRJvX9a55IS2p!AN9<&DcF4d0XEKFa*+cts;c@fvw%@l{CLjpIPsDjoEsR+Tcjxki z(A;NxuNrRK)-xvzuDOLzb9oDiIQuSPtY zSd0y7sos?Ss4Ff!F@!0+3nUMxyl|y{uYf2S?iM~bb7|Ko~-d2uCxr!ve@MG$W$cx&~~U3%Z9n%@!>D=@lLN&Xs`B|ljQ{MLu39@B2O zZT}vN0pQ3@H$yHWa06R)s4yWmmq?qpo3R2q-!6xUJ_ zsUve!lStp`EgFD4At){zuDuPNBvLQVrP+Nkoxb}|TE_c_CTufQZwfO09AfISWFIhG z^+5VZcKs(`0plD_$O^NhWI?ed0Zrf*h%G`}7kOxPtsLlrb?%w9f`S=Q@syT|3eo9H zj;V`pP0^~{5{Il;TcSt(^HC?CRp(E0sgNQvS_1EZ?!y}cDhY>6l>(xVn7{W=-6wJE zpOkc@Bsbm#6M|>p+bKD{(Rusx+r>N@g@`$31IekO&?TxX4!mc25T=X|DHL=Y0Z$M_+w(ZvGf3I{twX-bfC%#n%Gr)ZB-8Kl$~;C+oeQu3%f4?arI^= z)n`j}RTq}Wj6#1NWjv%prjVe#v0&64_n;ewA*NUnF%WTdZIOC3F*h6()Qj2;lJpa& zzyCs>FCn*FZ1-yyN`%)H=t^rB&&3dH?dbeyWD`%Mq-5u_jW@CI)##gaQ9rfjpE}sF zkdNKC6%IH*p^bJppzuS{HRl=3Mpw7GlI?5%a_f~~BfUT9bKR3z>Z!5blQX}J3bj64 z7y9;teenTz1n#@0j2PiY(|-n9AXMsFAzjF9yC#i`lR~JDD%D8n&3Hx?gwAdQXoLuQ zBe-Q8mR0}uW^+-T^M&?#2&-`0wKD+QD!<6Q!?^b|81dNFDtiW}(R9N=t7Gd^QFAln zE=hoZglTH+b!zq~~DPI}Pcg`}$4St13 zr;E*SI1A;h@XhFxmptzr!0C={NmWpEZ#Z}&4?wP9n#WxViLE$eC}r=eClu9(Ic+_R zu_1DD0&q-9m<<&S#RsX)Va5mAtYt z_}l$hsfDEamgvs4{#QAv4r7vz+&@5MKP!y7O~I`*E@b5D1t& z#1l$Y_ARAX`yjQrzmeBDn|FR2WDl%O7(d{~_A(%YvHwt^&tFb>*w|`Qq#7}0zYoSJYZyiEZ7<}jjVHXf5%c|e*Oz>+# zflbguZVQmZDaS3tj7i>E=w5Y_ys`>eF_S+US&0tUv3Gp2p(8~D0vME2WWn>Vn?D&r z*D;aFV#9Sn&r*N%pWzQJ6LctWHdC01P!Z>Kd5}*6jyF($0;_bGaTv+r-=fZlhQANG zmNFxyG~A;_*=fxyPo1n@Of*))WHT%^OPpdzmB^9oriKQLOMT{SI21NUNJmt#oW!-z0+(-uU)=suBu0dWdAwXGylVf!gLb%o807;Uwj}_ z+n{OKrZzha!A*5~5(Nucy}5x$0`dpn=|#e;4HW)AXRp}_LkZGLRgD}N2@W?{^N11Rh6-RE zY=^AQo^EFS0X!I0=_LK{QTf8`C{1?4c1n^ck)@wAtCO;%rY^fTwUm0}G3PpWJg#!8 zK;B0-oRSoKD}Lna0oal$iE$la;1!EBV9~iFlAxKd!Xkb>x>C`tbJ84{Q=(w+sNb3g zv;ZiH;~p{sR9D$D%dkCIntPaqzp@QQb8edDfCCML%A(Kj&l7WoxoZvr8lgw`+>FdZ zwZc!}4nh!K)S`=+<0!?2(m+c>I~aLX!u}OrO%yF&2_gp18&b;eIsCO(iHKG5-zM@2 zi)GLO6Lxt6IxfKEI^#F~CPo<`RB=|WUv?0kpKQc>_a>sooeq`#E1{K@0~SNA=XBR+ z3y$9o8w+fNd^w0vjmT)cNY6xsNW8#pIrxmkt1Xt6?uw zd~VP90%gLLd|bmfKjv39*O%5By}iK$RJ?%eE(UDyTUHR>JQ2K=oB)ZGkp!(PO{@Y3 z@NnHK4?@CWRZONXV%I<}28p2F!uM_3YPhJv(Cb{Rs^?v{U3qeBhaM^d{wK)ao?(Sy z*r}OSJU}Uauebj}mkn;9@;H@SmqPe-_?&Tr&t3Q6IUxY>_ZY9jzH*#umiHRKKa}(3mCVPlYEG!JCZFq5~?t;{b=3*ume+Mo|7Mgoh1; zY-LvG%z#*kEum8xCU5Z)y@yivjUG^d^2ygfmQx{DUw^*9^#8U)hic7pYWus0VSAZ< zV7MR&`s0OR23-4(Etd}u<@ux=E917rcZL|czOXF&&+SoIi{rJ#U}sA6d(5mg1MY=i zuVh-TEnUco_}+&i^0@+D#m+AaUNS6Fd}=I;cujD0{N805aDYDhyx1UV zWc!><&EqVBx6?uV_`?a!{VM9xlTSDRTUZ9n!3o#&XcAaboOk$O1b91 zMnc>rb6L^@5wqC}VvJOqSF^XA>mqbB8zG7Vf~emcsE1gMH}HbVh6iOg!$Gs8Js(P_ z7S|S3(a(Le#?6}&~@RYf9Id=y;zxZgwJ6I zjSbHbn6urEmPKdQO||_n(a5i)zim5`kFfvMqo;hm#D@=p`-V-$j{1v9vz5Pjbfz}P2s7Dg5 zIdiEoe7>KC8s_O!kn|=Wsweubsr8{88-K zG^3Lk0@y8^Zu|5Qpp+1okmc8@L~2EZ&1Ov}PDaLPT!_x+SDuLId>z`d{?7>{OOYd9 zuCax($FF%Cb*@$G@1?OLURtZ(`V&kL-buCj=c0<8q4!g2R*mz9unhC?A;okOo)Q%# z?BQ!Ds!Fc9z9oq-TR)urIffF?8A???LD)U_fqeTl@d65{y1~7Ty4=eut*}?Te$g8$ z^6=v2(Po_=QM7# z6;~%yWRNr6v}g z6f~(JUZ#@-G^Koi>nstF>YA@L`P;tA${p<+3(-WzAwG zI-)fJQ#uPpA)4G=aeix6+H0$_JwiF<1VGS8Oo#$*yr`g63;x(OWjH7bDb@DY#EU{J ze`%5D%TK21kRzSwQbsfZWYp~iRY-0fj=*o)(WTo;wMB%{lb2q&$UDM+HT#SU=?s;0 z%W(Y^4%t9g{P!X%C39OAJn>#_lPrUk(sM|m5HoUs64Vp)Of7ZxDe#5|&q767JLg~< z42wTAo%eB7H{_r!0~apuWvEmy9zi7Rn6qti3hF+Fk6ZtYf9px(Mopr(5XC-OhX<(y zhnGxkS|jmHz%EDR6TV3?`sydi0hvP-g;rEYdcq7N5J)7~gN)1l*xZE+KK!g4Bf++! zo;Y4g@F*NeI=*TYhY%QkKdjLlAaSSo_7q*6=n1WKzJUa~&=L1NGCiT_>Ot05V?i^` zkGUY@;%KPIe{zicabr+C>#OE~D)kBn*Pef(Xo&hi0MKdVcg0llTlf5f>dQLuoGO=a zr{4^40%Q19hnds5$JarRW`R6T#%YZ=_J3-UE!QUU5)gac0mwjD&op&|{ z;{OsL)O~_nq=7!|Yq?L&oz|vmQXg{o!Uz^+z9MMp5-7ZPtz@P1;YC+LSb0cZ=zAII z_Kr}YTa0YzKK#yaxOzkg(W5uk4Vg;fBoM4DrU-7gpcw@u0OCL2w@3>SSzXhLRLK_A z{Q$-OR{+xmUjzqWFXSM!x$?T6@s1>>pLrA;AG`3RiQ3qiir;(20CXk4Ghg%2NR_6V ze_7HvdFnJElFLsH3E)klY4^w9EsrP7B*bc7#vWn3p~wgLXo3EJxC9WNZS!I%l34Vf zz>#z*Tqq7J_E~TpT1lHu`DXpkWrKo5I%~kX1)M5oIX=eS^geYY7r2R*V3?z6zin1~ z!9ir8nX#hH7Qe@(YcrCTm+7LtZkT_E{Y40IkytMgfpd}% zF00b3J>Are9Gq8Pb$0GiKR>_RfS)us0TE?uj7FR0>m@#g~R;ay0h&thDq}1n8M` z*@Fzk!*eC+n>f)r1dF?)?mOMZMbV;fE9>xd2(XHqpzg(Vtd70#|f(~V) zLI{#T)Dyn)-l^!(ip9ZxgO$T)j$U)wbMD`8B?9T%jGVkl>x_|cw$(-Ep#Hl2W5 z*7*PTY?fe;0Qwtaq~4_pmYI2qo>gU-D^*C78dST&!8(H$A3IUIYiy03AD59=IoBsN z(C$KHTqPf(oUoxYbA0=O;+$I^_&G>-E6*tgM*$zHny`tiI;4oS%yv@ z7{D`>YIoMGNU{vm9d;Li8SGnxgtVs1c-j_idKZn#yd>#vX-bJbm05x1FYkdB zu;qL$yP{nl{h-5_K0o8h2PTc8p@r$#Udhfb*`gd0^)GXytnRaw0Bo|U3rI-6!4;jl@*FB z1SwK3+7TO&>UVAm?3wnpi~8;5Cl+=S=Fc);Bm3H6=MTYmpH$ucBjGCC6^|FvwtAs4 z^Yr@~n+%c>VG$d9a~&)|yYZyZyxEcy(H1w~Ae15g1O^AE||zUQopJe{F?~N&>-a1 z{qguB>r~DAFM=sKhC$vx!xES%WBJ&!mVn|4%2WlcBh$)dLy?kHtEbP;7Wf~G0kyqS zIL1L80t93kf_KKGnl^++VHp*~Jx_qAvCR|8E47h{*Oc)XC}d*|ZYmE5m8d5`My^9g zS#xK1}w_-g!)QR2pbD*ok1ZER^exYmt=0*a ze*l+fx>^}7z_Vl8}d_CtFF6`S|AU zhXgdsFHj_;-ul#CEi1UJhfNSE6Xf2SiBXVqJhHzxbxL1bvG)w05Mq7lp9<{Dc)x#f z*}Shm)o59HLQ^3Vm`ct5VCT&gBka+%%YFX<;7V{7d+Sf7&_)?kIlvzOv&)mKuKOu) zh7Vz0UHL3H;3*euhh8LPDFWgmzB4Bjx1D2rhos&^^E=>SUAZe%tk+4ReCJlZ$U>=}e%5F6^=SPE3XOI#!kqVk%(Bpo?pIQA{%Z zX|5@1w$?yFC+)c|C!saf*k=)qL?H&Kd$VwZn?LtvT6`F8tz|M0mX<$p;mev9DvlLI zidE&DY@MEonoY2_={J>FNVy9>1U83`FJG)L^;6halxnJcBf_kqEKpkaHh$Pw6oeG~ z%UxeZvd#Gqj@IA5zn`WZ=wlbi7|N|&$fKxerJXIKkJJ9GhQgv>CNx&IfRz>0z5k^(ExPlU&}w%2__;A5|=&3BkG zzZ$+R*f)-T-ENob>bT=`{oyo5^pG7?uyT}$Gy3tDxiHQc>7-}nc{eW>b% zGORaMus^(O!7O!h1b*U0d7|mtJ1*K0+`k{{ z@2XMgb6Oma8-4Zh?2N5PQY&Z?&|5nD@;;}NmUG-=&#ucsPG;7+w41SAh1DNjT} zNJd~PxHm`VtZC_hEAn|AGLX+;P1Hq>RQbMZKyaK$bl~TxM%I?P!p2KeP$%BQg6K-j z$H1j!#h%yKSpTzv#NUWi;e&@6P*rwt(xgZ$ z5I?gju$Q{zpMx;^;z2wP;YrLN(_0PrgKw+^iO@*#iNi{YabBVbiX^CjI~chGV`R+s z?)YjG^_GgSBv!`pO@qVEU}n&)?XL|3ZOg7aWpe8iMy1L^OH0cz{jD!~op9kt207@6 z;g=v);7)<5xCnKm16VNw5FKCTBa{D`sifqBW}q>j(05__A!NSGueJD6BJ{L(vg?U? zpNzBDB}*SPe_O-^gcpta9r}*6#V!*X{6a$zW7d3iqOAK6M$=3^Q)K~`1WJj|Hsp4%-EwDhUJVAaF hw1H&HD8n_Iksevfl3!{D_&qSlJvCj`kIL4l{{dqwv7i6| literal 9055 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 P00000NkvXXu0mjf*S({W literal 12620 zcmeHt`8(9#`~P{)48}fX8I@(Usbpy*>kQKJDocrEr$X5(*%I?qNl{TLm24@yvhU2a z$QlaSjYMP{5o4Q~@6qf1d0*G}PxxHd=a=Dno^#*lKKHrT$2oUQjSmS6tQP6o z-~<2|_%8=HY2K<}jgi^;ype4BQkk>4vaiFHyfAKJ0{#3P5LB%;N& z(3{672Qa)p(A`D*j42dC^$qduoAV4+#doVYh=m9rS+~{P^arN=8Z#Xa%FSHjPOdaOp0$#vu&ttWF#sKnXTPtI zw7?8r9eP-r%~~vboIm`Q=NB}Z+izq59{KIg{-9CQ|Kn{OS)QtMSPlV{lSMoG)&ciB z&vnLf-0NtwIz^AcoSXgy2Nr6i?imr~kgfT=q*A10M(g9N4!L z5HR)Q#Z1N>V<2Pz0O7F&%Mn56>6e!#ms|PK-Kzq0dw=A~2mRsrJu#?&*2i5e z<^cu3yDC}AvMieCD!P-#WXQa1dn@+~dd%$>Wr3_be)yJVOGR1uN z^O=l$*vF$`@F1S+vET-HH~2AV8{)vfpN@Y5k@aSQ4v&Mlo={l1zofiCMV|DrimITA zw1`3-?M?2-njV~*43VE_morr6ns4rfj(fn&Sx8l$)LTVWpKA{N`$bFY`(KU?hs<&a<_?@S}FC zicGsIN?B**A$qs;uficEy>_ed{&dSd8u`ONy?+W7UaHReMh^UT{b$gw`$Z=E@8e@U)H!Ju^#RStD zxNoSRdtkt(d^F7ez3BJ4d)^V5r#?ITkg!QLAm+j4KnL4pPTwewqK-ig=3jXj7Bwlb zFMR-t*w3$pK>97TjP-ZBo;YwQE2^6lxWi;QIj_SlQ(Sth`x7Y^h<}S} zjEqc+uvIq?OuYQB!5jd!w+ovB{Kw?ZHdl)k5Vq>uPMp9a+b5R&UWx%o|19U+v@~m} z#*uj=`+6ExA-+98lycgqkS`?Npw?D1DZ*G*tAp_+Q_g>YKfRiWuLeMndGU%Fd^_Hl(md0Pml%rKN%Le$K^27VB@ z62htbzip=@@BB}c@Fm_QOonWShQQ4lYv%h(0j~IPslPXS{3uUz1UV zI?%nf(pJ;N)3QVARvF!V--SR2v;R^SbomsIy&Sz43f`=HV{AJxhDVHg{zd?20{D8) z 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 B{%rsN diff --git a/public/res/android/android-chrome-72x72.png b/public/res/android/android-chrome-72x72.png index 32d5d51646aed74aa40cb39a4af3161802fb74d2..7aa6caab1206aa1727999a4e983d1d909f52102c 100644 GIT binary patch literal 4503 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 delta 1511 zcmVw*C@BjlSb{c*nOwX)#W}H?)nIM^? zj0vgfIUvAc5hEk4jV)7)CTnC^BgE`GW&y0{fRIA6aLm!Pa=E-eSRed+dkOupvz*4d zDrl%JM&~-BG}2K@XWNQW*11{9906)f1eM`9fy5s8`+pr}G|_W2P42tJRzK~ErGWAj z5KP!9AZFqs^G3zO10z~^V9Wx<;jP84Rx)KkGSM@>yNrH)=?+5^dn+@pIVnhkHKjos z-;Z;((KzC>H3M5q-Kt+r?ICZX=}ID*jJku?k`n_!*r&- z!n+iMhkw|?0R$@?rr!q}jmWb4QKrfk4+R~=Vd_{@JokWLuDs|$N@aq>boGsfv`E4} zDx2sTYAdJi`Y?Unnd(*FbSCB=5O&5qKmfo41YLQpF70#JiJN~99!>@hpycJ{QGb6w zZQatC_C6wUP61&@%u6p3944!ATcb$YYqrG4z?0P)%iJhUtz z_>uMVW-n6ln(eDYv@W)U4sD8f4+y2%4M5Pv-W4gIH}`8YU@r94(AAH7=&$eIr+>yj zHT1_Q3k}T zVom_1nDx%(H1c#z%75T9JUG+mhbR$^`wI}H3JAjP8e4>ko=TXOO;lat;jxdX)xa)U8Bkxf zBRE#U*xlGUX9XxAXjbvCDsZL`uFDz>n$lc=aSVtz?tq`j!kK;N0lM&9Rn~wWsVT}C z97ljK!^yT`g{~aBTM5MFqZ4}h543D#1;pnusK zU!@qtJMQ5P&CKZ~3&*ad`19PcBqXFl$~E%^bN$2nYKcQ9A)4if~5eD?kCkY-m(4ew8WjvtPU_1qjCNOF$yC=2bKq zK)i8=SpexvKu~WTzc;f>HJK7G1Am8AG~WUec@cz}j}?;0q_O9b1q2;$jrtQ%K;fhn zhldQ%wPVe~@~#4CPkqF{fC3VRZH^{v5HULvjW<+M%#GoEP64Uj6*6FTMY({iyJtIH z{OI|d!Ep_c6G&s7#auw5xieF5d&~R)ZTxu&2=|^vfIfSy)L=MFG~cGrq+CRi=Ya5E=Rq=mdk6OpGeN>@Zr-l<6(A;j z-vR)Tau9B*+NBKMlUet_0_0a|KpKz+qycF_K9&Zg0ck)QkOt&q{R`aM)sM=ki4Fh& N002ovPDHLkV1h$`;%5K= diff --git a/public/res/android/android-chrome-96x96.png b/public/res/android/android-chrome-96x96.png index 5da68e4b8978211d33d8d3a21d5701e642b0932f..5ab44fe9df1da828a7a2aa029aa72cb745219a61 100644 GIT binary patch literal 6234 zcmV-g7^UZlP)iTyaf?J!cX-NuslB0 zg!dp?L?KG!T#!cOaS6D;dd>9rTgtBk3TUzG2acWq>LyV->RkYy0P#8_`0j=Gc(DjZ zl)*dO4DRmgl`|*o${OU;gkRWqbXg>#C!@_+@6zCX0AvL3{{i5>9d03nCl6RejHn3M zU|KqD&#V7r=H{I#L3Sqmvi_q-dSfPwQinJj1PEHXCBD1r@OZ4!#(3vGlpr{7^O~7e zZ7D%^jC3ZtvH@d`@kYNWqDs8A0fOlkYlk!uh}On?S1o}(Y4hqCv$Ha8O929&!oFil zBQaAYN)Pfj2oN#daI*)n$ul8ISnTZ&z4U~!uibnMl;k%VNx$Bm=Up{!;crnpSy$u4C7$l5!yRSXI6y60A zi5{FN9_*9Vf1VY}e4&>NCi$`h#|{y#=X>w;Hbr*BPUpc#aRv^NMLpV7wT3 zSpk74xmhU_aW>uUboWXIjo`6LnTYf5X5b=tSqnhfz*C2c;aza11&npCy<-XBCY4YN+%zZO|)n}mX@2D5)edy`Fv&1hMoZnJY$Iych;MN~q$hS}XTJ9&TwTMXW z`t%~7&s|!FoZ<47Yq{pu2bnhW9ZE}jWGvy+A;~#Metw+ZJ#p| zf4litJo?!4870_Vd?pBjfIDxwoG%`GI5S`Q8%vje%*IVMY}i=Miq-2_zhMi}XoUPk zUQX%RvesT#K%b%C_MH*oSm#Kknz{X!%edmZr)PG&L+j4V?VNKt>-pZl-OHbznn_7< zVVg?G3NjjvV6DXi_uc*j&N*XrtDjl($wpp$sfs`R=>_J^|By(;pp_yLF&LwBP`S63 zF02?fq2oL^h!ULlqyx)u9{3R#pEE88H7#+xv#cx&*<(f>!}OPD^U3;6M5B>#w`M+< z2O`l3(WqhL<{DDXX;MvTo|rm=*WP}g9t8=?ON&S(;uQ5L;ONf};oQnG6cpr9UAvXS zf&`6CDXMF?Vy!10kA_aX#oy?xOZpskMu*j#srXRe(8wb{zn1Tu{&j-DVvOFaW}CIF zSKoY(Z=H85iM-ejd4l(zAAbJ~h7K9XyYDZhuC9S}U`aNnDC&{V312;m6Gx4}7=!mN z=VwC5vaDFOj<;qnWYUxudE=c0#A8w7u}G)qBhq)+nZBbTk2Z=eTk08q!-ZUT)!F+* z_$}c`DaBcrPUOXxswgbX51nXclLpoik41U>kz4r6vBP)ko*%E>$ja3l7;;d5%FBv! z%Kp#)n#M!FdzMveHV}hrky>T^3i+G(#p z7<2lMn7?Rw=nJ-sSVG6`Nj9d4$0A&Q;VDeG;Uc^TwHxe9Ivo&+80NmWlyQ|e5RDo- z(+!yRf|&@Ungjaxso=X8oQ(H=mr2!jU6i?`tutUqYehwQF+t!E6ySFJ8KsEFq7)Yu zaM}4M@3e#4Iu|B)ndDFFFdS`YXyTSTe~o}l)&zu9_ zUieA`9WkwT|C_THa_7C1d829pu~>xantDVO#%Q7uLnNZ{d%)JV+X9>mvGt$EeJPhQ z?kq~lk#c(5-)K&!GxvzJ5<*8{EUUC+V+v6rKDYcnBA~Tq{f268x#nEPo-i^D7FvXy zS&PhWq7Rm?=AAi<`P+LR^7fn$m@{t)@mPc-h7G1)pK=Nd@@Pn=Sihl~)t_u+)8<;@ zu?X>av_lD-X%9ajBBYwr^zT=}kw+YqNw(U~K(oSKwPpjaz5PB_a~88~m1kq;7o41`kY+YnpJT2|J5~Hx%JLJFlowkqR|Nb`jj#Jumc%${0L4S zGm<{N%Xax$s%z>w?c5);bos}`W9_qqc0!O4SjXT4`-We_`5e4t1(Iw`G4bCYW73rA zG&MDopPxrbNnwWE$~?=^B8wIgYU`5x@aBhDxn={m-E>i#7~OUrApG_1_t{cYPib*s z4!3J+8~EJegBXAP1&lfV=uF}yI~co&AWHbow?}d5`Qyk>#OYO0Ole7vR#|b@x>l}U z&+3miQs2->#Apf&667ah3_h?g-x%`+?)k+O5_x$zXWQe1r5&W3!s96^E@<(j&NktH zymk|pTzM~V&0a)lNg;Xh7}hzQ^=+4?y$2kTh@ofCQhxsM(+oXi0Oy{0!Y+M6HoSc5 z&#w`UM$lTbrKXPZ(ju$8QKDUE%1M4taV~pm-S#$W! zAD`#l1cQ=gC(>Wj1H@b;X=6eRN4yrquQ#vR9m z8!lw=2gdos_uwI=)`Mw4nzbK}HEnKWevB0?fBMt&kM7sQ4%xo6YnT0|sF z+}fF-nf8+ON-1jU8n$&YWWvZBZ~r~-E?7oIc`@m9(C){_oQHT`j1{Za^Vhc*@Qu+U zan7T)v>AFP8&mxB7gGqVBXEwpe{vcBeDNtEY*>p{>QlqxmQGk&v_oa#Z)-ynm;UR0 zyfCvWaw1^;W zkv(`>>zMoA(p`{n!q0v=mA9%E(YIF_k3W1p|9tT&p@5vnXx*W_!rD;y$;K2HUwJRn zXU(BkMG4MstCns5$}~8D@7VV>?NONb5Q|4y{NZZq>XQ@}%ySPJ!kz5g5RMefSFYoV z>mH;}uQLAh$c;M{ezwMIt!u@8?pPBE3rEIimMmRE+!K z3d+WhN(qlX_5$NZACviFl4C>ngyInd@1VT2s8yRqHfC>ZYUW>l^h;{$8u-f-w{hsf z{RslsO88lNLu=J03CBM1^D96Y#ho4fz^`~JlASLgA^UtYtp zBZjux`Of)P?JNxqP0U}kf_d*R+FrP5(^ma*RYtw}a+s%2}kiNgFm z3JVgs_xHsz9pUhu^Ar~3G2x!c3_WB3S6q4;lc&DCEe7@-6TY*a9)MEQS@iIs1NqC8 z+b~+^cJ-F(dd8e_Gj;VzUjCnZ=-;;@mw#ko;*EC}@xX7MW&XnDVNaxOZWVMlHvkVsWb}~ zujJkbpXRS`Eg(NXo?~eDXmCttTfpEEm-^0V$ZBXvQCyr)b92z)UefxlNhUpbEhl~b z=v*c0qZMm8@r+xD$D+)5`cC@vD$ONhTWYrQ>KpUv-?yBRM-6H7zNu82e>#663l^^; z9*=gpmUp$MVN^&z3k3yvq*Cb?JEZ@fj^d&Mh91(t6<0I)sh3%^b~9s77(w6O zWv!_0rlw|29y5}WM-9QbuzPM>OCvO=17fiV=bSN$rc6s>S9_e>?78JhuJR?h)L+_d zMb-wCmzGjdUYw(@8ZlBFv+Z~K^(otq25C>x}h%tD& zUT3@M3Nw~O!tT1lf&|e_LZ?J{Z@~&aTCt9Pz03Ig@WGidQ?@J0?7FyKU0om23?le0 zrUBD6W-e`ODQqd(`HNPtwV{b4hYzM#MR6-=+E&fl711WV^|vM4GTJUSW6E@uk7Jak zwzh#}V=Bint@&g#);fCkDnV3K)0tIX8#Kwe&qCCfkI-UpvSDPh=<{%B*^QnPgj z2i&IcgHZU(SFPuwD<5D}^;TlBD86kTx=UQ9D+R{ZI*JPmc=Yk<3_h?o7k%dh1|86g z)gN!lF`vE-B=MPt?Tk^(p0|`MZum88*Vkk?Als!MYc*X87JW!-6c!}7>GsE{u5F<5 zn_p(ql$Y32vz4CZMLUpYSteAAF!{e<;kKVmp*bB;kRjlti;p(#QWlUcuZlpA9{Jq+ zt7kcIU@x@RY}{N+&+?*H7LX<7YU>)9@UtmQdG@aq82s4B*J&2O=W; z-^{tZGj}N^MfsnJ?DjJqhAL4srvpAWY#`ByAqXswJ@r~x)AfJf7LtDq2+GhA2lTH1 zVD9|o%v-R6L?SbgV?R5#rt3ivf)+(#LHI=C3$xxO)s!X?kF|eRTz44SaSO<#F| zoW5tjJhVx5y%;E*6Ixf_$lCSQEMK*Ocr3D?4sq?GRD{-=CCk?G&fJekHa1gQQV>Qz z`=bK34X)@KU(b5TPsDk1_EMIu*wA7EitLw%{21|kB-$M0UC=c`3=hUA>bEvhQ`?a1 zwBEnO@4YA^-rA20!ctNCAn$FLit)4#=X(16F!ThxC@tRErN*OXi|Dc5 ziHay^(`#b&wN+})oJW#Msj1pT@gh#T4_(`9mx$m+oHkLEQd8&5c_gU-jOBMOXl_>F z?)Tlt5bmuOWV}L@3!0ma<#zz;_~XZ$Ij`Kk*!$oK6UkFPJP(&#FMap5=hwF9AFPSw zDer?P=Dc$E;^U7WZ`7z!6C5DUo4c$_rizgLypgEi5F!rD9M&${D-%E~9z#EIgz zNw~67Gj*!_{Fqx`G$uYK2$F%Ix=+>Xl;qw8#w1J-G|hN-#vLb7S*e&Bo~*r1z*C=e zc!-X<>FmHYR~a22WNj*=1W&g`bmTA>35BoY#<}L@Q6HS0X}fe;S*n)D9FCtbL8@l{ zbTckE%j2uHi5o9IXn9^h_q97$#_5U=w22#!uf_#uRn7eA=J69INXzlEJ8bF7$|v>I zsb|?EN8dP7oBYX2=|h4b8GvX(#CLD+T_S$?n7FsbB#d+RLmM=lH*e-otFp4)<$#3j z4-OrDMOnVCn5-l5u|c{Kj|(I`?ohg6X-BxpjIFhTGA5FTO*c(zup7@?JoCZLZ3+KV zrB_*5si$Tf#ZhC&Pf(bflr~WtG~w}qfZi`_gdIQ4zzSltiQ{b0>@vAc$$C~tls0%&rOo72lNAcc;mkV|}8h(F5cu^)Z_(|bx zGai-+>M8MVQq{DdEZAOVbjpyN@#9tH!owwd(-EhRPk0|4p&Vnq3q~SpAowAf5$5|d zm+V27;N415rphU%i8Rld^W4N_PVmDPcqUHVtsa{H2ll{ch7}LkSpWb407*qoM6N<$ Eg34HaWdHyG delta 2009 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
    yNan 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 Hu0mjfyJ31P literal 3096 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 diff --git a/public/res/apple/apple-touch-icon-167x167.png b/public/res/apple/apple-touch-icon-167x167.png index 3f26f91b57890d8ad2c7327b6c3c2badd7970d2c..963c024d6a1bc63f6ea8168c590f84ed03a5c091 100644 GIT binary patch literal 11346 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 KLTbRSul@(HS$PEj diff --git a/public/res/apple/apple-touch-icon-57x57.png b/public/res/apple/apple-touch-icon-57x57.png index 2e2081f5f6c211a342eb4f6e27e6e25eeda7f0e3..5cad75b275f1666ab48515772539ca73305ce64a 100644 GIT binary patch delta 3473 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 delta 1169 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 delta 1242 zcmV<01SR{e9PJ5^BYy<6NklyjoN`h7~)u0VV z(wY_{+Mp&%Jn+a?u?8ch#e=q9LB$_RX{#WJkWw$udZD6GlxU1sZDUPU?5~e}C%((< z?EAj+PG)vzo6HYi!tDFboBix_yw7`#@?~bxNYW^`zkmnq0e^eI9?PfjkUK{@_&@10&kcRpu4N`xw_h% zr-2U@7=lGvJAdL<-)83ejswGzB3QjT=LeOn&0~|A zW$N8j5q~#FvoR~j2TWm)HWgDwMg~QrQEIEni)%|Nu&B^@xMh+lFg!s24l7D#l?IjE zcTr`IX5|ZO%Hlr9`zZ$&#jjDw;Kpgn47jhOie7eg(3=}S(tkZC>Fw=f+VgL#Bbv3X zmar)WHYPCC;TTs2`}*=h4b-SP)m56CXJ(F0Uw>ezXMIuZJ>&$8`~9ayT;OMW>bbDc zsh@XHX(5u|*wh0fA*ljC?x>8tcW!xzx>l44E&TYKnL=P_+LX!3OxT@h3@n?6Re>Q@SfhIb9)Fk2D6s{r42+DZT2_kGzjyACswxPv zUVj=?Ut`+__DNPssHV(d2M&KieOn_#f^`biGO%djuM)?9eK%VP4$~15meIPd0LvEt zvU-|jXfx1((>r3x3B|^f-Gd>3dfOFX2(_@)`55kOW?5d5fsD+cQFprnEOP66YeO_} zsGbY_Vt=g^w*S~V;e7k*{2r~(;;MXifPbeHh^Hr!mCb-ztt=9(Y-jyz3)p9I=ff;v z0Y(N3!2?_cmRT8vh0(QT7Dho~1`M}$6&S*%oGP>oSQfu}VAo=p36+;@%N8r6KX=X? z%+iPIZ3DZOrBz+qs0Uy2S4OgBAXE3R?T*3P0`@8FRqHJ-Dmcu5cdh*_eSob%_kV7# z;6lS?MX=AWAEmNTVR`}k-Y*}`j$y8~j<@XA9UGTB3hNlK?|mY}&Ik`26&m)juN(%(K{eXxdDKL2CGB*-Nm*K0}02U(3PbH z)VI4%+m*ca)okJMY1#sd#DismwNuZwlnC3MxX_YvU|eT_wRnfz>iCJmBFj4|urC10L>r2TWBkZ1(EZ6#xJL07*qoM6N<$ Eg6OnkE&u=k diff --git a/public/res/apple/apple-touch-icon-72x72.png b/public/res/apple/apple-touch-icon-72x72.png index 32d5d51646aed74aa40cb39a4af3161802fb74d2..7aa6caab1206aa1727999a4e983d1d909f52102c 100644 GIT binary patch literal 4503 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 delta 1511 zcmVw*C@BjlSb{c*nOwX)#W}H?)nIM^? zj0vgfIUvAc5hEk4jV)7)CTnC^BgE`GW&y0{fRIA6aLm!Pa=E-eSRed+dkOupvz*4d zDrl%JM&~-BG}2K@XWNQW*11{9906)f1eM`9fy5s8`+pr}G|_W2P42tJRzK~ErGWAj z5KP!9AZFqs^G3zO10z~^V9Wx<;jP84Rx)KkGSM@>yNrH)=?+5^dn+@pIVnhkHKjos z-;Z;((KzC>H3M5q-Kt+r?ICZX=}ID*jJku?k`n_!*r&- z!n+iMhkw|?0R$@?rr!q}jmWb4QKrfk4+R~=Vd_{@JokWLuDs|$N@aq>boGsfv`E4} zDx2sTYAdJi`Y?Unnd(*FbSCB=5O&5qKmfo41YLQpF70#JiJN~99!>@hpycJ{QGb6w zZQatC_C6wUP61&@%u6p3944!ATcb$YYqrG4z?0P)%iJhUtz z_>uMVW-n6ln(eDYv@W)U4sD8f4+y2%4M5Pv-W4gIH}`8YU@r94(AAH7=&$eIr+>yj zHT1_Q3k}T zVom_1nDx%(H1c#z%75T9JUG+mhbR$^`wI}H3JAjP8e4>ko=TXOO;lat;jxdX)xa)U8Bkxf zBRE#U*xlGUX9XxAXjbvCDsZL`uFDz>n$lc=aSVtz?tq`j!kK;N0lM&9Rn~wWsVT}C z97ljK!^yT`g{~aBTM5MFqZ4}h543D#1;pnusK zU!@qtJMQ5P&CKZ~3&*ad`19PcBqXFl$~E%^bN$2nYKcQ9A)4if~5eD?kCkY-m(4ew8WjvtPU_1qjCNOF$yC=2bKq zK)i8=SpexvKu~WTzc;f>HJK7G1Am8AG~WUec@cz}j}?;0q_O9b1q2;$jrtQ%K;fhn zhldQ%wPVe~@~#4CPkqF{fC3VRZH^{v5HULvjW<+M%#GoEP64Uj6*6FTMY({iyJtIH z{OI|d!Ep_c6G&s7#auw5xieF5d&~R)ZTxu&2=|^vfIfSy)L=MFG~cGrq+CRi=Ya5E=Rq=mdk6OpGeN>@Zr-l<6(A;j z-vR)Tau9B*+NBKMlUet_0_0a|KpKz+qycF_K9&Zg0ck)QkOt&q{R`aM)sM=ki4Fh& N002ovPDHLkV1h$`;%5K= diff --git a/public/res/apple/apple-touch-icon-76x76.png b/public/res/apple/apple-touch-icon-76x76.png index 1164b72d4d511247bb3685036e3858b7de7aa3cd..1448098d13759d4c902b5288fce71e367cf64e37 100644 GIT binary patch literal 4748 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 delta 1590 zcmV-62Fdx1CB_VpBYy@BNkl_ttFlQorT6j%lXnLSLs{Wag4hdF!q z?mg$;Jqvf2^Wr<;?qRQd_&uL<7Nw@7k(Rs`&G&yG(WHTCV1F8zr=)>tU>caGq=9K* z8knb~foWhGm}~j(Sh6sI)#Rnq^K1N$ex^Lj`OhR3u#dN7(_>YcQtM$^XE$aUTF}6t zO{E#a^{_0qenA3*WibE{07hF2p!{06rYC`|DM+V-_Xp_xj%>R6c#ffSZCSLp&QHP2 z*n+Pa*t-u|gMYwlc#mJ(P$;&@IRzHDa|sPK2WBBf6JWXpX9mWg@mY|?E%Q3rxHl)j z0PCGlmJk-x=bhOUDtG=DA8HIlfPesqLF3~VuS07C-T?-elZC}==e^F-y&2di9XWJ) zdp>>h#BzFjO92fu=F?Z*%jmOhxt35p0Sv#i8(6%HI)4QQP*-*YX{^7D#-CkbscTOc z(-)n2VbJCs7+ewV6$>y();;k{!=mg!nFX)_tU6P=0w!x+qfZsk*zQug_GDhvHOQ`_ z=fT}%LGc>hRYI5AbChOESHSqv!Ab!1>ybM8VJAl$G|Fc(}k7=I6N$-Td%dy4}C#L@oZ#!aZJFVx*~O%(lM~E>P%Z$a6v0r zJT$nfjB9Qz&yE=u^hT&~{(y;!)M(bl>$j7Q5$=f+#r&*etp(ChM!JGj=wD3rwKa6K zH80}*K$@Ds?--b<_PRN=K?n?**k2>GvX6R7q88^3d3``X4 zWneLxUDIr3PTX!_eXaov0zC8kyVmA;511%^nKga9KH@X@e$#2JI@x_6?cY{QR}VGQ z^?~Z-0|uG1CZ-!sfz4dlLH~Svf&Lx67^dGx2I$!4VyX?M(Ootpp@ag4Bw?=0*t5=e z1b>CFZe2PM0g4xMQ=cy-Ie?+eFlS8EdU#g%s-?EDZX9l~g#|qlT5kYL@hwgcV9-x{ zE5beptrseMQCTrD*dhc5?d)n-0!ukCWIZ>)&_>N>+McEK<1_Nr{a>HHY71-X@_EYk z=gc24Q61(67~GPTCF6VKClU~{;h!H4T7SZt_}~<+tgKK5%M~zDrH?A!4YaY{C5~b7 z`uXM6G=1(-BV*$0cl*{EZmklQD`2A0j!bJ5VPUU7K-ZrucMZ!d#6DH6P|UzKlw{Zf zLjZRQ3~f@ZkiPCKGm6uqfZAQ6n1Nw-rLsM`In<=YssJorRuvO4NVHSrB#ybUE`P); ztJhJtc@h}f7gcWQ1Qde#-(Q}Y&4S2|C|=q77&dwmm}u$>w?wHRf;DrZK^hkNLaWjN zJtPk;-fro7n@u#=m&uk>hij!^O`h6pTbu$Kn@*@4M=J#;8db|w{lDy6DFw`G5@8nV zuW^NA9Ob}d_9hX~?SS2Q@opiksejWQQvD*JdKwsn-5yaavm05<027UG!djuh7H1TaZ67FOO@OOk5*>L0F#B4Fu)MYL>V(0w8;ajlmdhMK3uJCloJ)s(OQQlq%OeBp%@h4` zhZPrKOrnVfukU;FZLJK!Tm-D7uqd&CrQXh-B5PwOt8wtn&dTg|hdM(oa{?>wz<6-5 zI0g@p@I4xM{`BFQ2lI`ytbbM~rbqWwgtbXC?;W8lFExd&eXA|YIF}!fTWoU*Oh$e8 zSf8`!g=6N%?CWT&?K+&gPI_Qgw>ZqgadOP=FNPw|@%|7$Q- zcZztyT9Y0agwxJj%QKDZE^sRd-{UoNT*ANcI$H+;l-*6Yqyv@+(l5X?Fb&L8(!ew@ o4a`&0z%(!o%u~|9G_d&UKc&>qycpq$2><{907*qoM6N<$f+NBE)&Kwi diff --git a/public/res/svg/ratatoskr.svg b/public/res/svg/ratatoskr.svg index 3ca98122..1092ffc4 100644 --- a/public/res/svg/ratatoskr.svg +++ b/public/res/svg/ratatoskr.svg @@ -1,15 +1 @@ - - - - - - - - - - - - - - - + diff --git a/src/app/features/scene/BackgroundScene.tsx b/src/app/features/scene/BackgroundScene.tsx index 10785a5f..0c8d0fb3 100644 --- a/src/app/features/scene/BackgroundScene.tsx +++ b/src/app/features/scene/BackgroundScene.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { useClientConfig } from '../../hooks/useClientConfig'; import { getFallbackSession } from '../../state/sessions'; import { mountBackgroundScene } from './host'; import { readCapabilities, resolveTier } from './tier'; +import { getSceneStyleOverride } from './sceneStyle'; import './sceneShell.css'; /** Set on while a scene runs, so signed-out screens can let it show through. */ @@ -16,7 +17,11 @@ export const SCENE_ACTIVE_ATTRIBUTE = 'data-scene'; * Tier 'off' renders nothing and loads nothing. */ export function BackgroundScene() { - const config = useClientConfig().backgroundScene; + const configured = useClientConfig().backgroundScene; + const config = useMemo(() => { + const style = getSceneStyleOverride(); + return configured && style ? { ...configured, style } : configured; + }, [configured]); const enabled = resolveTier(config, readCapabilities(navigator)) !== 'off'; const canvasRef = useRef(null); diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx index 5a9d84d6..4e67bd81 100644 --- a/src/app/features/scene/SkyStateControl.tsx +++ b/src/app/features/scene/SkyStateControl.tsx @@ -1,9 +1,29 @@ import React, { MouseEventHandler, useState } from 'react'; -import { Box, config, Icon, IconButton, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds'; +import { + Box, + config, + Icon, + IconButton, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Text, +} from 'folds'; import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '../../utils/keyboard'; import { useSkyState } from '../../hooks/useSkyState'; import { SKY_STATES, setSkyState, type SkyState } from './skyState'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { + getSceneStyleOverride, + SERVED_SCENE_STYLES, + setSceneStyle, + type ServedSceneStyle, +} from './sceneStyle'; + +const STYLE_LABEL: Record = { terrain: 'Terrain', ridge: 'Ridges' }; const LABEL: Record = { auto: 'Auto', @@ -19,6 +39,9 @@ const LABEL: Record = { export function SkyStateControl() { const state = useSkyState(); const [cords, setCords] = useState(); + const configuredStyle = useClientConfig().backgroundScene?.style; + const sceneStyle: ServedSceneStyle = + getSceneStyleOverride() ?? (configuredStyle === 'terrain' ? 'terrain' : 'ridge'); const open: MouseEventHandler = (evt) => { setCords(evt.currentTarget.getBoundingClientRect()); @@ -62,6 +85,32 @@ export function SkyStateControl() { ))} + + Background + + {SERVED_SCENE_STYLES.map((st) => ( + { + setCords(undefined); + if (st !== sceneStyle) setSceneStyle(st); + }} + after={sceneStyle === st ? : undefined} + > + + {STYLE_LABEL[st]} + + + ))}
diff --git a/src/app/features/scene/sceneStyle.ts b/src/app/features/scene/sceneStyle.ts new file mode 100644 index 00000000..7c31233e --- /dev/null +++ b/src/app/features/scene/sceneStyle.ts @@ -0,0 +1,28 @@ +// Temporary: which background the viewer picked (terrain or ridges), kept in one localStorage key so the two scenes we +// serve can be compared on any device. Absent, the client config's backgroundScene.style decides. +import type { SceneStyle } from './tier'; + +export type ServedSceneStyle = Extract; +export const SERVED_SCENE_STYLES: readonly ServedSceneStyle[] = ['terrain', 'ridge']; +export const SCENE_STYLE_KEY = 'ratatoskr.sceneStyle'; + +export function getSceneStyleOverride(): ServedSceneStyle | undefined { + try { + const v = localStorage.getItem(SCENE_STYLE_KEY); + return SERVED_SCENE_STYLES.includes(v as ServedSceneStyle) + ? (v as ServedSceneStyle) + : undefined; + } catch { + return undefined; + } +} + +/** Store the pick and reload, so the scene host mounts the other scene from a clean page. */ +export function setSceneStyle(s: ServedSceneStyle): void { + try { + localStorage.setItem(SCENE_STYLE_KEY, s); + } catch { + return; + } + window.location.reload(); +} diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index a3c0c567..db168251 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -7,7 +7,7 @@ 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 { SKY_PLATE_FALLBACK } from './skyPlate'; +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 // outside a chat bubble would sit on bare sky, which a sky state can make light, dark or both @@ -47,7 +47,9 @@ const within = (ancestor: string) => plateRegions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', '); globalStyle(within(scene), { - backgroundColor: fill, + // Blur with a light tint toward the sky, like the chat bubbles; never an opaque slab (PLATE_TINT). + backgroundColor: [fill, `color-mix(in srgb, ${fill} ${PLATE_TINT * 100}%, transparent)`], + backdropFilter: 'blur(12px)', color: text, // Themed ink was chosen for the clear shell; inside the plate every token reads the plate's. vars: { diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts index 353134ab..b82a3026 100644 --- a/src/app/features/scene/skyPlate.ts +++ b/src/app/features/scene/skyPlate.ts @@ -18,3 +18,11 @@ export const SKY_PLATE_FALLBACK = { * on, the worst link contrast is 5.0 at Dusk, 5.7 at Day and 11.2 at Night. */ export const FOOTER_TINT = 0.82; + +/** + * How much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind a plate (title bars, lists, the + * composer, the sign-in card). Meta, 2026-11-09: "The app shouldn't need chrome, and where it does help for readability it + * should be like the chat bubbles, blur with a bit of tint towards the background color. If that's not working, the fix + * is in the background, not covering it up." + */ +export const PLATE_TINT = 0.45; diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 4ab3f43a..dbd46501 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -10,7 +10,7 @@ // derived outside this code (src/sky/skyTokens.js). Midnight Sky's link is derived there against the lightest Night sky // (4.53 on it, 5.43 on the darkest). The plate (skyPlate.css.ts) is still in the shell, pending // its removal. -import type { FixedSkyState, SkyState } from './skyState'; +import { resolveSkyState, type FixedSkyState, type SkyState } from './skyState'; export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; @@ -21,4 +21,4 @@ const BY_STATE: Record = { }; export const skyThemeId = (state: SkyState): SkyThemeId | undefined => - state === 'auto' ? undefined : BY_STATE[state]; + BY_STATE[resolveSkyState(state)]; diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts index 7296025b..64f18970 100644 --- a/src/app/features/scene/terrain/terrainWorld.ts +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -11,9 +11,9 @@ 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: 192, tex: 512 }, - low: { grid: 320, tex: 1024 }, - full: { grid: 384, tex: 1024 }, + still: { grid: 256, tex: 1024 }, + low: { grid: 512, tex: 1024 }, + full: { grid: 768, tex: 2048 }, } as const; /** The camera: at the patch centre, above the highest peak, turning slowly and looking a little down. */ diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index c53e5a65..0d5c9406 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -21,6 +21,7 @@ import { import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths'; import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; +import { SkyStateControl } from '../../features/scene/SkyStateControl'; import { ServerPicker } from './ServerPicker'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; @@ -137,6 +138,7 @@ export function AuthLayout() { Ratatoskr logo Ratatoskr + diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index fc47dd79..f16617e8 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -1,6 +1,6 @@ import { globalStyle, style } from '@vanilla-extract/css'; import { DefaultReset, color, config, toRem } from 'folds'; -import { FOOTER_TINT } from '../../features/scene/skyPlate'; +import { FOOTER_TINT, PLATE_TINT } from '../../features/scene/skyPlate'; export const AuthLayout = style({ minHeight: '100%', @@ -27,13 +27,16 @@ export const AuthCard = style({ export const AuthLogo = style([ DefaultReset, { - width: toRem(26), - height: toRem(26), + width: toRem(32), + height: toRem(32), borderRadius: '50%', }, ]); +// The mark is white (Meta's matte); on the light sky theme it takes the dark ink instead. +globalStyle('body.noonday-sky img[src*="ratatoskr"]', { filter: 'brightness(0.18)' }); + export const AuthHeader = style({ padding: `0 ${config.space.S400}`, borderBottomWidth: config.borderWidth.B300, @@ -67,22 +70,35 @@ export const AuthFooter = style({ // made for their own grounds, not this card; Dark and Butter already use the same hsl(213 100% 80%). // The card is also made shorter so it leaves more of the scene visible on a phone. const scene = ':root[data-scene]'; +const tint = `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${FOOTER_TINT * 100}%, transparent)`; globalStyle(`${scene} ${AuthLayout}`, { backgroundColor: 'transparent', backgroundImage: 'none', }); globalStyle(`${scene} ${AuthCard}`, { - backgroundColor: 'var(--sky-bubble, #0d1a26)', + // Meta, 2026-11-09: no chrome; where readability needs help, the chat-bubble treatment (blur with a tint toward the + // background), and if that is not enough the fix goes in the background, not in a plate. Same tint as the footer band. + backgroundColor: [ + 'var(--sky-bubble, #0d1a26)', + `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${PLATE_TINT * 100}%, transparent)`, + ], + backdropFilter: 'blur(12px)', color: 'var(--sky-bubble-text, #b7c7d9)', borderRadius: config.radii.R500, - // A faint inner edge, so a pane this clear still reads as a surface. - borderColor: 'rgba(183, 199, 217, 0.14)', + border: 'none', + boxShadow: 'none', }); globalStyle(`${scene} body:not(.gamecult-theme) ${AuthCard}`, { vars: { '--tc-link': '#99c7ff' }, }); globalStyle(`${scene} ${AuthHeader}`, { backgroundColor: 'transparent', + borderBottomWidth: 0, + boxShadow: 'none', +}); +globalStyle(`${scene} #root header.${AuthHeader}`, { + backgroundColor: 'transparent', + backdropFilter: 'none', }); globalStyle(`${scene} ${AuthCardContent}`, { paddingTop: config.space.S500, @@ -99,7 +115,6 @@ const FADE_Y = 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)'; const FADE_X = 'linear-gradient(to right, transparent, #000 24px, #000 calc(100% - 24px), transparent)'; -const tint = `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${FOOTER_TINT * 100}%, transparent)`; globalStyle(`${scene} ${AuthFooter}`, { paddingTop: config.space.S500, paddingBottom: config.space.S500, From fcabaee59ed17a552e198a06a5b01e9e10f319da Mon Sep 17 00:00:00 2001 From: Eureka Self Date: Tue, 10 Nov 2026 13:22:41 +0000 Subject: [PATCH 069/111] nemesis: chromeless buttons, chips and room list; plates only on the composer and plain messages Co-Authored-By: Claude Opus 5.5 --- src/app/components/nav/styles.css.ts | 9 +++---- src/app/features/scene/BackgroundScene.tsx | 1 + src/app/features/scene/sceneButtons.css.ts | 28 ++++++++++++++++++++++ src/app/features/scene/skyPlate.css.ts | 18 +++++++------- 4 files changed, 42 insertions(+), 14 deletions(-) create mode 100644 src/app/features/scene/sceneButtons.css.ts diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index feee3276..e5db6c47 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -5,10 +5,11 @@ import { surfaceInk } from '../../features/scene/sceneShell.css'; // Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows // are three distinct steps above them. -const SCRIM_LABEL = '12%'; -const SCRIM_ROW = '16%'; -const SCRIM_HOVER = '26%'; -const SCRIM_SELECTED = '36%'; +// Meta, 2026-11-09: "this UI needs no chrome". Rows and labels are bare ink on the scene; hover and selection keep a faint wash. +const SCRIM_LABEL = '0%'; +const SCRIM_ROW = '0%'; +const SCRIM_HOVER = '14%'; +const SCRIM_SELECTED = '22%'; const SCRIM_PRESSED = '48%'; export const NavCategory = style([ diff --git a/src/app/features/scene/BackgroundScene.tsx b/src/app/features/scene/BackgroundScene.tsx index 0c8d0fb3..c447b4f0 100644 --- a/src/app/features/scene/BackgroundScene.tsx +++ b/src/app/features/scene/BackgroundScene.tsx @@ -5,6 +5,7 @@ import { mountBackgroundScene } from './host'; import { readCapabilities, resolveTier } from './tier'; import { getSceneStyleOverride } from './sceneStyle'; import './sceneShell.css'; +import './sceneButtons.css'; /** Set on while a scene runs, so signed-out screens can let it show through. */ export const SCENE_ACTIVE_ATTRIBUTE = 'data-scene'; diff --git a/src/app/features/scene/sceneButtons.css.ts b/src/app/features/scene/sceneButtons.css.ts new file mode 100644 index 00000000..301d5671 --- /dev/null +++ b/src/app/features/scene/sceneButtons.css.ts @@ -0,0 +1,28 @@ +import { globalStyle } from '@vanilla-extract/css'; + +// Meta, 2026-11-10: "buttons are big and ugly, too, they don't need chrome either". Over the scene a folds Button or +// Chip is its label in the page's ink, with a faint wash on hover and focus. Menus and dialogs (#portalContainer) keep +// folds' look. The selectors are folds' hashed recipe base classes (Button epr39zd, Chip _1bv8u1w6). +const scene = ':root[data-scene] #root'; +const controls = [`${scene} .epr39zd`, `${scene} ._1bv8u1w6`].join(', '); +globalStyle(controls, { + backgroundColor: 'transparent', + boxShadow: 'none', + border: 'none', + outline: 'none', + color: 'inherit', +}); +globalStyle( + [ + `${scene} .epr39zd:hover`, + `${scene} .epr39zd:focus-visible`, + `${scene} ._1bv8u1w6:hover`, + `${scene} ._1bv8u1w6:focus-visible`, + ].join(', '), + { backgroundColor: 'color-mix(in srgb, currentColor 12%, transparent)' } +); +globalStyle(`${scene} .epr39zd`, { + fontWeight: 700, + textDecoration: 'underline', + textUnderlineOffset: '0.25em', +}); diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index db168251..4472b98e 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -24,20 +24,18 @@ export const SkyPlate = style({}); // A recipe's first class is its base style. 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}`, - // Title bars: the room header, the room list's header, every page header. - '#root header', - // The room list, and the pages that are lists (explore, inbox, search). - `.${PageNavContent}`, - `.${PageContent}`, - // The composer, the members drawer, and the call status bar. + // The composer and the call status bar. `.${Editor}`, - `.${MembersDrawer}`, `.${CallStatus}`, - // The bars over the composer. - `.${RoomViewTyping}`, - base(RoomViewFollowing), // A message that is not a bubble (Modern, Compact) is plain text on the scene. `${base(MessageBase)}:not(:has(.${BubbleContent}))`, ]; From c4689299ca191648a7694dab05d03182634deab0 Mon Sep 17 00:00:00 2001 From: Eureka Self Date: Tue, 10 Nov 2026 13:46:51 +0000 Subject: [PATCH 070/111] nemesis: tab icon and notification icon are the night-sky tile (unread and highlight add a dot), not the white UI mark Co-Authored-By: Claude Opus 5.5 --- public/res/svg/ratatoskr-favicon-highlight.svg | 1 + public/res/svg/ratatoskr-favicon-unread.svg | 1 + public/res/svg/ratatoskr-favicon.svg | 1 + public/res/svg/ratatoskr-highlight.svg | 17 ----------------- public/res/svg/ratatoskr-unread.svg | 17 ----------------- src/app/pages/client/ClientNonUIFeatures.tsx | 14 +++++++------- 6 files changed, 10 insertions(+), 41 deletions(-) create mode 100644 public/res/svg/ratatoskr-favicon-highlight.svg create mode 100644 public/res/svg/ratatoskr-favicon-unread.svg create mode 100644 public/res/svg/ratatoskr-favicon.svg delete mode 100644 public/res/svg/ratatoskr-highlight.svg delete mode 100644 public/res/svg/ratatoskr-unread.svg diff --git a/public/res/svg/ratatoskr-favicon-highlight.svg b/public/res/svg/ratatoskr-favicon-highlight.svg new file mode 100644 index 00000000..f9a95e82 --- /dev/null +++ b/public/res/svg/ratatoskr-favicon-highlight.svg @@ -0,0 +1 @@ + diff --git a/public/res/svg/ratatoskr-favicon-unread.svg b/public/res/svg/ratatoskr-favicon-unread.svg new file mode 100644 index 00000000..f5bcb7d8 --- /dev/null +++ b/public/res/svg/ratatoskr-favicon-unread.svg @@ -0,0 +1 @@ + diff --git a/public/res/svg/ratatoskr-favicon.svg b/public/res/svg/ratatoskr-favicon.svg new file mode 100644 index 00000000..0c7cc393 --- /dev/null +++ b/public/res/svg/ratatoskr-favicon.svg @@ -0,0 +1 @@ + diff --git a/public/res/svg/ratatoskr-highlight.svg b/public/res/svg/ratatoskr-highlight.svg deleted file mode 100644 index 661442d4..00000000 --- a/public/res/svg/ratatoskr-highlight.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/public/res/svg/ratatoskr-unread.svg b/public/res/svg/ratatoskr-unread.svg deleted file mode 100644 index 69a08c95..00000000 --- a/public/res/svg/ratatoskr-unread.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index db3eefc2..62385769 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -3,9 +3,9 @@ import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; -import LogoSVG from '../../../../public/res/svg/ratatoskr.svg'; -import LogoUnreadSVG from '../../../../public/res/svg/ratatoskr-unread.svg'; -import LogoHighlightSVG from '../../../../public/res/svg/ratatoskr-highlight.svg'; +import FaviconSVG from '../../../../public/res/svg/ratatoskr-favicon.svg'; +import FaviconUnreadSVG from '../../../../public/res/svg/ratatoskr-favicon-unread.svg'; +import FaviconHighlightSVG from '../../../../public/res/svg/ratatoskr-favicon-highlight.svg'; import NotificationSound from '../../../../public/sound/notification.ogg'; import InviteSound from '../../../../public/sound/invite.ogg'; import { notificationPermission, setFavicon } from '../../utils/dom'; @@ -67,9 +67,9 @@ function FaviconUpdater() { }); if (notification) { - setFavicon(highlight ? LogoHighlightSVG : LogoUnreadSVG); + setFavicon(highlight ? FaviconHighlightSVG : FaviconUnreadSVG); } else { - setFavicon(LogoSVG); + setFavicon(FaviconSVG); } }, [roomToUnread]); @@ -89,8 +89,8 @@ function InviteNotifications() { const notify = useCallback( (count: number) => { const noti = new window.Notification('Invitation', { - icon: LogoSVG, - badge: LogoSVG, + icon: FaviconSVG, + badge: FaviconSVG, body: `You have ${count} new invitation request.`, silent: true, }); From 0fe41efb9b2a4f0fe3326eb682d366877f471084 Mon Sep 17 00:00:00 2001 From: Eureka Self Date: Tue, 10 Nov 2026 13:51:26 +0000 Subject: [PATCH 071/111] nemesis: Persona poses on by default; Safari 14 polyfills (Array.at/findLast, Object.hasOwn, structuredClone) and -webkit-backdrop-filter Co-Authored-By: Claude Opus 5.5 --- src/app/features/scene/skyPlate.css.ts | 1 + src/app/pages/auth/styles.css.ts | 3 ++ src/app/state/settings.ts | 2 +- src/index.tsx | 1 + src/polyfills.ts | 72 ++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/polyfills.ts diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index 4472b98e..f97efe26 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -48,6 +48,7 @@ globalStyle(within(scene), { // Blur with a light tint toward the sky, like the chat bubbles; never an opaque slab (PLATE_TINT). backgroundColor: [fill, `color-mix(in srgb, ${fill} ${PLATE_TINT * 100}%, transparent)`], backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', color: text, // Themed ink was chosen for the clear shell; inside the plate every token reads the plate's. vars: { diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index f16617e8..2341fe80 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -83,6 +83,7 @@ globalStyle(`${scene} ${AuthCard}`, { `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${PLATE_TINT * 100}%, transparent)`, ], backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', color: 'var(--sky-bubble-text, #b7c7d9)', borderRadius: config.radii.R500, border: 'none', @@ -99,6 +100,7 @@ globalStyle(`${scene} ${AuthHeader}`, { globalStyle(`${scene} #root header.${AuthHeader}`, { backgroundColor: 'transparent', backdropFilter: 'none', + WebkitBackdropFilter: 'none', }); globalStyle(`${scene} ${AuthCardContent}`, { paddingTop: config.space.S500, @@ -120,6 +122,7 @@ globalStyle(`${scene} ${AuthFooter}`, { paddingBottom: config.space.S500, backgroundColor: ['var(--sky-bubble, #0d1a26)', tint], backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', maskImage: `${FADE_Y}, ${FADE_X}`, maskComposite: 'intersect', color: 'var(--sky-bubble-text, #b7c7d9)', diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 2b48fb59..1031141f 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -85,7 +85,7 @@ const defaultSettings: Settings = { encUrlPreview: false, showHiddenEvents: false, legacyUsernameColor: false, - poseBubbles: false, + poseBubbles: true, showNotifications: true, isNotificationSounds: true, diff --git a/src/index.tsx b/src/index.tsx index c2461fe8..6c7a3bc2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,5 @@ /* eslint-disable import/first */ +import './polyfills'; import './preview-demo/seed'; import React from 'react'; import { createRoot } from 'react-dom/client'; diff --git a/src/polyfills.ts b/src/polyfills.ts new file mode 100644 index 00000000..2f018c4b --- /dev/null +++ b/src/polyfills.ts @@ -0,0 +1,72 @@ +/* eslint-disable no-extend-native, func-names */ +// Safari 14 (iPadOS 14) lacks these; the bundle (ours and matrix-js-sdk) calls them at runtime. Safari 15.4 has all. +// Loaded first by src/index.tsx. Each is installed only when missing. + +function at(this: ArrayLike, n: number): T | undefined { + const len = this.length; + const i = Math.trunc(n) || 0; + const k = i < 0 ? len + i : i; + return k < 0 || k >= len ? undefined : this[k]; +} +if (!Array.prototype.at) Object.defineProperty(Array.prototype, 'at', { value: at, writable: true, configurable: true }); +if (!String.prototype.at) + Object.defineProperty(String.prototype, 'at', { value: at, writable: true, configurable: true }); + +if (!(Array.prototype as unknown as Record).findLast) + Object.defineProperty(Array.prototype, 'findLast', { + value: function (this: T[], fn: (v: T, i: number, a: T[]) => unknown, thisArg?: unknown) { + for (let i = this.length - 1; i >= 0; i -= 1) if (fn.call(thisArg, this[i], i, this)) return this[i]; + return undefined; + }, + writable: true, + configurable: true, + }); +if (!(Array.prototype as unknown as Record).findLastIndex) + Object.defineProperty(Array.prototype, 'findLastIndex', { + value: function (this: T[], fn: (v: T, i: number, a: T[]) => unknown, thisArg?: unknown) { + for (let i = this.length - 1; i >= 0; i -= 1) if (fn.call(thisArg, this[i], i, this)) return i; + return -1; + }, + writable: true, + configurable: true, + }); + +if (!(Object as unknown as Record).hasOwn) + Object.defineProperty(Object, 'hasOwn', { + value: (o: object, k: PropertyKey) => Object.prototype.hasOwnProperty.call(o, k), + writable: true, + configurable: true, + }); + +// Structured clone for the data the client clones: plain objects, arrays, Map, Set, Date, RegExp, typed arrays, with cycles. +if (typeof (globalThis as { structuredClone?: unknown }).structuredClone !== 'function') { + const clone = (v: unknown, seen: Map): unknown => { + if (v === null || typeof v !== 'object') return v; + if (seen.has(v)) return seen.get(v); + if (v instanceof Date) return new Date(v.getTime()); + if (v instanceof RegExp) return new RegExp(v.source, v.flags); + if (ArrayBuffer.isView(v)) return (v as unknown as { slice: () => unknown }).slice(); + if (v instanceof ArrayBuffer) return v.slice(0); + if (v instanceof Map) { + const m = new Map(); + seen.set(v, m); + v.forEach((val, key) => m.set(clone(key, seen), clone(val, seen))); + return m; + } + if (v instanceof Set) { + const s = new Set(); + seen.set(v, s); + v.forEach((val) => s.add(clone(val, seen))); + return s; + } + const out: Record | unknown[] = Array.isArray(v) ? [] : {}; + seen.set(v, out); + Object.keys(v).forEach((k) => { + (out as Record)[k] = clone((v as Record)[k], seen); + }); + return out; + }; + (globalThis as { structuredClone?: unknown }).structuredClone = (v: unknown) => clone(v, new Map()); +} + +export {}; From 9d3343bb316c4d8acc8b12acd51be1863cfcf643 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 13:53:22 +0000 Subject: [PATCH 072/111] nemesis/demo: tests follow the new numbers; Ridge retries a zero-size start skyTheme: auto resolves to the clock's theme; the plate pins now assert the chromeless shell (only composer, call bar, call view and plain messages are plates). terrainWorld: ceilings for the 512/768 grids; still grid 256 -> 320 so its quads stay within two height texels at the nearest ground (the test is unchanged). skyPlate.css: drop the void-import placeholders. Ridge: a scene started with no area no longer throws on drawImage or returns null; it builds on the first resize that gives it one. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/ridge/Ridge.test.ts | 24 ++++++++++++++ src/app/features/scene/ridge/Ridge.ts | 14 +++++++-- src/app/features/scene/skyPlate.css.ts | 9 ------ src/app/features/scene/skyTheme.test.ts | 31 +++++++++++++------ .../scene/terrain/terrainWorld.test.ts | 9 ++++-- .../features/scene/terrain/terrainWorld.ts | 2 +- 6 files changed, 66 insertions(+), 23 deletions(-) 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; From 384cd17dac344bc195a2d92ab7a881d575c51df2 Mon Sep 17 00:00:00 2001 From: Nibu Date: Tue, 10 Nov 2026 14:00:44 +0000 Subject: [PATCH 073/111] scene: terrain fine grain domain-warped so its plane waves stop reading as hatching The fine albedo octave is three plane waves per projection (about 5 m periods): a regular lattice, resolved (not aliased) at near-ground pixel size, so it reads as diagonal hatching. Switching the octave off in a SwiftShader render removes it; coarse grain off does not. The octave's coordinates are now pushed by a slower sine each, in terrainAlbedo.wavesWarped and the shader's warpq. Co-Authored-By: Claude Sonnet 5.5 --- .../scene/terrain/terrainAlbedo.test.ts | 2 ++ .../features/scene/terrain/terrainAlbedo.ts | 19 +++++++++++++++---- .../features/scene/terrain/terrainShaders.ts | 5 ++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 17363282..6afac79d 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -114,6 +114,8 @@ describe('shader agrees with the pure function on its numbers', () => { expect(GRID_FRAG).toContain(`#define ALB_WOBBLE_S ${ALBEDO.wobbleSlope}`); expect(GRID_FRAG).toContain(`#define ALB_GRAIN_COARSE ${ALBEDO.grainCoarse}`); expect(GRID_FRAG).toContain(`#define ALB_STRATA ${ALBEDO.strata}`); + expect(GRID_FRAG).toContain(`#define ALB_FINE_WARP ${ALBEDO.fineWarp.toFixed(1)}`); + expect(GRID_FRAG).toContain('waves3(warpq(qy),'); // the only fetch for colour-by-material would be a texture; the height map's texelFetch is the only other read, and the sky lookup is the baked scattering table's expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky|uAP)/g)).toHaveLength(1); }); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index fef204ff..48dc2b42 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -31,6 +31,8 @@ export const ALBEDO = { /** Albedo grain: relative swing of the two sine-product octaves (about 20 m and 5 m periods), and of the rock strata. */ grainCoarse: 0.35, grainFine: 0.3, + /** Metres the fine octave's coordinates are pushed by a slower sine, so its parallel stripes bend and do not read as hatching. */ + fineWarp: 7, /** The fine octave fades out between these distances (metres): beyond them it is under a pixel and only shimmers. */ fineNear: 250, fineFar: 700, @@ -132,6 +134,13 @@ const FINE = [ ] as const; const waves = (k: ReadonlyArray, a: number, b: number) => k.reduce((sum, [ka, kb]) => sum + Math.sin(a * ka + b * kb), 0) / 3; +/** `waves` of coordinates warped by a sine each (the shader's `warpq`): three plane waves alone make a regular lattice. */ +const wavesWarped = (k: ReadonlyArray, a: number, b: number) => + waves( + k, + a + ALBEDO.fineWarp * Math.sin(b * 0.55 + 1.3), + b + ALBEDO.fineWarp * Math.sin(a * 0.47 + 0.7) + ); /** * The multiplier on albedo that gives the surface grain and the rock its layers, at a surface point (metres) with unit @@ -148,10 +157,12 @@ export function grainAt( distance: number ): number { const w = triWeights(n); - const over = (k: ReadonlyArray) => - w[0] * waves(k, z, y) + w[1] * waves(k, x, z) + w[2] * waves(k, x, y); - const coarse = over(COARSE); - const fine = over(FINE) * (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); + const over = ( + k: ReadonlyArray, + f: typeof waves + ) => w[0] * f(k, z, y) + w[1] * f(k, x, z) + w[2] * f(k, x, y); + const coarse = over(COARSE, waves); + const fine = over(FINE, wavesWarped) * (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); const layers = Math.sin(y * 0.9 + wobbleAt(x, y, z, n) * 3); return Math.max( 0, diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index b79e2873..f6bcce37 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -133,6 +133,7 @@ const ALBEDO_DEFINES = ` #define ALB_WOBBLE_S ${lit(ALBEDO.wobbleSlope)} #define ALB_GRAIN_COARSE ${lit(ALBEDO.grainCoarse)} #define ALB_GRAIN_FINE ${lit(ALBEDO.grainFine)} +#define ALB_FINE_WARP ${lit(ALBEDO.fineWarp)} #define ALB_STRATA ${lit(ALBEDO.strata)} #define ALB_FINE_NEAR ${lit(ALBEDO.fineNear)} #define ALB_FINE_FAR ${lit(ALBEDO.fineFar)} @@ -175,6 +176,8 @@ ${FETCH} // Triplanar patterns: each projection plane's value, blended by the weights of the face's normal (terrainAlbedo.triWeights). float wob2(vec2 q) { return sin(q.x * 0.043 + q.y * 0.017) * sin(q.y * 0.051 - q.x * 0.023); } float waves3(vec2 q, vec2 a, vec2 b, vec2 c) { return (sin(dot(q, a)) + sin(dot(q, b)) + sin(dot(q, c))) / 3.0; } +// The fine octave's coordinates pushed by a slower sine each (terrainAlbedo wavesWarped): three plane waves alone make a regular lattice. +vec2 warpq(vec2 q) { return q + ALB_FINE_WARP * vec2(sin(q.y * 0.55 + 1.3), sin(q.x * 0.47 + 0.7)); } void main() { // Normal from the bilinear height at a continuous position, a stencil 1.5 texels each side: smooth across texel borders. vec2 p = v_uv * float(uTex - 1); @@ -195,7 +198,7 @@ void main() { vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); // Surface grain and rock strata, the same arithmetic as terrainAlbedo.grainAt(). float coarse = tw.x * waves3(qx, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.y * waves3(qy, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.z * waves3(qz, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)); - float fine = tw.x * waves3(qx, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.y * waves3(qy, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.z * waves3(qz, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)); + float fine = tw.x * waves3(warpq(qx), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.y * waves3(warpq(qy), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.z * waves3(warpq(qz), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)); float near = 1.0 - smoothstep(ALB_FINE_NEAR, ALB_FINE_FAR, length(v_world - uCamera)); float layers = sin(v_world.y * 0.9 + w * 3.0); albedo *= max(0.0, 1.0 + ALB_GRAIN_COARSE * coarse + ALB_GRAIN_FINE * near * fine + ALB_STRATA * rockW * layers); From c657d686152aa8c8cb470cc0181b4ffe827323aa Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 14:03:21 +0000 Subject: [PATCH 074/111] PR #65 steward ruling: user themes win over auto; focus ring; logo on light themes skyThemeId('auto') is undefined again as on dev. sceneThemeId follows the resolved sky for auto only while a scene is on and the person chose no theme (signed-out pages pass none); useResolvedSkyState changes as the clock crosses so theme classes re-render. Scene buttons and chips keep a focus-visible outline. The white mark takes the dark filter through a LogoMark class on every themed under any prism-light theme (favicon and notification icons are files and keep the white mark). Background picker is hidden without a backgroundScene config. Ridge comment states what hasNoArea covers. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/logo/logoMark.css.ts | 9 ++++ src/app/features/scene/SkyStateControl.tsx | 59 ++++++++++++---------- src/app/features/scene/ridge/Ridge.ts | 2 +- src/app/features/scene/sceneButtons.css.ts | 6 ++- src/app/features/scene/skyTheme.test.ts | 57 +++++++++++++++++---- src/app/features/scene/skyTheme.ts | 19 ++++++- src/app/features/settings/about/About.tsx | 2 + src/app/hooks/useSkyState.ts | 20 +++++++- src/app/hooks/useTheme.ts | 27 ++++++---- src/app/pages/auth/AuthLayout.tsx | 7 ++- src/app/pages/auth/styles.css.ts | 3 -- src/app/pages/client/WelcomePage.tsx | 11 +++- src/app/state/settings.ts | 4 +- 13 files changed, 167 insertions(+), 59 deletions(-) create mode 100644 src/app/components/logo/logoMark.css.ts diff --git a/src/app/components/logo/logoMark.css.ts b/src/app/components/logo/logoMark.css.ts new file mode 100644 index 00000000..4c967088 --- /dev/null +++ b/src/app/components/logo/logoMark.css.ts @@ -0,0 +1,9 @@ +import { globalStyle, style } from '@vanilla-extract/css'; + +/** + * The Ratatoskr mark is white on transparent (Meta's matte). On any light theme (they all carry `prism-light`) a class + * of this name darkens it to the dark ink; put it on every that shows the mark on a themed surface. The favicon and + * notification icons are files the browser draws and cannot take it. + */ +export const LogoMark = style({}); +globalStyle(`body.prism-light .${LogoMark}`, { filter: 'brightness(0.18)' }); diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx index 4e67bd81..b664dc07 100644 --- a/src/app/features/scene/SkyStateControl.tsx +++ b/src/app/features/scene/SkyStateControl.tsx @@ -39,7 +39,8 @@ const LABEL: Record = { export function SkyStateControl() { const state = useSkyState(); const [cords, setCords] = useState(); - const configuredStyle = useClientConfig().backgroundScene?.style; + const backgroundScene = useClientConfig().backgroundScene; + const configuredStyle = backgroundScene?.style; const sceneStyle: ServedSceneStyle = getSceneStyleOverride() ?? (configuredStyle === 'terrain' ? 'terrain' : 'ridge'); @@ -85,32 +86,36 @@ export function SkyStateControl() { ))} - - Background - - {SERVED_SCENE_STYLES.map((st) => ( - { - setCords(undefined); - if (st !== sceneStyle) setSceneStyle(st); - }} - after={sceneStyle === st ? : undefined} - > - - {STYLE_LABEL[st]} - - - ))} + {backgroundScene && ( + <> + + Background + + {SERVED_SCENE_STYLES.map((st) => ( + { + setCords(undefined); + if (st !== sceneStyle) setSceneStyle(st); + }} + after={sceneStyle === st ? : undefined} + > + + {STYLE_LABEL[st]} + + + ))} + + )}
diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index d2312aaf..69a88864 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -180,7 +180,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { built = undefined; }; - /** True while the host has no area to paint (a scene started in a hidden or collapsed container). */ + /** True while there is nothing to paint on: the canvas has no client size and the window itself is 0 x 0 (`viewSize` falls back to the window). */ const hasNoArea = () => { const { w, h, scale } = viewSize(); const size = layerSize(w, h, scale); diff --git a/src/app/features/scene/sceneButtons.css.ts b/src/app/features/scene/sceneButtons.css.ts index 301d5671..5c96e71b 100644 --- a/src/app/features/scene/sceneButtons.css.ts +++ b/src/app/features/scene/sceneButtons.css.ts @@ -9,7 +9,6 @@ globalStyle(controls, { backgroundColor: 'transparent', boxShadow: 'none', border: 'none', - outline: 'none', color: 'inherit', }); globalStyle( @@ -21,6 +20,11 @@ globalStyle( ].join(', '), { backgroundColor: 'color-mix(in srgb, currentColor 12%, transparent)' } ); +// A visible keyboard focus ring in the control's own ink, on top of the hover wash. +globalStyle([`${scene} .epr39zd:focus-visible`, `${scene} ._1bv8u1w6:focus-visible`].join(', '), { + outline: '2px solid currentColor', + outlineOffset: '2px', +}); globalStyle(`${scene} .epr39zd`, { fontWeight: 700, textDecoration: 'underline', diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 80bc0423..18435ae6 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { atStandardTime } from './standardTime.testkit'; import { baked } from '../../../sky/baked'; import { AA, NIGHT_SKY_LIGHTEST, contrast, luminance } from '../../../sky/skyTokens.js'; @@ -9,10 +9,15 @@ import { MembersDrawer } from '../room/MembersDrawer.css'; import { RoomViewTyping } from '../room/RoomViewTyping.css'; import { FOOTER_TINT, SKY_PLATE_FALLBACK } from './skyPlate'; import { SkyPlate, plateRegions } from './skyPlate.css'; -import { getResolvedSkyState, setSkyClock, skyBackdrop } from './skyState'; +import { + getResolvedSkyState, + setSkyClock, + skyBackdrop, + subscribe as subscribeSky, +} from './skyState'; import type { SkyBackdrop } from './skyState'; import type { FixedSkyState } from './skyBand'; -import { skyThemeId } from './skyTheme'; +import { sceneThemeId, skyThemeId } from './skyTheme'; const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; const fixed = (['day', 'dusk', 'night'] as const).flatMap((s) => { @@ -39,17 +44,47 @@ describe('skyThemeId', () => { expect(skyThemeId('night')).toBe('midnight-sky'); }); - it('picks the theme of the state auto resolves to, at any hour', () => { + it('picks none for auto, at any hour: auto never overrides a signed-in theme', () => { [0, 6, 12, 18, 23].forEach((h) => { setSkyClock(() => atStandardTime(h, 10)); - const id = skyThemeId('auto'); - expect(id).toBeDefined(); - expect(id).toBe(skyThemeId(getResolvedSkyState())); + expect(skyThemeId('auto')).toBeUndefined(); }); - setSkyClock(() => atStandardTime(12)); - expect(skyThemeId('auto')).toBe('noonday-sky'); - setSkyClock(() => atStandardTime(0)); - expect(skyThemeId('auto')).toBe('midnight-sky'); + setSkyClock(); + }); + + describe('sceneThemeId, the theme the page wears', () => { + it('a fixed state always picks its theme, whatever the scene or the person chose', () => { + [true, false].forEach((scene) => + [true, false].forEach((chose) => { + expect(sceneThemeId('day', 'night', scene, chose)).toBe('noonday-sky'); + expect(sceneThemeId('night', 'day', scene, chose)).toBe('midnight-sky'); + }) + ); + }); + + it('auto follows the resolved sky only while a scene is on and the person chose no theme', () => { + expect(sceneThemeId('auto', 'day', true, false)).toBe('noonday-sky'); + expect(sceneThemeId('auto', 'dusk', true, false)).toBe('noonday-sky'); + expect(sceneThemeId('auto', 'night', true, false)).toBe('midnight-sky'); + expect(sceneThemeId('auto', 'day', false, false)).toBeUndefined(); + expect(sceneThemeId('auto', 'day', true, true)).toBeUndefined(); + }); + }); + + it('the resolved sky a hook subscribes to changes as the clock crosses, so theme classes follow', () => { + vi.useFakeTimers(); + let hour = 12; + setSkyClock(() => atStandardTime(hour, 10)); + const seen: string[] = []; + const off = subscribeSky((s) => seen.push(s)); + expect(getResolvedSkyState()).toBe('day'); + hour = 0; + setSkyClock(() => atStandardTime(hour, 10)); + vi.advanceTimersByTime(61_000); + expect(seen).toEqual(['night']); + expect(getResolvedSkyState()).toBe('night'); + off(); + vi.useRealTimers(); setSkyClock(); }); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index dbd46501..3d427cb2 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -10,7 +10,7 @@ // derived outside this code (src/sky/skyTokens.js). Midnight Sky's link is derived there against the lightest Night sky // (4.53 on it, 5.43 on the darkest). The plate (skyPlate.css.ts) is still in the shell, pending // its removal. -import { resolveSkyState, type FixedSkyState, type SkyState } from './skyState'; +import type { FixedSkyState, SkyState } from './skyState'; export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; @@ -21,4 +21,19 @@ const BY_STATE: Record = { }; export const skyThemeId = (state: SkyState): SkyThemeId | undefined => - BY_STATE[resolveSkyState(state)]; + state === 'auto' ? undefined : BY_STATE[state]; + +/** + * The theme the page wears: a fixed sky state always picks its theme; 'auto' follows the resolved sky (`resolved`, the + * clock's state) only while a scene is on and the person has not chosen a theme of their own, and otherwise picks none. + * Signed-out pages choose no theme, so they pass `userChoseTheme: false`. + */ +export const sceneThemeId = ( + state: SkyState, + resolved: FixedSkyState, + sceneOn: boolean, + userChoseTheme: boolean +): SkyThemeId | undefined => { + if (state !== 'auto') return BY_STATE[state]; + return sceneOn && !userChoseTheme ? BY_STATE[resolved] : undefined; +}; diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 6b304965..9ec74305 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -4,6 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; +import { LogoMark } from '../../../components/logo/logoMark.css'; import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg'; import { clearCacheAndReload } from '../../../../client/initMatrix'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -37,6 +38,7 @@ export function About({ requestClose }: AboutProps) { Ratatoskr logo useSyncExternalStore(subscribe, getSkyState); + +/** The sky being drawn: day, dusk or night. Unlike the choice it changes as the clock crosses, so it re-renders. */ +export const useResolvedSkyState = (): FixedSkyState => + useSyncExternalStore(subscribe, getResolvedSkyState); + +/** Whether a background scene runs on this device and config (the tier the scene host will mount). */ +export const useSceneOn = (): boolean => + resolveTier(useClientConfig().backgroundScene, readCapabilities(navigator)) !== 'off'; diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 00ac1ee9..60f89757 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -10,10 +10,10 @@ import { } from '../../colors.css'; import '../../gamecult.css'; import '../../sky.css'; -import { settingsAtom } from '../state/settings'; +import { defaultThemeId, settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; -import { skyThemeId } from '../features/scene/skyTheme'; -import { useSkyState } from './useSkyState'; +import { sceneThemeId } from '../features/scene/skyTheme'; +import { useResolvedSkyState, useSceneOn, useSkyState } from './useSkyState'; export enum ThemeKind { Light = 'light', @@ -102,20 +102,29 @@ export const useSystemThemeKind = (): ThemeKind => { return themeKind; }; -/** The theme the sky state asks for, or undefined while it is 'auto': auto never overrides a person's theme. */ -export const useSkyTheme = (): Theme | undefined => { - const id = skyThemeId(useSkyState()); - return id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme; -}; +const toTheme = (id: ReturnType): Theme | undefined => + id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme; + +/** A theme the person chose: the system scheme, or one other than the default. A saved Midnight Sky is the default. */ +const chosenTheme = (useSystemTheme: boolean, themeId?: string) => + useSystemTheme || (themeId !== undefined && themeId !== defaultThemeId); + +/** The theme the sky asks for on a page with no theme of its own (signed out), or undefined for none. */ +export const useSkyTheme = (): Theme | undefined => + toTheme(sceneThemeId(useSkyState(), useResolvedSkyState(), useSceneOn(), false)); export const useActiveTheme = (): Theme => { - const skyTheme = useSkyTheme(); + const sky = useSkyState(); + const resolved = useResolvedSkyState(); + const sceneOn = useSceneOn(); const systemThemeKind = useSystemThemeKind(); const themes = useThemes(); const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); const [themeId] = useSetting(settingsAtom, 'themeId'); const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId'); const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId'); + // A person's own theme wins over 'auto'; a fixed sky state is itself a choice and wins over the theme. + const skyTheme = toTheme(sceneThemeId(sky, resolved, sceneOn, chosenTheme(systemTheme, themeId))); if (skyTheme) return skyTheme; diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index 0d5c9406..49c7b774 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -21,6 +21,7 @@ import { import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths'; import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; +import { LogoMark } from '../../components/logo/logoMark.css'; import { SkyStateControl } from '../../features/scene/SkyStateControl'; import { ServerPicker } from './ServerPicker'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; @@ -135,7 +136,11 @@ export function AuthLayout() {
- Ratatoskr logo + Ratatoskr logo Ratatoskr diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index f16617e8..56ee090f 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -34,9 +34,6 @@ export const AuthLogo = style([ }, ]); -// The mark is white (Meta's matte); on the light sky theme it takes the dark ink instead. -globalStyle('body.noonday-sky img[src*="ratatoskr"]', { filter: 'brightness(0.18)' }); - export const AuthHeader = style({ padding: `0 ${config.space.S400}`, borderBottomWidth: config.borderWidth.B300, diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 74869c3f..8b63af73 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds'; import { Page, PageHero, PageHeroSection } from '../../components/page'; +import { LogoMark } from '../../components/logo/logoMark.css'; import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; export function WelcomePage() { @@ -14,7 +15,15 @@ export function WelcomePage() { > } + icon={ + Ratatoskr logo + } title="Welcome to Ratatoskr" subTitle={ diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 2b48fb59..9ca2496f 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -58,8 +58,10 @@ export interface Settings { soundscapeVoiceCap: number; } +export const defaultThemeId = 'midnight-sky'; + const defaultSettings: Settings = { - themeId: 'midnight-sky', + themeId: defaultThemeId, useSystemTheme: false, lightThemeId: undefined, darkThemeId: undefined, From d2d6fbcc0012cf05bb43197dd16e8665633db405 Mon Sep 17 00:00:00 2001 From: Eureka Self Date: Tue, 10 Nov 2026 14:04:34 +0000 Subject: [PATCH 075/111] nemesis: lookbehind regexes degrade where the engine lacks lookbehind (Safari < 16.4 blanked at load); markdown rules share regex.ts patterns Co-Authored-By: Claude Opus 5.5 --- src/app/plugins/markdown/inline/rules.ts | 3 +-- src/app/utils/regex.ts | 21 +++++++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/app/plugins/markdown/inline/rules.ts b/src/app/plugins/markdown/inline/rules.ts index 77bcbd57..2a7c6016 100644 --- a/src/app/plugins/markdown/inline/rules.ts +++ b/src/app/plugins/markdown/inline/rules.ts @@ -1,8 +1,7 @@ import { InlineMDRule } from './type'; +import { ESC_NEG_LB, URL_NEG_LB } from '../../../utils/regex'; const MIN_ANY = '(.+?)'; -const URL_NEG_LB = '(? unsafeText.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); -export const HTTP_URL_PATTERN = `https?:\\/\\/(?:www\\.)?(?:[^\\s)]*)(? { + try { + return new RegExp('(?<=a)b').test('ab'); + } catch { + return false; + } +})(); + +// Without lookbehind a URL keeps any trailing punctuation. +export const HTTP_URL_PATTERN = LOOKBEHIND + ? `https?:\\/\\/(?:www\\.)?(?:[^\\s)]*)(?()[\]\\.,;:\s@\\"]+(\.[^<>()[\]\\.,;:\s@\\"]+)*)|(\\".+\\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; -export const URL_NEG_LB = '(? Date: Tue, 10 Nov 2026 14:05:42 +0000 Subject: [PATCH 076/111] Diagnostic overlays (?probe=1, ?tierlabel=1) only where allowed; footer band test knows the Safari prefix debugOverlaysAllowed: a dev build, a VITE_PREVIEW_DEMO build, or backgroundScene.debugOverlays in the client config. A production visitor adding the query string gets neither the frame readout nor the tier label. footerBand.test pins -webkit-backdrop-filter (live commit 0fe41efb) and that it blurs as the standard one. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/debugOverlays.test.ts | 49 +++++++++++++++++++ src/app/features/scene/debugOverlays.ts | 15 ++++++ src/app/features/scene/index.ts | 6 ++- src/app/features/scene/ridge/Ridge.test.ts | 13 ++++- src/app/features/scene/ridge/Ridge.ts | 7 ++- src/app/features/scene/terrain/Terrain.ts | 9 ++-- .../features/scene/terrain/tierLabel.test.ts | 12 +++-- src/app/features/scene/terrain/tierLabel.ts | 18 ++++--- src/app/features/scene/tier.ts | 2 + src/app/pages/auth/footerBand.test.ts | 10 +++- 10 files changed, 123 insertions(+), 18 deletions(-) create mode 100644 src/app/features/scene/debugOverlays.test.ts create mode 100644 src/app/features/scene/debugOverlays.ts diff --git a/src/app/features/scene/debugOverlays.test.ts b/src/app/features/scene/debugOverlays.test.ts new file mode 100644 index 00000000..e1a4e61e --- /dev/null +++ b/src/app/features/scene/debugOverlays.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { debugOverlaysAllowed, overlayRequested } from './debugOverlays'; + +let search = ''; +beforeEach(() => { + search = ''; + vi.stubGlobal('window', { + get location() { + return { search }; + }, + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('debugOverlaysAllowed', () => { + it('a production build allows nothing by default', () => { + vi.stubEnv('DEV', false); + vi.stubEnv('VITE_PREVIEW_DEMO', ''); + expect(debugOverlaysAllowed(undefined)).toBe(false); + expect(debugOverlaysAllowed({ tier: 'full' })).toBe(false); + expect(debugOverlaysAllowed({ tier: 'full', debugOverlays: false })).toBe(false); + }); + + it('a dev build, a demo preview build or an explicit config flag allows them', () => { + vi.stubEnv('DEV', true); + expect(debugOverlaysAllowed(undefined)).toBe(true); + vi.stubEnv('DEV', false); + vi.stubEnv('VITE_PREVIEW_DEMO', '1'); + expect(debugOverlaysAllowed(undefined)).toBe(true); + vi.stubEnv('VITE_PREVIEW_DEMO', ''); + expect(debugOverlaysAllowed({ debugOverlays: true })).toBe(true); + }); +}); + +describe('overlayRequested', () => { + it('needs both the query and the permission', () => { + search = '?probe=1'; + expect(overlayRequested('probe', true)).toBe(true); + expect(overlayRequested('probe', false)).toBe(false); + expect(overlayRequested('tierlabel', true)).toBe(false); + search = '?tierlabel=1'; + expect(overlayRequested('tierlabel', true)).toBe(true); + search = ''; + expect(overlayRequested('probe', true)).toBe(false); + }); +}); diff --git a/src/app/features/scene/debugOverlays.ts b/src/app/features/scene/debugOverlays.ts new file mode 100644 index 00000000..a02d9e92 --- /dev/null +++ b/src/app/features/scene/debugOverlays.ts @@ -0,0 +1,15 @@ +import type { BackgroundSceneConfig } from './tier'; + +/** + * Whether the page URL may switch on the scene's diagnostic overlays (`?probe=1` frame readout, `?tierlabel=1` path + * label). Only a dev build, a demo preview build (VITE_PREVIEW_DEMO=1), or a config that says `debugOverlays: true` + * allows it; a production visitor adding a query string gets nothing. + */ +export const debugOverlaysAllowed = (config: BackgroundSceneConfig | undefined): boolean => + import.meta.env?.DEV === true || + import.meta.env?.VITE_PREVIEW_DEMO === '1' || + config?.debugOverlays === true; + +/** The page URL asks for this overlay (`?name=1`), and the overlays are allowed. */ +export const overlayRequested = (name: 'probe' | 'tierlabel', allowed: boolean): boolean => + allowed && new URLSearchParams(window.location.search).get(name) === '1'; diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 73377440..bc041672 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -1,5 +1,6 @@ import type { Scene, SceneOptions } from './Scene'; import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; +import { debugOverlaysAllowed } from './debugOverlays'; import { getSkyState, subscribe } from './skyState'; import type { SkyState } from './skyState'; @@ -17,7 +18,7 @@ export type StartOptions = Omit { let createScene: (o: SceneOptions) => Scene | null; @@ -82,7 +83,8 @@ export async function startBackgroundScene( ): Promise { const tier = resolveTier(config, capabilities); if (tier === 'off') return null; - const build = (state: SkyState) => create(config, { ...options, tier }, state); + const debug = debugOverlaysAllowed(config); + const build = (state: SkyState) => create(config, { ...options, tier, debug }, state); const state = getSkyState(); const key0 = config?.style === 'raymarch' ? null : await skyKey(config, state); const first = await build(state); diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts index 711054f6..adee2717 100644 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -428,7 +428,7 @@ describe('createRidgeScene', () => { it('?probe=1 keeps the rAF loop alive and shows median and p95', () => { search = '?probe=1'; const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY, debug: true })!; const readout = parent.children[parent.children.length - 1]; runFrames(16.7, 300); expect(rafQueue.length).toBe(1); // still sampling @@ -437,6 +437,17 @@ describe('createRidgeScene', () => { expect(parent.children.some((c: any) => c === readout)).toBe(false); }); + it('?probe=1 does nothing where the overlays are not allowed: no readout, the loop ends', () => { + search = '?probe=1'; + const { parent, canvas } = host(); + const before = parent.children.length; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + expect(parent.children.length).toBe(before + LAYERS.length); + runFrames(16.7, 300); + expect(rafQueue.length).toBe(0); + s.dispose(); + }); + 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; diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index 69a88864..fd40a967 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -1,4 +1,5 @@ import type { Scene, SceneOptions } from '../Scene'; +import { overlayRequested } from '../debugOverlays'; import { FrameSampler, FrameStats } from './probe'; import { skyColours, SkyColours, Srgb } from './skyColour'; import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars'; @@ -14,8 +15,10 @@ import { } from './ridgeWorld'; export type RidgeOptions = Pick & { - /** Show the rAF median/p95 readout. Default: the page URL has `?probe=1`. */ + /** Show the rAF median/p95 readout. Default: the page URL has `?probe=1`, where `debug` allows it. */ probe?: boolean; + /** The diagnostic overlays may be asked for by URL (debugOverlays.ts). Default: no. */ + debug?: boolean; /** Each time the readout would update, and once with `still` when the fallback fires. */ onProbe?: (s: FrameStats & { still: boolean }) => void; /** The sky's colours; default `skyColours()`, the one function to replace with Void's sun.js. */ @@ -151,7 +154,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { const parent = canvas.parentElement; if (!parent) return null; const sky = opts.sky ?? skyColours(); - const probe = opts.probe ?? new URLSearchParams(window.location.search).get('probe') === '1'; + const probe = opts.probe ?? overlayRequested('probe', opts.debug === true); const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); let built: Built | undefined; diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index b697927b..a1807305 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -22,6 +22,7 @@ import { SKY_FRAG, } from './terrainShaders'; import { createTerrainStars } from './terrainStars'; +import { overlayRequested } from '../debugOverlays'; import { showTierLabel } from './tierLabel'; export type TerrainOptions = Pick< @@ -36,8 +37,10 @@ export type TerrainOptions = Pick< band?: SkyBand; /** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */ seed?: number; - /** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`. */ + /** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`, where `debug` allows it. */ probe?: boolean; + /** The diagnostic overlays (readout, tier label) may be asked for by URL (debugOverlays.ts). Default: no. */ + debug?: boolean; onProbe?: (s: FrameStats & { still: boolean; triangles: number }) => void; }; @@ -89,7 +92,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const tier = opts.tier ?? 'full'; if (tier === 'off') return null; const { canvas } = opts; - const probe = opts.probe ?? new URLSearchParams(window.location.search).get('probe') === '1'; + const probe = opts.probe ?? overlayRequested('probe', opts.debug === true); const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, @@ -395,7 +398,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { }); canvas.parentElement.appendChild(readout); } - const removeLabel = showTierLabel(canvas, tier); + const removeLabel = showTierLabel(canvas, tier, opts.debug === true); if (still) settleToStill(); else wake(); if (!still && !moving()) draw(0); diff --git a/src/app/features/scene/terrain/tierLabel.test.ts b/src/app/features/scene/terrain/tierLabel.test.ts index 3e9f3a48..e3f18871 100644 --- a/src/app/features/scene/terrain/tierLabel.test.ts +++ b/src/app/features/scene/terrain/tierLabel.test.ts @@ -29,21 +29,27 @@ afterEach(() => vi.unstubAllGlobals()); describe('showTierLabel', () => { it('adds nothing in a normal build', () => { - expect(showTierLabel(canvas(true), 'full')).toBeNull(); + expect(showTierLabel(canvas(true), 'full', true)).toBeNull(); expect(appended).toHaveLength(0); }); it('names the path on screen when asked with ?tierlabel=1, and removes itself', () => { search = '?tierlabel=1'; - const remove = showTierLabel(canvas(true), 'sky-only'); + const remove = showTierLabel(canvas(true), 'sky-only', true); expect(appended).toHaveLength(1); expect(appended[0].textContent).toBe('terrain · sky-only'); remove!(); expect(appended[0].remove).toHaveBeenCalled(); }); + it('ignores ?tierlabel=1 where the overlays are not allowed (a production page)', () => { + search = '?tierlabel=1'; + expect(showTierLabel(canvas(true), 'full', false)).toBeNull(); + expect(appended).toHaveLength(0); + }); + it('has nowhere to go without a parent', () => { search = '?tierlabel=1'; - expect(showTierLabel(canvas(false), 'full')).toBeNull(); + expect(showTierLabel(canvas(false), 'full', true)).toBeNull(); }); }); diff --git a/src/app/features/scene/terrain/tierLabel.ts b/src/app/features/scene/terrain/tierLabel.ts index 0b2c79ec..4afc8a69 100644 --- a/src/app/features/scene/terrain/tierLabel.ts +++ b/src/app/features/scene/terrain/tierLabel.ts @@ -1,13 +1,19 @@ +import { overlayRequested } from '../debugOverlays'; + // A tiny label naming which terrain path is on screen ('full', 'low', 'still', or 'sky-only' where WebGL2 is missing), -// so a screenshot of a preview says what it shows. Built into preview builds only (VITE_PREVIEW_DEMO=1), or by `?tierlabel=1`. -const wanted = (): boolean => - import.meta.env?.VITE_PREVIEW_DEMO === '1' || - new URLSearchParams(window.location.search).get('tierlabel') === '1'; +// so a screenshot of a preview says what it shows. Built into preview builds (VITE_PREVIEW_DEMO=1), or by `?tierlabel=1` +// where the diagnostic overlays are allowed (debugOverlays.ts). +const wanted = (allowed: boolean): boolean => + import.meta.env?.VITE_PREVIEW_DEMO === '1' || overlayRequested('tierlabel', allowed); /** Adds the label to the canvas's parent; returns its remover, or null when no label is wanted. */ -export function showTierLabel(canvas: HTMLCanvasElement, text: string): (() => void) | null { +export function showTierLabel( + canvas: HTMLCanvasElement, + text: string, + allowed: boolean +): (() => void) | null { const parent = canvas.parentElement; - if (!parent || !wanted()) return null; + if (!parent || !wanted(allowed)) return null; const el = document.createElement('div'); el.textContent = `terrain · ${text}`; Object.assign(el.style, { diff --git a/src/app/features/scene/tier.ts b/src/app/features/scene/tier.ts index 4a976081..ba338f37 100644 --- a/src/app/features/scene/tier.ts +++ b/src/app/features/scene/tier.ts @@ -24,6 +24,8 @@ export type SceneStyle = 'ridge' | 'raymarch' | 'terrain'; export interface BackgroundSceneConfig { tier?: SceneTier; style?: SceneStyle; + /** Let the page URL switch on the diagnostic overlays (`?probe=1`, `?tierlabel=1`) in a production build. Default: no. */ + debugOverlays?: boolean; } export function readCapabilities(nav: Navigator): Capabilities { diff --git a/src/app/pages/auth/footerBand.test.ts b/src/app/pages/auth/footerBand.test.ts index 9d196200..97febbbb 100644 --- a/src/app/pages/auth/footerBand.test.ts +++ b/src/app/pages/auth/footerBand.test.ts @@ -43,9 +43,17 @@ describe('the sign-in footer band, as emitted', () => { ]); }); - it('declares exactly these properties, so nothing else (a shadow, a prefixed override) rides along', async () => { + it('declares exactly these properties, so nothing else (a shadow, a stray override) rides along', async () => { const [rule] = declarations(await emitted(), over); + // -webkit-backdrop-filter is the one prefixed property, for Safari 14; it must blur exactly as the standard one does. + const blur = (prop: string) => + rule + .find((d) => d.startsWith(`${prop}:`)) + ?.split(':')[1] + .trim(); + expect(blur('-webkit-backdrop-filter')).toBe(blur('backdrop-filter')); expect(rule.map((d) => d.slice(0, d.indexOf(':'))).sort()).toEqual([ + '-webkit-backdrop-filter', 'backdrop-filter', 'background-color', 'background-color', From 48d6d171daa7505127170aa1b40afbaca2e91909 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 14:09:00 +0000 Subject: [PATCH 077/111] Sky-only tier label follows the overlay gate; tests pin the door's debug forwarding Co-Authored-By: Claude Sonnet 5.5 --- .../scene/terrain/TerrainSky2D.test.ts | 36 +++++++++++++++++++ .../features/scene/terrain/TerrainSky2D.ts | 7 ++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/terrain/TerrainSky2D.test.ts b/src/app/features/scene/terrain/TerrainSky2D.test.ts index c3a95d69..d85d8f01 100644 --- a/src/app/features/scene/terrain/TerrainSky2D.test.ts +++ b/src/app/features/scene/terrain/TerrainSky2D.test.ts @@ -168,6 +168,42 @@ describe('createTerrainSkyScene', () => { }); }); +describe('the sky-only tier label', () => { + const labels = (parentAppend: ReturnType) => + parentAppend.mock.calls.filter(([e]) => e?.textContent === 'terrain · sky-only'); + + it('shows for ?tierlabel=1 only where the overlays are allowed', () => { + search = '?tierlabel=1'; + const off = canvasWith(); + createTerrainSkyScene({ canvas: off.canvas, tier: 'low' })!.dispose(); + expect(labels(off.parentAppend)).toHaveLength(0); + const on = canvasWith(); + createTerrainSkyScene({ canvas: on.canvas, tier: 'low', debug: true })!.dispose(); + expect(labels(on.parentAppend)).toHaveLength(1); + }); + + it('the door allows it from the config flag, and not from a plain production config', async () => { + vi.stubEnv('DEV', false); + vi.stubEnv('VITE_PREVIEW_DEMO', ''); + search = '?tierlabel=1'; + const plain = canvasWith(); + ( + await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas: plain.canvas }) + )?.dispose(); + expect(labels(plain.parentAppend)).toHaveLength(0); + const flagged = canvasWith(); + ( + await startBackgroundScene( + { tier: 'low', style: 'terrain', debugOverlays: true }, + {}, + { canvas: flagged.canvas } + ) + )?.dispose(); + expect(labels(flagged.parentAppend)).toHaveLength(1); + vi.unstubAllEnvs(); + }); +}); + describe('startBackgroundScene, terrain style', () => { it('without WebGL2 mounts the sky on the same canvas instead of returning null', async () => { const { canvas, painted } = canvasWith({ gl: null }); diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts index 71bc7a78..4653d470 100644 --- a/src/app/features/scene/terrain/TerrainSky2D.ts +++ b/src/app/features/scene/terrain/TerrainSky2D.ts @@ -9,7 +9,10 @@ import { CAMERA, view } from './terrainWorld'; export const SKY_WIDTH = 96; /** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */ -export type TerrainSkyOptions = Pick; +export type TerrainSkyOptions = Pick< + TerrainOptions, + 'canvas' | 'tier' | 'sky' | 'sun' | 'band' | 'debug' +>; /** * Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the @@ -69,7 +72,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { }; window.addEventListener('resize', onResize); paint(); - const removeLabel = showTierLabel(canvas, 'sky-only'); + const removeLabel = showTierLabel(canvas, 'sky-only', opts.debug === true); return { login() { From 9b9b495defbc193b6b7665662bfb6889818f12e9 Mon Sep 17 00:00:00 2001 From: Eureka Self Date: Tue, 10 Nov 2026 14:09:58 +0000 Subject: [PATCH 078/111] nemesis: drop the pose status strip; the portraits already show each Mind's mood (Meta: redundant, wastes vertical space) Co-Authored-By: Claude Opus 5.5 --- src/app/components/message/pose/poses.test.ts | 50 ------------ src/app/components/message/pose/poses.ts | 47 ----------- src/app/features/room/RoomView.tsx | 2 - .../features/room/RoomViewPoseStatus.css.ts | 40 ---------- src/app/features/room/RoomViewPoseStatus.tsx | 80 ------------------- 5 files changed, 219 deletions(-) delete mode 100644 src/app/features/room/RoomViewPoseStatus.css.ts delete mode 100644 src/app/features/room/RoomViewPoseStatus.tsx diff --git a/src/app/components/message/pose/poses.test.ts b/src/app/components/message/pose/poses.test.ts index 81c2f0c6..c6089f62 100644 --- a/src/app/components/message/pose/poses.test.ts +++ b/src/app/components/message/pose/poses.test.ts @@ -3,10 +3,7 @@ import catalogue from './catalogue.json'; import { mindOfSender, poseSrc, - poseStatusLine, - poseStatuses, resolvePose, - splitNewestStatus, } from './poses'; describe('resolvePose', () => { @@ -53,52 +50,5 @@ describe('poseSrc', () => { }); }); -describe('poseStatuses', () => { - const ev = (sender: string, ts: number, label: unknown) => ({ - sender, - localpart: sender, - ts, - label, - }); - it('reports the latest pose per Mind and when its unbroken run began', () => { - const out = poseStatuses([ - ev('aqua', 1, 'happy'), - ev('aqua', 2, 'sigh'), - ev('aqua', 3, 'sigh'), - ev('alice', 4, 'happy'), - ev('eureka', 5, 'probe'), - ]); - expect(out).toEqual([ - { mind: 'aqua', pose: 'sigh', since: 2 }, - { mind: 'eureka', pose: 'probe', since: 5 }, - ]); - }); - it('treats unknown labels as neutral runs', () => { - expect(poseStatuses([ev('void', 1, 'bogus'), ev('void', 2, undefined)])).toEqual([ - { mind: 'void', pose: 'neutral', since: 1 }, - ]); - }); -}); -describe('poseStatusLine', () => { - it('formats the client-status line', () => { - const since = new Date(2026, 10, 4, 9, 5).getTime(); - expect(poseStatusLine({ mind: 'void', pose: 'reading', since })).toBe( - 'Ratatoskr: Void is reading, since 09:05' - ); - }); -}); -describe('splitNewestStatus', () => { - const s = (mind: string, since: number) => ({ mind, pose: 'happy', since }); - - it('picks the status that changed last and keeps the others in their order', () => { - const { newest, rest } = splitNewestStatus([s('eureka', 100), s('void', 300), s('nibu', 200)]); - expect(newest?.mind).toBe('void'); - expect(rest.map((r) => r.mind)).toEqual(['eureka', 'nibu']); - }); - - it('has no newest for an empty list', () => { - expect(splitNewestStatus([])).toEqual({ newest: undefined, rest: [] }); - }); -}); diff --git a/src/app/components/message/pose/poses.ts b/src/app/components/message/pose/poses.ts index 7175196a..69429090 100644 --- a/src/app/components/message/pose/poses.ts +++ b/src/app/components/message/pose/poses.ts @@ -40,50 +40,3 @@ export const resolvePose = (mind: string, label: unknown): string => { export const poseSrc = (mind: string, pose: string, base = '/'): string => `${base}public/poses/${artPath(mind, pose) ?? artPath(mind, NEUTRAL)}`; -export type PoseStatus = { mind: string; pose: string; since: number }; -export type PoseEvent = { - sender: string; - localpart?: string; - name?: string; - ts: number; - label: unknown; -}; - -// Latest pose per Mind in a room's events (oldest first), and when that pose -// began: the first of its unbroken run of messages carrying the same pose. -export const poseStatuses = (events: PoseEvent[]): PoseStatus[] => { - const bySender = new Map(); - events.forEach((e) => { - const mind = mindOfSender(e.localpart, e.name); - if (!mind) return; - const entry = bySender.get(e.sender) ?? { mind, run: [] }; - entry.run.push({ pose: resolvePose(mind, e.label), ts: e.ts }); - bySender.set(e.sender, entry); - }); - return [...bySender.values()].map(({ mind, run }) => { - const last = run[run.length - 1]; - let i = run.length - 1; - while (i > 0 && run[i - 1].pose === last.pose) i -= 1; - return { mind, pose: last.pose, since: run[i].ts }; - }); -}; - -// The status that changed last, and the rest in their given order (phones show only the first). -export const splitNewestStatus = ( - statuses: PoseStatus[] -): { newest: PoseStatus | undefined; rest: PoseStatus[] } => { - let newest: PoseStatus | undefined; - statuses.forEach((st) => { - if (!newest || st.since > newest.since) newest = st; - }); - return { newest, rest: statuses.filter((st) => st !== newest) }; -}; - -const two = (n: number) => String(n).padStart(2, '0'); - -// Client-status text. Rendered locally only; it is never sent to a room. -export const poseStatusLine = ({ mind, pose, since }: PoseStatus): string => { - const d = new Date(since); - const name = mind.charAt(0).toUpperCase() + mind.slice(1); - return `Ratatoskr: ${name} is ${pose}, since ${two(d.getHours())}:${two(d.getMinutes())}`; -}; diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index cf91c638..3fbd81fa 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -11,7 +11,6 @@ import { useEditor } from '../../components/editor'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { RoomTimeline } from './RoomTimeline'; import { RoomViewTyping } from './RoomViewTyping'; -import { RoomViewPoseStatus } from './RoomViewPoseStatus'; import { RoomTombstone } from './RoomTombstone'; import { RoomInput } from './RoomInput'; import { RoomTrainingLine } from './RoomTrainingLine'; @@ -102,7 +101,6 @@ export function RoomView({ eventId }: { eventId?: string }) { roomInputRef={roomInputRef} editor={editor} /> - diff --git a/src/app/features/room/RoomViewPoseStatus.css.ts b/src/app/features/room/RoomViewPoseStatus.css.ts deleted file mode 100644 index d8319d0d..00000000 --- a/src/app/features/room/RoomViewPoseStatus.css.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { style } from '@vanilla-extract/css'; -import { DefaultReset, color, config, toRem } from 'folds'; - -const NARROW = 'screen and (max-width: 480px)'; - -export const StatusBlock = style([ - DefaultReset, - { - display: 'flex', - flexDirection: 'column', - flexShrink: 0, - padding: `0 ${toRem(16)}`, - // Phones: the newest mood and its "+N more" share one line. - '@media': { [NARROW]: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center' } }, - }, -]); - -// On phones only the newest mood shows until the strip is tapped; wide screens show every line. -export const StatusRow = style({ - '@media': { - [NARROW]: { - selectors: { - '&[data-newest="false"]': { display: 'none' }, - [`${StatusBlock}[data-open="true"] &`]: { display: 'block', flexBasis: '100%' }, - }, - }, - }, -}); - -export const StatusToggle = style([ - DefaultReset, - { - display: 'none', - minHeight: toRem(24), - padding: `0 ${config.space.S100}`, - color: color.Primary.Main, - cursor: 'pointer', - '@media': { [NARROW]: { display: 'block' } }, - }, -]); diff --git a/src/app/features/room/RoomViewPoseStatus.tsx b/src/app/features/room/RoomViewPoseStatus.tsx deleted file mode 100644 index e62f81c0..00000000 --- a/src/app/features/room/RoomViewPoseStatus.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Text } from 'folds'; -import { Room, RoomEvent } from 'matrix-js-sdk'; -import { settingsAtom } from '../../state/settings'; -import { useSetting } from '../../state/hooks/settings'; -import { getMxIdLocalPart } from '../../utils/matrix'; -import { getMemberDisplayName } from '../../utils/room'; -import { - POSE_KEY, - poseStatusLine, - poseStatuses, - splitNewestStatus, -} from '../../components/message/pose/poses'; -import * as css from './RoomViewPoseStatus.css'; - -// Client-status lines about each Mind's current pose. They are drawn from the -// timeline already in memory and are never sent as anyone's message. -export function RoomViewPoseStatus({ room }: { room: Room }) { - const [poseBubbles] = useSetting(settingsAtom, 'poseBubbles'); - const [, bump] = useState(0); - const [open, setOpen] = useState(false); - - useEffect(() => { - const onTimeline = () => bump((n) => n + 1); - room.on(RoomEvent.Timeline, onTimeline); - return () => { - room.removeListener(RoomEvent.Timeline, onTimeline); - }; - }, [room]); - - if (!poseBubbles) return null; - - const statuses = poseStatuses( - room - .getLiveTimeline() - .getEvents() - .filter((e) => e.getType() === 'm.room.message' && e.getSender()) - .map((e) => { - const sender = e.getSender() as string; - return { - sender, - localpart: getMxIdLocalPart(sender), - name: getMemberDisplayName(room, sender), - ts: e.getTs(), - label: e.getContent()[POSE_KEY], - }; - }) - ); - if (statuses.length === 0) return null; - - const { newest, rest } = splitNewestStatus(statuses); - - return ( -
- {statuses.map((st) => ( - - {poseStatusLine(st)} - - ))} - {rest.length > 0 && ( - - )} -
- ); -} From 3c92696507b4b9b9bfc6a46ea7e5cdea3d7014e0 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 14:15:47 +0000 Subject: [PATCH 079/111] Room list: groups of unjoined project rooms wait behind one Browse entry A steward group with no joined or invited room collapses into "Browse N more rooms" per tier, closed on every screen and remembered per device; a group the user is in anything of, loose estate rooms, and joined rooms stay up front. splitGroups is the pure rule, tested. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 52 ++++++++++++++++----- src/app/features/estate/buildEstate.test.ts | 39 +++++++++++++++- src/app/features/estate/buildEstate.ts | 14 ++++++ 3 files changed, 92 insertions(+), 13 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 2cbbfe4a..56b554fa 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -38,7 +38,7 @@ import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategorie import { makeNavCategoryId } from '../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { EstateRoom, EstateTier } from './buildEstate'; +import { EstateGroup, EstateRoom, EstateTier, splitGroups } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; import * as css from './EstateNavigator.css'; @@ -167,25 +167,53 @@ function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) { ); } +function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) { + return ( + + {group.rooms.map((r) => ( + + ))} + + ); +} + +/** + * Groups of rooms the user has not joined, behind one quiet entry. Closed until opened, on every screen; the opening is + * remembered per device like any other section. + */ +function Browse({ tierId, groups }: { tierId: string; groups: EstateGroup[] }) { + const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom()); + const key = makeNavCategoryId('estate', 'browse', tierId); + const open = toggled.has(key); + const count = groups.reduce((n, g) => n + g.rooms.length, 0); + + return ( + + + setToggled({ type: open ? 'DELETE' : 'PUT', categoryId: key })} + > + {`Browse ${count} more ${count === 1 ? 'room' : 'rooms'}`} + + + {open && groups.map((g) => )} + + ); +} + function Tier({ tier }: { tier: EstateTier }) { const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]); + const { front, browse } = useMemo(() => splitGroups(tier), [tier]); return ( {tier.loose.map((r) => ( ))} - {tier.groups.map((g) => ( - - {g.rooms.map((r) => ( - - ))} - + {front.map((g) => ( + ))} + {browse.length > 0 && } ); } diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 8f898a0f..7b63eac5 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfChild } from './buildEstate'; +import { + buildEstate, + EstateHierarchyRoom, + EstateInput, + splitGroups, + stewardOfChild, +} from './buildEstate'; const ROOT = '!root:gamecult.org'; const PROJECTS = '!projects:gamecult.org'; @@ -324,3 +330,34 @@ describe('direct invites', () => { ]); }); }); + +describe('splitGroups', () => { + const projects = (membership: EstateInput['membership']) => + buildEstate(input({ membership })).find((t) => t.id === PROJECTS)!; + const labels = (gs: { mind: string }[]) => gs.map((g) => g.mind); + + it('puts a group nobody has joined behind Browse, and keeps loose estate rooms out of it', () => { + const tier = projects(new Map()); + const { front, browse } = splitGroups(tier); + expect(labels(front)).toEqual([]); + expect(labels(browse).sort()).toEqual(['eureka', 'void']); + expect(tier.loose.map((r) => r.name)).toEqual(['Misc']); + }); + + it('keeps a group up front once any room in it is joined, its unjoined siblings with it', () => { + const { front, browse } = splitGroups(projects(new Map([['!cultlib:gamecult.org', 'join']]))); + expect(labels(front)).toEqual(['eureka']); + expect(labels(browse)).toEqual(['void']); + }); + + it('keeps a group up front for an invite, which is something to act on', () => { + const { front } = splitGroups(projects(new Map([['!aeth:gamecult.org', 'invite']]))); + expect(labels(front)).toEqual(['void']); + }); + + it('loses no group: front and browse together are the tier groups', () => { + const tier = projects(new Map([['!cultlib:gamecult.org', 'join']])); + const { front, browse } = splitGroups(tier); + expect([...front, ...browse].sort((a, b) => a.mind.localeCompare(b.mind))).toEqual(tier.groups); + }); +}); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts index b9232beb..ca656211 100644 --- a/src/app/features/estate/buildEstate.ts +++ b/src/app/features/estate/buildEstate.ts @@ -71,6 +71,20 @@ export type EstateInput = { mindLabels?: ReadonlyMap; }; +/** + * A tier's project-room groups split for the navigator: a group the user is in anything of (joined, or invited, which is + * something to act on) stays up front; a group of rooms nobody has joined waits behind one Browse entry, so the first + * screen is conversations, not a wall of Join buttons. Loose (estate) rooms are never grouped away. + */ +export const splitGroups = (tier: EstateTier): { front: EstateGroup[]; browse: EstateGroup[] } => { + const front: EstateGroup[] = []; + const browse: EstateGroup[] = []; + tier.groups.forEach((g) => + (g.rooms.some((r) => r.state !== 'joinable') ? front : browse).push(g) + ); + return { front, browse }; +}; + export const DIRECT_TIER_ID = 'direct'; export const ELSEWHERE_TIER_ID = 'elsewhere'; From 3c868254e549ffe999ad3b448409e5e68931c5ee Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 14:47:41 +0000 Subject: [PATCH 080/111] Category buttons expose aria-expanded RoomNavCategoryButton set open/closed only through its chevron. It now sets aria-expanded from `closed` (absent when the section does not toggle); tested by rendering to static markup. Co-Authored-By: Claude Sonnet 5.5 --- .../room-nav/RoomNavCategoryButton.test.tsx | 21 +++++++++++++++++++ .../room-nav/RoomNavCategoryButton.tsx | 1 + 2 files changed, 22 insertions(+) create mode 100644 src/app/features/room-nav/RoomNavCategoryButton.test.tsx diff --git a/src/app/features/room-nav/RoomNavCategoryButton.test.tsx b/src/app/features/room-nav/RoomNavCategoryButton.test.tsx new file mode 100644 index 00000000..a500f920 --- /dev/null +++ b/src/app/features/room-nav/RoomNavCategoryButton.test.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { RoomNavCategoryButton } from './RoomNavCategoryButton'; + +const html = (closed?: boolean) => + renderToStaticMarkup(Browse); + +describe('RoomNavCategoryButton', () => { + it('says it is collapsed when closed, so assistive tech does not rely on the chevron', () => { + expect(html(true)).toContain('aria-expanded="false"'); + }); + + it('says it is expanded when open', () => { + expect(html(false)).toContain('aria-expanded="true"'); + }); + + it('claims neither when the section does not toggle', () => { + expect(html(undefined)).not.toContain('aria-expanded'); + }); +}); diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx index 3af8aeac..e232b856 100644 --- a/src/app/features/room-nav/RoomNavCategoryButton.tsx +++ b/src/app/features/room-nav/RoomNavCategoryButton.tsx @@ -16,6 +16,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( src={closed ? Icons.ChevronRight : Icons.ChevronBottom} /> } + aria-expanded={closed === undefined ? undefined : !closed} {...props} ref={ref} > From 9877154033f65a14002952a86e075f39169e4adc Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 17:03:17 +0000 Subject: [PATCH 081/111] dings: port the hook from #36 onto dev, wire Aqua's v3 set behind dings.enabled (default off) Hook code is #36 (b3eb9d01) verbatim plus the switch. Clips and manifest are AquaSynth PR #22 out/, Nibu on proposal 1. Nobody has heard v3. Co-Authored-By: Claude Sonnet 5.5 --- config.json | 4 + public/dings/README.md | 17 +++ public/dings/manifest.json | 58 ++++++++ public/dings/neutral/dm.ogg | Bin 0 -> 13525 bytes public/dings/neutral/invite.ogg | Bin 0 -> 11198 bytes public/dings/neutral/mention.ogg | Bin 0 -> 9051 bytes public/dings/neutral/message.ogg | Bin 0 -> 7274 bytes public/dings/senders/aqua/dm.ogg | Bin 0 -> 4687 bytes public/dings/senders/aqua/invite.ogg | Bin 0 -> 4863 bytes public/dings/senders/aqua/mention.ogg | Bin 0 -> 4538 bytes public/dings/senders/aqua/message.ogg | Bin 0 -> 4563 bytes public/dings/senders/eureka/dm.ogg | Bin 0 -> 4583 bytes public/dings/senders/eureka/invite.ogg | Bin 0 -> 4727 bytes public/dings/senders/eureka/mention.ogg | Bin 0 -> 4443 bytes public/dings/senders/eureka/message.ogg | Bin 0 -> 4425 bytes public/dings/senders/heimdall/dm.ogg | Bin 0 -> 5184 bytes public/dings/senders/heimdall/invite.ogg | Bin 0 -> 5476 bytes public/dings/senders/heimdall/mention.ogg | Bin 0 -> 5217 bytes public/dings/senders/heimdall/message.ogg | Bin 0 -> 4746 bytes public/dings/senders/kiko/dm.ogg | Bin 0 -> 5703 bytes public/dings/senders/kiko/invite.ogg | Bin 0 -> 5627 bytes public/dings/senders/kiko/mention.ogg | Bin 0 -> 5044 bytes public/dings/senders/kiko/message.ogg | Bin 0 -> 5090 bytes public/dings/senders/libby/dm.ogg | Bin 0 -> 5426 bytes public/dings/senders/libby/invite.ogg | Bin 0 -> 5613 bytes public/dings/senders/libby/mention.ogg | Bin 0 -> 5108 bytes public/dings/senders/libby/message.ogg | Bin 0 -> 5075 bytes public/dings/senders/nibu/dm.ogg | Bin 0 -> 6138 bytes public/dings/senders/nibu/invite.ogg | Bin 0 -> 6151 bytes public/dings/senders/nibu/mention.ogg | Bin 0 -> 5734 bytes public/dings/senders/nibu/message.ogg | Bin 0 -> 5354 bytes public/dings/senders/void/dm.ogg | Bin 0 -> 5402 bytes public/dings/senders/void/invite.ogg | Bin 0 -> 5667 bytes public/dings/senders/void/mention.ogg | Bin 0 -> 5022 bytes public/dings/senders/void/message.ogg | Bin 0 -> 5362 bytes public/dings/senders/weksa/dm.ogg | Bin 0 -> 5682 bytes public/dings/senders/weksa/invite.ogg | Bin 0 -> 6120 bytes public/dings/senders/weksa/mention.ogg | Bin 0 -> 5291 bytes public/dings/senders/weksa/message.ogg | Bin 0 -> 5272 bytes src/app/features/dings/dings.test.ts | 144 +++++++++++++++++++ src/app/features/dings/dings.ts | 100 +++++++++++++ src/app/features/dings/manifest.test.ts | 80 +++++++++++ src/app/features/dings/useDings.ts | 55 +++++++ src/app/hooks/useClientConfig.ts | 4 + src/app/pages/client/ClientNonUIFeatures.tsx | 28 +++- vite.config.js | 4 + 46 files changed, 491 insertions(+), 3 deletions(-) create mode 100644 public/dings/README.md create mode 100644 public/dings/manifest.json create mode 100644 public/dings/neutral/dm.ogg create mode 100644 public/dings/neutral/invite.ogg create mode 100644 public/dings/neutral/mention.ogg create mode 100644 public/dings/neutral/message.ogg create mode 100644 public/dings/senders/aqua/dm.ogg create mode 100644 public/dings/senders/aqua/invite.ogg create mode 100644 public/dings/senders/aqua/mention.ogg create mode 100644 public/dings/senders/aqua/message.ogg create mode 100644 public/dings/senders/eureka/dm.ogg create mode 100644 public/dings/senders/eureka/invite.ogg create mode 100644 public/dings/senders/eureka/mention.ogg create mode 100644 public/dings/senders/eureka/message.ogg create mode 100644 public/dings/senders/heimdall/dm.ogg create mode 100644 public/dings/senders/heimdall/invite.ogg create mode 100644 public/dings/senders/heimdall/mention.ogg create mode 100644 public/dings/senders/heimdall/message.ogg create mode 100644 public/dings/senders/kiko/dm.ogg create mode 100644 public/dings/senders/kiko/invite.ogg create mode 100644 public/dings/senders/kiko/mention.ogg create mode 100644 public/dings/senders/kiko/message.ogg create mode 100644 public/dings/senders/libby/dm.ogg create mode 100644 public/dings/senders/libby/invite.ogg create mode 100644 public/dings/senders/libby/mention.ogg create mode 100644 public/dings/senders/libby/message.ogg create mode 100644 public/dings/senders/nibu/dm.ogg create mode 100644 public/dings/senders/nibu/invite.ogg create mode 100644 public/dings/senders/nibu/mention.ogg create mode 100644 public/dings/senders/nibu/message.ogg create mode 100644 public/dings/senders/void/dm.ogg create mode 100644 public/dings/senders/void/invite.ogg create mode 100644 public/dings/senders/void/mention.ogg create mode 100644 public/dings/senders/void/message.ogg create mode 100644 public/dings/senders/weksa/dm.ogg create mode 100644 public/dings/senders/weksa/invite.ogg create mode 100644 public/dings/senders/weksa/mention.ogg create mode 100644 public/dings/senders/weksa/message.ogg create mode 100644 src/app/features/dings/dings.test.ts create mode 100644 src/app/features/dings/dings.ts create mode 100644 src/app/features/dings/manifest.test.ts create mode 100644 src/app/features/dings/useDings.ts diff --git a/config.json b/config.json index dd4c887b..ed1bca6b 100644 --- a/config.json +++ b/config.json @@ -20,5 +20,9 @@ "backgroundScene": { "tier": "off" + }, + + "dings": { + "enabled": false } } diff --git a/public/dings/README.md b/public/dings/README.md new file mode 100644 index 00000000..573a78f9 --- /dev/null +++ b/public/dings/README.md @@ -0,0 +1,17 @@ +# Dings + +Pre-rendered earcons, served at `public/dings/` and read at runtime by `src/app/features/dings`. +Aqua renders the clips; nothing here is synthesised in the browser. + + dings/manifest.json + dings/senders//.ogg + dings/neutral/.ogg + +`manifest.json` is `{ "neutral": { : "" }, "senders": { : { : "" } } }` +with motif one of `mention`, `dm`, `message`, `invite`; paths are relative to this folder, `.ogg` or `.mp3`. +A sender's own clip wins, else the neutral clip, else silence. Without a `manifest.json` the client keeps +its stock notification sounds; once one exists, a motif with no clip is silent. + +The set here is Aqua's v3 (AquaSynth PR #22, staged at minds.gamecult.org/aqua/dings-voices-70a9795), +Nibu on proposal 1 (static-filter square). Nobody has heard it. The client plays none of it unless the +client config sets `"dings": { "enabled": true }` (default off, as in `config.json`). diff --git a/public/dings/manifest.json b/public/dings/manifest.json new file mode 100644 index 00000000..7096d195 --- /dev/null +++ b/public/dings/manifest.json @@ -0,0 +1,58 @@ +{ + "neutral": { + "mention": "neutral/mention.ogg", + "dm": "neutral/dm.ogg", + "message": "neutral/message.ogg", + "invite": "neutral/invite.ogg" + }, + "senders": { + "aqua": { + "mention": "senders/aqua/mention.ogg", + "dm": "senders/aqua/dm.ogg", + "message": "senders/aqua/message.ogg", + "invite": "senders/aqua/invite.ogg" + }, + "eureka": { + "mention": "senders/eureka/mention.ogg", + "dm": "senders/eureka/dm.ogg", + "message": "senders/eureka/message.ogg", + "invite": "senders/eureka/invite.ogg" + }, + "heimdall": { + "mention": "senders/heimdall/mention.ogg", + "dm": "senders/heimdall/dm.ogg", + "message": "senders/heimdall/message.ogg", + "invite": "senders/heimdall/invite.ogg" + }, + "kiko": { + "mention": "senders/kiko/mention.ogg", + "dm": "senders/kiko/dm.ogg", + "message": "senders/kiko/message.ogg", + "invite": "senders/kiko/invite.ogg" + }, + "libby": { + "mention": "senders/libby/mention.ogg", + "dm": "senders/libby/dm.ogg", + "message": "senders/libby/message.ogg", + "invite": "senders/libby/invite.ogg" + }, + "nibu": { + "mention": "senders/nibu/mention.ogg", + "dm": "senders/nibu/dm.ogg", + "message": "senders/nibu/message.ogg", + "invite": "senders/nibu/invite.ogg" + }, + "void": { + "mention": "senders/void/mention.ogg", + "dm": "senders/void/dm.ogg", + "message": "senders/void/message.ogg", + "invite": "senders/void/invite.ogg" + }, + "weksa": { + "mention": "senders/weksa/mention.ogg", + "dm": "senders/weksa/dm.ogg", + "message": "senders/weksa/message.ogg", + "invite": "senders/weksa/invite.ogg" + } + } +} \ No newline at end of file diff --git a/public/dings/neutral/dm.ogg b/public/dings/neutral/dm.ogg new file mode 100644 index 0000000000000000000000000000000000000000..8f7a269e1e9a98304f53deadc1fa52808660e341 GIT binary patch literal 13525 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 literal 0 HcmV?d00001 diff --git a/public/dings/neutral/invite.ogg b/public/dings/neutral/invite.ogg new file mode 100644 index 0000000000000000000000000000000000000000..8def2b7708e49b8dd3234c4290e31326c3789070 GIT binary patch literal 11198 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 Tl^D;oeXtpvpr8MyD*C?w^;=*f literal 0 HcmV?d00001 diff --git a/public/dings/neutral/message.ogg b/public/dings/neutral/message.ogg new file mode 100644 index 0000000000000000000000000000000000000000..f7e17fd46b51a2cdbf49f41360eea38bb2a28de5 GIT binary patch literal 7274 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 NdYfE+T-gMGe*>~s(Ifx> literal 0 HcmV?d00001 diff --git a/public/dings/senders/aqua/mention.ogg b/public/dings/senders/aqua/mention.ogg new file mode 100644 index 0000000000000000000000000000000000000000..1f9c3ffa422b5dbffcabc248e9ca864904513cdc GIT binary patch literal 4538 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 literal 0 HcmV?d00001 diff --git a/public/dings/senders/eureka/dm.ogg b/public/dings/senders/eureka/dm.ogg new file mode 100644 index 0000000000000000000000000000000000000000..a64c22f60b4025f0ac3569c2edf5cbd8126740c5 GIT binary patch literal 4583 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 literal 0 HcmV?d00001 diff --git a/public/dings/senders/eureka/invite.ogg b/public/dings/senders/eureka/invite.ogg new file mode 100644 index 0000000000000000000000000000000000000000..708dd38d86c0c07f62d0c4142f3c36e36137a38a GIT binary patch literal 4727 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 literal 0 HcmV?d00001 diff --git a/public/dings/senders/heimdall/dm.ogg b/public/dings/senders/heimdall/dm.ogg new file mode 100644 index 0000000000000000000000000000000000000000..f4844c021e4bca621f43c9cc89dec0f19c16c0d0 GIT binary patch literal 5184 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 literal 0 HcmV?d00001 diff --git a/public/dings/senders/heimdall/invite.ogg b/public/dings/senders/heimdall/invite.ogg new file mode 100644 index 0000000000000000000000000000000000000000..6e27e760b4f9aa1387826ffc5633be9da21ec8c9 GIT binary patch literal 5476 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 literal 0 HcmV?d00001 diff --git a/public/dings/senders/heimdall/mention.ogg b/public/dings/senders/heimdall/mention.ogg new file mode 100644 index 0000000000000000000000000000000000000000..e006414703fe25088289ba127c85f579645d7cf1 GIT binary patch literal 5217 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 literal 0 HcmV?d00001 diff --git a/public/dings/senders/kiko/invite.ogg b/public/dings/senders/kiko/invite.ogg new file mode 100644 index 0000000000000000000000000000000000000000..45d02d52bbdc736f2dbf40c8a789e530d3b575b2 GIT binary patch literal 5627 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 literal 0 HcmV?d00001 diff --git a/public/dings/senders/kiko/mention.ogg b/public/dings/senders/kiko/mention.ogg new file mode 100644 index 0000000000000000000000000000000000000000..388dbbabfff693398d68b15fdbf993f3a4d85163 GIT binary patch literal 5044 zcmb6+d0dmnwi9F*FbV=9Vo+2Aic$^$Sk@x=AJ1T(_1xcKmBE`}iRzr+3zn%Ygk|L+iXW1N3Bj1!xAYIojq zNyzUXw!B43ISw$rH~L7_#@+Feq(jl+fw**%H_6`4-pg!pTGjGOqKxeSDe z2$fbk6(v5nKl?oK^wpgPiRlkHxLH`Il)FvVr;Dz6QF-Z2q)ULFdXQ8KWO3M;|lv0_Qbg5(iu`xwtBdM`4#g5vxFSI zf>O!@a=BSn_70l(tRH>F22sKp+;S2^DZ~$>*gVZRJl(uP7_V)0wLJfv)g#M)wAnwl z#)irtq|W0ve;~T+P;PhAtQ}>6tSrgcW$tyVSFA`P5BW$00ZtYvGYU496p{Oh`U*Th z(u(6DD@xC3f4Ff|79C4hb$!q~-k3r#E8`gtW=7C4PVjkNe@!a$32(4BX|OlzfKeJd zxacokX*yVfuyn%HQ4pRX1G8YrgjkUZa7`%Wuubm@ZAA;Ht=!wo>FAy?;9>?pDDr`yQrjxxFp zBfy36pPCFyEpTihe$whX&zU4XEN;qAjLLM=IW=C)%|6whx#q#qSHw-}Cl?p4RXv$? z!6W@BIL#9g;p{X%YwF%+t`L^*qW>)XrrSS8$Q3x#R*4K^A4$Fm2>(>FSxSY^O!_0q z?Wg`U$rY2ua2Onc-%X;nZtzm%K|n-Jb5Snci##Tb*CUIBEUEbw0rwGgRCu=ATP);u zQ^|RB>@!~K{fjU_N+n-Pfs0PX6n@qzkSeO^s0z|+eY4HG$HTwd!T&d(E}lzQ0@J(4 zXOF%oL+0Vn@C}l1x&qjt-3~oJ!l$Eu0K?yr(-*+#pYiD_^6iyz{29E^YEIY*PXDY+ z&u#eRJWF7P9_RGUvO{~bcH*LfKork6NXFU2<1qC-dzUkUiace7?4TmvfkO7PUXL!G zr%b@~=dnWznS;mJ!Lz=-|Ku@>Qek$uhQ~BMJm_>86di!sAaOTJq#Y`?U!Ssf+TFf^c8XjgP46Yj{XVEE(SX|fe{?Y?we%>&+z;iNuh;oY<8A2 zXq46`PZ~~OGBL@4!81wyy@jK4-te4HPc<0G>)|C0&hR2H@R)O+z5h&N6eY1F>;rSD zgW~y?H<{P2jbD4MU2paH`T=$Uylu&g?f;%KUd{ww4EbN83Kb++SoDFbyk3>dOrM(E}=Sz+pkVe-h6q~R?){!|q9Bx&$O za@dK>z13O6Gf9sQjqh0f`B%TT-OYME-x5FXtT>)6ozl^QfWl(Ku1v?kqvN6RzVnRxD_vlFI}|Ze*L(Wg&}2O@_4S zDoJ2$jE$T~i!BovyOV9M$YXq}WF{1KbM7wMm=I1kn2_5mt|sWwUy|TDkGWNDz#0__ zbeyeLr3h$NltMwcj<_k1eoj&zz>2*!YRHn72@F5Ax&;#)%BV4`jfyE3DwWG&ErAfA zAc|@!22o^ka~stgL^;2(1P&jYWn?aOK^Imsvn<7ED4Y~PAcw23AQK&|l zB$t*6%MHz>*2rUYMNNB^$}=T1#+Y*BPpuq*gX1{!G`x~3DTrS~i3 zF}}A(pN6-&j2TC?*J>Ld5f^1lhjnq zrCYTK&*svai@8MFBjH&;8tno=-ef^OjaHRPr_sa`NLy_zZ4Y${_L`PrhmNZi;x`_( zemw2r@5t`EWznJU*#lX548wFeFl5y>N(=-rh6!NEc)Vx&SYgx)kw1&m@E{$0dlE2A z|4dSk1EZJ6hI2lQhhyX%)?N(LSIuEk4?ly9FiHz6etLIezRp&sb;@TEYS1@DeCME0rH)XTj+?r9s*W2}g#tG(@;ZbLHV_c0A;sP=>H!Urpwc8Yg~V3DWf?Ap z0iZM*{`r3m_^tZ?9+smQLBnY1_k%yY)ZF07I-QzUsfxabdYT0>>immE`Hyy^#wm2V zx2SLjML`ne(`i)lH+4NGt^)OOeQ!RY{SD zxOpq326XihQERjGW`0887L$Frn=BCDHJ#EThnj`mQ=i#cso!h0N9gvrBZ$s#DaEtS=Z0A}Aq%l?Guf6$fFY416J zP0JkT#fGvJObP~JdU41~WPs3-A-EC`s$Pl+2u{gbzwZRX10PCdk~HKHc?ag`vZPyg8u$|q4$LJ8wW<@BcA4({TdRgw`U6dGZ>XRU>O>71s3AbjmQq(&)MVO$WuLxzh`6xUy?^N*Y)H{j1 zZf%S4@ohU|DP}vfDJ@rj{8nYll7{~N{*`D*=Lz@b>wWw8E9SSa9PBqjmtRzMyt%j} zp!Kf8;N!HY?*{Le5ViRW^B4IJ1mtSjAc(=-a1U!%p^MQ_B0_cei&``6?OK%lgp>2e zX)M6$*m=R~h5m`&^!o-{WQ4lGm@@vm<8WF+W2&Hg>j(|6f&eDwhHiFS34s3m4gKwR zI60%oFZ^&SB(&h|)b(dM=`PFvU^iFt^5HGzh|J(w#=rNL{4CpC8ZIv0$r+>whQR`EI$ETEjwejTXKH zD+f0xj&EMO@bF*tuahSkH`bJ)CxacQm4-tdh_&KhwGiJ4@y|mLBXqKlj4nr3br?;0 z9_!U_f4f%EQ9b+8e~(2uLSFjt>`?g}*U(^y#p2LfjK}Y2TPOD4vRZe4P*u*7=Z_EF zSef-(;dIZ;M6z7zE`+{H)=E){!01pZteGDMZw))_OS9R27258^>4>X~?GrDeCqByg zB6d>xd+fOsm4^9e8eWT#&n7-36|{q4Auw)a;wr@7w#2 zZUHY=L$i9PL`L8ePR#zWx;%)u~r?*aG8g;h5x$0=n z(ACm)1I}~}#vva9YQ??|#?5@KyI)?3DfF8dsK5P19JOBdTqC;#c4I2wA6nfA$@DeV zqHtF9BFPZ|669tFLffkk0oxMGtt01WCaI&J_dZ{J&^ONyy{eDnJD>&&lG2~W<{tr1 zQ!GLz6okxxX?LSh@x-g$Ba=3YigE5~=oee=L#k}f(zaDzJ%@v%6I0uO$lvttqBe~dE?OfG3OiW;QW zQxzX6Dzf-jF&`#Xqwm$sm0@YV7r2H#R?$oY72D#R8qbeO7e-&*5XP-skQr#H@n?fz zuSiHq-te0zkfR_zq?K9qy@BgzY_DIMKi^jupUj#QU-7a;_r6kzM*R1OnVkd@5!tC( zW5oMy&)9C?s_1!BIGr^di5q-v^XVga)Sx3Sxyp!8F5Fa2RvTHiB98+oWtD92n;Z)ZhL5gwA9P7LMGN#Em!|GR6#?VuHlCla}{b guVt=(Kk#h2Ca)0!(A^lP`PolVU$GdSN}{8G1LB(U&j0`b literal 0 HcmV?d00001 diff --git a/public/dings/senders/kiko/message.ogg b/public/dings/senders/kiko/message.ogg new file mode 100644 index 0000000000000000000000000000000000000000..8eae8e4e171a3b0544a0db81125821e0966916fa GIT binary patch literal 5090 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 literal 0 HcmV?d00001 diff --git a/public/dings/senders/libby/invite.ogg b/public/dings/senders/libby/invite.ogg new file mode 100644 index 0000000000000000000000000000000000000000..f88e6d2877996eb88e609a02884f7b57fc8c18d2 GIT binary patch literal 5613 zcmb7Idpwj|_unJAC5>Fdkw&SEh$(V8&1=FG6U8tlG`UVfqcSGl)Koa~M$(Wpl{zGy zq|RxSl&D-PjoUF35)wM8=u~u*-+G?uoKNTX$M1cA`|~_|_Otd{Ywx|*`mVkAtd5NI zMM~&fv8ej-vyJzdghEhcAgeE z@qIZwNukFUy4OT)-mq|WTqJ2@RG2@mo#a7UWV6V|#@@!3WX|M92*RUcBT4kwtx<8C zVtL%ycrvDTI=u8%uB+YXOe%=-VTn@arGx4HH3t-Jq_*(n=)Al5fRG2 zV4t(I{^r<|oqNx%(A}AewfZisXxfol4z1TWi;Q+Y02-fU8=;4gG7^&2^5S}2Ufdir zF&xnLF|w$`TFD1&M25-K?2f=8(<6^vUQh>3)TRbZ1|uPwnrV_Q)7>=ruZmol83fSQHnCm<%gfApiAE)h_<DiVxxGf`cdlp{j6-DKp{cj)IhI$J` z16$T@F;+Y);>=WW3)ds?DUE4X7iYPGZ(GM-x+31KF5dH=$nTzH?UcWq0B(mu)3K!c zF0DJVv{A>dvDNmWj+c+ESL=WA`0DP(;{_ICdSZP}v`}qvW+-kJA*eMtt>&t!7zr3s zPd_8&HRZj1=%|&~pI@wdytVp(-|_k7#kt4%QpZ8@MyX++m@g&YKUR}}tSOH#_0Y^K zRJ68FKdxF*+yXSiL9te?VJPrLwdAQ5x32-&fTp~GbLG0wIv_eeUsCR;(1$DKON-Zu zrPYqTAaS7McucuE2U;H^#IGN!V>B4|TcwVN4-Nbl`pBU6#6D7DhDw;O=~>ewCh>=~ zj^tD7$pt3q8Os^P`@NZl^9$qm;VUO0ltlbKicM1t!ct9(#BmxH=L)jYEbg2C*0AV- zC01RyBXt_Obcg7wNx9O(u(WIQ4^Nj2oaA40bdM>N$O0D>LV%MC6^H_-k|HvfsTkt; zNXd-@ROIi|_}+0NW{ui2o4Fo{_>0 z$oa>kSURi(VQ|9XC<;4BsT1#C<)W@|@HgbF*rHo^*!bRIvz5P^d9_$R{L^;zTia$G zI)sa_9;aDP(5lb%UBYR#N6ZCknRW!#{vi2-Ga-DBT9a>4h=0ocrT%;LuZ=N|ACw;im@wz=T}Rww~94Vn;|>I(`S6|0EDVCHCJM(#LS0j)yA}IV_LEN zsI31~+AwH=VYBf}JJsof(aw9hce8eGIOw9eVIk0)nfE1E zI(!(Cvqn=O8El645>aDnfHra?AflRSVJ6d^JRpwKA{UCorKV>^{QHjm;{7ci#bSPo zBl$2B`;3!%{2}&}I+9N$!A7TK46kJpKt&mCQ9@dEZaO7EqhxIUcQ^ zzB+VPo0|{Ii!I?b`*DL>Y+HYaPdgt!mX95;-H+8d>e-s(C2!;TumnM6ywGjD&M|81 z75L#7waA4b}oj|n&*=<@f8tenJf2{-sda(D6P z+BXN!pC3B^O5>KrgWH#;RJ?s$X)O|Te&BUQKCV3UrteGb;h4_bl%X>IJbe;0JvQ(GVCT9gLNomVY?$h7h_b9Q~Vfo(!g+5B`-nKh* z+evv@dhclB{f$G*XYD%mXT$aMSD$O+K06I2WJpIe;ml%y#fHskOeki#E{*fL$+w}T)YRgFI5KnbWK7uSF)tC}^g9#%P`Fe`G+qp*hZnw%ij z(~?ZaL6~mPLC34I=&6Nw*C+-*C>b@t1~*t~VFwaCyU~uY3yzYUnCQy``r(a|ep!t4 zW|1t~>vDfbSOayyAmU-U>NFQvpuQ`aQYMS$Q3i~^4NjJUDl1tBbl`)x8NoVakHA)P zP*q%$7bC5%%MD*`B{L3hpw{HXJS-o}3(5qjYdXM&lqTGWd7XEJJX`9#Kj@qTXx;~2C&$f7I;qa>NhU=&LLwi+00H#UjZ80TY2hh~9< z)mtndymj-jDJg$pu`<^PvKqBIAXx!6@xzJdR^*7a(FrN@#A!_1B*(tu^aLJSU+l9b+Wa z*tlk9Z@~u3OZco*5DL|FQ87){bb(W$$i+#+1Nj z8CDDxv{D%O=l@mVyXybD(nHVLy{Km*F)IAs7Rrt!fRzUqX}y#!#STURY=l zet{wjboD5f7=$0RlLs<^1y)l&mc{%xrFL!1>+++runUp_IGs=>`r##ZgOB+m7_(FY zr*H3)nl&*O9aVdG642wRT8OB=M0zPJ-rvD!Ep8?=B&4NMYQ=ml0I(Z_0&4K{usiu1 zEh+OG?X-xwNI{z`@&<@vd_ZH|Q^;aQn;HL&MlQtkVZG!+ICPsUPAekl{nncPcZ>NK zUjdWxO#;N!O>tSQI-P`R!CKh3c*~+t7op8PuqB2lpNQ}aNJ^i#b{oP2ACAf-Y2Zfk za?H>v^`yI`6b*8XSfWYZZ+zC1oFvMYQc_Zn=m$;Fn*^s>B6f6A+N)%4lSx3TYU)fx z3*CF#HA#*5b5+%~Yggd5;}VhL?q;cSK)sFTLz#&z7ur0VL`$h%-cd6--mqui!q-kj z7vetR-KR*yy{cN>e(9T(`Hy>)Pn=<@;#W9Gn9~rdQz2?M8?X4OPSriNUd_V;PEZIn zp(<5W6{_UsW?R?X`-U)m_97Q1b_G)uWo5;wAG72 zu)|8vTVuh(i9I8)U_T)KYKN+3%?4_bRtz>Fw2SbsN1P7V5!EN7)3nC7vj^8a$$M&9 zrLt`@%G44UD`nQ!&A|BJj(aYFI$pmt`aHiV}{mCXm)*IuO!}@w;vP1Hk`}TL|@iodZU9SYi(yyV+f@ z?W?D~`_0+D=1x$?a<9Wp1k{3ddnhLcEe`fc2`-&%{T`I_Z(lrLs02at978WmtVa7ckO2|7Q87LuYXt(Uf#}Xmh`(-dP zVsJR1e@&p*7Jcw9ibAspY7l8qZV2O6Er9XX(~;1yb0tu|?nKC>Y}Y4OnvhmGUNJdg zTsCm#C$JXkQ9-n8w{bC~D|{n^)g05oK1oKCQ2r83?2E^do@w!~PUX(*eqp?)m@HL7 z`XHo`LHSRL5b7ueoPaG?Af#XRh3!x1dEc?erD<0T|K&O0f3f4t#i8fm!-cre?JTLQ zbmGG^bAs?ejH@o)N0+V*PTB)rSEe=}vRR*?wwG2VsZa!^_H7u$iu@@x zNf-k@fFk7k3~3OQsm|lh)TC3y0NQxs>uM7vHIz!Z&MoEDcgIF$yXcvFhlsb-8b*q@ zAUD>jug(((e%KdTvW2`%@UnWe+IcAc`W{_M>DNoM)ExchG7 zv*?b!)F|l`?k7!b3gFj-j|oZJc6G%|8OJa z{KuAYi7XFQeYv4EqJqZtDib)v*43MG9~$bsdUg4O`gq=%5wHgnp@L8?T$8Xu9fcgC zwd00FA)h|J?yNW66s+8Q>)r+k0`FC2#oDM+h4b3FIwAA%#|PKPW$(Ow&EP+nB!vj7 znl=?}Rz__92)e(`e!l%h&`S)Xv%n=whwTZs_HQflN*up$=<4+bJ%T?0`W!+g7HbP_ zgkzKQ@hPbJrmb`qQnWkl+F z&KBIc>D1NnL#7)1=KKM7ln))3mKYMVoOe9f^i)1g+UYt8>Cz`Y8&`a7YqGR&`sDiR zSGzCrfc$d=Awm!BZq=MIT4WQ9mYTf93-aSmj2$W+9-czII*oAMWV80@g*m9_DF$&` zWSO(Qc&_`8DB^tQlo0BUNAF96cl}Hi?3qZpdcLewo_EG!>Lw-BLg>j|Pl$Si$JfZ1 z95z1!62j(qkYW@QxIGV4A(~n8l&7EHydSdfUg{Y2d*CaCDnGn0b>8*hqJ?wUEh##61GLs0fzch-?4y3l~^Gv{^ zqw%G)o?KaVLO%L=Xf!zD(^fC-sAu4z0s?xn7n_?7mUp2%ZWx90*E(5zY4D9Zzsa{# zhD(2xT-MG%n~E(2A=P|f%D_0$-Asp{VeLW{NNMjt{g?Kk8%s_GC%g!|!^YIx5`?yh zMu7BOwZjRQKwefF!0g$$skfr~rc|=HGPY=6Z=@Z)K)V%S2NziM1iIywO9 zpz-E`if9-ZWUu*=FpIGL+xW;BA%-A`FJpfQ4X+{K{}%~sFwVak#)(n3=O~X%4*T?F zJ32ut#|g+GF11%09N6asEsk3`GkpD$@ARYg9yj~A@OV<%bVi6nH+?O2o+T< z&E4H}b@bQW`^r|D?@q;_fr^!Pze}wF=*87itKGK&CnP&XO+!c*i75v8@qKPjua3@| zF*wEF%B~TEk`FmZW+XFm9)vx!&hBx0${3nuFe!8*0yUe?K<2Z}5&GL2G_ z7uifHB?((-jpPHSu8)he{oD*G#eOWi@?r^_MOB9hUtHDi6~6q(7&(dP20}g$xE*9z z3m~I#BqmuaPiqz#iNO`f`>6Tt^Q6``QtKBPB4ed z-62qI7pQUtok2JdBnYh%bpO5MrjrS%XbwGV4kc?gC2PPXOE)Q{LI*`*E-A828*s^Z z=#?=yCTrua+~`C3WykU#m!B}gryv4M!CK$Ww4t3D(YvznF~2k>zvRE|%uP*phzY)I z+(y#&mGL(ExQCk%#FWXlX^gjDDRgQSE?b%4(U{fXzSj725FqT(m}d5#0m~Y* zm)$XQzSH5~6kL>1~oP(?YwZ z7U)-&w*zj*kkqtcMg+`@Y0uX$?+O9l(7XAAWi{pi9g-Bxm)8Vp<>5kw%JOwmWgWdA zI1W81h^sN=0`zB;^hK*7h6BChgCU+iOo&^=eanX9`^kwJdXeVVCDz%q5{{T=7txw1 z#j_4)tYDQN@MF)IFH6{uTTVtOiTGs}Tc=n?rdpRuwm=36PLXLV3W7?G%KV4k47@&4^5UT? ziuN0Q8MtAaJ5y%1ebMi{B0(~1^BGTOPVja?=#QfA>SXSHk)|_I(|LIP^b~$*?%%rB zbg%?r<%E}`Br=`WC_S*+%}^WQ@yc1b&Ajm_>DE!3RX^MKw%fP<;k4$pQ=6G5q)Wgw zZre0bhXp@i3Ae+U-@)Olw)4BH_~F%wp;d{R(Jy+Z=*!B1vYAcK81VXgm7yKm*)%-X#v-Oc2?XL-Vm?^@GmH*Nc!xHxr}an2mwU5Af(q;3PJ zc_Je0ot6hk?k(0s(&E+Zhti+g{bQv6}HnUN7>kC zywdxLG*C&W98ZFSPKN|P`y?n8U9?RHnKr&!=H21p-|po9#-~lhXiMOFclZRDd2&=9 z{v6+6xu7kOAKvcNaSuM7{R27v&VsH$PWOmUN3L(DO5o2Cg;xn8b_%*j866kkQ_!2h z4c{T?8s&#~9$txy3IKqTOrd3H|Y1m}9Ha`?fyqV+la-cFA;k*7++^%wEO zbGVxA{LoR~&VPwGxyeB7uNHAFw`i8007dJ88qDvSzNL4`p6=1)Xna*|9Ot$`ZU;vM zdb`z_s9-rCFkLwUO)$4>#MwWA+s5ICCU8Q7_+6vi&=HY8Cow#Sk5NYjn&~NB>csv8 zE*Fy=6graF-I?=9E$Sch>8Jt&MIE9<&4?)an20;(+4-+TPHrMk&R;*4tSNuj@GAZE z>1U^(8(p)zef^yA<<~v6izK4%F+p#1PwkOc1MeG-#&ut(J*yJ-)Sk7!_ImLie#mzv zG2JE4-d=igsdmw`OSGQaf0}btot*F`DXN_00gF8Y_Y?bFR{W(X;(nrL=bnh2CpxPR z_m3ntZ+^Do>+cKyXt{Lw`MZYrcdkP_GL*w6hLBKLZaADYF}M18HcB-^8;J3yw29Vi z6W9-Arf#e$Jfh=Jaa4=9p{&?~I=re`f&%^H-#?gj+4<4{?GDf=lIj`P^_#pb+6Qi+MH-OMBj z%Z`>Ki8Lv{8N^PL7YFj<&Ofr?DNjl)R@t2gf`InOlGj4VluNbB)v%TzNKg<(w=D-z z6iR&y-5W%?KDGr8A8Ne?>rf?#qLkNzC~AF(yEipg5=o`h-;Zpmsm~49KPk^-rNeT= z)9Kaf*h#XRAzJ0>@)1i+x#cQ5XW$Sxk35L1pv!aPV$Tm+@b1VTspFJaOVzQy=N~s`>#CaOEm1utR+xeANk}%Iovvly!}H zyfsuciPyrY&y8!X8O;yRgi<$gfdeVePY=r05HH&#+j`3+J?jQ(_FeMLJR#ezL3$vQ zRi7s$vYMp_{8+4G0C`g+Su9p%GMmLJmqXbaVP(7dt|Ww1gl#%ZJ0z~#W`Fy&hrctw z>%7e!GtUmF%Iz4Y&55I`qG~aaz!)ZwqY{aF&9IA6CuII8K1T%Q;M8pnKTajOh5`V5)vD24w>0syvhC? zzA8P0B23)$tdmXL;8rMc^PT$AX}Ntl2<`fhq-%~%Po27 z&YfXwm;%<+jApTapf#+Gdr|YFIJk%o`#wIJ}aQpVFtPhDhOV{t; zO+Y=9OcBv=iSk@lLXeBqTHH-GNX$&7HAsb~P{1B68i>K~!(NneTvFyZ?xck6sKw2d z_(6$c^8m-XrRBw(HZx%yN0DLjFe*g`mu_3_ZAAjqacJsa5c~7IVm51>1K13$ab9dV znS@EfAWSa~*~!ci+S&(4;s)L0QGuaJhv%)`iSWdSt1?*`wwba5b971*`6fBVh*B?= zn@|prN_;3uk{l&1B{kb3+<4jqxXlu=tCP+?9b1P?0@61yWFuz8zLV}r2E?Bfii;O7 zz-z}XD#gpgUhj~his?_AiR>3TJeV*Q zUZu?M>C-)aimi`7;b6m@j8LN7gQ?X4esHtOo)??Bxx5n3Ad4#e>5Z_lx|`pRd82a~yMnX8M7voqbr z(Z!KQqb{~vNVV5xEdTK7<2U9lig0*bLP^-f{ie68`D*iNeb=U$0|(LY1J~s@-dx`C zq4s5GY)U9|G-~5(E40D%{nran`^6GW1+?!^d@8uT%j44sEGXpKCM4c~5sr{f!HEEG zRnuP$*1cQB6f55D8W`^97Vx~|(tbf-_30w=OqVlODCYf}N!Ub$mc>?xb_yP_FLJi|LnQR<+N+YmD!p_l}yac$$?Yy7kX zR;8=BMg`2J>6oL%uhSPHwC8^Bq8%WkuyNzgp7k}4*#Xjt2==rXv^Uf+vq1f%g;$JVoK@C)Ni0*x{v~Kd7%h#5+mR6G%;hYT2-b9`8H6!!)Be2$@6wrCm75D3`LfQyAg&}Keb4cE}vWcL~ z&jP_-fHiTJW;%=>Fnuqa56BGts(1C~8F4Ce>!(AWr(gaujK2M<%e7}3ju@YD_Uwln^a_ZWxLa#oEnG4&g=J>wEc&M5 zimXzM_I-ReI?w9Q@F$ns@SsmbUK1{U&KM+qxe)l*&P06*IRHgq-K?tb#{!sQX6=U| z%lBjI!Ld?oft%o19QOu#XipG7%ElFyA*CV4k>ftBKk|Dab-m^(X>U1Y7P|Y1B|^-L zteg8!0GyxdiXZ!+7JN{wu)^{h0NP9~#6(I=qk4Fi;C`IT6mQcXci>>K> z&b+#>@AoWszmT|QXU3Y?wCfWP*#yW6f1F2Aed#SW`yRHO_t}^FCSQ12Xz8ozaA{n5 z&peudrbBF)+B$)0J;5-Xg)H3-#g5Cb4y>CT^1P7fq%q4K;KimTnIa~51WDljgDPHx z!h#Swpaa6A?e;JC(=4aj)vD;GtB+t8i> literal 0 HcmV?d00001 diff --git a/public/dings/senders/libby/message.ogg b/public/dings/senders/libby/message.ogg new file mode 100644 index 0000000000000000000000000000000000000000..d923f60bdd2fad0bc6ecfdebc6e4de6b0f60685b GIT binary patch literal 5075 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 H3p)P;7?nxp literal 0 HcmV?d00001 diff --git a/public/dings/senders/nibu/invite.ogg b/public/dings/senders/nibu/invite.ogg new file mode 100644 index 0000000000000000000000000000000000000000..4111e533e78ec7f5df3280c261ac841de4053252 GIT binary patch literal 6151 zcmb7Idpwlc|NlIb+en%s$)ZuZj3|nVvSwYvL@|tOQy3DO5~e#lm4r%GL(*im>ykF5 zwoz)c(=I7VE}=rEEw>ceE^H~k&ok5RtNs1)`+k4t^~`zB^Ev0d&-q->=gjKxa4(?1 z=c3{K%@VSyJ$^uqFeJ8ajtPl~B?*M^8uuMn%rng0}WCfO5u-mP(Bz}T1N zyKxE}OYHWGh~6-Nbxb&YV?>CL9G&h;UueG2+gfrZ9D~0vx8~_?X-q|Ht zyKY?{e!OeXX=m+S$z<#4S?Btl$>rD@xL%~U>kiUl>6W2W0F*$;P|Jyt*+0KNY&flZ zlBb?YHQCyqVlJAt-!}76K)=z^cKheHJ%(x%{FMQv##A#()MmRF?f<)C6l8~_G77Up zY8XZOv9%V}^nIE(z4=Ey?5T`=50*)BzKG2-YYB)QxUSj}JNV5AJ&|UQAb(eKKIpg? zB!PW#RU{kskNcZc-vO`7F7c~-+hvnIBc*CJbvw^Oq>$Ctz9 zZsRv^BnFfyJl%}`2AI!l32QY3kEQ=cwP zd*+fhDHj$Q5?=U5%*c3b3qTe4>S-%3EWV_|BUI(aRx zKDzv#mesu`%ez|co|f)S|Hb2_y+>{@$cWL&^;wZZwMFT{azR`-;egN9-y9FTnK z89k>y=k;A{&77{hV(k-6H&VP$%qcIOANt{3|N5Nn)8*RON)eryBPsV*;K@N_>x$Qk z>uy*-!5Jx!PDGVcx!C$ER6KB(O44xM&lOc}K1|eG@B`hOC->6h(^NvVjS7v98pa*b zJep^5i;-`5IBf;1c%KJ*+MI&8y>ia!0Ex6Ot=K3@Hze7pNF1YXaymaF)ud+b&$SEh zn3C1m-|KApOMj<1XjoioWSLqu`|u7+x^rXCS-V6PNLm8s6`+AL3KSlNN~MRUf1@%@ z?jK3nG3XU}d)2>G+$-aIlMEWZcrQAV}iEJ`?(JUovrbmt%ui5 zO$zhR`q!gi9XSEgo#gH)3OQs^E#9}vo~lsrXU}ooqFw!+{_XFKS6(!BZ#2F8r{(I` zmJM1?Xf9q;xD8VTO*&y-i@8l!VNIN{=B;55%fbRL$NQJXcMgB?v6&5epD|nRImO3w`-*?~U!Ie)Tl&YAKbHrAZ$ zzxp4ae>?{r1|KK3vD3diM{1$(fX;K#)cv)BN81LB4slLXzf=H#G+kpQ8A^;j*=Egb zt7f(t$J>_kpGq_7S}52|d8D0SGorWac6MdPt__FmHFoxA$ENMPduYb3EiY(`l6Q>H zoT;?q@NviFEhsc6pkeIP-KXzRYt$poU&VeV{;|cM7$wWNpDEjy_O+;5wf-Mx#tR7nK+T`fjXzBUJtwCVh z5XW_Ga`V!1;xs#Ya@>6-{08r^z(&ia`}k?)>CN%9;y?7}w7qj{%5raQ=6iAkfo1&Q z?fkZ3+ontS$?u5c25#d&91aU?J?t!p@PCu=|UKDjL5>Tc>>l8Xzi!(QL8hf_CoEKX<} z-XAXCRRhPW!JFH}5#YLQEhLn$Bn&AJGx?pq+=uV1Jma_xoG|}5j=xXX!(p!fJAo%B zJ}@(k>>cKJPEC5)68|KQ%O#P0{NKg5wPtp;2%e0%HI*TQf+j(H=Q~08aRGP4sr79< zCo7&O30pU^zq9zm&7nhQ&h(#oss5|Uo!?5wUwhqNu|Onf8{v0^w^tk)dOCXZyQsF` zEc(l0+bhnQ{`z`RLYUvq!icuQ{`Xh=u2wAQziQE5abBC#+{y`DoYb7T|EW&G(+BZS zmah0`q2LGco!b+Fx97H&9e(mIzGh?pis`$*|Fib$;g=t7#(c2p*_KxKN`s09McRh> zDGhDCi!&fu3xa4dnik4NYz>SD1)BD(Nj%!Zp8U{SS874N4$IXnM8m#2TU2Y!I3>!m zXPE7u95BA1Qil~$BJqieG;5);qE3l)9T;YXEs<^|&3YCpMzO0{-C~Sx*wM1E=X9JV z`#Bwt^T-SN+Pp5YNW;d&AW_6JvB(sKXcSlauu~=Z-n^&_T{^tFQzD&}CKs@SZ_%a8 ztF;T@lKrcP`FwGpo{JrIa8Pqqqv8%&PnxT+OmX zA!dxK2O+iPRat?mrzGjDL%7|*L)MpDA}177`Y9GaBzdPxF0Q-M#0qEdt=>HfIcqJ+ zii*6@t;4$~>1v6pyI#~1>3*T>QAn+Aw{Ga&a@C3U$e@k`!=kJul5f$i|9Npn3qqN- zJi-q7;>~)pMy+W1!}2i#bO-mQIFi!#F22c<(<%tVI3FyKQ(E{uKa%pWjL)^+)PWu`H7Pjz z(ba(u6$><~U%8C~sg^zn)i?SXJjvf+S}Na_3P7-iy^7I(4SUQAMfNU?SpY#&0_f7j zg&rGjv4D=5CcQEssss-0hgV|N(;oYZ99$>C?AxQH$}j4iwd#{y1hh}k1R8a5U1>&~&r-cLaxoc$keO_8 zQyi;_4(!OXKp1&G?85j=OHBVvJ0)T-RM2LLJkUkSaY$p`R?H=ZW)Sz8#wZ~1$X-SP zCf#!t*@_5zKes0T(_;TUE}zZ%JOigujpTF5>Qo|$Lbk|tu}ZGdPQ9*IFpCqupc z6A#Z`vmNAykEt@fE?^^L1u5vHTl7kLk~*VGEYV=>(=T*mB#JWYERvFs>I9CTG7hs@ z8kss#_E6@UrxT#6Mr8vtc+V+^L^axvQt6c|m+-b@ADZOiXsVJzZDx8}7=Y<~^Jn9j zNjFzKs#1=dCfhrIz=mc|+e@qb1JqrlH>gXN4<*fMmnoh6fvqaP!ePLi2vDs;o7AB1 ze4tv@CHa<`t1D(u0QDeM1!*wF(b2NH;mHs&dB#F}HhBe86eT4^)=yp=m6YW7K&2y_ z6n*49kfaZMU`{if>dW@=VtaXcxdg8X2v{5Bx5ht!;~&TgSmVcDMwr?T{{ALO*xz;# zImRN3J()Q06m0xX)?SYO;hI&P&fvK72d4ZUnL1BH6J#G%GljpFz#B86Ca6ut@zUv^ zw5TI-ceI79KWLDZpa%075MWFJTL`_}vTRiVk=^sS;@jnL?lg{0=<9fXEIn~q!PVUz zw4*n2{vi30O&>3Pt1kOWsuF<;!Xo_ETY2qAwPwSL}<`4@ZFkQt%F0G(PdeTq*mO=(pXv0!2A^e>Jo0OpjWq;*+(L`bSTjPeW zK<`ML3)Fo5{<>&&utEms3!!QY1D&OV{UsdU#mnPF4dH^cb zAleIp0tncQoktBPK=hN_-T_e0@fi>A*S^30W2@3u7^iOV48K_hXtt#17-w#{XG8g zg5a(z{juS1&3c1ploVc3!Yj6Hx(U%bO*j+wS_&1I=huN)Ul@BkpxpEclbs3G1>*y2Iu%? zGsd-Q%)@^~-nPsNTCEZ^PK{JdX{L$nu7UEa^cgUcLxD~Jw>vva?}Oih-N*Nj?jwW| z#ETdL*0xwyPP{!@9nR|hYhWxSvM41l2kn75Hd3?>mVU4y>IER{db^x}-^eY#D{Gq! zzx-VET%GCquvyImM*JiI`7W3g`}q*J_(M{^>Bd^LfFtF^D-~tgm={z$;66Js=dEq@ z)V@zEMix0vN{LKeiAcgJAUmT1FKFXQ7Yh3Jys|Kg1*LN!=q-MWskGYrt*aaH=5}Xw zAEs3`@5C4qf|P_~xKt>*eNZk145^Q+4lX&^F*q|~aP#X}*O)Vm`K0&wfUw^RPU|YR zJj?*u9anhe2J}>vGlh1x%=f$-!t_tI?W2G&`j#P|sD!&g6SU!vWru!f%3N2t)gjgmK~2kKMI zagOj~3J70QLG)M^eOG{l{!18=zlYhOot-=E?AYrq;FQ&cpUjykkXL~)(n&FNu4?b5 zm|T0AVCU{Wc<8a++QSAB-Ywd`2>0U1>^@+&YW-pieN0uMe1#i<6sF|(O4+D0A*8^_ z6~gjkn7(RkW-`V_ z?5{ZEWCQh8N)v?NkQnDW@&6F!ON<6+ADZ5H9>Lxe4JY4Ul$kK1Ly8364U#pD0A1CJ z5&q}__;Y!+%`Wcy75-zw2S@(E@E|2XXbsWcYp%ZlyzN9={ zF35gy%?5sjGp@$8<`J0wfE)RLp$6zG=Z=)LAAr$cob7D5BkKj9f-5U7kv6$RQww(gzGnT-K|@3sJxa@;v?##P0c<3CLDb>~>%bRl~yB(Tm8l5Kfb(GGpGw z!p!JGQX})*&Qhr$j6#FLJ)5hqeNckXqZQQ)mmT>tEn_VG@x%cM@N49|ho68vo(9+Y{xxzi{sooj5-YCA|y}b1S93i1K~E(9PGX&rQ1GJhKd3plbah#Ub*2PC#lehUr3Q5 z@O>~WQ@vFVI1e1)LaA?Ual4L!uR3FiW zDf*tTmk$^oLK?^lhVblz{N fOd)LyLY%B3Fso_QR+s5dj@~&XuXO~ZeW3Y2CV5nI literal 0 HcmV?d00001 diff --git a/public/dings/senders/nibu/mention.ogg b/public/dings/senders/nibu/mention.ogg new file mode 100644 index 0000000000000000000000000000000000000000..a36a9325a50b4f17498b933135017b1471ea313f GIT binary patch literal 5734 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 wQtrK2xO{-9H*IsL{wbyt5)^A&A=yo6j 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 WAtHIBATkPT^ZJ(-lp7x@f`0?$4`aUo literal 0 HcmV?d00001 diff --git a/public/dings/senders/void/mention.ogg b/public/dings/senders/void/mention.ogg new file mode 100644 index 0000000000000000000000000000000000000000..80ec43e39c6f8d7d48bf87f1aabd5dd4340e4278 GIT binary patch literal 5022 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 QD~|%Z!A*gaE4{)00P0INDgXcg literal 0 HcmV?d00001 diff --git a/public/dings/senders/void/message.ogg b/public/dings/senders/void/message.ogg new file mode 100644 index 0000000000000000000000000000000000000000..5c8fd5c87a32a70d32a665803b05f212e3490cd3 GIT binary patch literal 5362 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 Q&q9wjK@#Ps{#Vrh2RQ}OrT_o{ literal 0 HcmV?d00001 diff --git a/public/dings/senders/weksa/invite.ogg b/public/dings/senders/weksa/invite.ogg new file mode 100644 index 0000000000000000000000000000000000000000..b9dd564347dba2d527ccc66b45b46e07d619e2cb GIT binary patch literal 6120 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 DQ{hdY literal 0 HcmV?d00001 diff --git a/src/app/features/dings/dings.test.ts b/src/app/features/dings/dings.test.ts new file mode 100644 index 00000000..d5c5e601 --- /dev/null +++ b/src/app/features/dings/dings.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest'; +import { DING_VOLUME, createDingPlayer, motifFor, parseManifest, pickDing } from './dings'; + +const manifest = parseManifest({ + neutral: { mention: 'neutral/mention.ogg', message: 'neutral/message.ogg' }, + senders: { + eureka: { mention: 'eureka/mention.ogg', message: 'eureka/message.ogg', dm: 'eureka/dm.ogg' }, + nibu: { message: 'nibu/message.ogg' }, + }, +})!; + +describe('dings manifest', () => { + it('is read defensively: junk is no manifest, bad paths are dropped', () => { + expect(parseManifest(undefined)).toBeUndefined(); + expect(parseManifest('x')).toBeUndefined(); + expect(parseManifest({ neutral: 3, senders: [] })).toBeUndefined(); + const m = parseManifest({ + neutral: { message: '../../etc/passwd.ogg', mention: 'https://evil.example/a.ogg' }, + senders: { void: { message: 'void/message.wav', dm: 'void/dm.ogg', mention: '/abs.ogg' } }, + })!; + expect(m.neutral).toEqual({}); + expect(m.senders.void).toEqual({ dm: 'void/dm.ogg' }); + }); +}); + +describe('manifest guards', () => { + it('rejects a dot segment in the middle of a path', () => { + const m = parseManifest({ + neutral: { message: 'a/../b.ogg', mention: 'a/b.ogg' }, + senders: {}, + })!; + expect(m.neutral).toEqual({ mention: 'a/b.ogg' }); + }); + + it('is not fooled by an array of senders', () => { + expect(parseManifest({ neutral: { message: 'a.ogg' }, senders: [] })).toBeUndefined(); + }); + + it('ignores motifs a clip set only inherits', () => { + const inherited = Object.create({ message: 'x/message.ogg' }); + const m = parseManifest({ neutral: inherited, senders: {} })!; + expect(m.neutral).toEqual({}); + }); + + it('gives object-prototype names no clip of their own', () => { + const m = parseManifest({ neutral: { message: 'n/message.ogg' }, senders: {} })!; + ['__proto__', 'constructor', 'toString', 'hasOwnProperty'].forEach((who) => { + expect(pickDing(m, who, 'message')?.path).toBe('n/message.ogg'); + }); + }); + + it('reports the motif volume in the pick itself', () => { + expect(pickDing(manifest, 'eureka', 'message')?.volume).toBe(DING_VOLUME.message); + expect(pickDing(manifest, 'stranger', 'mention')?.volume).toBe(1); + }); +}); + +describe('which ding plays', () => { + it('uses the sender instrument for the motif', () => { + expect(pickDing(manifest, 'eureka', 'mention')).toEqual({ + path: 'eureka/mention.ogg', + volume: DING_VOLUME.mention, + }); + }); + + it('falls back to the neutral ding for an unknown sender', () => { + expect(pickDing(manifest, 'stranger', 'message')?.path).toBe('neutral/message.ogg'); + }); + + it('falls back to neutral when the sender has no clip for that motif', () => { + expect(pickDing(manifest, 'nibu', 'mention')?.path).toBe('neutral/mention.ogg'); + }); + + it('is silent when neither the sender nor neutral has a clip', () => { + expect(pickDing(manifest, 'eureka', 'invite')).toBeUndefined(); + expect(pickDing(manifest, 'stranger', 'dm')).toBeUndefined(); + }); + + it('is loud for mentions and DMs and quiet for ordinary messages', () => { + expect(DING_VOLUME.mention).toBe(1); + expect(DING_VOLUME.dm).toBe(1); + expect(DING_VOLUME.message).toBeLessThan(DING_VOLUME.invite); + expect(DING_VOLUME.invite).toBeLessThan(DING_VOLUME.mention); + }); + + it('names the motif: invite over mention over DM over message', () => { + expect(motifFor({ invite: true, highlight: true, direct: true })).toBe('invite'); + expect(motifFor({ highlight: true, direct: true })).toBe('mention'); + expect(motifFor({ highlight: false, direct: true })).toBe('dm'); + expect(motifFor({ highlight: false, direct: false })).toBe('message'); + }); +}); + +describe('audio unlock', () => { + const make = () => { + const listeners = new Map void>(); + const target = { + addEventListener: (t: string, f: () => void) => listeners.set(t, f), + removeEventListener: (t: string) => listeners.delete(t), + }; + const played: Array<{ url: string; volume: number }> = []; + const player = createDingPlayer((url) => { + const clip = { volume: 1, play: vi.fn(async () => undefined) }; + played.push({ + url, + get volume() { + return clip.volume; + }, + } as never); + return clip; + }, target); + return { listeners, player, played }; + }; + + it('does not play before the first user gesture, and plays after it', () => { + const { listeners, player, played } = make(); + expect(player.play('a.ogg', 1)).toBe(false); + expect(played).toHaveLength(0); + listeners.get('pointerdown')!(); + expect(player.play('a.ogg', 0.3)).toBe(true); + expect(played).toHaveLength(1); + expect(played[0].volume).toBe(0.3); + }); + + it('stops listening once unlocked', () => { + const { listeners, player } = make(); + listeners.get('keydown')!(); + expect(listeners.size).toBe(0); + expect(player.unlocked()).toBe(true); + }); + + it('a clip the browser refuses does not throw', async () => { + const target = { + addEventListener: (_t: string, f: () => void) => f(), + removeEventListener: () => undefined, + }; + const player = createDingPlayer( + () => ({ volume: 1, play: () => Promise.reject(new Error('NotAllowedError')) }), + target + ); + expect(() => player.play('a.ogg', 1)).not.toThrow(); + await Promise.resolve(); + }); +}); diff --git a/src/app/features/dings/dings.ts b/src/app/features/dings/dings.ts new file mode 100644 index 00000000..562700f3 --- /dev/null +++ b/src/app/features/dings/dings.ts @@ -0,0 +1,100 @@ +// Dings: pre-rendered earcons shipped as static assets, `dings//.ogg` plus +// `dings/manifest.json` (docs/gamecult/ratatoskr-target.md, Agreements). The sender picks the +// instrument, the event picks the motif. This file is the whole decision; the client only asks it. + +export type Motif = 'mention' | 'dm' | 'message' | 'invite'; +type Clips = Partial>; +export type DingManifest = { neutral: Clips; senders: Record }; + +/** Client config `dings`. Off unless `enabled` is exactly true; nothing about dings loads otherwise. */ +export type DingsConfig = { enabled?: boolean }; +export const dingsEnabled = (config: DingsConfig | undefined): boolean => config?.enabled === true; + +const MOTIFS: Motif[] = ['mention', 'dm', 'message', 'invite']; + +// Mentions and DMs loud, an ordinary message quiet. +export const DING_VOLUME: Record = { + mention: 1, + dm: 1, + invite: 0.7, + message: 0.3, +}; + +const isRecord = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +// A clip path stays inside dings/: relative, ogg or mp3 (not wav), no dot segments, no scheme. +const isClipPath = (v: unknown): v is string => + typeof v === 'string' && + /^[A-Za-z0-9_-]+(\/[A-Za-z0-9_.-]+)*\.(ogg|mp3)$/.test(v) && + !v.split('/').includes('..'); + +const readClips = (v: unknown): Clips => { + const clips: Clips = {}; + if (!isRecord(v)) return clips; + MOTIFS.forEach((m) => { + if (Object.prototype.hasOwnProperty.call(v, m) && isClipPath(v[m])) clips[m] = v[m] as string; + }); + return clips; +}; + +/** The manifest is JSON from outside the typed world: read it defensively, undefined if unusable. */ +export const parseManifest = (json: unknown): DingManifest | undefined => { + if (!isRecord(json) || !isRecord(json.neutral) || !isRecord(json.senders)) return undefined; + const senders: Record = Object.create(null); + Object.keys(json.senders).forEach((localpart) => { + senders[localpart] = readClips( + json.senders && (json.senders as Record)[localpart] + ); + }); + return { neutral: readClips(json.neutral), senders }; +}; + +/** The sender's own clip, else the neutral one, else nothing: silence. */ +export const pickDing = ( + manifest: DingManifest, + localpart: string, + motif: Motif +): { path: string; volume: number } | undefined => { + const own = Object.prototype.hasOwnProperty.call(manifest.senders, localpart) + ? manifest.senders[localpart][motif] + : undefined; + const path = own ?? manifest.neutral[motif]; + return path === undefined ? undefined : { path, volume: DING_VOLUME[motif] }; +}; + +export const motifFor = (e: { invite?: boolean; highlight: boolean; direct: boolean }): Motif => { + if (e.invite) return 'invite'; + if (e.highlight) return 'mention'; + return e.direct ? 'dm' : 'message'; +}; + +type Clip = { volume: number; play: () => Promise }; +type GestureTarget = { + addEventListener: (type: string, listener: () => void) => void; + removeEventListener: (type: string, listener: () => void) => void; +}; +const GESTURES = ['pointerdown', 'keydown', 'touchend']; + +/** + * Browsers refuse audio until the first user gesture. The player stays silent until one has + * happened, then plays; a clip the browser still refuses is dropped, never thrown. + */ +export const createDingPlayer = (make: (url: string) => Clip, target: GestureTarget) => { + let unlocked = false; + const unlock = () => { + unlocked = true; + GESTURES.forEach((g) => target.removeEventListener(g, unlock)); + }; + GESTURES.forEach((g) => target.addEventListener(g, unlock)); + return { + unlocked: () => unlocked, + play(url: string, volume: number): boolean { + if (!unlocked) return false; + const clip = make(url); + clip.volume = volume; + clip.play().catch(() => undefined); + return true; + }, + }; +}; diff --git a/src/app/features/dings/manifest.test.ts b/src/app/features/dings/manifest.test.ts new file mode 100644 index 00000000..fb194cd1 --- /dev/null +++ b/src/app/features/dings/manifest.test.ts @@ -0,0 +1,80 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { DingManifest, Motif, dingsEnabled, parseManifest, pickDing } from './dings'; + +// The staged v3 set (Aqua, AquaSynth PR #22). Nobody has heard it; these tests check the manifest +// against this code, not the sound. +const DINGS = join(__dirname, '../../../../public/dings'); +const raw = JSON.parse(readFileSync(join(DINGS, 'manifest.json'), 'utf8')); +const manifest = parseManifest(raw) as DingManifest; +const MOTIFS: Motif[] = ['mention', 'dm', 'message', 'invite']; +const VOICES = ['aqua', 'eureka', 'heimdall', 'kiko', 'libby', 'nibu', 'void', 'weksa']; + +// Vorbis-in-Ogg duration: last page's granule position over the sample rate in the id header. +const oggMs = (file: string): number => { + const b = readFileSync(join(DINGS, file)); + const id = b.indexOf('\x01vorbis', 0, 'latin1'); + const rate = b.readUInt32LE(id + 12); + const last = b.lastIndexOf('OggS', b.length, 'latin1'); + return (Number(b.readBigUInt64LE(last + 6)) / rate) * 1000; +}; + +describe('dings switch', () => { + it('is off unless the config says exactly true', () => { + expect(dingsEnabled(undefined)).toBe(false); + expect(dingsEnabled({})).toBe(false); + expect(dingsEnabled({ enabled: false })).toBe(false); + expect(dingsEnabled({ enabled: 'true' as unknown as boolean })).toBe(false); + expect(dingsEnabled({ enabled: true })).toBe(true); + }); + + it('ships off in config.json', () => { + const config = JSON.parse(readFileSync(join(__dirname, '../../../../config.json'), 'utf8')); + expect(config.dings).toEqual({ enabled: false }); + expect(dingsEnabled(config.dings)).toBe(false); + }); +}); + +describe('v3 manifest against the parser', () => { + it('survives parseManifest whole: no path or motif is dropped', () => { + expect(manifest.neutral).toEqual(raw.neutral); + expect(Object.keys(manifest.senders).sort()).toEqual(VOICES); + VOICES.forEach((v) => expect(manifest.senders[v]).toEqual(raw.senders[v])); + MOTIFS.forEach((m) => expect(manifest.neutral[m]).toBeDefined()); + }); + + it('names a file that exists for every clip, and no file the manifest omits', () => { + const paths = [ + ...Object.values(manifest.neutral), + ...Object.values(manifest.senders).flatMap((c) => Object.values(c)), + ]; + expect(paths).toHaveLength(4 + VOICES.length * 4); + paths.forEach((p) => expect(existsSync(join(DINGS, p as string)), p).toBe(true)); + }); + + it('maps a sender localpart to that sender folder, per motif', () => { + VOICES.forEach((v) => + MOTIFS.forEach((m) => expect(pickDing(manifest, v, m)?.path).toBe(`senders/${v}/${m}.ogg`)) + ); + }); + + it('an unknown sender and an invite get the neutral clip', () => { + MOTIFS.forEach((m) => expect(pickDing(manifest, 'someone', m)?.path).toBe(`neutral/${m}.ogg`)); + }); + + it('Nibu is the static-filter square (pick 1): senders/nibu, never the proposals folder', () => { + MOTIFS.forEach((m) => { + const path = pickDing(manifest, 'nibu', m)?.path as string; + expect(path).toBe(`senders/nibu/${m}.ogg`); + expect(path).not.toContain('proposals'); + }); + }); + + it('keeps the clip lengths the design assumes: message under 400 ms, nothing over 600 ms', () => { + VOICES.forEach((v) => { + expect(oggMs(`senders/${v}/message.ogg`)).toBeLessThan(400); + MOTIFS.forEach((m) => expect(oggMs(`senders/${v}/${m}.ogg`)).toBeLessThan(601)); + }); + }); +}); diff --git a/src/app/features/dings/useDings.ts b/src/app/features/dings/useDings.ts new file mode 100644 index 00000000..31899996 --- /dev/null +++ b/src/app/features/dings/useDings.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { trimTrailingSlash } from '../../utils/common'; +import { getMxIdLocalPart } from '../../utils/matrix'; +import { + DingManifest, + Motif, + createDingPlayer, + dingsEnabled, + parseManifest, + pickDing, +} from './dings'; + +const DINGS_ROOT = `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/dings`; + +/** + * Plays a ding for a sender and motif. The manifest is read once at runtime. Returns false when + * there is no manifest (nothing rendered yet), so the caller keeps the stock sound; once there is + * one, a sender or motif with no clip is silence, as the design says, and the return is true. + * The client config's `dings.enabled` is the switch (default off): off, nothing is fetched, no + * gesture listener is added, and the return is always false, so the stock sound plays. + */ +export const useDings = () => { + const enabled = dingsEnabled(useClientConfig().dings); + const manifestRef = useRef(); + const player = useMemo( + () => (enabled ? createDingPlayer((url) => new Audio(url), window) : undefined), + [enabled] + ); + + useEffect(() => { + if (!enabled) return undefined; + let live = true; + fetch(`${DINGS_ROOT}/manifest.json`) + .then((r) => (r.ok ? r.json() : undefined)) + .then((json) => { + if (live) manifestRef.current = parseManifest(json); + }) + .catch(() => undefined); + return () => { + live = false; + }; + }, [enabled]); + + return useCallback( + (sender: string, motif: Motif): boolean => { + const manifest = manifestRef.current; + if (!player || !manifest) return false; + const ding = pickDing(manifest, getMxIdLocalPart(sender) ?? sender, motif); + if (ding) player.play(`${DINGS_ROOT}/${ding.path}`, ding.volume); + return true; + }, + [player] + ); +}; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 6c793e65..759e0c59 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -1,4 +1,5 @@ import { createContext, useContext } from 'react'; +import type { DingsConfig } from '../features/dings/dings'; import type { BackgroundSceneConfig } from '../features/scene/tier'; export type HashRouterConfig = { @@ -22,6 +23,9 @@ export type ClientConfig = { backgroundScene?: BackgroundSceneConfig; + // Per-Mind notification earcons (src/app/features/dings). Off unless `enabled: true`. + dings?: DingsConfig; + // Dev preview only: the build answers a pretend homeserver in-page (src/preview-demo). previewDemo?: boolean; }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 62385769..5bc97d65 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -26,6 +26,9 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { mDirectAtom } from '../../state/mDirectList'; +import { motifFor } from '../../features/dings/dings'; +import { useDings } from '../../features/dings/useDings'; function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); @@ -85,6 +88,7 @@ function InviteNotifications() { const navigate = useNavigate(); const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); + const playDing = useDings(); const notify = useCallback( (count: number) => { @@ -115,10 +119,20 @@ function InviteNotifications() { } if (notificationSound) { - playSound(); + // The sender of an invite is not in the invite list, so it gets the neutral invite ding. + if (!playDing('', 'invite')) playSound(); } } - }, [mx, invites, perviousInviteLen, showNotifications, notificationSound, notify, playSound]); + }, [ + mx, + invites, + perviousInviteLen, + showNotifications, + notificationSound, + notify, + playSound, + playDing, + ]); return ( // eslint-disable-next-line jsx-a11y/media-has-caption @@ -136,6 +150,8 @@ function MessageNotifications() { const useAuthentication = useMediaAuthentication(); const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); + const mDirects = useAtomValue(mDirectAtom); + const playDing = useDings(); const navigate = useNavigate(); const notificationSelected = useInboxNotificationsSelected(); @@ -227,7 +243,11 @@ function MessageNotifications() { } if (notificationSound) { - playSound(); + const motif = motifFor({ + highlight: unreadInfo.highlight > 0, + direct: mDirects.has(room.roomId), + }); + if (!playDing(sender, motif)) playSound(); } }; mx.on(RoomEvent.Timeline, handleTimelineEvent); @@ -240,6 +260,8 @@ function MessageNotifications() { notificationSelected, showNotifications, playSound, + playDing, + mDirects, notify, selectedRoomId, useAuthentication, diff --git a/vite.config.js b/vite.config.js index 891c8216..e9b67550 100644 --- a/vite.config.js +++ b/vite.config.js @@ -46,6 +46,10 @@ const copyFiles = { src: 'public/poses', dest: 'public/', }, + { + src: 'public/dings', + dest: 'public/', + }, ], }; From 1dea4b51e50892188b49ecf5b42e539e72249b72 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 17:25:27 +0000 Subject: [PATCH 082/111] dings: Nibu carries proposal 3 (8 kHz 5-bit smoothed sine) for every event Meta heard v3 and chose proposal 3; Nibu confirmed it as her voice for all events. Co-Authored-By: Claude Sonnet 5.5 --- public/dings/senders/nibu/dm.ogg | Bin 6138 -> 6557 bytes public/dings/senders/nibu/invite.ogg | Bin 6151 -> 6583 bytes public/dings/senders/nibu/mention.ogg | Bin 5734 -> 6257 bytes public/dings/senders/nibu/message.ogg | Bin 5354 -> 5663 bytes src/app/features/dings/manifest.test.ts | 16 +++++++++++++++- 5 files changed, 15 insertions(+), 1 deletion(-) diff --git a/public/dings/senders/nibu/dm.ogg b/public/dings/senders/nibu/dm.ogg index 918858f172e83f10c7177c500e3f1e883e11b938..ede0ec4511b51b44fb71bd1ebaa906235e150389 100644 GIT binary patch delta 2967 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 b0ty>F+V#!w%!-R^s7TfMERrf10(}1ifM4$i delta 2545 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 V8^1sPj zTj>xkPuUq0d3u!Tsmr!Vi&xj&VPV)&+Kk(Cp~Z+%?k&t_21dVj7ThILO9D(=e%23Y3=B6+!>@^)4W07w3Qns8Pypb>rCPJdKR^oL!aYGVbIoQ4DCO=pJM4Un|nRWc)TRqc3moel(%8uT}E@aM}JMmTnFrT5=BoH@hhbWG| z77nw<;4G70<-Ebf*q+7f37W}aU|`fgXT}FzAD|-VpIWy7pe%(gQ@Yj6FZiDCR;NU! z$6jwk%YI?7LodD+^~=AsUB2eY?D3Kq3}7#!m_~r}YGFkURGW+?ak(NAN9#Y@A2Hwq zxZDFOmPo0fDwu?J(|?is;|4|Tx&DJBD=R#inrEl*k9=5uHKYJAvj zoj0T=f8R*}xM&&-25aru+re3nwHF=V-Z*`~ZDKWq1B53<2U3x|DCU~j%h{f|4~bSN z`v+Z*vJ~#G3!FdyJ!ZAN6HPhCAs)}t&i^J1bwPrjom!joev~dLyhVeRN^l=(Zf#-9 zAw)%Io6=S`mf=F#j_mNeq31@KS z__C(f@Mqp&^*z@yD((nqf(EsCq$f5xhv2*C>ihO{e?t6QJZXy_5?(doeu5hVcdKQDvt Ai2wiq delta 2566 zcmX|Dd0bOh7XDsH0zrfbh>Ef(D1*r2f})6tbwNc$1r$LL#EQU=LMuqCki0rhDY)Rc zYXxbmV%ZZ1vC^0Ezz ziwlMb|NEKw&zvF*tp~K4{L|LMOqeq?UsJU3;kl5Gf$H2Z`d11c1h3X^WIY$2LFTYSib;ug}}k`V!N^^6pR16^#ud78C^9OJ!5`7e`;5Q zI#MrPvNtg&z=sy9!X;I4_}!{l5|2Uw6deu+(OvfgjvGxHgoX%)r8tX4_)-EqeO_XN z`kGtEoY64(_*}@*`zi8SZgIf#&dm*x4T&ec%SR&&=v^g=c3~zUVyAR*A79bWY`Na% zNvtc%tfBUaWZ2sxd?7&v(SP~K9t-wLr}Lk~VVj#e4;3@IuI~woSzi13icXIWB@gNF zHWZd2NcOY|9|g?fzBpT!8*|!Vb7dgj0C!A~5{&3@2FdhsxBWn*{O4XsII2RI4M0~^ z*Yyt1n2G04ZElKe8Kpjv5cs7GVsJqxMO|XvPC3(->x1O#yhgN*55*#+hC(jJ*Ms15 z2Fk&dzxmtsdoXTa(Xs`|QMI}=n9-EE-81I(<>vKqo$d`Wqs|<^EG7-|4r7_>VXPB2 zPh$Hqi&o+A2sQ?jm%t~KVHFZJ&I0c!gBe#}_CVgO*>B0ZWz~SgjDvpq{qo(uuDTP+ zewwSu!y?uSGi{Z9BIngRQ0*@28X8?H;x5`?Yy@~4@RXpGU+&t6z~p*- z$2{a$CNN5A{>PU3{ql3hfBI+ojxwq?a|6_Eolizb2T7xiOoZeV?;XoOu#>dy90lDW z5$b@2l^cs6Ay^G8$kPqEn|L5YUjcnoUP>H zxmTDrsf0HKqwHzqs~=oC{ZfWB_RiH!oINNzAt!*K*umgWO3@-8Al#&Ab&-TkT!3O1 zM0e8suuOjcQFYKm5tNldjWVF(!{Pa~Ow1Z+uQg&C=8Ir2t1aI;WowPrDPF5ie!qVH zS0`OPgiEvxY_m5`4DAp4qbvBz`Dj}R#~h+sBtLy|dC@|`X&&O&C<2>u?OR)r?2*!A zN}?D8E*Z5Aes^}eY_;+-`RJ<;s2cg>TDpqBH~~F8h8lD~mqGuR9j4Dh0mY-fpQp$T zF#`A2o}$L#U_?Z>O#Ur({F?pMT{ z_qj4?(nSR>Z%~!(MLuj#HrV$v?5Q=TE1X7#<`O`81Y_3J)qSH-EL}SuKYE`#<)vJs z7Nac!x;l})f4Qb!G`94uN)Z^S-BzoJ+HMyg^2B;M;kD$BX@s)c>Y6hemBwt}7B?eA z$_obrc)dTvilDoU0O5=!u7G3g#GjtTR@Z8c#L;EL$+(z7h*;H-cE^*^b#F*KwR)5P z_*RES2B+^6a@20LpzP$FX{ag}4`gQwAZP1LDB8DF6-XrJA=cDw&NiE~{F+1wosB>W zK4^tj&S0oxuu@A;DpLl@Ey|ZsGqILvAkxB)$KO?EI3 zA_A&>b!^^skIZ1htgY7beam2WnR-orVnDT_1LH?@~axattvt7jxxhNB8)y0`iWe3Ku0Fz-Uf&mD?! zDw9fqX4*8Ks?zVWwI~rX#a9-MgHk9}ya!fO=Xl_}Yf(m95)HYQO)IQ)ku(A~iKMyC z2n;W(o#`3vcH_9$zm%4OF!?h z(T@9Q;WjzovwniBCvBbzc_^)lw(QrOk6|UbJxXw=mX#`;gib|`sSI3Q=)hrQ= z?tOHSS_!)`KmD;LnR%_)k+?Q9v(R9X)EgsPN?g$EOtMHFCX%3@70U&UwzVr{;OVBI z%CaDjIy@o@78YlA-@0ef7yh|FN*tM@VMMksz3s4fd3?u#d0e!i=s{@Rl~ab|i;4(K zbz+Wi18Y}}v_r#TN5;!|GDw49(*;Q=)B?d8C!Uf82!D5Oii7jxdIm`ynQH=Rg!~Bh z7sb4Sd7Yeo#Oar^_9?yXO+CtlbKd2GSf`BMGJLZiqZBGAwoMr=n5G&Pk05k5d&H9W zOQ*NmZzCi4W9{@3VmjFa*(JRfPIt{xZfPxTwsu$j-0;bNRink~RPXYsTu-_Sn=J?p btLJ#f)Bl`@)~5Uj%ao0S3#xVC8e#rl7f*d8 diff --git a/public/dings/senders/nibu/mention.ogg b/public/dings/senders/nibu/mention.ogg index a36a9325a50b4f17498b933135017b1471ea313f..6a065e39d171638f3e009678c6c921e66bd82a59 100644 GIT binary patch delta 2673 zcmXX{2|QHm8~$d-GGjLwYZDnssi-8fjC6^cq=+oj*kiI4k>ZTFY8u3)vP)SKG9nZ* zgcfUtF0M5i%Pk4bOi2GT`k&wLJHPXt_ni0n-sgSa=Vfbu(^0bZ^*s%c;Q!YJDnp~`=zQ7*-HBrame5O_m_MF|1U~9epPAf@~c2L|Sts+5i{zkE~#wK2LF$fM61c3M#WBJMKll#~>^O%`DOzcMO)9cix4k#qE zABuls5Eeem4L0ub7!Y7IslJh*vH$U{-{C&JvHwPxbK4@b++^A3=hbv2K%9Y+-3?vSS9(j0mq8A4P{`@MM9xW|*LR_+gCT;Sw9-sb70QFRS zVCGnp$V@>5>gw#RhASmKwcX*DTWh}t*PyvOqa-lB50U~|HbRoj#bM6puWlB+E;OE&Q7?%nzvVdoe$$n+*#+0bJ4xF4DBu$X6c^s-ox|XtG8}5s}{Lh=8V+AKt`4qjJ{qnJ^ENpFH zaou7ErEIfAkhU%WKw!nRl1gT#Wf?9JP<^MIuFmwUbrRx(8ZVYaby7*5ve?sj(wT&# zmarX#-aV zF-XZB1zT^NYeINQ*>TJg^HNk56uk+&OSilKsA`6;c$?ZbT6D{qXPkeU;8$(!J^9Lb z(z(y#0n+fpQiyhwiPT(`NQS8E+nSvk0`tNkSdZ^c8eV`@7qDt`dyPZmty|36Z?YFq zup2gfyo6GSt$uKpk{vE!ch_2lr?2O;!_s~@CT!OYy8+D>+2pqVeLwd_OXB{7W@D0R zMZ9-vCvdN~NmvX9ZX4=WlA6{5LvVhGStvkylrv4A`j2DqrY9(F-(Q&?&VY(P)Zz@o zOz#$89yzvaExi8JgeA^#muI`@UEjwhry~vP#FsSt?l3&h&~vsKDfcJGykki}6fZUr zBXmC76R+YwKQejqh45BDoq>$u`mIL+lbbm!S&B0p|5mO(wp*{Q8USORYPfHiM&dw$ zu=Hx{0XeL?+@wtPMi;wEJy)i~d!W7~v$+8#N4VBft^ z!B1me9YV1})O7f$U)cyS7XD@R?aJCW<&SH7k6_+zM&b@R48mq6ZyRVowpd}KdkUj? zI9S7Uzf$gP62v-$kN2E8r;952Xp2+=^)a4sfZ$61ndij4+V#iF{7@T2 z1n)EK$4eHzjXAna$I2~pC@b?^O)R!BQw=VI+MQ+mVAgAZ0&~xgSEr+_gu-~e5em?4 zMQFq=QtzHNd85z4#;~rR5;Qa#XH81+j3{09W66Hoj`ps^h1N!9CB9z*KnBh}Ss4j( zEps}v6e(OxPY@wKNv}-~kk4Y2A~pLq`_och$Ki}kBuVZhq@ZI~AJs)dnwx=3ImpR+4^} z>!QnebfI9|7f*3pmEC50ZPmU42-R%2;Lbx~KTMW~ zxVL_(tS`8Eb^Pfir^!O4Nx5r)>VO5R{24mN{=Ay{#FuLTtBv(<8PJcffeknF1ju4i zQHQ5WG~O!c4b4{gLkAlAqPLlh9@e?qAFG%!_Hl|BHDhJIYzA<^yGlc{6uh2@nO@C*{;l@z7pfU0nkjyw!#Yyp*>=iz|E1&&q*a%`Ir709d1?SOSmmiJDRM<%&cG;_P_6 z1#waC&<<2|W-Fm=*e&mGU;nL&CoL`%dOxoJRzeU8x4{S3Tnu2{v!b<50M+fVz$imV zK&o+_%fbKTR385RQzoR++a?5_-_FQh+VM6xnm2}2th$Q_Z2))2FBuWiULm4|<`wzJ zthDa;Npo#%X`GfcX1V$oe(xxXR~zWCl5llGh%ogH0g>N}q?+uuEO>Mpwk{{ejr`U~ z8NTj=PJI*=@wo~z4#!EITuO-3jZL4geRRwGpXx_!O|;Kb(Yb-b@2@rR_Uud(-cB$%d6)bOJD3CAA4C((Urcmnnj&!D>dv`+~~pTYX7=FtTds5 G1^)ww!jS|3 delta 2146 zcmX|CYgAKL7XI$N2_b<!0W6oYgGyY0eJ_Fa<)sFSCbYzqW7L5U0tI|=lD2PbrT%6VO>)y4`{`UU%{`Njk zHTP#Lqt>pC1&#k7ub=Ga37^+p`G68-fTQqFk^di^TYHYj4a)Vpj5jw0gWy#bSSeGw zY2&o9u`w|T(c1VGE8^p{F)>lmF-zZH6cQHR)Ze+7O1Yah^^M=2{$m{lQ}c2wua;#` zzfjYh=b0OREI4lNG0l8%L!NQ;*lD_Bo5;Z6oS1kF?6AE@N8FhsHaKU|{5e-W!m8b2 zKLLXx)n~S^HpMXI>ANcDcpxDT@Q@Xnd`fI*_8l>w>Hdf=h}i(s<-spMk2Y?*+{+73yLwpOVwl$}c>+#d!C4+5ZY1Gr^Lk(AC- zu&_#m)Wx0lpd*xNJCdD2dFInFJV*0j)hTFysOXc`sshQ>?Jd~E7 zoTh!VwYW7^q;8lMKyi9t_ya8)CCQa^e;&k*XUV0nop0R}0v~2yJf-_UjQl&`7msJj z&unU9c>YF0us1x4&U=CKP)79CDK0ITcG;3wY+nD5Wl$03o4pO!&@zD|kZ+{e{#e6y zIq({8iVL=q>?&yZ(1-zvzc==^19rF9#?YF)+E^4*X-dn@sEiXW{j1KN=|12`^30$_ z;AP;x71gWcRu_Ra_d3sRG=Dv1^5(@VN+H7h2Cyl_P@CgV>3$U7c}(eU9;!h{=>7>v z37bh_%-wRY&N%ytYh2ca^hQFSTprUOr;TPRg7*EhMeMFyvX49V;?hM_AA?7L}e^%=@;EP(;aCL677Wq)<7-7)x4WPUXVNtwx@~? zeY?aYSDm7&%suOm`sC#5jzbQwGb6w1OHqrYdj+LS6l3yR0`vY z^+cvQi8NqWCiYSgEqna=k^ahe!}Lii-qlLF-aocXC#uiN#=M~rg!5$IsK)mq(b zOpeIYK3C^>oqE&x;bmg$yAl)i;Lj$hk5R)}uQ88`^Mlm~tUcIZu%Q4SvNp$#ds6pF zr3{beU3Ua!xzt+Y)Ejj2=&YnxhsxcJwWhz5X7aYTVV=q}9gCEDc6>gKS&976I(t5c zdj#%XM-+#^O2EmkQxzmC@O%90im3>iP*1b&X@^_?inMj5fIvKL`dzxT)X^08~@A`Za*+rc2TDA?PT&*iE-BHRs>WZ{mom)SvkrXlI zDn@7U@d$!FsS`M*mJf8p=q;L?0sb4Zt!M;bP1Y3=ZWe70XKtNF^&Y$9st6{7rQHc8dM_Qb@otxh`J*^;GokJWv zPA`9!JUQ)5X~4D7hJQ|*nHsr?^!4V6CZSsEHoD>LpM??!g00c0)3CrRVHU%Bpo) OnKyCRZq4>D=>GxfBJ)N7 diff --git a/public/dings/senders/nibu/message.ogg b/public/dings/senders/nibu/message.ogg index 13fae9acb26d8f6626594b154e58c41ee8ac1c39..c324158291c5f12871b24158787d21584dcbd83f 100644 GIT binary patch delta 2074 zcmXX{dpwiv8^1R*hel3GMhTNbsa_oj6ADc^{UYU%%(7zTSdwQ(Cc?t+{Rx?h2)&i_ zl9_rHDXLXU=Jd*>O{!(f@qHej-}A@yJlE%Y-`DrKuj_kV_vA*lx$Yqf#T%f(|DT%g zH;BmR`>IaYtGb(Uh>wE(dOjoXfsaH5TH}Ji03ZOe%Zs$;h1R;b)6_GeWHKc*I4YJJ zP9cYfY!um6K-^2H?oE*bWq>TD4}M75JQ6_7Y?)@@fbNlOS!PquRJ3Fu=-8=@aFl%I zprXx1KvzYg^P;7ie3Z|eJiKik?4LRIV=*k~P&EZ|yX&z&X1coTM~n@#Fd`*0cbI~QL)s~=eOcs9k>V2} z!x0Z}(h6Y7EJG!@eSk5ZY&Y2W(D;kbJ0VWN>Hflb?Y=l=8-wC6y1Voiv9qFEi=9gY ztBX`o41IeC{q#cSJ{|h%?N^~}gALnVAF4;&a<_x&k68G>bC5EWd=QU1SI5qsJO>$# z`y*8MiMLP*Wq3G)@EXV+UDMfc#9^<~ijx6wPJ>gG!X(y1GRzfof$zK&XUnDsI@6ZBYgY*FCziN(GH3NPN}cruw~J`g=;8D5v1D6UfI*6QHYK1}+>|EW`Ir^$zSROi z#5X%0`@<9>b{4?Jbv6?MwiE;>nu%mNfy^;wJtfr;IJ|7LVIm1c!gOlPNY! zWc#a&?IS}hS!H>t8%nfmS89W8CU6yUasXa08CpZgRHFaFtiZ20+n zl%@PHInW#e3)cVvPSHQZmvjy1obrstEUW|0D8KI>0cbv~Nw~J?m zI@s#?-?rP_Ds?u-WlZlmjNf+H_R!J&w&gE3Ym8SF8;4L&qvh#H*9;3#fE%CHuGz|d zHrvtL=7M8S9v+ya)0UXqo0Opma=8uw=Smnm^)IZeh_V~_r*7%>dk#$)SFF)j(~-C| za@;16qxe%GXlH;`(tow zxUP{C-eAwrRDbGE6=;`3<(fBWQUD&B>BpE^KP|lWID=D zZ#7;au=X4Gk1ltb#aFrAtMY2YvRga^u4MbrG3C0nQW0=Gw57TfV|X$@D}BkPL9A_l zkEQ^|Tf303irLRAK=V*b&gHpo&g}wiBK<1P8U}cRE)bG?H-B%WAI1HWQ{kfIIjeNC zI1W06U;j1nzm&V2+#8qnpYLF#1B2MxBo5iVu~@~bosz=S#e}ARKjp%+#8C?CKZ6(j#H&8(A6x5KPR$oYl7-a?v) zTXHuuD#IUmuR}YoSd1E6k9rbZKxluY1c#Z2l~(f9e2fXEIkbCE&n>3*Lpr|d@8Jny z%F=XL&YkRFGOrAW<hHT#)qlb_VAA2z$H|fl?SycQ$a9bG6yu-)+q3)e z^=^}~ReIxQgy~!!i-pZWo4}(E0ACDj1{+R~9Uy*BZplfWSw%vI9r0j6BMb~LxB?Tu zMt8plHVt89&6+&TS*Mz=kxz0zUQ9wWx9@X2M6Ww12rvBk4Z6V}E&tlPb;pCIc^%Br zh)Cfbb#p6^A>RYy#zpf3+!3WER?{|pk>dSka9um9L> G8T&7#v5AcU delta 1763 zcmXw3e^gWF8Gdel5W)|G7+OWAXhldsNUfGClBUxZ9b%#oP*I3(okvB1Ryj;G`KtC* z7w z`%IDdWx?v5JCne}%ST${+{}HpoSxVE=in$mE@o)_#oey_i^)U&hOjj+R&oihs;u8H zRXQ_8o05{eF)2m8K4shHjIFw~t*YcW)7|e2eXO>+w_a5AT#O3-wuo;$zOA(;yQ#5b z$5Z9d!OMHi)z&5#pdWiD)Rh&1!|_;wkgZ&a!BZsm?EjCHjBODkp@&E6jf{b~pvqwW zo>ns}tPZUoW|^)_k(vxBgZ0+1)02(H+Gb0q{~||+wA4&7JS{%#cm&D9>?%gefx=T1 z#@CzP{HPqq!r>O6@&I(PaJ>R}_x@E~g3szN)QOY1+o`S+svG5>Y^;6$iAkjOo@zC89%|I`=RZF&~-q31?qH;5H5_8190C`p; zc#sSe(-7jXdoH!pG6#0`axCM2<_}LGAFWax_eLw-*s(i(T3FqEdAs3g-;@N0NGZ2@ zECDBCFLy_gGuNf12B?6Esx2zD$Rp;q@5QNwzD?j>H=7hQ7s3%q#TNM^b2`E)zggDy z42taAqC_znFWwPIV3X?$qqqMr+j;l=lf~EgYtPA|7()AUm@|Ar;ZV%yyD)XLGboo) z9L^tbuA(Dn5#pKYRck&^Z!Pcpv~2v*_^umQVtP%d4tUqn22qR@5J{4OQKe>m%!LoK zA{kwVH+H^(s`>PXWY#)G%cY8x9@_Jv)#bs!lF;r<+6CIL5DO0(GhnNRUZ!k58aw)E zkJs(VLid6#HOAh+O%yEx?Dz;A(RB(MkjW7Shpxpijuw!IG_F@E+S30R?*(Frdl-9< zvb8_1eKC2D=fO1yw*>m--OUBR-7~WMsC(j0SKQABUdld00kO);y~lgz!=s#ohF4I6 zkdJi68Q&%R9@u2OT0VykU|xJVph~n+>1T6a2NYSfJ%4$X7S3gmE>m`u7mpgK@*khf zzWv{#4fK4`6g{^SQ!|hmCxY<#Fm&3T5*X}o&0+|hF0&t9v;jxTmga4fQxe<6`&9n? zbutDaaKBCcp#)Rj>750!1C2fEz=|HXVNrwTCAfeV&-?{e3eQ;s*d!1h1OX3*>n>Fb zK|a>@tfyAou4>d$uzJE;Oa*xlpNMZ*8^K`Hnm1nyq8Efgax08ZiSo;w112rmh82CTAwQF$X_JieKLUplJ*gXR0!~|AnqPyZT4j_$3xR! zN})cu47irzL6Pzs`!3l+C|vuNEfz!P%|<|1$Ok#Jmn!&rqC4#8b6<=68tOf*KU|YW xOUggI^*h|SC9X{3B(onp8NI3uaM|^xrvmX-X-ujwbo+n(PX;AB3y&NX{2w90gQWlf diff --git a/src/app/features/dings/manifest.test.ts b/src/app/features/dings/manifest.test.ts index fb194cd1..7017669a 100644 --- a/src/app/features/dings/manifest.test.ts +++ b/src/app/features/dings/manifest.test.ts @@ -1,4 +1,5 @@ import { readFileSync, existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { DingManifest, Motif, dingsEnabled, parseManifest, pickDing } from './dings'; @@ -63,7 +64,7 @@ describe('v3 manifest against the parser', () => { MOTIFS.forEach((m) => expect(pickDing(manifest, 'someone', m)?.path).toBe(`neutral/${m}.ogg`)); }); - it('Nibu is the static-filter square (pick 1): senders/nibu, never the proposals folder', () => { + it('Nibu carries pick 3 (8 kHz 5-bit smoothed sine) for every event: senders/nibu, never the proposals folder', () => { MOTIFS.forEach((m) => { const path = pickDing(manifest, 'nibu', m)?.path as string; expect(path).toBe(`senders/nibu/${m}.ogg`); @@ -71,6 +72,19 @@ describe('v3 manifest against the parser', () => { }); }); + it("Nibu's four files are AquaSynth's proposals/nibu3 (sha256 prefix)", () => { + const want: Record = { + mention: '3833f504903f', + dm: 'ad4839400f2c', + message: '6ec6fc893b94', + invite: 'aefb6ff79c42', + }; + MOTIFS.forEach((m) => { + const hash = createHash('sha256').update(readFileSync(join(DINGS, `senders/nibu/${m}.ogg`))); + expect(hash.digest('hex').slice(0, 12)).toBe(want[m]); + }); + }); + it('keeps the clip lengths the design assumes: message under 400 ms, nothing over 600 ms', () => { VOICES.forEach((v) => { expect(oggMs(`senders/${v}/message.ogg`)).toBeLessThan(400); From f1287a6f3aefb1fad9fbb56b98f87693fbdf6160 Mon Sep 17 00:00:00 2001 From: Libby Date: Tue, 10 Nov 2026 17:29:51 +0000 Subject: [PATCH 083/111] auth: newcomer-facing sign-in, server and sign-up messages that say what to do next Replaces Cinny's stock wording (Invalid Username or Password., Failed to find homeserver., Failed to connect..., the dead-end registration-disabled text) with plain messages in one module, authMessages.ts, used by the login form, the auth layout, the login page and the register page. Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/AuthLayout.tsx | 15 ++-- src/app/pages/auth/authMessages.test.ts | 68 +++++++++++++++++ src/app/pages/auth/authMessages.ts | 74 +++++++++++++++++++ src/app/pages/auth/login/Login.tsx | 5 +- .../pages/auth/login/PasswordLoginForm.tsx | 15 ++-- src/app/pages/auth/register/Register.tsx | 9 ++- 6 files changed, 166 insertions(+), 20 deletions(-) create mode 100644 src/app/pages/auth/authMessages.test.ts create mode 100644 src/app/pages/auth/authMessages.ts diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index 49c7b774..a2d652a3 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -32,6 +32,7 @@ import { AuthFlowsLoader } from '../../components/AuthFlowsLoader'; import { AuthFlowsProvider } from '../../hooks/useAuthFlows'; import { AuthServerProvider } from '../../hooks/useAuthServer'; import { tryDecodeURIComponent } from '../../utils/dom'; +import { serverTroubleMessage } from './authMessages'; const currentAuthPath = (pathname: string): string => { if (matchPath(LOGIN_PATH, pathname)) { @@ -158,18 +159,18 @@ export function AuthLayout() { />
{discoveryState.status === AsyncStatus.Loading && ( - + )} {discoveryState.status === AsyncStatus.Error && ( - + )} {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && ( )} {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && ( - + )} {discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && ( @@ -182,7 +183,7 @@ export function AuthLayout() { /> )} error={() => ( - + )} > {(specVersions) => ( @@ -192,7 +193,9 @@ export function AuthLayout() { )} error={() => ( - + )} > {(authFlows) => ( diff --git a/src/app/pages/auth/authMessages.test.ts b/src/app/pages/auth/authMessages.test.ts new file mode 100644 index 00000000..04b43ffb --- /dev/null +++ b/src/app/pages/auth/authMessages.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + loginErrorMessage, + noSignInMethodMessage, + registerTroubleMessage, + serverTroubleMessage, + type RegisterTrouble, + type ServerTrouble, +} from './authMessages'; +import { LoginError } from './login/loginUtil'; + +const stock = [ + 'Invalid Username or Password.', + 'Failed to find homeserver.', + 'Failed to connect', + 'Failed to login', +]; +const troubles: ServerTrouble[] = [ + 'not-found', + 'unusable-setup', + 'bad-address', + 'unreachable', + 'no-sign-in-info', +]; +const registerTroubles: RegisterTrouble[] = ['closed', 'rate-limited', 'invalid', 'unsupported']; + +describe('sign-in and sign-up messages', () => { + it('has a message for every sign-in error, each different, none the stock wording', () => { + const messages = Object.values(LoginError).map((e) => loginErrorMessage(e)); + expect(new Set(messages).size).toBe(messages.length); + messages.forEach((m) => stock.forEach((s) => expect(m).not.toContain(s))); + }); + + it('keeps wrong credentials apart from a missing server', () => { + expect(loginErrorMessage(LoginError.Forbidden)).toContain('username and password'); + expect(loginErrorMessage(LoginError.InvalidServer)).toContain('@name:server'); + }); + + it('names the server in every server message and says what to do', () => { + troubles.forEach((t) => { + const m = serverTroubleMessage(t, 'chat.example', 'bad.host'); + expect(m).toContain('chat.example'); + stock.forEach((s) => expect(m).not.toContain(s)); + }); + expect(serverTroubleMessage('unusable-setup', 'chat.example', 'bad.host')).toContain( + 'bad.host' + ); + expect(serverTroubleMessage('unreachable', 'chat.example')).toContain('reload'); + }); + + it('tells a person who cannot fix a broken server setup to tell its owner', () => { + expect(serverTroubleMessage('bad-address', 'chat.example')).toContain( + 'whoever runs the server' + ); + expect(noSignInMethodMessage('chat.example')).toContain('chat.example'); + }); + + it('says closed sign-up is closed and points to sign in or the inviter, without a Register link to nowhere', () => { + const m = registerTroubleMessage('closed', 'chat.example'); + expect(m).toContain('chat.example'); + expect(m).toContain('sign in'); + expect(m).toContain('invited you'); + expect(m).not.toContain('disabled'); + registerTroubles.forEach((t) => + expect(registerTroubleMessage(t, 'chat.example').length).toBeGreaterThan(20) + ); + }); +}); diff --git a/src/app/pages/auth/authMessages.ts b/src/app/pages/auth/authMessages.ts new file mode 100644 index 00000000..3cc858fe --- /dev/null +++ b/src/app/pages/auth/authMessages.ts @@ -0,0 +1,74 @@ +import { LoginError } from './login/loginUtil'; + +// What the sign-in and sign-up pages say when something goes wrong. Each message says what happened +// in plain words and what the person can do next; none is a status code or a Matrix term. + +export const loginErrorMessage = (error: LoginError): string => { + switch (error) { + case LoginError.ServerNotAllowed: + return 'This app only signs in to the servers in its Homeserver list. Pick one from the list above.'; + case LoginError.InvalidServer: + return 'We could not find the server in that username. A full username looks like @name:server; the part after the colon is the server.'; + case LoginError.Forbidden: + return 'That username and password did not match. Check both and try again, or use "Forget Password?" beside the password.'; + case LoginError.UserDeactivated: + return 'This account has been turned off. Ask whoever runs this server to turn it back on.'; + case LoginError.InvalidRequest: + return 'The server could not read that sign-in. Check the username and password for stray spaces and try again.'; + case LoginError.RateLimited: + return 'Too many tries in a short time. Wait a minute, then try again.'; + case LoginError.Unknown: + default: + return 'Sign-in did not work and the server gave no reason. Wait a minute and try again; if it keeps failing, tell whoever invited you.'; + } +}; + +export type ServerTrouble = + | 'not-found' + | 'unusable-setup' + | 'bad-address' + | 'unreachable' + | 'no-sign-in-info'; + +/** `server` is the name shown in the Homeserver box; `host` is the name the server's own setup gave. */ +export const serverTroubleMessage = ( + trouble: ServerTrouble, + server: string, + host?: string +): string => { + switch (trouble) { + case 'not-found': + return `We could not find a chat server called ${server}. Check the name for typos, or try again in a minute.`; + case 'unusable-setup': + return `We found ${server}, but the setup it points to (${ + host ?? 'unknown' + }) is not usable. You cannot fix this from here: tell whoever runs the server.`; + case 'bad-address': + return `We found ${server}, but the address it gives for chat is not valid. You cannot fix this from here: tell whoever runs the server.`; + case 'unreachable': + return `We found ${server} but could not connect to it. It may be down for a moment: wait a minute and reload this page. If it stays down, tell whoever invited you.`; + case 'no-sign-in-info': + return `Connected to ${server}, but it did not say how to sign in. Reload the page; if it happens again, tell whoever runs the server.`; + default: + return `We could not reach ${server}. Reload the page and try again.`; + } +}; + +export const noSignInMethodMessage = (server: string): string => + `${server} does not offer a way to sign in that this app can use. Pick another server from the Homeserver list, or tell whoever runs ${server}.`; + +export type RegisterTrouble = 'closed' | 'rate-limited' | 'invalid' | 'unsupported'; + +export const registerTroubleMessage = (trouble: RegisterTrouble, server: string): string => { + switch (trouble) { + case 'closed': + return `New accounts cannot be made on ${server} from this page yet. If you were given an account, sign in with it. If you were not, ask whoever invited you how to get one.`; + case 'rate-limited': + return 'Too many tries in a short time. Wait a minute, then try again.'; + case 'invalid': + return `${server} did not accept the request to make an account. Reload the page and try again; if it keeps failing, tell whoever invited you.`; + case 'unsupported': + default: + return `${server} asks for something this app cannot do when making an account. Tell whoever invited you.`; + } +}; diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index 9d890bef..bc3136f4 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -13,6 +13,7 @@ import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils' import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin'; import { LoginPathSearchParams } from '../../paths'; import { useClientConfig } from '../../../hooks/useClientConfig'; +import { noSignInMethodMessage } from '../authMessages'; const getLoginTokenSearchParam = () => { // when using hasRouter query params in existing route @@ -85,9 +86,7 @@ export function Login() { )} {!parsedFlows.password && !parsedFlows.sso && ( <> - - {`This client does not support login on "${server}" homeserver. Password and SSO based login method not found.`} - + {noSignInMethodMessage(server)} )} diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 3c8641d9..7934bc9f 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -35,6 +35,7 @@ import { } from './loginUtil'; import { PasswordInput } from '../../../components/password-input'; import { FieldError } from '../FiledError'; +import { loginErrorMessage } from '../authMessages'; import { getResetPasswordPath } from '../../pathUtils'; import { stopPropagation } from '../../../utils/keyboard'; @@ -215,10 +216,10 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog {loginState.status === AsyncStatus.Error && ( <> {loginState.error.errcode === LoginError.ServerNotAllowed && ( - + )} {loginState.error.errcode === LoginError.InvalidServer && ( - + )} )} @@ -232,19 +233,19 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog {loginState.status === AsyncStatus.Error && ( <> {loginState.error.errcode === LoginError.Forbidden && ( - + )} {loginState.error.errcode === LoginError.UserDeactivated && ( - + )} {loginState.error.errcode === LoginError.InvalidRequest && ( - + )} {loginState.error.errcode === LoginError.RateLimited && ( - + )} {loginState.error.errcode === LoginError.Unknown && ( - + )} )} diff --git a/src/app/pages/auth/register/Register.tsx b/src/app/pages/auth/register/Register.tsx index 7176489b..286fa5d3 100644 --- a/src/app/pages/auth/register/Register.tsx +++ b/src/app/pages/auth/register/Register.tsx @@ -12,6 +12,7 @@ import { SupportedUIAFlowsLoader } from '../../../components/SupportedUIAFlowsLo import { getLoginPath } from '../../pathUtils'; import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin'; import { RegisterPathSearchParams } from '../../paths'; +import { registerTroubleMessage } from '../authMessages'; const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams => useMemo( @@ -40,17 +41,17 @@ export function Register() { {registerFlows.status === RegisterFlowStatus.RegistrationDisabled && !sso && ( - Registration has been disabled on this homeserver. + {registerTroubleMessage('closed', server)} )} {registerFlows.status === RegisterFlowStatus.RateLimited && !sso && ( - You have been rate-limited! Please try after some time. + {registerTroubleMessage('rate-limited', server)} )} {registerFlows.status === RegisterFlowStatus.InvalidRequest && !sso && ( - Invalid Request! Failed to get any registration options. + {registerTroubleMessage('invalid', server)} )} {registerFlows.status === RegisterFlowStatus.FlowRequired && ( @@ -62,7 +63,7 @@ export function Register() { {(supportedFlows) => supportedFlows.length === 0 ? ( - This application does not support registration on this homeserver. + {registerTroubleMessage('unsupported', server)} ) : ( Date: Tue, 10 Nov 2026 17:43:18 +0000 Subject: [PATCH 084/111] scene: one owner of ink per sky state (names, phone-dusk labels), New Messages as plain label ink Sender names over the terrain step to the state's ink: same OKLCH hue and chroma, darker by day and dusk, lighter at night, until 4.5:1 on the lightest measured terrain patch (capped at OKLCH L 0.9 at night). Sidebar labels get a darker dusk ink on phones. An explicit theme still wins. The unread divider keeps only its green line. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 3 +- src/app/features/room-nav/styles.css.ts | 3 +- src/app/features/room/RoomTimeline.tsx | 13 +- src/app/features/room/message/Message.tsx | 6 +- src/app/features/scene/sceneShell.css.ts | 7 + src/app/features/scene/skyInk.test.ts | 177 ++++++++++++++++++++++ src/app/features/scene/skyInk.ts | 95 ++++++++++++ src/app/hooks/useMemberPowerTag.ts | 15 +- src/app/hooks/useTheme.ts | 29 +++- src/app/pages/ThemeManager.tsx | 22 ++- src/util/colorMXID.js | 10 +- 11 files changed, 361 insertions(+), 19 deletions(-) create mode 100644 src/app/features/scene/skyInk.test.ts create mode 100644 src/app/features/scene/skyInk.ts diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index e5db6c47..6d1a6691 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -1,7 +1,7 @@ import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; -import { surfaceInk } from '../../features/scene/sceneShell.css'; +import { skyLabelInk, surfaceInk } from '../../features/scene/sceneShell.css'; // Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows // are three distinct steps above them. @@ -222,6 +222,7 @@ phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, { textDecorationLine: 'underline', }); phoneLabel(`:root[data-scene] ${label}`, { + color: skyLabelInk('inherit'), textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, }); diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index b9655923..e5ab4f8e 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -1,6 +1,6 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; -import { surfaceInk } from '../scene/sceneShell.css'; +import { skyLabelInk, surfaceInk } from '../scene/sceneShell.css'; export const CategoryButton = style({ flexGrow: 1, @@ -15,6 +15,7 @@ export const CategoryButton = style({ borderRadius: 0, }, ':root[data-scene] &&': { + color: skyLabelInk('inherit'), textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, filter: `drop-shadow(0 0 3px ${surfaceInk})`, }, diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index c533528d..552fb04c 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -123,7 +123,8 @@ import { useSpaceOptionally } from '../../hooks/useSpace'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; -import { useTheme } from '../../hooks/useTheme'; +import { useSkyInk, useTheme } from '../../hooks/useTheme'; +import { skyLabelInk } from '../scene/sceneShell.css'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; @@ -462,10 +463,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); const theme = useTheme(); + const skyInk = useSkyInk(); const accessiblePowerTagColors = useAccessiblePowerTagColors( theme.kind, creatorsTag, - powerLevelTags + powerLevelTags, + skyInk ); const permissions = useRoomPermissions(creators, powerLevels); @@ -1674,9 +1677,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli newDivider && eventJSX && eventSender !== mx.getUserId() ? ( - - New Messages - + + New Messages + ) : null; diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 5c4421b6..62bb4dae 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -82,6 +82,8 @@ import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents'; import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room'; import { PowerIcon } from '../../../components/power'; import colorMXID from '../../../../util/colorMXID'; +import { useSkyInk } from '../../../hooks/useTheme'; +import { userColorInk } from '../../scene/skyInk'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -723,6 +725,7 @@ export const Message = as<'div', MessageProps>( ref ) => { const mx = useMatrixClient(); + const skyInk = useSkyInk(); const useAuthentication = useMediaAuthentication(); const senderId = mEvent.getSender() ?? ''; @@ -743,7 +746,8 @@ export const Message = as<'div', MessageProps>( ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) : undefined; - const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; + const hashColor = skyInk ? userColorInk(senderId, skyInk) : colorMXID(senderId); + const usernameColor = legacyUsernameColor ? hashColor : tagColor; const headerJSX = !collapse && ( by the theme manager (ThemeManager.tsx) only where it + * differs from the theme's; unset, `skyLabelInk(fallback)` is the fallback, so a person's own theme keeps its ink. + */ +export const SKY_LABEL_INK = '--sky-label-ink'; +export const skyLabelInk = (fallback: string) => `var(${SKY_LABEL_INK}, ${fallback})`; diff --git a/src/app/features/scene/skyInk.test.ts b/src/app/features/scene/skyInk.test.ts new file mode 100644 index 00000000..4cf2a6c4 --- /dev/null +++ b/src/app/features/scene/skyInk.test.ts @@ -0,0 +1,177 @@ +import chroma from 'chroma-js'; +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../sky/baked'; +import { AA, NIGHT_SKY_LIGHTEST, luminance } from '../../../sky/skyTokens.js'; +import { indexColorMXID } from '../../../util/colorMXID'; +import type { FixedSkyState } from './skyBand'; +import { + LIGHTEST_PATCH, + NIGHT_INK_MAX_L, + inkForState, + labelInk, + sceneInk, + userColorInk, +} from './skyInk'; +import { sceneThemeId } from './skyTheme'; + +// Every name colour the client can show over the scene, per state and viewport: Aqua's key colour #22d3e6 as +// accessibleColor leaves it (Lab L capped to 50 on a light theme: #00889a; unchanged on a dark one: #22d3e6), and +// the eight hash hues of the theme the state wears (noonday for day and dusk, midnight for night). Columns: name, +// state, phone, colour in, ink out. Aqua's day and dusk rows are the values measured in +// ratatoskr-sidebar-contrast-20261110/results.md (T4): #05707f, #016a78, #055763, #03454f, and #43e5f8 at night. +const TABLE: Array<[string, FixedSkyState, boolean, string, string]> = [ + ['aqua', 'day', false, '#00889a', '#05707f'], + ['aqua', 'day', true, '#00889a', '#016a78'], + ['aqua', 'dusk', false, '#00889a', '#055763'], + ['aqua', 'dusk', true, '#00889a', '#03454f'], + ['aqua', 'night', false, '#22d3e6', '#43e5f8'], + ['aqua', 'night', true, '#22d3e6', '#87f1ff'], + ['hash1', 'day', false, '#2b4675', '#2b4675'], + ['hash1', 'day', true, '#2b4675', '#2b4675'], + ['hash1', 'dusk', false, '#2b4675', '#2b4675'], + ['hash1', 'dusk', true, '#2b4675', '#233e6c'], + ['hash1', 'night', false, '#4683ff', '#bcd3ff'], + ['hash1', 'night', true, '#4683ff', '#cbddfe'], + ['hash2', 'day', false, '#5d366d', '#5d366d'], + ['hash2', 'day', true, '#5d366d', '#5d366d'], + ['hash2', 'dusk', false, '#5d366d', '#5d366d'], + ['hash2', 'dusk', true, '#5d366d', '#562f66'], + ['hash2', 'night', false, '#986fff', '#d4cbfe'], + ['hash2', 'night', true, '#986fff', '#ded7fe'], + ['hash3', 'day', false, '#214e3e', '#214e3e'], + ['hash3', 'day', true, '#214e3e', '#214e3e'], + ['hash3', 'dusk', false, '#214e3e', '#214e3e'], + ['hash3', 'dusk', true, '#214e3e', '#184636'], + ['hash3', 'night', false, '#3893b1', '#86defe'], + ['hash3', 'night', true, '#3893b1', '#ace8ff'], + ['hash4', 'day', false, '#6d334a', '#6d334a'], + ['hash4', 'day', true, '#6d334a', '#6d334a'], + ['hash4', 'dusk', false, '#6d334a', '#6d334a'], + ['hash4', 'dusk', true, '#6d334a', '#652c43'], + ['hash4', 'night', false, '#b26adc', '#e8c7fe'], + ['hash4', 'night', true, '#b26adc', '#edd2ff'], + ['hash5', 'day', false, '#5f401d', '#5f401d'], + ['hash5', 'day', true, '#5f401d', '#5f401d'], + ['hash5', 'dusk', false, '#5f401d', '#5f401d'], + ['hash5', 'dusk', true, '#5f401d', '#563814'], + ['hash5', 'night', false, '#9f8365', '#ecceae'], + ['hash5', 'night', true, '#9f8365', '#f6d8b7'], + ['hash6', 'day', false, '#204c52', '#204c52'], + ['hash6', 'day', true, '#204c52', '#204c52'], + ['hash6', 'dusk', false, '#204c52', '#204c52'], + ['hash6', 'dusk', true, '#204c52', '#19454b'], + ['hash6', 'night', false, '#358dde', '#b1d7ff'], + ['hash6', 'night', true, '#358dde', '#c6e1fe'], + ['hash7', 'day', false, '#41426c', '#41426c'], + ['hash7', 'day', true, '#41426c', '#41426c'], + ['hash7', 'dusk', false, '#41426c', '#41426c'], + ['hash7', 'dusk', true, '#41426c', '#3b3b65'], + ['hash7', 'night', false, '#677dff', '#c4d1fe'], + ['hash7', 'night', true, '#677dff', '#d3ddfe'], + ['hash8', 'day', false, '#3f4a20', '#3f4a20'], + ['hash8', 'day', true, '#3f4a20', '#3f4a20'], + ['hash8', 'dusk', false, '#3f4a20', '#3f4a20'], + ['hash8', 'dusk', true, '#3f4a20', '#394319'], + ['hash8', 'night', false, '#6a926a', '#b2ddb1'], + ['hash8', 'night', true, '#6a926a', '#c0ebc0'], +]; + +const patchRatio = (hex: string, state: FixedSkyState, phone: boolean) => { + const p = LIGHTEST_PATCH[state][phone ? 'phone' : 'desktop']; + const l = luminance(hex); + return (Math.max(l, p) + 0.05) / (Math.min(l, p) + 0.05); +}; +const hueGap = (a: string, b: string) => { + const d = Math.abs(chroma(a).oklch()[2] - chroma(b).oklch()[2]) % 360; + return Math.min(d, 360 - d); +}; + +describe('inkForState', () => { + it.each(TABLE)('%s %s phone=%s: %s -> %s', (_name, state, phone, base, ink) => { + expect(inkForState(base, { state, phone })).toBe(ink); + }); + + it('clears 4.5:1 on the lightest patch, keeps the hue, and moves lightness the way the state needs', () => { + TABLE.forEach(([, state, phone, base, ink]) => { + const [l0] = chroma(base).oklch(); + const [l1] = chroma(ink).oklch(); + if (ink === base) { + expect(patchRatio(base, state, phone)).toBeGreaterThanOrEqual(AA); + return; + } + expect(state === 'night' ? l1 > l0 : l1 < l0).toBe(true); + expect(hueGap(base, ink)).toBeLessThan(2); + if (l1 < NIGHT_INK_MAX_L - 0.006) + expect(patchRatio(ink, state, phone)).toBeGreaterThanOrEqual(AA); + }); + }); + + it('stops lightening at NIGHT_INK_MAX_L: a phone-night name keeps its hue instead of reaching 4.5:1 on the one pale snow patch', () => { + const aqua = inkForState('#22d3e6', { state: 'night', phone: true }); + expect(aqua).toBe('#87f1ff'); + expect(chroma(aqua).oklch()[0]).toBeLessThanOrEqual(NIGHT_INK_MAX_L); + expect(chroma(aqua).oklch()[1]).toBeGreaterThan(0.08); + expect(patchRatio(aqua, 'night', true)).toBeGreaterThan(3.9); + // The one-patch figure is what the cap gives up; against the sky's own lightest (skyBackdrop) it clears AA. + expect((luminance(aqua) + 0.05) / (NIGHT_SKY_LIGHTEST + 0.05)).toBeGreaterThanOrEqual(AA); + }); + + it('leaves a colour that already clears its patch untouched, and a non-colour alone', () => { + expect(inkForState('#2b4675', { state: 'day', phone: false })).toBe('#2b4675'); + expect(inkForState('var(--mx-uc-1)', { state: 'day', phone: false })).toBe('var(--mx-uc-1)'); + }); +}); + +describe('userColorInk', () => { + it('takes the hash colour of the theme the state wears, then steps it', () => { + const id = '@aqua:gamecult.org'; + const n = indexColorMXID(id) - 1; + expect(userColorInk(id, { state: 'dusk', phone: true })).toBe( + inkForState(baked.noonday.userColors[n], { state: 'dusk', phone: true }) + ); + expect(userColorInk(id, { state: 'night', phone: false })).toBe( + inkForState(baked.midnight.userColors[n], { state: 'night', phone: false }) + ); + }); +}); + +describe('sceneInk: an explicit theme wins over the auto ink', () => { + it('is the sky state and viewport only while the sky theme is worn and a scene runs', () => { + // auto, scene on, no theme of the person's own: the sky theme is worn. + expect(sceneInk(sceneThemeId('auto', 'dusk', true, false), true, 'dusk', true)).toEqual({ + state: 'dusk', + phone: true, + }); + // auto, but the person chose a theme: it wins, the theme's own ink stands. + expect(sceneInk(sceneThemeId('auto', 'dusk', true, true), true, 'dusk', true)).toBeUndefined(); + // a fixed sky state is itself a choice and wins over the theme (skyTheme.ts), so its ink applies. + expect(sceneInk(sceneThemeId('night', 'night', true, true), true, 'night', false)).toEqual({ + state: 'night', + phone: false, + }); + // no scene behind the page: nothing for the ink to be measured against. + expect( + sceneInk(sceneThemeId('auto', 'day', false, false), false, 'day', false) + ).toBeUndefined(); + expect(sceneInk(sceneThemeId('day', 'day', true, false), false, 'day', false)).toBeUndefined(); + }); +}); + +describe('labelInk', () => { + it('differs from the theme only on a phone at dusk', () => { + const states: FixedSkyState[] = ['day', 'dusk', 'night']; + states.forEach((state) => + [false, true].forEach((phone) => { + expect(labelInk({ state, phone })).toBe(state === 'dusk' && phone ? '#29221a' : undefined); + }) + ); + }); + + it('is the shipped dark ink, same hue and chroma, a little darker', () => { + const dark = baked.noonday.tokens.Surface.OnContainer; + const ink = labelInk({ state: 'dusk', phone: true }) as string; + expect(dark).toBe('#342d25'); + expect(luminance(ink)).toBeLessThan(luminance(dark)); + expect(hueGap(dark, ink)).toBeLessThan(2); + }); +}); diff --git a/src/app/features/scene/skyInk.ts b/src/app/features/scene/skyInk.ts new file mode 100644 index 00000000..7219159c --- /dev/null +++ b/src/app/features/scene/skyInk.ts @@ -0,0 +1,95 @@ +// The one owner of ink for a sky state: which colour a sender name or a sidebar label wears over the scene, +// per fixed sky state (day, dusk, night) and viewport (phone or not). Pure: the hooks that read the resolved +// sky and the viewport (useSkyInk, hooks/useTheme.ts) only hand their values here. +// +// Names are bare ink over the terrain, so the theme's fitted colours (fitted against panel grounds, skyTokens.js) +// can land on the same luminance as the ridge behind them. The rule is measured, not tuned by eye: keep the +// colour's OKLCH hue and chroma and move its lightness (darker by day and dusk, lighter at night, in 0.005 steps) +// until it clears 4.5:1 (WCAG) against the LIGHTEST 24x12 patch of terrain under the timeline in that state +// and viewport. Measured 2026-11-10 on the demo's terrain scene at 1920x1080 and 390x844 (Chrome, SwiftShader, +// not a GPU; ratatoskr-sidebar-contrast-20261110/results.md, T3). These are the scene's own patches, not +// `skyBackdrop` (skyState.ts): that is the bare sky's extreme (day 0.90, dusk 0.97), which the terrain under the +// timeline never shows, and at night the terrain's snow (0.103, phone 0.149) is lighter than the sky (0.0816). +// Only a person whose theme is the sky's (no explicit theme of their own, sceneThemeId) wears this ink. +import chroma from 'chroma-js'; +import { AA, luminance } from '../../../sky/skyTokens.js'; +import { baked } from '../../../sky/baked'; +import { indexColorMXID } from '../../../util/colorMXID'; +import type { FixedSkyState } from './skyBand'; +import type { SkyThemeId } from './skyTheme'; + +export interface SkyInk { + readonly state: FixedSkyState; + /** At or under the repo's mobile breakpoint (ScreenSize.Mobile, hooks/useScreenSize.ts); tablets read as desktop. */ + readonly phone: boolean; +} + +/** + * The ink in force: only while the page wears the sky's theme (`themeId`, from sceneThemeId, so a person's explicit + * theme, which that function lets win over 'auto', keeps its own ink) and a scene runs behind it. Otherwise undefined. + */ +export const sceneInk = ( + themeId: SkyThemeId | undefined, + sceneOn: boolean, + state: FixedSkyState, + phone: boolean +): SkyInk | undefined => (themeId && sceneOn ? { state, phone } : undefined); + +/** WCAG relative luminance of the lightest 24x12 patch of terrain behind the timeline. */ +export const LIGHTEST_PATCH: Record = { + day: { desktop: 0.777, phone: 0.703 }, + dusk: { desktop: 0.531, phone: 0.4 }, + night: { desktop: 0.103, phone: 0.149 }, +}; + +/** Night lightening stops here (OKLCH L): above it every hue reads white, and the hue is what names the sender. */ +export const NIGHT_INK_MAX_L = 0.9; + +const STEP = 0.005; +const SKY_COLORS = { noonday: baked.noonday.userColors, midnight: baked.midnight.userColors }; + +const inGamut = (l: number, c: number, h: number): string | undefined => { + for (let k = c; k > 1e-4; k *= 0.97) { + const out = chroma.oklch(l, k, h); + if (!out.clipped()) return out.hex(); + } + return chroma.oklch(l, 0, 0).hex(); +}; + +/** The ink for a colour (#rrggbb) in this sky state and viewport: itself when it already clears the lightest patch. */ +export function inkForState(hex: string, { state, phone }: SkyInk): string { + if (!chroma.valid(hex)) return hex; + const patch = LIGHTEST_PATCH[state][phone ? 'phone' : 'desktop']; + const ratio = (h: string) => { + const l = luminance(h); + return (Math.max(l, patch) + 0.05) / (Math.min(l, patch) + 0.05); + }; + if (ratio(hex) >= AA) return hex; + const [l0, c0, h0] = chroma(hex).oklch(); + const hue = Number.isNaN(h0) ? 0 : h0; + const dir = state === 'night' ? 1 : -1; + let best = hex; + for (let k = 1; k < 200; k += 1) { + const l = l0 + dir * k * STEP; + if (l < 0 || l > (state === 'night' ? NIGHT_INK_MAX_L : 1)) break; + best = inGamut(l, c0, hue) ?? best; + if (ratio(best) >= AA) break; + } + return best; +} + +/** The ink of a user's hash colour (the --mx-uc-1..8 the theme would give) in this sky state and viewport. */ +export function userColorInk(userId: string, ink: SkyInk): string { + const palette = ink.state === 'night' ? SKY_COLORS.midnight : SKY_COLORS.noonday; + return inkForState(palette[indexColorMXID(userId) - 1], ink); +} + +/** + * The sidebar label ink where it differs from the theme's. Phone Dusk: the dark ink #342d25 reached a median of 3.98:1 + * on the glyph pixels (worst 1.66); the same hue and chroma at OKLCH L 0.257 (#29221a) reaches 4.53 (worst 1.92), + * measured over the shipped dusk phone shot, halo included. Nothing darker is searched for: the ridge's dusk ochre + * sits at the dark ink's own luminance, so the worst pixels stay below 4.5. Every other state keeps the theme's + * ink (undefined): desktop and night were measured as shipped. + */ +export const labelInk = ({ state, phone }: SkyInk): string | undefined => + state === 'dusk' && phone ? '#29221a' : undefined; diff --git a/src/app/hooks/useMemberPowerTag.ts b/src/app/hooks/useMemberPowerTag.ts index 31e52aa0..68051749 100644 --- a/src/app/hooks/useMemberPowerTag.ts +++ b/src/app/hooks/useMemberPowerTag.ts @@ -6,6 +6,7 @@ import { MemberPowerTag, MemberPowerTagIcon } from '../../types/matrix/room'; import { useRoomCreatorsTag } from './useRoomCreatorsTag'; import { ThemeKind } from './useTheme'; import { accessibleColor } from '../plugins/color'; +import { inkForState, type SkyInk } from '../features/scene/skyInk'; export type GetMemberPowerTag = (userId: string) => MemberPowerTag; @@ -44,23 +45,29 @@ export const getPowerTagIconSrc = ( export const useAccessiblePowerTagColors = ( themeKind: ThemeKind, creatorsTag: MemberPowerTag, - powerLevelTags: PowerLevelTags + powerLevelTags: PowerLevelTags, + skyInk?: SkyInk ): Map => { const accessibleColors: Map = useMemo(() => { const colors: Map = new Map(); + // Over the scene a name wears the sky state's ink (skyInk.ts) in place of the theme's accessible colour's own step. + const ink = (color: string) => { + const accessible = accessibleColor(themeKind, color); + return skyInk ? inkForState(accessible, skyInk) : accessible; + }; if (creatorsTag.color) { - colors.set(creatorsTag.color, accessibleColor(themeKind, creatorsTag.color)); + colors.set(creatorsTag.color, ink(creatorsTag.color)); } Object.values(powerLevelTags).forEach((tag) => { const { color } = tag; if (!color) return; - colors.set(color, accessibleColor(themeKind, color)); + colors.set(color, ink(color)); }); return colors; - }, [powerLevelTags, creatorsTag, themeKind]); + }, [powerLevelTags, creatorsTag, themeKind, skyInk]); return accessibleColors; }; diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 60f89757..1ef0278d 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -13,7 +13,9 @@ import '../../sky.css'; import { defaultThemeId, settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; import { sceneThemeId } from '../features/scene/skyTheme'; +import { sceneInk, type SkyInk } from '../features/scene/skyInk'; import { useResolvedSkyState, useSceneOn, useSkyState } from './useSkyState'; +import { ScreenSize, useScreenSizeContext } from './useScreenSize'; export enum ThemeKind { Light = 'light', @@ -113,18 +115,39 @@ const chosenTheme = (useSystemTheme: boolean, themeId?: string) => export const useSkyTheme = (): Theme | undefined => toTheme(sceneThemeId(useSkyState(), useResolvedSkyState(), useSceneOn(), false)); -export const useActiveTheme = (): Theme => { +/** The sky theme the signed-in page wears, or undefined when the person's own theme wins. */ +const useSceneSkyThemeId = (): ReturnType => { const sky = useSkyState(); const resolved = useResolvedSkyState(); const sceneOn = useSceneOn(); + const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); + const [themeId] = useSetting(settingsAtom, 'themeId'); + // A person's own theme wins over 'auto'; a fixed sky state is itself a choice and wins over the theme. + return sceneThemeId(sky, resolved, sceneOn, chosenTheme(systemTheme, themeId)); +}; + +/** The ink the page wears over the scene (`sceneInk`, skyInk.ts), or undefined when the theme's own ink stands. */ +export const useSceneInk = (): SkyInk | undefined => { + const themeId = useSceneSkyThemeId(); + const state = useResolvedSkyState(); + const sceneOn = useSceneOn(); + const phone = useScreenSizeContext() === ScreenSize.Mobile; + return useMemo(() => sceneInk(themeId, sceneOn, state, phone), [themeId, sceneOn, state, phone]); +}; + +const SkyInkContext = createContext(undefined); +export const SkyInkContextProvider = SkyInkContext.Provider; +/** The ink for this sky state and viewport (provided once by the theme manager), or undefined for the theme's own. */ +export const useSkyInk = (): SkyInk | undefined => useContext(SkyInkContext); + +export const useActiveTheme = (): Theme => { const systemThemeKind = useSystemThemeKind(); const themes = useThemes(); const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); const [themeId] = useSetting(settingsAtom, 'themeId'); const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId'); const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId'); - // A person's own theme wins over 'auto'; a fixed sky state is itself a choice and wins over the theme. - const skyTheme = toTheme(sceneThemeId(sky, resolved, sceneOn, chosenTheme(systemTheme, themeId))); + const skyTheme = toTheme(useSceneSkyThemeId()); if (skyTheme) return skyTheme; diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index b7c50d93..980d35b7 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -2,12 +2,16 @@ import React, { ReactNode, useEffect } from 'react'; import { configClass, varsClass } from 'folds'; import { MidnightSkyTheme, + SkyInkContextProvider, ThemeContextProvider, useActiveTheme, + useSceneInk, useSkyTheme, } from '../hooks/useTheme'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; +import { labelInk } from '../features/scene/skyInk'; +import { SKY_LABEL_INK } from '../features/scene/sceneShell.css'; export function UnAuthRouteThemeManager() { // Signed-out pages are Midnight Sky unless the sky state picks otherwise; they ignore the @@ -26,6 +30,18 @@ export function UnAuthRouteThemeManager() { export function AuthRouteThemeManager({ children }: { children: ReactNode }) { const activeTheme = useActiveTheme(); const [monochromeMode] = useSetting(settingsAtom, 'monochromeMode'); + const skyInk = useSceneInk(); + const labels = skyInk && labelInk(skyInk); + + // The sky's label ink where it differs from the theme's (skyInk.ts); unset, the theme's ink stands. + useEffect(() => { + const root = document.documentElement; + if (labels) root.style.setProperty(SKY_LABEL_INK, labels); + else root.style.removeProperty(SKY_LABEL_INK); + return () => { + root.style.removeProperty(SKY_LABEL_INK); + }; + }, [labels]); useEffect(() => { document.body.className = ''; @@ -40,5 +56,9 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { } }, [activeTheme, monochromeMode]); - return {children}; + return ( + + {children} + + ); } diff --git a/src/util/colorMXID.js b/src/util/colorMXID.js index 95600d29..efebf90c 100644 --- a/src/util/colorMXID.js +++ b/src/util/colorMXID.js @@ -10,16 +10,20 @@ function hashCode(str) { for (i = 0; i < str.length; i += 1) { chr = str.charCodeAt(i); // eslint-disable-next-line no-bitwise - hash = ((hash << 5) - hash) + chr; + hash = (hash << 5) - hash + chr; // eslint-disable-next-line no-bitwise hash |= 0; } return Math.abs(hash); } +/** Which of the eight user colours (1..8) a user id takes. */ +export function indexColorMXID(userId) { + return (hashCode(userId) % 8) + 1; +} + export function cssColorMXID(userId) { - const colorNumber = hashCode(userId) % 8; - return `--mx-uc-${colorNumber + 1}`; + return `--mx-uc-${indexColorMXID(userId)}`; } export default function colorMXID(userId) { From de9955a24a241cfdc9a77a18ab485e9e81212300 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 17:59:07 +0000 Subject: [PATCH 085/111] dings: Aqua's v4 set (Void single onset, Nibu proposal 3 everywhere), still behind dings.enabled Replaces the 32 sender clips with AquaSynth 21e83ec's staged render; manifest and neutral fallback are byte-identical to the old ones. The manifest test now pins every clip by sha256 prefix. Unheard. Co-Authored-By: Claude Sonnet 5.5 --- public/dings/README.md | 4 +- public/dings/senders/aqua/dm.ogg | Bin 4687 -> 4687 bytes public/dings/senders/aqua/invite.ogg | Bin 4863 -> 4863 bytes public/dings/senders/aqua/mention.ogg | Bin 4538 -> 4538 bytes public/dings/senders/aqua/message.ogg | Bin 4563 -> 4563 bytes public/dings/senders/eureka/dm.ogg | Bin 4583 -> 4583 bytes public/dings/senders/eureka/invite.ogg | Bin 4727 -> 4727 bytes public/dings/senders/eureka/mention.ogg | Bin 4443 -> 4443 bytes public/dings/senders/eureka/message.ogg | Bin 4425 -> 4425 bytes public/dings/senders/heimdall/dm.ogg | Bin 5184 -> 5184 bytes public/dings/senders/heimdall/invite.ogg | Bin 5476 -> 5477 bytes public/dings/senders/heimdall/mention.ogg | Bin 5217 -> 5217 bytes public/dings/senders/heimdall/message.ogg | Bin 4746 -> 4746 bytes public/dings/senders/kiko/dm.ogg | Bin 5703 -> 5703 bytes public/dings/senders/kiko/invite.ogg | Bin 5627 -> 5627 bytes public/dings/senders/kiko/mention.ogg | Bin 5044 -> 5044 bytes public/dings/senders/kiko/message.ogg | Bin 5090 -> 5091 bytes public/dings/senders/libby/dm.ogg | Bin 5426 -> 5425 bytes public/dings/senders/libby/invite.ogg | Bin 5613 -> 5613 bytes public/dings/senders/libby/mention.ogg | Bin 5108 -> 5108 bytes public/dings/senders/libby/message.ogg | Bin 5075 -> 5074 bytes public/dings/senders/nibu/dm.ogg | Bin 6557 -> 6556 bytes public/dings/senders/nibu/invite.ogg | Bin 6583 -> 6583 bytes public/dings/senders/nibu/mention.ogg | Bin 6257 -> 6257 bytes public/dings/senders/nibu/message.ogg | Bin 5663 -> 5662 bytes public/dings/senders/void/dm.ogg | Bin 5402 -> 4754 bytes public/dings/senders/void/invite.ogg | Bin 5667 -> 5035 bytes public/dings/senders/void/mention.ogg | Bin 5022 -> 4561 bytes public/dings/senders/void/message.ogg | Bin 5362 -> 4570 bytes public/dings/senders/weksa/dm.ogg | Bin 5682 -> 5683 bytes public/dings/senders/weksa/invite.ogg | Bin 6120 -> 6122 bytes public/dings/senders/weksa/mention.ogg | Bin 5291 -> 5292 bytes public/dings/senders/weksa/message.ogg | Bin 5272 -> 5272 bytes src/app/features/dings/manifest.test.ts | 55 +++++++++++++++++----- 34 files changed, 44 insertions(+), 15 deletions(-) diff --git a/public/dings/README.md b/public/dings/README.md index 573a78f9..b36e1fb7 100644 --- a/public/dings/README.md +++ b/public/dings/README.md @@ -12,6 +12,6 @@ with motif one of `mention`, `dm`, `message`, `invite`; paths are relative to th A sender's own clip wins, else the neutral clip, else silence. Without a `manifest.json` the client keeps its stock notification sounds; once one exists, a motif with no clip is silent. -The set here is Aqua's v3 (AquaSynth PR #22, staged at minds.gamecult.org/aqua/dings-voices-70a9795), -Nibu on proposal 1 (static-filter square). Nobody has heard it. The client plays none of it unless the +The set here is Aqua's v4 (AquaSynth merge 21e83ec, staged at minds.gamecult.org/aqua/dings-voices-dd9be66), +Nibu on proposal 3 (8 kHz 5-bit smoothed sine) for all four events. Nobody has heard it. The client plays none of it unless the client config sets `"dings": { "enabled": true }` (default off, as in `config.json`). diff --git a/public/dings/senders/aqua/dm.ogg b/public/dings/senders/aqua/dm.ogg index 0a2b98da6b7cd4b4836af63bbbdf70323b098fdb..5a23554656f200923f9ff45ff79b50f5a12a466b 100644 GIT binary patch delta 83 zcmX@Fa$ZHsKRrE|fr$YMmh4h@2Qk~YA1<0GrN;x}GXnW8XRjRI81jb~EC5o?UyxWo YIfm~LQ{4N>3jEKRDjsd-6mVk#0LAwi8vpeO2QlUNG6R0j6iG6R0j6l9n|I4czL;moB1wg8=FrMGC JIfgHe82~AC5w8FM diff --git a/public/dings/senders/aqua/message.ogg b/public/dings/senders/aqua/message.ogg index ce68c47aa5e6474202000332c852eaa000f12680..3704f55aa24d3b942ab7dc4639ea754a15452c18 100644 GIT binary patch delta 69 zcmcbtd|6q_KRrE|fr$YM<~aD+ftc0DqwY?W(&GW~8G-!BYisQ{hWz0L3xHI&Rjir4 JIfk#C831S76CD5m delta 69 zcmcbtd|6q_KRrE|fr$YMwk$j+4`QYs%+#7FrN;x}GXnX6b&eSuL;moB1wg8=^1eB@ JIfk#C832%&6r}(F diff --git a/public/dings/senders/eureka/dm.ogg b/public/dings/senders/eureka/dm.ogg index a64c22f60b4025f0ac3569c2edf5cbd8126740c5..57024fbe5d6d158ebb190f7096dee302840c9771 100644 GIT binary patch delta 69 zcmaE^{9IYeKRrE|fr$YMS|&7QfSAjCHpoqs(&GW~8G-y?kE4w@hWz0L3xHI=l)wFK Ja|~ZMGXR@76+8d{ delta 69 zcmaE^{9IYeKRrE|fr$YM=1j5W1~F&c{~$S0N{M{TM$hWz0L3xHIA6??R3 Ja}1v}3jn2Z6t@5X diff --git a/public/dings/senders/eureka/mention.ogg b/public/dings/senders/eureka/mention.ogg index 0025d7429d2c03ead0c324b27675bc9a4791ed65..c4085faead552f391c5ae999be696952dbec8541 100644 GIT binary patch delta 69 zcmcbubX!TvKRrE|fr$YMdQMN11~G5R>Y7cI(&GW~8G-yN?lQ5BA%A$m0wC2N)fE?P Jj^X>y1OQ@-69WJM delta 69 zcmcbubX!TvKRrE|fr$YMmh_3Lf|&c7JqjmE>G6R0j6nX$nTk&~hWz0L3xHIAj@Mqe JIfm~)698l36KVhe diff --git a/public/dings/senders/eureka/message.ogg b/public/dings/senders/eureka/message.ogg index beb2a203b9d9af6218fde08c56c7c07126c0d4e0..6cd340ce391adf6c38117879b4170fad7febede9 100644 GIT binary patch delta 69 zcmX@9bW%ymKRrE|fr$YMYP5XWK}@qQ9> delta 69 zcmX@9bW%ymKRrE|fr$YMrks}rvo1<1Zk#Bk#{=Rs0!41$Q;XXe@`o2J08*V3pr^k% IhVLyC08MBT0{{R3 diff --git a/public/dings/senders/heimdall/dm.ogg b/public/dings/senders/heimdall/dm.ogg index f4844c021e4bca621f43c9cc89dec0f19c16c0d0..145e885336ba8be55c558c981273ace4d1088bbc 100644 GIT binary patch delta 91 zcmX@0aX>@LKRrE|fr$YMQnuYn2Qj7mejS`BrN;x}GXnXM;ubSDhWz0L3xHH_l~MaJ gIfk!8@Z$nIMus=@_=3A_yj?$wT5)dv%ID1r0J`TKvj6}9 delta 91 zcmX@0aX>@LKRrE|fr$YMYU*-wK+FY)_U28L(&GW~8G-ympV=304Ee(g767T{ZGZB3 gatvRGU|qm`28J^K_LZ*lmM;3qHJ4@cS3Yl60K%#r=l}o! diff --git a/public/dings/senders/heimdall/invite.ogg b/public/dings/senders/heimdall/invite.ogg index 6e27e760b4f9aa1387826ffc5633be9da21ec8c9..98403c21128d08f0960988357323e9255b6b963d 100644 GIT binary patch delta 142 zcmaE&^;ApBKRrE|fr$YMLfp-zLCn)xJN8YK(&GW~8G-zzTPkO54Ee(g767T{bbDq! zIfhS?(Q$JbUp1@k-B00+JNDTs9M~@EVfUW@Yz>ROwHWGrHtUI)GXemWrz{=- diff --git a/public/dings/senders/heimdall/mention.ogg b/public/dings/senders/heimdall/mention.ogg index e006414703fe25088289ba127c85f579645d7cf1..5a39a98ce44d2b6673af338c344850af4fd23bbc 100644 GIT binary patch delta 123 zcmaE;@lZp`KRrE|fr$YMa$=8(ftVao|5r|w(&GW~8G-y1XE%k7A%A$m0wC49vQ<4c z$MB^v8q^E$W>{a?yKvbKCWb$OlSE^dgA8DJ@U<+YuW${z4W2c!wzz delta 123 zcmaE;@lZp`KRrE|fr$YMT2kV~K}@!*9XBUR>G6R0j6l9;j_Tu$A%A$m0wC3bXLerN z9K)BwXt2MN?~CkG6R0j6nWD{t0#)L;moB1wg80>*6~m Q$MA(PEtlE6iSGt80J)D8J^%m! delta 76 zcmeBD?NXKUPfrhKU}AuR65l=%5Hsfe#qx<#dORRLBanaFb>F^?A%A$m0wC4-+z%or Q$MA(PeV5z3iSGt80NaZdt^fc4 diff --git a/public/dings/senders/kiko/dm.ogg b/public/dings/senders/kiko/dm.ogg index 8b76726e0b934f2c741cd1844fec12b9ec8e6c67..7c35b072c4ed71a0c4942185fe976cd5266f1aba 100644 GIT binary patch delta 124 zcmX@Eb6iKtKRrE|fr$YMTy$PYf|x~9YJDe4>G6R0j6nX|TO}7ahWz0L3xHI=?&tl# zIficuo1u8Vf4=IMn*Wn0S8V>xT(q~#LzsaflfjRffkT{uA%LBM;Yd6qgTtE*+vGqd MKgir{C@RJX0LL37x&QzG delta 124 zcmX@Eb6iKtKRrE|fr$YMVs<RNwp za}1vXix~FM%d{&o&$N>=s UhYpa!1DawC3=zDO*Ngf90I0z%0{{R3 delta 134 zcmeyZ{aahgKRrE|fr$YMd?qIG6R0j6i-;O=jfAkUzX&0g!6b*>$d) zWB3$U#P}a5wdjG=0D=GEpPCK_Cmr~=a`Pmizf21G_a>LsT>bkse%;q}Q&$151TF>z T7m&gSBHRoNYuF~Q7xe)EzU3%P diff --git a/public/dings/senders/kiko/mention.ogg b/public/dings/senders/kiko/mention.ogg index 388dbbabfff693398d68b15fdbf993f3a4d85163..12cccf293a5cf9c59988f06a428a7e6ee674f0ac 100644 GIT binary patch delta 113 zcmdm@zC~TiKRrE|fr$YMeC+4iftV#b6FyIr(&GW~8G-!!b}uD2hWz0L3xHG$tQX$9 zIfm~xBh$Y(lPv{Qn0NjR-kc!tj!FEg2q!B;s4#~($Q%ZSbGbmGL7ic8s<1x*$f_Q8 delta 113 zcmdm@zC~TiKRrE|fr$YMQo5FefSA=4>vm0)(&GW~8G-y;2GdG6R0j6lAhi@D>*kUzZQAOVo-C!Gtn zNa<>&+1T5dSz4HGPUo{_(*Hk8Ns*PoL5g7kTMG}!7*-(G;AUX>vD7-7f#CrM1B2W) fn|$+w)1RID`Si`^D+0F|SzUpO95!zjGGGJ%NH!;= diff --git a/public/dings/senders/libby/dm.ogg b/public/dings/senders/libby/dm.ogg index 8b580a4bfc3c17780ebb3ed86552d970bdfe9825..1316c19600e1fda5f082ccbb6245736fd55b0058 100644 GIT binary patch delta 156 zcmdm_wNXpTKRrE|fr$YMR6ZrKgP2Z>X0DznrN;x}GXnYT#zHO|L;mn8fdoLRyR2XE zD@b{}xmsG9SXmh8n~SL_#d%4oiyIR;78YVC?2pEA( so_Cq?KqM!F!(NdC6~~wu8qR!A;{*yj6i=2HzQ>Zi&rW_bqlh6R0NWWYDF6Tf delta 156 zcmdm}wMk3LKRrE|fr$YMY@AZWK+G>EpEXUC(&GW~8G-ya2K?q5L;mn8fdoLR7X?jS zq9En%=4xqaVr5~VZ!V^$6zk>W=xDpSh_8ypc<1MuTmSdJzE|2==sWQO1A{;i1A`MA tP~(AOD+UW~MurvBnIz6fFfdH`c~1vqLk8buIpKRO+WYMEH;EWB0s!9vEI$AM diff --git a/public/dings/senders/libby/invite.ogg b/public/dings/senders/libby/invite.ogg index f88e6d2877996eb88e609a02884f7b57fc8c18d2..4b92a0b7d285989c60e106eabb1a72d3aeee91b6 100644 GIT binary patch delta 112 zcmaE>{Z?DbKRrE|fr$YML@r11f|%vkWj0Ne(&GW~8G(GoKlP>?L;moB1wg7@ekVWJ x9K&~?RrPLG7fU7+!xByghD4x*91x3eevqCQ&j(~NFgU!l-Id|IIYFeC5ddU)9bEtb delta 112 zcmaE>{Z?DbKRrE|fr$YMbdI`mf|yJWJ7^M*4_Kf&o1A*T)>wZ0A?mG0ssI2 diff --git a/public/dings/senders/libby/message.ogg b/public/dings/senders/libby/message.ogg index d923f60bdd2fad0bc6ecfdebc6e4de6b0f60685b..432f8623c5cd8de934c10644e03c39a185c8888c 100644 GIT binary patch delta 135 zcmcbteo0-*KRrE|fr$YMWKMjx0x>rzyV*^Y(&GW~8G-yqjVkgRL;mneg9JdT_3iWC zNz2NGdpX-!8e5qfx=2ZE&gQ$yB=Wy)W42?!m#H9A7#ilS)xG?vVsohADMk_Z)tW+5 W3=D_N85smXk_Eo(3nm*2F8~1fQzoDQ delta 136 zcmcblepy}0KRrE|fr$YMOe{B7fS89K$f-?~(&GW~8G-y2A2{kahWz1`1_^*vU;qA= zTUu5w+{@X<(%8z>&_zmeb2i^qCeix#jmrZaE`~#ltlGA%=g3E%%^`xP7)7F2YYIsL U%{FIb5CBy4TaPRzE)6Vzyew+Qplo=D88S1{>HWmB&SJ`oDrf=X6 zMwt_Km&AbjzsL)57*9-bG$?M}dvf(OYPfudAtoQ;%)c;xxK0@jJM>2IM57+c;?NQ#ZNH;0FlN-SpWb4 delta 231 zcmbPZJl9ytKRrE|fr$YMcrGr905Q|ohHRTCrN;x}GXnYR92ZaA81jb~EC5pN#kWFl zatxmYW7_6YzLiXj*_-7B)mXWl8Sb5bYub5U{@iANF=fX3V1~MHcTL5<{#16Hx^tPv z9|pb@`IjII{8F0Iw;UT)-ZZoHG0 zx-P2XWq%^`VIiJ#;pg@LZ(Fq}lv(x;ANftbBYuY)H_>G6R0j6nV_LxYnWL;moB1wg8^-ygiW JIfgHq8vubE6r2D6 diff --git a/public/dings/senders/nibu/mention.ogg b/public/dings/senders/nibu/mention.ogg index 6a065e39d171638f3e009678c6c921e66bd82a59..5f6d2dfc2d20d5310cf4b084dce302400d795bcf 100644 GIT binary patch delta 267 zcmexp@XjI!B3EvCiG zcYKL#J;S;#C3>n5(^qDO9kW2Dacur6xPXzdWb!Ow4Wp>5F`F7*>&ISQ((JwW+qApB zE1rm2wr#hXZ{{nQ-xMI0?O48g`%MX5eg*?Wpu@K*GHm`VoW{g4IrR0novVNR-JB>U GzytuTlWHdb delta 267 zcmexp@XG6R0j6i-~rI+2tkUzZQAOVo-M{kbk ziO84bWEG^Orlu!vPUov*=JefK`+eu;KmR%>zZ3Lhf5OJV;4qtcvWw7J=9>B)n?;1T zF~&zT?Ad>Oedq7~U|&OB1_z;|TUddnHP=@$97*sI_4(PmIgXL(pj=E5TXpW`6bnD*ylh delta 216 zcmbQIGhau_KRrE|fr$YM)_h#(0AgNSb;5I^lpYU=&j{pix!g2)W5^#~agYE=b&!wb z84>xioUDSB)YSBp&FOq~%$&_xzuy(7|NPrK`JJF2N6KcPx?Dbn$<9J&neW)gY!(*Y z#u(quP_ch|yzqDa<+GBa7#dW%GP!}u*#3WE=r}k-YsM$(^u0_hO}0Cp@O-Q3?1ypE!{O482%mmvp?1G|E;z0SMsH@=SMb=Keoc2sEO>^Fpows|6 z`8RhQS6*%VYQO!9_qDh6%#OW}TorS!7$md!$m7JV%=1NBSsCgy>omd{J8HkrZYy7L z@`^|}(Di>(s+bO_)UyJuIS?FO-*oTKYbkD%Af}1`{@7@*i%8w)-mY;pWw!opom$tI zuTy9HOD&8MjjElk&nRcPFG8GgiT&~X2eW%-J>p{6P+$1H7~*?|-_rh)3g@nr0J#&K zx^&moKDK0Kn8&eCYdOP{^PG1+w9YEz6o9H_V4Gi`&t?aV^d?T>>=%2qXR@4#&t3wO zjk{5+wHv78WA^87KrO5?4=$@hbUFMlXIml&VrRO3-SjbybA$N3_S%oT8D5?5w5a?I z3_?~035ZgLJzE)mv4hwP7NmqT2bjgB*M3~hu;M%CoISM*m(37kV5kSH{BO+MaAK8O z^i>OnQ|t3ULifz>&e8u9SZ!i{R~nHnH|&>T6fk-kc0Fz#9~(p5w?C)F_pK6M?EKfs z*U%Si5(5LWuKcjoBtseWtN z;`z7hm^Z?pG7g4@7iXQg7{N+3n#`}si3RLGv>cckp;lD_lN{SVsh3GCASE?gcLH~d ZU(vrJa+#sH-xiY43U2RLe!enL762&Cd7S_N delta 1643 zcmYLIdpy%?7=O16C6hF_)+Co)lG~J|Hup}EOXPkjx6|nGkwwOSKMEaPoGwaJTBjPy zrIankkcy+w$*r`x9E%QVg@|T5zg3^ldH#4m@AJIR^ZmZ>^L*d)1`HEz&)C>~01bYc z*yWc5*_JljcJG|_=9MxZh4{n^AzJr+#mX+SfGivCywiwZx7WnP0k5fRXn5b(z`#H+ z%F5F63M*S*SAWBXl|H$RI-<PFU`xK~w#<23(~N_10c=o=PYoTcZGv@?es4Hl*{m zC^t#OcI9A(NZn^v?gjBlk=q&JOnNLPLJbpB4k~TR8?hM3{sZ<;LgJGOL|G2%{ew7K9f7waf@fMI- zU`>5U*43h_8D^jzH@@7?MNe3S3ER44%4KHGq!PapOiGPckO0{?8foCC$pQenho`qA zLn06Kt3YhUR9A04JnowE5rio$ zk*b;DQv_uJ&6a5Ml<0tfKnSR292UUR>1OTSNPy5UZlvn+1~Q1rBCImj!^6_!```d< zyBv^t_oLO2Bnev#Sz)Ac^Isj7EIaEVQ0enLaT)%1(zM`J7J@dwaH5jJB+U z@su)mC2gCeY9O&4l-EE#60~A?hrAJrHHq~3>kNclX3%MjMdVi=a=G>9370Aa?_={i z_v#NC@q&U;w;E_oISDH$d8VL=jXa9L-eq=({MizPQ$La{qD3Hz?|=(%~^dpM*4$ z1(Nw}H-`gJ(guHp+`qS2bEYtaXi^M*L6qBgo|&My__k3}phxYA&Hpe?f6-OD5tW@lu@zSiTlMtaazIJB~ z%P1XyXw%Q_?kF(Rp%0q6GM0kq?r8RjCMgBW^C+b%h8s9%vFhv|R+jO<;1g6|Xu%sp z)Sx@G1tGJX5FdYUm98|i#_V@9mUibvc}}&jN7H5Nd*LoBA2^(??Av;UW8*5Oi`2Tv zSIx?>&S_nKAqNc~jy-F-f66K%T^GPpmo4gw5g`xuif^jVom3lR z@ovQIQv0C|cpOoc9QSbYfwd@5TqBOe6?5k_-6#Bs!T*02i$^%1Xjp#JMjcU1jaUOG zDVft`(r>T|4S%43Pz*Rx|84Z38X~H}8os^yoF8r2dCAekavh)F`RzxRm);>Iq5Li! F_z!jE#XA51 diff --git a/public/dings/senders/void/invite.ogg b/public/dings/senders/void/invite.ogg index 0b344456aa20b0f0bbc8807728512440a794c415..ed46cbb91d5dd46845c139731487738547de0926 100644 GIT binary patch delta 1433 zcmYjRX;4#F6h1F3hM+_R$`Zj;WfKKz1Cb?*;z&kNz&N5}SzJcNg@LGvRuT##7Eu%w z6a>^_L~KA5QIsXxvB7EyHi#Grp@5)*21;!xNnZLMFxv0ex%YhMoNvB6=l-KFk z&D=0cceuVBV?c@8s>p<4dEsiXWPa5#aeAqQ&nZxpi_@lQS8xKd%lcZji9Y_d_8v{a zE07-zO_CQETI96(n>kycedHGOBXipA?+5B4xnb6LHvo3i2h*_GGnc$95-_ahJQ#|k z3-1|Nn|pT7XEkEueIL^v;!Xj)WhrMu_mO`?+{VIt3Ba6Y zV9m22*~`4kW6K>Brjs*=DP92a8K}lwxyE}Dg0|~fw=Ttga5nkGuqnipQ7387uH^&h z29p&e3xKX#y{PHHL!(j>+m={kg-x6s2%V-t?QiegWvmIh;=HWX*FQEs)H;kUYq!+8PuTw#Hs(s2dq>Es)iaY2EgRh#`aqjUnSPs%2}G zu^WbrgofRs_-Z2~uBwHKFgsMEB0j>YR0XL280(RER06cJV$MLWEiQKaWByFaNOW{S z{R!@=b6rcBPyF3~JIrcQZ%inCky{V*CJ9bzJN$-z=TRiK&gV;5ZV1oVUJ z+3K?ZM2|kP*o_}alIq9zsPGher+(j%O{$Lfel)G>V(YiQy(AF~2Ow{iv-Xx@`P33N z`JeI{ZPy*Py1~;veq;THz*CEd#3fFL*3}0xF+5Q>iHxX0r`3E8MkX*fC+F~y3Fz@0 z{b16cDAGub8Oj7@kAUgi85_Bx95*-*#)eHv6GATxX4(2&b6oXddGaICNy48`8%HfRUYHvTS zL4+NuiYVmh(ys-=-4Wc)n2(H+T_%$SJzp&VX{e42@UWu#iZ!n#h}R4v^cq7_1kY0R z!JjIZtxhrnsK*#Mx+RL~Ablgp!bZhi+q+&bJ&{Jfs)CN39T+2b`I}<)rJ7yB9uvo~ zCInzTHjwzr@y6l(=|v{Mq%(eb0Dh$mT6b}91lNW+$%Jl2v^AWM%V>RikS9V~cOQW+ za|6T2BF>j@bdv=}Wqny>DVGP*quGh$y}l|M#I)-fJ|yaMzWVn{&Cld7`;yAs4s6oZ z<=2~wE=Lj2JNjY`;igu_Tav<~lzWJ=Rhz)YaBC<#Zgb0!E!)ol=UTmY^mfzJ6{^@1 zZHbj-3;)-z+8o#mwvE4zsx!fPaxX%fRM{}&J^dx;viQ8~RKJ@ckji$?(QIr(aMnPi X;72FyPw)^==95?Ts%7W-GF$i$2&YiB delta 2070 zcmYLKdpy&7AOCJ9LsFq!rX!NZhl7=a{Jh>L}IGVX+zj0!SB!v=&LqoZ& zSXr}qL@Ae@+?E=-L^;$-jk57<>3N>dU*FI7^Zs03pKFa_Iab>>Bm@uO;3v&$t|iN! zFBrR?2t` z(P*>*>i&U&mG=?>l7^6(133Bm0o+i=+^gkFO2A5sY`2_!g0mXm`$`(oL{X?(qaRUN*y5oQE+h@S>3#;&|K8E@?@*tslXYB!X}`F{wysYW0R?sI z&3eU-1)K~CItK9wW|b}f?vJQ=_?88+eHoX)s-Y#Qrc_oIppmP^i8{LjaLL=NFxy~Y zxkJo>g7#f#^j2slQP=!{t)eJ>av&|vu&7iWfR-ll0ql7;$&~IV?FD&|c39TGpB}=% zvntudzzR@rFJS#jF#xAswT!czl`9TeO`YV5kI=5d`aR*Bh8s&MNra6 zBd4B!LGJoyUC_c81b={RPU{LDRD8-BnYxRq1%Su#%mG(%G8YHG%sZqxpq!3mk#7Bt zH7-71P?b8L{V|q#htE>dVj)y;^{j~eQ%VdA?*SDu(vCrZJ(y!XxfrQ?Hp3Wz@xCRe zpJVs{T0*{w^LYw@|K$7xQgrP+PbCUQe#fR0AlQ9@OsATTF@dd)766pi?mUo-0RG8} zI(b387z^;yy^wiX8q+e9ebO8Db`7Z{7tiUG>8vJ98u&`HuD{gZwiI!cV5iL?U>-NH zQ6}{b*(q1^6v>a+_af(dOtl69P=;=R=Ddbvx=9Tj0t=_+u@6$(YRnZ*Xfeml$+vOaRF%IIIia+Gcz}~*RKqh zL;nEn3k#;ji$0&JfH6~=@K+0Cs6W#fa1DT+&AysD+1eOD@gAGSXU2xQ>-R*aq@`nT z*DL#Y8U;?>wP;GK?0FvgS@DR;UD{DkjhPv&as~S|v#8?3xq~Hvxt1yoyv&@$%U*f$rQpyT*c(zM1V?w0$J`n{-xzTW_F{a|?jrpH)b<{fGr zV~0Id@a5Z@Tf*)SnY7Lq+WlwN5r-`GPho zcvM_YAyw10JOSX28m{g%ZsRRi1@NF#x(W5}rrW3d3sdF@Cx;LRjouRTys0_OX~Sbm z0Gu+%o$zM-Sq-Puex_>CV6KPN4Zgzl)w;q28&9g%h3)&|ge_v06JBi}hclOE7N)nU zLuWKuTvij7svn(Cqi7Lc6JcbJ zhc6Zf2W`TmndxslYaNd~K7T36jA(;OBA<%P!O2C5HY3y@TR$WA$2Kt4h<{b0=gR9N zKnptS1|B?=FrCYCV@ietP6A4X1{=zHrVeJ`#f8B^&bP^q-$LHozyN>L#^c6F(*7Z! z{QO{9n6JaIe_ZEG*5^Zv@+{w*`SbJhM6#d@g)0c~+oL$3xo)O<*rK&16x`X>8@1%} zrl*Bk0lNNxMBS-+*tyE1O|i{03soLz_5mM8CD!s2v`dd|_Li|r?l z*M|U@Yep3gd-J6StWjJT2Crud63WQYI{!am;8qC&Agiv-h1gU(!nZVuO;6(yS3FrI zso%S=4<7&D2?Ls80PJ@Nwr}px1ya~;4+Nr=&n_iL%lE5G9j%fG3DzUuezH}zl3}LZ VV$*eh?8sGV!HY$aCi}St_z!*fiQ)hN diff --git a/public/dings/senders/void/mention.ogg b/public/dings/senders/void/mention.ogg index 80ec43e39c6f8d7d48bf87f1aabd5dd4340e4278..b6295e00cd637181fe9814c5369e34c7b224fa5b 100644 GIT binary patch delta 653 zcmbQIeo)R$u>G6R0j6i<$kEZh*L;mpEfdoLRb5Aj; ziA(uvX{xEHs3}LGkAL;VoIQJ{@tgg%^h!-Zjmk?u^Rf0Z2@h9@Tue;RdgN)IEsJT8rnjzto-phCippt8U_{ERJAAimMbvB==>qAzE zk{jz88zvN1miq%;&hYQppZ%!^|KGB$3%H*d5G)c0QPc2`lcBz$z;=DVG(!U$!@X@l zRf_*^#oDj9U$O$|F^H-O`RoiE_-u0)1KqQ4+aLR70`YIx>Ay0UVlzEz0*MQTM`DZ& zN^j5B0=Xblk{8-;t=<#3|0R;w*RV2h)St6t2l~7YXiB=H(C+&S>K2MFo5A*N_x&13 OAU0ICDL$3jr2zo&i0U{1 delta 1117 zcmcbpJWpN9KRrE|fr$YMSnf3mt);HE*;Z8*D4?vYs;H#M6V`se|9j(t8Q(-5fLb~s%+WwC zByXSF_G|U+b7%ao@7rG{YwC5o{{OAgD=QYfe;+?PaB;zWh9!0X|Jv-m=)eio3)KCb zp``MLOIV7l{(c$OElvOXPru6L*zxb}T(C4l@b9{~I-V(Y|9{T9S|2H;k>~FWk+^m; z^F`vE!WxE@x_W!Q%@-Y^=Dp{h(=WVfqUhc~o7m>m0ge0iR)=Ywz1>z8us$zSLoF*A zhB;OLf7gLE0W89uz1*kqahr{HSQ8~s@?Jtk^1 zIiBAVyl%^0)hSi&zg9dw@XKC};WI-+mto)a{xAD?yePdg`$3+`lIwGfa$nDvQTf%L zTX1smi3r2cVCzX@It-4tO=T8`D>D2#JAHSh+JTZezv{1Dwl-e2Z%^SDUv~M52dDm5 z7nFy`%{S5dx-0wQPc~=Y4F86>lDtu}T3uC>mxm`=PH9PTG z^ZDtOT+>yWx{hr0PxK6ZUOR2_E~)8FzLN|j0@jLzhcY&tvdPciuAqH&gMm}6vjFeI zWZf^fw2zn`STcKiedvM&z2sT_^Au{e)AJT`q;y<)*KjX4Jy z0~AGoA-dteKXb!`$i+9j8Rx8KX7GA)ZBoO|g3Mg0ur@s|2?@GG^STvau;J@xaa=Ye%FHdw{pyP>wgDuE(9urs5|>TQ4QH!1wHlCxiNUkT(Kh)p-*`56wx ztiQe%$p5$XkGS}g_}$m}Uu|bFb$!SRQL^KoK10GO?Ycjg85m4Jil&R6iQe6B{z^P6 zyekvp8iqIX85vT-Ozb2X80G zE+XZprKPH>qN1v%5@o8dudAV{s9+Lx{lDFrrUey`Lzx+XS`+kMe*I*1{YrpKjdWfyzf%%^kwk`92S2EYl_*=iI)9sDj!fJOW)1xK_ zK`vlmc#(fwfZ@WnKVIrTRv%^Dm;P5iilwoz#-Y~v>;yHi3WkRITO|w*77pK)*@}!~ z80yUb-d}V;{J8SMeeKRGBrmblLrhZe3uR(>Q-8^9)dU8HI`+T&Qyur;TFZQQx2(*p zSxK`XW-(M&F)$dOKel-a(3x`A|Ljja`2SXH{FQvGDcl3+fh^uFzb@2~jp-IXi=ZZHn1Zko|Hi_VVrLT7LL$yYD}@In!o6 zN>rZr|84v_bJZ8o!i)@Cg@Agd>IqeCJA32x!d=qV$)(fZWiQop+kE|DvDyi-!@GSN zldi7umD1D*b_?28tQYuA@7C)Nj;^WJ0{lJ5|2~cL*^! z|cu`e>3v81kAqC$P~vK>qefA;6|LG(7%sw=W0d%Qx30Y zm~P7^*YWpRnBD&J{bv66I|A#2eYipL3=9tYCJWjya=g^9`?y?4BYj@B7)Ufu{qOy( zhxS|9Tb#e2OAZM35eA6@&HHZJz{J2k&pz9W;kmpg@4aV#e-dJ-+xjP0^jv&3%ZhkVbQnRD9r!88pzvtn9amLGhIw26 fD04qwcQ<=M-NI!v*nYfoPl70#Fi-B@mD7v>K*lb4 delta 1603 zcmX|Adpy%?7=M3bZfojfpIjRiYJ@b(T-scsa*(4TmsyUiC?faRZ{4V*yD%kL;)n_% z$*iM}Ri8LwMcG!IlVf7!?(A~5^f}L8@Av(FpYQv8-sgEftow#D@T1WJ00R7o=_u(> z%40$>GT^d-^)e-f6u95N$G3Km1ie#vp;XgOp1XuK_O-BZGt@INH?Jd`nVIk3YHPK{ zP+#9@1Fn{CW`YUKX~T+DTy4iJfx?6OfnQfSoq`(5+bT#KvcGyxn+3n?lM+3i#f+Wz zoXcjG9Z^#!DIX)r{jzjWJ+!!VW`XXmjM18Q?L?=?Mi=$$wPdpVi?1NzA;6BQA8oo2)>$sjNfbAF zHX((?p1ftgASvZnCuT&c>^CjWL%=B(ByzoN?p^LNP9JqN8c3RKVa>jdo401B+NTl3 z)CX>Ogy);WRT}{>xA&I@H^5C$W4wglBqmQKO&qrjqR2DUjUS8TE(iD{eVdqhL%0jh z@%*ZUF{3$K?mv9ArG?KuN=qyi4(`=w4FGmeS)>X?F8ii%Ogp5s=DWDL(MidrJu1eI`pqD!{=A zqNIPVDWjF@LmRB@ctku_p^@GdE~t+hndZ{exa+3TiGF_AwWeVoI`hT{Ad0S3SY{$~ z#G+@MjF`+wW+?!$vMcB9)d2@ihtcH?A*Mo)U*Be-3?EdG=P8vjQPPTx0QgZr%*jb!zLf=k1=d|G0Q^_ z%|5?u;&ef^^w1rJI!)t7N0ST|Hk+Ouc+lL;u6C`OyI&MG_&H&kK<*FM#;BJSz%CYtVD+WK?c9I9+v3;0`+JJ0ZNZ=s&VvXP|e5I?zSK_Rd5(<+r(@(b5IK$q5W9e`AG~X-&dJ=YV7qm z@+FcQt{GS;^$&umZ5L5pi`vHa?jf9|!nAYRh3iZGT|NoDy3*fGz}zDIR8Nmh4K{Qa zPGR?R9FdL9V4YmKREGrzjh)$X8nc;BSd|$k&!9h6Ta6J`seWfo(*JhfJ`@ZxW8`$= zw42w9wu-Lr1fx2KXd`x1agM%4ycPYwnK2lyTr@AF{-IAZGK`9=NH2(^AKC{~k33)^&qonzMqk0G-(>#-Hq?=qK8_=*l~)f zSFQ_UGqtdhENy>Q(UzO;Cq($*(Iz~4hf#NrbA5~k`<(PBW_cCZs`~|TBt?erq*rTF&oIv7 z0liTobbY9St>%lN8N-C&m~#hz4AjIB1Q`4I{qbA?WDSy3i?8HkdcBGz>S16B9ZRDT MCZ6Qa=IbK=1Io+28UO$Q diff --git a/public/dings/senders/weksa/dm.ogg b/public/dings/senders/weksa/dm.ogg index 3eb3fa88c8d4444a1f853ac52e7ced92a8f87634..37b5f990d09f611b962c35b0e5984ebf1f168a91 100644 GIT binary patch delta 148 zcmdm_vsp*VKRrE|fr$YMjwnwS2QhV?ESWV?N{F?$3?&N6gC#A1k65^MpsGz8!uHBiElo%4?>tJnRVz9Z9?>(b_zp1Me7sCdi z&NB=TLHd8qW@LC^0%W{rVz4nc1=(Tn|M(;j-7x*x=7j>&Sp)3%?7aEf?b`MqoxyMG z_FtZ*w2&=dq nUtbq=mjEql>;*aX#T^D_pmfrX%u6ghKt@KbS|-Z@c@+i#JF?$3?&4_eC#A1k65^MpsGz8!uHBiElo%4?>tJnWqPMw`?>(d8`B_SetPBxA zqtYCt7)n4UEZ8r}z~INs!0F_TSI+ofyFI!fOpsk$Qs(!;hEmSXxdqFmSM1ft(1`IcFV%9guzn;(VCLD`!nD6 uW7eA;Eb;uiJa#VYFxhaC(_se>P^#nMGEZS3y%#8F#rH4!*o~x diff --git a/public/dings/senders/weksa/mention.ogg b/public/dings/senders/weksa/mention.ogg index 148a6aca49b402a19a33f1757f759a1e6c38d553..98fa0f7507bb4695057a2603e27a0fb8268b90a4 100644 GIT binary patch delta 301 zcmZ3jxkgjUKRrE|fr$YM&OCc41Y*AK`1@v}lpYU=&j{o;p6lz z^1q6nZgZ<+V|c*Fz>pwscuXRhC4qZ``ntJOH|O5omV3MH!SAMP&!n2(|K9ih_cmR| z12qw^-u`CVti~V4#Qslg?z;Sg{F9pnef9TcxjAt$Yydjt8xw~((DVh{fY#)*GBA8# zV@SyNTMM!=rfwTEh@LO8`M;n&qxl_ue#ZT3zjJx~{__2;GO?SsMpGz=p}}&;Zcc|C fAcgOR85rz{H3<`!D`+$Bwp|QDC*o+YX)8uaB delta 299 zcmZ3Zxmr`oKRrE|fr$YMo;WfkftVFHTpcG$>G6R0j6gnjd`soVkUzWvAOVnSg$L{R zNy@3^OitiSOW5_{%l7;0A4UexaqXY&Z})Yj|J{AIZcGdjK&{rM3cer{85kbvXWD;x zdMdZ~4-dluSq6p!^9+wkB(pd$emk;tbJ4ck+dw2x6Mx}WjPr-u``>HtZ&PLX;QirS zE#qc&{xBx?dt$Qs`3J=(w+Q;`+nFjWvVzU8W^CaG8XgcW%+RnM=&}lCh7IP+!$215 z+201ay}_SnlaM{5xy`n6#{I{B=RUde{=$0q$H^yWDFMxx(Y$*$1K5ZUc8m-Ul!5Mg VXRQp>*YH*vNc1pmE)zCm1OOHDWzGNq diff --git a/public/dings/senders/weksa/message.ogg b/public/dings/senders/weksa/message.ogg index 2e678ea6c91affc50dbba2b0c5ba43ab90215b08..f552996b8ee1c137cfe304f25badcdc6f2c1c34c 100644 GIT binary patch delta 202 zcmbQCIYU#*KRrE|fr$YM_L#2l1u^9+e`ZgV(&GW~8G-!E2WlHPhWz0L3xHH}m^|p2 z9K-inc{Y%rNSuM;fMCPp$Bzq^xUUCE_uQ^e2hk3x3A?y97x5c0%GOEs%~DciWrzT3 z5K@Q%N;Z@O_4NU{Nnw)@3M8n=2L`JwVPGgO*v=3Dl3u9Az#yZ-z>pv}! = { + 'aqua/mention': 'cedb63552008', + 'aqua/dm': '5b996d01c770', + 'aqua/message': '8be19ccd505a', + 'aqua/invite': '462e63687f0b', + 'eureka/mention': '7afe0bcc90d0', + 'eureka/dm': '3909d70b0d45', + 'eureka/message': '0b466cd4b437', + 'eureka/invite': '837e1bbbdacf', + 'heimdall/mention': '36051569eb2c', + 'heimdall/dm': '26ebc4158d51', + 'heimdall/message': 'c383d6ce3da0', + 'heimdall/invite': 'fd47e9a97bb2', + 'kiko/mention': 'e73d2d586f4e', + 'kiko/dm': 'b3e39946ab70', + 'kiko/message': '6730b9df5bfa', + 'kiko/invite': '1a3c1d118b9e', + 'libby/mention': 'eb9f3ac60b02', + 'libby/dm': '77dfd01cc3ca', + 'libby/message': '257b84a114a2', + 'libby/invite': '8ef7e91100b8', + 'nibu/mention': '56484d731df3', + 'nibu/dm': '573f5db4de4b', + 'nibu/message': '30f6a5c9ce54', + 'nibu/invite': 'bb36c0975f35', + 'void/mention': 'dee69f4af562', + 'void/dm': '92e3ebbc0b29', + 'void/message': 'fadc5556b681', + 'void/invite': 'd602121389d8', + 'weksa/mention': 'db46a36ab3c5', + 'weksa/dm': 'efe993382a46', + 'weksa/message': '00e323635949', + 'weksa/invite': '9bf03a36758b', +}; const MOTIFS: Motif[] = ['mention', 'dm', 'message', 'invite']; const VOICES = ['aqua', 'eureka', 'heimdall', 'kiko', 'libby', 'nibu', 'void', 'weksa']; @@ -37,7 +72,7 @@ describe('dings switch', () => { }); }); -describe('v3 manifest against the parser', () => { +describe('v4 manifest against the parser', () => { it('survives parseManifest whole: no path or motif is dropped', () => { expect(manifest.neutral).toEqual(raw.neutral); expect(Object.keys(manifest.senders).sort()).toEqual(VOICES); @@ -72,17 +107,11 @@ describe('v3 manifest against the parser', () => { }); }); - it("Nibu's four files are AquaSynth's proposals/nibu3 (sha256 prefix)", () => { - const want: Record = { - mention: '3833f504903f', - dm: 'ad4839400f2c', - message: '6ec6fc893b94', - invite: 'aefb6ff79c42', - }; - MOTIFS.forEach((m) => { - const hash = createHash('sha256').update(readFileSync(join(DINGS, `senders/nibu/${m}.ogg`))); - expect(hash.digest('hex').slice(0, 12)).toBe(want[m]); - }); + it("every voice file is Aqua's v4 render (sha256 prefix)", () => { + const hash = (file: string) => + createHash('sha256').update(readFileSync(join(DINGS, file))).digest('hex').slice(0, 12); + expect(Object.keys(V4_SHA).length).toBe(VOICES.length * MOTIFS.length); + Object.entries(V4_SHA).forEach(([k, want]) => expect(hash(`senders/${k}.ogg`)).toBe(want)); }); it('keeps the clip lengths the design assumes: message under 400 ms, nothing over 600 ms', () => { From 14ea0dbab0ddd0db10bf1db086f5c92a87c0a7ec Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 18:00:41 +0000 Subject: [PATCH 086/111] scene: skyLabelInk leaves sceneShell.css.ts (vanilla-extract refuses a function export; vite build failed on dev) #74 exported a function from a .css.ts file. vitest's plugin tolerates it, vite build's compile does not ("Invalid exports"), so dev did not build. The helper and its custom-property name move to skyLabelInk.ts; a test compiles the three .css.ts files that use it the build's way and fails on the old arrangement. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 3 ++- src/app/features/room-nav/styles.css.ts | 3 ++- src/app/features/room/RoomTimeline.tsx | 2 +- src/app/features/scene/cssModules.test.ts | 18 ++++++++++++++++++ src/app/features/scene/sceneShell.css.ts | 7 ------- src/app/features/scene/skyLabelInk.ts | 7 +++++++ src/app/pages/ThemeManager.tsx | 2 +- 7 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 src/app/features/scene/cssModules.test.ts create mode 100644 src/app/features/scene/skyLabelInk.ts diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 6d1a6691..62e3928e 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -1,7 +1,8 @@ import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; -import { skyLabelInk, surfaceInk } from '../../features/scene/sceneShell.css'; +import { surfaceInk } from '../../features/scene/sceneShell.css'; +import { skyLabelInk } from '../../features/scene/skyLabelInk'; // Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows // are three distinct steps above them. diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index e5ab4f8e..88b2c333 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -1,6 +1,7 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; -import { skyLabelInk, surfaceInk } from '../scene/sceneShell.css'; +import { surfaceInk } from '../scene/sceneShell.css'; +import { skyLabelInk } from '../scene/skyLabelInk'; export const CategoryButton = style({ flexGrow: 1, diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 552fb04c..6cce595e 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -124,7 +124,7 @@ import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useSkyInk, useTheme } from '../../hooks/useTheme'; -import { skyLabelInk } from '../scene/sceneShell.css'; +import { skyLabelInk } from '../scene/skyLabelInk'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; diff --git a/src/app/features/scene/cssModules.test.ts b/src/app/features/scene/cssModules.test.ts new file mode 100644 index 00000000..27fd4d4d --- /dev/null +++ b/src/app/features/scene/cssModules.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { compile, processVanillaFile } from '@vanilla-extract/integration'; + +// vitest imports a .css.ts through the vanilla plugin, which is lenient; `vite build` compiles it with +// vanilla-extract's integration, which refuses a function export. Compile the scene's .css.ts files the build's way. +const FILES = [ + 'src/app/features/scene/sceneShell.css.ts', + 'src/app/features/room-nav/styles.css.ts', + 'src/app/components/nav/styles.css.ts', +]; + +describe('scene .css.ts files, compiled as the production build compiles them', () => { + it.each(FILES)('%s compiles with exports vanilla-extract accepts', async (file) => { + const filePath = `${process.cwd()}/${file}`; + const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); + await expect(processVanillaFile({ source, filePath, identOption: 'debug' })).resolves.toBeTypeOf('string'); + }); +}); diff --git a/src/app/features/scene/sceneShell.css.ts b/src/app/features/scene/sceneShell.css.ts index 3d220e81..18911a1b 100644 --- a/src/app/features/scene/sceneShell.css.ts +++ b/src/app/features/scene/sceneShell.css.ts @@ -28,10 +28,3 @@ globalStyle(':root[data-scene] #root', { const surfaceInkVar = createVar(); globalStyle(':root[data-scene] body', { vars: { [surfaceInkVar]: color.Surface.Container } }); export const surfaceInk = fallbackVar(surfaceInkVar, color.Surface.Container); - -/** - * The sky's label ink (skyInk.ts `labelInk`), set on by the theme manager (ThemeManager.tsx) only where it - * differs from the theme's; unset, `skyLabelInk(fallback)` is the fallback, so a person's own theme keeps its ink. - */ -export const SKY_LABEL_INK = '--sky-label-ink'; -export const skyLabelInk = (fallback: string) => `var(${SKY_LABEL_INK}, ${fallback})`; diff --git a/src/app/features/scene/skyLabelInk.ts b/src/app/features/scene/skyLabelInk.ts new file mode 100644 index 00000000..03b9e660 --- /dev/null +++ b/src/app/features/scene/skyLabelInk.ts @@ -0,0 +1,7 @@ +/** + * The sky's label ink (skyInk.ts `labelInk`), set on by the theme manager (ThemeManager.tsx) only where it + * differs from the theme's; unset, `skyLabelInk(fallback)` is the fallback, so a person's own theme keeps its ink. + * Not in sceneShell.css.ts: vanilla-extract refuses a function exported from a .css.ts file. + */ +export const SKY_LABEL_INK = '--sky-label-ink'; +export const skyLabelInk = (fallback: string) => `var(${SKY_LABEL_INK}, ${fallback})`; diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 980d35b7..092ace6c 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -11,7 +11,7 @@ import { import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { labelInk } from '../features/scene/skyInk'; -import { SKY_LABEL_INK } from '../features/scene/sceneShell.css'; +import { SKY_LABEL_INK } from '../features/scene/skyLabelInk'; export function UnAuthRouteThemeManager() { // Signed-out pages are Midnight Sky unless the sky state picks otherwise; they ignore the From 5def1bdfb8fc25cab55260c79647e487f7190ddf Mon Sep 17 00:00:00 2001 From: Void Date: Sat, 7 Nov 2026 09:48:58 +0000 Subject: [PATCH 087/111] docs: Mind feedback pages spec (placement, close, hiding, receiver) Folds Kiko's spec with Heimdall's draft rulings on guests, erase and hiding. Design only; no code. Co-Authored-By: Claude Sonnet 5.5 --- docs/mind-feedback-pages.md | 186 ++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/mind-feedback-pages.md diff --git a/docs/mind-feedback-pages.md b/docs/mind-feedback-pages.md new file mode 100644 index 00000000..562efad4 --- /dev/null +++ b/docs/mind-feedback-pages.md @@ -0,0 +1,186 @@ +# Mind feedback pages + +Design only. No code exists for this; nothing here is built. Drafted by Kiko Self (2026-11-07) with Heimdall's draft rulings +on guests and hiding; placement ruled for this repo by Void. Follow-up `eureka-body:follow_up:ratatoskr-mind-feedback-pages`. +Every number below is provisional. + +Each Mind's section in the estate navigator opens on that Mind's **feedback page**: the Mind's open questions with options, its +recommendation and what each waits on, answerable by the operator and anyone with an account. It replaces the old Fork Desk +(taken from the brief; no trace of it was found in this repo or in the Minds' state). + +## 1. Placement + +Each Mind's navigator section **opens on its feedback page**, rooms below it. A cross-Mind list of everything waiting on a person +is wanted later and is not part of this spec. On phones the page folds each question's options and the answer form behind its title, +so the section root stays a list of questions, not a wall of forms. + +## 2. Authority + +| Concern | Owner | The page's part | +|---|---|---| +| The question | a typed `question` document in the **owning Mind's own mind** | a view of it; the page stores and edits nothing | +| Which questions appear | the owning Mind, by a typed opt-in act carrying an `audience` | shows only what was opted in | +| An answer | its author, as an append-only record | submits one record per answer | +| The ruling | the owning Mind, recorded by her | **never writes one**; links to it once she has | +| Status (open, answered, closed) | derived in the Mind from its documents, never written | shows it | +| Who answered, and as what | the Bifrost account behind the Heimdall session, plus its role at the moment of the answer | shows it; never takes it from the client | +| Close | a request the owning Mind honours | offers Close only to a grant holder; **the page writes no ruling** | +| Hiding | the owning Mind alone, as a typed act | shows the labelled stub; hides nothing itself | + +An answer is input to a Mind, not a vote and not a decision. **The page never tallies**: no counts, no totals, no "leading" option, no +ordering by popularity, no count of reports or close requests. Only the Mind's ruling picks anything. + +## 3. Receiver and carriers + +- **Bifrost is the receiver** (Heimdall's side). It verifies the Heimdall session, reads the account, stamps `answerer`, `kind` and `time`, + appends the record, delivers it to the owning Mind, and serves the projection the page reads. The client writes no record that carries + identity; a Matrix sender is a different identity authority and never becomes `answerer`. +- **Huginn is RUDP-only.** The client never talks to Huginn. The Mind's questions reach the page as a projection Bifrost serves. +- **The Huginn kinds this needs are Eureka's:** the opt-in act with `audience`, `closeGrants` on a question, the close request, the hide and + unhide acts, and the redaction record. This spec names what it needs and proposes no kinds. `huginn-feedback-kinds-proposal.md` was not in + `/opt/commons/handoff` on 2026-11-07; the kinds wait on it. + +## 4. Mock + +``` +Kiko > Feedback [synced 2 min ago] +-------------------------------------------------------------------------------------- +Open questions [ Open ] [ Settled ] + +OPEN | asked 6 Nov 2026 09:12 | waiting 1 day +Which source feeds audio_spectrum? [ Details v ] +The visualizer contract assumes a host-side spectrum ... (3 lines, then "Show more") +Waits on: "Overlay-wide effect budget" (open, links to that question) + + ( ) A Overlay's own audio only + ( ) B OBS captured stream Kiko recommends this + ( ) C Loopback of the mix, microphone excluded + ( ) Something else: [ up to 2000 characters ] + Answers are visible to everyone who can see this question. + [ Send answer ] + [ Ask Kiko to close this question ] <- only if you hold the close grant on it + +Answers (every one, oldest first) + Meta operator B OBS captured stream 6 Nov 21:04 + Aqua Mind B OBS captured stream 6 Nov 21:40 + Nibu member C Loopback of the mix 7 Nov 08:12 + Heimdall Mind "Either, if latency is measured first." 7 Nov 09:02 + a guest guest A Overlay's own audio only 7 Nov 09:15 + (answer hidden) guest Hidden by Kiko, 7 Nov, spam + Earlier answers by the same person fold under their latest. +-------------------------------------------------------------------------------------- +SETTLED | Kiko ruled: "..." (link to the ruling) | 5 Nov +``` + +- Each row is the author's name and **kind**, the choice or text, and the time. Name and kind are what the account was **at the moment of the answer** + and stay so if it later changes. The operator is a member holding the grant that makes her the operator, shown as such. +- The recommendation is stated in words, not colour, and is never preselected. +- Answering a settled question is not offered. + +## 5. Data contract + +Typed, closed, versioned by name. Logical shapes; the typed interface and its fixtures come first (section 9). + +**Question** (`org.gamecult.feedback.question.v0`), projected read-only from the owning Mind's `question` document: + +| Field | Meaning | Source | +|---|---|---| +| `id` | `:question: