From 05948689b4865af1e8ab4a535f11044ee4d86bbf Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 23:08:48 +0000 Subject: [PATCH 001/143] Scene: a throwing ray-march createScene goes to onCompileFail create() called the ray-march createScene after the try, so a throw while building it escaped onCompileFail and the CSS ground was never kept. The call now sits inside the try with the other styles. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 15 +++++++++++++++ src/app/features/scene/index.ts | 11 ++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 23c6b297..b6fcf2a7 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -239,4 +239,19 @@ describe('startBackgroundScene follows the sky state', () => { await flush(); expect(createScene).toHaveBeenCalledOnce(); }); + + it('keeps the ground and reports a ray-march scene that throws while being created', async () => { + const boom = new Error('shader link failed'); + createScene.mockImplementation(() => { + throw boom; + }); + const onCompileFail = vi.fn(); + const scene = await startBackgroundScene({ ...config, style: 'raymarch' }, caps, { + canvas, + onCompileFail, + }); + expect(scene).toBeNull(); + expect(onCompileFail).toHaveBeenCalledOnce(); + expect(onCompileFail).toHaveBeenCalledWith(boom); + }); }); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index bc041672..4e1db231 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -21,12 +21,11 @@ async function create( options: StartOptions & { tier: SceneTier; debug?: boolean }, state: SkyState ): 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') { + if (config?.style === 'raymarch') return (await import('./Scene')).createScene(options); + if (config?.style === 'terrain') { const { createTerrainScene } = await import('./terrain/Terrain'); const sky = terrainSkyFor(state); const scene = createTerrainScene({ ...options, ...sky }); @@ -34,15 +33,13 @@ async function create( 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) }); } + 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); } /** From 764ee441bd3a19f8fa4f565824f7519653e4fa37 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 01:31:26 +0000 Subject: [PATCH 002/143] auth: the register entry takes https only, http only for a local host (Heimdall) registrationEntry accepts an https url, or http when the host is localhost, *.localhost, 127.0.0.1 or ::1. A test pins both sides, including a look-alike host. The two tests that pass config.json as a ClientConfig now cast it, so tsc is clean for them. Co-Authored-By: Claude Sonnet 5.5 --- src/app/hooks/useClientConfig.test.ts | 4 ++-- src/app/pages/auth/registrationEntry.test.ts | 12 +++++++++++- src/app/pages/auth/registrationEntry.ts | 13 ++++++++++--- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/useClientConfig.test.ts b/src/app/hooks/useClientConfig.test.ts index a2004d2f..440ba15c 100644 --- a/src/app/hooks/useClientConfig.test.ts +++ b/src/app/hooks/useClientConfig.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { clientDefaultServer } from './useClientConfig'; +import { ClientConfig, clientDefaultServer } from './useClientConfig'; import config from '../../../config.json'; describe('default homeserver', () => { @@ -15,6 +15,6 @@ describe('default homeserver', () => { }); it('is gamecult.org in the repo config.json too', () => { - expect(clientDefaultServer(config)).toBe('gamecult.org'); + expect(clientDefaultServer(config as ClientConfig)).toBe('gamecult.org'); }); }); diff --git a/src/app/pages/auth/registrationEntry.test.ts b/src/app/pages/auth/registrationEntry.test.ts index 3af32faa..53b15fb7 100644 --- a/src/app/pages/auth/registrationEntry.test.ts +++ b/src/app/pages/auth/registrationEntry.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { registrationEntry } from './registrationEntry'; +import type { ClientConfig } from '../../hooks/useClientConfig'; import { PREVIEW_NOTICE_TEXT, TRAINING_NOTICE_TEXT, @@ -17,7 +18,7 @@ describe('registration entry', () => { }); it('ships off in config.json', () => { - expect(registrationEntry(config)).toBeUndefined(); + expect(registrationEntry(config as ClientConfig)).toBeUndefined(); expect(config.registration.enabled).toBe(false); }); @@ -44,4 +45,13 @@ describe('registration entry', () => { const entry = registrationEntry({ previewDemo: true, registration: { enabled: true, url } }); expect(entry?.notice).toBe(PREVIEW_NOTICE_TEXT); }); + + it('accepts http only for a local development host', () => { + const on = (u: string) => registrationEntry({ registration: { enabled: true, url: u } }); + expect(on('http://localhost:8080/register')?.url).toBe('http://localhost:8080/register'); + expect(on('http://127.0.0.1:8080/register')).toBeDefined(); + expect(on('http://accounts.example/register')).toBeUndefined(); + expect(on('http://localhost.evil.example/register')).toBeUndefined(); + expect(on('https://accounts.example/register')).toBeDefined(); + }); }); diff --git a/src/app/pages/auth/registrationEntry.ts b/src/app/pages/auth/registrationEntry.ts index 3049403e..6c65732e 100644 --- a/src/app/pages/auth/registrationEntry.ts +++ b/src/app/pages/auth/registrationEntry.ts @@ -3,17 +3,24 @@ import { TRAINING_NOTICE_VERSION, trainingNoticeText } from '../../utils/trainin export type RegistrationEntry = { url: string; notice: string; noticeVersion: number }; +// Plain http is for a developer's own machine only; a stranger's sign-up page is https. +const isLocalHost = (hostname: string): boolean => + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]'; + /** * The sign-up entry the sign-in page may show: the account service's registration page, when the - * switch is on and the configured url is a plain http(s) address. Anything else is no entry. The + * switch is on and the configured url is an https address (http only for localhost). Anything else is no entry. The * consent sentence and its version are trainingNotice.ts's, the ones a room shows. */ export const registrationEntry = (config: ClientConfig): RegistrationEntry | undefined => { const { enabled, url } = config.registration ?? {}; if (enabled !== true || typeof url !== 'string') return undefined; try { - const { protocol } = new URL(url); - if (protocol !== 'https:' && protocol !== 'http:') return undefined; + const { protocol, hostname } = new URL(url); + if (protocol !== 'https:' && !(protocol === 'http:' && isLocalHost(hostname))) return undefined; } catch { return undefined; } From f8115609b22fc02469f8985af19809b2dc7d5abb Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 01:31:35 +0000 Subject: [PATCH 003/143] Register entry: https only, http only for a local host (#87) --- src/app/hooks/useClientConfig.test.ts | 4 ++-- src/app/pages/auth/registrationEntry.test.ts | 12 +++++++++++- src/app/pages/auth/registrationEntry.ts | 13 ++++++++++--- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/useClientConfig.test.ts b/src/app/hooks/useClientConfig.test.ts index a2004d2f..440ba15c 100644 --- a/src/app/hooks/useClientConfig.test.ts +++ b/src/app/hooks/useClientConfig.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { clientDefaultServer } from './useClientConfig'; +import { ClientConfig, clientDefaultServer } from './useClientConfig'; import config from '../../../config.json'; describe('default homeserver', () => { @@ -15,6 +15,6 @@ describe('default homeserver', () => { }); it('is gamecult.org in the repo config.json too', () => { - expect(clientDefaultServer(config)).toBe('gamecult.org'); + expect(clientDefaultServer(config as ClientConfig)).toBe('gamecult.org'); }); }); diff --git a/src/app/pages/auth/registrationEntry.test.ts b/src/app/pages/auth/registrationEntry.test.ts index 3af32faa..53b15fb7 100644 --- a/src/app/pages/auth/registrationEntry.test.ts +++ b/src/app/pages/auth/registrationEntry.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { registrationEntry } from './registrationEntry'; +import type { ClientConfig } from '../../hooks/useClientConfig'; import { PREVIEW_NOTICE_TEXT, TRAINING_NOTICE_TEXT, @@ -17,7 +18,7 @@ describe('registration entry', () => { }); it('ships off in config.json', () => { - expect(registrationEntry(config)).toBeUndefined(); + expect(registrationEntry(config as ClientConfig)).toBeUndefined(); expect(config.registration.enabled).toBe(false); }); @@ -44,4 +45,13 @@ describe('registration entry', () => { const entry = registrationEntry({ previewDemo: true, registration: { enabled: true, url } }); expect(entry?.notice).toBe(PREVIEW_NOTICE_TEXT); }); + + it('accepts http only for a local development host', () => { + const on = (u: string) => registrationEntry({ registration: { enabled: true, url: u } }); + expect(on('http://localhost:8080/register')?.url).toBe('http://localhost:8080/register'); + expect(on('http://127.0.0.1:8080/register')).toBeDefined(); + expect(on('http://accounts.example/register')).toBeUndefined(); + expect(on('http://localhost.evil.example/register')).toBeUndefined(); + expect(on('https://accounts.example/register')).toBeDefined(); + }); }); diff --git a/src/app/pages/auth/registrationEntry.ts b/src/app/pages/auth/registrationEntry.ts index 3049403e..6c65732e 100644 --- a/src/app/pages/auth/registrationEntry.ts +++ b/src/app/pages/auth/registrationEntry.ts @@ -3,17 +3,24 @@ import { TRAINING_NOTICE_VERSION, trainingNoticeText } from '../../utils/trainin export type RegistrationEntry = { url: string; notice: string; noticeVersion: number }; +// Plain http is for a developer's own machine only; a stranger's sign-up page is https. +const isLocalHost = (hostname: string): boolean => + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]'; + /** * The sign-up entry the sign-in page may show: the account service's registration page, when the - * switch is on and the configured url is a plain http(s) address. Anything else is no entry. The + * switch is on and the configured url is an https address (http only for localhost). Anything else is no entry. The * consent sentence and its version are trainingNotice.ts's, the ones a room shows. */ export const registrationEntry = (config: ClientConfig): RegistrationEntry | undefined => { const { enabled, url } = config.registration ?? {}; if (enabled !== true || typeof url !== 'string') return undefined; try { - const { protocol } = new URL(url); - if (protocol !== 'https:' && protocol !== 'http:') return undefined; + const { protocol, hostname } = new URL(url); + if (protocol !== 'https:' && !(protocol === 'http:' && isLocalHost(hostname))) return undefined; } catch { return undefined; } From 1be9e76012bf01dc846546980603bf90b9b169a6 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 12:09:46 +0000 Subject: [PATCH 004/143] Bubble pose follows message sentiment when the event carries no pose label Co-Authored-By: Claude Sonnet 5.5 --- .../components/message/pose/sentiment.test.ts | 42 +++++++++++++++++++ src/app/components/message/pose/sentiment.ts | 34 +++++++++++++++ src/app/features/room/message/Message.tsx | 5 ++- 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 src/app/components/message/pose/sentiment.test.ts create mode 100644 src/app/components/message/pose/sentiment.ts diff --git a/src/app/components/message/pose/sentiment.test.ts b/src/app/components/message/pose/sentiment.test.ts new file mode 100644 index 00000000..2897bc49 --- /dev/null +++ b/src/app/components/message/pose/sentiment.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { classifyPose, messagePose } from './sentiment'; + +describe('classifyPose', () => { + it.each([ + ['concerned', 'Sorry, that build failed with an error'], + ['annoyed', 'ugh, this again'], + ['surprised', 'wow, no way'], + ['determined', "on it, I'll fix that"], + ['amused', 'hahaha that is funny'], + ['happy', 'thanks, great work'], + ['thinking', 'should we ship it?'], + ['neutral', 'Merged the branch.'], + ])('%s', (label, text) => expect(classifyPose(text)).toBe(label)); + it('is neutral for empty or non-string text', () => { + expect(classifyPose('')).toBe('neutral'); + expect(classifyPose(undefined)).toBe('neutral'); + expect(classifyPose(5)).toBe('neutral'); + }); + it('is deterministic', () => { + expect(classifyPose('thanks?')).toBe(classifyPose('thanks?')); + }); +}); + +describe('messagePose', () => { + it('lets an explicit label win over the text', () => { + expect(messagePose('aqua', { body: 'thanks!', 'org.gamecult.pose': 'annoyed' })).toBe( + 'annoyed' + ); + }); + it('keeps an unknown explicit label neutral instead of classifying', () => { + expect(messagePose('aqua', { body: 'thanks!', 'org.gamecult.pose': 'conduct' })).toBe( + 'neutral' + ); + }); + it('classifies when there is no label, and drops labels the Mind lacks', () => { + expect(messagePose('aqua', { body: 'thanks!' })).toBe('happy'); + expect(messagePose('aqua', { body: 'sorry' })).toBe('concerned'); + expect(messagePose('kiko', { body: 'sorry' })).toBe('neutral'); + expect(messagePose('aqua', {})).toBe('neutral'); + }); +}); diff --git a/src/app/components/message/pose/sentiment.ts b/src/app/components/message/pose/sentiment.ts new file mode 100644 index 00000000..096c4957 --- /dev/null +++ b/src/app/components/message/pose/sentiment.ts @@ -0,0 +1,34 @@ +import { POSE_KEY, NEUTRAL, resolvePose } from './poses'; + +// Local sentiment -> pose label. A lexicon over the message text, no network, no model: the +// same text always gives the same pose. Rules are tried in order and the first hit wins, so a +// message that says "sorry" and "thanks" reads as concerned. Labels outside a Mind's art fall +// back to neutral in resolvePose, so 'concerned' and 'determined' need no per-Mind table. +const RULES: [string, RegExp][] = [ + ['concerned', /\b(errors?|sorry|apolog\w+|oops|fail(ed|s|ure)?|broke(n)?|crash\w*|bug)\b/i], + ['annoyed', /\b(annoy\w*|ugh+|argh+|frustrat\w*|again\?|why won'?t|stop it)\b/i], + ['surprised', /\b(wow|whoa|woah|omg|no way|unexpected\w*|surpris\w*|wait,? what)\b|[?!]{2,}/i], + [ + 'determined', + /\b(let'?s (go|do|fix|ship)|on it|i'?ll (fix|do|handle)|will do|fixing|resolved?)\b/i, + ], + ['amused', /\b(ha(ha)+|lol|lmao|rofl|funny|hilarious)\b|😂|🤣/i], + [ + 'happy', + /\b(thanks?|thank you|thx|great|awesome|nice|love|perfect|excellent|yay|congrats)\b|🎉|😊/i, + ], + ['thinking', /\?\s*$|\b(hmm+|maybe|perhaps|i wonder|not sure|how (do|does|about)|what if)\b/i], +]; + +export const classifyPose = (text: unknown): string => { + if (typeof text !== 'string') return NEUTRAL; + const hit = RULES.find(([, re]) => re.test(text)); + return hit ? hit[0] : NEUTRAL; +}; + +// An explicit org.gamecult.pose label always wins, even when it is unknown (then neutral). +// Only events with no label are read for sentiment. +export const messagePose = (mind: string, content: Record): string => + content[POSE_KEY] !== undefined + ? resolvePose(mind, content[POSE_KEY]) + : resolvePose(mind, classifyPose(content.body)); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 281d4d78..4b3d436f 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -61,7 +61,8 @@ import { import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings'; import { useSetting } from '../../../state/hooks/settings'; import { PoseAvatar } from '../../../components/message/pose/PoseAvatar'; -import { POSE_KEY, MINDS, mindOfSender, resolvePose } from '../../../components/message/pose/poses'; +import { MINDS, mindOfSender } from '../../../components/message/pose/poses'; +import { messagePose } from '../../../components/message/pose/sentiment'; import { useEntranceDelay } from '../../../components/message/pose/entrance'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; @@ -835,7 +836,7 @@ export const Message = as<'div', MessageProps>( const poseJSX = !collapse && mind && ( Date: Wed, 11 Nov 2026 12:18:31 +0000 Subject: [PATCH 005/143] Classifier outputs only the shared pose set Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/pose/sentiment.test.ts | 6 ++---- src/app/components/message/pose/sentiment.ts | 7 ++++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/app/components/message/pose/sentiment.test.ts b/src/app/components/message/pose/sentiment.test.ts index 2897bc49..bc4a8c28 100644 --- a/src/app/components/message/pose/sentiment.test.ts +++ b/src/app/components/message/pose/sentiment.test.ts @@ -3,14 +3,13 @@ import { classifyPose, messagePose } from './sentiment'; describe('classifyPose', () => { it.each([ - ['concerned', 'Sorry, that build failed with an error'], ['annoyed', 'ugh, this again'], ['surprised', 'wow, no way'], - ['determined', "on it, I'll fix that"], ['amused', 'hahaha that is funny'], ['happy', 'thanks, great work'], ['thinking', 'should we ship it?'], ['neutral', 'Merged the branch.'], + ['neutral', 'sorry, that failed with an error'], ])('%s', (label, text) => expect(classifyPose(text)).toBe(label)); it('is neutral for empty or non-string text', () => { expect(classifyPose('')).toBe('neutral'); @@ -33,9 +32,8 @@ describe('messagePose', () => { 'neutral' ); }); - it('classifies when there is no label, and drops labels the Mind lacks', () => { + it('classifies when there is no label, and never outputs a signature label', () => { expect(messagePose('aqua', { body: 'thanks!' })).toBe('happy'); - expect(messagePose('aqua', { body: 'sorry' })).toBe('concerned'); expect(messagePose('kiko', { body: 'sorry' })).toBe('neutral'); expect(messagePose('aqua', {})).toBe('neutral'); }); diff --git a/src/app/components/message/pose/sentiment.ts b/src/app/components/message/pose/sentiment.ts index 096c4957..bd685a07 100644 --- a/src/app/components/message/pose/sentiment.ts +++ b/src/app/components/message/pose/sentiment.ts @@ -1,11 +1,12 @@ import { POSE_KEY, NEUTRAL, resolvePose } from './poses'; +// catalogue.json beside this file is a vendored copy of /opt/commons/cast-art/poses/catalogue.json +// (npm run sync:poses); nothing else publishes it to the browser. // Local sentiment -> pose label. A lexicon over the message text, no network, no model: the // same text always gives the same pose. Rules are tried in order and the first hit wins, so a -// message that says "sorry" and "thanks" reads as concerned. Labels outside a Mind's art fall -// back to neutral in resolvePose, so 'concerned' and 'determined' need no per-Mind table. +// message that says "wow" and "thanks" reads as surprised. Only the shared labels (identical art +// for all eight Minds, catalogue.json) are ever output; signature labels are explicit-only. const RULES: [string, RegExp][] = [ - ['concerned', /\b(errors?|sorry|apolog\w+|oops|fail(ed|s|ure)?|broke(n)?|crash\w*|bug)\b/i], ['annoyed', /\b(annoy\w*|ugh+|argh+|frustrat\w*|again\?|why won'?t|stop it)\b/i], ['surprised', /\b(wow|whoa|woah|omg|no way|unexpected\w*|surpris\w*|wait,? what)\b|[?!]{2,}/i], [ From 90250ca0764c55b256cd6c80404e429eff89485c Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 15:42:15 +0000 Subject: [PATCH 006/143] Default homeserver is chat.gamecult.org: gamecult.org serves no .well-known/matrix/client, so discovery 404s Co-Authored-By: Claude Sonnet 5.5 --- config.json | 1 + src/app/hooks/useClientConfig.test.ts | 10 +++++----- src/app/hooks/useClientConfig.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/config.json b/config.json index 0ebdf7c3..90ed6dfb 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,7 @@ { "defaultHomeserver": 0, "homeserverList": [ + "chat.gamecult.org", "gamecult.org", "converser.eu", "matrix.org", diff --git a/src/app/hooks/useClientConfig.test.ts b/src/app/hooks/useClientConfig.test.ts index 440ba15c..bafbdbf2 100644 --- a/src/app/hooks/useClientConfig.test.ts +++ b/src/app/hooks/useClientConfig.test.ts @@ -3,9 +3,9 @@ import { ClientConfig, clientDefaultServer } from './useClientConfig'; import config from '../../../config.json'; describe('default homeserver', () => { - it('is gamecult.org when the config names none', () => { - expect(clientDefaultServer({})).toBe('gamecult.org'); - expect(clientDefaultServer({ homeserverList: [] })).toBe('gamecult.org'); + it('is chat.gamecult.org when the config names none', () => { + expect(clientDefaultServer({})).toBe('chat.gamecult.org'); + expect(clientDefaultServer({ homeserverList: [] })).toBe('chat.gamecult.org'); }); it('is the listed server the config points at', () => { @@ -14,7 +14,7 @@ describe('default homeserver', () => { ).toBe('chat.gamecult.org'); }); - it('is gamecult.org in the repo config.json too', () => { - expect(clientDefaultServer(config as ClientConfig)).toBe('gamecult.org'); + it('is chat.gamecult.org in the repo config.json too', () => { + expect(clientDefaultServer(config as ClientConfig)).toBe('chat.gamecult.org'); }); }); diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index feb38e0a..7f0899be 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -53,7 +53,7 @@ export function useClientConfig(): ClientConfig { } export const clientDefaultServer = (clientConfig: ClientConfig): string => - clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'gamecult.org'; + clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'chat.gamecult.org'; export const clientAllowedServer = (clientConfig: ClientConfig, server: string): boolean => { const { homeserverList, allowCustomHomeservers } = clientConfig; From abb1ee988ce16994e6994a59c220d403008d234f Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 16:00:45 +0000 Subject: [PATCH 007/143] Background scene on by default in the repo config (tier full; the device ceiling still lowers it) Co-Authored-By: Claude Sonnet 5.5 --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index 90ed6dfb..16955527 100644 --- a/config.json +++ b/config.json @@ -27,7 +27,7 @@ }, "backgroundScene": { - "tier": "off" + "tier": "full" }, "dings": { From 568d6fd4ab8ce6eb6a846da01da30aa7378c4cb4 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 16:12:02 +0000 Subject: [PATCH 008/143] Navigator: one gutter column for chevron, room icon and avatar; one constant step per nesting level Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/NavGutter.tsx | 12 ++++ src/app/components/nav/index.ts | 1 + src/app/components/nav/styles.css.ts | 28 ++++++++ src/app/features/estate/EstateNavigator.tsx | 46 +++++++++---- .../room-nav/RoomNavCategoryButton.tsx | 13 ++-- src/app/features/room-nav/RoomNavItem.tsx | 66 ++++++++++--------- src/app/features/room-nav/styles.css.ts | 8 ++- 7 files changed, 123 insertions(+), 51 deletions(-) create mode 100644 src/app/components/nav/NavGutter.tsx diff --git a/src/app/components/nav/NavGutter.tsx b/src/app/components/nav/NavGutter.tsx new file mode 100644 index 00000000..c0a6e50e --- /dev/null +++ b/src/app/components/nav/NavGutter.tsx @@ -0,0 +1,12 @@ +import React, { ReactNode } from 'react'; +import * as css from './styles.css'; + +/** The fixed-width first column of a navigator row or section header; its content is centred in it. */ +export function NavGutter({ children }: { children: ReactNode }) { + return {children}; +} + +/** One nesting step: everything inside is indented by the same constant. */ +export function NavLevel({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/src/app/components/nav/index.ts b/src/app/components/nav/index.ts index 12267446..2ed29601 100644 --- a/src/app/components/nav/index.ts +++ b/src/app/components/nav/index.ts @@ -4,3 +4,4 @@ export * from './NavEmptyLayout'; export * from './NavItem'; export * from './NavItemContent'; export * from './NavItemOptions'; +export * from './NavGutter'; diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 62e3928e..34dc81c0 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -13,6 +13,29 @@ const SCRIM_HOVER = '14%'; const SCRIM_SELECTED = '22%'; const SCRIM_PRESSED = '48%'; +// The navigator's one column grid. Every row, section header and join row begins with a gutter of +// the same width, with its chevron, room icon or avatar centred in it, so every label starts at +// the same x for its depth; each nesting level (NavLevel) indents by one constant step. +const GUTTER = toRem(32); +const LEVEL_STEP = toRem(16); + +export const NavGutter = style({ + width: GUTTER, + minWidth: GUTTER, + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + +export const NavLevel = style({ + paddingLeft: LEVEL_STEP, +}); + +// Chevrons and room icons share one resting weight, so a foldout is not outweighed by its rooms. +export const NAV_ICON_REST_OPACITY = 0.6; +export const NAV_ICON_UNREAD_OPACITY = 0.9; + export const NavCategory = style([ DefaultReset, { @@ -22,6 +45,11 @@ export const NavCategory = style([ export const NavCategoryHeader = style({ gap: config.space.S100, + selectors: { + // The header's own side padding would shift its gutter off the rows': the section button + // carries the same left inset as NavItemContent instead. + '&&': { paddingLeft: 0 }, + }, '@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). diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 27fcfbad..8066498d 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -1,16 +1,18 @@ import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react'; 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 { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; import { NavButton, NavCategory, NavCategoryHeader, + NavGutter, NavItem, NavItemContent, + NavLevel, } from '../../components/nav'; -import { NavAction, NavLabel } from '../../components/nav/styles.css'; +import { NAV_ICON_REST_OPACITY, 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'; @@ -77,12 +79,14 @@ function JoinRow({ room }: { room: EstateRoom }) { return ( - + - - - + + + + + {room.name} @@ -166,7 +170,7 @@ function Collapsible({ categoryId, label, rooms, trailing, children }: Collapsib {trailing} - {!closed && children} + {!closed && {children}} ); } @@ -206,7 +210,13 @@ function Browse({ tierId, groups }: { tierId: string; groups: EstateGroup[] }) { {`Browse ${count} more ${count === 1 ? 'room' : 'rooms'}`} - {open && groups.map((g) => )} + {open && ( + + {groups.map((g) => ( + + ))} + + )} ); } @@ -235,9 +245,11 @@ function EntryPoints() { navigate(getHomeCreatePath())}> - - - + + + + + Create Room @@ -252,9 +264,15 @@ function EntryPoints() { setOpen(true)}> - - - + + + + + Join with Address diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx index e232b856..74945be3 100644 --- a/src/app/features/room-nav/RoomNavCategoryButton.tsx +++ b/src/app/features/room-nav/RoomNavCategoryButton.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { as, Chip, Icon, Icons, Text } from 'folds'; import classNames from 'classnames'; +import { NavGutter } from '../../components/nav'; import * as css from './styles.css'; export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( @@ -9,17 +10,17 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( className={classNames(css.CategoryButton, className)} variant="Background" radii="Pill" - before={ + aria-expanded={closed === undefined ? undefined : !closed} + {...props} + ref={ref} + > + - } - aria-expanded={closed === undefined ? undefined : !closed} - {...props} - ref={ref} - > + {children} diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index ff23930e..48531a19 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -20,8 +20,12 @@ import { 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 { NavGutter, NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; +import { + NAV_ICON_REST_OPACITY, + NAV_ICON_UNREAD_OPACITY, + 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'; @@ -330,34 +334,36 @@ export function RoomNavItem({ - - {showAvatar ? ( - ( - - {nameInitials(roomName)} - - )} - /> - ) : ( - - )} - + + + {showAvatar ? ( + ( + + {nameInitials(roomName)} + + )} + /> + ) : ( + + )} + + Date: Wed, 11 Nov 2026 16:31:25 +0000 Subject: [PATCH 009/143] Docs: the repo is GameCult/Ratatoskr now; the background scene is on in the dev config Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- docs/gamecult/ratatoskr-target.md | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dfc722ad..abeba8b2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ and your own place in it) instead of an invite list. Mobile first. **Live now:** the Cinny engine (matrix-js-sdk, Rust crypto, sync) under a Ratatoskr UI: branding, GameCult theme, estate navigation, pose portraits -(opt-in setting), a background scene and soundscape (opt-in, off by default). +(opt-in setting), a background scene (on in the dev config, `backgroundScene.tier`; the device can lower it) and an opt-in soundscape. **Planned:** see [`docs/gamecult/ratatoskr-target.md`](docs/gamecult/ratatoskr-target.md) for the target and cut sequence. Internal names that are protocol or stored state (`cinny_*` localStorage keys, the `in.cinny.room.power_level_tags` event diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md index 166c5c72..b56c90f8 100644 --- a/docs/gamecult/ratatoskr-target.md +++ b/docs/gamecult/ratatoskr-target.md @@ -1,7 +1,7 @@ # Ratatoskr: target and prose map Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge -`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence). +`GameCult/Ratatoskr` (renamed from `GameCult/cinny`; the forge redirects the old name). Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it is handed to Void's mind once, by procedure, as soon as that hand-off procedure @@ -346,7 +346,6 @@ Each later cut is one line, with what it waits on. attention document, scoped to room members; waits on Huginn publishing it. - `rename-ratatoskr`: package name, app name and branding, manifest and titles, the `/preview/` path and its deploy, and every in-repo `cinny` - reference that is ours (not upstream engine paths); waits on Void's forge - rename of `GameCult/cinny` to `GameCult/Ratatoskr` - (`eureka-body:ruling:chat-client-named-ratatoskr`), and the campaign's repo - list moves with it. + reference that is ours (not upstream engine paths). The forge rename of + `GameCult/cinny` to `GameCult/Ratatoskr` is done + (`eureka-body:ruling:chat-client-named-ratatoskr`). From a90223437e7eafe56363cd0c156c558e1b446b88 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 16:43:19 +0000 Subject: [PATCH 010/143] Navigator: pin each Mind's Desk and media pages at the top of its foldout Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/desk/DeskEntry.tsx | 25 ++++++++---- src/app/features/desk/DeskView.test.tsx | 16 ++++---- src/app/features/estate/EstateNavigator.tsx | 44 +++++++++++++++------ 3 files changed, 60 insertions(+), 25 deletions(-) diff --git a/src/app/features/desk/DeskEntry.tsx b/src/app/features/desk/DeskEntry.tsx index a36ec8e4..8eab087f 100644 --- a/src/app/features/desk/DeskEntry.tsx +++ b/src/app/features/desk/DeskEntry.tsx @@ -1,16 +1,27 @@ import React from 'react'; -import { Link } from 'react-router-dom'; -import { Text } from 'folds'; +import { Box, Icon, Icons, Text } from 'folds'; +import { NavGutter, NavItem, NavItemContent, NavLink } from '../../components/nav'; +import { NAV_ICON_REST_OPACITY, NavLabel } from '../../components/nav/styles.css'; import { getDeskPath } from '../../pages/pathUtils'; import { deskFor } from './source'; -/** The way into a Mind's desk, set in its navigator group header; nothing at all when nothing is open. */ +/** A Mind's desk, pinned at the top of its navigator group: always there, with the open count when there is one. */ export function DeskEntry({ mind }: { mind: string }) { const open = deskFor(mind)?.open_count ?? 0; - if (open === 0) return null; return ( - - {`Desk · ${open} open`} - + + + + + + + + + {open > 0 ? `Desk · ${open} open` : 'Desk'} + + + + + ); } diff --git a/src/app/features/desk/DeskView.test.tsx b/src/app/features/desk/DeskView.test.tsx index ede59229..ca10ea66 100644 --- a/src/app/features/desk/DeskView.test.tsx +++ b/src/app/features/desk/DeskView.test.tsx @@ -139,12 +139,14 @@ describe('DeskEntry', () => { setDeskSource(() => ({ ...STATES.empty, open_count: 5 })); expect(entry('void')).toContain('Desk · 5 open'); }); - it('is absent at zero open, when nothing is published, and when only settled forks exist', () => { - setDeskSource(() => STATES.empty); - expect(entry('void')).toBe(''); - setDeskSource(() => undefined); - expect(entry('void')).toBe(''); - setDeskSource(() => ({ ...STATES.settled, open_count: 0 })); - expect(entry('void')).toBe(''); + it('is pinned even at zero open, when nothing is published, and when only settled forks exist', () => { + const bare = (html: string) => html.replace(/<[^>]*>/g, ''); + const states = [STATES.empty, undefined, { ...STATES.settled, open_count: 0 }]; + states.forEach((state) => { + setDeskSource(() => state); + const html = entry('void'); + expect(bare(html)).toBe('Desk'); + expect(html).toContain('href="/desk/void"'); + }); }); }); diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 8066498d..c6285bc8 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -29,6 +29,8 @@ import { _RoomSearchParams } from '../../pages/paths'; import { searchModalAtom } from '../../state/searchModal'; import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { isFileZone } from '../albums/isFileZone'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useCallEmbed } from '../../hooks/useCallEmbed'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; @@ -140,11 +142,9 @@ type CollapsibleProps = { categoryId: string; label: string; rooms: EstateRoom[]; - /** Sits in the header beside the label, so a collapsed section still shows it. */ - trailing?: ReactNode; children: ReactNode; }; -function Collapsible({ categoryId, label, rooms, trailing, children }: CollapsibleProps) { +function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) { const mobile = useScreenSizeContext() === ScreenSize.Mobile; const selectedRoomId = useSelectedRoom(); const roomToUnread = useAtomValue(roomToUnreadAtom); @@ -168,22 +168,44 @@ function Collapsible({ categoryId, label, rooms, trailing, children }: Collapsib {label} - {trailing} {!closed && {children}} ); } +/** The Mind's media pages (file-zone rooms) first, then its other rooms in their given order. */ +function useMediaFirst(rooms: EstateRoom[]): EstateRoom[] { + const mx = useMatrixClient(); + const clientConfig = useClientConfig(); + return useMemo(() => { + const isMedia = (r: EstateRoom) => { + const mxRoom = mx.getRoom(r.roomId); + return ( + !!mxRoom && + isFileZone( + { + roomId: r.roomId, + aliases: [mxRoom.getCanonicalAlias(), ...mxRoom.getAltAliases()].filter( + (a): a is string => !!a + ), + createType: mxRoom.currentState.getStateEvents('m.room.create', '')?.getContent().type, + }, + clientConfig + ) + ); + }; + const media = rooms.filter(isMedia); + return [...media, ...rooms.filter((r) => !media.includes(r))]; + }, [mx, clientConfig, rooms]); +} + function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) { + const rooms = useMediaFirst(group.rooms); return ( - } - > - {group.rooms.map((r) => ( + + + {rooms.map((r) => ( ))} From d803cd1cf4bcc534194a7f01d285120d1bfbe58b Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 16:50:32 +0000 Subject: [PATCH 011/143] Navigator: quieter room icons, Join as a text action, calmer unread badge Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 30 +++++++++---------- .../components/unread-badge/UnreadBadge.tsx | 3 +- src/app/features/desk/DeskEntry.tsx | 8 +++-- src/app/features/estate/EstateNavigator.tsx | 14 +++++---- src/app/features/room-nav/RoomNavItem.tsx | 4 +-- 5 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 34dc81c0..d66097af 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -32,8 +32,10 @@ export const NavLevel = style({ paddingLeft: LEVEL_STEP, }); -// Chevrons and room icons share one resting weight, so a foldout is not outweighed by its rooms. +// Chevrons and room icons are matched in weight, so a foldout is not outweighed by its rooms. export const NAV_ICON_REST_OPACITY = 0.6; +// A room's glyph is bigger and denser than the chevron, so it rests lighter to read as the same weight. +export const NAV_ROOM_ICON_REST_OPACITY = 0.42; export const NAV_ICON_UNREAD_OPACITY = 0.9; export const NavCategory = style([ @@ -264,22 +266,18 @@ globalStyle(`:root[data-scene] ${NavItemBase} svg, :root[data-scene] ${NavItemBa }, }); -// The Join action on a phone is a plain underlined word, not a pill. +// The Join action is a plain underlined word on every screen, 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, - }, - }, + selectors: { + '&&': { + backgroundColor: 'transparent', + boxShadow: 'none', + border: 'none', + borderRadius: 0, + minWidth: 0, + textDecoration: 'underline', + textUnderlineOffset: toRem(4), + flexShrink: 0, }, }, }); diff --git a/src/app/components/unread-badge/UnreadBadge.tsx b/src/app/components/unread-badge/UnreadBadge.tsx index f8bfefa3..84eb5ae4 100644 --- a/src/app/components/unread-badge/UnreadBadge.tsx +++ b/src/app/components/unread-badge/UnreadBadge.tsx @@ -22,7 +22,8 @@ export function UnreadBadge({ highlight, count }: UnreadBadgeProps) { 0 ? '400' : '200'} - fill="Solid" + // A mention is the one solid pill; plain unread is a soft tint, so a row's count is never its loudest part. + fill={highlight ? 'Solid' : 'Soft'} radii="Pill" outlined={false} > diff --git a/src/app/features/desk/DeskEntry.tsx b/src/app/features/desk/DeskEntry.tsx index 8eab087f..a0c3244c 100644 --- a/src/app/features/desk/DeskEntry.tsx +++ b/src/app/features/desk/DeskEntry.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, Icon, Icons, Text } from 'folds'; import { NavGutter, NavItem, NavItemContent, NavLink } from '../../components/nav'; -import { NAV_ICON_REST_OPACITY, NavLabel } from '../../components/nav/styles.css'; +import { NAV_ROOM_ICON_REST_OPACITY, NavLabel } from '../../components/nav/styles.css'; import { getDeskPath } from '../../pages/pathUtils'; import { deskFor } from './source'; @@ -14,7 +14,11 @@ export function DeskEntry({ mind }: { mind: string }) { - + {open > 0 ? `Desk · ${open} open` : 'Desk'} diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index c6285bc8..cc98c337 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -12,7 +12,7 @@ import { NavItemContent, NavLevel, } from '../../components/nav'; -import { NAV_ICON_REST_OPACITY, NavAction, NavLabel } from '../../components/nav/styles.css'; +import { NAV_ROOM_ICON_REST_OPACITY, 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'; @@ -86,7 +86,7 @@ function JoinRow({ room }: { room: EstateRoom }) { - + @@ -108,7 +108,7 @@ function JoinRow({ room }: { room: EstateRoom }) { variant={room.state === 'invited' ? 'Primary' : 'Secondary'} fill="Soft" radii="Pill" - before={busy ? : } + before={busy ? : undefined} onClick={join} disabled={busy} aria-label={`Join ${room.name}`} @@ -269,7 +269,11 @@ function EntryPoints() { - + @@ -291,7 +295,7 @@ function EntryPoints() { diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 48531a19..e8a349f0 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -22,7 +22,7 @@ import FocusTrap from 'focus-trap-react'; import { useAtom, useAtomValue } from 'jotai'; import { NavGutter, NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import { - NAV_ICON_REST_OPACITY, + NAV_ROOM_ICON_REST_OPACITY, NAV_ICON_UNREAD_OPACITY, NavLabel, } from '../../components/nav/styles.css'; @@ -354,7 +354,7 @@ export function RoomNavItem({ ) : ( Date: Wed, 11 Nov 2026 17:00:29 +0000 Subject: [PATCH 012/143] Navigator: a Media row after Desk in every Mind foldout, from the #-files alias or a carried room id Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 57 +++++++------ src/app/features/estate/MediaRow.test.tsx | 29 +++++++ src/app/features/estate/MediaRow.tsx | 83 ++++++++++++++++++ src/app/features/estate/mediaRoom.test.ts | 93 +++++++++++++++++++++ src/app/features/estate/mediaRoom.ts | 90 ++++++++++++++++++++ 5 files changed, 323 insertions(+), 29 deletions(-) create mode 100644 src/app/features/estate/MediaRow.test.tsx create mode 100644 src/app/features/estate/MediaRow.tsx create mode 100644 src/app/features/estate/mediaRoom.test.ts create mode 100644 src/app/features/estate/mediaRoom.ts diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index cc98c337..2a38e1f0 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -1,4 +1,11 @@ -import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react'; +import React, { + ComponentProps, + MouseEventHandler, + ReactNode, + useCallback, + useMemo, + useRef, +} from 'react'; import { useNavigate } from 'react-router-dom'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; @@ -29,8 +36,9 @@ import { _RoomSearchParams } from '../../pages/paths'; import { searchModalAtom } from '../../state/searchModal'; import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { useClientConfig } from '../../hooks/useClientConfig'; -import { isFileZone } from '../albums/isFileZone'; +import { MINDS } from '../../components/message/pose/poses'; +import { MediaLink, MediaNote, useMindMedia } from './MediaRow'; +import { MediaRow as MediaRowState } from './mediaRoom'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useCallEmbed } from '../../hooks/useCallEmbed'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; @@ -49,6 +57,8 @@ import * as css from './EstateNavigator.css'; import { DeskEntry } from '../desk'; import { SkyStateControl } from '../scene/SkyStateControl'; +type IconSrc = ComponentProps['src']; + /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; @@ -62,7 +72,7 @@ const estateRoomPath = (mx: ReturnType, room: EstateRoom return getHomeRoomPath(alias); }; -function JoinRow({ room }: { room: EstateRoom }) { +function JoinRow({ room, icon = Icons.Hash }: { room: EstateRoom; icon?: IconSrc }) { const mx = useMatrixClient(); const navigate = useNavigate(); const [state, join] = useAsyncCallback( @@ -86,7 +96,7 @@ function JoinRow({ room }: { room: EstateRoom }) { - + @@ -174,37 +184,26 @@ function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) { ); } -/** The Mind's media pages (file-zone rooms) first, then its other rooms in their given order. */ -function useMediaFirst(rooms: EstateRoom[]): EstateRoom[] { +/** The Media row every Mind in the roster has: its file-zone room, or an honest note that there is none yet. */ +function MindMedia({ media }: { media: MediaRowState }) { const mx = useMatrixClient(); - const clientConfig = useClientConfig(); - return useMemo(() => { - const isMedia = (r: EstateRoom) => { - const mxRoom = mx.getRoom(r.roomId); - return ( - !!mxRoom && - isFileZone( - { - roomId: r.roomId, - aliases: [mxRoom.getCanonicalAlias(), ...mxRoom.getAltAliases()].filter( - (a): a is string => !!a - ), - createType: mxRoom.currentState.getStateEvents('m.room.create', '')?.getContent().type, - }, - clientConfig - ) - ); - }; - const media = rooms.filter(isMedia); - return [...media, ...rooms.filter((r) => !media.includes(r))]; - }, [mx, clientConfig, rooms]); + if (media.kind === 'checking') return ; + if (media.kind === 'unset') return ; + if (media.kind === 'error') return ; + if (media.room.state === 'joined') return ; + return ; } function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) { - const rooms = useMediaFirst(group.rooms); + const media = useMindMedia(group.mind); + const hasMedia = MINDS.includes(group.mind); + // The media room is the Media row; it is not listed a second time among the Mind's rooms. + const rooms = + media.kind === 'room' ? group.rooms.filter((r) => r.roomId !== media.room.roomId) : group.rooms; return ( + {hasMedia && } {rooms.map((r) => ( ))} diff --git a/src/app/features/estate/MediaRow.test.tsx b/src/app/features/estate/MediaRow.test.tsx new file mode 100644 index 00000000..6b590158 --- /dev/null +++ b/src/app/features/estate/MediaRow.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; +import { MediaLink, MediaNote } from './MediaRow'; + +const text = (html: string) => html.replace(/<[^>]*>/g, ''); + +describe('Media row', () => { + it('a Mind without a media room reads "not set up" and links nowhere', () => { + const html = renderToStaticMarkup( + + + + ); + expect(text(html)).toBe('Media · not set up'); + expect(html).not.toContain('href='); + }); + + it('a joined media room reads "Media" and opens the room', () => { + const html = renderToStaticMarkup( + + + + ); + expect(text(html)).toBe('Media'); + expect(html).toContain('href="/home/%23void-files%3Agamecult.org/"'); + }); +}); diff --git a/src/app/features/estate/MediaRow.tsx b/src/app/features/estate/MediaRow.tsx new file mode 100644 index 00000000..684fa6c5 --- /dev/null +++ b/src/app/features/estate/MediaRow.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useAtomValue } from 'jotai'; +import { Avatar, Box, Icon, Icons, Text } from 'folds'; +import { NavGutter, NavItem, NavItemContent, NavLink } from '../../components/nav'; +import { NAV_ROOM_ICON_REST_OPACITY, NavLabel } from '../../components/nav/styles.css'; +import { MINDS } from '../../components/message/pose/poses'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { allInvitesAtom } from '../../state/room-list/inviteList'; +import { useEstateRootId } from './useEstate'; +import { + STEWARDSHIPS_KEY, + MediaRow as MediaRowState, + lookupMediaRoom, + mediaRoomIdFromStewardships, + mediaRow, +} from './mediaRoom'; + +const mediaIcon = ( + + + +); + +/** A Media row that is not a link: not set up, still looking, or the lookup failed. Says so; shows no data. */ +export function MediaNote({ note, title }: { note: string; title?: string }) { + return ( + + + + {mediaIcon} + + {`Media · ${note}`} + + + + + ); +} + +/** The joined Media row: opens the room, which shows its album view. */ +export function MediaLink({ to }: { to: string }) { + return ( + + + + + {mediaIcon} + + Media + + + + + + ); +} + +/** The Mind's media room as the navigator sees it right now. */ +export const useMindMedia = (mind: string): MediaRowState => { + const mx = useMatrixClient(); + const { rootId } = useEstateRootId(); + const joined = useAtomValue(allRoomsAtom); + const invited = useAtomValue(allInvitesAtom); + const server = mx.getDomain() ?? ''; + const carried = mediaRoomIdFromStewardships( + rootId + ? mx.getRoom(rootId)?.currentState.getStateEvents(STEWARDSHIPS_KEY, '')?.getContent() + : undefined, + mind + ); + const lookup = useQuery({ + queryKey: ['mind-media', mind, server, carried], + queryFn: () => lookupMediaRoom(mx, mind, server, carried), + staleTime: 60_000, + enabled: MINDS.includes(mind), + }); + const membership = new Map(); + invited.forEach((id) => membership.set(id, 'invite')); + joined.forEach((id) => membership.set(id, 'join')); + return mediaRow(lookup.data, membership); +}; diff --git a/src/app/features/estate/mediaRoom.test.ts b/src/app/features/estate/mediaRoom.test.ts new file mode 100644 index 00000000..2cfbce81 --- /dev/null +++ b/src/app/features/estate/mediaRoom.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { lookupMediaRoom, mediaAlias, mediaRoomIdFromStewardships, mediaRow } from './mediaRoom'; + +const notFound = Object.assign(new Error('Room alias not found'), { + httpStatus: 404, + errcode: 'M_NOT_FOUND', +}); + +const clientFor = (answer: () => Promise<{ room_id: string; servers?: string[] }>) => { + const asked: string[] = []; + return { + asked, + mx: { + getRoomIdForAlias: (alias: string) => { + asked.push(alias); + return answer(); + }, + }, + }; +}; + +describe('the Media row of a Mind', () => { + it("looks the room up by Eureka's alias pattern", async () => { + const { mx, asked } = clientFor(async () => ({ + room_id: '!v:gamecult.org', + servers: ['gamecult.org'], + })); + expect(mediaAlias('void', 'gamecult.org')).toBe('#void-files:gamecult.org'); + expect(await lookupMediaRoom(mx, 'void', 'gamecult.org')).toEqual({ + found: true, + roomId: '!v:gamecult.org', + via: ['gamecult.org'], + }); + expect(asked).toEqual(['#void-files:gamecult.org']); + }); + + it('uses a room id the stewardships state carries and asks nobody', async () => { + const { mx, asked } = clientFor(async () => { + throw new Error('must not be asked'); + }); + const carried = mediaRoomIdFromStewardships( + { void: ['GameCult/x'], media: { void: '!c:gamecult.org' } }, + 'void' + ); + expect(carried).toBe('!c:gamecult.org'); + expect(await lookupMediaRoom(mx, 'void', 'gamecult.org', carried)).toMatchObject({ + found: true, + roomId: '!c:gamecult.org', + }); + expect(asked).toEqual([]); + }); + + it('ignores a stewardships state that carries no id for the Mind, or something that is not a room id', () => { + expect(mediaRoomIdFromStewardships({ void: ['GameCult/x'] }, 'void')).toBeUndefined(); + expect(mediaRoomIdFromStewardships({ media: { void: 'nope' } }, 'void')).toBeUndefined(); + expect(mediaRoomIdFromStewardships(undefined, 'void')).toBeUndefined(); + }); + + it('says not set up when the alias does not exist, and unavailable when the lookup fails otherwise', async () => { + const missing = clientFor(async () => { + throw notFound; + }); + expect(await lookupMediaRoom(missing.mx, 'kiko', 'gamecult.org')).toEqual({ + found: false, + reason: 'unset', + }); + const down = clientFor(async () => { + throw Object.assign(new Error('gateway'), { httpStatus: 502 }); + }); + expect(await lookupMediaRoom(down.mx, 'kiko', 'gamecult.org')).toMatchObject({ + found: false, + reason: 'error', + message: 'gateway', + }); + }); + + it("joins the found room to the user's membership: joined opens, invited and unjoined are joinable", () => { + const found = { found: true as const, roomId: '!m:gamecult.org', via: ['gamecult.org'] }; + const state = (m?: 'join' | 'invite') => + mediaRow(found, new Map(m ? [['!m:gamecult.org', m]] : [])); + expect(state('join')).toMatchObject({ kind: 'room', room: { state: 'joined', name: 'Media' } }); + expect(state('invite')).toMatchObject({ kind: 'room', room: { state: 'invited' } }); + expect(state()).toMatchObject({ + kind: 'room', + room: { state: 'joinable', via: ['gamecult.org'], direct: false }, + }); + }); + + it('shows no room while the lookup is out, and none for a room that is not there', () => { + expect(mediaRow(undefined, new Map())).toEqual({ kind: 'checking' }); + expect(mediaRow({ found: false, reason: 'unset' }, new Map())).toEqual({ kind: 'unset' }); + }); +}); diff --git a/src/app/features/estate/mediaRoom.ts b/src/app/features/estate/mediaRoom.ts new file mode 100644 index 00000000..a99c3d9d --- /dev/null +++ b/src/app/features/estate/mediaRoom.ts @@ -0,0 +1,90 @@ +/** + * A Mind's media room: the file-zone room Eureka provisions under the alias #-files:. + * The navigator shows a Media row for every Mind; this module decides what that row says and where it points. + * A room id carried by the root Space's org.gamecult.stewardships state wins over the alias; the alias is the + * convention until the provisioning projection carries ids (ratatoskr:follow_up:stewardships-carry-room-ids). + */ +import { EstateRoom } from './buildEstate'; + +export const STEWARDSHIPS_KEY = 'org.gamecult.stewardships'; + +export const mediaAlias = (mind: string, server: string): string => `#${mind}-files:${server}`; + +/** + * The room id the stewardships state names for a Mind's media room, if it does. The state's mind-to-repos + * lists are not read here; the id lives under `media[]` (provisional shape, awaiting the follow-up above). + */ +export const mediaRoomIdFromStewardships = (content: unknown, mind: string): string | undefined => { + const id = (content as { media?: Record } | null | undefined)?.media?.[mind]; + return typeof id === 'string' && id.startsWith('!') ? id : undefined; +}; + +type MediaClient = { + getRoomIdForAlias(alias: string): Promise<{ room_id: string; servers?: string[] }>; +}; + +export type MediaLookup = + | { found: true; roomId: string; via: string[] } + | { found: false; reason: 'unset' | 'error'; message?: string }; + +/** Resolve a Mind's media room: a carried id if there is one, else the alias. A 404 means it is not set up. */ +export const lookupMediaRoom = async ( + mx: MediaClient, + mind: string, + server: string, + carriedId?: string +): Promise => { + if (carriedId) return { found: true, roomId: carriedId, via: [server] }; + try { + const r = await mx.getRoomIdForAlias(mediaAlias(mind, server)); + return { found: true, roomId: r.room_id, via: r.servers?.length ? r.servers : [server] }; + } catch (e) { + const err = e as { + httpStatus?: number; + errcode?: string; + data?: { errcode?: string }; + message?: string; + }; + const missing = err.httpStatus === 404 || (err.errcode ?? err.data?.errcode) === 'M_NOT_FOUND'; + return missing + ? { found: false, reason: 'unset' } + : { + found: false, + reason: 'error', + message: err.message ?? 'Could not look up the media room', + }; + } +}; + +export type MediaRow = + | { kind: 'checking' } + | { kind: 'unset' } + | { kind: 'error'; message: string } + | { kind: 'room'; room: EstateRoom }; + +const MEMBERSHIP_STATE = { join: 'joined', invite: 'invited', none: 'joinable' } as const; + +/** What the Media row shows: the lookup result joined to the user's own membership. */ +export const mediaRow = ( + lookup: MediaLookup | undefined, + membership: ReadonlyMap +): MediaRow => { + if (!lookup) return { kind: 'checking' }; + if (!lookup.found) { + return lookup.reason === 'unset' + ? { kind: 'unset' } + : { kind: 'error', message: lookup.message ?? 'Could not look up the media room' }; + } + const m = membership.get(lookup.roomId); + return { + kind: 'room', + room: { + roomId: lookup.roomId, + name: 'Media', + state: MEMBERSHIP_STATE[m ?? 'none'], + via: lookup.via, + parentId: null, + direct: false, + }, + }; +}; From 1195b0bffc48b24b37e41ad54821bc035df7723a Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 17:12:06 +0000 Subject: [PATCH 013/143] Navigator ink: chevrons 0.9, room icons 0.26, solid unread dot, label plate over the scene Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 21 ++++++++++++------- .../components/unread-badge/UnreadBadge.tsx | 5 +++-- .../room-nav/RoomNavCategoryButton.tsx | 3 ++- src/app/features/room-nav/styles.css.ts | 7 ------- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index d66097af..35cd729a 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -2,7 +2,6 @@ import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@van 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 } 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. @@ -32,10 +31,10 @@ export const NavLevel = style({ paddingLeft: LEVEL_STEP, }); -// Chevrons and room icons are matched in weight, so a foldout is not outweighed by its rooms. -export const NAV_ICON_REST_OPACITY = 0.6; +// Chevrons and room icons are matched in measured ink (a room glyph is about 1.7x a chevron's, unread rooms stay far above), so a foldout is not outweighed by its rooms. +export const NAV_ICON_REST_OPACITY = 0.9; // A room's glyph is bigger and denser than the chevron, so it rests lighter to read as the same weight. -export const NAV_ROOM_ICON_REST_OPACITY = 0.42; +export const NAV_ROOM_ICON_REST_OPACITY = 0.26; export const NAV_ICON_UNREAD_OPACITY = 0.9; export const NavCategory = style([ @@ -252,9 +251,17 @@ phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, { opacity: 0.6, textDecorationLine: 'underline', }); -phoneLabel(`:root[data-scene] ${label}`, { - color: skyLabelInk('inherit'), - textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, +// Over the scene a label sits on a feathered plate of the theme's own surface (not a card: no edge, the +// shadow carries it past the text) and wears the theme's own ink, the colour that surface was made for. +// The plate is on the label itself, so the label is at full strength here: an opacity would fade the plate too. +const LABEL_PLATE = `color-mix(in srgb, ${surfaceInk} 80%, transparent)`; +/** The same plate for text that is not a row label (a foldout's heading). */ +export const NavPlate = style({}); +globalStyle(`:root[data-scene] ${label}, :root[data-scene] .${NavPlate}`, { + opacity: 1, + backgroundColor: LABEL_PLATE, + boxShadow: `0 0 6px 5px ${LABEL_PLATE}`, + borderRadius: toRem(6), }); // Icons, dots and the Join action share the label's halo over the scene. diff --git a/src/app/components/unread-badge/UnreadBadge.tsx b/src/app/components/unread-badge/UnreadBadge.tsx index 84eb5ae4..00c7fa2f 100644 --- a/src/app/components/unread-badge/UnreadBadge.tsx +++ b/src/app/components/unread-badge/UnreadBadge.tsx @@ -22,8 +22,9 @@ export function UnreadBadge({ highlight, count }: UnreadBadgeProps) { 0 ? '400' : '200'} - // A mention is the one solid pill; plain unread is a soft tint, so a row's count is never its loudest part. - fill={highlight ? 'Solid' : 'Soft'} + // A mention and the bare unread dot are solid (a soft dot measured 1.6:1 against the ground); a plain unread + // count is a soft tint, so a row's count is never its loudest part. + fill={highlight || count === 0 ? 'Solid' : 'Soft'} radii="Pill" outlined={false} > diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx index 74945be3..90e1f5d6 100644 --- a/src/app/features/room-nav/RoomNavCategoryButton.tsx +++ b/src/app/features/room-nav/RoomNavCategoryButton.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { as, Chip, Icon, Icons, Text } from 'folds'; import classNames from 'classnames'; import { NavGutter } from '../../components/nav'; +import { NavPlate } from '../../components/nav/styles.css'; import * as css from './styles.css'; export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( @@ -21,7 +22,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( src={closed ? Icons.ChevronRight : Icons.ChevronBottom} /> - + {children} diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index 1d46e596..f87de142 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -1,7 +1,5 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; -import { surfaceInk } from '../scene/sceneShell.css'; -import { skyLabelInk } from '../scene/skyLabelInk'; import { NAV_ICON_REST_OPACITY } from '../../components/nav/styles.css'; export const CategoryButton = style({ @@ -21,11 +19,6 @@ export const CategoryButton = style({ border: 'none', borderRadius: 0, }, - ':root[data-scene] &&': { - color: skyLabelInk('inherit'), - textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, - filter: `drop-shadow(0 0 3px ${surfaceInk})`, - }, }, }, }, From d226ca45fbc0302e7c321bbcd28807b6a3341b8a Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 17:12:57 +0000 Subject: [PATCH 014/143] Media row: a joined file-zone room (by type or fileZoneRooms) is the row; alias or carried id only finds an unjoined one Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 2 +- src/app/features/estate/MediaRow.tsx | 28 +++++++++++++++++++-- src/app/features/estate/mediaRoom.test.ts | 27 +++++++++++++++++++- src/app/features/estate/mediaRoom.ts | 10 ++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 2a38e1f0..8b8b3644 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -195,7 +195,7 @@ function MindMedia({ media }: { media: MediaRowState }) { } function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) { - const media = useMindMedia(group.mind); + const media = useMindMedia(group.mind, group.rooms); const hasMedia = MINDS.includes(group.mind); // The media room is the Media row; it is not listed a second time among the Mind's rooms. const rooms = diff --git a/src/app/features/estate/MediaRow.tsx b/src/app/features/estate/MediaRow.tsx index 684fa6c5..6277f09c 100644 --- a/src/app/features/estate/MediaRow.tsx +++ b/src/app/features/estate/MediaRow.tsx @@ -4,6 +4,9 @@ import { useAtomValue } from 'jotai'; import { Avatar, Box, Icon, Icons, Text } from 'folds'; import { NavGutter, NavItem, NavItemContent, NavLink } from '../../components/nav'; import { NAV_ROOM_ICON_REST_OPACITY, NavLabel } from '../../components/nav/styles.css'; +import { EstateRoom } from './buildEstate'; +import { isFileZone } from '../albums/isFileZone'; +import { useClientConfig } from '../../hooks/useClientConfig'; import { MINDS } from '../../components/message/pose/poses'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { allRoomsAtom } from '../../state/room-list/roomList'; @@ -15,6 +18,7 @@ import { lookupMediaRoom, mediaRoomIdFromStewardships, mediaRow, + typedMediaRoom, } from './mediaRoom'; const mediaIcon = ( @@ -58,12 +62,31 @@ export function MediaLink({ to }: { to: string }) { } /** The Mind's media room as the navigator sees it right now. */ -export const useMindMedia = (mind: string): MediaRowState => { +export const useMindMedia = (mind: string, rooms: readonly EstateRoom[]): MediaRowState => { const mx = useMatrixClient(); const { rootId } = useEstateRootId(); const joined = useAtomValue(allRoomsAtom); const invited = useAtomValue(allInvitesAtom); + const config = useClientConfig(); const server = mx.getDomain() ?? ''; + // A joined room whose type says it is a file zone is the Media row; the alias or carried id only finds a + // room the client cannot type yet (not joined). + const typed = typedMediaRoom(rooms, (roomId) => { + const room = mx.getRoom(roomId); + return ( + !!room && + isFileZone( + { + roomId, + aliases: [room.getCanonicalAlias(), ...room.getAltAliases()].filter( + (a): a is string => !!a + ), + createType: room.currentState.getStateEvents('m.room.create', '')?.getContent().type, + }, + config + ) + ); + }); const carried = mediaRoomIdFromStewardships( rootId ? mx.getRoom(rootId)?.currentState.getStateEvents(STEWARDSHIPS_KEY, '')?.getContent() @@ -74,10 +97,11 @@ export const useMindMedia = (mind: string): MediaRowState => { queryKey: ['mind-media', mind, server, carried], queryFn: () => lookupMediaRoom(mx, mind, server, carried), staleTime: 60_000, - enabled: MINDS.includes(mind), + enabled: MINDS.includes(mind) && !typed, }); const membership = new Map(); invited.forEach((id) => membership.set(id, 'invite')); joined.forEach((id) => membership.set(id, 'join')); + if (typed) return { kind: 'room', room: { ...typed, name: 'Media' } }; return mediaRow(lookup.data, membership); }; diff --git a/src/app/features/estate/mediaRoom.test.ts b/src/app/features/estate/mediaRoom.test.ts index 2cfbce81..af53c12b 100644 --- a/src/app/features/estate/mediaRoom.test.ts +++ b/src/app/features/estate/mediaRoom.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { lookupMediaRoom, mediaAlias, mediaRoomIdFromStewardships, mediaRow } from './mediaRoom'; +import { + lookupMediaRoom, + mediaAlias, + mediaRoomIdFromStewardships, + mediaRow, + typedMediaRoom, +} from './mediaRoom'; const notFound = Object.assign(new Error('Room alias not found'), { httpStatus: 404, @@ -90,4 +96,23 @@ describe('the Media row of a Mind', () => { expect(mediaRow(undefined, new Map())).toEqual({ kind: 'checking' }); expect(mediaRow({ found: false, reason: 'unset' }, new Map())).toEqual({ kind: 'unset' }); }); + + it('takes the joined room whose type says file zone, whatever its alias, before any alias lookup', () => { + const room = (roomId: string, state: 'joined' | 'joinable') => ({ + roomId, + name: roomId, + state, + via: [], + parentId: null, + direct: false, + }); + const rooms = [ + room('!chat', 'joined'), + room('!old-files-slug', 'joined'), + room('!unjoined', 'joinable'), + ]; + const zones = new Set(['!old-files-slug', '!unjoined']); + expect(typedMediaRoom(rooms, (id) => zones.has(id))?.roomId).toBe('!old-files-slug'); + expect(typedMediaRoom(rooms, () => false)).toBeUndefined(); + }); }); diff --git a/src/app/features/estate/mediaRoom.ts b/src/app/features/estate/mediaRoom.ts index a99c3d9d..a5f09664 100644 --- a/src/app/features/estate/mediaRoom.ts +++ b/src/app/features/estate/mediaRoom.ts @@ -19,6 +19,16 @@ export const mediaRoomIdFromStewardships = (content: unknown, mind: string): str return typeof id === 'string' && id.startsWith('!') ? id : undefined; }; +/** + * The Mind's media room among the rooms the client already knows: the first one whose room type (or the + * fileZoneRooms config) says it is a file zone. The type is the authority; the alias is only how an unknown + * room is found, below. + */ +export const typedMediaRoom = ( + rooms: readonly EstateRoom[], + isZone: (roomId: string) => boolean +): EstateRoom | undefined => rooms.find((r) => r.state === 'joined' && isZone(r.roomId)); + type MediaClient = { getRoomIdForAlias(alias: string): Promise<{ room_id: string; servers?: string[] }>; }; From ec48dbf2ed953fe105385e3a6ffeef22cde77b47 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 17:23:13 +0000 Subject: [PATCH 015/143] Navigator: no label plate (Meta); icon weights and the solid unread dot stay Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 17 +++++------------ .../features/room-nav/RoomNavCategoryButton.tsx | 3 +-- src/app/features/room-nav/styles.css.ts | 7 +++++++ 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 35cd729a..e6156362 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -2,6 +2,7 @@ import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@van 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 } 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. @@ -31,7 +32,7 @@ export const NavLevel = style({ paddingLeft: LEVEL_STEP, }); -// Chevrons and room icons are matched in measured ink (a room glyph is about 1.7x a chevron's, unread rooms stay far above), so a foldout is not outweighed by its rooms. +// Chevrons and room icons are matched in measured ink (a room glyph is about 1.7x a chevron's; unread rooms stay far above), so a foldout is not outweighed by its rooms. export const NAV_ICON_REST_OPACITY = 0.9; // A room's glyph is bigger and denser than the chevron, so it rests lighter to read as the same weight. export const NAV_ROOM_ICON_REST_OPACITY = 0.26; @@ -251,17 +252,9 @@ phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, { opacity: 0.6, textDecorationLine: 'underline', }); -// Over the scene a label sits on a feathered plate of the theme's own surface (not a card: no edge, the -// shadow carries it past the text) and wears the theme's own ink, the colour that surface was made for. -// The plate is on the label itself, so the label is at full strength here: an opacity would fade the plate too. -const LABEL_PLATE = `color-mix(in srgb, ${surfaceInk} 80%, transparent)`; -/** The same plate for text that is not a row label (a foldout's heading). */ -export const NavPlate = style({}); -globalStyle(`:root[data-scene] ${label}, :root[data-scene] .${NavPlate}`, { - opacity: 1, - backgroundColor: LABEL_PLATE, - boxShadow: `0 0 6px 5px ${LABEL_PLATE}`, - borderRadius: toRem(6), +phoneLabel(`:root[data-scene] ${label}`, { + color: skyLabelInk('inherit'), + textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, }); // Icons, dots and the Join action share the label's halo over the scene. diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx index 90e1f5d6..74945be3 100644 --- a/src/app/features/room-nav/RoomNavCategoryButton.tsx +++ b/src/app/features/room-nav/RoomNavCategoryButton.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { as, Chip, Icon, Icons, Text } from 'folds'; import classNames from 'classnames'; import { NavGutter } from '../../components/nav'; -import { NavPlate } from '../../components/nav/styles.css'; import * as css from './styles.css'; export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( @@ -22,7 +21,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( src={closed ? Icons.ChevronRight : Icons.ChevronBottom} /> - + {children} diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index f87de142..1d46e596 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -1,5 +1,7 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; +import { surfaceInk } from '../scene/sceneShell.css'; +import { skyLabelInk } from '../scene/skyLabelInk'; import { NAV_ICON_REST_OPACITY } from '../../components/nav/styles.css'; export const CategoryButton = style({ @@ -19,6 +21,11 @@ export const CategoryButton = style({ border: 'none', borderRadius: 0, }, + ':root[data-scene] &&': { + color: skyLabelInk('inherit'), + textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, + filter: `drop-shadow(0 0 3px ${surfaceInk})`, + }, }, }, }, From a3b09efb006cc96722aefcd0b5f3b56a016b4217 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 17:39:58 +0000 Subject: [PATCH 016/143] Unread room icons rest at 0.6, the dot and label weight carry unread Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index e6156362..5c3f37b0 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -36,7 +36,7 @@ export const NavLevel = style({ export const NAV_ICON_REST_OPACITY = 0.9; // A room's glyph is bigger and denser than the chevron, so it rests lighter to read as the same weight. export const NAV_ROOM_ICON_REST_OPACITY = 0.26; -export const NAV_ICON_UNREAD_OPACITY = 0.9; +export const NAV_ICON_UNREAD_OPACITY = 0.6; export const NavCategory = style([ DefaultReset, From 48d9c56dc7026d6be3daa193dd9e730eeaa43c42 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 17:13:46 +0000 Subject: [PATCH 017/143] Ridge: shade the faces by depth below the crest, and hold the ridge inside the sky state's label band Volume: the foot of each face darkens (scaled by the layer's contrast, before the mist lifts it toward the haze), the sun catches the top, and the crest rim gets a soft glow under its 2 px lip. Baked per pixel; nothing per frame. Legibility, with no plate: the state's luminance band (skyBand.ts, the one the sky is shaped into) now also shapes the layers, as a shift of linear light in the bake. Day and dusk add the zenith colour so the darkest ridge pixel is on the text floor; night scales to the ceiling. The face keeps its whole luminance gradient. Rows of full-opacity label over a ridge measure 2.4-2.6 before and 6.7-7.1 after at day and dusk (360 px, real label colour against the real pixels under it). Tests: 'foot is hazier' becomes two tests (the foot is clearly darker; the valley mist still pulls it toward the haze), plus the band tests and ridgeSkyFor carrying the state band. Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/ridge/ridgeBake.test.ts | 111 +++++++++++++++++- src/app/features/scene/ridge/ridgeBake.ts | 76 +++++++++++- src/app/features/scene/ridge/skyColour.ts | 4 + src/app/features/scene/skyForState.test.ts | 6 + src/app/features/scene/skyForState.ts | 8 +- 5 files changed, 192 insertions(+), 13 deletions(-) diff --git a/src/app/features/scene/ridge/ridgeBake.test.ts b/src/app/features/scene/ridge/ridgeBake.test.ts index 7c452954..8ce91f41 100644 --- a/src/app/features/scene/ridge/ridgeBake.test.ts +++ b/src/app/features/scene/ridge/ridgeBake.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; import { BakeParams, bakeSlice, faceLight, layerPaint, sunDirection } from './ridgeBake'; +import { SKY_BAND } from '../skyBand'; +import { ridgeSkyFor } from '../skyForState'; import { LACUNARITY, LAYERS, profile } from './ridgeWorld'; import { skyColours, SkyColours } from './skyColour'; @@ -155,22 +157,41 @@ describe('shading from the slope normal', () => { } }); - it('the foot of the face is hazier than its top: it moves toward the haze colour', () => { + it('the foot of the face is clearly darker than its top, on every layer: volume from the depth below the crest', () => { + // Without the depth shading the bottom row is 0.93 to 0.98 of the luminance 12 rows under the crest; with it, 0.73 to 0.82. + for (const li of [0, 1, 2, 3]) { + const cols = 900; + const d = bake(params(li, 0, cols, { spec: { ...LAYERS[li], mist: 0, gully: 0 } })); + let ratio = 0; + let n = 0; + for (let x = 0; x < cols; x += 1) { + const y = solidRow(d, cols, x); + if (y + 90 >= BAND) continue; + n += 1; + ratio += lum(d, cols, x, BAND - 1) / Math.max(1, lum(d, cols, x, y + 12)); + } + expect(n).toBeGreaterThan(30); + expect(ratio / n).toBeLessThan(0.88); + } + }); + + it('the valley mist still pulls the foot toward the haze colour', () => { const cols = 300; const p = params(0, 0, cols); - const d = bake(p); + const misty = bake(p); + const clear = bake(params(0, 0, cols, { spec: { ...LAYERS[0], mist: 0 } })); const haze = layerPaint(p.spec, p.sky).haze; - const dist = (x: number, y: number) => { + const dist = (d: Uint8ClampedArray, x: number, y: number) => { const o = (y * cols + x) * 4; return Math.hypot(d[o] - haze[0], d[o + 1] - haze[1], d[o + 2] - haze[2]); }; let nearer = 0; let n = 0; for (let x = 0; x < cols; x += 1) { - const y = solidRow(d, cols, x); + const y = solidRow(misty, cols, x); if (y + 200 >= BAND) continue; n += 1; - if (dist(x, y + 200) < dist(x, y + 20)) nearer += 1; + if (dist(misty, x, y + 200) < dist(clear, x, y + 200)) nearer += 1; } expect(n).toBeGreaterThan(50); expect(nearer / n).toBeGreaterThan(0.9); @@ -301,3 +322,83 @@ describe('the ridged multifractal core, each rule on its own', () => { expect(gap(0)).toBeLessThan(gap(3)); }); }); + +describe('the state band: the theme ink clears 4.5:1 on a ridge as on bare sky', () => { + const linear = (v: number) => { + const c = v / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + const luma = (d: Uint8ClampedArray, o: number) => + 0.2126 * linear(d[o]) + 0.7152 * linear(d[o + 1]) + 0.0722 * linear(d[o + 2]); + /** Lowest and highest luminance over the solid pixels of every layer. */ + const range = (band: typeof SKY_BAND[keyof typeof SKY_BAND] | undefined) => { + let lo = 1; + let hi = 0; + for (const li of [0, 1, 2, 3]) { + const p = params(li, 0, 700, { sky: { ...SKY, band } }); + const d = bake(p); + for (let o = 0; o < d.length; o += 4) { + if (d[o + 3] < 255) continue; + const l = luma(d, o); + lo = Math.min(lo, l); + hi = Math.max(hi, l); + } + } + return { lo, hi }; + }; + + it("day and dusk: no ridge pixel is darker than the text floor (Day's), on every layer", () => { + for (const band of [SKY_BAND.day, SKY_BAND.dusk]) { + expect(range(band).lo).toBeGreaterThanOrEqual(SKY_BAND.day.floor); + } + }); + + it('night: no ridge pixel is lighter than the ceiling', () => { + expect(range(SKY_BAND.night).hi).toBeLessThanOrEqual(SKY_BAND.night.ceiling); + }); + + it('is the identity with no band, so nothing else changes', () => { + expect(layerPaint(LAYERS[2], SKY).shift).toBeNull(); + expect(range(undefined).lo).toBeLessThan(SKY_BAND.day.floor); + }); + + it('keeps the shading: the foot is still darker than the top inside the band', () => { + // Inside the band the face's range is compressed, not removed: dusk, no mist or gullies, bottom row over 12 rows under the crest. + const sky = { ...SKY, band: SKY_BAND.dusk }; + for (const li of [0, 1, 2, 3]) { + const cols = 900; + const d = bake(params(li, 0, cols, { sky, spec: { ...LAYERS[li], mist: 0, gully: 0 } })); + let ratio = 0; + let n = 0; + for (let x = 0; x < cols; x += 1) { + const y = solidRow(d, cols, x); + if (y + 90 >= BAND) continue; + n += 1; + ratio += luma(d, ((BAND - 1) * cols + x) * 4) / luma(d, ((y + 12) * cols + x) * 4); + } + expect(n).toBeGreaterThan(30); + // Measured 0.84, 0.85, 0.91, 0.97 far to near: the nearest ground is near black, so its range is small in absolute terms. + expect(ratio / n).toBeLessThan(li < 3 ? 0.95 : 1); + } + }); + + it('the real states, over a sweep of the sun: no pixel overflows the encoding (an overflow reads as a stray black or yellow channel)', () => { + for (const state of ['day', 'dusk', 'night'] as const) { + for (const sunAzimuth of [0, 0.1, 0.25, 0.3, 0.5, 0.75, 0.9]) { + const sky = { ...ridgeSkyFor(state), sunAzimuth }; + for (const li of [0, 1, 2, 3]) { + const d = bake(params(li, 1000, 400, { sky })); + let stray = 0; + for (let o = 0; o < d.length; o += 4) { + if (d[o + 3] < 255) continue; + // A channel far below both others is the signature of a lookup that read past its table. + const lo = Math.min(d[o], d[o + 1], d[o + 2]); + const mid = d[o] + d[o + 1] + d[o + 2] - lo - Math.max(d[o], d[o + 1], d[o + 2]); + if (mid - lo > 120) stray += 1; + } + expect(stray).toBe(0); + } + } + } + }); +}); diff --git a/src/app/features/scene/ridge/ridgeBake.ts b/src/app/features/scene/ridge/ridgeBake.ts index 3d47526c..770c30e0 100644 --- a/src/app/features/scene/ridge/ridgeBake.ts +++ b/src/app/features/scene/ridge/ridgeBake.ts @@ -4,6 +4,8 @@ // gradient, erosion streaks and a lit rim on the crest. The depth treatment (colour, haze, contrast // by distance) is ridgeWorld's `layerColour`, `hazeColour` and `transmittance`, used here and not redone. import { gullyAt, LayerSpec, profile, hazeColour, layerColour, transmittance } from './ridgeWorld'; +import type { SkyBand } from '../skyBand'; +import { LUMA, SKY_BAND } from '../skyBand'; import type { SkyColours, Srgb } from './skyColour'; export type Vec3 = readonly [number, number, number]; @@ -58,6 +60,8 @@ const SUN_WHITE: Srgb = [255, 238, 205]; /** Everything `bakeSlice` needs per layer, computed once. */ export interface LayerPaint { + /** The state's band as a shift of linear light (raises the floor, lowers the ceiling); null when the sky has none. */ + shift: BandShift | null; base: Srgb; haze: Srgb; warm: Srgb; @@ -65,6 +69,46 @@ export interface LayerPaint { sun: Vec3; } +const decode = (v: number): number => { + const s = v / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; +}; +const DECODE = Float32Array.from({ length: 256 }, (_, v) => decode(v)); +const ENCODE_STEPS = 4095; +const ENCODE = Uint8ClampedArray.from({ length: ENCODE_STEPS + 1 }, (_, i) => { + const l = i / ENCODE_STEPS; + return 255 * (l <= 0.0031308 ? 12.92 * l : 1.055 * l ** (1 / 2.4) - 0.055); +}); +/** What the band is held clear of at its edges: the 8-bit rounding and the blur and in-scatter the chunk takes after the bake. */ +const BAND_MARGIN = 0.012; + +/** Linear light out = in * `scale` + `add[channel]`. */ +export interface BandShift { + add: readonly [number, number, number]; + scale: number; +} + +/** + * The state's band (skyBand.ts) as a shift of linear light, solved once per layer. Adding `add` (the zenith colour, so + * the ridge reads as shadowed by the sky above, a hue apart from the horizon behind it) raises every pixel's + * luminance by exactly the floor, so the face keeps its whole gradient and the darkest pixel is on the floor; scaling by + * `scale` puts the lightest pixel on the ceiling. Smooth in the pixel's own luminance: no cut and no region. + * The floor is the text bound (Day's), not Dusk's higher link bound: the link ink is not set on ridges, and Dusk's + * floor washes the faces to a flat pale slab. + */ +export function bandShift(band: SkyBand | undefined, lift: Srgb): BandShift | null { + if (!band) return null; + const floor = Math.min(band.floor, SKY_BAND.day.floor); + const scale = band.ceiling < 1 ? band.ceiling - BAND_MARGIN / 3 : 1; + if (floor <= 0 && scale === 1) return null; + const lin = lift.map(decode); + const k = + floor > 0 + ? (floor + BAND_MARGIN) / (LUMA[0] * lin[0] + LUMA[1] * lin[1] + LUMA[2] * lin[2]) + : 0; + return { add: [lin[0] * k, lin[1] * k, lin[2] * k], scale }; +} + export function layerPaint(spec: LayerSpec, sky: SkyColours): LayerPaint { const warm: Srgb = [ (sky.horizon[0] + SUN_WHITE[0]) / 2, @@ -72,6 +116,7 @@ export function layerPaint(spec: LayerSpec, sky: SkyColours): LayerPaint { (sky.horizon[2] + SUN_WHITE[2]) / 2, ]; return { + shift: bandShift(sky.band, sky.top), base: layerColour(spec, sky), haze: hazeColour(sky), warm, @@ -109,7 +154,8 @@ export function bakeSlice( const crest = crestRows(p, c0, c1); const faceH = 0.45 * p.viewH; const rim = 2.2 * p.scale; - const { base, haze, warm, contrast, sun } = paint; + const glow = 9 * p.scale; + const { base, haze, warm, contrast, sun, shift } = paint; for (let c = c0; c < c1; c += 1) { const cy = crest[c - c0 + 1]; const slope = (crest[c - c0 + 2] - crest[c - c0]) * -0.5; // rows up per column @@ -123,15 +169,35 @@ export function bakeSlice( const cover = clamp01(y + 1 - cy); const gully = spec.gully * gl * smoothstep(0, 0.2, dn); // Shade the ground colour by the sun; the lit warm light fades down the face and in the gullies. - const shade = (0.7 + 0.3 * lit * contrast + 0.3 * (1 - contrast)) * (1 - 0.5 * gully); - const warmK = 0.22 * contrast * lit * lit * (1 - 0.7 * dn) * (1 - 0.6 * gully); - const rimK = clamp01(1 - d / rim) * contrast * (0.3 + 0.7 * lit) * 0.6; + // Volume from the depth below the crest: the face darkens toward its foot (before the mist lifts it + // toward the haze), and the sun catches its top. A few multiplies per pixel, in the bake only. + const foot = 1 - 0.4 * contrast * smoothstep(0.05, 0.9, dn); + const top = 1 - smoothstep(0, 0.3, dn); + const shade = (0.7 + 0.3 * lit * contrast + 0.3 * (1 - contrast)) * (1 - 0.5 * gully) * foot; + const warmK = + 0.22 * contrast * lit * lit * (1 - 0.7 * dn) * (1 - 0.6 * gully) + + 0.04 * contrast * lit * top; + // A hard 2 px lip on the crest and a soft glow under it. + const rimK = + (clamp01(1 - d / rim) * 0.4 + (d < glow * 5 ? Math.exp(-d / glow) * 0.08 : 0)) * + contrast * + (0.3 + 0.7 * lit); const mist = spec.mist * smoothstep(0, 1, dn); const o = (y * p.cols + c) * 4; for (let i = 0; i < 3; i += 1) { let v = base[i] * shade + warm[i] * (warmK + rimK); v += (haze[i] - v) * mist; - out[o + i] = v; + out[o + i] = shift + ? ENCODE[ + Math.min( + ENCODE_STEPS, + ((DECODE[v >= 255 ? 255 : v <= 0 ? 0 : Math.round(v)] * shift.scale + + shift.add[i]) * + ENCODE_STEPS) | + 0 + ) + ] + : v; } out[o + 3] = cover * 255; } diff --git a/src/app/features/scene/ridge/skyColour.ts b/src/app/features/scene/ridge/skyColour.ts index 7917d89e..1fa7e6fe 100644 --- a/src/app/features/scene/ridge/skyColour.ts +++ b/src/app/features/scene/ridge/skyColour.ts @@ -4,6 +4,8 @@ // 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. +import type { SkyBand } from '../skyBand'; + export type Srgb = readonly [number, number, number]; export interface SkyColours { @@ -17,6 +19,8 @@ export interface SkyColours { ground: Srgb; /** Where the sun sits across one drift period, 0..1; the layers pick up a faint warm in-scatter there. Omit for none. */ sunAzimuth?: number; + /** The state's luminance band (skyBand.ts): the layers are shaped into it as the sky is, so the theme ink clears 4.5:1 on a ridge as on bare sky. Omit for none. */ + band?: SkyBand; } export function skyColours(_now: Date = new Date()): SkyColours { diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index a8199b1b..37e170cb 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -77,6 +77,12 @@ describe('the bands the text ink needs', () => { }); describe('ridgeSkyFor', () => { + it('carries the state band, which the layers are shaped into', () => { + for (const state of ['day', 'dusk', 'night'] as const) { + expect(ridgeSkyFor(state).band).toEqual(SKY_BAND[state]); + } + }); + it('draws the gradient of the sky auto resolves to, never nothing', () => { setSkyClock(() => atStandardTime(12, 0)); expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('day')); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index 71e07ca6..a9551f65 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -20,7 +20,8 @@ const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).jo 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 => ({ +const fromGradient = (g: readonly string[], ground: Srgb, band: SkyBand): SkyColours => ({ + band, top: rgb(g[0]), mid: rgb(g[Math.floor(g.length / 2)]), horizon: rgb(g[g.length - 1]), @@ -46,15 +47,16 @@ export function ridgeSkyFor(state: SkyState): SkyColours { const band = SKY_BAND.dusk; return { ...twilight, + band, top: inBand(twilight.top, band), mid: inBand(twilight.mid, band), horizon: inBand(twilight.horizon, band), }; } case 'day': - return fromGradient(baked.noonday.gradient, ground); + return fromGradient(baked.noonday.gradient, ground, SKY_BAND.day); default: - return fromGradient(baked.midnight.gradient, ground); + return fromGradient(baked.midnight.gradient, ground, SKY_BAND.night); } } From 57f0ef8df2d9ef15d3143a09e08ddd2013783aa5 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 18:12:02 +0000 Subject: [PATCH 018/143] Desk: raised_at on the ForkCard (required key, instant or nil); the card shows its age only when there is one Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/desk/DeskView.test.tsx | 30 +++++++++++++++++++++++++ src/app/features/desk/DeskView.tsx | 12 +++++++--- src/app/features/desk/age.ts | 17 ++++++++++++++ src/app/features/desk/buildDesk.ts | 3 +++ src/app/features/desk/fixtures.ts | 1 + src/app/features/desk/forkContract.ts | 6 +++++ src/preview-demo/deskForks.test.ts | 1 + src/preview-demo/deskForks.ts | 2 ++ 8 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 src/app/features/desk/age.ts diff --git a/src/app/features/desk/DeskView.test.tsx b/src/app/features/desk/DeskView.test.tsx index ca10ea66..19be8aff 100644 --- a/src/app/features/desk/DeskView.test.tsx +++ b/src/app/features/desk/DeskView.test.tsx @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize'; import { DeskEntry } from './DeskEntry'; import { DeskView, SIGN_IN_NOTE } from './DeskView'; +import { ForkList } from './forkContract'; import { STATES } from './fixtures'; import { setDeskSource } from './source'; @@ -121,6 +122,35 @@ describe('DeskView', () => { }); }); +describe("a question's age", () => { + const NOW = Date.parse('2026-11-11T12:00:00Z'); + const listRaised = (raisedAt: string | null): ForkList => { + const entry = STATES.noAnswers.forks[0]; + return { + ...STATES.noAnswers, + forks: [{ ...entry, card: { ...entry.card, raised_at: raisedAt } }], + }; + }; + const html = (raisedAt: string | null) => + renderToStaticMarkup( + + + + ); + it('reads days since the card was first admitted', () => { + expect(html('2026-11-06T12:00:00Z')).toContain('raised 5d ago'); + }); + it('reads hours inside a day, and just now inside an hour', () => { + expect(html('2026-11-11T07:30:00Z')).toContain('raised 4h ago'); + expect(html('2026-11-11T11:40:00Z')).toContain('raised just now'); + }); + it('shows no age when the card carries none, or an instant that does not parse or is ahead of the clock', () => { + ['null', 'yesterday', '2027-01-01T00:00:00Z'].forEach((v) => { + expect(html(v === 'null' ? null : v)).not.toContain('raised '); + }); + }); +}); + describe('DeskEntry', () => { afterEach(() => setDeskSource(() => undefined)); const entry = (mind: string) => diff --git a/src/app/features/desk/DeskView.tsx b/src/app/features/desk/DeskView.tsx index e57bfa70..ceb42fd9 100644 --- a/src/app/features/desk/DeskView.tsx +++ b/src/app/features/desk/DeskView.tsx @@ -6,6 +6,7 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk'; import { ForkList } from './forkContract'; import { deskMood } from './deskMood'; +import { raisedAge } from './age'; import * as css from './DeskView.css'; /** Slice 1 has no session: every viewer is signed out, and the note says why it cannot be used. */ @@ -45,7 +46,7 @@ function Answers({ answers }: { answers: AnswerRow[] }) { ); } -function Row({ row }: { row: DeskRow }) { +function Row({ row, now }: { row: DeskRow; now: number }) { const phone = useScreenSizeContext() === ScreenSize.Mobile; // Phones list titles; a tap opens one. Wide screens show every row open. const [open, setOpen] = useState(!phone); @@ -77,6 +78,9 @@ function Row({ row }: { row: DeskRow }) { {open && ( <> + {raisedAge(row.raisedAt, now) && ( +

{raisedAge(row.raisedAt, now)}

+ )}

{row.context}

    {row.options.map((o) => ( @@ -126,10 +130,12 @@ type DeskViewProps = { list?: ForkList; /** Fork ids whose options_hash failed the recompute (optionsHash.ts). */ unverified?: ReadonlySet; + /** The clock the ages are read against; the page's own, a test's fixed one. */ + now?: number; }; /** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */ -export function DeskView({ mind, label, list, unverified }: DeskViewProps) { +export function DeskView({ mind, label, list, unverified, now = Date.now() }: DeskViewProps) { const desk = buildDesk(list, unverified); // Slice 1 has no session, so nothing on the desk is the viewer's answer yet. const mood = deskMood(desk.counts.open, 0); @@ -139,7 +145,7 @@ export function DeskView({ mind, label, list, unverified }: DeskViewProps) {
      {desk.open.map((row) => ( - + ))}
    {/* Only Minds with art in the catalogue get a booth; none gets another Mind's face. */} diff --git a/src/app/features/desk/age.ts b/src/app/features/desk/age.ts new file mode 100644 index 00000000..a8473f01 --- /dev/null +++ b/src/app/features/desk/age.ts @@ -0,0 +1,17 @@ +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +/** + * How long ago a question was raised, from the card's `raised_at` (an RFC3339 instant) and the clock the caller + * holds. Nothing when there is no instant, it does not parse, or it is ahead of the clock: the desk shows no age + * rather than a wrong one. + */ +export const raisedAge = (raisedAt: string | undefined, now: number): string | undefined => { + const then = raisedAt === undefined ? NaN : Date.parse(raisedAt); + const ms = now - then; + if (Number.isNaN(ms) || ms < 0) return undefined; + if (ms < HOUR) return 'raised just now'; + if (ms < DAY) return `raised ${Math.floor(ms / HOUR)}h ago`; + return `raised ${Math.floor(ms / DAY)}d ago`; +}; diff --git a/src/app/features/desk/buildDesk.ts b/src/app/features/desk/buildDesk.ts index e09dec1f..4d65a437 100644 --- a/src/app/features/desk/buildDesk.ts +++ b/src/app/features/desk/buildDesk.ts @@ -28,6 +28,8 @@ export type DeskRow = /** The recommendation resolved to its option's text; absent if it names no option. */ recommended?: string; depends: string[]; + /** When the question was first admitted, as served; absent when the card carries none. */ + raisedAt?: string; /** In admission order, as served. */ answers: AnswerRow[]; }; @@ -83,6 +85,7 @@ export const buildDesk = ( options: card.options.map((o) => ({ ...o, recommended: o.id === card.recommendation })), recommended: card.options.find((o) => o.id === card.recommendation)?.text, depends: card.depends, + raisedAt: card.raised_at ?? undefined, answers: record.answers.map((a) => answerRow(a, entry)), }); }); diff --git a/src/app/features/desk/fixtures.ts b/src/app/features/desk/fixtures.ts index e74c37ba..d9b055ca 100644 --- a/src/app/features/desk/fixtures.ts +++ b/src/app/features/desk/fixtures.ts @@ -16,6 +16,7 @@ export const card = (label: string, over: Partial = {}): ForkCard => ( leans: null, flag: null, raised_in: null, + raised_at: null, deciders: ['d1'], status: 'open', ruling: null, diff --git a/src/app/features/desk/forkContract.ts b/src/app/features/desk/forkContract.ts index ce196116..91b3653f 100644 --- a/src/app/features/desk/forkContract.ts +++ b/src/app/features/desk/forkContract.ts @@ -24,6 +24,12 @@ export type ForkCard = { /** Not shown by the desk. */ flag: string | null; raised_in: { kind: string; id: string } | null; + /** + * When the question was first admitted (an RFC3339 instant, as `answered_at`), or nil when the mind holds no + * admission time. Always written; outside `options_hash`; it never moves when the question is edited. Age is + * the client's: now minus `raised_at`. + */ + raised_at: string | null; deciders: string[]; status: 'open' | 'closed'; /** The ruling id when a ruling closed the fork; nil when open, and nil on a closed card withdrawn with no ruling. */ diff --git a/src/preview-demo/deskForks.test.ts b/src/preview-demo/deskForks.test.ts index 035dd2d8..56fd2573 100644 --- a/src/preview-demo/deskForks.test.ts +++ b/src/preview-demo/deskForks.test.ts @@ -18,6 +18,7 @@ const KEYS = { 'leans', 'flag', 'raised_in', + 'raised_at', 'deciders', 'status', 'ruling', diff --git a/src/preview-demo/deskForks.ts b/src/preview-demo/deskForks.ts index a0ca67bf..fe1bbfcb 100644 --- a/src/preview-demo/deskForks.ts +++ b/src/preview-demo/deskForks.ts @@ -25,6 +25,7 @@ const card = (label: string, order: number, over: Partial): ForkCard = leans: null, flag: null, raised_in: null, + raised_at: null, deciders: ['demo-decider'], status: 'open', ruling: null, @@ -90,6 +91,7 @@ const list = (mind: string, forks: ForkEntry[], stamp: string): ForkList => ({ const nibu = (): ForkList => { const sky = card('sky-label', 4, { + raised_at: new Date(Date.now() - 5 * DAY).toISOString().replace(/\.\d+Z$/, 'Z'), title: 'Where should the sky label sit on a phone?', context: 'The label can ride the horizon or sit in the header. The horizon is prettier; the header never moves.', From 9e02536e02b0783ec2854a6635f5ce09bbf6e647 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 18:23:14 +0000 Subject: [PATCH 019/143] Desk: card contract at CultLib 3a51bbab: decider_count, answerer_count, nullable answerer; the counts show when the ids are withheld Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/desk/DeskView.test.tsx | 28 +++++++++++++++++++++++++ src/app/features/desk/DeskView.tsx | 5 +++++ src/app/features/desk/buildDesk.ts | 5 +++++ src/app/features/desk/fixtures.ts | 2 ++ src/app/features/desk/forkContract.ts | 11 +++++++++- src/preview-demo/deskForks.test.ts | 10 ++++++++- src/preview-demo/deskForks.ts | 2 ++ 7 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/app/features/desk/DeskView.test.tsx b/src/app/features/desk/DeskView.test.tsx index 19be8aff..c93c5916 100644 --- a/src/app/features/desk/DeskView.test.tsx +++ b/src/app/features/desk/DeskView.test.tsx @@ -151,6 +151,34 @@ describe("a question's age", () => { }); }); +describe('counts when the ids are withheld', () => { + const withheld = (): ForkList => { + const entry = STATES.noAnswers.forks[0]; + return { + ...STATES.noAnswers, + forks: [ + { + ...entry, + card: { ...entry.card, deciders: [], decider_count: 3 }, + record: { ...entry.record, answerer_count: 2 }, + }, + ], + }; + }; + it('shows how many deciders and answerers there are, naming none', () => { + const html = renderToStaticMarkup( + + + + ); + expect(html).toContain('3 deciders · 2 answerers'); + }); + it('says the singular for one', () => { + const html = view('noAnswers', ScreenSize.Desktop); + expect(html).toContain('1 decider · 0 answerers'); + }); +}); + describe('DeskEntry', () => { afterEach(() => setDeskSource(() => undefined)); const entry = (mind: string) => diff --git a/src/app/features/desk/DeskView.tsx b/src/app/features/desk/DeskView.tsx index ceb42fd9..2c73890d 100644 --- a/src/app/features/desk/DeskView.tsx +++ b/src/app/features/desk/DeskView.tsx @@ -12,6 +12,8 @@ import * as css from './DeskView.css'; /** Slice 1 has no session: every viewer is signed out, and the note says why it cannot be used. */ export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can read the questions.'; +const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`; + const date = (iso: string) => iso.slice(0, 10); const OUTCOME: Record = { @@ -81,6 +83,9 @@ function Row({ row, now }: { row: DeskRow; now: number }) { {raisedAge(row.raisedAt, now) && (

    {raisedAge(row.raisedAt, now)}

    )} +

    + {`${plural(row.deciderCount, 'decider')} · ${plural(row.answererCount, 'answerer')}`} +

    {row.context}

      {row.options.map((o) => ( diff --git a/src/app/features/desk/buildDesk.ts b/src/app/features/desk/buildDesk.ts index 4d65a437..0b18794c 100644 --- a/src/app/features/desk/buildDesk.ts +++ b/src/app/features/desk/buildDesk.ts @@ -30,6 +30,9 @@ export type DeskRow = depends: string[]; /** When the question was first admitted, as served; absent when the card carries none. */ raisedAt?: string; + /** Counts as served: the same in the decider and the withheld view, so they show either way. */ + deciderCount: number; + answererCount: number; /** In admission order, as served. */ answers: AnswerRow[]; }; @@ -86,6 +89,8 @@ export const buildDesk = ( recommended: card.options.find((o) => o.id === card.recommendation)?.text, depends: card.depends, raisedAt: card.raised_at ?? undefined, + deciderCount: card.decider_count, + answererCount: record.answerer_count, answers: record.answers.map((a) => answerRow(a, entry)), }); }); diff --git a/src/app/features/desk/fixtures.ts b/src/app/features/desk/fixtures.ts index d9b055ca..8f999f43 100644 --- a/src/app/features/desk/fixtures.ts +++ b/src/app/features/desk/fixtures.ts @@ -18,6 +18,7 @@ export const card = (label: string, over: Partial = {}): ForkCard => ( raised_in: null, raised_at: null, deciders: ['d1'], + decider_count: 1, status: 'open', ruling: null, ruled_from: [], @@ -61,6 +62,7 @@ export const entry = ( fork_id: c.fork_id, options_hash: c.options_hash, counted_answer_id: counted, + answerer_count: new Set(answers.map((a) => a.answerer)).size, answers, }, }; diff --git a/src/app/features/desk/forkContract.ts b/src/app/features/desk/forkContract.ts index 91b3653f..5bf10080 100644 --- a/src/app/features/desk/forkContract.ts +++ b/src/app/features/desk/forkContract.ts @@ -30,7 +30,13 @@ export type ForkCard = { * the client's: now minus `raised_at`. */ raised_at: string | null; + /** + * Who may answer. Empty when this reader is not a decider (the ids are withheld); otherwise it holds exactly + * `decider_count` ids. + */ deciders: string[]; + /** How many deciders there are, 1..64: the same number in both reader views. */ + decider_count: number; status: 'open' | 'closed'; /** The ruling id when a ruling closed the fork; nil when open, and nil on a closed card withdrawn with no ruling. */ ruling: string | null; @@ -64,7 +70,8 @@ export type DeskAnswer = { answered_at: string; received_at: string; claim_in_future: boolean; - answerer: string; + /** Who answered; nil when this reader is not a decider (withheld). */ + answerer: string | null; authority: string; request_id: string; }; @@ -75,6 +82,8 @@ export type ForkRecord = { options_hash: string; /** Derived by the receiver; the page renders it and never recomputes it. */ counted_answer_id: string | null; + /** Distinct answerers over every stored answer, refusals included, at least 0: the same number in both views. */ + answerer_count: number; /** In admission order. */ answers: DeskAnswer[]; }; diff --git a/src/preview-demo/deskForks.test.ts b/src/preview-demo/deskForks.test.ts index 56fd2573..001342f5 100644 --- a/src/preview-demo/deskForks.test.ts +++ b/src/preview-demo/deskForks.test.ts @@ -20,13 +20,21 @@ const KEYS = { 'raised_in', 'raised_at', 'deciders', + 'decider_count', 'status', 'ruling', 'ruled_from', 'order', 'options_hash', ], - record: ['schemaVersion', 'fork_id', 'options_hash', 'counted_answer_id', 'answers'], + record: [ + 'schemaVersion', + 'fork_id', + 'options_hash', + 'counted_answer_id', + 'answerer_count', + 'answers', + ], answer: [ 'schemaVersion', 'answer_id', diff --git a/src/preview-demo/deskForks.ts b/src/preview-demo/deskForks.ts index fe1bbfcb..91543cab 100644 --- a/src/preview-demo/deskForks.ts +++ b/src/preview-demo/deskForks.ts @@ -27,6 +27,7 @@ const card = (label: string, order: number, over: Partial): ForkCard = raised_in: null, raised_at: null, deciders: ['demo-decider'], + decider_count: 1, status: 'open', ruling: null, ruled_from: [], @@ -75,6 +76,7 @@ const entry = ( fork_id: c.fork_id, options_hash: c.options_hash, counted_answer_id: counted, + answerer_count: new Set(answers.map((a) => a.answerer)).size, answers, }, }); From 79fae54a0a24615424e41bff365e7dbee7a5962a Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 20:53:36 +0000 Subject: [PATCH 020/143] Desk: age parses strict RFC3339 only; a served count outside the contract shows no count line Soul found Date.parse accepting '2026-11-06', 'Nov 6 2026', '0' and 2026-02-30, and decider_count 0/65 or a missing/negative answerer_count rendering as served. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/desk/DeskView.test.tsx | 27 ++++++++++++++++++++++++- src/app/features/desk/DeskView.tsx | 8 +++++--- src/app/features/desk/age.ts | 14 ++++++++++++- src/app/features/desk/buildDesk.ts | 13 +++++++----- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/app/features/desk/DeskView.test.tsx b/src/app/features/desk/DeskView.test.tsx index c93c5916..9c60ed53 100644 --- a/src/app/features/desk/DeskView.test.tsx +++ b/src/app/features/desk/DeskView.test.tsx @@ -145,7 +145,7 @@ describe("a question's age", () => { expect(html('2026-11-11T11:40:00Z')).toContain('raised just now'); }); it('shows no age when the card carries none, or an instant that does not parse or is ahead of the clock', () => { - ['null', 'yesterday', '2027-01-01T00:00:00Z'].forEach((v) => { + ['null', 'yesterday', '2027-01-01T00:00:00Z', '2026-11-06', 'Nov 6 2026', '0', '2026-02-30T00:00:00Z'].forEach((v) => { expect(html(v === 'null' ? null : v)).not.toContain('raised '); }); }); @@ -173,6 +173,31 @@ describe('counts when the ids are withheld', () => { ); expect(html).toContain('3 deciders · 2 answerers'); }); + it('shows no count line for a served count outside the contract', () => { + const bad = (card: object, record: object): string => { + const entry = STATES.noAnswers.forks[0]; + const list = { + ...STATES.noAnswers, + forks: [{ card: { ...entry.card, ...card }, record: { ...entry.record, ...record } }], + } as ForkList; + return renderToStaticMarkup( + + + + ); + }; + [ + [{ decider_count: 0 }, {}], + [{ decider_count: 65 }, {}], + [{ decider_count: undefined }, {}], + [{}, { answerer_count: -1 }], + [{}, { answerer_count: 1.5 }], + ].forEach(([c, r]) => { + const out = bad(c, r); + expect(out).not.toContain('undefined'); + expect(out).not.toMatch(/deciders? ·/); + }); + }); it('says the singular for one', () => { const html = view('noAnswers', ScreenSize.Desktop); expect(html).toContain('1 decider · 0 answerers'); diff --git a/src/app/features/desk/DeskView.tsx b/src/app/features/desk/DeskView.tsx index 2c73890d..f83dcd82 100644 --- a/src/app/features/desk/DeskView.tsx +++ b/src/app/features/desk/DeskView.tsx @@ -83,9 +83,11 @@ function Row({ row, now }: { row: DeskRow; now: number }) { {raisedAge(row.raisedAt, now) && (

      {raisedAge(row.raisedAt, now)}

      )} -

      - {`${plural(row.deciderCount, 'decider')} · ${plural(row.answererCount, 'answerer')}`} -

      + {row.deciderCount !== undefined && row.answererCount !== undefined && ( +

      + {`${plural(row.deciderCount, 'decider')} · ${plural(row.answererCount, 'answerer')}`} +

      + )}

      {row.context}

        {row.options.map((o) => ( diff --git a/src/app/features/desk/age.ts b/src/app/features/desk/age.ts index a8473f01..52853032 100644 --- a/src/app/features/desk/age.ts +++ b/src/app/features/desk/age.ts @@ -7,8 +7,20 @@ const DAY = 24 * HOUR; * holds. Nothing when there is no instant, it does not parse, or it is ahead of the clock: the desk shows no age * rather than a wrong one. */ +const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + +/** Date.parse is lenient ("2026-11-06", "Nov 6 2026", "0"); the card's instant is RFC3339 and a real calendar day. */ +const parseRfc3339 = (s: string): number => { + const m = RFC3339.exec(s); + if (!m) return NaN; + const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]; + const day = new Date(Date.UTC(y, mo - 1, d)); + if (day.getUTCMonth() !== mo - 1 || day.getUTCDate() !== d) return NaN; + return Date.parse(s); +}; + export const raisedAge = (raisedAt: string | undefined, now: number): string | undefined => { - const then = raisedAt === undefined ? NaN : Date.parse(raisedAt); + const then = raisedAt === undefined ? NaN : parseRfc3339(raisedAt); const ms = now - then; if (Number.isNaN(ms) || ms < 0) return undefined; if (ms < HOUR) return 'raised just now'; diff --git a/src/app/features/desk/buildDesk.ts b/src/app/features/desk/buildDesk.ts index 0b18794c..fbe2caaf 100644 --- a/src/app/features/desk/buildDesk.ts +++ b/src/app/features/desk/buildDesk.ts @@ -30,9 +30,9 @@ export type DeskRow = depends: string[]; /** When the question was first admitted, as served; absent when the card carries none. */ raisedAt?: string; - /** Counts as served: the same in the decider and the withheld view, so they show either way. */ - deciderCount: number; - answererCount: number; + /** Counts as served: the same in the decider and the withheld view, so they show either way. Absent when the served number is outside the contract's range or not an integer. */ + deciderCount?: number; + answererCount?: number; /** In admission order, as served. */ answers: AnswerRow[]; }; @@ -62,6 +62,9 @@ const answerRow = (a: DeskAnswer, entry: ForkEntry): AnswerRow => ({ counted: entry.record.counted_answer_id === a.answer_id, }); +const validCount = (n: number, min: number, max: number): number | undefined => + Number.isInteger(n) && n >= min && n <= max ? n : undefined; + export const buildDesk = ( list: ForkList | undefined, unverified: ReadonlySet = new Set() @@ -89,8 +92,8 @@ export const buildDesk = ( recommended: card.options.find((o) => o.id === card.recommendation)?.text, depends: card.depends, raisedAt: card.raised_at ?? undefined, - deciderCount: card.decider_count, - answererCount: record.answerer_count, + deciderCount: validCount(card.decider_count, 1, 64), + answererCount: validCount(record.answerer_count, 0, Infinity), answers: record.answers.map((a) => answerRow(a, entry)), }); }); From 65d54d4bf4a913edde07d9a48d4ef133760da360 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 20:55:27 +0000 Subject: [PATCH 021/143] Navigator: the Browse open state is kept per screen class, like every other section Libby's read of #67: Browse had no phone/wide split, so opening it on one screen class opened it on the other. The store keeps its name and localStorage key (a rename would drop saved state); the comment says what a key means. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 8b8b3644..b560e08c 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -213,11 +213,14 @@ function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) /** * 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. + * remembered per device and screen class like any other section. */ function Browse({ tierId, groups }: { tierId: string; groups: EstateGroup[] }) { + const mobile = useScreenSizeContext() === ScreenSize.Mobile; const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom()); - const key = makeNavCategoryId('estate', 'browse', tierId); + // The store holds sections flipped from their default (it keeps Cinny's name, and its localStorage key). Browse's + // default is closed, so a key here means open; like Collapsible it is kept per screen class. + const key = makeNavCategoryId('estate', mobile ? 'phone' : 'wide', 'browse', tierId); const open = toggled.has(key); const count = groups.reduce((n, g) => n + g.rooms.length, 0); From 5e35bb6d76bf91cf27fa291d441919fe828fbc90 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 21:07:48 +0000 Subject: [PATCH 022/143] Pose thumbs: README and script comments state the real counts and CSS widths Co-Authored-By: Claude Sonnet 5.5 --- preview-demo/poses/README.md | 2 +- scripts/make-pose-thumbs.mjs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/preview-demo/poses/README.md b/preview-demo/poses/README.md index a1287f2d..2142a4c2 100644 --- a/preview-demo/poses/README.md +++ b/preview-demo/poses/README.md @@ -1,6 +1,6 @@ # Demo pose thumbnails -These 24 PNGs (about 200 KB, 160 px wide, palette-quantised) are downscaled copies of GameCult's own +These 36 PNGs (eureka, void and nibu, 12 each; about 1.8 MB, up to 480 px wide, palette-quantised) are downscaled copies of GameCult's own Persona art from `/opt/commons/cast-art/poses`. That directory carries no provenance or licence note: the image model that made the art and the terms it was made under are not recorded. diff --git a/scripts/make-pose-thumbs.mjs b/scripts/make-pose-thumbs.mjs index f78fc19e..b7acc050 100644 --- a/scripts/make-pose-thumbs.mjs +++ b/scripts/make-pose-thumbs.mjs @@ -1,5 +1,6 @@ // Regenerates preview-demo/poses from the cast-art poses: PNG thumbnails sized for the -// portrait's displayed width x a phone's device pixel ratio (not for the 124px desktop box). +// portrait's displayed width x device pixel ratio: the bubble portrait is 190 CSS px on desktop and +// min(38vw, 160px) on a phone (layout.css.ts), the desk booth 168 / 88 px (DeskView.css.ts). // Dependency-free: decodes 8-bit RGBA PNGs, area-averages in premultiplied alpha, writes a // 256-colour palette PNG with a tRNS alpha table. Usage: node scripts/make-pose-thumbs.mjs [src] import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; @@ -7,7 +8,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { inflateSync, deflateSync, crc32 } from 'node:zlib'; -const WIDTH = 480; // phone portrait: min(50vw, 210px) x DPR 2.5; art is never upscaled +const WIDTH = 480; // about the 190px desktop portrait x DPR 2.5; art is never upscaled const src = process.argv[2] ?? '/opt/commons/cast-art/poses'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const outDir = join(root, 'preview-demo/poses'); From 9c5af7aede6766ac1710b4e5dff5bafebafb35e0 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 21:08:15 +0000 Subject: [PATCH 023/143] config.json is the live chat.gamecult.org config Fetched from https://chat.gamecult.org/config.json 2026-11-11: one homeserver, custom homeservers off, gamecult featured space, hash router on, scene tier full with style terrain. dings and registration (keys the live release predates) keep the repo's off defaults. A release built from the repo now ships its own config. Co-Authored-By: Claude Sonnet 5.5 --- config.json | 31 +++++++++++-------------------- scripts/preview-config.js | 2 +- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/config.json b/config.json index 16955527..53e9e938 100644 --- a/config.json +++ b/config.json @@ -1,39 +1,30 @@ { "defaultHomeserver": 0, "homeserverList": [ - "chat.gamecult.org", - "gamecult.org", - "converser.eu", - "matrix.org", - "mozilla.org", - "unredacted.org", - "xmr.se" + "chat.gamecult.org" ], - "allowCustomHomeservers": true, + "allowCustomHomeservers": false, "featuredCommunities": { "openAsDefault": false, - "spaces": ["#gamecult:gamecult.org"], - "rooms": [ - "#tuwunel:grin.hu", - "#free-software-movement:matrix.org", - "#gentoo:matrix.org" + "spaces": [ + "#gamecult:gamecult.org" ], - "servers": ["activematrixrooms.com", "matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"] + "rooms": [], + "servers": [ + "gamecult.org" + ] }, - "hashRouter": { - "enabled": false, + "enabled": true, "basename": "/" }, - "backgroundScene": { - "tier": "full" + "tier": "full", + "style": "terrain" }, - "dings": { "enabled": false }, - "registration": { "enabled": false, "url": "" diff --git a/scripts/preview-config.js b/scripts/preview-config.js index dbd50049..73670f04 100644 --- a/scripts/preview-config.js +++ b/scripts/preview-config.js @@ -1,5 +1,5 @@ // Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json -// in the repo stays 'off'. Previews are static directories with no SPA fallback, so they also switch +// in the repo is the live chat.gamecult.org config. 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. With --demo it // also switches on the pretend homeserver (src/preview-demo): previewDemo true, and the pretend // server as the only homeserver. Usage: From 4b7697aaebd877f790665967bf475f6db8192735 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 21:08:53 +0000 Subject: [PATCH 024/143] preview-config: pin the preview scene style (ridge unless given) now that config.json says terrain Soul: the repo config now carries the live style, so previews would have turned terrain. Co-Authored-By: Claude Sonnet 5.5 --- scripts/preview-config.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/preview-config.js b/scripts/preview-config.js index 73670f04..0b7bc9e9 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 is the live chat.gamecult.org config. Previews are static directories with no SPA fallback, so they also switch +// in the repo is the live chat.gamecult.org config (style terrain), so previews pin the style too, ridge unless +// given. 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. With --demo it // also switches on the pretend homeserver (src/preview-demo): previewDemo true, and the pretend // server as the only homeserver. Usage: @@ -10,11 +11,11 @@ import { fileURLToPath } from 'url'; const args = process.argv.slice(2); const demo = args.includes('--demo'); -const [tier = 'low', style] = args.filter((a) => !a.startsWith('--')); +const [tier = 'low', style = 'ridge'] = args.filter((a) => !a.startsWith('--')); 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 } : {}) }; +config.backgroundScene = { ...config.backgroundScene, tier, style }; if (demo) { config.previewDemo = true; config.defaultHomeserver = 0; @@ -29,5 +30,5 @@ if (demo) { } fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); console.log( - `${file}: backgroundScene.tier = ${tier}${style ? `, style = ${style}` : ''}${demo ? ', previewDemo on' : ''}` + `${file}: backgroundScene.tier = ${tier}, style = ${style}${demo ? ', previewDemo on' : ''}` ); From af328bdb903d963b4ac5410e9ddec8d6b6862723 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 21:26:36 +0000 Subject: [PATCH 025/143] File zone: Mind contract; plaintext sha256 in info on send; 'read by ' from org.gamecult.file_received Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone-mind-contract.md | 80 +++++++++++++++++++++ src/app/features/albums/AlbumView.tsx | 6 ++ src/app/features/albums/buildAlbums.test.ts | 59 +++++++++++++++ src/app/features/albums/buildAlbums.ts | 41 +++++++++++ src/app/features/albums/fileReceipt.test.ts | 38 ++++++++++ src/app/features/albums/fileReceipt.ts | 29 ++++++++ src/app/features/room/RoomInput.tsx | 22 ++++-- 7 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 docs/file-zone-mind-contract.md create mode 100644 src/app/features/albums/fileReceipt.test.ts create mode 100644 src/app/features/albums/fileReceipt.ts diff --git a/docs/file-zone-mind-contract.md b/docs/file-zone-mind-contract.md new file mode 100644 index 00000000..2a52132d --- /dev/null +++ b/docs/file-zone-mind-contract.md @@ -0,0 +1,80 @@ +# File zone: what a Mind can read from a file event + +Ruling `ratatoskr:ruling:persona-file-sharing-zone`. The Mind-side fetch is the mouth's +(`eureka-body:cut_spec:mouth-fetch-attachment.r1`); this page is the client side of the same contract. +A Mind cannot fetch an `mxc://` itself (the media endpoint answers 401), so it never holds one. + +## What the Mind passes + +Room id and event id of the file event. Never a bare `mxc://`, URL or hash. The mouth fetches the event, +reads the media reference from its content, and enforces the Mind's own membership in that room at fetch +time. A room the Mind is not in is refused by the mouth, whatever the client wrote. + +## What the client writes (this repo, `RoomInput.tsx` send path) + +A file event is an `m.room.message` whose `msgtype` is `m.audio`, `m.image`, `m.video` or `m.file`: + +| field | where | note | +|---|---|---| +| `msgtype` | content | selects the kind from the file's mimetype prefix | +| `body`, `filename` | content | both the file's name (display only; never a path) | +| `url` | content | the `mxc://` of an unencrypted file; the mouth reads it | +| `file` | content | present instead of `url` in an encrypted room: `{url, key, iv, hashes.sha256 (ciphertext), v}`. A Mind has no key; the mouth refuses it | +| `info.mimetype` | content | the browser-reported type, normalised by `safeFile` (e.g. `application/ogg` becomes `audio/ogg`). A claim, not proof: the mouth sniffs magic bytes | +| `info.size` | content | bytes of the file as uploaded | +| `info["org.gamecult.sha256"]` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) | + +The hash is computed over the same bytes that are uploaded. A file sent by something other than this +client may lack `info["org.gamecult.sha256"]`; the mouth then logs the hash it computed and the receipt +below is displayed as unchecked. + +Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail +reference; audio carries no duration. A Mind should not rely on any of those. + +## Limits and refusals + +- Size: the mouth streams at most 5 MiB and refuses a larger file. The client does not enforce this cap; + its own limit is the homeserver's `m.upload.size`. A file over 5 MiB can be shared with people and + not read by a Mind. +- Refusals are the mouth's and are typed: not a member of the room, bridged or Discord room, encrypted + file, over the cap, content type not matching the magic bytes. The client does not re-check them and + nothing in the client depends on the mouth's log. +- The Mind cites files by room id and event id only; a file in a room the Mind is not in does not exist + for it. + +## Receipt: `org.gamecult.file_received` + +After a successful fetch the Mind posts one event of type `org.gamecult.file_received` to the room of the +file, as a reply in the album's thread, so it arrives with the album's own listing (no extra request): + +```json +{ + "m.relates_to": { "rel_type": "m.thread", "event_id": "" }, + "org.gamecult.file_received": { + "event_id": "", + "sha256": "", + "size": 123456 + } +} +``` + +No paths, no URLs, no filenames. The Mind is the event's `sender`. The receipt carries no `msgtype`, so +older clients and the album's file listing ignore it. + +The client draws it on the file row as `read by `: + +- one receipt per Mind per file, the latest wins, a second Mind adds a name; +- `sha256` equal to `info["org.gamecult.sha256"]`: plain `read by `; +- both present and different: `read by (hash differs)`; +- either missing: plain `read by `, unverified; +- a receipt whose `event_id` is not a file in that album, a malformed one, or a redacted one is ignored. + +A receipt is a claim by its sender and is shown to anyone in the room; any member could post one. +It proves nothing about who read the file. + +## Not built yet + +- Files sent from the composer carry no thread relation, so they reach an album only if written into a + thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Receipts are + thread replies, so they show only where the file is. +- Encrypted zones are not listed. diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index 28e0c477..18673d6e 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -15,6 +15,7 @@ import { getBlobSafeMimeType } from '../../utils/mimeTypes'; import { timeDayMonYear } from '../../utils/time'; import { getMemberDisplayName } from '../../utils/room'; import { Album, AlbumFile } from './buildAlbums'; +import { readLine } from './fileReceipt'; import { useAlbums } from './useAlbums'; import * as css from './AlbumView.css'; @@ -47,6 +48,11 @@ function FileRow({ room, file, author }: FileRowProps) { {file.name} + {file.readBy.length > 0 && ( + + {readLine(file.readBy, (id) => getMemberDisplayName(room, id) ?? id)} + + )} {audio ? ( } /> ) : ( diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index 3a27bed6..48617733 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -214,3 +214,62 @@ describe('buildAlbums', () => { expect(one(root('$q', 1), [threaded]).count).toBe(1); }); }); + +describe('Mind receipts', () => { + const HASH = 'a'.repeat(64); + const file = (id: string, sha?: string) => + reply(id, 10, 'm.audio', id, { info: sha ? { 'org.gamecult.sha256': sha } : {} }); + const receipt = (id: string, ts: number, sender: string, fileId: unknown, sha?: unknown) => + ({ + event_id: id, + type: 'org.gamecult.file_received', + sender, + origin_server_ts: ts, + content: { 'org.gamecult.file_received': { event_id: fileId, sha256: sha } }, + } as ZoneEvent); + + it('shows who read a file, with the hash agreeing', () => { + const a = one(root('$r', 1), [file('$f', HASH), receipt('$x', 20, '@void:x', '$f', HASH)]); + expect(a.files[0].readBy).toEqual([{ sender: '@void:x', ts: 20, hash: 'match' }]); + expect(a.count).toBe(1); + }); + it('flags a hash that differs, and leaves one unchecked when either side has none', () => { + const a = one(root('$r', 1), [ + file('$f', HASH), + file('$g'), + receipt('$x', 20, '@void:x', '$f', 'b'.repeat(64)), + receipt('$y', 21, '@void:x', '$g', HASH), + receipt('$z', 22, '@void:x', '$f', undefined), + ]); + expect(a.files.find((f) => f.id === '$g')?.readBy[0].hash).toBe('unchecked'); + expect(a.files.find((f) => f.id === '$f')?.readBy).toEqual([ + { sender: '@void:x', ts: 22, hash: 'unchecked' }, + ]); + }); + it('keeps one receipt per Mind, the latest, and a second Mind beside it', () => { + const a = one(root('$r', 1), [ + file('$f', HASH), + receipt('$1', 20, '@void:x', '$f', 'b'.repeat(64)), + receipt('$2', 30, '@void:x', '$f', HASH), + receipt('$3', 25, '@eureka:x', '$f', HASH), + ]); + expect(a.files[0].readBy.map((r) => [r.sender, r.hash])).toEqual([ + ['@eureka:x', 'match'], + ['@void:x', 'match'], + ]); + }); + it('ignores a receipt for a file not in the album, a malformed one, and a redacted one', () => { + const redacted = { + ...receipt('$d', 20, '@void:x', '$f', HASH), + unsigned: { redacted_because: {} }, + }; + const a = one(root('$r', 1), [ + file('$f', HASH), + receipt('$a', 20, '@void:x', '$elsewhere', HASH), + receipt('$b', 21, '@void:x', 42, HASH), + redacted, + ]); + expect(a.files[0].readBy).toEqual([]); + expect(a.count).toBe(1); + }); +}); diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 8e57262b..23c08543 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -1,6 +1,7 @@ // Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON // Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread, // into albums. See docs/file-zone.md for the rulings this encodes. +import { FILE_HASH_KEY, FILE_RECEIVED } from './fileReceipt'; /** The slice of a Matrix event this module reads. Every field may be missing. */ export type ZoneEvent = { @@ -16,6 +17,9 @@ export type AlbumInput = { root: ZoneEvent; replies: ZoneEvent[] }; export type FileKind = 'audio' | 'image' | 'video' | 'file'; +/** A Mind's receipt for one file. `hash` is whether its sha256 equals the one the file event carries. */ +export type FileReader = { sender: string; ts: number; hash: 'match' | 'differs' | 'unchecked' }; + export type AlbumFile = { id: string; kind: FileKind; @@ -23,6 +27,8 @@ export type AlbumFile = { ts: number; /** The event content, as RenderMessageContent reads it. */ content: Record; + /** Receipts in this album that name this file, one per sender (the latest), oldest first. */ + readBy: FileReader[]; }; export type Album = { @@ -70,9 +76,43 @@ const toFile = (e: ZoneEvent): AlbumFile | undefined => { name: asString(e.content.filename) || asString(e.content.body), ts: tsOf(e.origin_server_ts), content: e.content, + readBy: [], }; }; +// A receipt is a thread reply of type org.gamecult.file_received whose content names a file of this +// album; it carries no msgtype, so it never passes `live`. +const receiptOf = (e: ZoneEvent) => { + if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined; + if (e.unsigned?.redacted_because) return undefined; + const body = e.content?.[FILE_RECEIVED] as { event_id?: unknown; sha256?: unknown } | undefined; + if (typeof body?.event_id !== 'string' || typeof e.sender !== 'string') return undefined; + return { + file: body.event_id, + sender: e.sender, + ts: tsOf(e.origin_server_ts), + sha256: body.sha256, + }; +}; + +const attachReceipts = (files: AlbumFile[], replies: ZoneEvent[]) => { + const byId = new Map(files.map((f) => [f.id, f])); + replies + .map(receiptOf) + .filter((r): r is NonNullable => !!r) + .sort((a, b) => a.ts - b.ts) + .forEach((r) => { + const file = byId.get(r.file); + if (!file) return; + const sent = (file.content.info as Record | undefined)?.[FILE_HASH_KEY]; + let hash: FileReader['hash'] = 'unchecked'; + if (typeof sent === 'string' && typeof r.sha256 === 'string') + hash = sent === r.sha256 ? 'match' : 'differs'; + const others = file.readBy.filter((x) => x.sender !== r.sender); + file.readBy = [...others, { sender: r.sender, ts: r.ts, hash }]; + }); +}; + export const UNTITLED = 'Untitled album'; // An edited root carries its latest edit under unsigned["m.relations"]["m.replace"]; the title is @@ -96,6 +136,7 @@ const toAlbum = (input: AlbumInput): Album | undefined => { return true; }) .sort((a, b) => a.ts - b.ts); + attachReceipts(files, Array.isArray(replies) ? replies : []); return { id: root.event_id, title: titleOf(root), diff --git a/src/app/features/albums/fileReceipt.test.ts b/src/app/features/albums/fileReceipt.test.ts new file mode 100644 index 00000000..9612669e --- /dev/null +++ b/src/app/features/albums/fileReceipt.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { hashForSend, readLine, sha256Hex } from './fileReceipt'; + +describe('sha256Hex', () => { + it('hashes the bytes: the FIPS 180 vector for "abc"', async () => { + expect(await sha256Hex(new Blob(['abc']))).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' + ); + }); + it('hashes empty input', async () => { + expect(await sha256Hex(new Blob([]))).toBe( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + ); + }); +}); + +describe('hashForSend', () => { + it('gives no hash, and does not throw, when the blob cannot be read', async () => { + const broken = { arrayBuffer: () => Promise.reject(new Error('gone')) } as unknown as Blob; + expect(await hashForSend(broken)).toBeUndefined(); + }); +}); + +describe('readLine', () => { + const name = (s: string) => s.slice(1, s.indexOf(':')); + it('names each reader and flags a hash that differs', () => { + expect( + readLine( + [ + { sender: '@void:x', ts: 1, hash: 'match' }, + { sender: '@eureka:x', ts: 2, hash: 'differs' }, + { sender: '@aqua:x', ts: 3, hash: 'unchecked' }, + ], + name + ) + ).toBe('read by void, eureka (hash differs), aqua'); + }); +}); diff --git a/src/app/features/albums/fileReceipt.ts b/src/app/features/albums/fileReceipt.ts new file mode 100644 index 00000000..b6a79178 --- /dev/null +++ b/src/app/features/albums/fileReceipt.ts @@ -0,0 +1,29 @@ +// The file-zone Mind contract (docs/file-zone-mind-contract.md): the plaintext hash a sender writes +// into a file event, and the receipt a Mind answers with. + +import type { FileReader } from './buildAlbums'; + +/** Hex sha256 of the plaintext bytes, written at `content.info[FILE_HASH_KEY]` in unencrypted rooms. */ +export const FILE_HASH_KEY = 'org.gamecult.sha256'; + +/** A Mind's receipt: event type, and the content key holding { event_id, sha256, size }. */ +export const FILE_RECEIVED = 'org.gamecult.file_received'; + +export const sha256Hex = async (data: Blob): Promise => { + const digest = await crypto.subtle.digest('SHA-256', await data.arrayBuffer()); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join(''); +}; + +/** The hash to write for a file about to be sent in an unencrypted room; none when it cannot be computed. */ +export const hashForSend = async (data: Blob): Promise => { + try { + return await sha256Hex(data); + } catch { + return undefined; + } +}; + +export const readLine = (readBy: FileReader[], name: (sender: string) => string): string => + `read by ${readBy + .map((r) => (r.hash === 'differs' ? `${name(r.sender)} (hash differs)` : name(r.sender))) + .join(', ')}`; diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 46675428..3f0a45d4 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -99,6 +99,7 @@ import { getImageMsgContent, getVideoMsgContent, } from './msgContent'; +import { FILE_HASH_KEY, hashForSend } from '../albums/fileReceipt'; import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room'; import { CommandAutocomplete } from './CommandAutocomplete'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands'; @@ -280,16 +281,23 @@ export const RoomInput = forwardRef( const fileItem = selectedFiles.find((f) => f.file === upload.file); if (!fileItem) throw new Error('Broken upload'); + let content: IContent; if (fileItem.file.type.startsWith('image')) { - return getImageMsgContent(mx, fileItem, upload.mxc); + content = await getImageMsgContent(mx, fileItem, upload.mxc); + } else if (fileItem.file.type.startsWith('video')) { + content = await getVideoMsgContent(mx, fileItem, upload.mxc); + } else if (fileItem.file.type.startsWith('audio')) { + content = getAudioMsgContent(fileItem, upload.mxc); + } else { + content = getFileMsgContent(fileItem, upload.mxc); } - if (fileItem.file.type.startsWith('video')) { - return getVideoMsgContent(mx, fileItem, upload.mxc); + // A Mind verifies what it fetched against this. An encrypted file already carries the + // ciphertext hash in file.hashes; a plaintext hash would leak the file's identity. + if (!fileItem.encInfo) { + const sha256 = await hashForSend(fileItem.originalFile); + if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 }; } - if (fileItem.file.type.startsWith('audio')) { - return getAudioMsgContent(fileItem, upload.mxc); - } - return getFileMsgContent(fileItem, upload.mxc); + return content; }); handleCancelUpload(uploads); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); From c34e536132888fdf621086d6dd145e8050b44f05 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 21:27:24 +0000 Subject: [PATCH 026/143] File zone: a receipt counts only from a Mind's own sender and with the file's hash; info.sha256 Heimdall's two rules (#minds): trust the Matrix sender, never a field inside; read means the receipt hash equals the file event's. Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone-mind-contract.md | 51 +++++++++++---------- src/app/features/albums/buildAlbums.test.ts | 47 +++++++++++++------ src/app/features/albums/buildAlbums.ts | 28 ++++++----- src/app/features/albums/fileReceipt.test.ts | 11 ++--- src/app/features/albums/fileReceipt.ts | 8 ++-- 5 files changed, 87 insertions(+), 58 deletions(-) diff --git a/docs/file-zone-mind-contract.md b/docs/file-zone-mind-contract.md index 2a52132d..0a52ba76 100644 --- a/docs/file-zone-mind-contract.md +++ b/docs/file-zone-mind-contract.md @@ -14,19 +14,19 @@ time. A room the Mind is not in is refused by the mouth, whatever the client wro A file event is an `m.room.message` whose `msgtype` is `m.audio`, `m.image`, `m.video` or `m.file`: -| field | where | note | -|---|---|---| -| `msgtype` | content | selects the kind from the file's mimetype prefix | -| `body`, `filename` | content | both the file's name (display only; never a path) | -| `url` | content | the `mxc://` of an unencrypted file; the mouth reads it | -| `file` | content | present instead of `url` in an encrypted room: `{url, key, iv, hashes.sha256 (ciphertext), v}`. A Mind has no key; the mouth refuses it | -| `info.mimetype` | content | the browser-reported type, normalised by `safeFile` (e.g. `application/ogg` becomes `audio/ogg`). A claim, not proof: the mouth sniffs magic bytes | -| `info.size` | content | bytes of the file as uploaded | -| `info["org.gamecult.sha256"]` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) | +| field | where | note | +| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `msgtype` | content | selects the kind from the file's mimetype prefix | +| `body`, `filename` | content | both the file's name (display only; never a path) | +| `url` | content | the `mxc://` of an unencrypted file; the mouth reads it | +| `file` | content | present instead of `url` in an encrypted room: `{url, key, iv, hashes.sha256 (ciphertext), v}`. A Mind has no key; the mouth refuses it | +| `info.mimetype` | content | the browser-reported type, normalised by `safeFile` (e.g. `application/ogg` becomes `audio/ogg`). A claim, not proof: the mouth sniffs magic bytes | +| `info.size` | content | bytes of the file as uploaded | +| `info.sha256` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) | The hash is computed over the same bytes that are uploaded. A file sent by something other than this -client may lack `info["org.gamecult.sha256"]`; the mouth then logs the hash it computed and the receipt -below is displayed as unchecked. +client may lack `info.sha256`; the mouth then logs the hash it computed, and the row can show no `read by` +for it. Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail reference; audio carries no duration. A Mind should not rely on any of those. @@ -44,8 +44,8 @@ reference; audio carries no duration. A Mind should not rely on any of those. ## Receipt: `org.gamecult.file_received` -After a successful fetch the Mind posts one event of type `org.gamecult.file_received` to the room of the -file, as a reply in the album's thread, so it arrives with the album's own listing (no extra request): +The Mind posts one event of type `org.gamecult.file_received` after a successful fetch, to the room of the +file, as a reply in the album's thread so it arrives with the album's own listing (no extra request): ```json { @@ -58,19 +58,24 @@ file, as a reply in the album's thread, so it arrives with the album's own listi } ``` -No paths, no URLs, no filenames. The Mind is the event's `sender`. The receipt carries no `msgtype`, so -older clients and the album's file listing ignore it. +Two rules, both enforced on each side: -The client draws it on the file row as `read by `: +1. **The sender is the Mind.** The row trusts the Matrix `sender` of the receipt event and nothing inside it: + no Mind field is read. `read by X` counts only when the sender is X's own Matrix identity (the + localpart is a Mind in the Persona catalogue; a display name or a lookalike does not count). +2. **Read means verified.** The Mind posts only after the sha256 of the bytes it fetched equals the file + event's `info.sha256`, and the receipt carries that hash. On a mismatch it posts nothing (or a typed + refusal that the row does not draw). The row checks it again: the receipt's `sha256` must equal the + file event's `info.sha256`. A file event with no `info.sha256` can show no `read by`. -- one receipt per Mind per file, the latest wins, a second Mind adds a name; -- `sha256` equal to `info["org.gamecult.sha256"]`: plain `read by `; -- both present and different: `read by (hash differs)`; -- either missing: plain `read by `, unverified; -- a receipt whose `event_id` is not a file in that album, a malformed one, or a redacted one is ignored. +The receipt is inert: no paths, URLs, filenames or anything a browser would fetch, and the client reads only +`event_id`, `sha256` and the sender. It has no `msgtype`, so the album's file listing ignores it. The mouth's +log is the fuller record for Minds; this event is the public tick. -A receipt is a claim by its sender and is shown to anyone in the room; any member could post one. -It proves nothing about who read the file. +The row draws `read by `: one receipt per Mind per file (the latest), several Minds side by side. A +receipt for an event that is not a file of that album, a malformed one, and a redacted one are ignored. +The sender check keeps a member from ticking for a Mind; it does not prove the Mind read the file beyond +what the hash and the Mind's own account show. ## Not built yet diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index 48617733..db596613 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -218,44 +218,65 @@ describe('buildAlbums', () => { describe('Mind receipts', () => { const HASH = 'a'.repeat(64); const file = (id: string, sha?: string) => - reply(id, 10, 'm.audio', id, { info: sha ? { 'org.gamecult.sha256': sha } : {} }); - const receipt = (id: string, ts: number, sender: string, fileId: unknown, sha?: unknown) => + reply(id, 10, 'm.audio', id, { info: sha ? { sha256: sha } : {} }); + const receipt = ( + id: string, + ts: number, + sender: string, + fileId: unknown, + sha?: unknown, + extra: Record = {} + ) => ({ event_id: id, type: 'org.gamecult.file_received', sender, origin_server_ts: ts, - content: { 'org.gamecult.file_received': { event_id: fileId, sha256: sha } }, + content: { 'org.gamecult.file_received': { event_id: fileId, sha256: sha, ...extra } }, } as ZoneEvent); - it('shows who read a file, with the hash agreeing', () => { + it('shows who read a file when the receipt carries the file event hash', () => { const a = one(root('$r', 1), [file('$f', HASH), receipt('$x', 20, '@void:x', '$f', HASH)]); - expect(a.files[0].readBy).toEqual([{ sender: '@void:x', ts: 20, hash: 'match' }]); + expect(a.files[0].readBy).toEqual([{ sender: '@void:x', ts: 20 }]); expect(a.count).toBe(1); }); - it('flags a hash that differs, and leaves one unchecked when either side has none', () => { + it('counts nothing for a hash that differs, a missing hash on either side, or a bad hash', () => { const a = one(root('$r', 1), [ file('$f', HASH), file('$g'), receipt('$x', 20, '@void:x', '$f', 'b'.repeat(64)), receipt('$y', 21, '@void:x', '$g', HASH), receipt('$z', 22, '@void:x', '$f', undefined), + receipt('$w', 23, '@void:x', '$f', 7), ]); - expect(a.files.find((f) => f.id === '$g')?.readBy[0].hash).toBe('unchecked'); - expect(a.files.find((f) => f.id === '$f')?.readBy).toEqual([ - { sender: '@void:x', ts: 22, hash: 'unchecked' }, + expect(a.files.every((f) => f.readBy.length === 0)).toBe(true); + }); + it('trusts the event sender, never a Mind field inside the content', () => { + const a = one(root('$r', 1), [ + file('$f', HASH), + receipt('$x', 20, '@mallory:x', '$f', HASH, { mind: 'void', sender: '@void:x' }), ]); + expect(a.files[0].readBy).toEqual([]); + }); + it('counts only a Mind identity: the localpart, not a display name or a lookalike server part', () => { + const a = one(root('$r', 1), [ + file('$f', HASH), + receipt('$x', 20, '@void-fan:x', '$f', HASH), + receipt('$y', 21, '@x:void', '$f', HASH), + receipt('$z', 22, '@aqua:x', '$f', HASH), + ]); + expect(a.files[0].readBy.map((r) => r.sender)).toEqual(['@aqua:x']); }); it('keeps one receipt per Mind, the latest, and a second Mind beside it', () => { const a = one(root('$r', 1), [ file('$f', HASH), - receipt('$1', 20, '@void:x', '$f', 'b'.repeat(64)), + receipt('$1', 20, '@void:x', '$f', HASH), receipt('$2', 30, '@void:x', '$f', HASH), receipt('$3', 25, '@eureka:x', '$f', HASH), ]); - expect(a.files[0].readBy.map((r) => [r.sender, r.hash])).toEqual([ - ['@eureka:x', 'match'], - ['@void:x', 'match'], + expect(a.files[0].readBy).toEqual([ + { sender: '@eureka:x', ts: 25 }, + { sender: '@void:x', ts: 30 }, ]); }); it('ignores a receipt for a file not in the album, a malformed one, and a redacted one', () => { diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 23c08543..78c581c2 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -1,6 +1,7 @@ // Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON // Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread, // into albums. See docs/file-zone.md for the rulings this encodes. +import { MINDS } from '../../components/message/pose/poses'; import { FILE_HASH_KEY, FILE_RECEIVED } from './fileReceipt'; /** The slice of a Matrix event this module reads. Every field may be missing. */ @@ -17,8 +18,8 @@ export type AlbumInput = { root: ZoneEvent; replies: ZoneEvent[] }; export type FileKind = 'audio' | 'image' | 'video' | 'file'; -/** A Mind's receipt for one file. `hash` is whether its sha256 equals the one the file event carries. */ -export type FileReader = { sender: string; ts: number; hash: 'match' | 'differs' | 'unchecked' }; +/** A Mind's verified receipt for one file: its sender, who the Matrix server says posted it. */ +export type FileReader = { sender: string; ts: number }; export type AlbumFile = { id: string; @@ -86,7 +87,8 @@ const receiptOf = (e: ZoneEvent) => { if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined; if (e.unsigned?.redacted_because) return undefined; const body = e.content?.[FILE_RECEIVED] as { event_id?: unknown; sha256?: unknown } | undefined; - if (typeof body?.event_id !== 'string' || typeof e.sender !== 'string') return undefined; + if (typeof body?.event_id !== 'string' || typeof body.sha256 !== 'string') return undefined; + if (typeof e.sender !== 'string') return undefined; return { file: body.event_id, sender: e.sender, @@ -95,21 +97,25 @@ const receiptOf = (e: ZoneEvent) => { }; }; +// Only the event's sender counts, never a name inside the content, and only a Mind's own Matrix +// identity (localpart in the Persona catalogue). "Read" means verified: the receipt's hash must be +// the file event's own. +const isMind = (sender: string) => MINDS.includes(sender.replace(/^@/, '').split(':')[0]); + const attachReceipts = (files: AlbumFile[], replies: ZoneEvent[]) => { const byId = new Map(files.map((f) => [f.id, f])); replies .map(receiptOf) - .filter((r): r is NonNullable => !!r) + .filter((r): r is NonNullable => !!r && isMind(r.sender)) .sort((a, b) => a.ts - b.ts) .forEach((r) => { const file = byId.get(r.file); - if (!file) return; - const sent = (file.content.info as Record | undefined)?.[FILE_HASH_KEY]; - let hash: FileReader['hash'] = 'unchecked'; - if (typeof sent === 'string' && typeof r.sha256 === 'string') - hash = sent === r.sha256 ? 'match' : 'differs'; - const others = file.readBy.filter((x) => x.sender !== r.sender); - file.readBy = [...others, { sender: r.sender, ts: r.ts, hash }]; + const sent = (file?.content.info as Record | undefined)?.[FILE_HASH_KEY]; + if (!file || typeof sent !== 'string' || sent !== r.sha256) return; + file.readBy = [ + ...file.readBy.filter((x) => x.sender !== r.sender), + { sender: r.sender, ts: r.ts }, + ]; }); }; diff --git a/src/app/features/albums/fileReceipt.test.ts b/src/app/features/albums/fileReceipt.test.ts index 9612669e..01eb37c0 100644 --- a/src/app/features/albums/fileReceipt.test.ts +++ b/src/app/features/albums/fileReceipt.test.ts @@ -22,17 +22,16 @@ describe('hashForSend', () => { }); describe('readLine', () => { - const name = (s: string) => s.slice(1, s.indexOf(':')); - it('names each reader and flags a hash that differs', () => { + it('names each reader', () => { + const name = (s: string) => s.slice(1, s.indexOf(':')); expect( readLine( [ - { sender: '@void:x', ts: 1, hash: 'match' }, - { sender: '@eureka:x', ts: 2, hash: 'differs' }, - { sender: '@aqua:x', ts: 3, hash: 'unchecked' }, + { sender: '@void:x', ts: 1 }, + { sender: '@eureka:x', ts: 2 }, ], name ) - ).toBe('read by void, eureka (hash differs), aqua'); + ).toBe('read by void, eureka'); }); }); diff --git a/src/app/features/albums/fileReceipt.ts b/src/app/features/albums/fileReceipt.ts index b6a79178..9a4d0ee5 100644 --- a/src/app/features/albums/fileReceipt.ts +++ b/src/app/features/albums/fileReceipt.ts @@ -4,9 +4,9 @@ import type { FileReader } from './buildAlbums'; /** Hex sha256 of the plaintext bytes, written at `content.info[FILE_HASH_KEY]` in unencrypted rooms. */ -export const FILE_HASH_KEY = 'org.gamecult.sha256'; +export const FILE_HASH_KEY = 'sha256'; -/** A Mind's receipt: event type, and the content key holding { event_id, sha256, size }. */ +/** A Mind's receipt: event type, and the content key holding { event_id, sha256, size }. Inert: nothing in it is fetched or trusted but the sender and the hash. */ export const FILE_RECEIVED = 'org.gamecult.file_received'; export const sha256Hex = async (data: Blob): Promise => { @@ -24,6 +24,4 @@ export const hashForSend = async (data: Blob): Promise => { }; export const readLine = (readBy: FileReader[], name: (sender: string) => string): string => - `read by ${readBy - .map((r) => (r.hash === 'differs' ? `${name(r.sender)} (hash differs)` : name(r.sender))) - .join(', ')}`; + `read by ${readBy.map((r) => name(r.sender)).join(', ')}`; From 08ecacd4382740c16c082e9ed6adf744a3f78f8b Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 21:28:30 +0000 Subject: [PATCH 027/143] File zone: a receipt counts only from @:; doc says what info holds when media fails to load Soul: a federated @void:evil.example passed the localpart check. Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone-mind-contract.md | 6 +++--- src/app/features/albums/buildAlbums.test.ts | 6 +++++- src/app/features/albums/buildAlbums.ts | 17 +++++++++-------- src/app/features/albums/useAlbums.ts | 2 +- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/file-zone-mind-contract.md b/docs/file-zone-mind-contract.md index 0a52ba76..36bb7a80 100644 --- a/docs/file-zone-mind-contract.md +++ b/docs/file-zone-mind-contract.md @@ -24,11 +24,11 @@ A file event is an `m.room.message` whose `msgtype` is `m.audio`, `m.image`, `m. | `info.size` | content | bytes of the file as uploaded | | `info.sha256` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) | -The hash is computed over the same bytes that are uploaded. A file sent by something other than this +The hash is computed over the same bytes that are uploaded, reading the whole file into memory before send (large videos pay for it); if hashing fails the file is sent without one. A file sent by something other than this client may lack `info.sha256`; the mouth then logs the hash it computed, and the row can show no `read by` for it. -Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail +`info.mimetype` and `info.size` are written on audio and file events always; on image and video events `info` exists only if the browser could load the media, otherwise `info` holds `sha256` alone. Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail reference; audio carries no duration. A Mind should not rely on any of those. ## Limits and refusals @@ -62,7 +62,7 @@ Two rules, both enforced on each side: 1. **The sender is the Mind.** The row trusts the Matrix `sender` of the receipt event and nothing inside it: no Mind field is read. `read by X` counts only when the sender is X's own Matrix identity (the - localpart is a Mind in the Persona catalogue; a display name or a lookalike does not count). + full id is `@:` for a Mind in the Persona catalogue; a display name, a lookalike or another server's `@void:evil.example` does not count). 2. **Read means verified.** The Mind posts only after the sha256 of the bytes it fetched equals the file event's `info.sha256`, and the receipt carries that hash. On a mismatch it posts nothing (or a typed refusal that the row does not draw). The row checks it again: the receipt's `sha256` must equal the diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index db596613..407a7c87 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -235,6 +235,8 @@ describe('Mind receipts', () => { content: { 'org.gamecult.file_received': { event_id: fileId, sha256: sha, ...extra } }, } as ZoneEvent); + const one = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }], 'x')[0]; + it('shows who read a file when the receipt carries the file event hash', () => { const a = one(root('$r', 1), [file('$f', HASH), receipt('$x', 20, '@void:x', '$f', HASH)]); expect(a.files[0].readBy).toEqual([{ sender: '@void:x', ts: 20 }]); @@ -258,12 +260,14 @@ describe('Mind receipts', () => { ]); expect(a.files[0].readBy).toEqual([]); }); - it('counts only a Mind identity: the localpart, not a display name or a lookalike server part', () => { + it('counts only a Mind of this homeserver: not another server, a lookalike, or a bare name', () => { const a = one(root('$r', 1), [ file('$f', HASH), receipt('$x', 20, '@void-fan:x', '$f', HASH), receipt('$y', 21, '@x:void', '$f', HASH), receipt('$z', 22, '@aqua:x', '$f', HASH), + receipt('$e', 23, '@void:evil.example', '$f', HASH), + receipt('$b', 24, 'void:x', '$f', HASH), ]); expect(a.files[0].readBy.map((r) => r.sender)).toEqual(['@aqua:x']); }); diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 78c581c2..22a872ce 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -98,15 +98,15 @@ const receiptOf = (e: ZoneEvent) => { }; // Only the event's sender counts, never a name inside the content, and only a Mind's own Matrix -// identity (localpart in the Persona catalogue). "Read" means verified: the receipt's hash must be +// identity on this homeserver (`@:`, the mind named in the Persona catalogue). "Read" means verified: the receipt's hash must be // the file event's own. -const isMind = (sender: string) => MINDS.includes(sender.replace(/^@/, '').split(':')[0]); +const isMind = (sender: string, server: string) => MINDS.some((m) => sender === `@${m}:${server}`); -const attachReceipts = (files: AlbumFile[], replies: ZoneEvent[]) => { +const attachReceipts = (files: AlbumFile[], replies: ZoneEvent[], server: string) => { const byId = new Map(files.map((f) => [f.id, f])); replies .map(receiptOf) - .filter((r): r is NonNullable => !!r && isMind(r.sender)) + .filter((r): r is NonNullable => !!r && isMind(r.sender, server)) .sort((a, b) => a.ts - b.ts) .forEach((r) => { const file = byId.get(r.file); @@ -130,7 +130,7 @@ const titleOf = (root: ZoneEvent & { content: Record }): string return asString(edited) || asString(root.content.body) || UNTITLED; }; -const toAlbum = (input: AlbumInput): Album | undefined => { +const toAlbum = (input: AlbumInput, server: string): Album | undefined => { const { root, replies } = input ?? {}; if (!live(root)) return undefined; const seen = new Set(); @@ -142,7 +142,7 @@ const toAlbum = (input: AlbumInput): Album | undefined => { return true; }) .sort((a, b) => a.ts - b.ts); - attachReceipts(files, Array.isArray(replies) ? replies : []); + attachReceipts(files, Array.isArray(replies) ? replies : [], server); return { id: root.event_id, title: titleOf(root), @@ -155,10 +155,11 @@ const toAlbum = (input: AlbumInput): Album | undefined => { }; /** Newest album first; files oldest first. Loose room messages never reach this function. */ -export const buildAlbums = (inputs: AlbumInput[]): Album[] => { +/** `server` is the homeserver's own domain: only its Minds' receipts count. */ +export const buildAlbums = (inputs: AlbumInput[], server = ''): Album[] => { const seen = new Set(); return inputs - .map(toAlbum) + .map((i) => toAlbum(i, server)) .filter((a): a is Album => { if (!a || seen.has(a.id)) return false; seen.add(a.id); diff --git a/src/app/features/albums/useAlbums.ts b/src/app/features/albums/useAlbums.ts index a01f1efb..7c1d5a57 100644 --- a/src/app/features/albums/useAlbums.ts +++ b/src/app/features/albums/useAlbums.ts @@ -59,7 +59,7 @@ export const useAlbums = (room: Room): AlbumsState => { ) ).chunk ?? [], })); - return buildAlbums(inputs); + return buildAlbums(inputs, mx.getDomain() ?? ''); }, retry: 1, refetchInterval: REFRESH_MS, From 3971c72de79ff1987e9e9c120414356a435f4255 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 21:28:49 +0000 Subject: [PATCH 028/143] File zone tests: no shadowed helper name Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/albums/buildAlbums.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index 407a7c87..e98e4f51 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -235,15 +235,15 @@ describe('Mind receipts', () => { content: { 'org.gamecult.file_received': { event_id: fileId, sha256: sha, ...extra } }, } as ZoneEvent); - const one = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }], 'x')[0]; + const oneX = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }], 'x')[0]; it('shows who read a file when the receipt carries the file event hash', () => { - const a = one(root('$r', 1), [file('$f', HASH), receipt('$x', 20, '@void:x', '$f', HASH)]); + const a = oneX(root('$r', 1), [file('$f', HASH), receipt('$x', 20, '@void:x', '$f', HASH)]); expect(a.files[0].readBy).toEqual([{ sender: '@void:x', ts: 20 }]); expect(a.count).toBe(1); }); it('counts nothing for a hash that differs, a missing hash on either side, or a bad hash', () => { - const a = one(root('$r', 1), [ + const a = oneX(root('$r', 1), [ file('$f', HASH), file('$g'), receipt('$x', 20, '@void:x', '$f', 'b'.repeat(64)), @@ -254,14 +254,14 @@ describe('Mind receipts', () => { expect(a.files.every((f) => f.readBy.length === 0)).toBe(true); }); it('trusts the event sender, never a Mind field inside the content', () => { - const a = one(root('$r', 1), [ + const a = oneX(root('$r', 1), [ file('$f', HASH), receipt('$x', 20, '@mallory:x', '$f', HASH, { mind: 'void', sender: '@void:x' }), ]); expect(a.files[0].readBy).toEqual([]); }); it('counts only a Mind of this homeserver: not another server, a lookalike, or a bare name', () => { - const a = one(root('$r', 1), [ + const a = oneX(root('$r', 1), [ file('$f', HASH), receipt('$x', 20, '@void-fan:x', '$f', HASH), receipt('$y', 21, '@x:void', '$f', HASH), @@ -272,7 +272,7 @@ describe('Mind receipts', () => { expect(a.files[0].readBy.map((r) => r.sender)).toEqual(['@aqua:x']); }); it('keeps one receipt per Mind, the latest, and a second Mind beside it', () => { - const a = one(root('$r', 1), [ + const a = oneX(root('$r', 1), [ file('$f', HASH), receipt('$1', 20, '@void:x', '$f', HASH), receipt('$2', 30, '@void:x', '$f', HASH), @@ -288,7 +288,7 @@ describe('Mind receipts', () => { ...receipt('$d', 20, '@void:x', '$f', HASH), unsigned: { redacted_because: {} }, }; - const a = one(root('$r', 1), [ + const a = oneX(root('$r', 1), [ file('$f', HASH), receipt('$a', 20, '@void:x', '$elsewhere', HASH), receipt('$b', 21, '@void:x', 42, HASH), From 64ecdc03f78e0b11e893afebbd59ea7878b75389 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 22:08:12 +0000 Subject: [PATCH 029/143] config.json: ship the ridge scene style (Meta, 2026-11-11) Co-Authored-By: Claude Sonnet 5.5 --- config.json | 2 +- scripts/preview-config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config.json b/config.json index 53e9e938..ef2a7a94 100644 --- a/config.json +++ b/config.json @@ -20,7 +20,7 @@ }, "backgroundScene": { "tier": "full", - "style": "terrain" + "style": "ridge" }, "dings": { "enabled": false diff --git a/scripts/preview-config.js b/scripts/preview-config.js index 0b7bc9e9..29bfa793 100644 --- a/scripts/preview-config.js +++ b/scripts/preview-config.js @@ -1,5 +1,5 @@ // Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json -// in the repo is the live chat.gamecult.org config (style terrain), so previews pin the style too, ridge unless +// in the repo is the live chat.gamecult.org config (style ridge), and previews pin the style too, ridge unless // given. 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. With --demo it // also switches on the pretend homeserver (src/preview-demo): previewDemo true, and the pretend From 00890b210857a44531c0a4dd865b25b50f74982b Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 22:33:36 +0000 Subject: [PATCH 030/143] File zone: 'read by' finds receipts anywhere in the room, in the album listing and on the timeline row readersOf (fileReceipt.ts) is the one reader: a receipt is a thread reply or a plain room event, matched by content.event_id; the sender must be @:, the hash the file event's, an m.reference present must name the same event, one entry per Mind. The timeline file message draws it too. Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone-mind-contract.md | 16 ++- src/app/features/albums/AlbumView.tsx | 8 +- src/app/features/albums/FileReadBy.tsx | 30 +++++ src/app/features/albums/buildAlbums.ts | 49 +------- src/app/features/albums/fileReceipt.test.ts | 130 +++++++++++++++++++- src/app/features/albums/fileReceipt.ts | 64 +++++++++- src/app/features/albums/useFileReaders.ts | 50 ++++++++ src/app/features/room/RoomTimeline.tsx | 4 + 8 files changed, 295 insertions(+), 56 deletions(-) create mode 100644 src/app/features/albums/FileReadBy.tsx create mode 100644 src/app/features/albums/useFileReaders.ts diff --git a/docs/file-zone-mind-contract.md b/docs/file-zone-mind-contract.md index 36bb7a80..dd02a8fc 100644 --- a/docs/file-zone-mind-contract.md +++ b/docs/file-zone-mind-contract.md @@ -45,7 +45,9 @@ reference; audio carries no duration. A Mind should not rely on any of those. ## Receipt: `org.gamecult.file_received` The Mind posts one event of type `org.gamecult.file_received` after a successful fetch, to the room of the -file, as a reply in the album's thread so it arrives with the album's own listing (no extra request): +file. When the file has an album root the receipt is a reply in the album's thread, so it arrives with the album's +own listing; otherwise (a composer file sent with no thread) it is a plain room event with no `m.relates_to`. Both +count, and the client finds a receipt by its `event_id` anywhere in the room's loaded timeline or the album's thread: ```json { @@ -58,6 +60,12 @@ file, as a reply in the album's thread so it arrives with the album's own listin } ``` +A plain room-event receipt may also carry `m.relates_to: {rel_type: "m.reference", event_id: }`, which lets +the mouth find its earlier receipts through `/relations` for dedup. The client matches by `content.event_id` and reads no +relation but this one: if a `m.reference` is present its `event_id` must equal `content.event_id`, else the receipt counts +for nothing. A thread relation is not read. However many receipts a Mind has posted for a file, the row draws one +`read by` for that Mind. + Two rules, both enforced on each side: 1. **The sender is the Mind.** The row trusts the Matrix `sender` of the receipt event and nothing inside it: @@ -80,6 +88,8 @@ what the hash and the Mind's own account show. ## Not built yet - Files sent from the composer carry no thread relation, so they reach an album only if written into a - thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Receipts are - thread replies, so they show only where the file is. + thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Their receipts are plain + room events, and the timeline row for the file shows `read by` from them. +- The timeline row reads the room's loaded timeline only: a receipt older than what the client has paged in is not + seen there (an album's own listing reads the thread, which is paged separately). - Encrypted zones are not listed. diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index 18673d6e..1bf30579 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -15,7 +15,7 @@ import { getBlobSafeMimeType } from '../../utils/mimeTypes'; import { timeDayMonYear } from '../../utils/time'; import { getMemberDisplayName } from '../../utils/room'; import { Album, AlbumFile } from './buildAlbums'; -import { readLine } from './fileReceipt'; +import { FileReadBy } from './FileReadBy'; import { useAlbums } from './useAlbums'; import * as css from './AlbumView.css'; @@ -48,11 +48,7 @@ function FileRow({ room, file, author }: FileRowProps) { {file.name} - {file.readBy.length > 0 && ( - - {readLine(file.readBy, (id) => getMemberDisplayName(room, id) ?? id)} - - )} + {audio ? ( } /> ) : ( diff --git a/src/app/features/albums/FileReadBy.tsx b/src/app/features/albums/FileReadBy.tsx new file mode 100644 index 00000000..0c81849a --- /dev/null +++ b/src/app/features/albums/FileReadBy.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { MatrixEvent, Room } from 'matrix-js-sdk'; +import { Text } from 'folds'; +import { getMemberDisplayName } from '../../utils/room'; +import { FileReader, readLine } from './fileReceipt'; +import { useFileReaders } from './useFileReaders'; +import * as css from './AlbumView.css'; + +type FileReadByProps = { + room: Room; + fileId: string; + content: Record; + /** Receipts already read elsewhere (an album's thread replies): merged with the timeline's, a Mind once. */ + also?: FileReader[]; +}; +/** "read by " under a file: the Minds whose verified receipts are in the room's timeline. Nothing when none. */ +export function FileReadBy({ room, fileId, content, also = [] }: FileReadByProps) { + const seen = useFileReaders(room, fileId, content); + const readers = [...new Map([...also, ...seen].map((r) => [r.sender, r])).values()]; + if (readers.length === 0) return null; + return ( + + {readLine(readers, (id) => getMemberDisplayName(room, id) ?? id)} + + ); +} + +/** A timeline message that is a file: audio, image, video or file. */ +export const isFileMessage = (mEvent: MatrixEvent): boolean => + ['m.audio', 'm.image', 'm.video', 'm.file'].includes(mEvent.getContent().msgtype); diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 22a872ce..afd764e1 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -1,8 +1,8 @@ // Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON // Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread, // into albums. See docs/file-zone.md for the rulings this encodes. -import { MINDS } from '../../components/message/pose/poses'; -import { FILE_HASH_KEY, FILE_RECEIVED } from './fileReceipt'; +import { readersOf } from './fileReceipt'; +import type { FileReader } from './fileReceipt'; /** The slice of a Matrix event this module reads. Every field may be missing. */ export type ZoneEvent = { @@ -18,9 +18,6 @@ export type AlbumInput = { root: ZoneEvent; replies: ZoneEvent[] }; export type FileKind = 'audio' | 'image' | 'video' | 'file'; -/** A Mind's verified receipt for one file: its sender, who the Matrix server says posted it. */ -export type FileReader = { sender: string; ts: number }; - export type AlbumFile = { id: string; kind: FileKind; @@ -81,44 +78,6 @@ const toFile = (e: ZoneEvent): AlbumFile | undefined => { }; }; -// A receipt is a thread reply of type org.gamecult.file_received whose content names a file of this -// album; it carries no msgtype, so it never passes `live`. -const receiptOf = (e: ZoneEvent) => { - if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined; - if (e.unsigned?.redacted_because) return undefined; - const body = e.content?.[FILE_RECEIVED] as { event_id?: unknown; sha256?: unknown } | undefined; - if (typeof body?.event_id !== 'string' || typeof body.sha256 !== 'string') return undefined; - if (typeof e.sender !== 'string') return undefined; - return { - file: body.event_id, - sender: e.sender, - ts: tsOf(e.origin_server_ts), - sha256: body.sha256, - }; -}; - -// Only the event's sender counts, never a name inside the content, and only a Mind's own Matrix -// identity on this homeserver (`@:`, the mind named in the Persona catalogue). "Read" means verified: the receipt's hash must be -// the file event's own. -const isMind = (sender: string, server: string) => MINDS.some((m) => sender === `@${m}:${server}`); - -const attachReceipts = (files: AlbumFile[], replies: ZoneEvent[], server: string) => { - const byId = new Map(files.map((f) => [f.id, f])); - replies - .map(receiptOf) - .filter((r): r is NonNullable => !!r && isMind(r.sender, server)) - .sort((a, b) => a.ts - b.ts) - .forEach((r) => { - const file = byId.get(r.file); - const sent = (file?.content.info as Record | undefined)?.[FILE_HASH_KEY]; - if (!file || typeof sent !== 'string' || sent !== r.sha256) return; - file.readBy = [ - ...file.readBy.filter((x) => x.sender !== r.sender), - { sender: r.sender, ts: r.ts }, - ]; - }); -}; - export const UNTITLED = 'Untitled album'; // An edited root carries its latest edit under unsigned["m.relations"]["m.replace"]; the title is @@ -141,8 +100,8 @@ const toAlbum = (input: AlbumInput, server: string): Album | undefined => { seen.add(f.id); return true; }) - .sort((a, b) => a.ts - b.ts); - attachReceipts(files, Array.isArray(replies) ? replies : [], server); + .sort((a, b) => a.ts - b.ts) + .map((f) => ({ ...f, readBy: readersOf(replies, f.id, f.content, server) })); return { id: root.event_id, title: titleOf(root), diff --git a/src/app/features/albums/fileReceipt.test.ts b/src/app/features/albums/fileReceipt.test.ts index 01eb37c0..39386e4f 100644 --- a/src/app/features/albums/fileReceipt.test.ts +++ b/src/app/features/albums/fileReceipt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { hashForSend, readLine, sha256Hex } from './fileReceipt'; +import { ReceiptEvent, hashForSend, readLine, readersOf, sha256Hex } from './fileReceipt'; describe('sha256Hex', () => { it('hashes the bytes: the FIPS 180 vector for "abc"', async () => { @@ -35,3 +35,131 @@ describe('readLine', () => { ).toBe('read by void, eureka'); }); }); + +describe('readersOf: receipts anywhere in the room, thread reply or plain event', () => { + const HASH = 'a'.repeat(64); + const file = { info: { sha256: HASH } }; + const receipt = ( + id: string, + ts: number, + sender: string, + sha: unknown | undefined, + fileId: string | undefined, + relates?: object + ): ReceiptEvent => ({ + event_id: id, + type: 'org.gamecult.file_received', + sender, + origin_server_ts: ts, + content: { + ...(relates ? { 'm.relates_to': relates } : {}), + 'org.gamecult.file_received': { + event_id: fileId ?? '$f', + sha256: sha === undefined ? HASH : sha, + }, + }, + }); + const readers = (events: ReceiptEvent[], content: Record | undefined) => + readersOf(events, '$f', content, 'gamecult.org').map((r) => r.sender); + + it('counts a threadless receipt, as a composer file with no album root gets', () => { + expect(readers([receipt('$1', 5, '@void:gamecult.org', undefined, undefined)], file)).toEqual([ + '@void:gamecult.org', + ]); + }); + it('counts a thread reply the same way', () => { + const t = { rel_type: 'm.thread', event_id: '$root' }; + expect(readers([receipt('$1', 5, '@eureka:gamecult.org', HASH, '$f', t)], file)).toEqual([ + '@eureka:gamecult.org', + ]); + }); + it('refuses lookalike localparts and foreign servers', () => { + expect( + readers( + [ + receipt('$1', 1, '@void2:gamecult.org', undefined, undefined), + receipt('$2', 2, '@voidd:gamecult.org', undefined, undefined), + receipt('$3', 3, '@Void:gamecult.org', undefined, undefined), + receipt('$4', 4, '@void:evil.example', undefined, undefined), + receipt('$5', 5, '@void:gamecult.org.evil.example', undefined, undefined), + receipt('$6', 6, '@mallory:gamecult.org', undefined, undefined), + ], + file + ) + ).toEqual([]); + }); + it('shows nothing for a mismatched hash, a file with no hash, or another file', () => { + expect( + readers([receipt('$1', 1, '@void:gamecult.org', 'b'.repeat(64), undefined)], file) + ).toEqual([]); + expect( + readers([receipt('$1', 1, '@void:gamecult.org', undefined, undefined)], { info: {} }) + ).toEqual([]); + expect( + readers([receipt('$1', 1, '@void:gamecult.org', undefined, undefined)], undefined) + ).toEqual([]); + expect(readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$other')], file)).toEqual([]); + }); + it('keeps one entry per Mind, the latest, oldest first', () => { + expect( + readersOf( + [ + receipt('$1', 30, '@void:gamecult.org', undefined, undefined), + receipt('$2', 10, '@void:gamecult.org', undefined, undefined), + receipt('$3', 20, '@aqua:gamecult.org', undefined, undefined), + ], + '$f', + file, + 'gamecult.org' + ) + ).toEqual([ + { sender: '@aqua:gamecult.org', ts: 20 }, + { sender: '@void:gamecult.org', ts: 30 }, + ]); + }); + it('ignores a redacted receipt and a malformed one', () => { + const redacted = { + ...receipt('$1', 1, '@void:gamecult.org', undefined, undefined), + unsigned: { redacted_because: {} }, + }; + expect( + readers( + [ + redacted, + receipt('$2', 2, '@void:gamecult.org', 7, undefined), + { + ...receipt('$3', 3, '@void:gamecult.org', undefined, undefined), + type: 'm.room.message', + }, + ], + file + ) + ).toEqual([]); + }); + + describe('an m.reference to the file', () => { + const ref = (id: string) => ({ rel_type: 'm.reference', event_id: id }); + it('counts when it names the same event as the content', () => { + expect( + readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$f'))], file) + ).toEqual(['@void:gamecult.org']); + }); + it('counts for nothing when it names another event', () => { + expect( + readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$other'))], file) + ).toEqual([]); + }); + it('draws one entry per Mind however many receipts exist', () => { + expect( + readers( + [ + receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$f')), + receipt('$2', 2, '@void:gamecult.org', HASH, '$f'), + receipt('$3', 3, '@void:gamecult.org', HASH, '$f', ref('$f')), + ], + file + ) + ).toEqual(['@void:gamecult.org']); + }); + }); +}); diff --git a/src/app/features/albums/fileReceipt.ts b/src/app/features/albums/fileReceipt.ts index 9a4d0ee5..053570e9 100644 --- a/src/app/features/albums/fileReceipt.ts +++ b/src/app/features/albums/fileReceipt.ts @@ -1,7 +1,20 @@ // The file-zone Mind contract (docs/file-zone-mind-contract.md): the plaintext hash a sender writes // into a file event, and the receipt a Mind answers with. -import type { FileReader } from './buildAlbums'; +import { MINDS } from '../../components/message/pose/poses'; + +/** A Mind's verified receipt for one file: its sender, who the Matrix server says posted it. */ +export type FileReader = { sender: string; ts: number }; + +/** The slice of a Matrix event a receipt is read from. Every field may be missing. */ +export type ReceiptEvent = { + event_id?: string; + type?: string; + sender?: string; + origin_server_ts?: number; + content?: Record; + unsigned?: Record; +}; /** Hex sha256 of the plaintext bytes, written at `content.info[FILE_HASH_KEY]` in unencrypted rooms. */ export const FILE_HASH_KEY = 'sha256'; @@ -25,3 +38,52 @@ export const hashForSend = async (data: Blob): Promise => { export const readLine = (readBy: FileReader[], name: (sender: string) => string): string => `read by ${readBy.map((r) => name(r.sender)).join(', ')}`; + +const receiptOf = (e: ReceiptEvent) => { + if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined; + if (e.unsigned?.redacted_because) return undefined; + const body = e.content?.[FILE_RECEIVED] as { event_id?: unknown; sha256?: unknown } | undefined; + if (typeof body?.event_id !== 'string' || typeof body.sha256 !== 'string') return undefined; + if (typeof e.sender !== 'string') return undefined; + // A reference to the file (Eureka's dedup, via /relations) must name the same event as the content, or the + // receipt counts for nothing. Matching is by content.event_id; other relations (a thread) are not read. + const rel = e.content?.['m.relates_to'] as { rel_type?: unknown; event_id?: unknown } | undefined; + if (rel?.rel_type === 'm.reference' && rel.event_id !== body.event_id) return undefined; + const ts = e.origin_server_ts; + return { + file: body.event_id, + sender: e.sender, + ts: typeof ts === 'number' && Number.isFinite(ts) ? ts : 0, + sha256: body.sha256, + }; +}; + +// Only the event's sender counts, never a name inside the content, and only a Mind's own Matrix +// identity on this homeserver (`@:`, a mind named in the Persona catalogue). +const isMind = (sender: string, server: string) => MINDS.some((m) => sender === `@${m}:${server}`); + +/** + * Who has read the file `fileId`, from receipts found anywhere in `events` (an album's thread replies, or the room's + * timeline: a receipt is a thread reply when the file has an album root, otherwise a plain room event; both count). + * "Read" means verified: the receipt's sha256 must equal the file event's `info.sha256`, so a file with no hash, or a + * mismatch, shows nothing. One entry per Mind (its latest), oldest first. + */ +export const readersOf = ( + events: ReceiptEvent[], + fileId: string, + fileContent: Record | undefined, + server: string +): FileReader[] => { + const sent = (fileContent?.info as Record | undefined)?.[FILE_HASH_KEY]; + if (typeof sent !== 'string') return []; + const latest = new Map(); + events + .map(receiptOf) + .filter( + (r): r is NonNullable => + !!r && r.file === fileId && r.sha256 === sent && isMind(r.sender, server) + ) + .sort((a, b) => a.ts - b.ts) + .forEach((r) => latest.set(r.sender, { sender: r.sender, ts: r.ts })); + return [...latest.values()].sort((a, b) => a.ts - b.ts); +}; diff --git a/src/app/features/albums/useFileReaders.ts b/src/app/features/albums/useFileReaders.ts new file mode 100644 index 00000000..b39471c8 --- /dev/null +++ b/src/app/features/albums/useFileReaders.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; +import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { FILE_RECEIVED, FileReader, ReceiptEvent, readersOf } from './fileReceipt'; + +const asReceiptEvent = (e: MatrixEvent): ReceiptEvent => ({ + event_id: e.getId(), + type: e.getType(), + sender: e.getSender(), + origin_server_ts: e.getTs(), + content: e.getContent(), + unsigned: e.isRedacted() ? { redacted_because: true } : undefined, +}); + +/** + * Who has read a file, from the receipts in the room's loaded timeline: a plain room event or a thread reply, both + * count (docs/file-zone-mind-contract.md). `content` is the file event's content, which carries its `info.sha256`. + */ +export const useFileReaders = ( + room: Room, + fileId: string, + content: Record +): FileReader[] => { + const mx = useMatrixClient(); + const server = mx.getDomain() ?? ''; + const [readers, setReaders] = useState([]); + useEffect(() => { + const scan = () => { + const receipts = room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter((e) => e.getType() === FILE_RECEIVED) + .map(asReceiptEvent); + const next = readersOf(receipts, fileId, content, server); + setReaders((cur) => + cur.length === next.length && + cur.every((r, i) => r.sender === next[i].sender && r.ts === next[i].ts) + ? cur + : next + ); + }; + scan(); + room.on(RoomEvent.Timeline, scan); + return () => { + room.off(RoomEvent.Timeline, scan); + }; + }, [room, fileId, content, server]); + return readers; +}; diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 6cce595e..e9c13e2a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -106,6 +106,7 @@ import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/mat import { useKeyDown } from '../../hooks/useKeyDown'; import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; import { RenderMessageContent } from '../../components/RenderMessageContent'; +import { FileReadBy, isFileMessage } from '../albums/FileReadBy'; import { Image } from '../../components/media'; import { ImageViewer } from '../../components/image-viewer'; import { roomToParentsAtom } from '../../state/room/roomToParents'; @@ -1111,6 +1112,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli outlineAttachment={messageLayout === MessageLayout.Bubble} /> )} + {!mEvent.isRedacted() && isFileMessage(mEvent) && ( + + )} ); }, From da2445044da5dd4f9427e8ceb0614802c0c74408 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 22:34:39 +0000 Subject: [PATCH 031/143] File zone: the readers hook rescans on redaction and reset; replies guard is one local; reader order by time Soul findings 1, 2 (doc), 4 and the merge order. Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone-mind-contract.md | 2 ++ src/app/features/albums/FileReadBy.tsx | 4 +++- src/app/features/albums/buildAlbums.ts | 5 +++-- src/app/features/albums/useFileReaders.ts | 6 ++++-- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/file-zone-mind-contract.md b/docs/file-zone-mind-contract.md index dd02a8fc..00f73a4a 100644 --- a/docs/file-zone-mind-contract.md +++ b/docs/file-zone-mind-contract.md @@ -90,6 +90,8 @@ what the hash and the Mind's own account show. - Files sent from the composer carry no thread relation, so they reach an album only if written into a thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Their receipts are plain room events, and the timeline row for the file shows `read by` from them. +- A receipt that is still encrypted when it arrives is not seen until a later timeline event triggers a rescan; zones are + unencrypted and the hash is written only there, so this is the encrypted-room limit again. - The timeline row reads the room's loaded timeline only: a receipt older than what the client has paged in is not seen there (an album's own listing reads the thread, which is paged separately). - Encrypted zones are not listed. diff --git a/src/app/features/albums/FileReadBy.tsx b/src/app/features/albums/FileReadBy.tsx index 0c81849a..add99658 100644 --- a/src/app/features/albums/FileReadBy.tsx +++ b/src/app/features/albums/FileReadBy.tsx @@ -16,7 +16,9 @@ type FileReadByProps = { /** "read by " under a file: the Minds whose verified receipts are in the room's timeline. Nothing when none. */ export function FileReadBy({ room, fileId, content, also = [] }: FileReadByProps) { const seen = useFileReaders(room, fileId, content); - const readers = [...new Map([...also, ...seen].map((r) => [r.sender, r])).values()]; + const readers = [...new Map([...also, ...seen].map((r) => [r.sender, r])).values()].sort( + (a, b) => a.ts - b.ts + ); if (readers.length === 0) return null; return ( diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index afd764e1..6fe8a452 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -93,7 +93,8 @@ const toAlbum = (input: AlbumInput, server: string): Album | undefined => { const { root, replies } = input ?? {}; if (!live(root)) return undefined; const seen = new Set(); - const files = (Array.isArray(replies) ? replies : []) + const list = Array.isArray(replies) ? replies : []; + const files = list .map(toFile) .filter((f): f is AlbumFile => { if (!f || seen.has(f.id)) return false; @@ -101,7 +102,7 @@ const toAlbum = (input: AlbumInput, server: string): Album | undefined => { return true; }) .sort((a, b) => a.ts - b.ts) - .map((f) => ({ ...f, readBy: readersOf(replies, f.id, f.content, server) })); + .map((f) => ({ ...f, readBy: readersOf(list, f.id, f.content, server) })); return { id: root.event_id, title: titleOf(root), diff --git a/src/app/features/albums/useFileReaders.ts b/src/app/features/albums/useFileReaders.ts index b39471c8..76517600 100644 --- a/src/app/features/albums/useFileReaders.ts +++ b/src/app/features/albums/useFileReaders.ts @@ -41,9 +41,11 @@ export const useFileReaders = ( ); }; scan(); - room.on(RoomEvent.Timeline, scan); + // A redaction removes a tick and a reset drops the window: both rescan, not only a new event. + const events = [RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset] as const; + events.forEach((ev) => room.on(ev, scan)); return () => { - room.off(RoomEvent.Timeline, scan); + events.forEach((ev) => room.off(ev, scan)); }; }, [room, fileId, content, server]); return readers; From 1c5331da4d03483a9c4e6cbf71e62e5d9b5a9da8 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 22:41:35 +0000 Subject: [PATCH 032/143] File zone: one room subscription for every 'read by' row receiptStore.ts holds one Timeline/Redaction/TimelineReset listener set per room while any row is mounted and a snapshot of the receipt events; useFileReaders derives each row's readers from it with readersOf (same rules). 200 rows register one listener per event, not 200. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/albums/receiptStore.test.ts | 116 +++++++++++++++++++ src/app/features/albums/receiptStore.ts | 79 +++++++++++++ src/app/features/albums/useFileReaders.ts | 49 ++------ 3 files changed, 206 insertions(+), 38 deletions(-) create mode 100644 src/app/features/albums/receiptStore.test.ts create mode 100644 src/app/features/albums/receiptStore.ts diff --git a/src/app/features/albums/receiptStore.test.ts b/src/app/features/albums/receiptStore.test.ts new file mode 100644 index 00000000..c97202e5 --- /dev/null +++ b/src/app/features/albums/receiptStore.test.ts @@ -0,0 +1,116 @@ +import { EventEmitter } from 'events'; +import { describe, expect, it, vi } from 'vitest'; +import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk'; +import { receiptStoreOf } from './receiptStore'; +import { readersOf } from './fileReceipt'; + +const HASH = 'a'.repeat(64); + +type Fake = { + id: string; + type: string; + sender: string; + ts: number; + content: object; + redacted?: boolean; +}; +const event = (f: Fake) => + ({ + getId: () => f.id, + getType: () => f.type, + getSender: () => f.sender, + getTs: () => f.ts, + getContent: () => f.content, + isRedacted: () => !!f.redacted, + } as unknown as MatrixEvent); +const receipt = (id: string, sender: string, fileId = '$f', over: Partial = {}): Fake => ({ + id, + type: 'org.gamecult.file_received', + sender, + ts: 1, + content: { 'org.gamecult.file_received': { event_id: fileId, sha256: HASH } }, + ...over, +}); + +const fakeRoom = (initial: Fake[] = []) => { + const emitter = new EventEmitter(); + emitter.setMaxListeners(0); + let events = initial; + const room = Object.assign(emitter, { + getUnfilteredTimelineSet: () => ({ + getLiveTimeline: () => ({ getEvents: () => events.map(event) }), + }), + }) as unknown as Room; + return { + room, + emitter, + set: (next: Fake[]) => { + events = next; + }, + }; +}; +const listeners = (e: EventEmitter) => + [RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset].map((n) => e.listenerCount(n)); + +describe('receiptStoreOf: one subscription per room', () => { + it('registers one listener per event for 200 file rows, and none once they are gone', () => { + const { room, emitter } = fakeRoom(); + const store = receiptStoreOf(room); + const off = Array.from({ length: 200 }, () => store.subscribe(() => undefined)); + expect(listeners(emitter)).toEqual([1, 1, 1]); + off.forEach((o) => o()); + expect(listeners(emitter)).toEqual([0, 0, 0]); + }); + + it('is one store per room', () => { + const a = fakeRoom(); + const b = fakeRoom(); + expect(receiptStoreOf(a.room)).toBe(receiptStoreOf(a.room)); + expect(receiptStoreOf(a.room)).not.toBe(receiptStoreOf(b.room)); + }); + + it('notifies rows when a receipt arrives, not for unrelated timeline events, and gives readersOf what it needs', () => { + const { room, emitter, set } = fakeRoom(); + const store = receiptStoreOf(room); + const cb = vi.fn(); + store.subscribe(cb); + expect(store.getSnapshot()).toEqual([]); + set([{ id: '$m', type: 'm.room.message', sender: '@x:gamecult.org', ts: 1, content: {} }]); + emitter.emit(RoomEvent.Timeline); + expect(cb).not.toHaveBeenCalled(); + set([receipt('$r', '@void:gamecult.org')]); + emitter.emit(RoomEvent.Timeline); + expect(cb).toHaveBeenCalledTimes(1); + const snap = store.getSnapshot(); + expect( + readersOf(snap, '$f', { info: { sha256: HASH } }, 'gamecult.org').map((r) => r.sender) + ).toEqual(['@void:gamecult.org']); + emitter.emit(RoomEvent.Timeline); + expect(store.getSnapshot()).toBe(snap); + }); + + it('rescans on a redaction and on a timeline reset', () => { + const { room, emitter, set } = fakeRoom([receipt('$r', '@void:gamecult.org')]); + const store = receiptStoreOf(room); + const cb = vi.fn(); + store.subscribe(cb); + expect(store.getSnapshot()).toHaveLength(1); + set([receipt('$r', '@void:gamecult.org', '$f', { redacted: true })]); + emitter.emit(RoomEvent.Redaction); + expect(cb).toHaveBeenCalledTimes(1); + expect( + readersOf(store.getSnapshot(), '$f', { info: { sha256: HASH } }, 'gamecult.org') + ).toEqual([]); + set([]); + emitter.emit(RoomEvent.TimelineReset); + expect(cb).toHaveBeenCalledTimes(2); + expect(store.getSnapshot()).toEqual([]); + }); + + it('picks up what is already loaded when the first row subscribes', () => { + const { room } = fakeRoom([receipt('$r', '@aqua:gamecult.org')]); + const store = receiptStoreOf(room); + store.subscribe(() => undefined); + expect(store.getSnapshot()).toHaveLength(1); + }); +}); diff --git a/src/app/features/albums/receiptStore.ts b/src/app/features/albums/receiptStore.ts new file mode 100644 index 00000000..1f69aba9 --- /dev/null +++ b/src/app/features/albums/receiptStore.ts @@ -0,0 +1,79 @@ +import type { MatrixEvent, Room } from 'matrix-js-sdk'; +import { RoomEvent } from 'matrix-js-sdk'; +import { FILE_RECEIVED, ReceiptEvent } from './fileReceipt'; + +/** + * One subscription per room to the file receipts in its loaded timeline, shared by every file row: a row costs a + * callback in a Set, not a Room listener (matrix-js-sdk warns past 100 listeners). The snapshot is the receipt events + * themselves; each row derives its own readers from it with `readersOf`, so the rules (sender, hash, reference, one per + * Mind) live in one place and the store holds no verdicts. The snapshot changes identity only when the set of receipts + * or one's redaction state does, so rows re-render for a receipt, not for every timeline event. + */ +export type ReceiptStore = { + subscribe: (onChange: () => void) => () => void; + getSnapshot: () => ReceiptEvent[]; +}; + +const asReceiptEvent = (e: MatrixEvent): ReceiptEvent => ({ + event_id: e.getId(), + type: e.getType(), + sender: e.getSender(), + origin_server_ts: e.getTs(), + content: e.getContent(), + unsigned: e.isRedacted() ? { redacted_because: true } : undefined, +}); + +const same = (a: ReceiptEvent[], b: ReceiptEvent[]): boolean => + a.length === b.length && + a.every( + (r, i) => + r.event_id === b[i].event_id && + !r.unsigned?.redacted_because === !b[i].unsigned?.redacted_because + ); + +// A redaction removes a tick and a reset drops the window: both rescan, not only a new event. +const EVENTS = [RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset] as const; + +const make = (room: Room): ReceiptStore => { + const subscribers = new Set<() => void>(); + let snapshot: ReceiptEvent[] = []; + const read = (): boolean => { + const next = room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter((e) => e.getType() === FILE_RECEIVED) + .map(asReceiptEvent); + if (same(snapshot, next)) return false; + snapshot = next; + return true; + }; + const scan = () => { + if (read()) subscribers.forEach((cb) => cb()); + }; + return { + subscribe: (onChange) => { + subscribers.add(onChange); + if (subscribers.size === 1) { + EVENTS.forEach((ev) => room.on(ev, scan)); + read(); // the first row sees what is already loaded; it reads the snapshot after subscribing + } + return () => { + subscribers.delete(onChange); + if (subscribers.size === 0) EVENTS.forEach((ev) => room.off(ev, scan)); + }; + }, + getSnapshot: () => snapshot, + }; +}; + +const stores = new WeakMap(); + +export const receiptStoreOf = (room: Room): ReceiptStore => { + let store = stores.get(room); + if (!store) { + store = make(room); + stores.set(room, store); + } + return store; +}; diff --git a/src/app/features/albums/useFileReaders.ts b/src/app/features/albums/useFileReaders.ts index 76517600..5ade3827 100644 --- a/src/app/features/albums/useFileReaders.ts +++ b/src/app/features/albums/useFileReaders.ts @@ -1,20 +1,13 @@ -import { useEffect, useState } from 'react'; -import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk'; +import { useMemo, useSyncExternalStore } from 'react'; +import { Room } from 'matrix-js-sdk'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { FILE_RECEIVED, FileReader, ReceiptEvent, readersOf } from './fileReceipt'; - -const asReceiptEvent = (e: MatrixEvent): ReceiptEvent => ({ - event_id: e.getId(), - type: e.getType(), - sender: e.getSender(), - origin_server_ts: e.getTs(), - content: e.getContent(), - unsigned: e.isRedacted() ? { redacted_because: true } : undefined, -}); +import { FileReader, readersOf } from './fileReceipt'; +import { receiptStoreOf } from './receiptStore'; /** * Who has read a file, from the receipts in the room's loaded timeline: a plain room event or a thread reply, both * count (docs/file-zone-mind-contract.md). `content` is the file event's content, which carries its `info.sha256`. + * The room's one shared subscription (receiptStore.ts) feeds every file row. */ export const useFileReaders = ( room: Room, @@ -23,30 +16,10 @@ export const useFileReaders = ( ): FileReader[] => { const mx = useMatrixClient(); const server = mx.getDomain() ?? ''; - const [readers, setReaders] = useState([]); - useEffect(() => { - const scan = () => { - const receipts = room - .getUnfilteredTimelineSet() - .getLiveTimeline() - .getEvents() - .filter((e) => e.getType() === FILE_RECEIVED) - .map(asReceiptEvent); - const next = readersOf(receipts, fileId, content, server); - setReaders((cur) => - cur.length === next.length && - cur.every((r, i) => r.sender === next[i].sender && r.ts === next[i].ts) - ? cur - : next - ); - }; - scan(); - // A redaction removes a tick and a reset drops the window: both rescan, not only a new event. - const events = [RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset] as const; - events.forEach((ev) => room.on(ev, scan)); - return () => { - events.forEach((ev) => room.off(ev, scan)); - }; - }, [room, fileId, content, server]); - return readers; + const store = receiptStoreOf(room); + const receipts = useSyncExternalStore(store.subscribe, store.getSnapshot); + return useMemo( + () => readersOf(receipts, fileId, content, server), + [receipts, fileId, content, server] + ); }; From 6ba5647ea8ed50cd1eb20c1ba9bf4ec4d06374f2 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 01:58:39 +0000 Subject: [PATCH 033/143] File receipts read 'fetched for ', never 'read by' The receipt means the mouth fetched and stored the file for the Mind, not that it is in her sandbox or was read. Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone-mind-contract.md | 17 +++++++++-------- src/app/features/albums/FileReadBy.tsx | 2 +- src/app/features/albums/fileReceipt.test.ts | 2 +- src/app/features/albums/fileReceipt.ts | 2 +- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/file-zone-mind-contract.md b/docs/file-zone-mind-contract.md index 00f73a4a..8b350c6c 100644 --- a/docs/file-zone-mind-contract.md +++ b/docs/file-zone-mind-contract.md @@ -25,7 +25,7 @@ A file event is an `m.room.message` whose `msgtype` is `m.audio`, `m.image`, `m. | `info.sha256` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) | The hash is computed over the same bytes that are uploaded, reading the whole file into memory before send (large videos pay for it); if hashing fails the file is sent without one. A file sent by something other than this -client may lack `info.sha256`; the mouth then logs the hash it computed, and the row can show no `read by` +client may lack `info.sha256`; the mouth then logs the hash it computed, and the row can show no `fetched for` for it. `info.mimetype` and `info.size` are written on audio and file events always; on image and video events `info` exists only if the browser could load the media, otherwise `info` holds `sha256` alone. Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail @@ -35,7 +35,7 @@ reference; audio carries no duration. A Mind should not rely on any of those. - Size: the mouth streams at most 5 MiB and refuses a larger file. The client does not enforce this cap; its own limit is the homeserver's `m.upload.size`. A file over 5 MiB can be shared with people and - not read by a Mind. + not fetched for a Mind. - Refusals are the mouth's and are typed: not a member of the room, bridged or Discord room, encrypted file, over the cap, content type not matching the magic bytes. The client does not re-check them and nothing in the client depends on the mouth's log. @@ -64,24 +64,25 @@ A plain room-event receipt may also carry `m.relates_to: {rel_type: "m.reference the mouth find its earlier receipts through `/relations` for dedup. The client matches by `content.event_id` and reads no relation but this one: if a `m.reference` is present its `event_id` must equal `content.event_id`, else the receipt counts for nothing. A thread relation is not read. However many receipts a Mind has posted for a file, the row draws one -`read by` for that Mind. +`fetched for` for that Mind. Two rules, both enforced on each side: 1. **The sender is the Mind.** The row trusts the Matrix `sender` of the receipt event and nothing inside it: - no Mind field is read. `read by X` counts only when the sender is X's own Matrix identity (the + no Mind field is read. `fetched for X` counts only when the sender is X's own Matrix identity (the full id is `@:` for a Mind in the Persona catalogue; a display name, a lookalike or another server's `@void:evil.example` does not count). -2. **Read means verified.** The Mind posts only after the sha256 of the bytes it fetched equals the file +2. **Fetched means verified.** The Mind posts only after the sha256 of the bytes it fetched equals the file event's `info.sha256`, and the receipt carries that hash. On a mismatch it posts nothing (or a typed refusal that the row does not draw). The row checks it again: the receipt's `sha256` must equal the - file event's `info.sha256`. A file event with no `info.sha256` can show no `read by`. + file event's `info.sha256`. A file event with no `info.sha256` can show no `fetched for`. The receipt is inert: no paths, URLs, filenames or anything a browser would fetch, and the client reads only `event_id`, `sha256` and the sender. It has no `msgtype`, so the album's file listing ignores it. The mouth's log is the fuller record for Minds; this event is the public tick. -The row draws `read by `: one receipt per Mind per file (the latest), several Minds side by side. A +The row draws `fetched for `: one receipt per Mind per file (the latest), several Minds side by side. A receipt for an event that is not a file of that album, a malformed one, and a redacted one are ignored. +The wording is deliberate: the receipt says the mouth fetched and stored the file for the Mind, not that it is in the Mind's sandbox and not that it was read. Client strings never say "saved" or "read". The sender check keeps a member from ticking for a Mind; it does not prove the Mind read the file beyond what the hash and the Mind's own account show. @@ -89,7 +90,7 @@ what the hash and the Mind's own account show. - Files sent from the composer carry no thread relation, so they reach an album only if written into a thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Their receipts are plain - room events, and the timeline row for the file shows `read by` from them. + room events, and the timeline row for the file shows `fetched for` from them. - A receipt that is still encrypted when it arrives is not seen until a later timeline event triggers a rescan; zones are unencrypted and the hash is written only there, so this is the encrypted-room limit again. - The timeline row reads the room's loaded timeline only: a receipt older than what the client has paged in is not diff --git a/src/app/features/albums/FileReadBy.tsx b/src/app/features/albums/FileReadBy.tsx index add99658..28f9205c 100644 --- a/src/app/features/albums/FileReadBy.tsx +++ b/src/app/features/albums/FileReadBy.tsx @@ -13,7 +13,7 @@ type FileReadByProps = { /** Receipts already read elsewhere (an album's thread replies): merged with the timeline's, a Mind once. */ also?: FileReader[]; }; -/** "read by " under a file: the Minds whose verified receipts are in the room's timeline. Nothing when none. */ +/** "fetched for " under a file: the Minds whose verified receipts are in the room's timeline. Nothing when none. */ export function FileReadBy({ room, fileId, content, also = [] }: FileReadByProps) { const seen = useFileReaders(room, fileId, content); const readers = [...new Map([...also, ...seen].map((r) => [r.sender, r])).values()].sort( diff --git a/src/app/features/albums/fileReceipt.test.ts b/src/app/features/albums/fileReceipt.test.ts index 39386e4f..c3c85f5e 100644 --- a/src/app/features/albums/fileReceipt.test.ts +++ b/src/app/features/albums/fileReceipt.test.ts @@ -32,7 +32,7 @@ describe('readLine', () => { ], name ) - ).toBe('read by void, eureka'); + ).toBe('fetched for void, eureka'); }); }); diff --git a/src/app/features/albums/fileReceipt.ts b/src/app/features/albums/fileReceipt.ts index 053570e9..93fa73f0 100644 --- a/src/app/features/albums/fileReceipt.ts +++ b/src/app/features/albums/fileReceipt.ts @@ -37,7 +37,7 @@ export const hashForSend = async (data: Blob): Promise => { }; export const readLine = (readBy: FileReader[], name: (sender: string) => string): string => - `read by ${readBy.map((r) => name(r.sender)).join(', ')}`; + `fetched for ${readBy.map((r) => name(r.sender)).join(', ')}`; const receiptOf = (e: ReceiptEvent) => { if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined; From df245e0b76fd83d7ee14fe725b0df9994f36755f Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 02:48:04 +0000 Subject: [PATCH 034/143] Ridge scene: physical sky over Vienna, real horizon, live weather (Nibu) (#110) Nibu's ridge atmosphere (nibu/ridge-atmosphere b0489bc9): Hillaire sky, Copernicus horizon, TAWES weather with clear-sky fallback. Credit: Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at. Soul passed (Nibu 5 findings fixed; Void Soul pass). Co-authored-by: Nibu Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/bundle.cjs | 1 + scripts/bake-horizon.py | 123 ++++ src/app/features/scene/README.md | 16 + src/app/features/scene/SkyStateControl.tsx | 18 + src/app/features/scene/index.test.ts | 9 +- src/app/features/scene/index.ts | 12 +- src/app/features/scene/ridge/Ridge.test.ts | 62 +- src/app/features/scene/ridge/Ridge.ts | 115 +++- src/app/features/scene/ridge/clouds.ts | 74 +++ src/app/features/scene/ridge/credits.test.ts | 16 + src/app/features/scene/ridge/credits.ts | 11 + .../scene/ridge/earthAtmosphere.test.ts | 96 +++ .../features/scene/ridge/earthAtmosphere.ts | 552 ++++++++++++++++++ src/app/features/scene/ridge/horizon.test.ts | 27 + src/app/features/scene/ridge/horizon.ts | 54 ++ .../features/scene/ridge/horizonLayer.test.ts | 63 ++ .../features/scene/ridge/physicalSky.test.ts | 105 ++++ src/app/features/scene/ridge/physicalSky.ts | 124 ++++ src/app/features/scene/ridge/rain.test.ts | 32 + src/app/features/scene/ridge/rain.ts | 54 ++ src/app/features/scene/ridge/ridgeBake.ts | 13 +- src/app/features/scene/ridge/ridgeWorld.ts | 37 +- src/app/features/scene/ridge/skyColour.ts | 3 + src/app/features/scene/ridge/skyDome.ts | 122 ++++ src/app/features/scene/ridge/toneMap.ts | 194 ++++++ src/app/features/scene/ridge/vienna.test.ts | 56 ++ src/app/features/scene/ridge/vienna.ts | 74 +++ .../features/scene/ridge/viennaHorizon.json | 1 + src/app/features/scene/ridge/weather.ts | 51 ++ .../features/scene/ridge/weatherFeed.test.ts | 164 ++++++ src/app/features/scene/ridge/weatherFeed.ts | 122 ++++ src/app/features/scene/skyForState.test.ts | 56 +- src/app/features/scene/skyForState.ts | 105 ++-- src/app/features/scene/skyState.test.ts | 4 +- src/app/features/scene/skyState.ts | 12 +- 35 files changed, 2457 insertions(+), 121 deletions(-) create mode 100644 scripts/bake-horizon.py create mode 100644 src/app/features/scene/ridge/clouds.ts create mode 100644 src/app/features/scene/ridge/credits.test.ts create mode 100644 src/app/features/scene/ridge/credits.ts create mode 100644 src/app/features/scene/ridge/earthAtmosphere.test.ts create mode 100644 src/app/features/scene/ridge/earthAtmosphere.ts create mode 100644 src/app/features/scene/ridge/horizon.test.ts create mode 100644 src/app/features/scene/ridge/horizon.ts create mode 100644 src/app/features/scene/ridge/horizonLayer.test.ts create mode 100644 src/app/features/scene/ridge/physicalSky.test.ts create mode 100644 src/app/features/scene/ridge/physicalSky.ts create mode 100644 src/app/features/scene/ridge/rain.test.ts create mode 100644 src/app/features/scene/ridge/rain.ts create mode 100644 src/app/features/scene/ridge/skyDome.ts create mode 100644 src/app/features/scene/ridge/toneMap.ts create mode 100644 src/app/features/scene/ridge/vienna.test.ts create mode 100644 src/app/features/scene/ridge/vienna.ts create mode 100644 src/app/features/scene/ridge/viennaHorizon.json create mode 100644 src/app/features/scene/ridge/weather.ts create mode 100644 src/app/features/scene/ridge/weatherFeed.test.ts create mode 100644 src/app/features/scene/ridge/weatherFeed.ts diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs index 31accf37..9d3079cd 100644 --- a/harness/scene/bundle.cjs +++ b/harness/scene/bundle.cjs @@ -29,6 +29,7 @@ esbuild plugins: [rawPlugin], format: 'iife', target: 'es2020', + define: { 'import.meta.env.BASE_URL': '"/"' }, }) .then(() => { if (ridge) { diff --git a/scripts/bake-horizon.py b/scripts/bake-horizon.py new file mode 100644 index 00000000..372e70e8 --- /dev/null +++ b/scripts/bake-horizon.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Bake the 360 degree horizon seen from the ridge viewpoint (src/app/features/scene/ridge/vienna.ts: VIENNA) into +src/app/features/scene/ridge/viennaHorizon.json: per azimuth, the highest elevation angle the terrain subtends, the distance and +height of the point that sets it, and the surface slope there (the normal). The source is Copernicus DEM GLO-30, a DSM (it +includes buildings and trees, which is what a horizon is made of). Source, licence and attribution go beside the bake, in its +`source` block, so the file cannot travel without them. + + python3 scripts/bake-horizon.py [out.json] + +Needs numpy, tifffile, imagecodecs (pip, in a venv: this is a build-time tool, the bake is committed). +""" +import json +import math +import sys +from pathlib import Path + +import numpy as np +import tifffile + +LAT, LON = 48.1929826, 16.3534237 # the server location Meta gave, 2026-11-11; keep equal to VIENNA in vienna.ts +STEP_DEG = 0.5 +RADIUS_KM = 60.0 +EARTH_R = 6_371_000.0 +REFRACTION_K = 0.13 # standard terrestrial refraction: effective radius R / (1 - k) +EYE_ABOVE_GROUND = 0.0 # the viewpoint is the DSM surface at the point (a roof, in a city); the page lifts it with VIENNA.altitudeKm +MIN_KM = 1.0 # the DSM at 30 m cannot resolve neighbouring buildings as a horizon; nearer than this is not ridge + + +class Mosaic: + def __init__(self, folder: Path): + self.tiles = {} + for p in sorted(folder.glob("Copernicus_DSM_COG_10_N*_E*_DEM.tif")): + lat0 = int(p.name.split("_N")[1][:2]) + lon0 = int(p.name.split("_E")[1][:3]) + with tifffile.TiffFile(p) as t: + self.tiles[(lat0, lon0)] = t.pages[0].asarray() + + def height(self, lat: float, lon: float) -> float: + key = (math.floor(lat), math.floor(lon)) + a = self.tiles.get(key) + if a is None: + return float("nan") + n = a.shape[0] + # Tile origin is the north-west corner; pixels are 1/n degree, pixel-is-area (centre at +0.5). + y = (key[0] + 1 - lat) * n - 0.5 + x = (lon - key[1]) * n - 0.5 + y0, x0 = int(math.floor(y)), int(math.floor(x)) + y0 = min(max(y0, 0), n - 2) + x0 = min(max(x0, 0), n - 2) + fy, fx = min(max(y - y0, 0), 1), min(max(x - x0, 0), 1) + top = a[y0, x0] * (1 - fx) + a[y0, x0 + 1] * fx + bot = a[y0 + 1, x0] * (1 - fx) + a[y0 + 1, x0 + 1] * fx + return float(top * (1 - fy) + bot * fy) + + +def offset(lat: float, lon: float, az_deg: float, d_m: float): + az = math.radians(az_deg) + dlat = d_m * math.cos(az) / 111_320.0 + dlon = d_m * math.sin(az) / (111_320.0 * math.cos(math.radians(lat))) + return lat + dlat, lon + dlon + + +def main() -> None: + folder = Path(sys.argv[1]) + out = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("src/app/features/scene/ridge/viennaHorizon.json") + m = Mosaic(folder) + h0 = m.height(LAT, LON) + EYE_ABOVE_GROUND + az_count = int(360 / STEP_DEG) + elev, dist, peak, east, north = [], [], [], [], [] + for i in range(az_count): + az = i * STEP_DEG + best, best_d, best_h, best_pt = -90.0, 0.0, h0, (LAT, LON) + d = MIN_KM * 1000 + while d <= RADIUS_KM * 1000: + lat, lon = offset(LAT, LON, az, d) + h = m.height(lat, lon) + if not math.isnan(h): + drop = d * d / (2 * EARTH_R) * (1 - REFRACTION_K) + ang = math.degrees(math.atan2(h - h0 - drop, d)) + if ang > best: + best, best_d, best_h, best_pt = ang, d, h, (lat, lon) + d += 30.0 if d < 5000 else 60.0 if d < 20000 else 120.0 + # Surface slope at the peak (rise per metre, east and north), central difference over 60 m. + la, lo = best_pt + dl, dn = 60.0 / 111_320.0, 60.0 / (111_320.0 * math.cos(math.radians(la))) + ge = (m.height(la, lo + dn) - m.height(la, lo - dn)) / 120.0 + gn = (m.height(la + dl, lo) - m.height(la - dl, lo)) / 120.0 + elev.append(round(best, 3)) + dist.append(round(best_d / 1000, 2)) + peak.append(round(best_h, 1)) + east.append(round(ge, 3)) + north.append(round(gn, 3)) + doc = { + "viewpoint": {"lat": LAT, "lon": LON, "groundM": round(h0, 1)}, + "stepDeg": STEP_DEG, + "radiusKm": RADIUS_KM, + "minKm": MIN_KM, + "curvature": f"drop d^2/2R*(1-{REFRACTION_K}), R={int(EARTH_R)} m", + "source": { + "dataset": "Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid", + "tiles": sorted(f"N{a:02d}_00_E{b:03d}_00" for a, b in m.tiles), + "tileBytesEach": "about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed", + "url": "https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)", + "licence": "Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty " + "(https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)", + "attribution": "Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 " + "provided under COPERNICUS by the European Union and ESA; all rights reserved.", + "bakedBy": "scripts/bake-horizon.py", + }, + "elevationDeg": elev, + "distanceKm": dist, + "peakHeightM": peak, + "slopeEast": east, + "slopeNorth": north, + } + out.write_text(json.dumps(doc, separators=(",", ":")) + "\n") + top = int(np.argmax(elev)) + print(f"ground {h0:.1f} m; {az_count} azimuths; highest {elev[top]:.2f} deg at az {top * STEP_DEG}, {dist[top]} km, {peak[top]} m") + print(f"wrote {out} ({out.stat().st_size} bytes)") + + +if __name__ == "__main__": + main() diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 957f1a42..45fec5cd 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -78,6 +78,22 @@ holds a GL context gives no 2D one, so a GL failure after context creation (comp 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. +## Ridge scene: Vienna, the real horizon and the weather + +The ridge sky is the physical atmosphere (`ridge/earthAtmosphere.ts`) lit by the real sun over the viewpoint, 48.1929826 N, 16.3534237 E +(`ridge/vienna.ts`). Its farthest layer is the real horizon of that viewpoint, standing still under the sun: `ridge/viennaHorizon.json`, +baked by `scripts/bake-horizon.py` from Copernicus DEM GLO-30 (nine 1 degree tiles, about 380 MB, kept out of the repo). The file carries its +own `source` block with the dataset, tiles, licence and the attribution the licence requires. Its elevation range is 2.1 degrees, so the +layer draws the shape of the ring stretched to a fixed relief, not its scale. + +The weather is one number (`ridge/weather.ts`), from the page URL's `?weather=`, else `weather.json` beside `config.json`, else clear. +The browser never calls a weather service. `weather.json` is written by Eureka's refresher (gamecult-ops `docs/weather-feed.md`, a 5-minute timer; the only writer) from GeoSphere Austria's TAWES station 11035 (Wien Hohe Warte), credit +"Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at". Shape: `{fetched_at, observed_at, station, precip_mm_10min, humidity_pct, +glow_wm2, sunshine_s_10min, attribution}`, null for a missing measurement. A file fetched more than 30 minutes ago, or holding an observation more than 40 minutes old, or from another station, is a clear +sky. Rain is the gauge; cloud is an estimate from the global radiation against Haurwitz's clear-sky value, cross-checked by the sunshine +duration, with the humidity as the fallback when the sun is low (`ridge/weatherFeed.ts`). The weather draws a cloud deck in the sky +(`ridge/clouds.ts`), thickens the layers' valley mist, and lays tiled rain streaks over everything (`ridge/rain.ts`, compositor-driven, none in a still). + ## Entry points - `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx index b664dc07..30efaa1c 100644 --- a/src/app/features/scene/SkyStateControl.tsx +++ b/src/app/features/scene/SkyStateControl.tsx @@ -15,6 +15,7 @@ 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 { HORIZON_CREDIT, WEATHER_CREDIT, WEATHER_CREDIT_URL, WEATHER_DATASET } from './ridge/credits'; import { useClientConfig } from '../../hooks/useClientConfig'; import { getSceneStyleOverride, @@ -116,6 +117,23 @@ export function SkyStateControl() { ))} )} + {backgroundScene && sceneStyle === 'ridge' && ( + + + Data + + + Datenquelle: GeoSphere Austria -{' '} + + {WEATHER_CREDIT_URL} + + . {WEATHER_DATASET}. + + + {HORIZON_CREDIT} + + + )} diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index b6fcf2a7..5574d60c 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -1,6 +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'; import { setSkyClock, setSkyState } from './skyState'; @@ -48,7 +47,7 @@ describe('startBackgroundScene follows the sky state', () => { createRidgeScene.mockReturnValue(fakeScene()); const scene = await startBackgroundScene(config, caps, { canvas }); expect(createRidgeScene).toHaveBeenCalledOnce(); - expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]); + expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(hex(ridgeSkyFor('night').top)); scene?.dispose(); }); @@ -67,7 +66,7 @@ describe('startBackgroundScene follows the sky state', () => { 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]); + expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(hex(ridgeSkyFor('night').top)); scene?.dispose(); }); @@ -79,7 +78,7 @@ describe('startBackgroundScene follows the sky state', () => { setSkyState('day'); await flush(); expect(first.dispose).toHaveBeenCalledOnce(); - expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]); + expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(hex(ridgeSkyFor('day').top)); scene?.dispose(); expect(second.dispose).toHaveBeenCalledOnce(); }); @@ -92,7 +91,7 @@ describe('startBackgroundScene follows the sky state', () => { 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(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(hex(ridgeSkyFor('day').top)); expect(a.dispose).toHaveBeenCalledOnce(); scene?.dispose(); expect(b.dispose).toHaveBeenCalledOnce(); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 4e1db231..36010b40 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -16,6 +16,9 @@ export type StartOptions = Omit void; }; +/** The observation file the ridge sky reads: the page's own, beside config.json. The browser calls no weather service. */ +const weatherUrl = (): string => `${import.meta.env.BASE_URL.replace(/\/$/, '')}/weather.json`; + async function create( config: BackgroundSceneConfig | undefined, options: StartOptions & { tier: SceneTier; debug?: boolean }, @@ -23,7 +26,7 @@ async function create( ): Promise { 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'); + const { loadWeather, ridgeSkyFor, terrainSkyFor } = await import('./skyForState'); if (config?.style === 'raymarch') return (await import('./Scene')).createScene(options); if (config?.style === 'terrain') { const { createTerrainScene } = await import('./terrain/Terrain'); @@ -35,6 +38,7 @@ async function create( return createTerrainSkyScene({ ...options, ...sky }); } const { createRidgeScene } = await import('./ridge/Ridge'); + await loadWeather(weatherUrl()); return createRidgeScene({ ...options, sky: ridgeSkyFor(state) }); } catch (e) { options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); @@ -52,8 +56,10 @@ async function skyKey( state: SkyState ): Promise { try { - const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState'); - return JSON.stringify(config?.style === 'terrain' ? terrainSkyFor(state) : ridgeSkyFor(state)); + const { loadWeather, ridgeSkyKey, terrainSkyFor } = await import('./skyForState'); + if (config?.style === 'terrain') return JSON.stringify(terrainSkyFor(state)); + await loadWeather(weatherUrl()); + return ridgeSkyKey(state); } catch { // The chunk failed to load: no key, so every change rebuilds (and `create` reports the failure). return null; diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts index 864491f1..0234e558 100644 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -17,6 +17,7 @@ import { } from './ridgeWorld'; import { skyColours, SkyColours } from './skyColour'; import { makeStars } from './stars'; +import { makePhysicalSky } from './physicalSky'; import { startBackgroundScene } from '../index'; describe('noise', () => { @@ -208,6 +209,7 @@ function fakeEl(tag = 'div') { this.attrs[k] = v; }, getContext: vi.fn(() => ctx()), + toDataURL: () => 'data:,', remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((c: any) => c !== this); @@ -273,6 +275,8 @@ function ctx(withFilter = false) { putImageData: (img: any) => puts.push(img), beginPath() {}, moveTo() {}, + stroke() {}, + createRadialGradient: () => ({ addColorStop: vi.fn() }), lineTo() {}, closePath() {}, // Stars: arc+fill is one draw. A context that has cleared a rect is the twinkle overlay's. @@ -378,9 +382,9 @@ describe('createRidgeScene', () => { expect(s).not.toBeNull(); const tracks = tracksOf(parent); expect(tracks).toHaveLength(LAYERS.length); - tracks.forEach((t: any) => { + tracks.forEach((t: any, i: number) => { expect(t.children).toHaveLength(3); - expect(t.animate).toHaveBeenCalledTimes(1); + expect(t.animate).toHaveBeenCalledTimes(Number.isFinite(LAYERS[i].seconds) ? 1 : 0); t.children.forEach((c: any) => { expect(Number.isInteger(c.width) && Number.isInteger(c.height)).toBe(true); expect(c.width).toBe(layerSize(400, 800, 2).chunk); @@ -428,7 +432,7 @@ describe('createRidgeScene', () => { const { canvas } = host(); const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const anims = animsOf(canvas.parentElement); - expect(anims.length).toBe(LAYERS.length + 1); // one drift per layer and the sway + expect(anims.length).toBe(LAYERS.filter((l) => Number.isFinite(l.seconds)).length + 1); // one drift per drifting layer (the horizon does not) and the sway expect(anims.every((a: any) => a.pause.mock.calls.length > 0)).toBe(true); anims.forEach((a: any) => expect(a.play).not.toHaveBeenCalled()); expect(rafQueue).toHaveLength(0); @@ -759,7 +763,7 @@ describe('endless drift: chunks baked on demand and recycled', () => { it('when the drift animation ends, the chunks move one slot left and the track restarts', async () => { const { parent, s, records } = start(); await until(() => records.length === 3 * LAYERS.length); - const track = tracksOf(parent)[0]; + const track = tracksOf(parent)[1]; // the first layer that drifts: the horizon, [0], stands still const [e0, e1, e2] = track.children; const first = track.anims[0]; // One chunk of drift, not one viewport: the track slides by the width of a chunk. @@ -777,8 +781,8 @@ describe('endless drift: chunks baked on demand and recycled', () => { it('the canvas that left is baked with the next stretch of world, and each rotation after it, without end', async () => { const { parent, s, records } = start(); await until(() => records.length === 3 * LAYERS.length); - const track = tracksOf(parent)[0]; - const done = () => records.filter((r) => r.layer === 0).map((r) => r.chunk); + const track = tracksOf(parent)[1]; + const done = () => records.filter((r) => r.layer === 1).map((r) => r.chunk); for (let m = 3; m <= 5; m += 1) { track.anims[track.anims.length - 1].finish(); await until(() => done().includes(m)); @@ -786,9 +790,9 @@ describe('endless drift: chunks baked on demand and recycled', () => { expect(done()).toEqual([0, 1, 2, 3, 4, 5]); // The pixels of chunks 3 to 5 are the world's, not a repeat of an earlier one. const imgs = baked(); - const layer0 = imgs.filter((_, i) => records[i].layer === 0); - [3, 4, 5].forEach((m, k) => expect(layer0[3 + k].equals(expected(0, m))).toBe(true)); - expect(layer0[3].equals(layer0[0])).toBe(false); + const layer1 = imgs.filter((_, i) => records[i].layer === 1); + [3, 4, 5].forEach((m, k) => expect(layer1[3 + k].equals(expected(1, m))).toBe(true)); + expect(layer1[3].equals(layer1[0])).toBe(false); s.dispose(); }); @@ -812,3 +816,43 @@ describe('endless drift: chunks baked on demand and recycled', () => { s.dispose(); }); }); + +describe('rain in the scene', () => { + const rainy = (w: number): SkyColours => ({ ...DAY, physical: makePhysicalSky('day', new Date('2026-06-21T10:40:00Z'), w) }); + const rainEls = (parent: any) => parent.children.filter((c: any) => c.tag === 'div' && c !== rigOf(parent)); + + it('lays one falling tile per rain layer over the ridges when it rains, each slid by its own height on the compositor', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: rainy(1) })!; + const els = rainEls(parent); + expect(els).toHaveLength(2); + els.forEach((e: any) => { + expect(e.anims).toHaveLength(1); + expect(e.anims[0].options.iterations).toBe(Infinity); + }); + s.dispose(); + expect(rainEls(parent)).toHaveLength(0); + els.forEach((e: any) => expect(e.anims[0].cancel).toHaveBeenCalled()); + }); + + it('draws no rain in dry weather, and none in a still', () => { + const dry = host(); + createRidgeScene({ canvas: dry.canvas, tier: 'full', sky: rainy(0.3) })!.dispose(); + const a = host(); + const sa = createRidgeScene({ canvas: a.canvas, tier: 'full', sky: rainy(0.3) })!; + expect(rainEls(a.parent)).toHaveLength(0); + sa.dispose(); + const still = host(); + const ss = createRidgeScene({ canvas: still.canvas, tier: 'still', sky: rainy(1) })!; + expect(rainEls(still.parent)).toHaveLength(0); + ss.dispose(); + }); + + it('pauses with the rest when motion is reduced', () => { + motion.matches = true; + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: rainy(1) })!; + rainEls(parent).forEach((e: any) => expect(e.anims[0].play).not.toHaveBeenCalled()); + s.dispose(); + }); +}); diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index 72985d4c..8590a833 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -3,9 +3,13 @@ import type { Scene, SceneOptions } from '../Scene'; import { overlayRequested } from '../debugOverlays'; import { FrameSampler, FrameStats } from './probe'; import { skyColours, SkyColours, Srgb } from './skyColour'; +import { paintSky as paintPhysicalSky } from './physicalSky'; import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars'; import { bakeSlice, BakeParams, layerPaint } from './ridgeBake'; -import { LAYERS, LayerSpec, edgeBlur, layerSize, scatterAlpha, transmittance } from './ridgeWorld'; +import { LAYERS, LayerSpec, edgeBlur, HORIZON_TRACK_SHIFT, layerSize, scatterAlpha, transmittance } from './ridgeWorld'; +import { hfovDeg } from './horizon'; +import { viewFor, VFOV_DEG } from './skyDome'; +import { drawRainTile, RAIN_TILES } from './rain'; /** One chunk's bake: `ms` is the summed task time, `maxTaskMs` the longest single task; `first` marks the bake of the first view. */ export interface BakeRecord { @@ -34,9 +38,55 @@ function context2d(c: HTMLCanvasElement): CanvasRenderingContext2D | null { return c.getContext('2d'); } -function paintSky(canvas: HTMLCanvasElement, sky: SkyColours): boolean { +/** The sun: a bright core of its true angular size (at least 2.5 px) and a short soft falloff, in the colour the air leaves it. */ +function drawSun( + ctx: CanvasRenderingContext2D, + d: NonNullable['sunDisc']>, + w: number, + h: number +): void { + const r = Math.max(2.5, d.radius * h); + const x = d.x * w; + const y = d.y * h; + const g = ctx.createRadialGradient(x, y, 0, x, y, r * 3.5); + const [cr, cg, cb] = d.rgb; + g.addColorStop(0, `rgba(${cr},${cg},${cb},${d.alpha})`); + g.addColorStop(1 / 3.5, `rgba(${cr},${cg},${cb},${d.alpha})`); + g.addColorStop(1, `rgba(${cr},${cg},${cb},0)`); + ctx.fillStyle = g; + ctx.fillRect(x - r * 3.5, y - r * 3.5, r * 7, r * 7); +} + +/** + * The host canvas's sky: the physical sky (`physicalSky.ts`) as a small grid of display pixels, scaled up smoothly (it is a soft + * field); without one, the three-stop gradient. + */ +function paintSky( + canvas: HTMLCanvasElement, + sky: SkyColours, + cssW: number, + cssH: number, + makeCanvas: () => HTMLCanvasElement +): boolean { const ctx = context2d(canvas); if (!ctx) return false; + if (sky.physical) { + const px = paintPhysicalSky(sky.physical, cssW, cssH); + const small = makeCanvas(); + small.width = px.w; + small.height = px.h; + const sctx = context2d(small); + if (sctx) { + const img = sctx.createImageData(px.w, px.h); + img.data.set(px.data); + sctx.putImageData(img, 0, 0); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(small, 0, 0, canvas.width, canvas.height); + if (px.sunDisc) drawSun(ctx, px.sunDisc, canvas.width, canvas.height); + return true; + } + } const g = ctx.createLinearGradient(0, 0, 0, canvas.height); g.addColorStop(0, css(sky.top)); g.addColorStop(0.5, css(sky.mid)); @@ -111,6 +161,13 @@ function chunkJob( const params: BakeParams = { spec, sky, + horizonView: + spec.horizon && sky.physical + ? { + azimuthDeg: viewFor(sky.physical.sun, size.period / viewH).azimuthDeg, + hfovDeg: hfovDeg(size.period / viewH, VFOV_DEG), + } + : undefined, period: size.period, scale, viewH, @@ -237,6 +294,8 @@ interface Built { makeCanvas: () => HTMLCanvasElement; /** The twinkling stars' own canvas, between the sky and the ridges; absent in a day sky and in 'still'. */ twinkle?: HTMLCanvasElement; + /** The rain's tiles and their falling, present only when the weather rains (`rain.ts`). */ + rain?: { els: HTMLElement[]; anims: (Animation | undefined)[] }; } /** Yaw and pitch: the camera's slow sway, a loop of this many seconds over the whole rig. */ @@ -291,6 +350,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { built?.layers.forEach((l) => l.anim?.cancel()); built?.rig.remove(); built?.twinkle?.remove(); + built?.rain?.anims.forEach((a) => a?.cancel()); + built?.rain?.els.forEach((e) => e.remove()); cancelAnimationFrame(twRaf); twRaf = 0; built = undefined; @@ -355,7 +416,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { /** One chunk of drift as one animation, holding its end until `rotate` hands the track the next chunk. */ const startDrift = (l: LayerState, onEnd: (l: LayerState) => void) => { - if (!built) return; + if (!built || !Number.isFinite(l.spec.seconds)) return; // the horizon layer does not drift const { size, scale } = built; l.anim = l.track.animate?.( [ @@ -406,6 +467,47 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { ); }; + /** + * The rain over everything: per tile, a div of the tile's texture (drawn once) taller than the viewport by one tile, slid down one + * tile height per loop on the compositor. 'still' draws none (a settled still has no animation to carry it). + */ + const startRain = (rain: number, colour: Srgb, w: number, h: number, scale: number): NonNullable => { + const els: HTMLElement[] = []; + const anims: (Animation | undefined)[] = []; + RAIN_TILES.forEach((t, k) => { + const tile = document.createElement('canvas'); + tile.width = t.size; + tile.height = t.size; + const tctx = context2d(tile); + if (!tctx) return; + drawRainTile(tctx, t, rain, colour, scale, k * 41); + const px = t.size / scale; + const el = document.createElement('div'); + el.setAttribute('aria-hidden', 'true'); + Object.assign(el.style, { + position: 'absolute', + left: '0', + top: `${-px}px`, + width: '100%', + height: `${h + px}px`, + backgroundImage: `url(${tile.toDataURL()})`, + backgroundSize: `${px}px ${px}px`, + pointerEvents: 'none', + willChange: 'transform', + }); + parent.appendChild(el); + els.push(el); + anims.push( + el.animate?.([{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(0,${px}px,0)` }], { + duration: t.seconds * 1000, + iterations: Infinity, + easing: 'linear', + }) + ); + }); + return { els, anims }; + }; + /** Bake the sky, then the layers. Returns false when a 2D context is missing or there is no area yet. */ const build = (): boolean => { teardownLayers(); @@ -415,7 +517,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { const size = layerSize(w, h, scale); canvas.width = size.period; canvas.height = Math.round(h * scale); - if (!paintSky(canvas, sky)) return false; + if (!paintSky(canvas, sky, w, h, () => document.createElement('canvas'))) return false; // 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); @@ -454,7 +556,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { const track = document.createElement('div'); Object.assign(track.style, { position: 'absolute', - left: '0', + left: spec.horizon ? `${(-HORIZON_TRACK_SHIFT * size.period) / scale}px` : '0', bottom: '0', width: `${size.period / scale}px`, height: `${size.height / scale}px`, @@ -504,6 +606,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { parent.appendChild(rig); layers.forEach((l) => startDrift(l, rotate)); built.sway = startSway(rig, w, h); + if (tier !== 'still' && sky.physical && sky.physical.weather.rain > 0) built.rain = startRain(sky.physical.weather.rain, sky.horizon, w, h, scale); pump(); syncPlayState(); return true; @@ -537,7 +640,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { }; function syncPlayState() { - const anims = built ? [built.sway, ...built.layers.map((l) => l.anim)] : []; + const anims = built ? [built.sway, ...built.layers.map((l) => l.anim), ...(built.rain?.anims ?? [])] : []; anims.forEach((a) => (moving() ? a?.play() : a?.pause())); if (!built?.twinkle) return; if (moving()) { diff --git a/src/app/features/scene/ridge/clouds.ts b/src/app/features/scene/ridge/clouds.ts new file mode 100644 index 00000000..1b4e8757 --- /dev/null +++ b/src/app/features/scene/ridge/clouds.ts @@ -0,0 +1,74 @@ +/* eslint-disable no-param-reassign -- `cloudRadiance` fills the caller's array */ +// A cloud deck over the sky dome: per texel, the view ray hits a plane at cloud height, a three-octave value noise there is the +// deck's thickness, `cover` says how much of it is cloud, and a cloudy texel is the cloud's own radiance (lit by the sun through the +// air and by the sky, thinned for the thickness of the deck) in place of the sky behind it. One noise sample per octave per texel on +// the dome's small grid: no march, no texture, nothing per frame. +import { hash2 } from './noise'; +import type { Weather } from './weather'; + +const smooth = (t: number): number => t * t * (3 - 2 * t); +const smoothstep = (a: number, b: number, v: number): number => { + const t = Math.min(1, Math.max(0, (v - a) / (b - a))); + return smooth(t); +}; + +/** 2D value noise in [0, 1). */ +export function noise2(x: number, y: number): number { + const cx = Math.floor(x); + const cy = Math.floor(y); + const u = smooth(x - cx); + const v = smooth(y - cy); + const a = hash2(cx, cy); + const b = hash2(cx + 1, cy); + const c = hash2(cx, cy + 1); + const d = hash2(cx + 1, cy + 1); + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; +} + +/** Plane-projected texels per unit of ray slope, and the octave gain. */ +const SCALE = 1.6; +/** Below this ray elevation (sine) the deck fades into the horizon haze. */ +const HORIZON_FADE: readonly [number, number] = [0.02, 0.22]; + +/** + * Cloud amount 0..1 along a ray with horizontal direction (`dx`, `dy`) and vertical sine `vz`. At cover 0 there is none and at 1 + * the sky is solid overcast; between, the noise thresholds into puffs with soft edges. `drift` offsets the pattern (the weather's + * wind, in plane units). + */ +export function cloudAmount(dx: number, dy: number, vz: number, cover: number, drift = 0): number { + if (cover <= 0 || vz <= 0) return 0; + const px = (dx / (vz + 0.1)) * SCALE + drift; + const py = (dy / (vz + 0.1)) * SCALE; + let n = 0; + let amp = 0.55; + let f = 1; + let norm = 0; + for (let k = 0; k < 3; k += 1) { + n += amp * noise2(px * f + k * 17.3, py * f + k * 5.1); + norm += amp; + amp *= 0.5; + f *= 2.1; + } + n /= norm; + // cover 1 puts the threshold below every value: solid; cover 0 above every value: none. + const thr = 1.05 - cover * 0.95; + return smoothstep(thr - 0.12, thr + 0.12, n) * smoothstep(HORIZON_FADE[0], HORIZON_FADE[1], vz); +} + +/** + * The radiance of cloud, per unit of sun irradiance, for a sun at sine `sunSin` seen through `sunTrans` (the air's transmittance to + * the sun) over a sky whose zenith radiance is `zenith`. A white scatterer (albedo 0.8): the sun's direct light, which thick cover + * blocks (`cover` 1 passes a fifth), and the sky's, which it does not. Written into `out`. + */ +export function cloudRadiance( + sunSin: number, + sunTrans: readonly number[], + zenith: readonly number[], + cover: number, + out: number[] +): void { + const direct = (Math.max(sunSin, 0) / Math.PI) * (1 - 0.8 * cover); + for (let i = 0; i < 3; i += 1) out[i] = 0.8 * (sunTrans[i] * direct + zenith[i]); +} + +export const hasClouds = (w: Weather): boolean => w.cover > 0.02; diff --git a/src/app/features/scene/ridge/credits.test.ts b/src/app/features/scene/ridge/credits.test.ts new file mode 100644 index 00000000..633a1b33 --- /dev/null +++ b/src/app/features/scene/ridge/credits.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { HORIZON_CREDIT, WEATHER_CREDIT, WEATHER_CREDIT_URL, WEATHER_DATASET } from './credits'; +import { HORIZON_ATTRIBUTION } from './horizon'; +import { WEATHER_ATTRIBUTION } from './weatherFeed'; + +describe('the credits the Sky menu shows', () => { + it('are the sources\' own', () => { + expect(HORIZON_CREDIT).toBe(HORIZON_ATTRIBUTION); + expect(WEATHER_CREDIT).toBe(WEATHER_ATTRIBUTION); + }); + it('credit GeoSphere with its requested line, and state a licence only with the dataset named', () => { + expect(WEATHER_CREDIT).toBe(`Datenquelle: GeoSphere Austria - ${WEATHER_CREDIT_URL}`); + expect(WEATHER_CREDIT).not.toMatch(/CC BY/); + expect(WEATHER_DATASET).toMatch(/TAWES.*10-minute.*CC BY 4\.0/); + }); +}); diff --git a/src/app/features/scene/ridge/credits.ts b/src/app/features/scene/ridge/credits.ts new file mode 100644 index 00000000..1a0327ea --- /dev/null +++ b/src/app/features/scene/ridge/credits.ts @@ -0,0 +1,11 @@ +// What the Ridge background owes its data sources, shown in the Sky menu (SkyStateControl). Plain strings in their own module so the +// main chunk carries these and not the baked horizon table or the weather parser; credits.test.ts holds them equal to the sources'. + +/** GeoSphere's own requested credit line. It names no licence: that is the dataset's (below). */ +export const WEATHER_CREDIT = 'Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at'; +export const WEATHER_CREDIT_URL = 'https://data.hub.geosphere.at'; +/** The dataset the weather is read from, and its licence as the dataset page names it (data.hub.geosphere.at, tawes-v1-10min). */ +export const WEATHER_DATASET = 'TAWES weather stations, 10-minute data (station 11035), CC BY 4.0'; +/** The terrain silhouette's credit, as the Copernicus DEM licence asks (equal to `HORIZON_ATTRIBUTION`). */ +export const HORIZON_CREDIT = + 'Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.'; diff --git a/src/app/features/scene/ridge/earthAtmosphere.test.ts b/src/app/features/scene/ridge/earthAtmosphere.test.ts new file mode 100644 index 00000000..cc5d4c43 --- /dev/null +++ b/src/app/features/scene/ridge/earthAtmosphere.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { bakeAtmosphere, bakeCostSteps, PLANET_KM } from './earthAtmosphere'; +import { TOP_KM } from './earthAtmosphere'; + +const rad = (deg: number) => Math.cos(((90 - deg) * Math.PI) / 180); +const luma = (c: number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; + +const atNoon = bakeAtmosphere({ sunElevationDeg: 45 }); +const atSunset = bakeAtmosphere({ sunElevationDeg: 0 }); +const atDark = bakeAtmosphere({ sunElevationDeg: -20 }); + +/** Sky radiance looking `elev` degrees above the horizon, `az` degrees from the sun's azimuth. */ +const sky = (a: ReturnType, elev: number, az: number) => { + const out = [0, 0, 0]; + a.radiance(Math.sin((elev * Math.PI) / 180), Math.cos((az * Math.PI) / 180), out); + return out; +}; + +describe('earthAtmosphere: the published Earth atmosphere, baked to look-up tables', () => { + it('has the geometry of the references (a 100 km shell on a 6360 km planet)', () => { + expect(PLANET_KM).toBe(6360); + expect(TOP_KM - PLANET_KM).toBe(100); + }); + + it('transmittance to space: the zenith is the published vertical optical depth, red passes most, blue least', () => { + const t = [0, 0, 0]; + atNoon.transmittanceToSpace(1, t); + // Rayleigh column at 8 km: 5.802e-3 * 8 = 0.0464 (red), 0.108 (green), 0.265 (blue); Mie 4.44e-3 * 1.2 = 0.0053; ozone + // 0.65e-3 * 15 = 0.00975 (red), 0.0282 (green), 0.0013 (blue). So exp(-tau) there. + expect(t[0]).toBeCloseTo(Math.exp(-(0.0464 + 0.0053 + 0.0098)), 1); + expect(t[1]).toBeCloseTo(Math.exp(-(0.1085 + 0.0053 + 0.0282)), 1); + expect(t[2]).toBeCloseTo(Math.exp(-(0.2648 + 0.0053 + 0.0013)), 1); + expect(t[0]).toBeGreaterThan(t[1]); + expect(t[1]).toBeGreaterThan(t[2]); + }); + + it('a noon sky is blue: the zenith is bluer than the horizon, and bluer than it is red', () => { + const zenith = sky(atNoon, 70, 90); + expect(zenith[2]).toBeGreaterThan(zenith[0] * 1.5); + const horizon = sky(atNoon, 1, 90); + expect(luma(horizon)).toBeGreaterThan(luma(zenith)); // the long path whitens and brightens the horizon + expect(horizon[2] / horizon[0]).toBeLessThan(zenith[2] / zenith[0]); + }); + + it('the sky toward the sun at sunset is red-orange, and the zenith opposite is blue', () => { + const toward = sky(atSunset, 3, 5); + expect(toward[0]).toBeGreaterThan(toward[2] * 1.5); + const away = sky(atSunset, 40, 180); + expect(away[2]).toBeGreaterThan(away[0]); + }); + + it('a clear sky brightens toward the sun (the Mie lobe)', () => { + expect(luma(sky(atNoon, 45, 4))).toBeGreaterThan(2 * luma(sky(atNoon, 30, 90))); + }); + + it('the sky is dark when the sun is 20 degrees under the horizon, a thousandth of noon or less', () => { + expect(luma(sky(atDark, 20, 90))).toBeLessThan(luma(sky(atNoon, 20, 90)) * 1e-3); + }); + + it('twilight keeps a blue zenith (the ozone absorption of red)', () => { + const twilight = bakeAtmosphere({ sunElevationDeg: -6 }); + const z = sky(twilight, 60, 180); + expect(z[2]).toBeGreaterThan(z[0] * 1.5); + }); + + it('haze (Mie x4) whitens and brightens the horizon haze band', () => { + const hazy = bakeAtmosphere({ sunElevationDeg: 30, mieScale: 4 }); + const clear = bakeAtmosphere({ sunElevationDeg: 30 }); + const h = sky(hazy, 12, 20); + const c = sky(clear, 12, 20); + expect(luma(h)).toBeGreaterThan(luma(c)); // more aerosol, more forward scattering toward the sun + expect(h[2] / h[0]).toBeLessThan(c[2] / c[0]); // and less blue + }); + + it('is energy-sane: the multiple-scattering term never makes the sky brighter than the sun it takes in, and stays finite', () => { + for (const a of [atNoon, atSunset, atDark]) { + for (let i = 0; i < a.skyView.data.length; i += 1) { + expect(Number.isFinite(a.skyView.data[i])).toBe(true); + expect(a.skyView.data[i]).toBeGreaterThanOrEqual(0); + expect(a.skyView.data[i]).toBeLessThan(5); + } + } + expect(rad(0)).toBeCloseTo(0, 6); + }); + + it('bake cost: the three LUTs take about the stated number of march steps, in tens of milliseconds', () => { + const steps = bakeCostSteps(); + const t0 = performance.now(); + bakeAtmosphere({ sunElevationDeg: 24 }); + const ms = performance.now() - t0; + // eslint-disable-next-line no-console + console.log(`atmosphere bake: ${steps} march steps, ${ms.toFixed(1)} ms`); + expect(steps).toBeLessThan(1_000_000); + expect(ms).toBeLessThan(1500); + }); +}); diff --git a/src/app/features/scene/ridge/earthAtmosphere.ts b/src/app/features/scene/ridge/earthAtmosphere.ts new file mode 100644 index 00000000..f5350244 --- /dev/null +++ b/src/app/features/scene/ridge/earthAtmosphere.ts @@ -0,0 +1,552 @@ +/* eslint-disable no-param-reassign, no-plusplus, no-bitwise -- numeric kernels over typed arrays */ +// Earth's atmosphere as three small look-up tables, baked once on the CPU: transmittance, multiple scattering and +// the sky-view LUT. Per sky texel the scene then does one bilinear lookup. No ray march after the bake. +// +// The method is Hillaire 2020, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique" (EGSR): +// the transmittance LUT in Bruneton and Neyret's (2008, 2017) parameterisation, the multiple-scattering LUT of his +// section 5.5 (a geometric series over an isotropic second order), and the sky-view LUT of 5.3 with the +// squared elevation distribution. The kernels follow his reference code (sebh/UnrealEngineSkyAtmosphere, +// Resources/RenderSkyRayMarching.hlsl `IntegrateScatteredLuminance`, `NewMultiScattCS`, `SkyViewLutPS`; MIT). +// +// Earth's coefficients are the ones that code takes from Bruneton's 2017 reference implementation +// (ebruneton/precomputed_atmospheric_scattering, `atmosphere/demo/demo.cc`), in 1/km, at sea level: +// Rayleigh scattering (5.802, 13.558, 33.1) e-3 at (R, G, B) wavelengths, scale height 8 km. No Rayleigh absorption. +// Mie scattering 3.996e-3 and extinction 4.44e-3 (single scattering albedo 0.9), grey, scale height 1.2 km, +// Cornette-Shanks phase, asymmetry g 0.8. +// Ozone absorption (0.650, 1.881, 0.085) e-3, a tent layer 15 km either side of 25 km (it makes the zenith blue at twilight). +// Planet 6360 km, top of the atmosphere 6460 km (100 km: the Karman line, 99.99% of the air). +// Rayleigh phase 3/(16 pi) (1 + cos^2). The sun is a point of unit irradiance; the consumer scales it (`toneMap.ts`). +// +// Single value the references do not give: ground albedo. 0.25 here, a grass, roofs and bare ground figure for a +// city and its wood; it only feeds the multiple-scattering term (and so the dusk and the shadow side of the sky). +// +// Simplifications against the paper, all on the cheap side: no ground bounce inside the sky-view LUT itself (its rays +// leave to space), fixed observer height, a spherical Earth with no terrain, the sun a point (the disc is the +// consumer's), no refraction, no clouds (`weather.ts`: haze is a multiplier on the Mie coefficients). + +export const PLANET_KM = 6360; +export const TOP_KM = 6460; +const RAYLEIGH_SCATTER: Vec3 = [5.802e-3, 13.558e-3, 33.1e-3]; +const RAYLEIGH_SCALE_KM = 8; +const MIE_SCATTER = 3.996e-3; +const MIE_EXTINCT = 4.44e-3; +const MIE_SCALE_KM = 1.2; +export const MIE_G = 0.8; +const OZONE_ABSORB: Vec3 = [0.65e-3, 1.881e-3, 0.085e-3]; +const OZONE_PEAK_KM = 25; +const OZONE_HALF_WIDTH_KM = 15; +export const GROUND_ALBEDO = 0.25; +const RADIUS_OFFSET_KM = 0.01; + +export type Vec3 = readonly [number, number, number]; + +const PI = Math.PI; +const FOUR_PI = 4 * PI; +const clamp = (x: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, x)); + +export interface AtmosphereOptions { + /** Sun elevation above the horizon in degrees (Vienna's, `vienna.ts`). */ + sunElevationDeg: number; + /** Multiplier on both Mie coefficients: haze. 1 is the reference air. */ + mieScale?: number; + /** Observer height above the ground in km. Vienna's lowest point is 0.151. */ + observerKm?: number; +} + +/** Sizes of the three tables and the march step counts. The sum is the bake's cost, see `bakeCostSteps`. */ +export const LUT = { + transmittance: { w: 64, h: 24, steps: 32 }, + multiScatter: { w: 16, h: 16, dirs: 6, steps: 14 }, + skyView: { w: 72, h: 40, steps: 24 }, +} as const; + +const NO_WEATHER = 1; + +interface Medium { + scatter: Vec3; + extinction: Vec3; + /** Mie scattering and Rayleigh scattering separately, for the phase functions. */ + mie: number; + ray: Vec3; +} + +function sampleMedium(altKm: number, mieScale: number): Medium { + const dRay = Math.exp(-altKm / RAYLEIGH_SCALE_KM); + const dMie = Math.exp(-altKm / MIE_SCALE_KM) * mieScale; + const tent = + altKm < OZONE_PEAK_KM + ? altKm / OZONE_HALF_WIDTH_KM - OZONE_PEAK_KM / OZONE_HALF_WIDTH_KM + 1 + : -altKm / OZONE_HALF_WIDTH_KM + OZONE_PEAK_KM / OZONE_HALF_WIDTH_KM + 1; + const dOzone = clamp(tent, 0, 1); + const ray: Vec3 = [ + dRay * RAYLEIGH_SCATTER[0], + dRay * RAYLEIGH_SCATTER[1], + dRay * RAYLEIGH_SCATTER[2], + ]; + const mie = dMie * MIE_SCATTER; + const ext = dMie * MIE_EXTINCT; + return { + ray, + mie, + scatter: [ray[0] + mie, ray[1] + mie, ray[2] + mie], + extinction: [ + ray[0] + ext + dOzone * OZONE_ABSORB[0], + ray[1] + ext + dOzone * OZONE_ABSORB[1], + ray[2] + ext + dOzone * OZONE_ABSORB[2], + ], + }; +} + +/** Nearest positive hit of a unit-direction ray with a sphere at the origin: its distance, or -1. */ +function raySphere( + ox: number, + oy: number, + oz: number, + dx: number, + dy: number, + dz: number, + r: number +): number { + const b = ox * dx + oy * dy + oz * dz; + const c = ox * ox + oy * oy + oz * oz - r * r; + if (c > 0 && b > 0) return -1; + const disc = b * b - c; + if (disc < 0) return -1; + const s = Math.sqrt(disc); + const t0 = -b - s; + return t0 > 0 ? t0 : -b + s > 0 ? -b + s : -1; +} + +const rayleighPhase = (cosT: number): number => (3 / (16 * PI)) * (1 + cosT * cosT); +/** Henyey-Greenstein, Hillaire's `hgPhase` with the view direction flipped (his WorldDir is an "in" direction). */ +const hgPhase = (g: number, cosT: number): number => { + const denom = 1 + g * g + 2 * g * cosT; + return (1 - g * g) / (FOUR_PI * denom * Math.sqrt(denom)); +}; + +/** A 2D table with `c` channels, sampled bilinearly with clamped edges. */ +export interface Lut2 { + w: number; + h: number; + c: number; + data: Float32Array; +} +const makeLut = (w: number, h: number, c: number): Lut2 => ({ + w, + h, + c, + data: new Float32Array(w * h * c), +}); + +/** Bilinear at texel-space coordinates (0..w-1, 0..h-1), written into `out`. */ +function sampleTexel(l: Lut2, x: number, y: number, out: number[] | Float32Array): void { + const fx = clamp(x, 0, l.w - 1); + const fy = clamp(y, 0, l.h - 1); + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const x1 = Math.min(l.w - 1, x0 + 1); + const y1 = Math.min(l.h - 1, y0 + 1); + const tx = fx - x0; + const ty = fy - y0; + for (let k = 0; k < l.c; k++) { + const a = l.data[(y0 * l.w + x0) * l.c + k]; + const b = l.data[(y0 * l.w + x1) * l.c + k]; + const c = l.data[(y1 * l.w + x0) * l.c + k]; + const d = l.data[(y1 * l.w + x1) * l.c + k]; + out[k] = (a + (b - a) * tx) * (1 - ty) + (c + (d - c) * tx) * ty; + } +} + +// Transmittance LUT, Bruneton's parameterisation (r, mu) -> (x_mu, x_r) over rho = horizon distance of height r. +const H_TOP = Math.sqrt(TOP_KM * TOP_KM - PLANET_KM * PLANET_KM); + +function transmittanceUv(r: number, mu: number): [number, number] { + const rho = Math.sqrt(Math.max(0, r * r - PLANET_KM * PLANET_KM)); + const disc = r * r * (mu * mu - 1) + TOP_KM * TOP_KM; + const d = Math.max(0, -r * mu + Math.sqrt(Math.max(0, disc))); + const dMin = TOP_KM - r; + const dMax = rho + H_TOP; + return [(d - dMin) / (dMax - dMin), rho / H_TOP]; +} +function transmittanceParams(xMu: number, xR: number): [number, number] { + const rho = H_TOP * xR; + const r = Math.sqrt(rho * rho + PLANET_KM * PLANET_KM); + const dMin = TOP_KM - r; + const dMax = rho + H_TOP; + const d = dMin + xMu * (dMax - dMin); + const mu = d === 0 ? 1 : (H_TOP * H_TOP - rho * rho - d * d) / (2 * r * d); + return [r, clamp(mu, -1, 1)]; +} + +const toSub = (u: number, res: number): number => (u + 0.5 / res) * (res / (res + 1)); +const fromSub = (u: number, res: number): number => (u - 0.5 / res) * (res / (res - 1)); + +export interface Atmosphere { + readonly transmittance: Lut2; + readonly multiScatter: Lut2; + readonly skyView: Lut2; + readonly sunElevationDeg: number; + readonly mieScale: number; + readonly observerKm: number; + /** Cosine of the sun's zenith angle. */ + readonly sunCosZenith: number; + /** Zenith angle (radians) of the astronomical horizon at the observer: a little past 90 degrees. */ + readonly horizonZenith: number; + /** Transmittance from the observer along a direction `mu` (cos of its zenith angle), to space, per channel. */ + transmittanceToSpace(mu: number, out: number[]): void; + /** Sky radiance per unit of sun irradiance for view zenith cos and cos of the azimuth from the sun; linear RGB. */ + radiance(viewCosZenith: number, cosFromSun: number, out: number[]): void; + /** Light the sky and sun give a horizontal surface at the observer: direct (per unit sun irradiance) and ambient. */ + irradiance: { direct: Vec3; ambient: Vec3 }; +} + +interface March { + L: [number, number, number]; + transmittance: [number, number, number]; + multiScatAs1: [number, number, number]; + opticalDepth: [number, number, number]; +} + +/** + * Hillaire's `IntegrateScatteredLuminance`. Ray from (ox,oy,oz) along unit d, sun unit s; `miePhase` picks the real phase + * functions (the sky) or the isotropic one (the multiple-scattering bake); `ms` the multiple-scattering LUT (absent while it is being built). + */ +function march( + ox: number, + oy: number, + oz: number, + dx: number, + dy: number, + dz: number, + sx: number, + sy: number, + sz: number, + steps: number, + ground: boolean, + miePhase: boolean, + variable: boolean, + mieScale: number, + trLut: Lut2, + ms: Lut2 | undefined, + out: March +): void { + out.L = [0, 0, 0]; + out.transmittance = [1, 1, 1]; + out.multiScatAs1 = [0, 0, 0]; + out.opticalDepth = [0, 0, 0]; + const tBottom = raySphere(ox, oy, oz, dx, dy, dz, PLANET_KM); + const tTop = raySphere(ox, oy, oz, dx, dy, dz, TOP_KM); + let tMax: number; + if (tBottom < 0) { + if (tTop < 0) return; + tMax = tTop; + } else { + tMax = tTop > 0 ? Math.min(tTop, tBottom) : tBottom; + } + const cosT = dx * sx + dy * sy + dz * sz; + const phaseMie = miePhase ? hgPhase(MIE_G, -cosT) : 1 / FOUR_PI; + const phaseRay = miePhase ? rayleighPhase(cosT) : 1 / FOUR_PI; + const tmp = [0, 0]; + const msv = [0, 0, 0]; + const trv = [0, 0, 0]; + let tPrev = 0; + for (let s = 0; s < steps; s++) { + let t0; + let t1; + if (variable) { + t0 = tMax * ((s / steps) * (s / steps)); + t1 = tMax * (((s + 1) / steps) * ((s + 1) / steps)); + } else { + t0 = (tMax * s) / steps; + t1 = (tMax * (s + 1)) / steps; + } + const t = t0 + (t1 - t0) * 0.3; + const dt = t1 - t0; + tPrev = t; + const px = ox + t * dx; + const py = oy + t * dy; + const pz = oz + t * dz; + const r = Math.sqrt(px * px + py * py + pz * pz); + const med = sampleMedium(r - PLANET_KM, mieScale); + const sunCos = (px * sx + py * sy + pz * sz) / r; + const [u, v] = transmittanceUv(r, sunCos); + sampleTexel(trLut, u * (trLut.w - 1), v * (trLut.h - 1), trv); + // Planet shadow: the sun's ray from here must not meet the ground. + const shadow = raySphere(px, py, pz, sx, sy, sz, PLANET_KM) >= 0 ? 0 : 1; + if (ms) { + const uu = toSub(sunCos * 0.5 + 0.5, ms.w); + const vv = toSub(clamp((r - PLANET_KM) / (TOP_KM - PLANET_KM), 0, 1), ms.h); + sampleTexel(ms, uu * ms.w - 0.5, vv * ms.h - 0.5, msv); + } else { + msv[0] = 0; + msv[1] = 0; + msv[2] = 0; + } + for (let k = 0; k < 3; k++) { + const ext = med.extinction[k]; + const od = ext * dt; + const sampleT = Math.exp(-od); + out.opticalDepth[k] += od; + const phaseScatter = med.mie * phaseMie + med.ray[k] * phaseRay; + const S = shadow * trv[k] * phaseScatter + msv[k] * med.scatter[k]; + const inv = ext > 0 ? (1 - sampleT) / ext : dt; + const th = out.transmittance[k]; + out.L[k] += th * S * inv; + out.multiScatAs1[k] += th * med.scatter[k] * inv; + out.transmittance[k] = th * sampleT; + } + // Isotropic second-order inputs for the multiple-scattering bake use the same quantities; they are + // accumulated by the caller through `out.L` with `miePhase` false (uniform phase) so nothing more is kept here. + } + void tPrev; + void tmp; + if (ground && tBottom > 0 && tMax === tBottom) { + const px = ox + tBottom * dx; + const py = oy + tBottom * dy; + const pz = oz + tBottom * dz; + const r = Math.sqrt(px * px + py * py + pz * pz); + const sunCos = (px * sx + py * sy + pz * sz) / r; + const [u, v] = transmittanceUv(r, sunCos); + sampleTexel(trLut, u * (trLut.w - 1), v * (trLut.h - 1), trv); + const ndotl = Math.max(0, sunCos); + for (let k = 0; k < 3; k++) { + out.L[k] += (trv[k] * out.transmittance[k] * ndotl * GROUND_ALBEDO) / PI; + } + } +} + +function bakeTransmittance(mieScale: number): Lut2 { + const { w, h, steps } = LUT.transmittance; + const lut = makeLut(w, h, 3); + const noLut = makeLut(1, 1, 3); // not read: `ms` is absent and the sun terms are discarded below + noLut.data.fill(1); + for (let j = 0; j < h; j++) { + for (let i = 0; i < w; i++) { + const [r, mu] = transmittanceParams(i / (w - 1), j / (h - 1)); + const sinT = Math.sqrt(1 - mu * mu); + // Optical depth only: a straight integration of the extinction to space. + const tTop = raySphere(0, 0, r, 0, sinT, mu, TOP_KM); + const tBottom = raySphere(0, 0, r, 0, sinT, mu, PLANET_KM); + const tMax = tBottom > 0 ? Math.min(tTop, tBottom) : tTop; + const od = [0, 0, 0]; + for (let s = 0; s < steps; s++) { + const t = (tMax * (s + 0.3)) / steps; + const px = t * sinT; + const pz = r + t * mu; + const med = sampleMedium(Math.sqrt(px * px + pz * pz) - PLANET_KM, mieScale); + const dt = tMax / steps; + for (let k = 0; k < 3; k++) od[k] += med.extinction[k] * dt; + } + const o = (j * w + i) * 3; + for (let k = 0; k < 3; k++) lut.data[o + k] = Math.exp(-od[k]); + } + } + return lut; +} + +function bakeMultiScatter(mieScale: number, trLut: Lut2): Lut2 { + const { w, h, dirs, steps } = LUT.multiScatter; + const lut = makeLut(w, h, 3); + const res: March = { + L: [0, 0, 0], + transmittance: [1, 1, 1], + multiScatAs1: [0, 0, 0], + opticalDepth: [0, 0, 0], + }; + const count = dirs * dirs; + for (let j = 0; j < h; j++) { + for (let i = 0; i < w; i++) { + const u = fromSub((i + 0.5) / w, w); + const v = fromSub((j + 0.5) / h, h); + const cosSun = clamp(u * 2 - 1, -1, 1); + const sx = Math.sqrt(Math.max(0, 1 - cosSun * cosSun)); + const sz = cosSun; + // Hillaire's PLANET_RADIUS_OFFSET: a ray that starts exactly on the ground is degenerate. + const height = PLANET_KM + RADIUS_OFFSET_KM + clamp(v, 0, 1) * (TOP_KM - PLANET_KM - RADIUS_OFFSET_KM); + const sumMs = [0, 0, 0]; + const sumL = [0, 0, 0]; + for (let a = 0; a < dirs; a++) { + for (let b = 0; b < dirs; b++) { + const theta = (2 * PI * (a + 0.5)) / dirs; + const cosPhi = 1 - (2 * (b + 0.5)) / dirs; + const sinPhi = Math.sqrt(1 - cosPhi * cosPhi); + march( + 0, + 0, + height, + Math.cos(theta) * sinPhi, + Math.sin(theta) * sinPhi, + cosPhi, + sx, + 0, + sz, + steps, + true, + false, + false, + mieScale, + trLut, + undefined, + res + ); + for (let k = 0; k < 3; k++) { + sumMs[k] += (res.multiScatAs1[k] * FOUR_PI) / count; + sumL[k] += (res.L[k] * FOUR_PI) / count; + } + } + } + const o = (j * w + i) * 3; + for (let k = 0; k < 3; k++) { + const fms = sumMs[k] / FOUR_PI; // equation 7 + const l2 = sumL[k] / FOUR_PI; // equation 5 + lut.data[o + k] = l2 / (1 - fms); // equation 10: the geometric series of all orders + } + } + } + return lut; +} + +/** + * Sky-view LUT axes (Hillaire's non-linear mapping, upper hemisphere only: nothing below the horizon is drawn). y: view zenith angle + * from 0 to the horizon's, squared toward it; x: the azimuth from the sun, the cosine mapped `-(2 c^2 - 1)`. + */ +function skyViewParamsToUv(viewCos: number, cosFromSun: number, horizonZenith: number): [number, number] { + const zen = Math.acos(clamp(viewCos, -1, 1)); + let coord = 1 - clamp(zen / horizonZenith, 0, 1); + coord = Math.sqrt(coord); + const y = 1 - coord; // 0 at the zenith, 1 at the horizon + const x = Math.sqrt(clamp(-cosFromSun * 0.5 + 0.5, 0, 1)); + return [x, y]; +} +function skyViewUvToParams(x: number, y: number, horizonZenith: number): [number, number] { + let coord = 1 - y; + coord *= coord; + coord = 1 - coord; + const viewCos = Math.cos(horizonZenith * coord); + const c = x * x; + return [viewCos, -(c * 2 - 1)]; +} + +function bakeSkyView( + sunCos: number, + observerKm: number, + mieScale: number, + trLut: Lut2, + ms: Lut2, + horizonZenith: number +): Lut2 { + const { w, h, steps } = LUT.skyView; + const lut = makeLut(w, h, 3); + const res: March = { + L: [0, 0, 0], + transmittance: [1, 1, 1], + multiScatAs1: [0, 0, 0], + opticalDepth: [0, 0, 0], + }; + const sx = Math.sqrt(Math.max(0, 1 - sunCos * sunCos)); + const height = PLANET_KM + observerKm; + for (let j = 0; j < h; j++) { + for (let i = 0; i < w; i++) { + const [viewCos, lightViewCos] = skyViewUvToParams(i / (w - 1), j / (h - 1), horizonZenith); + const sinZ = Math.sqrt(1 - viewCos * viewCos); + march( + 0, + 0, + height, + sinZ * lightViewCos, + sinZ * Math.sqrt(Math.max(0, 1 - lightViewCos * lightViewCos)), + viewCos, + sx, + 0, + sunCos, + steps, + false, + true, + true, + mieScale, + trLut, + ms, + res + ); + const o = (j * w + i) * 3; + lut.data[o] = res.L[0]; + lut.data[o + 1] = res.L[1]; + lut.data[o + 2] = res.L[2]; + } + } + return lut; +} + +/** Number of ray-march steps the three bakes take: the CPU cost in the unit the tests budget. */ +export const bakeCostSteps = (): number => { + const t = LUT.transmittance; + const m = LUT.multiScatter; + const s = LUT.skyView; + return t.w * t.h * t.steps + m.w * m.h * m.dirs * m.dirs * m.steps + s.w * s.h * s.steps; +}; + +export function bakeAtmosphere(opts: AtmosphereOptions): Atmosphere { + const mieScale = opts.mieScale ?? NO_WEATHER; + const observerKm = opts.observerKm ?? 0.151; + const sunCos = Math.cos(((90 - opts.sunElevationDeg) * PI) / 180); + const height = PLANET_KM + observerKm; + const beta = Math.acos(Math.sqrt(height * height - PLANET_KM * PLANET_KM) / height); + const horizonZenith = PI - beta; + const transmittance = bakeTransmittance(mieScale); + const multiScatter = bakeMultiScatter(mieScale, transmittance); + const skyView = bakeSkyView(sunCos, observerKm, mieScale, transmittance, multiScatter, horizonZenith); + const tmp = [0, 0, 0]; + const trv = [0, 0, 0]; + const atm: Atmosphere = { + transmittance, + multiScatter, + skyView, + sunElevationDeg: opts.sunElevationDeg, + mieScale, + observerKm, + sunCosZenith: sunCos, + horizonZenith, + transmittanceToSpace(mu, out) { + const [u, v] = transmittanceUv(height, mu); + sampleTexel(transmittance, u * (transmittance.w - 1), v * (transmittance.h - 1), trv); + out[0] = trv[0]; + out[1] = trv[1]; + out[2] = trv[2]; + }, + radiance(viewCos, cosFromSun, out) { + const [x, y] = skyViewParamsToUv(Math.max(viewCos, Math.cos(horizonZenith)), cosFromSun, horizonZenith); + sampleTexel(skyView, x * (skyView.w - 1), y * (skyView.h - 1), tmp); + out[0] = tmp[0]; + out[1] = tmp[1]; + out[2] = tmp[2]; + }, + irradiance: { direct: [0, 0, 0], ambient: [0, 0, 0] }, + }; + // Direct: the sun's transmittance times the cosine; ambient: the sky's radiance averaged over the upper hemisphere (cosine weighted). + const direct: number[] = [0, 0, 0]; + if (opts.sunElevationDeg > -1) { + atm.transmittanceToSpace(Math.max(sunCos, 0), direct); + const c = Math.max(0, sunCos); + direct[0] *= c; + direct[1] *= c; + direct[2] *= c; + } + const amb = [0, 0, 0]; + const out = [0, 0, 0]; + const N = 12; + const M = 6; + let wsum = 0; + for (let a = 0; a < N; a++) { + for (let b = 0; b < M; b++) { + const az = (PI * (a + 0.5)) / N; // 0..pi from the sun: the sky is symmetric + const zen = (0.5 * PI * (b + 0.5)) / M; + const wgt = Math.cos(zen) * Math.sin(zen); + atm.radiance(Math.cos(zen), Math.cos(az), out); + for (let k = 0; k < 3; k++) amb[k] += out[k] * wgt; + wsum += wgt; + } + } + atm.irradiance.direct = [direct[0], direct[1], direct[2]]; + atm.irradiance.ambient = [(amb[0] / wsum) * PI, (amb[1] / wsum) * PI, (amb[2] / wsum) * PI]; + return atm; +} diff --git a/src/app/features/scene/ridge/horizon.test.ts b/src/app/features/scene/ridge/horizon.test.ts new file mode 100644 index 00000000..092d5835 --- /dev/null +++ b/src/app/features/scene/ridge/horizon.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import bake from './viennaHorizon.json'; +import { VIENNA } from './vienna'; +import { horizonDistanceKm, horizonElevation, horizonNormal } from './horizon'; + +describe('the baked horizon', () => { + it('was baked from the viewpoint the sun is computed for', () => { + expect(bake.viewpoint.lat).toBe(VIENNA.lat); + expect(bake.viewpoint.lon).toBe(VIENNA.lon); + expect(bake.viewpoint.groundM / 1000).toBeCloseTo(VIENNA.altitudeKm, 2); + }); + it('carries its source and licence beside the data', () => { + expect(bake.source.licence).toMatch(/attribution required/); + expect(bake.source.attribution).toMatch(/COPERNICUS/); + }); + it('is a closed ring of plausible angles: the Wienerwald NW, open Marchfeld E', () => { + expect(bake.elevationDeg).toHaveLength(720); + expect(horizonElevation(0)).toBeCloseTo(horizonElevation(360), 6); + expect(horizonElevation(305)).toBeGreaterThan(horizonElevation(70)); + expect(Math.max(...bake.elevationDeg)).toBeLessThan(5); + expect(horizonDistanceKm(305)).toBeGreaterThan(1); + }); + it('interpolates between samples and returns unit normals', () => { + expect(horizonElevation(100.25)).toBeCloseTo((bake.elevationDeg[200] + bake.elevationDeg[201]) / 2, 6); + expect(Math.hypot(...horizonNormal(250))).toBeCloseTo(1, 6); + }); +}); diff --git a/src/app/features/scene/ridge/horizon.ts b/src/app/features/scene/ridge/horizon.ts new file mode 100644 index 00000000..82265a77 --- /dev/null +++ b/src/app/features/scene/ridge/horizon.ts @@ -0,0 +1,54 @@ +// The terrain horizon seen from the viewpoint, baked from open DEM by `scripts/bake-horizon.py` (source, licence and attribution are +// in the file's `source` block and in `HORIZON_ATTRIBUTION`). +import bake from './viennaHorizon.json'; + +export const HORIZON_ATTRIBUTION = bake.source.attribution; + +const N = bake.elevationDeg.length; + +/** Index pair and weight of an azimuth (degrees clockwise from north) on the baked ring. */ +function at(azimuth: number): [number, number, number] { + const f = ((((azimuth / bake.stepDeg) % N) + N) % N); + const i = Math.floor(f); + return [i, (i + 1) % N, f - i]; +} + +const lerp = (arr: readonly number[], az: number): number => { + const [a, b, t] = at(az); + return arr[a] * (1 - t) + arr[b] * t; +}; + +/** Elevation of the horizon, degrees above the astronomical horizon, at an azimuth. */ +export const horizonElevation = (azimuth: number): number => lerp(bake.elevationDeg, azimuth); + +/** Distance of the terrain that sets the horizon, km. */ +export const horizonDistanceKm = (azimuth: number): number => lerp(bake.distanceKm, azimuth); + +/** Unit surface normal of the terrain at the horizon point: x east, y up, z north. */ +export function horizonNormal(azimuth: number): [number, number, number] { + const e = lerp(bake.slopeEast, azimuth); + const n = lerp(bake.slopeNorth, azimuth); + const l = Math.hypot(e, 1, n); + return [-e / l, 1 / l, -n / l]; +} + +/** The elevations blurred over +-SMOOTH_DEG (a box, around the ring): at 30 m a city's roofs are the horizon's teeth, and a silhouette of them reads as a fence. */ +const SMOOTH_DEG = 1.5; +const SMOOTH = Array.from({ length: N }, (_, i) => { + const k = Math.round(SMOOTH_DEG / bake.stepDeg); + let sum = 0; + for (let d = -k; d <= k; d += 1) sum += bake.elevationDeg[(i + d + N) % N]; + return sum / (2 * k + 1); +}); +const MIN_E = Math.min(...SMOOTH); +const RANGE_E = Math.max(...SMOOTH) - MIN_E; + +/** The horizon at an azimuth as 0..1 of the ring's own range (the lowest point of the horizon 0, the highest 1): the silhouette's shape, not its scale. */ +export const horizonShape = (azimuth: number): number => (lerp(SMOOTH, azimuth) - MIN_E) / RANGE_E; + +/** Real range of the smoothed horizon, degrees: what `horizonShape` stretches to the layer's relief. */ +export const HORIZON_RANGE_DEG = RANGE_E; + +/** Horizontal field of view, degrees, of a camera with the sky's vertical field (`skyDome.ts` VFOV_DEG) at an aspect ratio. */ +export const hfovDeg = (aspect: number, vfovDeg: number): number => + (2 * Math.atan(aspect * Math.tan((vfovDeg * Math.PI) / 360)) * 180) / Math.PI; diff --git a/src/app/features/scene/ridge/horizonLayer.test.ts b/src/app/features/scene/ridge/horizonLayer.test.ts new file mode 100644 index 00000000..8ed27e4c --- /dev/null +++ b/src/app/features/scene/ridge/horizonLayer.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { hfovDeg, horizonShape } from './horizon'; +import { HORIZON_TRACK_SHIFT, LAYERS, layerProfile, profile } from './ridgeWorld'; +import { layerPaint } from './ridgeBake'; +import { weatherOf } from './weather'; +import { skyColours } from './skyColour'; +import { makePhysicalSky } from './physicalSky'; +import { VFOV_DEG } from './skyDome'; + +describe('the horizon layer', () => { + const far = LAYERS[0]; + const view = { azimuthDeg: 200, hfovDeg: hfovDeg(16 / 9, VFOV_DEG) }; + + it('is the farthest layer, real and standing still', () => { + expect(far.horizon).toBe(true); + expect(Number.isFinite(far.seconds)).toBe(false); + expect(LAYERS.slice(1).every((l) => !l.horizon && Number.isFinite(l.seconds))).toBe(true); + }); + + it('reads world-x as azimuth across the viewport: the left edge is half the field to the left of the view, the right edge half to the right', () => { + const left = layerProfile(far, HORIZON_TRACK_SHIFT, view); + const right = layerProfile(far, HORIZON_TRACK_SHIFT + 1, view); + expect(left).toBeCloseTo(horizonShape(200 - view.hfovDeg / 2), 9); + expect(right).toBeCloseTo(horizonShape(200 + view.hfovDeg / 2), 9); + }); + + it('stays in 0..1 and covers a real range of shape, not a line', () => { + const v = Array.from({ length: 200 }, (_, i) => layerProfile(far, -0.06 + (i / 200) * 1.2, { azimuthDeg: 305, hfovDeg: view.hfovDeg })); + expect(Math.min(...v)).toBeGreaterThanOrEqual(0); + expect(Math.max(...v)).toBeLessThanOrEqual(1); + expect(Math.max(...v) - Math.min(...v)).toBeGreaterThan(0.2); + }); + + it('is the noise profile for the other layers, and for the horizon layer when no view is known', () => { + expect(layerProfile(LAYERS[2], 0.4, view)).toBe(profile(LAYERS[2], 0.4)); + expect(layerProfile(far, 0.4)).toBe(profile(far, 0.4)); + }); + + it('the horizontal field of a 1:1 view is the vertical one, and grows with the aspect', () => { + expect(hfovDeg(1, VFOV_DEG)).toBeCloseTo(VFOV_DEG, 9); + expect(hfovDeg(16 / 9, VFOV_DEG)).toBeGreaterThan(VFOV_DEG); + }); +}); + +describe('weather in the layers', () => { + it('mist multiplies the layers\' own, and clear air leaves it alone', () => { + const base = skyColours(); + const day = new Date('2026-06-21T10:40:00Z'); + const clear = layerPaint(LAYERS[1], { ...base, physical: makePhysicalSky('day', day, 0) }); + const misty = layerPaint(LAYERS[1], { ...base, physical: makePhysicalSky('day', day, 0.8) }); + expect(clear.mistK).toBe(1); + expect(misty.mistK).toBeGreaterThan(2); + expect(layerPaint(LAYERS[1], base).mistK).toBe(1); + }); +}); + +describe('the weather number', () => { + it('a number that is not finite is a clear sky, not an error', () => { + expect(weatherOf(Number.NaN).value).toBe(0); + expect(weatherOf(Infinity).value).toBe(0); + expect(weatherOf(0.5).value).toBe(0.5); + }); +}); diff --git a/src/app/features/scene/ridge/physicalSky.test.ts b/src/app/features/scene/ridge/physicalSky.test.ts new file mode 100644 index 00000000..bb8c75a2 --- /dev/null +++ b/src/app/features/scene/ridge/physicalSky.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { makePhysicalSky, paintSky } from './physicalSky'; +import { skyBackdrop } from '../skyState'; +import type { FixedSkyState } from '../skyBand'; + +// The Aqua method on tonemapped pixels: paint the sky the way the host canvas does and measure the WCAG luminance of every 8-bit +// texel, against the bounds the page text was checked against (`skyBackdrop`, 4.5:1). Over the weather, the day of the year, and +// viewports of different shape (the fit is made on one 16:9 grid and the paint on another). +const lin = (v: number): number => { + const c = v / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +}; +const luma = (d: Uint8ClampedArray, i: number): number => 0.2126 * lin(d[i]) + 0.7152 * lin(d[i + 1]) + 0.0722 * lin(d[i + 2]); + +const DAYS = ['2026-03-20', '2026-06-21', '2026-09-23', '2026-12-21']; +const WEATHER = [0, 0.3, 0.55, 0.75, 1]; +const VIEWPORTS: [number, number][] = [ + [1920, 1080], + [1024, 768], + [2560, 1080], + [390, 844], +]; + +describe.each(['day', 'dusk', 'night'] as FixedSkyState[])('the %s sky stays inside its backdrop bounds', (state) => { + it('over weather, season and viewport shape', () => { + const b = skyBackdrop(state); + let lo = 2; + let hi = -1; + for (const day of DAYS) { + for (const w of WEATHER) { + const sky = makePhysicalSky(state, new Date(`${day}T10:40:00Z`), w); + for (const [vw, vh] of VIEWPORTS) { + const px = paintSky(sky, vw, vh); + for (let i = 0; i < px.w * px.h * 4; i += 4) { + const l = luma(px.data, i); + lo = Math.min(lo, l); + hi = Math.max(hi, l); + } + } + } + } + expect(lo).toBeGreaterThanOrEqual(b.darkest); + expect(hi).toBeLessThanOrEqual(b.lightest); + }); +}); + +describe('weather in the sky', () => { + it('overcast is cloud: less blue and less contrast than clear air at the same sun', () => { + const at = new Date('2026-06-21T10:40:00Z'); + const clear = paintSky(makePhysicalSky('day', at, 0), 640, 360); + const grey = paintSky(makePhysicalSky('day', at, 0.7), 640, 360); + const spread = (p: typeof clear) => { + let lo = 2; + let hi = -1; + for (let i = 0; i < p.w * p.h * 4; i += 4) { + const l = luma(p.data, i); + lo = Math.min(lo, l); + hi = Math.max(hi, l); + } + return hi - lo; + }; + expect(spread(grey)).toBeLessThan(spread(clear)); + const mid = (p: typeof clear) => { + const i = (Math.floor(p.h * 0.3) * p.w + Math.floor(p.w / 2)) * 4; + return p.data[i + 2] - p.data[i]; + }; + expect(mid(grey)).toBeLessThan(mid(clear)); + }); +}); + +describe('the sun disc', () => { + it('keeps the display floor the dark link was fitted to, at every hour it is drawn', () => { + const lin = (c: number) => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4); + let drawn = 0; + for (const state of ['day', 'dusk'] as FixedSkyState[]) { + for (let m = 0; m < 24 * 60; m += 10) { + const d = paintSky(makePhysicalSky(state, new Date(Date.UTC(2026, 10, 11, 0, m)), 0), 1280, 720).sunDisc; + if (!d) continue; + drawn += 1; + expect(0.2126 * lin(d.rgb[0]) + 0.7152 * lin(d.rgb[1]) + 0.0722 * lin(d.rgb[2])).toBeGreaterThanOrEqual(0.4744); + } + } + expect(drawn).toBeGreaterThan(0); + }); + const noon = new Date('2026-11-11T10:40:00Z'); // 21 degrees: in June the noon sun is above the top of the image + it('is drawn by the host, in a clear sky by day, white with the sun high', () => { + const d = paintSky(makePhysicalSky('day', noon, 0), 1280, 720).sunDisc; + expect(d).toBeDefined(); + expect(d!.x).toBeGreaterThan(0); + expect(d!.x).toBeLessThan(1); + expect(d!.y).toBeGreaterThan(0); + expect(d!.y).toBeLessThan(0.78); // above the horizon line + expect(d!.alpha).toBe(1); + expect(Math.min(...d!.rgb)).toBeGreaterThan(150); // a warm white at 21 degrees + }); + it('is hidden by overcast and absent at night', () => { + expect(paintSky(makePhysicalSky('day', noon, 0.7), 1280, 720).sunDisc).toBeUndefined(); + expect(paintSky(makePhysicalSky('night', noon, 0), 1280, 720).sunDisc).toBeUndefined(); + }); + it('is orange-tinted low in the sky', () => { + const d = paintSky(makePhysicalSky('dusk', noon, 0), 1280, 720).sunDisc; + expect(d).toBeDefined(); + expect(d!.rgb[0]).toBeGreaterThan(d!.rgb[2]); + }); +}); diff --git a/src/app/features/scene/ridge/physicalSky.ts b/src/app/features/scene/ridge/physicalSky.ts new file mode 100644 index 00000000..48a10a16 --- /dev/null +++ b/src/app/features/scene/ridge/physicalSky.ts @@ -0,0 +1,124 @@ +// The ridge scene's sky: Vienna's sun on the real clock, the atmosphere baked from it, a camera, and the display that makes it +// pixels. `makePhysicalSky` does the one expensive thing (the LUT bake, tens of ms) and fits the display from a fixed 16:9 +// grid, so resizing the window repaints the same sky. `paintSky` is per-texel lookups only. +import type { FixedSkyState } from '../skyBand'; +import { bakeAtmosphere } from './earthAtmosphere'; +import type { Atmosphere } from './earthAtmosphere'; +import { domeSize, HORIZON_AT, renderDome, SUN_RADIUS, sunOnScreen, viewFor, VFOV_DEG } from './skyDome'; +import type { Dome, DomeView } from './skyDome'; +import type { Srgb } from './skyColour'; +import { display8, fitDisplay, holdBudget, MARGIN } from './toneMap'; +import type { Display, Fit } from './toneMap'; +import { sunForState, VIENNA } from './vienna'; +import type { SunSample } from './vienna'; +import { weatherOf } from './weather'; +import type { Weather } from './weather'; + +export interface PhysicalSky { + state: FixedSkyState; + sun: SunSample; + weather: Weather; + atm: Atmosphere; + /** The display fitted to this sky's budget. */ + fit: Fit; + /** The sky at the top, the middle and the horizon of the image (mean of each row), for the layers' haze and the stars' cover. */ + stops: { top: Srgb; mid: Srgb; horizon: Srgb }; + /** How long the bake took, ms. */ + bakeMs: number; +} + +const CANON = { w: 64, h: 36, aspect: 16 / 9 }; + +const clock = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()); + +export function rowMean(dome: Dome, display: Display, row: number): Srgb { + const out = [0, 0, 0]; + const acc = [0, 0, 0]; + const j = Math.min(dome.h - 1, Math.max(0, row)); + for (let i = 0; i < dome.w; i += 1) { + const o = (j * dome.w + i) * 3; + display8(dome.hdr[o], dome.hdr[o + 1], dome.hdr[o + 2], display, out); + acc[0] += out[0]; + acc[1] += out[1]; + acc[2] += out[2]; + } + return [Math.round(acc[0] / dome.w), Math.round(acc[1] / dome.w), Math.round(acc[2] / dome.w)]; +} + +export function makePhysicalSky(state: FixedSkyState, now: Date, weatherValue: number): PhysicalSky { + const t0 = clock(); + const sun = sunForState(state, now); + const weather = weatherOf(weatherValue); + const atm = bakeAtmosphere({ + sunElevationDeg: sun.elevation, + mieScale: weather.mieScale, + observerKm: VIENNA.altitudeKm, + }); + const dome = renderDome(atm, sun, viewFor(sun, CANON.aspect), CANON.w, CANON.h, weather.cover); + const fit = fitDisplay(state, dome.hdr, dome.meanLuma); + const at = (f: number) => rowMean(dome, fit.display, Math.round(f * CANON.h)); + return { + state, + sun, + weather, + atm, + fit, + stops: { top: at(0.02), mid: at(0.5), horizon: at(HORIZON_AT) }, + bakeMs: clock() - t0, + }; +} + +export interface SkyPixels { + w: number; + h: number; + /** RGBA, 8 bit. */ + data: Uint8ClampedArray; + dome: Dome; + view: DomeView; + /** The sun's disc for the host to draw on the upscaled canvas (it is smaller than a texel): place as fractions of the image, colour, radius in fractions of the height, opacity after the cloud. */ + sunDisc?: { x: number; y: number; rgb: Srgb; radius: number; alpha: number }; +} + +/** The sky for a viewport, as a small RGBA grid the host canvas upscales smoothly. */ +export function paintSky(p: PhysicalSky, cssW: number, cssH: number): SkyPixels { + const { w, h } = domeSize(cssW, cssH); + const view = viewFor(p.sun, cssW / cssH); + const dome = renderDome(p.atm, p.sun, view, w, h, p.weather.cover); + const data = new Uint8ClampedArray(w * h * 4); + const out = [0, 0, 0]; + const display = holdBudget(p.state, dome.hdr, p.fit.display); + for (let i = 0; i < w * h; i += 1) { + display8(dome.hdr[i * 3], dome.hdr[i * 3 + 1], dome.hdr[i * 3 + 2], display, out); + data[i * 4] = out[0]; + data[i * 4 + 1] = out[1]; + data[i * 4 + 2] = out[2]; + data[i * 4 + 3] = 255; + } + return { w, h, data, dome, view, sunDisc: sunDisc(p, view) }; +} + +/** The luma the disc must keep: the day/dusk floor of toneMap's budget. */ +const DISC_FLOOR = 0.4744 + MARGIN; +const smooth01 = (a: number, b: number, v: number): number => { + const t = Math.min(1, Math.max(0, (v - a) / (b - a))); + return t * t * (3 - 2 * t); +}; + +/** + * The sun's disc: always brighter than the display's white, so what is left of it is its colour, the air's transmittance to the sun + * normalised to its strongest channel (white high up, orange near the horizon). Thick cloud hides it. + */ +function sunDisc(p: PhysicalSky, view: DomeView): SkyPixels['sunDisc'] { + const at = sunOnScreen(p.sun, view); + const alpha = 1 - smooth01(0.1, 0.6, p.weather.cover); + if (!at || alpha <= 0) return undefined; + const trans = [0, 0, 0]; + p.atm.transmittanceToSpace(Math.max(Math.sin((p.sun.elevation * Math.PI) / 180), 0), trans); + const top = Math.max(...trans, 1e-9); + const rgb = trans.map((t) => Math.round(255 * (t / top) ** 0.6)); + // The disc sits on the sky, so it keeps the display floor the dark link was fitted to: mix toward white until it does. + const lin = (c: number): number => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4); + const luma = (c: number[]): number => 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]); + for (let m = 0; luma(rgb) < DISC_FLOOR && m < 40; m += 1) for (let i = 0; i < 3; i += 1) rgb[i] = Math.round(rgb[i] + (255 - rgb[i]) * 0.1); + return { ...at, rgb: rgb as unknown as Srgb, radius: (SUN_RADIUS * 180) / Math.PI / VFOV_DEG, alpha }; +} diff --git a/src/app/features/scene/ridge/rain.test.ts b/src/app/features/scene/ridge/rain.test.ts new file mode 100644 index 00000000..6575ea20 --- /dev/null +++ b/src/app/features/scene/ridge/rain.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { drawRainTile, RAIN_TILES, streakCount } from './rain'; + +const recorder = () => { + const calls: string[] = []; + const ctx = new Proxy({} as Record, { + get: (t, k: string) => (k in t ? t[k] : (...a: unknown[]) => calls.push(`${k}(${a.join(',')})`)), + set: (t, k: string, v) => { + t[k] = v; + return true; + }, + }) as unknown as CanvasRenderingContext2D; + return { ctx, calls }; +}; + +describe('rain', () => { + it('draws no streaks without rain and more with more', () => { + expect(streakCount(RAIN_TILES[0], 0)).toBe(0); + expect(streakCount(RAIN_TILES[0], 0.3)).toBeLessThan(streakCount(RAIN_TILES[0], 1)); + const { ctx, calls } = recorder(); + drawRainTile(ctx, RAIN_TILES[0], 0, [200, 210, 220], 1); + expect(calls.filter((c) => c.startsWith('stroke'))).toHaveLength(0); + }); + it('is the same tile every time, and every streak is drawn in the nine wraps so the tile repeats without a seam', () => { + const a = recorder(); + const b = recorder(); + drawRainTile(a.ctx, RAIN_TILES[1], 1, [200, 210, 220], 1); + drawRainTile(b.ctx, RAIN_TILES[1], 1, [200, 210, 220], 1); + expect(a.calls).toEqual(b.calls); + expect(a.calls.filter((c) => c.startsWith('stroke'))).toHaveLength(streakCount(RAIN_TILES[1], 1) * 9); + }); +}); diff --git a/src/app/features/scene/ridge/rain.ts b/src/app/features/scene/ridge/rain.ts new file mode 100644 index 00000000..699130cf --- /dev/null +++ b/src/app/features/scene/ridge/rain.ts @@ -0,0 +1,54 @@ +// Rain as one tiled texture of slanted streaks, drawn once and slid down by the compositor (a CSS animation on a transform: no per-frame +// script, no per-frame paint). Two tiles of different size and speed read as depth. The streaks wear the sky's own horizon colour, so +// whatever they lighten stays inside the state's luminance bounds (a streak is a blend of two colours the sky already has). +import { hash2 } from './noise'; +import type { Srgb } from './skyColour'; + +export interface RainTile { + /** Tile edge, canvas px. */ + size: number; + /** Seconds for a tile to fall its own height. */ + seconds: number; + /** Streaks per tile at rain 1. */ + streaks: number; + /** Streak length, tile fractions. */ + length: number; + /** Peak alpha. */ + alpha: number; +} + +/** Far, fine and slow; near, long and quick. */ +export const RAIN_TILES: readonly RainTile[] = [ + { size: 192, seconds: 0.9, streaks: 46, length: 0.16, alpha: 0.32 }, + { size: 320, seconds: 0.6, streaks: 22, length: 0.24, alpha: 0.42 }, +]; + +/** Slant of a streak: pixels left per pixel down. */ +export const RAIN_SLANT = 0.18; + +/** Streaks to draw at a rain amount 0..1 (none at 0). */ +export const streakCount = (t: RainTile, rain: number): number => (rain <= 0 ? 0 : Math.max(4, Math.round(t.streaks * rain))); + +/** Draw one tile. The streaks wrap across the tile's edges so the tile repeats without a seam. */ +export function drawRainTile(ctx: CanvasRenderingContext2D, t: RainTile, rain: number, colour: Srgb, scale: number, seed = 0): void { + const n = streakCount(t, rain); + ctx.clearRect(0, 0, t.size, t.size); + ctx.lineCap = 'round'; + ctx.lineWidth = Math.max(1, scale); + for (let i = 0; i < n; i += 1) { + const x = hash2(i, seed + 3) * t.size; + const y = hash2(i, seed + 7) * t.size; + const len = t.length * t.size * (0.7 + 0.6 * hash2(i, seed + 11)); + const a = t.alpha * (0.5 + 0.5 * hash2(i, seed + 13)) * (0.5 + 0.5 * rain); + ctx.strokeStyle = `rgba(${colour[0]},${colour[1]},${colour[2]},${a.toFixed(3)})`; + // Draw each streak at its place and one tile over in each direction it can cross an edge. + [-t.size, 0, t.size].forEach((ox) => + [-t.size, 0, t.size].forEach((oy) => { + ctx.beginPath(); + ctx.moveTo(x + ox, y + oy); + ctx.lineTo(x + ox - len * RAIN_SLANT, y + oy + len); + ctx.stroke(); + }) + ); + } +} diff --git a/src/app/features/scene/ridge/ridgeBake.ts b/src/app/features/scene/ridge/ridgeBake.ts index 770c30e0..72736d85 100644 --- a/src/app/features/scene/ridge/ridgeBake.ts +++ b/src/app/features/scene/ridge/ridgeBake.ts @@ -3,7 +3,7 @@ // slope of the heightfield's crest line against the sun; the face under it takes a light-to-haze // gradient, erosion streaks and a lit rim on the crest. The depth treatment (colour, haze, contrast // by distance) is ridgeWorld's `layerColour`, `hazeColour` and `transmittance`, used here and not redone. -import { gullyAt, LayerSpec, profile, hazeColour, layerColour, transmittance } from './ridgeWorld'; +import { gullyAt, HorizonView, LayerSpec, layerProfile, hazeColour, layerColour, transmittance } from './ridgeWorld'; import type { SkyBand } from '../skyBand'; import { LUMA, SKY_BAND } from '../skyBand'; import type { SkyColours, Srgb } from './skyColour'; @@ -45,6 +45,8 @@ export interface BakeParams { rows: number; /** Row count of the visible band; the crest is measured from the top of it. */ bandH: number; + /** Where world-x is a compass azimuth, for the horizon layer (`LayerSpec.horizon`); absent: every layer is noise. */ + horizonView?: HorizonView; /** World pixel of the buffer's column 0 (may be fractional-free negative: a chunk's left minus its padding). */ px0: number; /** Columns of the buffer. */ @@ -67,6 +69,8 @@ export interface LayerPaint { warm: Srgb; contrast: number; sun: Vec3; + /** How much the weather's mist multiplies the layer's own (`weather.ts`): 1 in clear air. */ + mistK: number; } const decode = (v: number): number => { @@ -123,6 +127,7 @@ export function layerPaint(spec: LayerSpec, sky: SkyColours): LayerPaint { // Distance flattens relief: the same transmittance that thins the colour. contrast: 0.35 + 0.65 * transmittance(spec), sun: sunDirection(sky), + mistK: 1 + 2.5 * (sky.physical?.weather.mist ?? 0), }; } @@ -132,7 +137,7 @@ function crestRows(p: BakeParams, c0: number, c1: number): Float64Array { const amp = p.spec.relief * p.viewH; const lift = p.spec.base * p.viewH; for (let c = c0 - 1; c <= c1; c += 1) { - const top = Math.min(p.bandH, lift + amp * profile(p.spec, (p.px0 + c) / p.period)); + const top = Math.min(p.bandH, lift + amp * layerProfile(p.spec, (p.px0 + c) / p.period, p.horizonView)); out[c - c0 + 1] = p.bandH - top; } return out; @@ -155,7 +160,7 @@ export function bakeSlice( const faceH = 0.45 * p.viewH; const rim = 2.2 * p.scale; const glow = 9 * p.scale; - const { base, haze, warm, contrast, sun, shift } = paint; + const { base, haze, warm, contrast, sun, shift, mistK } = paint; for (let c = c0; c < c1; c += 1) { const cy = crest[c - c0 + 1]; const slope = (crest[c - c0 + 2] - crest[c - c0]) * -0.5; // rows up per column @@ -182,7 +187,7 @@ export function bakeSlice( (clamp01(1 - d / rim) * 0.4 + (d < glow * 5 ? Math.exp(-d / glow) * 0.08 : 0)) * contrast * (0.3 + 0.7 * lit); - const mist = spec.mist * smoothstep(0, 1, dn); + const mist = Math.min(0.9, spec.mist * mistK) * smoothstep(0, 1, dn); const o = (y * p.cols + c) * 4; for (let i = 0; i < 3; i += 1) { let v = base[i] * shade + warm[i] * (warmK + rimK); diff --git a/src/app/features/scene/ridge/ridgeWorld.ts b/src/app/features/scene/ridge/ridgeWorld.ts index 569b43bd..6bd8e3b1 100644 --- a/src/app/features/scene/ridge/ridgeWorld.ts +++ b/src/app/features/scene/ridge/ridgeWorld.ts @@ -1,6 +1,7 @@ // Every number of the ridge scene, and the pure functions that turn them into silhouettes. No DOM. // A ridge is a function of world-x (t, in viewport widths): ridged multifractal noise, domain-warped, // one function for every layer with the parameters below. Nothing in it repeats. +import { horizonShape } from './horizon'; import { valueNoise } from './noise'; import type { Srgb, SkyColours } from './skyColour'; @@ -29,23 +30,40 @@ export interface LayerSpec { gullyPx: number; /** 0..1 how far the foot of the face dissolves into the valley mist. */ mist: number; + /** + * The layer is the real horizon of the viewpoint (`horizon.ts`), not noise: world-x is compass azimuth across the viewport + * (`HorizonView`) and it does not drift, so it stays under the sun it is lit by. Its `relief` is the ring's own range. + */ + horizon?: boolean; } +/** Where the camera looks and how wide it sees, to read world-x as an azimuth: the sky's own view (`skyDome.ts viewFor`). */ +export interface HorizonView { + /** Compass azimuth of the viewport's centre, degrees clockwise from north. */ + azimuthDeg: number; + hfovDeg: number; +} + +/** The static horizon layer's track sits this many viewport widths left of the screen, so the camera's sway never shows its edge. */ +export const HORIZON_TRACK_SHIFT = 0.06; + + /** Four layers: one function, four parameter rows. */ export const LAYERS: readonly LayerSpec[] = [ { index: 0, baseCells: 2.2, - relief: 0.24, + relief: 0.16, crag: 0.85, - base: 0.34, + base: 0.3, depth: 0.12, - seconds: 420, + seconds: Infinity, octaves: 5, warp: 0.6, gully: 0.3, gullyPx: 16, mist: 0.35, + horizon: true, }, { index: 1, @@ -128,6 +146,15 @@ export function profile(spec: LayerSpec, t: number): number { return Math.min(1, 0.85 * (sum / norm) * PEAK_NORM + 0.15 * swell); } +/** + * Silhouette height in [0, 1] of a layer at world-x `t` (viewport widths). The horizon layer reads the baked horizon at the azimuth `t` + * is under `view`; every other layer, or a horizon layer with no view, is the noise `profile`. + */ +export function layerProfile(spec: LayerSpec, t: number, view?: HorizonView): number { + if (!spec.horizon || !view) return profile(spec, t); + return horizonShape(view.azimuthDeg + (t - HORIZON_TRACK_SHIFT - 0.5) * view.hfovDeg); +} + /** Erosion streak strength in [0, 1] at world-x `wxCss` (CSS px): a folded noise at gully width. */ export function gullyAt(spec: LayerSpec, wxCss: number): number { const g = 1 - Math.abs(2 * valueNoise(wxCss / spec.gullyPx, spec.index * 131 + 5) - 1); @@ -198,7 +225,7 @@ export function layerSize(cssW: number, cssH: number, scale: number) { } /** Ridge-top y (canvas pixels, from the top of the layer's band) at world-x `t` (viewport widths). */ -export function ridgeY(spec: LayerSpec, t: number, bandH: number, viewH: number): number { - const topFromBottom = spec.base * viewH + spec.relief * viewH * profile(spec, t); +export function ridgeY(spec: LayerSpec, t: number, bandH: number, viewH: number, view?: HorizonView): number { + const topFromBottom = spec.base * viewH + spec.relief * viewH * layerProfile(spec, t, view); return bandH - Math.min(bandH, topFromBottom); } diff --git a/src/app/features/scene/ridge/skyColour.ts b/src/app/features/scene/ridge/skyColour.ts index 1fa7e6fe..e2898a0b 100644 --- a/src/app/features/scene/ridge/skyColour.ts +++ b/src/app/features/scene/ridge/skyColour.ts @@ -5,6 +5,7 @@ // and bakes the result. import type { SkyBand } from '../skyBand'; +import type { PhysicalSky } from './physicalSky'; export type Srgb = readonly [number, number, number]; @@ -21,6 +22,8 @@ export interface SkyColours { sunAzimuth?: number; /** The state's luminance band (skyBand.ts): the layers are shaped into it as the sky is, so the theme ink clears 4.5:1 on a ridge as on bare sky. Omit for none. */ band?: SkyBand; + /** The physical sky this palette was read from; when present the host canvas is painted from it, not from the three stops. */ + physical?: PhysicalSky; } export function skyColours(_now: Date = new Date()): SkyColours { diff --git a/src/app/features/scene/ridge/skyDome.ts b/src/app/features/scene/ridge/skyDome.ts new file mode 100644 index 00000000..41da66b8 --- /dev/null +++ b/src/app/features/scene/ridge/skyDome.ts @@ -0,0 +1,122 @@ +// The sky as a camera sees it: for each texel of a small grid, the view ray, its radiance out of the atmosphere's sky-view +// LUT (`earthAtmosphere.ts`), and nothing else; one lookup per texel, no march. The result is linear HDR +// radiance per unit of sun irradiance (a `Float32Array`); `toneMap.ts` makes it pixels. +import type { Atmosphere } from './earthAtmosphere'; +import type { SunSample } from './vienna'; +import { cloudAmount, cloudRadiance } from './clouds'; + +const RAD = Math.PI / 180; +/** The sun's angular radius in radians (Hillaire's/Bruneton's `sun_angular_radius`). */ +export const SUN_RADIUS = 0.004675; +/** Vertical field of view of the camera, degrees, and where the horizon sits down the image: the ridge scene's sky stops. */ +export const VFOV_DEG = 50; +export const HORIZON_AT = 0.78; + +export interface DomeView { + /** Compass azimuth the camera faces, degrees clockwise from north. */ + azimuthDeg: number; + /** Width over height of the viewport. */ + aspect: number; +} + +/** Where the camera looks: toward the sun's side while it is up, so the sun sits right of centre; south in the dark. */ +export const viewFor = (sun: SunSample, aspect: number): DomeView => ({ + azimuthDeg: sun.elevation > -6 ? sun.azimuth - 24 : 180, + aspect, +}); + +export interface Dome { + w: number; + h: number; + /** Linear radiance, 3 floats per texel, row 0 at the top. */ + hdr: Float32Array; + /** Mean over the grid of the log of the radiance luminance, exponentiated: what auto-exposure reads. */ + meanLuma: number; +} + +/** Grid for a viewport: about 8 CSS px per texel, capped. */ +export const domeSize = (cssW: number, cssH: number): { w: number; h: number } => ({ + w: Math.min(256, Math.max(8, Math.ceil(cssW / 8))), + h: Math.min(160, Math.max(8, Math.ceil(cssH / 8))), +}); + +/** + * Render the HDR sky. Rays below the horizon take the horizon's radiance (the ridges cover them). The sun's disc is not here: it is smaller than a texel, so the host draws it on the upscaled canvas (`sunOnScreen`). `cover` (0..1, `weather.ts`) lays the cloud deck (`clouds.ts`) over it. + */ +export function renderDome( + atm: Atmosphere, + sun: SunSample, + view: DomeView, + w: number, + h: number, + cover = 0 +): Dome { + const hdr = new Float32Array(w * h * 3); + const tanHalf = Math.tan((VFOV_DEG * RAD) / 2); + const fa = view.azimuthDeg * RAD; + const fx = Math.sin(fa); + const fy = Math.cos(fa); + const rx = Math.cos(fa); + const ry = -Math.sin(fa); + const sunAz = sun.azimuth * RAD; + const sunEl = sun.elevation * RAD; + const sz = Math.sin(sunEl); + const trans = [0, 0, 0]; + atm.transmittanceToSpace(Math.max(sz, 0), trans); // the cloud's sunlight + const rgb = [0, 0, 0]; + const cloud = [0, 0, 0]; + if (cover > 0) { + const zenith = [0, 0, 0]; + atm.radiance(1, 1, zenith); + cloudRadiance(sz, trans, zenith, cover, cloud); + } + let logSum = 0; + for (let j = 0; j < h; j += 1) { + const v = (HORIZON_AT - (j + 0.5) / h) * 2 * tanHalf; + for (let i = 0; i < w; i += 1) { + const u = ((i + 0.5) / w - 0.5) * view.aspect * 2 * tanHalf; + const dx = fx + rx * u; + const dy = fy + ry * u; + const dz = v; + const n = Math.hypot(dx, dy, dz); + const vz = dz / n; + const cosFromSun = Math.cos(Math.atan2(dx, dy) - sunAz); + atm.radiance(vz, cosFromSun, rgb); + let r = rgb[0]; + let g = rgb[1]; + let b = rgb[2]; + let lum = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]; + if (cover > 0) { + const c = cloudAmount(dx / n, dy / n, vz, cover); + lum += (0.2126 * cloud[0] + 0.7152 * cloud[1] + 0.0722 * cloud[2] - lum) * c; + r += (cloud[0] - r) * c; + g += (cloud[1] - g) * c; + b += (cloud[2] - b) * c; + } + const o = (j * w + i) * 3; + hdr[o] = r; + hdr[o + 1] = g; + hdr[o + 2] = b; + logSum += Math.log(Math.max(1e-9, lum)); + } + } + return { w, h, hdr, meanLuma: Math.exp(logSum / (w * h)) }; +} + +/** Where the sun is on the image, as fractions of its width and height (y down), or null when it is behind the camera or below the horizon. */ +export function sunOnScreen(sun: SunSample, view: DomeView): { x: number; y: number } | null { + if (sun.elevation < -0.3) return null; + const tanHalf = Math.tan((VFOV_DEG * RAD) / 2); + const fa = view.azimuthDeg * RAD; + const az = sun.azimuth * RAD; + const el = sun.elevation * RAD; + const sx = Math.cos(el) * Math.sin(az); + const sy = Math.cos(el) * Math.cos(az); + const along = sx * Math.sin(fa) + sy * Math.cos(fa); + if (along <= 0.05) return null; + const u = (sx * Math.cos(fa) - sy * Math.sin(fa)) / along; + const v = Math.sin(el) / along; + const x = u / (view.aspect * 2 * tanHalf) + 0.5; + const y = HORIZON_AT - v / (2 * tanHalf); + return x < -0.05 || x > 1.05 || y < -0.05 || y > 1.05 ? null : { x, y }; +} diff --git a/src/app/features/scene/ridge/toneMap.ts b/src/app/features/scene/ridge/toneMap.ts new file mode 100644 index 00000000..b91a4d6d --- /dev/null +++ b/src/app/features/scene/ridge/toneMap.ts @@ -0,0 +1,194 @@ +// The one display transform of the ridge scene: linear scene radiance in, 8-bit sRGB out. Exposure, then the ACES fit of +// Stephen Hill (BakingLab, "ACES.hlsl": the sRGB-to-AP1 input matrix, the RRT and ODT rational fit, the AP1-to-sRGB output +// matrix), then the sRGB encode. A per-pixel function with no state, applied once to the baked sky (CPU: the scene is a 2D +// canvas, there is no float render target to fall back from). +// +// Exposure is chosen per sky state (`exposureFor`): the sky is physical radiance, so brightness is the camera's, not a +// shaping of the result. Day and dusk expose to the sky's own luminance (adaptation, as an eye or auto-exposure does) so the +// darkest of the sky stays where the state's dark ink needs a backdrop; night is fixed, the physical dark. +import type { FixedSkyState } from '../skyBand'; + +type Rgb = readonly [number, number, number]; + +const IN: readonly Rgb[] = [ + [0.59719, 0.35458, 0.04823], + [0.076, 0.90834, 0.01566], + [0.0284, 0.13383, 0.83777], +]; +const OUT: readonly Rgb[] = [ + [1.60475, -0.53108, -0.07367], + [-0.10208, 1.10813, -0.00605], + [-0.00327, -0.07276, 1.07602], +]; +const fit = (v: number): number => (v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081); +const encode = (l: number): number => { + const c = Math.min(1, Math.max(0, l)); + return 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); +}; + +/** ACES (Hill's fit) of linear radiance `r, g, b` times `exposure`, to linear display RGB 0..1, written into `out`. */ +export function acesLinear(r: number, g: number, b: number, exposure: number, out: number[]): void { + const x = r * exposure; + const y = g * exposure; + const z = b * exposure; + const p = [0, 1, 2].map((i) => fit(Math.max(0, IN[i][0] * x + IN[i][1] * y + IN[i][2] * z))); + for (let i = 0; i < 3; i += 1) out[i] = Math.min(1, Math.max(0, OUT[i][0] * p[0] + OUT[i][1] * p[1] + OUT[i][2] * p[2])); +} + +/** The 8-bit sRGB of linear radiance, exposed and tone-mapped. */ +export function toneMap8(r: number, g: number, b: number, exposure: number, out: number[]): void { + acesLinear(r, g, b, exposure, out); + out[0] = Math.round(encode(out[0])); + out[1] = Math.round(encode(out[1])); + out[2] = Math.round(encode(out[2])); +} + +/** WCAG relative luminance of linear display RGB. */ +export const displayLuma = (c: ArrayLike): number => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; + +/** What a sky state needs of its backdrop's display luminance, from the ink it wears (skyBand.ts, skyInk.ts). */ +export interface Budget { + /** The darkest texel may not be darker than this (dark ink needs a light backdrop). 0: no floor. */ + floor: number; + /** The lightest texel may not be lighter than this (light ink needs a dark one). 1: no ceiling. */ + ceiling: number; + /** Display luminance the log-mean of the sky is exposed to, before the budget is applied; undefined: a fixed exposure. */ + key?: number; + /** Exposure range, in multiples of scene radiance (per unit sun irradiance). */ + min: number; + max: number; +} + +/** Display luminance slack for the 8-bit rounding and the smooth upscale of the baked grid. */ +export const MARGIN = 0.004; + +/** + * Day and Dusk wear the same dark ink, so one floor: its bound plus margin, 0.4744, which is what the dark link needs (about 0.4554 for 4.5:1, skyTheme.test.ts); a lower day floor (0.317 was tried) leaves the link at 3.3:1. Night wears light ink, and its + * dimmer foldout headers (L 0.386 on screen, rat-crest-a7c95ce-20261111) need a backdrop of at most 0.047. + */ +export const BUDGET: Record = { + day: { floor: 0.4744 + MARGIN, ceiling: 1, key: 0.45, min: 4, max: 60 }, + dusk: { floor: 0.4744 + MARGIN, ceiling: 1, key: 0.5, min: 4, max: 400 }, + night: { floor: 0, ceiling: 0.047 - MARGIN, key: 0.03, min: 8, max: 3000 }, +}; + +/** The display transform of a sky: exposure, then ACES, then a black lift (veiling glare) in display-linear. */ +export interface Display { + exposure: number; + /** Added to every channel after the curve, then rescaled so white stays white: out = lift + (1 - lift) * aces. */ + lift: number; +} + +/** The 8-bit sRGB of linear radiance through a display. */ +export function display8(r: number, g: number, b: number, d: Display, out: number[]): void { + acesLinear(r, g, b, d.exposure, out); + for (let i = 0; i < 3; i += 1) out[i] = Math.round(encode(d.lift + (1 - d.lift) * out[i])); +} + +const decode = (v: number): number => { + const c = v / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +}; +/** Display luminance (WCAG relative, from the encoded bytes) of a radiance through a display. */ +export function displayLumaOf(r: number, g: number, b: number, d: Display, tmp: number[] = [0, 0, 0]): number { + display8(r, g, b, d, tmp); + return displayLuma([decode(tmp[0]), decode(tmp[1]), decode(tmp[2])]); +} + +export interface Fit { + display: Display; + /** Darkest and lightest display luminance of the grid under `display`. */ + lo: number; + hi: number; + /** The exposure before the budget moved it, and what the budget took (for the numbers a report cites). */ + keyExposure: number; +} + +/** + * Choose the display of a sky, the one place brightness is decided. `hdr` is the grid's radiance (3 floats a texel) and + * `meanLuma` its log-mean luminance. Exposure starts at the state's key (adaptation); a ceiling lowers it until the + * lightest texel is inside the budget (bisection: luminance is monotone in exposure); a floor raises the black lift until the + * darkest is (luminance is monotone in lift, and linear: the lift needed is solved in one step). + */ +export function fitDisplay(state: FixedSkyState, hdr: Float32Array, meanLuma: number): Fit { + const b = BUDGET[state]; + const keyExposure = + b.key === undefined ? b.min : Math.min(b.max, Math.max(b.min, (b.key * 1.6) / Math.max(meanLuma, 1e-9))); + const n = hdr.length / 3; + const stride = Math.max(1, Math.floor(n / 4000)); + const tmp = [0, 0, 0]; + const range = (d: Display) => { + let lo = 2; + let hi = -1; + for (let i = 0; i < n; i += stride) { + const l = displayLumaOf(hdr[i * 3], hdr[i * 3 + 1], hdr[i * 3 + 2], d, tmp); + if (l < lo) lo = l; + if (l > hi) hi = l; + } + return { lo, hi }; + }; + let exposure = keyExposure; + let r = range({ exposure, lift: 0 }); + if (r.hi > b.ceiling) { + let lo = 0; + let hiE = exposure; + for (let k = 0; k < 24; k += 1) { + const mid = (lo + hiE) / 2; + if (range({ exposure: mid, lift: 0 }).hi > b.ceiling) hiE = mid; + else lo = mid; + } + exposure = lo; + r = range({ exposure, lift: 0 }); + } + let lift = 0; + if (r.lo < b.floor) { + // Linear in the displayed linear luminance: L' = lift + (1 - lift) L, but through the 8-bit encode; solve in linear then check once. + lift = (b.floor - r.lo) / (1 - r.lo); + let probe = range({ exposure, lift }); + for (let k = 0; k < 6 && probe.lo < b.floor; k += 1) { + lift += (b.floor - probe.lo) * 1.05; + probe = range({ exposure, lift }); + } + r = probe; + } + return { display: { exposure, lift }, lo: r.lo, hi: r.hi, keyExposure }; +} + +/** + * The display, moved only as far as the budget needs, for a grid other than the one it was fitted on (another viewport's shape, + * its own texels): the fit is made once on a canonical grid so that resizing repaints the same sky, and this keeps a texel the canonical + * grid did not have inside the state's bounds. A display that already holds them is returned as it is. + */ +export function holdBudget(state: FixedSkyState, hdr: Float32Array, d: Display): Display { + const b = BUDGET[state]; + const n = hdr.length / 3; + const tmp = [0, 0, 0]; + const range = (x: Display) => { + let lo = 2; + let hi = -1; + for (let i = 0; i < n; i += 1) { + const l = displayLumaOf(hdr[i * 3], hdr[i * 3 + 1], hdr[i * 3 + 2], x, tmp); + if (l < lo) lo = l; + if (l > hi) hi = l; + } + return { lo, hi }; + }; + let cur = d; + let r = range(cur); + if (r.hi > b.ceiling) { + let lo = 0; + let hiE = cur.exposure; + for (let k = 0; k < 20; k += 1) { + const mid = (lo + hiE) / 2; + if (range({ exposure: mid, lift: cur.lift }).hi > b.ceiling) hiE = mid; + else lo = mid; + } + cur = { exposure: lo, lift: cur.lift }; + r = range(cur); + } + for (let k = 0; k < 8 && r.lo < b.floor; k += 1) { + cur = { exposure: cur.exposure, lift: cur.lift + ((b.floor - r.lo) / (1 - r.lo)) * 1.05 + 1e-4 }; + r = range(cur); + } + return cur; +} diff --git a/src/app/features/scene/ridge/vienna.test.ts b/src/app/features/scene/ridge/vienna.test.ts new file mode 100644 index 00000000..e0d4a4c7 --- /dev/null +++ b/src/app/features/scene/ridge/vienna.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { solarNoon, STATE_ELEVATION, sunForState, VIENNA, viennaSun } from './vienna'; + +const at = (iso: string) => new Date(iso); + +describe('the sun over Vienna', () => { + it('stands at 90 - lat + declination at solar noon: the solstices and equinox', () => { + const june = viennaSun(solarNoon(at('2026-06-21T12:00:00Z'))); + expect(june.elevation).toBeCloseTo(90 - VIENNA.lat + 23.44, 0); + const december = viennaSun(solarNoon(at('2026-12-21T12:00:00Z'))); + expect(december.elevation).toBeCloseTo(90 - VIENNA.lat - 23.44, 0); + const march = viennaSun(solarNoon(at('2026-03-20T12:00:00Z'))); + expect(march.elevation).toBeCloseTo(90 - VIENNA.lat, 0); + }); + + it('is due south at solar noon, east in the morning and west in the afternoon', () => { + const noon = solarNoon(at('2026-09-23T12:00:00Z')); + expect(Math.abs(viennaSun(noon).azimuth - 180)).toBeLessThan(2); // solarNoon is found to 5 minutes, which is 1.25 degrees of azimuth + expect(viennaSun(new Date(noon.getTime() - 3 * 3600_000)).azimuth).toBeLessThan(150); + expect(viennaSun(new Date(noon.getTime() + 3 * 3600_000)).azimuth).toBeGreaterThan(210); + }); + + it('solar noon at 16.35 E falls near 10:40 UTC in mid November', () => { + // 16.3534 E is 65.4 minutes ahead of Greenwich; the equation of time in mid November is +15 minutes (sun early): 12:00 - 65.5 - 15 = 10:40 UTC. + const n = solarNoon(at('2026-11-11T12:00:00Z')); + const minutes = n.getUTCHours() * 60 + n.getUTCMinutes(); + expect(minutes).toBeGreaterThan(10 * 60 + 30); + expect(minutes).toBeLessThan(10 * 60 + 50); + }); + + it('has a short day in December and a long one in June: published 8 h 4 min and 16 h 0 min with refraction, which adds about 6 minutes each end the ephemeris leaves out', () => { + const daylight = (day: string) => { + let n = 0; + for (let m = 0; m < 24 * 60; m += 1) if (viennaSun(new Date(Date.parse(day) + m * 60_000)).elevation > 0) n += 1; + return n; + }; + expect(Math.abs(daylight('2026-12-21T00:00:00Z') - (8 * 60 + 4))).toBeLessThan(15); + expect(Math.abs(daylight('2026-06-21T00:00:00Z') - (16 * 60 + 0))).toBeLessThan(15); + }); + + it('sunForState: the real sun when it is in the state, else a moment of today that is', () => { + const noon = solarNoon(at('2026-11-11T12:00:00Z')); + expect(sunForState('day', noon).time).toEqual(noon); + const midnight = new Date(noon.getTime() + 12 * 3600_000); + for (const state of ['day', 'dusk', 'night'] as const) { + for (const now of [noon, midnight, at('2026-11-11T15:30:00Z')]) { + const s = sunForState(state, now); + const [lo, hi] = STATE_ELEVATION[state]; + expect(s.elevation).toBeGreaterThanOrEqual(lo - 0.01); + expect(s.elevation).toBeLessThanOrEqual(hi + 0.01); + } + } + expect(sunForState('dusk', noon).elevation).toBeCloseTo(0, 1); + expect(sunForState('dusk', noon).azimuth).toBeGreaterThan(180); // sets in the west half + }); +}); diff --git a/src/app/features/scene/ridge/vienna.ts b/src/app/features/scene/ridge/vienna.ts new file mode 100644 index 00000000..56188226 --- /dev/null +++ b/src/app/features/scene/ridge/vienna.ts @@ -0,0 +1,74 @@ +// The sun over the server's viewpoint in Vienna (48.1929826 N, 16.3534237 E, 183 m: Meta's location, the Copernicus DSM height there) at a real moment: one ephemeris, `sunPosition` of src/sky/sun.js (the +// low-precision NOAA/Meeus series, a hundredth of a degree for 1900..2100; no refraction), and the choice of a moment for each +// fixed sky state. The ridge sky is the physical atmosphere (`earthAtmosphere.ts`) lit from this sun. +import { sunPosition } from '../../../../sky/sun.js'; +import { DAY_FROM } from '../skyState'; +import type { FixedSkyState } from '../skyBand'; + +export const VIENNA = { lat: 48.1929826, lon: 16.3534237, altitudeKm: 0.183 } as const; + +export interface SunSample { + /** Degrees above the horizon, no refraction. */ + elevation: number; + /** Degrees clockwise from north. */ + azimuth: number; + /** The moment this is the sun of. */ + time: Date; +} + +export function viennaSun(time: Date): SunSample { + const { elevation, azimuth } = sunPosition(time, VIENNA.lat, VIENNA.lon) as { + elevation: number; + azimuth: number; + }; + return { elevation, azimuth, time }; +} + +const MIN = 60_000; + +/** Sun elevation range of each fixed state: the thresholds `resolveSkyState` uses (day from DAY_FROM, night below -DAY_FROM). */ +export const STATE_ELEVATION: Record = { + day: [DAY_FROM, 90], + dusk: [-DAY_FROM, DAY_FROM], + night: [-90, -DAY_FROM], +}; + +const startOfUtcDay = (t: Date): number => Math.floor(t.getTime() / (24 * 60 * MIN)) * 24 * 60 * MIN; + +/** The moment of the day (UTC day of `t`, every 5 minutes) the sun is highest over Vienna: solar noon to the step. */ +export function solarNoon(t: Date): Date { + const d0 = startOfUtcDay(t); + let best = d0; + let bestEl = -90; + for (let m = 0; m < 24 * 60; m += 5) { + const el = viennaSun(new Date(d0 + m * MIN)).elevation; + if (el > bestEl) { + bestEl = el; + best = d0 + m * MIN; + } + } + return new Date(best); +} + +/** + * The sun a state draws. The real sun over Vienna now when it is in the state's range (so 'auto' is the real sky); otherwise the + * sun of today at a moment that is: noon for Day, the evening sunset for Dusk, the opposite of noon for Night. A picker that + * forces Day at night shows a noon sky, as a person looking at a noon photograph would. + */ +export function sunForState(state: FixedSkyState, now: Date): SunSample { + const real = viennaSun(now); + const [lo, hi] = STATE_ELEVATION[state]; + if (real.elevation >= lo && real.elevation <= hi) return real; + const noon = solarNoon(now); + if (state === 'day') return viennaSun(noon); + if (state === 'night') return viennaSun(new Date(noon.getTime() + 12 * 60 * MIN)); + // Dusk: bisect the evening crossing of the horizon between noon and the opposite moment. + let a = noon.getTime(); + let b = noon.getTime() + 12 * 60 * MIN; + for (let i = 0; i < 30; i += 1) { + const m = (a + b) / 2; + if (viennaSun(new Date(m)).elevation > 0) a = m; + else b = m; + } + return viennaSun(new Date((a + b) / 2)); +} diff --git a/src/app/features/scene/ridge/viennaHorizon.json b/src/app/features/scene/ridge/viennaHorizon.json new file mode 100644 index 00000000..62a4a126 --- /dev/null +++ b/src/app/features/scene/ridge/viennaHorizon.json @@ -0,0 +1 @@ +{"viewpoint":{"lat":48.1929826,"lon":16.3534237,"groundM":183.3},"stepDeg":0.5,"radiusKm":60.0,"minKm":1.0,"curvature":"drop d^2/2R*(1-0.13), R=6371000 m","source":{"dataset":"Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid","tiles":["N47_00_E015_00","N47_00_E016_00","N47_00_E017_00","N48_00_E015_00","N48_00_E016_00","N48_00_E017_00","N49_00_E015_00","N49_00_E016_00","N49_00_E017_00"],"tileBytesEach":"about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed","url":"https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)","licence":"Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty (https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)","attribution":"Contains modified Copernicus data: DEM \u00a9 DLR e.V. 2010-2014 and \u00a9 Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.","bakedBy":"scripts/bake-horizon.py"},"elevationDeg":[1.608,1.522,1.467,1.452,1.433,1.426,1.497,1.6,1.597,1.564,1.532,1.495,1.467,1.544,1.62,1.655,1.707,1.775,1.803,1.683,1.532,1.394,1.263,1.297,1.358,1.412,1.433,1.339,1.264,1.269,1.195,1.142,1.13,1.22,1.401,1.368,1.326,1.294,1.378,1.46,1.488,1.516,1.549,1.598,1.647,1.698,1.769,1.816,1.801,1.783,1.695,1.619,1.586,1.547,1.442,1.335,1.282,1.286,1.335,1.421,1.449,1.437,1.313,1.268,1.164,1.14,1.098,1.06,0.984,0.938,0.91,0.877,0.884,0.872,0.943,0.974,0.919,0.873,0.861,0.839,0.731,0.673,0.56,0.494,0.527,0.601,0.566,0.584,0.579,0.643,0.666,0.741,0.851,0.711,0.703,0.573,0.54,0.488,0.433,0.452,0.467,0.483,0.475,0.517,0.419,0.428,0.457,0.489,0.522,0.482,0.5,0.527,0.528,0.471,0.458,0.487,0.521,0.578,0.553,0.419,0.301,0.324,0.425,0.468,0.512,0.558,0.575,0.521,0.477,0.444,0.372,0.297,0.365,0.446,0.486,0.523,0.532,0.516,0.516,0.531,0.52,0.546,0.565,0.535,0.527,0.508,0.516,0.37,0.359,0.33,0.375,0.454,0.546,0.496,0.449,0.425,0.447,0.398,0.388,0.439,0.476,0.496,0.517,0.541,0.494,0.439,0.383,0.411,0.502,0.621,0.578,0.548,0.568,0.642,0.713,0.708,0.654,0.704,0.741,0.709,0.677,0.661,0.66,0.666,0.662,0.651,0.699,0.727,0.746,0.809,0.788,0.799,0.793,0.702,0.734,0.79,0.754,0.704,0.616,0.598,0.708,0.719,0.706,0.704,0.726,0.826,0.849,0.85,0.813,0.853,0.926,0.971,0.918,1.003,1.0,0.927,0.985,1.031,1.043,1.067,1.13,1.022,1.02,1.043,1.101,1.144,1.099,1.082,1.064,1.2,1.238,1.16,1.084,1.202,1.326,1.39,1.429,1.405,1.332,1.358,1.361,1.357,1.354,1.335,1.319,1.376,1.309,1.289,1.257,1.283,1.237,1.296,1.353,1.259,1.314,1.236,1.307,1.344,1.36,1.251,1.292,1.308,1.26,1.288,1.365,1.391,1.414,1.32,1.299,1.35,1.288,1.361,1.303,1.268,1.29,1.305,1.326,1.344,1.328,1.32,1.358,1.375,1.443,1.477,1.492,1.525,1.513,1.422,1.314,1.294,1.337,1.414,1.343,1.397,1.431,1.409,1.378,1.423,1.438,1.343,1.257,1.208,1.209,1.233,1.251,1.267,1.274,1.248,1.254,1.297,1.34,1.359,1.34,1.199,1.162,1.255,1.292,1.29,1.24,1.222,1.24,1.271,1.264,1.317,1.256,1.26,1.321,1.301,1.266,1.268,1.308,1.357,1.474,1.419,1.438,1.393,1.354,1.343,1.354,1.333,1.321,1.359,1.437,1.47,1.395,1.413,1.446,1.466,1.434,1.446,1.458,1.419,1.522,1.502,1.506,1.512,1.528,1.526,1.485,1.427,1.508,1.5,1.48,1.534,1.514,1.449,1.449,1.386,1.368,1.388,1.404,1.374,1.374,1.352,1.3,1.347,1.332,1.265,1.235,1.39,1.285,1.226,1.233,1.235,1.211,1.164,1.183,1.217,1.143,1.128,1.117,1.112,1.086,1.071,1.118,1.103,1.002,0.99,0.995,1.025,0.998,1.024,0.971,1.045,1.09,1.11,1.108,1.081,1.171,1.308,1.446,1.482,1.484,1.494,1.469,1.472,1.479,1.493,1.467,1.388,1.293,1.203,1.104,1.145,1.12,1.028,1.004,0.982,0.968,0.933,0.97,0.986,1.035,1.12,1.227,1.281,1.316,1.312,1.273,1.187,1.205,1.197,1.199,1.176,1.177,1.142,1.19,1.377,1.369,1.322,1.295,1.385,1.493,1.576,1.679,1.769,1.622,1.711,1.641,1.517,1.582,1.525,1.39,1.389,1.343,1.331,1.369,1.343,1.257,1.212,1.134,1.039,1.03,1.075,1.027,1.059,1.112,1.132,1.14,1.16,1.167,1.178,1.179,1.192,1.214,1.245,1.289,1.286,1.279,1.259,1.256,1.202,1.138,1.159,1.116,1.12,1.102,0.999,1.042,1.051,1.09,1.143,1.244,1.269,1.296,1.353,1.412,1.579,1.686,1.814,1.857,1.773,1.731,1.748,1.742,1.668,1.594,1.628,1.692,1.76,1.822,1.863,1.904,1.895,1.849,1.786,1.778,1.731,1.666,1.633,1.561,1.526,1.662,1.747,1.75,1.704,1.631,1.563,1.473,1.501,1.488,1.464,1.441,1.386,1.31,1.298,1.293,1.306,1.371,1.42,1.416,1.417,1.418,1.409,1.399,1.408,1.431,1.434,1.437,1.509,1.544,1.58,1.674,1.528,1.534,1.517,1.497,1.525,1.546,1.562,1.58,1.55,1.634,1.614,1.66,1.643,1.663,1.68,1.829,1.99,2.058,2.014,1.911,2.025,2.023,2.036,2.031,2.009,2.012,2.02,2.109,2.107,2.12,2.171,2.179,2.125,2.084,2.09,2.129,2.183,2.16,2.172,2.239,2.237,2.218,2.217,2.137,2.169,2.203,2.233,2.262,2.272,2.21,2.133,1.955,1.909,1.97,2.138,2.187,2.229,2.278,2.191,2.167,2.213,2.327,2.409,2.31,2.242,2.205,2.152,2.154,2.258,2.217,2.126,2.054,1.97,1.942,2.022,1.894,1.886,1.921,1.897,1.936,1.964,1.978,1.961,1.927,1.879,1.904,1.96,1.9,1.922,2.023,2.061,2.115,1.99,1.805,1.736,1.776,1.841,1.863,1.934,1.942,1.962,2.043,2.082,2.082,2.092,2.062,1.957,1.794,1.821,1.913,2.009,2.066,2.105,2.036,1.964,1.984,2.005,2.036,2.072,2.094,2.022,2.153,2.177,2.146,2.125,2.061,2.004,1.955,1.902,1.84,1.767,1.7,1.696,1.718,1.725,1.709,1.745,1.805,1.836,1.939,2.053,2.14,2.139,2.142,2.035,1.902,1.779,1.667,1.564,1.57,1.581,1.628,1.693,1.739,1.738,1.738,1.675],"distanceKm":[1.0,1.0,1.06,1.06,1.06,1.06,1.06,1.03,1.0,1.0,1.0,1.0,1.09,1.09,1.0,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.12,1.12,1.12,1.12,1.12,1.12,1.09,1.09,1.09,1.09,1.0,1.0,1.0,1.0,1.03,1.06,1.06,1.03,1.03,1.03,1.03,1.03,1.03,1.0,1.0,1.0,1.0,1.0,1.03,1.03,1.03,1.03,1.03,1.0,1.0,1.06,1.06,1.06,1.06,1.06,1.12,1.0,1.0,1.24,1.24,1.21,1.21,1.3,1.3,1.27,1.3,1.33,1.33,1.33,1.33,1.27,1.24,1.27,1.27,1.27,1.03,1.03,1.42,1.84,1.84,1.81,1.03,1.72,1.63,1.63,1.63,1.33,1.33,1.06,1.06,1.72,1.72,1.69,1.69,1.69,1.69,1.69,1.81,1.81,1.75,1.51,1.75,1.75,1.75,1.75,1.75,1.72,1.72,1.72,1.72,1.72,1.72,1.72,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.42,1.42,1.24,1.3,1.3,1.3,1.3,1.3,1.3,1.3,1.27,1.03,1.36,1.36,1.36,1.36,1.36,1.45,1.45,1.09,1.12,1.12,1.54,1.57,1.48,1.48,1.48,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.18,1.18,1.21,1.21,1.21,1.12,1.12,1.12,1.12,1.12,1.15,1.15,1.15,1.15,1.18,1.15,1.15,1.24,1.24,1.27,1.27,1.24,1.24,1.24,1.27,1.27,1.27,1.9,1.9,1.93,1.93,1.93,1.99,1.99,1.99,1.99,1.9,2.02,2.02,2.02,2.05,2.02,1.99,1.99,1.99,1.48,1.21,1.21,1.57,1.69,1.69,1.66,1.39,1.39,1.39,1.54,1.6,1.54,1.54,1.54,1.48,1.45,1.21,1.21,1.21,1.27,1.39,1.39,1.36,1.39,1.39,1.39,1.33,1.33,1.33,1.33,1.33,1.48,1.45,1.39,1.39,1.18,1.18,1.18,1.48,1.48,1.27,1.24,1.27,1.36,1.36,1.39,1.33,1.3,1.3,1.3,1.3,1.3,1.3,1.33,1.0,1.42,1.42,1.27,1.27,1.27,1.27,1.03,1.03,1.03,1.03,1.03,1.03,1.03,1.03,1.06,1.06,1.03,1.03,1.03,1.36,1.06,1.06,1.06,1.06,1.09,1.09,1.18,1.21,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.15,1.15,1.36,1.36,1.36,1.36,1.33,1.33,3.13,1.21,1.21,3.34,3.31,2.83,2.65,2.65,1.3,2.29,1.3,1.15,1.15,1.15,2.29,2.68,2.41,2.23,2.23,2.2,1.12,2.23,2.17,2.17,2.17,1.84,2.74,1.87,1.87,2.95,2.2,2.17,2.17,2.35,2.35,2.23,2.23,2.11,2.14,2.08,2.2,2.2,2.2,2.2,2.2,2.2,2.41,2.41,2.38,2.41,2.41,2.44,2.44,2.44,1.99,2.08,2.08,2.08,2.08,2.05,2.05,2.5,2.38,2.47,2.29,2.23,2.17,1.69,1.69,2.38,2.47,2.38,2.17,2.71,2.71,2.2,2.47,2.41,2.41,2.32,2.38,2.38,2.08,2.5,2.11,2.11,1.75,1.75,1.48,1.81,1.81,1.78,1.78,1.87,17.2,17.2,17.26,17.32,17.44,18.1,17.56,17.5,17.38,17.5,17.62,17.68,17.74,17.8,14.86,14.92,14.98,2.98,26.38,26.38,26.38,26.62,26.14,26.02,26.02,26.38,26.26,26.38,26.38,26.26,26.26,26.26,14.98,14.98,14.98,14.44,13.12,13.18,13.66,13.72,13.72,13.84,13.84,13.84,13.72,12.52,12.58,12.58,12.28,12.22,12.28,16.54,14.02,15.04,16.18,16.24,17.08,17.26,17.44,17.5,17.44,17.32,17.44,17.02,13.96,14.14,9.22,9.16,9.1,9.22,9.28,9.28,9.22,9.16,9.16,9.34,8.68,8.68,8.62,8.74,8.86,8.92,8.86,8.92,8.92,11.74,14.08,11.98,12.22,12.04,34.3,10.36,10.06,9.64,9.58,9.58,9.7,9.7,10.18,10.18,10.18,10.24,10.06,10.48,9.94,9.82,9.76,9.7,8.02,8.14,8.14,8.14,8.08,8.56,8.38,8.38,8.32,8.38,8.02,8.02,8.02,8.14,8.2,7.9,7.9,7.84,7.78,7.66,7.72,7.72,7.72,1.0,1.0,1.0,1.0,1.0,1.45,1.21,1.21,1.3,1.15,1.15,1.15,1.15,1.15,1.15,1.15,1.33,1.33,1.33,1.48,1.3,1.3,1.3,1.42,1.09,2.02,1.81,2.59,2.59,2.53,1.21,1.75,1.69,1.66,2.38,1.57,1.57,1.57,7.18,7.18,7.24,7.24,7.18,7.12,1.0,1.0,1.0,1.0,1.0,7.36,7.3,7.3,7.18,7.24,7.06,7.06,7.06,7.06,7.0,7.12,7.12,7.12,7.0,7.0,7.0,7.0,7.24,7.24,7.06,7.18,7.0,7.06,7.0,7.0,6.94,7.0,7.24,1.03,1.03,1.03,1.0,1.0,1.0,1.06,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.03,1.03,1.03,1.03,1.03,1.03,1.27,1.27,1.27,1.24,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.09,1.09,1.06,1.06,1.06,1.06,1.06,1.09,1.09,9.64,9.58,9.64,9.64,9.7,9.7,9.7,9.7,9.7,9.64,9.64,9.64,9.7,1.03,1.03,1.03,1.03,1.03,1.03,9.52,1.0,1.0,9.4,8.68,8.68,8.68,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,9.52,9.46,9.46,9.46,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"peakHeightM":[211.4,209.9,210.5,210.2,209.9,209.7,211.0,212.1,211.2,210.6,210.1,209.4,211.3,212.7,211.6,214.8,215.8,217.1,217.7,215.4,212.5,209.9,207.4,208.7,209.9,211.0,211.4,209.5,208.1,207.5,206.1,205.1,204.8,204.6,207.8,207.2,206.5,206.6,208.8,210.4,210.1,210.6,211.2,212.1,213.0,213.9,214.2,215.0,214.8,214.5,212.9,212.5,211.9,211.2,209.3,207.3,205.7,205.8,208.1,209.6,210.2,209.9,207.6,208.2,203.7,203.2,207.2,206.3,204.2,203.2,204.0,203.3,203.0,203.2,205.3,206.0,204.7,203.7,202.5,201.5,199.6,198.3,195.8,192.2,192.8,198.3,201.7,202.3,201.8,194.9,203.5,204.5,207.7,203.7,199.7,196.7,193.3,192.4,196.5,197.1,197.2,197.7,197.5,198.7,195.8,197.0,197.9,198.4,197.2,198.2,198.7,199.6,199.6,197.9,197.2,198.1,199.1,200.8,200.1,196.0,192.5,189.0,190.8,191.5,192.3,193.1,193.4,192.4,191.7,191.1,189.8,190.8,192.5,193.0,194.4,195.2,195.5,195.1,195.1,195.4,195.2,195.5,193.5,196.1,195.9,195.5,195.6,192.2,192.5,191.8,190.5,192.2,194.0,196.8,195.7,194.4,195.0,193.7,190.1,191.0,191.7,192.0,192.4,192.8,192.0,191.0,190.0,191.8,193.7,196.5,195.6,194.9,194.5,195.9,197.3,197.2,196.1,197.5,198.2,197.6,196.9,197.0,196.6,196.7,197.7,197.5,198.9,199.5,199.5,200.9,200.4,201.1,201.0,198.9,207.9,209.7,208.9,207.2,204.3,204.3,208.1,208.5,208.1,206.9,209.1,212.7,213.5,214.0,212.2,213.2,215.7,217.3,207.1,204.6,204.5,208.8,212.5,213.9,213.7,209.3,210.8,208.2,210.9,212.6,213.0,214.2,213.0,211.4,210.3,208.7,209.5,207.9,207.4,212.6,215.6,216.4,218.1,217.5,215.7,214.9,215.0,214.9,214.8,214.4,217.5,218.3,215.2,214.7,209.3,209.8,208.9,216.9,218.4,211.3,211.8,210.8,214.4,215.3,216.4,212.4,212.7,213.1,212.0,212.6,214.4,215.0,216.2,206.4,215.6,216.9,211.9,213.6,212.3,211.5,206.5,206.8,207.2,207.5,207.2,207.1,207.8,208.1,210.0,210.7,210.2,210.8,210.5,217.2,207.7,207.3,208.1,209.5,208.9,209.9,212.8,213.1,209.6,210.4,210.7,208.9,207.3,206.3,206.4,206.8,207.2,207.5,208.9,208.4,213.2,214.2,215.2,215.7,214.5,211.2,247.4,209.9,210.7,259.2,255.7,244.2,241.1,242.6,212.1,236.3,211.9,208.7,209.9,209.5,234.2,243.1,238.7,236.5,241.0,238.1,211.5,237.8,234.9,234.5,234.9,226.3,246.9,227.9,230.4,259.6,237.2,237.1,238.4,243.8,242.5,239.9,240.4,235.8,240.4,238.1,241.4,241.7,242.3,242.2,240.6,238.4,247.1,246.8,245.2,248.2,247.4,245.4,245.4,242.7,231.1,234.0,234.5,233.4,233.5,231.9,230.1,242.5,239.0,238.2,233.0,237.7,232.3,219.6,219.9,235.0,235.9,232.0,228.4,241.4,237.8,226.9,231.8,230.5,229.4,227.0,230.1,229.5,219.9,226.9,220.2,221.3,214.0,214.8,208.5,216.5,217.9,218.0,217.9,218.8,554.9,596.3,639.3,651.8,655.9,677.7,654.8,653.7,652.5,660.3,655.7,632.9,605.1,578.6,484.8,496.7,491.5,237.4,693.3,682.9,676.3,665.1,672.6,677.4,699.5,746.5,793.0,820.8,836.9,831.7,813.8,774.3,513.7,511.5,512.0,493.9,464.6,457.9,479.7,526.0,524.0,515.7,509.3,531.0,553.8,538.4,562.8,582.7,541.4,558.5,545.3,640.0,583.8,599.0,593.8,595.2,603.6,604.7,620.9,614.5,586.6,570.3,549.1,511.8,447.5,462.2,354.3,358.3,365.5,371.3,373.8,377.0,376.8,377.3,377.6,383.6,372.4,377.1,382.3,384.6,386.4,384.8,382.9,375.9,365.9,430.3,471.0,427.2,428.5,403.2,887.7,380.7,381.5,382.0,397.5,401.8,409.1,418.8,441.2,470.9,490.1,514.8,516.4,515.3,490.4,489.5,486.6,472.2,410.8,419.1,428.3,437.9,444.8,466.6,466.6,465.3,456.6,449.4,436.6,430.0,421.0,419.8,411.4,398.0,416.8,426.6,425.1,415.1,407.2,398.0,385.9,209.6,209.3,208.9,208.5,207.5,216.6,210.8,210.7,213.0,210.9,211.9,211.8,211.8,211.8,211.6,211.4,216.1,216.6,216.7,220.6,217.6,218.4,219.2,224.9,212.4,237.7,231.4,251.4,252.7,252.0,216.4,231.7,229.2,230.8,250.7,228.9,228.5,229.0,397.4,416.0,438.4,447.0,439.3,424.3,218.7,218.7,218.9,218.8,218.4,445.6,444.4,455.8,451.0,454.8,454.3,455.3,448.6,443.6,442.1,451.4,458.2,455.3,452.1,460.3,460.0,457.7,467.1,457.0,454.1,463.0,459.5,465.5,464.3,456.8,445.0,425.5,428.2,218.8,221.8,222.7,222.3,223.1,221.6,223.5,222.0,224.0,225.4,223.7,222.5,221.8,220.9,222.1,224.0,223.2,221.6,220.3,218.8,226.4,228.2,225.4,224.2,216.9,216.5,217.1,217.6,217.9,217.6,217.0,216.1,216.6,220.6,219.5,218.9,220.8,221.5,222.5,220.2,217.7,216.4,488.4,497.5,503.2,515.2,518.5,522.0,535.7,542.3,542.3,541.8,536.7,518.9,493.5,216.1,217.8,219.5,220.5,221.2,220.0,516.0,218.0,218.4,523.4,502.5,505.8,494.9,220.9,221.3,220.8,220.4,219.3,218.3,217.5,216.5,215.5,214.2,213.0,212.9,213.3,213.5,473.5,477.6,487.5,492.6,217.2,219.2,220.7,220.7,220.7,218.9,216.5,214.4,212.5,210.6,210.7,210.9,211.8,212.9,213.7,213.7,213.7,212.6],"slopeEast":[0.002,-0.001,0.056,0.043,0.022,0.002,0.007,-0.005,0.014,0.012,0.002,-0.013,0.046,0.027,-0.083,-0.005,-0.017,-0.01,-0.007,-0.03,-0.051,-0.056,-0.058,-0.088,-0.109,-0.099,-0.085,-0.079,-0.077,0.008,0.005,0.006,-0.01,0.062,0.068,0.071,0.074,0.084,0.019,-0.001,0.023,0.02,0.021,0.024,0.025,0.031,0.054,0.031,0.003,-0.022,-0.034,-0.054,-0.055,-0.049,-0.047,-0.045,-0.038,-0.041,-0.051,-0.054,-0.073,-0.086,-0.104,-0.05,-0.085,-0.083,0.125,0.082,0.049,0.018,0.064,0.034,-0.013,-0.004,-0.06,-0.087,-0.128,-0.166,-0.129,-0.038,-0.154,-0.158,-0.163,-0.116,-0.12,0.088,0.055,0.048,0.095,0.036,0.154,0.016,-0.009,-0.018,-0.079,-0.121,-0.161,-0.174,0.003,0.037,0.108,0.103,0.1,0.106,0.101,-0.064,-0.045,0.013,0.149,0.083,0.078,0.071,0.039,-0.062,-0.065,-0.118,-0.105,-0.072,-0.045,-0.025,-0.001,-0.008,-0.007,-0.02,-0.037,-0.058,-0.078,-0.075,-0.069,-0.063,-0.054,-0.008,0.003,0.123,0.036,0.015,-0.005,0.017,0.049,0.086,0.068,0.013,0.032,-0.024,-0.01,0.005,0.009,-0.005,-0.004,0.021,0.062,-0.012,-0.014,-0.013,-0.031,0.02,-0.004,-0.021,-0.005,-0.018,-0.026,-0.032,-0.038,-0.046,-0.055,-0.063,-0.067,-0.007,0.009,-0.048,-0.051,-0.051,-0.004,-0.015,-0.025,-0.018,0.002,-0.002,0.005,0.013,0.02,0.049,0.013,0.002,-0.023,-0.009,-0.075,-0.065,0.018,0.02,0.024,-0.023,-0.022,-0.049,0.01,0.056,-0.003,-0.001,0.008,-0.048,-0.05,-0.03,-0.015,0.002,-0.045,-0.077,-0.052,-0.113,-0.035,0.052,0.029,-0.006,-0.008,0.119,0.111,0.049,0.019,-0.001,-0.008,0.003,0.025,0.028,0.004,-0.059,0.008,0.005,-0.0,0.055,0.03,-0.044,-0.024,-0.002,-0.029,-0.015,-0.028,-0.04,-0.079,-0.084,-0.073,0.118,0.104,0.095,0.098,0.122,-0.042,0.014,0.108,0.117,-0.077,-0.066,-0.052,0.006,0.018,-0.026,-0.008,0.016,-0.019,-0.02,0.006,0.033,0.056,0.035,0.017,0.002,0.011,0.009,-0.015,0.039,-0.016,-0.019,0.073,0.06,0.028,0.012,-0.052,-0.037,-0.027,-0.026,-0.025,-0.027,-0.034,-0.033,-0.112,-0.098,-0.018,-0.005,0.014,-0.002,-0.012,-0.021,-0.041,-0.06,-0.073,-0.068,-0.007,0.023,-0.002,0.014,0.031,0.042,0.045,0.05,0.06,0.075,0.066,0.054,0.029,0.03,-0.066,-0.045,-0.033,-0.035,0.1,0.072,0.022,-0.017,0.002,0.006,0.105,0.007,-0.015,0.014,-0.019,-0.045,0.021,-0.027,-0.032,-0.043,0.023,-0.048,0.051,-0.072,-0.099,-0.013,0.016,0.062,0.05,0.066,0.041,0.022,-0.031,-0.028,-0.003,0.047,-0.019,-0.007,0.014,-0.047,0.019,-0.023,-0.023,0.008,-0.064,0.049,-0.035,-0.026,0.01,0.043,0.057,0.094,-0.076,-0.07,0.043,0.029,0.051,0.001,0.036,0.056,-0.016,-0.042,-0.015,-0.0,0.037,0.052,0.089,0.032,0.021,0.06,-0.018,0.018,0.002,0.036,0.058,-0.043,0.005,-0.0,-0.004,-0.084,-0.088,0.055,-0.004,0.02,0.027,0.018,-0.003,-0.013,-0.01,-0.005,0.032,0.037,-0.085,-0.114,0.016,-0.076,-0.081,0.043,0.029,0.014,-0.247,-0.334,-0.183,-0.03,-0.023,0.027,0.067,0.018,-0.17,0.04,0.076,0.16,0.142,0.214,-0.239,-0.036,0.236,0.006,-0.075,-0.015,-0.134,0.191,-0.098,-0.235,-0.094,-0.348,-0.156,-0.126,0.06,-0.008,0.039,0.184,0.048,0.037,0.035,-0.057,-0.06,0.062,-0.295,-0.071,-0.175,0.046,-0.045,-0.173,-0.173,-0.161,-0.148,0.042,-0.034,-0.145,0.247,0.002,-0.209,0.028,-0.129,-0.037,0.154,-0.069,-0.023,0.149,0.119,-0.146,0.175,0.112,-0.005,-0.029,-0.11,-0.064,-0.06,-0.018,-0.04,0.03,-0.087,-0.043,-0.015,-0.031,-0.13,-0.068,-0.06,0.001,-0.021,-0.0,-0.013,0.032,0.012,-0.025,0.013,0.081,0.008,-0.101,0.006,-0.063,-0.072,-0.034,-0.109,-0.03,-0.058,-0.068,-0.174,-0.1,-0.092,-0.088,-0.053,0.072,0.007,-0.043,-0.029,-0.004,-0.033,-0.07,-0.071,-0.07,-0.077,-0.07,-0.021,-0.02,-0.061,-0.004,-0.114,-0.02,-0.023,0.032,0.05,-0.037,-0.005,-0.02,-0.109,-0.063,0.014,-0.015,-0.037,0.007,0.019,0.035,0.051,0.064,-0.018,-0.034,-0.025,0.038,-0.074,-0.071,-0.068,-0.065,-0.061,-0.056,-0.05,-0.038,-0.032,-0.02,0.035,-0.023,-0.034,-0.042,-0.009,-0.053,-0.061,-0.048,0.032,0.055,-0.052,0.019,0.04,-0.026,0.007,-0.033,-0.04,-0.044,-0.047,0.134,-0.002,0.095,0.061,-0.082,-0.127,0.039,0.057,0.077,0.092,0.081,0.007,-0.041,0.057,-0.038,0.02,0.004,-0.126,-0.118,-0.042,-0.061,-0.009,-0.034,-0.028,-0.016,0.066,-0.008,-0.033,-0.062,-0.168,0.054,0.058,-0.007,0.007,-0.054,-0.042,-0.257,-0.152,0.009,0.029,0.032,0.036,-0.033,-0.046,-0.05,0.018,-0.059,-0.065,-0.078,-0.093,-0.109,-0.126,-0.136,-0.072,-0.057,-0.05,-0.039,-0.032,-0.029,-0.024,-0.055,-0.05,-0.057,0.019,0.031,0.057,0.078,0.091,0.084,0.071,0.05,0.033,0.066,0.024,-0.039,-0.043,-0.062,-0.086,-0.078,-0.035,-0.041,0.114,-0.004,0.08,-0.043,0.008,0.035,0.117,0.075,0.091,-0.088,-0.074,-0.157,-0.187,0.023,0.023,0.021,0.021,0.017,0.002,-0.01,0.054,0.037,0.016,0.058,-0.096,-0.292,0.009,0.007,-0.003,-0.011,-0.009,-0.005,-0.017,-0.031,-0.04,-0.029,-0.019,0.007,0.036,0.064,0.06,0.091,0.084,0.021,0.044,0.024,0.007,0.007,0.006,-0.001,-0.004,-0.011,-0.026,-0.037,-0.048,-0.06,-0.057,-0.054,-0.051,-0.031,-0.014,-0.007],"slopeNorth":[-0.005,0.005,-0.003,0.002,-0.006,-0.016,-0.044,-0.027,-0.024,-0.026,-0.026,-0.026,-0.05,-0.054,0.02,-0.042,-0.033,-0.022,-0.009,0.008,0.025,0.032,0.037,0.003,0.01,0.01,0.013,0.017,0.026,0.059,0.018,-0.02,-0.039,0.024,0.024,0.008,-0.005,-0.003,-0.06,-0.088,-0.024,-0.033,-0.037,-0.039,-0.046,-0.052,-0.026,-0.019,-0.023,-0.025,-0.027,-0.106,-0.113,-0.118,-0.111,-0.103,-0.016,-0.016,-0.129,-0.122,-0.096,-0.075,-0.046,-0.071,0.056,0.061,-0.108,-0.077,0.028,0.067,-0.029,-0.009,0.048,-0.009,-0.029,-0.018,0.006,0.025,0.144,0.19,0.17,0.167,0.149,-0.078,-0.083,-0.0,-0.081,-0.04,-0.001,-0.033,-0.082,-0.031,0.061,0.14,0.078,0.132,0.104,0.127,-0.011,-0.01,0.071,0.129,0.113,0.103,0.13,-0.038,-0.037,0.044,0.052,0.016,0.01,-0.01,-0.02,-0.014,0.06,0.086,0.082,0.067,0.049,0.051,0.058,-0.08,-0.095,-0.099,-0.098,-0.093,-0.064,-0.024,0.007,0.03,0.041,-0.085,-0.064,-0.021,-0.069,-0.063,-0.054,-0.027,0.013,0.058,0.082,0.091,0.065,0.04,0.085,0.117,0.139,0.134,0.059,0.047,0.008,0.002,0.005,0.01,0.034,0.039,0.068,0.077,-0.041,-0.017,-0.011,-0.017,-0.021,-0.021,-0.003,0.016,0.034,-0.057,-0.046,-0.067,-0.062,-0.059,-0.032,-0.028,-0.023,-0.01,-0.01,-0.033,-0.028,-0.004,0.019,-0.01,0.056,0.067,-0.061,-0.049,-0.108,-0.079,-0.0,0.018,0.024,0.053,0.08,0.085,-0.008,0.015,0.016,0.027,0.048,-0.05,-0.038,-0.058,-0.086,-0.001,-0.061,-0.038,-0.014,0.027,0.062,-0.036,-0.007,0.046,-0.023,-0.001,0.01,-0.073,0.053,0.073,-0.02,0.004,-0.001,-0.014,-0.069,-0.016,-0.041,0.001,0.021,-0.017,-0.019,-0.115,-0.11,-0.083,-0.119,-0.097,-0.076,-0.101,-0.069,-0.054,-0.022,-0.045,-0.028,-0.016,-0.008,0.019,-0.037,0.005,0.007,0.01,-0.092,-0.096,-0.079,0.074,0.07,-0.076,-0.025,-0.075,-0.039,0.003,-0.006,0.013,-0.03,-0.064,-0.079,-0.076,-0.056,-0.026,0.042,-0.084,0.038,0.055,0.002,0.033,0.01,-0.003,0.001,-0.02,-0.041,-0.057,-0.056,-0.053,-0.045,-0.037,0.043,0.037,-0.023,-0.013,-0.012,0.063,0.029,0.024,0.012,-0.007,0.01,0.006,-0.057,0.007,-0.017,0.02,0.061,0.058,0.039,0.021,0.001,-0.02,-0.025,-0.024,0.039,0.052,0.03,0.03,0.028,0.035,0.003,0.005,0.027,-0.048,-0.059,0.005,-0.037,-0.029,-0.058,-0.066,-0.009,-0.02,0.014,-0.009,-0.015,-0.01,-0.045,-0.036,-0.085,-0.017,0.011,-0.052,0.048,-0.025,-0.024,-0.035,-0.031,-0.05,-0.058,-0.013,-0.05,-0.053,-0.058,-0.033,-0.017,0.001,-0.025,-0.066,-0.023,-0.014,-0.011,-0.059,-0.019,-0.009,0.005,-0.007,-0.034,-0.051,0.002,0.036,0.0,0.007,-0.022,-0.028,-0.046,-0.051,-0.018,-0.021,-0.013,0.012,-0.005,-0.035,-0.036,0.011,-0.065,0.025,0.006,0.018,-0.103,0.005,-0.007,-0.025,-0.061,-0.044,-0.049,-0.008,0.004,-0.043,0.022,-0.062,-0.019,-0.055,0.001,-0.01,-0.057,-0.005,-0.051,-0.064,-0.023,0.029,-0.044,0.079,0.066,-0.049,-0.043,0.043,0.037,0.009,-0.04,-0.175,-0.046,-0.025,-0.055,-0.045,0.01,-0.078,-0.036,-0.127,-0.126,-0.145,0.102,0.094,-0.123,0.055,-0.109,0.133,-0.142,-0.009,-0.085,0.067,0.024,0.056,-0.06,0.156,0.025,-0.068,0.018,-0.106,0.077,0.019,-0.02,-0.043,0.021,-0.021,0.053,-0.023,-0.091,-0.044,0.016,0.174,0.024,0.172,0.213,-0.05,-0.021,-0.051,-0.255,0.041,0.076,-0.168,0.031,-0.153,-0.058,-0.022,0.063,-0.084,-0.122,-0.153,-0.157,-0.171,0.014,0.054,0.036,0.084,0.009,0.019,0.018,-0.012,0.013,0.008,0.011,0.061,0.061,0.057,-0.013,-0.036,-0.024,-0.045,-0.046,-0.101,-0.156,-0.066,-0.239,-0.1,-0.077,-0.243,-0.08,0.073,0.039,0.179,0.106,0.005,0.086,0.044,0.321,0.232,0.195,0.145,-0.074,-0.069,-0.057,0.021,-0.1,-0.165,0.063,0.139,0.12,0.091,0.134,0.082,0.064,-0.056,-0.118,-0.146,-0.004,-0.123,-0.142,-0.092,-0.121,0.172,0.209,0.083,-0.15,-0.088,-0.128,-0.152,-0.196,0.004,-0.017,-0.027,-0.037,-0.042,0.021,0.014,0.009,0.003,0.082,0.074,0.061,0.048,0.031,0.013,-0.006,0.081,0.074,0.055,-0.036,0.041,0.024,0.006,-0.068,-0.022,0.073,0.021,0.055,0.009,0.022,0.079,-0.019,0.052,0.08,0.02,0.073,0.04,0.01,0.224,0.291,0.218,-0.011,-0.137,-0.234,0.091,0.094,0.095,0.093,0.062,0.02,0.061,0.003,0.044,-0.063,0.145,-0.012,-0.046,-0.011,0.075,0.117,0.046,-0.033,0.108,0.084,-0.011,-0.094,-0.024,-0.13,0.075,0.1,0.124,-0.018,-0.058,-0.136,-0.149,-0.227,-0.022,0.105,0.119,0.113,0.09,0.076,0.045,-0.045,0.032,0.029,0.034,0.051,0.055,0.044,0.028,-0.072,-0.095,-0.088,-0.06,-0.032,-0.005,0.003,-0.016,-0.008,0.039,0.085,0.076,0.061,0.046,0.035,0.031,0.022,0.018,0.014,0.006,0.014,0.009,-0.002,-0.002,0.003,-0.003,-0.024,-0.018,0.068,0.212,0.049,0.187,0.06,0.024,0.003,-0.084,-0.173,0.03,-0.138,-0.17,-0.157,0.006,-0.007,-0.026,-0.034,-0.038,-0.045,0.001,-0.075,-0.067,0.237,0.215,0.109,0.055,-0.068,-0.066,-0.059,-0.054,-0.059,-0.062,-0.065,-0.07,-0.072,-0.064,-0.054,-0.045,-0.035,-0.022,0.059,0.161,0.071,0.021,-0.021,-0.027,-0.033,-0.039,-0.044,-0.038,-0.029,-0.034,-0.057,-0.078,-0.075,-0.071,-0.076,-0.085,-0.083,-0.057,-0.03,-0.018]} diff --git a/src/app/features/scene/ridge/weather.ts b/src/app/features/scene/ridge/weather.ts new file mode 100644 index 00000000..68ea88ea --- /dev/null +++ b/src/app/features/scene/ridge/weather.ts @@ -0,0 +1,51 @@ +// One weather number, 0..1, and what it does to the sky. It is the page URL's `?weather=` (for a still), else the number the +// observation file gives (`weatherFeed.ts`, set by `setFedWeather`), else a clear sky. 0 clear, about 0.3 fair (a few clouds), 0.55 overcast, 0.75 drizzle and mist, 1 steady rain. +// What each part touches: haze multiplies the atmosphere's Mie coefficients before the LUTs are baked (`earthAtmosphere.ts`: +// the same air, more aerosol); cloud cover, mist and rain are layers over the baked sky (`clouds.ts`, `Ridge.ts`). + +let fed: number | undefined; +/** The weather number read from `weather.json`, or undefined to forget it. */ +export const setFedWeather = (v: number | undefined): void => { + fed = v; +}; + +/** The weather the ridge sky is drawn for: the URL's, else the file's, else clear. */ +export const ridgeWeather = (): number => urlWeather() ?? fed ?? 0; + +export interface Weather { + /** The number it was made from. */ + value: number; + /** 0..1 share of the sky the cloud layer covers. */ + cover: number; + /** Multiplier on the Mie coefficients: 1 is the reference air. */ + mieScale: number; + /** 0..1 strength of the mist over the lower ridges. */ + mist: number; + /** 0..1 rain streaks; 0 draws none and builds no layer. */ + rain: number; +} + +const clamp01 = (v: number): number => Math.min(1, Math.max(0, v)); +const smooth = (a: number, b: number, v: number): number => { + const t = clamp01((v - a) / (b - a)); + return t * t * (3 - 2 * t); +}; + +export function weatherOf(value: number): Weather { + const w = clamp01(Number.isFinite(value) ? value : 0); + return { + value: w, + cover: smooth(0.1, 0.7, w), + mieScale: 1 + 5 * smooth(0.2, 1, w), + mist: smooth(0.45, 0.9, w), + rain: smooth(0.68, 0.95, w), + }; +} + +/** The page URL's `?weather=<0..1>`, when it is a number. */ +export function urlWeather(search: string = typeof location === 'undefined' ? '' : location.search): number | undefined { + const v = new URLSearchParams(search).get('weather'); + if (v === null || v.trim() === '') return undefined; + const n = Number(v); + return Number.isFinite(n) ? clamp01(n) : undefined; +} diff --git a/src/app/features/scene/ridge/weatherFeed.test.ts b/src/app/features/scene/ridge/weatherFeed.test.ts new file mode 100644 index 00000000..e12bbefc --- /dev/null +++ b/src/app/features/scene/ridge/weatherFeed.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import { viennaSun } from './vienna'; +import { + CLEAR_SKY, + currentWeather, + fetchWeatherFile, + parseWeatherFile, + OBSERVATION_STALE_MS, + STALE_MS, + WEATHER_ATTRIBUTION, + weatherFromFile, + clearSkyGhi, + cloudEstimate, + cloudFromGlow, + WeatherFile, +} from './weatherFeed'; + +const file = (over: Partial = {}): WeatherFile => ({ + fetched_at: '2026-11-11T10:45:00Z', + observed_at: '2026-11-11T10:40:00Z', + station: 11035, + precip_mm_10min: 0, + humidity_pct: 60, + glow_wm2: null, + sunshine_s_10min: null, + attribution: WEATHER_ATTRIBUTION, + ...over, +}); +const NOW = new Date('2026-11-11T10:50:00Z'); + +describe('weather from the file', () => { + it('credits GeoSphere as its legal page asks, and claims no licence', () => { + expect(WEATHER_ATTRIBUTION).toBe('Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at'); + expect(WEATHER_ATTRIBUTION).not.toMatch(/CC/); + }); + it('rain is 0.8..1, by rate', () => { + expect(weatherFromFile(file({ precip_mm_10min: 0.1 }), 20)).toBeCloseTo(0.813, 2); + expect(weatherFromFile(file({ precip_mm_10min: 3 }), -20)).toBe(1); + }); + it('the clear-sky radiation is Haurwitz: about 335 W/m2 at 21 degrees, none below the horizon', () => { + expect(clearSkyGhi(21)).toBeCloseTo(335, -1); + expect(clearSkyGhi(-5)).toBe(0); + }); + it('noon clear: radiation at the clear-sky value is no cloud', () => { + expect(weatherFromFile(file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 600, humidity_pct: 95 }), 21)).toBe(0); + expect(cloudFromGlow(300, 21)).toBeCloseTo(0.56, 1); // k = 0.9 is still a few oktas + }); + it('noon overcast: a fifth of the clear-sky radiation is nearly full cloud, and with saturated air, mist', () => { + expect(cloudFromGlow(0.2 * clearSkyGhi(21), 21)).toBeGreaterThan(0.9); + expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 80 }), 21)).toBeGreaterThan(0.6); + expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 100 }), 21)).toBe(0.75); + }); + it('the radiation, not the humidity, decides by day: humid air under a bright sky is clear', () => { + expect(weatherFromFile(file({ glow_wm2: clearSkyGhi(30), sunshine_s_10min: 600, humidity_pct: 98 }), 30)).toBe(0); + }); + it('dusk: with the sun at 3 degrees the humidity ramp speaks, cloud 0 at 60% and 1 at 95%', () => { + expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 60 }), 3)).toBe(0); + expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 77.5 }), 3)).toBeCloseTo(0.35, 2); + expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 95 }), 3)).toBeCloseTo(0.7, 2); + }); + it('night: the radiation and sunshine are 0.0 and the humidity speaks; saturated air is mist, 0.75', () => { + expect(weatherFromFile(file({ glow_wm2: 0, humidity_pct: 40 }), -20)).toBe(0); + expect(weatherFromFile(file({ glow_wm2: 0, humidity_pct: 100 }), -20)).toBe(0.75); + }); + it('the 2026-11-10 midday fixture, GLOW 56-89 W/m2 under an overcast sky, is full cloud; the clear midday of 2026-10-15, 546-569, is none', () => { + const nov = viennaSun(new Date('2026-11-10T10:40:00Z')).elevation; + const oct = viennaSun(new Date('2026-10-15T10:50:00Z')).elevation; + for (const g of [56, 72, 89]) expect(cloudFromGlow(g, nov)).toBeGreaterThan(0.95); + for (const g of [546, 569]) expect(cloudFromGlow(g, oct)).toBe(0); + expect(weatherFromFile(file({ glow_wm2: 72, sunshine_s_10min: 0 }), nov)).toBeGreaterThan(0.65); + expect(weatherFromFile(file({ glow_wm2: 560, sunshine_s_10min: 600, humidity_pct: 70 }), oct)).toBe(0); + }); + it('the sunshine cross-checks the radiation: agreement keeps the radiation, a half-sky disagreement splits it', () => { + expect(cloudEstimate(file({ glow_wm2: 0.2 * clearSkyGhi(21), sunshine_s_10min: 0 }), 21)).toBeGreaterThan(0.9); + // Radiation says clear, the sunshine says none: each is half trusted. + expect(cloudEstimate(file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 0 }), 21)).toBe(0.5); + // No radiation reading by day: the sunshine speaks alone. + expect(cloudEstimate(file({ glow_wm2: null, sunshine_s_10min: 300 }), 21)).toBe(0.5); + expect(cloudEstimate(file({ glow_wm2: 0, sunshine_s_10min: 600 }), 21)).toBe(0); + }); + it('with the sun below the instruments\' reach there is no estimate: the humidity speaks', () => { + expect(cloudEstimate(file({ glow_wm2: 3, sunshine_s_10min: 0 }), 3)).toBeNull(); + }); + it('null: unknown measurements are not zero, and nothing known is a clear sky', () => { + expect(weatherFromFile(file({ glow_wm2: null, humidity_pct: null, precip_mm_10min: null, sunshine_s_10min: null }), 21)).toBe(CLEAR_SKY); + expect(weatherFromFile(file({ glow_wm2: null, humidity_pct: 95 }), 21)).toBeCloseTo(0.7, 2); + }); +}); + +describe('the weather now', () => { + it('uses the file while it is fresh', () => { + expect(currentWeather(file({ glow_wm2: 0, humidity_pct: 95 }), NOW)).toBeCloseTo(0.7, 2); + }); + it('falls back to a clear sky when the file was fetched more than 30 minutes ago', () => { + const f = file({ humidity_pct: 100, fetched_at: '2026-11-11T10:19:00Z' }); + expect(NOW.getTime() - Date.parse(f.fetched_at)).toBeGreaterThan(STALE_MS); + expect(currentWeather(f, NOW)).toBe(CLEAR_SKY); + }); + it('the 30 minute mark itself is still fresh', () => { + expect(currentWeather(file({ glow_wm2: 0, humidity_pct: 100, fetched_at: '2026-11-11T10:20:00Z' }), NOW)).toBe(0.75); + }); + it('falls back to a clear sky when the observation is more than 40 minutes old, though the file is fresh', () => { + const f = file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:09:00Z' }); + expect(NOW.getTime() - Date.parse(f.observed_at)).toBeGreaterThan(OBSERVATION_STALE_MS); + expect(currentWeather(f, NOW)).toBe(CLEAR_SKY); + expect(currentWeather(file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:11:00Z' }), NOW)).toBeGreaterThan(0.8); + }); + it('falls back to a clear sky with no file, and with a fetch time from the future', () => { + expect(currentWeather(null, NOW)).toBe(CLEAR_SKY); + expect(currentWeather(file({ precip_mm_10min: 2, fetched_at: '2026-11-12T10:40:00Z' }), NOW)).toBe(CLEAR_SKY); + }); +}); + +describe('bad instrument values', () => { + it('a negative sunshine is no reading, not full cloud', () => { + expect(cloudEstimate(file({ glow_wm2: null, sunshine_s_10min: -5 }), 21)).toBeNull(); + }); + it('the radiation is set against the sun of the ten minutes it averaged, not the sun now', () => { + // Sun rising: 07:45Z observation (mean of 07:35-07:45), read 35 minutes later at the stale limit. + const f = file({ observed_at: '2026-11-11T07:45:00Z', fetched_at: '2026-11-11T07:46:00Z', glow_wm2: 100, sunshine_s_10min: null, humidity_pct: 50 }); + const at = (m: number) => currentWeather(f, new Date(Date.parse(f.observed_at) + m * 60_000)); + expect(at(0)).toBe(at(29)); + }); +}); + +describe('reading weather.json', () => { + it('reads Eureka\'s file as the host writes it: Z times, a whole-station gap as nulls, 0.0 at night', () => { + const night = JSON.parse( + '{"fetched_at":"2026-11-11T22:48:22Z","observed_at":"2026-11-11T22:40:00Z","station":11035,"precip_mm_10min":0.1,"humidity_pct":100.0,"sunshine_s_10min":0.0,"glow_wm2":0.0,"attribution":"Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at"}' + ); + expect(parseWeatherFile(night)).toMatchObject({ precip_mm_10min: 0.1, humidity_pct: 100, glow_wm2: 0, sunshine_s_10min: 0 }); + const gap = parseWeatherFile({ ...night, precip_mm_10min: null, humidity_pct: null, sunshine_s_10min: null, glow_wm2: null }); + expect(gap).toMatchObject({ precip_mm_10min: null, humidity_pct: null, sunshine_s_10min: null, glow_wm2: null }); + expect(currentWeather(gap, new Date('2026-11-11T22:50:00Z'))).toBe(CLEAR_SKY); + }); + it('is not another station\'s file', () => { + expect(parseWeatherFile({ ...file(), station: 11036 })).toBeNull(); + }); + it('parses the agreed shape, keeping nulls and the sunshine it never reads', () => { + const raw = { ...file(), precip_mm_10min: null, glow_wm2: 12, sunshine_s_10min: 0 }; + expect(parseWeatherFile(raw)).toEqual(raw); + }); + it('rejects a body without usable times, and anything not an object', () => { + expect(parseWeatherFile({ ...file(), observed_at: 'yesterday' })).toBeNull(); + expect(parseWeatherFile({ ...file(), fetched_at: undefined })).toBeNull(); + expect(parseWeatherFile(null)).toBeNull(); + expect(parseWeatherFile('x')).toBeNull(); + }); + it('rejects a time with no zone: it would be read in the client\'s local time', () => { + expect(parseWeatherFile({ ...file(), observed_at: '2026-11-10T10:40:00' })).toBeNull(); + expect(parseWeatherFile({ ...file(), observed_at: '2026-11-10T11:40:00+01:00' })).not.toBeNull(); + }); + it('is no observation when the file is missing, fails, or is not JSON', async () => { + expect(await fetchWeatherFile('/weather.json', (async () => ({ ok: false })) as unknown as typeof fetch)).toBeNull(); + expect(await fetchWeatherFile('/weather.json', (async () => { throw new Error('offline'); }) as unknown as typeof fetch)).toBeNull(); + expect( + await fetchWeatherFile('/weather.json', (async () => ({ ok: true, json: async () => { throw new Error('bad'); } })) as unknown as typeof fetch) + ).toBeNull(); + }); + it('reads a good file', async () => { + const f = (async () => ({ ok: true, json: async () => file() })) as unknown as typeof fetch; + expect(await fetchWeatherFile('/weather.json', f)).toEqual(file()); + }); +}); diff --git a/src/app/features/scene/ridge/weatherFeed.ts b/src/app/features/scene/ridge/weatherFeed.ts new file mode 100644 index 00000000..39379fd5 --- /dev/null +++ b/src/app/features/scene/ridge/weatherFeed.ts @@ -0,0 +1,122 @@ +// The weather over the viewpoint as one `weather.ts` number, read from a static `weather.json` next to the page (config.json's +// neighbour). The browser never calls a weather service: Eureka's refresher (gamecult-ops, docs/weather-feed.md; the only writer) +// writes the file from GeoSphere Austria's TAWES 10-minute observations (station 11035, Wien Hohe +// Warte). Rain is the gauge. CLOUD IS AN ESTIMATE: TAWES has no cloud cover. By day it is the measured global radiation (GLOW) against +// what a clear sky gives at the sun's position now (`clearSkyGhi`, Haurwitz), cross-checked by the sunshine duration (`SO`, seconds of +// 600 the direct beam was strong enough to burn the card); with the sun low or down it is a ramp of the humidity. A file that is +// missing, unreadable, malformed or stale (below) is no observation, and no observation is a clear sky. +import { WEATHER_CREDIT } from './credits'; +import { viennaSun } from './vienna'; + +/** Reader rule of docs/weather-feed.md: the file is stale 30 minutes after it was fetched or 40 after the observation it holds (the station reports every 10). */ +export const STALE_MS = 30 * 60_000; +export const OBSERVATION_STALE_MS = 40 * 60_000; +export const STATION = 11035; +const SKEW_MS = 5 * 60_000; +export const CLEAR_SKY = 0; +/** The credit GeoSphere's legal page asks for. */ +export const WEATHER_ATTRIBUTION = WEATHER_CREDIT; + +/** The file's shape; every measurement is null when the station did not report it. Times are ISO 8601. */ +export interface WeatherFile { + fetched_at: string; + observed_at: string; + station: number; + precip_mm_10min: number | null; + humidity_pct: number | null; + /** Global radiation, W/m2, last 10 minutes. */ + glow_wm2: number | null; + sunshine_s_10min: number | null; + attribution: string; +} + +const clamp01 = (v: number): number => Math.min(1, Math.max(0, v)); +const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null); + +/** An ISO time that names its zone (`Z` or an offset); `Date.parse` would read a bare one in the client's local time. */ +const isZonedTime = (v: unknown): v is string => typeof v === 'string' && /(Z|[+-]\d\d:?\d\d)$/.test(v) && !Number.isNaN(Date.parse(v)); + +/** A `WeatherFile` from parsed JSON, or null when it does not have the shape (times missing or unparseable). */ +export function parseWeatherFile(json: unknown): WeatherFile | null { + const j = json as Record | null; + if (!j || typeof j !== 'object') return null; + if (!isZonedTime(j.observed_at) || !isZonedTime(j.fetched_at)) return null; + if (j.station !== STATION) return null; + return { + fetched_at: j.fetched_at, + observed_at: j.observed_at, + station: STATION, + precip_mm_10min: num(j.precip_mm_10min), + humidity_pct: num(j.humidity_pct), + glow_wm2: num(j.glow_wm2), + sunshine_s_10min: num(j.sunshine_s_10min), + attribution: typeof j.attribution === 'string' ? j.attribution : WEATHER_ATTRIBUTION, + }; +} + +/** Global horizontal radiation of a clear sky, W/m2, sun at `el` degrees: Haurwitz (1945), 1098 cos(z) exp(-0.057 / cos(z)). */ +export function clearSkyGhi(el: number): number { + const c = Math.sin((el * Math.PI) / 180); + return c <= 0 ? 0 : 1098 * c * Math.exp(-0.057 / c); +} + +/** Below this sun elevation the radiation says too little (clear-sky ~100 W/m2 at 8 degrees) and the humidity is read instead. */ +export const GLOW_FROM_DEG = 8; + +/** + * Cloud 0..1 from measured global radiation against the clear-sky value: the clearness ratio k inverted through Kasten and + * Czeplak (1980), k = 1 - 0.75 (N/8)^3.4, N the oktas. Null when the sun is too low or the reading is missing or 0. + */ +export function cloudFromGlow(glow: number | null, sunEl: number): number | null { + if (glow === null || glow <= 0 || sunEl < GLOW_FROM_DEG) return null; + const k = Math.min(1, glow / clearSkyGhi(sunEl)); + return clamp01(((1 - k) / 0.75) ** (1 / 3.4)); +} + +/** The cloud the sunshine duration implies: the share of the ten minutes the sun did not shine. Null when the sun is too low or it is unreported. */ +export function cloudFromSunshine(so: number | null, sunEl: number): number | null { + if (so === null || so < 0 || sunEl < GLOW_FROM_DEG) return null; + return 1 - clamp01(so / 600); +} + +/** The cloud estimate by day, or null when the sun is too low for either instrument (the caller reads the humidity). */ +export function cloudEstimate(f: WeatherFile, sunEl: number): number | null { + const g = cloudFromGlow(f.glow_wm2, sunEl); + const s = cloudFromSunshine(f.sunshine_s_10min, sunEl); + if (g !== null && s !== null) return Math.abs(g - s) > 0.5 ? (g + s) / 2 : g; // instruments that disagree by half a sky are each half trusted + return g ?? s; +} + +/** + * The weather number of an observation with the sun at `sunEl` degrees. Rain sets 0.8..1 by rate (0.1 mm in ten minutes is the + * gauge's step: drizzle). Without rain, cloud (above) puts the number up to 0.7 (overcast); where the instruments cannot give it + * (the sun low or down), cloud is a ramp of the humidity, 0 at 60% to 1 at 95%. Saturated air (92% to 100%) sets a mist floor of up to 0.75 when the sky is + * more than half cloud. + */ +export function weatherFromFile(f: WeatherFile, sunEl: number): number { + const rain = f.precip_mm_10min ?? 0; + if (rain > 0) return clamp01(0.8 + 0.2 * Math.min(1, rain / 1.5)); + const cloud = cloudEstimate(f, sunEl) ?? (f.humidity_pct === null ? 0 : clamp01((f.humidity_pct - 60) / 35)); + const mist = f.humidity_pct === null || cloud < 0.5 ? 0 : clamp01((f.humidity_pct - 92) / 8) * 0.75; + return Math.max(0.7 * cloud, mist); +} + +/** The weather number now: the file's, or a clear sky when there is none or it is stale (see STALE_MS) or from the future. */ +export function currentWeather(f: WeatherFile | null, now: Date = new Date()): number { + if (!f) return CLEAR_SKY; + const fetched = now.getTime() - Date.parse(f.fetched_at); + const observed = now.getTime() - Date.parse(f.observed_at); + if (!(fetched <= STALE_MS && fetched >= -SKEW_MS && observed <= OBSERVATION_STALE_MS && observed >= -SKEW_MS)) return CLEAR_SKY; + // GLOW and SO are means of the ten minutes ending at observed_at: the sun of their midpoint is the one to set them against. + return weatherFromFile(f, viennaSun(new Date(Date.parse(f.observed_at) - 5 * 60_000)).elevation); +} + +/** Fetch and parse `weather.json`; null on any failure. */ +export async function fetchWeatherFile(url: string, fetcher: typeof fetch = fetch): Promise { + try { + const res = await fetcher(url, { cache: 'no-store' }); + return res.ok ? parseWeatherFile(await res.json()) : null; + } catch { + return null; + } +} diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 37e170cb..7c8e14b1 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -9,12 +9,13 @@ import { TERRAIN_REACH_DEG, ridgeDrawn, ridgeSkyFor, + ridgeSkyKey, terrainDrawn, terrainSkyFor, } from './skyForState'; import { displayedLuminance, luma, shapeToBand, SKY_BAND } from './skyBand'; import type { FixedSkyState } from './skyBand'; -import { setSkyClock, skyBackdrop } from './skyState'; +import { DAY_FROM, setSkyClock, skyBackdrop } from './skyState'; import { view } from './terrain/terrainWorld'; import { skyThemeId } from './skyTheme'; @@ -77,47 +78,50 @@ describe('the bands the text ink needs', () => { }); describe('ridgeSkyFor', () => { - it('carries the state band, which the layers are shaped into', () => { - for (const state of ['day', 'dusk', 'night'] as const) { - expect(ridgeSkyFor(state).band).toEqual(SKY_BAND[state]); + it('carries the state band, which the layers are shaped into, and the physical sky of that state', () => { + for (const state of STATES) { + const sky = ridgeSkyFor(state, 0.3); + expect(sky.band).toEqual(SKY_BAND[state]); + expect(sky.physical?.state).toBe(state); + expect(sky.physical?.stops).toEqual({ top: sky.top, mid: sky.mid, horizon: sky.horizon }); } }); - it('draws the gradient of the sky auto resolves to, never nothing', () => { + it('draws the sky auto resolves to, never nothing', () => { + const key = (s: ReturnType) => [s.top, s.mid, s.horizon, s.band, s.physical?.sun]; setSkyClock(() => atStandardTime(12, 0)); - expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('day')); + expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('day', 0.3))); setSkyClock(() => atStandardTime(0, 0)); - expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('night')); + expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('night', 0.3))); setSkyClock(() => atStandardTime(18, 10)); - expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('dusk')); + expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('dusk', 0.3))); }); - it('draws a lavender, rose and orange twilight for dusk', () => { - const d = ridgeSkyFor('dusk'); - 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]); - 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("is lit from the real sun over Vienna: a forced state's sun is inside the state, the clear noon sky is blue", () => { + const day = ridgeSkyFor('day', 0); + expect(day.physical?.sun.elevation).toBeGreaterThanOrEqual(DAY_FROM); + expect(day.top[2]).toBeGreaterThan(day.top[0]); + const dusk = ridgeSkyFor('dusk', 0); + expect(Math.abs(dusk.physical?.sun.elevation ?? 99)).toBeLessThanOrEqual(DAY_FROM); + expect(ridgeSkyFor('night', 0).physical?.sun.elevation).toBeLessThanOrEqual(-DAY_FROM); + expect(day.ground).toEqual(skyColours().ground); }); - 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('keys a rebuild by state and weather, not by the moving sun', () => { + setSkyClock(() => atStandardTime(12, 0)); + expect(ridgeSkyKey('auto', 0.3)).toBe(ridgeSkyKey('day', 0.3)); + expect(ridgeSkyKey('day', 0.3)).not.toBe(ridgeSkyKey('day', 0.8)); + expect(ridgeSkyKey('day', 0.3)).not.toBe(ridgeSkyKey('night', 0.3)); }); - it.each(STATES)('%s: every stop and blend lies in the band its text ink needs', (state) => { + it.each(STATES)('%s: the display is fitted to the budget its text ink needs', (state) => { const { floor, ceiling } = needs(state); const r = ridgeDrawn(state); expect(r.darkest).toBeGreaterThanOrEqual(floor); expect(r.lightest).toBeLessThanOrEqual(ceiling); + const fit = ridgeSkyFor(state, 0.3).physical?.fit; + expect(fit?.lo).toBeGreaterThanOrEqual(r.darkest - 1e-9); + expect(fit?.hi).toBeLessThanOrEqual(r.lightest + 1e-9); }); }); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index a9551f65..8a5f9768 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -8,56 +8,61 @@ 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'; -import { resolveSkyState } from './skyState'; +import { makePhysicalSky } from './ridge/physicalSky'; +import { BUDGET } from './ridge/toneMap'; +import { ridgeWeather, setFedWeather } from './ridge/weather'; +import { currentWeather, fetchWeatherFile } from './ridge/weatherFeed'; +import { resolveSkyState, skyNow } from './skyState'; import type { SkyState } from './skyState'; -import { displayedLuminance, hexToLinear, shapeToBand, SKY_BAND } from './skyBand'; +import { displayedLuminance, 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; - -const fromGradient = (g: readonly string[], ground: Srgb, band: SkyBand): SkyColours => ({ - band, - 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; -const toByte = (v: number): number => - Math.round(255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055)); +/** + * What a rebuild of the ridge scene is for: the fixed state it draws and the weather. The sun moves with the clock inside a state and + * is not part of the key (the scene is baked at its start), as the sky was one gradient per state before. + */ +export const ridgeSkyKey = ( + state: SkyState, + weather: number = ridgeWeather(), + now: Date = skyNow() +): string => `ridge:${resolveSkyState(state, now)}:${weather}`; -/** 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; +const WEATHER_REFRESH_MS = 10 * 60_000; +let weatherAt = -Infinity; +/** + * Read `weather.json` (the page's own, next to config.json) into the weather the ridge sky is drawn for; at most once in ten minutes. + * Never throws: a missing, stale or broken file is a clear sky (`currentWeather`). + */ +export async function loadWeather(url: string, now: Date = skyNow()): Promise { + if (now.getTime() - weatherAt < WEATHER_REFRESH_MS) return; + weatherAt = now.getTime(); + setFedWeather(currentWeather(await fetchWeatherFile(url), now)); +} -/** 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 { +/** + * The ridge scene's `sky` option: the physical sky over Vienna for the state ('auto': the real sun now; a forced state: the + * sun of today at a moment inside it, `sunForState`), as a palette for the layers and stars plus the `physical` sky the host canvas + * is painted from. The atmosphere LUTs are baked here, once per scene start (tens of ms). + */ +export function ridgeSkyFor( + state: SkyState, + weather: number = ridgeWeather(), + now: Date = skyNow() +): SkyColours { const { ground } = skyColours(); - switch (resolveSkyState(state)) { - case 'dusk': { - const twilight = skyColours(); - const band = SKY_BAND.dusk; - return { - ...twilight, - band, - top: inBand(twilight.top, band), - mid: inBand(twilight.mid, band), - horizon: inBand(twilight.horizon, band), - }; - } - case 'day': - return fromGradient(baked.noonday.gradient, ground, SKY_BAND.day); - default: - return fromGradient(baked.midnight.gradient, ground, SKY_BAND.night); - } + const fixed = resolveSkyState(state, now); + const physical = makePhysicalSky(fixed, now, weather); + return { + ...physical.stops, + ground, + band: SKY_BAND[fixed], + physical, + }; } /** @@ -93,26 +98,12 @@ export interface Range { } /** - * 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). + * The ridge sky's range: what its display is fitted to hold (`BUDGET`, toneMap.ts), which `physicalSky.test.ts` proves over the + * sun's whole year over Vienna. Dark ink needs the floor; light ink the ceiling. */ 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) }; + const b = BUDGET[resolveSkyState(state)]; + return { darkest: b.floor, lightest: b.ceiling }; } /** diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index ae4d9e83..d0a902a2 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -107,10 +107,10 @@ describe('sky state', () => { }); describe('skyBackdrop', () => { - it('puts night below day and dusk, and dusk reaches the sun glow', () => { + it('puts night below day and dusk', () => { expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('day').darkest); expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest); - expect(skyBackdrop('dusk').lightest).toBeGreaterThan(skyBackdrop('day').lightest); + expect(skyBackdrop('dusk').lightest).toBeGreaterThanOrEqual(skyBackdrop('day').lightest); }); it('holds the baked gradients for day and night', () => { diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index 0b60d99b..52b0cdc8 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -80,6 +80,9 @@ function sync(): void { timer = setTimeout(tick, TICK_MS); } +/** The moment the sky is drawn for: the real time, or the test clock. */ +export const skyNow = (): Date => clock(); + /** Replace the clock (tests); no argument restores the real one. */ export function setSkyClock(now?: () => Date): void { clock = now ?? (() => new Date()); @@ -123,16 +126,17 @@ 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 +// the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). The ridge sky is the physical sky over Vienna, its +// display fitted to the state's budget (ridge/toneMap.ts `BUDGET`): the budget is its extreme, whatever the sun's elevation. 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. 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.460352, lightest: 0.968744 }, - night: { darkest: 0.05987, lightest: 0.081642 }, + day: { darkest: 0.461063, lightest: 1 }, + dusk: { darkest: 0.460352, lightest: 1 }, + night: { darkest: 0, lightest: 0.081642 }, }; export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)]; From 07b82f24770e67b72d70841118bbd436ef510979 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 09:51:15 +0000 Subject: [PATCH 035/143] Pin sceneButtons folds recipe classes to the installed folds (#111) --- src/app/features/scene/sceneButtons.css.ts | 20 +++++++------- src/app/features/scene/sceneButtons.test.ts | 30 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 src/app/features/scene/sceneButtons.test.ts diff --git a/src/app/features/scene/sceneButtons.css.ts b/src/app/features/scene/sceneButtons.css.ts index 5c96e71b..9fca44f3 100644 --- a/src/app/features/scene/sceneButtons.css.ts +++ b/src/app/features/scene/sceneButtons.css.ts @@ -2,9 +2,14 @@ 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). +// folds' look. The selectors are folds' hashed recipe base classes (Button, Chip; pinned below). +// sceneButtons.test.ts resolves both from the installed folds and fails when a folds upgrade rehashes them. +export const FOLDS_BUTTON_CLASS = 'epr39zd'; +export const FOLDS_CHIP_CLASS = '_1bv8u1w6'; const scene = ':root[data-scene] #root'; -const controls = [`${scene} .epr39zd`, `${scene} ._1bv8u1w6`].join(', '); +const btn = `${scene} .${FOLDS_BUTTON_CLASS}`; +const chip = `${scene} .${FOLDS_CHIP_CLASS}`; +const controls = [btn, chip].join(', '); globalStyle(controls, { backgroundColor: 'transparent', boxShadow: 'none', @@ -12,20 +17,15 @@ globalStyle(controls, { color: 'inherit', }); globalStyle( - [ - `${scene} .epr39zd:hover`, - `${scene} .epr39zd:focus-visible`, - `${scene} ._1bv8u1w6:hover`, - `${scene} ._1bv8u1w6:focus-visible`, - ].join(', '), + [`${btn}:hover`, `${btn}:focus-visible`, `${chip}:hover`, `${chip}:focus-visible`].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(', '), { +globalStyle([`${btn}:focus-visible`, `${chip}:focus-visible`].join(', '), { outline: '2px solid currentColor', outlineOffset: '2px', }); -globalStyle(`${scene} .epr39zd`, { +globalStyle(btn, { fontWeight: 700, textDecoration: 'underline', textUnderlineOffset: '0.25em', diff --git a/src/app/features/scene/sceneButtons.test.ts b/src/app/features/scene/sceneButtons.test.ts new file mode 100644 index 00000000..f3a2ff91 --- /dev/null +++ b/src/app/features/scene/sceneButtons.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'fs'; +import { createRequire } from 'module'; +import { dirname, join } from 'path'; +import { describe, expect, it } from 'vitest'; + +// sceneButtons.css.ts restyles folds' Button and Chip by their hashed recipe base classes. A folds upgrade rehashes +// them and the chromeless buttons would silently revert, so resolve the classes from the installed folds. +const foldsDir = dirname(createRequire(import.meta.url).resolve('folds/package.json')); +const dist = readFileSync(join(foldsDir, 'dist/index.js'), 'utf8'); +const css = readFileSync(join(foldsDir, 'dist/style.css'), 'utf8'); +const sceneButtons = readFileSync( + `${process.cwd()}/src/app/features/scene/sceneButtons.css.ts`, + 'utf8' +); + +const recipeBase = (name: string) => + new RegExp(`\\b${name}\\$1 = createRuntimeFn\\(\\{ defaultClassName: "([^" ]+)`).exec(dist)?.[1]; +const pinned = (name: string) => new RegExp(`${name} = '([^']+)'`).exec(sceneButtons)?.[1]; + +describe('sceneButtons pins folds recipe base classes', () => { + it.each([ + ['Button', 'FOLDS_BUTTON_CLASS'], + ['Chip', 'FOLDS_CHIP_CLASS'], + ])('%s base class matches the installed folds', (recipe, constant) => { + const base = recipeBase(recipe); + expect(base, `folds no longer builds ${recipe} with createRuntimeFn`).toBeTruthy(); + expect(pinned(constant)).toBe(base); + expect(css).toContain(`.${base} {`); + }); +}); From 010e68b5e8b7d50f30a848de211ebbba8e27da98 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 10:50:03 +0000 Subject: [PATCH 036/143] A long reply quote stays inside its column (#112) --- src/app/components/message/layout/Bubble.tsx | 4 +- .../message/layout/bubbleBounds.test.ts | 48 +++++++++++++++++++ .../components/message/layout/layout.css.ts | 16 ++++++- src/preview-demo/fixtures.ts | 10 ++++ src/preview-demo/server.ts | 28 ++++++----- 5 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 src/app/components/message/layout/bubbleBounds.test.ts diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx index bd2056b9..6f4e94b0 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -60,13 +60,13 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>( {header &&
        {header}
        } {hideBubble ? ( children ) : ( - + { + const filePath = `${process.cwd()}/src/app/components/message/layout/layout.css.ts`; + const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); + const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); + const bytes = Buffer.from( + (code.match(/source=#?([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1], + 'base64' + ); + return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); +}; + +const declarations = (css: string, className: string): string[] => { + const rule = new RegExp(`^\\.layout_${className}__[\\w-]+ \\{\\n([^}]*)\\}`, 'm').exec(css); + expect(rule, `no .layout_${className} rule emitted`).toBeTruthy(); + return (rule as RegExpExecArray)[1].split('\n').map((l) => l.trim().replace(/;$/, '')); +}; + +describe('bubble rows shrink to their column', () => { + it.each([ + ['BubbleColumn', ['min-width: 0']], + ['BubbleContentRow', ['min-width: 0', 'max-width: 100%']], + ['BubbleContent', ['min-width: 0']], + ])('%s', async (name, expected) => { + const css = await emitted(); + expect(declarations(css, name)).toEqual(expect.arrayContaining(expected)); + }); + + it('BubbleContent keeps its 800px cap inside the column width', async () => { + const css = await emitted(); + expect(declarations(css, 'BubbleContent').join('\n')).toMatch(/max-width: min\(100%, 50rem\)/); + }); + + it('Bubble.tsx puts BubbleColumn on the column and BubbleContentRow on the row', () => { + const tsx = readFileSync( + `${process.cwd()}/src/app/components/message/layout/Bubble.tsx`, + 'utf8' + ); + expect(tsx).toMatch(/classNames\(css\.BubbleColumn,/); + expect(tsx).toMatch(//); + }); +}); diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 3037e66b..3d1a9c83 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -139,8 +139,22 @@ export const BubbleBefore = style({ minWidth: toRem(36), }); +// minWidth 0 down the column: a flex item never shrinks below its content (a reply quote's unbroken text), +// so without it a long quote pushes the column past the row, and a right-aligned row pushes it off the left edge. +export const BubbleColumn = style({ + minWidth: 0, +}); + +// The row around a bubble sizes to its content in a right-aligned (own) column, and a quote's nowrap text +// makes that content as wide as the quote: cap the row at the column so the bubble can shrink inside it. +export const BubbleContentRow = style({ + minWidth: 0, + maxWidth: '100%', +}); + export const BubbleContent = style({ - maxWidth: toRem(800), + minWidth: 0, + maxWidth: `min(100%, ${toRem(800)})`, padding: config.space.S200, backgroundColor: color.SurfaceVariant.Container, color: color.SurfaceVariant.OnContainer, diff --git a/src/preview-demo/fixtures.ts b/src/preview-demo/fixtures.ts index 18b2fac1..f88ff746 100644 --- a/src/preview-demo/fixtures.ts +++ b/src/preview-demo/fixtures.ts @@ -33,6 +33,8 @@ export type DemoLine = { body: string; html?: string; pose?: string; + // Index of an earlier line in the same room this one replies to. + replyTo?: number; minutesAgo: number; }; export type DemoRoom = { @@ -116,6 +118,14 @@ export const ROOMS: DemoRoom[] = [ }, // A long display name: the name pill has to show all of it on a phone. { from: user('heimdall'), body: 'Ok.', pose: 'thinking', minutesAgo: 20 }, + // A long message to reply to: the quote above each reply must stay inside its column on a phone. + { + from: user('visitor'), + body: "The read exists from 2026-11-04. It's research only, nothing is wired up yet, so please do not treat the notes_in_a_single_unbroken_token_that_keeps_going_past_the_edge as a decision.", + minutesAgo: 12, + }, + { from: ME, body: 'Noted, thank you.', replyTo: 8, minutesAgo: 11 }, + { from: user('void'), body: 'Same here.', replyTo: 8, minutesAgo: 10 }, ], }, { diff --git a/src/preview-demo/server.ts b/src/preview-demo/server.ts index ed8cbf46..4a70c95b 100644 --- a/src/preview-demo/server.ts +++ b/src/preview-demo/server.ts @@ -145,21 +145,27 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => { ROOMS.forEach((room) => { stateOf.set(room.id, buildState(room)); + const lineIds: string[] = []; room.lines.forEach((l) => { + const event = mk( + 'm.room.message', + l.from, + { + msgtype: 'm.text', + body: l.body, + ...(l.html ? { format: 'org.matrix.custom.html', formatted_body: l.html } : {}), + ...(l.pose ? { [POSE_KEY]: l.pose } : {}), + ...(l.replyTo === undefined + ? {} + : { 'm.relates_to': { 'm.in_reply_to': { event_id: lineIds[l.replyTo] } } }), + }, + now() - l.minutesAgo * 60 * 1000 + ); + lineIds.push(event.event_id); timeline.push({ version: 1, roomId: room.id, - event: mk( - 'm.room.message', - l.from, - { - msgtype: 'm.text', - body: l.body, - ...(l.html ? { format: 'org.matrix.custom.html', formatted_body: l.html } : {}), - ...(l.pose ? { [POSE_KEY]: l.pose } : {}), - }, - now() - l.minutesAgo * 60 * 1000 - ), + event, }); }); }); From df26eb4c99305e213a9f66ced43018ff29cac9a4 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 10:50:08 +0000 Subject: [PATCH 037/143] Footer band test pins the emitted tint and ink (#113) --- src/app/pages/auth/footerBand.test.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/app/pages/auth/footerBand.test.ts b/src/app/pages/auth/footerBand.test.ts index 97febbbb..f453f0bf 100644 --- a/src/app/pages/auth/footerBand.test.ts +++ b/src/app/pages/auth/footerBand.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import zlib from 'zlib'; import { compile, processVanillaFile } from '@vanilla-extract/integration'; +import { FOOTER_TINT, SKY_PLATE_FALLBACK } from '../../features/scene/skyPlate'; import { AuthFooter } from './styles.css'; // The CSS styles.css.ts emits for the sign-in footer, read back from vanilla-extract's own compile. @@ -32,8 +33,24 @@ describe('the sign-in footer band, as emitted', () => { 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('); + expect(fills[0]).toBe(`background-color: var(--sky-bubble, ${SKY_PLATE_FALLBACK.fill})`); + }); + + it('tints with FOOTER_TINT of the plate fill, the amount skyTheme.test.ts checks the ink against', async () => { + const [rule] = declarations(await emitted(), over); + const fills = rule.filter((d) => d.startsWith('background-color:')); + expect(fills[1]).toBe( + `background-color: color-mix(in srgb, var(--sky-bubble, ${SKY_PLATE_FALLBACK.fill}) ${ + FOOTER_TINT * 100 + }%, transparent)` + ); + }); + + it("is inked with the plate's ink, on the footer and on its links", async () => { + const css = await emitted(); + const ink = `color: var(--sky-bubble-text, ${SKY_PLATE_FALLBACK.text})`; + expect(declarations(css, over)[0].filter((d) => d.startsWith('color:'))).toEqual([ink]); + expect(declarations(css, `${over} a`)).toEqual([[ink]]); }); it('blurs what is behind it by 12px, the radius of the chat bubbles', async () => { From f9f94808d5e3f1f0f74798335f56098d2ab83071 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 11:41:43 +0000 Subject: [PATCH 038/143] An explicit theme choice (even Midnight Sky) wins over the sky (#114) --- src/app/features/scene/skyInk.test.ts | 8 +++- src/app/features/scene/skyTheme.test.ts | 18 ++++++--- src/app/features/scene/skyTheme.ts | 7 ++-- src/app/features/settings/general/General.tsx | 2 + src/app/hooks/useTheme.test.ts | 40 +++++++++++++++++++ src/app/hooks/useTheme.ts | 14 ++++--- src/app/state/settings.ts | 2 + 7 files changed, 75 insertions(+), 16 deletions(-) create mode 100644 src/app/hooks/useTheme.test.ts diff --git a/src/app/features/scene/skyInk.test.ts b/src/app/features/scene/skyInk.test.ts index 3a583090..888744b3 100644 --- a/src/app/features/scene/skyInk.test.ts +++ b/src/app/features/scene/skyInk.test.ts @@ -146,8 +146,12 @@ describe('sceneInk: an explicit theme wins over the auto ink', () => { }); // 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({ + // a fixed sky state does not override the person's theme either: the theme's own ink stands. + expect( + sceneInk(sceneThemeId('night', 'night', true, true), true, 'night', false) + ).toBeUndefined(); + // a fixed sky state with no theme of the person's own: the sky theme's ink applies. + expect(sceneInk(sceneThemeId('night', 'night', true, false), true, 'night', false)).toEqual({ state: 'night', phone: false, }); diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index c7e460f3..aab972fb 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -53,12 +53,18 @@ describe('skyThemeId', () => { }); 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('a fixed state picks its theme, whatever the scene, while the person chose no theme', () => { + [true, false].forEach((scene) => { + expect(sceneThemeId('day', 'night', scene, false)).toBe('noonday-sky'); + expect(sceneThemeId('night', 'day', scene, false)).toBe('midnight-sky'); + }); + }); + + it('a theme the person chose wins over every sky state, fixed or auto', () => { + (['auto', 'day', 'dusk', 'night'] as const).forEach((state) => + [true, false].forEach((scene) => + expect(sceneThemeId(state, 'night', scene, true)).toBeUndefined() + ) ); }); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 5dff3e06..e1934d81 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -24,8 +24,8 @@ export const skyThemeId = (state: SkyState): SkyThemeId | undefined => 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. + * The theme the page wears: a theme the person chose always wins, and picks none here. Otherwise a fixed sky state + * picks its theme, and 'auto' follows the resolved sky (`resolved`, the clock's state) only while a scene is on. * Signed-out pages choose no theme, so they pass `userChoseTheme: false`. */ export const sceneThemeId = ( @@ -34,6 +34,7 @@ export const sceneThemeId = ( sceneOn: boolean, userChoseTheme: boolean ): SkyThemeId | undefined => { + if (userChoseTheme) return undefined; if (state !== 'auto') return BY_STATE[state]; - return sceneOn && !userChoseTheme ? BY_STATE[resolved] : undefined; + return sceneOn ? BY_STATE[resolved] : undefined; }; diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 35184656..49b09a60 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -81,6 +81,7 @@ function SelectTheme({ disabled }: { disabled?: boolean }) { const themes = useThemes(); const themeNames = useThemeNames(); const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId'); + const [, setThemeChosen] = useSetting(settingsAtom, 'themeChosen'); const [menuCords, setMenuCords] = useState(); const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme; @@ -90,6 +91,7 @@ function SelectTheme({ disabled }: { disabled?: boolean }) { const handleThemeSelect = (theme: Theme) => { setThemeId(theme.id); + setThemeChosen(true); setMenuCords(undefined); }; diff --git a/src/app/hooks/useTheme.test.ts b/src/app/hooks/useTheme.test.ts new file mode 100644 index 00000000..dcb96e49 --- /dev/null +++ b/src/app/hooks/useTheme.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'fs'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +// settings.ts reads localStorage when it loads, and this suite runs without a DOM. +vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }); +let chosenTheme: typeof import('./useTheme').chosenTheme; +let defaultThemeId: string; +beforeAll(async () => { + ({ chosenTheme } = await import('./useTheme')); + ({ defaultThemeId } = await import('../state/settings')); +}); + +describe('chosenTheme: what counts as a theme the person chose', () => { + it('is not a theme before anything was picked: no id saved, or the default saved', () => { + expect(chosenTheme(false, undefined, undefined)).toBe(false); + expect(chosenTheme(false, defaultThemeId, undefined)).toBe(false); + expect(chosenTheme(false, defaultThemeId, false)).toBe(false); + }); + + it('is a pick of Midnight Sky made in the theme menu, which sets the flag', () => { + expect(chosenTheme(false, defaultThemeId, true)).toBe(true); + }); + + it('is a saved id other than the default, with or without the flag', () => { + expect(chosenTheme(false, 'dark-theme', undefined)).toBe(true); + expect(chosenTheme(false, 'noonday-sky', true)).toBe(true); + }); + + it('is the system scheme', () => { + expect(chosenTheme(true, defaultThemeId, undefined)).toBe(true); + }); + + it('the theme menu sets the flag together with the id', () => { + const tsx = readFileSync( + `${process.cwd()}/src/app/features/settings/general/General.tsx`, + 'utf8' + ); + expect(tsx).toMatch(/setThemeId\(theme\.id\);\s*setThemeChosen\(true\);/); + }); +}); diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 1ef0278d..730b12d4 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -107,9 +107,12 @@ export const useSystemThemeKind = (): ThemeKind => { 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); +/** + * A theme the person chose: the system scheme, any pick made in the theme menu (`themeChosen`, which also covers + * Midnight Sky, the default), or a saved id other than the default (picks from before the flag existed). + */ +export const chosenTheme = (useSystemTheme: boolean, themeId?: string, themeChosen?: boolean) => + useSystemTheme || themeChosen === true || (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 => @@ -122,8 +125,9 @@ const useSceneSkyThemeId = (): ReturnType => { 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)); + const [themeChosen] = useSetting(settingsAtom, 'themeChosen'); + // A person's own theme wins over the sky, fixed or 'auto'. + return sceneThemeId(sky, resolved, sceneOn, chosenTheme(systemTheme, themeId, themeChosen)); }; /** The ink the page wears over the scene (`sceneInk`, skyInk.ts), or undefined when the theme's own ink stands. */ diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index b5165929..21c449af 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -18,6 +18,8 @@ export enum MessageLayout { export interface Settings { themeId?: string; + /** Set when a theme is picked in the menu, so a picked default (Midnight Sky) reads as chosen. Absent until then. */ + themeChosen?: boolean; useSystemTheme: boolean; lightThemeId?: string; darkThemeId?: string; From c5d1b9c90ff2696836710039a34bb5c82a642b35 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 13:01:46 +0000 Subject: [PATCH 039/143] File zone: share a set of files as one album (flag off) (#115) --- config.json | 3 + docs/file-zone.md | 38 ++++-- src/app/features/albums/AlbumView.css.ts | 20 +++ src/app/features/albums/AlbumView.tsx | 8 ++ src/app/features/albums/ShareSet.tsx | 150 +++++++++++++++++++++ src/app/features/albums/shareSet.test.ts | 160 +++++++++++++++++++++++ src/app/features/albums/shareSet.ts | 83 ++++++++++++ src/app/features/room/RoomInput.tsx | 18 +-- src/app/features/room/msgContent.ts | 13 ++ src/app/hooks/useClientConfig.ts | 3 + 10 files changed, 469 insertions(+), 27 deletions(-) create mode 100644 src/app/features/albums/ShareSet.tsx create mode 100644 src/app/features/albums/shareSet.test.ts create mode 100644 src/app/features/albums/shareSet.ts diff --git a/config.json b/config.json index ef2a7a94..f98575d9 100644 --- a/config.json +++ b/config.json @@ -25,6 +25,9 @@ "dings": { "enabled": false }, + "fileZoneShare": { + "enabled": false + }, "registration": { "enabled": false, "url": "" diff --git a/docs/file-zone.md b/docs/file-zone.md index 01b5a4c5..7f380d69 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -71,21 +71,21 @@ Reuse the composer's pipeline, pointed at an album instead of the main timeline: - `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress. - On success, the msgContent builder produces the event; the zone adds the thread relation to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`). -- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order. +- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order. - Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change. ## Existing code to reuse (verified present in the repo) -| need | where | -|---|---| -| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) | -| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` | -| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` | -| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` | -| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` | -| event content builders | `features/room/msgContent.ts` | -| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) | -| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` | +| need | where | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) | +| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` | +| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` | +| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` | +| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` | +| event content builders | `features/room/msgContent.ts` | +| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) | +| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` | Nothing named "gallery" exists as a feature; `AudioContent` takes its controls as render props (`renderMediaControl`), so it can be rehosted in a row without a fork. **UNKNOWN:** whether `AudioContent` can be rendered outside a timeline event context (it needs only `url`, `info`, `mimeType`, `encInfo`, from the event content, so probably yes). @@ -104,3 +104,19 @@ Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordin - How the client identifies the zone room (config id recommended). - Whether exports are per listener (proposed) or a merged all-listeners file. - Where `device` comes from, now that reactions cannot carry it (export dialog, proposed). + +## Share a set (built, behind `fileZoneShare.enabled`) + +The ruling `ratatoskr:ruling:persona-file-sharing-zone` asks that a set of files arrive together, not one attachment at a time. The zone already holds one thread per album, so a set is an album: **one root text message plus one `m.thread` reply per file**, the exact shape `buildAlbums` reads. Nothing new on the wire and no homeserver change; a Mind's mouth that posts into the zone room and a person using the client produce the same events. + +Matrix options checked: (a) a thread, chosen: stock, rendered by every client, already the zone's album; (b) `m.gallery` (MSC4274) puts several items in one event, but it is unstable, most clients and this one do not render it, and each item still needs its own upload; (c) a shared custom relation: no client groups by it. A thread costs one extra event per set (the root) and nothing else. + +Client, `src/app/features/albums`: + +- `shareSet.ts`: `sendShareSet(port, title, files)`. Sends the root, then each file in pick order, each reply falling back to the previous event (`threadRelation`). A file whose upload fails is named in the result and the rest still go; a root that fails to send throws before any upload. The root alone is not an album in the list (`/threads` returns only roots that have a reply), so a share where every upload failed leaves a lone text message in the timeline. After a send the panel keeps only the failed files picked; a retry makes a second album. Ports are injected, so the order and failure rules are tested without a homeserver (`shareSet.test.ts` reads the sent events back through `buildAlbums`). +- `ShareSet.tsx`: phone layout. Idle, it is one text row, "Share files". Picking files (any number from the native picker; a second pick replaces the first) mounts a title field, the picked names on one dim line, and Send / Cancel as full-width rows; progress is "Sending 2 of 5". It is shown only on the album list (kept mounted but hidden while an album is open, so a running send keeps its state), one send at a time per room (tracked outside the component; leaving the page does not stop a send, it only loses its progress and result), and never in an encrypted zone (re-checked when Send is tapped) (the plaintext `info.sha256` the Mind verifies against is written in unencrypted rooms only). +- Content per file comes from `getMsgContent` in `features/room/msgContent.ts`, now also used by the composer, so a shared file and an attached file carry the same `info` including `sha256`. + +Flag: `fileZoneShare.enabled` in `config.json`, default `false`. With it off nothing mounts. + +Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; sharing into an existing album ("add files"); upload cancel; pagination beyond 100 albums/files. diff --git a/src/app/features/albums/AlbumView.css.ts b/src/app/features/albums/AlbumView.css.ts index c4f4fc79..d60dba43 100644 --- a/src/app/features/albums/AlbumView.css.ts +++ b/src/app/features/albums/AlbumView.css.ts @@ -49,3 +49,23 @@ export const FilesWide = style({ }); export const Dim = style({ opacity: config.opacity.P300 }); + +/** The share panel: idle it is one row; the title, names and Send mount only once files are picked. */ +export const SharePanel = style({ + display: 'flex', + flexDirection: 'column', + gap: config.space.S200, +}); + +export const ShareTitle = style({ + all: 'unset', + boxSizing: 'border-box', + minHeight: 44, + width: '100%', + borderBottom: `1px solid ${color.Background.ContainerLine}`, + color: color.Background.OnContainer, + ':focus-visible': { borderBottomColor: color.Background.OnContainer }, +}); + +/** Picked names and send notes wrap, even a long unbroken name. */ +export const ShareNames = style({ overflowWrap: 'anywhere' }); diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index 1bf30579..b670ee2b 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -16,6 +16,7 @@ import { timeDayMonYear } from '../../utils/time'; import { getMemberDisplayName } from '../../utils/room'; import { Album, AlbumFile } from './buildAlbums'; import { FileReadBy } from './FileReadBy'; +import { ShareSet, useShareEnabled } from './ShareSet'; import { useAlbums } from './useAlbums'; import * as css from './AlbumView.css'; @@ -93,6 +94,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { const open: Album | undefined = albums.find((a) => a.id === openId); const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author; const encrypted = room.hasEncryptionStateEvent(); + const canShare = useShareEnabled(room); let body: React.ReactNode; if (loading) body = Loading albums…; @@ -134,6 +136,12 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
        {body} + {/* Stays mounted while an album is open, so a running send keeps its progress. */} + {canShare && ( + + )} diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx new file mode 100644 index 00000000..1d9c4302 --- /dev/null +++ b/src/app/features/albums/ShareSet.tsx @@ -0,0 +1,150 @@ +import React, { useRef, useState } from 'react'; +import { Text } from 'folds'; +import { useQueryClient } from '@tanstack/react-query'; +import { Room } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { uploadContent } from '../../utils/matrix'; +import { safeFile } from '../../utils/mimeTypes'; +import { getMsgContent } from '../room/msgContent'; +import { FILE_HASH_KEY, hashForSend } from './fileReceipt'; +import { DEFAULT_TITLE, remainingAfter, resultNote, sendShareSet } from './shareSet'; +import * as css from './AlbumView.css'; + +/** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */ +export const useShareEnabled = (room: Room): boolean => + !!useClientConfig().fileZoneShare?.enabled && !room.hasEncryptionStateEvent(); + +/** Rooms with a share in flight. Module-level: leaving the page unmounts the panel, not the send. */ +const sending = new Set(); + +type Phase = { kind: 'idle' } | { kind: 'sending'; done: number } | { kind: 'done'; text: string }; + +/** + * One row when idle. Picking files (any number, one picker) opens the panel: a title, the picked + * names, Send. Sending makes one album of them (shareSet.ts). Nothing here is visible until used. + */ +export function ShareSet({ room }: { room: Room }) { + const mx = useMatrixClient(); + const queryClient = useQueryClient(); + const input = useRef(null); + const [files, setFiles] = useState([]); + const [title, setTitle] = useState(''); + const [phase, setPhase] = useState({ kind: 'idle' }); + + const reset = () => { + setFiles([]); + setTitle(''); + if (input.current) input.current.value = ''; + }; + + const send = async () => { + // One send at a time, and never into a room that has turned encrypted since the panel mounted. + if (sending.has(room.roomId)) return; + if (room.hasEncryptionStateEvent()) { + setPhase({ kind: 'done', text: 'Not shared: this room is encrypted.' }); + return; + } + sending.add(room.roomId); + const items = files.map((file) => ({ name: file.name, mimeType: file.type, file })); + setPhase({ kind: 'sending', done: 0 }); + let settled = 0; + try { + const res = await sendShareSet( + { + send: async (content) => (await mx.sendMessage(room.roomId, content as any)).event_id, + contentOf: async (f) => { + const file = safeFile(f.file); + const mxc = await new Promise((resolve, reject) => { + uploadContent(mx, file, { onSuccess: resolve, onError: reject }); + }); + const content = await getMsgContent( + mx, + { + file, + originalFile: f.file, + metadata: { markedAsSpoiler: false }, + encInfo: undefined, + }, + mxc + ); + const sha256 = await hashForSend(f.file); + if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 }; + return content; + }, + onFile: () => { + settled += 1; + setPhase({ kind: 'sending', done: settled }); + }, + }, + title, + items + ); + queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }); + const left = remainingAfter(items, res).map((i) => i.file); + setFiles(left); + if (left.length === 0) reset(); + setPhase({ kind: 'done', text: resultNote(res) }); + } catch (e: any) { + setPhase({ kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` }); + } finally { + sending.delete(room.roomId); + } + }; + + return ( +
        + { + setFiles(Array.from(e.target.files ?? [])); + setPhase({ kind: 'idle' }); + }} + /> + {files.length === 0 && phase.kind !== 'sending' && ( + + )} + {phase.kind === 'done' && ( + + {phase.text} + + )} + {files.length > 0 && ( + <> + setTitle(e.target.value)} + /> + + {files.map((f) => f.name).join(' · ')} + + {phase.kind === 'sending' ? ( + + Sending {phase.done} of {files.length}… + + ) : ( + <> + + + + )} + + )} +
        + ); +} diff --git a/src/app/features/albums/shareSet.test.ts b/src/app/features/albums/shareSet.test.ts new file mode 100644 index 00000000..f43e5a35 --- /dev/null +++ b/src/app/features/albums/shareSet.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; +import config from '../../../../config.json'; +import { ZoneEvent, buildAlbums } from './buildAlbums'; +import { + DEFAULT_TITLE, + ShareFile, + SharePort, + remainingAfter, + resultNote, + sendShareSet, + threadRelation, +} from './shareSet'; + +const kindOf = (f: ShareFile) => (f.mimeType.startsWith('audio') ? 'm.audio' : 'm.file'); + +/** A fake room: records what is sent, as the events Synapse would return from /relations. */ +const room = (failOn: string[] = []) => { + const events: ZoneEvent[] = []; + const port: SharePort = { + send: async (content) => { + const id = `$e${events.length + 1}`; + events.push({ + event_id: id, + type: 'm.room.message', + sender: '@aqua:chat.gamecult.org', + origin_server_ts: 1000 + events.length, + content, + }); + return id; + }, + contentOf: async (f) => { + if (failOn.includes(f.name)) throw new Error('upload failed'); + return { + msgtype: kindOf(f), + body: f.name, + filename: f.name, + url: `mxc://x/${f.name}`, + info: { mimetype: f.mimeType }, + }; + }, + }; + const album = () => { + const [root, ...replies] = events; + return buildAlbums([{ root, replies }], 'chat.gamecult.org')[0]; + }; + return { events, port, album }; +}; + +const clips: ShareFile[] = [ + { name: 'a.ogg', mimeType: 'audio/ogg' }, + { name: 'b.ogg', mimeType: 'audio/ogg' }, + { name: 'c.txt', mimeType: 'text/plain' }, +]; + +describe('sendShareSet', () => { + it('reads back as one album of the files in pick order', async () => { + const r = room(); + const res = await sendShareSet(r.port, 'Take 4', clips); + expect(res).toEqual({ albumId: '$e1', sent: 3, failed: [], failedAt: [] }); + const album = r.album(); + expect(album.title).toBe('Take 4'); + expect(album.files.map((f) => [f.name, f.kind])).toEqual([ + ['a.ogg', 'audio'], + ['b.ogg', 'audio'], + ['c.txt', 'file'], + ]); + }); + + it('threads every file under the root, each falling back to the previous event', async () => { + const r = room(); + await sendShareSet(r.port, 't', clips); + const rels = r.events.slice(1).map((e) => e.content?.['m.relates_to']); + expect(rels).toEqual([ + threadRelation('$e1', '$e1'), + threadRelation('$e1', '$e2'), + threadRelation('$e1', '$e3'), + ]); + expect(r.events[0].content?.['m.relates_to']).toBeUndefined(); + }); + + it('titles an untitled share', async () => { + const r = room(); + await sendShareSet(r.port, ' ', clips.slice(0, 1)); + expect(r.album().title).toBe(DEFAULT_TITLE); + }); + + it('names a failed file and still sends the rest', async () => { + const r = room(['b.ogg']); + const res = await sendShareSet(r.port, 't', clips); + expect(res.failed).toEqual(['b.ogg']); + expect(res.sent).toBe(2); + expect(r.album().files.map((f) => f.name)).toEqual(['a.ogg', 'c.txt']); + // the next file falls back to the last event that exists, not to the failed one + expect(r.events[2].content?.['m.relates_to']).toEqual(threadRelation('$e1', '$e2')); + }); + + it('throws when the root cannot be sent, uploading nothing', async () => { + let uploads = 0; + const port: SharePort = { + send: async () => { + throw new Error('M_FORBIDDEN'); + }, + contentOf: async () => { + uploads += 1; + return {}; + }, + }; + await expect(sendShareSet(port, 't', clips)).rejects.toThrow('M_FORBIDDEN'); + expect(uploads).toBe(0); + }); +}); + +describe('the share flag', () => { + it('ships off', () => { + expect(config.fileZoneShare).toEqual({ enabled: false }); + }); +}); + +describe('after a send', () => { + it('keeps the failed one of two same-named files, not both', async () => { + const twins: ShareFile[] = [ + { name: 'a.ogg', mimeType: 'audio/ogg' }, + { name: 'a.ogg', mimeType: 'audio/ogg' }, + ]; + let n = 0; + const r = room(); + const port: SharePort = { + ...r.port, + contentOf: async (f) => { + n += 1; + if (n === 2) throw new Error('second upload failed'); + return r.port.contentOf(f); + }, + }; + const res = await sendShareSet(port, 't', twins); + expect(res.failedAt).toEqual([1]); + expect(remainingAfter(twins, res)).toEqual([twins[1]]); + }); + + it('keeps only the failed files picked, so a retry is one tap', async () => { + const r = room(['b.ogg']); + const res = await sendShareSet(r.port, 't', clips); + expect(remainingAfter(clips, res).map((f) => f.name)).toEqual(['b.ogg']); + expect(resultNote(res)).toBe('Shared 2. Not sent: b.ogg'); + }); + + it('keeps everything and says nothing was shared when every upload failed', async () => { + const r = room(clips.map((c) => c.name)); + const res = await sendShareSet(r.port, 't', clips); + expect(remainingAfter(clips, res)).toEqual(clips); + expect(resultNote(res)).toBe('Nothing was shared. Not sent: a.ogg, b.ogg, c.txt'); + }); + + it('clears the pick on full success', async () => { + const r = room(); + const res = await sendShareSet(r.port, 't', clips); + expect(remainingAfter(clips, res)).toEqual([]); + expect(resultNote(res)).toBe('Shared 3 files.'); + }); +}); diff --git a/src/app/features/albums/shareSet.ts b/src/app/features/albums/shareSet.ts new file mode 100644 index 00000000..803fbb87 --- /dev/null +++ b/src/app/features/albums/shareSet.ts @@ -0,0 +1,83 @@ +// Share a set of files at once: one album root (a text message) and one thread reply per file. +// That is exactly the shape buildAlbums reads, so a set shared here and a set a Mind's mouth posts +// are the same thing to every viewer. Ports are injected so the order and the failure rules are +// testable without a homeserver. See docs/file-zone.md, "Share a set". + +export type ShareFile = { name: string; mimeType: string }; + +/** What sendShareSet needs from the outside world. */ +export type SharePort = { + /** Send an event and return its id. */ + send: (content: Record) => Promise; + /** Upload one file and build its message content (msgtype, url, info incl. sha256). */ + contentOf: (file: F) => Promise>; + /** Called after each file settles, for progress. */ + onFile?: (index: number, result: 'sent' | 'failed') => void; +}; + +export type ShareResult = { + /** The album root's event id; present as soon as the root is sent. */ + albumId: string; + sent: number; + failed: string[]; + /** Positions in the files array of those that failed; names can repeat, positions cannot. */ + failedAt: number[]; +}; + +/** The reply relation Matrix clients expect for a thread message: root, with the last event as fallback reply. */ +export const threadRelation = (rootId: string, lastId: string) => ({ + rel_type: 'm.thread', + event_id: rootId, + is_falling_back: true, + 'm.in_reply_to': { event_id: lastId }, +}); + +export const DEFAULT_TITLE = 'Shared files'; + +export const titleOrDefault = (title: string): string => title.trim() || DEFAULT_TITLE; + +/** + * Files go one at a time, in the order given, so thread order is pick order. A file that fails + * to upload is reported by name and the rest still go; the root is already out, so stopping would + * leave an empty album and the sender nothing to retry from. A root that fails to send throws: + * nothing exists yet. + */ +export const sendShareSet = async ( + port: SharePort, + title: string, + files: F[] +): Promise => { + const albumId = await port.send({ msgtype: 'm.text', body: titleOrDefault(title) }); + const result: ShareResult = { albumId, sent: 0, failed: [], failedAt: [] }; + let lastId = albumId; + for (let i = 0; i < files.length; i += 1) { + try { + // eslint-disable-next-line no-await-in-loop + const content = await port.contentOf(files[i]); + // eslint-disable-next-line no-await-in-loop + lastId = await port.send({ ...content, 'm.relates_to': threadRelation(albumId, lastId) }); + result.sent += 1; + port.onFile?.(i, 'sent'); + } catch { + result.failed.push(files[i].name); + result.failedAt.push(i); + port.onFile?.(i, 'failed'); + } + } + return result; +}; + +/** + * What stays picked after a send: only the files that did not go, so a retry is one tap. A retry + * makes a second album (adding to an existing one is not built), and a share where nothing went + * leaves its root as a lone message the album list does not show (it lists threads with replies). + */ +export const remainingAfter = (files: F[], result: ShareResult): F[] => + files.filter((_, i) => result.failedAt.includes(i)); + +export const resultNote = (result: ShareResult): string => { + if (result.failed.length === 0) + return `Shared ${result.sent} ${result.sent === 1 ? 'file' : 'files'}.`; + if (result.sent === 0) return `Nothing was shared. Not sent: ${result.failed.join(', ')}`; + return `Shared ${result.sent}. Not sent: ${result.failed.join(', ')}`; +}; diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 3f0a45d4..b4483d8b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -93,12 +93,7 @@ import { safeFile } from '../../utils/mimeTypes'; import { fulfilledPromiseSettledResult } from '../../utils/common'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; -import { - getAudioMsgContent, - getFileMsgContent, - getImageMsgContent, - getVideoMsgContent, -} from './msgContent'; +import { getMsgContent } from './msgContent'; import { FILE_HASH_KEY, hashForSend } from '../albums/fileReceipt'; import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room'; import { CommandAutocomplete } from './CommandAutocomplete'; @@ -281,16 +276,7 @@ export const RoomInput = forwardRef( const fileItem = selectedFiles.find((f) => f.file === upload.file); if (!fileItem) throw new Error('Broken upload'); - let content: IContent; - if (fileItem.file.type.startsWith('image')) { - content = await getImageMsgContent(mx, fileItem, upload.mxc); - } else if (fileItem.file.type.startsWith('video')) { - content = await getVideoMsgContent(mx, fileItem, upload.mxc); - } else if (fileItem.file.type.startsWith('audio')) { - content = getAudioMsgContent(fileItem, upload.mxc); - } else { - content = getFileMsgContent(fileItem, upload.mxc); - } + const content = await getMsgContent(mx, fileItem, upload.mxc); // A Mind verifies what it fetched against this. An encrypted file already carries the // ciphertext hash in file.hashes; a plaintext hash would leak the file's identity. if (!fileItem.encInfo) { diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index 5b7cd145..d6cd6b93 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -169,3 +169,16 @@ export const getFileMsgContent = (item: TUploadItem, mxc: string): IContent => { } return content; }; + +/** The message content for an uploaded file, by its type. */ +export const getMsgContent = async ( + mx: MatrixClient, + item: TUploadItem, + mxc: string +): Promise => { + const { type } = item.file; + if (type.startsWith('image')) return getImageMsgContent(mx, item, mxc); + if (type.startsWith('video')) return getVideoMsgContent(mx, item, mxc); + if (type.startsWith('audio')) return getAudioMsgContent(item, mxc); + return getFileMsgContent(item, mxc); +}; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 7f0899be..2ba69290 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -34,6 +34,9 @@ export type ClientConfig = { // Rooms (ids or aliases) shown as the album view; a room whose create type is org.gamecult.file_zone needs no entry. fileZoneRooms?: string[]; + // Sharing a set of files into a file-zone room as one album. Off unless `enabled` is true. + fileZoneShare?: { enabled?: boolean }; + // The sign-up entry on the sign-in page. Off unless `enabled` is true; `url` is the account // service's own registration page (a value, never a route this client invents). registration?: RegistrationConfig; From 494d2a7ed4afc5ade5068ba55741f98868e56342 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 13:15:49 +0000 Subject: [PATCH 040/143] File zone share: component tests, state machine moved out of ShareSet (#116) --- docs/file-zone.md | 1 + src/app/features/albums/ShareSet.test.tsx | 108 ++++++++ src/app/features/albums/ShareSet.tsx | 180 ++++++------ .../features/albums/shareController.test.ts | 261 ++++++++++++++++++ src/app/features/albums/shareController.ts | 87 ++++++ src/app/features/albums/shareSet.ts | 2 +- 6 files changed, 553 insertions(+), 86 deletions(-) create mode 100644 src/app/features/albums/ShareSet.test.tsx create mode 100644 src/app/features/albums/shareController.test.ts create mode 100644 src/app/features/albums/shareController.ts diff --git a/docs/file-zone.md b/docs/file-zone.md index 7f380d69..7990485d 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -114,6 +114,7 @@ Matrix options checked: (a) a thread, chosen: stock, rendered by every client, a Client, `src/app/features/albums`: - `shareSet.ts`: `sendShareSet(port, title, files)`. Sends the root, then each file in pick order, each reply falling back to the previous event (`threadRelation`). A file whose upload fails is named in the result and the rest still go; a root that fails to send throws before any upload. The root alone is not an album in the list (`/threads` returns only roots that have a reply), so a share where every upload failed leaves a lone text message in the timeline. After a send the panel keeps only the failed files picked; a retry makes a second album. Ports are injected, so the order and failure rules are tested without a homeserver (`shareSet.test.ts` reads the sent events back through `buildAlbums`). +- `shareController.ts`: the panel's state machine (picked files, phase, the one-send-per-room guard, the encrypted refusal, failed files kept by position), apart from React so tests drive it without a DOM; `shareController.test.ts` and `ShareSet.test.tsx` (static render of each state, the flag) pin it. No DOM test environment exists in this repo, so a real tap on a real input is covered by the phone check, not by tests. - `ShareSet.tsx`: phone layout. Idle, it is one text row, "Share files". Picking files (any number from the native picker; a second pick replaces the first) mounts a title field, the picked names on one dim line, and Send / Cancel as full-width rows; progress is "Sending 2 of 5". It is shown only on the album list (kept mounted but hidden while an album is open, so a running send keeps its state), one send at a time per room (tracked outside the component; leaving the page does not stop a send, it only loses its progress and result), and never in an encrypted zone (re-checked when Send is tapped) (the plaintext `info.sha256` the Mind verifies against is written in unencrypted rooms only). - Content per file comes from `getMsgContent` in `features/room/msgContent.ts`, now also used by the composer, so a shared file and an attached file carry the same `info` including `sha256`. diff --git a/src/app/features/albums/ShareSet.test.tsx b/src/app/features/albums/ShareSet.test.tsx new file mode 100644 index 00000000..bfa9a619 --- /dev/null +++ b/src/app/features/albums/ShareSet.test.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { ClientConfigProvider } from '../../hooks/useClientConfig'; +import type { ClientConfig } from '../../hooks/useClientConfig'; +import { ShareState } from './shareController'; +import { ShareSetView, shareEnabled, useShareEnabled } from './ShareSet'; + +const text = (html: string) => + html + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +const noop = () => undefined; +const file = (name: string) => ({ name, type: 'audio/ogg' } as File); + +const view = (over: Partial> = {}) => + renderToStaticMarkup( + + ); + +describe('the share panel, drawn', () => { + it('idle: one "Share files" row and no title, names, Send or Cancel', () => { + const html = view(); + expect(text(html)).toBe('Share files ›'); + expect(html).not.toContain('aria-label="Album title"'); + expect(html).toContain('type="file"'); + expect(html).toContain('multiple'); + expect(html).toContain('hidden'); + }); + + it('picked: the title field, the names on one line, Send N files and Cancel; no pick row', () => { + const html = view({ files: [file('a.ogg'), file('b.ogg')] }); + expect(html).toContain('aria-label="Album title"'); + expect(html).toContain('placeholder="Shared files"'); + expect(text(html)).toBe('a.ogg · b.ogg Send 2 files Cancel'); + expect(text(html)).not.toContain('Share files'); + }); + + it('one picked file reads "Send 1 file"', () => { + expect(text(view({ files: [file('a.ogg')] }))).toContain('Send 1 file Cancel'); + }); + + it('sending: progress replaces Send and Cancel, and the title is locked', () => { + const html = view({ + files: [file('a'), file('b'), file('c')], + phase: { kind: 'sending', done: 2 }, + }); + expect(text(html)).toContain('Sending 2 of 3…'); + expect(text(html)).not.toContain('Send 3 files'); + expect(text(html)).not.toContain('Cancel'); + expect(html).toMatch( + /aria-label="Album title"[^>]*disabled|disabled[^>]*aria-label="Album title"/ + ); + }); + + it('the encrypted note shows above the kept pick', () => { + const html = view({ + files: [file('a.ogg')], + phase: { kind: 'done', text: 'Not shared: this room is encrypted.' }, + }); + expect(text(html)).toBe('Not shared: this room is encrypted. a.ogg Send 1 file Cancel'); + }); + + it('after a full success the idle row and the note show together', () => { + expect(text(view({ phase: { kind: 'done', text: 'Shared 2 files.' } }))).toBe( + 'Share files › Shared 2 files.' + ); + }); +}); + +describe('the flag', () => { + it('off, missing or empty offers no share', () => { + expect(shareEnabled({}, false)).toBe(false); + expect(shareEnabled({ fileZoneShare: {} }, false)).toBe(false); + expect(shareEnabled({ fileZoneShare: { enabled: false } }, false)).toBe(false); + }); + + it('on offers it, except in an encrypted room', () => { + expect(shareEnabled({ fileZoneShare: { enabled: true } }, false)).toBe(true); + expect(shareEnabled({ fileZoneShare: { enabled: true } }, true)).toBe(false); + }); + + it('the hook reads the client config and the room, as AlbumView mounts it', () => { + const room = (encrypted: boolean) => ({ hasEncryptionStateEvent: () => encrypted } as never); + function Probe({ encrypted }: { encrypted: boolean }) { + return {String(useShareEnabled(room(encrypted)))}; + } + const probe = (config: ClientConfig, encrypted: boolean) => + text( + renderToStaticMarkup( + + + + ) + ); + expect(probe({}, false)).toBe('false'); + expect(probe({ fileZoneShare: { enabled: true } }, false)).toBe('true'); + expect(probe({ fileZoneShare: { enabled: true } }, true)).toBe('false'); + }); +}); diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx index 1d9c4302..7b507d92 100644 --- a/src/app/features/albums/ShareSet.tsx +++ b/src/app/features/albums/ShareSet.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import React, { useRef, useState, useSyncExternalStore } from 'react'; import { Text } from 'folds'; import { useQueryClient } from '@tanstack/react-query'; import { Room } from 'matrix-js-sdk'; @@ -8,104 +8,55 @@ import { uploadContent } from '../../utils/matrix'; import { safeFile } from '../../utils/mimeTypes'; import { getMsgContent } from '../room/msgContent'; import { FILE_HASH_KEY, hashForSend } from './fileReceipt'; -import { DEFAULT_TITLE, remainingAfter, resultNote, sendShareSet } from './shareSet'; +import { ShareController, ShareState, createShareController } from './shareController'; +import { DEFAULT_TITLE, sendShareSet } from './shareSet'; import * as css from './AlbumView.css'; /** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */ +export const shareEnabled = ( + config: { fileZoneShare?: { enabled?: boolean } }, + encrypted: boolean +) => !!config.fileZoneShare?.enabled && !encrypted; + export const useShareEnabled = (room: Room): boolean => - !!useClientConfig().fileZoneShare?.enabled && !room.hasEncryptionStateEvent(); + shareEnabled(useClientConfig(), room.hasEncryptionStateEvent()); -/** Rooms with a share in flight. Module-level: leaving the page unmounts the panel, not the send. */ -const sending = new Set(); - -type Phase = { kind: 'idle' } | { kind: 'sending'; done: number } | { kind: 'done'; text: string }; +type ShareViewProps = { + state: ShareState; + onPickClick: () => void; + onPicked: (files: File[]) => void; + onTitle: (title: string) => void; + onSend: () => void; + onCancel: () => void; + inputRef?: React.Ref; +}; /** * One row when idle. Picking files (any number, one picker) opens the panel: a title, the picked - * names, Send. Sending makes one album of them (shareSet.ts). Nothing here is visible until used. + * names, Send. Nothing but the row is visible until files are picked. */ -export function ShareSet({ room }: { room: Room }) { - const mx = useMatrixClient(); - const queryClient = useQueryClient(); - const input = useRef(null); - const [files, setFiles] = useState([]); - const [title, setTitle] = useState(''); - const [phase, setPhase] = useState({ kind: 'idle' }); - - const reset = () => { - setFiles([]); - setTitle(''); - if (input.current) input.current.value = ''; - }; - - const send = async () => { - // One send at a time, and never into a room that has turned encrypted since the panel mounted. - if (sending.has(room.roomId)) return; - if (room.hasEncryptionStateEvent()) { - setPhase({ kind: 'done', text: 'Not shared: this room is encrypted.' }); - return; - } - sending.add(room.roomId); - const items = files.map((file) => ({ name: file.name, mimeType: file.type, file })); - setPhase({ kind: 'sending', done: 0 }); - let settled = 0; - try { - const res = await sendShareSet( - { - send: async (content) => (await mx.sendMessage(room.roomId, content as any)).event_id, - contentOf: async (f) => { - const file = safeFile(f.file); - const mxc = await new Promise((resolve, reject) => { - uploadContent(mx, file, { onSuccess: resolve, onError: reject }); - }); - const content = await getMsgContent( - mx, - { - file, - originalFile: f.file, - metadata: { markedAsSpoiler: false }, - encInfo: undefined, - }, - mxc - ); - const sha256 = await hashForSend(f.file); - if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 }; - return content; - }, - onFile: () => { - settled += 1; - setPhase({ kind: 'sending', done: settled }); - }, - }, - title, - items - ); - queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }); - const left = remainingAfter(items, res).map((i) => i.file); - setFiles(left); - if (left.length === 0) reset(); - setPhase({ kind: 'done', text: resultNote(res) }); - } catch (e: any) { - setPhase({ kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` }); - } finally { - sending.delete(room.roomId); - } - }; - +export function ShareSetView({ + state, + onPickClick, + onPicked, + onTitle, + onSend, + onCancel, + inputRef, +}: ShareViewProps) { + const { files, title, phase, epoch } = state; return (
        { - setFiles(Array.from(e.target.files ?? [])); - setPhase({ kind: 'idle' }); - }} + onChange={(e) => onPicked(Array.from(e.target.files ?? []))} /> {files.length === 0 && phase.kind !== 'sending' && ( - )} @@ -122,7 +73,7 @@ export function ShareSet({ room }: { room: Room }) { placeholder={DEFAULT_TITLE} aria-label="Album title" disabled={phase.kind === 'sending'} - onChange={(e) => setTitle(e.target.value)} + onChange={(e) => onTitle(e.target.value)} /> {files.map((f) => f.name).join(' · ')} @@ -133,12 +84,12 @@ export function ShareSet({ room }: { room: Room }) { ) : ( <> - - @@ -148,3 +99,62 @@ export function ShareSet({ room }: { room: Room }) {
        ); } + +export function ShareSet({ room }: { room: Room }) { + const mx = useMatrixClient(); + const queryClient = useQueryClient(); + const input = useRef(null); + // Built once per mount (AlbumView keys the panel by room): the pick lives in the controller, so a + // rerender with a new client or Room object must not build another. Live values go through `live`. + const live = useRef({ mx, room, queryClient }); + live.current = { mx, room, queryClient }; + const [controller]: [ShareController, unknown] = useState(() => + createShareController({ + roomId: room.roomId, + isEncrypted: () => live.current.room.hasEncryptionStateEvent(), + onSent: () => + live.current.queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }), + run: (files, title, onFile) => + sendShareSet( + { + send: async (content) => + (await live.current.mx.sendMessage(room.roomId, content as any)).event_id, + contentOf: async (f) => { + const file = safeFile(f.file); + const mxc = await new Promise((resolve, reject) => { + uploadContent(live.current.mx, file, { onSuccess: resolve, onError: reject }); + }); + const content = await getMsgContent( + live.current.mx, + { + file, + originalFile: f.file, + metadata: { markedAsSpoiler: false }, + encInfo: undefined, + }, + mxc + ); + const sha256 = await hashForSend(f.file); + if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 }; + return content; + }, + onFile, + }, + title, + files.map((file) => ({ name: file.name, mimeType: file.type, file })) + ), + }) + ); + const state = useSyncExternalStore(controller.subscribe, controller.getState); + return ( + input.current?.click()} + onPicked={controller.pick} + onTitle={controller.setTitle} + onSend={controller.send} + onCancel={controller.cancel} + /> + ); +} diff --git a/src/app/features/albums/shareController.test.ts b/src/app/features/albums/shareController.test.ts new file mode 100644 index 00000000..3cbc03ce --- /dev/null +++ b/src/app/features/albums/shareController.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest'; +import { Picked, ShareDeps, createShareController } from './shareController'; +import { ShareResult } from './shareSet'; + +const file = (name: string): Picked => ({ name, type: 'audio/ogg' }); +const ok = (n: number): ShareResult => ({ albumId: '$r', sent: n, failed: [], failedAt: [] }); + +/** A run the test settles by hand, so "in flight" is a state the test can stand in. */ +const hand = () => { + const calls: { files: Picked[]; title: string; onFile: () => void }[] = []; + let release: (r: ShareResult) => void = () => undefined; + let fail: (e: Error) => void = () => undefined; + const run: ShareDeps['run'] = (files, title, onFile) => { + calls.push({ files, title, onFile }); + return new Promise((resolve, reject) => { + release = resolve; + fail = reject; + }); + }; + return { calls, run, release: (r: ShareResult) => release(r), fail: (e: Error) => fail(e) }; +}; + +const make = (over: Partial> = {}, roomId = '!zone:x') => { + const h = hand(); + const sentTo: string[] = []; + const c = createShareController({ + roomId, + isEncrypted: () => false, + run: h.run, + onSent: () => sentTo.push(roomId), + ...over, + }); + return { c, h, sentTo }; +}; + +describe('picking', () => { + it('starts idle with nothing picked, and a pick holds the files in order', () => { + const { c } = make(); + expect(c.getState().files).toEqual([]); + expect(c.getState().phase).toEqual({ kind: 'idle' }); + c.pick([file('a'), file('b')]); + expect(c.getState().files.map((f) => f.name)).toEqual(['a', 'b']); + }); + + it('Cancel empties the pick and the title and bumps the input epoch', () => { + const { c } = make(); + c.pick([file('a')]); + c.setTitle('Take 4'); + const before = c.getState().epoch; + c.cancel(); + expect(c.getState()).toMatchObject({ files: [], title: '', epoch: before + 1 }); + }); + + it('subscribers hear every change and stop hearing after unsubscribe', () => { + const { c } = make(); + let heard = 0; + const off = c.subscribe(() => { + heard += 1; + }); + c.pick([file('a')]); + off(); + c.pick([]); + expect(heard).toBe(1); + }); +}); + +describe('the Send guard', () => { + it('a double tap starts one send', async () => { + const { c, h } = make(); + c.pick([file('a'), file('b')]); + const first = c.send(); + const second = c.send(); + expect(h.calls).toHaveLength(1); + h.release(ok(2)); + await Promise.all([first, second]); + expect(h.calls).toHaveLength(1); + }); + + it('a re-mounted panel for the same room cannot start a second send while one is in flight', async () => { + const { c, h } = make(); + c.pick([file('a')]); + const first = c.send(); + // The page was left and returned to: a new controller, nothing picked in it but a new pick. + const again = make({}, '!zone:x'); + again.c.pick([file('b')]); + await again.c.send(); + expect(again.h.calls).toHaveLength(0); + h.release(ok(1)); + await first; + // Once the first has settled the room is free again. + const next = again.c.send(); + expect(again.h.calls).toHaveLength(1); + again.h.release(ok(1)); + await next; + }); + + it('another room is not blocked by a send in flight', async () => { + const a = make({}, '!a:x'); + const b = make({}, '!b:x'); + a.c.pick([file('a')]); + b.c.pick([file('b')]); + const sendA = a.c.send(); + const sendB = b.c.send(); + expect(b.h.calls).toHaveLength(1); + a.h.release(ok(1)); + b.h.release(ok(1)); + await Promise.all([sendA, sendB]); + }); + + it('sends nothing when nothing is picked', async () => { + const { c, h } = make(); + await c.send(); + expect(h.calls).toHaveLength(0); + }); + + it('a send that throws frees the room and says so', async () => { + const { c, h } = make(); + c.pick([file('a')]); + const p = c.send(); + h.fail(new Error('M_FORBIDDEN')); + await p; + expect(c.getState().phase).toEqual({ kind: 'done', text: 'Could not share: M_FORBIDDEN' }); + expect(c.getState().files).toHaveLength(1); + const retry = c.send(); + expect(h.calls).toHaveLength(2); + h.release(ok(1)); + await retry; + }); +}); + +describe('progress and results', () => { + it('counts files as they settle, passing the title through', async () => { + const { c, h } = make(); + c.pick([file('a'), file('b')]); + c.setTitle('Take 4'); + const p = c.send(); + expect(c.getState().phase).toEqual({ kind: 'sending', done: 0 }); + expect(h.calls[0].title).toBe('Take 4'); + h.calls[0].onFile(); + expect(c.getState().phase).toEqual({ kind: 'sending', done: 1 }); + h.release(ok(2)); + await p; + expect(c.getState().phase).toEqual({ kind: 'done', text: 'Shared 2 files.' }); + }); + + it('full success clears the pick, tells the room list, and remounts the input', async () => { + const { c, h, sentTo } = make(); + c.pick([file('a')]); + const { epoch } = c.getState(); + const p = c.send(); + h.release(ok(1)); + await p; + expect(c.getState()).toMatchObject({ files: [], title: '', epoch: epoch + 1 }); + expect(sentTo).toEqual(['!zone:x']); + }); + + it('failed files stay picked by position, so a same-named twin that went is not re-sent', async () => { + const { c, h } = make(); + const twins = [file('a.ogg'), file('a.ogg'), file('b.ogg')]; + c.pick(twins); + const p = c.send(); + h.release({ albumId: '$r', sent: 2, failed: ['a.ogg'], failedAt: [1] }); + await p; + expect(c.getState().files).toEqual([twins[1]]); + expect(c.getState().phase).toEqual({ kind: 'done', text: 'Shared 2. Not sent: a.ogg' }); + }); + + it('when every upload failed everything stays picked and the note says nothing was shared', async () => { + const { c, h } = make(); + const picked = [file('a'), file('b')]; + c.pick(picked); + const p = c.send(); + h.release({ albumId: '$r', sent: 0, failed: ['a', 'b'], failedAt: [0, 1] }); + await p; + expect(c.getState().files).toEqual(picked); + expect(c.getState().phase).toEqual({ + kind: 'done', + text: 'Nothing was shared. Not sent: a, b', + }); + }); + + it('a new pick after a result drops the result note', async () => { + const { c, h } = make(); + c.pick([file('a')]); + const p = c.send(); + h.release(ok(1)); + await p; + c.pick([file('b')]); + expect(c.getState().phase).toEqual({ kind: 'idle' }); + }); +}); + +describe('what a send leaves behind', () => { + it('keeps the title when some files failed, so the retry has the same name', async () => { + const { c, h } = make(); + c.pick([file('a'), file('b')]); + c.setTitle('Take 4'); + const p = c.send(); + h.release({ albumId: '$r', sent: 1, failed: ['b'], failedAt: [1] }); + await p; + expect(c.getState().title).toBe('Take 4'); + }); + + it('tells the room list only after the send settled, and not when it threw', async () => { + const { c, h, sentTo } = make(); + c.pick([file('a')]); + const p = c.send(); + expect(sentTo).toEqual([]); + h.release(ok(1)); + await p; + expect(sentTo).toEqual(['!zone:x']); + + const bad = make(); + bad.c.pick([file('a')]); + const q = bad.c.send(); + bad.h.fail(new Error('boom')); + await q; + expect(bad.sentTo).toEqual([]); + }); + + it('a success lands in one notification: the pick is never empty while still sending', async () => { + const { c, h } = make(); + c.pick([file('a')]); + const p = c.send(); + const seen: string[] = []; + c.subscribe(() => { + const st = c.getState(); + seen.push(`${st.files.length}:${st.phase.kind}`); + }); + h.release(ok(1)); + await p; + expect(seen).toEqual(['0:done']); + }); +}); + +describe('an encrypted room', () => { + it('says so, sends nothing and keeps the pick', async () => { + const { c, h } = make({ isEncrypted: () => true }); + c.pick([file('a')]); + await c.send(); + expect(c.getState().phase).toEqual({ + kind: 'done', + text: 'Not shared: this room is encrypted.', + }); + expect(c.getState().files).toHaveLength(1); + expect(h.calls).toHaveLength(0); + }); + + it('is checked at Send, not at mount: a room that turns encrypted after the pick is refused', async () => { + let encrypted = false; + const { c, h } = make({ isEncrypted: () => encrypted }); + c.pick([file('a')]); + encrypted = true; + await c.send(); + expect(h.calls).toHaveLength(0); + expect(c.getState().phase).toMatchObject({ + kind: 'done', + text: 'Not shared: this room is encrypted.', + }); + }); +}); diff --git a/src/app/features/albums/shareController.ts b/src/app/features/albums/shareController.ts new file mode 100644 index 00000000..b6219c72 --- /dev/null +++ b/src/app/features/albums/shareController.ts @@ -0,0 +1,87 @@ +// The share panel's state machine, apart from React and Matrix: what is picked, what phase the +// send is in, and the rules that keep a room to one send at a time. ShareSet.tsx draws it. +import { ShareResult, remainingAfter, resultNote } from './shareSet'; + +export type Picked = { name: string; type: string }; + +export type SharePhase = + | { kind: 'idle' } + | { kind: 'sending'; done: number } + | { kind: 'done'; text: string }; + +export type ShareState = { + files: F[]; + title: string; + phase: SharePhase; + /** Bumps whenever the pick is emptied, so the file input can be remounted and re-pick the same files. */ + epoch: number; +}; + +export const ENCRYPTED_NOTE = 'Not shared: this room is encrypted.'; + +export type ShareDeps = { + roomId: string; + isEncrypted: () => boolean; + /** Sends the files as one album; reports each settled file through onFile. */ + run: (files: F[], title: string, onFile: () => void) => Promise; + /** Called after a send that reached the room, whatever it left unsent. */ + onSent?: () => void; +}; + +/** Rooms with a share in flight. Module-level: leaving the page unmounts the panel, not the send. */ +const sending = new Set(); + +export const createShareController = (deps: ShareDeps) => { + let state: ShareState = { files: [], title: '', phase: { kind: 'idle' }, epoch: 0 }; + const listeners = new Set<() => void>(); + const set = (patch: Partial>) => { + state = { ...state, ...patch }; + listeners.forEach((l) => l()); + }; + + const clear = () => set({ files: [], title: '', epoch: state.epoch + 1 }); + + const send = async () => { + if (sending.has(deps.roomId) || state.files.length === 0) return; + if (deps.isEncrypted()) { + set({ phase: { kind: 'done', text: ENCRYPTED_NOTE } }); + return; + } + sending.add(deps.roomId); + const { files, title } = state; + set({ phase: { kind: 'sending', done: 0 } }); + let settled = 0; + try { + const res = await deps.run(files, title, () => { + settled += 1; + set({ phase: { kind: 'sending', done: settled } }); + }); + deps.onSent?.(); + const left = remainingAfter(files, res); + const phase: SharePhase = { kind: 'done', text: resultNote(res) }; + // One notification: no state is ever drawn with the pick emptied and the send still running. + if (left.length === 0) set({ files: [], title: '', epoch: state.epoch + 1, phase }); + else set({ files: left, phase }); + } catch (e: any) { + set({ phase: { kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` } }); + } finally { + sending.delete(deps.roomId); + } + }; + + return { + getState: () => state, + subscribe: (l: () => void) => { + listeners.add(l); + return () => { + listeners.delete(l); + }; + }, + pick: (files: F[]) => set({ files, phase: { kind: 'idle' } }), + setTitle: (title: string) => set({ title }), + cancel: clear, + send, + }; +}; + +export type ShareController = ReturnType>; diff --git a/src/app/features/albums/shareSet.ts b/src/app/features/albums/shareSet.ts index 803fbb87..3e572595 100644 --- a/src/app/features/albums/shareSet.ts +++ b/src/app/features/albums/shareSet.ts @@ -72,7 +72,7 @@ export const sendShareSet = async ( * makes a second album (adding to an existing one is not built), and a share where nothing went * leaves its root as a lone message the album list does not show (it lists threads with replies). */ -export const remainingAfter = (files: F[], result: ShareResult): F[] => +export const remainingAfter = (files: T[], result: ShareResult): T[] => files.filter((_, i) => result.failedAt.includes(i)); export const resultNote = (result: ShareResult): string => { From d4463a80cf7e3b09be276705a1d2d3298e580afa Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 14:19:25 +0000 Subject: [PATCH 041/143] Phone and desk visual findings: notice line owns its ground, tap hit areas stop at their neighbours, nav rows as tall as their targets Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 6 ++ src/app/features/room/RoomTrainingLine.tsx | 5 +- src/app/features/room/RoomViewHeader.css.ts | 11 ++-- src/app/features/scene/skyInk.test.ts | 38 ++++++------- src/app/features/scene/skyInk.ts | 27 ++++----- src/app/pages/auth/AuthFooter.tsx | 2 +- .../pages/auth/login/PasswordLoginForm.tsx | 5 +- src/app/pages/auth/styles.css.ts | 22 ++++++++ src/app/phoneTapTargets.test.ts | 56 +++++++++++++++---- 9 files changed, 117 insertions(+), 55 deletions(-) diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 5c3f37b0..5156ed41 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -53,6 +53,10 @@ export const NavCategoryHeader = style({ '&&': { paddingLeft: 0 }, }, '@media': { + // A phone's section header is as tall as its 44 px button, so it does not overlap the next row. + 'screen and (max-width: 750px)': { + selectors: { '&&': { minHeight: toRem(44), height: toRem(44) } }, + }, // 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)': { @@ -117,6 +121,8 @@ const NavItemBase = style({ [PHONE]: { backgroundColor: 'transparent', borderRadius: 0, + // The row is as tall as the 44 px tap target inside it (EstateNavigator.css.ts), so neighbours do not overlap. + minHeight: toRem(44), }, [WIDE]: { selectors: { diff --git a/src/app/features/room/RoomTrainingLine.tsx b/src/app/features/room/RoomTrainingLine.tsx index fd410979..6fdab6f4 100644 --- a/src/app/features/room/RoomTrainingLine.tsx +++ b/src/app/features/room/RoomTrainingLine.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { useStateEvent } from '../../hooks/useStateEvent'; import { useClientConfig } from '../../hooks/useClientConfig'; import { useSkyInk } from '../../hooks/useTheme'; -import { noticeInk } from '../scene/skyInk'; +import { noticeStyle } from '../scene/skyInk'; import { ROOM_NOTICE_EVENT_TYPE, parseRoomNotice, @@ -24,7 +24,8 @@ export function RoomTrainingLine({ room }: { room: Room }) { size="T200" style={{ padding: `${config.space.S100} 0`, - color: (skyInk && noticeInk(skyInk)) ?? color.SurfaceVariant.OnContainer, + color: color.SurfaceVariant.OnContainer, + ...noticeStyle(skyInk), }} > {line} diff --git a/src/app/features/room/RoomViewHeader.css.ts b/src/app/features/room/RoomViewHeader.css.ts index cff21a99..e342c80c 100644 --- a/src/app/features/room/RoomViewHeader.css.ts +++ b/src/app/features/room/RoomViewHeader.css.ts @@ -2,15 +2,14 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; -// Phone tap target: the topic is an 18 px line under the room name; its button grows 9 px up and 17 px down -// (44 px to tap; more below because the name is above) and the negative margin keeps the layout where it was. +// Phone tap target: the topic is an 18 px line directly under the room name (no gap), so its button grows only +// downward, 26 px (44 px to tap) and the negative margin keeps the layout where it was: growing up would take taps +// from the name line. export const HeaderTopic = style({ '@media': { [`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: { - paddingTop: 9, - paddingBottom: 17, - marginTop: -9, - marginBottom: -17, + paddingBottom: 26, + marginBottom: -26, }, }, ':hover': { diff --git a/src/app/features/scene/skyInk.test.ts b/src/app/features/scene/skyInk.test.ts index 888744b3..0bf4ea06 100644 --- a/src/app/features/scene/skyInk.test.ts +++ b/src/app/features/scene/skyInk.test.ts @@ -7,10 +7,11 @@ import type { FixedSkyState } from './skyBand'; import { LIGHTEST_PATCH, NIGHT_INK_MAX_L, - NOTICE_GROUND, + NOTICE_INK, + NOTICE_SCRIM, inkForState, labelInk, - noticeInk, + noticeStyle, sceneInk, userColorInk, } from './skyInk'; @@ -182,27 +183,24 @@ describe('labelInk', () => { }); }); -describe('noticeInk', () => { - const ratio = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +describe('noticeStyle', () => { + // The scrim over the lightest possible ground (white) is the worst case the ink can meet. + const [r, g, b, a] = NOTICE_SCRIM.match(/[\d.]+/g)!.map(Number); + const worst = chroma.rgb( + ...([r, g, b].map((c) => c * a + 255 * (1 - a)) as [number, number, number]) + ); + const ratio = (x: number, y: number) => (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); - it('is light enough to clear AA on the dark ridge wherever the theme ink does not', () => { - (['day', 'dusk'] as FixedSkyState[]).forEach((state) => { - const ink = noticeInk({ state, phone: true }) as string; - expect(ratio(luminance(ink), NOTICE_GROUND)).toBeGreaterThanOrEqual(AA); - // the dark theme ink it replaces does not - expect( - ratio(luminance(baked.noonday.tokens.SurfaceVariant.OnContainer), NOTICE_GROUND) - ).toBeLessThan(AA); - }); + it('clears AA for its ink over its scrim even on a white ground', () => { + expect(ratio(luminance(NOTICE_INK), luminance(worst.hex()))).toBeGreaterThanOrEqual(AA); }); - it('leaves night (already light) and every desktop state to the theme', () => { - expect(noticeInk({ state: 'night', phone: true })).toBeUndefined(); - (['day', 'dusk', 'night'] as FixedSkyState[]).forEach((state) => - expect(noticeInk({ state, phone: false })).toBeUndefined() + it('is the same in every sky state and viewport while a scene runs, and absent without one', () => { + const styles = (['day', 'dusk', 'night'] as FixedSkyState[]).flatMap((state) => + [true, false].map((phone) => noticeStyle({ state, phone })) ); - expect( - ratio(luminance(baked.midnight.tokens.SurfaceVariant.OnContainer), NOTICE_GROUND) - ).toBeGreaterThanOrEqual(AA); + styles.forEach((x) => expect(x).toEqual(styles[0])); + expect(styles[0]).toMatchObject({ color: NOTICE_INK, backgroundColor: NOTICE_SCRIM }); + expect(noticeStyle(undefined)).toBeUndefined(); }); }); diff --git a/src/app/features/scene/skyInk.ts b/src/app/features/scene/skyInk.ts index b7a3667f..07671f5a 100644 --- a/src/app/features/scene/skyInk.ts +++ b/src/app/features/scene/skyInk.ts @@ -95,18 +95,19 @@ export const labelInk = ({ state, phone }: SkyInk): string | undefined => state === 'dusk' && phone ? '#29221a' : undefined; /** - * WCAG relative luminance of the lightest pixel under the notice line above the composer on a phone: the dark - * foreground ridge, the same in every state (measured 2026-11-10 from the Starfire phone shots at 390x844, - * ratatoskr-dev-preview/phone-room-{day,dusk,night}.png, text region x 15-355 CSS px: median 0.0052, 90th percentile - * 0.0089, rounded up to 0.01). The labelInk patches above are the lightest terrain behind the timeline; here the - * ground is dark, so the ink must be light. + * The training-notice line above the composer sits on whatever the scene draws there: the dark ridge on a phone in + * the Starfire shots, but a mid-tone ridge at other widths and the near-black foreground ridge on a desktop, so no + * ink chosen for one ground holds on the others (measured 2026-11-10 on the pretend-server line, ratatoskr-dev-preview/ + * taps2-REPORT.md: phone day 3.08, dusk 3.97 with light ink; desktop day 1.01, dusk 1.22 with the theme's dark ink). + * So the line carries its own ground: light ink on a dark scrim, in every sky state, on every viewport, whatever + * the scene is behind it. The scrim is composited over the lightest ground there is (white); the ink over that + * worst case clears AA (skyInk.test.ts). */ -export const NOTICE_GROUND = 0.01; +export const NOTICE_INK = baked.midnight.tokens.SurfaceVariant.OnContainer; +export const NOTICE_SCRIM = 'rgba(10, 14, 24, 0.82)'; -/** - * The ink of the training-notice line above the composer where the theme's own does not clear AA there. On a phone the - * line sits on the dark ridge in every state, but Noonday (day, dusk) wears dark ink, 1.2:1 at dusk by the numbers - * above; Midnight's (night) is already light and stays (undefined). Desktop is unmeasured and stays. - */ -export const noticeInk = ({ state, phone }: SkyInk): string | undefined => - phone && state !== 'night' ? baked.midnight.tokens.SurfaceVariant.OnContainer : undefined; +/** The style of the notice line while a scene runs behind it: its own ground and ink, or undefined without one. */ +export const noticeStyle = (ink: SkyInk | undefined) => + ink + ? { color: NOTICE_INK, backgroundColor: NOTICE_SCRIM, borderRadius: 4, paddingInline: 6 } + : undefined; diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index 2b888f55..305df247 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -4,7 +4,7 @@ import * as css from './styles.css'; export function AuthFooter() { return ( - + About diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 7934bc9f..57a82daa 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -36,6 +36,7 @@ import { import { PasswordInput } from '../../../components/password-input'; import { FieldError } from '../FiledError'; import { loginErrorMessage } from '../authMessages'; +import { ForgotLink } from '../styles.css'; import { getResetPasswordPath } from '../../pathUtils'; import { stopPropagation } from '../../../utils/keyboard'; @@ -251,7 +252,9 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog )} - Forget Password? + + Forget Password? + diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index e762207b..f8a7a95b 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -54,6 +54,17 @@ export const AuthFooter = style({ padding: config.space.S200, }); +// The footer's links: 16 px apart, and on a phone 22 px between rows, so each link's 11 px of hit area above and +// below meets its neighbour's and does not cross it (see the tap-target rules at the foot of this file). +export const AuthFooterLinks = style({ + gap: config.space.S400, + '@media': { [`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: { rowGap: 22 } }, +}); + +// Forget Password? sits 5 px under the password field and 16 px above Login: on a phone its hit area takes the 5 and +// grows 7 px of real layout so the 44 reach neither. +export const ForgotLink = 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 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 @@ -134,6 +145,17 @@ globalStyle(`${scene} ${AuthFooter} a`, { // back out of the layout, so nothing moves and nothing is drawn. An inline link ignores the margin and its padding // never moves a line, which is the same result. const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; +globalStyle(`${AuthCard} a.${ForgotLink}`, { + '@media': { + [SCREEN_PHONE]: { + display: 'inline-block', + paddingTop: 5, + paddingBottom: 23, + marginTop: -5, + marginBottom: -16, + }, + }, +}); globalStyle(`${AuthCard} a`, { '@media': { [SCREEN_PHONE]: { paddingBlock: 14, marginBlock: -14 } }, }); diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index 57a40bfc..e0c986db 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -42,37 +42,55 @@ const rulesFor = (css: string, selector: RegExp, media: string | undefined): Dec const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; const TAP = 44; -// [target, css file, selector, measured height in CSS px on the Starfire phone run (390x844)]. -const targets: [string, string, RegExp, number][] = [ +// [target, css file, selector, measured height in CSS px on the Starfire phone run (390x844), free space measured +// above and below the target before its neighbour's box (taps2-REPORT.md): the hit area may reach out of the layout +// box (negative margin) by no more than that, or it takes taps from its neighbour. Space it needs beyond that is +// real layout growth, which the rules above take from the padding the margin does not give back. +const targets: [string, string, RegExp, number, number, number][] = [ [ 'sender-name button', 'components/message/layout/layout.css.ts', /^button\.layout_Username__/, 23, + 11, + 11, ], [ 'room-topic button', 'features/room/RoomViewHeader.css.ts', /RoomViewHeader_HeaderTopic__\w+$/, 18, + 0, + 26, ], - ['sign-in card links', 'pages/auth/styles.css.ts', /styles_AuthCard__\w+ a$/, 16], - ['sign-in footer links', 'pages/auth/styles.css.ts', /styles_AuthFooter__\w+ a$/, 22], + ['sign-in card links', 'pages/auth/styles.css.ts', /styles_AuthCard__\w+ a$/, 16, 14, 14], + [ + 'forget-password link', + 'pages/auth/styles.css.ts', + /styles_AuthCard__\w+ a\.styles_ForgotLink__\w+$/, + 16, + 5, + 16, + ], + // The footer's rows are 22 px apart on a phone (AuthFooterLinks), 11 px for each side of the gap. + ['sign-in footer links', 'pages/auth/styles.css.ts', /styles_AuthFooter__\w+ a$/, 22, 11, 11], ]; -describe.each(targets)('phone tap target: %s', (_name, file, selector, height) => { +describe.each(targets)('phone tap target: %s', (_name, file, selector, height, above, below) => { it(`grows its hit area to ${TAP} px without moving layout`, async () => { const css = await emitted(file); const [phone] = rulesFor(css, selector, PHONE); expect(phone, 'a phone rule exists').toBeDefined(); - const top = phone['padding-top'] ?? phone['padding-block']; - const bottom = phone['padding-bottom'] ?? phone['padding-block']; - const marginTop = phone['margin-top'] ?? phone['margin-block']; - const marginBottom = phone['margin-bottom'] ?? phone['margin-block']; + const top = phone['padding-top'] ?? phone['padding-block'] ?? 0; + const bottom = phone['padding-bottom'] ?? phone['padding-block'] ?? 0; + const marginTop = phone['margin-top'] ?? phone['margin-block'] ?? 0; + const marginBottom = phone['margin-bottom'] ?? phone['margin-block'] ?? 0; expect(height + top + bottom).toBeGreaterThanOrEqual(TAP); - // the margin takes back exactly what the padding added, so the target does not move or push its neighbours - expect(marginTop).toBe(-top); - expect(marginBottom).toBe(-bottom); + // the hit area reaches out of the layout box by no more than the free space on that side + expect(-marginTop).toBeLessThanOrEqual(above); + expect(-marginBottom).toBeLessThanOrEqual(below); + expect(marginTop).toBeLessThanOrEqual(0); + expect(marginBottom).toBeLessThanOrEqual(0); }); it('leaves every other screen as it was: no padding or margin outside the phone query', async () => { @@ -82,3 +100,17 @@ describe.each(targets)('phone tap target: %s', (_name, file, selector, height) = ); }); }); + +describe('phone navigator rows', () => { + // The links and buttons inside a row are 44 px (EstateNavigator.css.ts); a row shorter than that lets them spill + // onto the next row (the 38 px pitch of taps2-REPORT.md), so the row and the section header are 44 too. + it('are as tall as their tap targets', async () => { + const css = await emitted('components/nav/styles.css.ts'); + const phone = [ + ...css.matchAll(/@media screen and \(max-width: 750px\) \{\n([\s\S]*?\n)\}(?:\n|$)/g), + ] + .map((m) => m[1]) + .join('\n'); + expect(phone.match(/min-height: 2\.75rem/g)?.length).toBeGreaterThanOrEqual(2); + }); +}); From 96954c0fb58a8ece4e240f382819d2d69764d9f0 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 14:28:34 +0000 Subject: [PATCH 042/143] Desk: fold chevron, quiet text 0.9, solid note border, recorded rulings on their own ground Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/call-status/CallControl.tsx | 13 +- src/app/features/call/CallControls.tsx | 10 +- src/app/features/call/Controls.tsx | 2 +- src/app/features/call/PrescreenControls.tsx | 1 - src/app/features/desk/DeskView.css.ts | 31 +- src/app/features/desk/DeskView.test.tsx | 30 +- src/app/features/desk/DeskView.tsx | 7 +- src/app/features/dings/manifest.test.ts | 5 +- src/app/features/lobby/LobbyHero.tsx | 4 +- src/app/features/room/RoomTrainingLine.tsx | 4 +- src/app/features/scene/SkyStateControl.tsx | 13 +- src/app/features/scene/creatures.test.ts | 4 +- src/app/features/scene/creatures.ts | 4 +- src/app/features/scene/cssModules.test.ts | 4 +- src/app/features/scene/ridge/Ridge.test.ts | 8 +- src/app/features/scene/ridge/Ridge.ts | 38 ++- src/app/features/scene/ridge/credits.test.ts | 2 +- .../features/scene/ridge/earthAtmosphere.ts | 24 +- src/app/features/scene/ridge/horizon.test.ts | 5 +- src/app/features/scene/ridge/horizon.ts | 2 +- .../features/scene/ridge/horizonLayer.test.ts | 6 +- .../features/scene/ridge/physicalSky.test.ts | 57 ++-- src/app/features/scene/ridge/physicalSky.ts | 32 +- src/app/features/scene/ridge/rain.test.ts | 7 +- src/app/features/scene/ridge/rain.ts | 12 +- src/app/features/scene/ridge/ridgeBake.ts | 15 +- src/app/features/scene/ridge/ridgeWorld.ts | 9 +- src/app/features/scene/ridge/toneMap.ts | 26 +- src/app/features/scene/ridge/vienna.test.ts | 3 +- src/app/features/scene/ridge/vienna.ts | 3 +- .../features/scene/ridge/viennaHorizon.json | 307 +++++++++++++++++- src/app/features/scene/ridge/weather.ts | 4 +- .../features/scene/ridge/weatherFeed.test.ts | 129 ++++++-- src/app/features/scene/ridge/weatherFeed.ts | 24 +- src/app/features/scene/shaderSource.ts | 4 +- src/app/features/scene/skyForState.test.ts | 8 +- src/app/features/scene/skyInk.test.ts | 18 +- src/app/features/scene/skyInk.ts | 13 +- .../features/scene/terrain/terrainAlbedo.ts | 6 +- src/app/phoneTapTargets.test.ts | 12 +- 40 files changed, 752 insertions(+), 154 deletions(-) diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 1808be92..3cf38b1f 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -14,7 +14,7 @@ type MicrophoneButtonProps = { function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) { const [micState, toggleMic] = useAsyncCallback(onToggle); const loading = micState.status === AsyncStatus.Loading; - + return ( callEmbed.control.toggleMicrophone(), [callEmbed]); + const handleMicrophoneToggle = useCallback( + () => callEmbed.control.toggleMicrophone(), + [callEmbed] + ); const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]); const [hangupState, hangup] = useAsyncCallback( @@ -195,11 +198,7 @@ export function CallControl({ disabled={!callJoined} /> {!compact && } - + {!compact && ( callEmbed.control.toggleMicrophone(), [callEmbed]); + const handleMicrophoneToggle = useCallback( + () => callEmbed.control.toggleMicrophone(), + [callEmbed] + ); const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]); const [hangupState, hangup] = useAsyncCallback( @@ -97,10 +100,7 @@ export function CallControls({ callEmbed }: CallControlsProps) { > - + callEmbed.control.toggleSound()} /> {!compact && } diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx index 6aa94ff5..459904f6 100644 --- a/src/app/features/call/Controls.tsx +++ b/src/app/features/call/Controls.tsx @@ -18,7 +18,7 @@ type MicrophoneButtonProps = { export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { const [micState, toggleMic] = useAsyncCallback(onToggle); const loading = micState.status === AsyncStatus.Loading; - + return ( toggleMicrophone(), [toggleMicrophone]); const handleVideoToggle = useCallback(async () => toggleVideo(), [toggleVideo]); diff --git a/src/app/features/desk/DeskView.css.ts b/src/app/features/desk/DeskView.css.ts index 4c7d08a7..042f0644 100644 --- a/src/app/features/desk/DeskView.css.ts +++ b/src/app/features/desk/DeskView.css.ts @@ -30,13 +30,29 @@ export const Title = style({ export const Fold = style({ all: 'unset', cursor: 'pointer', - display: 'block', width: '100%', minHeight: 44, boxSizing: 'border-box', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: config.space.S200, + // A folded row says it can be opened: a chevron at the end, turned down while the row is open. + selectors: { + '&::after': { + content: '"›"', + flexShrink: 0, + fontSize: '1.5em', + lineHeight: 1, + transition: 'transform 120ms', + }, + '&[aria-expanded=true]::after': { transform: 'rotate(90deg)' }, + }, }); -export const Quiet = style({ opacity: config.opacity.P300, margin: `${config.space.S100} 0` }); +// Quiet lines (age, who it waits on, helper text) were measured at 4.5 over the day sky and 3.9-4.0 at dusk and night +// with the theme's 0.75 (taps2/desk-REPORT.md); 0.9 clears 4.5 in all three. +export const Quiet = style({ opacity: 0.9, margin: `${config.space.S100} 0` }); export const Body = style({ whiteSpace: 'pre-wrap', margin: `${config.space.S200} 0` }); export const Options = style({ margin: 0, padding: 0, listStyle: 'none' }); export const Option = style({ padding: `${config.space.S100} 0` }); @@ -50,8 +66,9 @@ export const Note = style({ font: 'inherit', color: 'inherit', background: 'none', - border: `1px solid ${color.Background.ContainerLine}`, - opacity: config.opacity.Disabled, + // The note is disabled (slice 1). Its border is a UI boundary (3:1 wanted; the faint line under the 0.38 disabled + // opacity measured 2.0), so it is drawn dashed in the full ink and not faded. + border: '1px dashed currentColor', }); /** Sticky beside the rows on wide screens; a small fixed corner image on phones. */ @@ -97,3 +114,9 @@ export const BoothLine = style({ }, }, }); + +/** The recorded rulings sit on the dark ridge in every sky; they carry their own ground (scrimStyle). */ +export const Recorded = style({ + marginTop: config.space.S400, + padding: `${config.space.S200} ${config.space.S300}`, +}); diff --git a/src/app/features/desk/DeskView.test.tsx b/src/app/features/desk/DeskView.test.tsx index 9c60ed53..1bc7aa59 100644 --- a/src/app/features/desk/DeskView.test.tsx +++ b/src/app/features/desk/DeskView.test.tsx @@ -1,14 +1,19 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { MemoryRouter } from 'react-router-dom'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize'; +import { SkyInkContextProvider } from '../../hooks/useTheme'; +import { SCRIM_GROUND, SCRIM_INK } from '../scene/skyInk'; import { DeskEntry } from './DeskEntry'; import { DeskView, SIGN_IN_NOTE } from './DeskView'; import { ForkList } from './forkContract'; import { STATES } from './fixtures'; import { setDeskSource } from './source'; +// useTheme reads the stored settings when it loads. +vi.hoisted(() => vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined })); + const view = ( state: keyof typeof STATES | 'unpublished', size: ScreenSize, @@ -66,6 +71,19 @@ describe('DeskView', () => { expect(html).toContain('Question done (ruling:done-1)'); expect(html).not.toContain('Question shut'); }); + it('gives the recorded rulings their own ground and ink while a scene runs, none without one', () => { + const withScene = renderToStaticMarkup( + + + + + + ); + // renderToStaticMarkup writes the inline style as the browser would read it back + expect(withScene).toContain(SCRIM_GROUND.replace(/, /g, ', ')); + expect(withScene).toContain(`color:${SCRIM_INK}`); + expect(view('settled', ScreenSize.Desktop)).not.toContain('background-color'); + }); it('shows each outcome quietly, marks the counted answer, and renders a note only when sent', () => { const html = view('answered', ScreenSize.Desktop); expect(html).toContain( @@ -145,7 +163,15 @@ describe("a question's age", () => { expect(html('2026-11-11T11:40:00Z')).toContain('raised just now'); }); it('shows no age when the card carries none, or an instant that does not parse or is ahead of the clock', () => { - ['null', 'yesterday', '2027-01-01T00:00:00Z', '2026-11-06', 'Nov 6 2026', '0', '2026-02-30T00:00:00Z'].forEach((v) => { + [ + 'null', + 'yesterday', + '2027-01-01T00:00:00Z', + '2026-11-06', + 'Nov 6 2026', + '0', + '2026-02-30T00:00:00Z', + ].forEach((v) => { expect(html(v === 'null' ? null : v)).not.toContain('raised '); }); }); diff --git a/src/app/features/desk/DeskView.tsx b/src/app/features/desk/DeskView.tsx index f83dcd82..64727799 100644 --- a/src/app/features/desk/DeskView.tsx +++ b/src/app/features/desk/DeskView.tsx @@ -4,6 +4,8 @@ import { PoseAvatar } from '../../components/message/pose/PoseAvatar'; import { MINDS } from '../../components/message/pose/poses'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk'; +import { useSkyInk } from '../../hooks/useTheme'; +import { scrimStyle } from '../scene/skyInk'; import { ForkList } from './forkContract'; import { deskMood } from './deskMood'; import { raisedAge } from './age'; @@ -144,6 +146,7 @@ type DeskViewProps = { /** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */ export function DeskView({ mind, label, list, unverified, now = Date.now() }: DeskViewProps) { const desk = buildDesk(list, unverified); + const skyInk = useSkyInk(); // Slice 1 has no session, so nothing on the desk is the viewer's answer yet. const mood = deskMood(desk.counts.open, 0); return ( @@ -166,7 +169,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De )}
        {desk.recorded.length > 0 && ( - <> +

        Recorded as rulings

          {desk.recorded.map((r) => ( @@ -175,7 +178,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De ))}
        - +
        )} ); diff --git a/src/app/features/dings/manifest.test.ts b/src/app/features/dings/manifest.test.ts index 0001e685..722481b1 100644 --- a/src/app/features/dings/manifest.test.ts +++ b/src/app/features/dings/manifest.test.ts @@ -109,7 +109,10 @@ describe('v4 manifest against the parser', () => { 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); + 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)); }); diff --git a/src/app/features/lobby/LobbyHero.tsx b/src/app/features/lobby/LobbyHero.tsx index 1cd0b8e2..22774476 100644 --- a/src/app/features/lobby/LobbyHero.tsx +++ b/src/app/features/lobby/LobbyHero.tsx @@ -22,7 +22,9 @@ export function LobbyHero() { const name = useRoomName(space); const topic = useRoomTopic(space); const avatarMxc = useRoomAvatar(space); - const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; + const avatarUrl = avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined + : undefined; return ( {line} diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx index 30efaa1c..a4313a8f 100644 --- a/src/app/features/scene/SkyStateControl.tsx +++ b/src/app/features/scene/SkyStateControl.tsx @@ -15,7 +15,12 @@ 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 { HORIZON_CREDIT, WEATHER_CREDIT, WEATHER_CREDIT_URL, WEATHER_DATASET } from './ridge/credits'; +import { + HORIZON_CREDIT, + WEATHER_CREDIT, + WEATHER_CREDIT_URL, + WEATHER_DATASET, +} from './ridge/credits'; import { useClientConfig } from '../../hooks/useClientConfig'; import { getSceneStyleOverride, @@ -118,7 +123,11 @@ export function SkyStateControl() { )} {backgroundScene && sceneStyle === 'ridge' && ( - + Data diff --git a/src/app/features/scene/creatures.test.ts b/src/app/features/scene/creatures.test.ts index 938c217f..00dd48ef 100644 --- a/src/app/features/scene/creatures.test.ts +++ b/src/app/features/scene/creatures.test.ts @@ -12,7 +12,9 @@ describe('creature scale', () => { it('draws nothing for a neutral creature, whatever sizeBucket it was sent', () => { expect(creatureScale({ neutral: true })).toBeNull(); - buckets.forEach((sizeBucket) => expect(creatureScale({ sizeBucket, neutral: true })).toBeNull()); + buckets.forEach((sizeBucket) => + expect(creatureScale({ sizeBucket, neutral: true })).toBeNull() + ); }); it('does not crash or default to medium when a non-neutral creature has no sizeBucket', () => { diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index a0b2bd29..d88ce4b5 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -33,5 +33,7 @@ export const BUCKET_SCALE: Readonly> = { /** The draw scale, or null when nothing is drawn: a neutral creature, or a bucket that is absent or unknown. */ export function creatureScale(c: Pick): number | null { if (c.neutral || c.sizeBucket === undefined) return null; - return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; + return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) + ? BUCKET_SCALE[c.sizeBucket] + : null; } diff --git a/src/app/features/scene/cssModules.test.ts b/src/app/features/scene/cssModules.test.ts index 27fd4d4d..7205b822 100644 --- a/src/app/features/scene/cssModules.test.ts +++ b/src/app/features/scene/cssModules.test.ts @@ -13,6 +13,8 @@ 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'); + await expect( + processVanillaFile({ source, filePath, identOption: 'debug' }) + ).resolves.toBeTypeOf('string'); }); }); diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts index 0234e558..cb24f04b 100644 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -818,8 +818,12 @@ describe('endless drift: chunks baked on demand and recycled', () => { }); describe('rain in the scene', () => { - const rainy = (w: number): SkyColours => ({ ...DAY, physical: makePhysicalSky('day', new Date('2026-06-21T10:40:00Z'), w) }); - const rainEls = (parent: any) => parent.children.filter((c: any) => c.tag === 'div' && c !== rigOf(parent)); + const rainy = (w: number): SkyColours => ({ + ...DAY, + physical: makePhysicalSky('day', new Date('2026-06-21T10:40:00Z'), w), + }); + const rainEls = (parent: any) => + parent.children.filter((c: any) => c.tag === 'div' && c !== rigOf(parent)); it('lays one falling tile per rain layer over the ridges when it rains, each slid by its own height on the compositor', () => { const { parent, canvas } = host(); diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index 8590a833..b0320b1b 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -6,7 +6,15 @@ import { skyColours, SkyColours, Srgb } from './skyColour'; import { paintSky as paintPhysicalSky } from './physicalSky'; import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars'; import { bakeSlice, BakeParams, layerPaint } from './ridgeBake'; -import { LAYERS, LayerSpec, edgeBlur, HORIZON_TRACK_SHIFT, layerSize, scatterAlpha, transmittance } from './ridgeWorld'; +import { + LAYERS, + LayerSpec, + edgeBlur, + HORIZON_TRACK_SHIFT, + layerSize, + scatterAlpha, + transmittance, +} from './ridgeWorld'; import { hfovDeg } from './horizon'; import { viewFor, VFOV_DEG } from './skyDome'; import { drawRainTile, RAIN_TILES } from './rain'; @@ -471,7 +479,13 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { * The rain over everything: per tile, a div of the tile's texture (drawn once) taller than the viewport by one tile, slid down one * tile height per loop on the compositor. 'still' draws none (a settled still has no animation to carry it). */ - const startRain = (rain: number, colour: Srgb, w: number, h: number, scale: number): NonNullable => { + const startRain = ( + rain: number, + colour: Srgb, + w: number, + h: number, + scale: number + ): NonNullable => { const els: HTMLElement[] = []; const anims: (Animation | undefined)[] = []; RAIN_TILES.forEach((t, k) => { @@ -498,11 +512,14 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { parent.appendChild(el); els.push(el); anims.push( - el.animate?.([{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(0,${px}px,0)` }], { - duration: t.seconds * 1000, - iterations: Infinity, - easing: 'linear', - }) + el.animate?.( + [{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(0,${px}px,0)` }], + { + duration: t.seconds * 1000, + iterations: Infinity, + easing: 'linear', + } + ) ); }); return { els, anims }; @@ -606,7 +623,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { parent.appendChild(rig); layers.forEach((l) => startDrift(l, rotate)); built.sway = startSway(rig, w, h); - if (tier !== 'still' && sky.physical && sky.physical.weather.rain > 0) built.rain = startRain(sky.physical.weather.rain, sky.horizon, w, h, scale); + if (tier !== 'still' && sky.physical && sky.physical.weather.rain > 0) + built.rain = startRain(sky.physical.weather.rain, sky.horizon, w, h, scale); pump(); syncPlayState(); return true; @@ -640,7 +658,9 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { }; function syncPlayState() { - const anims = built ? [built.sway, ...built.layers.map((l) => l.anim), ...(built.rain?.anims ?? [])] : []; + const anims = built + ? [built.sway, ...built.layers.map((l) => l.anim), ...(built.rain?.anims ?? [])] + : []; anims.forEach((a) => (moving() ? a?.play() : a?.pause())); if (!built?.twinkle) return; if (moving()) { diff --git a/src/app/features/scene/ridge/credits.test.ts b/src/app/features/scene/ridge/credits.test.ts index 633a1b33..887cc2d4 100644 --- a/src/app/features/scene/ridge/credits.test.ts +++ b/src/app/features/scene/ridge/credits.test.ts @@ -4,7 +4,7 @@ import { HORIZON_ATTRIBUTION } from './horizon'; import { WEATHER_ATTRIBUTION } from './weatherFeed'; describe('the credits the Sky menu shows', () => { - it('are the sources\' own', () => { + it("are the sources' own", () => { expect(HORIZON_CREDIT).toBe(HORIZON_ATTRIBUTION); expect(WEATHER_CREDIT).toBe(WEATHER_ATTRIBUTION); }); diff --git a/src/app/features/scene/ridge/earthAtmosphere.ts b/src/app/features/scene/ridge/earthAtmosphere.ts index f5350244..cadddbc5 100644 --- a/src/app/features/scene/ridge/earthAtmosphere.ts +++ b/src/app/features/scene/ridge/earthAtmosphere.ts @@ -362,7 +362,8 @@ function bakeMultiScatter(mieScale: number, trLut: Lut2): Lut2 { const sx = Math.sqrt(Math.max(0, 1 - cosSun * cosSun)); const sz = cosSun; // Hillaire's PLANET_RADIUS_OFFSET: a ray that starts exactly on the ground is degenerate. - const height = PLANET_KM + RADIUS_OFFSET_KM + clamp(v, 0, 1) * (TOP_KM - PLANET_KM - RADIUS_OFFSET_KM); + const height = + PLANET_KM + RADIUS_OFFSET_KM + clamp(v, 0, 1) * (TOP_KM - PLANET_KM - RADIUS_OFFSET_KM); const sumMs = [0, 0, 0]; const sumL = [0, 0, 0]; for (let a = 0; a < dirs; a++) { @@ -410,7 +411,11 @@ function bakeMultiScatter(mieScale: number, trLut: Lut2): Lut2 { * Sky-view LUT axes (Hillaire's non-linear mapping, upper hemisphere only: nothing below the horizon is drawn). y: view zenith angle * from 0 to the horizon's, squared toward it; x: the azimuth from the sun, the cosine mapped `-(2 c^2 - 1)`. */ -function skyViewParamsToUv(viewCos: number, cosFromSun: number, horizonZenith: number): [number, number] { +function skyViewParamsToUv( + viewCos: number, + cosFromSun: number, + horizonZenith: number +): [number, number] { const zen = Math.acos(clamp(viewCos, -1, 1)); let coord = 1 - clamp(zen / horizonZenith, 0, 1); coord = Math.sqrt(coord); @@ -494,7 +499,14 @@ export function bakeAtmosphere(opts: AtmosphereOptions): Atmosphere { const horizonZenith = PI - beta; const transmittance = bakeTransmittance(mieScale); const multiScatter = bakeMultiScatter(mieScale, transmittance); - const skyView = bakeSkyView(sunCos, observerKm, mieScale, transmittance, multiScatter, horizonZenith); + const skyView = bakeSkyView( + sunCos, + observerKm, + mieScale, + transmittance, + multiScatter, + horizonZenith + ); const tmp = [0, 0, 0]; const trv = [0, 0, 0]; const atm: Atmosphere = { @@ -514,7 +526,11 @@ export function bakeAtmosphere(opts: AtmosphereOptions): Atmosphere { out[2] = trv[2]; }, radiance(viewCos, cosFromSun, out) { - const [x, y] = skyViewParamsToUv(Math.max(viewCos, Math.cos(horizonZenith)), cosFromSun, horizonZenith); + const [x, y] = skyViewParamsToUv( + Math.max(viewCos, Math.cos(horizonZenith)), + cosFromSun, + horizonZenith + ); sampleTexel(skyView, x * (skyView.w - 1), y * (skyView.h - 1), tmp); out[0] = tmp[0]; out[1] = tmp[1]; diff --git a/src/app/features/scene/ridge/horizon.test.ts b/src/app/features/scene/ridge/horizon.test.ts index 092d5835..0061bab1 100644 --- a/src/app/features/scene/ridge/horizon.test.ts +++ b/src/app/features/scene/ridge/horizon.test.ts @@ -21,7 +21,10 @@ describe('the baked horizon', () => { expect(horizonDistanceKm(305)).toBeGreaterThan(1); }); it('interpolates between samples and returns unit normals', () => { - expect(horizonElevation(100.25)).toBeCloseTo((bake.elevationDeg[200] + bake.elevationDeg[201]) / 2, 6); + expect(horizonElevation(100.25)).toBeCloseTo( + (bake.elevationDeg[200] + bake.elevationDeg[201]) / 2, + 6 + ); expect(Math.hypot(...horizonNormal(250))).toBeCloseTo(1, 6); }); }); diff --git a/src/app/features/scene/ridge/horizon.ts b/src/app/features/scene/ridge/horizon.ts index 82265a77..89d896bc 100644 --- a/src/app/features/scene/ridge/horizon.ts +++ b/src/app/features/scene/ridge/horizon.ts @@ -8,7 +8,7 @@ const N = bake.elevationDeg.length; /** Index pair and weight of an azimuth (degrees clockwise from north) on the baked ring. */ function at(azimuth: number): [number, number, number] { - const f = ((((azimuth / bake.stepDeg) % N) + N) % N); + const f = (((azimuth / bake.stepDeg) % N) + N) % N; const i = Math.floor(f); return [i, (i + 1) % N, f - i]; } diff --git a/src/app/features/scene/ridge/horizonLayer.test.ts b/src/app/features/scene/ridge/horizonLayer.test.ts index 8ed27e4c..b034d03c 100644 --- a/src/app/features/scene/ridge/horizonLayer.test.ts +++ b/src/app/features/scene/ridge/horizonLayer.test.ts @@ -25,7 +25,9 @@ describe('the horizon layer', () => { }); it('stays in 0..1 and covers a real range of shape, not a line', () => { - const v = Array.from({ length: 200 }, (_, i) => layerProfile(far, -0.06 + (i / 200) * 1.2, { azimuthDeg: 305, hfovDeg: view.hfovDeg })); + const v = Array.from({ length: 200 }, (_, i) => + layerProfile(far, -0.06 + (i / 200) * 1.2, { azimuthDeg: 305, hfovDeg: view.hfovDeg }) + ); expect(Math.min(...v)).toBeGreaterThanOrEqual(0); expect(Math.max(...v)).toBeLessThanOrEqual(1); expect(Math.max(...v) - Math.min(...v)).toBeGreaterThan(0.2); @@ -43,7 +45,7 @@ describe('the horizon layer', () => { }); describe('weather in the layers', () => { - it('mist multiplies the layers\' own, and clear air leaves it alone', () => { + it("mist multiplies the layers' own, and clear air leaves it alone", () => { const base = skyColours(); const day = new Date('2026-06-21T10:40:00Z'); const clear = layerPaint(LAYERS[1], { ...base, physical: makePhysicalSky('day', day, 0) }); diff --git a/src/app/features/scene/ridge/physicalSky.test.ts b/src/app/features/scene/ridge/physicalSky.test.ts index bb8c75a2..be12945e 100644 --- a/src/app/features/scene/ridge/physicalSky.test.ts +++ b/src/app/features/scene/ridge/physicalSky.test.ts @@ -10,7 +10,8 @@ const lin = (v: number): number => { const c = v / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; -const luma = (d: Uint8ClampedArray, i: number): number => 0.2126 * lin(d[i]) + 0.7152 * lin(d[i + 1]) + 0.0722 * lin(d[i + 2]); +const luma = (d: Uint8ClampedArray, i: number): number => + 0.2126 * lin(d[i]) + 0.7152 * lin(d[i + 1]) + 0.0722 * lin(d[i + 2]); const DAYS = ['2026-03-20', '2026-06-21', '2026-09-23', '2026-12-21']; const WEATHER = [0, 0.3, 0.55, 0.75, 1]; @@ -21,28 +22,31 @@ const VIEWPORTS: [number, number][] = [ [390, 844], ]; -describe.each(['day', 'dusk', 'night'] as FixedSkyState[])('the %s sky stays inside its backdrop bounds', (state) => { - it('over weather, season and viewport shape', () => { - const b = skyBackdrop(state); - let lo = 2; - let hi = -1; - for (const day of DAYS) { - for (const w of WEATHER) { - const sky = makePhysicalSky(state, new Date(`${day}T10:40:00Z`), w); - for (const [vw, vh] of VIEWPORTS) { - const px = paintSky(sky, vw, vh); - for (let i = 0; i < px.w * px.h * 4; i += 4) { - const l = luma(px.data, i); - lo = Math.min(lo, l); - hi = Math.max(hi, l); +describe.each(['day', 'dusk', 'night'] as FixedSkyState[])( + 'the %s sky stays inside its backdrop bounds', + (state) => { + it('over weather, season and viewport shape', () => { + const b = skyBackdrop(state); + let lo = 2; + let hi = -1; + for (const day of DAYS) { + for (const w of WEATHER) { + const sky = makePhysicalSky(state, new Date(`${day}T10:40:00Z`), w); + for (const [vw, vh] of VIEWPORTS) { + const px = paintSky(sky, vw, vh); + for (let i = 0; i < px.w * px.h * 4; i += 4) { + const l = luma(px.data, i); + lo = Math.min(lo, l); + hi = Math.max(hi, l); + } } } } - } - expect(lo).toBeGreaterThanOrEqual(b.darkest); - expect(hi).toBeLessThanOrEqual(b.lightest); - }); -}); + expect(lo).toBeGreaterThanOrEqual(b.darkest); + expect(hi).toBeLessThanOrEqual(b.lightest); + }); + } +); describe('weather in the sky', () => { it('overcast is cloud: less blue and less contrast than clear air at the same sun', () => { @@ -70,14 +74,21 @@ describe('weather in the sky', () => { describe('the sun disc', () => { it('keeps the display floor the dark link was fitted to, at every hour it is drawn', () => { - const lin = (c: number) => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4); + const lin = (c: number) => + c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4; let drawn = 0; for (const state of ['day', 'dusk'] as FixedSkyState[]) { for (let m = 0; m < 24 * 60; m += 10) { - const d = paintSky(makePhysicalSky(state, new Date(Date.UTC(2026, 10, 11, 0, m)), 0), 1280, 720).sunDisc; + const d = paintSky( + makePhysicalSky(state, new Date(Date.UTC(2026, 10, 11, 0, m)), 0), + 1280, + 720 + ).sunDisc; if (!d) continue; drawn += 1; - expect(0.2126 * lin(d.rgb[0]) + 0.7152 * lin(d.rgb[1]) + 0.0722 * lin(d.rgb[2])).toBeGreaterThanOrEqual(0.4744); + expect( + 0.2126 * lin(d.rgb[0]) + 0.7152 * lin(d.rgb[1]) + 0.0722 * lin(d.rgb[2]) + ).toBeGreaterThanOrEqual(0.4744); } } expect(drawn).toBeGreaterThan(0); diff --git a/src/app/features/scene/ridge/physicalSky.ts b/src/app/features/scene/ridge/physicalSky.ts index 48a10a16..0e034416 100644 --- a/src/app/features/scene/ridge/physicalSky.ts +++ b/src/app/features/scene/ridge/physicalSky.ts @@ -4,7 +4,15 @@ import type { FixedSkyState } from '../skyBand'; import { bakeAtmosphere } from './earthAtmosphere'; import type { Atmosphere } from './earthAtmosphere'; -import { domeSize, HORIZON_AT, renderDome, SUN_RADIUS, sunOnScreen, viewFor, VFOV_DEG } from './skyDome'; +import { + domeSize, + HORIZON_AT, + renderDome, + SUN_RADIUS, + sunOnScreen, + viewFor, + VFOV_DEG, +} from './skyDome'; import type { Dome, DomeView } from './skyDome'; import type { Srgb } from './skyColour'; import { display8, fitDisplay, holdBudget, MARGIN } from './toneMap'; @@ -45,7 +53,11 @@ export function rowMean(dome: Dome, display: Display, row: number): Srgb { return [Math.round(acc[0] / dome.w), Math.round(acc[1] / dome.w), Math.round(acc[2] / dome.w)]; } -export function makePhysicalSky(state: FixedSkyState, now: Date, weatherValue: number): PhysicalSky { +export function makePhysicalSky( + state: FixedSkyState, + now: Date, + weatherValue: number +): PhysicalSky { const t0 = clock(); const sun = sunForState(state, now); const weather = weatherOf(weatherValue); @@ -117,8 +129,16 @@ function sunDisc(p: PhysicalSky, view: DomeView): SkyPixels['sunDisc'] { const top = Math.max(...trans, 1e-9); const rgb = trans.map((t) => Math.round(255 * (t / top) ** 0.6)); // The disc sits on the sky, so it keeps the display floor the dark link was fitted to: mix toward white until it does. - const lin = (c: number): number => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4); - const luma = (c: number[]): number => 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]); - for (let m = 0; luma(rgb) < DISC_FLOOR && m < 40; m += 1) for (let i = 0; i < 3; i += 1) rgb[i] = Math.round(rgb[i] + (255 - rgb[i]) * 0.1); - return { ...at, rgb: rgb as unknown as Srgb, radius: (SUN_RADIUS * 180) / Math.PI / VFOV_DEG, alpha }; + const lin = (c: number): number => + c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4; + const luma = (c: number[]): number => + 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]); + for (let m = 0; luma(rgb) < DISC_FLOOR && m < 40; m += 1) + for (let i = 0; i < 3; i += 1) rgb[i] = Math.round(rgb[i] + (255 - rgb[i]) * 0.1); + return { + ...at, + rgb: rgb as unknown as Srgb, + radius: (SUN_RADIUS * 180) / Math.PI / VFOV_DEG, + alpha, + }; } diff --git a/src/app/features/scene/ridge/rain.test.ts b/src/app/features/scene/ridge/rain.test.ts index 6575ea20..80ce2289 100644 --- a/src/app/features/scene/ridge/rain.test.ts +++ b/src/app/features/scene/ridge/rain.test.ts @@ -4,7 +4,8 @@ import { drawRainTile, RAIN_TILES, streakCount } from './rain'; const recorder = () => { const calls: string[] = []; const ctx = new Proxy({} as Record, { - get: (t, k: string) => (k in t ? t[k] : (...a: unknown[]) => calls.push(`${k}(${a.join(',')})`)), + get: (t, k: string) => + k in t ? t[k] : (...a: unknown[]) => calls.push(`${k}(${a.join(',')})`), set: (t, k: string, v) => { t[k] = v; return true; @@ -27,6 +28,8 @@ describe('rain', () => { drawRainTile(a.ctx, RAIN_TILES[1], 1, [200, 210, 220], 1); drawRainTile(b.ctx, RAIN_TILES[1], 1, [200, 210, 220], 1); expect(a.calls).toEqual(b.calls); - expect(a.calls.filter((c) => c.startsWith('stroke'))).toHaveLength(streakCount(RAIN_TILES[1], 1) * 9); + expect(a.calls.filter((c) => c.startsWith('stroke'))).toHaveLength( + streakCount(RAIN_TILES[1], 1) * 9 + ); }); }); diff --git a/src/app/features/scene/ridge/rain.ts b/src/app/features/scene/ridge/rain.ts index 699130cf..2644ed96 100644 --- a/src/app/features/scene/ridge/rain.ts +++ b/src/app/features/scene/ridge/rain.ts @@ -27,10 +27,18 @@ export const RAIN_TILES: readonly RainTile[] = [ export const RAIN_SLANT = 0.18; /** Streaks to draw at a rain amount 0..1 (none at 0). */ -export const streakCount = (t: RainTile, rain: number): number => (rain <= 0 ? 0 : Math.max(4, Math.round(t.streaks * rain))); +export const streakCount = (t: RainTile, rain: number): number => + rain <= 0 ? 0 : Math.max(4, Math.round(t.streaks * rain)); /** Draw one tile. The streaks wrap across the tile's edges so the tile repeats without a seam. */ -export function drawRainTile(ctx: CanvasRenderingContext2D, t: RainTile, rain: number, colour: Srgb, scale: number, seed = 0): void { +export function drawRainTile( + ctx: CanvasRenderingContext2D, + t: RainTile, + rain: number, + colour: Srgb, + scale: number, + seed = 0 +): void { const n = streakCount(t, rain); ctx.clearRect(0, 0, t.size, t.size); ctx.lineCap = 'round'; diff --git a/src/app/features/scene/ridge/ridgeBake.ts b/src/app/features/scene/ridge/ridgeBake.ts index 72736d85..063c4e3e 100644 --- a/src/app/features/scene/ridge/ridgeBake.ts +++ b/src/app/features/scene/ridge/ridgeBake.ts @@ -3,7 +3,15 @@ // slope of the heightfield's crest line against the sun; the face under it takes a light-to-haze // gradient, erosion streaks and a lit rim on the crest. The depth treatment (colour, haze, contrast // by distance) is ridgeWorld's `layerColour`, `hazeColour` and `transmittance`, used here and not redone. -import { gullyAt, HorizonView, LayerSpec, layerProfile, hazeColour, layerColour, transmittance } from './ridgeWorld'; +import { + gullyAt, + HorizonView, + LayerSpec, + layerProfile, + hazeColour, + layerColour, + transmittance, +} from './ridgeWorld'; import type { SkyBand } from '../skyBand'; import { LUMA, SKY_BAND } from '../skyBand'; import type { SkyColours, Srgb } from './skyColour'; @@ -137,7 +145,10 @@ function crestRows(p: BakeParams, c0: number, c1: number): Float64Array { const amp = p.spec.relief * p.viewH; const lift = p.spec.base * p.viewH; for (let c = c0 - 1; c <= c1; c += 1) { - const top = Math.min(p.bandH, lift + amp * layerProfile(p.spec, (p.px0 + c) / p.period, p.horizonView)); + const top = Math.min( + p.bandH, + lift + amp * layerProfile(p.spec, (p.px0 + c) / p.period, p.horizonView) + ); out[c - c0 + 1] = p.bandH - top; } return out; diff --git a/src/app/features/scene/ridge/ridgeWorld.ts b/src/app/features/scene/ridge/ridgeWorld.ts index 6bd8e3b1..c6c745c3 100644 --- a/src/app/features/scene/ridge/ridgeWorld.ts +++ b/src/app/features/scene/ridge/ridgeWorld.ts @@ -47,7 +47,6 @@ export interface HorizonView { /** The static horizon layer's track sits this many viewport widths left of the screen, so the camera's sway never shows its edge. */ export const HORIZON_TRACK_SHIFT = 0.06; - /** Four layers: one function, four parameter rows. */ export const LAYERS: readonly LayerSpec[] = [ { @@ -225,7 +224,13 @@ export function layerSize(cssW: number, cssH: number, scale: number) { } /** Ridge-top y (canvas pixels, from the top of the layer's band) at world-x `t` (viewport widths). */ -export function ridgeY(spec: LayerSpec, t: number, bandH: number, viewH: number, view?: HorizonView): number { +export function ridgeY( + spec: LayerSpec, + t: number, + bandH: number, + viewH: number, + view?: HorizonView +): number { const topFromBottom = spec.base * viewH + spec.relief * viewH * layerProfile(spec, t, view); return bandH - Math.min(bandH, topFromBottom); } diff --git a/src/app/features/scene/ridge/toneMap.ts b/src/app/features/scene/ridge/toneMap.ts index b91a4d6d..44bc9dbe 100644 --- a/src/app/features/scene/ridge/toneMap.ts +++ b/src/app/features/scene/ridge/toneMap.ts @@ -20,7 +20,8 @@ const OUT: readonly Rgb[] = [ [-0.10208, 1.10813, -0.00605], [-0.00327, -0.07276, 1.07602], ]; -const fit = (v: number): number => (v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081); +const fit = (v: number): number => + (v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081); const encode = (l: number): number => { const c = Math.min(1, Math.max(0, l)); return 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); @@ -32,7 +33,8 @@ export function acesLinear(r: number, g: number, b: number, exposure: number, ou const y = g * exposure; const z = b * exposure; const p = [0, 1, 2].map((i) => fit(Math.max(0, IN[i][0] * x + IN[i][1] * y + IN[i][2] * z))); - for (let i = 0; i < 3; i += 1) out[i] = Math.min(1, Math.max(0, OUT[i][0] * p[0] + OUT[i][1] * p[1] + OUT[i][2] * p[2])); + for (let i = 0; i < 3; i += 1) + out[i] = Math.min(1, Math.max(0, OUT[i][0] * p[0] + OUT[i][1] * p[1] + OUT[i][2] * p[2])); } /** The 8-bit sRGB of linear radiance, exposed and tone-mapped. */ @@ -44,7 +46,8 @@ export function toneMap8(r: number, g: number, b: number, exposure: number, out: } /** WCAG relative luminance of linear display RGB. */ -export const displayLuma = (c: ArrayLike): number => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +export const displayLuma = (c: ArrayLike): number => + 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; /** What a sky state needs of its backdrop's display luminance, from the ink it wears (skyBand.ts, skyInk.ts). */ export interface Budget { @@ -90,7 +93,13 @@ const decode = (v: number): number => { return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; /** Display luminance (WCAG relative, from the encoded bytes) of a radiance through a display. */ -export function displayLumaOf(r: number, g: number, b: number, d: Display, tmp: number[] = [0, 0, 0]): number { +export function displayLumaOf( + r: number, + g: number, + b: number, + d: Display, + tmp: number[] = [0, 0, 0] +): number { display8(r, g, b, d, tmp); return displayLuma([decode(tmp[0]), decode(tmp[1]), decode(tmp[2])]); } @@ -113,7 +122,9 @@ export interface Fit { export function fitDisplay(state: FixedSkyState, hdr: Float32Array, meanLuma: number): Fit { const b = BUDGET[state]; const keyExposure = - b.key === undefined ? b.min : Math.min(b.max, Math.max(b.min, (b.key * 1.6) / Math.max(meanLuma, 1e-9))); + b.key === undefined + ? b.min + : Math.min(b.max, Math.max(b.min, (b.key * 1.6) / Math.max(meanLuma, 1e-9))); const n = hdr.length / 3; const stride = Math.max(1, Math.floor(n / 4000)); const tmp = [0, 0, 0]; @@ -187,7 +198,10 @@ export function holdBudget(state: FixedSkyState, hdr: Float32Array, d: Display): r = range(cur); } for (let k = 0; k < 8 && r.lo < b.floor; k += 1) { - cur = { exposure: cur.exposure, lift: cur.lift + ((b.floor - r.lo) / (1 - r.lo)) * 1.05 + 1e-4 }; + cur = { + exposure: cur.exposure, + lift: cur.lift + ((b.floor - r.lo) / (1 - r.lo)) * 1.05 + 1e-4, + }; r = range(cur); } return cur; diff --git a/src/app/features/scene/ridge/vienna.test.ts b/src/app/features/scene/ridge/vienna.test.ts index e0d4a4c7..4f26303c 100644 --- a/src/app/features/scene/ridge/vienna.test.ts +++ b/src/app/features/scene/ridge/vienna.test.ts @@ -31,7 +31,8 @@ describe('the sun over Vienna', () => { it('has a short day in December and a long one in June: published 8 h 4 min and 16 h 0 min with refraction, which adds about 6 minutes each end the ephemeris leaves out', () => { const daylight = (day: string) => { let n = 0; - for (let m = 0; m < 24 * 60; m += 1) if (viennaSun(new Date(Date.parse(day) + m * 60_000)).elevation > 0) n += 1; + for (let m = 0; m < 24 * 60; m += 1) + if (viennaSun(new Date(Date.parse(day) + m * 60_000)).elevation > 0) n += 1; return n; }; expect(Math.abs(daylight('2026-12-21T00:00:00Z') - (8 * 60 + 4))).toBeLessThan(15); diff --git a/src/app/features/scene/ridge/vienna.ts b/src/app/features/scene/ridge/vienna.ts index 56188226..f21082e5 100644 --- a/src/app/features/scene/ridge/vienna.ts +++ b/src/app/features/scene/ridge/vienna.ts @@ -33,7 +33,8 @@ export const STATE_ELEVATION: Record = night: [-90, -DAY_FROM], }; -const startOfUtcDay = (t: Date): number => Math.floor(t.getTime() / (24 * 60 * MIN)) * 24 * 60 * MIN; +const startOfUtcDay = (t: Date): number => + Math.floor(t.getTime() / (24 * 60 * MIN)) * 24 * 60 * MIN; /** The moment of the day (UTC day of `t`, every 5 minutes) the sun is highest over Vienna: solar noon to the step. */ export function solarNoon(t: Date): Date { diff --git a/src/app/features/scene/ridge/viennaHorizon.json b/src/app/features/scene/ridge/viennaHorizon.json index 62a4a126..adbc289a 100644 --- a/src/app/features/scene/ridge/viennaHorizon.json +++ b/src/app/features/scene/ridge/viennaHorizon.json @@ -1 +1,306 @@ -{"viewpoint":{"lat":48.1929826,"lon":16.3534237,"groundM":183.3},"stepDeg":0.5,"radiusKm":60.0,"minKm":1.0,"curvature":"drop d^2/2R*(1-0.13), R=6371000 m","source":{"dataset":"Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid","tiles":["N47_00_E015_00","N47_00_E016_00","N47_00_E017_00","N48_00_E015_00","N48_00_E016_00","N48_00_E017_00","N49_00_E015_00","N49_00_E016_00","N49_00_E017_00"],"tileBytesEach":"about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed","url":"https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)","licence":"Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty (https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)","attribution":"Contains modified Copernicus data: DEM \u00a9 DLR e.V. 2010-2014 and \u00a9 Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.","bakedBy":"scripts/bake-horizon.py"},"elevationDeg":[1.608,1.522,1.467,1.452,1.433,1.426,1.497,1.6,1.597,1.564,1.532,1.495,1.467,1.544,1.62,1.655,1.707,1.775,1.803,1.683,1.532,1.394,1.263,1.297,1.358,1.412,1.433,1.339,1.264,1.269,1.195,1.142,1.13,1.22,1.401,1.368,1.326,1.294,1.378,1.46,1.488,1.516,1.549,1.598,1.647,1.698,1.769,1.816,1.801,1.783,1.695,1.619,1.586,1.547,1.442,1.335,1.282,1.286,1.335,1.421,1.449,1.437,1.313,1.268,1.164,1.14,1.098,1.06,0.984,0.938,0.91,0.877,0.884,0.872,0.943,0.974,0.919,0.873,0.861,0.839,0.731,0.673,0.56,0.494,0.527,0.601,0.566,0.584,0.579,0.643,0.666,0.741,0.851,0.711,0.703,0.573,0.54,0.488,0.433,0.452,0.467,0.483,0.475,0.517,0.419,0.428,0.457,0.489,0.522,0.482,0.5,0.527,0.528,0.471,0.458,0.487,0.521,0.578,0.553,0.419,0.301,0.324,0.425,0.468,0.512,0.558,0.575,0.521,0.477,0.444,0.372,0.297,0.365,0.446,0.486,0.523,0.532,0.516,0.516,0.531,0.52,0.546,0.565,0.535,0.527,0.508,0.516,0.37,0.359,0.33,0.375,0.454,0.546,0.496,0.449,0.425,0.447,0.398,0.388,0.439,0.476,0.496,0.517,0.541,0.494,0.439,0.383,0.411,0.502,0.621,0.578,0.548,0.568,0.642,0.713,0.708,0.654,0.704,0.741,0.709,0.677,0.661,0.66,0.666,0.662,0.651,0.699,0.727,0.746,0.809,0.788,0.799,0.793,0.702,0.734,0.79,0.754,0.704,0.616,0.598,0.708,0.719,0.706,0.704,0.726,0.826,0.849,0.85,0.813,0.853,0.926,0.971,0.918,1.003,1.0,0.927,0.985,1.031,1.043,1.067,1.13,1.022,1.02,1.043,1.101,1.144,1.099,1.082,1.064,1.2,1.238,1.16,1.084,1.202,1.326,1.39,1.429,1.405,1.332,1.358,1.361,1.357,1.354,1.335,1.319,1.376,1.309,1.289,1.257,1.283,1.237,1.296,1.353,1.259,1.314,1.236,1.307,1.344,1.36,1.251,1.292,1.308,1.26,1.288,1.365,1.391,1.414,1.32,1.299,1.35,1.288,1.361,1.303,1.268,1.29,1.305,1.326,1.344,1.328,1.32,1.358,1.375,1.443,1.477,1.492,1.525,1.513,1.422,1.314,1.294,1.337,1.414,1.343,1.397,1.431,1.409,1.378,1.423,1.438,1.343,1.257,1.208,1.209,1.233,1.251,1.267,1.274,1.248,1.254,1.297,1.34,1.359,1.34,1.199,1.162,1.255,1.292,1.29,1.24,1.222,1.24,1.271,1.264,1.317,1.256,1.26,1.321,1.301,1.266,1.268,1.308,1.357,1.474,1.419,1.438,1.393,1.354,1.343,1.354,1.333,1.321,1.359,1.437,1.47,1.395,1.413,1.446,1.466,1.434,1.446,1.458,1.419,1.522,1.502,1.506,1.512,1.528,1.526,1.485,1.427,1.508,1.5,1.48,1.534,1.514,1.449,1.449,1.386,1.368,1.388,1.404,1.374,1.374,1.352,1.3,1.347,1.332,1.265,1.235,1.39,1.285,1.226,1.233,1.235,1.211,1.164,1.183,1.217,1.143,1.128,1.117,1.112,1.086,1.071,1.118,1.103,1.002,0.99,0.995,1.025,0.998,1.024,0.971,1.045,1.09,1.11,1.108,1.081,1.171,1.308,1.446,1.482,1.484,1.494,1.469,1.472,1.479,1.493,1.467,1.388,1.293,1.203,1.104,1.145,1.12,1.028,1.004,0.982,0.968,0.933,0.97,0.986,1.035,1.12,1.227,1.281,1.316,1.312,1.273,1.187,1.205,1.197,1.199,1.176,1.177,1.142,1.19,1.377,1.369,1.322,1.295,1.385,1.493,1.576,1.679,1.769,1.622,1.711,1.641,1.517,1.582,1.525,1.39,1.389,1.343,1.331,1.369,1.343,1.257,1.212,1.134,1.039,1.03,1.075,1.027,1.059,1.112,1.132,1.14,1.16,1.167,1.178,1.179,1.192,1.214,1.245,1.289,1.286,1.279,1.259,1.256,1.202,1.138,1.159,1.116,1.12,1.102,0.999,1.042,1.051,1.09,1.143,1.244,1.269,1.296,1.353,1.412,1.579,1.686,1.814,1.857,1.773,1.731,1.748,1.742,1.668,1.594,1.628,1.692,1.76,1.822,1.863,1.904,1.895,1.849,1.786,1.778,1.731,1.666,1.633,1.561,1.526,1.662,1.747,1.75,1.704,1.631,1.563,1.473,1.501,1.488,1.464,1.441,1.386,1.31,1.298,1.293,1.306,1.371,1.42,1.416,1.417,1.418,1.409,1.399,1.408,1.431,1.434,1.437,1.509,1.544,1.58,1.674,1.528,1.534,1.517,1.497,1.525,1.546,1.562,1.58,1.55,1.634,1.614,1.66,1.643,1.663,1.68,1.829,1.99,2.058,2.014,1.911,2.025,2.023,2.036,2.031,2.009,2.012,2.02,2.109,2.107,2.12,2.171,2.179,2.125,2.084,2.09,2.129,2.183,2.16,2.172,2.239,2.237,2.218,2.217,2.137,2.169,2.203,2.233,2.262,2.272,2.21,2.133,1.955,1.909,1.97,2.138,2.187,2.229,2.278,2.191,2.167,2.213,2.327,2.409,2.31,2.242,2.205,2.152,2.154,2.258,2.217,2.126,2.054,1.97,1.942,2.022,1.894,1.886,1.921,1.897,1.936,1.964,1.978,1.961,1.927,1.879,1.904,1.96,1.9,1.922,2.023,2.061,2.115,1.99,1.805,1.736,1.776,1.841,1.863,1.934,1.942,1.962,2.043,2.082,2.082,2.092,2.062,1.957,1.794,1.821,1.913,2.009,2.066,2.105,2.036,1.964,1.984,2.005,2.036,2.072,2.094,2.022,2.153,2.177,2.146,2.125,2.061,2.004,1.955,1.902,1.84,1.767,1.7,1.696,1.718,1.725,1.709,1.745,1.805,1.836,1.939,2.053,2.14,2.139,2.142,2.035,1.902,1.779,1.667,1.564,1.57,1.581,1.628,1.693,1.739,1.738,1.738,1.675],"distanceKm":[1.0,1.0,1.06,1.06,1.06,1.06,1.06,1.03,1.0,1.0,1.0,1.0,1.09,1.09,1.0,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.12,1.12,1.12,1.12,1.12,1.12,1.09,1.09,1.09,1.09,1.0,1.0,1.0,1.0,1.03,1.06,1.06,1.03,1.03,1.03,1.03,1.03,1.03,1.0,1.0,1.0,1.0,1.0,1.03,1.03,1.03,1.03,1.03,1.0,1.0,1.06,1.06,1.06,1.06,1.06,1.12,1.0,1.0,1.24,1.24,1.21,1.21,1.3,1.3,1.27,1.3,1.33,1.33,1.33,1.33,1.27,1.24,1.27,1.27,1.27,1.03,1.03,1.42,1.84,1.84,1.81,1.03,1.72,1.63,1.63,1.63,1.33,1.33,1.06,1.06,1.72,1.72,1.69,1.69,1.69,1.69,1.69,1.81,1.81,1.75,1.51,1.75,1.75,1.75,1.75,1.75,1.72,1.72,1.72,1.72,1.72,1.72,1.72,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.42,1.42,1.24,1.3,1.3,1.3,1.3,1.3,1.3,1.3,1.27,1.03,1.36,1.36,1.36,1.36,1.36,1.45,1.45,1.09,1.12,1.12,1.54,1.57,1.48,1.48,1.48,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.18,1.18,1.21,1.21,1.21,1.12,1.12,1.12,1.12,1.12,1.15,1.15,1.15,1.15,1.18,1.15,1.15,1.24,1.24,1.27,1.27,1.24,1.24,1.24,1.27,1.27,1.27,1.9,1.9,1.93,1.93,1.93,1.99,1.99,1.99,1.99,1.9,2.02,2.02,2.02,2.05,2.02,1.99,1.99,1.99,1.48,1.21,1.21,1.57,1.69,1.69,1.66,1.39,1.39,1.39,1.54,1.6,1.54,1.54,1.54,1.48,1.45,1.21,1.21,1.21,1.27,1.39,1.39,1.36,1.39,1.39,1.39,1.33,1.33,1.33,1.33,1.33,1.48,1.45,1.39,1.39,1.18,1.18,1.18,1.48,1.48,1.27,1.24,1.27,1.36,1.36,1.39,1.33,1.3,1.3,1.3,1.3,1.3,1.3,1.33,1.0,1.42,1.42,1.27,1.27,1.27,1.27,1.03,1.03,1.03,1.03,1.03,1.03,1.03,1.03,1.06,1.06,1.03,1.03,1.03,1.36,1.06,1.06,1.06,1.06,1.09,1.09,1.18,1.21,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.15,1.15,1.36,1.36,1.36,1.36,1.33,1.33,3.13,1.21,1.21,3.34,3.31,2.83,2.65,2.65,1.3,2.29,1.3,1.15,1.15,1.15,2.29,2.68,2.41,2.23,2.23,2.2,1.12,2.23,2.17,2.17,2.17,1.84,2.74,1.87,1.87,2.95,2.2,2.17,2.17,2.35,2.35,2.23,2.23,2.11,2.14,2.08,2.2,2.2,2.2,2.2,2.2,2.2,2.41,2.41,2.38,2.41,2.41,2.44,2.44,2.44,1.99,2.08,2.08,2.08,2.08,2.05,2.05,2.5,2.38,2.47,2.29,2.23,2.17,1.69,1.69,2.38,2.47,2.38,2.17,2.71,2.71,2.2,2.47,2.41,2.41,2.32,2.38,2.38,2.08,2.5,2.11,2.11,1.75,1.75,1.48,1.81,1.81,1.78,1.78,1.87,17.2,17.2,17.26,17.32,17.44,18.1,17.56,17.5,17.38,17.5,17.62,17.68,17.74,17.8,14.86,14.92,14.98,2.98,26.38,26.38,26.38,26.62,26.14,26.02,26.02,26.38,26.26,26.38,26.38,26.26,26.26,26.26,14.98,14.98,14.98,14.44,13.12,13.18,13.66,13.72,13.72,13.84,13.84,13.84,13.72,12.52,12.58,12.58,12.28,12.22,12.28,16.54,14.02,15.04,16.18,16.24,17.08,17.26,17.44,17.5,17.44,17.32,17.44,17.02,13.96,14.14,9.22,9.16,9.1,9.22,9.28,9.28,9.22,9.16,9.16,9.34,8.68,8.68,8.62,8.74,8.86,8.92,8.86,8.92,8.92,11.74,14.08,11.98,12.22,12.04,34.3,10.36,10.06,9.64,9.58,9.58,9.7,9.7,10.18,10.18,10.18,10.24,10.06,10.48,9.94,9.82,9.76,9.7,8.02,8.14,8.14,8.14,8.08,8.56,8.38,8.38,8.32,8.38,8.02,8.02,8.02,8.14,8.2,7.9,7.9,7.84,7.78,7.66,7.72,7.72,7.72,1.0,1.0,1.0,1.0,1.0,1.45,1.21,1.21,1.3,1.15,1.15,1.15,1.15,1.15,1.15,1.15,1.33,1.33,1.33,1.48,1.3,1.3,1.3,1.42,1.09,2.02,1.81,2.59,2.59,2.53,1.21,1.75,1.69,1.66,2.38,1.57,1.57,1.57,7.18,7.18,7.24,7.24,7.18,7.12,1.0,1.0,1.0,1.0,1.0,7.36,7.3,7.3,7.18,7.24,7.06,7.06,7.06,7.06,7.0,7.12,7.12,7.12,7.0,7.0,7.0,7.0,7.24,7.24,7.06,7.18,7.0,7.06,7.0,7.0,6.94,7.0,7.24,1.03,1.03,1.03,1.0,1.0,1.0,1.06,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.03,1.03,1.03,1.03,1.03,1.03,1.27,1.27,1.27,1.24,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.09,1.09,1.06,1.06,1.06,1.06,1.06,1.09,1.09,9.64,9.58,9.64,9.64,9.7,9.7,9.7,9.7,9.7,9.64,9.64,9.64,9.7,1.03,1.03,1.03,1.03,1.03,1.03,9.52,1.0,1.0,9.4,8.68,8.68,8.68,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,9.52,9.46,9.46,9.46,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"peakHeightM":[211.4,209.9,210.5,210.2,209.9,209.7,211.0,212.1,211.2,210.6,210.1,209.4,211.3,212.7,211.6,214.8,215.8,217.1,217.7,215.4,212.5,209.9,207.4,208.7,209.9,211.0,211.4,209.5,208.1,207.5,206.1,205.1,204.8,204.6,207.8,207.2,206.5,206.6,208.8,210.4,210.1,210.6,211.2,212.1,213.0,213.9,214.2,215.0,214.8,214.5,212.9,212.5,211.9,211.2,209.3,207.3,205.7,205.8,208.1,209.6,210.2,209.9,207.6,208.2,203.7,203.2,207.2,206.3,204.2,203.2,204.0,203.3,203.0,203.2,205.3,206.0,204.7,203.7,202.5,201.5,199.6,198.3,195.8,192.2,192.8,198.3,201.7,202.3,201.8,194.9,203.5,204.5,207.7,203.7,199.7,196.7,193.3,192.4,196.5,197.1,197.2,197.7,197.5,198.7,195.8,197.0,197.9,198.4,197.2,198.2,198.7,199.6,199.6,197.9,197.2,198.1,199.1,200.8,200.1,196.0,192.5,189.0,190.8,191.5,192.3,193.1,193.4,192.4,191.7,191.1,189.8,190.8,192.5,193.0,194.4,195.2,195.5,195.1,195.1,195.4,195.2,195.5,193.5,196.1,195.9,195.5,195.6,192.2,192.5,191.8,190.5,192.2,194.0,196.8,195.7,194.4,195.0,193.7,190.1,191.0,191.7,192.0,192.4,192.8,192.0,191.0,190.0,191.8,193.7,196.5,195.6,194.9,194.5,195.9,197.3,197.2,196.1,197.5,198.2,197.6,196.9,197.0,196.6,196.7,197.7,197.5,198.9,199.5,199.5,200.9,200.4,201.1,201.0,198.9,207.9,209.7,208.9,207.2,204.3,204.3,208.1,208.5,208.1,206.9,209.1,212.7,213.5,214.0,212.2,213.2,215.7,217.3,207.1,204.6,204.5,208.8,212.5,213.9,213.7,209.3,210.8,208.2,210.9,212.6,213.0,214.2,213.0,211.4,210.3,208.7,209.5,207.9,207.4,212.6,215.6,216.4,218.1,217.5,215.7,214.9,215.0,214.9,214.8,214.4,217.5,218.3,215.2,214.7,209.3,209.8,208.9,216.9,218.4,211.3,211.8,210.8,214.4,215.3,216.4,212.4,212.7,213.1,212.0,212.6,214.4,215.0,216.2,206.4,215.6,216.9,211.9,213.6,212.3,211.5,206.5,206.8,207.2,207.5,207.2,207.1,207.8,208.1,210.0,210.7,210.2,210.8,210.5,217.2,207.7,207.3,208.1,209.5,208.9,209.9,212.8,213.1,209.6,210.4,210.7,208.9,207.3,206.3,206.4,206.8,207.2,207.5,208.9,208.4,213.2,214.2,215.2,215.7,214.5,211.2,247.4,209.9,210.7,259.2,255.7,244.2,241.1,242.6,212.1,236.3,211.9,208.7,209.9,209.5,234.2,243.1,238.7,236.5,241.0,238.1,211.5,237.8,234.9,234.5,234.9,226.3,246.9,227.9,230.4,259.6,237.2,237.1,238.4,243.8,242.5,239.9,240.4,235.8,240.4,238.1,241.4,241.7,242.3,242.2,240.6,238.4,247.1,246.8,245.2,248.2,247.4,245.4,245.4,242.7,231.1,234.0,234.5,233.4,233.5,231.9,230.1,242.5,239.0,238.2,233.0,237.7,232.3,219.6,219.9,235.0,235.9,232.0,228.4,241.4,237.8,226.9,231.8,230.5,229.4,227.0,230.1,229.5,219.9,226.9,220.2,221.3,214.0,214.8,208.5,216.5,217.9,218.0,217.9,218.8,554.9,596.3,639.3,651.8,655.9,677.7,654.8,653.7,652.5,660.3,655.7,632.9,605.1,578.6,484.8,496.7,491.5,237.4,693.3,682.9,676.3,665.1,672.6,677.4,699.5,746.5,793.0,820.8,836.9,831.7,813.8,774.3,513.7,511.5,512.0,493.9,464.6,457.9,479.7,526.0,524.0,515.7,509.3,531.0,553.8,538.4,562.8,582.7,541.4,558.5,545.3,640.0,583.8,599.0,593.8,595.2,603.6,604.7,620.9,614.5,586.6,570.3,549.1,511.8,447.5,462.2,354.3,358.3,365.5,371.3,373.8,377.0,376.8,377.3,377.6,383.6,372.4,377.1,382.3,384.6,386.4,384.8,382.9,375.9,365.9,430.3,471.0,427.2,428.5,403.2,887.7,380.7,381.5,382.0,397.5,401.8,409.1,418.8,441.2,470.9,490.1,514.8,516.4,515.3,490.4,489.5,486.6,472.2,410.8,419.1,428.3,437.9,444.8,466.6,466.6,465.3,456.6,449.4,436.6,430.0,421.0,419.8,411.4,398.0,416.8,426.6,425.1,415.1,407.2,398.0,385.9,209.6,209.3,208.9,208.5,207.5,216.6,210.8,210.7,213.0,210.9,211.9,211.8,211.8,211.8,211.6,211.4,216.1,216.6,216.7,220.6,217.6,218.4,219.2,224.9,212.4,237.7,231.4,251.4,252.7,252.0,216.4,231.7,229.2,230.8,250.7,228.9,228.5,229.0,397.4,416.0,438.4,447.0,439.3,424.3,218.7,218.7,218.9,218.8,218.4,445.6,444.4,455.8,451.0,454.8,454.3,455.3,448.6,443.6,442.1,451.4,458.2,455.3,452.1,460.3,460.0,457.7,467.1,457.0,454.1,463.0,459.5,465.5,464.3,456.8,445.0,425.5,428.2,218.8,221.8,222.7,222.3,223.1,221.6,223.5,222.0,224.0,225.4,223.7,222.5,221.8,220.9,222.1,224.0,223.2,221.6,220.3,218.8,226.4,228.2,225.4,224.2,216.9,216.5,217.1,217.6,217.9,217.6,217.0,216.1,216.6,220.6,219.5,218.9,220.8,221.5,222.5,220.2,217.7,216.4,488.4,497.5,503.2,515.2,518.5,522.0,535.7,542.3,542.3,541.8,536.7,518.9,493.5,216.1,217.8,219.5,220.5,221.2,220.0,516.0,218.0,218.4,523.4,502.5,505.8,494.9,220.9,221.3,220.8,220.4,219.3,218.3,217.5,216.5,215.5,214.2,213.0,212.9,213.3,213.5,473.5,477.6,487.5,492.6,217.2,219.2,220.7,220.7,220.7,218.9,216.5,214.4,212.5,210.6,210.7,210.9,211.8,212.9,213.7,213.7,213.7,212.6],"slopeEast":[0.002,-0.001,0.056,0.043,0.022,0.002,0.007,-0.005,0.014,0.012,0.002,-0.013,0.046,0.027,-0.083,-0.005,-0.017,-0.01,-0.007,-0.03,-0.051,-0.056,-0.058,-0.088,-0.109,-0.099,-0.085,-0.079,-0.077,0.008,0.005,0.006,-0.01,0.062,0.068,0.071,0.074,0.084,0.019,-0.001,0.023,0.02,0.021,0.024,0.025,0.031,0.054,0.031,0.003,-0.022,-0.034,-0.054,-0.055,-0.049,-0.047,-0.045,-0.038,-0.041,-0.051,-0.054,-0.073,-0.086,-0.104,-0.05,-0.085,-0.083,0.125,0.082,0.049,0.018,0.064,0.034,-0.013,-0.004,-0.06,-0.087,-0.128,-0.166,-0.129,-0.038,-0.154,-0.158,-0.163,-0.116,-0.12,0.088,0.055,0.048,0.095,0.036,0.154,0.016,-0.009,-0.018,-0.079,-0.121,-0.161,-0.174,0.003,0.037,0.108,0.103,0.1,0.106,0.101,-0.064,-0.045,0.013,0.149,0.083,0.078,0.071,0.039,-0.062,-0.065,-0.118,-0.105,-0.072,-0.045,-0.025,-0.001,-0.008,-0.007,-0.02,-0.037,-0.058,-0.078,-0.075,-0.069,-0.063,-0.054,-0.008,0.003,0.123,0.036,0.015,-0.005,0.017,0.049,0.086,0.068,0.013,0.032,-0.024,-0.01,0.005,0.009,-0.005,-0.004,0.021,0.062,-0.012,-0.014,-0.013,-0.031,0.02,-0.004,-0.021,-0.005,-0.018,-0.026,-0.032,-0.038,-0.046,-0.055,-0.063,-0.067,-0.007,0.009,-0.048,-0.051,-0.051,-0.004,-0.015,-0.025,-0.018,0.002,-0.002,0.005,0.013,0.02,0.049,0.013,0.002,-0.023,-0.009,-0.075,-0.065,0.018,0.02,0.024,-0.023,-0.022,-0.049,0.01,0.056,-0.003,-0.001,0.008,-0.048,-0.05,-0.03,-0.015,0.002,-0.045,-0.077,-0.052,-0.113,-0.035,0.052,0.029,-0.006,-0.008,0.119,0.111,0.049,0.019,-0.001,-0.008,0.003,0.025,0.028,0.004,-0.059,0.008,0.005,-0.0,0.055,0.03,-0.044,-0.024,-0.002,-0.029,-0.015,-0.028,-0.04,-0.079,-0.084,-0.073,0.118,0.104,0.095,0.098,0.122,-0.042,0.014,0.108,0.117,-0.077,-0.066,-0.052,0.006,0.018,-0.026,-0.008,0.016,-0.019,-0.02,0.006,0.033,0.056,0.035,0.017,0.002,0.011,0.009,-0.015,0.039,-0.016,-0.019,0.073,0.06,0.028,0.012,-0.052,-0.037,-0.027,-0.026,-0.025,-0.027,-0.034,-0.033,-0.112,-0.098,-0.018,-0.005,0.014,-0.002,-0.012,-0.021,-0.041,-0.06,-0.073,-0.068,-0.007,0.023,-0.002,0.014,0.031,0.042,0.045,0.05,0.06,0.075,0.066,0.054,0.029,0.03,-0.066,-0.045,-0.033,-0.035,0.1,0.072,0.022,-0.017,0.002,0.006,0.105,0.007,-0.015,0.014,-0.019,-0.045,0.021,-0.027,-0.032,-0.043,0.023,-0.048,0.051,-0.072,-0.099,-0.013,0.016,0.062,0.05,0.066,0.041,0.022,-0.031,-0.028,-0.003,0.047,-0.019,-0.007,0.014,-0.047,0.019,-0.023,-0.023,0.008,-0.064,0.049,-0.035,-0.026,0.01,0.043,0.057,0.094,-0.076,-0.07,0.043,0.029,0.051,0.001,0.036,0.056,-0.016,-0.042,-0.015,-0.0,0.037,0.052,0.089,0.032,0.021,0.06,-0.018,0.018,0.002,0.036,0.058,-0.043,0.005,-0.0,-0.004,-0.084,-0.088,0.055,-0.004,0.02,0.027,0.018,-0.003,-0.013,-0.01,-0.005,0.032,0.037,-0.085,-0.114,0.016,-0.076,-0.081,0.043,0.029,0.014,-0.247,-0.334,-0.183,-0.03,-0.023,0.027,0.067,0.018,-0.17,0.04,0.076,0.16,0.142,0.214,-0.239,-0.036,0.236,0.006,-0.075,-0.015,-0.134,0.191,-0.098,-0.235,-0.094,-0.348,-0.156,-0.126,0.06,-0.008,0.039,0.184,0.048,0.037,0.035,-0.057,-0.06,0.062,-0.295,-0.071,-0.175,0.046,-0.045,-0.173,-0.173,-0.161,-0.148,0.042,-0.034,-0.145,0.247,0.002,-0.209,0.028,-0.129,-0.037,0.154,-0.069,-0.023,0.149,0.119,-0.146,0.175,0.112,-0.005,-0.029,-0.11,-0.064,-0.06,-0.018,-0.04,0.03,-0.087,-0.043,-0.015,-0.031,-0.13,-0.068,-0.06,0.001,-0.021,-0.0,-0.013,0.032,0.012,-0.025,0.013,0.081,0.008,-0.101,0.006,-0.063,-0.072,-0.034,-0.109,-0.03,-0.058,-0.068,-0.174,-0.1,-0.092,-0.088,-0.053,0.072,0.007,-0.043,-0.029,-0.004,-0.033,-0.07,-0.071,-0.07,-0.077,-0.07,-0.021,-0.02,-0.061,-0.004,-0.114,-0.02,-0.023,0.032,0.05,-0.037,-0.005,-0.02,-0.109,-0.063,0.014,-0.015,-0.037,0.007,0.019,0.035,0.051,0.064,-0.018,-0.034,-0.025,0.038,-0.074,-0.071,-0.068,-0.065,-0.061,-0.056,-0.05,-0.038,-0.032,-0.02,0.035,-0.023,-0.034,-0.042,-0.009,-0.053,-0.061,-0.048,0.032,0.055,-0.052,0.019,0.04,-0.026,0.007,-0.033,-0.04,-0.044,-0.047,0.134,-0.002,0.095,0.061,-0.082,-0.127,0.039,0.057,0.077,0.092,0.081,0.007,-0.041,0.057,-0.038,0.02,0.004,-0.126,-0.118,-0.042,-0.061,-0.009,-0.034,-0.028,-0.016,0.066,-0.008,-0.033,-0.062,-0.168,0.054,0.058,-0.007,0.007,-0.054,-0.042,-0.257,-0.152,0.009,0.029,0.032,0.036,-0.033,-0.046,-0.05,0.018,-0.059,-0.065,-0.078,-0.093,-0.109,-0.126,-0.136,-0.072,-0.057,-0.05,-0.039,-0.032,-0.029,-0.024,-0.055,-0.05,-0.057,0.019,0.031,0.057,0.078,0.091,0.084,0.071,0.05,0.033,0.066,0.024,-0.039,-0.043,-0.062,-0.086,-0.078,-0.035,-0.041,0.114,-0.004,0.08,-0.043,0.008,0.035,0.117,0.075,0.091,-0.088,-0.074,-0.157,-0.187,0.023,0.023,0.021,0.021,0.017,0.002,-0.01,0.054,0.037,0.016,0.058,-0.096,-0.292,0.009,0.007,-0.003,-0.011,-0.009,-0.005,-0.017,-0.031,-0.04,-0.029,-0.019,0.007,0.036,0.064,0.06,0.091,0.084,0.021,0.044,0.024,0.007,0.007,0.006,-0.001,-0.004,-0.011,-0.026,-0.037,-0.048,-0.06,-0.057,-0.054,-0.051,-0.031,-0.014,-0.007],"slopeNorth":[-0.005,0.005,-0.003,0.002,-0.006,-0.016,-0.044,-0.027,-0.024,-0.026,-0.026,-0.026,-0.05,-0.054,0.02,-0.042,-0.033,-0.022,-0.009,0.008,0.025,0.032,0.037,0.003,0.01,0.01,0.013,0.017,0.026,0.059,0.018,-0.02,-0.039,0.024,0.024,0.008,-0.005,-0.003,-0.06,-0.088,-0.024,-0.033,-0.037,-0.039,-0.046,-0.052,-0.026,-0.019,-0.023,-0.025,-0.027,-0.106,-0.113,-0.118,-0.111,-0.103,-0.016,-0.016,-0.129,-0.122,-0.096,-0.075,-0.046,-0.071,0.056,0.061,-0.108,-0.077,0.028,0.067,-0.029,-0.009,0.048,-0.009,-0.029,-0.018,0.006,0.025,0.144,0.19,0.17,0.167,0.149,-0.078,-0.083,-0.0,-0.081,-0.04,-0.001,-0.033,-0.082,-0.031,0.061,0.14,0.078,0.132,0.104,0.127,-0.011,-0.01,0.071,0.129,0.113,0.103,0.13,-0.038,-0.037,0.044,0.052,0.016,0.01,-0.01,-0.02,-0.014,0.06,0.086,0.082,0.067,0.049,0.051,0.058,-0.08,-0.095,-0.099,-0.098,-0.093,-0.064,-0.024,0.007,0.03,0.041,-0.085,-0.064,-0.021,-0.069,-0.063,-0.054,-0.027,0.013,0.058,0.082,0.091,0.065,0.04,0.085,0.117,0.139,0.134,0.059,0.047,0.008,0.002,0.005,0.01,0.034,0.039,0.068,0.077,-0.041,-0.017,-0.011,-0.017,-0.021,-0.021,-0.003,0.016,0.034,-0.057,-0.046,-0.067,-0.062,-0.059,-0.032,-0.028,-0.023,-0.01,-0.01,-0.033,-0.028,-0.004,0.019,-0.01,0.056,0.067,-0.061,-0.049,-0.108,-0.079,-0.0,0.018,0.024,0.053,0.08,0.085,-0.008,0.015,0.016,0.027,0.048,-0.05,-0.038,-0.058,-0.086,-0.001,-0.061,-0.038,-0.014,0.027,0.062,-0.036,-0.007,0.046,-0.023,-0.001,0.01,-0.073,0.053,0.073,-0.02,0.004,-0.001,-0.014,-0.069,-0.016,-0.041,0.001,0.021,-0.017,-0.019,-0.115,-0.11,-0.083,-0.119,-0.097,-0.076,-0.101,-0.069,-0.054,-0.022,-0.045,-0.028,-0.016,-0.008,0.019,-0.037,0.005,0.007,0.01,-0.092,-0.096,-0.079,0.074,0.07,-0.076,-0.025,-0.075,-0.039,0.003,-0.006,0.013,-0.03,-0.064,-0.079,-0.076,-0.056,-0.026,0.042,-0.084,0.038,0.055,0.002,0.033,0.01,-0.003,0.001,-0.02,-0.041,-0.057,-0.056,-0.053,-0.045,-0.037,0.043,0.037,-0.023,-0.013,-0.012,0.063,0.029,0.024,0.012,-0.007,0.01,0.006,-0.057,0.007,-0.017,0.02,0.061,0.058,0.039,0.021,0.001,-0.02,-0.025,-0.024,0.039,0.052,0.03,0.03,0.028,0.035,0.003,0.005,0.027,-0.048,-0.059,0.005,-0.037,-0.029,-0.058,-0.066,-0.009,-0.02,0.014,-0.009,-0.015,-0.01,-0.045,-0.036,-0.085,-0.017,0.011,-0.052,0.048,-0.025,-0.024,-0.035,-0.031,-0.05,-0.058,-0.013,-0.05,-0.053,-0.058,-0.033,-0.017,0.001,-0.025,-0.066,-0.023,-0.014,-0.011,-0.059,-0.019,-0.009,0.005,-0.007,-0.034,-0.051,0.002,0.036,0.0,0.007,-0.022,-0.028,-0.046,-0.051,-0.018,-0.021,-0.013,0.012,-0.005,-0.035,-0.036,0.011,-0.065,0.025,0.006,0.018,-0.103,0.005,-0.007,-0.025,-0.061,-0.044,-0.049,-0.008,0.004,-0.043,0.022,-0.062,-0.019,-0.055,0.001,-0.01,-0.057,-0.005,-0.051,-0.064,-0.023,0.029,-0.044,0.079,0.066,-0.049,-0.043,0.043,0.037,0.009,-0.04,-0.175,-0.046,-0.025,-0.055,-0.045,0.01,-0.078,-0.036,-0.127,-0.126,-0.145,0.102,0.094,-0.123,0.055,-0.109,0.133,-0.142,-0.009,-0.085,0.067,0.024,0.056,-0.06,0.156,0.025,-0.068,0.018,-0.106,0.077,0.019,-0.02,-0.043,0.021,-0.021,0.053,-0.023,-0.091,-0.044,0.016,0.174,0.024,0.172,0.213,-0.05,-0.021,-0.051,-0.255,0.041,0.076,-0.168,0.031,-0.153,-0.058,-0.022,0.063,-0.084,-0.122,-0.153,-0.157,-0.171,0.014,0.054,0.036,0.084,0.009,0.019,0.018,-0.012,0.013,0.008,0.011,0.061,0.061,0.057,-0.013,-0.036,-0.024,-0.045,-0.046,-0.101,-0.156,-0.066,-0.239,-0.1,-0.077,-0.243,-0.08,0.073,0.039,0.179,0.106,0.005,0.086,0.044,0.321,0.232,0.195,0.145,-0.074,-0.069,-0.057,0.021,-0.1,-0.165,0.063,0.139,0.12,0.091,0.134,0.082,0.064,-0.056,-0.118,-0.146,-0.004,-0.123,-0.142,-0.092,-0.121,0.172,0.209,0.083,-0.15,-0.088,-0.128,-0.152,-0.196,0.004,-0.017,-0.027,-0.037,-0.042,0.021,0.014,0.009,0.003,0.082,0.074,0.061,0.048,0.031,0.013,-0.006,0.081,0.074,0.055,-0.036,0.041,0.024,0.006,-0.068,-0.022,0.073,0.021,0.055,0.009,0.022,0.079,-0.019,0.052,0.08,0.02,0.073,0.04,0.01,0.224,0.291,0.218,-0.011,-0.137,-0.234,0.091,0.094,0.095,0.093,0.062,0.02,0.061,0.003,0.044,-0.063,0.145,-0.012,-0.046,-0.011,0.075,0.117,0.046,-0.033,0.108,0.084,-0.011,-0.094,-0.024,-0.13,0.075,0.1,0.124,-0.018,-0.058,-0.136,-0.149,-0.227,-0.022,0.105,0.119,0.113,0.09,0.076,0.045,-0.045,0.032,0.029,0.034,0.051,0.055,0.044,0.028,-0.072,-0.095,-0.088,-0.06,-0.032,-0.005,0.003,-0.016,-0.008,0.039,0.085,0.076,0.061,0.046,0.035,0.031,0.022,0.018,0.014,0.006,0.014,0.009,-0.002,-0.002,0.003,-0.003,-0.024,-0.018,0.068,0.212,0.049,0.187,0.06,0.024,0.003,-0.084,-0.173,0.03,-0.138,-0.17,-0.157,0.006,-0.007,-0.026,-0.034,-0.038,-0.045,0.001,-0.075,-0.067,0.237,0.215,0.109,0.055,-0.068,-0.066,-0.059,-0.054,-0.059,-0.062,-0.065,-0.07,-0.072,-0.064,-0.054,-0.045,-0.035,-0.022,0.059,0.161,0.071,0.021,-0.021,-0.027,-0.033,-0.039,-0.044,-0.038,-0.029,-0.034,-0.057,-0.078,-0.075,-0.071,-0.076,-0.085,-0.083,-0.057,-0.03,-0.018]} +{ + "viewpoint": { "lat": 48.1929826, "lon": 16.3534237, "groundM": 183.3 }, + "stepDeg": 0.5, + "radiusKm": 60.0, + "minKm": 1.0, + "curvature": "drop d^2/2R*(1-0.13), R=6371000 m", + "source": { + "dataset": "Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid", + "tiles": [ + "N47_00_E015_00", + "N47_00_E016_00", + "N47_00_E017_00", + "N48_00_E015_00", + "N48_00_E016_00", + "N48_00_E017_00", + "N49_00_E015_00", + "N49_00_E016_00", + "N49_00_E017_00" + ], + "tileBytesEach": "about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed", + "url": "https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)", + "licence": "Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty (https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)", + "attribution": "Contains modified Copernicus data: DEM \u00a9 DLR e.V. 2010-2014 and \u00a9 Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.", + "bakedBy": "scripts/bake-horizon.py" + }, + "elevationDeg": [ + 1.608, 1.522, 1.467, 1.452, 1.433, 1.426, 1.497, 1.6, 1.597, 1.564, 1.532, 1.495, 1.467, 1.544, + 1.62, 1.655, 1.707, 1.775, 1.803, 1.683, 1.532, 1.394, 1.263, 1.297, 1.358, 1.412, 1.433, 1.339, + 1.264, 1.269, 1.195, 1.142, 1.13, 1.22, 1.401, 1.368, 1.326, 1.294, 1.378, 1.46, 1.488, 1.516, + 1.549, 1.598, 1.647, 1.698, 1.769, 1.816, 1.801, 1.783, 1.695, 1.619, 1.586, 1.547, 1.442, + 1.335, 1.282, 1.286, 1.335, 1.421, 1.449, 1.437, 1.313, 1.268, 1.164, 1.14, 1.098, 1.06, 0.984, + 0.938, 0.91, 0.877, 0.884, 0.872, 0.943, 0.974, 0.919, 0.873, 0.861, 0.839, 0.731, 0.673, 0.56, + 0.494, 0.527, 0.601, 0.566, 0.584, 0.579, 0.643, 0.666, 0.741, 0.851, 0.711, 0.703, 0.573, 0.54, + 0.488, 0.433, 0.452, 0.467, 0.483, 0.475, 0.517, 0.419, 0.428, 0.457, 0.489, 0.522, 0.482, 0.5, + 0.527, 0.528, 0.471, 0.458, 0.487, 0.521, 0.578, 0.553, 0.419, 0.301, 0.324, 0.425, 0.468, + 0.512, 0.558, 0.575, 0.521, 0.477, 0.444, 0.372, 0.297, 0.365, 0.446, 0.486, 0.523, 0.532, + 0.516, 0.516, 0.531, 0.52, 0.546, 0.565, 0.535, 0.527, 0.508, 0.516, 0.37, 0.359, 0.33, 0.375, + 0.454, 0.546, 0.496, 0.449, 0.425, 0.447, 0.398, 0.388, 0.439, 0.476, 0.496, 0.517, 0.541, + 0.494, 0.439, 0.383, 0.411, 0.502, 0.621, 0.578, 0.548, 0.568, 0.642, 0.713, 0.708, 0.654, + 0.704, 0.741, 0.709, 0.677, 0.661, 0.66, 0.666, 0.662, 0.651, 0.699, 0.727, 0.746, 0.809, 0.788, + 0.799, 0.793, 0.702, 0.734, 0.79, 0.754, 0.704, 0.616, 0.598, 0.708, 0.719, 0.706, 0.704, 0.726, + 0.826, 0.849, 0.85, 0.813, 0.853, 0.926, 0.971, 0.918, 1.003, 1.0, 0.927, 0.985, 1.031, 1.043, + 1.067, 1.13, 1.022, 1.02, 1.043, 1.101, 1.144, 1.099, 1.082, 1.064, 1.2, 1.238, 1.16, 1.084, + 1.202, 1.326, 1.39, 1.429, 1.405, 1.332, 1.358, 1.361, 1.357, 1.354, 1.335, 1.319, 1.376, 1.309, + 1.289, 1.257, 1.283, 1.237, 1.296, 1.353, 1.259, 1.314, 1.236, 1.307, 1.344, 1.36, 1.251, 1.292, + 1.308, 1.26, 1.288, 1.365, 1.391, 1.414, 1.32, 1.299, 1.35, 1.288, 1.361, 1.303, 1.268, 1.29, + 1.305, 1.326, 1.344, 1.328, 1.32, 1.358, 1.375, 1.443, 1.477, 1.492, 1.525, 1.513, 1.422, 1.314, + 1.294, 1.337, 1.414, 1.343, 1.397, 1.431, 1.409, 1.378, 1.423, 1.438, 1.343, 1.257, 1.208, + 1.209, 1.233, 1.251, 1.267, 1.274, 1.248, 1.254, 1.297, 1.34, 1.359, 1.34, 1.199, 1.162, 1.255, + 1.292, 1.29, 1.24, 1.222, 1.24, 1.271, 1.264, 1.317, 1.256, 1.26, 1.321, 1.301, 1.266, 1.268, + 1.308, 1.357, 1.474, 1.419, 1.438, 1.393, 1.354, 1.343, 1.354, 1.333, 1.321, 1.359, 1.437, 1.47, + 1.395, 1.413, 1.446, 1.466, 1.434, 1.446, 1.458, 1.419, 1.522, 1.502, 1.506, 1.512, 1.528, + 1.526, 1.485, 1.427, 1.508, 1.5, 1.48, 1.534, 1.514, 1.449, 1.449, 1.386, 1.368, 1.388, 1.404, + 1.374, 1.374, 1.352, 1.3, 1.347, 1.332, 1.265, 1.235, 1.39, 1.285, 1.226, 1.233, 1.235, 1.211, + 1.164, 1.183, 1.217, 1.143, 1.128, 1.117, 1.112, 1.086, 1.071, 1.118, 1.103, 1.002, 0.99, 0.995, + 1.025, 0.998, 1.024, 0.971, 1.045, 1.09, 1.11, 1.108, 1.081, 1.171, 1.308, 1.446, 1.482, 1.484, + 1.494, 1.469, 1.472, 1.479, 1.493, 1.467, 1.388, 1.293, 1.203, 1.104, 1.145, 1.12, 1.028, 1.004, + 0.982, 0.968, 0.933, 0.97, 0.986, 1.035, 1.12, 1.227, 1.281, 1.316, 1.312, 1.273, 1.187, 1.205, + 1.197, 1.199, 1.176, 1.177, 1.142, 1.19, 1.377, 1.369, 1.322, 1.295, 1.385, 1.493, 1.576, 1.679, + 1.769, 1.622, 1.711, 1.641, 1.517, 1.582, 1.525, 1.39, 1.389, 1.343, 1.331, 1.369, 1.343, 1.257, + 1.212, 1.134, 1.039, 1.03, 1.075, 1.027, 1.059, 1.112, 1.132, 1.14, 1.16, 1.167, 1.178, 1.179, + 1.192, 1.214, 1.245, 1.289, 1.286, 1.279, 1.259, 1.256, 1.202, 1.138, 1.159, 1.116, 1.12, 1.102, + 0.999, 1.042, 1.051, 1.09, 1.143, 1.244, 1.269, 1.296, 1.353, 1.412, 1.579, 1.686, 1.814, 1.857, + 1.773, 1.731, 1.748, 1.742, 1.668, 1.594, 1.628, 1.692, 1.76, 1.822, 1.863, 1.904, 1.895, 1.849, + 1.786, 1.778, 1.731, 1.666, 1.633, 1.561, 1.526, 1.662, 1.747, 1.75, 1.704, 1.631, 1.563, 1.473, + 1.501, 1.488, 1.464, 1.441, 1.386, 1.31, 1.298, 1.293, 1.306, 1.371, 1.42, 1.416, 1.417, 1.418, + 1.409, 1.399, 1.408, 1.431, 1.434, 1.437, 1.509, 1.544, 1.58, 1.674, 1.528, 1.534, 1.517, 1.497, + 1.525, 1.546, 1.562, 1.58, 1.55, 1.634, 1.614, 1.66, 1.643, 1.663, 1.68, 1.829, 1.99, 2.058, + 2.014, 1.911, 2.025, 2.023, 2.036, 2.031, 2.009, 2.012, 2.02, 2.109, 2.107, 2.12, 2.171, 2.179, + 2.125, 2.084, 2.09, 2.129, 2.183, 2.16, 2.172, 2.239, 2.237, 2.218, 2.217, 2.137, 2.169, 2.203, + 2.233, 2.262, 2.272, 2.21, 2.133, 1.955, 1.909, 1.97, 2.138, 2.187, 2.229, 2.278, 2.191, 2.167, + 2.213, 2.327, 2.409, 2.31, 2.242, 2.205, 2.152, 2.154, 2.258, 2.217, 2.126, 2.054, 1.97, 1.942, + 2.022, 1.894, 1.886, 1.921, 1.897, 1.936, 1.964, 1.978, 1.961, 1.927, 1.879, 1.904, 1.96, 1.9, + 1.922, 2.023, 2.061, 2.115, 1.99, 1.805, 1.736, 1.776, 1.841, 1.863, 1.934, 1.942, 1.962, 2.043, + 2.082, 2.082, 2.092, 2.062, 1.957, 1.794, 1.821, 1.913, 2.009, 2.066, 2.105, 2.036, 1.964, + 1.984, 2.005, 2.036, 2.072, 2.094, 2.022, 2.153, 2.177, 2.146, 2.125, 2.061, 2.004, 1.955, + 1.902, 1.84, 1.767, 1.7, 1.696, 1.718, 1.725, 1.709, 1.745, 1.805, 1.836, 1.939, 2.053, 2.14, + 2.139, 2.142, 2.035, 1.902, 1.779, 1.667, 1.564, 1.57, 1.581, 1.628, 1.693, 1.739, 1.738, 1.738, + 1.675 + ], + "distanceKm": [ + 1.0, 1.0, 1.06, 1.06, 1.06, 1.06, 1.06, 1.03, 1.0, 1.0, 1.0, 1.0, 1.09, 1.09, 1.0, 1.09, 1.09, + 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.12, 1.12, 1.12, 1.12, 1.12, 1.12, 1.09, 1.09, 1.09, 1.09, + 1.0, 1.0, 1.0, 1.0, 1.03, 1.06, 1.06, 1.03, 1.03, 1.03, 1.03, 1.03, 1.03, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.03, 1.03, 1.03, 1.03, 1.03, 1.0, 1.0, 1.06, 1.06, 1.06, 1.06, 1.06, 1.12, 1.0, 1.0, 1.24, + 1.24, 1.21, 1.21, 1.3, 1.3, 1.27, 1.3, 1.33, 1.33, 1.33, 1.33, 1.27, 1.24, 1.27, 1.27, 1.27, + 1.03, 1.03, 1.42, 1.84, 1.84, 1.81, 1.03, 1.72, 1.63, 1.63, 1.63, 1.33, 1.33, 1.06, 1.06, 1.72, + 1.72, 1.69, 1.69, 1.69, 1.69, 1.69, 1.81, 1.81, 1.75, 1.51, 1.75, 1.75, 1.75, 1.75, 1.75, 1.72, + 1.72, 1.72, 1.72, 1.72, 1.72, 1.72, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.42, + 1.42, 1.24, 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 1.27, 1.03, 1.36, 1.36, 1.36, 1.36, 1.36, 1.45, + 1.45, 1.09, 1.12, 1.12, 1.54, 1.57, 1.48, 1.48, 1.48, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.18, 1.18, 1.21, 1.21, 1.21, 1.12, 1.12, 1.12, 1.12, 1.12, 1.15, 1.15, 1.15, 1.15, 1.18, + 1.15, 1.15, 1.24, 1.24, 1.27, 1.27, 1.24, 1.24, 1.24, 1.27, 1.27, 1.27, 1.9, 1.9, 1.93, 1.93, + 1.93, 1.99, 1.99, 1.99, 1.99, 1.9, 2.02, 2.02, 2.02, 2.05, 2.02, 1.99, 1.99, 1.99, 1.48, 1.21, + 1.21, 1.57, 1.69, 1.69, 1.66, 1.39, 1.39, 1.39, 1.54, 1.6, 1.54, 1.54, 1.54, 1.48, 1.45, 1.21, + 1.21, 1.21, 1.27, 1.39, 1.39, 1.36, 1.39, 1.39, 1.39, 1.33, 1.33, 1.33, 1.33, 1.33, 1.48, 1.45, + 1.39, 1.39, 1.18, 1.18, 1.18, 1.48, 1.48, 1.27, 1.24, 1.27, 1.36, 1.36, 1.39, 1.33, 1.3, 1.3, + 1.3, 1.3, 1.3, 1.3, 1.33, 1.0, 1.42, 1.42, 1.27, 1.27, 1.27, 1.27, 1.03, 1.03, 1.03, 1.03, 1.03, + 1.03, 1.03, 1.03, 1.06, 1.06, 1.03, 1.03, 1.03, 1.36, 1.06, 1.06, 1.06, 1.06, 1.09, 1.09, 1.18, + 1.21, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.15, 1.15, 1.36, 1.36, 1.36, + 1.36, 1.33, 1.33, 3.13, 1.21, 1.21, 3.34, 3.31, 2.83, 2.65, 2.65, 1.3, 2.29, 1.3, 1.15, 1.15, + 1.15, 2.29, 2.68, 2.41, 2.23, 2.23, 2.2, 1.12, 2.23, 2.17, 2.17, 2.17, 1.84, 2.74, 1.87, 1.87, + 2.95, 2.2, 2.17, 2.17, 2.35, 2.35, 2.23, 2.23, 2.11, 2.14, 2.08, 2.2, 2.2, 2.2, 2.2, 2.2, 2.2, + 2.41, 2.41, 2.38, 2.41, 2.41, 2.44, 2.44, 2.44, 1.99, 2.08, 2.08, 2.08, 2.08, 2.05, 2.05, 2.5, + 2.38, 2.47, 2.29, 2.23, 2.17, 1.69, 1.69, 2.38, 2.47, 2.38, 2.17, 2.71, 2.71, 2.2, 2.47, 2.41, + 2.41, 2.32, 2.38, 2.38, 2.08, 2.5, 2.11, 2.11, 1.75, 1.75, 1.48, 1.81, 1.81, 1.78, 1.78, 1.87, + 17.2, 17.2, 17.26, 17.32, 17.44, 18.1, 17.56, 17.5, 17.38, 17.5, 17.62, 17.68, 17.74, 17.8, + 14.86, 14.92, 14.98, 2.98, 26.38, 26.38, 26.38, 26.62, 26.14, 26.02, 26.02, 26.38, 26.26, 26.38, + 26.38, 26.26, 26.26, 26.26, 14.98, 14.98, 14.98, 14.44, 13.12, 13.18, 13.66, 13.72, 13.72, + 13.84, 13.84, 13.84, 13.72, 12.52, 12.58, 12.58, 12.28, 12.22, 12.28, 16.54, 14.02, 15.04, + 16.18, 16.24, 17.08, 17.26, 17.44, 17.5, 17.44, 17.32, 17.44, 17.02, 13.96, 14.14, 9.22, 9.16, + 9.1, 9.22, 9.28, 9.28, 9.22, 9.16, 9.16, 9.34, 8.68, 8.68, 8.62, 8.74, 8.86, 8.92, 8.86, 8.92, + 8.92, 11.74, 14.08, 11.98, 12.22, 12.04, 34.3, 10.36, 10.06, 9.64, 9.58, 9.58, 9.7, 9.7, 10.18, + 10.18, 10.18, 10.24, 10.06, 10.48, 9.94, 9.82, 9.76, 9.7, 8.02, 8.14, 8.14, 8.14, 8.08, 8.56, + 8.38, 8.38, 8.32, 8.38, 8.02, 8.02, 8.02, 8.14, 8.2, 7.9, 7.9, 7.84, 7.78, 7.66, 7.72, 7.72, + 7.72, 1.0, 1.0, 1.0, 1.0, 1.0, 1.45, 1.21, 1.21, 1.3, 1.15, 1.15, 1.15, 1.15, 1.15, 1.15, 1.15, + 1.33, 1.33, 1.33, 1.48, 1.3, 1.3, 1.3, 1.42, 1.09, 2.02, 1.81, 2.59, 2.59, 2.53, 1.21, 1.75, + 1.69, 1.66, 2.38, 1.57, 1.57, 1.57, 7.18, 7.18, 7.24, 7.24, 7.18, 7.12, 1.0, 1.0, 1.0, 1.0, 1.0, + 7.36, 7.3, 7.3, 7.18, 7.24, 7.06, 7.06, 7.06, 7.06, 7.0, 7.12, 7.12, 7.12, 7.0, 7.0, 7.0, 7.0, + 7.24, 7.24, 7.06, 7.18, 7.0, 7.06, 7.0, 7.0, 6.94, 7.0, 7.24, 1.03, 1.03, 1.03, 1.0, 1.0, 1.0, + 1.06, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.03, 1.03, 1.03, 1.03, 1.03, 1.03, 1.27, 1.27, 1.27, + 1.24, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.09, 1.09, 1.06, 1.06, 1.06, 1.06, 1.06, + 1.09, 1.09, 9.64, 9.58, 9.64, 9.64, 9.7, 9.7, 9.7, 9.7, 9.7, 9.64, 9.64, 9.64, 9.7, 1.03, 1.03, + 1.03, 1.03, 1.03, 1.03, 9.52, 1.0, 1.0, 9.4, 8.68, 8.68, 8.68, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 9.52, 9.46, 9.46, 9.46, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 + ], + "peakHeightM": [ + 211.4, 209.9, 210.5, 210.2, 209.9, 209.7, 211.0, 212.1, 211.2, 210.6, 210.1, 209.4, 211.3, + 212.7, 211.6, 214.8, 215.8, 217.1, 217.7, 215.4, 212.5, 209.9, 207.4, 208.7, 209.9, 211.0, + 211.4, 209.5, 208.1, 207.5, 206.1, 205.1, 204.8, 204.6, 207.8, 207.2, 206.5, 206.6, 208.8, + 210.4, 210.1, 210.6, 211.2, 212.1, 213.0, 213.9, 214.2, 215.0, 214.8, 214.5, 212.9, 212.5, + 211.9, 211.2, 209.3, 207.3, 205.7, 205.8, 208.1, 209.6, 210.2, 209.9, 207.6, 208.2, 203.7, + 203.2, 207.2, 206.3, 204.2, 203.2, 204.0, 203.3, 203.0, 203.2, 205.3, 206.0, 204.7, 203.7, + 202.5, 201.5, 199.6, 198.3, 195.8, 192.2, 192.8, 198.3, 201.7, 202.3, 201.8, 194.9, 203.5, + 204.5, 207.7, 203.7, 199.7, 196.7, 193.3, 192.4, 196.5, 197.1, 197.2, 197.7, 197.5, 198.7, + 195.8, 197.0, 197.9, 198.4, 197.2, 198.2, 198.7, 199.6, 199.6, 197.9, 197.2, 198.1, 199.1, + 200.8, 200.1, 196.0, 192.5, 189.0, 190.8, 191.5, 192.3, 193.1, 193.4, 192.4, 191.7, 191.1, + 189.8, 190.8, 192.5, 193.0, 194.4, 195.2, 195.5, 195.1, 195.1, 195.4, 195.2, 195.5, 193.5, + 196.1, 195.9, 195.5, 195.6, 192.2, 192.5, 191.8, 190.5, 192.2, 194.0, 196.8, 195.7, 194.4, + 195.0, 193.7, 190.1, 191.0, 191.7, 192.0, 192.4, 192.8, 192.0, 191.0, 190.0, 191.8, 193.7, + 196.5, 195.6, 194.9, 194.5, 195.9, 197.3, 197.2, 196.1, 197.5, 198.2, 197.6, 196.9, 197.0, + 196.6, 196.7, 197.7, 197.5, 198.9, 199.5, 199.5, 200.9, 200.4, 201.1, 201.0, 198.9, 207.9, + 209.7, 208.9, 207.2, 204.3, 204.3, 208.1, 208.5, 208.1, 206.9, 209.1, 212.7, 213.5, 214.0, + 212.2, 213.2, 215.7, 217.3, 207.1, 204.6, 204.5, 208.8, 212.5, 213.9, 213.7, 209.3, 210.8, + 208.2, 210.9, 212.6, 213.0, 214.2, 213.0, 211.4, 210.3, 208.7, 209.5, 207.9, 207.4, 212.6, + 215.6, 216.4, 218.1, 217.5, 215.7, 214.9, 215.0, 214.9, 214.8, 214.4, 217.5, 218.3, 215.2, + 214.7, 209.3, 209.8, 208.9, 216.9, 218.4, 211.3, 211.8, 210.8, 214.4, 215.3, 216.4, 212.4, + 212.7, 213.1, 212.0, 212.6, 214.4, 215.0, 216.2, 206.4, 215.6, 216.9, 211.9, 213.6, 212.3, + 211.5, 206.5, 206.8, 207.2, 207.5, 207.2, 207.1, 207.8, 208.1, 210.0, 210.7, 210.2, 210.8, + 210.5, 217.2, 207.7, 207.3, 208.1, 209.5, 208.9, 209.9, 212.8, 213.1, 209.6, 210.4, 210.7, + 208.9, 207.3, 206.3, 206.4, 206.8, 207.2, 207.5, 208.9, 208.4, 213.2, 214.2, 215.2, 215.7, + 214.5, 211.2, 247.4, 209.9, 210.7, 259.2, 255.7, 244.2, 241.1, 242.6, 212.1, 236.3, 211.9, + 208.7, 209.9, 209.5, 234.2, 243.1, 238.7, 236.5, 241.0, 238.1, 211.5, 237.8, 234.9, 234.5, + 234.9, 226.3, 246.9, 227.9, 230.4, 259.6, 237.2, 237.1, 238.4, 243.8, 242.5, 239.9, 240.4, + 235.8, 240.4, 238.1, 241.4, 241.7, 242.3, 242.2, 240.6, 238.4, 247.1, 246.8, 245.2, 248.2, + 247.4, 245.4, 245.4, 242.7, 231.1, 234.0, 234.5, 233.4, 233.5, 231.9, 230.1, 242.5, 239.0, + 238.2, 233.0, 237.7, 232.3, 219.6, 219.9, 235.0, 235.9, 232.0, 228.4, 241.4, 237.8, 226.9, + 231.8, 230.5, 229.4, 227.0, 230.1, 229.5, 219.9, 226.9, 220.2, 221.3, 214.0, 214.8, 208.5, + 216.5, 217.9, 218.0, 217.9, 218.8, 554.9, 596.3, 639.3, 651.8, 655.9, 677.7, 654.8, 653.7, + 652.5, 660.3, 655.7, 632.9, 605.1, 578.6, 484.8, 496.7, 491.5, 237.4, 693.3, 682.9, 676.3, + 665.1, 672.6, 677.4, 699.5, 746.5, 793.0, 820.8, 836.9, 831.7, 813.8, 774.3, 513.7, 511.5, + 512.0, 493.9, 464.6, 457.9, 479.7, 526.0, 524.0, 515.7, 509.3, 531.0, 553.8, 538.4, 562.8, + 582.7, 541.4, 558.5, 545.3, 640.0, 583.8, 599.0, 593.8, 595.2, 603.6, 604.7, 620.9, 614.5, + 586.6, 570.3, 549.1, 511.8, 447.5, 462.2, 354.3, 358.3, 365.5, 371.3, 373.8, 377.0, 376.8, + 377.3, 377.6, 383.6, 372.4, 377.1, 382.3, 384.6, 386.4, 384.8, 382.9, 375.9, 365.9, 430.3, + 471.0, 427.2, 428.5, 403.2, 887.7, 380.7, 381.5, 382.0, 397.5, 401.8, 409.1, 418.8, 441.2, + 470.9, 490.1, 514.8, 516.4, 515.3, 490.4, 489.5, 486.6, 472.2, 410.8, 419.1, 428.3, 437.9, + 444.8, 466.6, 466.6, 465.3, 456.6, 449.4, 436.6, 430.0, 421.0, 419.8, 411.4, 398.0, 416.8, + 426.6, 425.1, 415.1, 407.2, 398.0, 385.9, 209.6, 209.3, 208.9, 208.5, 207.5, 216.6, 210.8, + 210.7, 213.0, 210.9, 211.9, 211.8, 211.8, 211.8, 211.6, 211.4, 216.1, 216.6, 216.7, 220.6, + 217.6, 218.4, 219.2, 224.9, 212.4, 237.7, 231.4, 251.4, 252.7, 252.0, 216.4, 231.7, 229.2, + 230.8, 250.7, 228.9, 228.5, 229.0, 397.4, 416.0, 438.4, 447.0, 439.3, 424.3, 218.7, 218.7, + 218.9, 218.8, 218.4, 445.6, 444.4, 455.8, 451.0, 454.8, 454.3, 455.3, 448.6, 443.6, 442.1, + 451.4, 458.2, 455.3, 452.1, 460.3, 460.0, 457.7, 467.1, 457.0, 454.1, 463.0, 459.5, 465.5, + 464.3, 456.8, 445.0, 425.5, 428.2, 218.8, 221.8, 222.7, 222.3, 223.1, 221.6, 223.5, 222.0, + 224.0, 225.4, 223.7, 222.5, 221.8, 220.9, 222.1, 224.0, 223.2, 221.6, 220.3, 218.8, 226.4, + 228.2, 225.4, 224.2, 216.9, 216.5, 217.1, 217.6, 217.9, 217.6, 217.0, 216.1, 216.6, 220.6, + 219.5, 218.9, 220.8, 221.5, 222.5, 220.2, 217.7, 216.4, 488.4, 497.5, 503.2, 515.2, 518.5, + 522.0, 535.7, 542.3, 542.3, 541.8, 536.7, 518.9, 493.5, 216.1, 217.8, 219.5, 220.5, 221.2, + 220.0, 516.0, 218.0, 218.4, 523.4, 502.5, 505.8, 494.9, 220.9, 221.3, 220.8, 220.4, 219.3, + 218.3, 217.5, 216.5, 215.5, 214.2, 213.0, 212.9, 213.3, 213.5, 473.5, 477.6, 487.5, 492.6, + 217.2, 219.2, 220.7, 220.7, 220.7, 218.9, 216.5, 214.4, 212.5, 210.6, 210.7, 210.9, 211.8, + 212.9, 213.7, 213.7, 213.7, 212.6 + ], + "slopeEast": [ + 0.002, -0.001, 0.056, 0.043, 0.022, 0.002, 0.007, -0.005, 0.014, 0.012, 0.002, -0.013, 0.046, + 0.027, -0.083, -0.005, -0.017, -0.01, -0.007, -0.03, -0.051, -0.056, -0.058, -0.088, -0.109, + -0.099, -0.085, -0.079, -0.077, 0.008, 0.005, 0.006, -0.01, 0.062, 0.068, 0.071, 0.074, 0.084, + 0.019, -0.001, 0.023, 0.02, 0.021, 0.024, 0.025, 0.031, 0.054, 0.031, 0.003, -0.022, -0.034, + -0.054, -0.055, -0.049, -0.047, -0.045, -0.038, -0.041, -0.051, -0.054, -0.073, -0.086, -0.104, + -0.05, -0.085, -0.083, 0.125, 0.082, 0.049, 0.018, 0.064, 0.034, -0.013, -0.004, -0.06, -0.087, + -0.128, -0.166, -0.129, -0.038, -0.154, -0.158, -0.163, -0.116, -0.12, 0.088, 0.055, 0.048, + 0.095, 0.036, 0.154, 0.016, -0.009, -0.018, -0.079, -0.121, -0.161, -0.174, 0.003, 0.037, 0.108, + 0.103, 0.1, 0.106, 0.101, -0.064, -0.045, 0.013, 0.149, 0.083, 0.078, 0.071, 0.039, -0.062, + -0.065, -0.118, -0.105, -0.072, -0.045, -0.025, -0.001, -0.008, -0.007, -0.02, -0.037, -0.058, + -0.078, -0.075, -0.069, -0.063, -0.054, -0.008, 0.003, 0.123, 0.036, 0.015, -0.005, 0.017, + 0.049, 0.086, 0.068, 0.013, 0.032, -0.024, -0.01, 0.005, 0.009, -0.005, -0.004, 0.021, 0.062, + -0.012, -0.014, -0.013, -0.031, 0.02, -0.004, -0.021, -0.005, -0.018, -0.026, -0.032, -0.038, + -0.046, -0.055, -0.063, -0.067, -0.007, 0.009, -0.048, -0.051, -0.051, -0.004, -0.015, -0.025, + -0.018, 0.002, -0.002, 0.005, 0.013, 0.02, 0.049, 0.013, 0.002, -0.023, -0.009, -0.075, -0.065, + 0.018, 0.02, 0.024, -0.023, -0.022, -0.049, 0.01, 0.056, -0.003, -0.001, 0.008, -0.048, -0.05, + -0.03, -0.015, 0.002, -0.045, -0.077, -0.052, -0.113, -0.035, 0.052, 0.029, -0.006, -0.008, + 0.119, 0.111, 0.049, 0.019, -0.001, -0.008, 0.003, 0.025, 0.028, 0.004, -0.059, 0.008, 0.005, + -0.0, 0.055, 0.03, -0.044, -0.024, -0.002, -0.029, -0.015, -0.028, -0.04, -0.079, -0.084, + -0.073, 0.118, 0.104, 0.095, 0.098, 0.122, -0.042, 0.014, 0.108, 0.117, -0.077, -0.066, -0.052, + 0.006, 0.018, -0.026, -0.008, 0.016, -0.019, -0.02, 0.006, 0.033, 0.056, 0.035, 0.017, 0.002, + 0.011, 0.009, -0.015, 0.039, -0.016, -0.019, 0.073, 0.06, 0.028, 0.012, -0.052, -0.037, -0.027, + -0.026, -0.025, -0.027, -0.034, -0.033, -0.112, -0.098, -0.018, -0.005, 0.014, -0.002, -0.012, + -0.021, -0.041, -0.06, -0.073, -0.068, -0.007, 0.023, -0.002, 0.014, 0.031, 0.042, 0.045, 0.05, + 0.06, 0.075, 0.066, 0.054, 0.029, 0.03, -0.066, -0.045, -0.033, -0.035, 0.1, 0.072, 0.022, + -0.017, 0.002, 0.006, 0.105, 0.007, -0.015, 0.014, -0.019, -0.045, 0.021, -0.027, -0.032, + -0.043, 0.023, -0.048, 0.051, -0.072, -0.099, -0.013, 0.016, 0.062, 0.05, 0.066, 0.041, 0.022, + -0.031, -0.028, -0.003, 0.047, -0.019, -0.007, 0.014, -0.047, 0.019, -0.023, -0.023, 0.008, + -0.064, 0.049, -0.035, -0.026, 0.01, 0.043, 0.057, 0.094, -0.076, -0.07, 0.043, 0.029, 0.051, + 0.001, 0.036, 0.056, -0.016, -0.042, -0.015, -0.0, 0.037, 0.052, 0.089, 0.032, 0.021, 0.06, + -0.018, 0.018, 0.002, 0.036, 0.058, -0.043, 0.005, -0.0, -0.004, -0.084, -0.088, 0.055, -0.004, + 0.02, 0.027, 0.018, -0.003, -0.013, -0.01, -0.005, 0.032, 0.037, -0.085, -0.114, 0.016, -0.076, + -0.081, 0.043, 0.029, 0.014, -0.247, -0.334, -0.183, -0.03, -0.023, 0.027, 0.067, 0.018, -0.17, + 0.04, 0.076, 0.16, 0.142, 0.214, -0.239, -0.036, 0.236, 0.006, -0.075, -0.015, -0.134, 0.191, + -0.098, -0.235, -0.094, -0.348, -0.156, -0.126, 0.06, -0.008, 0.039, 0.184, 0.048, 0.037, 0.035, + -0.057, -0.06, 0.062, -0.295, -0.071, -0.175, 0.046, -0.045, -0.173, -0.173, -0.161, -0.148, + 0.042, -0.034, -0.145, 0.247, 0.002, -0.209, 0.028, -0.129, -0.037, 0.154, -0.069, -0.023, + 0.149, 0.119, -0.146, 0.175, 0.112, -0.005, -0.029, -0.11, -0.064, -0.06, -0.018, -0.04, 0.03, + -0.087, -0.043, -0.015, -0.031, -0.13, -0.068, -0.06, 0.001, -0.021, -0.0, -0.013, 0.032, 0.012, + -0.025, 0.013, 0.081, 0.008, -0.101, 0.006, -0.063, -0.072, -0.034, -0.109, -0.03, -0.058, + -0.068, -0.174, -0.1, -0.092, -0.088, -0.053, 0.072, 0.007, -0.043, -0.029, -0.004, -0.033, + -0.07, -0.071, -0.07, -0.077, -0.07, -0.021, -0.02, -0.061, -0.004, -0.114, -0.02, -0.023, + 0.032, 0.05, -0.037, -0.005, -0.02, -0.109, -0.063, 0.014, -0.015, -0.037, 0.007, 0.019, 0.035, + 0.051, 0.064, -0.018, -0.034, -0.025, 0.038, -0.074, -0.071, -0.068, -0.065, -0.061, -0.056, + -0.05, -0.038, -0.032, -0.02, 0.035, -0.023, -0.034, -0.042, -0.009, -0.053, -0.061, -0.048, + 0.032, 0.055, -0.052, 0.019, 0.04, -0.026, 0.007, -0.033, -0.04, -0.044, -0.047, 0.134, -0.002, + 0.095, 0.061, -0.082, -0.127, 0.039, 0.057, 0.077, 0.092, 0.081, 0.007, -0.041, 0.057, -0.038, + 0.02, 0.004, -0.126, -0.118, -0.042, -0.061, -0.009, -0.034, -0.028, -0.016, 0.066, -0.008, + -0.033, -0.062, -0.168, 0.054, 0.058, -0.007, 0.007, -0.054, -0.042, -0.257, -0.152, 0.009, + 0.029, 0.032, 0.036, -0.033, -0.046, -0.05, 0.018, -0.059, -0.065, -0.078, -0.093, -0.109, + -0.126, -0.136, -0.072, -0.057, -0.05, -0.039, -0.032, -0.029, -0.024, -0.055, -0.05, -0.057, + 0.019, 0.031, 0.057, 0.078, 0.091, 0.084, 0.071, 0.05, 0.033, 0.066, 0.024, -0.039, -0.043, + -0.062, -0.086, -0.078, -0.035, -0.041, 0.114, -0.004, 0.08, -0.043, 0.008, 0.035, 0.117, 0.075, + 0.091, -0.088, -0.074, -0.157, -0.187, 0.023, 0.023, 0.021, 0.021, 0.017, 0.002, -0.01, 0.054, + 0.037, 0.016, 0.058, -0.096, -0.292, 0.009, 0.007, -0.003, -0.011, -0.009, -0.005, -0.017, + -0.031, -0.04, -0.029, -0.019, 0.007, 0.036, 0.064, 0.06, 0.091, 0.084, 0.021, 0.044, 0.024, + 0.007, 0.007, 0.006, -0.001, -0.004, -0.011, -0.026, -0.037, -0.048, -0.06, -0.057, -0.054, + -0.051, -0.031, -0.014, -0.007 + ], + "slopeNorth": [ + -0.005, 0.005, -0.003, 0.002, -0.006, -0.016, -0.044, -0.027, -0.024, -0.026, -0.026, -0.026, + -0.05, -0.054, 0.02, -0.042, -0.033, -0.022, -0.009, 0.008, 0.025, 0.032, 0.037, 0.003, 0.01, + 0.01, 0.013, 0.017, 0.026, 0.059, 0.018, -0.02, -0.039, 0.024, 0.024, 0.008, -0.005, -0.003, + -0.06, -0.088, -0.024, -0.033, -0.037, -0.039, -0.046, -0.052, -0.026, -0.019, -0.023, -0.025, + -0.027, -0.106, -0.113, -0.118, -0.111, -0.103, -0.016, -0.016, -0.129, -0.122, -0.096, -0.075, + -0.046, -0.071, 0.056, 0.061, -0.108, -0.077, 0.028, 0.067, -0.029, -0.009, 0.048, -0.009, + -0.029, -0.018, 0.006, 0.025, 0.144, 0.19, 0.17, 0.167, 0.149, -0.078, -0.083, -0.0, -0.081, + -0.04, -0.001, -0.033, -0.082, -0.031, 0.061, 0.14, 0.078, 0.132, 0.104, 0.127, -0.011, -0.01, + 0.071, 0.129, 0.113, 0.103, 0.13, -0.038, -0.037, 0.044, 0.052, 0.016, 0.01, -0.01, -0.02, + -0.014, 0.06, 0.086, 0.082, 0.067, 0.049, 0.051, 0.058, -0.08, -0.095, -0.099, -0.098, -0.093, + -0.064, -0.024, 0.007, 0.03, 0.041, -0.085, -0.064, -0.021, -0.069, -0.063, -0.054, -0.027, + 0.013, 0.058, 0.082, 0.091, 0.065, 0.04, 0.085, 0.117, 0.139, 0.134, 0.059, 0.047, 0.008, 0.002, + 0.005, 0.01, 0.034, 0.039, 0.068, 0.077, -0.041, -0.017, -0.011, -0.017, -0.021, -0.021, -0.003, + 0.016, 0.034, -0.057, -0.046, -0.067, -0.062, -0.059, -0.032, -0.028, -0.023, -0.01, -0.01, + -0.033, -0.028, -0.004, 0.019, -0.01, 0.056, 0.067, -0.061, -0.049, -0.108, -0.079, -0.0, 0.018, + 0.024, 0.053, 0.08, 0.085, -0.008, 0.015, 0.016, 0.027, 0.048, -0.05, -0.038, -0.058, -0.086, + -0.001, -0.061, -0.038, -0.014, 0.027, 0.062, -0.036, -0.007, 0.046, -0.023, -0.001, 0.01, + -0.073, 0.053, 0.073, -0.02, 0.004, -0.001, -0.014, -0.069, -0.016, -0.041, 0.001, 0.021, + -0.017, -0.019, -0.115, -0.11, -0.083, -0.119, -0.097, -0.076, -0.101, -0.069, -0.054, -0.022, + -0.045, -0.028, -0.016, -0.008, 0.019, -0.037, 0.005, 0.007, 0.01, -0.092, -0.096, -0.079, + 0.074, 0.07, -0.076, -0.025, -0.075, -0.039, 0.003, -0.006, 0.013, -0.03, -0.064, -0.079, + -0.076, -0.056, -0.026, 0.042, -0.084, 0.038, 0.055, 0.002, 0.033, 0.01, -0.003, 0.001, -0.02, + -0.041, -0.057, -0.056, -0.053, -0.045, -0.037, 0.043, 0.037, -0.023, -0.013, -0.012, 0.063, + 0.029, 0.024, 0.012, -0.007, 0.01, 0.006, -0.057, 0.007, -0.017, 0.02, 0.061, 0.058, 0.039, + 0.021, 0.001, -0.02, -0.025, -0.024, 0.039, 0.052, 0.03, 0.03, 0.028, 0.035, 0.003, 0.005, + 0.027, -0.048, -0.059, 0.005, -0.037, -0.029, -0.058, -0.066, -0.009, -0.02, 0.014, -0.009, + -0.015, -0.01, -0.045, -0.036, -0.085, -0.017, 0.011, -0.052, 0.048, -0.025, -0.024, -0.035, + -0.031, -0.05, -0.058, -0.013, -0.05, -0.053, -0.058, -0.033, -0.017, 0.001, -0.025, -0.066, + -0.023, -0.014, -0.011, -0.059, -0.019, -0.009, 0.005, -0.007, -0.034, -0.051, 0.002, 0.036, + 0.0, 0.007, -0.022, -0.028, -0.046, -0.051, -0.018, -0.021, -0.013, 0.012, -0.005, -0.035, + -0.036, 0.011, -0.065, 0.025, 0.006, 0.018, -0.103, 0.005, -0.007, -0.025, -0.061, -0.044, + -0.049, -0.008, 0.004, -0.043, 0.022, -0.062, -0.019, -0.055, 0.001, -0.01, -0.057, -0.005, + -0.051, -0.064, -0.023, 0.029, -0.044, 0.079, 0.066, -0.049, -0.043, 0.043, 0.037, 0.009, -0.04, + -0.175, -0.046, -0.025, -0.055, -0.045, 0.01, -0.078, -0.036, -0.127, -0.126, -0.145, 0.102, + 0.094, -0.123, 0.055, -0.109, 0.133, -0.142, -0.009, -0.085, 0.067, 0.024, 0.056, -0.06, 0.156, + 0.025, -0.068, 0.018, -0.106, 0.077, 0.019, -0.02, -0.043, 0.021, -0.021, 0.053, -0.023, -0.091, + -0.044, 0.016, 0.174, 0.024, 0.172, 0.213, -0.05, -0.021, -0.051, -0.255, 0.041, 0.076, -0.168, + 0.031, -0.153, -0.058, -0.022, 0.063, -0.084, -0.122, -0.153, -0.157, -0.171, 0.014, 0.054, + 0.036, 0.084, 0.009, 0.019, 0.018, -0.012, 0.013, 0.008, 0.011, 0.061, 0.061, 0.057, -0.013, + -0.036, -0.024, -0.045, -0.046, -0.101, -0.156, -0.066, -0.239, -0.1, -0.077, -0.243, -0.08, + 0.073, 0.039, 0.179, 0.106, 0.005, 0.086, 0.044, 0.321, 0.232, 0.195, 0.145, -0.074, -0.069, + -0.057, 0.021, -0.1, -0.165, 0.063, 0.139, 0.12, 0.091, 0.134, 0.082, 0.064, -0.056, -0.118, + -0.146, -0.004, -0.123, -0.142, -0.092, -0.121, 0.172, 0.209, 0.083, -0.15, -0.088, -0.128, + -0.152, -0.196, 0.004, -0.017, -0.027, -0.037, -0.042, 0.021, 0.014, 0.009, 0.003, 0.082, 0.074, + 0.061, 0.048, 0.031, 0.013, -0.006, 0.081, 0.074, 0.055, -0.036, 0.041, 0.024, 0.006, -0.068, + -0.022, 0.073, 0.021, 0.055, 0.009, 0.022, 0.079, -0.019, 0.052, 0.08, 0.02, 0.073, 0.04, 0.01, + 0.224, 0.291, 0.218, -0.011, -0.137, -0.234, 0.091, 0.094, 0.095, 0.093, 0.062, 0.02, 0.061, + 0.003, 0.044, -0.063, 0.145, -0.012, -0.046, -0.011, 0.075, 0.117, 0.046, -0.033, 0.108, 0.084, + -0.011, -0.094, -0.024, -0.13, 0.075, 0.1, 0.124, -0.018, -0.058, -0.136, -0.149, -0.227, + -0.022, 0.105, 0.119, 0.113, 0.09, 0.076, 0.045, -0.045, 0.032, 0.029, 0.034, 0.051, 0.055, + 0.044, 0.028, -0.072, -0.095, -0.088, -0.06, -0.032, -0.005, 0.003, -0.016, -0.008, 0.039, + 0.085, 0.076, 0.061, 0.046, 0.035, 0.031, 0.022, 0.018, 0.014, 0.006, 0.014, 0.009, -0.002, + -0.002, 0.003, -0.003, -0.024, -0.018, 0.068, 0.212, 0.049, 0.187, 0.06, 0.024, 0.003, -0.084, + -0.173, 0.03, -0.138, -0.17, -0.157, 0.006, -0.007, -0.026, -0.034, -0.038, -0.045, 0.001, + -0.075, -0.067, 0.237, 0.215, 0.109, 0.055, -0.068, -0.066, -0.059, -0.054, -0.059, -0.062, + -0.065, -0.07, -0.072, -0.064, -0.054, -0.045, -0.035, -0.022, 0.059, 0.161, 0.071, 0.021, + -0.021, -0.027, -0.033, -0.039, -0.044, -0.038, -0.029, -0.034, -0.057, -0.078, -0.075, -0.071, + -0.076, -0.085, -0.083, -0.057, -0.03, -0.018 + ] +} diff --git a/src/app/features/scene/ridge/weather.ts b/src/app/features/scene/ridge/weather.ts index 68ea88ea..edcb2ae3 100644 --- a/src/app/features/scene/ridge/weather.ts +++ b/src/app/features/scene/ridge/weather.ts @@ -43,7 +43,9 @@ export function weatherOf(value: number): Weather { } /** The page URL's `?weather=<0..1>`, when it is a number. */ -export function urlWeather(search: string = typeof location === 'undefined' ? '' : location.search): number | undefined { +export function urlWeather( + search: string = typeof location === 'undefined' ? '' : location.search +): number | undefined { const v = new URLSearchParams(search).get('weather'); if (v === null || v.trim() === '') return undefined; const n = Number(v); diff --git a/src/app/features/scene/ridge/weatherFeed.test.ts b/src/app/features/scene/ridge/weatherFeed.test.ts index e12bbefc..0f490cad 100644 --- a/src/app/features/scene/ridge/weatherFeed.test.ts +++ b/src/app/features/scene/ridge/weatherFeed.test.ts @@ -30,7 +30,9 @@ const NOW = new Date('2026-11-11T10:50:00Z'); describe('weather from the file', () => { it('credits GeoSphere as its legal page asks, and claims no licence', () => { - expect(WEATHER_ATTRIBUTION).toBe('Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at'); + expect(WEATHER_ATTRIBUTION).toBe( + 'Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at' + ); expect(WEATHER_ATTRIBUTION).not.toMatch(/CC/); }); it('rain is 0.8..1, by rate', () => { @@ -42,21 +44,41 @@ describe('weather from the file', () => { expect(clearSkyGhi(-5)).toBe(0); }); it('noon clear: radiation at the clear-sky value is no cloud', () => { - expect(weatherFromFile(file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 600, humidity_pct: 95 }), 21)).toBe(0); + expect( + weatherFromFile( + file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 600, humidity_pct: 95 }), + 21 + ) + ).toBe(0); expect(cloudFromGlow(300, 21)).toBeCloseTo(0.56, 1); // k = 0.9 is still a few oktas }); it('noon overcast: a fifth of the clear-sky radiation is nearly full cloud, and with saturated air, mist', () => { expect(cloudFromGlow(0.2 * clearSkyGhi(21), 21)).toBeGreaterThan(0.9); - expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 80 }), 21)).toBeGreaterThan(0.6); - expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 100 }), 21)).toBe(0.75); + expect( + weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 80 }), 21) + ).toBeGreaterThan(0.6); + expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 100 }), 21)).toBe( + 0.75 + ); }); it('the radiation, not the humidity, decides by day: humid air under a bright sky is clear', () => { - expect(weatherFromFile(file({ glow_wm2: clearSkyGhi(30), sunshine_s_10min: 600, humidity_pct: 98 }), 30)).toBe(0); + expect( + weatherFromFile( + file({ glow_wm2: clearSkyGhi(30), sunshine_s_10min: 600, humidity_pct: 98 }), + 30 + ) + ).toBe(0); }); it('dusk: with the sun at 3 degrees the humidity ramp speaks, cloud 0 at 60% and 1 at 95%', () => { - expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 60 }), 3)).toBe(0); - expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 77.5 }), 3)).toBeCloseTo(0.35, 2); - expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 95 }), 3)).toBeCloseTo(0.7, 2); + expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 60 }), 3)).toBe( + 0 + ); + expect( + weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 77.5 }), 3) + ).toBeCloseTo(0.35, 2); + expect( + weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 95 }), 3) + ).toBeCloseTo(0.7, 2); }); it('night: the radiation and sunshine are 0.0 and the humidity speaks; saturated air is mist, 0.75', () => { expect(weatherFromFile(file({ glow_wm2: 0, humidity_pct: 40 }), -20)).toBe(0); @@ -68,21 +90,30 @@ describe('weather from the file', () => { for (const g of [56, 72, 89]) expect(cloudFromGlow(g, nov)).toBeGreaterThan(0.95); for (const g of [546, 569]) expect(cloudFromGlow(g, oct)).toBe(0); expect(weatherFromFile(file({ glow_wm2: 72, sunshine_s_10min: 0 }), nov)).toBeGreaterThan(0.65); - expect(weatherFromFile(file({ glow_wm2: 560, sunshine_s_10min: 600, humidity_pct: 70 }), oct)).toBe(0); + expect( + weatherFromFile(file({ glow_wm2: 560, sunshine_s_10min: 600, humidity_pct: 70 }), oct) + ).toBe(0); }); it('the sunshine cross-checks the radiation: agreement keeps the radiation, a half-sky disagreement splits it', () => { - expect(cloudEstimate(file({ glow_wm2: 0.2 * clearSkyGhi(21), sunshine_s_10min: 0 }), 21)).toBeGreaterThan(0.9); + expect( + cloudEstimate(file({ glow_wm2: 0.2 * clearSkyGhi(21), sunshine_s_10min: 0 }), 21) + ).toBeGreaterThan(0.9); // Radiation says clear, the sunshine says none: each is half trusted. expect(cloudEstimate(file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 0 }), 21)).toBe(0.5); // No radiation reading by day: the sunshine speaks alone. expect(cloudEstimate(file({ glow_wm2: null, sunshine_s_10min: 300 }), 21)).toBe(0.5); expect(cloudEstimate(file({ glow_wm2: 0, sunshine_s_10min: 600 }), 21)).toBe(0); }); - it('with the sun below the instruments\' reach there is no estimate: the humidity speaks', () => { + it("with the sun below the instruments' reach there is no estimate: the humidity speaks", () => { expect(cloudEstimate(file({ glow_wm2: 3, sunshine_s_10min: 0 }), 3)).toBeNull(); }); it('null: unknown measurements are not zero, and nothing known is a clear sky', () => { - expect(weatherFromFile(file({ glow_wm2: null, humidity_pct: null, precip_mm_10min: null, sunshine_s_10min: null }), 21)).toBe(CLEAR_SKY); + expect( + weatherFromFile( + file({ glow_wm2: null, humidity_pct: null, precip_mm_10min: null, sunshine_s_10min: null }), + 21 + ) + ).toBe(CLEAR_SKY); expect(weatherFromFile(file({ glow_wm2: null, humidity_pct: 95 }), 21)).toBeCloseTo(0.7, 2); }); }); @@ -97,17 +128,26 @@ describe('the weather now', () => { expect(currentWeather(f, NOW)).toBe(CLEAR_SKY); }); it('the 30 minute mark itself is still fresh', () => { - expect(currentWeather(file({ glow_wm2: 0, humidity_pct: 100, fetched_at: '2026-11-11T10:20:00Z' }), NOW)).toBe(0.75); + expect( + currentWeather( + file({ glow_wm2: 0, humidity_pct: 100, fetched_at: '2026-11-11T10:20:00Z' }), + NOW + ) + ).toBe(0.75); }); it('falls back to a clear sky when the observation is more than 40 minutes old, though the file is fresh', () => { const f = file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:09:00Z' }); expect(NOW.getTime() - Date.parse(f.observed_at)).toBeGreaterThan(OBSERVATION_STALE_MS); expect(currentWeather(f, NOW)).toBe(CLEAR_SKY); - expect(currentWeather(file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:11:00Z' }), NOW)).toBeGreaterThan(0.8); + expect( + currentWeather(file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:11:00Z' }), NOW) + ).toBeGreaterThan(0.8); }); it('falls back to a clear sky with no file, and with a fetch time from the future', () => { expect(currentWeather(null, NOW)).toBe(CLEAR_SKY); - expect(currentWeather(file({ precip_mm_10min: 2, fetched_at: '2026-11-12T10:40:00Z' }), NOW)).toBe(CLEAR_SKY); + expect( + currentWeather(file({ precip_mm_10min: 2, fetched_at: '2026-11-12T10:40:00Z' }), NOW) + ).toBe(CLEAR_SKY); }); }); @@ -117,23 +157,45 @@ describe('bad instrument values', () => { }); it('the radiation is set against the sun of the ten minutes it averaged, not the sun now', () => { // Sun rising: 07:45Z observation (mean of 07:35-07:45), read 35 minutes later at the stale limit. - const f = file({ observed_at: '2026-11-11T07:45:00Z', fetched_at: '2026-11-11T07:46:00Z', glow_wm2: 100, sunshine_s_10min: null, humidity_pct: 50 }); + const f = file({ + observed_at: '2026-11-11T07:45:00Z', + fetched_at: '2026-11-11T07:46:00Z', + glow_wm2: 100, + sunshine_s_10min: null, + humidity_pct: 50, + }); const at = (m: number) => currentWeather(f, new Date(Date.parse(f.observed_at) + m * 60_000)); expect(at(0)).toBe(at(29)); }); }); describe('reading weather.json', () => { - it('reads Eureka\'s file as the host writes it: Z times, a whole-station gap as nulls, 0.0 at night', () => { + it("reads Eureka's file as the host writes it: Z times, a whole-station gap as nulls, 0.0 at night", () => { const night = JSON.parse( '{"fetched_at":"2026-11-11T22:48:22Z","observed_at":"2026-11-11T22:40:00Z","station":11035,"precip_mm_10min":0.1,"humidity_pct":100.0,"sunshine_s_10min":0.0,"glow_wm2":0.0,"attribution":"Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at"}' ); - expect(parseWeatherFile(night)).toMatchObject({ precip_mm_10min: 0.1, humidity_pct: 100, glow_wm2: 0, sunshine_s_10min: 0 }); - const gap = parseWeatherFile({ ...night, precip_mm_10min: null, humidity_pct: null, sunshine_s_10min: null, glow_wm2: null }); - expect(gap).toMatchObject({ precip_mm_10min: null, humidity_pct: null, sunshine_s_10min: null, glow_wm2: null }); + expect(parseWeatherFile(night)).toMatchObject({ + precip_mm_10min: 0.1, + humidity_pct: 100, + glow_wm2: 0, + sunshine_s_10min: 0, + }); + const gap = parseWeatherFile({ + ...night, + precip_mm_10min: null, + humidity_pct: null, + sunshine_s_10min: null, + glow_wm2: null, + }); + expect(gap).toMatchObject({ + precip_mm_10min: null, + humidity_pct: null, + sunshine_s_10min: null, + glow_wm2: null, + }); expect(currentWeather(gap, new Date('2026-11-11T22:50:00Z'))).toBe(CLEAR_SKY); }); - it('is not another station\'s file', () => { + it("is not another station's file", () => { expect(parseWeatherFile({ ...file(), station: 11036 })).toBeNull(); }); it('parses the agreed shape, keeping nulls and the sunshine it never reads', () => { @@ -146,15 +208,30 @@ describe('reading weather.json', () => { expect(parseWeatherFile(null)).toBeNull(); expect(parseWeatherFile('x')).toBeNull(); }); - it('rejects a time with no zone: it would be read in the client\'s local time', () => { + it("rejects a time with no zone: it would be read in the client's local time", () => { expect(parseWeatherFile({ ...file(), observed_at: '2026-11-10T10:40:00' })).toBeNull(); - expect(parseWeatherFile({ ...file(), observed_at: '2026-11-10T11:40:00+01:00' })).not.toBeNull(); + expect( + parseWeatherFile({ ...file(), observed_at: '2026-11-10T11:40:00+01:00' }) + ).not.toBeNull(); }); it('is no observation when the file is missing, fails, or is not JSON', async () => { - expect(await fetchWeatherFile('/weather.json', (async () => ({ ok: false })) as unknown as typeof fetch)).toBeNull(); - expect(await fetchWeatherFile('/weather.json', (async () => { throw new Error('offline'); }) as unknown as typeof fetch)).toBeNull(); expect( - await fetchWeatherFile('/weather.json', (async () => ({ ok: true, json: async () => { throw new Error('bad'); } })) as unknown as typeof fetch) + await fetchWeatherFile('/weather.json', (async () => ({ + ok: false, + })) as unknown as typeof fetch) + ).toBeNull(); + expect( + await fetchWeatherFile('/weather.json', (async () => { + throw new Error('offline'); + }) as unknown as typeof fetch) + ).toBeNull(); + expect( + await fetchWeatherFile('/weather.json', (async () => ({ + ok: true, + json: async () => { + throw new Error('bad'); + }, + })) as unknown as typeof fetch) ).toBeNull(); }); it('reads a good file', async () => { diff --git a/src/app/features/scene/ridge/weatherFeed.ts b/src/app/features/scene/ridge/weatherFeed.ts index 39379fd5..b2b4a7b0 100644 --- a/src/app/features/scene/ridge/weatherFeed.ts +++ b/src/app/features/scene/ridge/weatherFeed.ts @@ -34,7 +34,8 @@ const clamp01 = (v: number): number => Math.min(1, Math.max(0, v)); const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null); /** An ISO time that names its zone (`Z` or an offset); `Date.parse` would read a bare one in the client's local time. */ -const isZonedTime = (v: unknown): v is string => typeof v === 'string' && /(Z|[+-]\d\d:?\d\d)$/.test(v) && !Number.isNaN(Date.parse(v)); +const isZonedTime = (v: unknown): v is string => + typeof v === 'string' && /(Z|[+-]\d\d:?\d\d)$/.test(v) && !Number.isNaN(Date.parse(v)); /** A `WeatherFile` from parsed JSON, or null when it does not have the shape (times missing or unparseable). */ export function parseWeatherFile(json: unknown): WeatherFile | null { @@ -96,8 +97,10 @@ export function cloudEstimate(f: WeatherFile, sunEl: number): number | null { export function weatherFromFile(f: WeatherFile, sunEl: number): number { const rain = f.precip_mm_10min ?? 0; if (rain > 0) return clamp01(0.8 + 0.2 * Math.min(1, rain / 1.5)); - const cloud = cloudEstimate(f, sunEl) ?? (f.humidity_pct === null ? 0 : clamp01((f.humidity_pct - 60) / 35)); - const mist = f.humidity_pct === null || cloud < 0.5 ? 0 : clamp01((f.humidity_pct - 92) / 8) * 0.75; + const cloud = + cloudEstimate(f, sunEl) ?? (f.humidity_pct === null ? 0 : clamp01((f.humidity_pct - 60) / 35)); + const mist = + f.humidity_pct === null || cloud < 0.5 ? 0 : clamp01((f.humidity_pct - 92) / 8) * 0.75; return Math.max(0.7 * cloud, mist); } @@ -106,13 +109,24 @@ export function currentWeather(f: WeatherFile | null, now: Date = new Date()): n if (!f) return CLEAR_SKY; const fetched = now.getTime() - Date.parse(f.fetched_at); const observed = now.getTime() - Date.parse(f.observed_at); - if (!(fetched <= STALE_MS && fetched >= -SKEW_MS && observed <= OBSERVATION_STALE_MS && observed >= -SKEW_MS)) return CLEAR_SKY; + if ( + !( + fetched <= STALE_MS && + fetched >= -SKEW_MS && + observed <= OBSERVATION_STALE_MS && + observed >= -SKEW_MS + ) + ) + return CLEAR_SKY; // GLOW and SO are means of the ten minutes ending at observed_at: the sun of their midpoint is the one to set them against. return weatherFromFile(f, viennaSun(new Date(Date.parse(f.observed_at) - 5 * 60_000)).elevation); } /** Fetch and parse `weather.json`; null on any failure. */ -export async function fetchWeatherFile(url: string, fetcher: typeof fetch = fetch): Promise { +export async function fetchWeatherFile( + url: string, + fetcher: typeof fetch = fetch +): Promise { try { const res = await fetcher(url, { cache: 'no-store' }); return res.ok ? parseWeatherFile(await res.json()) : null; diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts index 42631d57..6c7d7efd 100644 --- a/src/app/features/scene/shaderSource.ts +++ b/src/app/features/scene/shaderSource.ts @@ -37,5 +37,7 @@ export function fragmentSource(stage: Stage, defines = ''): string { const erosion = solid && WORLD.phacelleGradBound > 0 ? phacelle : ''; const world = solid ? sdf : ''; const math = trimGlsl(cultmath, [erosion, common, world, stages[stage]]); - return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${stages[stage]}`; + return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${ + stages[stage] + }`; } diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 7c8e14b1..a04ac173 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -88,7 +88,13 @@ describe('ridgeSkyFor', () => { }); it('draws the sky auto resolves to, never nothing', () => { - const key = (s: ReturnType) => [s.top, s.mid, s.horizon, s.band, s.physical?.sun]; + const key = (s: ReturnType) => [ + s.top, + s.mid, + s.horizon, + s.band, + s.physical?.sun, + ]; setSkyClock(() => atStandardTime(12, 0)); expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('day', 0.3))); setSkyClock(() => atStandardTime(0, 0)); diff --git a/src/app/features/scene/skyInk.test.ts b/src/app/features/scene/skyInk.test.ts index 0bf4ea06..c92892f6 100644 --- a/src/app/features/scene/skyInk.test.ts +++ b/src/app/features/scene/skyInk.test.ts @@ -7,11 +7,11 @@ import type { FixedSkyState } from './skyBand'; import { LIGHTEST_PATCH, NIGHT_INK_MAX_L, - NOTICE_INK, - NOTICE_SCRIM, + SCRIM_INK, + SCRIM_GROUND, inkForState, labelInk, - noticeStyle, + scrimStyle, sceneInk, userColorInk, } from './skyInk'; @@ -183,24 +183,24 @@ describe('labelInk', () => { }); }); -describe('noticeStyle', () => { +describe('scrimStyle', () => { // The scrim over the lightest possible ground (white) is the worst case the ink can meet. - const [r, g, b, a] = NOTICE_SCRIM.match(/[\d.]+/g)!.map(Number); + const [r, g, b, a] = (SCRIM_GROUND.match(/[\d.]+/g) ?? []).map(Number); const worst = chroma.rgb( ...([r, g, b].map((c) => c * a + 255 * (1 - a)) as [number, number, number]) ); const ratio = (x: number, y: number) => (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); it('clears AA for its ink over its scrim even on a white ground', () => { - expect(ratio(luminance(NOTICE_INK), luminance(worst.hex()))).toBeGreaterThanOrEqual(AA); + expect(ratio(luminance(SCRIM_INK), luminance(worst.hex()))).toBeGreaterThanOrEqual(AA); }); it('is the same in every sky state and viewport while a scene runs, and absent without one', () => { const styles = (['day', 'dusk', 'night'] as FixedSkyState[]).flatMap((state) => - [true, false].map((phone) => noticeStyle({ state, phone })) + [true, false].map((phone) => scrimStyle({ state, phone })) ); styles.forEach((x) => expect(x).toEqual(styles[0])); - expect(styles[0]).toMatchObject({ color: NOTICE_INK, backgroundColor: NOTICE_SCRIM }); - expect(noticeStyle(undefined)).toBeUndefined(); + expect(styles[0]).toMatchObject({ color: SCRIM_INK, backgroundColor: SCRIM_GROUND }); + expect(scrimStyle(undefined)).toBeUndefined(); }); }); diff --git a/src/app/features/scene/skyInk.ts b/src/app/features/scene/skyInk.ts index 07671f5a..b8e38f31 100644 --- a/src/app/features/scene/skyInk.ts +++ b/src/app/features/scene/skyInk.ts @@ -103,11 +103,14 @@ export const labelInk = ({ state, phone }: SkyInk): string | undefined => * the scene is behind it. The scrim is composited over the lightest ground there is (white); the ink over that * worst case clears AA (skyInk.test.ts). */ -export const NOTICE_INK = baked.midnight.tokens.SurfaceVariant.OnContainer; -export const NOTICE_SCRIM = 'rgba(10, 14, 24, 0.82)'; +export const SCRIM_INK = baked.midnight.tokens.SurfaceVariant.OnContainer; +export const SCRIM_GROUND = 'rgba(10, 14, 24, 0.82)'; -/** The style of the notice line while a scene runs behind it: its own ground and ink, or undefined without one. */ -export const noticeStyle = (ink: SkyInk | undefined) => +/** + * The style of a line of text that must be read whatever the scene draws behind it (the training notice, the desk's + * recorded rulings): its own ground and ink while a scene runs, or undefined without one. + */ +export const scrimStyle = (ink: SkyInk | undefined) => ink - ? { color: NOTICE_INK, backgroundColor: NOTICE_SCRIM, borderRadius: 4, paddingInline: 6 } + ? { color: SCRIM_INK, backgroundColor: SCRIM_GROUND, borderRadius: 4, paddingInline: 6 } : undefined; diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index 48dc2b42..fdbd9360 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -157,10 +157,8 @@ export function grainAt( distance: number ): number { const w = triWeights(n); - 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 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); diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index e0c986db..996a7556 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -9,11 +9,13 @@ const emitted = async (file: string) => { const filePath = `${process.cwd()}/src/app/${file}`; const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); - const bytes = Buffer.from( - (code.match(/source=#?([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1], - 'base64' - ); - return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); + // A file with recipes inlines one source per rule group; read them all. + return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)] + .map((m) => { + const bytes = Buffer.from(m[1], 'base64'); + return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); + }) + .join('\n'); }; type Decls = Record; From 3751adcb649adcd68231c129c1c336ff6a5cab58 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 16:50:16 +0000 Subject: [PATCH 043/143] docs: Ratatoskr client build, install, verify and rollback runbook Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 100 ++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/gamecult/client-build-install.md diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md new file mode 100644 index 00000000..88a7ee2c --- /dev/null +++ b/docs/gamecult/client-build-install.md @@ -0,0 +1,100 @@ +# Ratatoskr client: build, install, verify, roll back + +Who does what. The steward (Void) builds and hands off; Void holds no root and does not deploy. +Eureka installs on the chat.gamecult.org host, which is Eureka's. This page is the shared +procedure. Nothing here is run by the build step against the live host. + +## Facts about the host + +- Web root: `/srv/cinny/`. `current` is a symlink to one directory under `releases/`; the web + server serves `current`. Releases are `releases/ratatoskr-<8hex>` (older ones are + `ratatoskr-nemesis-<8hex>`). Previous releases stay on disk; that is the rollback. +- The client is static. `config.json` sits in the release root beside `index.html` and is read + at load. Cinny's `Dockerfile` and `docker-nginx.conf` are upstream's container path, not the + chat.gamecult.org path. +- Check the pointer before anything else: `readlink /srv/cinny/current`. + +## 1. Build (steward) + +The repo's `config.json` is the served config. It must equal what `https://chat.gamecult.org/config.json` +serves (Ratatoskr #103); `vite build` copies it into `dist/` (`vite.config.js`, static copy of +`config.json`). Do not hand-edit `dist/config.json`. + +```sh +git clone https://eureka.gamecult.org/forge/GameCult/Ratatoskr.git && cd Ratatoskr +git checkout # build a named commit, not a branch tip +npm ci # .npmrc sets legacy-peer-deps and save-exact +node scripts/sync-poses.mjs # pose catalogue and art from /opt/commons/cast-art/poses +NODE_OPTIONS=--max_old_space_size=6144 npm run build # writes dist/ +``` + +`sync-poses` is needed because the pose art is not committed; skip it and the build ships with no +poses. Do not use `npm run build:preview` for production: it rewrites `dist/config.json` for +preview (hash router, chosen tier and style). + +The handoff build for the host is scripted outside the repo: +`/opt/commons/handoff/ratatoskr-main-build/build-prod-dist.sh ` (see its `README.md`). +It writes `/opt/commons/handoff/ratatoskr-main-/{dist,SHA256SUMS,BUILD-INFO,build.log}`, +locks the files read-only, and refuses to overwrite. The folder, with the three pins +(`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and the node and npm versions, is what +the install request names. + +Before handing off: + +```sh +cd /dist && sha256sum -c ../SHA256SUMS # 0 failures +grep -rl 'demo.invalid\|demo-preview-token' /dist # must print nothing +diff <(curl -s https://chat.gamecult.org/config.json) /dist/config.json # report any difference +``` + +A config difference is a finding to report, not something to paper over: either live drifted or +the repo is wrong, and a ruling (Meta, for `backgroundScene.style`) says which ships. + +## 2. Install (host owner, root, by hand) + +Unpack beside the live release; do not touch the live directory. + +```sh +R=ratatoskr-<8hex> +H=/opt/commons/handoff/ratatoskr-main- +cp -r $H/dist /srv/cinny/releases/$R +cmp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json # expect no output +(cd /srv/cinny/releases/$R && sha256sum -c $H/SHA256SUMS) +``` + +If `cmp` reports a difference, stop and settle which config ships. If the decision is to keep +the host's config byte-for-byte, `cp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json` +and expect `sha256sum -c` to flag `config.json` only (use `--ignore-missing` and read the one line). + +Note the current target first, for the rollback: `readlink /srv/cinny/current`. + +Switch (atomic): + +```sh +ln -sfn releases/$R /srv/cinny/current +``` + +## 3. Verify + +- `readlink /srv/cinny/current` names `releases/$R`. +- `curl -s https://chat.gamecult.org/config.json | sha256sum` equals the release's `config.json`. +- Load https://chat.gamecult.org/ and hard-refresh (the service worker, `sw.js`, can hold the + previous assets). Sign-in card shows, the background scene draws, the browser console has no + page errors, a poses/asset request returns 200 and not index.html. + +## 4. Roll back + +Point `current` at the release noted before the switch. Live at the time of writing is +`ratatoskr-nemesis-9b9b495d`: + +```sh +ln -sfn releases/ratatoskr-nemesis-9b9b495d /srv/cinny/current +``` + +Hard-refresh to clear `sw.js`. Do not delete the failed release; leave it for diagnosis. + +## Open + +- `backgroundScene.style`: the repo and the served config both say `ridge` since #106 (Meta, + 2026-11-11). Confirm against the live file at install time with the `cmp` above; the earlier + `terrain` mismatch is closed only when live is switched. From fef7d34fdb61498b698ef305d672a4a062527f48 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 16:50:28 +0000 Subject: [PATCH 044/143] runbook: record the served-config drift as observed Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index 88a7ee2c..9f133cb8 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -95,6 +95,8 @@ Hard-refresh to clear `sw.js`. Do not delete the failed release; leave it for di ## Open -- `backgroundScene.style`: the repo and the served config both say `ridge` since #106 (Meta, - 2026-11-11). Confirm against the live file at install time with the `cmp` above; the earlier - `terrain` mismatch is closed only when live is switched. +- The served config (checked 2026-11-12) is not the repo's: it says `backgroundScene.style` + `terrain` where the repo says `ridge` (#106, Meta 2026-11-11), and lacks the repo's `dings`, + `fileZoneShare` and `registration` blocks (all off in the repo). The new keys default off or + inert when absent, so only the style is a decision. The `cmp` in step 2 shows the drift at + install time; Meta says which style ships. From 45facff436894d3b1f02bed16fb3ffed9b7367ed Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 16:51:41 +0000 Subject: [PATCH 045/143] runbook: handoff build ships its own pinned config; drift check is not a pass check Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 69 ++++++++++++++++----------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index 9f133cb8..cb060d2a 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -16,35 +16,46 @@ procedure. Nothing here is run by the build step against the live host. ## 1. Build (steward) -The repo's `config.json` is the served config. It must equal what `https://chat.gamecult.org/config.json` -serves (Ratatoskr #103); `vite build` copies it into `dist/` (`vite.config.js`, static copy of -`config.json`). Do not hand-edit `dist/config.json`. +Two builds exist, and they treat `config.json` differently. + +**By hand (the repo's config).** `vite build` copies the repo's `config.json` into `dist/` +(`vite.config.js`, static copy of `config.json`). Ratatoskr #103 made the repo file the intended +served config, and #106 set `backgroundScene.style` to `ridge`; the served file has not caught up +(see Open). Do not hand-edit `dist/config.json`. ```sh git clone https://eureka.gamecult.org/forge/GameCult/Ratatoskr.git && cd Ratatoskr git checkout # build a named commit, not a branch tip -npm ci # .npmrc sets legacy-peer-deps and save-exact -node scripts/sync-poses.mjs # pose catalogue and art from /opt/commons/cast-art/poses +npm ci --ignore-scripts # .npmrc sets legacy-peer-deps; skips husky's prepare hook +node scripts/sync-poses.mjs # pose art is not committed; this also rewrites the tracked + # src/app/components/message/pose/catalogue.json NODE_OPTIONS=--max_old_space_size=6144 npm run build # writes dist/ ``` -`sync-poses` is needed because the pose art is not committed; skip it and the build ships with no -poses. Do not use `npm run build:preview` for production: it rewrites `dist/config.json` for -preview (hash router, chosen tier and style). +Skip `sync-poses` and the build ships with no poses. Do not use `npm run build:preview` for +production: it rewrites `dist/config.json` for preview (hash router, chosen tier and style). -The handoff build for the host is scripted outside the repo: -`/opt/commons/handoff/ratatoskr-main-build/build-prod-dist.sh ` (see its `README.md`). -It writes `/opt/commons/handoff/ratatoskr-main-/{dist,SHA256SUMS,BUILD-INFO,build.log}`, -locks the files read-only, and refuses to overwrite. The folder, with the three pins -(`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and the node and npm versions, is what -the install request names. +**The handoff build (what the host installs).** +`/opt/commons/handoff/ratatoskr-main-build/build-prod-dist.sh ` (see its `README.md`) +writes `/opt/commons/handoff/ratatoskr-main-/{dist,SHA256SUMS,BUILD-INFO,build.log}`, +locks the files read-only and refuses to overwrite. It does **not** ship the repo's `config.json`: +it discards it and writes its own pinned `config.production.json` into `dist/config.json`, and +BUILD-INFO says so (`config_json_source`). That pin (sha256 `a26d846b…`, pinned 2026-11-09) has no +`backgroundScene` block, so it no longer equals the served file, and the script's live-config +check stops the build; earlier handoffs ran it with `SKIP_LIVE_CHECK=1` or a modified copy whose +pin was the repo's config (see `config_json_pinned_path` in +`ratatoskr-main-df245e0b76fd83d7ee14fe725b0df9994f36755f/BUILD-INFO`). Repin deliberately +(README, "The pinned production config") or say in the handoff which config the dist carries. +The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and +the node and npm versions, is what the install request names. Before handing off: ```sh cd /dist && sha256sum -c ../SHA256SUMS # 0 failures grep -rl 'demo.invalid\|demo-preview-token' /dist # must print nothing -diff <(curl -s https://chat.gamecult.org/config.json) /dist/config.json # report any difference +# from Yggdrasil the public name may not resolve; --resolve points it at the local server +diff <(curl -fsS --resolve chat.gamecult.org:443:127.0.0.1 https://chat.gamecult.org/config.json) /dist/config.json ``` A config difference is a finding to report, not something to paper over: either live drifted or @@ -52,23 +63,27 @@ the repo is wrong, and a ruling (Meta, for `backgroundScene.style`) says which s ## 2. Install (host owner, root, by hand) -Unpack beside the live release; do not touch the live directory. +Note the rollback target first: `readlink /srv/cinny/current`. + +Unpack beside the live release; do not touch the live directory. The target must not exist, or +`cp -r` nests `dist` inside it. ```sh R=ratatoskr-<8hex> H=/opt/commons/handoff/ratatoskr-main- +test ! -e /srv/cinny/releases/$R cp -r $H/dist /srv/cinny/releases/$R -cmp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json # expect no output +cmp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json (cd /srv/cinny/releases/$R && sha256sum -c $H/SHA256SUMS) ``` -If `cmp` reports a difference, stop and settle which config ships. If the decision is to keep -the host's config byte-for-byte, `cp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json` -and expect `sha256sum -c` to flag `config.json` only (use `--ignore-missing` and read the one line). +`cmp` is a drift check, not a pass check. Live has no trailing newline; the repo's file has one, +so even equal content differs. Read the difference (`diff` the two) and settle which config +ships. To keep the host's config byte-for-byte, as past installs did, +`cp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json`; `sha256sum -c` then flags +`config.json` only (read that one line, or use `--ignore-missing`). -Note the current target first, for the rollback: `readlink /srv/cinny/current`. - -Switch (atomic): +Switch (a single symlink swap; `ln -sfn` unlinks then links, so there is a moment with no link): ```sh ln -sfn releases/$R /srv/cinny/current @@ -96,7 +111,7 @@ Hard-refresh to clear `sw.js`. Do not delete the failed release; leave it for di ## Open - The served config (checked 2026-11-12) is not the repo's: it says `backgroundScene.style` - `terrain` where the repo says `ridge` (#106, Meta 2026-11-11), and lacks the repo's `dings`, - `fileZoneShare` and `registration` blocks (all off in the repo). The new keys default off or - inert when absent, so only the style is a decision. The `cmp` in step 2 shows the drift at - install time; Meta says which style ships. + `terrain` where the repo says `ridge` (#106, Meta 2026-11-11), lacks the repo's `dings`, + `fileZoneShare` and `registration` blocks (all off in the repo), and has no trailing newline. + The new keys default off or inert when absent, so only the style is a decision. Meta says which + style ships. The handoff script's pin is stale against live (section 1). From 45579b8ec8306918022cdeb00c9798333ce35382 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 16:54:58 +0000 Subject: [PATCH 046/143] runbook: the handoff build ships the repo's config.json and its live check always runs Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index cb060d2a..029b48cb 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -38,14 +38,13 @@ production: it rewrites `dist/config.json` for preview (hash router, chosen tier **The handoff build (what the host installs).** `/opt/commons/handoff/ratatoskr-main-build/build-prod-dist.sh ` (see its `README.md`) writes `/opt/commons/handoff/ratatoskr-main-/{dist,SHA256SUMS,BUILD-INFO,build.log}`, -locks the files read-only and refuses to overwrite. It does **not** ship the repo's `config.json`: -it discards it and writes its own pinned `config.production.json` into `dist/config.json`, and -BUILD-INFO says so (`config_json_source`). That pin (sha256 `a26d846b…`, pinned 2026-11-09) has no -`backgroundScene` block, so it no longer equals the served file, and the script's live-config -check stops the build; earlier handoffs ran it with `SKIP_LIVE_CHECK=1` or a modified copy whose -pin was the repo's config (see `config_json_pinned_path` in -`ratatoskr-main-df245e0b76fd83d7ee14fe725b0df9994f36755f/BUILD-INFO`). Repin deliberately -(README, "The pinned production config") or say in the handoff which config the dist carries. +locks the files read-only and refuses to overwrite. The dist carries the repo's `config.json` at +that sha (BUILD-INFO `config_json_source`, `config_json_sha256`); there is no separate pinned copy. +Its live check always runs: it fetches the served config and compares it byte for byte with the +repo's. A difference prints the diff and stops the build. `ALLOW_CONFIG_DRIFT=1` builds anyway and +BUILD-INFO records `config_json_live_check: DIFFERS ...` and `config_json_live_sha256`, so the +handoff states which config differs and the installer decides. From Yggdrasil the public name does +not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`. The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and the node and npm versions, is what the install request names. @@ -114,4 +113,5 @@ Hard-refresh to clear `sw.js`. Do not delete the failed release; leave it for di `terrain` where the repo says `ridge` (#106, Meta 2026-11-11), lacks the repo's `dings`, `fileZoneShare` and `registration` blocks (all off in the repo), and has no trailing newline. The new keys default off or inert when absent, so only the style is a decision. Meta says which - style ships. The handoff script's pin is stale against live (section 1). + style ships. Until the served file is replaced, every handoff build stops at the live check + unless `ALLOW_CONFIG_DRIFT=1` is set (section 1). From 3543b1013b827ec172a553270b6b5b64d38dac4a Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 17:07:54 +0000 Subject: [PATCH 047/143] runbook: sha256sum --ignore-missing hides nothing; name the config compare Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index 029b48cb..33d3526f 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -80,7 +80,7 @@ cmp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json so even equal content differs. Read the difference (`diff` the two) and settle which config ships. To keep the host's config byte-for-byte, as past installs did, `cp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json`; `sha256sum -c` then flags -`config.json` only (read that one line, or use `--ignore-missing`). +`config.json` only (read that one line; `--ignore-missing` does not hide a mismatch). Switch (a single symlink swap; `ln -sfn` unlinks then links, so there is a moment with no link): @@ -91,7 +91,7 @@ ln -sfn releases/$R /srv/cinny/current ## 3. Verify - `readlink /srv/cinny/current` names `releases/$R`. -- `curl -s https://chat.gamecult.org/config.json | sha256sum` equals the release's `config.json`. +- `curl -s https://chat.gamecult.org/config.json | sha256sum` equals `sha256sum /srv/cinny/releases/$R/config.json`. - Load https://chat.gamecult.org/ and hard-refresh (the service worker, `sw.js`, can hold the previous assets). Sign-in card shows, the background scene draws, the browser console has no page errors, a poses/asset request returns 200 and not index.html. From 9297ba08dd3ad5462d609a8a65033d2a75f2167b Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 18:09:47 +0000 Subject: [PATCH 048/143] docs: chat shell compression, measured state and brotli plan Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/chat-shell-compression.md | 105 ++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/gamecult/chat-shell-compression.md diff --git a/docs/gamecult/chat-shell-compression.md b/docs/gamecult/chat-shell-compression.md new file mode 100644 index 00000000..d474e08a --- /dev/null +++ b/docs/gamecult/chat-shell-compression.md @@ -0,0 +1,105 @@ +# chat.gamecult.org: shell compression and caching + +Follow-up `ratatoskr:follow_up:chat-shell-compression`. Measured 2026-11-12 against the live +host, build `f8115609` (the `index-CAataSJe.js` bundle). The host config is +`nginx/chat.gamecult.org.conf` in `gamecult-ops`, owned by Eureka; nothing here is applied +to the host. + +## What the host serves now + +The 2026-10-31 probe (no Content-Encoding, no Cache-Control on `/assets/`) is stale: the +site file already carries `gzip on` for js, css, json, svg and wasm and an immutable +`Cache-Control` on `/assets/` and `/preview/assets/`. Probed with `curl -H 'Accept-Encoding: X'`. + +| Resource | raw | gzip (served now) | brotli (served now) | +|---|---|---|---| +| `/assets/index-CAataSJe.js` | 3,732,771 | 956,085 | none: `br` is ignored, identity returned | +| `/assets/index-BLdKqKqx.css` | 111,267 | 19,952 | none | +| `/assets/matrix_sdk_crypto_wasm_bg-DwIieW7K.wasm` | 5,573,870 | 1,878,147 | none | +| `/` (index.html, no-cache) | 3,066 | gzip | none | + +Headers: hashed `/assets/` answer `Cache-Control: public, max-age=31536000, immutable`; +`/`, `index.html`, `sw.js`, `manifest.json`, `config.json` answer `no-cache`; compressed +responses carry `Vary: Accept-Encoding` and a weak ETag. `config.json` (424 B) is below +`gzip_min_length 1024` and goes uncompressed, which is correct. + +So the original ask is done. What is left is brotli, which nginx 1.26.3 here cannot serve: +`/usr/sbin/nginx -V` shows `--with-http_gzip_static_module` and no brotli module. + +## What brotli buys + +Level-5 gzip is what the host does per request. Offline measurement on the same files +(node `zlib`, 2026-11-12): + +| File | raw | gzip 5 (live) | gzip 9 | brotli 11 | brotli vs live | +|---|---|---|---|---|---| +| main chunk `index-D0yL0Dgy.js` | 3,868,313 | 1,015,680 | 1,001,446 | 773,992 | -24% | +| crypto wasm | 5,573,870 | 1,868,282 | 1,851,108 | 1,343,595 | -28% | +| `ReactPrism-Dm2uRF4v.js` | 577,517 | 209,339 | 208,376 | 163,525 | -22% | + +A cold load of the two big files falls from about 2.8 MB to about 2.1 MB on the wire; the +saving is ~0.7 MB once per hashed build per device, since assets are immutable. Every +device with the service worker cache never pays it twice. gzip 9 alone buys 1-2%, so +`gzip_static` without brotli is not worth a build step. Brotli is the whole prize, and it +needs one root step. + +Cost: brotli q11 takes about 12 s for the wasm and 8 s for the main chunk on one core, so +the whole dist is roughly a minute at build time and zero CPU per request. Do not use q11 +on the fly; that is why the plan is precompressed twins. + +## Root list for Eureka (host) + +1. `apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static` + (Debian package names; check they match nginx 1.26.3's ABI before reload; if the + archive has no matching build, stop and tell Void, the fallback is no change). +2. In the `chat.gamecult.org` `server` block, beside the existing gzip lines: + +```nginx + brotli_static on; # serve .br when the client sends br + gzip_static on; # serve .gz; falls back to the on-the-fly gzip above + brotli on; # on-the-fly br for files with no twin (index.html, small files) + brotli_comp_level 5; + brotli_types text/css application/javascript text/javascript application/json application/manifest+json image/svg+xml application/wasm; +``` + + The `location ^~ /assets/` and `/preview/` blocks inherit these; they only add headers. + No other change: the `Cache-Control` and `Vary` lines stay as they are, and + `gzip_vary on` already makes the on-the-fly and static paths vary correctly. +3. `nginx -t && systemctl reload nginx`. +4. Install a build with twins (below) the usual way + (`docs/gamecult/client-build-install.md`). Twins for files that are not there change + nothing: nginx falls back to the plain file. + +Rollback: remove the five lines and reload. The `.br`/`.gz` files can stay on disk, unused. + +## Build side + +`build-prod-dist.sh` (Void's, in `/opt/commons/handoff/ratatoskr-main-build/`) gains +`PRECOMPRESS=1`, which runs `precompress.cjs ` before the manifest is written. It +writes `.br` (q11) and `.gz` (level 9, no mtime, deterministic for one node) next +to each js, css, wasm, json, svg, html file over 1 KiB, only when smaller than the original. +Default is off, so builds installed before the host change do not ship ~12 MB of twins that +nothing serves. The twins appear in `SHA256SUMS`; the determinism compare still applies. +Tested on `ReactPrism-Dm2uRF4v.js` (gzip roundtrip identical to the original); a full +`PRECOMPRESS=1` build has not been run because it waits on step 1. + +The script is not in this repo; its placement is a standing open item of the build handoff. + +## Before/after measurement + +Run from any machine; `X` is the asset URL. Before = today's table above. + +```sh +for ae in identity gzip br; do + printf '%s ' "$ae"; curl -s -o /dev/null -H "Accept-Encoding: $ae" -w '%{size_download} %header{content-encoding}\n' "$X" +done +curl -sI -H 'Accept-Encoding: br' "$X" | grep -iE 'content-encoding|vary|etag|cache-control' +``` + +Pass: with `br` the main chunk is about 774 KB and the wasm about 1.34 MB with +`Content-Encoding: br`; with `gzip` the bytes are no larger than today; with `identity` +the file is the raw size; `Vary: Accept-Encoding` and the immutable `Cache-Control` are +still present on all three; `index.html`, `sw.js` and `config.json` still `no-cache`. +The service worker's registration and an existing session must still work (open chat on +the operator's phone, hard reload once, check the network panel's "transferred" column). +Check `/preview/assets/` the same way. From 74fc2fbcd472706be2d7ef3653b75b12221b3510 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 18:15:59 +0000 Subject: [PATCH 049/143] docs: fix build id, SW claim, step order, twin size after Soul pass Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/chat-shell-compression.md | 39 +++++++++++++------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/docs/gamecult/chat-shell-compression.md b/docs/gamecult/chat-shell-compression.md index d474e08a..c8ac66cd 100644 --- a/docs/gamecult/chat-shell-compression.md +++ b/docs/gamecult/chat-shell-compression.md @@ -1,14 +1,15 @@ # chat.gamecult.org: shell compression and caching Follow-up `ratatoskr:follow_up:chat-shell-compression`. Measured 2026-11-12 against the live -host, build `f8115609` (the `index-CAataSJe.js` bundle). The host config is +host, which serves release `ratatoskr-nemesis-9b9b495d` (the `index-CAataSJe.js` bundle; the offline table +below also uses the `f8115609` handoff build, `index-D0yL0Dgy.js`, and says which row is which). The host config is `nginx/chat.gamecult.org.conf` in `gamecult-ops`, owned by Eureka; nothing here is applied to the host. ## What the host serves now The 2026-10-31 probe (no Content-Encoding, no Cache-Control on `/assets/`) is stale: the -site file already carries `gzip on` for js, css, json, svg and wasm and an immutable +`nginx.conf` has `gzip on`, the site file sets level 5, `gzip_vary` and `gzip_types` for js, css, json, svg and wasm, and carries an immutable `Cache-Control` on `/assets/` and `/preview/assets/`. Probed with `curl -H 'Accept-Encoding: X'`. | Resource | raw | gzip (served now) | brotli (served now) | @@ -31,28 +32,31 @@ So the original ask is done. What is left is brotli, which nginx 1.26.3 here can Level-5 gzip is what the host does per request. Offline measurement on the same files (node `zlib`, 2026-11-12): -| File | raw | gzip 5 (live) | gzip 9 | brotli 11 | brotli vs live | +| File | raw | gzip 5 (node) | gzip 9 | brotli 11 | brotli vs live | |---|---|---|---|---|---| -| main chunk `index-D0yL0Dgy.js` | 3,868,313 | 1,015,680 | 1,001,446 | 773,992 | -24% | +| live main chunk `index-CAataSJe.js` | 3,732,771 | 957,199 | 945,424 | 725,167 | -24% | +| handoff main chunk `index-D0yL0Dgy.js` | 3,868,313 | 1,015,680 | 1,001,446 | 773,992 | -24% | | crypto wasm | 5,573,870 | 1,868,282 | 1,851,108 | 1,343,595 | -28% | | `ReactPrism-Dm2uRF4v.js` | 577,517 | 209,339 | 208,376 | 163,525 | -22% | A cold load of the two big files falls from about 2.8 MB to about 2.1 MB on the wire; the -saving is ~0.7 MB once per hashed build per device, since assets are immutable. Every -device with the service worker cache never pays it twice. gzip 9 alone buys 1-2%, so +saving is ~0.7 MB once per hashed build per device while the HTTP cache keeps the file (immutable; the +service worker does no precaching, so eviction or a new device pays again). gzip 9 alone buys 1-2%, so `gzip_static` without brotli is not worth a build step. Brotli is the whole prize, and it needs one root step. Cost: brotli q11 takes about 12 s for the wasm and 8 s for the main chunk on one core, so -the whole dist is roughly a minute at build time and zero CPU per request. Do not use q11 +the whole dist is about two minutes at build time (measured, 84 files) and zero CPU per request. Do not use q11 on the fly; that is why the plan is precompressed twins. ## Root list for Eureka (host) -1. `apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static` - (Debian package names; check they match nginx 1.26.3's ABI before reload; if the - archive has no matching build, stop and tell Void, the fallback is no change). -2. In the `chat.gamecult.org` `server` block, beside the existing gzip lines: +1. `apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static` (trixie/main, 1.0.0~rc-6, + depend on `nginx-abi-1.26.3-1`, which the installed nginx provides; apt may offer an nginx patch upgrade + alongside, say so before accepting). Do not enable the config yet. +2. Install a build with twins (`PRECOMPRESS=1`, below) the usual way + (`docs/gamecult/client-build-install.md`). Twins for files that are not there change nothing. +3. In the `chat.gamecult.org` `server` block, beside the existing gzip lines: ```nginx brotli_static on; # serve .br when the client sends br @@ -62,13 +66,11 @@ on the fly; that is why the plan is precompressed twins. brotli_types text/css application/javascript text/javascript application/json application/manifest+json image/svg+xml application/wasm; ``` - The `location ^~ /assets/` and `/preview/` blocks inherit these; they only add headers. + The `location ^~ /assets/` block and `/preview/`, `/element/` and `/mockups/` inherit these; they only add headers. No other change: the `Cache-Control` and `Vary` lines stay as they are, and `gzip_vary on` already makes the on-the-fly and static paths vary correctly. -3. `nginx -t && systemctl reload nginx`. -4. Install a build with twins (below) the usual way - (`docs/gamecult/client-build-install.md`). Twins for files that are not there change - nothing: nginx falls back to the plain file. +4. `nginx -t && systemctl reload nginx`. Twins first, config second: with `brotli on` and no twins every + client gets on-the-fly q5 brotli of the 3.7 and 5.6 MB files, which costs CPU per request. Rollback: remove the five lines and reload. The `.br`/`.gz` files can stay on disk, unused. @@ -78,7 +80,7 @@ Rollback: remove the five lines and reload. The `.br`/`.gz` files can stay on di `PRECOMPRESS=1`, which runs `precompress.cjs ` before the manifest is written. It writes `.br` (q11) and `.gz` (level 9, no mtime, deterministic for one node) next to each js, css, wasm, json, svg, html file over 1 KiB, only when smaller than the original. -Default is off, so builds installed before the host change do not ship ~12 MB of twins that +Default is off, so builds installed before the host change do not ship ~27 MB of twins that nothing serves. The twins appear in `SHA256SUMS`; the determinism compare still applies. Tested on `ReactPrism-Dm2uRF4v.js` (gzip roundtrip identical to the original); a full `PRECOMPRESS=1` build has not been run because it waits on step 1. @@ -102,4 +104,5 @@ the file is the raw size; `Vary: Accept-Encoding` and the immutable `Cache-Contr still present on all three; `index.html`, `sw.js` and `config.json` still `no-cache`. The service worker's registration and an existing session must still work (open chat on the operator's phone, hard reload once, check the network panel's "transferred" column). -Check `/preview/assets/` the same way. +Check `/preview/assets/` the same way, but `/preview/` is served from `/srv/cinny/preview`, which this build +script does not produce: it gets on-the-fly brotli, not twins, until a preview build carries them. From 64a2656ad7a337fa17c4cc61f52536ea1a25afac Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 19:58:40 +0000 Subject: [PATCH 050/143] pwa: installable manifest fixes and Web Push stage 0-1 (worker handlers, session in IndexedDB, settings toggle, dark config block) Stage 0: manifest gains id "/" (what Chrome already derives from start_url), scope, a 512 maskable icon, and drops the portrait lock. The service worker keeps the session ({baseUrl, accessToken}) in IndexedDB `ratatoskr-sw` so a push can wake a worker with no tab; the page's existing logout message deletes it. Stage 1: `push` and `notificationclick` handlers (src/app/features/push), a pusher toggle in notification settings, `push` block in config.json shipped empty (off). Tested against a mock homeserver and Matrix gateway body. Co-Authored-By: Claude Sonnet 5.5 --- config.json | 5 + public/manifest.json | 9 +- .../android-chrome-maskable-512x512.png | Bin 0 -> 20178 bytes src/app/features/push/PushNotification.tsx | 69 +++++++++++ src/app/features/push/config.test.ts | 24 ++++ src/app/features/push/config.ts | 12 ++ src/app/features/push/manifest.test.ts | 29 +++++ src/app/features/push/payload.test.ts | 108 ++++++++++++++++++ src/app/features/push/payload.ts | 95 +++++++++++++++ src/app/features/push/pusher.test.ts | 94 +++++++++++++++ src/app/features/push/pusher.ts | 86 ++++++++++++++ src/app/features/push/sessionStore.test.ts | 96 ++++++++++++++++ src/app/features/push/sessionStore.ts | 78 +++++++++++++ src/app/features/push/testkit.ts | 41 +++++++ src/app/features/push/usePushOpen.ts | 33 ++++++ src/app/features/push/worker.test.ts | 103 +++++++++++++++++ src/app/features/push/worker.ts | 61 ++++++++++ .../notifications/SystemNotification.tsx | 14 +++ src/app/hooks/useClientConfig.ts | 4 + src/app/pages/client/ClientNonUIFeatures.tsx | 2 + src/sw.ts | 33 ++++++ 21 files changed, 995 insertions(+), 1 deletion(-) create mode 100644 public/res/android/android-chrome-maskable-512x512.png create mode 100644 src/app/features/push/PushNotification.tsx create mode 100644 src/app/features/push/config.test.ts create mode 100644 src/app/features/push/config.ts create mode 100644 src/app/features/push/manifest.test.ts create mode 100644 src/app/features/push/payload.test.ts create mode 100644 src/app/features/push/payload.ts create mode 100644 src/app/features/push/pusher.test.ts create mode 100644 src/app/features/push/pusher.ts create mode 100644 src/app/features/push/sessionStore.test.ts create mode 100644 src/app/features/push/sessionStore.ts create mode 100644 src/app/features/push/testkit.ts create mode 100644 src/app/features/push/usePushOpen.ts create mode 100644 src/app/features/push/worker.test.ts create mode 100644 src/app/features/push/worker.ts diff --git a/config.json b/config.json index f98575d9..fea9a59e 100644 --- a/config.json +++ b/config.json @@ -28,6 +28,11 @@ "fileZoneShare": { "enabled": false }, + "push": { + "vapidPublicKey": "", + "gatewayUrl": "", + "appId": "" + }, "registration": { "enabled": false, "url": "" diff --git a/public/manifest.json b/public/manifest.json index ef0a3510..c28e7b41 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,12 +1,13 @@ { "name": "Ratatoskr", "short_name": "Ratatoskr", + "id": "/", "description": "GameCult's Matrix client", "dir": "auto", "lang": "en-US", "display": "standalone", - "orientation": "portrait", "start_url": "./", + "scope": "./", "background_color": "#07111a", "theme_color": "#07111a", "icons": [ @@ -54,6 +55,12 @@ "src": "./public/android/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" + }, + { + "src": "./public/android/android-chrome-maskable-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ] } diff --git a/public/res/android/android-chrome-maskable-512x512.png b/public/res/android/android-chrome-maskable-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..211a06505160a3fa1f6b165146d779db27c6714a GIT binary patch literal 20178 zcmZ_0XIN7~v@X1W|dr>tSgl(gz8xM+9rehAo zss>`!)tFhSDQKuKJ$ph;U8s(2Ln!n2g$%bH55B_2DPKO;^$i&DX%l1BcNodW*YuZu zT_Oj-S^)q$n(!_W9FqdPn88aqjuv_Wq(z1P-;;kt(R~4SAJKr{@&tcF@VR0C+Nglf zq2LA3ffv6Cm9px;_u>DZ{8vZ+{f1f>=rwr`cK@bt7j@t0GB}vozqTJ<0w>t3BIm)L zM5)2=|DHj?>;JoNc}%u^V?8$ zsJ#G3{E&Q+ypsR(zs;Ec9|8ZS{@-uN3;-FcHubL&`@gsU=w;@lSX^=XAFZJ_3_uRJ zxc{$7{hKDg4P^(JaANSk$t4st|G&wFg1`R{mUQG$DKq?Q@jrP@tp)TNKK@S@!|qRk ztoq+9wg#(*O37DSQ)$kflC0%Z*Ani zP5nP!{Nei; ze@msz^WT{A;R$d;u7O|ruO9wOZd`o+_Y}gkkxz)mzAyAh`JR)BxX))1B#6o}tj&RJ zc^n8we9u_LEzVQXRRX(3T%3#KdHNcIb!1dM>C`s+X~GX>gTCjbUs-R?J&H2gywO$S zoqT_)Me}r|Su5O3p+Kc8XR|6^hu2I>sy4q3kI`?lRdQsxryIzWCXz2Kn$i>vOZkd& zNnubT2+cuc_*-zi@hH-X+A*K30E8G~#z^++miAsv;+7BAKjx7ZgYDqee2Km$^&~=Z zDKRwV4nD9%JEy@YBDhNZ8h#|{scWQG*Q|n|`$m1jJ-x@mSEg-OmEa>?&a_YfD5ws1 z_ZyN^RWi2>5O)aavo30zbeA-Y2(2!xM8H5^%EqLq0lCK-{`Z z-NGVs{Sp_Ka8*@RA3Hgq>M{>eKU?uj0-lL?pbq1=Iv?)5kE`<9UpZFNmZrlfYG@i` zodUV%Ax3yK`iL4__=V2TUFcHmo}cLj$&_Z%h~Ceq7;P=$UgAv+|2J*$mpm*h-)O9( z8!dYcg4WSJ-CcqwvsD!#nGp;Ow4CbckzbOxRLuhkjIs^TYzlxH=m;Uqe=chgT6FXa z&D*yvl7oz{hgUmdu%3TsT@Cz^&P|p{+bTKm$j|}l*qmPyALko=^d7p;35P@uOB`I> zPj(XFTXlOAIzoP#`cJh9C}g`6e^{bl|MW{dMhf*0(qLYVU?BrpElgSLjiPN)AyI+! z^MSluIWMRNW0i;k*Sp2Pn(5GLu7wR*JmxxG(-3gYseNQX??cuP} zU6=!#G!gslYv=2&q>9z)PY3(!dj`epEkY=bQQn=$w6QtziTOPv1C>=;+Obe1SMDPy zxWG(Q(E0}es)~L#NQNu*TM^5t(}OK>5jf$-NVWp*=0}fT7Dp&%>Maz0>$GItVcnw7 zw6(4^XTUWZKwJXE2W-qY(fz!#yLG8@ zV-#BMJX(gjcyMzG;yH=CND1sR(gC}sdx-K-%Um_%*`ULdw4)QN%xAd7OVsn79c}p7 zSC|;3zct-d7P0DUE*(DqB$#aL_X3tv^R9OL{=%IIyI6N;DJ8W{MmcF@^MNvLtmzFt zwDa<%S^x?_y_cQ7I@z9;_9>r|dQgLQZpZsgwI^*2<&7$i1076m-N%i!JGoMhaLy+0zwCAd$GdmlZVtZQka<(TdV5Uh$r|k+Yv&z*CQ53c z;ISWUA*qJLRZxL;C|QXyJ1#}sT1H|x`#5ObEGnE)((4!b`1rZ63U|~rvrnuMxsgUiJt)`phyp({30Kh-9 zMR{Pm&^=y2RBw8_5m9rMN$pJW;r9yFugxK=@8`dS-KS3ND{SKI&s60aOdbq;0t*)G z(X&bl(0Z8u_F1Jx>Z{w+gBCehw9C3h#aY1ZPz4%LxC<6(=Q~(AEK{H2+G1luyQE<` z+{Y@K#^npizLvu3MU+Ef52zo+1;3W~>TFE{fKQ@AbdM){L?egj*yMg5@n~ z*=J+cFCsWW=|lyJ{3dR2T|X2B8Jv;01~)VB9Q;x_{C-$%Oy)8?eBGH<)DjOxOW@3ddHwa zRN$bb<%*S}oK^SR(d}nrwcky;#NP%|W-A7X9houKTrmEfi#b2)Y!E~lieLF&v+%@| zDj~F#kuTMjI=V(&)grSs>eIbPOQiQb)j`-x8%i(VvED(AQQJCPzVqmxCUQuDP))G? zl$!oxe%wD`d~Z#t?~=NJ@}L#vm)V%O&&Pi{xPQS}MxTMI+J23C{X<9Uv`NKb?sOZi z@6AyqZ)w}9S)TFl+v%9A1n4F{@7g> zz+IwFxOl9?S6LTx#htg}Win(j65web^P%)kFt4<#;G|R>Mi|N-WnPPn$xKd7y^>?m zvUhM{dDV21{`8#3P@v~G&eL^cr0jOO_3Q2ZB<8F+)T{a852pgAa35*kO*mBa#)-2J2-BorqErTy zZN|Rk)@0!sGsKyA6>VFU)FY_^8ME!83@Rf4yct|6!;jmJmtyi%JOmvZMa^$ z22FRpE466L^(ViKvU>W88O}87Xo-W(G=U3VDK5f%f1Nk?f`sKc<}*OrnYvK^-8$xf zYh$EU>=Zi&#S!KrG!L~?oUy-KbqdtG-b&c>S|4(n{btE7d+lz4E*B{8*8i>|>^L5p zG-%zdp_|qAGW+eGPApV}&Z5Nwap%7Geywv}9*m^unq6uNJ2gI-?Cd>1FckKf%?Xz@ z7;e_;D@I2JDq)AR3IXdo_e#FqTjP_BWZcVy`9J2YziNBI_3WaYWP%&fzW?2C;n#cp zqL<-zQLN=x+(duRjb4V7ZT2|peHE<|JNhu+vT)?(ZDD1;z-I#4%%~x;dkF3*EX?p1qwcYEWb=1OwsgSrhi*iqyct5kv)Zr( zr59zm>2l$lZ-SRrxZ~QG;yuM(1)_a^Vvd&A%YX?V|H+$mG z%Ja|jG|#(7_G;`OWWxqGA?>02f}vG;2oCOQCI)IHHzI&lR$Q59yO`W{uy2T1UOE z{2sl(Tj9>0Cpi-KqErl`T1Kv4)9B#?jXz2T(Tuz`(_034i6%#SL`c?&R50QGRjF;~ zw0mT^31oTfqjF+s1ECc!E@2LCpYb-)+G!{8RK*|BZikzpsQk%#ALi894QmLfTl zS6`_N1{sa?Ks-H_ez#l)K4|VWG+)NlU%BltZ0Y0H$ekxA@GROXkd2jvvea20F$)Vl zD6bKM#oH|LWZ+>NJ(x8^*^S{Mas?8zgTV_EI3rjd{=ptM>`o8%p3V4_y#wu)##CU) z(A|g-Qe(qA0Q#zqpk*BUpe3iOlP@xj`3h0rEIih$dvJo(>>Xkqa`A=328Pn`6NAQ# zs_c>=#_FbZ$|2$|U-S1R8eOmI;(>znW565I@UFmmSQuT497y%k~Td^7iXbGTv9O_iQ)9}C4ebn)~6 zH71XR-Y0BjqIjfCBjP2)>w?hrUm;_88wYR=(1Z(m{bJt_E$=pp1#AIoXk4)r^|sZv zT<|-Iv8eagZN;I((j#V#UvF_^3(fUjTo?Xuk+)5~9M#J43{e6~zHTL9rQ^IQ5h;G( zXV^^Q)^lND-L!+U(z(W-+65mb3Y^S^A6de=kA<37_qM{h3Us6M)^2?|>A7*9(q9Yk zpQx#&vpCjSoS-ERX*)$Jsp*&XX&Jj|v>E-@v8jEmeD>C|22>_OZJ?)3zi|?zr%ZU# zDcuxEjJZP$%m5v?NBxuQZoG1YyY2?)%uNU;KZQ*RE80do@--`n5eBcznH8XApmZ3v zmUKQ3h9?&H*IZwQpq|b%#owS*e&+NFsm(P&dQ85*l5>3nVCHa$i11cbM{Mms-3Z;@ z^eO48M(4`dZ&*PVa#T=8XLNbPqvFr&Nh6ELr;CiNX4mg}JDjYx!3=dL9!b zD+t`2$=;+7hW6titvcMFY+MeF`taQ|4GuMv&`UWT3uxi2T=;9w%zffRFh+-)+eXc|I&8d z>AAq4-Y368qyhDh0JC=&%7-OtUE+88Vb4n?Zugvv-=mk;?Li#VcC;;U%o3hV!S{7E&lqYKL$>Ui^-uo7%b<#0P+_F36k6D|m z7gGj8^9X|C^$VNZB7$-u)1&nYq_L{bqUF5FoE%p75x&J5NR_1!u5t=IW z$Se#1a@Zk=#&3%IID+bfuAJ+qD?J__)xImdUc@&glNIHz&Q-ayLlM30RKkOq074T& z8e_5PUa;=!{bG)Lg)e_RUXHmT5kHoH(t>kZb-H1Cp&{WoKJhh{WVcqO=H70+Z3UBw ze%O7EaVM4^pvE=A7ECtlx$TV%^H01AZUE~?-{#7a zCEdTVs5oE5#l>*7=1G4D+@PA>@pYh)XNrq^cK=nSkh)C8ps7=TcczIMED@g7!vflD zy1*ujGBUW?`$vKpYbM3jeyTv-bV|xxT1sa59(Um3+%%T5CpjzVp{I)H{id`=1R*Pz zx9;WQS=KGy4H&x*5YNTxV8e;$RLV?>J=E^qbL)Gwzi%>O!HfJ5968NnUB;r8e5 zfg5M@LQ1L;CdkNn&N`?$N!MG{vaRLw71;bC-MYO@^K3SrR(9j`q$G&W@A!`TDk}IW z1U7eA62OBR(b?rKW7 zeWaoQuz?2y$S0h2T!Ia5D0CVm#!rz~Aynvez}Gv-LKM2d)MWW+@v$>)XrXM#An%pD z`!|YU#ltdJ6y4G`t_py08}lGVNBT*F)Aq>Pmyv0uNU0dmT{3;Fr9=6U6|)%#;gjRg zXL;!pw)E?Iae>PALb;kulUf+S{HKg8uT$U{dCy*gqnRYrIuHCKpwdBS#<;HM_Nj>`zT4w^sI*~s?h8fMz==My#9%OyU>9BKgh?DjLtj-Jzuf$AwG84UT{it}YV zSKq@so8g3g%%#)XUqH#d#ZBVk!pF&T(D}WU{$2ModvbxYmT66BigOK%8JSy=4w&=h zJtX5)vWpUaJQLYac-5Xp!MAQZZPjf&UoQh!$^pZv;t|WQ3`$#H;g?tho#WwaiZ()& zUV4Azt^w3>p99i0KuP?2(ihDNVa(B?B!E~!R>wv~7x{PVP=V2efc<3){(8bn14E3~ zF8L+o*XA#Isa>)I_eN9KWM`=>Ul=*%VV^S7k@96vcWs@gj9js{vHD5pyITjgVen5#ar6n>zpab?)neYQZBe{fs35xPsuC%f=b(Bs<(aaDKLS7Q+({cq!a>r;Pp3#9B_%74xs3e z+$c_~y~rnyBDZMP}qunusrVw17MU$`juyw0|!-%y6_$g*w zj=Y!H!K)Nt&;G8e#RLh{CE&jG3Oii3DH&!VhyjgJ5a&^ad{ahH87mJiFz0=_{MEk?787~mmDI$>29;PiGv4fVa8ttjont_bzB1>&WS{Y(2 zg&L9K+R;UB1DEV6Sc%vop7ywhmO>g&a?*GhTgNxHRtP(V3i-T85gIVyyeHbvL!GO3-=lLGQkwuKz&niR$x5ycBqx zR4lOO$cft2uD~p$kHTA5OeB)G#VcYJPM;b7oiOtE$6hUJ=x^IC-t_ZbJJ;AA!yb|q zhiF}qHf{ARsoVAo7SJ8>T55k5&nt`KFYS3Sq28A|i{je|0~XO+8U52gtnoha)j>kF z;cs!I>ou8M-!()EA7SF{2S9PbY=RoSsji$I55`K8W)4IM|wFk3RKjI$_ zWC&u+cE~FBz5u|Tlk)w8HY8tVpN|VQAe7~-bQW^mcRO>sWhHr^bb4mUu824DE+1+g z`a3~O^?Gz&uo`rBJ;}dTsO@m1tK4;JI>L@># zz3NKmvF{sy`J!3y4YEZ8d*Fn6TPAQW%hIZ5&&Kp{%IIM7o#Wqd6S`4rV~29gsCHnR z?r~kYs1rl0EQM&BdCLwGB|8jr#3=rl@N5L5mi`(kk#GuOTRoE2x5xIEd+T0vB+3A#=%3@z%7am__@>y_IR}ZsnuVjH!Le zxk8Jwg`(8-&K&XhPHgf0^qhw9tDJ8xDO#0V!npJmD2mWRV!Dic3ZVUtXfY#i>~c#EseS4_XT^8 z>kd2GiwxJ>PGA*tqg4Zg*WdFM{r35gBmhE_S=5C!Aw+x6oEO~g3+>`1FudwdIG@dh z?+Ty9?V#BXEe+SKXLR?8$*3U)8CDgj^SaP*tzr$EE2cHs(Uef9T80?QMv*%j!N=#h~2SFy92u)W7LI>o@#fCqX4`$DHXU*YSpK)D&^tsr4(9 zYRl)!;ra6hi{sU3L3b|+wI^uaOj)nLb!lrGAy$VQ?{yW5wU#t32V1E_xDnoDbHt}{ zcsa>3xzjaM?U~OD1xw3{=Pmc_1^7izn)?!bv2(x1Lx@lt^Lgj}t-5TM>yH5CO-iWm zkD0RgL+#^PQ9RnZxBwA%nqb_bav z)JWv=ecIsMqWvYi`fV3`QwTcGnz3cR38x-&gSIn(H6W;rKaG2 zL)9@am@y#=OiSJ32}^D8u1P8%o2YaAOW4tnpFIrn zhQn>X*v%ZG+MQas?MM3RWj<$ncP2hqC)yTXDwxyI6rl3NuWs@hr+T7jqKkrX)gQOB zA@oPdM_r^e$<+0_lHc-ZapYYsJPC9@L3~ombZ>}J!pj=LIV!_%4Z%edb|*q-TF8wA zFG6ctrvG-grG!hK^ELI-s2Hqv|Z;jc|t)pK=5E zs_dLlnHwO^ff-Cv>s= zX|k!5=Vc|-kT}1!Rtey|SBINA@jQ(k4_c@EfpkeVZBRzvnx>f%8?LA-f5%jY5H;>& z)ciH_{R)}98^Lno3q%5Z0smHaGJ;Sh(QEGo-?<4!7)fP6F*n zBelnem_L=y=uzZOc2S+v$cwTp*m7tad};}L8u2j-Md1X6>&lc+Vo{`070UliNbwjO z{@^^YBX_`XrOsqOX=fcC&aqq(=~GjR2rNjux+5+z)wUPdu>N4dxuOQ?{BA3 zeDzXfo4vM4OX125*PdVR+jpaTo1zh~mj~Sob)%Q`D^)%?*0pf#Bg}EPU`&Et0q(0l z;gPZPD%3a+vKp|v5|;nK6vO_#oe>yz3T4VxUpclLwA0~}u_QeUOvE=Ti-7onPKTfE zTuloT!ADc^)@Z+67s@qN{dfK`|HcvPXD{LLpy{Vf+l3jht#h|d9hv$JD}4y3datV> zXj-l8wT=BY5U81DFZPvlj-EguNwU7*Sw47w9^tL|>!U886p{%mpRu^wKw>%6g1KjY+^>84EW>qHK7R!nD8O!A7 z?a7%qR@Qd-rNlv@d+*@%xk?pcS{X142V-}=+o2Fq#j{oACn!^U76HqPk?b&>wXlMB z#%9%oQ3e8IcM$me1mw79`L)Cf?Z!-LpFRvlhue=YY1RE#VSh7)a~3{E8|~OFtasX~)1LXSL>wO-AaEI| z=5Q827whlWnbzit{#hp{XkK;9N;W*E4A!r#`<%94XD$93Kl=xkQZeY{E~DkGZVQZ3 zPL5l|Hh&-FJ4b#UmJ@4TY)7_AqIG?08jBd)kJO`7rhi91lZJ>$m=2gzD8s^Z&*uCV zKNx;TBFbL8OBxTGZX|NpcelVQ=xvfeeu*;(lND|cV5Fl&sV?8#~c{K6B?{%wCGJPTZ&If`unlAl2ZmXF4FB^K=h4<@_qkSd=uydGUTC(H{aLFr73{ zH%v;TcuT6IJNn)I((hTAk+Hhb8?A*(seMT#+4I9<)8@Y1ad*P!gj!S`Iml;aYJE-7 zQrZJao!B{qy42>_vr}-D|HaaDfavS#Mz&$h{G`c?V5=(ov(Y{?PDy7SMq-waN*Yw(8 zz+e*Y3LWTyI4(F6p}#rO1#%kL z*oh(F_MRJPiQghVf{j}vRx6MZD6VKoZ#YU6Li#A0h<$~=~qiNBF+UpFauL?RIfqT+`ezd z)|&4H zHw3uI~GD zYQf!G$a&aB9z1nUsBmeVIOxP*-e3mIE)Y_9fWy5q5Th|MJv-7%D0!vk%;Pa!(qPS( zWxga%!4D5iw#ecA#AgtiW$AyP!%ZZfYONo{t}=yHrGw>`^c?WUgjVaYWu}6%i?hUY zWHzNSm&&V&Iyx9@sn9VlLJy5ui&@YvryQ`JenPrSZR1BIR}%FkqEV#N%Pr+w5RBBz z_?fp+b%YS|?D}YbPNc3T(&+jSevB2epr$T-Rnc4I{;pnNVy{7SBDajMSeIOx<2~un z{e(%E`;(?7)C(c5VBDyg?#GSgNCfIW@}1#7pa1N;sI`fPW0s9wLMwTZlNzN8m zfG}QUY<8lo=u`EgFxO0gwtxc!@`>yPG*-tC z2F|@7U=RFtyjN}@g}?nNR=7plMF2Jp7niVyYrG3VMf1%>e$z53Vk%e%r!i6K;YI;0 zygCPE&k`yy2O2?>GU@bizHC(3hGJ_!CSB$_FM2 z467VP!*Bb31q*Y$>ls7Lfzirc(+OKQ(Z~LH$+3f3uU*0e>vJ7DFX7m-3T%%q0_LDR z2E)!fByTW8efpHAiQnDRLNYrY)D!hfop3j-S)c%PvhE>$ZSuSP6wN~r?04;Ryw@Cn ze|JR3PthS<@yW-kXXHdkhYHG`i59m?7Z7lLz4-_Br#xjad@hYQw+3C2feDX+pjbnB zlI1{H_;YrvCJ6cY1#bDrLID8Vi*@o~FCCrweA@2%TDk2W5_I4>>-e;QT@NNG`*Q6C z;8^H>3mE#cmG<;s#y34+T@M8CjD&#;5G!6(gF?`;if(4}ooHjX#B`mAw1b_%iht=K zXzT^u$ejso@s13)O|u)`Y#lKQ4$MHX?77@Ju)0WEXAPJRNAFL~?FU514|9L`6Izrh zDBGXRb8gT_d0Wpi9$=9x*x{HQ_s7u?``6SyN;%`gzqIhAng(c&^{5FI5v8*3PKAgjR@vf?QCI zhT1lB95f_c$iYcTbR;&9Z7}Sq9%_00u2wp?5W-`)!Pz!6!R|P9q-49+P&A$DLH?j* z@D2__#O7C}h+Jq+x~)AQ1a@n)>*E0dKW!4g#$wlvl3WMT>HCP5;V+|c1>f}7!Q#Xw z-<9l4d9NcmgjNpb?;z^9xf14j!@P>|zJ0$_!2XTf zW6U;s&&D%S%LO`y`tcOX=8IqCK1-B6wvHiZyx_6!?v0%zKk!oCQc0>j!Kf(l1Zt;% zl?l{O_F}z)yv1x&T6WTl6-;E~k2(u8?DeAGs}1+YAR~3K-JNXaug6R4?!Vgd+>l(= zIw*J?z57YOVd~~-?HKb9eMqk1PYLCI#11+yjEF{OLy}9Tj5Uq0f~Nzj>hNpZSx~gD zwL3yHx;2HFl|otk749!#NmQd1lMlYurZxkM=zFK2n^MlM7AU=I>Kgb_XFKfq<8r3T`} zlQU%QG>__cjo>rf{A$YTd>9pPkIr&l@IcJ|R=f4r>JZu9PV}?D)3IN@tkp!v9r%8t z&jt*@)V+(9gWjh;v@Ckt9=wNU)6*f7s7JbLk?Pm z^$SoYe<*H50nED;xnHgxOchgZiIG3xLR}ejv)hKXjkHSr`H#?6^i$<1(w?ca9Q>?v zz!+O;pDt`gI2y@YF*-pOCIO0Rc3@p;nY>92qrM39iNQx-HSJp}`r)FoD zyD?ci5T5+(hwhK7{T#F%_YfK$vn2-_dG();=hsJak?7eh()b+)o|N)%>&WzS3z(h# zy4qpZb+F7Q<&NrXi_lh+&iKk1Sar7>NcR?L<706&@Yh`X`9}{G<;(!x=APWsVQCTc zog?oB)p*VL{olt*A^QH3t*)dEy<28YK?xOK+hb;%+r5u`o8|Y>?E+uRwWUKyIQx|! z!LP3mo_cf&ss7{5whEMlTmGt)1}vsuS?5fgnpu_QfWoI+Mo1NAkhn}mzab2`H^-Z^ zTn=iXt z?>oX}C3rD+sxNa0bA`^MITJvav3?TdGjC~qeYs^orxGb zz_*woLLx*TBq~lg&0E91q)l@W$EkBn>7_Jh%tEq^LJ7NLl%!prNG144P$eK4xRTq;Gvi@EM)>5?iAu=1Aso#LAac^Le3kcU7gPE45K20H*s5DKNGQAlv`z-rPf;JhYWL}OQy;!!R|zZ20qyVm> zrYdJG17VjYx9$(n)od*V>sd(phF1aa1jABC=Xd=L)s5=+3wc% zd`HKL3mW(7u01zsOE&DxDcf=gDYRUDaQbqS)3n;DB^dUXY36u9;4}btX=eZ6k!jO0 zN18lZZe!k|KB)w$2l}0SWD^^KFfb1f)uT^gXKzb$W`geU<#Lt0xvTzjidN4z+6VN^ zGF=_p*Z|Oux-^qetli$X7G33-WN2pnf|&lK8Ij>ROqzR63YG%nw@Ic^-f^v<$dN9G zM2aW4!^khdsyC;#$2V}J6-0!R!|UZzAN@L#P2Y>#^L3p33Y1_zr+vK{E@qCRqR!{B=Y%1^N$y;hF>b};AG-5@3K z?S!=~p>!WA3xQ}1GnhvdE;ISs;_cvgNm0WH(qP_Ba!WD%DK|Rr-*noZ$5Ciw{saE@ zBcgfnNNYVmy_w4Dbe5>2thFgAVt7x#o$QfwHE>rI2eV8%a&VjqsFr|N#ByzRDmp*h zx>rNq{t`QHOwt+}Ss^va{(9y|IjMG`POU%NuEXaXs8s%HxD-j{=9Z}CDxa2*7hw!H zq+Wh<{Cn@#?$9sC>6409I1P4o1N-BUujx;1wGudc?Cpp%PGn;MO1KOXuMuue`An-@`t4U$a1f}!x{P-K>w3d+hCmr9T0p4*kb2XF&3&}F*oni zuYF%a=b@XRXdFJ9cHVVIVio1HjN&_|mws(wt9?}&-Y=_T{D z#7gT)H#VP51PcE$2>^>$2d!%RsPpycC_tf6mx905m~%7u3(txyD%|`6Cod@JsmEN- zO1MMv-J4*5w6?9svHS5%AOQWEzPB}%@nAWQ*vi?$yu)I58Qy++<;xQ2rvU2Fyg(L3 zg~Tz+=YXe1^&t8Un0XGXt95Vr^H}YdZ;^-vVjc(o_Sn8vK};7sfC9p%R|Kil#1kPg z)_2Qx4#f!Psm9CO)Uv!rBtZ)S^}a=#M9RhB6OL0y}LeY7jd3`;SDiE z)z1wnMRT-YC#M^KgnP`Fc*m?T()miRja<1}hZY^sVcrzwQS$MY=K=h^-vz+zL%wzU z{lqiRv{+HgH<*D^rGD}QGQ8%HL!!uWu=$(uf_D6BB##uZhP0JloQclZO_6{ z{BLE&E#U6!;Xq1I%K_s?Yn?oU?dO}dcUtO6b$G}b0gLReVPWEyJ#{Bliz=Gqltw8# zt^<=fMR9q1I*=tJ8AuAPvdS?5ds;ZYjTbyz#fc!D`}d`!izyIzWXAP}^tI~@GJQJNCUn}r{| zhrYu?0qj}OX(FHNW#6 zTY+UOq}Rhb3BS%O-=>0Q{iL|@!w{r#X?)1Aq2KxmEGc5UTV{p)DI|8#2)H6eG;by& zDWPu0TZfr%MJcVo!+;Aa^m?E4@Vy%~2z?|~?f`jHKCB^xkLdL~7EcGj_jdOrCh+@3 zi_}m6IQdXN7;TIZ?IE?LF82UOc1K<$X@C6-FSd4S_Y(NG)Tkx4ieMs{GKt=F%6R0Fbh9oq&7$4MKCM)uM?)(~*Iz@>T{uS;7jJn48C{X{W z%!vZ5-y{N=?;-G~t1tDI^9|=JU5)xz+on6N&AJCBveJf3ND7ag1U^CI zA0KZ#W`X)K0zA~pm?_zqCz%aO($;{?Lnh4M;W?+ke9r0BCv7>2y zW=X^n3NywZxO@WTqXV~lIBVT-T`sZ}bT}jRLhbI= zP<70HfrYyOl@K(|Kz;f_RvKJM8QjPryvp%+Z|N5nyPTdri;4hhzgsr>8mE8I3AXdA z2hleTsvx%3XXZHTkrKDYBv`PNgZc+&rd?a0vPY1j_0iJZKBLk^?F`uSY?y!A7JIV@ zFdr|(Bh=Vji`ozIM&h3ZU+Bmd8LL9y@hTtspaGh+;``I0Oo010)gSrZ>-P-YP$8*X zLxI{rCCqJ00Da}2Bdz1RJ?q(fso7WAvG=lsDZ^zC#v0tW~pG{Dbk~7 zsXN9D)#dgcqVPXJAUv?hpx6itVARVSRLk};;m&2$<&ylOpz2Bb0PVD`-Yv<|ey2P9 zO8J1bLm}jXjafBi_pmkj{0|^k;TDchF~0qGiis!$R0bVPL4aW!+)nW9Z^d_(M9IG148FuvF8;5T4=}34RhPs6d?Y^+Nq^V(%&gB! z-lDoy5OA44a~M^keG{R#yu8{ABCUDD{9d|_L@JOxtfQ7<(5K<*5X7ZoL43W#JjWf> z?Ium1dpQ|~mAw{?`K4Ac;dKtmanyF_UvvGI(>JltX#NcsA`W#z`lJW=5_Kp#wrH<# zIhY-v4yXyq3?NDA2gz>dH`@U36eY{6Ra&qBp^IZjOd$857@C_FKZ=8i$ZRQHy4nUR z#kVJ)S%xgHsnN=&=9&Lt9YR8Pg3ngJWVQiXlW)=oP4>MhICT|^wp@8V=iABcR0D+6na2%PCVIqcGvC9 ztQfI)RIzd zFR>lFqEHd=NS0LB*v>dYd3Oj0)7ZIhDd;u!AqRZ{h^K;iuQ5<-?mD(chnKdFpkqL9(@iSOnS>R(VIOO`jBHwG+~<*x8!C*f+&N6nc8{!0=aJ*or7U-gg5T_=MjR=qBdwuYCT(C ziCTSTVlfi!rG2+D`a7vbj26soR)ikifReZ?th#zX85qCnSZ6Em)Z04;4z15ly>GlO zP}Z!S;&>pXxlkRi%k+-%{OZjaReMpwFYzcCQDXfLS!a$>J^z?xXas%ew3`mPL_nWS z-ErNhGxKN@g*~tGm@fFw3Rdze-P)(!?)ImcRM_yoxZ#hKS0cQ&;cQV#%qJ8bp%PT; z210bK*e&p2b(w7#MSgOI7_QNKXCE}N5f9F1tk#~?>^4(80y9Y`1~+f6ZSt&-L*D&M zWggf9LDzG@Tt;8B4os`7`Eh2>e=HhsQfaJQhgBT7!Qb{A;$+osrM61`rO*0Vi+8<<8pU`dcds|=5XxjXukX$VTXm*58fLj`rSEal=5 zJw0={Wzp17%qH0PlcB5Ej7~oMikB_DLqMI8=Kd z>HaWu|1RR4x~vEO)5RTN+C3Q9(BYWvGyPdIXx%=9$)ErehtV_t2BkOJx;3NaeXk5M ztiT)ydLc?Q(94%VAIA?oGFNFVMO6)he=%n&uw1<(?DBT_pFk?*TJEX-JdF3^aF-SJ zb+0A({I5E$JRHikjXz^lJ{5{dmT)LST4Q8O*t zEN$o*%MfA|Y8>H|HBrsjVvI4{xnHi!$#uT|nCp73d7th6-S>Mx_wzjO{WFpJO3i;; zcH>e~dYSGtPYEJm%7o$d{Sl-uXSxkMl~IAKh!^_GFD=6U(RHF@nx!$UUi!#Ab`HVT z_rAF*XF;v%zN1|k7|)eA&}$i~t4zsQQ6TTs-?g1M-v%Mkd8f)%CwdsJMJ=hr<)x*S zMN53`s$H5K+iEHWlK@STgIn6+W(H?kZ0ZqLFH!HO6LW7KtuZT+&%8AWrUPQpLP^q3 ze!6}G&Qo0HpzW&!WtB`y=JiC`>^{ofH`-)2LB|T+)81xW5VQ1~`TF4IqdR7Ox9eVB z2pPdi;Z2lli*NhJ4@}iD1f3QT$^CLH*POfs(oWSQ0#p4m*pwI3;mX0`Mc*S8Dq>AV zLuSQ@ywa+Oj9Jo!E_AC!FG#&< zR~uBaB~=VbXeb21CV>r6ae+JvyofpoNj6T+$@$=A2W3Q=)28}&lw5_OVS459MBW#%B=>o}Jw~7Lxb6mmK zSpUz4>MQyDBql`#E|Rx-CN>d+~g{NXc<*J7O61CcRVly>0d#S}<_ zd~X;ewu~Hx^mOO=ePwyqQISBlwG7c$kWeB0e_k=v!XE{Dq`nlFcO$W8F~lmL{Ia(i z?*zm~a~jG&2j0aLC44mkk>Z1isunSlxe>)QYbq`N@kJZ^ODx0a@CMX zFEvOZjysPLs)etH(q1KLc!B{h3EMj1TxmII< z@MxW2L+Rnr-hANNf4EESe8y}r_sIG|VY%J8(J^0*bwsw6gWhdKk;!lqX86(7iI&&1 zpi7v84)j;<7o-c^Sw5!hATbf^RYFKxg=*3|{jZI2+dAJfJN6F`d^z78$FvGb<6{GI zxVltKTGeIln>6e@Fa4;Gg8J*HM|;}5eF~{8XK`JjA+*lEn%(5rQtVoNX02VgIOgi4 z=)#sC4^+}V(8e^rk56Rqh<3iLo2SpmU)U~dJfdRTJfwS;$dWk|e0lOY|2S^>?|^BZc<&`J}| z<9%&;@i0J;)gSPn3k#FesgfZN&HSrduE^L?ksKlD6t>*drETf2r?S0ey4!06#LG#h z<}0+3&y^>~LmIRB!(POqv~5e+`Ui8&_7&yAV{lQ)q(^LL1uYAX3 zeHNA9V|gWMhFMj4*L*ugoKXY6=Q$d`laEuD(DxWx9=t|p zk`XUkOG?CNr$V6H))5Z&Rpi#sl3}+?MP&|Z7DqDn4q1-5id%>tBjifNyhOxfYOjsb z2RmBcB!yKFoPfK#iLg#%h~X)ALr+g(9^xl=G9@r3fD-+13jJ(gQ;lawt_EG))xLFA zMtcpgv3#3@>~lR$P%}hu%lcxrR{ud2$rhOh)}P2!o@wrj*shb0na)HZ2S|MXHZz|a z!~>jaYeUcdbT}HxmAQP&w6x^{igaM9pZd~vm*T`{PA1%(9WX_X4rD(Uym+ zth81i4?07rPGSD$w^uRj6#+L;oqJ6pPwvZJNx3YxpQ*V-8HFW&D-#fKF;C2YBLFZ`qnUSX$58#j1~59Z_YxKqi4*A&9u-?~d#e)i}oh1ER+`lqgv)O8n&;~+*Z zV#;9632;hM_dOz8L@0vKkFce31Ph4eApIjDJnz2*h-G0Q+^s@bp^G{W-M9znTfxcP z80iuaAiZ+{%>N-)g2%WDa1{Yp(NhB5aS>D=Wp^S6OvajJp!qlxmk!jyTBo7h%E5_@ zSm$IhaEL$E1kSEaIk8ZCB2yOz9iV7^G)VfH7zr?d@hPanJLk!SCRTbVfny9VE^AMP zcP8#V{!e!x{+Pgc0jH;;jtekPu#R4WAUb6!#G--pn@jqGpf${WzgN^r1ekzMqT2ElUQqm>t z0Ggp+p^W!?va)0xtJ`Ym%ow=xThtc*EYvu-;{^=KBfasKP(Wn(p<*>8?*{plxKQ)m q51*bexYb;AVH9s%v*U2crb>x~I(}<_Q4bsM!fw6Ox=gDb(SHLEzjh7) literal 0 HcmV?d00001 diff --git a/src/app/features/push/PushNotification.tsx b/src/app/features/push/PushNotification.tsx new file mode 100644 index 00000000..bf81640c --- /dev/null +++ b/src/app/features/push/PushNotification.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Switch, Text, color } from 'folds'; +import { SettingTile } from '../../components/setting-tile'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { getNotificationState, usePermissionState } from '../../hooks/usePermission'; +import type { ActivePushConfig } from './config'; +import { disablePush, enablePush } from './pusher'; + +export const pushSupported = (): boolean => + 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; + +const readyManager = async () => (await navigator.serviceWorker.ready).pushManager; + +/** Background notifications through the installed app's service worker. The caller renders it only when the host's config fills the push block and the browser can. */ +export function PushNotification({ cfg }: { cfg: ActivePushConfig }) { + const mx = useMatrixClient(); + const permission = usePermissionState('notifications', getNotificationState()); + const [on, setOn] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + + useEffect(() => { + readyManager() + .then((m) => m.getSubscription()) + .then((s) => setOn(!!s)) + .catch(() => undefined); + }, [cfg]); + + const toggle = useCallback( + async (enable: boolean) => { + setBusy(true); + setError(undefined); + try { + if (enable) { + if ((await window.Notification.requestPermission()) !== 'granted') { + throw new Error('Notification permission was not granted.'); + } + await enablePush(mx, await readyManager(), cfg, navigator.userAgent.slice(0, 64)); + } else { + await disablePush(mx, await readyManager(), cfg); + } + setOn(enable); + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not change background notifications.'); + } finally { + setBusy(false); + } + }, + [cfg, mx] + ); + + return ( + + {error ?? 'Notification permission is blocked. Allow it from the browser address bar.'} + + ) : ( + + Notify when the app is closed. Installing the app to the home screen helps on iOS. + + ) + } + after={} + /> + ); +} diff --git a/src/app/features/push/config.test.ts b/src/app/features/push/config.test.ts new file mode 100644 index 00000000..f014138a --- /dev/null +++ b/src/app/features/push/config.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { activePushConfig } from './config'; + +describe('activePushConfig', () => { + it('is off unless all three keys are filled', () => { + expect(activePushConfig(undefined)).toBeUndefined(); + expect(activePushConfig({})).toBeUndefined(); + expect( + activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'https://g/_matrix/push/v1/notify' }) + ).toBeUndefined(); + expect(activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'u', appId: 'a' })).toEqual({ + vapidPublicKey: 'k', + gatewayUrl: 'u', + appId: 'a', + }); + }); + it('the shipped config.json carries the block empty, so push is off', () => { + const cfg = JSON.parse(readFileSync(join(__dirname, '../../../../config.json'), 'utf8')); + expect(cfg.push).toEqual({ vapidPublicKey: '', gatewayUrl: '', appId: '' }); + expect(activePushConfig(cfg.push)).toBeUndefined(); + }); +}); diff --git a/src/app/features/push/config.ts b/src/app/features/push/config.ts new file mode 100644 index 00000000..31e476cd --- /dev/null +++ b/src/app/features/push/config.ts @@ -0,0 +1,12 @@ +// Web Push is off unless the host's config.json fills all three keys (ships empty). The public key is not a secret; +// the gateway URL is the push gateway's notify endpoint the homeserver calls (`/_matrix/push/v1/notify`). +export type PushConfig = { + vapidPublicKey?: string; + gatewayUrl?: string; + appId?: string; +}; + +export type ActivePushConfig = Required; + +export const activePushConfig = (c?: PushConfig): ActivePushConfig | undefined => + c?.vapidPublicKey && c.gatewayUrl && c.appId ? (c as ActivePushConfig) : undefined; diff --git a/src/app/features/push/manifest.test.ts b/src/app/features/push/manifest.test.ts new file mode 100644 index 00000000..806b3fc4 --- /dev/null +++ b/src/app/features/push/manifest.test.ts @@ -0,0 +1,29 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const ROOT = join(__dirname, '../../../..'); +const manifest = JSON.parse(readFileSync(join(ROOT, 'public/manifest.json'), 'utf8')); + +describe('web app manifest', () => { + it('has a stable identity and scope, and no orientation lock', () => { + expect(manifest.id).toBe('/'); // what Chrome already derives from start_url "./" at the site root + expect(manifest.scope).toBe('./'); + expect(manifest.start_url).toBe('./'); + expect(manifest.display).toBe('standalone'); + expect(manifest.orientation).toBeUndefined(); + }); + it('offers 192 and 512 icons for any use and a 512 maskable one, every file present', () => { + const icons = manifest.icons as { src: string; sizes: string; purpose?: string }[]; + const any = icons.filter((i) => !i.purpose); + expect(any.map((i) => i.sizes)).toEqual(expect.arrayContaining(['192x192', '512x512'])); + const maskable = icons.filter((i) => i.purpose === 'maskable'); + expect(maskable.map((i) => i.sizes)).toEqual(['512x512']); + // the build copies public/res/android to public/android (vite.config.js) + icons.forEach((i) => + expect( + existsSync(join(ROOT, i.src.replace('./public/android/', 'public/res/android/'))) + ).toBe(true) + ); + }); +}); diff --git a/src/app/features/push/payload.test.ts b/src/app/features/push/payload.test.ts new file mode 100644 index 00000000..8e9e04eb --- /dev/null +++ b/src/app/features/push/payload.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { describePush, GENERIC, parsePush } from './payload'; +import { matrixNotify, mockHomeserver, SESSION } from './testkit'; + +describe('parsePush', () => { + it('reads the Matrix gateway body and the bare inner object', () => { + const a = parsePush(matrixNotify('!r:x', '$e')); + expect(a).toMatchObject({ roomId: '!r:x', eventId: '$e' }); + expect(parsePush({ room_id: '!r:x', event_id: '$e' })).toMatchObject({ roomId: '!r:x' }); + }); + it('rejects a body with no room id', () => { + expect(parsePush({ notification: { event_id: '$e' } })).toBeUndefined(); + expect(parsePush(undefined)).toBeUndefined(); + expect(parsePush('x')).toBeUndefined(); + }); + it('carries text the gateway added', () => { + expect( + parsePush({ + notification: { + room_id: '!r:x', + room_name: 'Lobby', + sender_display_name: 'Ann', + content: { body: 'hi' }, + }, + }) + ).toMatchObject({ roomName: 'Lobby', sender: 'Ann', text: 'hi' }); + }); +}); + +describe('describePush', () => { + const ids = { roomId: '!r:x', eventId: '$e' }; + const event = { + type: 'm.room.message', + sender: '@ann:x', + content: { msgtype: 'm.text', body: 'hello there' }, + }; + + it('resolves ids through the homeserver with the stored token', async () => { + const hs = mockHomeserver({ + token: 'tok-1', + events: { $e: event }, + roomNames: { '!r:x': 'Lobby' }, + }); + expect(await describePush(ids, SESSION, hs.fetchFn)).toEqual({ + title: 'Lobby', + body: 'ann: hello there', + }); + expect(hs.seen).toHaveLength(2); + expect( + hs.seen.every( + (r) => r.auth === 'Bearer tok-1' && r.url.startsWith('https://matrix.example/_matrix/') + ) + ).toBe(true); + expect(hs.seen[0].url).toContain('/rooms/!r%3Ax/'); + }); + it('shows the generic notification with no session, and asks nobody', async () => { + const hs = mockHomeserver({ token: 'tok-1' }); + expect(await describePush(ids, undefined, hs.fetchFn)).toEqual({ + title: 'Ratatoskr', + body: 'New message', + }); + expect(hs.seen).toHaveLength(0); + }); + it('falls back when the token is refused, the network is down, or the event is encrypted', async () => { + const stale = mockHomeserver({ token: 'other', events: { $e: event } }); + expect(await describePush(ids, SESSION, stale.fetchFn)).toEqual(GENERIC); + const down = mockHomeserver({ token: 'tok-1', failAll: true }); + expect(await describePush(ids, SESSION, down.fetchFn)).toEqual(GENERIC); + const enc = mockHomeserver({ + token: 'tok-1', + events: { + $e: { + type: 'm.room.encrypted', + sender: '@ann:x', + content: { algorithm: 'm.megolm.v1.aes-sha2' }, + }, + }, + roomNames: { '!r:x': 'Lobby' }, + }); + expect(await describePush(ids, SESSION, enc.fetchFn)).toEqual({ + title: 'Lobby', + body: 'New message', + }); + }); + it('does not fetch what the push already carried', async () => { + const hs = mockHomeserver({ token: 'tok-1' }); + const shown = await describePush( + { ...ids, roomName: 'R', sender: 'Bo', text: 'yo' }, + SESSION, + hs.fetchFn + ); + expect(shown).toEqual({ title: 'R', body: 'Bo: yo' }); + expect(hs.seen).toHaveLength(0); + }); + it('clips a long body', async () => { + const hs = mockHomeserver({ + token: 'tok-1', + events: { $e: { ...event, content: { body: 'x'.repeat(500) } } }, + }); + const shown = await describePush(ids, SESSION, hs.fetchFn); + expect(shown.body.length).toBeLessThanOrEqual(200); + expect(shown.body.endsWith('…')).toBe(true); + }); + it('shows the generic notification when the push names no room', async () => { + const hs = mockHomeserver({ token: 'tok-1' }); + expect(await describePush(undefined, SESSION, hs.fetchFn)).toEqual(GENERIC); + }); +}); diff --git a/src/app/features/push/payload.ts b/src/app/features/push/payload.ts new file mode 100644 index 00000000..b722d92a --- /dev/null +++ b/src/app/features/push/payload.ts @@ -0,0 +1,95 @@ +import type { SessionRecord } from './sessionStore'; + +/** What a push names. The homeserver sends ids only (`push.include_content: false`); a gateway may add text. */ +export interface PushIds { + roomId: string; + eventId?: string; + roomName?: string; + sender?: string; + text?: string; +} + +export interface Shown { + title: string; + body: string; +} + +const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); + +/** The Matrix push body is `{ notification: {...} }`; a gateway may forward the inner object. No room id: not ours to show. */ +export function parsePush(raw: unknown): PushIds | undefined { + const top = (raw ?? {}) as Record; + const n = (top.notification ?? top) as Record; + const roomId = str(n.room_id); + if (!roomId) return undefined; + const content = (n.content ?? {}) as Record; + return { + roomId, + eventId: str(n.event_id), + roomName: str(n.room_name), + sender: str(n.sender_display_name) ?? str(n.sender), + text: str(content.body), + }; +} + +export const GENERIC: Shown = { title: 'Ratatoskr', body: 'New message' }; +const MAX_BODY = 200; +const clip = (s: string): string => (s.length > MAX_BODY ? `${s.slice(0, MAX_BODY - 1)}…` : s); +const localpart = (userId: string): string => userId.replace(/^@/, '').split(':')[0]; + +type Fetch = (url: string, init: RequestInit) => Promise; + +async function getJson( + f: Fetch, + session: SessionRecord, + path: string, + timeoutMs: number +): Promise | undefined> { + try { + const res = await f(`${session.baseUrl.replace(/\/+$/, '')}${path}`, { + headers: { Authorization: `Bearer ${session.accessToken}` }, + signal: AbortSignal.timeout(timeoutMs), + }); + return res.ok ? ((await res.json()) as Record) : undefined; + } catch { + return undefined; + } +} + +/** + * Title and body for a push. Text the push carried wins. Otherwise, with a session, the event and the room's name are + * fetched (clear-text rooms; an encrypted event shows the generic body). Every failure ends at the generic notification: + * a push must always show something. + */ +export async function describePush( + ids: PushIds | undefined, + session: SessionRecord | undefined, + f: Fetch, + timeoutMs = 4000 +): Promise { + if (!ids) return GENERIC; + let { roomName, sender, text } = ids; + if (session && (!text || !roomName)) { + const room = encodeURIComponent(ids.roomId); + const [event, name] = await Promise.all([ + !text && ids.eventId + ? getJson( + f, + session, + `/_matrix/client/v3/rooms/${room}/event/${encodeURIComponent(ids.eventId)}`, + timeoutMs + ) + : undefined, + !roomName + ? getJson(f, session, `/_matrix/client/v3/rooms/${room}/state/m.room.name`, timeoutMs) + : undefined, + ]); + const content = (event?.content ?? {}) as Record; + if (event?.type === 'm.room.message') text = str(content.body); + if (!sender && str(event?.sender)) sender = localpart(event?.sender as string); + roomName = roomName ?? str(name?.name); + } + let { body } = GENERIC; + if (text) body = sender ? `${sender}: ${text}` : text; + return { title: roomName ?? GENERIC.title, body: clip(body) }; +} diff --git a/src/app/features/push/pusher.test.ts b/src/app/features/push/pusher.test.ts new file mode 100644 index 00000000..73b270cb --- /dev/null +++ b/src/app/features/push/pusher.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import type { IPusherRequest } from 'matrix-js-sdk'; +import { disablePush, enablePush, PushManagerPort, vapidKeyBytes, webPushPusher } from './pusher'; + +const cfg = { + vapidPublicKey: 'BAUD', + gatewayUrl: 'https://matrix.example/_matrix/push/v1/notify', + appId: 'org.gamecult.ratatoskr.web', +}; +const key = (...b: number[]) => Uint8Array.from(b).buffer; + +function subscription(unsubscribed: string[] = []): PushSubscription { + return { + endpoint: 'https://push.example/send/abc', + getKey: (n: string) => (n === 'p256dh' ? key(251, 255, 1) : key(250, 2)), + unsubscribe: async () => { + unsubscribed.push('x'); + return true; + }, + } as unknown as PushSubscription; +} + +function world() { + const calls: string[] = []; + const pushers: IPusherRequest[] = []; + let sub: PushSubscription | null = null; + const unsub: string[] = []; + const mx = { + setPusher: async (p: IPusherRequest) => { + calls.push(p.kind === null ? 'delete-pusher' : 'set-pusher'); + pushers.push(p); + }, + }; + const push: PushManagerPort = { + getSubscription: async () => sub, + subscribe: async (o) => { + calls.push(`subscribe:${o.userVisibleOnly}:${o.applicationServerKey.length}`); + sub = subscription(unsub); + return sub; + }, + }; + return { mx, push, calls, pushers, unsub }; +} + +describe('web push pusher', () => { + it('decodes the URL-safe VAPID key', () => { + expect([...vapidKeyBytes('-_8')]).toEqual([251, 255]); + }); + it('is the Sygnal webpush shape: app id, p256dh pushkey, subscription in data, ids only', () => { + const p = webPushPusher(cfg, subscription(), 'phone') as unknown as Record & { + data: Record; + }; + expect(p).toMatchObject({ + kind: 'http', + app_id: cfg.appId, + pushkey: '-_8B', + device_display_name: 'phone', + append: false, + }); + expect(p.data).toMatchObject({ + url: cfg.gatewayUrl, + format: 'event_id_only', + endpoint: 'https://push.example/send/abc', + auth: '-gI', + p256dh: '-_8B', + }); + }); + it('enable subscribes with userVisibleOnly, then registers the pusher; a second enable reuses the subscription', async () => { + const w = world(); + await enablePush(w.mx, w.push, cfg, 'phone'); + expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']); + await enablePush(w.mx, w.push, cfg, 'phone'); + expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher', 'set-pusher']); + }); + it('disable deletes the pusher before the subscription, and does nothing when there is none', async () => { + const w = world(); + await disablePush(w.mx, w.push, cfg); + expect(w.calls).toEqual([]); + await enablePush(w.mx, w.push, cfg, 'phone'); + await disablePush(w.mx, w.push, cfg); + expect(w.calls.slice(-1)).toEqual(['delete-pusher']); + expect(w.pushers.at(-1)).toMatchObject({ kind: null, app_id: cfg.appId, pushkey: '-_8B' }); + expect(w.unsub).toEqual(['x']); + }); + it('a failed pusher write leaves the subscription alone', async () => { + const w = world(); + await enablePush(w.mx, w.push, cfg, 'phone'); + w.mx.setPusher = async () => { + throw new Error('homeserver said no'); + }; + await expect(disablePush(w.mx, w.push, cfg)).rejects.toThrow('homeserver said no'); + expect(w.unsub).toEqual([]); + }); +}); diff --git a/src/app/features/push/pusher.ts b/src/app/features/push/pusher.ts new file mode 100644 index 00000000..65c0aca4 --- /dev/null +++ b/src/app/features/push/pusher.ts @@ -0,0 +1,86 @@ +import type { IPusherRequest } from 'matrix-js-sdk'; +import type { ActivePushConfig } from './config'; + +/** What the page needs of the homeserver client and the browser's push manager, so tests supply fakes. */ +export interface PusherClient { + setPusher(p: IPusherRequest): Promise; +} +export interface PushManagerPort { + getSubscription(): Promise; + subscribe(o: { + userVisibleOnly: boolean; + applicationServerKey: Uint8Array; + }): Promise; +} + +export function vapidKeyBytes(b64url: string): Uint8Array { + const pad = '='.repeat((4 - (b64url.length % 4)) % 4); + const bin = atob((b64url + pad).replace(/-/g, '+').replace(/_/g, '/')); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); +} + +const b64url = (buf: ArrayBuffer | null): string => + btoa(String.fromCharCode(...new Uint8Array(buf ?? new ArrayBuffer(0)))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + +/** + * The Matrix pusher for a browser subscription (Sygnal's `webpush` shape: the subscription's keys ride in `data`; the + * pushkey is the subscription's p256dh key, as Element Web does). `event_id_only` asks the homeserver for ids, which + * the worker resolves itself. + */ +export function webPushPusher( + cfg: ActivePushConfig, + sub: PushSubscription, + deviceName: string +): IPusherRequest { + const p256dh = b64url(sub.getKey('p256dh')); + return { + kind: 'http', + app_id: cfg.appId, + pushkey: p256dh, + app_display_name: 'Ratatoskr', + device_display_name: deviceName, + lang: navigator.language || 'en', + data: { + url: cfg.gatewayUrl, + format: 'event_id_only', + endpoint: sub.endpoint, + auth: b64url(sub.getKey('auth')), + p256dh, + }, + append: false, + } as IPusherRequest; +} + +export async function enablePush( + mx: PusherClient, + push: PushManagerPort, + cfg: ActivePushConfig, + deviceName: string +): Promise { + const sub = + (await push.getSubscription()) ?? + (await push.subscribe({ + userVisibleOnly: true, + applicationServerKey: vapidKeyBytes(cfg.vapidPublicKey), + })); + await mx.setPusher(webPushPusher(cfg, sub, deviceName)); +} + +/** Delete the pusher, then the subscription. The order matters: a pusher with no subscription would keep failing at the gateway. */ +export async function disablePush( + mx: PusherClient, + push: PushManagerPort, + cfg: ActivePushConfig +): Promise { + const sub = await push.getSubscription(); + if (!sub) return; + await mx.setPusher({ + kind: null, + app_id: cfg.appId, + pushkey: b64url(sub.getKey('p256dh')), + } as unknown as IPusherRequest); + await sub.unsubscribe(); +} diff --git a/src/app/features/push/sessionStore.test.ts b/src/app/features/push/sessionStore.test.ts new file mode 100644 index 00000000..81668a5f --- /dev/null +++ b/src/app/features/push/sessionStore.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { idbPort, RecordPort, SessionRecord, sessionStore } from './sessionStore'; + +const memory = (): RecordPort & { held?: unknown } => { + const m: RecordPort & { held?: unknown } = { + async read() { + return m.held; + }, + async write(v) { + m.held = v; + }, + }; + return m; +}; + +describe('sessionStore', () => { + it('saves a valid session and loads it back', async () => { + const s = sessionStore(memory()); + await s.save('https://hs/', 'tok'); + expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' }); + }); + it('deletes the record on logout, and on anything that is not two strings', async () => { + const port = memory(); + const s = sessionStore(port); + await s.save('https://hs/', 'tok'); + await s.save(undefined, undefined); + expect(port.held).toBeUndefined(); + await s.save('https://hs/', 'tok'); + await s.save('https://hs/', 5); + expect(await s.load()).toBeUndefined(); + }); + it('ignores a record that is not a session', async () => { + const port = memory(); + port.held = { baseUrl: 1 }; + expect(await sessionStore(port).load()).toBeUndefined(); + }); +}); + +// Just enough of IndexedDB for the port: one database, object stores of key -> value, async request callbacks. +function fakeIndexedDB(): IDBFactory { + const dbs = new Map>>(); + const request = (run: () => T) => { + const r = {} as IDBRequest; + setTimeout(() => { + (r as { result: T }).result = run(); + r.onsuccess?.(new Event('success')); + }, 0); + return r; + }; + return { + open(name: string) { + const r = {} as IDBOpenDBRequest; + setTimeout(() => { + const fresh = !dbs.has(name); + const stores = dbs.get(name) ?? new Map(); + dbs.set(name, stores); + const db = { + createObjectStore: (n: string) => stores.set(n, new Map()), + close: () => undefined, + transaction: (n: string) => { + const tx = {} as IDBTransaction; + const store = stores.get(n) as Map; + const api = { + get: (k: string) => request(() => store.get(k)), + put: (v: unknown, k: string) => { + store.set(k, v); + setTimeout(() => tx.oncomplete?.(new Event('complete')), 0); + }, + delete: (k: string) => { + store.delete(k); + setTimeout(() => tx.oncomplete?.(new Event('complete')), 0); + }, + }; + (tx as { objectStore: unknown }).objectStore = () => api; + return tx; + }, + } as unknown as IDBDatabase; + (r as { result: IDBDatabase }).result = db; + if (fresh) r.onupgradeneeded?.(new Event('upgradeneeded') as IDBVersionChangeEvent); + r.onsuccess?.(new Event('success')); + }, 0); + return r; + }, + } as unknown as IDBFactory; +} + +describe('idbPort', () => { + it('round-trips the one record through IndexedDB and deletes it', async () => { + const s = sessionStore(idbPort(fakeIndexedDB())); + expect(await s.load()).toBeUndefined(); + await s.save('https://hs/', 'tok'); + expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' }); + await s.save(undefined, undefined); + expect(await s.load()).toBeUndefined(); + }); +}); diff --git a/src/app/features/push/sessionStore.ts b/src/app/features/push/sessionStore.ts new file mode 100644 index 00000000..adaa711b --- /dev/null +++ b/src/app/features/push/sessionStore.ts @@ -0,0 +1,78 @@ +// The one thing a push needs that a closed app cannot give: the access token, to fetch the event a push names. +// Stored: `{ baseUrl, accessToken }`, one record, in this origin's IndexedDB `ratatoskr-sw` (not reachable by other +// origins; the token is already in the page's own storage). Written when the page hands the worker its session, +// deleted when the page hands it none (logout). Nothing else is stored, and nothing else reads it but the push handler. +export interface SessionRecord { + baseUrl: string; + accessToken: string; +} + +/** The two operations the store needs from a key-value backend, so tests run without IndexedDB. */ +export interface RecordPort { + read(): Promise; + write(value: SessionRecord | undefined): Promise; +} + +const DB = 'ratatoskr-sw'; +const STORE = 'session'; +const KEY = 'session'; + +const done = (req: IDBRequest): Promise => + new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + +export function idbPort(factory: IDBFactory): RecordPort { + const open = (): Promise => { + const req = factory.open(DB, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE); + return done(req); + }; + return { + async read() { + const db = await open(); + try { + return await done(db.transaction(STORE).objectStore(STORE).get(KEY)); + } finally { + db.close(); + } + }, + async write(value) { + const db = await open(); + try { + const tx = db.transaction(STORE, 'readwrite'); + const store = tx.objectStore(STORE); + const finished = new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + if (value) store.put(value, KEY); + else store.delete(KEY); + await finished; + } finally { + db.close(); + } + }, + }; +} + +export function sessionStore(port: RecordPort) { + return { + /** A valid session replaces the record; anything else (logout) deletes it. */ + async save(baseUrl: unknown, accessToken: unknown): Promise { + await port.write( + typeof baseUrl === 'string' && typeof accessToken === 'string' && baseUrl && accessToken + ? { baseUrl, accessToken } + : undefined + ); + }, + async load(): Promise { + const v = (await port.read()) as Partial | undefined; + return v && typeof v.baseUrl === 'string' && typeof v.accessToken === 'string' + ? { baseUrl: v.baseUrl, accessToken: v.accessToken } + : undefined; + }, + }; +} diff --git a/src/app/features/push/testkit.ts b/src/app/features/push/testkit.ts new file mode 100644 index 00000000..7f594f9f --- /dev/null +++ b/src/app/features/push/testkit.ts @@ -0,0 +1,41 @@ +import type { SessionRecord } from './sessionStore'; + +/** A mock homeserver: answers the two reads the push handler makes, and records every request it saw. */ +export function mockHomeserver(opts: { + token: string; + events?: Record; + roomNames?: Record; + failAll?: boolean; +}) { + const seen: { url: string; auth: string | null }[] = []; + const fetchFn = async (url: string, init: RequestInit): Promise => { + const auth = new Headers(init.headers).get('Authorization'); + seen.push({ url, auth }); + if (opts.failAll) throw new Error('network down'); + if (auth !== `Bearer ${opts.token}`) return new Response('{}', { status: 401 }); + const ev = /\/rooms\/([^/]+)\/event\/([^/]+)$/.exec(url); + if (ev) { + const e = opts.events?.[decodeURIComponent(ev[2])]; + return e ? new Response(JSON.stringify(e)) : new Response('{}', { status: 404 }); + } + const nm = /\/rooms\/([^/]+)\/state\/m\.room\.name$/.exec(url); + if (nm) { + const name = opts.roomNames?.[decodeURIComponent(nm[1])]; + return name ? new Response(JSON.stringify({ name })) : new Response('{}', { status: 404 }); + } + return new Response('{}', { status: 404 }); + }; + return { fetchFn, seen }; +} + +export const SESSION: SessionRecord = { baseUrl: 'https://matrix.example/', accessToken: 'tok-1' }; + +/** The body a homeserver posts to a push gateway for `format: event_id_only` (Matrix push gateway API). */ +export const matrixNotify = (roomId: string, eventId: string) => ({ + notification: { + event_id: eventId, + room_id: roomId, + counts: { unread: 1 }, + devices: [{ app_id: 'org.gamecult.ratatoskr.web', pushkey: 'k', data: {} }], + }, +}); diff --git a/src/app/features/push/usePushOpen.ts b/src/app/features/push/usePushOpen.ts new file mode 100644 index 00000000..e3b88d3a --- /dev/null +++ b/src/app/features/push/usePushOpen.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { OPEN_ROOM, PUSH_EVENT_PARAM, PUSH_ROOM_PARAM } from './worker'; + +/** Opens the room a push notification named: from the worker's message (app open) or the start URL (app was closed). */ +export function usePushOpen(): void { + const { navigateRoom } = useRoomNavigate(); + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const roomId = params.get(PUSH_ROOM_PARAM); + if (roomId) { + navigateRoom(roomId, params.get(PUSH_EVENT_PARAM) ?? undefined); + params.delete(PUSH_ROOM_PARAM); + params.delete(PUSH_EVENT_PARAM); + const rest = params.toString(); + window.history.replaceState( + null, + '', + `${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}` + ); + } + const onMessage = (ev: MessageEvent) => { + if (ev.data?.type === OPEN_ROOM && typeof ev.data.roomId === 'string') { + navigateRoom( + ev.data.roomId, + typeof ev.data.eventId === 'string' ? ev.data.eventId : undefined + ); + } + }; + navigator.serviceWorker?.addEventListener('message', onMessage); + return () => navigator.serviceWorker?.removeEventListener('message', onMessage); + }, [navigateRoom]); +} diff --git a/src/app/features/push/worker.test.ts b/src/app/features/push/worker.test.ts new file mode 100644 index 00000000..f1f766ce --- /dev/null +++ b/src/app/features/push/worker.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest'; +import { handleClick, handlePush, OPEN_ROOM, WorkerPort } from './worker'; +import { matrixNotify, mockHomeserver, SESSION } from './testkit'; + +type Shown = { title: string; options: NotificationOptions & { renotify?: boolean } }; + +function port(over: Partial = {}) { + const shown: Shown[] = []; + const opened: string[] = []; + const w: WorkerPort = { + windows: async () => [], + openWindow: async (u) => { + opened.push(u); + }, + show: async (title, options) => { + shown.push({ title, options }); + }, + loadSession: async () => SESSION, + fetch: mockHomeserver({ token: 'tok-1' }).fetchFn, + scope: 'https://chat.example/', + ...over, + }; + return { w, shown, opened }; +} + +const msg = { + type: 'm.room.message', + sender: '@ann:x', + content: { msgtype: 'm.text', body: 'hello' }, +}; + +describe('push against a mock gateway and homeserver', () => { + it('a push the homeserver sent for event_id_only becomes a notification naming room, sender and text', async () => { + const hs = mockHomeserver({ + token: 'tok-1', + events: { $e: msg }, + roomNames: { '!r:x': 'Lobby' }, + }); + const { w, shown } = port({ fetch: hs.fetchFn }); + await handlePush(matrixNotify('!r:x', '$e'), w); + expect(shown).toHaveLength(1); + expect(shown[0].title).toBe('Lobby'); + expect(shown[0].options.body).toBe('ann: hello'); + expect(shown[0].options.tag).toBe('!r:x'); + expect(shown[0].options.renotify).toBe(true); + expect(shown[0].options.data).toEqual({ roomId: '!r:x', eventId: '$e' }); + }); + it('with no stored session it still shows (a push must always show) and sends the token nowhere', async () => { + const hs = mockHomeserver({ token: 'tok-1', events: { $e: msg } }); + const { w, shown } = port({ fetch: hs.fetchFn, loadSession: async () => undefined }); + await handlePush(matrixNotify('!r:x', '$e'), w); + expect(shown[0]).toMatchObject({ title: 'Ratatoskr', options: { body: 'New message' } }); + expect(hs.seen).toHaveLength(0); + }); + it('shows the generic notification for a body it cannot read', async () => { + const { w, shown } = port(); + await handlePush(undefined, w); + expect(shown[0].title).toBe('Ratatoskr'); + expect(shown[0].options.tag).toBeUndefined(); + expect(shown[0].options.data).toBeUndefined(); + }); + it('shows nothing while a window is visible and focused, and shows when the window is hidden', async () => { + const win = (visibilityState: string, focused: boolean) => ({ + visibilityState, + focused, + focus: async () => undefined, + postMessage: () => undefined, + }); + const a = port({ windows: async () => [win('visible', true)] }); + await handlePush(matrixNotify('!r:x', '$e'), a.w); + expect(a.shown).toHaveLength(0); + const b = port({ windows: async () => [win('hidden', false), win('visible', false)] }); + await handlePush(matrixNotify('!r:x', '$e'), b.w); + expect(b.shown).toHaveLength(1); + }); +}); + +describe('notification click', () => { + it('focuses the open window and tells it the room', async () => { + const focus = vi.fn(async () => undefined); + const postMessage = vi.fn(); + const { w, opened } = port({ + windows: async () => [{ visibilityState: 'hidden', focused: false, focus, postMessage }], + }); + await handleClick({ roomId: '!r:x', eventId: '$e' }, w); + expect(focus).toHaveBeenCalled(); + expect(postMessage).toHaveBeenCalledWith({ type: OPEN_ROOM, roomId: '!r:x', eventId: '$e' }); + expect(opened).toHaveLength(0); + }); + it('with no window, starts the app with the room in the URL', async () => { + const { w, opened } = port(); + await handleClick({ roomId: '!r:x', eventId: '$e' }, w); + const u = new URL(opened[0]); + expect(u.origin + u.pathname).toBe('https://chat.example/'); + expect(u.searchParams.get('pushRoom')).toBe('!r:x'); + expect(u.searchParams.get('pushEvent')).toBe('$e'); + }); + it('with no window and no target, just opens the app', async () => { + const { w, opened } = port(); + await handleClick(undefined, w); + expect(opened).toEqual(['https://chat.example/']); + }); +}); diff --git a/src/app/features/push/worker.ts b/src/app/features/push/worker.ts new file mode 100644 index 00000000..5e3ccd75 --- /dev/null +++ b/src/app/features/push/worker.ts @@ -0,0 +1,61 @@ +import { describePush, parsePush } from './payload'; +import type { SessionRecord } from './sessionStore'; + +/** The slice of the service worker's scope the handlers use, so they run in tests without a worker. */ +export interface WorkerPort { + windows(): Promise< + { + visibilityState: string; + focused: boolean; + focus(): Promise; + postMessage(m: unknown): void; + }[] + >; + openWindow(url: string): Promise; + show(title: string, options: NotificationOptions): Promise; + loadSession(): Promise; + fetch(url: string, init: RequestInit): Promise; + /** The worker's scope URL: where a cold click opens the app. */ + scope: string; +} + +export interface PushTarget { + roomId: string; + eventId?: string; +} + +/** A push shows a notification, unless the app is open and focused (browsers allow that, and the page notifies itself). */ +export async function handlePush(raw: unknown, w: WorkerPort): Promise { + const ids = parsePush(raw); + if ((await w.windows()).some((c) => c.visibilityState === 'visible' && c.focused)) return; + const shown = await describePush(ids, await w.loadSession(), w.fetch); + await w.show(shown.title, { + body: shown.body, + // One notification per room, replaced (and re-alerted) by the next message there. + tag: ids?.roomId, + renotify: !!ids?.roomId, + icon: './public/android/android-chrome-192x192.png', + data: ids ? ({ roomId: ids.roomId, eventId: ids.eventId } satisfies PushTarget) : undefined, + } as NotificationOptions); +} + +export const OPEN_ROOM = 'openRoom'; +export const PUSH_ROOM_PARAM = 'pushRoom'; +export const PUSH_EVENT_PARAM = 'pushEvent'; + +/** A click focuses an open window and tells it which room; with none open it starts the app with the room in the URL. */ +export async function handleClick(target: PushTarget | undefined, w: WorkerPort): Promise { + const windows = await w.windows(); + const open = windows[0]; + if (open) { + await open.focus(); + if (target?.roomId) open.postMessage({ type: OPEN_ROOM, ...target }); + return; + } + const url = new URL(w.scope); + if (target?.roomId) { + url.searchParams.set(PUSH_ROOM_PARAM, target.roomId); + if (target.eventId) url.searchParams.set(PUSH_EVENT_PARAM, target.eventId); + } + await w.openWindow(url.href); +} diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 38115980..90630e1c 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -10,6 +10,9 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { PushNotification, pushSupported } from '../../push/PushNotification'; +import { activePushConfig } from '../../push/config'; +import { useClientConfig } from '../../../hooks/useClientConfig'; function EmailNotification() { const mx = useMatrixClient(); @@ -85,6 +88,7 @@ function EmailNotification() { } export function SystemNotification() { + const pushCfg = activePushConfig(useClientConfig().push); const notifPermission = usePermissionState('notifications', getNotificationState()); const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); const [isNotificationSounds, setIsNotificationSounds] = useSetting( @@ -133,6 +137,16 @@ export function SystemNotification() { } /> + {pushCfg && pushSupported() && ( + + + + )} diff --git a/src/sw.ts b/src/sw.ts index 69293b1d..4df85a88 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,8 +1,14 @@ /// +import { handleClick, handlePush, PushTarget, WorkerPort } from './app/features/push/worker'; +import { idbPort, sessionStore } from './app/features/push/sessionStore'; + export type {}; declare const self: ServiceWorkerGlobalScope; +// What a push can do with no tab open: the session is kept in IndexedDB (sessionStore.ts), not only in `sessions` below. +const persisted = sessionStore(idbPort(self.indexedDB)); + type SessionInfo = { accessToken: string; baseUrl: string; @@ -30,6 +36,7 @@ async function cleanupDeadClients() { } function setSession(clientId: string, accessToken: any, baseUrl: any) { + persisted.save(baseUrl, accessToken).catch(() => undefined); if (typeof accessToken === 'string' && typeof baseUrl === 'string') { sessions.set(clientId, { accessToken, baseUrl }); } else { @@ -156,3 +163,29 @@ self.addEventListener('fetch', (event: FetchEvent) => { }) ); }); + +const workerPort: WorkerPort = { + windows: async () => [ + ...(await self.clients.matchAll({ type: 'window', includeUncontrolled: true })), + ], + openWindow: (url) => self.clients.openWindow(url), + show: (title, options) => self.registration.showNotification(title, options), + loadSession: () => persisted.load().catch(() => undefined), + fetch: (url, init) => fetch(url, init), + scope: self.registration.scope, +}; + +self.addEventListener('push', (event: PushEvent) => { + let raw: unknown; + try { + raw = event.data?.json(); + } catch { + raw = undefined; + } + event.waitUntil(handlePush(raw, workerPort)); +}); + +self.addEventListener('notificationclick', (event: NotificationEvent) => { + event.notification.close(); + event.waitUntil(handleClick(event.notification.data as PushTarget | undefined, workerPort)); +}); From 695a016d4fa7a93b7a7544b72cbffb822b48c8b0 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 20:05:10 +0000 Subject: [PATCH 051/143] push: keep the token only while subscribed; toggle reads the account's pusher; replace a stale VAPID subscription Soul pass findings on the stage 0-1 branch: the worker stored the token on every deployment, push configured or not (now only while the browser holds a push subscription; the page re-sends its session after the toggle changes it); the toggle read the browser's subscription, not the account's pusher (now both); a subscription under an old VAPID key was reused (now replaced); a failing client list now still shows the notification. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/push/PushNotification.tsx | 11 +++-- src/app/features/push/pusher.test.ts | 57 ++++++++++++++++++++-- src/app/features/push/pusher.ts | 35 ++++++++++--- src/app/features/push/sessionStore.test.ts | 15 ++++++ src/app/features/push/sessionStore.ts | 14 +++++- src/app/features/push/worker.test.ts | 5 ++ src/app/features/push/worker.ts | 3 +- src/sw.ts | 8 ++- 8 files changed, 129 insertions(+), 19 deletions(-) diff --git a/src/app/features/push/PushNotification.tsx b/src/app/features/push/PushNotification.tsx index bf81640c..a818354e 100644 --- a/src/app/features/push/PushNotification.tsx +++ b/src/app/features/push/PushNotification.tsx @@ -4,7 +4,8 @@ import { SettingTile } from '../../components/setting-tile'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { getNotificationState, usePermissionState } from '../../hooks/usePermission'; import type { ActivePushConfig } from './config'; -import { disablePush, enablePush } from './pusher'; +import { pushSessionToSW } from '../../../sw-session'; +import { disablePush, enablePush, pushIsOn } from './pusher'; export const pushSupported = (): boolean => 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; @@ -21,10 +22,10 @@ export function PushNotification({ cfg }: { cfg: ActivePushConfig }) { useEffect(() => { readyManager() - .then((m) => m.getSubscription()) - .then((s) => setOn(!!s)) + .then((m) => pushIsOn(mx, m, cfg)) + .then(setOn) .catch(() => undefined); - }, [cfg]); + }, [cfg, mx]); const toggle = useCallback( async (enable: boolean) => { @@ -39,6 +40,8 @@ export function PushNotification({ cfg }: { cfg: ActivePushConfig }) { } else { await disablePush(mx, await readyManager(), cfg); } + // The worker keeps the token only while a subscription exists: tell it the new state. + pushSessionToSW(mx.baseUrl, mx.getAccessToken() ?? undefined); setOn(enable); } catch (e) { setError(e instanceof Error ? e.message : 'Could not change background notifications.'); diff --git a/src/app/features/push/pusher.test.ts b/src/app/features/push/pusher.test.ts index 73b270cb..ffefa1f4 100644 --- a/src/app/features/push/pusher.test.ts +++ b/src/app/features/push/pusher.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; import type { IPusherRequest } from 'matrix-js-sdk'; -import { disablePush, enablePush, PushManagerPort, vapidKeyBytes, webPushPusher } from './pusher'; +import { + disablePush, + enablePush, + PushManagerPort, + pushIsOn, + vapidKeyBytes, + webPushPusher, +} from './pusher'; const cfg = { vapidPublicKey: 'BAUD', @@ -9,8 +16,9 @@ const cfg = { }; const key = (...b: number[]) => Uint8Array.from(b).buffer; -function subscription(unsubscribed: string[] = []): PushSubscription { +function subscription(unsubscribed: string[], serverKey?: ArrayBuffer): PushSubscription { return { + options: { applicationServerKey: serverKey ?? vapidKeyBytes(cfg.vapidPublicKey).buffer }, endpoint: 'https://push.example/send/abc', getKey: (n: string) => (n === 'p256dh' ? key(251, 255, 1) : key(250, 2)), unsubscribe: async () => { @@ -30,6 +38,16 @@ function world() { calls.push(p.kind === null ? 'delete-pusher' : 'set-pusher'); pushers.push(p); }, + // The account's pushers as the homeserver would list them: what setPusher last wrote, minus deletions. + getPushers: async () => ({ + pushers: pushers + .filter( + (p, i) => + p.kind !== null && + !pushers.slice(i + 1).some((q) => q.kind === null && q.pushkey === p.pushkey) + ) + .map((p) => ({ app_id: p.app_id, pushkey: p.pushkey })), + }), }; const push: PushManagerPort = { getSubscription: async () => sub, @@ -39,7 +57,10 @@ function world() { return sub; }, }; - return { mx, push, calls, pushers, unsub }; + const swapSub = (s: PushSubscription | null) => { + sub = s; + }; + return { mx, push, calls, pushers, unsub, swapSub }; } describe('web push pusher', () => { @@ -47,7 +68,10 @@ describe('web push pusher', () => { expect([...vapidKeyBytes('-_8')]).toEqual([251, 255]); }); it('is the Sygnal webpush shape: app id, p256dh pushkey, subscription in data, ids only', () => { - const p = webPushPusher(cfg, subscription(), 'phone') as unknown as Record & { + const p = webPushPusher(cfg, subscription([]), 'phone') as unknown as Record< + string, + unknown + > & { data: Record; }; expect(p).toMatchObject({ @@ -91,4 +115,29 @@ describe('web push pusher', () => { await expect(disablePush(w.mx, w.push, cfg)).rejects.toThrow('homeserver said no'); expect(w.unsub).toEqual([]); }); + it('the toggle is on only with a subscription and the pusher of this account for it', async () => { + const w = world(); + expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false); + await enablePush(w.mx, w.push, cfg, 'phone'); + expect(await pushIsOn(w.mx, w.push, cfg)).toBe(true); + // another account on the same browser: the subscription exists, its pusher does not + const other = { ...w.mx, getPushers: async () => ({ pushers: [] }) }; + expect(await pushIsOn(other, w.push, cfg)).toBe(false); + await disablePush(w.mx, w.push, cfg); + expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false); + }); + it('a subscription made under another VAPID key is replaced, not reused', async () => { + const w = world(); + const old = [] as string[]; + w.swapSub(subscription(old, Uint8Array.from([9, 9, 9]).buffer)); + await enablePush(w.mx, w.push, cfg, 'phone'); + expect(old).toEqual(['x']); + expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']); + }); + it('a failed pusher write after subscribing leaves the toggle off', async () => { + const w = world(); + const fail = { ...w.mx, setPusher: async () => Promise.reject(new Error('no')) }; + await expect(enablePush(fail, w.push, cfg, 'phone')).rejects.toThrow('no'); + expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false); + }); }); diff --git a/src/app/features/push/pusher.ts b/src/app/features/push/pusher.ts index 65c0aca4..d57c2821 100644 --- a/src/app/features/push/pusher.ts +++ b/src/app/features/push/pusher.ts @@ -4,6 +4,7 @@ import type { ActivePushConfig } from './config'; /** What the page needs of the homeserver client and the browser's push manager, so tests supply fakes. */ export interface PusherClient { setPusher(p: IPusherRequest): Promise; + getPushers(): Promise<{ pushers: { app_id: string; pushkey: string }[] }>; } export interface PushManagerPort { getSubscription(): Promise; @@ -54,22 +55,42 @@ export function webPushPusher( } as IPusherRequest; } +const sameKey = (sub: PushSubscription, vapid: Uint8Array): boolean => { + const have = sub.options?.applicationServerKey; + return !have || b64url(have) === b64url(vapid.buffer as ArrayBuffer); +}; + +/** Subscribe (replacing a subscription made under another VAPID key), then register the pusher for this account. */ export async function enablePush( mx: PusherClient, push: PushManagerPort, cfg: ActivePushConfig, deviceName: string ): Promise { - const sub = - (await push.getSubscription()) ?? - (await push.subscribe({ - userVisibleOnly: true, - applicationServerKey: vapidKeyBytes(cfg.vapidPublicKey), - })); + const key = vapidKeyBytes(cfg.vapidPublicKey); + let sub = await push.getSubscription(); + if (sub && !sameKey(sub, key)) { + await sub.unsubscribe(); + sub = null; + } + sub = sub ?? (await push.subscribe({ userVisibleOnly: true, applicationServerKey: key })); await mx.setPusher(webPushPusher(cfg, sub, deviceName)); } -/** Delete the pusher, then the subscription. The order matters: a pusher with no subscription would keep failing at the gateway. */ +/** On only when this browser holds a subscription AND this account has a pusher for it (the two live in different places). */ +export async function pushIsOn( + mx: PusherClient, + push: PushManagerPort, + cfg: ActivePushConfig +): Promise { + const sub = await push.getSubscription(); + if (!sub) return false; + const pushkey = b64url(sub.getKey('p256dh')); + const { pushers } = await mx.getPushers(); + return pushers.some((p) => p.app_id === cfg.appId && p.pushkey === pushkey); +} + +/** Delete this account's pusher, then the subscription. The order matters: a pusher with no subscription would keep failing at the gateway. */ export async function disablePush( mx: PusherClient, push: PushManagerPort, diff --git a/src/app/features/push/sessionStore.test.ts b/src/app/features/push/sessionStore.test.ts index 81668a5f..86cf9147 100644 --- a/src/app/features/push/sessionStore.test.ts +++ b/src/app/features/push/sessionStore.test.ts @@ -29,6 +29,21 @@ describe('sessionStore', () => { await s.save('https://hs/', 5); expect(await s.load()).toBeUndefined(); }); + it('keeps the token only while a push subscription exists', async () => { + const port = memory(); + const s = sessionStore(port); + await s.saveIfSubscribed('https://hs/', 'tok', async () => false); + expect(port.held).toBeUndefined(); + await s.saveIfSubscribed('https://hs/', 'tok', async () => true); + expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' }); + await s.saveIfSubscribed('https://hs/', 'tok', async () => false); + expect(port.held).toBeUndefined(); + await s.saveIfSubscribed('https://hs/', 'tok', async () => true); + await s.saveIfSubscribed('https://hs/', 'tok', () => + Promise.reject(new Error('no push manager')) + ); + expect(port.held).toBeUndefined(); + }); it('ignores a record that is not a session', async () => { const port = memory(); port.held = { baseUrl: 1 }; diff --git a/src/app/features/push/sessionStore.ts b/src/app/features/push/sessionStore.ts index adaa711b..f1835e9c 100644 --- a/src/app/features/push/sessionStore.ts +++ b/src/app/features/push/sessionStore.ts @@ -1,7 +1,8 @@ // The one thing a push needs that a closed app cannot give: the access token, to fetch the event a push names. // Stored: `{ baseUrl, accessToken }`, one record, in this origin's IndexedDB `ratatoskr-sw` (not reachable by other -// origins; the token is already in the page's own storage). Written when the page hands the worker its session, -// deleted when the page hands it none (logout). Nothing else is stored, and nothing else reads it but the push handler. +// origins; the token is already in the page's own storage). Written when the page hands the worker its session +// while this browser holds a push subscription; deleted when the page hands it none (logout) or the subscription is +// gone. A browser that never turned push on stores nothing. Nothing else reads it but the push handler. export interface SessionRecord { baseUrl: string; accessToken: string; @@ -68,6 +69,15 @@ export function sessionStore(port: RecordPort) { : undefined ); }, + /** The token is kept only while this browser holds a push subscription; without one the record is deleted. */ + async saveIfSubscribed( + baseUrl: unknown, + accessToken: unknown, + subscribed: () => Promise + ): Promise { + const keep = await subscribed().catch(() => false); + await this.save(keep ? baseUrl : undefined, keep ? accessToken : undefined); + }, async load(): Promise { const v = (await port.read()) as Partial | undefined; return v && typeof v.baseUrl === 'string' && typeof v.accessToken === 'string' diff --git a/src/app/features/push/worker.test.ts b/src/app/features/push/worker.test.ts index f1f766ce..5eaad32f 100644 --- a/src/app/features/push/worker.test.ts +++ b/src/app/features/push/worker.test.ts @@ -59,6 +59,11 @@ describe('push against a mock gateway and homeserver', () => { expect(shown[0].options.tag).toBeUndefined(); expect(shown[0].options.data).toBeUndefined(); }); + it('shows even when the window list cannot be read', async () => { + const { w, shown } = port({ windows: () => Promise.reject(new Error('no clients')) }); + await handlePush(matrixNotify('!r:x', '$e'), w); + expect(shown).toHaveLength(1); + }); it('shows nothing while a window is visible and focused, and shows when the window is hidden', async () => { const win = (visibilityState: string, focused: boolean) => ({ visibilityState, diff --git a/src/app/features/push/worker.ts b/src/app/features/push/worker.ts index 5e3ccd75..a98f873d 100644 --- a/src/app/features/push/worker.ts +++ b/src/app/features/push/worker.ts @@ -27,7 +27,8 @@ export interface PushTarget { /** A push shows a notification, unless the app is open and focused (browsers allow that, and the page notifies itself). */ export async function handlePush(raw: unknown, w: WorkerPort): Promise { const ids = parsePush(raw); - if ((await w.windows()).some((c) => c.visibilityState === 'visible' && c.focused)) return; + const open = await w.windows().catch(() => []); + if (open.some((c) => c.visibilityState === 'visible' && c.focused)) return; const shown = await describePush(ids, await w.loadSession(), w.fetch); await w.show(shown.title, { body: shown.body, diff --git a/src/sw.ts b/src/sw.ts index 4df85a88..ce9878c9 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -36,7 +36,13 @@ async function cleanupDeadClients() { } function setSession(clientId: string, accessToken: any, baseUrl: any) { - persisted.save(baseUrl, accessToken).catch(() => undefined); + persisted + .saveIfSubscribed( + baseUrl, + accessToken, + async () => !!(await self.registration.pushManager.getSubscription()) + ) + .catch(() => undefined); if (typeof accessToken === 'string' && typeof baseUrl === 'string') { sessions.set(clientId, { accessToken, baseUrl }); } else { From 7316c92ff32f195f38587ff5cc9184ea55144d4d Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:14:16 +0000 Subject: [PATCH 052/143] composer: persist the draft on input, keep unconfirmed sends in a durable outbox The draft lived in a memory-only jotai atom written only when RoomInput unmounted, so a reload lost it. A sent message was cleared from the editor at once and held nowhere durable. The draft is now written to localStorage (debounced, flushed on pagehide/hidden) and restored on load; a send stays in a per-account outbox under its txn id until the server confirms, shown above the composer with Resend and Discard. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomInput.tsx | 137 ++++++++++++++----- src/app/features/room/RoomOutbox.test.ts | 55 ++++++++ src/app/features/room/RoomOutbox.tsx | 96 +++++++++++++ src/app/state/room/composerStore.test.ts | 116 ++++++++++++++++ src/app/state/room/composerStore.ts | 164 +++++++++++++++++++++++ 5 files changed, 531 insertions(+), 37 deletions(-) create mode 100644 src/app/features/room/RoomOutbox.test.ts create mode 100644 src/app/features/room/RoomOutbox.tsx create mode 100644 src/app/state/room/composerStore.test.ts create mode 100644 src/app/state/room/composerStore.ts diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index b4483d8b..b3d59d18 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -12,6 +12,8 @@ import { isKeyHotkey } from 'is-hotkey'; import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { Transforms, Editor } from 'slate'; +import { RoomOutbox, sendTracked } from './RoomOutbox'; +import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore'; import { Box, Dialog, @@ -221,22 +223,66 @@ export const RoomInput = forwardRef( useCallback((width) => setHideStickerBtn(width < 500), []) ); + const userId = mx.getUserId() ?? ''; + const draftSaveTimer = useRef>(); + + // The atom is only a cache: after a reload it is empty and the stored draft is the truth. + useEffect(() => { + if (msgDraft.length === 0) { + const stored = loadDraft(userId, roomId); + if (stored.length > 0) setMsgDraft(stored); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, roomId]); + useEffect(() => { Transforms.insertFragment(editor, msgDraft); }, [editor, msgDraft]); + const persistDraft = useCallback(() => { + clearTimeout(draftSaveTimer.current); + draftSaveTimer.current = undefined; + saveDraft(userId, roomId, editor.children); + }, [userId, roomId, editor]); + + // Write on input (debounced), and flush when the page is hidden or unloading, which is + // all a mobile reload gives us. + const handleEditorChange = useCallback(() => { + clearTimeout(draftSaveTimer.current); + draftSaveTimer.current = setTimeout(persistDraft, 400); + }, [persistDraft]); + + useEffect(() => { + const flush = () => { + if (draftSaveTimer.current !== undefined) persistDraft(); + }; + const onVisibility = () => { + if (document.visibilityState === 'hidden') flush(); + }; + window.addEventListener('pagehide', flush); + document.addEventListener('visibilitychange', onVisibility); + return () => { + window.removeEventListener('pagehide', flush); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, [persistDraft]); + useEffect( () => () => { + clearTimeout(draftSaveTimer.current); + draftSaveTimer.current = undefined; if (!isEmptyEditor(editor)) { const parsedDraft = JSON.parse(JSON.stringify(editor.children)); setMsgDraft(parsedDraft); + saveDraft(userId, roomId, parsedDraft); } else { setMsgDraft([]); + clearDraft(userId, roomId); } resetEditor(editor); resetEditorHistory(editor); }, - [roomId, editor, setMsgDraft] + [roomId, editor, setMsgDraft, userId] ); const handleFileMetadata = useCallback( @@ -366,12 +412,25 @@ export const RoomInput = forwardRef( content['m.relates_to'].is_falling_back = false; } } - mx.sendMessage(roomId, content as any); + sendTracked(mx, roomId, content); + clearTimeout(draftSaveTimer.current); + draftSaveTimer.current = undefined; + clearDraft(userId, roomId); resetEditor(editor); resetEditorHistory(editor); setReplyDraft(undefined); sendTypingStatus(false); - }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]); + }, [ + mx, + roomId, + userId, + editor, + replyDraft, + sendTypingStatus, + setReplyDraft, + isMarkdown, + commands, + ]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { @@ -537,44 +596,48 @@ export const RoomInput = forwardRef( onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onPaste={handlePaste} + onChange={handleEditorChange} top={ - replyDraft && ( -
        - - setReplyDraft(undefined)} - variant="SurfaceVariant" - size="300" - radii="300" + <> + + {replyDraft && ( +
        + - - - - {replyDraft.relation?.rel_type === RelationType.Thread && } - - - {getMemberDisplayName(room, replyDraft.userId) ?? - getMxIdLocalPart(replyDraft.userId) ?? - replyDraft.userId} - - - } + setReplyDraft(undefined)} + variant="SurfaceVariant" + size="300" + radii="300" > - - {trimReplyFromBody(replyDraft.body)} - - + + + + {replyDraft.relation?.rel_type === RelationType.Thread && } + + + {getMemberDisplayName(room, replyDraft.userId) ?? + getMxIdLocalPart(replyDraft.userId) ?? + replyDraft.userId} + + + } + > + + {trimReplyFromBody(replyDraft.body)} + + + - -
        - ) +
        + )} + } before={ { + const m = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }); +}; + +const fakeClient = (send: (...a: unknown[]) => Promise) => + ({ getUserId: () => '@a:x', makeTxnId: () => 'txn-1', sendMessage: send } as never); + +describe('sendTracked', () => { + beforeEach(stubLocalStorage); + afterEach(() => vi.unstubAllGlobals()); + + const content = { msgtype: 'm.text', body: 'typed before reload' }; + + it('holds the message in the outbox while the send is in flight, so a reload keeps it', async () => { + let finish!: () => void; + const mx = fakeClient( + () => + new Promise((r) => { + finish = () => r({ event_id: '$e' }); + }) + ); + const done = sendTracked(mx, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending', content }]); + finish(); + await done; + expect(loadOutbox('@a:x')).toEqual([]); + }); + + it('a failed send stays, marked failed, and is not cleared', async () => { + const mx = fakeClient(() => Promise.reject(new Error('ECONNRESET'))); + await sendTracked(mx, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([ + { txnId: 'txn-1', status: 'failed', error: 'ECONNRESET', content }, + ]); + }); + + it('sends under the stored txn id, so a resend after an unknown outcome deduplicates', async () => { + const send = vi.fn().mockResolvedValue({}); + await sendTracked(fakeClient(send), '!r:x', content); + expect(send).toHaveBeenCalledWith('!r:x', content, 'txn-1'); + }); +}); diff --git a/src/app/features/room/RoomOutbox.tsx b/src/app/features/room/RoomOutbox.tsx new file mode 100644 index 00000000..4d3d6aca --- /dev/null +++ b/src/app/features/room/RoomOutbox.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useState } from 'react'; +import { MatrixClient } from 'matrix-js-sdk'; +import { Box, Chip, Text, color, config } from 'folds'; +import { + OutboxEntry, + enqueueOutbox, + failOutbox, + loadOutbox, + removeOutbox, + retryOutbox, + subscribeOutbox, +} from '../../state/room/composerStore'; + +const attempt = (mx: MatrixClient, userId: string, entry: OutboxEntry): Promise => + mx + .sendMessage(entry.roomId, entry.content as never, entry.txnId) + .then(() => removeOutbox(userId, entry.txnId)) + .catch((e: unknown) => + failOutbox(userId, entry.txnId, e instanceof Error ? e.message : String(e)) + ); + +/** Send a message, keeping it in the durable outbox until the server confirms it. */ +export const sendTracked = ( + mx: MatrixClient, + roomId: string, + content: Record +): Promise => { + const userId = mx.getUserId() ?? ''; + const entry = { roomId, content, txnId: mx.makeTxnId() }; + enqueueOutbox(userId, entry); + return attempt(mx, userId, { ...entry, status: 'pending', ts: Date.now() }); +}; + +export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => { + const read = () => loadOutbox(userId).filter((e) => e.roomId === roomId); + const [entries, setEntries] = useState(read); + useEffect(() => { + setEntries(read()); + return subscribeOutbox(() => setEntries(read())); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, roomId]); + return entries; +}; + +type RoomOutboxProps = { mx: MatrixClient; roomId: string }; + +/** + * Messages this client sent that the server has not confirmed: still sending, or failed. + * They also appear here after a reload, since the outbox is durable. Resending reuses the + * original txn id, so a send that did land the first time is not duplicated. + */ +export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { + const userId = mx.getUserId() ?? ''; + const entries = useRoomOutbox(userId, roomId); + if (entries.length === 0) return null; + + return ( + + {entries.map((entry) => { + const failed = entry.status === 'failed'; + return ( + + + + {failed ? `Not sent${entry.error ? `: ${entry.error}` : ''}` : 'Sending…'} + + + {String(entry.content.body ?? '')} + + + { + retryOutbox(userId, entry.txnId); + attempt(mx, userId, entry); + }} + > + Resend + + removeOutbox(userId, entry.txnId)}> + Discard + + + ); + })} + + ); +} diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts new file mode 100644 index 00000000..d3957d68 --- /dev/null +++ b/src/app/state/room/composerStore.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { Descendant } from 'slate'; +import { + clearDraft, + enqueueOutbox, + failOutbox, + loadDraft, + loadOutbox, + removeOutbox, + retryOutbox, + saveDraft, + subscribeOutbox, +} from './composerStore'; + +const memoryStore = () => { + const m = new Map(); + return { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + size: () => m.size, + }; +}; + +const para = (text: string): Descendant[] => [{ type: 'paragraph', children: [{ text }] } as never]; + +describe('draft', () => { + it('survives a reload: a new read of the same store returns what input wrote', () => { + const store = memoryStore(); + saveDraft('@a:x', '!r:x', para('half a thought'), store); + expect(loadDraft('@a:x', '!r:x', store)).toEqual(para('half a thought')); + }); + + it('is keyed by account and room', () => { + const store = memoryStore(); + saveDraft('@a:x', '!r:x', para('one'), store); + expect(loadDraft('@b:x', '!r:x', store)).toEqual([]); + expect(loadDraft('@a:x', '!other:x', store)).toEqual([]); + }); + + it('emptying the editor removes the stored draft', () => { + const store = memoryStore(); + saveDraft('@a:x', '!r:x', para('x'), store); + saveDraft('@a:x', '!r:x', para(''), store); + expect(store.size()).toBe(0); + }); + + it('clearDraft removes it; corrupt storage reads as no draft', () => { + const store = memoryStore(); + saveDraft('@a:x', '!r:x', para('x'), store); + clearDraft('@a:x', '!r:x', store); + expect(loadDraft('@a:x', '!r:x', store)).toEqual([]); + store.setItem('ratatoskr.draft.@a:x.!r:x', '{not json'); + expect(loadDraft('@a:x', '!r:x', store)).toEqual([]); + }); + + it('a throwing store (quota) does not throw into the composer', () => { + const store = { + getItem: () => null, + setItem: () => { + throw new Error('quota'); + }, + removeItem: () => {}, + }; + expect(() => saveDraft('@a:x', '!r:x', para('x'), store)).not.toThrow(); + }); +}); + +describe('outbox', () => { + const entry = { txnId: 't1', roomId: '!r:x', content: { msgtype: 'm.text', body: 'hi' } }; + + it('keeps a sent message until it is confirmed, across a reload', () => { + const store = memoryStore(); + enqueueOutbox('@a:x', entry, 5, store); + const after = loadOutbox('@a:x', store); + expect(after).toEqual([{ ...entry, status: 'pending', ts: 5 }]); + removeOutbox('@a:x', 't1', store); + expect(loadOutbox('@a:x', store)).toEqual([]); + expect(store.size()).toBe(0); + }); + + it('a failed send stays, marked failed with its error; retry returns it to pending', () => { + const store = memoryStore(); + enqueueOutbox('@a:x', entry, 5, store); + failOutbox('@a:x', 't1', 'offline', store); + expect(loadOutbox('@a:x', store)[0]).toMatchObject({ status: 'failed', error: 'offline' }); + retryOutbox('@a:x', 't1', store); + expect(loadOutbox('@a:x', store)[0]).toMatchObject({ status: 'pending', txnId: 't1' }); + expect(loadOutbox('@a:x', store)[0].error).toBeUndefined(); + }); + + it('re-enqueueing the same txn id does not duplicate it; other accounts are separate', () => { + const store = memoryStore(); + enqueueOutbox('@a:x', entry, 5, store); + enqueueOutbox('@a:x', entry, 6, store); + expect(loadOutbox('@a:x', store)).toHaveLength(1); + expect(loadOutbox('@b:x', store)).toEqual([]); + }); + + it('notifies subscribers on every change', () => { + const store = memoryStore(); + let n = 0; + const off = subscribeOutbox(() => { + n += 1; + }); + enqueueOutbox('@a:x', entry, 5, store); + removeOutbox('@a:x', 't1', store); + off(); + enqueueOutbox('@a:x', entry, 5, store); + expect(n).toBe(2); + }); +}); diff --git a/src/app/state/room/composerStore.ts b/src/app/state/room/composerStore.ts new file mode 100644 index 00000000..8401e0d7 --- /dev/null +++ b/src/app/state/room/composerStore.ts @@ -0,0 +1,164 @@ +import { Descendant } from 'slate'; + +/** + * Durable composer state. The in-memory jotai atoms in roomInputDrafts.ts are a cache of this; + * a page reload keeps only what was written here. + * + * Draft: the unsent editor contents of one room, rewritten on input and removed when emptied. + * Outbox: a sent message stays here, with its txn id, until the server confirms it. A reload + * mid-send therefore leaves it behind to be shown and resent; resending under the same txn id + * makes the server deduplicate if the first attempt did land. + */ + +export type OutboxStatus = 'pending' | 'failed'; + +export type OutboxEntry = { + txnId: string; + roomId: string; + content: Record; + status: OutboxStatus; + error?: string; + ts: number; +}; + +type Store = Pick; + +const defaultStore = (): Store | undefined => { + try { + return localStorage; + } catch { + return undefined; + } +}; + +const draftKey = (userId: string, roomId: string) => `ratatoskr.draft.${userId}.${roomId}`; +const outboxKey = (userId: string) => `ratatoskr.outbox.${userId}`; + +const readJson = (store: Store | undefined, key: string): T | undefined => { + const raw = store?.getItem(key); + if (raw === null || raw === undefined) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + return undefined; + } +}; + +const writeJson = (store: Store | undefined, key: string, value: unknown): void => { + try { + store?.setItem(key, JSON.stringify(value)); + } catch { + // quota or private mode: the in-memory atom still holds the draft for this session + } +}; + +const isEmptyDraft = (draft: Descendant[]): boolean => + draft.length === 0 || + (draft.length === 1 && + 'children' in draft[0] && + draft[0].children.length === 1 && + 'text' in draft[0].children[0] && + draft[0].children[0].text === ''); + +export const loadDraft = ( + userId: string, + roomId: string, + store: Store | undefined = defaultStore() +): Descendant[] => { + const draft = readJson(store, draftKey(userId, roomId)); + return Array.isArray(draft) ? draft : []; +}; + +export const saveDraft = ( + userId: string, + roomId: string, + draft: Descendant[], + store: Store | undefined = defaultStore() +): void => { + if (isEmptyDraft(draft)) { + store?.removeItem(draftKey(userId, roomId)); + return; + } + writeJson(store, draftKey(userId, roomId), draft); +}; + +export const clearDraft = ( + userId: string, + roomId: string, + store: Store | undefined = defaultStore() +): void => { + store?.removeItem(draftKey(userId, roomId)); +}; + +export const loadOutbox = ( + userId: string, + store: Store | undefined = defaultStore() +): OutboxEntry[] => { + const entries = readJson(store, outboxKey(userId)); + return Array.isArray(entries) ? entries : []; +}; + +const outboxListeners = new Set<() => void>(); +export const subscribeOutbox = (listener: () => void): (() => void) => { + outboxListeners.add(listener); + return () => outboxListeners.delete(listener); +}; + +const saveOutbox = (userId: string, entries: OutboxEntry[], store: Store | undefined) => { + if (entries.length === 0) store?.removeItem(outboxKey(userId)); + else writeJson(store, outboxKey(userId), entries); + outboxListeners.forEach((l) => l()); +}; + +export const enqueueOutbox = ( + userId: string, + entry: Omit, + now: number = Date.now(), + store: Store | undefined = defaultStore() +): void => { + const rest = loadOutbox(userId, store).filter((e) => e.txnId !== entry.txnId); + saveOutbox(userId, [...rest, { ...entry, status: 'pending', ts: now }], store); +}; + +export const failOutbox = ( + userId: string, + txnId: string, + error: string, + store: Store | undefined = defaultStore() +): void => { + saveOutbox( + userId, + loadOutbox(userId, store).map((e) => + e.txnId === txnId ? { ...e, status: 'failed', error } : e + ), + store + ); +}; + +/** Retry in flight: failed goes back to pending so it is not shown as dead while it is sent. */ +export const retryOutbox = ( + userId: string, + txnId: string, + store: Store | undefined = defaultStore() +): void => { + saveOutbox( + userId, + loadOutbox(userId, store).map((e) => + e.txnId === txnId ? { ...e, status: 'pending', error: undefined } : e + ), + store + ); +}; + +/** Server confirmed the send, or the user discarded it. */ +export const removeOutbox = ( + userId: string, + txnId: string, + store: Store | undefined = defaultStore() +): void => { + saveOutbox( + userId, + loadOutbox(userId, store).filter((e) => e.txnId !== txnId), + store + ); +}; From 350ec987e0e06d312809a2627f8097db9f8d71d7 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:17:06 +0000 Subject: [PATCH 053/143] outbox: survive a refused storage write, mark reload-interrupted sends, hide Resend on live sends Soul pass findings: a failed localStorage write no longer loses the entry (memory copy wins until a write succeeds), a stale draft is dropped when its rewrite fails, storage reads/removes cannot throw, and pending entries older than the page load read as interrupted with Resend available. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomOutbox.test.ts | 4 ++- src/app/features/room/RoomOutbox.tsx | 14 ++++++++- src/app/state/room/composerStore.test.ts | 15 ++++++++++ src/app/state/room/composerStore.ts | 38 ++++++++++++++++++------ 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/app/features/room/RoomOutbox.test.ts b/src/app/features/room/RoomOutbox.test.ts index f0efe1ff..df22f9d4 100644 --- a/src/app/features/room/RoomOutbox.test.ts +++ b/src/app/features/room/RoomOutbox.test.ts @@ -20,7 +20,9 @@ const fakeClient = (send: (...a: unknown[]) => Promise) => describe('sendTracked', () => { beforeEach(stubLocalStorage); - afterEach(() => vi.unstubAllGlobals()); + afterEach(() => { + vi.unstubAllGlobals(); + }); const content = { msgtype: 'm.text', body: 'typed before reload' }; diff --git a/src/app/features/room/RoomOutbox.tsx b/src/app/features/room/RoomOutbox.tsx index 4d3d6aca..cf164472 100644 --- a/src/app/features/room/RoomOutbox.tsx +++ b/src/app/features/room/RoomOutbox.tsx @@ -42,6 +42,9 @@ export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => return entries; }; +/** Entries still 'pending' that predate this page load lost their promise in a reload: outcome unknown. */ +const pageStart = Date.now(); + type RoomOutboxProps = { mx: MatrixClient; roomId: string }; /** @@ -62,6 +65,8 @@ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { > {entries.map((entry) => { const failed = entry.status === 'failed'; + const interrupted = !failed && entry.ts < pageStart; + const canResend = failed || interrupted; return ( @@ -69,12 +74,17 @@ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { size="T200" style={{ color: failed ? color.Critical.Main : color.Surface.OnContainer }} > - {failed ? `Not sent${entry.error ? `: ${entry.error}` : ''}` : 'Sending…'} + {(() => { + if (failed) return `Not sent${entry.error ? `: ${entry.error}` : ''}`; + return interrupted ? 'Interrupted by a reload: may not have been sent' : 'Sending…'; + })()} {String(entry.content.body ?? '')} + {canResend && ( + <> removeOutbox(userId, entry.txnId)}> Discard + + )} ); })} diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts index d3957d68..8eb36e4f 100644 --- a/src/app/state/room/composerStore.test.ts +++ b/src/app/state/room/composerStore.test.ts @@ -113,4 +113,19 @@ describe('outbox', () => { enqueueOutbox('@a:x', entry, 5, store); expect(n).toBe(2); }); + + it('when storage refuses the write, memory keeps the entry so a failed send still shows', () => { + const broken = { + getItem: () => null, + setItem: () => { + throw new Error('quota'); + }, + removeItem: () => {}, + }; + enqueueOutbox('@q:x', entry, 5, broken); + failOutbox('@q:x', 't1', 'offline', broken); + expect(loadOutbox('@q:x', broken)).toMatchObject([{ txnId: 't1', status: 'failed' }]); + removeOutbox('@q:x', 't1', broken); + expect(loadOutbox('@q:x', broken)).toEqual([]); + }); }); diff --git a/src/app/state/room/composerStore.ts b/src/app/state/room/composerStore.ts index 8401e0d7..1cbe54aa 100644 --- a/src/app/state/room/composerStore.ts +++ b/src/app/state/room/composerStore.ts @@ -35,20 +35,31 @@ const draftKey = (userId: string, roomId: string) => `ratatoskr.draft.${userId}. const outboxKey = (userId: string) => `ratatoskr.outbox.${userId}`; const readJson = (store: Store | undefined, key: string): T | undefined => { - const raw = store?.getItem(key); - if (raw === null || raw === undefined) return undefined; try { + const raw = store?.getItem(key); + if (raw === null || raw === undefined) return undefined; return JSON.parse(raw) as T; } catch { return undefined; } }; -const writeJson = (store: Store | undefined, key: string, value: unknown): void => { +const writeJson = (store: Store | undefined, key: string, value: unknown): boolean => { try { - store?.setItem(key, JSON.stringify(value)); + if (!store) return false; + store.setItem(key, JSON.stringify(value)); + return true; } catch { // quota or private mode: the in-memory atom still holds the draft for this session + return false; + } +}; + +const removeKey = (store: Store | undefined, key: string): void => { + try { + store?.removeItem(key); + } catch { + // nothing durable to remove } }; @@ -76,10 +87,10 @@ export const saveDraft = ( store: Store | undefined = defaultStore() ): void => { if (isEmptyDraft(draft)) { - store?.removeItem(draftKey(userId, roomId)); + removeKey(store, draftKey(userId, roomId)); return; } - writeJson(store, draftKey(userId, roomId), draft); + if (!writeJson(store, draftKey(userId, roomId), draft)) removeKey(store, draftKey(userId, roomId)); }; export const clearDraft = ( @@ -87,13 +98,18 @@ export const clearDraft = ( roomId: string, store: Store | undefined = defaultStore() ): void => { - store?.removeItem(draftKey(userId, roomId)); + removeKey(store, draftKey(userId, roomId)); }; +/** Outbox contents whose last write to storage failed: memory is then the only copy, so it wins. */ +const unpersisted = new Map(); + export const loadOutbox = ( userId: string, store: Store | undefined = defaultStore() ): OutboxEntry[] => { + const held = unpersisted.get(userId); + if (held) return held; const entries = readJson(store, outboxKey(userId)); return Array.isArray(entries) ? entries : []; }; @@ -105,8 +121,12 @@ export const subscribeOutbox = (listener: () => void): (() => void) => { }; const saveOutbox = (userId: string, entries: OutboxEntry[], store: Store | undefined) => { - if (entries.length === 0) store?.removeItem(outboxKey(userId)); - else writeJson(store, outboxKey(userId), entries); + const ok = + entries.length === 0 + ? (removeKey(store, outboxKey(userId)), true) + : writeJson(store, outboxKey(userId), entries); + if (ok) unpersisted.delete(userId); + else unpersisted.set(userId, entries); outboxListeners.forEach((l) => l()); }; From a5319766758b7e62f1e6a24d1b8ca5665666ddfc Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:17:26 +0000 Subject: [PATCH 054/143] lint: import order, explicit no-op test stubs Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomInput.tsx | 4 ++-- src/app/state/room/composerStore.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index b3d59d18..e92ae575 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -12,8 +12,6 @@ import { isKeyHotkey } from 'is-hotkey'; import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { Transforms, Editor } from 'slate'; -import { RoomOutbox, sendTracked } from './RoomOutbox'; -import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore'; import { Box, Dialog, @@ -30,6 +28,8 @@ import { config, toRem, } from 'folds'; +import { RoomOutbox, sendTracked } from './RoomOutbox'; +import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts index 8eb36e4f..92bdcdfd 100644 --- a/src/app/state/room/composerStore.test.ts +++ b/src/app/state/room/composerStore.test.ts @@ -64,7 +64,7 @@ describe('draft', () => { setItem: () => { throw new Error('quota'); }, - removeItem: () => {}, + removeItem: () => undefined, }; expect(() => saveDraft('@a:x', '!r:x', para('x'), store)).not.toThrow(); }); @@ -120,7 +120,7 @@ describe('outbox', () => { setItem: () => { throw new Error('quota'); }, - removeItem: () => {}, + removeItem: () => undefined, }; enqueueOutbox('@q:x', entry, 5, broken); failOutbox('@q:x', 't1', 'offline', broken); From a92f6c4294969188593e5677299edfb0d83c745e Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:25:50 +0000 Subject: [PATCH 055/143] timeline: hide unconfirmed local echoes; the outbox is the one place they show The SDK puts a local echo in the live timeline on sendMessage and marks it NOT_SENT on failure; the timeline rendered it like any delivered message and a reload dropped it, so a send killed with the page looked sent and vanished. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomTimeline.tsx | 20 ++++++++++++++++++++ src/app/features/room/localEcho.test.ts | 20 ++++++++++++++++++++ src/app/features/room/localEcho.ts | 10 ++++++++++ 3 files changed, 50 insertions(+) create mode 100644 src/app/features/room/localEcho.test.ts create mode 100644 src/app/features/room/localEcho.ts diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index e9c13e2a..37ad6f54 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -47,6 +47,7 @@ import { import { isKeyHotkey } from 'is-hotkey'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { useTranslation } from 'react-i18next'; +import { isUnconfirmedLocalEcho } from './localEcho'; import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator'; @@ -389,6 +390,19 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) }, [room, onArrive]); }; +/** A local echo changes status (or becomes its remote echo) without a Timeline event: redraw. */ +const useLocalEchoRefresh = (room: Room, onUpdate: () => void) => { + useEffect(() => { + const handle: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (_evt, r) => { + if (r.roomId === room.roomId) onUpdate(); + }; + room.on(RoomEvent.LocalEchoUpdated, handle); + return () => { + room.removeListener(RoomEvent.LocalEchoUpdated, handle); + }; + }, [room, onUpdate]); +}; + const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { useEffect(() => { const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => { @@ -648,6 +662,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ) ); + useLocalEchoRefresh( + room, + useCallback(() => setTimeline((ct) => ({ ...ct })), []) + ); + const handleOpenEvent = useCallback( async ( evtId: string, @@ -1646,6 +1665,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli if (mEvent.isRedacted() && !showHiddenEvents) { return null; } + if (isUnconfirmedLocalEcho(mEvent)) return null; if (!newDivider && readUptoEventIdRef.current) { newDivider = prevEvent?.getId() === readUptoEventIdRef.current; diff --git a/src/app/features/room/localEcho.test.ts b/src/app/features/room/localEcho.test.ts new file mode 100644 index 00000000..fa44ac9b --- /dev/null +++ b/src/app/features/room/localEcho.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { EventStatus } from 'matrix-js-sdk'; +import { isUnconfirmedLocalEcho } from './localEcho'; + +describe('isUnconfirmedLocalEcho', () => { + it.each([ + EventStatus.ENCRYPTING, + EventStatus.QUEUED, + EventStatus.SENDING, + EventStatus.NOT_SENT, + EventStatus.CANCELLED, + ])('hides %s', (status) => { + expect(isUnconfirmedLocalEcho({ status })).toBe(true); + }); + + it('shows a server-confirmed event and one the server has accepted (SENT)', () => { + expect(isUnconfirmedLocalEcho({ status: null })).toBe(false); + expect(isUnconfirmedLocalEcho({ status: EventStatus.SENT })).toBe(false); + }); +}); diff --git a/src/app/features/room/localEcho.ts b/src/app/features/room/localEcho.ts new file mode 100644 index 00000000..4f827500 --- /dev/null +++ b/src/app/features/room/localEcho.ts @@ -0,0 +1,10 @@ +import { EventStatus, MatrixEvent } from 'matrix-js-sdk'; + +/** + * A local echo the server has not accepted: the SDK adds it to the live timeline the moment + * sendMessage is called, marks it NOT_SENT on failure, and drops it on reload. Rendered like + * any message it looks delivered and then vanishes. The outbox (RoomOutbox) is the one place + * that shows unconfirmed sends, so the timeline hides these until the status is SENT. + */ +export const isUnconfirmedLocalEcho = (mEvent: Pick): boolean => + mEvent.status !== null && mEvent.status !== undefined && mEvent.status !== EventStatus.SENT; From 77edebff9c49f9231b3688c188a9156de1f36844 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:31:29 +0000 Subject: [PATCH 056/143] client: watchdog, resend on reload, outbox for every send, no in-place client restart Sends have no timeout and sync polls do, so a dead connection leaves a send SENDING forever with no request reaching the server while its echo looked delivered. A watchdog (send pending past 20 s, sync ERROR/STOPPED for 30 s) calls a guarded reload; what the reload interrupts is sent again under its own txn id. Uploads and stickers go through the outbox. Resend takes a fresh txn id while the SDK still holds the failed echo. The resume-sync recovery no longer calls stopClient/startClient, which closes the rust crypto for good ("null pointer passed to rust" on later encrypted sends). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomInput.tsx | 11 +- src/app/features/room/RoomOutbox.test.ts | 71 ++++-------- src/app/features/room/RoomOutbox.tsx | 103 ++++++++++-------- src/app/features/room/RoomTimeline.tsx | 9 +- src/app/state/room/composerStore.test.ts | 10 +- src/app/state/room/composerStore.ts | 13 ++- src/app/state/room/outboxSend.test.ts | 132 +++++++++++++++++++++++ src/app/state/room/outboxSend.ts | 66 ++++++++++++ src/client/initMatrix.test.ts | 20 ++++ src/client/initMatrix.ts | 47 +++++++- src/client/resumeSync.test.ts | 35 ++---- src/client/resumeSync.ts | 30 +++--- src/client/watchdog.test.ts | 83 ++++++++++++++ src/client/watchdog.ts | 48 +++++++++ 14 files changed, 523 insertions(+), 155 deletions(-) create mode 100644 src/app/state/room/outboxSend.test.ts create mode 100644 src/app/state/room/outboxSend.ts create mode 100644 src/client/initMatrix.test.ts create mode 100644 src/client/watchdog.test.ts create mode 100644 src/client/watchdog.ts diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index e92ae575..03926da9 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -28,7 +28,8 @@ import { config, toRem, } from 'folds'; -import { RoomOutbox, sendTracked } from './RoomOutbox'; +import { RoomOutbox } from './RoomOutbox'; +import { sendTracked } from '../../state/room/outboxSend'; import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -333,7 +334,7 @@ export const RoomInput = forwardRef( }); handleCancelUpload(uploads); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); - contents.forEach((content) => mx.sendMessage(roomId, content as any)); + contents.forEach((content) => sendTracked(mx, roomId, content as Record)); }; const submit = useCallback(() => { @@ -492,11 +493,7 @@ export const RoomInput = forwardRef( await getImageUrlBlob(stickerUrl) ); - mx.sendEvent(roomId, EventType.Sticker, { - body: label, - url: mxc, - info, - }); + sendTracked(mx, roomId, { body: label, url: mxc, info }, EventType.Sticker); }; return ( diff --git a/src/app/features/room/RoomOutbox.test.ts b/src/app/features/room/RoomOutbox.test.ts index df22f9d4..b2c29b14 100644 --- a/src/app/features/room/RoomOutbox.test.ts +++ b/src/app/features/room/RoomOutbox.test.ts @@ -1,57 +1,24 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { loadOutbox } from '../../state/room/composerStore'; -import { sendTracked } from './RoomOutbox'; +import { describe, expect, it } from 'vitest'; +import { SLOW_SEND_MS, outboxView } from './RoomOutbox'; -const stubLocalStorage = () => { - const m = new Map(); - vi.stubGlobal('localStorage', { - getItem: (k: string) => m.get(k) ?? null, - setItem: (k: string, v: string) => { - m.set(k, v); - }, - removeItem: (k: string) => { - m.delete(k); - }, - }); -}; - -const fakeClient = (send: (...a: unknown[]) => Promise) => - ({ getUserId: () => '@a:x', makeTxnId: () => 'txn-1', sendMessage: send } as never); - -describe('sendTracked', () => { - beforeEach(stubLocalStorage); - afterEach(() => { - vi.unstubAllGlobals(); +describe('outboxView', () => { + it('walks sending -> still sending (resendable) -> failed', () => { + expect(outboxView({ status: 'pending', ts: 100 }, 100, 0)).toMatchObject({ + label: 'Sending…', + canResend: false, + }); + expect(outboxView({ status: 'pending', ts: 100 }, 100 + SLOW_SEND_MS, 0)).toMatchObject({ + label: 'Still sending: slow connection', + canResend: true, + }); + expect(outboxView({ status: 'failed', ts: 100, error: 'x' }, 100, 0)).toMatchObject({ + failed: true, + canResend: true, + }); }); - const content = { msgtype: 'm.text', body: 'typed before reload' }; - - it('holds the message in the outbox while the send is in flight, so a reload keeps it', async () => { - let finish!: () => void; - const mx = fakeClient( - () => - new Promise((r) => { - finish = () => r({ event_id: '$e' }); - }) - ); - const done = sendTracked(mx, '!r:x', content); - expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending', content }]); - finish(); - await done; - expect(loadOutbox('@a:x')).toEqual([]); - }); - - it('a failed send stays, marked failed, and is not cleared', async () => { - const mx = fakeClient(() => Promise.reject(new Error('ECONNRESET'))); - await sendTracked(mx, '!r:x', content); - expect(loadOutbox('@a:x')).toMatchObject([ - { txnId: 'txn-1', status: 'failed', error: 'ECONNRESET', content }, - ]); - }); - - it('sends under the stored txn id, so a resend after an unknown outcome deduplicates', async () => { - const send = vi.fn().mockResolvedValue({}); - await sendTracked(fakeClient(send), '!r:x', content); - expect(send).toHaveBeenCalledWith('!r:x', content, 'txn-1'); + it('an entry older than this page load reads as interrupted', () => { + expect(outboxView({ status: 'pending', ts: 5 }, 6, 10).label).toMatch(/Interrupted/); }); }); + diff --git a/src/app/features/room/RoomOutbox.tsx b/src/app/features/room/RoomOutbox.tsx index cf164472..21c3e224 100644 --- a/src/app/features/room/RoomOutbox.tsx +++ b/src/app/features/room/RoomOutbox.tsx @@ -3,32 +3,40 @@ import { MatrixClient } from 'matrix-js-sdk'; import { Box, Chip, Text, color, config } from 'folds'; import { OutboxEntry, - enqueueOutbox, - failOutbox, loadOutbox, removeOutbox, - retryOutbox, subscribeOutbox, } from '../../state/room/composerStore'; +import { PAGE_START, resendTracked } from '../../state/room/outboxSend'; -const attempt = (mx: MatrixClient, userId: string, entry: OutboxEntry): Promise => - mx - .sendMessage(entry.roomId, entry.content as never, entry.txnId) - .then(() => removeOutbox(userId, entry.txnId)) - .catch((e: unknown) => - failOutbox(userId, entry.txnId, e instanceof Error ? e.message : String(e)) - ); +/** A send still unconfirmed after this long is offered Resend: the request may be hung. */ +export const SLOW_SEND_MS = 30_000; -/** Send a message, keeping it in the durable outbox until the server confirms it. */ -export const sendTracked = ( - mx: MatrixClient, - roomId: string, - content: Record -): Promise => { - const userId = mx.getUserId() ?? ''; - const entry = { roomId, content, txnId: mx.makeTxnId() }; - enqueueOutbox(userId, entry); - return attempt(mx, userId, { ...entry, status: 'pending', ts: Date.now() }); +export type OutboxView = { label: string; failed: boolean; canResend: boolean }; + +export const outboxView = ( + entry: Pick, + now: number, + since: number = PAGE_START +): OutboxView => { + if (entry.status === 'failed') { + return { + label: `Not sent${entry.error ? `: ${entry.error}` : ''}`, + failed: true, + canResend: true, + }; + } + if (entry.ts < since) { + return { + label: 'Interrupted by a reload: may not have been sent', + failed: false, + canResend: true, + }; + } + if (now - entry.ts >= SLOW_SEND_MS) { + return { label: 'Still sending: slow connection', failed: false, canResend: true }; + } + return { label: 'Sending…', failed: false, canResend: false }; }; export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => { @@ -42,19 +50,28 @@ export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => return entries; }; -/** Entries still 'pending' that predate this page load lost their promise in a reload: outcome unknown. */ -const pageStart = Date.now(); +/** Re-render every few seconds while anything is waiting, so a slow send turns resendable. */ +const useNow = (active: boolean): number => { + const [now, setNow] = useState(Date.now()); + useEffect(() => { + if (!active) return undefined; + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 5000); + return () => clearInterval(id); + }, [active]); + return now; +}; type RoomOutboxProps = { mx: MatrixClient; roomId: string }; /** - * Messages this client sent that the server has not confirmed: still sending, or failed. - * They also appear here after a reload, since the outbox is durable. Resending reuses the - * original txn id, so a send that did land the first time is not duplicated. + * Messages this client sent that the server has not confirmed: sending, slow, interrupted by a + * reload, or failed, each with Resend where one makes sense and Discard. */ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { const userId = mx.getUserId() ?? ''; const entries = useRoomOutbox(userId, roomId); + const now = useNow(entries.length > 0); if (entries.length === 0) return null; return ( @@ -64,40 +81,32 @@ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} > {entries.map((entry) => { - const failed = entry.status === 'failed'; - const interrupted = !failed && entry.ts < pageStart; - const canResend = failed || interrupted; + const view = outboxView(entry, now); return ( - {(() => { - if (failed) return `Not sent${entry.error ? `: ${entry.error}` : ''}`; - return interrupted ? 'Interrupted by a reload: may not have been sent' : 'Sending…'; - })()} + {view.label} {String(entry.content.body ?? '')} - {canResend && ( + {view.canResend && ( <> - { - retryOutbox(userId, entry.txnId); - attempt(mx, userId, entry); - }} - > - Resend - - removeOutbox(userId, entry.txnId)}> - Discard - + resendTracked(mx, entry)}> + Resend + + removeOutbox(userId, entry.txnId)} + > + Discard + )} diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 37ad6f54..9bd776f5 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -664,7 +664,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli useLocalEchoRefresh( room, - useCallback(() => setTimeline((ct) => ({ ...ct })), []) + useCallback(() => { + // the echo was hidden while unconfirmed, so its arrival scrolled to nothing: scroll now + if (atBottomRef.current) { + scrollToBottomRef.current.count += 1; + scrollToBottomRef.current.smooth = true; + } + setTimeline((ct) => ({ ...ct })); + }, []) ); const handleOpenEvent = useCallback( diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts index 92bdcdfd..0c4f8ac8 100644 --- a/src/app/state/room/composerStore.test.ts +++ b/src/app/state/room/composerStore.test.ts @@ -88,11 +88,19 @@ describe('outbox', () => { enqueueOutbox('@a:x', entry, 5, store); failOutbox('@a:x', 't1', 'offline', store); expect(loadOutbox('@a:x', store)[0]).toMatchObject({ status: 'failed', error: 'offline' }); - retryOutbox('@a:x', 't1', store); + retryOutbox('@a:x', 't1', 9, 't1', store); expect(loadOutbox('@a:x', store)[0]).toMatchObject({ status: 'pending', txnId: 't1' }); expect(loadOutbox('@a:x', store)[0].error).toBeUndefined(); }); + it('a resend can rekey the entry and restarts its clock', () => { + const store = memoryStore(); + enqueueOutbox('@a:x', entry, 5, store); + failOutbox('@a:x', 't1', 'offline', store); + retryOutbox('@a:x', 't1', 50, 't2', store); + expect(loadOutbox('@a:x', store)).toMatchObject([{ txnId: 't2', status: 'pending', ts: 50 }]); + }); + it('re-enqueueing the same txn id does not duplicate it; other accounts are separate', () => { const store = memoryStore(); enqueueOutbox('@a:x', entry, 5, store); diff --git a/src/app/state/room/composerStore.ts b/src/app/state/room/composerStore.ts index 1cbe54aa..ca1f6bcb 100644 --- a/src/app/state/room/composerStore.ts +++ b/src/app/state/room/composerStore.ts @@ -15,6 +15,8 @@ export type OutboxStatus = 'pending' | 'failed'; export type OutboxEntry = { txnId: string; roomId: string; + /** Event type; absent means m.room.message. */ + type?: string; content: Record; status: OutboxStatus; error?: string; @@ -155,16 +157,23 @@ export const failOutbox = ( ); }; -/** Retry in flight: failed goes back to pending so it is not shown as dead while it is sent. */ +/** + * A resend begins: the entry is pending again from `now`, under `newTxnId` when the SDK still + * holds the failed attempt's echo under the old one (it refuses to add a second echo for a txn id). + */ export const retryOutbox = ( userId: string, txnId: string, + now: number = Date.now(), + newTxnId: string = txnId, store: Store | undefined = defaultStore() ): void => { saveOutbox( userId, loadOutbox(userId, store).map((e) => - e.txnId === txnId ? { ...e, status: 'pending', error: undefined } : e + e.txnId === txnId + ? { ...e, txnId: newTxnId, status: 'pending', error: undefined, ts: now } + : e ), store ); diff --git a/src/app/state/room/outboxSend.test.ts b/src/app/state/room/outboxSend.test.ts new file mode 100644 index 00000000..8f1db8fd --- /dev/null +++ b/src/app/state/room/outboxSend.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { enqueueOutbox, failOutbox, loadOutbox } from './composerStore'; +import { resendInterrupted, resendTracked, sendTracked } from './outboxSend'; + +const stubLocalStorage = () => { + const m = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }); +}; + +const fakeClient = (send: (...a: unknown[]) => Promise) => + ({ getUserId: () => '@a:x', makeTxnId: () => 'txn-1', sendEvent: send } as never); + +describe('sendTracked', () => { + beforeEach(stubLocalStorage); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const content = { msgtype: 'm.text', body: 'typed before reload' }; + + it('holds the message in the outbox while the send is in flight, so a reload keeps it', async () => { + let finish!: () => void; + const mx = fakeClient( + () => + new Promise((r) => { + finish = () => r({ event_id: '$e' }); + }) + ); + const done = sendTracked(mx, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending', content }]); + finish(); + await done; + expect(loadOutbox('@a:x')).toEqual([]); + }); + + it('a failed send stays, marked failed, and is not cleared', async () => { + const mx = fakeClient(() => Promise.reject(new Error('ECONNRESET'))); + await sendTracked(mx, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([ + { txnId: 'txn-1', status: 'failed', error: 'ECONNRESET', content }, + ]); + }); + + it('sends under the stored txn id, so a resend after an unknown outcome deduplicates', async () => { + const send = vi.fn().mockResolvedValue({}); + await sendTracked(fakeClient(send), '!r:x', content); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-1'); + }); +}); + +describe('resendTracked', () => { + beforeEach(stubLocalStorage); + afterEach(() => { + vi.unstubAllGlobals(); + }); + const entry = { + txnId: 'txn-old', + roomId: '!r:x', + content: { msgtype: 'm.text', body: 'b' }, + status: 'failed' as const, + ts: 1, + }; + + it('uses a fresh txn id while the SDK still holds the failed echo (it would throw on the old one)', async () => { + const send = vi.fn().mockResolvedValue({}); + const mx = { + getUserId: () => '@a:x', + makeTxnId: () => 'txn-new', + sendEvent: send, + getRoom: () => ({ getEventForTxnId: () => ({}) }), + } as never; + await resendTracked(mx, entry); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', entry.content, 'txn-new'); + }); + + it('reuses the txn id after a reload, when no echo exists, so the server deduplicates', async () => { + const send = vi.fn().mockResolvedValue({}); + const mx = { + getUserId: () => '@a:x', + makeTxnId: () => 'txn-new', + sendEvent: send, + getRoom: () => ({ getEventForTxnId: () => undefined }), + } as never; + await resendTracked(mx, entry); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', entry.content, 'txn-old'); + }); + + it('a synchronous throw from the SDK marks the entry failed instead of escaping', async () => { + const mx = { + getUserId: () => '@a:x', + makeTxnId: () => 'txn-new', + sendEvent: () => { + throw new Error('addPendingEvent called on an event with known txnId'); + }, + getRoom: () => undefined, + } as never; + await sendTracked(mx, '!r:x', { body: 'x' }); + expect(loadOutbox('@a:x')[0]).toMatchObject({ status: 'failed' }); + }); +}); + +describe('resendInterrupted', () => { + beforeEach(stubLocalStorage); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('resends pending entries older than the page under their own txn id, and leaves failed ones', async () => { + const send = vi.fn().mockResolvedValue({}); + const mx = { + getUserId: () => '@a:x', + makeTxnId: () => 'fresh', + sendEvent: send, + getRoom: () => undefined, + } as never; + enqueueOutbox('@a:x', { txnId: 'old', roomId: '!r:x', content: { body: 'o' } }, 5); + enqueueOutbox('@a:x', { txnId: 'bad', roomId: '!r:x', content: { body: 'b' } }, 5); + failOutbox('@a:x', 'bad', 'nope'); + enqueueOutbox('@a:x', { txnId: 'new', roomId: '!r:x', content: { body: 'n' } }, 500); + await resendInterrupted(mx, 100); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', { body: 'o' }, 'old'); + }); +}); diff --git a/src/app/state/room/outboxSend.ts b/src/app/state/room/outboxSend.ts new file mode 100644 index 00000000..2bea0de7 --- /dev/null +++ b/src/app/state/room/outboxSend.ts @@ -0,0 +1,66 @@ +import { MatrixClient } from 'matrix-js-sdk'; +import { + OutboxEntry, + enqueueOutbox, + failOutbox, + loadOutbox, + removeOutbox, + retryOutbox, +} from './composerStore'; + +/** Entries still 'pending' that predate this page load lost their promise in a reload: outcome unknown. */ +export const PAGE_START = Date.now(); + +const attempt = async (mx: MatrixClient, userId: string, entry: OutboxEntry): Promise => { + try { + await mx.sendEvent(entry.roomId, (entry.type ?? 'm.room.message') as never, entry.content as never, entry.txnId); + removeOutbox(userId, entry.txnId); + } catch (e) { + // sendEvent can also throw synchronously (e.g. a txn id the SDK already holds) + failOutbox(userId, entry.txnId, e instanceof Error ? e.message : String(e)); + } +}; + +/** + * Send, keeping the message in the durable outbox until the server confirms it. The timeline + * hides the SDK's local echo until then, so this is the only place an unconfirmed send shows. + */ +export const sendTracked = ( + mx: MatrixClient, + roomId: string, + content: Record, + type?: string +): Promise => { + const userId = mx.getUserId() ?? ''; + const entry = { roomId, content, txnId: mx.makeTxnId(), ...(type ? { type } : {}) }; + enqueueOutbox(userId, entry); + return attempt(mx, userId, { ...entry, status: 'pending', ts: Date.now() }); +}; + +/** + * Resend. An echo still held by the SDK under the old txn id (a failure in this session) makes + * it refuse the same id, so that attempt is abandoned under a fresh id. After a reload the echo + * is gone and the old id is reused, so a send that did land is deduplicated by the server. + */ +export const resendTracked = (mx: MatrixClient, entry: OutboxEntry): Promise => { + const userId = mx.getUserId() ?? ''; + const held = mx.getRoom(entry.roomId)?.getEventForTxnId(entry.txnId); + const txnId = held ? mx.makeTxnId() : entry.txnId; + retryOutbox(userId, entry.txnId, Date.now(), txnId); + return attempt(mx, userId, { ...entry, txnId, status: 'pending', ts: Date.now() }); +}; + + +/** + * After a reload, send again every entry the reload interrupted. They have no SDK echo, so each + * keeps its txn id and the server deduplicates one that did land. Failed entries are left for + * the user: they failed with an answer, these are only unknown. + */ +export const resendInterrupted = (mx: MatrixClient, since: number = PAGE_START): Promise => { + const userId = mx.getUserId() ?? ''; + return Promise.all( + loadOutbox(userId) + .filter((e) => e.status === 'pending' && e.ts < since) + .map((e) => resendTracked(mx, e)) + ); +}; diff --git a/src/client/initMatrix.test.ts b/src/client/initMatrix.test.ts new file mode 100644 index 00000000..193aa0cb --- /dev/null +++ b/src/client/initMatrix.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from 'vitest'; +import { RECOVER_RELOAD_MIN_GAP_MS, reloadToRecoverSync } from './initMatrix'; + +const memory = () => { + const m = new Map(); + return { getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => { + m.set(k, v); + } }; +}; + +describe('reloadToRecoverSync', () => { + it('reloads once, then refuses inside the gap so a down server cannot loop it', () => { + const store = memory(); + const reload = vi.fn(); + expect(reloadToRecoverSync(1_000_000, store, reload)).toBe(true); + expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS - 1, store, reload)).toBe(false); + expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS, store, reload)).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 871a7625..2cb6a203 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -1,9 +1,19 @@ -import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk'; +import { + ClientEvent, + createClient, + MatrixClient, + IndexedDBStore, + IndexedDBCryptoStore, + SyncState, +} from 'matrix-js-sdk'; import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { pushSessionToSW } from '../sw-session'; import { startResumeSync } from './resumeSync'; +import { startWatchdog } from './watchdog'; +import { loadOutbox } from '../app/state/room/composerStore'; +import { resendInterrupted } from '../app/state/room/outboxSend'; type Session = { baseUrl: string; @@ -46,9 +56,42 @@ const startSync = (mx: MatrixClient) => lazyLoadMembers: true, }); +const RECOVER_RELOAD_KEY = 'ratatoskr.recoverReloadAt'; +export const RECOVER_RELOAD_MIN_GAP_MS = 60_000; + +/** Reload to bring a dead sync back, at most once a minute, so a server that is down cannot loop us. */ +export const reloadToRecoverSync = ( + now: number = Date.now(), + store: Pick = sessionStorage, + reload: () => void = () => window.location.reload() +): boolean => { + const last = Number(store.getItem(RECOVER_RELOAD_KEY) ?? 0); + if (now - last < RECOVER_RELOAD_MIN_GAP_MS) return false; + store.setItem(RECOVER_RELOAD_KEY, String(now)); + reload(); + return true; +}; + export const startClient = async (mx: MatrixClient) => { await startSync(mx); - startResumeSync(mx, { restart: startSync }); + const recover = () => reloadToRecoverSync(); + startResumeSync(mx, { recover }); + startWatchdog({ + getSyncState: () => mx.getSyncState(), + pendingAges: () => + loadOutbox(mx.getUserId() ?? '') + .filter((e) => e.status === 'pending') + .map((e) => Date.now() - e.ts), + isVisible: () => document.visibilityState === 'visible', + recover, + }); + // What a reload interrupted is sent again once sync is up. + const onSync = (state: SyncState) => { + if (state !== SyncState.Prepared && state !== SyncState.Syncing) return; + mx.off(ClientEvent.Sync, onSync); + resendInterrupted(mx); + }; + mx.on(ClientEvent.Sync, onSync); }; export const clearCacheAndReload = async (mx: MatrixClient) => { diff --git a/src/client/resumeSync.test.ts b/src/client/resumeSync.test.ts index eada97af..22347a1f 100644 --- a/src/client/resumeSync.test.ts +++ b/src/client/resumeSync.test.ts @@ -12,11 +12,10 @@ const setup = (state: string | null = 'SYNCING') => { state, getSyncState: () => mx.state, retryImmediately: vi.fn(() => true), - stopClient: vi.fn(), }; - const restart = vi.fn(async () => {}); + const recover = vi.fn(); const stop = startResumeSync(mx, { - restart, + recover, doc: doc as never, win: win as never, }); @@ -28,7 +27,7 @@ const setup = (state: string | null = 'SYNCING') => { doc.visibilityState = 'visible'; doc.dispatchEvent(new Event('visibilitychange')); }; - return { doc, win, mx, restart, stop, hide, show }; + return { doc, win, mx, recover, stop, hide, show }; }; const settle = () => vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS); @@ -88,34 +87,18 @@ describe('startResumeSync', () => { const t = setup(state); t.win.dispatchEvent(new Event('online')); await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); - expect(t.restart).not.toHaveBeenCalled(); + expect(t.recover).not.toHaveBeenCalled(); t.stop(); } }); - it('does one stop/start when sync is still unhealthy, never overlapping', async () => { + it('calls recover once when sync is still unhealthy, and never stops the client itself', async () => { const t = setup('RECONNECTING'); - let release!: () => void; - t.restart.mockImplementation( - () => - new Promise((r) => { - release = r; - }) - ); t.win.dispatchEvent(new Event('online')); await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); - expect(t.mx.stopClient).toHaveBeenCalledTimes(1); - expect(t.restart).toHaveBeenCalledTimes(1); - // a trigger while the restart is in flight does nothing - t.win.dispatchEvent(new Event('online')); - await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); - expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); - expect(t.restart).toHaveBeenCalledTimes(1); - release(); - await vi.advanceTimersByTimeAsync(0); - t.win.dispatchEvent(new Event('online')); - await settle(); - expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2); + expect(t.recover).toHaveBeenCalledTimes(1); + expect(t.recover).toHaveBeenCalledWith(t.mx); + expect(Object.keys(t.mx)).not.toContain('stopClient'); }); it('removes listeners and cancels pending work on dispose', async () => { @@ -124,7 +107,7 @@ describe('startResumeSync', () => { await settle(); t.stop(); await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); - expect(t.restart).not.toHaveBeenCalled(); + expect(t.recover).not.toHaveBeenCalled(); t.win.dispatchEvent(new Event('online')); await settle(); expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); diff --git a/src/client/resumeSync.ts b/src/client/resumeSync.ts index 5f81547b..30fbab70 100644 --- a/src/client/resumeSync.ts +++ b/src/client/resumeSync.ts @@ -2,7 +2,6 @@ export type SyncHost = { getSyncState(): string | null; retryImmediately(): boolean; - stopClient(): void; }; // matrix-js-sdk SyncState values for a live sync loop. @@ -16,43 +15,40 @@ type ResumeSyncOptions = { /** Event sources and visibility; default to the browser globals. */ doc?: Pick; win?: Pick; - /** Restart the client after stopClient; must resolve once startClient has been issued. */ - restart: (mx: Host) => Promise; + /** + * Called once when sync is still unhealthy after the nudge. It must not stop and restart the + * client in place: stopClient() closes the rust crypto's OlmMachine for good and startClient() + * does not reopen it, so every later encrypted send fails with "null pointer passed to rust". + * A page reload is the only restart that brings crypto back. + */ + recover: (mx: Host) => void; }; /** * A backgrounded tab or PWA leaves the sync long-poll dead until its 30 s timeout. * On return (visible after a real absence, pageshow from bfcache, online) nudge the - * poll with retryImmediately; if sync is still unhealthy after HEALTH_CHECK_MS, do one - * stop/start. Returns a disposer that removes every listener and timer. + * poll with retryImmediately; if sync is still unhealthy after HEALTH_CHECK_MS, call recover once. Returns a disposer that removes every listener and timer. */ export const startResumeSync = ( mx: Host, - { restart, doc = document, win = window }: ResumeSyncOptions + { recover, doc = document, win = window }: ResumeSyncOptions ): (() => void) => { let hiddenAt: number | undefined; let debounce: ReturnType | undefined; let healthCheck: ReturnType | undefined; - let restarting = false; let disposed = false; - const check = async () => { + const check = () => { healthCheck = undefined; - if (disposed || restarting || HEALTHY.has(mx.getSyncState())) return; - restarting = true; - try { - mx.stopClient(); - await restart(mx); - } finally { - restarting = false; - } + if (disposed || HEALTHY.has(mx.getSyncState())) return; + recover(mx); }; const resume = () => { if (debounce !== undefined) clearTimeout(debounce); debounce = setTimeout(() => { debounce = undefined; - if (disposed || restarting || healthCheck !== undefined) return; + if (disposed || healthCheck !== undefined) return; mx.retryImmediately(); healthCheck = setTimeout(check, HEALTH_CHECK_MS); }, RESUME_DEBOUNCE_MS); diff --git a/src/client/watchdog.test.ts b/src/client/watchdog.test.ts new file mode 100644 index 00000000..4bf9cc2f --- /dev/null +++ b/src/client/watchdog.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SEND_STALL_MS, SYNC_BAD_TICKS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog'; + +const setup = (over: Partial[0]> = {}) => { + const recover = vi.fn(); + const o = { + getSyncState: () => 'SYNCING' as string | null, + pendingAges: () => [] as number[], + isVisible: () => true, + recover, + ...over, + }; + const stop = startWatchdog(o); + return { recover, stop }; +}; +const tick = (n: number) => vi.advanceTimersByTime(WATCHDOG_INTERVAL_MS * n); + +describe('watchdog', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('stays quiet while sync is healthy and nothing is pending', () => { + const t = setup(); + tick(100); + expect(t.recover).not.toHaveBeenCalled(); + t.stop(); + }); + + it('recovers when a send has been pending past the stall threshold', () => { + let age = 0; + const t = setup({ pendingAges: () => [age] }); + age = SEND_STALL_MS - 1; + tick(1); + expect(t.recover).not.toHaveBeenCalled(); + age = SEND_STALL_MS; + tick(1); + expect(t.recover).toHaveBeenCalledTimes(1); + t.stop(); + }); + + it.each(['ERROR', 'STOPPED'])('recovers when sync sits in %s for the bad-tick count, not before', (state) => { + const t = setup({ getSyncState: () => state }); + tick(SYNC_BAD_TICKS - 1); + expect(t.recover).not.toHaveBeenCalled(); + tick(1); + expect(t.recover).toHaveBeenCalledTimes(1); + t.stop(); + }); + + it('a recovered sync resets the count; RECONNECTING and a null state are js-sdk working, not bad', () => { + let state: string | null = 'ERROR'; + const t = setup({ getSyncState: () => state }); + tick(SYNC_BAD_TICKS - 1); + state = 'SYNCING'; + tick(1); + state = 'ERROR'; + tick(SYNC_BAD_TICKS - 1); + state = 'RECONNECTING'; + tick(50); + state = null; + tick(50); + expect(t.recover).not.toHaveBeenCalled(); + t.stop(); + }); + + it('does nothing while the page is hidden', () => { + const t = setup({ isVisible: () => false, pendingAges: () => [1e9], getSyncState: () => 'STOPPED' }); + tick(50); + expect(t.recover).not.toHaveBeenCalled(); + t.stop(); + }); + + it('stop() ends it', () => { + const t = setup({ pendingAges: () => [1e9] }); + t.stop(); + tick(10); + expect(t.recover).not.toHaveBeenCalled(); + }); +}); diff --git a/src/client/watchdog.ts b/src/client/watchdog.ts new file mode 100644 index 00000000..f5ea86f8 --- /dev/null +++ b/src/client/watchdog.ts @@ -0,0 +1,48 @@ +/** + * The page can go on looking alive while its connection is dead: sync polls get cut by their + * own timeout, and a send has no timeout at all, so it hangs unsent while its echo sits in the + * composer's room. This notices the two signs and calls `recover` (a guarded reload; the + * outbox then resends what was interrupted). + * - a send pending past `stallMs` + * - sync stuck in ERROR or STOPPED for `syncBadTicks` consecutive ticks + * Nothing fires while the page is hidden: a frozen background tab is not a dead connection. + */ +export type WatchdogOptions = { + getSyncState: () => string | null; + /** Ages in ms of sends still pending. */ + pendingAges: () => number[]; + isVisible: () => boolean; + recover: () => void; + intervalMs?: number; + stallMs?: number; + syncBadTicks?: number; +}; + +export const WATCHDOG_INTERVAL_MS = 5000; +export const SEND_STALL_MS = 20_000; +export const SYNC_BAD_TICKS = 6; + +export const startWatchdog = ({ + getSyncState, + pendingAges, + isVisible, + recover, + intervalMs = WATCHDOG_INTERVAL_MS, + stallMs = SEND_STALL_MS, + syncBadTicks = SYNC_BAD_TICKS, +}: WatchdogOptions): (() => void) => { + let badTicks = 0; + const id = setInterval(() => { + if (!isVisible()) { + badTicks = 0; + return; + } + const state = getSyncState(); + badTicks = state === 'ERROR' || state === 'STOPPED' ? badTicks + 1 : 0; + if (badTicks >= syncBadTicks || pendingAges().some((age) => age >= stallMs)) { + badTicks = 0; + recover(); + } + }, intervalMs); + return () => clearInterval(id); +}; From 69d752ff0b5b6da7368dc45a6d0be781be849142 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:34:21 +0000 Subject: [PATCH 057/143] watchdog: send stall only, own-page entries, online only; resume sync nudges and never restarts Soul pass on 77edebff: the watchdog no longer counts interrupted entries from a previous page (reload loop at startup), no longer reloads on sync ERROR/STOPPED (js-sdk recovers offline by itself) or while offline, refuses instead of throwing when sessionStorage is unusable, startClient is idempotent, and the slow-send label says Resend may duplicate. resumeSync drops its health check and recover hook: nudge only. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomOutbox.test.ts | 3 +- src/app/features/room/RoomOutbox.tsx | 6 ++- src/app/state/room/composerStore.test.ts | 3 +- src/app/state/room/composerStore.ts | 11 +++-- src/app/state/room/outboxSend.ts | 13 ++++-- src/app/state/room/roomInputDrafts.ts | 4 +- src/client/initMatrix.test.ts | 25 ++++++++++-- src/client/initMatrix.ts | 28 ++++++++----- src/client/resumeSync.test.ts | 50 +++++------------------ src/client/resumeSync.ts | 37 +++++------------ src/client/watchdog.test.ts | 51 +++++++----------------- src/client/watchdog.ts | 37 ++++++----------- 12 files changed, 112 insertions(+), 156 deletions(-) diff --git a/src/app/features/room/RoomOutbox.test.ts b/src/app/features/room/RoomOutbox.test.ts index b2c29b14..55597694 100644 --- a/src/app/features/room/RoomOutbox.test.ts +++ b/src/app/features/room/RoomOutbox.test.ts @@ -8,7 +8,7 @@ describe('outboxView', () => { canResend: false, }); expect(outboxView({ status: 'pending', ts: 100 }, 100 + SLOW_SEND_MS, 0)).toMatchObject({ - label: 'Still sending: slow connection', + label: expect.stringMatching(/^Still sending/), canResend: true, }); expect(outboxView({ status: 'failed', ts: 100, error: 'x' }, 100, 0)).toMatchObject({ @@ -21,4 +21,3 @@ describe('outboxView', () => { expect(outboxView({ status: 'pending', ts: 5 }, 6, 10).label).toMatch(/Interrupted/); }); }); - diff --git a/src/app/features/room/RoomOutbox.tsx b/src/app/features/room/RoomOutbox.tsx index 21c3e224..1e77df43 100644 --- a/src/app/features/room/RoomOutbox.tsx +++ b/src/app/features/room/RoomOutbox.tsx @@ -34,7 +34,11 @@ export const outboxView = ( }; } if (now - entry.ts >= SLOW_SEND_MS) { - return { label: 'Still sending: slow connection', failed: false, canResend: true }; + return { + label: 'Still sending: slow connection (Resend may duplicate it if it lands later)', + failed: false, + canResend: true, + }; } return { label: 'Sending…', failed: false, canResend: false }; }; diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts index 0c4f8ac8..f5a0f35b 100644 --- a/src/app/state/room/composerStore.test.ts +++ b/src/app/state/room/composerStore.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { Descendant } from 'slate'; import { + PAGE_ID, clearDraft, enqueueOutbox, failOutbox, @@ -77,7 +78,7 @@ describe('outbox', () => { const store = memoryStore(); enqueueOutbox('@a:x', entry, 5, store); const after = loadOutbox('@a:x', store); - expect(after).toEqual([{ ...entry, status: 'pending', ts: 5 }]); + expect(after).toEqual([{ ...entry, status: 'pending', ts: 5, tab: PAGE_ID }]); removeOutbox('@a:x', 't1', store); expect(loadOutbox('@a:x', store)).toEqual([]); expect(store.size()).toBe(0); diff --git a/src/app/state/room/composerStore.ts b/src/app/state/room/composerStore.ts index ca1f6bcb..91933d72 100644 --- a/src/app/state/room/composerStore.ts +++ b/src/app/state/room/composerStore.ts @@ -21,8 +21,12 @@ export type OutboxEntry = { status: OutboxStatus; error?: string; ts: number; + /** The page that last sent it: only that page's watchdog may judge it stalled. */ + tab?: string; }; +export const PAGE_ID = Math.random().toString(36).slice(2); + type Store = Pick; const defaultStore = (): Store | undefined => { @@ -92,7 +96,8 @@ export const saveDraft = ( removeKey(store, draftKey(userId, roomId)); return; } - if (!writeJson(store, draftKey(userId, roomId), draft)) removeKey(store, draftKey(userId, roomId)); + if (!writeJson(store, draftKey(userId, roomId), draft)) + removeKey(store, draftKey(userId, roomId)); }; export const clearDraft = ( @@ -139,7 +144,7 @@ export const enqueueOutbox = ( store: Store | undefined = defaultStore() ): void => { const rest = loadOutbox(userId, store).filter((e) => e.txnId !== entry.txnId); - saveOutbox(userId, [...rest, { ...entry, status: 'pending', ts: now }], store); + saveOutbox(userId, [...rest, { ...entry, status: 'pending', ts: now, tab: PAGE_ID }], store); }; export const failOutbox = ( @@ -172,7 +177,7 @@ export const retryOutbox = ( userId, loadOutbox(userId, store).map((e) => e.txnId === txnId - ? { ...e, txnId: newTxnId, status: 'pending', error: undefined, ts: now } + ? { ...e, txnId: newTxnId, status: 'pending', error: undefined, ts: now, tab: PAGE_ID } : e ), store diff --git a/src/app/state/room/outboxSend.ts b/src/app/state/room/outboxSend.ts index 2bea0de7..845e64a0 100644 --- a/src/app/state/room/outboxSend.ts +++ b/src/app/state/room/outboxSend.ts @@ -13,7 +13,12 @@ export const PAGE_START = Date.now(); const attempt = async (mx: MatrixClient, userId: string, entry: OutboxEntry): Promise => { try { - await mx.sendEvent(entry.roomId, (entry.type ?? 'm.room.message') as never, entry.content as never, entry.txnId); + await mx.sendEvent( + entry.roomId, + (entry.type ?? 'm.room.message') as never, + entry.content as never, + entry.txnId + ); removeOutbox(userId, entry.txnId); } catch (e) { // sendEvent can also throw synchronously (e.g. a txn id the SDK already holds) @@ -50,13 +55,15 @@ export const resendTracked = (mx: MatrixClient, entry: OutboxEntry): Promise => { +export const resendInterrupted = ( + mx: MatrixClient, + since: number = PAGE_START +): Promise => { const userId = mx.getUserId() ?? ''; return Promise.all( loadOutbox(userId) diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index 5d8ec8c7..f3bf9e1c 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -20,9 +20,7 @@ export type TUploadItem = { export type TUploadListAtom = ReturnType>; -export const roomIdToUploadItemsAtomFamily = atomFamily( - createListAtom -); +export const roomIdToUploadItemsAtomFamily = atomFamily(createListAtom); export const roomUploadAtomFamily = createUploadAtomFamily(); diff --git a/src/client/initMatrix.test.ts b/src/client/initMatrix.test.ts index 193aa0cb..8db257ea 100644 --- a/src/client/initMatrix.test.ts +++ b/src/client/initMatrix.test.ts @@ -3,9 +3,12 @@ import { RECOVER_RELOAD_MIN_GAP_MS, reloadToRecoverSync } from './initMatrix'; const memory = () => { const m = new Map(); - return { getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => { + return { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { m.set(k, v); - } }; + }, + }; }; describe('reloadToRecoverSync', () => { @@ -13,8 +16,24 @@ describe('reloadToRecoverSync', () => { const store = memory(); const reload = vi.fn(); expect(reloadToRecoverSync(1_000_000, store, reload)).toBe(true); - expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS - 1, store, reload)).toBe(false); + expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS - 1, store, reload)).toBe( + false + ); expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS, store, reload)).toBe(true); expect(reload).toHaveBeenCalledTimes(2); }); }); + +describe('reloadToRecoverSync with unusable storage', () => { + it('refuses instead of throwing when sessionStorage throws', () => { + const reload = vi.fn(); + const broken = { + getItem: () => { + throw new Error('denied'); + }, + setItem: () => undefined, + }; + expect(reloadToRecoverSync(1, broken, reload)).toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); +}); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 2cb6a203..6c7886ac 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -12,7 +12,7 @@ import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { pushSessionToSW } from '../sw-session'; import { startResumeSync } from './resumeSync'; import { startWatchdog } from './watchdog'; -import { loadOutbox } from '../app/state/room/composerStore'; +import { PAGE_ID, loadOutbox } from '../app/state/room/composerStore'; import { resendInterrupted } from '../app/state/room/outboxSend'; type Session = { @@ -59,31 +59,38 @@ const startSync = (mx: MatrixClient) => const RECOVER_RELOAD_KEY = 'ratatoskr.recoverReloadAt'; export const RECOVER_RELOAD_MIN_GAP_MS = 60_000; -/** Reload to bring a dead sync back, at most once a minute, so a server that is down cannot loop us. */ +/** Reload to bring a dead connection back, at most once a minute, so a server that is down cannot loop us. */ export const reloadToRecoverSync = ( now: number = Date.now(), store: Pick = sessionStorage, reload: () => void = () => window.location.reload() ): boolean => { - const last = Number(store.getItem(RECOVER_RELOAD_KEY) ?? 0); - if (now - last < RECOVER_RELOAD_MIN_GAP_MS) return false; - store.setItem(RECOVER_RELOAD_KEY, String(now)); + try { + const last = Number(store.getItem(RECOVER_RELOAD_KEY) ?? 0); + if (now - last < RECOVER_RELOAD_MIN_GAP_MS) return false; + store.setItem(RECOVER_RELOAD_KEY, String(now)); + } catch { + return false; + } reload(); return true; }; +const started = new WeakSet(); + export const startClient = async (mx: MatrixClient) => { await startSync(mx); - const recover = () => reloadToRecoverSync(); - startResumeSync(mx, { recover }); + if (started.has(mx)) return; + started.add(mx); + startResumeSync(mx); startWatchdog({ - getSyncState: () => mx.getSyncState(), pendingAges: () => loadOutbox(mx.getUserId() ?? '') - .filter((e) => e.status === 'pending') + .filter((e) => e.status === 'pending' && e.tab === PAGE_ID) .map((e) => Date.now() - e.ts), isVisible: () => document.visibilityState === 'visible', - recover, + isOnline: () => navigator.onLine, + recover: () => reloadToRecoverSync(), }); // What a reload interrupted is sent again once sync is up. const onSync = (state: SyncState) => { @@ -93,7 +100,6 @@ export const startClient = async (mx: MatrixClient) => { }; mx.on(ClientEvent.Sync, onSync); }; - export const clearCacheAndReload = async (mx: MatrixClient) => { mx.stopClient(); clearNavToActivePathStore(mx.getSafeUserId()); diff --git a/src/client/resumeSync.test.ts b/src/client/resumeSync.test.ts index 22347a1f..e90ac6a8 100644 --- a/src/client/resumeSync.test.ts +++ b/src/client/resumeSync.test.ts @@ -1,24 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { HEALTH_CHECK_MS, MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync'; +import { MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync'; class Target extends EventTarget { visibilityState: 'visible' | 'hidden' = 'visible'; } -const setup = (state: string | null = 'SYNCING') => { +const setup = () => { const doc = new Target(); const win = new Target(); - const mx = { - state, - getSyncState: () => mx.state, - retryImmediately: vi.fn(() => true), - }; - const recover = vi.fn(); - const stop = startResumeSync(mx, { - recover, - doc: doc as never, - win: win as never, - }); + const mx = { retryImmediately: vi.fn(() => true) }; + const stop = startResumeSync(mx, { doc: doc as never, win: win as never }); const hide = () => { doc.visibilityState = 'hidden'; doc.dispatchEvent(new Event('visibilitychange')); @@ -27,7 +18,7 @@ const setup = (state: string | null = 'SYNCING') => { doc.visibilityState = 'visible'; doc.dispatchEvent(new Event('visibilitychange')); }; - return { doc, win, mx, recover, stop, hide, show }; + return { doc, win, mx, stop, hide, show }; }; const settle = () => vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS); @@ -55,7 +46,7 @@ describe('startResumeSync', () => { t.hide(); await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS - 1); t.show(); - await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS); expect(t.mx.retryImmediately).not.toHaveBeenCalled(); }); @@ -67,7 +58,6 @@ describe('startResumeSync', () => { t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true })); await settle(); expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); t.win.dispatchEvent(new Event('online')); await settle(); expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2); @@ -82,34 +72,14 @@ describe('startResumeSync', () => { expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); }); - it('does not restart when sync is healthy after the check window', async () => { - for (const state of ['PREPARED', 'SYNCING', 'CATCHUP']) { - const t = setup(state); - t.win.dispatchEvent(new Event('online')); - await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); - expect(t.recover).not.toHaveBeenCalled(); - t.stop(); - } - }); - - it('calls recover once when sync is still unhealthy, and never stops the client itself', async () => { - const t = setup('RECONNECTING'); - t.win.dispatchEvent(new Event('online')); - await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); - expect(t.recover).toHaveBeenCalledTimes(1); - expect(t.recover).toHaveBeenCalledWith(t.mx); - expect(Object.keys(t.mx)).not.toContain('stopClient'); - }); - it('removes listeners and cancels pending work on dispose', async () => { - const t = setup('ERROR'); + const t = setup(); t.win.dispatchEvent(new Event('online')); - await settle(); t.stop(); - await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); - expect(t.recover).not.toHaveBeenCalled(); + await settle(); + expect(t.mx.retryImmediately).not.toHaveBeenCalled(); t.win.dispatchEvent(new Event('online')); await settle(); - expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + expect(t.mx.retryImmediately).not.toHaveBeenCalled(); }); }); diff --git a/src/client/resumeSync.ts b/src/client/resumeSync.ts index 30fbab70..a09f39f8 100644 --- a/src/client/resumeSync.ts +++ b/src/client/resumeSync.ts @@ -1,56 +1,38 @@ /** The slice of MatrixClient this module drives; a MatrixClient satisfies it. */ export type SyncHost = { - getSyncState(): string | null; retryImmediately(): boolean; }; -// matrix-js-sdk SyncState values for a live sync loop. -const HEALTHY: ReadonlySet = new Set(['PREPARED', 'SYNCING', 'CATCHUP']); - export const RESUME_DEBOUNCE_MS = 250; export const MIN_HIDDEN_MS = 5000; -export const HEALTH_CHECK_MS = 3000; -type ResumeSyncOptions = { +type ResumeSyncOptions = { /** Event sources and visibility; default to the browser globals. */ doc?: Pick; win?: Pick; - /** - * Called once when sync is still unhealthy after the nudge. It must not stop and restart the - * client in place: stopClient() closes the rust crypto's OlmMachine for good and startClient() - * does not reopen it, so every later encrypted send fails with "null pointer passed to rust". - * A page reload is the only restart that brings crypto back. - */ - recover: (mx: Host) => void; }; /** * A backgrounded tab or PWA leaves the sync long-poll dead until its 30 s timeout. * On return (visible after a real absence, pageshow from bfcache, online) nudge the - * poll with retryImmediately; if sync is still unhealthy after HEALTH_CHECK_MS, call recover once. Returns a disposer that removes every listener and timer. + * poll with retryImmediately. It never stops and restarts the client: stopClient() closes + * the rust crypto's OlmMachine for good and startClient() does not reopen it. A connection + * that stays dead is the watchdog's business. Returns a disposer that removes every + * listener and timer. */ -export const startResumeSync = ( - mx: Host, - { recover, doc = document, win = window }: ResumeSyncOptions +export const startResumeSync = ( + mx: SyncHost, + { doc = document, win = window }: ResumeSyncOptions = {} ): (() => void) => { let hiddenAt: number | undefined; let debounce: ReturnType | undefined; - let healthCheck: ReturnType | undefined; let disposed = false; - const check = () => { - healthCheck = undefined; - if (disposed || HEALTHY.has(mx.getSyncState())) return; - recover(mx); - }; - const resume = () => { if (debounce !== undefined) clearTimeout(debounce); debounce = setTimeout(() => { debounce = undefined; - if (disposed || healthCheck !== undefined) return; - mx.retryImmediately(); - healthCheck = setTimeout(check, HEALTH_CHECK_MS); + if (!disposed) mx.retryImmediately(); }, RESUME_DEBOUNCE_MS); }; @@ -74,7 +56,6 @@ export const startResumeSync = ( return () => { disposed = true; if (debounce !== undefined) clearTimeout(debounce); - if (healthCheck !== undefined) clearTimeout(healthCheck); doc.removeEventListener('visibilitychange', onVisibility); win.removeEventListener('pageshow', onPageShow); win.removeEventListener('online', resume); diff --git a/src/client/watchdog.test.ts b/src/client/watchdog.test.ts index 4bf9cc2f..7d80f099 100644 --- a/src/client/watchdog.test.ts +++ b/src/client/watchdog.test.ts @@ -1,16 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SEND_STALL_MS, SYNC_BAD_TICKS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog'; +import { SEND_STALL_MS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog'; const setup = (over: Partial[0]> = {}) => { const recover = vi.fn(); - const o = { - getSyncState: () => 'SYNCING' as string | null, - pendingAges: () => [] as number[], + const stop = startWatchdog({ + pendingAges: () => [], isVisible: () => true, + isOnline: () => true, recover, ...over, - }; - const stop = startWatchdog(o); + }); return { recover, stop }; }; const tick = (n: number) => vi.advanceTimersByTime(WATCHDOG_INTERVAL_MS * n); @@ -23,14 +22,14 @@ describe('watchdog', () => { vi.useRealTimers(); }); - it('stays quiet while sync is healthy and nothing is pending', () => { + it('stays quiet while nothing is pending', () => { const t = setup(); tick(100); expect(t.recover).not.toHaveBeenCalled(); t.stop(); }); - it('recovers when a send has been pending past the stall threshold', () => { + it('recovers when a send has been pending past the stall threshold, not before', () => { let age = 0; const t = setup({ pendingAges: () => [age] }); age = SEND_STALL_MS - 1; @@ -42,36 +41,14 @@ describe('watchdog', () => { t.stop(); }); - it.each(['ERROR', 'STOPPED'])('recovers when sync sits in %s for the bad-tick count, not before', (state) => { - const t = setup({ getSyncState: () => state }); - tick(SYNC_BAD_TICKS - 1); - expect(t.recover).not.toHaveBeenCalled(); - tick(1); - expect(t.recover).toHaveBeenCalledTimes(1); - t.stop(); - }); - - it('a recovered sync resets the count; RECONNECTING and a null state are js-sdk working, not bad', () => { - let state: string | null = 'ERROR'; - const t = setup({ getSyncState: () => state }); - tick(SYNC_BAD_TICKS - 1); - state = 'SYNCING'; - tick(1); - state = 'ERROR'; - tick(SYNC_BAD_TICKS - 1); - state = 'RECONNECTING'; + it('does nothing while the page is hidden or offline', () => { + const hidden = setup({ isVisible: () => false, pendingAges: () => [1e9] }); + const offline = setup({ isOnline: () => false, pendingAges: () => [1e9] }); tick(50); - state = null; - tick(50); - expect(t.recover).not.toHaveBeenCalled(); - t.stop(); - }); - - it('does nothing while the page is hidden', () => { - const t = setup({ isVisible: () => false, pendingAges: () => [1e9], getSyncState: () => 'STOPPED' }); - tick(50); - expect(t.recover).not.toHaveBeenCalled(); - t.stop(); + expect(hidden.recover).not.toHaveBeenCalled(); + expect(offline.recover).not.toHaveBeenCalled(); + hidden.stop(); + offline.stop(); }); it('stop() ends it', () => { diff --git a/src/client/watchdog.ts b/src/client/watchdog.ts index f5ea86f8..6b97e8fc 100644 --- a/src/client/watchdog.ts +++ b/src/client/watchdog.ts @@ -1,48 +1,37 @@ /** - * The page can go on looking alive while its connection is dead: sync polls get cut by their - * own timeout, and a send has no timeout at all, so it hangs unsent while its echo sits in the - * composer's room. This notices the two signs and calls `recover` (a guarded reload; the - * outbox then resends what was interrupted). - * - a send pending past `stallMs` - * - sync stuck in ERROR or STOPPED for `syncBadTicks` consecutive ticks - * Nothing fires while the page is hidden: a frozen background tab is not a dead connection. + * The page can go on looking alive while its connection is dead: sync polls are cut by their + * own timeout and js-sdk retries them, but a send has no timeout at all, so on a half-open + * connection it hangs SENDING and no request reaches the server. A send pending past + * `stallMs` is the sign, and `recover` (a guarded reload) the answer; the outbox then sends + * again what the reload interrupted. Sync state is not watched: ERROR is also what offline + * looks like, and js-sdk recovers from that itself. + * Nothing fires while the page is hidden (a frozen tab is not a dead connection) or offline + * (a reload there only blanks the page). */ export type WatchdogOptions = { - getSyncState: () => string | null; - /** Ages in ms of sends still pending. */ + /** Ages in ms of this page's sends still pending. */ pendingAges: () => number[]; isVisible: () => boolean; + isOnline: () => boolean; recover: () => void; intervalMs?: number; stallMs?: number; - syncBadTicks?: number; }; export const WATCHDOG_INTERVAL_MS = 5000; export const SEND_STALL_MS = 20_000; -export const SYNC_BAD_TICKS = 6; export const startWatchdog = ({ - getSyncState, pendingAges, isVisible, + isOnline, recover, intervalMs = WATCHDOG_INTERVAL_MS, stallMs = SEND_STALL_MS, - syncBadTicks = SYNC_BAD_TICKS, }: WatchdogOptions): (() => void) => { - let badTicks = 0; const id = setInterval(() => { - if (!isVisible()) { - badTicks = 0; - return; - } - const state = getSyncState(); - badTicks = state === 'ERROR' || state === 'STOPPED' ? badTicks + 1 : 0; - if (badTicks >= syncBadTicks || pendingAges().some((age) => age >= stallMs)) { - badTicks = 0; - recover(); - } + if (!isVisible() || !isOnline()) return; + if (pendingAges().some((age) => age >= stallMs)) recover(); }, intervalMs); return () => clearInterval(id); }; From bb6391a70b5e56efad3351c5ad5dff2005a01a60 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:35:46 +0000 Subject: [PATCH 058/143] initMatrix: resolve sessionStorage inside the guard's try; ownPendingAges tested Co-Authored-By: Claude Sonnet 5.5 --- src/client/initMatrix.test.ts | 47 +++++++++++++++++++++++++++-------- src/client/initMatrix.ts | 14 +++++++---- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/client/initMatrix.test.ts b/src/client/initMatrix.test.ts index 8db257ea..95a8d966 100644 --- a/src/client/initMatrix.test.ts +++ b/src/client/initMatrix.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { RECOVER_RELOAD_MIN_GAP_MS, reloadToRecoverSync } from './initMatrix'; +import { PAGE_ID, enqueueOutbox, failOutbox } from '../app/state/room/composerStore'; +import { RECOVER_RELOAD_MIN_GAP_MS, ownPendingAges, reloadToRecoverSync } from './initMatrix'; const memory = () => { const m = new Map(); @@ -15,11 +16,13 @@ describe('reloadToRecoverSync', () => { it('reloads once, then refuses inside the gap so a down server cannot loop it', () => { const store = memory(); const reload = vi.fn(); - expect(reloadToRecoverSync(1_000_000, store, reload)).toBe(true); - expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS - 1, store, reload)).toBe( - false + expect(reloadToRecoverSync(1_000_000, () => store, reload)).toBe(true); + expect( + reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS - 1, () => store, reload) + ).toBe(false); + expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS, () => store, reload)).toBe( + true ); - expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS, store, reload)).toBe(true); expect(reload).toHaveBeenCalledTimes(2); }); }); @@ -27,13 +30,35 @@ describe('reloadToRecoverSync', () => { describe('reloadToRecoverSync with unusable storage', () => { it('refuses instead of throwing when sessionStorage throws', () => { const reload = vi.fn(); - const broken = { - getItem: () => { - throw new Error('denied'); - }, - setItem: () => undefined, + const denied = () => { + throw new Error('SecurityError'); }; - expect(reloadToRecoverSync(1, broken, reload)).toBe(false); + expect(reloadToRecoverSync(1, denied, reload)).toBe(false); expect(reload).not.toHaveBeenCalled(); }); }); + +describe('ownPendingAges', () => { + it("counts only this page's pending sends, not other pages' or failed ones", () => { + const m = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }); + const e = (txnId: string) => ({ txnId, roomId: '!r:x', content: {} }); + enqueueOutbox('@a:x', e('mine'), 1000); + enqueueOutbox('@a:x', e('failed'), 1000); + failOutbox('@a:x', 'failed', 'x'); + enqueueOutbox('@a:x', e('theirs'), 1000); + const entries = JSON.parse(m.get('ratatoskr.outbox.@a:x') as string); + entries.find((x: { txnId: string }) => x.txnId === 'theirs').tab = `${PAGE_ID}-other`; + m.set('ratatoskr.outbox.@a:x', JSON.stringify(entries)); + expect(ownPendingAges('@a:x', 1500)).toEqual([500]); + vi.unstubAllGlobals(); + }); +}); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 6c7886ac..871c186a 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -62,10 +62,11 @@ export const RECOVER_RELOAD_MIN_GAP_MS = 60_000; /** Reload to bring a dead connection back, at most once a minute, so a server that is down cannot loop us. */ export const reloadToRecoverSync = ( now: number = Date.now(), - store: Pick = sessionStorage, + getStore: () => Pick = () => sessionStorage, reload: () => void = () => window.location.reload() ): boolean => { try { + const store = getStore(); const last = Number(store.getItem(RECOVER_RELOAD_KEY) ?? 0); if (now - last < RECOVER_RELOAD_MIN_GAP_MS) return false; store.setItem(RECOVER_RELOAD_KEY, String(now)); @@ -76,6 +77,12 @@ export const reloadToRecoverSync = ( return true; }; +/** Ages in ms of the sends this page made that are still pending: the only ones it may judge stalled. */ +export const ownPendingAges = (userId: string, now: number = Date.now()): number[] => + loadOutbox(userId) + .filter((e) => e.status === 'pending' && e.tab === PAGE_ID) + .map((e) => now - e.ts); + const started = new WeakSet(); export const startClient = async (mx: MatrixClient) => { @@ -84,10 +91,7 @@ export const startClient = async (mx: MatrixClient) => { started.add(mx); startResumeSync(mx); startWatchdog({ - pendingAges: () => - loadOutbox(mx.getUserId() ?? '') - .filter((e) => e.status === 'pending' && e.tab === PAGE_ID) - .map((e) => Date.now() - e.ts), + pendingAges: () => ownPendingAges(mx.getUserId() ?? ''), isVisible: () => document.visibilityState === 'visible', isOnline: () => navigator.onLine, recover: () => reloadToRecoverSync(), From ccb7a8ebb61b581ea8294502f597a83c09472d8a Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:40:05 +0000 Subject: [PATCH 059/143] Build stamps its git sha into index.html as a build-sha meta tag Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 4 +++ scripts/buildSha.js | 27 ++++++++++++++++++ scripts/buildSha.test.js | 40 +++++++++++++++++++++++++++ vite.config.js | 2 ++ 4 files changed, 73 insertions(+) create mode 100644 scripts/buildSha.js create mode 100644 scripts/buildSha.test.js diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index 33d3526f..d89541da 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -47,6 +47,10 @@ handoff states which config differs and the installer decides. From Yggdrasil th not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`. The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and the node and npm versions, is what the install request names. +Every build stamps its commit into `index.html` (`scripts/buildSha.js`): ``, +with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. The handoff +build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block. +To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`. Before handing off: diff --git a/scripts/buildSha.js b/scripts/buildSha.js new file mode 100644 index 00000000..741c5983 --- /dev/null +++ b/scripts/buildSha.js @@ -0,0 +1,27 @@ +// The commit a bundle was built from, stamped into its index.html as , so a built +// folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`. +// BUILD_SHA wins when it is a full sha (a build from a source archive has no git); otherwise git's HEAD, +// suffixed -dirty when the tree has uncommitted changes; otherwise 'unknown'. +import { execFileSync } from 'child_process'; + +const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + +export const readBuildSha = (env = process.env, run = git) => { + const fromEnv = (env.BUILD_SHA ?? '').trim(); + if (/^[0-9a-f]{40}$/.test(fromEnv)) return fromEnv; + try { + const head = run('rev-parse', 'HEAD').trim(); + if (!/^[0-9a-f]{40}$/.test(head)) return 'unknown'; + return run('status', '--porcelain').trim() === '' ? head : `${head}-dirty`; + } catch { + return 'unknown'; + } +}; + +export const buildShaPlugin = (sha = readBuildSha()) => ({ + name: 'ratatoskr-build-sha', + transformIndexHtml: () => [ + { tag: 'meta', attrs: { name: 'build-sha', content: sha }, injectTo: 'head-prepend' }, + ], +}); diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js new file mode 100644 index 00000000..e80f01b5 --- /dev/null +++ b/scripts/buildSha.test.js @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { buildShaPlugin, readBuildSha } from './buildSha'; + +const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e'; +const gitAt = + (head, status = '') => + (cmd) => + cmd === 'rev-parse' ? `${head}\n` : status; + +describe('readBuildSha', () => { + it('reads the checkout it is built from', () => { + expect(readBuildSha({}, gitAt(SHA))).toBe(SHA); + }); + it('marks a tree with uncommitted changes, so a stamp never vouches for code it does not hold', () => { + expect(readBuildSha({}, gitAt(SHA, ' M src/x.ts\n'))).toBe(`${SHA}-dirty`); + }); + it('lets BUILD_SHA stand in where there is no git, and ignores a value that is not a full sha', () => { + const noGit = () => { + throw new Error('not a git repository'); + }; + expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA); + expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown'); + }); + it("says 'unknown' rather than failing the build when git is absent", () => { + expect( + readBuildSha({}, () => { + throw new Error('spawn git ENOENT'); + }) + ).toBe('unknown'); + }); +}); + +describe('buildShaPlugin', () => { + it("hands vite a meta tag for the page's head carrying the sha", () => { + const plugin = buildShaPlugin(SHA); + expect(plugin.transformIndexHtml()).toEqual([ + { tag: 'meta', attrs: { name: 'build-sha', content: SHA }, injectTo: 'head-prepend' }, + ]); + }); +}); diff --git a/vite.config.js b/vite.config.js index e9b67550..699c3148 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,6 +10,7 @@ import { VitePWA } from 'vite-plugin-pwa'; import fs from 'fs'; import path from 'path'; import buildConfig from './build.config'; +import { buildShaPlugin } from './scripts/buildSha'; const copyFiles = { targets: [ @@ -104,6 +105,7 @@ export default defineConfig({ }), viteStaticCopy(copyFiles), vanillaExtractPlugin(), + buildShaPlugin(), wasm(), react(), VitePWA({ From 46e96500e495483626bcfc5d708e94df845ed86d Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:20:59 +0000 Subject: [PATCH 060/143] buildSha: ignore vite's temp config file, run git in the repo dir Co-Authored-By: Claude Sonnet 5.5 --- scripts/buildSha.js | 18 ++++++++++++++---- scripts/buildSha.test.js | 27 ++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/scripts/buildSha.js b/scripts/buildSha.js index 741c5983..d6c4870f 100644 --- a/scripts/buildSha.js +++ b/scripts/buildSha.js @@ -3,17 +3,27 @@ // BUILD_SHA wins when it is a full sha (a build from a source archive has no git); otherwise git's HEAD, // suffixed -dirty when the tree has uncommitted changes; otherwise 'unknown'. import { execFileSync } from 'child_process'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; -const git = (...args) => - execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); +const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -export const readBuildSha = (env = process.env, run = git) => { +// Vite writes vite.config.js.timestamp-*.mjs next to the config while it loads it; that file is the +// build's own, not source, so it must not mark the tree dirty. Any other untracked file still does. +const VITE_TEMP = ':(exclude,glob)**/vite.config.*.timestamp-*.mjs'; + +export const gitIn = + (cwd) => + (...args) => + execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + +export const readBuildSha = (env = process.env, run = gitIn(REPO_DIR)) => { const fromEnv = (env.BUILD_SHA ?? '').trim(); if (/^[0-9a-f]{40}$/.test(fromEnv)) return fromEnv; try { const head = run('rev-parse', 'HEAD').trim(); if (!/^[0-9a-f]{40}$/.test(head)) return 'unknown'; - return run('status', '--porcelain').trim() === '' ? head : `${head}-dirty`; + return run('status', '--porcelain', '--', '.', VITE_TEMP).trim() === '' ? head : `${head}-dirty`; } catch { return 'unknown'; } diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js index e80f01b5..368cc960 100644 --- a/scripts/buildSha.test.js +++ b/scripts/buildSha.test.js @@ -1,5 +1,9 @@ +import { execFileSync } from 'child_process'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { describe, expect, it } from 'vitest'; -import { buildShaPlugin, readBuildSha } from './buildSha'; +import { buildShaPlugin, gitIn, readBuildSha } from './buildSha'; const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e'; const gitAt = @@ -30,6 +34,27 @@ describe('readBuildSha', () => { }); }); +describe('readBuildSha against a real repository', () => { + const repo = () => { + const dir = mkdtempSync(join(tmpdir(), 'buildsha-')); + const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' }); + g('init', '-q'); + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n'); + g('add', '.'); + g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x'); + return dir; + }; + it("stays clean with vite's temp config file present, and is dirty with a stray source file", () => { + const dir = repo(); + const run = gitIn(dir); + writeFileSync(join(dir, 'vite.config.js.timestamp-1700000000-abc123.mjs'), ''); + expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}$/); + writeFileSync(join(dir, 'src', 'zz-stray.ts'), ''); + expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}-dirty$/); + }); +}); + describe('buildShaPlugin', () => { it("hands vite a meta tag for the page's head carrying the sha", () => { const plugin = buildShaPlugin(SHA); From 917f9a0da2c36e4152775daa99bb2d5f87e2e0ec Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:48:22 +0000 Subject: [PATCH 061/143] buildSha test: remove the temp repos after each test Co-Authored-By: Claude Sonnet 5.5 --- scripts/buildSha.test.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js index 368cc960..c45c8747 100644 --- a/scripts/buildSha.test.js +++ b/scripts/buildSha.test.js @@ -1,8 +1,8 @@ import { execFileSync } from 'child_process'; -import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { buildShaPlugin, gitIn, readBuildSha } from './buildSha'; const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e'; @@ -35,8 +35,13 @@ describe('readBuildSha', () => { }); describe('readBuildSha against a real repository', () => { + const made = []; + afterEach(() => { + made.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true })); + }); const repo = () => { const dir = mkdtempSync(join(tmpdir(), 'buildsha-')); + made.push(dir); const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' }); g('init', '-q'); mkdirSync(join(dir, 'src')); From f7395f12e3522077c74a111ec70fb902295e55c2 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:39:58 +0000 Subject: [PATCH 062/143] Phone: icon buttons, reaction chips and short sender names get a 44 px hit area Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/Reaction.css.ts | 11 +++++ .../components/message/layout/layout.css.ts | 7 ++- .../password-input/PasswordInput.tsx | 2 + src/app/features/room/RoomInput.tsx | 14 +++++- src/app/features/room/RoomViewHeader.tsx | 20 +++++++-- src/app/features/room/message/Message.tsx | 7 +++ src/app/features/scene/SkyStateControl.tsx | 2 + .../pages/auth/login/PasswordLoginForm.tsx | 2 + src/app/phoneTapTargets.test.ts | 40 +++++++++++++++++ src/app/styles/PhoneHit.css.ts | 45 +++++++++++++++++++ 10 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 src/app/styles/PhoneHit.css.ts diff --git a/src/app/components/message/Reaction.css.ts b/src/app/components/message/Reaction.css.ts index f020080e..08003f0e 100644 --- a/src/app/components/message/Reaction.css.ts +++ b/src/app/components/message/Reaction.css.ts @@ -1,6 +1,9 @@ import { createVar, style } from '@vanilla-extract/css'; import { DefaultReset, FocusOutline, color, config, toRem } from 'folds'; +import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; +import { PHONE_TAP } from '../../styles/PhoneHit.css'; +const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; const Container = createVar(); const ContainerHover = createVar(); const ContainerActive = createVar(); @@ -25,6 +28,14 @@ export const Reaction = style([ selectors: { 'button&': { cursor: 'pointer', + // Phone tap target: the chip is 26 px tall, so its hit area grows 9 px above and below (an ::after) and the + // same 9 px of margin opens the row, so the chips of one row and of the next never share a tap. + '@media': { [PHONE]: { position: 'relative', marginBlock: (PHONE_TAP - 26) / 2 } }, + }, + 'button&::after': { + '@media': { + [PHONE]: { content: '""', position: 'absolute', inset: `${-(PHONE_TAP - 26) / 2}px 0` }, + }, }, '&[aria-pressed=true]': { vars: { diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 3d1a9c83..39a40aa8 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -299,7 +299,10 @@ export const BubbleLeftArrow = style({ }); // Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the -// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves. +// negative margin takes the growth back out of the layout. Sideways it cannot borrow from the layout the +// same way (a negative inline margin makes the button shrink inside its shrink-to-fit parent and clip the name), so +// a name under 44 px is 44 px wide, text kept at the start: real growth of at most 16 px, in a row with room for it. +// No background, border or outline moves. const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; export const Username = style({ overflow: 'hidden', @@ -309,7 +312,7 @@ export const Username = style({ 'button&': { cursor: 'pointer', '@media': { - [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 }, + [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' }, }, }, 'button&:hover, button&:focus-visible': { diff --git a/src/app/components/password-input/PasswordInput.tsx b/src/app/components/password-input/PasswordInput.tsx index 4ffe0d83..6aa0d4f3 100644 --- a/src/app/components/password-input/PasswordInput.tsx +++ b/src/app/components/password-input/PasswordInput.tsx @@ -1,6 +1,7 @@ import React, { ComponentProps, forwardRef } from 'react'; import { Icon, IconButton, Input, config, Icons } from 'folds'; import { UseStateProvider } from '../UseStateProvider'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type PasswordInputProps = Omit, 'type' | 'size'> & { size: '400' | '500'; @@ -23,6 +24,7 @@ export const PasswordInput = forwardRef( <> {after} setVisible(!visible)} type="button" variant={visible ? 'Warning' : variant} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 03926da9..41f67d03 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -116,6 +116,7 @@ import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useComposingCheck } from '../../hooks/useComposingCheck'; +import { PhoneHitSpread32 } from '../../styles/PhoneHit.css'; interface RoomInputProps { editor: Editor; @@ -605,6 +606,7 @@ export const RoomInput = forwardRef( style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} > setReplyDraft(undefined)} variant="SurfaceVariant" size="300" @@ -638,6 +640,7 @@ export const RoomInput = forwardRef( } before={ pickFile('*')} variant="SurfaceVariant" size="300" @@ -649,6 +652,7 @@ export const RoomInput = forwardRef( after={ <> ( > {!hideStickerBtn && ( setEmojiBoardTab(EmojiBoardTab.Sticker)} variant="SurfaceVariant" @@ -704,6 +709,7 @@ export const RoomInput = forwardRef( )} ( )} - + diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 8089b5ef..3132c77a 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -72,6 +72,7 @@ import { RoomSettingsPage } from '../../state/roomSettings'; import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { webRTCSupported } from '../../utils/rtc'; +import { PhoneHitSpread40 } from '../../styles/PhoneHit.css'; type RoomMenuProps = { room: Room; @@ -336,6 +337,7 @@ function CallButton() { > {(triggerRef) => ( {(onBack) => ( - + @@ -531,7 +533,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { } > {(triggerRef) => ( - + )} @@ -548,6 +555,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { > {(triggerRef) => ( {(triggerRef) => ( - + )} @@ -631,6 +644,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { > {(triggerRef) => ( void; @@ -950,6 +951,7 @@ export const Message = as<'div', MessageProps>( } > ( )} ( {!isThreadedMessage && ( onReplyClick(ev, true)} data-event-id={mEvent.getId()} variant="SurfaceVariant" @@ -982,6 +986,7 @@ export const Message = as<'div', MessageProps>( )} {canEditEvent(mx, mEvent) && onEditId && ( onEditId(mEvent.getId())} variant="SurfaceVariant" size="300" @@ -1147,6 +1152,7 @@ export const Message = as<'div', MessageProps>( } > ( } > = { terrain: 'Terrain', ridge: 'Ridges' }; @@ -149,6 +150,7 @@ export function SkyStateControl() { } > (); @@ -94,6 +95,7 @@ function UsernameHint({ server }: { server: string }) { } > { expect(phone.match(/min-height: 2\.75rem/g)?.length).toBeGreaterThanOrEqual(2); }); }); + +describe('phone icon-button hit areas', () => { + const stripPhone = (css: string) => css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, ''); + + it('draw a 44 x 44 box around the button on a phone and nothing on any other screen', async () => { + const css = await emitted('styles/PhoneHit.css.ts'); + const phone = [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)]; + expect(phone.map((m) => m[1])).toEqual([PHONE]); + expect(css).toMatch(/::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/); + expect(css).toMatch(/::after \{[^}]*position: absolute;/); + expect(stripPhone(css).trim()).toBe(''); + }); + + it('open a row up by half the shortfall, so neighbouring hit areas meet and do not overlap', async () => { + const css = await emitted('styles/PhoneHit.css.ts'); + const margins = (selector: RegExp) => + rulesFor(css, selector, PHONE).map((d) => d['margin-inline']); + // 32 px composer buttons, 40 px header buttons, 26 px message-option buttons (which also open vertically, + // the menu around them clips a hit area drawn outside its box) + expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([9, 6, 2])); + expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]); + }); + + it("make a message's reaction chip 44 px to tap by opening 9 px above and below it", async () => { + const css = await emitted('components/message/Reaction.css.ts'); + const [chip] = rulesFor(css, /^button\.Reaction_Reaction__\w+$/, PHONE); + expect(chip['margin-block']).toBe((TAP - 26) / 2); + expect(css).toMatch(/::after \{[^}]*inset: -9px 0;/); + rulesFor(css, /^button\.Reaction_Reaction__\w+$/, undefined).forEach((d) => + expect(Object.keys(d)).not.toContain('margin-block') + ); + }); + + it('give a sender name a 44 px wide button too, text kept at its start', async () => { + const css = await emitted('components/message/layout/layout.css.ts'); + const [phone] = rulesFor(css, /^button\.layout_Username__/, PHONE); + expect(phone['min-width']).toBe(TAP); + expect(css).toMatch(/text-align: start;/); + }); +}); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts new file mode 100644 index 00000000..14ff375c --- /dev/null +++ b/src/app/styles/PhoneHit.css.ts @@ -0,0 +1,45 @@ +import { style } from '@vanilla-extract/css'; +import { MOBILE_BREAKPOINT } from '../hooks/useScreenSize'; + +// Phone tap target for an icon button: on a phone the button's hit area is 44 x 44 px centred on the drawn +// button (a ::after the button's own click reaches), so nothing drawn changes size. A button with neighbours +// 44 px closer than that would take their taps, so the `Spread` classes also give it horizontal margin of half +// the shortfall: the row grows by that, on phones only, and the hit areas meet instead of overlapping. +// Vertical room is cheaper than horizontal on a phone, so only the horizontal axis pays. +export const PHONE_TAP = 44; +const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; + +const hitArea = { + '@media': { + [PHONE]: { + content: '""', + position: 'absolute', + top: '50%', + left: '50%', + width: `max(100%, ${PHONE_TAP}px)`, + height: `max(100%, ${PHONE_TAP}px)`, + transform: 'translate(-50%, -50%)', + }, + }, +} as const; + +/** An icon button on its own: the hit area grows, nothing else moves. */ +export const PhoneHit = style({ + '@media': { [PHONE]: { position: 'relative' } }, + selectors: { '&::after': hitArea }, +}); + +/** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */ +const spread = (visual: number) => + style([PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }]); + +const PhoneHitSpread26 = spread(26); +export const PhoneHitSpread32 = spread(32); +export const PhoneHitSpread40 = spread(40); + +/** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row + * opens up on both axes, so the 44 px sits inside the container. */ +export const PhoneHitRoom26 = style([ + PhoneHitSpread26, + { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - 26) / 2 } } }, +]); From 856096e215c794aa29eb81cb1043082521563173 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:41:33 +0000 Subject: [PATCH 063/143] Desk back button: phone hit area Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/desk/Desk.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/features/desk/Desk.tsx b/src/app/features/desk/Desk.tsx index f1695716..a09501e7 100644 --- a/src/app/features/desk/Desk.tsx +++ b/src/app/features/desk/Desk.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; +import { PhoneHit } from '../../styles/PhoneHit.css'; import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { getHomePath } from '../../pages/pathUtils'; @@ -33,7 +34,11 @@ export function Desk() { {phone && ( - navigate(getHomePath())}> + navigate(getHomePath())} + > )} From ee55c322146ddfc135e68772b12449e404e0bc1a Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:27:14 +0000 Subject: [PATCH 064/143] Phone: quick-reaction emoji buttons and delete/report dialog close buttons get a 44 px hit area Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/message/Message.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 8ad1e958..a1bc17c9 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -86,7 +86,7 @@ import colorMXID from '../../../../util/colorMXID'; import { useSkyInk } from '../../../hooks/useTheme'; import { userColorInk } from '../../scene/skyInk'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; -import { PhoneHitRoom26 } from '../../../styles/PhoneHit.css'; +import { PhoneHitRoom26, PhoneHitSpread32 } from '../../../styles/PhoneHit.css'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -112,7 +112,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>( {recentEmojis.map((emoji) => ( Delete Message - + @@ -590,7 +595,12 @@ export const MessageReportItem = as< Report Message - + From 21ff16717711a0d576b04de4b67a94771a115511 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:48:09 +0000 Subject: [PATCH 065/143] phoneTapTargets: every IconButton in Message, RoomInput, RoomViewHeader carries a PhoneHit class Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneTapTargets.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index 5dcf2958..b50ec02d 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'fs'; import { describe, expect, it } from 'vitest'; import zlib from 'zlib'; import { compile, processVanillaFile } from '@vanilla-extract/integration'; @@ -156,3 +157,32 @@ describe('phone icon-button hit areas', () => { expect(css).toMatch(/text-align: start;/); }); }); + +describe('icon buttons in the room view', () => { + // The opening tag of every outside any {} expression. + const iconButtonTags = (src: string) => { + const tags: string[] = []; + for (let i = src.indexOf('' && depth === 0) break; + } + tags.push(src.slice(i, j + 1)); + } + return tags; + }; + + it.each([ + 'features/room/message/Message.tsx', + 'features/room/RoomInput.tsx', + 'features/room/RoomViewHeader.tsx', + ])('%s: every IconButton carries a PhoneHit class', (file) => { + const tags = iconButtonTags(readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8')); + expect(tags.length).toBeGreaterThan(0); + const bare = tags.filter((t) => !/PhoneHit/.test(t)); + expect(bare).toEqual([]); + }); +}); From 2a1f2c6b172d63c569bae7260b1b9716204ed891 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:02:08 +0000 Subject: [PATCH 066/143] Consumer-side tests: RoomOutbox as rendered; hung send to watchdog reload to resend Co-Authored-By: Claude Sonnet 5.5 --- .../features/room/RoomOutbox.render.test.tsx | 64 +++++++++++++++++ src/client/stallRecovery.test.ts | 70 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/app/features/room/RoomOutbox.render.test.tsx create mode 100644 src/client/stallRecovery.test.ts diff --git a/src/app/features/room/RoomOutbox.render.test.tsx b/src/app/features/room/RoomOutbox.render.test.tsx new file mode 100644 index 00000000..e4c258df --- /dev/null +++ b/src/app/features/room/RoomOutbox.render.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { enqueueOutbox, failOutbox } from '../../state/room/composerStore'; +import { RoomOutbox } from './RoomOutbox'; + +// What the person sees under the timeline for sends the server has not confirmed. +const text = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' '); + +describe('RoomOutbox as rendered', () => { + beforeEach(() => { + const m = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const mx = { getUserId: () => '@a:x' } as never; + const entry = (txnId: string, roomId: string, body: string) => ({ + txnId, + roomId, + content: { body }, + }); + + it('renders nothing when the room has no unconfirmed sends', () => { + enqueueOutbox('@a:x', entry('t1', '!other:x', 'elsewhere')); + expect(renderToStaticMarkup()).toBe(''); + }); + + it('shows a fresh send as Sending with its text and no Resend', () => { + enqueueOutbox('@a:x', entry('t1', '!r:x', 'on its way'), Date.now() + 1000); + const html = renderToStaticMarkup(); + expect(html).toContain('data-outbox-status="pending"'); + expect(text(html)).toContain('Sending…'); + expect(text(html)).toContain('on its way'); + expect(text(html)).not.toContain('Resend'); + }); + + it('shows a failed send with its error, Resend and Discard', () => { + enqueueOutbox('@a:x', entry('t2', '!r:x', 'did not go')); + failOutbox('@a:x', 't2', 'ECONNRESET'); + const html = renderToStaticMarkup(); + expect(html).toContain('data-outbox-status="failed"'); + expect(text(html)).toContain('Not sent: ECONNRESET'); + expect(text(html)).toContain('Resend'); + expect(text(html)).toContain('Discard'); + }); + + it('shows a send left over from before this page load as interrupted, resendable', () => { + enqueueOutbox('@a:x', entry('t3', '!r:x', 'lost to a reload'), 5); + const html = renderToStaticMarkup(); + expect(text(html)).toContain('Interrupted by a reload'); + expect(text(html)).toContain('Resend'); + }); +}); diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts new file mode 100644 index 00000000..bbeefd63 --- /dev/null +++ b/src/client/stallRecovery.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadOutbox } from '../app/state/room/composerStore'; +import { resendInterrupted, sendTracked } from '../app/state/room/outboxSend'; +import { RECOVER_RELOAD_MIN_GAP_MS, ownPendingAges, reloadToRecoverSync } from './initMatrix'; +import { SEND_STALL_MS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog'; + +// The dead-connection story from the user's side: a send hangs, the page notices and reloads +// itself once, and the message goes out again from the outbox under its own txn id. +const memoryStore = () => { + const m = new Map(); + return { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }; +}; + +describe('a send that hangs on a dead connection', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('localStorage', memoryStore()); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('is kept in the outbox, reloads the page once after the stall, and is resent under the same txn id', async () => { + const hung = vi.fn(() => new Promise(() => {})); + const mx = { + getUserId: () => '@a:x', + makeTxnId: () => 'txn-1', + sendEvent: hung, + getRoom: () => undefined, + } as never; + const reload = vi.fn(); + const session = memoryStore(); + const content = { msgtype: 'm.text', body: 'hello from a dead connection' }; + + const stop = startWatchdog({ + pendingAges: () => ownPendingAges('@a:x'), + isVisible: () => true, + isOnline: () => true, + recover: () => reloadToRecoverSync(Date.now(), () => session, reload), + }); + // intentionally never settles: the request is hung + sendTracked(mx, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending' }]); + + vi.advanceTimersByTime(SEND_STALL_MS - WATCHDOG_INTERVAL_MS); + expect(reload).not.toHaveBeenCalled(); + vi.advanceTimersByTime(2 * WATCHDOG_INTERVAL_MS); + expect(reload).toHaveBeenCalledTimes(1); + // the stall persists across the reload guard window: no reload loop + vi.advanceTimersByTime(RECOVER_RELOAD_MIN_GAP_MS - 2 * WATCHDOG_INTERVAL_MS); + expect(reload).toHaveBeenCalledTimes(1); + stop(); + + // the reloaded page: the entry is older than the page, the network works again + const send = vi.fn().mockResolvedValue({ event_id: '$e' }); + const reloaded = { ...mx, sendEvent: send } as never; + await resendInterrupted(reloaded, Date.now() + 1); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-1'); + expect(loadOutbox('@a:x')).toEqual([]); + }); +}); From 76c1f56bd8ec1d3b7d3adb5d1e8f995e26d8d682 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:02:17 +0000 Subject: [PATCH 067/143] stallRecovery test: lint --- src/client/stallRecovery.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index bbeefd63..aae6f2fe 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -30,7 +30,7 @@ describe('a send that hangs on a dead connection', () => { }); it('is kept in the outbox, reloads the page once after the stall, and is resent under the same txn id', async () => { - const hung = vi.fn(() => new Promise(() => {})); + const hung = vi.fn(() => new Promise(() => undefined)); const mx = { getUserId: () => '@a:x', makeTxnId: () => 'txn-1', From 9d1d39922c81d999ec0cdeb698e4f1a42f0d8101 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:02:33 +0000 Subject: [PATCH 068/143] stallRecovery test: lint clean --- src/client/stallRecovery.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index aae6f2fe..f2f35bf0 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -30,7 +30,12 @@ describe('a send that hangs on a dead connection', () => { }); it('is kept in the outbox, reloads the page once after the stall, and is resent under the same txn id', async () => { - const hung = vi.fn(() => new Promise(() => undefined)); + const hung = vi.fn( + () => + new Promise(() => { + // never settles + }) + ); const mx = { getUserId: () => '@a:x', makeTxnId: () => 'txn-1', From 09b062d7be2b09a4ec3d0eca739369884b72c25e Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:25:35 +0000 Subject: [PATCH 069/143] stallRecovery test: distinct txn ids per send; pin visible/online guards and own-tab pending filter; fix TS2698 Co-Authored-By: Claude Sonnet 5.5 --- src/client/stallRecovery.test.ts | 121 ++++++++++++++++++++++--------- 1 file changed, 85 insertions(+), 36 deletions(-) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index f2f35bf0..39ebb2b8 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -1,60 +1,74 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { loadOutbox } from '../app/state/room/composerStore'; +import { PAGE_ID, loadOutbox } from '../app/state/room/composerStore'; import { resendInterrupted, sendTracked } from '../app/state/room/outboxSend'; import { RECOVER_RELOAD_MIN_GAP_MS, ownPendingAges, reloadToRecoverSync } from './initMatrix'; import { SEND_STALL_MS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog'; // The dead-connection story from the user's side: a send hangs, the page notices and reloads // itself once, and the message goes out again from the outbox under its own txn id. -const memoryStore = () => { - const m = new Map(); - return { - getItem: (k: string) => m.get(k) ?? null, - setItem: (k: string, v: string) => { - m.set(k, v); - }, - removeItem: (k: string) => { - m.delete(k); - }, - }; -}; +const memoryStore = (m = new Map()) => ({ + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, +}); + +const OUTBOX_KEY = 'ratatoskr.outbox.@a:x'; describe('a send that hangs on a dead connection', () => { + let storage: Map; beforeEach(() => { vi.useFakeTimers(); - vi.stubGlobal('localStorage', memoryStore()); + storage = new Map(); + vi.stubGlobal('localStorage', memoryStore(storage)); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); - it('is kept in the outbox, reloads the page once after the stall, and is resent under the same txn id', async () => { - const hung = vi.fn( - () => - new Promise(() => { - // never settles - }) - ); - const mx = { + // Every send gets its own txn id, as the SDK's makeTxnId gives; a client that hands out one + // constant id could not tell a resend of the right message from a resend of the wrong one. + const hungClient = () => { + let n = 0; + return { getUserId: () => '@a:x', - makeTxnId: () => 'txn-1', - sendEvent: hung, + makeTxnId: () => { + n += 1; + return `txn-${n}`; + }, + sendEvent: vi.fn(() => new Promise(() => undefined)), // never settles: the request is hung getRoom: () => undefined, - } as never; + }; + }; + const watch = (env: { visible: boolean; online: boolean }) => { const reload = vi.fn(); const session = memoryStore(); - const content = { msgtype: 'm.text', body: 'hello from a dead connection' }; - const stop = startWatchdog({ pendingAges: () => ownPendingAges('@a:x'), - isVisible: () => true, - isOnline: () => true, + isVisible: () => env.visible, + isOnline: () => env.online, recover: () => reloadToRecoverSync(Date.now(), () => session, reload), }); - // intentionally never settles: the request is hung - sendTracked(mx, '!r:x', content); - expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending' }]); + return { reload, stop }; + }; + const stalled = () => vi.advanceTimersByTime(SEND_STALL_MS + 2 * WATCHDOG_INTERVAL_MS); + + it('is kept in the outbox, reloads the page once after the stall, and each message is resent under its own txn id', async () => { + const mx = hungClient(); + const first = { msgtype: 'm.text', body: 'hello from a dead connection' }; + const second = { msgtype: 'm.text', body: 'and a second one' }; + const { reload, stop } = watch({ visible: true, online: true }); + + sendTracked(mx as never, '!r:x', first); + sendTracked(mx as never, '!q:x', second); + expect(loadOutbox('@a:x')).toMatchObject([ + { txnId: 'txn-1', status: 'pending' }, + { txnId: 'txn-2', status: 'pending' }, + ]); vi.advanceTimersByTime(SEND_STALL_MS - WATCHDOG_INTERVAL_MS); expect(reload).not.toHaveBeenCalled(); @@ -65,11 +79,46 @@ describe('a send that hangs on a dead connection', () => { expect(reload).toHaveBeenCalledTimes(1); stop(); - // the reloaded page: the entry is older than the page, the network works again + // the reloaded page: the entries are older than the page, the network works again const send = vi.fn().mockResolvedValue({ event_id: '$e' }); - const reloaded = { ...mx, sendEvent: send } as never; - await resendInterrupted(reloaded, Date.now() + 1); - expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-1'); + const reloaded = { ...mx, sendEvent: send }; + await resendInterrupted(reloaded as never, Date.now() + 1); + expect(send).toHaveBeenCalledTimes(2); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', first, 'txn-1'); + expect(send).toHaveBeenCalledWith('!q:x', 'm.room.message', second, 'txn-2'); expect(loadOutbox('@a:x')).toEqual([]); }); + + it.each([ + ['hidden', { visible: false, online: true }], + ['offline', { visible: true, online: false }], + ])('does not reload a stalled page while it is %s, and does once it is back', (_name, env) => { + const mx = hungClient(); + const state = { ...env }; + const { reload, stop } = watch(state); + sendTracked(mx as never, '!r:x', { body: 'x' }); + stalled(); + vi.advanceTimersByTime(10 * SEND_STALL_MS); + expect(reload).not.toHaveBeenCalled(); + state.visible = true; + state.online = true; + vi.advanceTimersByTime(2 * WATCHDOG_INTERVAL_MS); + expect(reload).toHaveBeenCalledTimes(1); + stop(); + }); + + it('does not reload for a send that belongs to another tab, or one that already failed', () => { + const mx = hungClient(); + const { reload, stop } = watch({ visible: true, online: true }); + sendTracked(mx as never, '!r:x', { body: 'theirs' }); + sendTracked(mx as never, '!r:x', { body: 'failed' }); + const entries = JSON.parse(storage.get(OUTBOX_KEY) as string); + entries[0].tab = `${PAGE_ID}-other`; + entries[1].status = 'failed'; + storage.set(OUTBOX_KEY, JSON.stringify(entries)); + stalled(); + vi.advanceTimersByTime(10 * SEND_STALL_MS); + expect(reload).not.toHaveBeenCalled(); + stop(); + }); }); From fab6a04894ceff2972ed5dbd75de7b72c94672bd Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:25:46 +0000 Subject: [PATCH 070/143] stallRecovery test: lint clean Co-Authored-By: Claude Sonnet 5.5 --- src/client/stallRecovery.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index 39ebb2b8..148ebb31 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -40,7 +40,12 @@ describe('a send that hangs on a dead connection', () => { n += 1; return `txn-${n}`; }, - sendEvent: vi.fn(() => new Promise(() => undefined)), // never settles: the request is hung + sendEvent: vi.fn( + () => + new Promise(() => { + // never settles: the request is hung + }) + ), getRoom: () => undefined, }; }; From e04c7c074f48a879122466cdec3851e294619f1c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:48:49 +0000 Subject: [PATCH 071/143] stallRecovery test: a held SDK echo makes the resend use a fresh txn id Co-Authored-By: Claude Sonnet 5.5 --- src/client/stallRecovery.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index 148ebb31..ae1e5217 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -94,6 +94,25 @@ describe('a send that hangs on a dead connection', () => { expect(loadOutbox('@a:x')).toEqual([]); }); + it('resends under a fresh txn id when the SDK still holds an echo for the old one', async () => { + const mx = hungClient(); + const content = { msgtype: 'm.text', body: 'echo still held' }; + sendTracked(mx as never, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1' }]); + + const send = vi.fn().mockResolvedValue({ event_id: '$e' }); + const held = { + ...mx, + makeTxnId: () => 'txn-fresh', + sendEvent: send, + getRoom: () => ({ getEventForTxnId: (id: string) => (id === 'txn-1' ? {} : undefined) }), + }; + await resendInterrupted(held as never, Date.now() + 1); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-fresh'); + expect(loadOutbox('@a:x')).toEqual([]); + }); + it.each([ ['hidden', { visible: false, online: true }], ['offline', { visible: true, online: false }], From 0e3115809ea05f64c327170e57e1ebabfa550f39 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 04:02:30 +0000 Subject: [PATCH 072/143] phoneTapTargets: pin the exact PhoneHit variant of each IconButton Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneTapTargets.test.ts | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index b50ec02d..b190e643 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -175,14 +175,24 @@ describe('icon buttons in the room view', () => { return tags; }; - it.each([ - 'features/room/message/Message.tsx', - 'features/room/RoomInput.tsx', - 'features/room/RoomViewHeader.tsx', - ])('%s: every IconButton carries a PhoneHit class', (file) => { - const tags = iconButtonTags(readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8')); - expect(tags.length).toBeGreaterThan(0); - const bare = tags.filter((t) => !/PhoneHit/.test(t)); - expect(bare).toEqual([]); - }); + // The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size + // and whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. + const expected: Record = { + 'features/room/message/Message.tsx': [ + 'PhoneHitSpread32', // quick-reaction emoji buttons + 'PhoneHitSpread32', // delete dialog close + 'PhoneHitSpread32', // report dialog close + ...Array(6).fill('PhoneHitRoom26'), // message options bar, inside a clipping menu + ], + 'features/room/RoomInput.tsx': Array(6).fill('PhoneHitSpread32'), + 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), + }; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => { + const tags = iconButtonTags(readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8')); + expect(tags.map((t) => t.match(/\bPhoneHit\w+/)?.[0] ?? null)).toEqual(want); + } + ); }); From da9ef70c03b336bd1e636ff9a5bb08220a8159a7 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 04:04:45 +0000 Subject: [PATCH 073/143] Consumer tests: startClient wiring, Resend/Discard handlers, the 30 s slow-send threshold Co-Authored-By: Claude Sonnet 5.5 --- .../features/room/RoomOutbox.actions.test.tsx | 94 ++++++++++++++ src/app/features/room/RoomOutbox.test.ts | 5 + src/client/startClient.test.ts | 115 ++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 src/app/features/room/RoomOutbox.actions.test.tsx create mode 100644 src/client/startClient.test.ts diff --git a/src/app/features/room/RoomOutbox.actions.test.tsx b/src/app/features/room/RoomOutbox.actions.test.tsx new file mode 100644 index 00000000..c762220f --- /dev/null +++ b/src/app/features/room/RoomOutbox.actions.test.tsx @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { enqueueOutbox, failOutbox, loadOutbox } from '../../state/room/composerStore'; +import { RoomOutbox } from './RoomOutbox'; + +// The component's hooks are held still (state is its initial value, effects do not run) so the element tree +// it returns can be read and its click handlers called without a DOM. +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal(); + const useState = (init: unknown) => [ + typeof init === 'function' ? (init as () => unknown)() : init, + () => undefined, + ]; + return { + ...actual, + default: { ...actual, useState, useEffect: () => undefined }, + useState, + useEffect: () => undefined, + }; +}); + +type El = { props?: { children?: unknown; onClick?: () => void } }; +const flatten = (node: unknown): El[] => { + if (Array.isArray(node)) return node.flatMap(flatten); + if (node && typeof node === 'object') { + const el = node as El; + return [el, ...flatten(el.props?.children)]; + } + return []; +}; +const textOf = (node: unknown): string => { + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(textOf).join(''); + if (node && typeof node === 'object') return textOf((node as El).props?.children); + return ''; +}; +/** The onClick of the chip whose label is `label`. */ +const clickOf = (tree: unknown, label: string) => { + const chip = flatten(tree).find((e) => e.props?.onClick && textOf(e) === label); + if (!chip?.props?.onClick) throw new Error(`no ${label} chip`); + return chip.props.onClick; +}; + +describe('RoomOutbox actions', () => { + beforeEach(() => { + const m = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }); + enqueueOutbox('@a:x', { txnId: 'keep', roomId: '!r:x', content: { body: 'stay' } }); + enqueueOutbox('@a:x', { txnId: 'bad', roomId: '!r:x', content: { body: 'did not go' } }); + failOutbox('@a:x', 'bad', 'ECONNRESET'); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const mount = () => { + const mx = { + getUserId: () => '@a:x', + makeTxnId: () => 'txn-new', + getRoom: () => undefined, + sendEvent: vi.fn().mockResolvedValue({ event_id: '$e' }), + }; + const tree = (RoomOutbox as unknown as (p: unknown) => unknown)({ mx, roomId: '!r:x' }); + return { mx, tree }; + }; + + it('Discard removes that message from the outbox and no other', () => { + const { mx, tree } = mount(); + // both entries are shown; the failed one is the one with Discard + clickOf(tree, 'Discard')(); + expect(loadOutbox('@a:x').map((e) => e.txnId)).toEqual(['keep']); + expect(mx.sendEvent).not.toHaveBeenCalled(); + }); + + it('Resend sends that message again and does not discard it first', async () => { + const { mx, tree } = mount(); + clickOf(tree, 'Resend')(); + await Promise.resolve(); + expect(mx.sendEvent).toHaveBeenCalledTimes(1); + expect(mx.sendEvent).toHaveBeenCalledWith( + '!r:x', + 'm.room.message', + { body: 'did not go' }, + 'bad' + ); + }); +}); diff --git a/src/app/features/room/RoomOutbox.test.ts b/src/app/features/room/RoomOutbox.test.ts index 55597694..30d7c0bb 100644 --- a/src/app/features/room/RoomOutbox.test.ts +++ b/src/app/features/room/RoomOutbox.test.ts @@ -17,6 +17,11 @@ describe('outboxView', () => { }); }); + it('offers Resend to a pending send at 30 s and not a millisecond before', () => { + const at = (ms: number) => outboxView({ status: 'pending', ts: 100 }, 100 + ms, 0).canResend; + expect([at(29_999), at(30_000)]).toEqual([false, true]); + }); + it('an entry older than this page load reads as interrupted', () => { expect(outboxView({ status: 'pending', ts: 5 }, 6, 10).label).toMatch(/Interrupted/); }); diff --git a/src/client/startClient.test.ts b/src/client/startClient.test.ts new file mode 100644 index 00000000..11b488e3 --- /dev/null +++ b/src/client/startClient.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { enqueueOutbox } from '../app/state/room/composerStore'; +import { PAGE_START } from '../app/state/room/outboxSend'; +import { startClient } from './initMatrix'; +import { startResumeSync } from './resumeSync'; +import { startWatchdog } from './watchdog'; + +vi.mock('./resumeSync', () => ({ startResumeSync: vi.fn() })); +vi.mock('./watchdog', async (importOriginal) => ({ + ...(await importOriginal()), + startWatchdog: vi.fn(() => () => undefined), +})); + +const memoryStore = (m = new Map()) => ({ + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, +}); + +// startClient is the one place the dead-connection pieces are wired to the page: what the watchdog reads, +// when it may fire, what it does, and what runs after the reload. These tests call it and exercise that wiring. +describe('startClient wiring', () => { + let handlers: Array<(state: string) => void>; + let mx: Record & { + sendEvent: ReturnType; + off: ReturnType; + }; + beforeEach(() => { + vi.clearAllMocks(); + handlers = []; + vi.stubGlobal('localStorage', memoryStore()); + mx = { + startClient: vi.fn().mockResolvedValue(undefined), + getUserId: () => '@a:x', + makeTxnId: () => 'txn-new', + getRoom: () => undefined, + sendEvent: vi.fn().mockResolvedValue({ event_id: '$e' }), + on: (_ev: string, h: (state: string) => void) => { + handlers.push(h); + }, + off: vi.fn(), + }; + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const watchdogOptions = () => + (startWatchdog as unknown as ReturnType).mock.calls[0][0] as Parameters< + typeof startWatchdog + >[0]; + + it('starts sync, the resume nudge and the watchdog once per client', async () => { + await startClient(mx as never); + await startClient(mx as never); + expect(mx.startClient).toHaveBeenCalledTimes(2); + expect(startResumeSync).toHaveBeenCalledTimes(1); + expect(startResumeSync).toHaveBeenCalledWith(mx); + expect(startWatchdog).toHaveBeenCalledTimes(1); + }); + + it("feeds the watchdog this page's pending sends for this user", async () => { + await startClient(mx as never); + const { pendingAges } = watchdogOptions(); + expect(pendingAges()).toEqual([]); + enqueueOutbox('@a:x', { txnId: 't', roomId: '!r:x', content: {} }, Date.now() - 7000); + enqueueOutbox('@b:x', { txnId: 'u', roomId: '!r:x', content: {} }, Date.now() - 1000); + const ages = pendingAges(); + expect(ages).toHaveLength(1); + expect(ages[0]).toBeGreaterThanOrEqual(7000); + }); + + it('reads visibility and connectivity from the page', async () => { + const doc = { visibilityState: 'visible' }; + const nav = { onLine: true }; + vi.stubGlobal('document', doc); + vi.stubGlobal('navigator', nav); + await startClient(mx as never); + const { isVisible, isOnline } = watchdogOptions(); + expect([isVisible(), isOnline()]).toEqual([true, true]); + doc.visibilityState = 'hidden'; + nav.onLine = false; + expect([isVisible(), isOnline()]).toEqual([false, false]); + }); + + it('recovers by a guarded reload, at most once a minute', async () => { + const reload = vi.fn(); + vi.stubGlobal('window', { location: { reload } }); + vi.stubGlobal('sessionStorage', memoryStore()); + await startClient(mx as never); + const { recover } = watchdogOptions(); + recover(); + recover(); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('resends what a reload interrupted once sync is up, and only once', async () => { + enqueueOutbox( + '@a:x', + { txnId: 'old', roomId: '!r:x', content: { body: 'x' } }, + PAGE_START - 5000 + ); + await startClient(mx as never); + expect(handlers).toHaveLength(1); + handlers[0]('CONNECTING'); + expect(mx.sendEvent).not.toHaveBeenCalled(); + handlers[0]('SYNCING'); + expect(mx.off).toHaveBeenCalledWith('sync', handlers[0]); + expect(mx.sendEvent).toHaveBeenCalledWith('!r:x', 'm.room.message', { body: 'x' }, 'old'); + }); +}); From bd2d9800235054f27c995c15b377254b63b23d12 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 04:10:46 +0000 Subject: [PATCH 074/143] phoneTapTargets: pin top, left and transform of the hit-area box Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneTapTargets.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index b190e643..d4e31772 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -127,6 +127,10 @@ describe('phone icon-button hit areas', () => { expect(phone.map((m) => m[1])).toEqual([PHONE]); expect(css).toMatch(/::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/); expect(css).toMatch(/::after \{[^}]*position: absolute;/); + // centred on the button: top and left at half, pulled back by half its own size + expect(css).toMatch(/::after \{[^}]*top: 50%;/); + expect(css).toMatch(/::after \{[^}]*left: 50%;/); + expect(css).toMatch(/::after \{[^}]*transform: translate\(-50%, -50%\);/); expect(stripPhone(css).trim()).toBe(''); }); From ae3760fcc79e8bed793e5a806ffb574ed44214e8 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 04:11:15 +0000 Subject: [PATCH 075/143] RoomOutbox actions test: Resend keeps the entry in the outbox, pending again Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomOutbox.actions.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/app/features/room/RoomOutbox.actions.test.tsx b/src/app/features/room/RoomOutbox.actions.test.tsx index c762220f..0dbd4f5d 100644 --- a/src/app/features/room/RoomOutbox.actions.test.tsx +++ b/src/app/features/room/RoomOutbox.actions.test.tsx @@ -81,6 +81,12 @@ describe('RoomOutbox actions', () => { it('Resend sends that message again and does not discard it first', async () => { const { mx, tree } = mount(); + // the server has not answered yet: the resend is in flight + mx.sendEvent.mockReturnValue( + new Promise(() => { + // never settles + }) + ); clickOf(tree, 'Resend')(); await Promise.resolve(); expect(mx.sendEvent).toHaveBeenCalledTimes(1); @@ -90,5 +96,7 @@ describe('RoomOutbox actions', () => { { body: 'did not go' }, 'bad' ); + // still in the outbox, pending again under the same id, until the server confirms it + expect(loadOutbox('@a:x').find((e) => e.txnId === 'bad')).toMatchObject({ status: 'pending' }); }); }); From fc92809eb91192b2f238c6e210840c17108295cc Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 05:40:21 +0000 Subject: [PATCH 076/143] Phone: 44 px hit areas for the chat path's remaining icon buttons; pin variant, import and ::after content MessageEditor, RoomNavItem, MembersDrawer, ReactionViewer, RoomPinMenu, RoomViewTyping, CallChatView and JumpToTime get PhoneHit (alone) or PhoneHitSpread32 (two neighbours in a row). phoneTapTargets.test.ts now lists them, plus PasswordInput, Desk, SkyStateControl and PasswordLoginForm, which had no test, checks each class is imported from styles/PhoneHit.css, and pins the ::after content (a mutant without it passed before). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room-nav/RoomNavItem.tsx | 3 +++ src/app/features/room/CallChatView.tsx | 8 +++++- src/app/features/room/MembersDrawer.tsx | 3 +++ src/app/features/room/RoomViewTyping.tsx | 9 ++++++- .../features/room/jump-to-time/JumpToTime.tsx | 3 ++- .../features/room/message/MessageEditor.tsx | 3 +++ .../room/reaction-viewer/ReactionViewer.tsx | 3 ++- .../room/room-pin-menu/RoomPinMenu.tsx | 4 ++- src/app/phoneTapTargets.test.ts | 26 +++++++++++++++++-- 9 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index e8a349f0..842d7146 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -66,6 +66,7 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '../../hooks/useLivekitSupport'; import { StateEvent } from '../../../types/matrix/room'; import { webRTCSupported } from '../../utils/rtc'; +import { PhoneHitSpread32 } from '../../styles/PhoneHit.css'; type RoomNavItemMenuProps = { room: Room; @@ -229,6 +230,7 @@ function CallChatToggle() { return ( setChat(!chat)} aria-pressed={chat} aria-label="Toggle Chat" @@ -436,6 +438,7 @@ export function RoomNavItem({ } > {(triggerRef) => ( - + )} diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index d9205076..3162eff0 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -59,6 +59,7 @@ import { useSpaceOptionally } from '../../hooks/useSpace'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type MemberDrawerHeaderProps = { room: Room; @@ -87,6 +88,7 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) { > {(triggerRef) => ( setPeopleDrawer(false)} @@ -359,6 +361,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { virtualizer.scrollToOffset(0)} variant="Surface" radii="Pill" diff --git a/src/app/features/room/RoomViewTyping.tsx b/src/app/features/room/RoomViewTyping.tsx index 1142a3a8..9e0266e9 100644 --- a/src/app/features/room/RoomViewTyping.tsx +++ b/src/app/features/room/RoomViewTyping.tsx @@ -10,6 +10,7 @@ import { getMxIdLocalPart } from '../../utils/matrix'; import * as css from './RoomViewTyping.css'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; +import { PhoneHit } from '../../styles/PhoneHit.css'; export type RoomViewTypingProps = { room: Room; @@ -111,7 +112,13 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>( )} - + diff --git a/src/app/features/room/jump-to-time/JumpToTime.tsx b/src/app/features/room/jump-to-time/JumpToTime.tsx index 223c6cf6..4b231856 100644 --- a/src/app/features/room/jump-to-time/JumpToTime.tsx +++ b/src/app/features/room/jump-to-time/JumpToTime.tsx @@ -31,6 +31,7 @@ import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../ import { DatePicker, TimePicker } from '../../../components/time-date'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type JumpToTimeProps = { onCancel: () => void; @@ -108,7 +109,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) { Jump to Time - + diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 2d7f2097..45d79eb7 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -54,6 +54,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room'; import { mobileOrTablet } from '../../../utils/user-agent'; import { useComposingCheck } from '../../../hooks/useComposingCheck'; +import { PhoneHitSpread32 } from '../../../styles/PhoneHit.css'; type MessageEditorProps = { roomId: string; @@ -289,6 +290,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
        ( } > diff --git a/src/app/features/room/reaction-viewer/ReactionViewer.tsx b/src/app/features/room/reaction-viewer/ReactionViewer.tsx index 6c686bc5..b1a674d9 100644 --- a/src/app/features/room/reaction-viewer/ReactionViewer.tsx +++ b/src/app/features/room/reaction-viewer/ReactionViewer.tsx @@ -28,6 +28,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../../hooks/useSpace'; import { getMouseEventCords } from '../../../utils/dom'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export type ReactionViewerProps = { room: Room; @@ -100,7 +101,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>( {`Reacted with :${selectedShortcode}:`} - + diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index 9986849f..53f8d073 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -86,6 +86,7 @@ import { useGetMemberPowerTag, } from '../../../hooks/useMemberPowerTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type PinnedMessageProps = { room: Room; @@ -146,6 +147,7 @@ function PinnedMessage({ {canPinEvent && ( ( Pinned Messages - + diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index d4e31772..be4bceb6 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -126,6 +126,8 @@ describe('phone icon-button hit areas', () => { const phone = [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)]; expect(phone.map((m) => m[1])).toEqual([PHONE]); expect(css).toMatch(/::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/); + // no content, no box: the pseudo-element is not rendered at all + expect(css).toMatch(/::after \{[^}]*content: "";/); expect(css).toMatch(/::after \{[^}]*position: absolute;/); // centred on the button: top and left at half, pulled back by half its own size expect(css).toMatch(/::after \{[^}]*top: 50%;/); @@ -190,13 +192,33 @@ describe('icon buttons in the room view', () => { ], 'features/room/RoomInput.tsx': Array(6).fill('PhoneHitSpread32'), 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), + // The chat path's other buttons. Two neighbours in a row: Spread32. One on its own: the plain PhoneHit. + 'features/room/message/MessageEditor.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // toolbar, emoji + 'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // chat toggle, options + 'features/room/MembersDrawer.tsx': ['PhoneHit', 'PhoneHit'], // close, scroll to top + 'features/room/reaction-viewer/ReactionViewer.tsx': ['PhoneHit'], + 'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHit', 'PhoneHit'], // unpin, close + 'features/room/RoomViewTyping.tsx': ['PhoneHit'], + 'features/room/CallChatView.tsx': ['PhoneHit'], + 'features/room/jump-to-time/JumpToTime.tsx': ['PhoneHit'], + // Buttons that were fixed with #125 and had no test. + 'components/password-input/PasswordInput.tsx': ['PhoneHit'], + 'features/desk/Desk.tsx': ['PhoneHit'], + 'features/scene/SkyStateControl.tsx': ['PhoneHit'], + 'pages/auth/login/PasswordLoginForm.tsx': ['PhoneHit'], }; it.each(Object.entries(expected))( '%s: each IconButton carries its own PhoneHit class', (file, want) => { - const tags = iconButtonTags(readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8')); - expect(tags.map((t) => t.match(/\bPhoneHit\w+/)?.[0] ?? null)).toEqual(want); + const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); + expect(iconButtonTags(src).map((t) => t.match(/\bPhoneHit\w*/)?.[0] ?? null)).toEqual(want); + // the name is the one PhoneHit.css.ts exports, not a local of the same name + [...new Set(want)].forEach((name) => + expect(src).toMatch( + new RegExp(`import \\{[^}]*\\b${name}\\b[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'`) + ) + ); } ); }); From 446306b1e72b8c5103c04c7ffc54df4266650008 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 05:46:21 +0000 Subject: [PATCH 077/143] PhoneHit: Spread24 for the nav row's 24 px buttons, Spread26 for the unpin button; tests pin position, ::after declarations, className and import alias Soul pass on fc92809e: RoomNavItem's buttons are 24 px, not 32 (36 px pitch, hit areas overlapped); the unpin button is 26 px beside the Open chip. Six mutants survived (position: relative, pointer-events, z-index, title for className, aliased import); all now killed. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room-nav/RoomNavItem.tsx | 6 ++--- .../room/room-pin-menu/RoomPinMenu.tsx | 4 ++-- src/app/phoneTapTargets.test.ts | 24 +++++++++++++++---- src/app/styles/PhoneHit.css.ts | 3 ++- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 842d7146..8d53ce7f 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -66,7 +66,7 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '../../hooks/useLivekitSupport'; import { StateEvent } from '../../../types/matrix/room'; import { webRTCSupported } from '../../utils/rtc'; -import { PhoneHitSpread32 } from '../../styles/PhoneHit.css'; +import { PhoneHitSpread24 } from '../../styles/PhoneHit.css'; type RoomNavItemMenuProps = { room: Room; @@ -230,7 +230,7 @@ function CallChatToggle() { return ( setChat(!chat)} aria-pressed={chat} aria-label="Toggle Chat" @@ -438,7 +438,7 @@ export function RoomNavItem({ } > {canPinEvent && ( { expect(css).toMatch(/::after \{[^}]*top: 50%;/); expect(css).toMatch(/::after \{[^}]*left: 50%;/); expect(css).toMatch(/::after \{[^}]*transform: translate\(-50%, -50%\);/); + // the box anchors to its own button, receives the tap, and sits above nothing it should not + expect(css).toMatch(/@media [^{]+ \{\n\s*\.PhoneHit_PhoneHit__\w+ \{\n\s*position: relative;/); + const afterDecls = [...css.matchAll(/::after \{\n([^}]*)\}/g)].flatMap((m) => + m[1] + .split('\n') + .map((l) => l.trim().split(':')[0]) + .filter(Boolean) + ); + expect(afterDecls).not.toContain('pointer-events'); + expect(afterDecls).not.toContain('z-index'); expect(stripPhone(css).trim()).toBe(''); }); @@ -142,7 +152,7 @@ describe('phone icon-button hit areas', () => { rulesFor(css, selector, PHONE).map((d) => d['margin-inline']); // 32 px composer buttons, 40 px header buttons, 26 px message-option buttons (which also open vertically, // the menu around them clips a hit area drawn outside its box) - expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([9, 6, 2])); + expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([10, 9, 6, 2])); expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]); }); @@ -194,10 +204,10 @@ describe('icon buttons in the room view', () => { 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), // The chat path's other buttons. Two neighbours in a row: Spread32. One on its own: the plain PhoneHit. 'features/room/message/MessageEditor.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // toolbar, emoji - 'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // chat toggle, options + 'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread24', 'PhoneHitSpread24'], // chat toggle, options: 16 px icon + 8 padding = 24 px, 0 gap 'features/room/MembersDrawer.tsx': ['PhoneHit', 'PhoneHit'], // close, scroll to top 'features/room/reaction-viewer/ReactionViewer.tsx': ['PhoneHit'], - 'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHit', 'PhoneHit'], // unpin, close + 'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHitSpread26', 'PhoneHit'], // unpin (26 px, 8 px from the Open chip), close 'features/room/RoomViewTyping.tsx': ['PhoneHit'], 'features/room/CallChatView.tsx': ['PhoneHit'], 'features/room/jump-to-time/JumpToTime.tsx': ['PhoneHit'], @@ -212,11 +222,15 @@ describe('icon buttons in the room view', () => { '%s: each IconButton carries its own PhoneHit class', (file, want) => { const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); - expect(iconButtonTags(src).map((t) => t.match(/\bPhoneHit\w*/)?.[0] ?? null)).toEqual(want); + expect( + iconButtonTags(src).map((t) => t.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null) + ).toEqual(want); // the name is the one PhoneHit.css.ts exports, not a local of the same name [...new Set(want)].forEach((name) => expect(src).toMatch( - new RegExp(`import \\{[^}]*\\b${name}\\b[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'`) + new RegExp( + `import \\{[^}]*\\b${name}\\b(?!\\s+as\\b)[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'` + ) ) ); } diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 14ff375c..4ebc8370 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -33,7 +33,8 @@ export const PhoneHit = style({ const spread = (visual: number) => style([PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }]); -const PhoneHitSpread26 = spread(26); +export const PhoneHitSpread24 = spread(24); +export const PhoneHitSpread26 = spread(26); export const PhoneHitSpread32 = spread(32); export const PhoneHitSpread40 = spread(40); From 0c8474231e778da10b2c6cdb0a258e91fffba902 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 05:50:10 +0000 Subject: [PATCH 078/143] phoneTapTargets: each Spread class owns its margin, ::after declaration set is exact, no phone rule moves position, import must not be aliased Soul re-gate of 446306b1: swapped Spread24/26 definitions, display:none or overflow on the ::after, position:static on a Spread rule and an aliased import all passed; all killed now. Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneTapTargets.test.ts | 22 ++++++++++++++++++---- src/app/styles/PhoneHit.css.ts | 5 ++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index cf7a6b27..a6459ade 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -141,8 +141,20 @@ describe('phone icon-button hit areas', () => { .map((l) => l.trim().split(':')[0]) .filter(Boolean) ); - expect(afterDecls).not.toContain('pointer-events'); - expect(afterDecls).not.toContain('z-index'); + expect([...afterDecls].sort()).toEqual([ + 'content', + 'height', + 'left', + 'position', + 'top', + 'transform', + 'width', + ]); + // nothing on a phone moves a button off its own anchor: only relative (the button) and absolute (the box) + expect([...css.matchAll(/position: (\w+);/g)].map((m) => m[1]).sort()).toEqual([ + 'absolute', + 'relative', + ]); expect(stripPhone(css).trim()).toBe(''); }); @@ -152,7 +164,9 @@ describe('phone icon-button hit areas', () => { rulesFor(css, selector, PHONE).map((d) => d['margin-inline']); // 32 px composer buttons, 40 px header buttons, 26 px message-option buttons (which also open vertically, // the menu around them clips a hit area drawn outside its box) - expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([10, 9, 6, 2])); + // each class owns its own margin: (44 - drawn size) / 2 + const own = (size: number) => margins(new RegExp(`PhoneHit_PhoneHitSpread${size}__`)); + expect([own(24), own(26), own(32), own(40)]).toEqual([[10], [9], [6], [2]]); expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]); }); @@ -229,7 +243,7 @@ describe('icon buttons in the room view', () => { [...new Set(want)].forEach((name) => expect(src).toMatch( new RegExp( - `import \\{[^}]*\\b${name}\\b(?!\\s+as\\b)[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'` + `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` ) ) ); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 4ebc8370..8403c126 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -31,7 +31,10 @@ export const PhoneHit = style({ /** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */ const spread = (visual: number) => - style([PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }]); + style( + [PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }], + `PhoneHitSpread${visual}` + ); export const PhoneHitSpread24 = spread(24); export const PhoneHitSpread26 = spread(26); From c45cdc7bd7bca9729c7d565b3d84ab8ec97c0521 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:52:32 +0000 Subject: [PATCH 079/143] buildSha: BUILD_SHA no longer masks a dirty tree; the vite temp-file exclusion is untracked-only The pathspec exclude hid a tracked file of that name; the status lines are filtered instead. Tests against a real repo: explicit sha plus stray file, stray file beside the temp file in root and subfolder, tracked file of the temp name, new folder. Six mutants of the logic each fail a test. Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 3 +- scripts/buildSha.js | 22 +++++++---- scripts/buildSha.test.js | 56 +++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index d89541da..03bd3adc 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -48,7 +48,8 @@ not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`. The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and the node and npm versions, is what the install request names. Every build stamps its commit into `index.html` (`scripts/buildSha.js`): ``, -with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. The handoff +with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. An explicit `BUILD_SHA` names the commit where there is no git; where git +sees a changed tree it is suffixed `-dirty` too, so it never vouches for code it does not hold. The handoff build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block. To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`. diff --git a/scripts/buildSha.js b/scripts/buildSha.js index d6c4870f..fc025643 100644 --- a/scripts/buildSha.js +++ b/scripts/buildSha.js @@ -1,7 +1,8 @@ // The commit a bundle was built from, stamped into its index.html as , so a built // folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`. -// BUILD_SHA wins when it is a full sha (a build from a source archive has no git); otherwise git's HEAD, -// suffixed -dirty when the tree has uncommitted changes; otherwise 'unknown'. +// BUILD_SHA names the commit when it is a full sha (a build from a source archive has no git), otherwise git's +// HEAD does; either way -dirty is appended when git can see uncommitted changes, so an explicit sha never +// vouches for a tree that differs from it. No git and no BUILD_SHA: 'unknown'. import { execFileSync } from 'child_process'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; @@ -9,8 +10,9 @@ import { fileURLToPath } from 'url'; const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); // Vite writes vite.config.js.timestamp-*.mjs next to the config while it loads it; that file is the -// build's own, not source, so it must not mark the tree dirty. Any other untracked file still does. -const VITE_TEMP = ':(exclude,glob)**/vite.config.*.timestamp-*.mjs'; +// build's own, not source, so an UNTRACKED one must not mark the tree dirty. A tracked file of that name, +// or any other change, still does. Filtering status lines (not a pathspec) is what keeps it untracked-only. +const VITE_TEMP = /^\?\? (?:.*\/)?vite\.config\.[^/]*\.timestamp-[^/]*\.mjs$/; export const gitIn = (cwd) => @@ -19,13 +21,17 @@ export const gitIn = export const readBuildSha = (env = process.env, run = gitIn(REPO_DIR)) => { const fromEnv = (env.BUILD_SHA ?? '').trim(); - if (/^[0-9a-f]{40}$/.test(fromEnv)) return fromEnv; + const explicit = /^[0-9a-f]{40}$/.test(fromEnv) ? fromEnv : null; try { const head = run('rev-parse', 'HEAD').trim(); - if (!/^[0-9a-f]{40}$/.test(head)) return 'unknown'; - return run('status', '--porcelain', '--', '.', VITE_TEMP).trim() === '' ? head : `${head}-dirty`; + const sha = explicit ?? head; + if (!/^[0-9a-f]{40}$/.test(sha)) return 'unknown'; + const dirty = run('status', '--porcelain', '-uall', '--', '.') + .split('\n') + .some((l) => l.trim() !== '' && !VITE_TEMP.test(l)); + return dirty ? `${sha}-dirty` : sha; } catch { - return 'unknown'; + return explicit ?? 'unknown'; } }; diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js index c45c8747..b7bd85e7 100644 --- a/scripts/buildSha.test.js +++ b/scripts/buildSha.test.js @@ -23,6 +23,8 @@ describe('readBuildSha', () => { throw new Error('not a git repository'); }; expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA); + // an explicit sha with a clean tree is just itself + expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(SHA); expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown'); }); it("says 'unknown' rather than failing the build when git is absent", () => { @@ -60,6 +62,60 @@ describe('readBuildSha against a real repository', () => { }); }); +describe('readBuildSha dirty rules against a real repository', () => { + const made2 = []; + afterEach(() => { + made2.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true })); + }); + const TEMP = 'vite.config.js.timestamp-1700000000-abc123.mjs'; + const repo2 = (tracked = []) => { + const dir = mkdtempSync(join(tmpdir(), 'buildsha-')); + made2.push(dir); + const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' }); + g('init', '-q'); + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n'); + tracked.forEach((f) => writeFileSync(join(dir, f), 'x\n')); + g('add', '.'); + g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x'); + return { + dir, + head: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(), + }; + }; + it('does not let an explicit BUILD_SHA mask a dirty tree', () => { + const { dir } = repo2(); + const run = gitIn(dir); + expect(readBuildSha({ BUILD_SHA: SHA }, run)).toBe(SHA); + writeFileSync(join(dir, 'src', 'zz-stray.ts'), ''); + expect(readBuildSha({ BUILD_SHA: SHA }, run)).toBe(`${SHA}-dirty`); + }); + it('treats a stray source file as dirty even beside the vite temp file, in the root or a subfolder', () => { + const { dir, head } = repo2(); + const run = gitIn(dir); + writeFileSync(join(dir, TEMP), ''); + writeFileSync(join(dir, 'src', TEMP), ''); + expect(readBuildSha({}, run)).toBe(head); + writeFileSync(join(dir, 'src', 'zz-stray.ts'), ''); + expect(readBuildSha({}, run)).toBe(`${head}-dirty`); + }); + it('marks a tracked file of the temp name dirty once it changes', () => { + const { dir, head } = repo2([TEMP]); + const run = gitIn(dir); + expect(readBuildSha({}, run)).toBe(head); + writeFileSync(join(dir, TEMP), 'changed\n'); + expect(readBuildSha({}, run)).toBe(`${head}-dirty`); + }); + it('marks an untracked file in a new folder dirty (status lists files, not the folder)', () => { + const { dir, head } = repo2(); + mkdirSync(join(dir, 'new')); + writeFileSync(join(dir, 'new', TEMP), ''); + expect(readBuildSha({}, gitIn(dir))).toBe(head); + writeFileSync(join(dir, 'new', 'b.ts'), ''); + expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`); + }); +}); + describe('buildShaPlugin', () => { it("hands vite a meta tag for the page's head carrying the sha", () => { const plugin = buildShaPlugin(SHA); From c82193f0fb7613537938f9fdf3aced2d968cb411 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:56:27 +0000 Subject: [PATCH 080/143] buildSha: git speaks only for a tree it roots, mismatch and git failure fail closed, temp-file name matched exactly Soul found: explicit sha stamped clean over a different HEAD; status failure returned the bare explicit sha; an archive inside another repo got that repo's verdict; the temp regex let any untracked .mjs pass. Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 3 +- scripts/buildSha.js | 31 +++++++++----- scripts/buildSha.test.js | 58 +++++++++++++++++++++++---- 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index 03bd3adc..e2215d61 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -48,8 +48,7 @@ not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`. The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and the node and npm versions, is what the install request names. Every build stamps its commit into `index.html` (`scripts/buildSha.js`): ``, -with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. An explicit `BUILD_SHA` names the commit where there is no git; where git -sees a changed tree it is suffixed `-dirty` too, so it never vouches for code it does not hold. The handoff +with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive). The handoff build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block. To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`. diff --git a/scripts/buildSha.js b/scripts/buildSha.js index fc025643..61a89445 100644 --- a/scripts/buildSha.js +++ b/scripts/buildSha.js @@ -1,8 +1,9 @@ // The commit a bundle was built from, stamped into its index.html as , so a built // folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`. -// BUILD_SHA names the commit when it is a full sha (a build from a source archive has no git), otherwise git's -// HEAD does; either way -dirty is appended when git can see uncommitted changes, so an explicit sha never -// vouches for a tree that differs from it. No git and no BUILD_SHA: 'unknown'. +// Where git governs the tree (REPO_DIR is a repository root) git decides: HEAD, suffixed -dirty when the tree has +// uncommitted changes, or `-mismatch` when an explicit BUILD_SHA names a different commit, so a stamp +// never vouches for code it does not hold; a git failure there is 'unknown'. Where git does not govern (a source +// archive), a full-sha BUILD_SHA stands, else 'unknown'. import { execFileSync } from 'child_process'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; @@ -12,26 +13,38 @@ const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); // Vite writes vite.config.js.timestamp-*.mjs next to the config while it loads it; that file is the // build's own, not source, so an UNTRACKED one must not mark the tree dirty. A tracked file of that name, // or any other change, still does. Filtering status lines (not a pathspec) is what keeps it untracked-only. -const VITE_TEMP = /^\?\? (?:.*\/)?vite\.config\.[^/]*\.timestamp-[^/]*\.mjs$/; +const VITE_TEMP = /^\?\? (?:.*\/)?vite\.config\.[a-z]+\.timestamp-\d+-[0-9a-f]+\.mjs$/; export const gitIn = (cwd) => (...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); +const FULL_SHA = /^[0-9a-f]{40}$/; + export const readBuildSha = (env = process.env, run = gitIn(REPO_DIR)) => { const fromEnv = (env.BUILD_SHA ?? '').trim(); - const explicit = /^[0-9a-f]{40}$/.test(fromEnv) ? fromEnv : null; + const explicit = FULL_SHA.test(fromEnv) ? fromEnv : null; + // Git governs this tree only when REPO_DIR is its root: a source archive unpacked inside some other + // repository has no git of its own, and that repository's HEAD says nothing about it. + let governed; + try { + governed = run('rev-parse', '--show-prefix').trim() === ''; + } catch { + governed = false; + } + if (!governed) return explicit ?? 'unknown'; + // Git governs and answers: from here a failure is 'unknown', never the bare explicit sha. try { const head = run('rev-parse', 'HEAD').trim(); - const sha = explicit ?? head; - if (!/^[0-9a-f]{40}$/.test(sha)) return 'unknown'; + if (!FULL_SHA.test(head)) return 'unknown'; + if (explicit && explicit !== head) return `${explicit}-mismatch`; const dirty = run('status', '--porcelain', '-uall', '--', '.') .split('\n') .some((l) => l.trim() !== '' && !VITE_TEMP.test(l)); - return dirty ? `${sha}-dirty` : sha; + return dirty ? `${head}-dirty` : head; } catch { - return explicit ?? 'unknown'; + return 'unknown'; } }; diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js index b7bd85e7..4a270867 100644 --- a/scripts/buildSha.test.js +++ b/scripts/buildSha.test.js @@ -8,8 +8,8 @@ import { buildShaPlugin, gitIn, readBuildSha } from './buildSha'; const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e'; const gitAt = (head, status = '') => - (cmd) => - cmd === 'rev-parse' ? `${head}\n` : status; + (cmd, ...args) => + cmd === 'rev-parse' ? (args[0] === '--show-prefix' ? '\n' : `${head}\n`) : status; describe('readBuildSha', () => { it('reads the checkout it is built from', () => { @@ -23,8 +23,25 @@ describe('readBuildSha', () => { throw new Error('not a git repository'); }; expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA); - // an explicit sha with a clean tree is just itself - expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(SHA); + expect(readBuildSha({ BUILD_SHA: SHA }, gitAt(SHA))).toBe(SHA); + // a different HEAD is a mismatch, not a clean stamp of the explicit sha + expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(`${SHA}-mismatch`); + // a value that is not a lowercase full sha is ignored + ['b6b1d52', SHA.toUpperCase(), `${SHA}0`].forEach((v) => + expect(readBuildSha({ BUILD_SHA: v }, noGit)).toBe('unknown') + ); + // git that answers but cannot say what the tree holds fails closed + const brokenStatus = (cmd, ...args) => { + if (cmd === 'status') throw new Error('index file smaller than expected'); + return args[0] === '--show-prefix' ? '\n' : `${SHA}\n`; + }; + expect(readBuildSha({ BUILD_SHA: SHA }, brokenStatus)).toBe('unknown'); + expect(readBuildSha({}, brokenStatus)).toBe('unknown'); + expect(readBuildSha({}, gitAt('nope'))).toBe('unknown'); + // inside another repository's subfolder git does not govern: the explicit sha stands + const nested = (cmd, ...args) => (args[0] === '--show-prefix' ? 'vendor/ratatoskr/\n' : SHA); + expect(readBuildSha({ BUILD_SHA: SHA }, nested)).toBe(SHA); + expect(readBuildSha({}, nested)).toBe('unknown'); expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown'); }); it("says 'unknown' rather than failing the build when git is absent", () => { @@ -84,11 +101,38 @@ describe('readBuildSha dirty rules against a real repository', () => { }; }; it('does not let an explicit BUILD_SHA mask a dirty tree', () => { - const { dir } = repo2(); + const { dir, head } = repo2(); const run = gitIn(dir); - expect(readBuildSha({ BUILD_SHA: SHA }, run)).toBe(SHA); + expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(head); writeFileSync(join(dir, 'src', 'zz-stray.ts'), ''); - expect(readBuildSha({ BUILD_SHA: SHA }, run)).toBe(`${SHA}-dirty`); + expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(`${head}-dirty`); + }); + it('lets git speak only for a tree whose root it is: an archive unpacked inside another repository keeps its explicit sha', () => { + const { dir } = repo2(); + const inner = join(dir, 'vendor', 'ratatoskr'); + mkdirSync(inner, { recursive: true }); + writeFileSync(join(inner, 'x.ts'), ''); + expect(readBuildSha({ BUILD_SHA: SHA }, gitIn(inner))).toBe(SHA); + expect(readBuildSha({}, gitIn(inner))).toBe('unknown'); + }); + it("notices an untracked .mjs that is not vite's temp file", () => { + const { dir, head } = repo2(); + writeFileSync(join(dir, 'foo.mjs'), ''); + expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`); + rmSync(join(dir, 'foo.mjs')); + // only vite's exact shape is its own: vite.config..timestamp--.mjs + [ + 'vite.config.js.timestamp-1-ab.mjs.bak', + 'vite.config.js.timestamp-x-ab.mjs', + 'vite.config.js.timestamp-1-zz.mjs', + 'vite.config.a.b.timestamp-1-ab.mjs', + 'vite.config.js.timestamp-1-ab-c.mjs', + ].forEach((name) => { + writeFileSync(join(dir, name), ''); + expect(readBuildSha({}, gitIn(dir)), name).toBe(`${head}-dirty`); + rmSync(join(dir, name)); + }); + expect(readBuildSha({}, gitIn(dir))).toBe(head); }); it('treats a stray source file as dirty even beside the vite temp file, in the root or a subfolder', () => { const { dir, head } = repo2(); From eff894a89c15bf347ac374476715bc3577296495 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:57:45 +0000 Subject: [PATCH 081/143] docs: BUILD_SHA also stands where git cannot be read Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index e2215d61..b2505fe1 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -48,7 +48,7 @@ not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`. The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and the node and npm versions, is what the install request names. Every build stamps its commit into `index.html` (`scripts/buildSha.js`): ``, -with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive). The handoff +with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive) or cannot read (unusable git, for example a dubious-ownership refusal). The handoff build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block. To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`. From 2b019a430217898afb0cd82a82414186ab2ce6e0 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:58:48 +0000 Subject: [PATCH 082/143] Phone hit: Spread28, Spread36 and Room36 variants Co-Authored-By: Claude Sonnet 5.5 --- src/app/styles/PhoneHit.css.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 8403c126..c388e5f6 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -38,12 +38,18 @@ const spread = (visual: number) => export const PhoneHitSpread24 = spread(24); export const PhoneHitSpread26 = spread(26); +export const PhoneHitSpread28 = spread(28); export const PhoneHitSpread32 = spread(32); +export const PhoneHitSpread36 = spread(36); export const PhoneHitSpread40 = spread(40); /** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row * opens up on both axes, so the 44 px sits inside the container. */ -export const PhoneHitRoom26 = style([ - PhoneHitSpread26, - { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - 26) / 2 } } }, -]); +const room = (visual: number, spreadClass: string) => + style( + [spreadClass, { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }], + `PhoneHitRoom${visual}` + ); + +export const PhoneHitRoom26 = room(26, PhoneHitSpread26); +export const PhoneHitRoom36 = room(36, PhoneHitSpread36); From d917ccd3bba89d078ed9e5e9253fc2679c6231bf Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:59:50 +0000 Subject: [PATCH 083/143] Phone hit tests: shared kit, size tie, pinned new variants The helpers moved to phoneHitTestKit.ts so each later group adds its own phoneHit..test.ts. A new check ties PhoneHitSpread/Room to the px the button draws (IconButton size padding + Icon size); a size the source does not state needs an explicit allow-list entry. It found RoomInput's reply-cancel (16 px icon, 24 px button) carrying Spread32; it is Spread24 now. Room takes its inline margin from the Spread class of the same size, so the two axes cannot disagree. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomInput.tsx | 4 +- src/app/phoneHitTestKit.ts | 161 ++++++++++++++++++++++++++++ src/app/phoneTapTargets.test.ts | 100 +++++------------ src/app/styles/PhoneHit.css.ts | 23 ++-- 4 files changed, 205 insertions(+), 83 deletions(-) create mode 100644 src/app/phoneHitTestKit.ts diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 41f67d03..2d63cbf1 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -116,7 +116,7 @@ import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useComposingCheck } from '../../hooks/useComposingCheck'; -import { PhoneHitSpread32 } from '../../styles/PhoneHit.css'; +import { PhoneHitSpread24, PhoneHitSpread32 } from '../../styles/PhoneHit.css'; interface RoomInputProps { editor: Editor; @@ -606,7 +606,7 @@ export const RoomInput = forwardRef( style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} > setReplyDraft(undefined)} variant="SurfaceVariant" size="300" diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts new file mode 100644 index 00000000..bcd81540 --- /dev/null +++ b/src/app/phoneHitTestKit.ts @@ -0,0 +1,161 @@ +// Shared helpers for the phone hit-area tests (phoneTapTargets.test.ts and one phoneHit..test.ts per later +// group of IconButtons). Not a test file: it registers nothing, the test files call it. +import { readFileSync } from 'fs'; +import { expect } from 'vitest'; +import zlib from 'zlib'; +import { compile, processVanillaFile } from '@vanilla-extract/integration'; + +// The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does; +// vanilla-extract gzips the inlined source only when it is large, so both forms are read). +export const emitted = async (file: string) => { + const filePath = `${process.cwd()}/src/app/${file}`; + const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); + const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); + // A file with recipes inlines one source per rule group; read them all. + return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)] + .map((m) => { + const bytes = Buffer.from(m[1], 'base64'); + return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); + }) + .join('\n'); +}; + +export type Decls = Record; +const parse = (body: string): Decls => + Object.fromEntries( + body + .split('\n') + .map((l) => l.trim().replace(/;$/, '')) + .filter((l) => /^[a-z-]+: -?\d+px$/.test(l)) + .map((l) => [l.split(':')[0], Number(l.split(': ')[1].replace('px', ''))]) + ); + +/** Pixel declarations of the rules whose selector matches, inside or outside a media query. */ +export const rulesFor = (css: string, selector: RegExp, media: string | undefined): Decls[] => { + const scope = media + ? [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)] + .filter((m) => m[1] === media) + .map((m) => m[2]) + .join('\n') + : css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, ''); + return [...scope.matchAll(/^\s*([^{}\n]+) \{\n([^}]*)\}/gm)] + .filter((m) => selector.test(m[1])) + .map((m) => parse(m[2])); +}; + +/** The source of a file under src/app. */ +export const readSource = (file: string) => + readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); + +/** The end of a JSX opening tag starting at `from`: its closing > outside any {} expression. */ +const tagEnd = (src: string, from: number) => { + let depth = 0; + let j = from; + for (; j < src.length; j += 1) { + if (src[j] === '{') depth += 1; + else if (src[j] === '}') depth -= 1; + else if (src[j] === '>' && depth === 0) break; + } + return j; +}; + +/** The opening tag of every outside any {} expression. */ +export const iconButtonTags = (src: string) => { + const tags: string[] = []; + for (let i = src.indexOf(' { + const out: { tag: string; body: string }[] = []; + for (let i = src.indexOf('', end); + out.push({ tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) }); + } + return out; +}; + +const phoneHitOf = (tag: string) => tag.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null; + +/** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and + * whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks + * the name is the one PhoneHit.css.ts exports, not a local of the same name. */ +export const expectPhoneHitClasses = (file: string, want: string[]) => { + const src = readSource(file); + expect(iconButtonTags(src).map(phoneHitOf)).toEqual(want); + [...new Set(want)].forEach((name) => + expect(src).toMatch( + new RegExp( + `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` + ) + ) + ); +}; + +// IconButton `size` -> padding on each side, Icon `size` -> px (folds 2.7.2). Both default to 400. +const BUTTON_PAD: Record = { '300': 4, '400': 8, '500': 12, '600': 16 }; +const ICON_PX: Record = { + '50': 16, + '100': 18, + '200': 20, + '300': 22, + '400': 24, + '500': 28, +}; + +/** The size prop of a tag: its literal value, the default when absent, or undefined when it is an expression. */ +const sizeProp = (tag: string) => { + const m = tag.match(/\ssize=(?:"(\w+)"|\{([^}]*)\})/); + if (!m) return '400'; + return m[1] ?? m[2].match(/^'(\w+)'$/)?.[1]; +}; + +/** The px an IconButton draws, from its size prop and the Icon inside; undefined with a reason when the source + * does not say (a variable size, no Icon, Icons of different sizes). */ +export const drawnPx = (tag: string, body: string): { px: number } | { undetermined: string } => { + const pad = BUTTON_PAD[sizeProp(tag) ?? '']; + if (pad === undefined) return { undetermined: 'size of the IconButton is not a literal' }; + const icons = [...body.matchAll(/ { + const t = body.slice(m.index, tagEnd(body, m.index ?? 0) + 1); + return ICON_PX[sizeProp(t) ?? '']; + }); + if (icons.length === 0) return { undetermined: 'no inside' }; + if (icons.some((p) => p === undefined)) + return { undetermined: 'size of an Icon is not a literal' }; + if (new Set(icons).size > 1) return { undetermined: 'Icons of different sizes inside' }; + return { px: icons[0] + 2 * pad }; +}; + +/** Per file, per IconButton index (source order): why its drawn size cannot be derived from the source. */ +export type SizeAllow = Record>; + +/** A button carrying PhoneHitSpread or PhoneHitRoom draws N px. Plain PhoneHit has no number to tie. An + * undetermined size passes only with an entry in `allow`, and an entry for a size that can be derived fails. */ +export const expectSizeTie = (file: string, allow: SizeAllow = {}) => { + const allowed = allow[file] ?? {}; + const seen = new Set(); + iconButtons(readSource(file)).forEach(({ tag, body }, i) => { + const variant = phoneHitOf(tag)?.match(/^PhoneHit(?:Spread|Room)(\d+)$/); + if (!variant) { + expect(allowed[i], `${file} #${i} is allow-listed but has no sized variant`).toBeUndefined(); + return; + } + const drawn = drawnPx(tag, body); + if ('undetermined' in drawn) { + expect( + allowed[i], + `${file} #${i}: ${drawn.undetermined}; add it to the allow-list` + ).toBeDefined(); + seen.add(i); + return; + } + expect(allowed[i], `${file} #${i} is allow-listed but its size is derivable`).toBeUndefined(); + expect(Number(variant[1]), `${file} #${i} draws ${drawn.px} px`).toBe(drawn.px); + }); + expect(Object.keys(allowed).map(Number).sort()).toEqual([...seen].sort()); +}; diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index a6459ade..f2166cad 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -1,46 +1,12 @@ -import { readFileSync } from 'fs'; import { describe, expect, it } from 'vitest'; -import zlib from 'zlib'; -import { compile, processVanillaFile } from '@vanilla-extract/integration'; import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; - -// The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does; -// vanilla-extract gzips the inlined source only when it is large, so both forms are read). -const emitted = async (file: string) => { - const filePath = `${process.cwd()}/src/app/${file}`; - const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); - const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); - // A file with recipes inlines one source per rule group; read them all. - return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)] - .map((m) => { - const bytes = Buffer.from(m[1], 'base64'); - return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); - }) - .join('\n'); -}; - -type Decls = Record; -const parse = (body: string): Decls => - Object.fromEntries( - body - .split('\n') - .map((l) => l.trim().replace(/;$/, '')) - .filter((l) => /^[a-z-]+: -?\d+px$/.test(l)) - .map((l) => [l.split(':')[0], Number(l.split(': ')[1].replace('px', ''))]) - ); - -/** Pixel declarations of the rules whose selector matches, inside or outside a media query. */ -const rulesFor = (css: string, selector: RegExp, media: string | undefined): Decls[] => { - const scope = media - ? [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)] - .filter((m) => m[1] === media) - .map((m) => m[2]) - .join('\n') - : css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, ''); - return [...scope.matchAll(/^\s*([^{}\n]+) \{\n([^}]*)\}/gm)] - .filter((m) => selector.test(m[1])) - .map((m) => parse(m[2])); -}; +import { + emitted, + expectPhoneHitClasses, + expectSizeTie, + rulesFor, + type SizeAllow, +} from './phoneHitTestKit'; const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; const TAP = 44; @@ -166,8 +132,11 @@ describe('phone icon-button hit areas', () => { // the menu around them clips a hit area drawn outside its box) // each class owns its own margin: (44 - drawn size) / 2 const own = (size: number) => margins(new RegExp(`PhoneHit_PhoneHitSpread${size}__`)); - expect([own(24), own(26), own(32), own(40)]).toEqual([[10], [9], [6], [2]]); - expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]); + expect([24, 26, 28, 32, 36, 40].map(own)).toEqual([[10], [9], [8], [6], [4], [2]]); + // the Room variants open the block axis by the same amount, on top of their Spread class + const room = (size: number) => + rulesFor(css, new RegExp(`PhoneHit_PhoneHitRoom${size}__`), PHONE); + expect([room(26), room(36)]).toEqual([[{ 'margin-block': 9 }], [{ 'margin-block': 4 }]]); }); it("make a message's reaction chip 44 px to tap by opening 9 px above and below it", async () => { @@ -189,22 +158,6 @@ describe('phone icon-button hit areas', () => { }); describe('icon buttons in the room view', () => { - // The opening tag of every outside any {} expression. - const iconButtonTags = (src: string) => { - const tags: string[] = []; - for (let i = src.indexOf('' && depth === 0) break; - } - tags.push(src.slice(i, j + 1)); - } - return tags; - }; - // The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size // and whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. const expected: Record = { @@ -214,7 +167,7 @@ describe('icon buttons in the room view', () => { 'PhoneHitSpread32', // report dialog close ...Array(6).fill('PhoneHitRoom26'), // message options bar, inside a clipping menu ], - 'features/room/RoomInput.tsx': Array(6).fill('PhoneHitSpread32'), + 'features/room/RoomInput.tsx': ['PhoneHitSpread24', ...Array(5).fill('PhoneHitSpread32')], // reply cancel is a 16 px icon, the rest 24 px 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), // The chat path's other buttons. Two neighbours in a row: Spread32. One on its own: the plain PhoneHit. 'features/room/message/MessageEditor.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // toolbar, emoji @@ -232,21 +185,20 @@ describe('icon buttons in the room view', () => { 'pages/auth/login/PasswordLoginForm.tsx': ['PhoneHit'], }; + // IconButtons whose drawn size the source does not say (file -> index in source order -> why). Everything else + // is derived from the size props, so a Spread32 on a 24 px button fails. + const sizeAllow: SizeAllow = { + 'features/room/message/Message.tsx': { + 0: 'quick-reaction button wraps an emoji , no Icon; 32 px by its CSS class', + }, + }; + it.each(Object.entries(expected))( '%s: each IconButton carries its own PhoneHit class', - (file, want) => { - const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); - expect( - iconButtonTags(src).map((t) => t.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null) - ).toEqual(want); - // the name is the one PhoneHit.css.ts exports, not a local of the same name - [...new Set(want)].forEach((name) => - expect(src).toMatch( - new RegExp( - `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` - ) - ) - ); - } + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: each sized variant equals the px its button draws', (file) => + expectSizeTie(file, sizeAllow) ); }); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index c388e5f6..189e58d1 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -29,12 +29,17 @@ export const PhoneHit = style({ selectors: { '&::after': hitArea }, }); +const spreads = new Map(); + /** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */ -const spread = (visual: number) => - style( +const spread = (visual: number) => { + const cls = style( [PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }], `PhoneHitSpread${visual}` ); + spreads.set(visual, cls); + return cls; +}; export const PhoneHitSpread24 = spread(24); export const PhoneHitSpread26 = spread(26); @@ -44,12 +49,16 @@ export const PhoneHitSpread36 = spread(36); export const PhoneHitSpread40 = spread(40); /** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row - * opens up on both axes, so the 44 px sits inside the container. */ -const room = (visual: number, spreadClass: string) => + * opens up on both axes, so the 44 px sits inside the container. It is the Spread class of the same size plus the + * block margin, so the two axes cannot disagree about the size. */ +const room = (visual: number) => style( - [spreadClass, { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }], + [ + spreads.get(visual) ?? '', + { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }, + ], `PhoneHitRoom${visual}` ); -export const PhoneHitRoom26 = room(26, PhoneHitSpread26); -export const PhoneHitRoom36 = room(36, PhoneHitSpread36); +export const PhoneHitRoom26 = room(26); +export const PhoneHitRoom36 = room(36); From fe12d8a4b11b9e0862b4c25031939a85c78c277c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:05:58 +0000 Subject: [PATCH 084/143] Phone hit kit: pin Room composition, spread props are undetermined, tighter tag match Room throws when no Spread of its size exists, and a test reads the compiled class lists to prove Room composes Spread and PhoneHit. Props spread into an IconButton or Icon make the size undetermined (allow-list entry required). or /. iconButtonTags derives from iconButtons. expectSizeTie fails on a file with no IconButton. Fixture tests live in phoneHitTestKit.test.ts. Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneHitTestKit.test.ts | 41 ++++++++++++++++++++++ src/app/phoneHitTestKit.ts | 60 +++++++++++++++++++-------------- src/app/phoneTapTargets.test.ts | 15 +++++++++ src/app/styles/PhoneHit.css.ts | 12 +++---- 4 files changed, 97 insertions(+), 31 deletions(-) create mode 100644 src/app/phoneHitTestKit.test.ts diff --git a/src/app/phoneHitTestKit.test.ts b/src/app/phoneHitTestKit.test.ts new file mode 100644 index 00000000..92f1b23f --- /dev/null +++ b/src/app/phoneHitTestKit.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { drawnPx, expectSizeTie, iconButtons } from './phoneHitTestKit'; + +describe('phone hit test kit', () => { + it('reads { + const src = + ''; + expect(iconButtons(src).map((b) => b.tag)).toEqual([ + '', + '', + ]); + }); + + it('derives a drawn size from literal size props, defaults being 400', () => { + expect(drawnPx('', '')).toEqual({ px: 24 }); + expect(drawnPx('', '')).toEqual({ px: 40 }); + }); + + it('reads { + expect( + drawnPx('', '') + ).toEqual({ + px: 24, + }); + }); + + it('calls a size undetermined when props are spread into the IconButton or the Icon', () => { + expect(drawnPx('', '')).toHaveProperty('undetermined'); + expect(drawnPx('', '')).toHaveProperty( + 'undetermined' + ); + expect(drawnPx('', '')).toHaveProperty( + 'undetermined' + ); + expect(drawnPx('', '')).toHaveProperty('undetermined'); + }); + + it('fails a file with no IconButton, which would otherwise pass vacuously', () => { + expect(() => expectSizeTie('styles/PhoneHit.css.ts')).toThrow(/no IconButton/); + }); +}); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index bcd81540..a29dcc22 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -7,10 +7,23 @@ import { compile, processVanillaFile } from '@vanilla-extract/integration'; // The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does; // vanilla-extract gzips the inlined source only when it is large, so both forms are read). -export const emitted = async (file: string) => { +const compiled = async (file: string) => { const filePath = `${process.cwd()}/src/app/${file}`; const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); - const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); + return processVanillaFile({ source, filePath, identOption: 'debug' }); +}; + +/** The class list each export of a .css.ts file resolves to (a composed class lists the classes it composes). */ +export const classLists = async (file: string): Promise> => + Object.fromEntries( + [...(await compiled(file)).matchAll(/^export var (\w+) = '([^']*)';$/gm)].map((m) => [ + m[1], + m[2].split(' '), + ]) + ); + +export const emitted = async (file: string) => { + const code = await compiled(file); // A file with recipes inlines one source per rule group; read them all. return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)] .map((m) => { @@ -59,26 +72,18 @@ const tagEnd = (src: string, from: number) => { return j; }; -/** The opening tag of every outside any {} expression. */ -export const iconButtonTags = (src: string) => { - const tags: string[] = []; - for (let i = src.indexOf(' { - const out: { tag: string; body: string }[] = []; - for (let i = src.indexOf(' outside any {} expression, and + * what it wraps (empty when self-closing). */ +export const iconButtons = (src: string) => + [...src.matchAll(//])/g)].map((m) => { + const start = m.index ?? 0; + const end = tagEnd(src, start); + const tag = src.slice(start, end + 1); const close = src.indexOf('', end); - out.push({ tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) }); - } - return out; -}; + return { tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) }; + }); + +export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag); const phoneHitOf = (tag: string) => tag.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null; @@ -110,6 +115,8 @@ const ICON_PX: Record = { /** The size prop of a tag: its literal value, the default when absent, or undefined when it is an expression. */ const sizeProp = (tag: string) => { + // props spread in may carry a size of their own + if (tag.includes('{...')) return undefined; const m = tag.match(/\ssize=(?:"(\w+)"|\{([^}]*)\})/); if (!m) return '400'; return m[1] ?? m[2].match(/^'(\w+)'$/)?.[1]; @@ -119,14 +126,15 @@ const sizeProp = (tag: string) => { * does not say (a variable size, no Icon, Icons of different sizes). */ export const drawnPx = (tag: string, body: string): { px: number } | { undetermined: string } => { const pad = BUTTON_PAD[sizeProp(tag) ?? '']; - if (pad === undefined) return { undetermined: 'size of the IconButton is not a literal' }; - const icons = [...body.matchAll(/ { + if (pad === undefined) + return { undetermined: 'size of the IconButton is not a literal (or props are spread in)' }; + const icons = [...body.matchAll(//])/g)].map((m) => { const t = body.slice(m.index, tagEnd(body, m.index ?? 0) + 1); return ICON_PX[sizeProp(t) ?? '']; }); if (icons.length === 0) return { undetermined: 'no inside' }; if (icons.some((p) => p === undefined)) - return { undetermined: 'size of an Icon is not a literal' }; + return { undetermined: 'size of an Icon is not a literal (or props are spread in)' }; if (new Set(icons).size > 1) return { undetermined: 'Icons of different sizes inside' }; return { px: icons[0] + 2 * pad }; }; @@ -139,7 +147,9 @@ export type SizeAllow = Record>; export const expectSizeTie = (file: string, allow: SizeAllow = {}) => { const allowed = allow[file] ?? {}; const seen = new Set(); - iconButtons(readSource(file)).forEach(({ tag, body }, i) => { + const buttons = iconButtons(readSource(file)); + expect(buttons.length, `${file} has no IconButton`).toBeGreaterThan(0); + buttons.forEach(({ tag, body }, i) => { const variant = phoneHitOf(tag)?.match(/^PhoneHit(?:Spread|Room)(\d+)$/); if (!variant) { expect(allowed[i], `${file} #${i} is allow-listed but has no sized variant`).toBeUndefined(); diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index f2166cad..5a5394e7 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; import { + classLists, emitted, expectPhoneHitClasses, expectSizeTie, @@ -139,6 +140,20 @@ describe('phone icon-button hit areas', () => { expect([room(26), room(36)]).toEqual([[{ 'margin-block': 9 }], [{ 'margin-block': 4 }]]); }); + it('compose a Room variant from the Spread class of the same size, which composes PhoneHit', async () => { + const lists = await classLists('styles/PhoneHit.css.ts'); + const named = (list: string[], name: string) => list.filter((c) => c.includes(`_${name}__`)); + [26, 36].forEach((size) => { + const room = lists[`PhoneHitRoom${size}`]; + expect( + named(room, `PhoneHitSpread${size}`), + `Room${size} composes Spread${size}` + ).toHaveLength(1); + expect(named(room, 'PhoneHit'), `Room${size} composes PhoneHit`).toHaveLength(1); + }); + expect(lists.PhoneHitRoom36.some((c) => c.includes('_PhoneHitSpread26__'))).toBe(false); + }); + it("make a message's reaction chip 44 px to tap by opening 9 px above and below it", async () => { const css = await emitted('components/message/Reaction.css.ts'); const [chip] = rulesFor(css, /^button\.Reaction_Reaction__\w+$/, PHONE); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 189e58d1..9863851b 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -51,14 +51,14 @@ export const PhoneHitSpread40 = spread(40); /** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row * opens up on both axes, so the 44 px sits inside the container. It is the Spread class of the same size plus the * block margin, so the two axes cannot disagree about the size. */ -const room = (visual: number) => - style( - [ - spreads.get(visual) ?? '', - { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }, - ], +const room = (visual: number) => { + const spreadClass = spreads.get(visual); + if (!spreadClass) throw new Error(`PhoneHitRoom${visual} needs a PhoneHitSpread${visual}`); + return style( + [spreadClass, { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }], `PhoneHitRoom${visual}` ); +}; export const PhoneHitRoom26 = room(26); export const PhoneHitRoom36 = room(36); From 351d1bc74539201221ea428b8aeb975af2111d14 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:15:10 +0000 Subject: [PATCH 085/143] Phone hit: call controls get their hit-area variants, with a test pinning them Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/call-status/CallControl.tsx | 5 +++++ src/app/features/call/CallControls.tsx | 2 ++ src/app/features/call/Controls.tsx | 6 ++++++ src/app/phoneHit.call.test.ts | 15 +++++++++++++++ 4 files changed, 28 insertions(+) create mode 100644 src/app/phoneHit.call.test.ts diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 3cf38b1f..d63f8709 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -5,6 +5,7 @@ import { StatusDivider } from './components'; import { CallEmbed, useCallControlState } from '../../plugins/call'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { callEmbedAtom } from '../../state/callEmbed'; +import { PhoneHitSpread26 } from '../../styles/PhoneHit.css'; type MicrophoneButtonProps = { enabled: boolean; @@ -26,6 +27,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps > {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( { + it.each([ + ['features/call/Controls.tsx', Array(5).fill('PhoneHitSpread40')], + ['features/call/CallControls.tsx', ['PhoneHitSpread40']], + ['features/call-status/CallControl.tsx', Array(4).fill('PhoneHitSpread26')], + ])('%s', (file, want) => { + expectPhoneHitClasses(file as string, want as string[]); + expectSizeTie(file as string); + }); +}); From b8bef8c1751bf744190a1b788f92c980c166b45e Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:15:25 +0000 Subject: [PATCH 086/143] Phone hit: settings A icon buttons get PhoneHit, with a pinning test Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/settings/Settings.tsx | 3 +- src/app/features/settings/about/About.tsx | 3 +- src/app/features/settings/account/Account.tsx | 3 +- .../settings/developer-tools/DevelopTools.tsx | 3 +- src/app/features/settings/devices/Devices.tsx | 3 +- .../emojis-stickers/EmojisStickers.tsx | 3 +- .../settings/emojis-stickers/GlobalPacks.tsx | 3 ++ src/app/features/settings/general/General.tsx | 5 +++- .../notifications/KeywordMessages.tsx | 11 ++++++- .../settings/notifications/Notifications.tsx | 3 +- src/app/phoneHit.settingsA.test.ts | 29 +++++++++++++++++++ 11 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 src/app/phoneHit.settingsA.test.ts diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 5e1a20f4..6c91bc9d 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -33,6 +33,7 @@ import { About } from './about'; import { UseStateProvider } from '../../components/UseStateProvider'; import { stopPropagation } from '../../utils/keyboard'; import { LogoutDialog } from '../../components/LogoutDialog'; +import { PhoneHit } from '../../styles/PhoneHit.css'; export enum SettingsPages { GeneralPage, @@ -141,7 +142,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
        {screenSize === ScreenSize.Mobile && ( - + )} diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 9ec74305..19407bdf 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -8,6 +8,7 @@ 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'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type AboutProps = { requestClose: () => void; @@ -25,7 +26,7 @@ export function About({ requestClose }: AboutProps) { - + diff --git a/src/app/features/settings/account/Account.tsx b/src/app/features/settings/account/Account.tsx index c4b56e47..77ee8671 100644 --- a/src/app/features/settings/account/Account.tsx +++ b/src/app/features/settings/account/Account.tsx @@ -5,6 +5,7 @@ import { MatrixId } from './MatrixId'; import { Profile } from './Profile'; import { ContactInformation } from './ContactInfo'; import { IgnoredUserList } from './IgnoredUserList'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type AccountProps = { requestClose: () => void; @@ -20,7 +21,7 @@ export function Account({ requestClose }: AccountProps) {
        - + diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index a3f04567..7203fdc5 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -13,6 +13,7 @@ import { } from '../../../components/AccountDataEditor'; import { copyToClipboard } from '../../../utils/dom'; import { AccountData } from './AccountData'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type DeveloperToolsProps = { requestClose: () => void; @@ -51,7 +52,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
        - + diff --git a/src/app/features/settings/devices/Devices.tsx b/src/app/features/settings/devices/Devices.tsx index c957ab31..9e5821ba 100644 --- a/src/app/features/settings/devices/Devices.tsx +++ b/src/app/features/settings/devices/Devices.tsx @@ -26,6 +26,7 @@ import { } from '../../../hooks/useSecretStorage'; import { useCrossSigningActive } from '../../../hooks/useCrossSigning'; import { BackupRestoreTile } from '../../../components/BackupRestore'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; function DevicesPlaceholder() { return ( @@ -74,7 +75,7 @@ export function Devices({ requestClose }: DevicesProps) {
        - + diff --git a/src/app/features/settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/settings/emojis-stickers/EmojisStickers.tsx index 93715120..2c2193a9 100644 --- a/src/app/features/settings/emojis-stickers/EmojisStickers.tsx +++ b/src/app/features/settings/emojis-stickers/EmojisStickers.tsx @@ -5,6 +5,7 @@ import { GlobalPacks } from './GlobalPacks'; import { UserPack } from './UserPack'; import { ImagePack } from '../../../plugins/custom-emoji'; import { ImagePackView } from '../../../components/image-pack-view'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type EmojisStickersProps = { requestClose: () => void; @@ -30,7 +31,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
        - + diff --git a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx index a9288728..38647b13 100644 --- a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx +++ b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx @@ -43,6 +43,7 @@ import { allRoomsAtom } from '../../../state/room-list/roomList'; import { AccountDataEvent } from '../../../../types/matrix/accountData'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { stopPropagation } from '../../../utils/keyboard'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; function GlobalPackSelector({ packs, @@ -368,6 +369,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) { {removed ? ( ) : ( ; @@ -497,6 +498,7 @@ function DateHint({ hasChanges, handleReset }: DateHintProps) { > {hasChanges ? ( ) : ( - + diff --git a/src/app/features/settings/notifications/KeywordMessages.tsx b/src/app/features/settings/notifications/KeywordMessages.tsx index 7f84d607..b77cae21 100644 --- a/src/app/features/settings/notifications/KeywordMessages.tsx +++ b/src/app/features/settings/notifications/KeywordMessages.tsx @@ -15,6 +15,7 @@ import { } from '../../../hooks/useNotificationMode'; import { NotificationModeSwitcher } from './NotificationModeSwitcher'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; const NOTIFY_MODE_OPS: NotificationModeOptions = { highlight: true, @@ -75,6 +76,7 @@ function KeywordInput() { keyword && !addingKeyword && ( + {removing ? : } ); diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx index 095a9bba..61af1a09 100644 --- a/src/app/features/settings/notifications/Notifications.tsx +++ b/src/app/features/settings/notifications/Notifications.tsx @@ -8,6 +8,7 @@ import { KeywordMessagesNotifications } from './KeywordMessages'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type NotificationsProps = { requestClose: () => void; @@ -23,7 +24,7 @@ export function Notifications({ requestClose }: NotificationsProps) {
        - + diff --git a/src/app/phoneHit.settingsA.test.ts b/src/app/phoneHit.settingsA.test.ts new file mode 100644 index 00000000..37f1f3c2 --- /dev/null +++ b/src/app/phoneHit.settingsA.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +describe('icon buttons in settings A', () => { + // The class each IconButton must carry, in source order. Every one is alone in its row (a header close next to + // non-tappable title text, an input slot, or a list tile beside a non-tappable Avatar), so the plain PhoneHit. + const expected: Record = { + 'features/settings/developer-tools/DevelopTools.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/notifications/Notifications.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/notifications/KeywordMessages.tsx': ['PhoneHit', 'PhoneHit'], // input reset, remove: 26 px, alone + 'features/settings/emojis-stickers/EmojisStickers.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/emojis-stickers/GlobalPacks.tsx': ['PhoneHit', 'PhoneHit'], // undo / remove, a ternary pair, 26 px + 'features/settings/Settings.tsx': ['PhoneHit'], // mobile-only header close, 40 px + 'features/settings/about/About.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/general/General.tsx': ['PhoneHit', 'PhoneHit', 'PhoneHit'], // reset / info (ternary), header close + 'features/settings/account/Account.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/devices/Devices.tsx': ['PhoneHit'], // header close, 40 px + }; + + it.each(Object.entries(expected))('%s carries the right hit-area class', (file, want) => { + expectPhoneHitClasses(file, want); + }); + + // Plain PhoneHit has no size to tie, so nothing is allow-listed; the check still fails an allow-list entry + // or a sized variant that disagrees with the drawn size if one is added later. + it.each(Object.keys(expected))('%s ties a sized variant to the drawn size', (file) => { + expectSizeTie(file); + }); +}); From 0b40f4c66fbb45cee915d432479edbca3ae4b2bf Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:15:44 +0000 Subject: [PATCH 087/143] Phone hit: PhoneHit on the ten dialog and prompt close buttons, with a pinning test Co-Authored-By: Claude Sonnet 5.5 --- .../components/event-readers/EventReaders.tsx | 3 +- .../invite-user-prompt/InviteUserPrompt.tsx | 3 +- .../join-address-prompt/JoinAddressPrompt.tsx | 3 +- .../leave-room-prompt/LeaveRoomPrompt.tsx | 3 +- .../leave-space-prompt/LeaveSpacePrompt.tsx | 3 +- .../room-topic-viewer/RoomTopicViewer.tsx | 3 +- src/app/components/user-profile/PowerChip.tsx | 5 ++-- src/app/features/add-existing/AddExisting.tsx | 3 +- .../features/create-room/CreateRoomModal.tsx | 3 +- .../create-space/CreateSpaceModal.tsx | 3 +- src/app/phoneHit.dialogs.test.ts | 28 +++++++++++++++++++ 11 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 src/app/phoneHit.dialogs.test.ts diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index c7900237..72b188ce 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -24,6 +24,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { getMouseEventCords } from '../../utils/dom'; +import { PhoneHit } from '../../styles/PhoneHit.css'; export type EventReadersProps = { room: Room; @@ -52,7 +53,7 @@ export const EventReaders = as<'div', EventReadersProps>( Seen by - + diff --git a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx index ddac0576..a503fe13 100644 --- a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx +++ b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx @@ -42,6 +42,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { BreakWord } from '../../styles/Text.css'; import { useAlive } from '../../hooks/useAlive'; +import { PhoneHit } from '../../styles/PhoneHit.css'; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -173,7 +174,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) { - + diff --git a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx index 50a89418..ffd5241c 100644 --- a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx +++ b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx @@ -20,6 +20,7 @@ import { stopPropagation } from '../../utils/keyboard'; import { isRoomAlias, isRoomId } from '../../utils/matrix'; import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to'; import { tryDecodeURIComponent } from '../../utils/dom'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type JoinAddressProps = { onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void; @@ -82,7 +83,7 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) { Join with Address - + diff --git a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx index 217491e6..832dd59d 100644 --- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx +++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx @@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { stopPropagation } from '../../utils/keyboard'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type LeaveRoomPromptProps = { roomId: string; @@ -68,7 +69,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro Leave Room - + diff --git a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx index 8709b942..6c75ef99 100644 --- a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx +++ b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx @@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { stopPropagation } from '../../utils/keyboard'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type LeaveSpacePromptProps = { roomId: string; @@ -68,7 +69,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP Leave Space - + diff --git a/src/app/components/room-topic-viewer/RoomTopicViewer.tsx b/src/app/components/room-topic-viewer/RoomTopicViewer.tsx index 52128772..0d091e7e 100644 --- a/src/app/components/room-topic-viewer/RoomTopicViewer.tsx +++ b/src/app/components/room-topic-viewer/RoomTopicViewer.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import Linkify from 'linkify-react'; import * as css from './style.css'; import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; +import { PhoneHit } from '../../styles/PhoneHit.css'; export const RoomTopicViewer = as< 'div', @@ -26,7 +27,7 @@ export const RoomTopicViewer = as< {name} - + diff --git a/src/app/components/user-profile/PowerChip.tsx b/src/app/components/user-profile/PowerChip.tsx index a6758300..d3e9cf28 100644 --- a/src/app/components/user-profile/PowerChip.tsx +++ b/src/app/components/user-profile/PowerChip.tsx @@ -43,6 +43,7 @@ import { getPowerTagIconSrc, useGetMemberPowerTag } from '../../hooks/useMemberP import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type SelfDemoteAlertProps = { power: number; @@ -70,7 +71,7 @@ function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) { Self Demotion - + @@ -120,7 +121,7 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) Shared Power - + diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx index d100096b..a57a6b10 100644 --- a/src/app/features/add-existing/AddExisting.tsx +++ b/src/app/features/add-existing/AddExisting.tsx @@ -54,6 +54,7 @@ import { StateEvent } from '../../../types/matrix/room'; import { getViaServers } from '../../plugins/via-servers'; import { rateLimitedActions } from '../../utils/matrix'; import { useAlive } from '../../hooks/useAlive'; +import { PhoneHit } from '../../styles/PhoneHit.css'; const SEARCH_OPTS: UseAsyncSearchOptions = { limit: 500, @@ -201,7 +202,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM Add Existing - + diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx index d5d8c92d..9332f35c 100644 --- a/src/app/features/create-room/CreateRoomModal.tsx +++ b/src/app/features/create-room/CreateRoomModal.tsx @@ -24,6 +24,7 @@ import { import { CreateRoomModalState } from '../../state/createRoomModal'; import { stopPropagation } from '../../utils/keyboard'; import { CreateRoomType } from '../../components/create-room/types'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type CreateRoomModalProps = { state: CreateRoomModalState; @@ -63,7 +64,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) { - + diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx index c1bc6898..faa53b25 100644 --- a/src/app/features/create-space/CreateSpaceModal.tsx +++ b/src/app/features/create-space/CreateSpaceModal.tsx @@ -23,6 +23,7 @@ import { } from '../../state/hooks/createSpaceModal'; import { CreateSpaceModalState } from '../../state/createSpaceModal'; import { stopPropagation } from '../../utils/keyboard'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type CreateSpaceModalProps = { state: CreateSpaceModalState; @@ -61,7 +62,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) { New Space - + diff --git a/src/app/phoneHit.dialogs.test.ts b/src/app/phoneHit.dialogs.test.ts new file mode 100644 index 00000000..8b3416a5 --- /dev/null +++ b/src/app/phoneHit.dialogs.test.ts @@ -0,0 +1,28 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +// Dialog and prompt close buttons: each is a 32 px button (24 px Icon + 8 px padding) alone at the end of a +// header whose title is not tappable, so the plain PhoneHit; no neighbour to spread from, no clipping container. +describe('icon buttons in dialogs and prompts', () => { + const expected: Record = { + 'features/create-space/CreateSpaceModal.tsx': ['PhoneHit'], + 'features/create-room/CreateRoomModal.tsx': ['PhoneHit'], + 'features/add-existing/AddExisting.tsx': ['PhoneHit'], + 'components/room-topic-viewer/RoomTopicViewer.tsx': ['PhoneHit'], + 'components/leave-room-prompt/LeaveRoomPrompt.tsx': ['PhoneHit'], + 'components/leave-space-prompt/LeaveSpacePrompt.tsx': ['PhoneHit'], + 'components/event-readers/EventReaders.tsx': ['PhoneHit'], + 'components/invite-user-prompt/InviteUserPrompt.tsx': ['PhoneHit'], + 'components/join-address-prompt/JoinAddressPrompt.tsx': ['PhoneHit'], // join cancel + 'components/user-profile/PowerChip.tsx': ['PhoneHit', 'PhoneHit'], // self demotion, shared power + }; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) => + expectSizeTie(file) + ); +}); From cd90a044ed8524e4e38e59cfa2418fdf250a500c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:02 +0000 Subject: [PATCH 088/143] Phone hit: page back buttons and Lobby/Notifications lone buttons Co-Authored-By: Claude Sonnet 5.5 --- .../JoinBeforeNavigate.tsx | 3 +- src/app/features/lobby/LobbyHeader.tsx | 4 ++- src/app/pages/client/direct/DirectCreate.tsx | 3 +- src/app/pages/client/explore/Featured.tsx | 3 +- src/app/pages/client/explore/Server.tsx | 3 +- src/app/pages/client/home/CreateRoom.tsx | 3 +- src/app/pages/client/home/Search.tsx | 3 +- src/app/pages/client/inbox/Invites.tsx | 3 +- src/app/pages/client/inbox/Notifications.tsx | 4 ++- src/app/pages/client/space/Search.tsx | 3 +- src/app/phoneHit.pages.test.ts | 29 +++++++++++++++++++ src/app/phoneHitTestKit.ts | 4 +-- 12 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 src/app/phoneHit.pages.test.ts diff --git a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx index 028cd560..43576cff 100644 --- a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx +++ b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx @@ -10,6 +10,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { allRoomsAtom } from '../../state/room-list/roomList'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { BackRouteHandler } from '../../components/BackRouteHandler'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] }; export function JoinBeforeNavigate({ @@ -38,7 +39,7 @@ export function JoinBeforeNavigate({ {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx index ed526c68..ce248fe5 100644 --- a/src/app/features/lobby/LobbyHeader.tsx +++ b/src/app/features/lobby/LobbyHeader.tsx @@ -38,6 +38,7 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type LobbyMenuProps = { powerLevels: IPowerLevels; @@ -165,7 +166,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { {(onBack) => ( - + )} @@ -240,6 +241,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { > {(triggerRef) => ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/explore/Featured.tsx b/src/app/pages/client/explore/Featured.tsx index f056cbb5..c28fad0a 100644 --- a/src/app/pages/client/explore/Featured.tsx +++ b/src/app/pages/client/explore/Featured.tsx @@ -18,6 +18,7 @@ import * as css from './style.css'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function FeaturedRooms() { const { featuredCommunities } = useClientConfig(); @@ -33,7 +34,7 @@ export function FeaturedRooms() { {(onBack) => ( - + )} diff --git a/src/app/pages/client/explore/Server.tsx b/src/app/pages/client/explore/Server.tsx index 48f267cc..036c6488 100644 --- a/src/app/pages/client/explore/Server.tsx +++ b/src/app/pages/client/explore/Server.tsx @@ -45,6 +45,7 @@ import { getMxIdServer } from '../../../utils/matrix'; import { stopPropagation } from '../../../utils/keyboard'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams => useMemo( @@ -499,7 +500,7 @@ export function PublicRooms() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/home/CreateRoom.tsx b/src/app/pages/client/home/CreateRoom.tsx index fddd75aa..8e277889 100644 --- a/src/app/pages/client/home/CreateRoom.tsx +++ b/src/app/pages/client/home/CreateRoom.tsx @@ -12,6 +12,7 @@ import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { CreateRoomForm } from '../../../features/create-room'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function HomeCreateRoom() { const screenSize = useScreenSizeContext(); @@ -25,7 +26,7 @@ export function HomeCreateRoom() { {(onBack) => ( - + )} diff --git a/src/app/pages/client/home/Search.tsx b/src/app/pages/client/home/Search.tsx index d5ddfb77..aa454d2c 100644 --- a/src/app/pages/client/home/Search.tsx +++ b/src/app/pages/client/home/Search.tsx @@ -5,6 +5,7 @@ import { MessageSearch } from '../../../features/message-search'; import { useHomeRooms } from './useHomeRooms'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function HomeSearch() { const scrollRef = useRef(null); @@ -19,7 +20,7 @@ export function HomeSearch() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index ca26bac3..4d1580af 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -63,6 +63,7 @@ import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; import { useReportRoomSupported } from '../../../hooks/useReportRoomSupported'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; const COMPACT_CARD_WIDTH = 548; @@ -745,7 +746,7 @@ export function Invites() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 9592224f..8fddba44 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -97,6 +97,7 @@ import { } from '../../../hooks/useMemberPowerTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type RoomNotificationsGroup = { roomId: string; @@ -642,7 +643,7 @@ export function Notifications() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} @@ -694,6 +695,7 @@ export function Notifications() { onVisibilityChange={handleScrollTopVisibility} > virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" diff --git a/src/app/pages/client/space/Search.tsx b/src/app/pages/client/space/Search.tsx index 017262b5..a2849ab8 100644 --- a/src/app/pages/client/space/Search.tsx +++ b/src/app/pages/client/space/Search.tsx @@ -11,6 +11,7 @@ import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function SpaceSearch() { const mx = useMatrixClient(); @@ -34,7 +35,7 @@ export function SpaceSearch() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/phoneHit.pages.test.ts b/src/app/phoneHit.pages.test.ts new file mode 100644 index 00000000..97eb77b8 --- /dev/null +++ b/src/app/phoneHit.pages.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +// Page back buttons (and two lone buttons beside them). Every one is alone in its row, 40 px drawn (default +// IconButton and Icon), so plain PhoneHit; the vertical stacks of the page have no variant. +// null = deliberately without: LobbyHeader's Members button renders only when screenSize !== Mobile. +const files: Record = { + 'features/lobby/LobbyHeader.tsx': ['PhoneHit', null, 'PhoneHit'], + 'features/join-before-navigate/JoinBeforeNavigate.tsx': ['PhoneHit'], + 'pages/client/space/Search.tsx': ['PhoneHit'], + 'pages/client/explore/Server.tsx': ['PhoneHit'], + 'pages/client/explore/Featured.tsx': ['PhoneHit'], + 'pages/client/home/CreateRoom.tsx': ['PhoneHit'], + 'pages/client/home/Search.tsx': ['PhoneHit'], + // the second is the 30 px scroll-to-top pill, floating alone at the top centre + 'pages/client/inbox/Notifications.tsx': ['PhoneHit', 'PhoneHit'], + 'pages/client/inbox/Invites.tsx': ['PhoneHit'], + 'pages/client/direct/DirectCreate.tsx': ['PhoneHit'], +}; + +describe.each(Object.entries(files))('phone hit area: %s', (file, want) => { + it('gives each IconButton its variant', () => { + expectPhoneHitClasses(file, want); + }); + // plain PhoneHit has no size to tie; this fails when a sized variant appears whose number is not the drawn px + it('ties any sized variant to the drawn size', () => { + expectSizeTie(file); + }); +}); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index a29dcc22..46963024 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -90,10 +90,10 @@ const phoneHitOf = (tag: string) => tag.match(/className=\{[^}]*?\b(PhoneHit\w*) /** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and * whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks * the name is the one PhoneHit.css.ts exports, not a local of the same name. */ -export const expectPhoneHitClasses = (file: string, want: string[]) => { +export const expectPhoneHitClasses = (file: string, want: (string | null)[]) => { const src = readSource(file); expect(iconButtonTags(src).map(phoneHitOf)).toEqual(want); - [...new Set(want)].forEach((name) => + [...new Set(want.filter((w): w is string => w !== null))].forEach((name) => expect(src).toMatch( new RegExp( `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` From e209688de10cce5c91575de6a3410d8bcf7d2901 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:02 +0000 Subject: [PATCH 089/143] Phone hit: common-settings IconButtons get PhoneHit, with a pinning test Co-Authored-By: Claude Sonnet 5.5 --- .../developer-tools/DevelopTools.tsx | 3 ++- .../developer-tools/SendRoomEvent.tsx | 3 ++- .../developer-tools/StateEventEditor.tsx | 3 ++- .../emojis-stickers/EmojisStickers.tsx | 3 ++- .../emojis-stickers/RoomPacks.tsx | 3 +++ .../general/RoomEncryption.tsx | 8 +++++- .../common-settings/general/RoomUpgrade.tsx | 3 ++- .../common-settings/members/Members.tsx | 4 ++- .../permissions/PowersEditor.tsx | 3 ++- src/app/phoneHit.commonSettings.test.ts | 26 +++++++++++++++++++ 10 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 src/app/phoneHit.commonSettings.test.ts diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index 29b6aa51..61e05a62 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -30,6 +30,7 @@ import { AccountDataSubmitCallback, } from '../../../components/AccountDataEditor'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type DeveloperToolsProps = { requestClose: () => void; @@ -92,7 +93,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { - + diff --git a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx index f25ba7c3..a6e60a7b 100644 --- a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx +++ b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx @@ -22,6 +22,7 @@ import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { syntaxErrorPosition } from '../../../utils/dom'; import { Cursor } from '../../../plugins/text-area'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; const EDITOR_INTENT_SPACE_COUNT = 2; @@ -118,7 +119,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro - + diff --git a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx index 0ea96901..0f9f4034 100644 --- a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx +++ b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx @@ -31,6 +31,7 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; const EDITOR_INTENT_SPACE_COUNT = 2; @@ -275,7 +276,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi - + diff --git a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx index ad8ffae6..f7e901d5 100644 --- a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx +++ b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx @@ -4,6 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page'; import { ImagePack } from '../../../plugins/custom-emoji'; import { ImagePackView } from '../../../components/image-pack-view'; import { RoomPacks } from './RoomPacks'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type EmojisStickersProps = { requestClose: () => void; @@ -29,7 +30,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) { - + diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx index fdbe546e..7be8de35 100644 --- a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx +++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx @@ -40,6 +40,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useAlive } from '../../../hooks/useAlive'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type CreatePackTileProps = { packs: ImagePack[]; @@ -215,6 +216,7 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) { {canEdit && (removed ? ( ) : ( Enable Encryption - setPrompt(false)} radii="300"> + setPrompt(false)} + radii="300" + > diff --git a/src/app/features/common-settings/general/RoomUpgrade.tsx b/src/app/features/common-settings/general/RoomUpgrade.tsx index 45a480aa..2971d565 100644 --- a/src/app/features/common-settings/general/RoomUpgrade.tsx +++ b/src/app/features/common-settings/general/RoomUpgrade.tsx @@ -39,6 +39,7 @@ import { useAlive } from '../../../hooks/useAlive'; import { creatorsSupported } from '../../../utils/matrix'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { BreakWord } from '../../../styles/Text.css'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { const mx = useMatrixClient(); @@ -105,7 +106,7 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { {room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'} - + diff --git a/src/app/features/common-settings/members/Members.tsx b/src/app/features/common-settings/members/Members.tsx index 15cd1d08..a1dddb54 100644 --- a/src/app/features/common-settings/members/Members.tsx +++ b/src/app/features/common-settings/members/Members.tsx @@ -56,6 +56,7 @@ import { useSpaceOptionally } from '../../../hooks/useSpace'; import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../../hooks/useMemberPowerTag'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { getMouseEventCords } from '../../../utils/dom'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -161,7 +162,7 @@ export function Members({ requestClose }: MembersProps) { - + @@ -277,6 +278,7 @@ export function Members({ requestClose }: MembersProps) { anchorRef={scrollTopAnchorRef} > virtualizer.scrollToOffset(0)} variant="Surface" radii="Pill" diff --git a/src/app/features/common-settings/permissions/PowersEditor.tsx b/src/app/features/common-settings/permissions/PowersEditor.tsx index 97305946..0684a48a 100644 --- a/src/app/features/common-settings/permissions/PowersEditor.tsx +++ b/src/app/features/common-settings/permissions/PowersEditor.tsx @@ -49,6 +49,7 @@ import { useAlive } from '../../../hooks/useAlive'; import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; import { creatorsSupported } from '../../../utils/matrix'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type EditPowerProps = { maxPower: number; @@ -369,7 +370,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) { - + diff --git a/src/app/phoneHit.commonSettings.test.ts b/src/app/phoneHit.commonSettings.test.ts new file mode 100644 index 00000000..fdb2c4e0 --- /dev/null +++ b/src/app/phoneHit.commonSettings.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +// Every IconButton under features/common-settings, in source order. Header close buttons are alone at the +// header end (the title beside them is text), the dialog close buttons are alone in their Header, the +// scroll-to-top button floats alone, and the RoomPacks pair is a ternary (only one renders): all PhoneHit. +const files: [string, string[]][] = [ + ['features/common-settings/members/Members.tsx', ['PhoneHit', 'PhoneHit']], + ['features/common-settings/developer-tools/SendRoomEvent.tsx', ['PhoneHit']], + ['features/common-settings/developer-tools/StateEventEditor.tsx', ['PhoneHit']], + ['features/common-settings/developer-tools/DevelopTools.tsx', ['PhoneHit']], + ['features/common-settings/permissions/PowersEditor.tsx', ['PhoneHit']], + ['features/common-settings/emojis-stickers/EmojisStickers.tsx', ['PhoneHit']], + ['features/common-settings/emojis-stickers/RoomPacks.tsx', ['PhoneHit', 'PhoneHit']], + ['features/common-settings/general/RoomEncryption.tsx', ['PhoneHit']], + ['features/common-settings/general/RoomUpgrade.tsx', ['PhoneHit']], +]; + +describe.each(files)('phone hit area: %s', (file, want) => { + it('carries the variant its drawn size and neighbours call for', () => { + expectPhoneHitClasses(file, want); + }); + it('names a size that matches what it draws', () => { + expectSizeTie(file); + }); +}); From 200cf8fdb86e9cf26d8dd2f4be08738dfa6bdd21 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:07 +0000 Subject: [PATCH 090/143] Phone hit: viewer headers and UIA stages Back/close buttons beside non-tappable titles take PhoneHit; the zoom out/in buttons beside the zoom Chip take PhoneHitSpread24. Test pins each variant and ties the sized ones to the drawn size. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/Pdf-viewer/PdfViewer.tsx | 5 +++- src/app/components/UIAFlowOverlay.tsx | 2 ++ .../components/image-editor/ImageEditor.tsx | 3 ++- .../components/image-viewer/ImageViewer.tsx | 5 +++- src/app/components/text-viewer/TextViewer.tsx | 3 ++- .../components/uia-stages/PasswordStage.tsx | 3 ++- src/app/components/uia-stages/SSOStage.tsx | 3 ++- src/app/phoneHit.viewers.test.ts | 27 +++++++++++++++++++ 8 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 src/app/phoneHit.viewers.test.ts diff --git a/src/app/components/Pdf-viewer/PdfViewer.tsx b/src/app/components/Pdf-viewer/PdfViewer.tsx index 9c7fd980..d0e5f715 100644 --- a/src/app/components/Pdf-viewer/PdfViewer.tsx +++ b/src/app/components/Pdf-viewer/PdfViewer.tsx @@ -27,6 +27,7 @@ import { AsyncStatus } from '../../hooks/useAsyncCallback'; import { useZoom } from '../../hooks/useZoom'; import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist'; import { stopPropagation } from '../../utils/keyboard'; +import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css'; export type PdfViewerProps = { name: string; @@ -108,7 +109,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
        - + @@ -117,6 +118,7 @@ export const PdfViewer = as<'div', PdfViewerProps>( ( {Math.round(zoom * 100)}% 1 ? 'Success' : 'SurfaceVariant'} outlined={zoom > 1} size="300" diff --git a/src/app/components/UIAFlowOverlay.tsx b/src/app/components/UIAFlowOverlay.tsx index a2bdd1b8..49e1d821 100644 --- a/src/app/components/UIAFlowOverlay.tsx +++ b/src/app/components/UIAFlowOverlay.tsx @@ -13,6 +13,7 @@ import { IconButton, } from 'folds'; import FocusTrap from 'focus-trap-react'; +import { PhoneHit } from '../styles/PhoneHit.css'; export type UIAFlowOverlayProps = { currentStep: number; @@ -53,6 +54,7 @@ export function UIAFlowOverlay({ > {(anchorRef) => ( ( >
        - + diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 4956a7b6..4d2d7694 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -7,6 +7,7 @@ import * as css from './ImageViewer.css'; import { useZoom } from '../../hooks/useZoom'; import { usePan } from '../../hooks/usePan'; import { downloadMedia } from '../../utils/matrix'; +import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css'; export type ImageViewerProps = { alt: string; @@ -33,7 +34,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( >
        - + @@ -42,6 +43,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( ( {Math.round(zoom * 100)}% 1 ? 'Success' : 'SurfaceVariant'} outlined={zoom > 1} size="300" diff --git a/src/app/components/text-viewer/TextViewer.tsx b/src/app/components/text-viewer/TextViewer.tsx index ec4ed0a5..e9e150b5 100644 --- a/src/app/components/text-viewer/TextViewer.tsx +++ b/src/app/components/text-viewer/TextViewer.tsx @@ -5,6 +5,7 @@ import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'fo import { ErrorBoundary } from 'react-error-boundary'; import * as css from './TextViewer.css'; import { copyToClipboard } from '../../utils/dom'; +import { PhoneHit } from '../../styles/PhoneHit.css'; const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism')); @@ -53,7 +54,7 @@ export const TextViewer = as<'div', TextViewerProps>( >
        - + diff --git a/src/app/components/uia-stages/PasswordStage.tsx b/src/app/components/uia-stages/PasswordStage.tsx index 0164cae6..a7ca2d06 100644 --- a/src/app/components/uia-stages/PasswordStage.tsx +++ b/src/app/components/uia-stages/PasswordStage.tsx @@ -4,6 +4,7 @@ import { AuthType } from 'matrix-js-sdk'; import { StageComponentProps } from './types'; import { ErrorCode } from '../../cs-errorcode'; import { PasswordInput } from '../password-input'; +import { PhoneHit } from '../../styles/PhoneHit.css'; export function PasswordStage({ stageData, @@ -45,7 +46,7 @@ export function PasswordStage({ Account Password - +
        diff --git a/src/app/components/uia-stages/SSOStage.tsx b/src/app/components/uia-stages/SSOStage.tsx index e3d6126a..20decb6c 100644 --- a/src/app/components/uia-stages/SSOStage.tsx +++ b/src/app/components/uia-stages/SSOStage.tsx @@ -1,6 +1,7 @@ import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds'; import React, { useCallback, useEffect, useState } from 'react'; import { StageComponentProps } from './types'; +import { PhoneHit } from '../../styles/PhoneHit.css'; export function SSOStage({ ssoRedirectURL, @@ -56,7 +57,7 @@ export function SSOStage({ SSO Login - +
        diff --git a/src/app/phoneHit.viewers.test.ts b/src/app/phoneHit.viewers.test.ts new file mode 100644 index 00000000..3e579f4c --- /dev/null +++ b/src/app/phoneHit.viewers.test.ts @@ -0,0 +1,27 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +// Viewer headers and the UIA stages. The back/close button sits beside a title that is not tappable, so it is a +// plain PhoneHit; the zoom out/in buttons sit either side of a tappable zoom Chip, so they spread at their drawn +// 24 px (IconButton size 300 around a 16 px Icon). +// Not covered: the two scroll arrows of components/url-preview/UrlPreviewCard.tsx. They are position: absolute and +// PhoneHit sets position: relative on phones, which would pull them into the flow; they need a variant that keeps +// the position, which PhoneHit.css.ts does not have yet. +const files: [string, string[]][] = [ + ['components/uia-stages/PasswordStage.tsx', ['PhoneHit']], + ['components/uia-stages/SSOStage.tsx', ['PhoneHit']], + ['components/UIAFlowOverlay.tsx', ['PhoneHit']], + ['components/text-viewer/TextViewer.tsx', ['PhoneHit']], + ['components/image-editor/ImageEditor.tsx', ['PhoneHit']], + ['components/image-viewer/ImageViewer.tsx', ['PhoneHit', 'PhoneHitSpread24', 'PhoneHitSpread24']], + ['components/Pdf-viewer/PdfViewer.tsx', ['PhoneHit', 'PhoneHitSpread24', 'PhoneHitSpread24']], +]; + +describe.each(files)('phone hit area: %s', (file, want) => { + it('gives each icon button the variant of its surroundings', () => { + expectPhoneHitClasses(file, want); + }); + it('ties each sized variant to the size the button draws', () => { + expectSizeTie(file); + }); +}); From 32dc46c21f465e36928f5ba0f717d4ed4dfe16e9 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:11 +0000 Subject: [PATCH 091/143] Phone hit: PhoneHit on the settings B, auth and verification icon buttons Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/BackupRestore.tsx | 2 ++ src/app/components/DeviceVerification.tsx | 3 ++- .../components/DeviceVerificationSetup.tsx | 5 +++-- .../settings/account/IgnoredUserList.tsx | 2 ++ src/app/features/settings/account/Profile.tsx | 9 +++++++- .../features/settings/devices/DeviceTile.tsx | 2 ++ .../settings/devices/Verification.tsx | 2 ++ src/app/pages/auth/ServerPicker.tsx | 2 ++ src/app/phoneHit.settingsB.test.ts | 21 +++++++++++++++++++ 9 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 src/app/phoneHit.settingsB.test.ts diff --git a/src/app/components/BackupRestore.tsx b/src/app/components/BackupRestore.tsx index 068f437b..57a18674 100644 --- a/src/app/components/BackupRestore.tsx +++ b/src/app/components/BackupRestore.tsx @@ -30,6 +30,7 @@ import { } from '../hooks/useKeyBackup'; import { stopPropagation } from '../utils/keyboard'; import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification'; +import { PhoneHit } from '../styles/PhoneHit.css'; type BackupStatusProps = { enabled: boolean; @@ -177,6 +178,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) { )} Device Verification
        - +
        diff --git a/src/app/components/DeviceVerificationSetup.tsx b/src/app/components/DeviceVerificationSetup.tsx index 433fa6a1..68f42221 100644 --- a/src/app/components/DeviceVerificationSetup.tsx +++ b/src/app/components/DeviceVerificationSetup.tsx @@ -25,6 +25,7 @@ import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA'; import { useMatrixClient } from '../hooks/useMatrixClient'; import { useAlive } from '../hooks/useAlive'; import { UseStateProvider } from './UseStateProvider'; +import { PhoneHit } from '../styles/PhoneHit.css'; type UIACallback = ( authDict: AuthDict | null @@ -301,7 +302,7 @@ export const DeviceVerificationSetup = forwardRef Setup Device Verification
        - +
        @@ -336,7 +337,7 @@ export const DeviceVerificationReset = forwardRef Reset Device Verification
        - + diff --git a/src/app/features/settings/account/IgnoredUserList.tsx b/src/app/features/settings/account/IgnoredUserList.tsx index 98db9459..791a407b 100644 --- a/src/app/features/settings/account/IgnoredUserList.tsx +++ b/src/app/features/settings/account/IgnoredUserList.tsx @@ -8,6 +8,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { isUserId } from '../../../utils/matrix'; import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; import { useAlive } from '../../../hooks/useAlive'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; function IgnoreUserInput({ userList }: { userList: string[] }) { const mx = useMatrixClient(); @@ -67,6 +68,7 @@ function IgnoreUserInput({ userList }: { userList: string[] }) { userId && !ignoring && ( Remove Avatar - setAlertRemove(false)} radii="300"> + setAlertRemove(false)} + radii="300" + > @@ -273,6 +279,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) { hasChanges && !changingDisplayName && ( { + it('gives each icon button its variant', () => expectPhoneHitClasses(file, want)); + it('ties each sized variant to the size it draws', () => expectSizeTie(file)); +}); From 84ee800e3f4fddfb31e4491c9616b7f92d777b74 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:31 +0000 Subject: [PATCH 092/143] Phone hit: room/space settings, lobby, message search, explore icon buttons Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/lobby/HierarchyItemMenu.tsx | 2 ++ src/app/features/lobby/Lobby.tsx | 2 ++ .../features/message-search/MessageSearch.tsx | 2 ++ .../features/room-settings/RoomSettings.tsx | 3 +- .../room-settings/general/General.tsx | 3 +- .../room-settings/permissions/Permissions.tsx | 3 +- .../features/space-settings/SpaceSettings.tsx | 3 +- .../space-settings/general/General.tsx | 3 +- .../permissions/Permissions.tsx | 3 +- src/app/pages/client/explore/Explore.tsx | 8 +++++- src/app/phoneHit.roomSpace.test.ts | 28 +++++++++++++++++++ 11 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 src/app/phoneHit.roomSpace.test.ts diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index 1802e3b0..cdfa2d8a 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -30,6 +30,7 @@ import { IPowerLevels } from '../../hooks/usePowerLevels'; import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; +import { PhoneHitSpread24 } from '../../styles/PhoneHit.css'; type HierarchyItemWithParent = HierarchyItem & { parentId: string; @@ -231,6 +232,7 @@ export function HierarchyItemMenu({ return ( virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 26085b5f..a1759ef9 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -23,6 +23,7 @@ import { SearchResultGroup } from './SearchResultGroup'; import { SearchInput } from './SearchInput'; import { SearchFilters } from './SearchFilters'; import { VirtualTile } from '../../components/virtualizer'; +import { PhoneHit } from '../../styles/PhoneHit.css'; const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams => useMemo( @@ -193,6 +194,7 @@ export function MessageSearch({ virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx index 8601c365..8f96bd7c 100644 --- a/src/app/features/room-settings/RoomSettings.tsx +++ b/src/app/features/room-settings/RoomSettings.tsx @@ -17,6 +17,7 @@ import { Permissions } from './permissions'; import { RoomSettingsPage } from '../../state/roomSettings'; import { useRoom } from '../../hooks/useRoom'; import { DeveloperTools } from '../common-settings/developer-tools'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type RoomSettingsMenuItem = { page: RoomSettingsPage; @@ -117,7 +118,7 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) { {screenSize === ScreenSize.Mobile && ( - + )} diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx index 15157304..e29fe2da 100644 --- a/src/app/features/room-settings/general/General.tsx +++ b/src/app/features/room-settings/general/General.tsx @@ -15,6 +15,7 @@ import { } from '../../common-settings/general'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type GeneralProps = { requestClose: () => void; @@ -35,7 +36,7 @@ export function General({ requestClose }: GeneralProps) { - + diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx index 7572a71b..d513dda9 100644 --- a/src/app/features/room-settings/permissions/Permissions.tsx +++ b/src/app/features/room-settings/permissions/Permissions.tsx @@ -9,6 +9,7 @@ import { usePermissionGroups } from './usePermissionItems'; import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type PermissionsProps = { requestClose: () => void; @@ -45,7 +46,7 @@ export function Permissions({ requestClose }: PermissionsProps) { - + diff --git a/src/app/features/space-settings/SpaceSettings.tsx b/src/app/features/space-settings/SpaceSettings.tsx index b5fefc93..1030972f 100644 --- a/src/app/features/space-settings/SpaceSettings.tsx +++ b/src/app/features/space-settings/SpaceSettings.tsx @@ -17,6 +17,7 @@ import { Members } from '../common-settings/members'; import { DeveloperTools } from '../common-settings/developer-tools'; import { General } from './general'; import { Permissions } from './permissions'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type SpaceSettingsMenuItem = { page: SpaceSettingsPage; @@ -117,7 +118,7 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps) {screenSize === ScreenSize.Mobile && ( - + )} diff --git a/src/app/features/space-settings/general/General.tsx b/src/app/features/space-settings/general/General.tsx index 72040632..e99a4906 100644 --- a/src/app/features/space-settings/general/General.tsx +++ b/src/app/features/space-settings/general/General.tsx @@ -13,6 +13,7 @@ import { } from '../../common-settings/general'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type GeneralProps = { requestClose: () => void; @@ -33,7 +34,7 @@ export function General({ requestClose }: GeneralProps) { - + diff --git a/src/app/features/space-settings/permissions/Permissions.tsx b/src/app/features/space-settings/permissions/Permissions.tsx index 7572a71b..d513dda9 100644 --- a/src/app/features/space-settings/permissions/Permissions.tsx +++ b/src/app/features/space-settings/permissions/Permissions.tsx @@ -9,6 +9,7 @@ import { usePermissionGroups } from './usePermissionItems'; import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type PermissionsProps = { requestClose: () => void; @@ -45,7 +46,7 @@ export function Permissions({ requestClose }: PermissionsProps) { - + diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx index dae83166..6944c9f7 100644 --- a/src/app/pages/client/explore/Explore.tsx +++ b/src/app/pages/client/explore/Explore.tsx @@ -37,6 +37,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; import { stopPropagation } from '../../../utils/keyboard'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function AddServer() { const mx = useMatrixClient(); @@ -96,7 +97,12 @@ export function AddServer() { Add Server - setDialog(false)} radii="300"> + setDialog(false)} + radii="300" + > diff --git a/src/app/phoneHit.roomSpace.test.ts b/src/app/phoneHit.roomSpace.test.ts new file mode 100644 index 00000000..c98c2b09 --- /dev/null +++ b/src/app/phoneHit.roomSpace.test.ts @@ -0,0 +1,28 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +describe('icon buttons in room settings, space settings, lobby, search and explore', () => { + // The class each IconButton must carry, in source order. Vertical stacks and lone buttons: the plain PhoneHit. + const expected: Record = { + 'features/room-settings/RoomSettings.tsx': ['PhoneHit'], // close, phone only, alone at the header end + 'features/room-settings/general/General.tsx': ['PhoneHit'], // close, 40 px + 'features/room-settings/permissions/Permissions.tsx': ['PhoneHit'], + 'features/space-settings/SpaceSettings.tsx': ['PhoneHit'], + 'features/space-settings/general/General.tsx': ['PhoneHit'], + 'features/space-settings/permissions/Permissions.tsx': ['PhoneHit'], + // 16 px icon + 8 padding = 24 px, 8-12 px from the Open / Add chips beside it in a hierarchy card + 'features/lobby/HierarchyItemMenu.tsx': ['PhoneHitSpread24'], + 'features/lobby/Lobby.tsx': ['PhoneHit'], // floating scroll-to-top, alone + 'features/message-search/MessageSearch.tsx': ['PhoneHit'], // floating scroll-to-top, alone + 'pages/client/explore/Explore.tsx': ['PhoneHit'], // Add Server dialog close + }; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) => + expectSizeTie(file) + ); +}); From a0d047c139f7727f1d9e67780670ff9d713d0c71 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:41 +0000 Subject: [PATCH 093/143] Phone hit: message content and data editor icon buttons Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/AccountDataEditor.tsx | 3 ++- .../image-pack-view/ImagePackView.tsx | 3 ++- src/app/components/message/FileHeader.tsx | 2 ++ .../message/content/AudioContent.tsx | 2 ++ src/app/phoneHit.content.test.ts | 26 +++++++++++++++++++ src/app/plugins/react-custom-html-parser.tsx | 2 ++ 6 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 src/app/phoneHit.content.test.ts diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx index 2dbaf1f1..8c28b935 100644 --- a/src/app/components/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -16,6 +16,7 @@ import { } from 'folds'; import { MatrixError } from 'matrix-js-sdk'; import { Cursor } from '../plugins/text-area'; +import { PhoneHit } from '../styles/PhoneHit.css'; import { syntaxErrorPosition } from '../utils/dom'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { Page, PageHeader } from './page'; @@ -294,7 +295,7 @@ export function AccountDataEditor({ - + diff --git a/src/app/components/image-pack-view/ImagePackView.tsx b/src/app/components/image-pack-view/ImagePackView.tsx index ab81d503..15b1cd08 100644 --- a/src/app/components/image-pack-view/ImagePackView.tsx +++ b/src/app/components/image-pack-view/ImagePackView.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds'; import { PackAddress } from '../../plugins/custom-emoji'; +import { PhoneHit } from '../../styles/PhoneHit.css'; import { Page, PageHeader, PageContent } from '../page'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { RoomImagePack } from './RoomImagePack'; @@ -29,7 +30,7 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) { - + diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index 2ffc9ec4..41cef5f4 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -2,6 +2,7 @@ import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'f import React, { ReactNode, useCallback } from 'react'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import FileSaver from 'file-saver'; +import { PhoneHit } from '../../styles/PhoneHit.css'; import { mimeTypeToExt } from '../../utils/mimeTypes'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -48,6 +49,7 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow variant={hasError ? 'Critical' : 'SurfaceVariant'} size="300" radii="300" + className={PhoneHit} > {downloading ? ( diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index 478486a4..94132cea 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -3,6 +3,7 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react'; import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { Range } from 'react-range'; +import { PhoneHitSpread24 } from '../../../styles/PhoneHit.css'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { IAudioInfo } from '../../../../types/matrix/common'; @@ -159,6 +160,7 @@ export function AudioContent({ variant="SurfaceVariant" size="300" radii="Pill" + className={PhoneHitSpread24} onClick={() => setMute(!mute)} aria-pressed={mute} > diff --git a/src/app/phoneHit.content.test.ts b/src/app/phoneHit.content.test.ts new file mode 100644 index 00000000..741a5828 --- /dev/null +++ b/src/app/phoneHit.content.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +describe('icon buttons in message content and data editors', () => { + // The class each IconButton must carry, in source order. + const expected: Record = { + // mute toggle, 16 px icon + 8 padding = 24 px, 8 px from the volume slider (tappable) + 'components/message/content/AudioContent.tsx': ['PhoneHitSpread24'], + // download, alone at the end of the header row (26 px) + 'components/message/FileHeader.tsx': ['PhoneHit'], + // code block expand/collapse, 24 px, 8 px from the Copy chip + 'plugins/react-custom-html-parser.tsx': ['PhoneHitSpread24'], + // close at the header end, alone + 'components/AccountDataEditor.tsx': ['PhoneHit'], + 'components/image-pack-view/ImagePackView.tsx': ['PhoneHit'], + }; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) => + expectSizeTie(file) + ); +}); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 961131d9..2398a4c9 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -22,6 +22,7 @@ import Linkify from 'linkify-react'; import { ErrorBoundary } from 'react-error-boundary'; import { ChildNode } from 'domhandler'; import * as css from '../styles/CustomHtml.css'; +import { PhoneHitSpread24 } from '../styles/PhoneHit.css'; import { getMxIdLocalPart, getCanonicalAliasRoomId, @@ -282,6 +283,7 @@ export function CodeBlock({ variant="SurfaceVariant" outlined radii="300" + className={PhoneHitSpread24} onClick={toggleExpand} aria-label={expanded ? 'Collapse' : 'Expand'} > From da05210db29eb55d2c27f7c0fb490c10f07f36fa Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:46 +0000 Subject: [PATCH 094/143] Phone hit: editor toolbar, emoji sidebar, upload cards, client root Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/editor/Toolbar.tsx | 9 +++ .../emoji-board/components/Sidebar.tsx | 2 + .../upload-card/CompactUploadCardRenderer.tsx | 2 + .../upload-card/UploadCardRenderer.tsx | 2 + src/app/pages/client/ClientRoot.tsx | 2 + src/app/phoneHit.editor.test.ts | 61 +++++++++++++++++++ 6 files changed, 78 insertions(+) create mode 100644 src/app/phoneHit.editor.test.ts diff --git a/src/app/components/editor/Toolbar.tsx b/src/app/components/editor/Toolbar.tsx index 7d701c42..3721b909 100644 --- a/src/app/components/editor/Toolbar.tsx +++ b/src/app/components/editor/Toolbar.tsx @@ -36,6 +36,7 @@ import { KeySymbol } from '../../utils/key-symbol'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { stopPropagation } from '../../utils/keyboard'; +import { PhoneHit, PhoneHitRoom36, PhoneHitSpread36 } from '../../styles/PhoneHit.css'; function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) { return ( @@ -72,6 +73,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) { {(triggerRef) => ( {(triggerRef) => ( {(triggerRef) => ( handleMenuSelect(1)} size="400" @@ -178,6 +182,7 @@ export function HeadingBlockButton() { > {(triggerRef) => ( handleMenuSelect(2)} size="400" @@ -193,6 +198,7 @@ export function HeadingBlockButton() { > {(triggerRef) => ( handleMenuSelect(3)} size="400" @@ -208,6 +214,7 @@ export function HeadingBlockButton() { } > {(triggerRef) => ( {(triggerRef) => ( setIsMarkdown(!isMarkdown)} diff --git a/src/app/components/emoji-board/components/Sidebar.tsx b/src/app/components/emoji-board/components/Sidebar.tsx index de22b483..d2d5f197 100644 --- a/src/app/components/emoji-board/components/Sidebar.tsx +++ b/src/app/components/emoji-board/components/Sidebar.tsx @@ -14,6 +14,7 @@ import { } from 'folds'; import classNames from 'classnames'; import * as css from './styles.css'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function Sidebar({ children }: { children: ReactNode }) { return ( @@ -68,6 +69,7 @@ function SidebarBtn({ > {(ref) => ( )} )} { + // The class each IconButton must carry, in source order. + const expected: Record = { + 'components/editor/Toolbar.tsx': [ + 'PhoneHitSpread36', // mark button: size 400 + 20 px icon, in a row 4 px apart + 'PhoneHitSpread36', // block button, same row + 'PhoneHitRoom36', // heading 1, inside the heading Menu (overflow hidden) + 'PhoneHitRoom36', // heading 2 + 'PhoneHitRoom36', // heading 3 + 'PhoneHit', // heading opener: two 20 px icons, 56 px wide, only its height is short + 'PhoneHit', // Exit chip: text label, wider than 44 px + 'PhoneHit', // markdown toggle, alone at the end of the toolbar + ], + // A column of 40 px buttons: there is no vertical variant, so only PhoneHit; neighbours overlap by their 2 px gap. + 'components/emoji-board/components/Sidebar.tsx': ['PhoneHit'], + // Both cards put a Retry chip beside the cancel button when the upload failed. + 'components/upload-card/UploadCardRenderer.tsx': ['PhoneHitSpread28'], + 'components/upload-card/CompactUploadCardRenderer.tsx': ['PhoneHitSpread28'], + 'pages/client/ClientRoot.tsx': ['PhoneHit'], // alone, absolute in the corner + }; + + const sizeAllow: SizeAllow = {}; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: Spread and Room variants name the size they draw', (file) => + expectSizeTie(file, sizeAllow) + ); +}); + +describe('the estate navigator find button', () => { + // The only IconButton there needs no PhoneHit: every button in the navigator header is 44 px on a phone because + // the Targets class sets a min size, and the button sits under that class. + it('is 44 px by the Targets class instead', async () => { + const src = readSource('features/estate/EstateNavigator.tsx'); + const tags = iconButtonTags(src); + expect(tags).toHaveLength(1); + expect(tags[0]).not.toMatch(/PhoneHit/); + const header = src.slice(src.indexOf('')); + expect(header.indexOf('className={css.Targets}')).toBeGreaterThan(-1); + expect(header.indexOf('className={css.Targets}')).toBeLessThan(header.indexOf(' Date: Fri, 13 Nov 2026 07:21:00 +0000 Subject: [PATCH 095/143] Phone hit kit: read the className expression, not its source text A PhoneHit name inside a string, built by concatenation, behind ?:/&&/||/??, or followed by a props spread that can override className no longer satisfies the pin. Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneHitTestKit.test.ts | 47 ++++++++++++++++++++++++++++++++- src/app/phoneHitTestKit.ts | 43 +++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/app/phoneHitTestKit.test.ts b/src/app/phoneHitTestKit.test.ts index 92f1b23f..bd5f8bcd 100644 --- a/src/app/phoneHitTestKit.test.ts +++ b/src/app/phoneHitTestKit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { drawnPx, expectSizeTie, iconButtons } from './phoneHitTestKit'; +import { drawnPx, expectSizeTie, iconButtons, phoneHitOf } from './phoneHitTestKit'; describe('phone hit test kit', () => { it('reads { @@ -38,4 +38,49 @@ describe('phone hit test kit', () => { it('fails a file with no IconButton, which would otherwise pass vacuously', () => { expect(() => expectSizeTie('styles/PhoneHit.css.ts')).toThrow(/no IconButton/); }); + + describe('phoneHitOf reads the imported class, applied unconditionally', () => { + const tag = (expr: string) => ``; + + it('accepts the bare identifier and merges that always include it', () => { + expect(phoneHitOf(tag('PhoneHitSpread26'))).toBe('PhoneHitSpread26'); + expect(phoneHitOf(tag('classNames(PhoneHitRoom36, css.other)'))).toBe('PhoneHitRoom36'); + expect(phoneHitOf(tag('`${PhoneHit} ${css.other}`'))).toBe('PhoneHit'); + expect(phoneHitOf(tag("[PhoneHit, 'a b'].join(' ')"))).toBe('PhoneHit'); + expect(phoneHitOf(tag('classNames(css.a, { [css.b]: on }, PhoneHit)'))).toBe('PhoneHit'); + }); + + it('rejects the name as a string literal', () => { + expect(phoneHitOf(tag("'PhoneHitSpread26'"))).toBeNull(); + expect(phoneHitOf(tag('`PhoneHitSpread26`'))).toBeNull(); + }); + + it('rejects a name built by concatenation', () => { + expect(phoneHitOf(tag('"PhoneHitSpread26" + "x"'))).toBeNull(); + expect(phoneHitOf(tag("'PhoneHit' + 'Spread26'"))).toBeNull(); + }); + + it('rejects a conditionally applied name', () => { + expect(phoneHitOf(tag('cond ? PhoneHitSpread26 : undefined'))).toBeNull(); + expect(phoneHitOf(tag('cond && PhoneHitSpread26'))).toBeNull(); + expect(phoneHitOf(tag('cond || PhoneHitSpread26'))).toBeNull(); + expect(phoneHitOf(tag('maybe ?? PhoneHitSpread26'))).toBeNull(); + expect(phoneHitOf(tag("classNames(cond ? PhoneHit : '', css.a)"))).toBeNull(); + }); + + it('rejects a member of another object and a tag without className', () => { + expect(phoneHitOf(tag('other.PhoneHit'))).toBeNull(); + expect(phoneHitOf('')).toBeNull(); + }); + + it('rejects a spread after className, which can override it, and accepts one before', () => { + expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBe('PhoneHit'); + }); + + it('reads a className past braces inside strings', () => { + expect(phoneHitOf(tag("classNames('{', PhoneHit)"))).toBe('PhoneHit'); + }); + }); }); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index a29dcc22..d86f23f5 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -85,7 +85,48 @@ export const iconButtons = (src: string) => export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag); -const phoneHitOf = (tag: string) => tag.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null; +/** The expression of a tag's className={...} and the index of its closing brace, read to the balanced brace + * (strings and templates may hold braces); undefined when the tag has none. */ +const classNameExpr = (tag: string) => { + const m = /\sclassName=\{/.exec(tag); + if (!m) return undefined; + const from = m.index + m[0].length; + let depth = 1; + let quote = ''; + for (let j = from; j < tag.length; j += 1) { + const c = tag[j]; + if (quote) { + if (c === '\\') j += 1; + else if (c === quote) quote = ''; + } else if (c === '"' || c === "'" || c === '`') quote = c; + else if (c === '{') depth += 1; + else if (c === '}' && (depth -= 1) === 0) return { expr: tag.slice(from, j), end: j }; + } + return undefined; +}; + +/** The expression with its string literals and the literal text of its templates removed (a `${...}` keeps its + * expression), so only identifiers and operators remain. */ +const withoutLiterals = (expr: string) => + expr + .replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, ' ') + .replace(/`((?:[^`\\]|\\.)*)`/g, (_, body: string) => + [...body.matchAll(/\$\{([^}]*)\}/g)].map((t) => ` ${t[1]} `).join(' ') + ); + +/** The PhoneHit* class a tag carries: a bare identifier in its className expression, applied unconditionally. A + * name inside a string, built by concatenation, or behind ?:, &&, || or ??, or followed by a {...spread}, is not the + * imported class (or not always applied), so it reads as null. */ +export const phoneHitOf = (tag: string) => { + const found = classNameExpr(tag); + if (found === undefined) return null; + // props spread in after className can override it at runtime; before it they cannot + if (tag.slice(found.end).includes('{...')) return null; + const bare = withoutLiterals(found.expr); + if (/\?|&&|\|\|/.test(bare)) return null; + const names = [...bare.matchAll(/(? m[1]); + return names.length === 1 ? names[0] : null; +}; /** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and * whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks From 761d13fac5ca6b1d267eaba32bdc96c437d75b5f Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:21:57 +0000 Subject: [PATCH 096/143] Phone hit editor tests: pin estate Targets nesting and selector, ClientRoot inline absolute, sidebar gap Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneHit.editor.test.ts | 60 +++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/src/app/phoneHit.editor.test.ts b/src/app/phoneHit.editor.test.ts index 98bfc051..ef016b59 100644 --- a/src/app/phoneHit.editor.test.ts +++ b/src/app/phoneHit.editor.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; import { + classLists, emitted, expectPhoneHitClasses, expectSizeTie, @@ -23,7 +24,8 @@ describe('icon buttons in the editor, emoji board, upload cards and client root' 'PhoneHit', // Exit chip: text label, wider than 44 px 'PhoneHit', // markdown toggle, alone at the end of the toolbar ], - // A column of 40 px buttons: there is no vertical variant, so only PhoneHit; neighbours overlap by their 2 px gap. + // A column of 40 px buttons 4 px apart (the stack's gap="100", pinned below): there is no vertical variant, so only + // PhoneHit; the 44 px areas then touch with no overlap. 'components/emoji-board/components/Sidebar.tsx': ['PhoneHit'], // Both cards put a Retry chip beside the cancel button when the upload failed. 'components/upload-card/UploadCardRenderer.tsx': ['PhoneHitSpread28'], @@ -43,19 +45,63 @@ describe('icon buttons in the editor, emoji board, upload cards and client root' ); }); +/** The JSX element opened at `open` (index of its ``: nesting is counted, not guessed (no self-closing Box here). */ +const boxSpan = (src: string, open: number) => { + const marks = [...src.slice(open).matchAll(/])|<\/Box>/g)]; + const depths = marks.map((m, i) => + marks.slice(0, i + 1).reduce((d, x) => d + (x[0] === '' ? -1 : 1), 0) + ); + const end = depths.indexOf(0); + if (end === -1) throw new Error('unbalanced '); + return [open, open + (marks[end].index ?? 0) + marks[end][0].length] as const; +}; + describe('the estate navigator find button', () => { // The only IconButton there needs no PhoneHit: every button in the navigator header is 44 px on a phone because - // the Targets class sets a min size, and the button sits under that class. + // the Targets class sets a min size, and the button sits inside the Box that carries that class. it('is 44 px by the Targets class instead', async () => { const src = readSource('features/estate/EstateNavigator.tsx'); const tags = iconButtonTags(src); expect(tags).toHaveLength(1); expect(tags[0]).not.toMatch(/PhoneHit/); - const header = src.slice(src.indexOf('')); - expect(header.indexOf('className={css.Targets}')).toBeGreaterThan(-1); - expect(header.indexOf('className={css.Targets}')).toBeLessThan(header.indexOf(''); + const boxStart = src.indexOf('', boxStart))).toMatch(/className=\{css\.Targets\}/); + const button = src.indexOf(' { + // PhoneHit makes the button position: relative on a phone; the inline style wins over a class, so the corner + // button stays absolute and its ::after anchors to it. + it('ClientRoot keeps its corner button absolutely positioned inline', () => { + const [tag] = iconButtonTags(readSource('pages/client/ClientRoot.tsx')); + expect(tag).toMatch(/style=\{\{[^}]*position:\s*'absolute'/); + }); + + // A 40 px button with a 44 px area, in a column 4 px apart: the areas touch and do not overlap. A smaller gap + // overlaps them, a larger one leaves a dead strip. + it('the emoji sidebar stack keeps its 4 px gap', () => { + const src = readSource('components/emoji-board/components/Sidebar.tsx'); + const stack = src.slice( + src.indexOf('export const SidebarStack'), + src.indexOf('export function SidebarDivider') + ); + expect(stack).toMatch(/direction="Column"/); + expect(stack).toMatch(/gap="100"/); }); }); From 4eaa6f9c55469b4c2e2ff7e4a45d3fc679737080 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:28:37 +0000 Subject: [PATCH 097/143] Phone hit kit: read IconButton className off the TypeScript AST className must appear once, with no spread after it, as the imported PhoneHit* identifier bare, a classNames/clsx/cx direct argument, or a template span, and the identifier must occur nowhere else in the expression. Tag enumeration uses the same AST. expectPhoneHitClasses takes (string|null)[] (null = no class), as the phone-hit-pages branch does, and is exercised by a test on a temp source. Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneHitTestKit.test.ts | 114 ++++++++++++++++----- src/app/phoneHitTestKit.ts | 171 ++++++++++++++++++++------------ 2 files changed, 196 insertions(+), 89 deletions(-) diff --git a/src/app/phoneHitTestKit.test.ts b/src/app/phoneHitTestKit.test.ts index bd5f8bcd..a77a2e27 100644 --- a/src/app/phoneHitTestKit.test.ts +++ b/src/app/phoneHitTestKit.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { drawnPx, expectSizeTie, iconButtons, phoneHitOf } from './phoneHitTestKit'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { relative } from 'path'; +import { + drawnPx, + expectPhoneHitClasses, + expectSizeTie, + iconButtons, + phoneHitOf, +} from './phoneHitTestKit'; describe('phone hit test kit', () => { it('reads { @@ -39,38 +48,60 @@ describe('phone hit test kit', () => { expect(() => expectSizeTie('styles/PhoneHit.css.ts')).toThrow(/no IconButton/); }); - describe('phoneHitOf reads the imported class, applied unconditionally', () => { + describe('phoneHitOf reads the imported class off the AST, applied unconditionally', () => { const tag = (expr: string) => ``; + const ok = (expr: string, name: string) => expect(phoneHitOf(tag(expr))).toBe(name); + const no = (expr: string) => expect(phoneHitOf(tag(expr))).toBeNull(); it('accepts the bare identifier and merges that always include it', () => { - expect(phoneHitOf(tag('PhoneHitSpread26'))).toBe('PhoneHitSpread26'); - expect(phoneHitOf(tag('classNames(PhoneHitRoom36, css.other)'))).toBe('PhoneHitRoom36'); - expect(phoneHitOf(tag('`${PhoneHit} ${css.other}`'))).toBe('PhoneHit'); - expect(phoneHitOf(tag("[PhoneHit, 'a b'].join(' ')"))).toBe('PhoneHit'); - expect(phoneHitOf(tag('classNames(css.a, { [css.b]: on }, PhoneHit)'))).toBe('PhoneHit'); + ok('PhoneHitSpread26', 'PhoneHitSpread26'); + ok('classNames(PhoneHitRoom36, css.other)', 'PhoneHitRoom36'); + ok('clsx(css.a, { [css.b]: on }, PhoneHit)', 'PhoneHit'); + ok('cx(PhoneHit)', 'PhoneHit'); + ok('`${PhoneHit} ${css.other}`', 'PhoneHit'); + ok('classNames(/* PhoneHit */ PhoneHit)', 'PhoneHit'); }); - it('rejects the name as a string literal', () => { - expect(phoneHitOf(tag("'PhoneHitSpread26'"))).toBeNull(); - expect(phoneHitOf(tag('`PhoneHitSpread26`'))).toBeNull(); - }); - - it('rejects a name built by concatenation', () => { - expect(phoneHitOf(tag('"PhoneHitSpread26" + "x"'))).toBeNull(); - expect(phoneHitOf(tag("'PhoneHit' + 'Spread26'"))).toBeNull(); + it('rejects the name as a string, a concatenation or a comment', () => { + no("'PhoneHitSpread26'"); + no('`PhoneHitSpread26`'); + no('"PhoneHitSpread26" + "x"'); + no("'PhoneHit' + 'Spread26'"); + no('/* PhoneHit */ undefined'); + no('css.a // PhoneHit'); }); it('rejects a conditionally applied name', () => { - expect(phoneHitOf(tag('cond ? PhoneHitSpread26 : undefined'))).toBeNull(); - expect(phoneHitOf(tag('cond && PhoneHitSpread26'))).toBeNull(); - expect(phoneHitOf(tag('cond || PhoneHitSpread26'))).toBeNull(); - expect(phoneHitOf(tag('maybe ?? PhoneHitSpread26'))).toBeNull(); - expect(phoneHitOf(tag("classNames(cond ? PhoneHit : '', css.a)"))).toBeNull(); + no('cond ? PhoneHitSpread26 : undefined'); + no('cond && PhoneHitSpread26'); + no('cond || PhoneHitSpread26'); + no('maybe ?? PhoneHitSpread26'); + no("classNames(cond ? PhoneHit : '', css.a)"); + no('classNames(PhoneHit, cond && PhoneHit)'); }); - it('rejects a member of another object and a tag without className', () => { - expect(phoneHitOf(tag('other.PhoneHit'))).toBeNull(); + it('rejects every other expression shape the name can hide in', () => { + no('classNames({ PhoneHit: on })'); + no('clsx({ [PhoneHit]: on })'); + no('`${`${PhoneHit}`}`'); + no('typeof PhoneHit'); + no("(PhoneHit = 'x')"); + no('(PhoneHit, css.a)'); + no('(PhoneHit)'); + no('() => PhoneHit'); + no('PhoneHit.foo'); + no('other.PhoneHit'); + no('classNames(css.a, [PhoneHit])'); + no('wrap(PhoneHit)'); + no('PhoneHitSpread26é'); + no('`${css.a}`'); + }); + + it('rejects a tag without className, with two, or with a non-expression one', () => { expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBe('PhoneHit'); }); it('rejects a spread after className, which can override it, and accepts one before', () => { @@ -80,7 +111,44 @@ describe('phone hit test kit', () => { }); it('reads a className past braces inside strings', () => { - expect(phoneHitOf(tag("classNames('{', PhoneHit)"))).toBe('PhoneHit'); + ok("classNames('{', PhoneHit)", 'PhoneHit'); + }); + }); + + describe('expectPhoneHitClasses', () => { + const check = (src: string, want: (string | null)[]) => { + const dir = mkdtempSync(`${tmpdir()}/phone-hit-kit-`); + try { + writeFileSync(`${dir}/Probe.tsx`, src); + expectPhoneHitClasses(relative(`${process.cwd()}/src/app`, `${dir}/Probe.tsx`), want); + } finally { + rmSync(dir, { recursive: true }); + } + }; + const imp = "import { PhoneHit, PhoneHitSpread26 } from '../../styles/PhoneHit.css';\n"; + const src = + imp + + 'const A = () => (<>);'; + + it('pins each button in order, null meaning no class', () => { + check(src, ['PhoneHit', 'PhoneHitSpread26', null]); + expect(() => check(src, ['PhoneHit', 'PhoneHit', null])).toThrow(); + expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26', 'PhoneHit'])).toThrow(); + expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26'])).toThrow(); + }); + + it('fails a name that is not read from PhoneHit.css or is applied conditionally', () => { + const local = "const PhoneHit = 'x';\n"; + expect(() => check(local, ['PhoneHit'])).toThrow(); + expect(() => check(local, [null])).not.toThrow(); + const cond = imp + ''; + expect(() => check(cond, ['PhoneHit'])).toThrow(); + const aliased = + "import { Other as PhoneHit } from '../../styles/PhoneHit.css';\n"; + expect(() => check(aliased, ['PhoneHit'])).toThrow(); + const wrongModule = + "import { PhoneHit } from './other';\n"; + expect(() => check(wrongModule, ['PhoneHit'])).toThrow(); }); }); }); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index d86f23f5..896b917b 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -2,6 +2,7 @@ // group of IconButtons). Not a test file: it registers nothing, the test files call it. import { readFileSync } from 'fs'; import { expect } from 'vitest'; +import ts from 'typescript'; import zlib from 'zlib'; import { compile, processVanillaFile } from '@vanilla-extract/integration'; @@ -72,77 +73,113 @@ const tagEnd = (src: string, from: number) => { return j; }; -/** Every outside any {} expression, and - * what it wraps (empty when self-closing). */ +const tsx = (src: string) => + ts.createSourceFile('source.tsx', src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + +type Opening = ts.JsxOpeningElement | ts.JsxSelfClosingElement; + +/** Every IconButton element (not IconButtonX) of a parsed source, in source order. */ +const elements = (sf: ts.SourceFile) => { + const found: Opening[] = []; + const walk = (n: ts.Node) => { + if ( + (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) && + ts.isIdentifier(n.tagName) && + n.tagName.text === 'IconButton' + ) + found.push(n); + ts.forEachChild(n, walk); + }; + walk(sf); + return found; +}; + +const bodyOf = (el: Opening) => + ts.isJsxOpeningElement(el) && ts.isJsxElement(el.parent) + ? el.getSourceFile().text.slice(el.end, el.parent.closingElement.getStart()) + : ''; + +/** Every - [...src.matchAll(//])/g)].map((m) => { - const start = m.index ?? 0; - const end = tagEnd(src, start); - const tag = src.slice(start, end + 1); - const close = src.indexOf('', end); - return { tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) }; - }); + elements(tsx(src)).map((el) => ({ tag: el.getText(), body: bodyOf(el) })); export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag); -/** The expression of a tag's className={...} and the index of its closing brace, read to the balanced brace - * (strings and templates may hold braces); undefined when the tag has none. */ -const classNameExpr = (tag: string) => { - const m = /\sclassName=\{/.exec(tag); - if (!m) return undefined; - const from = m.index + m[0].length; - let depth = 1; - let quote = ''; - for (let j = from; j < tag.length; j += 1) { - const c = tag[j]; - if (quote) { - if (c === '\\') j += 1; - else if (c === quote) quote = ''; - } else if (c === '"' || c === "'" || c === '`') quote = c; - else if (c === '{') depth += 1; - else if (c === '}' && (depth -= 1) === 0) return { expr: tag.slice(from, j), end: j }; - } - return undefined; -}; - -/** The expression with its string literals and the literal text of its templates removed (a `${...}` keeps its - * expression), so only identifiers and operators remain. */ -const withoutLiterals = (expr: string) => - expr - .replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, ' ') - .replace(/`((?:[^`\\]|\\.)*)`/g, (_, body: string) => - [...body.matchAll(/\$\{([^}]*)\}/g)].map((t) => ` ${t[1]} `).join(' ') - ); - -/** The PhoneHit* class a tag carries: a bare identifier in its className expression, applied unconditionally. A - * name inside a string, built by concatenation, or behind ?:, &&, || or ??, or followed by a {...spread}, is not the - * imported class (or not always applied), so it reads as null. */ -export const phoneHitOf = (tag: string) => { - const found = classNameExpr(tag); - if (found === undefined) return null; - // props spread in after className can override it at runtime; before it they cannot - if (tag.slice(found.end).includes('{...')) return null; - const bare = withoutLiterals(found.expr); - if (/\?|&&|\|\|/.test(bare)) return null; - const names = [...bare.matchAll(/(? m[1]); - return names.length === 1 ? names[0] : null; -}; - -/** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and - * whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks - * the name is the one PhoneHit.css.ts exports, not a local of the same name. */ -export const expectPhoneHitClasses = (file: string, want: string[]) => { - const src = readSource(file); - expect(iconButtonTags(src).map(phoneHitOf)).toEqual(want); - [...new Set(want)].forEach((name) => - expect(src).toMatch( - new RegExp( - `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` - ) - ) +/** Local names the source imports from the PhoneHit.css module (as written, not aliased). */ +const importedHits = (sf: ts.SourceFile) => + new Set( + sf.statements.flatMap((st) => { + const nb = ts.isImportDeclaration(st) ? st.importClause?.namedBindings : undefined; + return nb && + ts.isNamedImports(nb) && + ts.isStringLiteral((st as ts.ImportDeclaration).moduleSpecifier) && + /^(\.\.\/)+styles\/PhoneHit\.css$/.test( + ((st as ts.ImportDeclaration).moduleSpecifier as ts.StringLiteral).text + ) + ? nb.elements.filter((e) => !e.propertyName).map((e) => e.name.text) + : []; + }) ); + +const MERGERS = new Set(['classNames', 'clsx', 'cx']); + +/** The PhoneHit* class an IconButton carries, or null. className must appear exactly once, with no props spread + * after it (a spread can override it at runtime), and its expression must be: the bare identifier; a classNames / + * clsx / cx call with it as a direct argument; or a template with it as a direct span. The identifier must occur + * nowhere else in the expression, so a key, a member, a closure, a conditional or a comma can never carry it. When + * `imported` is given, the identifier must also be a binding imported from the PhoneHit.css module. */ +const hitOf = (el: Opening, imported?: Set): string | null => { + const props = el.attributes.properties; + const at = props.flatMap((p, i) => + ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === 'className' ? [i] : [] + ); + if (at.length !== 1 || props.slice(at[0] + 1).some(ts.isJsxSpreadAttribute)) return null; + const attr = props[at[0]] as ts.JsxAttribute; + const expr = + attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression; + if (!expr) return null; + const isHit = (n: ts.Node): n is ts.Identifier => + ts.isIdentifier(n) && /^PhoneHit\w*$/.test(n.text) && (!imported || imported.has(n.text)); + let direct: ts.Node[] = []; + if (ts.isIdentifier(expr)) direct = [expr]; + else if ( + ts.isCallExpression(expr) && + ts.isIdentifier(expr.expression) && + MERGERS.has(expr.expression.text) + ) + direct = [...expr.arguments]; + else if (ts.isTemplateExpression(expr)) direct = expr.templateSpans.map((sp) => sp.expression); + const hits = direct.filter(isHit); + if (hits.length !== 1) return null; + let occurrences = 0; + const count = (n: ts.Node) => { + if (ts.isIdentifier(n) && n.text === hits[0].text) occurrences += 1; + ts.forEachChild(n, count); + }; + count(expr); + return occurrences === 1 ? hits[0].text : null; }; +/** The PhoneHit* class of one tag's text (see hitOf), without the import-binding check. */ +export const phoneHitOf = (tag: string) => { + const els = elements(tsx(tag.endsWith('/>') ? tag : `${tag}`)); + return els.length === 1 ? hitOf(els[0]) : null; +}; + +/** The PhoneHit* class of each IconButton of a source, in source order; null when it has none or the name is not + * the one imported from PhoneHit.css. */ +const phoneHitClasses = (src: string) => { + const sf = tsx(src); + const imported = importedHits(sf); + return elements(sf).map((el) => hitOf(el, imported)); +}; + +/** The class each IconButton must carry, in source order (null = must carry none): the variant is chosen by the + * button's drawn size and whether a clipping container or neighbours surround it, so a swapped variant is a wrong + * hit area. The name must also be the one PhoneHit.css exports, not a local of the same name. */ +export const expectPhoneHitClasses = (file: string, want: (string | null)[]) => + expect(phoneHitClasses(readSource(file))).toEqual(want); + // IconButton `size` -> padding on each side, Icon `size` -> px (folds 2.7.2). Both default to 400. const BUTTON_PAD: Record = { '300': 4, '400': 8, '500': 12, '600': 16 }; const ICON_PX: Record = { @@ -188,10 +225,12 @@ export type SizeAllow = Record>; export const expectSizeTie = (file: string, allow: SizeAllow = {}) => { const allowed = allow[file] ?? {}; const seen = new Set(); - const buttons = iconButtons(readSource(file)); + const src = readSource(file); + const buttons = iconButtons(src); + const classes = phoneHitClasses(src); expect(buttons.length, `${file} has no IconButton`).toBeGreaterThan(0); buttons.forEach(({ tag, body }, i) => { - const variant = phoneHitOf(tag)?.match(/^PhoneHit(?:Spread|Room)(\d+)$/); + const variant = classes[i]?.match(/^PhoneHit(?:Spread|Room)(\d+)$/); if (!variant) { expect(allowed[i], `${file} #${i} is allow-listed but has no sized variant`).toBeUndefined(); return; From d5a0c1b4ffd4b4248fecbaa3c4d18861f53c1126 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 13:46:44 +0000 Subject: [PATCH 098/143] Phone hit: a repo-wide guard for bare IconButtons, PhoneHitInPlace for the preview arrows, drop the dead Editor.preview phoneHit.coverage.test.ts walks every .tsx under src/app and fails on any IconButton without a PhoneHit class unless it is named with its reason (EstateNavigator Find a room, LobbyHeader Members). UrlPreviewCard's two absolute arrows take PhoneHitInPlace, which adds the hit area without PhoneHit's position: relative. Editor.preview.tsx was imported nowhere and held 11 bare IconButtons. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/editor/Editor.preview.tsx | 84 ------------------- .../components/url-preview/UrlPreviewCard.tsx | 12 ++- src/app/phoneHit.coverage.test.ts | 58 +++++++++++++ src/app/phoneHitTestKit.ts | 2 +- src/app/phoneTapTargets.test.ts | 10 ++- src/app/styles/PhoneHit.css.ts | 4 + 6 files changed, 80 insertions(+), 90 deletions(-) delete mode 100644 src/app/components/editor/Editor.preview.tsx create mode 100644 src/app/phoneHit.coverage.test.ts diff --git a/src/app/components/editor/Editor.preview.tsx b/src/app/components/editor/Editor.preview.tsx deleted file mode 100644 index b760dddc..00000000 --- a/src/app/components/editor/Editor.preview.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { useState } from 'react'; -import FocusTrap from 'focus-trap-react'; -import { - config, - Icon, - IconButton, - Icons, - Line, - Modal, - Overlay, - OverlayBackdrop, - OverlayCenter, -} from 'folds'; - -import { CustomEditor, useEditor } from './Editor'; -import { Toolbar } from './Toolbar'; -import { stopPropagation } from '../../utils/keyboard'; - -export function EditorPreview() { - const [open, setOpen] = useState(false); - const editor = useEditor(); - const [toolbar, setToolbar] = useState(false); - - return ( - <> - setOpen(!open)}> - - - }> - - setOpen(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - -
        - - - - } - after={ - <> - setToolbar(!toolbar)} - aria-pressed={toolbar} - > - - - - - - - - - - } - bottom={ - toolbar && ( -
        - - -
        - ) - } - /> -
        -
        -
        -
        -
        - - ); -} diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index ee8967af..5d40e10c 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -1,3 +1,4 @@ +import classNames from 'classnames'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { IPreviewUrlResponse } from 'matrix-js-sdk'; import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds'; @@ -10,6 +11,7 @@ import { useIntersectionObserver, } from '../../hooks/useIntersectionObserver'; import * as css from './UrlPreviewCard.css'; +import { PhoneHitInPlace } from '../../styles/PhoneHit.css'; import { tryDecodeURIComponent } from '../../utils/dom'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -181,7 +183,10 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => { <>
        (({ children, ...props }, ref) => { <>
        = { + 'features/estate/EstateNavigator.tsx': { + indices: [0], + why: 'Find a room sits in a css.Targets row that already gives it a 44 px phone target', + }, + 'features/lobby/LobbyHeader.tsx': { + indices: [1], + why: 'Members button, hidden on mobile', + }, +}; + +const tsxFiles = (dir: string): string[] => + readdirSync(`${process.cwd()}/src/app/${dir}`, { withFileTypes: true }).flatMap((e) => { + if (e.isDirectory()) return tsxFiles(`${dir}${e.name}/`); + return e.name.endsWith('.tsx') ? [`${dir}${e.name}`] : []; + }); + +describe('phone hit areas: every IconButton is covered or named', () => { + it('lists exactly the IconButtons without a PhoneHit class', () => { + const bare: Record = {}; + tsxFiles('').forEach((file) => { + const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); + if (!src.includes(' (c === null ? [i] : [])); + if (missing.length > 0) bare[file] = missing; + }); + expect(bare).toEqual( + Object.fromEntries(Object.entries(EXEMPT).map(([f, e]) => [f, e.indices])) + ); + }); +}); + +// UrlPreviewCard's arrows are absolutely positioned by their own class; PhoneHit would set position: relative on them +// and win or lose by stylesheet order, so they carry the variant that adds only the hit area. +describe('phone hit area of a button positioned by its own class', () => { + it('the arrows carry PhoneHitInPlace', () => { + expectPhoneHitClasses('components/url-preview/UrlPreviewCard.tsx', [ + 'PhoneHitInPlace', + 'PhoneHitInPlace', + ]); + }); + + it('PhoneHitInPlace draws the ::after and sets no position of its own', async () => { + const lists = await classLists('styles/PhoneHit.css.ts'); + expect(lists.PhoneHitInPlace).toHaveLength(1); + const css = await emitted('styles/PhoneHit.css.ts'); + const rules = [...css.matchAll(/^([^{}\n]*PhoneHitInPlace__[^{}\n]*) \{$/gm)].map((m) => m[1]); + expect(rules.length).toBeGreaterThan(0); + rules.forEach((sel) => expect(sel).toMatch(/::after$/)); + }); +}); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index 896b917b..d8449cba 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -168,7 +168,7 @@ export const phoneHitOf = (tag: string) => { /** The PhoneHit* class of each IconButton of a source, in source order; null when it has none or the name is not * the one imported from PhoneHit.css. */ -const phoneHitClasses = (src: string) => { +export const phoneHitClasses = (src: string) => { const sf = tsx(src); const imported = importedHits(sf); return elements(sf).map((el) => hitOf(el, imported)); diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index 5a5394e7..192f8414 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -102,13 +102,16 @@ describe('phone icon-button hit areas', () => { expect(css).toMatch(/::after \{[^}]*transform: translate\(-50%, -50%\);/); // the box anchors to its own button, receives the tap, and sits above nothing it should not expect(css).toMatch(/@media [^{]+ \{\n\s*\.PhoneHit_PhoneHit__\w+ \{\n\s*position: relative;/); - const afterDecls = [...css.matchAll(/::after \{\n([^}]*)\}/g)].flatMap((m) => + // PhoneHit and PhoneHitInPlace each draw it; the Spread and Room classes compose PhoneHit + const afters = [...css.matchAll(/::after \{\n([^}]*)\}/g)]; + expect(afters).toHaveLength(2); + const afterDecls = afters.flatMap((m) => m[1] .split('\n') .map((l) => l.trim().split(':')[0]) .filter(Boolean) ); - expect([...afterDecls].sort()).toEqual([ + expect([...new Set(afterDecls)].sort()).toEqual([ 'content', 'height', 'left', @@ -117,8 +120,9 @@ describe('phone icon-button hit areas', () => { 'transform', 'width', ]); - // nothing on a phone moves a button off its own anchor: only relative (the button) and absolute (the box) + // nothing on a phone moves a button off its own anchor: only relative (the button) and absolute (the boxes) expect([...css.matchAll(/position: (\w+);/g)].map((m) => m[1]).sort()).toEqual([ + 'absolute', 'absolute', 'relative', ]); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 9863851b..e971ecb3 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -29,6 +29,10 @@ export const PhoneHit = style({ selectors: { '&::after': hitArea }, }); +/** For a button that is already positioned (absolute or fixed) by its own class: PhoneHit's `position: relative` + * would fight that class by stylesheet order, and the positioned button already contains its ::after. */ +export const PhoneHitInPlace = style({ selectors: { '&::after': hitArea } }); + const spreads = new Map(); /** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */ From 88fd0a889a3d75b3cf0b9c274b955c5fdeaa560c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 14:30:46 +0000 Subject: [PATCH 099/143] docs: Commons and per-Persona Files desk, design only Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 105 +++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/commons-files-desk.md diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md new file mode 100644 index 00000000..9956cc60 --- /dev/null +++ b/docs/commons-files-desk.md @@ -0,0 +1,105 @@ +# Files desk: Commons and per-Persona file zones + +Status: design only. No code, nothing deployed. Items marked **UNKNOWN** are not verified against the Body; **Q-E** / **Q-M** are open questions for Eureka (host) and Meta (audience, uploads), collected at the end. + +Source: Meta, #swarm 2026-11-13 14:28Z; ruling `ratatoskr:ruling:persona-file-sharing-zone` (each Persona has her own file sharing zone the frontend shows: a browsable, previewing collection; the Self writes to her outbox, her Persona decides what is shared, the Self never holds Matrix credentials). + +## What exists, and what this adds + +| | Matrix file zone (`docs/file-zone.md`, built) | Files desk (this page) | +| -------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Storage | Matrix media in a `#files-` room, albums as threads | Directories on Yggdrasil, served by a small host | +| Listing | thread roots, `buildAlbums` | `GET /index?path=` | +| Unit | album of posted files | file in a directory tree | +| Strength | marks (keep / fix / drop), receipts (`fetched for`), any Matrix client works | whole trees without N uploads, no Matrix size limit, `/opt/commons` material becomes visible to people on a phone | +| Entry | Media row per Mind (`MediaRow.tsx`) | Files row (below) | + +They do not compete. The Matrix zone stays where a file needs a mark or a receipt (Aqua's listening kits); the Files desk is for browsing and fetching what is already on disk. Neither replaces the other, and the Files desk does not write Matrix events. The ruling left "Matrix media plus a listing versus a served directory" open; this page is the served-directory half, built beside the Matrix half already live. + +## Zones + +| Zone | Contents | Who sees it | +| ---------------- | ------------------------------------------------------------ | -------------------------------------------------------------- | +| Commons | a curated tree of `/opt/commons` | members and Minds | +| Persona `` | what that Persona has chosen to share from her Self's outbox | **Q-M:** all members, or the Persona's stewards and Minds only | +| handoff-private | `/opt/commons/handoff-private` (0750) | never: not a zone, not listed, not a path the host resolves | + +Layout of `/opt/commons` today (read 2026-11-13): `backlog` (755, per-Mind backlog md and summary.json), `bin`, `cast-art` (setgid, group-writable: poses, busts), `handoff` (setgid, group-writable, 92 entries of briefs, patches, receipts, dings, kits), `handoff-private` (0750, ACL), `opt` (toolchains, root-owned), `situation` (0750, setgid), all `nobody:nogroup`. **`handoff` is not a publishable tree:** it holds ops notes, custody receipts (`bifrost-appsecret-custody-receipt-*`) and patches written for Minds, with no sticky bit. Serving it wholesale shows secrets-adjacent notes to every member and lets any Mind with group write change what a phone user sees. The Commons zone is therefore an allowlist of roots (candidate: `cast-art`, `backlog`, and a new `commons/` directory Minds publish into on purpose), not `/opt/commons` itself. **Q-E:** which roots; whether `handoff` ever, and if so through a reviewed `published/` subdirectory. + +Per-Persona source is the outbox from `ratatoskr:follow_up:album-outbox-ops-and-persona-specs`: `/var/lib/mind--outbox`, owned by `mind--self`, 0750, ACL read for `mind-` and the host user. The Self writes there; the Persona decides what is shared. Making that decision a filesystem fact (a `shared/` subdirectory the Persona's turn populates by copy or hardlink, the host serves only `shared/`) keeps the host dumb: it never learns what "decides" means. **Q-E:** `shared/` subdirectory versus a per-file marker. + +## Phone-first UI + +Phone width is the design width; desktop adds the navigator beside it (as `Desk` does in `Router.tsx`). Vertical space is cheap, so a screen shows one thing and hides what is not in use. + +- **Entry.** A `Files` row pinned in the navigator beside `DeskEntry` and `MindMedia`: one at the estate level (Commons) and one inside each Mind's group (her zone), same `NavItem` anatomy as `DeskEntry`, dim when the zone has no items. Route `/files/:zone/*path`, sibling of `DESK_PATH`. +- **List.** One column. Row = icon or thumbnail, name (truncates in the middle, keeping the extension), size and age on a second dim line. Directories first. Rows are at least 56 px tall; the tap target is the whole row (the `PhoneHit` variants and the repo-wide guard test from `phoneHit.coverage.test.ts` apply to any IconButton inside). +- **Hidden until needed.** Breadcrumb is one line (current folder, a back arrow; the full path on tap). Search, sort and the grid toggle sit behind a single icon in the header and open inline above the list. Download and share appear on the open file, not on every row. Nothing is permanently stacked in the header. +- **Preview.** Tap a file: it opens in place below its row, full width, and pushes the rows below it down (vertical room is the cheap axis). Image: fitted, tap for the existing `ImageViewer`. Audio: the `AudioContent` controls rehosted (`renderMediaControl`), one player at a time, a mini-player pinned to the bottom only while something plays. Video: native controls. Text, md, json, patch: monospace, first 64 KiB, "show all" below. PDF: open in a new tab. Anything else: name, size, hash, one Download button. +- **Honesty rows.** Loading, empty, "host unreachable", "not allowed" are rows that say so (as `MediaNote` does); no spinner without a name for what it waits on. +- **Role-driven.** The index response carries the caller's role; the client uses it only to hide controls (upload), never to decide access. + +## Host contract + +The host is a service on Yggdrasil that Eureka owns. The client holds no filesystem knowledge. All JSON here is the xenos boundary between a browser and a file server, not state. + +- `GET /index?path=

        ` returns `{ role, zone, path, entries: [{ name, kind: "file"|"dir", size, mtime, mime, hash }] }`. `mtime` is unix seconds. `hash` is the lower-case hex sha256 of the plaintext bytes, absent for directories, and may be absent for a file whose hash is not computed yet (client shows nothing, never blocks on it). `mime` is sniffed by the host from magic bytes, not guessed from the extension. +- `GET /file?path=

        ` streams the bytes with `Accept-Ranges: bytes` and 206 for `Range` (audio and video seek; a phone on mobile data does not fetch 40 MB to play 10 seconds). `Content-Type` is the sniffed mime, `X-Content-Type-Options: nosniff`, `Content-Disposition: attachment` for anything that can run in the origin (`html`, `svg`, `js`, `xhtml`), and a `Content-Security-Policy: sandbox` on every response. +- `path` is `/`, with the zone one of the names the host announces. The host resolves the real path of every request and refuses anything that leaves the zone's root: `..`, absolute paths, NUL, symlinks that cross the root, dotfiles, and `handoff-private`, which is not on any allowlist. Errors are typed (`401 unauthenticated`, `403 zone not allowed`, `404`, `416`), and a path outside the allowlist is 404, not 403, so it says nothing about what exists. +- **Auth: Matrix OpenID.** The client calls `mx.getOpenIdToken()` (already used in `CallWidgetDriver.ts`) and sends it as `Authorization: Bearer `. The host verifies it against the homeserver's federation `openid/userinfo` endpoint, **pinned to our server name** (an `@void:evil.example` token must fail), caches the verified `sub` for the token's lifetime, and derives the role. It never sees the user's access token, and a Mind's Self never holds a token (ruling above): Minds reach the same bytes through the filesystem or the mouth, not this host. +- **Role** in the response: `member`, `mind`, `steward` (host decides from the Matrix id and the room/space membership the host reads; the client trusts none of it for access). **Q-E:** exact source of membership (estate Space membership read by the host with a service account, versus the Persona catalogue file for Minds). +- **Uploads, only if Meta approves (Q-M):** `PUT /file?path=

        ` with `Content-Length` required, a hard per-file cap (suggest 25 MiB; the existing Matrix limit is `m.upload.size` and the mouth fetches at most 5 MiB), no overwrite (409), into a single `inbox/` directory per zone, `201` with the stored name, size and hash, and the same path and sniff rules. Without approval the host serves no `PUT` and the client has no upload control; the `role` field is what would gate the control. The Matrix zone already carries uploads (`ShareSet`), so the Files desk does not need them to be useful. + +### The one problem the contract leaves: media elements cannot send headers + +`

        ` with `Content-Length` required, a hard per-file cap (suggest 25 MiB; the existing Matrix limit is `m.upload.size` and the mouth fetches at most 5 MiB), no overwrite (409), into a single `inbox/` directory per zone, `201` with the stored name, size and hash, and the same path and sniff rules. Without approval the host serves no `PUT` and the client has no upload control; the `role` field is what would gate the control. The Matrix zone already carries uploads (`ShareSet`), so the Files desk does not need them to be useful. +- **Uploads** (Meta approved for members and Minds, #swarm 2026-11-13 14:30Z; rules are Eureka's, below). `PUT /file?path=

        ` writes only under an `uploads/` subtree of a zone, with `Content-Length` required; the host enforces a size cap, a type allowlist (sniffed, not the client's claim), no overwrite unless the request carries an explicit flag, and never replaces a file a Mind wrote into the handoff tree; every write is audit-logged with the caller's Matrix id. Reads cover the whole allowlisted tree, so an uploaded file is listed like any other. Success is `201` with the stored name, size and hash. The `role` in the index response gates the upload control; the host gates the write. ### The one problem the contract leaves: media elements cannot send headers @@ -60,11 +60,47 @@ The host is a service on Yggdrasil that Eureka owns. The client holds no filesys Recommendation: 2 for audio and video, 1 for images and text. Confidence moderate: I have not tested a service worker against range requests on the target phone, and 2 is the usual answer for authenticated media (signed URLs). **Q-E.** +## Upload flow + +Design only. Every number and error code is a named placeholder until Eureka gives them on the PR: `` (per-file size), `` (per-pick total, if any), `` (the allowlist), `` (the explicit query flag), and the codes ``, ``, ``, ``, ``, ``, ``, ``, ``. + +**Where.** The control exists only where an upload is allowed: inside a folder under a zone's `uploads/` subtree (or at the `uploads/` row itself), and only when the index `role` is member or mind. Elsewhere there is no button; the list does not carry a disabled one. A folder outside `uploads/` shows a one-line dim note, "Uploads go in uploads/", which links to that folder. Creating subfolders under `uploads/` is not in this design (**Q-E8**). + +**Pick or drop.** One `Upload` row at the top of an `uploads/` folder opens the native picker (any number of files; on a phone this is the OS file sheet, camera roll included). On wide screens the list is also a drop target; the drop highlight appears only while a drag is over it. Nothing starts on pick: the picked files appear as a staging list (name, size, a type icon) with `Upload` and `Cancel` as full-width rows, as `ShareSet` does for a Matrix set. Files that already fail the client's cheap checks (over ``, a type outside ``) are marked in the staging list with the reason and left out of the send; the client mirrors the cap and allowlist as a courtesy only, the host is the authority, and the list is read from the host (**Q-E9**: an `/index` field or a `/limits` call) rather than hard-coded. + +**Send.** Files go one at a time, in pick order, each a `PUT /file?path=/` with the bearer token. One at a time keeps a phone on mobile data from starving every transfer, and a refusal names exactly one file. The transport is `XMLHttpRequest`, not `fetch`: `fetch` has no upload progress and the row needs it. The body is the `File` itself (streamed by the browser, not read into memory; unlike the Matrix path, the client computes no hash first, the host returns one). The name is the file's own name, with path separators and control characters replaced, never a path the user typed. + +**Progress.** The staging list becomes the progress list: per file a thin bar and "412 KB of 3.1 MB", per batch "2 of 5". Finished files show a tick and the stored name; refused ones show the refusal in place; waiting ones are dim. Only the active file animates. The list stays on screen after the last file until dismissed, so a refusal is not lost to a refresh of the folder underneath it. The folder listing refetches after each success, so the file appears in the list as it lands. + +**Cancel.** `Cancel` on the active file aborts the request (`xhr.abort()`) and goes on to the next; `Cancel all` on the batch aborts the active file and drops the rest. The host must discard an aborted partial write and never leave a half file in the listing (**Q-E10**, written to a temporary name and renamed on completion). Leaving the page aborts whatever runs, as the Matrix share does; the client does not resume. + +**Overwrite.** A name that already exists is refused with `` and shown as " is already there". A `Replace` row appears under that refusal only for a file the caller wrote themselves, and sends the same `PUT` with ``; a Mind-written handoff file refuses with `` and offers only "Keep both", which re-sends under a suffixed name (`name (2).ext`) chosen by the client. Whether the client can tell "mine" from the index (an `owner` field with the Matrix id) or learns it only from the refusal is **Q-E11**; absent an owner field, `Replace` is simply offered after `` and the host's answer decides. + +**Refusal messages.** One plain sentence per code, shown in the file's row; nothing says "error" without saying why, and none names a server path. + +| Code | Message (draft) | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `` | Sign-in expired. Reload to upload again. (The client fetches a fresh OpenID token once and retries before showing this.) | +| `` | Uploads are for members and Minds. | +| `` | That folder does not take uploads. | +| `` | "" is a file; allowed types are . | +| `` | "" is ; the limit is . | +| `` | "" is already there. | +| `` | "" was written by a Mind and cannot be replaced. | +| `` | The shared space is full. Tell a steward. | +| `` / network / timeout | Could not reach the file host. The file was not saved. | + +Retry is one tap on a failed row; the batch never retries on its own. An unknown code shows the host's status text as it is, still without a path. + +**Honest end state.** Done means the host answered `201` and the stored name, size and hash are shown. "Saved" is not said earlier; the hash the host returns is displayed so a sender can compare it with their own. + +**Reuse and not.** `ShareSet`'s state machine (staging, one-send-per-zone guard, failed files kept by position) is the model, but the Matrix share path and its `createUploadAtom` are tied to the SDK's media upload and are not reused; the new controller sits in `features/files/uploadController.ts` with the same injected-port shape, so order, refusal and cancel rules are tested against a fake `PUT` without a browser. Real transfer progress and a real range of a phone's picker stay a phone check. + ## Client shape Under `src/app/features/files`, mirroring `features/albums`: -- `filesHost.ts`: ports, `list(path)`, `url(path)`, `ticket(path)`; the only module that knows the contract. Injected, so the UI is tested against a fake and the real client never runs in a test. +- `filesHost.ts`: ports, `list(path)`, `url(path)`, `ticket(path)`, `put(path, file, onProgress, signal)`; the only module that knows the contract. Injected, so the UI is tested against a fake and the real client never runs in a test. - `useFilesZone.ts`: react-query on `['files', zone, path]`, `staleTime` 30 s; the role comes with every index. - `FilesDesk.tsx`, `FileRow.tsx`, `FilePreview.tsx`: pure rendering from the above; the preview picks its renderer from the host's `mime`, not the extension. - `config.json`: `filesDesk: { enabled: false, host: "https://…" }`, dark by default as `fileZoneShare.enabled` is. With it off, no row mounts and no request leaves the client. @@ -76,7 +112,7 @@ Under `src/app/features/files`, mirroring `features/albums`: 2. **Read-only Commons desk**: route, row, list, breadcrumb, role display, flagged off. 3. **Preview and player**: image, audio with ranges, text, video; the ticket if Q-E settles on 2. 4. **Per-Persona rows** in each Mind's group, once the outbox `shared/` convention and the zone provisioning exist. -5. **Uploads**: only on Meta's yes. +5. **Uploads**: the flow above; needs Eureka's codes and caps and the host's `PUT`. Each is its own PR. Slices 1 to 3 need no host to build and test; slice 2 needs the real host to be useful, not to land. @@ -95,11 +131,15 @@ The Matrix file zone (marks, receipts, `ShareSet`); any Mind-side fetch (the mou - **Q-E5.** Per-Persona publishing: a `shared/` subdirectory versus a marker, and who populates it when the Persona decides. - **Q-E6.** Hash cost: compute lazily and cache by (path, mtime, size), or at publish time. - **Q-E7.** CORS for `chat.gamecult.org` and the dev previews, rate limits, and the `Content-Disposition` list. +- **Q-E8.** Subfolders under `uploads/`: can a client create them (by a `PUT` path that implies them, or a `MKCOL`-like call), or is `uploads/` flat? +- **Q-E9.** Cap and allowlist discovery: fields in the index response, or a `/limits` endpoint; and the exact codes and cap numbers behind every placeholder in the upload flow. +- **Q-E10.** Partial writes: temporary name then rename, so an aborted or failed upload never appears in a listing. +- **Q-E11.** Does the index carry an `owner` Matrix id per file (so `Replace` is offered only on one's own files), and is the audit log readable by stewards from the client or only on the host? **For Meta:** - **Q-M1.** Audience: Commons for members and Minds as proposed; are Users and Patrons ever in (the Matrix zone's audience question, `ratatoskr:question:file-zone-audience`, is the same fork)? - **Q-M2.** Is a Persona's zone visible to all members or to her stewards and Minds? -- **Q-M3.** Uploads: yes or no, and if yes, who (members, Minds, stewards) and the cap. +- **Q-M3.** ~~Uploads~~ approved for members and Minds (2026-11-13 14:30Z); open only: may Users or Patrons upload if they are ever let in? - **Q-M4.** Name: "Files" for the row, "Commons" for the shared zone. - **Q-M5.** Does the Matrix Media row stay beside the Files row on each Mind, or does one absorb the other once the Files desk exists (recommendation: both stay; marks and receipts are Matrix's). From 0ab185bb57acee40c300f3a36d56daaac4405472 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 14:48:01 +0000 Subject: [PATCH 101/143] docs: Files desk as a comparison, Matrix as the single authority (A) against the host service (B); publisher rule Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 162 ++++++++++++++++++++++++++++++++++--- 1 file changed, 150 insertions(+), 12 deletions(-) diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index f11abb9e..7b106392 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -1,10 +1,148 @@ -# Files desk: Commons and per-Persona file zones +# Files desk: Commons and per-Persona file zones, two designs compared -Status: design only. No code, nothing deployed. Items marked **UNKNOWN** are not verified against the Body; **Q-E** / **Q-M** are open questions for Eureka (host) and Meta (audience, names), collected at the end. +Status: design only. No feature code is merged, nothing deployed. **The host-service design (Option B, first written here) is superseded in part** by Meta's ruling that two authorities, Matrix and a Commons host, are incoherent: albums already sit on Matrix, so the Commons should too. Eureka agrees (#minds 2026-11-13 14:40Z), has paused the `/index` and `/file` service, and is checking the homeserver upload cap and whether a Mind account or bot can post file events from Yggdrasil (answers will land on PR #142 and be folded in here). This page now compares **A: Matrix as the single authority** with **B: the host service**, recommends A, and keeps B in full below the line so the comparison can be checked. Items marked **UNKNOWN** are not verified; **Q-E** are questions for Eureka, **Q-M** for Meta. -Source: Meta, #swarm 2026-11-13 14:28Z; ruling `ratatoskr:ruling:persona-file-sharing-zone` (each Persona has her own file sharing zone the frontend shows: a browsable, previewing collection; the Self writes to her outbox, her Persona decides what is shared, the Self never holds Matrix credentials). +Source: Meta, #swarm 2026-11-13 14:28Z and 14:40Z; ruling `ratatoskr:ruling:persona-file-sharing-zone` (a browsable, previewing collection per Persona; the Self writes to her outbox, her Persona decides what is shared, the Self never holds Matrix credentials). -## What exists, and what this adds +## What exists on Matrix today (the base A must fit) + +Read from this repo at dev `8ba407cd`: + +- **Zone = a room** whose `m.room.create` type is `org.gamecult.file_zone` (`isFileZone.ts`), or one listed in config `fileZoneRooms`. One per Mind, `#-files:`, found by alias or by the carried id in the root Space's `org.gamecult.stewardships` (`mediaRoom.ts`, `MediaRow.tsx`); rendered as the Media row of each Mind's group. +- **Album = a thread root** (a text event; its `body`, or its latest `m.replace` edit, is the title); **file = a thread reply** with `msgtype` `m.audio|m.image|m.video|m.file` (`buildAlbums.ts`). A redacted event or an `m.replace` is never a file. Loose room messages are not shown. +- **Read** = `GET /rooms/{id}/threads` and `/relations/{root}/m.thread`, one page of 100 each (`useAlbums.ts`; pagination is a known gap). Unencrypted rooms only. +- **Write** = `sendShareSet` (`shareSet.ts`, behind `fileZoneShare.enabled`): a root, then one thread reply per file, content from `getMsgContent` with `info.sha256`. +- **Media** = authenticated Matrix media fetched to a blob (`downloadMedia` in `FileContent.tsx`): so the "media element cannot send a header" problem that Option B needed a ticket for is already solved this way, at the cost that a file is fully downloaded before it plays. +- **Receipts** = `org.gamecult.file_received` from a Mind's mouth after it fetches and verifies the sha256 (`docs/file-zone-mind-contract.md`); the mouth enforces the Mind's membership in the room. + +Option A is an extension of this, not a second system. + +## The two options + +**A. Matrix is the only authority.** Commons is one more file-zone room; each Mind's zone is the room she already has. Audience is room membership. Upload is the ordinary Matrix upload. Folders are albums (thread roots). A small publisher service with its own Matrix account posts allowlisted handoff documents into the Commons room. The Files desk is a view over file events in those rooms. + +**B. The host service.** A file host on Yggdrasil serves allowlisted directories (`GET /index`, `GET /file`, a capped `PUT`), authenticated by Matrix OpenID; the client browses it as a tree. Written out in full below the line. + +| | A: Matrix only | B: host service | +| -------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | +| Authorities | one (Matrix: identity, audience, storage, audit) | two (Matrix for identity, the host for files and access) | +| Audience | room membership and power levels | the host derives a role from Matrix identity and enforces it again | +| Audit of writes | the event `sender`, kept by the homeserver | the host's own log | +| Overwriting someone's file | impossible: events are immutable and only the sender's own edit counts; a redaction is a steward's act | host rule, host code | +| Upload size cap | the homeserver's `m.upload.size`, global, **UNKNOWN** value (Q-E) | the host's own, chosen freely | +| Type allowlist | not enforceable by the homeserver; client-side courtesy, steward redaction | enforced by the host (sniffed) | +| What is browsable | what was posted (published or uploaded) | what is on disk under the allowlist, live | +| Mind-written handoff docs | a publisher posts them once, when final (below) | the host serves the tree | +| Per-Persona zone | the existing Media room; the Self's outbox reaches it through the mouth, as the ruling says | a `shared/` directory the host serves | +| Folders | albums; nesting by path in the album title | real directories | +| Mind fetch | unchanged (mouth, room id and event id, receipts) | a second route the mouth would need | +| Media playback | blob download, as today; no seeking before the whole file arrives | ranges and a ticket, or the same blob | +| New moving parts | a room, a publisher | a host service, OpenID verification, CORS, tickets, path containment, rate limits | + +## Recommendation: A + +Confidence: high on the authority question (it agrees with Meta's ruling, with Eureka's lean, with the standing ruling that the Self never holds credentials while her Persona decides what is shared, and with the fact that everything built so far reads Matrix); moderate on the details below, which Eureka's answers can change. + +What A gives up, stated plainly: + +- **A live view of what is on disk.** Only what was published or uploaded appears. A handoff doc that was never marked final is not in the Commons. +- **Files over the homeserver's upload cap.** B had no Matrix limit; A has exactly one. +- **A server-side type allowlist.** Under A a member can upload any type the homeserver accepts; the client shows a refusal for types it does not want, and stewards redact. If a strict allowlist matters, a gating bot (post, check, redact) would have to exist, and it is a second authority again. +- **Seeking inside a large audio or video file** until Matrix media supports ranges for the client (**UNKNOWN**; the blob route is what runs today). +- **Hierarchy beyond the album level** as a native structure; see Folders. + +What it buys: one identity, one audience, one audit trail, immutable files, existing receipts and the mouth's fetch, and no new service to keep secure. + +## Option A in detail + +**Rooms.** Commons is a room with create type `org.gamecult.file_zone`, alias `#commons-files:` (name is **Q-M**), join rule `restricted` to the estate Space so members join without an invite and Minds are invited, history `shared`, unencrypted (the zones are, and `info.sha256` is written only there). Power levels: `events_default` 0 so members post; `redact` and `state_default` at steward level. The room is muted for push by provisioning: file posts are thread replies, which can raise unread counts and notifications (**UNKNOWN** under this server's push rules), so the room must not shout. Per-Persona zones are the rooms Eureka already provisions; nothing new. + +**Folders.** Four ways to have folders on Matrix, in order of preference: + +1. _Albums are folders; nesting is a path in the album title_ (`handoff/2026-11`, `cast-art/poses`). Zero wire change: `buildAlbums` already reads it, and the client groups albums by title path as a pure view. Cost: a thread root per folder, and the rule that a folder's root is posted once by whoever opens the folder. **Recommended.** +2. A `org.gamecult.file.path` key on each file event. Gives a real tree with files anywhere; needs every poster to write it and every client to honour it; other Matrix clients ignore it and show a flat thread. +3. A room per folder, in a Space. Native hierarchy and per-folder audience; heavy (a room is a membership, a sync entry and provisioning each) and it multiplies the sync load on a phone. +4. A state event per folder (`org.gamecult.file_folder`). Mutable folder metadata (title, order), writable only at steward level; useful later if folders need descriptions, not needed for a first cut. + +**Albums in each Mind's room** are the same mechanism: a Mind's Media room holds that Mind's albums, the Commons holds the shared ones. The Files desk is the same view over either room; a zone differs only by room, not by code. + +**The publisher.** A small service on Yggdrasil with its own Matrix account (a bot or a Mind-class account; whether either may post file events from there is Eureka's check, **Q-E1**). The Self never holds this account; the publisher holds it. It owns the allowlist. + +- **When it posts.** Handoff documents are edited in place, so mirroring every save would spam the room with drafts. The rule (Eureka's lean, adopted): **post once, when the document is marked final**; a later final version posts again with a `replaces` relation to the earlier event. How "final" is marked (a front-matter line, a sibling marker, a directory convention) is the publisher's concern and a **Q-E2**; the client must not know. +- **Allowlist.** Lives on the publisher: a list of source roots and patterns, read by the publisher only. `handoff-private` is not on it and the publisher never lists, reads or posts from it. The client holds no path to anything on disk. +- **The event.** A file event in the thread of the folder's album, built the way `getMsgContent` builds them (`msgtype`, `body`, `filename`, `url` from the media repo, `info.mimetype|size|sha256`), plus one extra key `org.gamecult.publish`: `{ source, sha256, replaces? }` where `source` is a path relative to the allowlisted root, never absolute, and `replaces` is the event id of the version it supersedes. +- **`replaces`, not `m.replace`.** `m.replace` is the stock edit relation, but Ratatoskr's `buildAlbums` deliberately never treats an edit as a file, several clients do not render an edited file, and an edit hides the earlier version. A custom key keeps both events as files; the view shows the latest of a chain and a "N earlier versions" row under it, and other Matrix clients see two ordinary file posts. A chain is read from the events themselves (each carries its `replaces`), not from a table. +- **Idempotence.** The publisher's own state (a typed CultCache document of `source`, `sha256`, `event_id`) says what it posted; if that is lost, it rebuilds by reading its own events back from the room (`org.gamecult.publish.source` plus `sha256`) rather than guessing. The same `sha256` never posts twice. +- **Receipts and fetch** work unchanged: the event is an ordinary file event in a file-zone room, so a Mind's mouth can fetch it by room id and event id (5 MiB cap there) and tick `fetched for`. + +**Uploads (approved for members and Minds).** The ordinary Matrix upload, through the composer pipeline and `ShareSet`. The host rules Eureka gave map onto Matrix as follows: + +| Host rule | Under A | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| only under an `uploads/` subtree | an album (folder) named `uploads/...`; the client offers upload only there (and in a Mind's own zone) | +| never overwrite Mind-written handoff files | free: other people's events cannot be replaced; a duplicate name is just another post in the thread | +| size cap | the homeserver's `m.upload.size`, shown from `MediaConfigLoader`; the client refuses a larger file before sending | +| type allowlist | client-side only; see "gives up" | +| explicit flag to overwrite | no overwrite exists; a new version is a new post, optionally with `replaces` set by the same sender | +| audit log with the Matrix id | the event `sender` | + +What A adds to `ShareSet`: sharing into an existing album ("add files", listed as not built in `docs/file-zone.md`), progress per file, cancel, and per-error refusal messages written against the SDK's `MatrixError` (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`) instead of host codes. The draft refusal table of Option B's upload flow carries over with those codes. **UNKNOWN:** whether the SDK exposes upload progress and abort for media (it does for `uploadContent` with a progress callback; the composer already shows progress, `state/upload.ts`). + +## What each costs on the phone + +Under both, the phone design is the same shape (one column, rows 56 px or more, hidden controls, in-place preview; see the Option B sections below, which are written against the host but whose layout applies). + +- **A, new cost:** the Commons room is one more room in sync and it must be muted; albums and files are one page of 100 each until pagination exists (a Commons of hundreds of documents needs it first); a folder tree is a title-path grouping, so a deep tree is a long list of albums with breadcrumbs, not true directories; a file plays after a full download (small audio and images are fine; a 40 MB clip is not). +- **A, no cost:** no second sign-in, no ticket, no CORS, no new fetch layer, no host-unreachable state beyond the homeserver's own. The album list, album view, share panel and receipts exist and are tested; the view is their extension. +- **B, new cost:** every screen is a second client against a second service with its own errors, token refresh and unreachable state; media needs the ticket or the blob; the tree and preview are new UI. +- **B, benefit:** a tree that is live and complete, ranges, and no Matrix size limit. + +## Changes to Eureka's host work + +Under A the paused service is not built, and most of its list disappears: + +- **Dropped:** `/index`, `/file`, `PUT`, OpenID verification pinned to the server name, CORS, path containment and sniff rules, tickets, the `uploads/` write rules, the role response, the per-path audit log (Q-E3, Q-E4, Q-E6, Q-E7, Q-E8 to Q-E11 of the Option B list). +- **Kept:** the Commons allowlist decision (Q-E1 of that list), moved to the publisher: which roots, and that `handoff` is only ever published through the final-marker rule, never browsed. +- **New:** (1) provision the Commons room (type, alias, restricted join to the estate Space, power levels, push muted); (2) the publisher: account, allowlist, final-marker rule, media upload, posting into folder threads, the `org.gamecult.publish` key, idempotence from its own events; (3) confirm the homeserver upload cap and whether the account may post file events from Yggdrasil (**Q-E1**, asked on the PR); (4) the per-Persona path stays the one already specified (`album-outbox-ops`: outbox, Persona decision, mouth post), so the `shared/` convention of Option B is retired. + +## Cuts under A + +1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer. +2. **Pagination** of albums and files in `useAlbums` (a prerequisite for a Commons of any size). +3. **Folders by title path** and the album list grouped by it. +4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it. +5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). + +PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake. + +## Not in scope + +The Mind-side fetch (the mouth's); encrypted rooms; deletion and rename by this client; search across zones; any permission model of our own beyond Matrix room membership and power levels. + +## Open questions + +**Eureka (#minds 14:40Z, answers to land on PR #142):** + +- **Q-E1.** What is the homeserver upload cap (`max_upload_size`), and may a bot or Mind-class account post file events from Yggdrasil (the publisher's account)? +- **Q-E2.** How a handoff doc is marked final (front matter, sibling marker, or directory), and which roots the allowlist names. +- **Q-E3.** Commons room provisioning: alias, restricted join to the estate Space, power levels, push rules. +- **Q-E4.** Does Synapse media honour `Range` for authenticated media (decides whether large audio and video need anything beyond the blob route)? + +**Meta:** + +- **Q-M1.** Commons audience: members and Minds as proposed; Users and Patrons never, or later? +- **Q-M2.** Is a Persona's zone visible to all members or to her stewards and Minds? (The Matrix zone's audience question, `ratatoskr:question:file-zone-audience`, is the same fork.) +- **Q-M3.** May Users or Patrons upload if they are ever let in? +- **Q-M4.** Names: "Files" for the view, "Commons" for the shared room. +- **Q-M5.** A type allowlist is not enforceable under A without a gating bot; is a client-side refusal plus steward redaction enough? + +--- + +# Option B in full (kept for the comparison; superseded in part by Option A above) + +The sections below were written against the host service. Where they speak of a host, a role, a ticket, a token, `/index` or `/file`, they are Option B. The phone layout, the zone table and the refusal wording carry over to A with the Matrix equivalents given above. + +### What exists, and what this adds | | Matrix file zone (`docs/file-zone.md`, built) | Files desk (this page) | | -------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | @@ -16,7 +154,7 @@ Source: Meta, #swarm 2026-11-13 14:28Z; ruling `ratatoskr:ruling:persona-file-sh They do not compete. The Matrix zone stays where a file needs a mark or a receipt (Aqua's listening kits); the Files desk is for browsing and fetching what is already on disk. Neither replaces the other, and the Files desk does not write Matrix events. The ruling left "Matrix media plus a listing versus a served directory" open; this page is the served-directory half, built beside the Matrix half already live. -## Zones +### Zones (host) | Zone | Contents | Who sees it | | ---------------- | ------------------------------------------------------------ | -------------------------------------------------------------- | @@ -28,7 +166,7 @@ Layout of `/opt/commons` today (read 2026-11-13): `backlog` (755, per-Mind backl Per-Persona source is the outbox from `ratatoskr:follow_up:album-outbox-ops-and-persona-specs`: `/var/lib/mind--outbox`, owned by `mind--self`, 0750, ACL read for `mind-` and the host user. The Self writes there; the Persona decides what is shared. Making that decision a filesystem fact (a `shared/` subdirectory the Persona's turn populates by copy or hardlink, the host serves only `shared/`) keeps the host dumb: it never learns what "decides" means. **Q-E:** `shared/` subdirectory versus a per-file marker. -## Phone-first UI +### Phone-first UI (host) Phone width is the design width; desktop adds the navigator beside it (as `Desk` does in `Router.tsx`). Vertical space is cheap, so a screen shows one thing and hides what is not in use. @@ -39,7 +177,7 @@ Phone width is the design width; desktop adds the navigator beside it (as `Desk` - **Honesty rows.** Loading, empty, "host unreachable", "not allowed" are rows that say so (as `MediaNote` does); no spinner without a name for what it waits on. - **Role-driven.** The index response carries the caller's role; the client uses it only to hide controls (upload), never to decide access. -## Host contract +### Host contract The host is a service on Yggdrasil that Eureka owns. The client holds no filesystem knowledge. All JSON here is the xenos boundary between a browser and a file server, not state. @@ -60,7 +198,7 @@ The host is a service on Yggdrasil that Eureka owns. The client holds no filesys Recommendation: 2 for audio and video, 1 for images and text. Confidence moderate: I have not tested a service worker against range requests on the target phone, and 2 is the usual answer for authenticated media (signed URLs). **Q-E.** -## Upload flow +### Upload flow (host) Design only. Every number and error code is a named placeholder until Eureka gives them on the PR: `` (per-file size), `` (per-pick total, if any), `` (the allowlist), `` (the explicit query flag), and the codes ``, ``, ``, ``, ``, ``, ``, ``, ``. @@ -96,7 +234,7 @@ Retry is one tap on a failed row; the batch never retries on its own. An unknown **Reuse and not.** `ShareSet`'s state machine (staging, one-send-per-zone guard, failed files kept by position) is the model, but the Matrix share path and its `createUploadAtom` are tied to the SDK's media upload and are not reused; the new controller sits in `features/files/uploadController.ts` with the same injected-port shape, so order, refusal and cancel rules are tested against a fake `PUT` without a browser. Real transfer progress and a real range of a phone's picker stay a phone check. -## Client shape +### Client shape (host) Under `src/app/features/files`, mirroring `features/albums`: @@ -106,7 +244,7 @@ Under `src/app/features/files`, mirroring `features/albums`: - `config.json`: `filesDesk: { enabled: false, host: "https://…" }`, dark by default as `fileZoneShare.enabled` is. With it off, no row mounts and no request leaves the client. - Test host: `src/preview-demo` already answers for a pretend homeserver through a fetch guard (`install.ts`); it gains a fake `/index` and `/file` with ranges so the previews are checked in a real browser before the real host exists. -## Cuts +### Cuts (host) 1. **Contract and fake host** (no UI): `filesHost.ts`, types, `preview-demo` fake, path tests (traversal strings from the host side are Eureka's; the client must only build `path` from entries it was given). 2. **Read-only Commons desk**: route, row, list, breadcrumb, role display, flagged off. @@ -116,11 +254,11 @@ Under `src/app/features/files`, mirroring `features/albums`: Each is its own PR. Slices 1 to 3 need no host to build and test; slice 2 needs the real host to be useful, not to land. -## Not in scope +### Not in scope (host) The Matrix file zone (marks, receipts, `ShareSet`); any Mind-side fetch (the mouth's); encrypted rooms; deletion and rename (a read surface first; the files belong to whoever wrote them on disk); search across zones; a permission model of our own beyond what the host derives from Matrix identity. -## Open questions +### Open questions (host) **For Eureka (host):** From a8e3acbde0cc08e91e8f56cb83b7a9fb72a62bec Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 15:46:23 +0000 Subject: [PATCH 102/143] docs: Files desk decided as Matrix-only (A); Eureka's notes folded in; cut 1 recorded Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index 7b106392..9689f744 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -1,6 +1,13 @@ # Files desk: Commons and per-Persona file zones, two designs compared -Status: design only. No feature code is merged, nothing deployed. **The host-service design (Option B, first written here) is superseded in part** by Meta's ruling that two authorities, Matrix and a Commons host, are incoherent: albums already sit on Matrix, so the Commons should too. Eureka agrees (#minds 2026-11-13 14:40Z), has paused the `/index` and `/file` service, and is checking the homeserver upload cap and whether a Mind account or bot can post file events from Yggdrasil (answers will land on PR #142 and be folded in here). This page now compares **A: Matrix as the single authority** with **B: the host service**, recommends A, and keeps B in full below the line so the comparison can be checked. Items marked **UNKNOWN** are not verified; **Q-E** are questions for Eureka, **Q-M** for Meta. +Status: **decided: Option A, Matrix as the single authority** (Meta, #swarm 2026-11-13 14:44Z: "Let's try matrix shaped"). Design only; no Files desk code is merged and nothing is deployed. Option B (the host service) is parked, not deleted: Eureka's branch `eureka-self/commons-host` at `a391960d` has no PR and a Soul verdict of NOT READY with fixes owed, and is woken only if Meta later wants a real file store. PR #143 (B's cut 1 in this repo) is closed unmerged. Items marked **UNKNOWN** are not verified; **Q-E** are questions for Eureka, **Q-M** for Meta. + +### Eureka's answers so far (`/opt/commons/handoff/commons-matrix-notes-20261113.md`, read 2026-11-13; not yet posted on PR #142) + +- **Upload cap (Q-E1, read).** Synapse 1.164.1; the repo template `homeserver.yaml` has `max_upload_size: 50M` and the nginx vhost `client_max_body_size 50m`; the live runtime file is root-only and was not read. No per-room or per-user per-file cap exists; `media_upload_limits` is a per-user time-window quota. `GET /_matrix/client/v1/media/config` returns `m.upload.size` to an authenticated client, which is what the client reads. Raising the cap takes the config line, a Synapse restart and nginx at least as large. +- **Posting (Q-E1, read).** A Self holds no Matrix credentials, so the publisher needs its own bot account and token (a MAS compatibility token via `mas-cli`). It uploads with `POST /_matrix/media/v3/upload` and sends `m.room.message` with `msgtype` `m.file` or `m.image`. The Persona mouth sends `m.text` only today, so file events are new for it too. +- **Not yet read:** the final-marker convention (Eureka proposes a sibling `.final` or a `published/` hardlink the publisher watches, with `org.gamecult.publish.replaces` carrying the prior event id and sha256), Commons room provisioning (alias, power levels, unencrypted, membership by the estate Space, publisher bot at power level 50), and whether Synapse media honours `Range` (to be checked in `synapse/media/media_repository.py` and against a live mxc). +- **Edited-doc stale copies:** a redacted file's media persists unless purged; the publisher dedupes by sha256. Source: Meta, #swarm 2026-11-13 14:28Z and 14:40Z; ruling `ratatoskr:ruling:persona-file-sharing-zone` (a browsable, previewing collection per Persona; the Self writes to her outbox, her Persona decides what is shared, the Self never holds Matrix credentials). @@ -39,7 +46,7 @@ Option A is an extension of this, not a second system. | Media playback | blob download, as today; no seeking before the whole file arrives | ranges and a ticket, or the same blob | | New moving parts | a room, a publisher | a host service, OpenID verification, CORS, tickets, path containment, rate limits | -## Recommendation: A +## Recommendation: A (decided) Confidence: high on the authority question (it agrees with Meta's ruling, with Eureka's lean, with the standing ruling that the Self never holds credentials while her Persona decides what is shared, and with the fact that everything built so far reads Matrix); moderate on the details below, which Eureka's answers can change. @@ -107,6 +114,8 @@ Under A the paused service is not built, and most of its list disappears: ## Cuts under A +Status 2026-11-13: none built. Cut 1 below is next, on branch `void/files-desk-view` (worktree `wt/files-view`, no commits yet); its shape: a pure `albumFolders` view (folders from `/`-separated album titles, leaf name, counts), folder navigation and breadcrumb in `AlbumView` through a presentational list component tested by static render, `ShareSet` sending into the current folder by prefixing the album title, and a quiet `Commons` row (alias `#commons-files:` or the carried `media.commons` id, through `lookupMediaRoom`, hidden while unset or looking, behind a dark `filesDesk.enabled` flag, and filtered out of the tiers so it is not listed twice). Muting is provisioning's (Q-E3), not the client's. + 1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer. 2. **Pagination** of albums and files in `useAlbums` (a prerequisite for a Commons of any size). 3. **Folders by title path** and the album list grouped by it. From 9db91378c52f981b45586f73c1504a8c320e1d2c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 17:52:10 +0000 Subject: [PATCH 103/143] Phone glyphs: icons inside IconButtons and the nav chevron draw larger on phones, box unchanged folds draws a glyph at 16-24 px; on a phone the 16/18/20 px ones read small in their buttons. One global rule per folds Icon size scales the glyph (22/24/24/26/28 px) at paint time inside an IconButton, so no button, PhoneHit area or row changes size and desktop is untouched. phoneGlyph.test.ts pins the folds class names, the px range and that the rule is a phone-only scale. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room-nav/styles.css.ts | 8 ++++ src/app/phoneGlyph.test.ts | 53 +++++++++++++++++++++++++ src/index.tsx | 1 + src/phoneGlyph.css.ts | 28 +++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 src/app/phoneGlyph.test.ts create mode 100644 src/phoneGlyph.css.ts diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index 1d46e596..afa0c1a0 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -3,6 +3,7 @@ import { config } from 'folds'; import { surfaceInk } from '../scene/sceneShell.css'; import { skyLabelInk } from '../scene/skyLabelInk'; import { NAV_ICON_REST_OPACITY } from '../../components/nav/styles.css'; +import { PHONE_GLYPHS } from '../../../phoneGlyph.css'; export const CategoryButton = style({ flexGrow: 1, @@ -32,4 +33,11 @@ export const CategoryButton = style({ }); export const CategoryButtonIcon = style({ opacity: NAV_ICON_REST_OPACITY, + // A phone draws the 16 px chevron as its IconButton neighbours are drawn (see phoneGlyph.css.ts): paint-time, so + // the gutter and the row keep their layout. + '@media': { + 'screen and (max-width: 750px)': { + scale: `${PHONE_GLYPHS['50'].phonePx / PHONE_GLYPHS['50'].px}`, + }, + }, }); diff --git a/src/app/phoneGlyph.test.ts b/src/app/phoneGlyph.test.ts new file mode 100644 index 00000000..0e54c22a --- /dev/null +++ b/src/app/phoneGlyph.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from 'fs'; +import { describe, expect, it } from 'vitest'; +import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; +import { emitted } from './phoneHitTestKit'; +import { ICON_BUTTON_CLASS, PHONE_GLYPHS } from '../phoneGlyph.css'; + +const foldsJs = readFileSync(`${process.cwd()}/node_modules/folds/dist/index.js`, 'utf8'); + +describe('phone glyphs inside IconButtons', () => { + it("name folds' own classes: the IconButton base class and one Icon class per size", () => { + expect(foldsJs).toContain( + `var IconButton$1 = createRuntimeFn({ defaultClassName: "${ICON_BUTTON_CLASS} ` + ); + const iconSizes = foldsJs.match( + /var Icon\$1 = createRuntimeFn\(\{[^\n]*variantClassNames: \{ size: \{([^}]*)\}/ + )?.[1]; + expect(iconSizes).toBeDefined(); + Object.entries(PHONE_GLYPHS).forEach(([size, { cls }]) => + expect(iconSizes).toContain(`"${size}": "${cls}"`) + ); + }); + + it('draw the folds Icon px (16 18 20 22 24) at 22..28 px, never smaller, never past the smallest button box', () => { + // folds IconButton padding is 4 px a side at the smallest size, so its box is the glyph plus 8. + Object.values(PHONE_GLYPHS).forEach(({ px, phonePx }) => { + expect(phonePx).toBeGreaterThanOrEqual(22); + expect(phonePx).toBeLessThanOrEqual(28); + expect(phonePx).toBeGreaterThan(px); + expect(phonePx).toBeLessThanOrEqual(px + 8); + }); + }); + + it('are a paint-time scale on phones only: no size, margin or padding, and no rule outside the phone query', async () => { + const css = await emitted('../phoneGlyph.css.ts'); + const phone = `@media screen and (max-width: ${MOBILE_BREAKPOINT}px)`; + expect( + css.replace(new RegExp(`${phone.replace(/[()]/g, '\\$&')} \\{\\n[\\s\\S]*?\\n\\}`, 'g'), '') + ).not.toMatch(/\.brs7ce5/); + Object.entries(PHONE_GLYPHS).forEach(([size, { cls, px, phonePx }]) => { + const rule = css.match( + new RegExp(`\\.${ICON_BUTTON_CLASS} > \\.${cls} \\{\\n([^}]*)\\}`) + )?.[1]; + expect(rule, `rule for Icon size ${size}`).toBeDefined(); + expect( + rule! + .trim() + .split('\n') + .map((l) => l.trim().split(':')[0]) + ).toEqual(['scale']); + expect(Number(rule!.match(/scale: ([\d.]+)/)![1])).toBeCloseTo(phonePx / px, 6); + }); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 51e9760f..eca2bc07 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,6 +14,7 @@ import '@fontsource/montserrat/200.css'; import '@fontsource/montserrat/300.css'; import '@fontsource/ibm-plex-mono/400.css'; import 'folds/dist/style.css'; +import './phoneGlyph.css'; import { configClass, varsClass } from 'folds'; enableMapSet(); diff --git a/src/phoneGlyph.css.ts b/src/phoneGlyph.css.ts new file mode 100644 index 00000000..14707f63 --- /dev/null +++ b/src/phoneGlyph.css.ts @@ -0,0 +1,28 @@ +import { globalStyle } from '@vanilla-extract/css'; +import { MOBILE_BREAKPOINT } from './app/hooks/useScreenSize'; + +// Phone glyph size. On a phone the glyph inside an IconButton is drawn larger, by `scale` (a paint-time transform: +// the button's box, its PhoneHit area and every row of buttons keep the layout they have, so nothing reflows at +// 320 px). folds draws a glyph at one of its Icon sizes and the IconButton's box is padding plus that glyph, so the +// factor is chosen per Icon size. Desktop is not touched. +// +// The class names are folds 2.7.2's own (the Icon size classes and the IconButton base class); phoneGlyph.test.ts +// reads them back from folds' dist, so a folds upgrade that renames them fails there instead of silently here. +export const ICON_BUTTON_CLASS = 'brs7ce5'; +/** folds Icon size -> [its class, its px, the px it is drawn at on a phone]. The 24 px glyph is the folds default and + * reads small in a 40 px button, so it grows too; 28 px and up are already large. */ +export const PHONE_GLYPHS = { + '50': { cls: 'cpipac7', px: 16, phonePx: 22 }, + '100': { cls: 'cpipac8', px: 18, phonePx: 24 }, + '200': { cls: 'cpipac9', px: 20, phonePx: 24 }, + '300': { cls: 'cpipaca', px: 22, phonePx: 26 }, + '400': { cls: 'cpipacb', px: 24, phonePx: 28 }, +} as const; + +const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; + +Object.values(PHONE_GLYPHS).forEach(({ cls, px, phonePx }) => + globalStyle(`.${ICON_BUTTON_CLASS} > .${cls}`, { + '@media': { [PHONE]: { scale: `${phonePx / px}` } }, + }) +); From 9d65a345079507d14df0f7416b43fd28fa145d74 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 18:20:12 +0000 Subject: [PATCH 104/143] phoneGlyph test: pin each Icon size's px to folds' own CSS Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneGlyph.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app/phoneGlyph.test.ts b/src/app/phoneGlyph.test.ts index 0e54c22a..6969da0e 100644 --- a/src/app/phoneGlyph.test.ts +++ b/src/app/phoneGlyph.test.ts @@ -5,6 +5,14 @@ import { emitted } from './phoneHitTestKit'; import { ICON_BUTTON_CLASS, PHONE_GLYPHS } from '../phoneGlyph.css'; const foldsJs = readFileSync(`${process.cwd()}/node_modules/folds/dist/index.js`, 'utf8'); +const foldsCss = readFileSync(`${process.cwd()}/node_modules/folds/dist/style.css`, 'utf8'); + +/** The px folds draws an Icon class at: its width variable's rem value, at the 16 px root. */ +const foldsPx = (cls: string) => { + const v = foldsCss.match(new RegExp(`\\.${cls} \\{\\s*width: var\\((--[\\w-]+)\\)`))?.[1]; + const rem = v && foldsCss.match(new RegExp(`${v}: ([\\d.]+)rem`))?.[1]; + return rem ? Number(rem) * 16 : undefined; +}; describe('phone glyphs inside IconButtons', () => { it("name folds' own classes: the IconButton base class and one Icon class per size", () => { @@ -20,6 +28,9 @@ describe('phone glyphs inside IconButtons', () => { ); }); + it("start from the px folds draws each Icon size at (the scale's divisor is not a guess)", () => + Object.values(PHONE_GLYPHS).forEach(({ cls, px }) => expect(foldsPx(cls)).toBe(px))); + it('draw the folds Icon px (16 18 20 22 24) at 22..28 px, never smaller, never past the smallest button box', () => { // folds IconButton padding is 4 px a side at the smallest size, so its box is the glyph plus 8. Object.values(PHONE_GLYPHS).forEach(({ px, phonePx }) => { From 4fd83fb4c415f1be025e0fecbbb35d570fffcf38 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 18:36:51 +0000 Subject: [PATCH 105/143] Soft logout keeps local data and re-logs in on the same device; MAS front hides register and reset Soft (soft_logout === true) drops only the access token and records the device; sign-in is fixed to that user and sends device_id; same user and device keeps the stores, anything else wipes via the one wipeLocalData. Hard logout still wipes. When discovery advertises an msc2965 account or issuer, the register and forgot-password entries give way to a notice pointing at the account page. Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/MasAccountNotice.tsx | 11 ++ src/app/pages/auth/login/Login.tsx | 13 +- .../pages/auth/login/PasswordLoginForm.tsx | 92 ++++++++------ src/app/pages/auth/login/SoftLogoutNotice.tsx | 20 +++ src/app/pages/auth/login/TokenLogin.tsx | 16 ++- src/app/pages/auth/login/loginUtil.ts | 16 ++- src/app/pages/auth/masAccount.test.ts | 74 +++++++++++ src/app/pages/auth/masAccount.ts | 32 +++++ .../auth/register/RegisterRoute.test.tsx | 39 ++++-- src/app/pages/auth/register/RegisterRoute.tsx | 18 ++- .../auth/reset-password/ResetPassword.tsx | 9 +- src/app/pages/client/ClientRoot.tsx | 14 +- src/app/state/softLogout.test.ts | 120 ++++++++++++++++++ src/app/state/softLogout.ts | 91 +++++++++++++ src/client/initMatrix.ts | 53 ++++++-- 15 files changed, 541 insertions(+), 77 deletions(-) create mode 100644 src/app/pages/auth/MasAccountNotice.tsx create mode 100644 src/app/pages/auth/login/SoftLogoutNotice.tsx create mode 100644 src/app/pages/auth/masAccount.test.ts create mode 100644 src/app/pages/auth/masAccount.ts create mode 100644 src/app/state/softLogout.test.ts create mode 100644 src/app/state/softLogout.ts diff --git a/src/app/pages/auth/MasAccountNotice.tsx b/src/app/pages/auth/MasAccountNotice.tsx new file mode 100644 index 00000000..b8f506b4 --- /dev/null +++ b/src/app/pages/auth/MasAccountNotice.tsx @@ -0,0 +1,11 @@ +import { Text } from 'folds'; +import React from 'react'; + +export function MasAccountNotice({ url }: { url: string }) { + return ( + + Accounts here are managed by your sign-in service. Create or recover an account on its{' '} + account page. + + ); +} diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index ea08e4f0..05204779 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -15,6 +15,10 @@ import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils' import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin'; import { LoginPathSearchParams } from '../../paths'; import { useClientConfig } from '../../../hooks/useClientConfig'; +import { useMasAccountUrl } from '../masAccount'; +import { MasAccountNotice } from '../MasAccountNotice'; +import { SoftLogoutNotice } from './SoftLogoutNotice'; +import { useSoftLogout } from '../../../state/softLogout'; import { noSignInMethodMessage } from '../authMessages'; const getLoginTokenSearchParam = () => { @@ -57,12 +61,15 @@ export function Login() { } const parsedFlows = useParsedLoginFlows(loginFlows.flows); + const masUrl = useMasAccountUrl(); + const softLogout = useSoftLogout(); return ( Login + {softLogout && } {parsedFlows.token && loginSearchParams.loginToken && ( )} @@ -93,9 +100,9 @@ export function Login() { )} - {registrationEntry(config) ? ( - - ) : ( + {masUrl && } + {!masUrl && registrationEntry(config) && } + {!masUrl && !registrationEntry(config) && ( Do not have an account? Register diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 6372684c..63c28577 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -19,7 +19,7 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import { Link } from 'react-router-dom'; -import { MatrixError } from 'matrix-js-sdk'; +import { LoginRequest, MatrixError } from 'matrix-js-sdk'; import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix'; import { EMAIL_REGEX } from '../../../utils/regex'; import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo'; @@ -39,6 +39,8 @@ import { loginErrorMessage } from '../authMessages'; import { ForgotLink } from '../styles.css'; import { getResetPasswordPath } from '../../pathUtils'; import { stopPropagation } from '../../../utils/keyboard'; +import { useSoftLogout, withSoftLogoutDevice } from '../../../state/softLogout'; +import { useMasAccountUrl } from '../masAccount'; import { PhoneHit } from '../../../styles/PhoneHit.css'; function UsernameHint({ server }: { server: string }) { @@ -118,6 +120,10 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog const server = useAuthServer(); const clientConfig = useClientConfig(); + const softLogout = useSoftLogout(); + const masUrl = useMasAccountUrl(); + const withDevice = (request: LoginRequest) => withSoftLogoutDevice(request, softLogout); + const serverDiscovery = useAutoDiscoveryInfo(); const baseUrl = serverDiscovery['m.homeserver'].base_url; @@ -130,15 +136,18 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined); const handleUsernameLogin = (username: string, password: string) => { - startLogin(baseUrl, { - type: 'm.login.password', - identifier: { - type: 'm.id.user', - user: username, - }, - password, - initial_device_display_name: 'Ratatoskr Web', - }); + startLogin( + baseUrl, + withDevice({ + type: 'm.login.password', + identifier: { + type: 'm.id.user', + user: username, + }, + password, + initial_device_display_name: 'Ratatoskr Web', + }) + ); }; const handleMxIdLogin = async (mxId: string, password: string) => { @@ -148,27 +157,33 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog const getBaseUrl = factoryGetBaseUrl(clientConfig, mxIdServer); - startLogin(getBaseUrl, { - type: 'm.login.password', - identifier: { - type: 'm.id.user', - user: mxIdUsername, - }, - password, - initial_device_display_name: 'Ratatoskr Web', - }); + startLogin( + getBaseUrl, + withDevice({ + type: 'm.login.password', + identifier: { + type: 'm.id.user', + user: mxIdUsername, + }, + password, + initial_device_display_name: 'Ratatoskr Web', + }) + ); }; const handleEmailLogin = (email: string, password: string) => { - startLogin(baseUrl, { - type: 'm.login.password', - identifier: { - type: 'm.id.thirdparty', - medium: 'email', - address: email, - }, - password, - initial_device_display_name: 'Ratatoskr Web', - }); + startLogin( + baseUrl, + withDevice({ + type: 'm.login.password', + identifier: { + type: 'm.id.thirdparty', + medium: 'email', + address: email, + }, + password, + initial_device_display_name: 'Ratatoskr Web', + }) + ); }; const handleSubmit: FormEventHandler = (evt) => { @@ -207,7 +222,8 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog Username )} - - - - Forget Password? - - - + {!masUrl && ( + + + + Forget Password? + + + + )} + + ); +} diff --git a/src/app/pages/auth/login/TokenLogin.tsx b/src/app/pages/auth/login/TokenLogin.tsx index 0541742d..51a4b46b 100644 --- a/src/app/pages/auth/login/TokenLogin.tsx +++ b/src/app/pages/auth/login/TokenLogin.tsx @@ -14,6 +14,7 @@ import React, { useCallback, useEffect } from 'react'; import { MatrixError } from 'matrix-js-sdk'; import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useSoftLogout, withSoftLogoutDevice } from '../../../state/softLogout'; import { CustomLoginResponse, LoginError, login, useLoginComplete } from './loginUtil'; function LoginTokenError({ message }: { message: string }) { @@ -46,6 +47,7 @@ type TokenLoginProps = { export function TokenLogin({ token }: TokenLoginProps) { const discovery = useAutoDiscoveryInfo(); const baseUrl = discovery['m.homeserver'].base_url; + const softLogout = useSoftLogout(); const [loginState, startLogin] = useAsyncCallback< CustomLoginResponse, @@ -54,12 +56,14 @@ export function TokenLogin({ token }: TokenLoginProps) { >(useCallback(login, [])); useEffect(() => { - startLogin(baseUrl, { - type: 'm.login.token', - token, - initial_device_display_name: 'Ratatoskr Web', - }); - }, [baseUrl, token, startLogin]); + startLogin( + baseUrl, + withSoftLogoutDevice( + { type: 'm.login.token', token, initial_device_display_name: 'Ratatoskr Web' }, + softLogout + ) + ); + }, [baseUrl, token, softLogout, startLogin]); useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined); diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index ba5b0bc9..f4124db7 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -11,6 +11,8 @@ import { } from '../../afterLoginRedirectPath'; import { getHomePath } from '../../pathUtils'; import { setFallbackSession } from '../../../state/sessions'; +import { clearSoftLogout, decideLoginOutcome, getSoftLogout } from '../../../state/softLogout'; +import { wipeLocalData } from '../../../../client/initMatrix'; export enum GetBaseUrlError { NotAllow = 'NotAllow', @@ -112,12 +114,18 @@ export const useLoginComplete = (data?: CustomLoginResponse) => { const navigate = useNavigate(); useEffect(() => { - if (data) { - const { response: loginRes, baseUrl: loginBaseUrl } = data; - setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl); + if (!data) return; + const { response: loginRes, baseUrl: loginBaseUrl } = data; + const finish = async () => { const afterLoginRedirectUrl = getAfterLoginRedirectPath(); + // A soft logout left stores behind; they stay only for the same user on the same device. + const outcome = decideLoginOutcome(getSoftLogout(), loginRes); + if (outcome === 'wipe') await wipeLocalData(); + clearSoftLogout(); + setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl); deleteAfterLoginRedirectPath(); navigate(afterLoginRedirectUrl ?? getHomePath(), { replace: true }); - } + }; + finish(); }, [data, navigate]); }; diff --git a/src/app/pages/auth/masAccount.test.ts b/src/app/pages/auth/masAccount.test.ts new file mode 100644 index 00000000..c45497f3 --- /dev/null +++ b/src/app/pages/auth/masAccount.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'fs'; +import { masAccountUrl } from './masAccount'; +import type { AutoDiscoveryInfo } from '../../cs-api'; + +const info = (auth?: unknown): AutoDiscoveryInfo => + ({ + 'm.homeserver': { base_url: 'https://hs.example' }, + ...(auth === undefined ? {} : { 'org.matrix.msc2965.authentication': auth }), + } as AutoDiscoveryInfo); + +describe('masAccountUrl', () => { + it('prefers the account url, then the issuer', () => { + expect( + masAccountUrl(info({ account: 'https://a.example/acc', issuer: 'https://a.example/' })) + ).toBe('https://a.example/acc'); + expect(masAccountUrl(info({ issuer: 'https://a.example/' }))).toBe('https://a.example/'); + }); + it('is undefined when nothing is advertised', () => { + expect(masAccountUrl(info())).toBeUndefined(); + expect(masAccountUrl(info({}))).toBeUndefined(); + }); + it('accepts http only for localhost', () => { + expect(masAccountUrl(info({ account: 'http://a.example/acc' }))).toBeUndefined(); + expect(masAccountUrl(info({ account: 'http://localhost:8080/acc' }))).toBe( + 'http://localhost:8080/acc' + ); + }); + it('skips an unusable account url and falls to a usable issuer', () => { + expect(masAccountUrl(info({ account: 'not a url', issuer: 'https://a.example/' }))).toBe( + 'https://a.example/' + ); + }); + it('rejects non-strings and non-http schemes', () => { + expect(masAccountUrl(info({ account: 42, issuer: { x: 1 } }))).toBeUndefined(); + expect(masAccountUrl(info({ account: 'data:text/html,x' }))).toBeUndefined(); + expect(masAccountUrl(info({ account: 'ftp://a.example' }))).toBeUndefined(); + }); +}); + +const src = (p: string) => readFileSync(`${process.cwd()}/src/app/pages/auth/${p}`, 'utf8'); + +describe('MAS front guards', () => { + it('Login hides both register entries behind the MAS predicate', () => { + const s = src('login/Login.tsx'); + expect(s).toContain('useMasAccountUrl()'); + expect(s).toContain('{masUrl && }'); + expect(s).toContain('{!masUrl && !registrationEntry(config) && ('); + }); + it('PasswordLoginForm hides the forgot link behind the MAS predicate', () => { + const s = src('login/PasswordLoginForm.tsx'); + expect(s).toContain('useMasAccountUrl()'); + expect(s).toMatch(/\{!masUrl && \([\s\S]*getResetPasswordPath/); + }); + it('RegisterRoute and ResetPassword show the notice under MAS', () => { + ['register/RegisterRoute.tsx', 'reset-password/ResetPassword.tsx'].forEach((p) => { + const s = src(p); + expect(s).toContain('useMasAccountUrl()'); + expect(s).toContain(' { + expect(src('login/PasswordLoginForm.tsx').match(/withDevice\(\{/g)).toHaveLength(3); + expect(src('login/TokenLogin.tsx')).toContain('withSoftLogoutDevice('); + }); + it('the logout listener and login completion defer to the one wipe', () => { + const root = readFileSync(`${process.cwd()}/src/app/pages/client/ClientRoot.tsx`, 'utf8'); + expect(root).toContain('classifyLogout(err)'); + expect(root).toContain('wipeLocalData(mx)'); + expect(root).not.toContain('localStorage.clear'); + expect(src('login/loginUtil.ts')).toContain('wipeLocalData()'); + }); +}); diff --git a/src/app/pages/auth/masAccount.ts b/src/app/pages/auth/masAccount.ts new file mode 100644 index 00000000..b131a916 --- /dev/null +++ b/src/app/pages/auth/masAccount.ts @@ -0,0 +1,32 @@ +import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; +import type { AutoDiscoveryInfo } from '../../cs-api'; + +// Plain http is for a developer's own machine only. +const isLocalHost = (hostname: string): boolean => + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]'; + +const usable = (value: unknown): string | undefined => { + if (typeof value !== 'string') return undefined; + try { + const { protocol, hostname } = new URL(value); + if (protocol === 'https:' || (protocol === 'http:' && isLocalHost(hostname))) return value; + } catch { + // not a url + } + return undefined; +}; + +/** + * The account page of the sign-in service fronting this homeserver (MAS), when discovery + * advertises one: the account url, else the issuer, the same preference the devices page uses. + * Undefined means the homeserver manages its own accounts. + */ +export const masAccountUrl = (info: AutoDiscoveryInfo): string | undefined => { + const auth = info['org.matrix.msc2965.authentication']; + return usable(auth?.account) ?? usable(auth?.issuer); +}; + +export const useMasAccountUrl = (): string | undefined => masAccountUrl(useAutoDiscoveryInfo()); diff --git a/src/app/pages/auth/register/RegisterRoute.test.tsx b/src/app/pages/auth/register/RegisterRoute.test.tsx index 043c1174..1fde64cd 100644 --- a/src/app/pages/auth/register/RegisterRoute.test.tsx +++ b/src/app/pages/auth/register/RegisterRoute.test.tsx @@ -3,16 +3,26 @@ import { describe, expect, it, vi } from 'vitest'; import { renderToString } from 'react-dom/server'; import { ClientConfigProvider } from '../../../hooks/useClientConfig'; import type { ClientConfig } from '../../../hooks/useClientConfig'; +import { AutoDiscoveryInfoProvider } from '../../../hooks/useAutoDiscoveryInfo'; +import type { AutoDiscoveryInfo } from '../../../cs-api'; import { RegisterRoute } from './RegisterRoute'; // The in-client form needs a homeserver; the route test only needs to know whether it was asked for. vi.mock('./Register', () => ({ Register: () =>

        in-client register form
        })); -const render = (config: ClientConfig) => +const plain: AutoDiscoveryInfo = { 'm.homeserver': { base_url: 'https://hs.example' } }; +const mas: AutoDiscoveryInfo = { + ...plain, + 'org.matrix.msc2965.authentication': { account: 'https://auth.example/account' }, +}; + +const render = (config: ClientConfig, info: AutoDiscoveryInfo = plain) => renderToString( - - - + + + + + ); describe('the /register route', () => { @@ -23,15 +33,26 @@ describe('the /register route', () => { }); it('with the switch off shows the in-client form', () => { - expect(render({ registration: { enabled: false, url: 'https://accounts.example/join' } })).toContain( - 'in-client register form' - ); + expect( + render({ registration: { enabled: false, url: 'https://accounts.example/join' } }) + ).toContain('in-client register form'); expect(render({})).toContain('in-client register form'); }); it('with the switch on but an unusable url falls back to the form', () => { - expect(render({ registration: { enabled: true, url: 'ftp://accounts.example/join' } })).toContain( - 'in-client register form' + expect( + render({ registration: { enabled: true, url: 'ftp://accounts.example/join' } }) + ).toContain('in-client register form'); + }); + + it('under MAS shows the account page notice, not the form, with the switch on or off', () => { + [{}, { registration: { enabled: true, url: 'https://accounts.example/join' } }].forEach( + (config) => { + const html = render(config, mas); + expect(html).toContain('href="https://auth.example/account"'); + expect(html).not.toContain('in-client register form'); + expect(html).not.toContain('Continue to sign up'); + } ); }); }); diff --git a/src/app/pages/auth/register/RegisterRoute.tsx b/src/app/pages/auth/register/RegisterRoute.tsx index 094bc8fd..fa56527a 100644 --- a/src/app/pages/auth/register/RegisterRoute.tsx +++ b/src/app/pages/auth/register/RegisterRoute.tsx @@ -2,6 +2,8 @@ import { Box, Text } from 'folds'; import React, { useEffect } from 'react'; import { useClientConfig } from '../../../hooks/useClientConfig'; import { registrationEntry } from '../registrationEntry'; +import { useMasAccountUrl } from '../masAccount'; +import { MasAccountNotice } from '../MasAccountNotice'; import { Register } from './Register'; // With the Register switch on, a stranger gets one way to register: the account service's own @@ -9,9 +11,21 @@ import { Register } from './Register'; // the switch off, the in-client form is the way. export function RegisterRoute() { const entry = registrationEntry(useClientConfig()); + const masUrl = useMasAccountUrl(); useEffect(() => { - if (entry) window.location.replace(entry.url); - }, [entry]); + if (entry && !masUrl) window.location.replace(entry.url); + }, [entry, masUrl]); + // Under MAS the in-client sign-up (UIA) cannot work; the account page is the only way. + if (masUrl) { + return ( + + + Register + + + + ); + } if (!entry) return ; return ( diff --git a/src/app/pages/auth/reset-password/ResetPassword.tsx b/src/app/pages/auth/reset-password/ResetPassword.tsx index c5e1d2ad..b9c2e9a6 100644 --- a/src/app/pages/auth/reset-password/ResetPassword.tsx +++ b/src/app/pages/auth/reset-password/ResetPassword.tsx @@ -4,6 +4,8 @@ import { Link, useSearchParams } from 'react-router-dom'; import { getLoginPath } from '../../pathUtils'; import { useAuthServer } from '../../../hooks/useAuthServer'; import { PasswordResetForm } from './PasswordResetForm'; +import { useMasAccountUrl } from '../masAccount'; +import { MasAccountNotice } from '../MasAccountNotice'; import { ResetPasswordPathSearchParams } from '../../paths'; const useResetPasswordSearchParams = ( @@ -18,6 +20,7 @@ const useResetPasswordSearchParams = ( export function ResetPassword() { const server = useAuthServer(); + const masUrl = useMasAccountUrl(); const [searchParams] = useSearchParams(); const resetPasswordSearchParams = useResetPasswordSearchParams(searchParams); @@ -26,7 +29,11 @@ export function ResetPassword() { Reset Password - + {masUrl ? ( + + ) : ( + + )} diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 565ff058..dd63226a 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -19,6 +19,7 @@ import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } import { clearCacheAndReload, clearLoginData, + wipeLocalData, initClient, logoutClient, startClient, @@ -35,6 +36,8 @@ import { stopPropagation } from '../../utils/keyboard'; import { SyncStatus } from './SyncStatus'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { getFallbackSession } from '../../state/sessions'; +import { beginSoftLogout, classifyLogout } from '../../state/softLogout'; +import { pushSessionToSW } from '../../../sw-session'; import { AutoDiscovery } from './AutoDiscovery'; import { PhoneHit } from '../../styles/PhoneHit.css'; @@ -127,10 +130,15 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) { const useLogoutListener = (mx?: MatrixClient) => { useEffect(() => { - const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { + const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async (err) => { mx?.stopClient(); - await mx?.clearStores(); - window.localStorage.clear(); + // Soft: the token is dead, the device is not. Keep the stores and ask for a sign-in. + if (classifyLogout(err) === 'soft' && beginSoftLogout()) { + pushSessionToSW(); + window.location.reload(); + return; + } + await wipeLocalData(mx); window.location.reload(); }; diff --git a/src/app/state/softLogout.test.ts b/src/app/state/softLogout.test.ts new file mode 100644 index 00000000..e529288d --- /dev/null +++ b/src/app/state/softLogout.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { MatrixError } from 'matrix-js-sdk'; +import { setFallbackSession, getFallbackSession } from './sessions'; +import { + beginSoftLogout, + classifyLogout, + clearSoftLogout, + decideLoginOutcome, + getSoftLogout, + setSoftLogout, + withSoftLogoutDevice, +} from './softLogout'; + +const memoryStorage = (): Storage => { + const m = new Map(); + return { + get length() { + return m.size; + }, + clear: () => m.clear(), + getItem: (k) => m.get(k) ?? null, + key: (i) => [...m.keys()][i] ?? null, + removeItem: (k) => { + m.delete(k); + }, + setItem: (k, v) => { + m.set(k, String(v)); + }, + }; +}; + +const record = { userId: '@a:hs', deviceId: 'DEV', baseUrl: 'https://hs.example' }; + +describe('classifyLogout', () => { + it('is soft only for soft_logout === true', () => { + expect(classifyLogout(new MatrixError({ errcode: 'M_UNKNOWN_TOKEN', soft_logout: true }))).toBe( + 'soft' + ); + expect(classifyLogout({ data: { soft_logout: true } })).toBe('soft'); + }); + it('is hard for false, absent, string-ish, and non-MatrixError values', () => { + expect( + classifyLogout(new MatrixError({ errcode: 'M_UNKNOWN_TOKEN', soft_logout: false })) + ).toBe('hard'); + expect(classifyLogout(new MatrixError({ errcode: 'M_UNKNOWN_TOKEN' }))).toBe('hard'); + expect(classifyLogout({ data: { soft_logout: 'true' } })).toBe('hard'); + expect(classifyLogout(undefined)).toBe('hard'); + expect(classifyLogout(null)).toBe('hard'); + expect(classifyLogout('boom')).toBe('hard'); + expect(classifyLogout(new Error('x'))).toBe('hard'); + }); +}); + +describe('the soft logout record', () => { + it('round-trips and clears', () => { + const s = memoryStorage(); + expect(getSoftLogout(s)).toBeUndefined(); + setSoftLogout(record, s); + expect(getSoftLogout(s)).toEqual(record); + clearSoftLogout(s); + expect(getSoftLogout(s)).toBeUndefined(); + }); + it('treats garbage and partial records as absent', () => { + const s = memoryStorage(); + s.setItem('ratatoskr_soft_logout', 'not json'); + expect(getSoftLogout(s)).toBeUndefined(); + s.setItem('ratatoskr_soft_logout', JSON.stringify({ userId: '@a:hs', deviceId: '' })); + expect(getSoftLogout(s)).toBeUndefined(); + }); +}); + +describe('beginSoftLogout', () => { + it('keeps the device keys and the record, drops only the access token', () => { + const s = memoryStorage(); + const real = globalThis.localStorage; + Object.defineProperty(globalThis, 'localStorage', { value: s, configurable: true }); + try { + setFallbackSession('tok', 'DEV', '@a:hs', 'https://hs.example'); + s.setItem('other_key', 'kept'); + } finally { + Object.defineProperty(globalThis, 'localStorage', { value: real, configurable: true }); + } + expect(beginSoftLogout(s)).toBe(true); + expect(getFallbackSession(s)).toBeUndefined(); + expect(s.getItem('cinny_access_token')).toBeNull(); + expect(s.getItem('cinny_device_id')).toBe('DEV'); + expect(s.getItem('other_key')).toBe('kept'); + expect(getSoftLogout(s)).toEqual(record); + }); + it('does nothing without a session', () => { + const s = memoryStorage(); + expect(beginSoftLogout(s)).toBe(false); + expect(getSoftLogout(s)).toBeUndefined(); + }); +}); + +describe('withSoftLogoutDevice', () => { + const req = { type: 'm.login.password', password: 'p' } as const; + it('asks for the recorded device on password and token requests alike', () => { + expect(withSoftLogoutDevice(req, record)).toEqual({ ...req, device_id: 'DEV' }); + const tok = { type: 'm.login.token', token: 't' }; + expect(withSoftLogoutDevice(tok, record)).toEqual({ ...tok, device_id: 'DEV' }); + }); + it('leaves the request alone without a record', () => { + expect(withSoftLogoutDevice(req, undefined)).toBe(req); + }); +}); + +describe('decideLoginOutcome', () => { + it('is plain without a record', () => { + expect(decideLoginOutcome(undefined, { user_id: '@a:hs', device_id: 'DEV' })).toBe('plain'); + }); + it('keeps for the same user and device', () => { + expect(decideLoginOutcome(record, { user_id: '@a:hs', device_id: 'DEV' })).toBe('keep'); + }); + it('wipes for another user or another device', () => { + expect(decideLoginOutcome(record, { user_id: '@b:hs', device_id: 'DEV' })).toBe('wipe'); + expect(decideLoginOutcome(record, { user_id: '@a:hs', device_id: 'NEW' })).toBe('wipe'); + }); +}); diff --git a/src/app/state/softLogout.ts b/src/app/state/softLogout.ts new file mode 100644 index 00000000..d7ea1180 --- /dev/null +++ b/src/app/state/softLogout.ts @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import type { LoginRequest } from 'matrix-js-sdk'; +import { getFallbackSession } from './sessions'; + +// A soft logout is the server saying "this token is dead, the device is not": the local stores +// stay, only the access token goes, and this record says whose device to sign back in as. +const SOFT_LOGOUT_KEY = 'ratatoskr_soft_logout'; +const ACCESS_TOKEN_KEY = 'cinny_access_token'; + +export type SoftLogoutRecord = { + userId: string; + deviceId: string; + baseUrl: string; +}; + +export const getSoftLogout = (storage: Storage = localStorage): SoftLogoutRecord | undefined => { + const raw = storage.getItem(SOFT_LOGOUT_KEY); + if (!raw) return undefined; + try { + const { userId, deviceId, baseUrl } = JSON.parse(raw) as Partial; + if ( + typeof userId === 'string' && + typeof deviceId === 'string' && + typeof baseUrl === 'string' && + userId && + deviceId && + baseUrl + ) { + return { userId, deviceId, baseUrl }; + } + } catch { + // an unreadable record is no record + } + return undefined; +}; + +export const setSoftLogout = (record: SoftLogoutRecord, storage: Storage = localStorage) => { + storage.setItem(SOFT_LOGOUT_KEY, JSON.stringify(record)); +}; + +export const clearSoftLogout = (storage: Storage = localStorage) => { + storage.removeItem(SOFT_LOGOUT_KEY); +}; + +export type LogoutKind = 'soft' | 'hard'; + +/** Soft only when the server said so: soft_logout === true. Anything else is hard. */ +export const classifyLogout = (err: unknown): LogoutKind => { + const data = (err as { data?: { soft_logout?: unknown } } | null | undefined)?.data; + return data?.soft_logout === true ? 'soft' : 'hard'; +}; + +/** + * Enter the soft-logout state: remember the device, drop only the access token. Returns false + * (and changes nothing) when there is no session to remember, which the caller treats as hard. + */ +export const beginSoftLogout = (storage: Storage = localStorage): boolean => { + const session = getFallbackSession(storage); + if (!session) return false; + setSoftLogout( + { userId: session.userId, deviceId: session.deviceId, baseUrl: session.baseUrl }, + storage + ); + storage.removeItem(ACCESS_TOKEN_KEY); + return true; +}; + +/** Every login request while a record stands asks for the same device back. */ +export const withSoftLogoutDevice = ( + request: LoginRequest, + record: SoftLogoutRecord | undefined +): LoginRequest => (record ? { ...request, device_id: record.deviceId } : request); + +export type LoginOutcome = 'plain' | 'keep' | 'wipe'; + +/** + * What a finished login does to local data. No record: a plain login, as ever. A record that + * names this very user and device: keep the stores. Any other answer: the stores belong to + * someone else, wipe them first. + */ +export const decideLoginOutcome = ( + record: SoftLogoutRecord | undefined, + response: { user_id: string; device_id: string } +): LoginOutcome => { + if (!record) return 'plain'; + return record.userId === response.user_id && record.deviceId === response.device_id + ? 'keep' + : 'wipe'; +}; + +export const useSoftLogout = (): SoftLogoutRecord | undefined => useState(() => getSoftLogout())[0]; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 871c186a..b6d22597 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -111,6 +111,45 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { window.location.reload(); }; +// The rust crypto databases the SDK names, for browsers without indexedDB.databases(). +const KNOWN_DATABASES = [ + 'web-sync-store', + 'crypto-store', + 'matrix-js-sdk::matrix-sdk-crypto', + 'matrix-js-sdk::matrix-sdk-crypto-meta', +]; + +const deleteDatabase = (name: string) => + new Promise((resolve) => { + const req = window.indexedDB.deleteDatabase(name); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); + +/** + * The one wipe of everything this browser holds for the account: client stores, every IndexedDB + * database, localStorage. Hard logout, a soft logout answered by another user or device, and + * "sign in as someone else" all end here. Callers reload afterwards. + */ +export const wipeLocalData = async (mx?: MatrixClient) => { + try { + await mx?.clearStores(); + } catch { + // the databases go below regardless + } + let names: string[]; + try { + names = (await window.indexedDB.databases()) + .map((db) => db.name) + .filter((name): name is string => !!name); + } catch { + names = KNOWN_DATABASES; + } + await Promise.all(names.map(deleteDatabase)); + window.localStorage.clear(); +}; + export const logoutClient = async (mx: MatrixClient) => { pushSessionToSW(); mx.stopClient(); @@ -119,21 +158,11 @@ export const logoutClient = async (mx: MatrixClient) => { } catch { // ignore if failed to logout } - await mx.clearStores(); - window.localStorage.clear(); + await wipeLocalData(mx); window.location.reload(); }; export const clearLoginData = async () => { - const dbs = await window.indexedDB.databases(); - - dbs.forEach((idbInfo) => { - const { name } = idbInfo; - if (name) { - window.indexedDB.deleteDatabase(name); - } - }); - - window.localStorage.clear(); + await wipeLocalData(); window.location.reload(); }; From 3a5258c3ef7344a9211f3ef9dddd5450d7e5cb09 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 18:40:59 +0000 Subject: [PATCH 106/143] Soul fixes: idempotent logout handler, keep register consent entry under MAS, settle stores on register First SessionLoggedOut decides; later events are ignored so a second soft event cannot fall into the wipe. Under MAS a configured registrationEntry (with its training notice) is kept; the MAS notice replaces only the in-client form and bare link. Registration goes through the same keep-or-wipe settle as login. Soft record drops its unused baseUrl. One shared https-or-localhost predicate. Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/login/Login.tsx | 7 +- src/app/pages/auth/login/loginUtil.ts | 6 +- src/app/pages/auth/masAccount.test.ts | 13 +-- src/app/pages/auth/masAccount.ts | 20 +--- .../auth/register/RegisterRoute.test.tsx | 21 ++-- src/app/pages/auth/register/RegisterRoute.tsx | 9 +- src/app/pages/auth/register/registerUtil.ts | 13 ++- src/app/pages/auth/registrationEntry.ts | 15 +-- src/app/pages/client/ClientRoot.tsx | 20 ++-- src/app/state/softLogout.test.ts | 96 ++++++++++++++++++- src/app/state/softLogout.ts | 63 +++++++++--- src/app/utils/secureUrl.ts | 16 ++++ 12 files changed, 214 insertions(+), 85 deletions(-) create mode 100644 src/app/utils/secureUrl.ts diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index 05204779..3606a9a4 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -62,6 +62,7 @@ export function Login() { const parsedFlows = useParsedLoginFlows(loginFlows.flows); const masUrl = useMasAccountUrl(); + const registerEntry = registrationEntry(config); const softLogout = useSoftLogout(); return ( @@ -100,9 +101,9 @@ export function Login() { )} - {masUrl && } - {!masUrl && registrationEntry(config) && } - {!masUrl && !registrationEntry(config) && ( + {registerEntry && } + {!registerEntry && masUrl && } + {!registerEntry && !masUrl && ( Do not have an account? Register diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index f4124db7..7a9cfb38 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -11,7 +11,7 @@ import { } from '../../afterLoginRedirectPath'; import { getHomePath } from '../../pathUtils'; import { setFallbackSession } from '../../../state/sessions'; -import { clearSoftLogout, decideLoginOutcome, getSoftLogout } from '../../../state/softLogout'; +import { settleLocalData } from '../../../state/softLogout'; import { wipeLocalData } from '../../../../client/initMatrix'; export enum GetBaseUrlError { @@ -119,9 +119,7 @@ export const useLoginComplete = (data?: CustomLoginResponse) => { const finish = async () => { const afterLoginRedirectUrl = getAfterLoginRedirectPath(); // A soft logout left stores behind; they stay only for the same user on the same device. - const outcome = decideLoginOutcome(getSoftLogout(), loginRes); - if (outcome === 'wipe') await wipeLocalData(); - clearSoftLogout(); + await settleLocalData(loginRes, () => wipeLocalData()); setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl); deleteAfterLoginRedirectPath(); navigate(afterLoginRedirectUrl ?? getHomePath(), { replace: true }); diff --git a/src/app/pages/auth/masAccount.test.ts b/src/app/pages/auth/masAccount.test.ts index c45497f3..f2331365 100644 --- a/src/app/pages/auth/masAccount.test.ts +++ b/src/app/pages/auth/masAccount.test.ts @@ -41,12 +41,12 @@ describe('masAccountUrl', () => { const src = (p: string) => readFileSync(`${process.cwd()}/src/app/pages/auth/${p}`, 'utf8'); describe('MAS front guards', () => { - it('Login hides both register entries behind the MAS predicate', () => { + it('Login keeps a configured register entry, else MAS notice, else the bare link', () => { const s = src('login/Login.tsx'); expect(s).toContain('useMasAccountUrl()'); - expect(s).toContain('{masUrl && }'); - expect(s).toContain('{!masUrl && !registrationEntry(config) && ('); + expect(s).toContain('{registerEntry && }'); + expect(s).toContain('{!registerEntry && masUrl && { const s = src('login/PasswordLoginForm.tsx'); @@ -66,9 +66,10 @@ describe('MAS front guards', () => { }); it('the logout listener and login completion defer to the one wipe', () => { const root = readFileSync(`${process.cwd()}/src/app/pages/client/ClientRoot.tsx`, 'utf8'); - expect(root).toContain('classifyLogout(err)'); + expect(root).toContain('createLogoutHandler('); expect(root).toContain('wipeLocalData(mx)'); expect(root).not.toContain('localStorage.clear'); - expect(src('login/loginUtil.ts')).toContain('wipeLocalData()'); + expect(src('login/loginUtil.ts')).toContain('settleLocalData(loginRes'); + expect(src('register/registerUtil.ts')).toContain('settleLocalData('); }); }); diff --git a/src/app/pages/auth/masAccount.ts b/src/app/pages/auth/masAccount.ts index b131a916..6e65b2a4 100644 --- a/src/app/pages/auth/masAccount.ts +++ b/src/app/pages/auth/masAccount.ts @@ -1,23 +1,9 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; +import { isSecureOrLocalUrl } from '../../utils/secureUrl'; import type { AutoDiscoveryInfo } from '../../cs-api'; -// Plain http is for a developer's own machine only. -const isLocalHost = (hostname: string): boolean => - hostname === 'localhost' || - hostname.endsWith('.localhost') || - hostname === '127.0.0.1' || - hostname === '[::1]'; - -const usable = (value: unknown): string | undefined => { - if (typeof value !== 'string') return undefined; - try { - const { protocol, hostname } = new URL(value); - if (protocol === 'https:' || (protocol === 'http:' && isLocalHost(hostname))) return value; - } catch { - // not a url - } - return undefined; -}; +const usable = (value: unknown): string | undefined => + typeof value === 'string' && isSecureOrLocalUrl(value) ? value : undefined; /** * The account page of the sign-in service fronting this homeserver (MAS), when discovery diff --git a/src/app/pages/auth/register/RegisterRoute.test.tsx b/src/app/pages/auth/register/RegisterRoute.test.tsx index 1fde64cd..47ea7179 100644 --- a/src/app/pages/auth/register/RegisterRoute.test.tsx +++ b/src/app/pages/auth/register/RegisterRoute.test.tsx @@ -45,14 +45,19 @@ describe('the /register route', () => { ).toContain('in-client register form'); }); - it('under MAS shows the account page notice, not the form, with the switch on or off', () => { - [{}, { registration: { enabled: true, url: 'https://accounts.example/join' } }].forEach( - (config) => { - const html = render(config, mas); - expect(html).toContain('href="https://auth.example/account"'); - expect(html).not.toContain('in-client register form'); - expect(html).not.toContain('Continue to sign up'); - } + it('under MAS with no configured entry shows the account page notice, not the form', () => { + const html = render({}, mas); + expect(html).toContain('href="https://auth.example/account"'); + expect(html).not.toContain('in-client register form'); + }); + + it('under MAS a configured entry keeps its own page, not the notice or the form', () => { + const html = render( + { registration: { enabled: true, url: 'https://accounts.example/join' } }, + mas ); + expect(html).toContain('href="https://accounts.example/join"'); + expect(html).not.toContain('auth.example/account'); + expect(html).not.toContain('in-client register form'); }); }); diff --git a/src/app/pages/auth/register/RegisterRoute.tsx b/src/app/pages/auth/register/RegisterRoute.tsx index fa56527a..ee68dd45 100644 --- a/src/app/pages/auth/register/RegisterRoute.tsx +++ b/src/app/pages/auth/register/RegisterRoute.tsx @@ -13,10 +13,11 @@ export function RegisterRoute() { const entry = registrationEntry(useClientConfig()); const masUrl = useMasAccountUrl(); useEffect(() => { - if (entry && !masUrl) window.location.replace(entry.url); - }, [entry, masUrl]); - // Under MAS the in-client sign-up (UIA) cannot work; the account page is the only way. - if (masUrl) { + if (entry) window.location.replace(entry.url); + }, [entry]); + // Under MAS the in-client sign-up (UIA) cannot work; with no configured entry the account page + // is the only way. A configured entry keeps its own page and its training notice. + if (!entry && masUrl) { return ( diff --git a/src/app/pages/auth/register/registerUtil.ts b/src/app/pages/auth/register/registerUtil.ts index 02f530fb..9d5afa87 100644 --- a/src/app/pages/auth/register/registerUtil.ts +++ b/src/app/pages/auth/register/registerUtil.ts @@ -17,6 +17,8 @@ import { import { getHomePath, getLoginPath, withSearchParam } from '../../pathUtils'; import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix'; import { setFallbackSession } from '../../../state/sessions'; +import { settleLocalData } from '../../../state/softLogout'; +import { wipeLocalData } from '../../../../client/initMatrix'; import type { RegisterTrouble } from '../authMessages'; export enum RegisterError { @@ -141,10 +143,15 @@ export const useRegisterComplete = (data?: CustomRegisterResponse) => { const deviceId = response.device_id; if (accessToken && deviceId) { - setFallbackSession(accessToken, deviceId, userId, baseUrl); const afterLoginRedirectPath = getAfterLoginRedirectPath(); - deleteAfterLoginRedirectPath(); - navigate(afterLoginRedirectPath ?? getHomePath(), { replace: true }); + // A new account never boots on a soft-logged-out user's stores. + settleLocalData({ user_id: userId, device_id: deviceId }, () => wipeLocalData()).then( + () => { + setFallbackSession(accessToken, deviceId, userId, baseUrl); + deleteAfterLoginRedirectPath(); + navigate(afterLoginRedirectPath ?? getHomePath(), { replace: true }); + } + ); } else { const username = getMxIdLocalPart(userId); const userServer = getMxIdServer(userId); diff --git a/src/app/pages/auth/registrationEntry.ts b/src/app/pages/auth/registrationEntry.ts index 6c65732e..2aa0e34d 100644 --- a/src/app/pages/auth/registrationEntry.ts +++ b/src/app/pages/auth/registrationEntry.ts @@ -1,15 +1,9 @@ import type { ClientConfig } from '../../hooks/useClientConfig'; +import { isSecureOrLocalUrl } from '../../utils/secureUrl'; import { TRAINING_NOTICE_VERSION, trainingNoticeText } from '../../utils/trainingNotice'; export type RegistrationEntry = { url: string; notice: string; noticeVersion: number }; -// Plain http is for a developer's own machine only; a stranger's sign-up page is https. -const isLocalHost = (hostname: string): boolean => - hostname === 'localhost' || - hostname.endsWith('.localhost') || - hostname === '127.0.0.1' || - hostname === '[::1]'; - /** * The sign-up entry the sign-in page may show: the account service's registration page, when the * switch is on and the configured url is an https address (http only for localhost). Anything else is no entry. The @@ -18,12 +12,7 @@ const isLocalHost = (hostname: string): boolean => export const registrationEntry = (config: ClientConfig): RegistrationEntry | undefined => { const { enabled, url } = config.registration ?? {}; if (enabled !== true || typeof url !== 'string') return undefined; - try { - const { protocol, hostname } = new URL(url); - if (protocol !== 'https:' && !(protocol === 'http:' && isLocalHost(hostname))) return undefined; - } catch { - return undefined; - } + if (!isSecureOrLocalUrl(url)) return undefined; return { url, notice: trainingNoticeText(config.previewDemo), diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index dd63226a..2cb69ed5 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -36,7 +36,7 @@ import { stopPropagation } from '../../utils/keyboard'; import { SyncStatus } from './SyncStatus'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { getFallbackSession } from '../../state/sessions'; -import { beginSoftLogout, classifyLogout } from '../../state/softLogout'; +import { createLogoutHandler } from '../../state/softLogout'; import { pushSessionToSW } from '../../../sw-session'; import { AutoDiscovery } from './AutoDiscovery'; import { PhoneHit } from '../../styles/PhoneHit.css'; @@ -130,17 +130,15 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) { const useLogoutListener = (mx?: MatrixClient) => { useEffect(() => { - const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async (err) => { - mx?.stopClient(); - // Soft: the token is dead, the device is not. Keep the stores and ask for a sign-in. - if (classifyLogout(err) === 'soft' && beginSoftLogout()) { - pushSessionToSW(); - window.location.reload(); - return; + const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = createLogoutHandler( + { + storage: window.localStorage, + stop: () => mx?.stopClient(), + wipe: () => wipeLocalData(mx), + reload: () => window.location.reload(), + forgetSessionInWorker: () => pushSessionToSW(), } - await wipeLocalData(mx); - window.location.reload(); - }; + ); mx?.on(HttpApiEvent.SessionLoggedOut, handleLogout); return () => { diff --git a/src/app/state/softLogout.test.ts b/src/app/state/softLogout.test.ts index e529288d..8df1a8f8 100644 --- a/src/app/state/softLogout.test.ts +++ b/src/app/state/softLogout.test.ts @@ -5,6 +5,8 @@ import { beginSoftLogout, classifyLogout, clearSoftLogout, + createLogoutHandler, + settleLocalData, decideLoginOutcome, getSoftLogout, setSoftLogout, @@ -29,7 +31,7 @@ const memoryStorage = (): Storage => { }; }; -const record = { userId: '@a:hs', deviceId: 'DEV', baseUrl: 'https://hs.example' }; +const record = { userId: '@a:hs', deviceId: 'DEV' }; describe('classifyLogout', () => { it('is soft only for soft_logout === true', () => { @@ -118,3 +120,95 @@ describe('decideLoginOutcome', () => { expect(decideLoginOutcome(record, { user_id: '@a:hs', device_id: 'NEW' })).toBe('wipe'); }); }); + +describe('the logout handler', () => { + const rig = (soft: boolean) => { + const storage = memoryStorage(); + storage.setItem('cinny_access_token', 'tok'); + storage.setItem('cinny_device_id', 'DEV'); + storage.setItem('cinny_user_id', '@a:hs'); + storage.setItem('cinny_hs_base_url', 'https://hs.example'); + const calls = { stop: 0, wipe: 0, reload: 0, worker: 0 }; + const handler = createLogoutHandler({ + storage, + stop: () => { + calls.stop += 1; + }, + wipe: async () => { + calls.wipe += 1; + }, + reload: () => { + calls.reload += 1; + }, + forgetSessionInWorker: () => { + calls.worker += 1; + }, + }); + const err = { data: { soft_logout: soft } }; + return { storage, calls, handler, err }; + }; + + it('soft twice: one begin, no wipe, record kept', async () => { + const { storage, calls, handler, err } = rig(true); + await handler(err); + await handler(err); + expect(calls).toEqual({ stop: 1, wipe: 0, reload: 1, worker: 1 }); + expect(getSoftLogout(storage)).toEqual(record); + expect(storage.getItem('cinny_device_id')).toBe('DEV'); + }); + it('soft then hard: the later event does not wipe', async () => { + const { calls, handler } = rig(true); + await handler({ data: { soft_logout: true } }); + await handler({ data: {} }); + expect(calls.wipe).toBe(0); + }); + it('a record already standing swallows a fresh handler too', async () => { + const { storage, calls, handler } = rig(false); + setSoftLogout(record, storage); + await handler({ data: {} }); + expect(calls).toEqual({ stop: 0, wipe: 0, reload: 0, worker: 0 }); + }); + it('hard twice: one wipe', async () => { + const { calls, handler, err } = rig(false); + await handler(err); + await handler(err); + expect(calls).toEqual({ stop: 1, wipe: 1, reload: 1, worker: 0 }); + }); +}); + +describe('settleLocalData', () => { + const run = async ( + rec: typeof record | undefined, + res: { user_id: string; device_id: string } + ) => { + const s = memoryStorage(); + if (rec) setSoftLogout(rec, s); + let wipes = 0; + const outcome = await settleLocalData( + res, + async () => { + wipes += 1; + }, + s + ); + return { outcome, wipes, left: getSoftLogout(s) }; + }; + it('same user and device: keep stores, retire the record', async () => { + expect(await run(record, { user_id: '@a:hs', device_id: 'DEV' })).toEqual({ + outcome: 'keep', + wipes: 0, + left: undefined, + }); + }); + it('another user or device (a new account too): wipe once, retire the record', async () => { + expect(await run(record, { user_id: '@new:hs', device_id: 'X' })).toEqual({ + outcome: 'wipe', + wipes: 1, + left: undefined, + }); + expect((await run(record, { user_id: '@a:hs', device_id: 'OTHER' })).wipes).toBe(1); + }); + it('no record: plain, no wipe', async () => { + expect((await run(undefined, { user_id: '@a:hs', device_id: 'D' })).wipes).toBe(0); + }); +}); diff --git a/src/app/state/softLogout.ts b/src/app/state/softLogout.ts index d7ea1180..645d0cc3 100644 --- a/src/app/state/softLogout.ts +++ b/src/app/state/softLogout.ts @@ -10,23 +10,15 @@ const ACCESS_TOKEN_KEY = 'cinny_access_token'; export type SoftLogoutRecord = { userId: string; deviceId: string; - baseUrl: string; }; export const getSoftLogout = (storage: Storage = localStorage): SoftLogoutRecord | undefined => { const raw = storage.getItem(SOFT_LOGOUT_KEY); if (!raw) return undefined; try { - const { userId, deviceId, baseUrl } = JSON.parse(raw) as Partial; - if ( - typeof userId === 'string' && - typeof deviceId === 'string' && - typeof baseUrl === 'string' && - userId && - deviceId && - baseUrl - ) { - return { userId, deviceId, baseUrl }; + const { userId, deviceId } = JSON.parse(raw) as Partial; + if (typeof userId === 'string' && typeof deviceId === 'string' && userId && deviceId) { + return { userId, deviceId }; } } catch { // an unreadable record is no record @@ -57,10 +49,7 @@ export const classifyLogout = (err: unknown): LogoutKind => { export const beginSoftLogout = (storage: Storage = localStorage): boolean => { const session = getFallbackSession(storage); if (!session) return false; - setSoftLogout( - { userId: session.userId, deviceId: session.deviceId, baseUrl: session.baseUrl }, - storage - ); + setSoftLogout({ userId: session.userId, deviceId: session.deviceId }, storage); storage.removeItem(ACCESS_TOKEN_KEY); return true; }; @@ -89,3 +78,47 @@ export const decideLoginOutcome = ( }; export const useSoftLogout = (): SoftLogoutRecord | undefined => useState(() => getSoftLogout())[0]; + +export type LogoutHandlerPorts = { + storage: Storage; + stop: () => void; + wipe: () => Promise; + reload: () => void; + forgetSessionInWorker: () => void; +}; + +/** + * The SessionLoggedOut handler. The SDK emits once per failing request and in-flight requests + * outlive stopClient, so the first event decides and every later one is ignored: a second soft + * event must never find the token already gone and fall into the wipe. + */ +export const createLogoutHandler = (ports: LogoutHandlerPorts) => { + let fired = false; + return async (err: unknown): Promise => { + if (fired || getSoftLogout(ports.storage)) return; + fired = true; + ports.stop(); + if (classifyLogout(err) === 'soft') { + if (beginSoftLogout(ports.storage)) ports.forgetSessionInWorker(); + ports.reload(); + return; + } + await ports.wipe(); + ports.reload(); + }; +}; + +/** + * Settle local data when a login or registration hands back a session: wipe first when a soft + * logout left stores that belong to someone else, then retire the record. + */ +export const settleLocalData = async ( + response: { user_id: string; device_id: string }, + wipe: () => Promise, + storage: Storage = localStorage +): Promise => { + const outcome = decideLoginOutcome(getSoftLogout(storage), response); + if (outcome === 'wipe') await wipe(); + clearSoftLogout(storage); + return outcome; +}; diff --git a/src/app/utils/secureUrl.ts b/src/app/utils/secureUrl.ts new file mode 100644 index 00000000..f1dacd9c --- /dev/null +++ b/src/app/utils/secureUrl.ts @@ -0,0 +1,16 @@ +// Plain http is for a developer's own machine only; a stranger's page is https. +const isLocalHost = (hostname: string): boolean => + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]'; + +/** True for an https url, or an http url on localhost. Anything else, including non-urls, is false. */ +export const isSecureOrLocalUrl = (value: string): boolean => { + try { + const { protocol, hostname } = new URL(value); + return protocol === 'https:' || (protocol === 'http:' && isLocalHost(hostname)); + } catch { + return false; + } +}; From 31d58075db819a1b3c562829a464ac4d33cc2b9a Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 18:56:08 +0000 Subject: [PATCH 107/143] Second Soul pass: soft event without a session wipes, wipe never rejects, rendered MAS tests wipeLocalData moves to its own module with injectable ports: per-database failures are contained and localStorage clears in a finally. A soft logout with no session to remember is treated as hard. Login/PasswordLoginForm/ResetPassword MAS behavior is now tested by rendering. Login and register completion settle once per response. Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/authFront.test.tsx | 113 ++++++++++++++++++++ src/app/pages/auth/login/loginUtil.ts | 8 +- src/app/pages/auth/masAccount.test.ts | 37 ------- src/app/pages/auth/register/registerUtil.ts | 8 +- src/app/pages/client/ClientRoot.tsx | 2 +- src/app/state/softLogout.test.ts | 7 ++ src/app/state/softLogout.ts | 5 +- src/client/initMatrix.ts | 40 +------ src/client/wipeLocalData.test.ts | 78 ++++++++++++++ src/client/wipeLocalData.ts | 58 ++++++++++ 10 files changed, 271 insertions(+), 85 deletions(-) create mode 100644 src/app/pages/auth/authFront.test.tsx create mode 100644 src/client/wipeLocalData.test.ts create mode 100644 src/client/wipeLocalData.ts diff --git a/src/app/pages/auth/authFront.test.tsx b/src/app/pages/auth/authFront.test.tsx new file mode 100644 index 00000000..3e6abfd7 --- /dev/null +++ b/src/app/pages/auth/authFront.test.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import { MemoryRouter } from 'react-router-dom'; +import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo'; +import { AuthServerProvider } from '../../hooks/useAuthServer'; +import { AuthFlowsProvider } from '../../hooks/useAuthFlows'; +import { ClientConfigProvider } from '../../hooks/useClientConfig'; +import type { ClientConfig } from '../../hooks/useClientConfig'; +import type { AutoDiscoveryInfo } from '../../cs-api'; +import { setSoftLogout } from '../../state/softLogout'; +import { Login } from './login/Login'; +import { ResetPassword } from './reset-password/ResetPassword'; + +vi.mock('../../../client/initMatrix', () => ({ clearLoginData: vi.fn() })); +// folds' Overlay portals into document, which the server renderer has none of. +vi.mock('folds', async (importOriginal) => ({ + ...(await importOriginal()), + Overlay: () => null, +})); +vi.mock('./reset-password/PasswordResetForm', () => ({ + PasswordResetForm: () =>
        in-client reset form
        , +})); + +const plain: AutoDiscoveryInfo = { 'm.homeserver': { base_url: 'https://hs.example' } }; +const mas: AutoDiscoveryInfo = { + ...plain, + 'org.matrix.msc2965.authentication': { account: 'https://auth.example/account' }, +}; +const entryConfig: ClientConfig = { + registration: { enabled: true, url: 'https://accounts.example/join' }, +}; +const flows = { + loginFlows: { flows: [{ type: 'm.login.password' }] }, + registerFlows: { status: 400 }, +} as never; + +const render = (ui: React.ReactElement, info: AutoDiscoveryInfo, config: ClientConfig = {}) => + renderToString( + + + + + {ui} + + + + + ); + +const store = new Map(); +beforeEach(() => { + store.clear(); + vi.stubGlobal('window', { location: { origin: 'https://app.example', search: '' } }); + vi.stubGlobal('localStorage', { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('Login register entries', () => { + it('non-MAS without an entry: the bare Register link, no notice', () => { + const html = render(, plain); + expect(html).toContain('Register'); + expect(html).not.toContain('managed by your sign-in service'); + }); + it('non-MAS with an entry: the entry and its notice', () => { + const html = render(, plain, entryConfig); + expect(html).toContain('href="https://accounts.example/join"'); + }); + it('MAS without an entry: the account page notice, no bare Register link', () => { + const html = render(, mas); + expect(html).toContain('managed by your sign-in service'); + expect(html).toContain('href="https://auth.example/account"'); + expect(html).not.toContain('Register'); + }); + it('MAS with an entry: the entry stays, no MAS notice', () => { + const html = render(, mas, entryConfig); + expect(html).toContain('href="https://accounts.example/join"'); + expect(html).not.toContain('managed by your sign-in service'); + }); +}); + +describe('PasswordLoginForm through Login', () => { + it('shows Forgot Password only without MAS', () => { + expect(render(, plain)).toContain('Forget Password?'); + expect(render(, mas)).not.toContain('Forget Password?'); + }); + it('fixes the username and offers someone else only with a soft record', () => { + expect(render(, plain)).not.toContain('readOnly'); + expect(render(, plain)).not.toContain('Sign in as someone else'); + setSoftLogout({ userId: '@a:hs.example', deviceId: 'DEV' }); + const html = render(, plain); + expect(html).toContain('value="@a:hs.example"'); + expect(html).toMatch(/readonly/i); + expect(html).toContain('Sign in as someone else'); + }); +}); + +describe('ResetPassword', () => { + it('shows the notice under MAS and the form otherwise', () => { + const under = render(, mas); + expect(under).toContain('href="https://auth.example/account"'); + expect(under).not.toContain('in-client reset form'); + const plainHtml = render(, plain); + expect(plainHtml).toContain('in-client reset form'); + expect(plainHtml).not.toContain('managed by your sign-in service'); + }); +}); diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index 7a9cfb38..d1cfea82 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -1,6 +1,6 @@ import to from 'await-to-js'; import { LoginRequest, LoginResponse, MatrixError, createClient } from 'matrix-js-sdk'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { ClientConfig, clientAllowedServer } from '../../../hooks/useClientConfig'; import { autoDiscovery, specVersions } from '../../../cs-api'; @@ -12,7 +12,7 @@ import { import { getHomePath } from '../../pathUtils'; import { setFallbackSession } from '../../../state/sessions'; import { settleLocalData } from '../../../state/softLogout'; -import { wipeLocalData } from '../../../../client/initMatrix'; +import { wipeLocalData } from '../../../../client/wipeLocalData'; export enum GetBaseUrlError { NotAllow = 'NotAllow', @@ -112,9 +112,11 @@ export const login = async ( export const useLoginComplete = (data?: CustomLoginResponse) => { const navigate = useNavigate(); + const settled = useRef(); useEffect(() => { - if (!data) return; + if (!data || settled.current === data) return; + settled.current = data; const { response: loginRes, baseUrl: loginBaseUrl } = data; const finish = async () => { const afterLoginRedirectUrl = getAfterLoginRedirectPath(); diff --git a/src/app/pages/auth/masAccount.test.ts b/src/app/pages/auth/masAccount.test.ts index f2331365..04bd6340 100644 --- a/src/app/pages/auth/masAccount.test.ts +++ b/src/app/pages/auth/masAccount.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'fs'; import { masAccountUrl } from './masAccount'; import type { AutoDiscoveryInfo } from '../../cs-api'; @@ -37,39 +36,3 @@ describe('masAccountUrl', () => { expect(masAccountUrl(info({ account: 'ftp://a.example' }))).toBeUndefined(); }); }); - -const src = (p: string) => readFileSync(`${process.cwd()}/src/app/pages/auth/${p}`, 'utf8'); - -describe('MAS front guards', () => { - it('Login keeps a configured register entry, else MAS notice, else the bare link', () => { - const s = src('login/Login.tsx'); - expect(s).toContain('useMasAccountUrl()'); - expect(s).toContain('{registerEntry && }'); - expect(s).toContain('{!registerEntry && masUrl && { - const s = src('login/PasswordLoginForm.tsx'); - expect(s).toContain('useMasAccountUrl()'); - expect(s).toMatch(/\{!masUrl && \([\s\S]*getResetPasswordPath/); - }); - it('RegisterRoute and ResetPassword show the notice under MAS', () => { - ['register/RegisterRoute.tsx', 'reset-password/ResetPassword.tsx'].forEach((p) => { - const s = src(p); - expect(s).toContain('useMasAccountUrl()'); - expect(s).toContain(' { - expect(src('login/PasswordLoginForm.tsx').match(/withDevice\(\{/g)).toHaveLength(3); - expect(src('login/TokenLogin.tsx')).toContain('withSoftLogoutDevice('); - }); - it('the logout listener and login completion defer to the one wipe', () => { - const root = readFileSync(`${process.cwd()}/src/app/pages/client/ClientRoot.tsx`, 'utf8'); - expect(root).toContain('createLogoutHandler('); - expect(root).toContain('wipeLocalData(mx)'); - expect(root).not.toContain('localStorage.clear'); - expect(src('login/loginUtil.ts')).toContain('settleLocalData(loginRes'); - expect(src('register/registerUtil.ts')).toContain('settleLocalData('); - }); -}); diff --git a/src/app/pages/auth/register/registerUtil.ts b/src/app/pages/auth/register/registerUtil.ts index 9d5afa87..dca4dd17 100644 --- a/src/app/pages/auth/register/registerUtil.ts +++ b/src/app/pages/auth/register/registerUtil.ts @@ -6,7 +6,7 @@ import { RegisterRequest, RegisterResponse, } from 'matrix-js-sdk'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { LoginPathSearchParams } from '../../paths'; import { ErrorCode } from '../../../cs-errorcode'; @@ -18,7 +18,7 @@ import { getHomePath, getLoginPath, withSearchParam } from '../../pathUtils'; import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix'; import { setFallbackSession } from '../../../state/sessions'; import { settleLocalData } from '../../../state/softLogout'; -import { wipeLocalData } from '../../../../client/initMatrix'; +import { wipeLocalData } from '../../../../client/wipeLocalData'; import type { RegisterTrouble } from '../authMessages'; export enum RegisterError { @@ -133,9 +133,11 @@ export const register = async ( export const useRegisterComplete = (data?: CustomRegisterResponse) => { const navigate = useNavigate(); + const settled = useRef(); useEffect(() => { - if (data) { + if (data && settled.current !== data) { + settled.current = data; const { response, baseUrl } = data; const userId = response.user_id; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 2cb69ed5..ba1876ce 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -19,11 +19,11 @@ import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } import { clearCacheAndReload, clearLoginData, - wipeLocalData, initClient, logoutClient, startClient, } from '../../../client/initMatrix'; +import { wipeLocalData } from '../../../client/wipeLocalData'; import { SplashScreen } from '../../components/splash-screen'; import { ServerConfigsLoader } from '../../components/ServerConfigsLoader'; import { CapabilitiesProvider } from '../../hooks/useCapabilities'; diff --git a/src/app/state/softLogout.test.ts b/src/app/state/softLogout.test.ts index 8df1a8f8..2f113dd4 100644 --- a/src/app/state/softLogout.test.ts +++ b/src/app/state/softLogout.test.ts @@ -168,6 +168,13 @@ describe('the logout handler', () => { await handler({ data: {} }); expect(calls).toEqual({ stop: 0, wipe: 0, reload: 0, worker: 0 }); }); + it('soft with no session in storage: one wipe, no record', async () => { + const { storage, calls, handler } = rig(true); + storage.removeItem('cinny_access_token'); + await handler({ data: { soft_logout: true } }); + expect(calls).toEqual({ stop: 1, wipe: 1, reload: 1, worker: 0 }); + expect(getSoftLogout(storage)).toBeUndefined(); + }); it('hard twice: one wipe', async () => { const { calls, handler, err } = rig(false); await handler(err); diff --git a/src/app/state/softLogout.ts b/src/app/state/softLogout.ts index 645d0cc3..bf0c3fd1 100644 --- a/src/app/state/softLogout.ts +++ b/src/app/state/softLogout.ts @@ -98,8 +98,9 @@ export const createLogoutHandler = (ports: LogoutHandlerPorts) => { if (fired || getSoftLogout(ports.storage)) return; fired = true; ports.stop(); - if (classifyLogout(err) === 'soft') { - if (beginSoftLogout(ports.storage)) ports.forgetSessionInWorker(); + // Soft needs a session to remember; without one nothing can vouch for the stores, so wipe. + if (classifyLogout(err) === 'soft' && beginSoftLogout(ports.storage)) { + ports.forgetSessionInWorker(); ports.reload(); return; } diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index b6d22597..a5cc2b35 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -7,6 +7,7 @@ import { SyncState, } from 'matrix-js-sdk'; +import { wipeLocalData } from './wipeLocalData'; import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { pushSessionToSW } from '../sw-session'; @@ -111,45 +112,6 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { window.location.reload(); }; -// The rust crypto databases the SDK names, for browsers without indexedDB.databases(). -const KNOWN_DATABASES = [ - 'web-sync-store', - 'crypto-store', - 'matrix-js-sdk::matrix-sdk-crypto', - 'matrix-js-sdk::matrix-sdk-crypto-meta', -]; - -const deleteDatabase = (name: string) => - new Promise((resolve) => { - const req = window.indexedDB.deleteDatabase(name); - req.onsuccess = () => resolve(); - req.onerror = () => resolve(); - req.onblocked = () => resolve(); - }); - -/** - * The one wipe of everything this browser holds for the account: client stores, every IndexedDB - * database, localStorage. Hard logout, a soft logout answered by another user or device, and - * "sign in as someone else" all end here. Callers reload afterwards. - */ -export const wipeLocalData = async (mx?: MatrixClient) => { - try { - await mx?.clearStores(); - } catch { - // the databases go below regardless - } - let names: string[]; - try { - names = (await window.indexedDB.databases()) - .map((db) => db.name) - .filter((name): name is string => !!name); - } catch { - names = KNOWN_DATABASES; - } - await Promise.all(names.map(deleteDatabase)); - window.localStorage.clear(); -}; - export const logoutClient = async (mx: MatrixClient) => { pushSessionToSW(); mx.stopClient(); diff --git a/src/client/wipeLocalData.test.ts b/src/client/wipeLocalData.test.ts new file mode 100644 index 00000000..2d3faa0a --- /dev/null +++ b/src/client/wipeLocalData.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { KNOWN_DATABASES, wipeLocalData } from './wipeLocalData'; + +type Mode = 'success' | 'blocked' | 'error' | 'throws'; + +const rig = (opts: { databases?: () => Promise<{ name?: string }[]>; mode?: Mode }) => { + const deleted: string[] = []; + let cleared = 0; + const indexedDB = { + databases: opts.databases, + deleteDatabase: (name: string) => { + deleted.push(name); + if (opts.mode === 'throws') throw new Error('sync boom'); + const req: Record void) | undefined> = {}; + setTimeout(() => { + if (opts.mode === 'blocked') req.onblocked?.(); + else if (opts.mode === 'error') req.onerror?.(); + else req.onsuccess?.(); + }, 0); + return req as unknown as IDBOpenDBRequest; + }, + }; + const storage = { + clear: () => { + cleared += 1; + }, + }; + return { deleted, cleared: () => cleared, ports: { indexedDB, storage } }; +}; + +describe('wipeLocalData', () => { + it('deletes every listed database and clears storage', async () => { + const r = rig({ databases: async () => [{ name: 'a' }, { name: 'b' }, {}] }); + await wipeLocalData(undefined, r.ports); + expect(r.deleted).toEqual(['a', 'b']); + expect(r.cleared()).toBe(1); + }); + it('falls back to the known names when databases() is absent or throws', async () => { + const absent = rig({}); + await wipeLocalData(undefined, absent.ports); + expect(absent.deleted).toEqual(KNOWN_DATABASES); + expect(absent.cleared()).toBe(1); + const throws = rig({ + databases: async () => { + throw new Error('nope'); + }, + }); + await wipeLocalData(undefined, throws.ports); + expect(throws.deleted).toEqual(KNOWN_DATABASES); + expect(throws.cleared()).toBe(1); + }); + it('a synchronous deleteDatabase throw does not abort the rest or the storage clear', async () => { + const r = rig({ databases: async () => [{ name: 'a' }, { name: 'b' }], mode: 'throws' }); + await expect(wipeLocalData(undefined, r.ports)).resolves.toBeUndefined(); + expect(r.deleted).toEqual(['a', 'b']); + expect(r.cleared()).toBe(1); + }); + it('blocked and errored deletions settle and still clear storage', async () => { + await Promise.all( + (['blocked', 'error'] as const).map(async (mode) => { + const r = rig({ databases: async () => [{ name: 'a' }], mode }); + await wipeLocalData(undefined, r.ports); + expect(r.cleared()).toBe(1); + }) + ); + }); + it('a throwing clearStores does not stop the wipe', async () => { + const r = rig({ databases: async () => [{ name: 'a' }] }); + const mx = { + clearStores: async () => { + throw new Error('x'); + }, + }; + await wipeLocalData(mx as never, r.ports); + expect(r.deleted).toEqual(['a']); + expect(r.cleared()).toBe(1); + }); +}); diff --git a/src/client/wipeLocalData.ts b/src/client/wipeLocalData.ts new file mode 100644 index 00000000..dc24951c --- /dev/null +++ b/src/client/wipeLocalData.ts @@ -0,0 +1,58 @@ +import type { MatrixClient } from 'matrix-js-sdk'; + +// The rust crypto databases the SDK names, for browsers without indexedDB.databases(). +export const KNOWN_DATABASES = [ + 'web-sync-store', + 'crypto-store', + 'matrix-js-sdk::matrix-sdk-crypto', + 'matrix-js-sdk::matrix-sdk-crypto-meta', +]; + +export type WipePorts = { + indexedDB: Pick & { + databases?: () => Promise<{ name?: string }[]>; + }; + storage: Pick; +}; + +const deleteDatabase = (idb: WipePorts['indexedDB'], name: string) => + new Promise((resolve) => { + try { + const req = idb.deleteDatabase(name); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + } catch { + // one database failing must not stop the rest + resolve(); + } + }); + +/** + * The one wipe of everything this browser holds for the account: client stores, every IndexedDB + * database, localStorage. Hard logout, a soft logout answered by another user or device, and + * "sign in as someone else" all end here. It never rejects, and localStorage (the access token + * with it) is cleared whatever the databases do. Callers reload afterwards. + */ +export const wipeLocalData = async ( + mx?: MatrixClient, + ports: WipePorts = { indexedDB: window.indexedDB, storage: window.localStorage } +): Promise => { + try { + try { + await mx?.clearStores(); + } catch { + // the databases go below regardless + } + let names = KNOWN_DATABASES; + try { + const listed = await ports.indexedDB.databases?.(); + if (listed) names = listed.map((db) => db.name).filter((n): n is string => !!n); + } catch { + // keep the known names + } + await Promise.all(names.map((name) => deleteDatabase(ports.indexedDB, name))); + } finally { + ports.storage.clear(); + } +}; From 8fb95402f59a87b60176e66e395e279b4b1a2994 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 19:13:04 +0000 Subject: [PATCH 108/143] wipeLocalData: bounded clearStores, storage clear that cannot reject; tests that bite Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/authFront.test.tsx | 2 +- src/client/wipeLocalData.test.ts | 25 +++++++++++++++++++++++++ src/client/wipeLocalData.ts | 20 +++++++++++++++++--- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/app/pages/auth/authFront.test.tsx b/src/app/pages/auth/authFront.test.tsx index 3e6abfd7..df15bbd5 100644 --- a/src/app/pages/auth/authFront.test.tsx +++ b/src/app/pages/auth/authFront.test.tsx @@ -91,7 +91,7 @@ describe('PasswordLoginForm through Login', () => { expect(render(, mas)).not.toContain('Forget Password?'); }); it('fixes the username and offers someone else only with a soft record', () => { - expect(render(, plain)).not.toContain('readOnly'); + expect(render(, plain)).not.toMatch(/readonly/i); expect(render(, plain)).not.toContain('Sign in as someone else'); setSoftLogout({ userId: '@a:hs.example', deviceId: 'DEV' }); const html = render(, plain); diff --git a/src/client/wipeLocalData.test.ts b/src/client/wipeLocalData.test.ts index 2d3faa0a..1cff5f16 100644 --- a/src/client/wipeLocalData.test.ts +++ b/src/client/wipeLocalData.test.ts @@ -39,6 +39,12 @@ describe('wipeLocalData', () => { const absent = rig({}); await wipeLocalData(undefined, absent.ports); expect(absent.deleted).toEqual(KNOWN_DATABASES); + expect(KNOWN_DATABASES).toEqual([ + 'web-sync-store', + 'crypto-store', + 'matrix-js-sdk::matrix-sdk-crypto', + 'matrix-js-sdk::matrix-sdk-crypto-meta', + ]); expect(absent.cleared()).toBe(1); const throws = rig({ databases: async () => { @@ -75,4 +81,23 @@ describe('wipeLocalData', () => { expect(r.deleted).toEqual(['a']); expect(r.cleared()).toBe(1); }); + it('a storage that refuses to clear does not make the wipe reject', async () => { + const r = rig({ databases: async () => [] }); + const ports = { + ...r.ports, + storage: { + clear: () => { + throw new Error('SecurityError'); + }, + }, + }; + await expect(wipeLocalData(undefined, ports)).resolves.toBeUndefined(); + }); + it('a client store that never closes holds the wipe only for the timeout, then storage is cleared', async () => { + const r = rig({ databases: async () => [{ name: 'a' }] }); + const mx = { clearStores: () => new Promise(() => undefined) } as never; + await wipeLocalData(mx, { ...r.ports, clearStoresTimeoutMs: 10 }); + expect(r.deleted).toEqual(['a']); + expect(r.cleared()).toBe(1); + }); }); diff --git a/src/client/wipeLocalData.ts b/src/client/wipeLocalData.ts index dc24951c..d0a2da86 100644 --- a/src/client/wipeLocalData.ts +++ b/src/client/wipeLocalData.ts @@ -13,8 +13,12 @@ export type WipePorts = { databases?: () => Promise<{ name?: string }[]>; }; storage: Pick; + /** How long a client store that will not close may hold the wipe up. */ + clearStoresTimeoutMs?: number; }; +const CLEAR_STORES_TIMEOUT_MS = 5000; + const deleteDatabase = (idb: WipePorts['indexedDB'], name: string) => new Promise((resolve) => { try { @@ -32,7 +36,8 @@ const deleteDatabase = (idb: WipePorts['indexedDB'], name: string) => * The one wipe of everything this browser holds for the account: client stores, every IndexedDB * database, localStorage. Hard logout, a soft logout answered by another user or device, and * "sign in as someone else" all end here. It never rejects, and localStorage (the access token - * with it) is cleared whatever the databases do. Callers reload afterwards. + * with it) is cleared whatever the databases do; a client store that hangs is given + * `clearStoresTimeoutMs`, then the wipe goes on. Callers reload afterwards. */ export const wipeLocalData = async ( mx?: MatrixClient, @@ -40,7 +45,12 @@ export const wipeLocalData = async ( ): Promise => { try { try { - await mx?.clearStores(); + await Promise.race([ + mx?.clearStores(), + new Promise((resolve) => { + setTimeout(resolve, ports.clearStoresTimeoutMs ?? CLEAR_STORES_TIMEOUT_MS); + }), + ]); } catch { // the databases go below regardless } @@ -53,6 +63,10 @@ export const wipeLocalData = async ( } await Promise.all(names.map((name) => deleteDatabase(ports.indexedDB, name))); } finally { - ports.storage.clear(); + try { + ports.storage.clear(); + } catch { + // storage that refuses to clear cannot be helped; the reload still has to follow + } } }; From cd30c3dc40e6bc56616297a63ef2a13fc93504ad Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 19:13:14 +0000 Subject: [PATCH 109/143] wipeLocalData test: lint-clean never-settling store Co-Authored-By: Claude Sonnet 5.5 --- src/client/wipeLocalData.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/client/wipeLocalData.test.ts b/src/client/wipeLocalData.test.ts index 1cff5f16..82c835de 100644 --- a/src/client/wipeLocalData.test.ts +++ b/src/client/wipeLocalData.test.ts @@ -95,7 +95,12 @@ describe('wipeLocalData', () => { }); it('a client store that never closes holds the wipe only for the timeout, then storage is cleared', async () => { const r = rig({ databases: async () => [{ name: 'a' }] }); - const mx = { clearStores: () => new Promise(() => undefined) } as never; + const mx = { + clearStores: () => + new Promise(() => { + // never settles + }), + } as never; await wipeLocalData(mx, { ...r.ports, clearStoresTimeoutMs: 10 }); expect(r.deleted).toEqual(['a']); expect(r.cleared()).toBe(1); From 53a724ae27278c628a428a85cb7dfa98a6db8a2c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 21:45:00 +0000 Subject: [PATCH 110/143] Files desk cut 1 under Matrix authority: folders by album title path, share into the current folder Albums titled a/b/c group into folders in AlbumView; ShareSet prefixes the current folder onto the album title it sends. No wire change. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/albums/AlbumView.tsx | 60 +++++++-- src/app/features/albums/ShareSet.tsx | 10 +- src/app/features/albums/albumFolders.test.ts | 129 +++++++++++++++++++ src/app/features/albums/albumFolders.ts | 73 +++++++++++ 4 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 src/app/features/albums/albumFolders.test.ts create mode 100644 src/app/features/albums/albumFolders.ts diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index b670ee2b..20c1e27c 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -15,6 +15,7 @@ import { getBlobSafeMimeType } from '../../utils/mimeTypes'; import { timeDayMonYear } from '../../utils/time'; import { getMemberDisplayName } from '../../utils/room'; import { Album, AlbumFile } from './buildAlbums'; +import { folderOf, listFolder, pathLabel } from './albumFolders'; import { FileReadBy } from './FileReadBy'; import { ShareSet, useShareEnabled } from './ShareSet'; import { useAlbums } from './useAlbums'; @@ -91,6 +92,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { const { loading, error, albums } = useAlbums(room); const [dateFormat] = useSetting(settingsAtom, 'dateFormatString'); const [openId, setOpenId] = useState(); + const [path, setPath] = useState([]); const open: Album | undefined = albums.find((a) => a.id === openId); const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author; const encrypted = room.hasEncryptionStateEvent(); @@ -103,8 +105,17 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image'); body = ( <> - {open.title} @@ -122,14 +133,41 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { body = This zone is encrypted, so its albums cannot be listed here.; else if (albums.length === 0) body = No albums yet.; else { - body = albums.map((a) => ( - - )); + const here = listFolder(albums, path); + body = ( + <> + {path.length > 0 && ( + + )} + {path.length > 0 && {pathLabel(path)}} + {here.folders.map((f) => ( + + ))} + {here.albums.map(({ album: a, leaf }) => ( + + ))} + {here.folders.length === 0 && here.albums.length === 0 && Nothing here.} + + ); } return ( @@ -139,7 +177,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { {/* Stays mounted while an album is open, so a running send keeps its progress. */} {canShare && ( )} ))} {here.folders.length === 0 && here.albums.length === 0 && Nothing here.} + {truncated && ( + + The list is cut short: older albums are not loaded. + + )} ); } diff --git a/src/app/features/albums/ShareSet.folder.test.tsx b/src/app/features/albums/ShareSet.folder.test.tsx new file mode 100644 index 00000000..7f23fd63 --- /dev/null +++ b/src/app/features/albums/ShareSet.folder.test.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MatrixClientProvider } from '../../hooks/useMatrixClient'; + +// ShareSet hands its folder to the title the send lands under. The controller is held still and its +// `run` captured, and sendShareSet records the title it was given, so the path from the `folder` +// prop to the album root's title is read without a homeserver or a DOM. +const captured = vi.hoisted(() => ({ + run: undefined as undefined | ((files: unknown[], title: string, onFile: () => void) => unknown), + titles: [] as string[], +})); + +vi.mock('./shareController', () => ({ + createShareController: (deps: { run: NonNullable }) => { + captured.run = deps.run; + return { + subscribe: () => () => undefined, + getState: () => ({ files: [], title: '', phase: { kind: 'idle' }, epoch: 0 }), + pick: () => undefined, + setTitle: () => undefined, + send: () => undefined, + cancel: () => undefined, + }; + }, +})); +vi.mock('./shareSet', async (importOriginal) => ({ + ...(await importOriginal()), + sendShareSet: async (_port: unknown, title: string) => { + captured.titles.push(title); + return { albumId: '$a', sent: 0, failed: [], failedAt: [] }; + }, +})); +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal(); + // The server renderer has no snapshot for an external store; read the client one. + const useSyncExternalStore = (_sub: unknown, get: () => unknown) => get(); + return { ...actual, default: { ...actual, useSyncExternalStore }, useSyncExternalStore }; +}); + +// eslint-disable-next-line import/first +import { ShareSet } from './ShareSet'; + +const mount = (folder?: string[]) => { + const room = { roomId: '!r:x', hasEncryptionStateEvent: () => false } as never; + renderToStaticMarkup( + + ({ event_id: '$e' }) } as never}> + + + + ); +}; +const send = async (typed: string) => { + await captured.run?.([], typed, () => undefined); + return captured.titles.at(-1); +}; + +describe('ShareSet: the folder reaches the album title', () => { + beforeEach(() => { + captured.run = undefined; + captured.titles.length = 0; + }); + + it('at the top, the typed title goes as typed', async () => { + mount(); + expect(await send('Poses')).toBe('Poses'); + }); + + it('inside a folder, the path prefixes the typed title', async () => { + mount(['cast-art', 'poses']); + expect(await send('Set 2')).toBe('cast-art/poses/Set 2'); + }); + + it('an empty typed title falls back to the default, still under the folder', async () => { + mount(['handoff']); + expect(await send(' ')).toBe('handoff/Shared files'); + }); + + it('a typed title containing a slash goes deeper', async () => { + mount(['a']); + expect(await send('b/c')).toBe('a/b/c'); + }); +}); diff --git a/src/app/features/albums/readPages.test.ts b/src/app/features/albums/readPages.test.ts new file mode 100644 index 00000000..124a32a6 --- /dev/null +++ b/src/app/features/albums/readPages.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { Page, readPages } from './readPages'; + +const pager = (pages: Page[]) => { + const froms: (string | undefined)[] = []; + return { + froms, + fetchPage: async (from: string | undefined) => { + froms.push(from); + return pages[froms.length - 1] ?? {}; + }, + }; +}; + +describe('readPages', () => { + it('one page without a token is the whole read', async () => { + const p = pager([{ chunk: [1, 2] }]); + expect(await readPages(p.fetchPage)).toEqual({ items: [1, 2], truncated: false }); + expect(p.froms).toEqual([undefined]); + }); + + it('follows next_batch in order, passing each token back', async () => { + const p = pager([ + { chunk: [1], next_batch: 'a' }, + { chunk: [2], next_batch: 'b' }, + { chunk: [3] }, + ]); + expect(await readPages(p.fetchPage)).toEqual({ items: [1, 2, 3], truncated: false }); + expect(p.froms).toEqual([undefined, 'a', 'b']); + }); + + it('a missing chunk reads as empty', async () => { + expect(await readPages(async () => ({}))).toEqual({ items: [], truncated: false }); + }); + + it('an empty page ends the read even when it carries a token', async () => { + const p = pager([ + { chunk: [1], next_batch: 'a' }, + { chunk: [], next_batch: 'b' }, + ]); + expect(await readPages(p.fetchPage)).toEqual({ items: [1], truncated: false }); + expect(p.froms).toHaveLength(2); + }); + + it('the cap stops the read and says it was cut short', async () => { + const p = pager(Array.from({ length: 10 }, (_, i) => ({ chunk: [i], next_batch: `t${i}` }))); + expect(await readPages(p.fetchPage, 3)).toEqual({ items: [0, 1, 2], truncated: true }); + expect(p.froms).toHaveLength(3); + }); + + it('a last page exactly at the cap is not truncated', async () => { + const p = pager([{ chunk: [1], next_batch: 'a' }, { chunk: [2] }]); + expect((await readPages(p.fetchPage, 2)).truncated).toBe(false); + }); +}); diff --git a/src/app/features/albums/readPages.ts b/src/app/features/albums/readPages.ts new file mode 100644 index 00000000..a2b888e5 --- /dev/null +++ b/src/app/features/albums/readPages.ts @@ -0,0 +1,27 @@ +/** One page of a Matrix paginated endpoint: its events and the token for the next page, if any. */ +export type Page = { chunk?: T[]; next_batch?: string }; + +/** Pages read per endpoint at most, so a runaway zone cannot hold the phone in a loop. */ +export const MAX_PAGES = 20; + +/** + * Reads every page of an endpoint by following `next_batch`, up to `maxPages`. `truncated` is true + * when the cap stopped the read with more still to come, so a caller can say the list is partial. + */ +export const readPages = async ( + fetchPage: (from: string | undefined) => Promise>, + maxPages = MAX_PAGES +): Promise<{ items: T[]; truncated: boolean }> => { + const items: T[] = []; + let from: string | undefined; + for (let n = 0; n < maxPages; n += 1) { + // eslint-disable-next-line no-await-in-loop + const page = await fetchPage(from); + items.push(...(page.chunk ?? [])); + // An empty page ends the read even if a token is returned: a server that keeps handing out + // tokens with nothing behind them would otherwise spend every page of the cap. + if (!page.next_batch || !page.chunk?.length) return { items, truncated: false }; + from = page.next_batch; + } + return { items, truncated: true }; +}; diff --git a/src/app/features/albums/useAlbums.ts b/src/app/features/albums/useAlbums.ts index 7c1d5a57..13c199ec 100644 --- a/src/app/features/albums/useAlbums.ts +++ b/src/app/features/albums/useAlbums.ts @@ -7,12 +7,11 @@ import { useClientConfig } from '../../hooks/useClientConfig'; import { Album, ZoneEvent, buildAlbums } from './buildAlbums'; import { isFileZone } from './isFileZone'; import { mapLimit } from './mapLimit'; +import { Page, readPages } from './readPages'; const REPLY_FETCHES_IN_FLIGHT = 6; const REFRESH_MS = 30_000; -type Chunk = { chunk?: ZoneEvent[] }; - /** True when the room is the file zone: by its create type, or listed in the client config. */ export const useIsFileZone = (room: Room): boolean => { const config = useClientConfig(); @@ -29,37 +28,49 @@ export const useIsFileZone = (room: Room): boolean => { ); }; -export type AlbumsState = { loading: boolean; error: Error | null; albums: Album[] }; +export type AlbumsState = { + loading: boolean; + error: Error | null; + albums: Album[]; + /** A read hit the page cap (readPages), so older albums or files are not in the list. */ + truncated: boolean; +}; + +const PAGE_LIMIT = '100'; /** - * Slice 1 reads ONE page of each endpoint (threads: 100 roots; replies per root: 100). A zone with - * more albums or files than that is truncated; pagination is a later slice. Reads are made with - * the logged-in client, so the room must be unencrypted: encrypted events carry no msgtype and - * are skipped. + * Reads every page of the zone's threads, then every page of each thread's replies, each capped at + * MAX_PAGES (readPages). Reads are made with the logged-in client, so the room must be + * unencrypted: encrypted events carry no msgtype and are skipped. */ export const useAlbums = (room: Room): AlbumsState => { const mx = useMatrixClient(); const { roomId } = room; const query = useQuery({ queryKey: ['albums', roomId], - queryFn: async (): Promise => { + queryFn: async (): Promise<{ albums: Album[]; truncated: boolean }> => { const get = (path: string, params: Record) => - mx.http.authedRequest(Method.Get, path, params, undefined, { + mx.http.authedRequest>(Method.Get, path, params, undefined, { prefix: ClientPrefix.V1, }); + const paged = (path: string, params: Record) => + readPages((from) => get(path, from ? { ...params, from } : params)); const room_ = encodeURIComponent(roomId); - const roots = (await get(`/rooms/${room_}/threads`, { limit: '100' })).chunk ?? []; - const inputs = await mapLimit(roots, REPLY_FETCHES_IN_FLIGHT, async (root: ZoneEvent) => ({ - root, - replies: - ( - await get( - `/rooms/${room_}/relations/${encodeURIComponent(root.event_id ?? '')}/m.thread`, - { limit: '100', dir: 'f' } - ) - ).chunk ?? [], - })); - return buildAlbums(inputs, mx.getDomain() ?? ''); + const roots = await paged(`/rooms/${room_}/threads`, { limit: PAGE_LIMIT }); + let { truncated } = roots; + const inputs = await mapLimit( + roots.items, + REPLY_FETCHES_IN_FLIGHT, + async (root: ZoneEvent) => { + const replies = await paged( + `/rooms/${room_}/relations/${encodeURIComponent(root.event_id ?? '')}/m.thread`, + { limit: PAGE_LIMIT, dir: 'f' } + ); + truncated = truncated || replies.truncated; + return { root, replies: replies.items }; + } + ); + return { albums: buildAlbums(inputs, mx.getDomain() ?? ''), truncated }; }, retry: 1, refetchInterval: REFRESH_MS, @@ -68,6 +79,7 @@ export const useAlbums = (room: Room): AlbumsState => { loading: query.isLoading, // A failed refetch keeps the albums already loaded; the error shows only when there are none. error: query.data ? null : (query.error as Error | null), - albums: query.data ?? [], + albums: query.data?.albums ?? [], + truncated: query.data?.truncated ?? false, }; }; From 6aaee8badd8dc40a6801775ccb48bc86653652f0 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 22:59:33 +0000 Subject: [PATCH 114/143] Cut 2: doc names the page cap; truncation note says albums or files Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 6 +++--- src/app/features/albums/AlbumView.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index 0018a432..98696766 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -21,7 +21,7 @@ Read from this repo at dev `8ba407cd`: - **Zone = a room** whose `m.room.create` type is `org.gamecult.file_zone` (`isFileZone.ts`), or one listed in config `fileZoneRooms`. One per Mind, `#-files:`, found by alias or by the carried id in the root Space's `org.gamecult.stewardships` (`mediaRoom.ts`, `MediaRow.tsx`); rendered as the Media row of each Mind's group. - **Album = a thread root** (a text event; its `body`, or its latest `m.replace` edit, is the title); **file = a thread reply** with `msgtype` `m.audio|m.image|m.video|m.file` (`buildAlbums.ts`). A redacted event or an `m.replace` is never a file. Loose room messages are not shown. -- **Read** = `GET /rooms/{id}/threads` and `/relations/{root}/m.thread`, one page of 100 each (`useAlbums.ts`; pagination is a known gap). Unencrypted rooms only. +- **Read** = `GET /rooms/{id}/threads` and `/relations/{root}/m.thread`, every page, 100 per request, at most 20 pages per endpoint (`useAlbums.ts`, `readPages.ts`; a capped read shows a note in the list). Unencrypted rooms only. - **Write** = `sendShareSet` (`shareSet.ts`, behind `fileZoneShare.enabled`): a root, then one thread reply per file, content from `getMsgContent` with `info.sha256`. - **Media** = authenticated Matrix media fetched to a blob (`downloadMedia` in `FileContent.tsx`): so the "media element cannot send a header" problem that Option B needed a ticket for is already solved this way, at the cost that a file is fully downloaded before it plays. - **Receipts** = `org.gamecult.file_received` from a Mind's mouth after it fetches and verifies the sha256 (`docs/file-zone-mind-contract.md`); the mouth enforces the Mind's membership in the room. @@ -103,7 +103,7 @@ What A adds to `ShareSet`: sharing into an existing album ("add files", listed a Under both, the phone design is the same shape (one column, rows 56 px or more, hidden controls, in-place preview; see the Option B sections below, which are written against the host but whose layout applies). -- **A, new cost:** the Commons room is one more room in sync and it must be muted; albums and files are one page of 100 each until pagination exists (a Commons of hundreds of documents needs it first); a folder tree is a title-path grouping, so a deep tree is a long list of albums with breadcrumbs, not true directories; a file plays after a full download (small audio and images are fine; a 40 MB clip is not). +- **A, new cost:** the Commons room is one more room in sync and it must be muted; albums and files are read to a cap of 20 pages of 100 per endpoint (a Commons past that shows a truncation note); a folder tree is a title-path grouping, so a deep tree is a long list of albums with breadcrumbs, not true directories; a file plays after a full download (small audio and images are fine; a 40 MB clip is not). - **A, no cost:** no second sign-in, no ticket, no CORS, no new fetch layer, no host-unreachable state beyond the homeserver's own. The album list, album view, share panel and receipts exist and are tested; the view is their extension. - **B, new cost:** every screen is a second client against a second service with its own errors, token refresh and unreachable state; media needs the ticket or the blob; the tree and preview are new UI. - **B, benefit:** a tree that is live and complete, ranges, and no Matrix size limit. @@ -121,7 +121,7 @@ Under A the paused service is not built, and most of its list disappears: Status 2026-11-13: cut 1 merged to dev as bb615570 (PR #147); cuts 2-5 follow. Cut 1's shape was: a pure `albumFolders` view (folders from `/`-separated album titles, leaf name, counts), folder navigation and breadcrumb in `AlbumView` through a presentational list component tested by static render, `ShareSet` sending into the current folder by prefixing the album title, and a quiet `Commons` row (alias `#commons:gamecult.org` or the carried `media.commons` id, through `lookupMediaRoom`, hidden while unset or looking, behind a dark `filesDesk.enabled` flag, and filtered out of the tiers so it is not listed twice). Muting is provisioning's (Q-E3), not the client's. 1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer. -2. **Pagination** of albums and files in `useAlbums` (a prerequisite for a Commons of any size). +2. **Pagination** of albums and files in `useAlbums` (done, PR #148). 3. **Folders by title path** and the album list grouped by it. 4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it. 5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index dbb9f8e0..73e63e7c 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -168,7 +168,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { {here.folders.length === 0 && here.albums.length === 0 && Nothing here.} {truncated && ( - The list is cut short: older albums are not loaded. + The list is cut short: some albums or files are not loaded. )} From ac6018b57cc635c9ef203f32d1d39723c15802fc Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 22:59:10 +0000 Subject: [PATCH 115/143] Files desk cut 3: show the latest version of a file chain, earlier versions folded under it A file event naming org.gamecult.publish.replaces supersedes the event it names. foldVersions reads the chain from the events alone; buildAlbums lists heads (count too) and AlbumView unfolds 'N earlier versions'. Both events stay ordinary files for other clients. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/albums/AlbumView.tsx | 22 ++++++- src/app/features/albums/buildAlbums.test.ts | 14 +++++ src/app/features/albums/buildAlbums.ts | 11 +++- src/app/features/albums/versions.test.ts | 65 +++++++++++++++++++++ src/app/features/albums/versions.ts | 46 +++++++++++++++ 5 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 src/app/features/albums/versions.test.ts create mode 100644 src/app/features/albums/versions.ts diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index 73e63e7c..944cc761 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -68,6 +68,26 @@ function FileRow({ room, file, author }: FileRowProps) { ); } +/** A file, and under it a row that unfolds the versions it supersedes. */ +function VersionedFileRow({ room, file, author }: FileRowProps) { + const [shown, setShown] = useState(false); + const n = file.earlier.length; + return ( + <> + + {n > 0 && ( + + )} + {shown && + file.earlier.map((e) => )} + + ); +} + const countLabel = (n: number) => `${n} ${n === 1 ? 'file' : 'files'}`; /** The zone never mounts RoomTimeline, so it sends the read receipt the timeline would. */ @@ -124,7 +144,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { {open.count === 0 && This album has no files.}
        {open.files.map((f) => ( - + ))}
        diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index e98e4f51..d8d8ed9f 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -215,6 +215,20 @@ describe('buildAlbums', () => { }); }); +describe('versions', () => { + const v = (id: string, ts: number, replaces?: string) => + reply(id, ts, 'm.file', id, replaces ? { 'org.gamecult.publish': { replaces } } : {}); + + it('lists the latest of a chain and folds the earlier versions under it', () => { + const a = one(root('$r', 1), [v('$v1', 10), v('$v2', 20, '$v1'), reply('$x', 30, 'm.file')]); + expect(a.files.map((f) => [f.id, f.earlier.map((e) => e.id)])).toEqual([ + ['$v2', ['$v1']], + ['$x', []], + ]); + expect(a.count).toBe(2); + }); +}); + describe('Mind receipts', () => { const HASH = 'a'.repeat(64); const file = (id: string, sha?: string) => diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 6fe8a452..69fe82b4 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -3,6 +3,7 @@ // into albums. See docs/file-zone.md for the rulings this encodes. import { readersOf } from './fileReceipt'; import type { FileReader } from './fileReceipt'; +import { foldVersions } from './versions'; /** The slice of a Matrix event this module reads. Every field may be missing. */ export type ZoneEvent = { @@ -27,6 +28,8 @@ export type AlbumFile = { content: Record; /** Receipts in this album that name this file, one per sender (the latest), oldest first. */ readBy: FileReader[]; + /** The versions this file supersedes, nearest first (`org.gamecult.publish.replaces`). */ + earlier: AlbumFile[]; }; export type Album = { @@ -75,6 +78,7 @@ const toFile = (e: ZoneEvent): AlbumFile | undefined => { ts: tsOf(e.origin_server_ts), content: e.content, readBy: [], + earlier: [], }; }; @@ -103,14 +107,15 @@ const toAlbum = (input: AlbumInput, server: string): Album | undefined => { }) .sort((a, b) => a.ts - b.ts) .map((f) => ({ ...f, readBy: readersOf(list, f.id, f.content, server) })); + const heads = foldVersions(files); return { id: root.event_id, title: titleOf(root), author: root.sender ?? '', ts: tsOf(root.origin_server_ts), - cover: files.find((f) => f.kind === 'image'), - files, - count: files.length, + cover: heads.find((f) => f.kind === 'image'), + files: heads, + count: heads.length, }; }; diff --git a/src/app/features/albums/versions.test.ts b/src/app/features/albums/versions.test.ts new file mode 100644 index 00000000..645f409d --- /dev/null +++ b/src/app/features/albums/versions.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { PUBLISH_KEY, foldVersions, replacesOf } from './versions'; + +type F = { id: string; content: Record; earlier: F[] }; +const f = (id: string, replaces?: string): F => ({ + id, + content: replaces ? { [PUBLISH_KEY]: { replaces } } : {}, + earlier: [], +}); +const shape = (files: F[]): unknown => files.map((x) => [x.id, x.earlier.map((e) => e.id)]); + +describe('replacesOf', () => { + it('reads the id the publisher key names', () => { + expect(replacesOf({ [PUBLISH_KEY]: { replaces: '$a' } })).toBe('$a'); + }); + it('anything else says nothing', () => { + expect(replacesOf({})).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: {} })).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: { replaces: 3 } })).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: { replaces: '' } })).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: 'x' })).toBeUndefined(); + }); +}); + +describe('foldVersions', () => { + it('files that replace nothing pass through in order, with nothing folded', () => { + expect(shape(foldVersions([f('$a'), f('$b')]))).toEqual([ + ['$a', []], + ['$b', []], + ]); + }); + + it('shows the latest of a chain with earlier versions nearest first', () => { + expect(shape(foldVersions([f('$a'), f('$b', '$a'), f('$c', '$b')]))).toEqual([ + ['$c', ['$b', '$a']], + ]); + }); + + it('keeps unrelated files beside a chain, in order', () => { + expect(shape(foldVersions([f('$a'), f('$x'), f('$b', '$a')]))).toEqual([ + ['$x', []], + ['$b', ['$a']], + ]); + }); + + it('a replacement whose target is not in the list stands alone', () => { + expect(shape(foldVersions([f('$b', '$gone')]))).toEqual([['$b', []]]); + }); + + it('a file two others replace sits under both', () => { + expect(shape(foldVersions([f('$a'), f('$b', '$a'), f('$c', '$a')]))).toEqual([ + ['$b', ['$a']], + ['$c', ['$a']], + ]); + }); + + it('a malformed loop shows no head and does not hang', () => { + const out = foldVersions([f('$a', '$b'), f('$b', '$a'), f('$s', '$s')]); + expect(out.map((x) => x.id)).toEqual(['$s']); + }); + + it('an empty list is empty', () => { + expect(foldVersions([])).toEqual([]); + }); +}); diff --git a/src/app/features/albums/versions.ts b/src/app/features/albums/versions.ts new file mode 100644 index 00000000..f38af62b --- /dev/null +++ b/src/app/features/albums/versions.ts @@ -0,0 +1,46 @@ +// Pure: a file event may name the event it supersedes in `org.gamecult.publish.replaces` (the +// publisher writes it on a later final version; docs/commons-files-desk.md, "replaces, not +// m.replace"). Both events stay ordinary files for every other client; this view shows the latest +// of a chain with the earlier ones folded under it. A chain is read from the events alone. + +/** Key the publisher writes its provenance under. */ +export const PUBLISH_KEY = 'org.gamecult.publish'; + +/** The event id a file says it replaces, if it says one. */ +export const replacesOf = (content: Record): string | undefined => { + const id = (content[PUBLISH_KEY] as { replaces?: unknown } | undefined)?.replaces; + return typeof id === 'string' && id ? id : undefined; +}; + +type Versioned = F & { earlier: F[] }; + +/** + * Files not replaced by another file in the list, oldest first as given, each carrying the files it + * supersedes (nearest first) in `earlier`. A replacement whose target is missing from the list (a + * redacted event, another thread) replaces nothing and stands as a head. A file two others claim to + * replace sits under both. The walk refuses to revisit a file, so a malformed chain cannot loop (an event id hashes its content, so a real one cannot cycle). + */ +export const foldVersions = }>( + files: Versioned[] +): Versioned[] => { + const byId = new Map(files.map((f) => [f.id, f])); + const target = (f: F): F | undefined => { + const id = replacesOf(f.content); + return id && id !== f.id ? byId.get(id) : undefined; + }; + const replaced = new Set(); + files.forEach((f) => { + const t = target(f); + if (t) replaced.add(t.id); + }); + const chainOf = (head: Versioned): Versioned[] => { + const out: Versioned[] = []; + const seen = new Set([head.id]); + for (let t = target(head); t && !seen.has(t.id); t = target(t)) { + seen.add(t.id); + out.push({ ...t, earlier: [] }); + } + return out; + }; + return files.filter((f) => !replaced.has(f.id)).map((h) => ({ ...h, earlier: chainOf(h) })); +}; From 796bc161a9bfae6fd133ad38475cdf45feda9546 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 23:00:10 +0000 Subject: [PATCH 116/143] Cut 3: doc marks versions done Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index 98696766..836af638 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -123,7 +123,7 @@ Status 2026-11-13: cut 1 merged to dev as bb615570 (PR #147); cuts 2-5 follow. C 1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer. 2. **Pagination** of albums and files in `useAlbums` (done, PR #148). 3. **Folders by title path** and the album list grouped by it. -4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it. +4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it (done, PR #149). 5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake. From 5835e565cd1fcb9b11b7b0b4633466ce417cb10b Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 23:03:33 +0000 Subject: [PATCH 117/143] Cut 3 Soul fixes: replaces counts only from the same sender; tests for the loop guard and the cover Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 2 +- src/app/features/albums/buildAlbums.test.ts | 10 ++++++++++ src/app/features/albums/buildAlbums.ts | 2 ++ src/app/features/albums/versions.test.ts | 18 ++++++++++++++++-- src/app/features/albums/versions.ts | 10 +++++++--- 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index 836af638..361624bd 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -82,7 +82,7 @@ What it buys: one identity, one audience, one audit trail, immutable files, exis - **When it posts.** Handoff documents are edited in place, so mirroring every save would spam the room with drafts. The rule (Eureka's lean, adopted): **post once, when the document is marked final**; a later final version posts again with a `replaces` relation to the earlier event. "Final" is a sibling `NAME.final` holding the document's sha256 (answered Q-E2); the client must not know. - **Allowlist.** Lives on the publisher: a list of source roots and patterns, read by the publisher only. `handoff-private` is not on it and the publisher never lists, reads or posts from it. The client holds no path to anything on disk. - **The event.** A file event in the thread of the folder's album, built the way `getMsgContent` builds them (`msgtype`, `body`, `filename`, `url` from the media repo, `info.mimetype|size|sha256`), plus one extra key `org.gamecult.publish`: `{ source, sha256, replaces? }` where `source` is a path relative to the allowlisted root, never absolute, and `replaces` is the event id of the version it supersedes. -- **`replaces`, not `m.replace`.** `m.replace` is the stock edit relation, but Ratatoskr's `buildAlbums` deliberately never treats an edit as a file, several clients do not render an edited file, and an edit hides the earlier version. A custom key keeps both events as files; the view shows the latest of a chain and a "N earlier versions" row under it, and other Matrix clients see two ordinary file posts. A chain is read from the events themselves (each carries its `replaces`), not from a table. +- **`replaces`, not `m.replace`.** `m.replace` is the stock edit relation, but Ratatoskr's `buildAlbums` deliberately never treats an edit as a file, several clients do not render an edited file, and an edit hides the earlier version. A custom key keeps both events as files; the view shows the latest of a chain and a "N earlier versions" row under it, and other Matrix clients see two ordinary file posts. A chain is read from the events themselves (each carries its `replaces`), not from a table, and a `replaces` counts only when its event has the same sender as the event it names, so nobody else in the thread can fold a file away. - **Idempotence.** The publisher's own state (a typed CultCache document of `source`, `sha256`, `event_id`) says what it posted; if that is lost, it rebuilds by reading its own events back from the room (`org.gamecult.publish.source` plus `sha256`) rather than guessing. The same `sha256` never posts twice. - **Receipts and fetch** work unchanged: the event is an ordinary file event in a file-zone room, so a Mind's mouth can fetch it by room id and event id (5 MiB cap there) and tick `fetched for`. diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index d8d8ed9f..3053a493 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -229,6 +229,16 @@ describe('versions', () => { }); }); +describe('versions: cover and sender', () => { + it('the cover comes from the shown version, not a folded image', () => { + const img = (id: string, ts: number, type: string, replaces?: string) => + reply(id, ts, type, id, replaces ? { 'org.gamecult.publish': { replaces } } : {}); + const a = one(root('$r', 1), [img('$v1', 10, 'm.image'), img('$v2', 20, 'm.file', '$v1')]); + expect(a.files.map((f) => f.id)).toEqual(['$v2']); + expect(a.cover).toBeUndefined(); + }); +}); + describe('Mind receipts', () => { const HASH = 'a'.repeat(64); const file = (id: string, sha?: string) => diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 69fe82b4..636ede38 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -23,6 +23,7 @@ export type AlbumFile = { id: string; kind: FileKind; name: string; + sender: string; ts: number; /** The event content, as RenderMessageContent reads it. */ content: Record; @@ -75,6 +76,7 @@ const toFile = (e: ZoneEvent): AlbumFile | undefined => { id: e.event_id, kind, name: asString(e.content.filename) || asString(e.content.body), + sender: e.sender ?? '', ts: tsOf(e.origin_server_ts), content: e.content, readBy: [], diff --git a/src/app/features/albums/versions.test.ts b/src/app/features/albums/versions.test.ts index 645f409d..e8ae64b8 100644 --- a/src/app/features/albums/versions.test.ts +++ b/src/app/features/albums/versions.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest'; import { PUBLISH_KEY, foldVersions, replacesOf } from './versions'; -type F = { id: string; content: Record; earlier: F[] }; -const f = (id: string, replaces?: string): F => ({ +type F = { id: string; sender: string; content: Record; earlier: F[] }; +const f = (id: string, replaces?: string, sender = '@m:x'): F => ({ id, + sender, content: replaces ? { [PUBLISH_KEY]: { replaces } } : {}, earlier: [], }); @@ -59,6 +60,19 @@ describe('foldVersions', () => { expect(out.map((x) => x.id)).toEqual(['$s']); }); + it('a loop reached from a head is walked once and ends', () => { + const out = foldVersions([f('$a', '$b'), f('$b', '$a'), f('$h', '$a')]); + expect(shape(out)).toEqual([['$h', ['$a', '$b']]]); + }); + + it('a replacement by another sender folds nothing', () => { + const out = foldVersions([f('$victim', undefined, '@m:x'), f('$evil', '$victim', '@e:x')]); + expect(shape(out)).toEqual([ + ['$victim', []], + ['$evil', []], + ]); + }); + it('an empty list is empty', () => { expect(foldVersions([])).toEqual([]); }); diff --git a/src/app/features/albums/versions.ts b/src/app/features/albums/versions.ts index f38af62b..8c7055db 100644 --- a/src/app/features/albums/versions.ts +++ b/src/app/features/albums/versions.ts @@ -17,16 +17,20 @@ type Versioned = F & { earlier: F[] }; /** * Files not replaced by another file in the list, oldest first as given, each carrying the files it * supersedes (nearest first) in `earlier`. A replacement whose target is missing from the list (a - * redacted event, another thread) replaces nothing and stands as a head. A file two others claim to + * redacted event, another thread) replaces nothing and stands as a head. A replacement by another sender is ignored. A file two others claim to * replace sits under both. The walk refuses to revisit a file, so a malformed chain cannot loop (an event id hashes its content, so a real one cannot cycle). */ -export const foldVersions = }>( +export const foldVersions = < + F extends { id: string; sender: string; content: Record } +>( files: Versioned[] ): Versioned[] => { const byId = new Map(files.map((f) => [f.id, f])); const target = (f: F): F | undefined => { const id = replacesOf(f.content); - return id && id !== f.id ? byId.get(id) : undefined; + const t = id && id !== f.id ? byId.get(id) : undefined; + // Only the sender of a file can supersede it, so no one else in the thread can fold it away. + return t && t.sender === f.sender ? t : undefined; }; const replaced = new Set(); files.forEach((f) => { From 0622d7d65dfa92f2f82b7d7bff70eca1b7d29a05 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 23:05:37 +0000 Subject: [PATCH 118/143] Files desk cut 4: add files to an open album; name the upload refusals sendShareSet takes a title or an existing album (no new root; the first file threads after the album's newest event). Failed files carry the refusal for M_TOO_LARGE, M_FORBIDDEN and M_LIMIT_EXCEEDED in the result note. AlbumView mounts an Add files panel inside an open album. Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 2 +- docs/file-zone.md | 2 +- src/app/features/albums/AlbumView.tsx | 9 +++- src/app/features/albums/ShareSet.test.tsx | 15 +++++- src/app/features/albums/ShareSet.tsx | 42 +++++++++------ src/app/features/albums/buildAlbums.test.ts | 13 ++++- src/app/features/albums/buildAlbums.ts | 7 +++ src/app/features/albums/shareSet.test.ts | 58 ++++++++++++++++++++- src/app/features/albums/shareSet.ts | 42 ++++++++++++--- 9 files changed, 161 insertions(+), 29 deletions(-) diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index 361624bd..511b0dbc 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -124,7 +124,7 @@ Status 2026-11-13: cut 1 merged to dev as bb615570 (PR #147); cuts 2-5 follow. C 2. **Pagination** of albums and files in `useAlbums` (done, PR #148). 3. **Folders by title path** and the album list grouped by it. 4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it (done, PR #149). -5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). +5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); cancel of a send in flight is not, and needs an abort path through `uploadContent`. PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake. diff --git a/docs/file-zone.md b/docs/file-zone.md index 7990485d..0a895226 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -120,4 +120,4 @@ Client, `src/app/features/albums`: Flag: `fileZoneShare.enabled` in `config.json`, default `false`. With it off nothing mounts. -Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; sharing into an existing album ("add files"); upload cancel; pagination beyond 100 albums/files. +Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; cancel of a send in flight (a pick can be cancelled before it goes). Built since: pagination to 20 pages of 100 per endpoint (PR #148), adding files to an open album with named refusals (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`). diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index 944cc761..fc76744a 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -14,7 +14,7 @@ import { markAsRead } from '../../utils/notifications'; import { getBlobSafeMimeType } from '../../utils/mimeTypes'; import { timeDayMonYear } from '../../utils/time'; import { getMemberDisplayName } from '../../utils/room'; -import { Album, AlbumFile } from './buildAlbums'; +import { Album, AlbumFile, lastEventId } from './buildAlbums'; import { folderOf, listFolder, pathLabel } from './albumFolders'; import { FileReadBy } from './FileReadBy'; import { ShareSet, useShareEnabled } from './ShareSet'; @@ -142,6 +142,13 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { {authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)}
        {open.count === 0 && This album has no files.} + {canShare && ( + + )}
        {open.files.map((f) => ( diff --git a/src/app/features/albums/ShareSet.test.tsx b/src/app/features/albums/ShareSet.test.tsx index bfa9a619..6ae47613 100644 --- a/src/app/features/albums/ShareSet.test.tsx +++ b/src/app/features/albums/ShareSet.test.tsx @@ -14,7 +14,7 @@ const text = (html: string) => const noop = () => undefined; const file = (name: string) => ({ name, type: 'audio/ogg' } as File); -const view = (over: Partial> = {}) => +const view = (over: Partial> = {}, adding = false) => renderToStaticMarkup( > = {}) => onTitle={noop} onSend={noop} onCancel={noop} + adding={adding} /> ); @@ -76,6 +77,18 @@ describe('the share panel, drawn', () => { }); }); +describe('adding to an existing album', () => { + it('the idle row says Add files', () => { + expect(text(view({}, true))).toBe('Add files ›'); + }); + + it('picked: no title field, the names, Send and Cancel', () => { + const html = view({ files: [file('a.ogg')] }, true); + expect(html).not.toContain('aria-label="Album title"'); + expect(text(html)).toBe('a.ogg Send 1 file Cancel'); + }); +}); + describe('the flag', () => { it('off, missing or empty offers no share', () => { expect(shareEnabled({}, false)).toBe(false); diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx index 1d76753a..a8e65b4f 100644 --- a/src/app/features/albums/ShareSet.tsx +++ b/src/app/features/albums/ShareSet.tsx @@ -10,7 +10,7 @@ import { getMsgContent } from '../room/msgContent'; import { FILE_HASH_KEY, hashForSend } from './fileReceipt'; import { ShareController, ShareState, createShareController } from './shareController'; import { titleIn } from './albumFolders'; -import { DEFAULT_TITLE, sendShareSet } from './shareSet'; +import { DEFAULT_TITLE, ExistingAlbum, sendShareSet } from './shareSet'; import * as css from './AlbumView.css'; /** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */ @@ -29,6 +29,8 @@ type ShareViewProps = { onTitle: (title: string) => void; onSend: () => void; onCancel: () => void; + /** Adding to an existing album: the files join it, so there is no title to ask for. */ + adding?: boolean; inputRef?: React.Ref; }; @@ -43,6 +45,7 @@ export function ShareSetView({ onTitle, onSend, onCancel, + adding, inputRef, }: ShareViewProps) { const { files, title, phase, epoch } = state; @@ -58,7 +61,7 @@ export function ShareSetView({ /> {files.length === 0 && phase.kind !== 'sending' && ( )} {phase.kind === 'done' && ( @@ -68,14 +71,16 @@ export function ShareSetView({ )} {files.length > 0 && ( <> - onTitle(e.target.value)} - /> + {!adding && ( + onTitle(e.target.value)} + /> + )} {files.map((f) => f.name).join(' · ')} @@ -101,15 +106,21 @@ export function ShareSetView({ ); } -/** `folder` is the path the share lands in: its albums are titled under it (docs/commons-files-desk.md). */ -export function ShareSet({ room, folder = [] }: { room: Room; folder?: string[] }) { +type ShareSetProps = { + room: Room; + /** The path a new album lands in: its title is prefixed by it (docs/commons-files-desk.md). */ + folder?: string[]; + /** Add the files to this album instead of making one. */ + into?: ExistingAlbum; +}; +export function ShareSet({ room, folder = [], into }: ShareSetProps) { const mx = useMatrixClient(); const queryClient = useQueryClient(); const input = useRef(null); // Built once per mount (AlbumView keys the panel by room): the pick lives in the controller, so a // rerender with a new client or Room object must not build another. Live values go through `live`. - const live = useRef({ mx, room, queryClient, folder }); - live.current = { mx, room, queryClient, folder }; + const live = useRef({ mx, room, queryClient, folder, into }); + live.current = { mx, room, queryClient, folder, into }; const [controller]: [ShareController, unknown] = useState(() => createShareController({ roomId: room.roomId, @@ -142,7 +153,7 @@ export function ShareSet({ room, folder = [] }: { room: Room; folder?: string[] }, onFile, }, - titleIn(live.current.folder, title, DEFAULT_TITLE), + live.current.into ?? titleIn(live.current.folder, title, DEFAULT_TITLE), files.map((file) => ({ name: file.name, mimeType: file.type, file })) ), }) @@ -157,6 +168,7 @@ export function ShareSet({ room, folder = [] }: { room: Room; folder?: string[] onTitle={controller.setTitle} onSend={controller.send} onCancel={controller.cancel} + adding={!!into} /> ); } diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index 3053a493..4bf72d0e 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { AlbumInput, ZoneEvent, buildAlbums } from './buildAlbums'; +import { AlbumInput, ZoneEvent, buildAlbums, lastEventId } from './buildAlbums'; const root = (id: string, ts: number, over: Partial = {}): ZoneEvent => ({ event_id: id, @@ -239,6 +239,17 @@ describe('versions: cover and sender', () => { }); }); +describe('lastEventId', () => { + it('is the newest event of the thread, a folded version included; the root when empty', () => { + const v = (id: string, ts: number, replaces?: string) => + reply(id, ts, 'm.file', id, replaces ? { 'org.gamecult.publish': { replaces } } : {}); + expect(lastEventId(one(root('$r', 1), [v('$a', 10), v('$b', 20, '$a'), v('$c', 15)]))).toBe( + '$b' + ); + expect(lastEventId(one(root('$r', 1), []))).toBe('$r'); + }); +}); + describe('Mind receipts', () => { const HASH = 'a'.repeat(64); const file = (id: string, sha?: string) => diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 636ede38..0fe2d113 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -134,3 +134,10 @@ export const buildAlbums = (inputs: AlbumInput[], server = ''): Album[] => { }) .sort((a, b) => b.ts - a.ts); }; + +/** The newest event in an album's thread (shown or folded), else its root: where a new file threads after. */ +export const lastEventId = (album: Album): string => + album.files + .flatMap((f) => [f, ...f.earlier]) + .reduce((a, f) => (!a || f.ts >= a.ts ? f : a), undefined)?.id ?? + album.id; diff --git a/src/app/features/albums/shareSet.test.ts b/src/app/features/albums/shareSet.test.ts index f43e5a35..d1db0b2e 100644 --- a/src/app/features/albums/shareSet.test.ts +++ b/src/app/features/albums/shareSet.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_TITLE, ShareFile, SharePort, + refusalOf, remainingAfter, resultNote, sendShareSet, @@ -56,7 +57,7 @@ describe('sendShareSet', () => { it('reads back as one album of the files in pick order', async () => { const r = room(); const res = await sendShareSet(r.port, 'Take 4', clips); - expect(res).toEqual({ albumId: '$e1', sent: 3, failed: [], failedAt: [] }); + expect(res).toEqual({ albumId: '$e1', sent: 3, failed: [], failedAt: [], reasons: [] }); const album = r.album(); expect(album.title).toBe('Take 4'); expect(album.files.map((f) => [f.name, f.kind])).toEqual([ @@ -158,3 +159,58 @@ describe('after a send', () => { expect(resultNote(res)).toBe('Shared 3 files.'); }); }); + +describe('adding to an existing album', () => { + it('sends no root; each file threads under the album, the first after `lastId`', async () => { + const r = room(); + const sent: Record[] = []; + const port = { + ...r.port, + send: async (c: Record) => { + sent.push(c); + return `$n${sent.length}`; + }, + }; + const res = await sendShareSet(port, { albumId: '$root', lastId: '$last' }, clips.slice(0, 2)); + expect(res.albumId).toBe('$root'); + expect(res.sent).toBe(2); + expect(sent.map((c) => c.msgtype)).toEqual(['m.audio', 'm.audio']); + expect(sent.map((c) => c['m.relates_to'])).toEqual([ + threadRelation('$root', '$last'), + threadRelation('$root', '$n1'), + ]); + }); +}); + +describe('refusals', () => { + const refuse = (errcode: string): SharePort => ({ + send: async () => '$e', + contentOf: async () => { + throw Object.assign(new Error('raw server text'), { errcode }); + }, + }); + + it('names the three upload refusals and nothing else', () => { + expect(refusalOf({ errcode: 'M_TOO_LARGE' })).toBe('too large for this server'); + expect(refusalOf({ errcode: 'M_FORBIDDEN' })).toBe('not allowed to upload here'); + expect(refusalOf({ errcode: 'M_LIMIT_EXCEEDED' })).toBe( + 'uploading too fast, try again shortly' + ); + expect(refusalOf({ errcode: 'M_UNKNOWN' })).toBeUndefined(); + expect(refusalOf({ errcode: 'constructor' })).toBeUndefined(); + expect(refusalOf(new Error('x'))).toBeUndefined(); + expect(refusalOf(null)).toBeUndefined(); + }); + + it('the note carries the reason beside each refused file', async () => { + const res = await sendShareSet(refuse('M_TOO_LARGE'), 't', clips.slice(0, 2)); + expect(resultNote(res)).toBe( + 'Nothing was shared. Not sent: a.ogg (too large for this server), b.ogg (too large for this server)' + ); + }); + + it('an unnamed failure lists the bare name', async () => { + const res = await sendShareSet(refuse('M_UNKNOWN'), 't', clips.slice(0, 1)); + expect(resultNote(res)).toBe('Nothing was shared. Not sent: a.ogg'); + }); +}); diff --git a/src/app/features/albums/shareSet.ts b/src/app/features/albums/shareSet.ts index 3e572595..c3652f62 100644 --- a/src/app/features/albums/shareSet.ts +++ b/src/app/features/albums/shareSet.ts @@ -22,6 +22,23 @@ export type ShareResult = { failed: string[]; /** Positions in the files array of those that failed; names can repeat, positions cannot. */ failedAt: number[]; + /** Why each failed file failed, parallel to `failed`, when the homeserver's refusal is one we name. */ + reasons?: (string | undefined)[]; +}; + +/** An album that already exists: new files join its thread after `lastId`, with no new root. */ +export type ExistingAlbum = { albumId: string; lastId: string }; + +const REFUSALS: Record = { + M_TOO_LARGE: 'too large for this server', + M_FORBIDDEN: 'not allowed to upload here', + M_LIMIT_EXCEEDED: 'uploading too fast, try again shortly', +}; + +/** The sentence for a homeserver refusal we know, from the error's Matrix errcode. */ +export const refusalOf = (e: unknown): string | undefined => { + const code = (e as { errcode?: unknown } | null)?.errcode; + return typeof code === 'string' && Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : undefined; }; /** The reply relation Matrix clients expect for a thread message: root, with the last event as fallback reply. */ @@ -44,12 +61,15 @@ export const titleOrDefault = (title: string): string => title.trim() || DEFAULT */ export const sendShareSet = async ( port: SharePort, - title: string, + into: string | ExistingAlbum, files: F[] ): Promise => { - const albumId = await port.send({ msgtype: 'm.text', body: titleOrDefault(title) }); - const result: ShareResult = { albumId, sent: 0, failed: [], failedAt: [] }; - let lastId = albumId; + const albumId = + typeof into === 'string' + ? await port.send({ msgtype: 'm.text', body: titleOrDefault(into) }) + : into.albumId; + const result: ShareResult = { albumId, sent: 0, failed: [], failedAt: [], reasons: [] }; + let lastId = typeof into === 'string' ? albumId : into.lastId; for (let i = 0; i < files.length; i += 1) { try { // eslint-disable-next-line no-await-in-loop @@ -58,8 +78,9 @@ export const sendShareSet = async ( lastId = await port.send({ ...content, 'm.relates_to': threadRelation(albumId, lastId) }); result.sent += 1; port.onFile?.(i, 'sent'); - } catch { + } catch (e) { result.failed.push(files[i].name); + result.reasons?.push(refusalOf(e)); result.failedAt.push(i); port.onFile?.(i, 'failed'); } @@ -69,15 +90,20 @@ export const sendShareSet = async ( /** * What stays picked after a send: only the files that did not go, so a retry is one tap. A retry - * makes a second album (adding to an existing one is not built), and a share where nothing went + * of a new album makes a second album, and a share where nothing went * leaves its root as a lone message the album list does not show (it lists threads with replies). */ export const remainingAfter = (files: T[], result: ShareResult): T[] => files.filter((_, i) => result.failedAt.includes(i)); +const failedList = (result: ShareResult): string => + result.failed + .map((name, i) => (result.reasons?.[i] ? `${name} (${result.reasons[i]})` : name)) + .join(', '); + export const resultNote = (result: ShareResult): string => { if (result.failed.length === 0) return `Shared ${result.sent} ${result.sent === 1 ? 'file' : 'files'}.`; - if (result.sent === 0) return `Nothing was shared. Not sent: ${result.failed.join(', ')}`; - return `Shared ${result.sent}. Not sent: ${result.failed.join(', ')}`; + if (result.sent === 0) return `Nothing was shared. Not sent: ${failedList(result)}`; + return `Shared ${result.sent}. Not sent: ${failedList(result)}`; }; From 90c6d5e2788071ec0dfeea4b28fb1e7675040d20 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 23:11:33 +0000 Subject: [PATCH 119/143] Cut 4: pin that ShareSet sends into an existing album Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/albums/ShareSet.folder.test.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/app/features/albums/ShareSet.folder.test.tsx b/src/app/features/albums/ShareSet.folder.test.tsx index 7f23fd63..a19f6e16 100644 --- a/src/app/features/albums/ShareSet.folder.test.tsx +++ b/src/app/features/albums/ShareSet.folder.test.tsx @@ -27,8 +27,8 @@ vi.mock('./shareController', () => ({ })); vi.mock('./shareSet', async (importOriginal) => ({ ...(await importOriginal()), - sendShareSet: async (_port: unknown, title: string) => { - captured.titles.push(title); + sendShareSet: async (_port: unknown, into: string | { albumId: string }) => { + captured.titles.push(typeof into === 'string' ? into : `into:${into.albumId}`); return { albumId: '$a', sent: 0, failed: [], failedAt: [] }; }, })); @@ -42,12 +42,12 @@ vi.mock('react', async (importOriginal) => { // eslint-disable-next-line import/first import { ShareSet } from './ShareSet'; -const mount = (folder?: string[]) => { +const mount = (folder?: string[], into?: { albumId: string; lastId: string }) => { const room = { roomId: '!r:x', hasEncryptionStateEvent: () => false } as never; renderToStaticMarkup( ({ event_id: '$e' }) } as never}> - + ); @@ -82,4 +82,9 @@ describe('ShareSet: the folder reaches the album title', () => { mount(['a']); expect(await send('b/c')).toBe('a/b/c'); }); + + it('an existing album takes the files instead of a title, whatever the folder', async () => { + mount(['a'], { albumId: '$alb', lastId: '$last' }); + expect(await send('ignored')).toBe('into:$alb'); + }); }); From e406ec7dc4b011be47794806f9166ed9abfdddc5 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 23:14:42 +0000 Subject: [PATCH 120/143] Files desk: Stop a send in flight; files in a replaces loop all show; Add files threads after the freshest event Stop aborts the running upload (onPromise + mx.cancelUpload) and leaves it and the rest picked as 'stopped'. foldVersions shows files caught in a replaces loop with no head instead of hiding them. An add reads the room timeline at send time for the newest event in the thread. Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 2 +- docs/file-zone.md | 2 +- src/app/features/albums/AlbumView.tsx | 4 +- .../features/albums/ShareSet.folder.test.tsx | 34 +++++-- src/app/features/albums/ShareSet.test.tsx | 2 + src/app/features/albums/ShareSet.tsx | 44 +++++++-- src/app/features/albums/buildAlbums.test.ts | 13 +-- src/app/features/albums/buildAlbums.ts | 8 +- .../features/albums/shareController.test.ts | 26 +++++- src/app/features/albums/shareController.ts | 21 +++-- src/app/features/albums/shareSet.test.ts | 89 ++++++++++++++++++- src/app/features/albums/shareSet.ts | 40 ++++++++- src/app/features/albums/versions.test.ts | 8 +- src/app/features/albums/versions.ts | 10 ++- 14 files changed, 262 insertions(+), 41 deletions(-) diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index 511b0dbc..7b06bb9d 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -124,7 +124,7 @@ Status 2026-11-13: cut 1 merged to dev as bb615570 (PR #147); cuts 2-5 follow. C 2. **Pagination** of albums and files in `useAlbums` (done, PR #148). 3. **Folders by title path** and the album list grouped by it. 4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it (done, PR #149). -5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); cancel of a send in flight is not, and needs an abort path through `uploadContent`. +5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); Stop for a send in flight is in too (PR for follow-up `files-desk-release-146-and-send-cancel`): it cancels the running upload through `onPromise` and `mx.cancelUpload`. PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake. diff --git a/docs/file-zone.md b/docs/file-zone.md index 0a895226..420f7016 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -120,4 +120,4 @@ Client, `src/app/features/albums`: Flag: `fileZoneShare.enabled` in `config.json`, default `false`. With it off nothing mounts. -Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; cancel of a send in flight (a pick can be cancelled before it goes). Built since: pagination to 20 pages of 100 per endpoint (PR #148), adding files to an open album with named refusals (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`). +Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; Built since: Stop for a send in flight (aborts the running upload, leaves it and the rest picked as "stopped"), pagination to 20 pages of 100 per endpoint (PR #148), adding files to an open album with named refusals (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`). diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index fc76744a..d6ee37c9 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -14,7 +14,7 @@ import { markAsRead } from '../../utils/notifications'; import { getBlobSafeMimeType } from '../../utils/mimeTypes'; import { timeDayMonYear } from '../../utils/time'; import { getMemberDisplayName } from '../../utils/room'; -import { Album, AlbumFile, lastEventId } from './buildAlbums'; +import { Album, AlbumFile, lastEvent } from './buildAlbums'; import { folderOf, listFolder, pathLabel } from './albumFolders'; import { FileReadBy } from './FileReadBy'; import { ShareSet, useShareEnabled } from './ShareSet'; @@ -146,7 +146,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { )}
        diff --git a/src/app/features/albums/ShareSet.folder.test.tsx b/src/app/features/albums/ShareSet.folder.test.tsx index a19f6e16..109d538c 100644 --- a/src/app/features/albums/ShareSet.folder.test.tsx +++ b/src/app/features/albums/ShareSet.folder.test.tsx @@ -27,8 +27,8 @@ vi.mock('./shareController', () => ({ })); vi.mock('./shareSet', async (importOriginal) => ({ ...(await importOriginal()), - sendShareSet: async (_port: unknown, into: string | { albumId: string }) => { - captured.titles.push(typeof into === 'string' ? into : `into:${into.albumId}`); + sendShareSet: async (_port: unknown, into: string | { albumId: string; lastId: string }) => { + captured.titles.push(typeof into === 'string' ? into : `into:${into.albumId}@${into.lastId}`); return { albumId: '$a', sent: 0, failed: [], failedAt: [] }; }, })); @@ -42,8 +42,19 @@ vi.mock('react', async (importOriginal) => { // eslint-disable-next-line import/first import { ShareSet } from './ShareSet'; -const mount = (folder?: string[], into?: { albumId: string; lastId: string }) => { - const room = { roomId: '!r:x', hasEncryptionStateEvent: () => false } as never; +const timeline: unknown[] = []; +const ev = (id: string, ts: number, threadRootId?: string, status: string | null = null) => ({ + getId: () => id, + getTs: () => ts, + threadRootId, + status, +}); +const mount = (folder?: string[], into?: { albumId: string; lastId: string; lastTs: number }) => { + const room = { + roomId: '!r:x', + hasEncryptionStateEvent: () => false, + getLiveTimeline: () => ({ getEvents: () => timeline }), + } as never; renderToStaticMarkup( ({ event_id: '$e' }) } as never}> @@ -61,6 +72,7 @@ describe('ShareSet: the folder reaches the album title', () => { beforeEach(() => { captured.run = undefined; captured.titles.length = 0; + timeline.length = 0; }); it('at the top, the typed title goes as typed', async () => { @@ -84,7 +96,17 @@ describe('ShareSet: the folder reaches the album title', () => { }); it('an existing album takes the files instead of a title, whatever the folder', async () => { - mount(['a'], { albumId: '$alb', lastId: '$last' }); - expect(await send('ignored')).toBe('into:$alb'); + mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 }); + expect(await send('ignored')).toBe('into:$alb@$last'); + }); + + it('the freshest sent event of that thread in the room timeline is where the files thread after', async () => { + timeline.push( + ev('$other', 99, '$else'), + ev('$local', 98, '$alb', 'sending'), + ev('$new', 50, '$alb') + ); + mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 }); + expect(await send('x')).toBe('into:$alb@$new'); }); }); diff --git a/src/app/features/albums/ShareSet.test.tsx b/src/app/features/albums/ShareSet.test.tsx index 6ae47613..b3da7a3d 100644 --- a/src/app/features/albums/ShareSet.test.tsx +++ b/src/app/features/albums/ShareSet.test.tsx @@ -23,6 +23,7 @@ const view = (over: Partial> = {}, adding = false) => onTitle={noop} onSend={noop} onCancel={noop} + onStop={noop} adding={adding} /> ); @@ -55,6 +56,7 @@ describe('the share panel, drawn', () => { phase: { kind: 'sending', done: 2 }, }); expect(text(html)).toContain('Sending 2 of 3…'); + expect(text(html)).toContain('Stop'); expect(text(html)).not.toContain('Send 3 files'); expect(text(html)).not.toContain('Cancel'); expect(html).toMatch( diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx index a8e65b4f..6bce53c7 100644 --- a/src/app/features/albums/ShareSet.tsx +++ b/src/app/features/albums/ShareSet.tsx @@ -10,7 +10,7 @@ import { getMsgContent } from '../room/msgContent'; import { FILE_HASH_KEY, hashForSend } from './fileReceipt'; import { ShareController, ShareState, createShareController } from './shareController'; import { titleIn } from './albumFolders'; -import { DEFAULT_TITLE, ExistingAlbum, sendShareSet } from './shareSet'; +import { DEFAULT_TITLE, ExistingAlbum, freshest, sendShareSet, ThreadEvent } from './shareSet'; import * as css from './AlbumView.css'; /** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */ @@ -29,6 +29,7 @@ type ShareViewProps = { onTitle: (title: string) => void; onSend: () => void; onCancel: () => void; + onStop: () => void; /** Adding to an existing album: the files join it, so there is no title to ask for. */ adding?: boolean; inputRef?: React.Ref; @@ -45,6 +46,7 @@ export function ShareSetView({ onTitle, onSend, onCancel, + onStop, adding, inputRef, }: ShareViewProps) { @@ -85,9 +87,14 @@ export function ShareSetView({ {files.map((f) => f.name).join(' · ')} {phase.kind === 'sending' ? ( - - Sending {phase.done} of {files.length}… - + <> + + Sending {phase.done} of {files.length}… + + + ) : ( <>