From 05948689b4865af1e8ab4a535f11044ee4d86bbf Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 23:08:48 +0000 Subject: [PATCH 01/40] 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 1be9e76012bf01dc846546980603bf90b9b169a6 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 12:09:46 +0000 Subject: [PATCH 02/40] 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 03/40] 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 04/40] 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 05/40] 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 06/40] 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 07/40] 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 08/40] 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 09/40] 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 10/40] 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 11/40] 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 12/40] 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 13/40] 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 14/40] 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 15/40] 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 16/40] 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 17/40] 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 18/40] 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 19/40] 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 20/40] 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 21/40] 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 22/40] 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 23/40] 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 24/40] 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 25/40] 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 26/40] 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 27/40] 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 28/40] 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 29/40] 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 30/40] 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 31/40] 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 32/40] 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 33/40] 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 34/40] 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 35/40] 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 36/40] 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 37/40] 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 eb0a1d0ee0784f1d114854ffdbbe7f56cc1bdf10 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 13:07:44 +0000 Subject: [PATCH 38/40] File zone share: panel state machine apart from React, with component tests Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone.md | 1 + src/app/features/albums/ShareSet.test.tsx | 99 ++++++++ src/app/features/albums/ShareSet.tsx | 176 +++++++------- .../features/albums/shareController.test.ts | 216 ++++++++++++++++++ src/app/features/albums/shareController.ts | 86 +++++++ src/app/features/albums/shareSet.ts | 2 +- 6 files changed, 494 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..7f70591e --- /dev/null +++ b/src/app/features/albums/ShareSet.test.tsx @@ -0,0 +1,99 @@ +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..0e22fbee 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, { useMemo, useRef, 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,58 @@ export function ShareSet({ room }: { room: Room }) {
        ); } + +export function ShareSet({ room }: { room: Room }) { + const mx = useMatrixClient(); + const queryClient = useQueryClient(); + const input = useRef(null); + const controller: ShareController = useMemo( + () => + createShareController({ + roomId: room.roomId, + isEncrypted: () => room.hasEncryptionStateEvent(), + onSent: () => queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }), + run: (files, title, onFile) => + 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, + }, + title, + files.map((file) => ({ name: file.name, mimeType: file.type, file })) + ), + }), + [mx, room, queryClient] + ); + 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..813c65b2 --- /dev/null +++ b/src/app/features/albums/shareController.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest'; +import { ENCRYPTED_NOTE, 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 }; +}; + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +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().epoch; + 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('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(); + await tick(); + expect(ENCRYPTED_NOTE).toBe('Not shared: this room is encrypted.'); + expect(c.getState().phase).toEqual({ kind: 'done', text: ENCRYPTED_NOTE }); + 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: ENCRYPTED_NOTE }); + }); +}); diff --git a/src/app/features/albums/shareController.ts b/src/app/features/albums/shareController.ts new file mode 100644 index 00000000..6674b5c7 --- /dev/null +++ b/src/app/features/albums/shareController.ts @@ -0,0 +1,86 @@ +// 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); + if (left.length === 0) clear(); + else set({ files: left }); + set({ phase: { kind: 'done', text: resultNote(res) } }); + } 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 86a68839b02bfe45c5673de2d91e4139a9edf83b Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 13:08:20 +0000 Subject: [PATCH 39/40] File zone share tests: lint Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/albums/shareController.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/app/features/albums/shareController.test.ts b/src/app/features/albums/shareController.test.ts index 813c65b2..f29091dc 100644 --- a/src/app/features/albums/shareController.test.ts +++ b/src/app/features/albums/shareController.test.ts @@ -33,8 +33,6 @@ const make = (over: Partial> = {}, roomId = '!zone:x') => { return { c, h, sentTo }; }; -const tick = () => new Promise((r) => setTimeout(r, 0)); - describe('picking', () => { it('starts idle with nothing picked, and a pick holds the files in order', () => { const { c } = make(); @@ -148,7 +146,7 @@ describe('progress and results', () => { 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().epoch; + const { epoch } = c.getState(); const p = c.send(); h.release(ok(1)); await p; @@ -197,7 +195,6 @@ describe('an encrypted room', () => { const { c, h } = make({ isEncrypted: () => true }); c.pick([file('a')]); await c.send(); - await tick(); expect(ENCRYPTED_NOTE).toBe('Not shared: this room is encrypted.'); expect(c.getState().phase).toEqual({ kind: 'done', text: ENCRYPTED_NOTE }); expect(c.getState().files).toHaveLength(1); From cf99096d3c1a2e5bdbfe52347c9863faab38e653 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 13:15:47 +0000 Subject: [PATCH 40/40] File zone share: controller built once per mount, one notification on success, tests for the surviving mutations Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/albums/ShareSet.test.tsx | 15 +++- src/app/features/albums/ShareSet.tsx | 76 ++++++++++--------- .../features/albums/shareController.test.ts | 56 +++++++++++++- src/app/features/albums/shareController.ts | 7 +- 4 files changed, 108 insertions(+), 46 deletions(-) diff --git a/src/app/features/albums/ShareSet.test.tsx b/src/app/features/albums/ShareSet.test.tsx index 7f70591e..bfa9a619 100644 --- a/src/app/features/albums/ShareSet.test.tsx +++ b/src/app/features/albums/ShareSet.test.tsx @@ -6,7 +6,11 @@ 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 text = (html: string) => + html + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); const noop = () => undefined; const file = (name: string) => ({ name, type: 'audio/ogg' } as File); @@ -45,11 +49,16 @@ describe('the share panel, drawn', () => { }); 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 } }); + 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"/); + expect(html).toMatch( + /aria-label="Album title"[^>]*disabled|disabled[^>]*aria-label="Album title"/ + ); }); it('the encrypted note shows above the kept pick', () => { diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx index 0e22fbee..7b507d92 100644 --- a/src/app/features/albums/ShareSet.tsx +++ b/src/app/features/albums/ShareSet.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef, useSyncExternalStore } 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'; @@ -104,42 +104,46 @@ export function ShareSet({ room }: { room: Room }) { const mx = useMatrixClient(); const queryClient = useQueryClient(); const input = useRef(null); - const controller: ShareController = useMemo( - () => - createShareController({ - roomId: room.roomId, - isEncrypted: () => room.hasEncryptionStateEvent(), - onSent: () => queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }), - run: (files, title, onFile) => - 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, + // 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; }, - title, - files.map((file) => ({ name: file.name, mimeType: file.type, file })) - ), - }), - [mx, room, queryClient] + onFile, + }, + title, + files.map((file) => ({ name: file.name, mimeType: file.type, file })) + ), + }) ); const state = useSyncExternalStore(controller.subscribe, controller.getState); return ( diff --git a/src/app/features/albums/shareController.test.ts b/src/app/features/albums/shareController.test.ts index f29091dc..3cbc03ce 100644 --- a/src/app/features/albums/shareController.test.ts +++ b/src/app/features/albums/shareController.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { ENCRYPTED_NOTE, Picked, ShareDeps, createShareController } from './shareController'; +import { Picked, ShareDeps, createShareController } from './shareController'; import { ShareResult } from './shareSet'; const file = (name: string): Picked => ({ name, type: 'audio/ogg' }); @@ -190,13 +190,58 @@ describe('progress and results', () => { }); }); +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(ENCRYPTED_NOTE).toBe('Not shared: this room is encrypted.'); - expect(c.getState().phase).toEqual({ kind: 'done', text: ENCRYPTED_NOTE }); + 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); }); @@ -208,6 +253,9 @@ describe('an encrypted room', () => { encrypted = true; await c.send(); expect(h.calls).toHaveLength(0); - expect(c.getState().phase).toMatchObject({ kind: 'done', text: ENCRYPTED_NOTE }); + 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 index 6674b5c7..b6219c72 100644 --- a/src/app/features/albums/shareController.ts +++ b/src/app/features/albums/shareController.ts @@ -58,9 +58,10 @@ export const createShareController = (deps: ShareDeps) => { }); deps.onSent?.(); const left = remainingAfter(files, res); - if (left.length === 0) clear(); - else set({ files: left }); - set({ phase: { kind: 'done', text: resultNote(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 {