From a588ab82b3adc4d153ca2eeacab6a66bbf319c91 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 05:00:32 +0100 Subject: [PATCH 01/64] GameCult theme, steward-grouped project rooms, preview base override Adds the gamecult-theme (brand palette, softer radii, Ubuntu/Montserrat/Plex Mono), makes it the default, hides the member drawer by default, defaults to bubble layout, groups rooms by org.gamecult.steward in the space nav (collapsed on phones), and lets CINNY_BASE set Vite's base for a sub-path preview build. Co-Authored-By: Claude Opus 5.5 --- build.config.ts | 2 +- package.json | 11 ++- .../steward-groups/stewardGroups.test.ts | 63 +++++++++++++ .../features/steward-groups/stewardGroups.ts | 91 ++++++++++++++++++ .../steward-groups/useStewardGroups.ts | 71 ++++++++++++++ src/app/hooks/useTheme.ts | 18 +++- src/app/pages/ThemeManager.tsx | 17 +--- src/app/pages/client/space/Space.tsx | 35 ++++++- src/app/state/settings.ts | 8 +- src/colors.css.ts | 92 +++++++++++++++++++ src/gamecult.css.ts | 20 ++++ src/index.css | 64 +++++++++++++ src/index.tsx | 7 ++ 13 files changed, 471 insertions(+), 28 deletions(-) create mode 100644 src/app/features/steward-groups/stewardGroups.test.ts create mode 100644 src/app/features/steward-groups/stewardGroups.ts create mode 100644 src/app/features/steward-groups/useStewardGroups.ts create mode 100644 src/gamecult.css.ts diff --git a/build.config.ts b/build.config.ts index ec8a41d0..ac51db97 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,3 +1,3 @@ export default { - base: '/', + base: process.env.CINNY_BASE ?? '/', }; diff --git a/package.json b/package.json index 8166ccd8..298fbf36 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "typecheck": "tsc --noEmit", "prepare": "husky install", "commit": "git-cz", - "bump": "node scripts/update-version.js" + "bump": "node scripts/update-version.js", + "test": "vitest run" }, "lint-staged": { "*.{ts,tsx,js,jsx}": "eslint", @@ -87,7 +88,10 @@ "slate-dom": "0.123.0", "slate-history": "0.113.1", "slate-react": "0.123.0", - "ua-parser-js": "1.0.35" + "ua-parser-js": "1.0.35", + "@fontsource/ubuntu": "5.2.5", + "@fontsource/montserrat": "5.2.5", + "@fontsource/ibm-plex-mono": "5.2.5" }, "devDependencies": { "@element-hq/element-call-embedded": "0.20.1", @@ -123,6 +127,7 @@ "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", - "vite-plugin-top-level-await": "1.4.4" + "vite-plugin-top-level-await": "1.4.4", + "vitest": "1.6.0" } } diff --git a/src/app/features/steward-groups/stewardGroups.test.ts b/src/app/features/steward-groups/stewardGroups.test.ts new file mode 100644 index 00000000..d0d4b7c3 --- /dev/null +++ b/src/app/features/steward-groups/stewardGroups.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { groupByStewards, parseSteward, StewardEntry } from './stewardGroups'; + +const room = (roomId: string, parentId = '!projects') => ({ roomId, parentId }); +const space = (roomId: string) => ({ roomId, space: true as const }); + +const base = { + spaceId: '!top', + isGroupOpen: () => true, + keepVisible: () => false, +}; + +const names = (out: StewardEntry<{ roomId: string }>[]) => + out.map((e) => (e.type === 'group' ? `G:${e.label}:${e.count}` : e.item.roomId)); + +describe('groupByStewards', () => { + const stewards: Record = { '!a': 'Void', '!b': 'Eureka', '!c': 'Void' }; + const stewardOf = (id: string) => stewards[id]; + + it('groups by steward, sorted by name, with unstewarded rooms first', () => { + const out = groupByStewards([room('!a'), room('!x'), room('!b'), room('!c')], { + ...base, + stewardOf, + }); + expect(names(out)).toEqual(['!x', 'G:Eureka:1', '!b', 'G:Void:2', '!a', '!c']); + }); + + it('collapsed groups hide rooms except kept-visible ones', () => { + const out = groupByStewards([room('!a'), room('!b'), room('!c')], { + ...base, + stewardOf, + isGroupOpen: () => false, + keepVisible: (id) => id === '!c', + }); + expect(names(out)).toEqual(['G:Eureka:1', 'G:Void:2', '!c']); + }); + + it('degrades to the original order when no room has steward state', () => { + const out = groupByStewards([room('!1'), space('!s'), room('!2', '!s')], { + ...base, + stewardOf: () => undefined, + }); + expect(names(out)).toEqual(['!1', '!s', '!2']); + }); + + it('does not merge runs separated by a space header', () => { + const out = groupByStewards([room('!a'), space('!s'), room('!c', '!s')], { + ...base, + stewardOf, + }); + expect(out.filter((e) => e.type === 'group')).toHaveLength(2); + }); +}); + +describe('parseSteward', () => { + it('accepts a string and rejects junk', () => { + expect(parseSteward({ steward: ' Void ' })).toBe('Void'); + expect(parseSteward({ steward: 3 })).toBeUndefined(); + expect(parseSteward({ steward: ' ' })).toBeUndefined(); + expect(parseSteward(undefined)).toBeUndefined(); + expect(parseSteward(null)).toBeUndefined(); + }); +}); diff --git a/src/app/features/steward-groups/stewardGroups.ts b/src/app/features/steward-groups/stewardGroups.ts new file mode 100644 index 00000000..c95dd935 --- /dev/null +++ b/src/app/features/steward-groups/stewardGroups.ts @@ -0,0 +1,91 @@ +/** + * Splits runs of rooms that carry an `org.gamecult.steward` state event into + * collapsible per-steward groups. Pure: the caller supplies the lookups, so the + * grouping is testable without a Matrix client. + */ +export const STEWARD_EVENT_TYPE = 'org.gamecult.steward'; + +export type HierarchyLike = { roomId: string; parentId?: string; space?: true }; + +export type StewardEntry = + | { type: 'item'; item: T } + | { type: 'group'; id: string; label: string; open: boolean; count: number }; + +export const makeStewardGroupId = (spaceId: string, parentId: string, steward: string) => + `${spaceId}|${parentId}|steward:${steward}`; + +export type StewardGroupOptions = { + spaceId: string; + /** steward name of a room, or undefined when the room has no (valid) steward state */ + stewardOf: (roomId: string) => string | undefined; + isGroupOpen: (groupId: string) => boolean; + /** rooms that stay visible inside a collapsed group (selected, unread, in a call) */ + keepVisible: (roomId: string) => boolean; +}; + +const byLabel = (a: string, b: string) => + a.localeCompare(b, undefined, { sensitivity: 'base' }) || a.localeCompare(b); + +export const groupByStewards = ( + items: T[], + { spaceId, stewardOf, isGroupOpen, keepVisible }: StewardGroupOptions +): StewardEntry[] => { + const out: StewardEntry[] = []; + + const flush = (run: T[]) => { + if (run.length === 0) return; + const groups = new Map(); + const loose: T[] = []; + run.forEach((item) => { + const steward = stewardOf(item.roomId); + if (steward === undefined) { + loose.push(item); + return; + } + const list = groups.get(steward); + if (list) list.push(item); + else groups.set(steward, [item]); + }); + + // No steward state anywhere in this run: leave the list exactly as Cinny built it. + if (groups.size === 0) { + run.forEach((item) => out.push({ type: 'item', item })); + return; + } + + loose.forEach((item) => out.push({ type: 'item', item })); + Array.from(groups.keys()) + .sort(byLabel) + .forEach((steward) => { + const rooms = groups.get(steward) ?? []; + const id = makeStewardGroupId(spaceId, rooms[0].parentId ?? spaceId, steward); + const open = isGroupOpen(id); + out.push({ type: 'group', id, label: steward, open, count: rooms.length }); + rooms + .filter((item) => open || keepVisible(item.roomId)) + .forEach((item) => out.push({ type: 'item', item })); + }); + }; + + let run: T[] = []; + items.forEach((item) => { + if (item.space) { + flush(run); + run = []; + out.push({ type: 'item', item }); + return; + } + run.push(item); + }); + flush(run); + return out; +}; + +/** Reads `steward` from raw state event content; anything unusable is "no steward". */ +export const parseSteward = (content: unknown): string | undefined => { + if (typeof content !== 'object' || content === null) return undefined; + const { steward } = content as { steward?: unknown }; + if (typeof steward !== 'string') return undefined; + const trimmed = steward.trim(); + return trimmed === '' ? undefined : trimmed; +}; diff --git a/src/app/features/steward-groups/useStewardGroups.ts b/src/app/features/steward-groups/useStewardGroups.ts new file mode 100644 index 00000000..ae1d2a3d --- /dev/null +++ b/src/app/features/steward-groups/useStewardGroups.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { MatrixClient, MatrixEvent, RoomStateEvent } from 'matrix-js-sdk'; +import { atomWithStorage } from 'jotai/utils'; +import { useAtom } from 'jotai'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { + groupByStewards, + HierarchyLike, + parseSteward, + StewardEntry, + STEWARD_EVENT_TYPE, +} from './stewardGroups'; + +// Explicit open/closed choices by group id. Absent = default: closed on phones, open elsewhere. +const stewardGroupChoicesAtom = atomWithStorage>( + 'gamecultStewardGroupOpen', + {} +); + +type Options = { + mx: MatrixClient; + spaceId: string; + items: T[]; + keepVisible: (roomId: string) => boolean; +}; + +export const useStewardGroups = ({ + mx, + spaceId, + items, + keepVisible, +}: Options) => { + const mobile = useScreenSizeContext() === ScreenSize.Mobile; + const [choices, setChoices] = useAtom(stewardGroupChoicesAtom); + const [stateVersion, setStateVersion] = useState(0); + + useEffect(() => { + const onState = (mEvent: MatrixEvent) => { + if (mEvent.getType() === STEWARD_EVENT_TYPE) setStateVersion((v) => v + 1); + }; + mx.on(RoomStateEvent.Events, onState); + return () => { + mx.removeListener(RoomStateEvent.Events, onState); + }; + }, [mx]); + + const stewardOf = useCallback( + (roomId: string) => + // stateVersion is read so a late-arriving steward event re-derives this lookup + stateVersion >= 0 + ? parseSteward( + mx.getRoom(roomId)?.currentState.getStateEvents(STEWARD_EVENT_TYPE, '')?.getContent() + ) + : undefined, + [mx, stateVersion] + ); + + const isGroupOpen = useCallback((id: string) => choices[id] ?? !mobile, [choices, mobile]); + + const entries: StewardEntry[] = useMemo( + () => groupByStewards(items, { spaceId, stewardOf, isGroupOpen, keepVisible }), + [items, spaceId, stewardOf, isGroupOpen, keepVisible] + ); + + const toggleGroup = useCallback( + (id: string) => setChoices((prev) => ({ ...prev, [id]: !(prev[id] ?? !mobile) })), + [setChoices, mobile] + ); + + return { entries, toggleGroup }; +}; diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index cdbb9dba..4bfe01da 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -1,7 +1,8 @@ import { lightTheme } from 'folds'; import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { onDarkFontWeight, onLightFontWeight } from '../../config.css'; -import { butterTheme, darkTheme, silverTheme } from '../../colors.css'; +import { butterTheme, darkTheme, gamecultTheme, silverTheme } from '../../colors.css'; +import '../../gamecult.css'; import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; @@ -38,8 +39,14 @@ export const ButterTheme: Theme = { classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'], }; +export const GameCultTheme: Theme = { + id: 'gamecult-theme', + kind: ThemeKind.Dark, + classNames: ['gamecult-theme', gamecultTheme, onDarkFontWeight, 'prism-dark'], +}; + export const useThemes = (): Theme[] => { - const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme], []); + const themes: Theme[] = useMemo(() => [GameCultTheme, LightTheme, SilverTheme, DarkTheme, ButterTheme], []); return themes; }; @@ -47,6 +54,7 @@ export const useThemes = (): Theme[] => { export const useThemeNames = (): Record => useMemo( () => ({ + [GameCultTheme.id]: 'GameCult', [LightTheme.id]: 'Light', [SilverTheme.id]: 'Silver', [DarkTheme.id]: 'Dark', @@ -84,15 +92,15 @@ export const useActiveTheme = (): Theme => { const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId'); if (!systemTheme) { - const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme; + const selectedTheme = themes.find((theme) => theme.id === themeId) ?? GameCultTheme; return selectedTheme; } const selectedTheme = systemThemeKind === ThemeKind.Dark - ? themes.find((theme) => theme.id === darkThemeId) ?? DarkTheme - : themes.find((theme) => theme.id === lightThemeId) ?? LightTheme; + ? themes.find((theme) => theme.id === darkThemeId) ?? GameCultTheme + : themes.find((theme) => theme.id === lightThemeId) ?? GameCultTheme; return selectedTheme; }; diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 69d50cdb..983ca684 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -1,29 +1,20 @@ import React, { ReactNode, useEffect } from 'react'; import { configClass, varsClass } from 'folds'; import { - DarkTheme, - LightTheme, + GameCultTheme, ThemeContextProvider, - ThemeKind, useActiveTheme, - useSystemThemeKind, } from '../hooks/useTheme'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; export function UnAuthRouteThemeManager() { - const systemThemeKind = useSystemThemeKind(); - useEffect(() => { document.body.className = ''; document.body.classList.add(configClass, varsClass); - if (systemThemeKind === ThemeKind.Dark) { - document.body.classList.add(...DarkTheme.classNames); - } - if (systemThemeKind === ThemeKind.Light) { - document.body.classList.add(...LightTheme.classNames); - } - }, [systemThemeKind]); + // The GameCult identity is single-theme: signed-out pages ignore the system scheme. + document.body.classList.add(...GameCultTheme.classNames); + }, []); return null; } diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 2a7c6199..c822ff38 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -51,6 +51,7 @@ import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; import { makeNavCategoryId } from '../../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; +import { useStewardGroups } from '../../../features/steward-groups/useStewardGroups'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; import { useRoomName } from '../../../hooks/useRoomMeta'; import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy'; @@ -425,8 +426,18 @@ export function Space() { ) ); + const { entries, toggleGroup } = useStewardGroups({ + mx, + spaceId: space.roomId, + items: hierarchy, + keepVisible: useCallback( + (rId: string) => roomToUnread.has(rId) || rId === selectedRoomId || callEmbed?.roomId === rId, + [roomToUnread, selectedRoomId, callEmbed] + ), + }); + const virtualizer = useVirtualizer({ - count: hierarchy.length, + count: entries.length, getScrollElement: () => scrollRef.current, estimateSize: () => 0, overscan: 10, @@ -491,7 +502,27 @@ export function Space() { }} > {virtualizer.getVirtualItems().map((vItem) => { - const { roomId } = hierarchy[vItem.index] ?? {}; + const entry = entries[vItem.index]; + if (!entry) return null; + if (entry.type === 'group') { + return ( + + + toggleGroup(entry.id)} + closed={!entry.open} + > + {entry.label} ยท {entry.count} + + + + ); + } + const { roomId } = entry.item; const room = mx.getRoom(roomId); if (!room) return null; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 9fbad4ff..6a894350 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -50,8 +50,8 @@ export interface Settings { } const defaultSettings: Settings = { - themeId: undefined, - useSystemTheme: true, + themeId: 'gamecult-theme', + useSystemTheme: false, lightThemeId: undefined, darkThemeId: undefined, monochromeMode: false, @@ -61,10 +61,10 @@ const defaultSettings: Settings = { pageZoom: 100, hideActivity: false, - isPeopleDrawer: true, + isPeopleDrawer: false, memberSortFilterIndex: 0, enterForNewline: false, - messageLayout: 0, + messageLayout: 2, messageSpacing: '400', hideMembershipEvents: false, hideNickAvatarEvents: true, diff --git a/src/colors.css.ts b/src/colors.css.ts index 268cbf78..9c03c2cb 100644 --- a/src/colors.css.ts +++ b/src/colors.css.ts @@ -236,3 +236,95 @@ export const butterTheme = createTheme(color, { OnContainer: '#F2EED3', }, }); + +// GameCult identity (gamecult-site quartz.config.ts): single dark theme on #07111a, +// orange #ff8a2a accent, sky #59b7ff links. The brand defines no status colours, so +// Success/Warning/Critical are invented here and kept distinct from the orange accent. +export const gamecultTheme = createTheme(color, { + Background: { + Container: '#07111a', + ContainerHover: '#0d1a26', + ContainerActive: '#122130', + ContainerLine: '#16212c', + OnContainer: '#b7c7d9', + }, + Surface: { + Container: '#09141f', + ContainerHover: '#0f1c29', + ContainerActive: '#152433', + ContainerLine: '#16212c', + OnContainer: '#b7c7d9', + }, + SurfaceVariant: { + Container: '#0d1a26', + ContainerHover: '#122130', + ContainerActive: '#16273a', + ContainerLine: '#1b2a38', + OnContainer: '#b7c7d9', + }, + Primary: { + Main: '#ff8a2a', + MainHover: '#ff9640', + MainActive: '#ffa155', + MainLine: '#ffab66', + OnMain: '#2a1300', + Container: '#2b1a0e', + ContainerHover: '#35200f', + ContainerActive: '#402611', + ContainerLine: '#4d2d13', + OnContainer: '#ffd3ab', + }, + Secondary: { + Main: '#eef5ff', + MainHover: '#dbe7f6', + MainActive: '#cddced', + MainLine: '#bfd0e4', + OnMain: '#07111a', + Container: '#16212c', + ContainerHover: '#1c2a38', + ContainerActive: '#233342', + ContainerLine: '#2a3b4c', + OnContainer: '#eef5ff', + }, + Success: { + Main: '#5fd0a4', + MainHover: '#72d8b0', + MainActive: '#84deba', + MainLine: '#93e3c2', + OnMain: '#042014', + Container: '#0c2a21', + ContainerHover: '#10342a', + ContainerActive: '#143e32', + ContainerLine: '#19493a', + OnContainer: '#c4f1de', + }, + Warning: { + Main: '#f2d04b', + MainHover: '#f5d963', + MainActive: '#f7e078', + MainLine: '#f9e68b', + OnMain: '#2a2100', + Container: '#2a2410', + ContainerHover: '#352d13', + ContainerActive: '#403616', + ContainerLine: '#4d411a', + OnContainer: '#f8ebb0', + }, + Critical: { + Main: '#ff7a90', + MainHover: '#ff8fa1', + MainActive: '#ffa0af', + MainLine: '#ffb0bc', + OnMain: '#3a0710', + Container: '#2e1118', + ContainerHover: '#391520', + ContainerActive: '#441a27', + ContainerLine: '#51202e', + OnContainer: '#ffd0d8', + }, + Other: { + FocusRing: 'rgba(89, 183, 255, 0.55)', + Shadow: 'rgba(0, 0, 0, 0.6)', + Overlay: 'rgba(3, 7, 13, 0.8)', + }, +}); diff --git a/src/gamecult.css.ts b/src/gamecult.css.ts new file mode 100644 index 00000000..4aaf3e76 --- /dev/null +++ b/src/gamecult.css.ts @@ -0,0 +1,20 @@ +import { globalStyle } from '@vanilla-extract/css'; +import { config } from 'folds'; + +// Softer, rounder shape language and an airier line height. Scoped to the GameCult +// theme class so the stock themes keep folds' defaults. +const scope = 'body.gamecult-theme'; + +globalStyle(scope, { + vars: { + [config.radii.R300]: '10px', + [config.radii.R400]: '16px', + [config.radii.R500]: '22px', + [config.lineHeight.T300]: '1.6', + [config.lineHeight.T400]: '1.7', + [config.lineHeight.T500]: '1.7', + [config.lineHeight.B400]: '1.6', + [config.lineHeight.B500]: '1.6', + [config.lineHeight.D400]: '1.7', + }, +}); diff --git a/src/index.css b/src/index.css index d4696367..f751b94d 100644 --- a/src/index.css +++ b/src/index.css @@ -135,3 +135,67 @@ li p::before { content: ''; display: inline-block; } + +/* GameCult identity: Ubuntu 300 prose, Montserrat thin titles, IBM Plex Mono labels. */ +.gamecult-theme { + --tc-link: #59b7ff; + + --mx-uc-1: hsl(208, 100%, 72%); + --mx-uc-2: hsl(280, 100%, 78%); + --mx-uc-3: hsl(163, 70%, 65%); + --mx-uc-4: hsl(343, 100%, 76%); + --mx-uc-5: hsl(28, 100%, 66%); + --mx-uc-6: hsl(181, 70%, 62%); + --mx-uc-7: hsl(243, 100%, 80%); + --mx-uc-8: hsl(94, 70%, 70%); + + --font-secondary: 'Ubuntu', var(--font-emoji), sans-serif; + --font-title: 'Montserrat', 'Ubuntu', sans-serif; + --font-mono: 'IBM Plex Mono', monospace; + --gamecult-rule: rgba(148, 163, 184, 0.22); + --gamecult-wash: 0.5; + + background-color: #07111a; + background-image: radial-gradient( + circle at 78% 14%, + rgb(255 138 42 / calc(0.18 * var(--gamecult-wash))), + transparent 18% + ), + radial-gradient( + circle at 18% 10%, + rgb(109 96 255 / calc(0.16 * var(--gamecult-wash))), + transparent 24% + ), + radial-gradient( + circle at 52% 0%, + rgb(89 183 255 / calc(0.14 * var(--gamecult-wash))), + transparent 28% + ), + linear-gradient(180deg, #03070d 0%, #07111a 44%, #09141f 100%); + background-attachment: fixed; + color-scheme: dark; + font-weight: 300; +} + +.gamecult-theme b, +.gamecult-theme strong { + font-weight: 500; +} + +.gamecult-theme h1, +.gamecult-theme h2, +.gamecult-theme h3 { + font-family: var(--font-title); + font-weight: 300; +} + +.gamecult-theme code, +.gamecult-theme pre { + font-family: var(--font-mono); +} + +/* Quiet secondary metadata: timestamps, edited marks and the like sit back. */ +.gamecult-theme time { + opacity: 0.55; + font-size: 0.78em; +} diff --git a/src/index.tsx b/src/index.tsx index 6cc0ca38..672ac5c0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,13 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { enableMapSet } from 'immer'; import '@fontsource/inter/variable.css'; +import '@fontsource/ubuntu/300.css'; +import '@fontsource/ubuntu/400.css'; +import '@fontsource/ubuntu/500.css'; +import '@fontsource/ubuntu/700.css'; +import '@fontsource/montserrat/200.css'; +import '@fontsource/montserrat/300.css'; +import '@fontsource/ibm-plex-mono/400.css'; import 'folds/dist/style.css'; import { configClass, varsClass } from 'folds'; From b8e696c81bd698ecd0ea88095b2c8bfdbecbd493 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 05:05:15 +0100 Subject: [PATCH 02/64] Lockfile for fontsource faces and vitest Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 686 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 682 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d61fec51..dc774000 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,10 @@ "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", + "@fontsource/ibm-plex-mono": "5.2.5", "@fontsource/inter": "4.5.14", + "@fontsource/montserrat": "5.2.5", + "@fontsource/ubuntu": "5.2.5", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -99,7 +102,8 @@ "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", - "vite-plugin-top-level-await": "1.4.4" + "vite-plugin-top-level-await": "1.4.4", + "vitest": "1.6.0" }, "engines": { "node": ">=16.0.0" @@ -2188,11 +2192,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.5.tgz", + "integrity": "sha512-G09N3GfuT9qj3Ax2FDZvKqZttzM3v+cco2l8uXamhKyXLdmlaUDH5o88/C3vtTHj2oT7yRKsvxz9F+BXbWKMYA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/inter": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-4.5.14.tgz", "integrity": "sha512-JDC9AocdPLuGsASkvWw9hS5gtHE7K9dOwL98XLrk5yjYqxy4uVnScG58NUvFMJDVJRl/7c8Wnap6PEs+7Zvj1Q==" }, + "node_modules/@fontsource/montserrat": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.2.5.tgz", + "integrity": "sha512-/OA+bdg1yqUArDUnEeCqDwgTDiIpvED56TxYskje8UKpsvjl/0A9eNod7IcdHz6/Osi70vo7briRO3la8/u+5Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ubuntu": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/ubuntu/-/ubuntu-5.2.5.tgz", + "integrity": "sha512-VKVFVqmJ9MGnOJW2dsQ982qHN8Zr+tNeEwEHnzt5VdGi9ZWL37wYFD32YsqukWlI/+I7v5ZkgHTEAvsZxU6aDA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.2.tgz", @@ -2307,6 +2338,19 @@ "@swc/helpers": "^0.5.0" } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -2347,9 +2391,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -4330,6 +4375,13 @@ "url": "https://ko-fi.com/dangreen" } }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "dev": true, + "license": "MIT" + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -5130,6 +5182,143 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/@vitest/expect": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz", + "integrity": "sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz", + "integrity": "sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.0", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.0.tgz", + "integrity": "sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.0.tgz", + "integrity": "sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz", + "integrity": "sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -5156,6 +5345,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -5448,6 +5650,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", @@ -5857,6 +6069,25 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5879,6 +6110,19 @@ "dev": true, "license": "MIT" }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -6614,6 +6858,19 @@ } } }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6715,6 +6972,16 @@ "node": ">=8" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -7638,6 +7905,85 @@ "node": ">=0.8.x" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/expand-tilde": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", @@ -8078,6 +8424,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", @@ -8121,6 +8477,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -8525,6 +8894,16 @@ "node": ">= 6" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -9719,6 +10098,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10051,6 +10447,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -10173,6 +10579,13 @@ "dev": true, "license": "MIT" }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -10439,6 +10852,35 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -10830,6 +11272,16 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==" }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/pdfjs-dist": { "version": "4.2.67", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.2.67.tgz", @@ -10971,6 +11423,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -11993,6 +12480,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -12220,6 +12714,20 @@ "deprecated": "Please use @jridgewell/sourcemap-codec instead", "dev": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -12383,6 +12891,19 @@ "node": ">=10" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -12395,6 +12916,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.3.tgz", @@ -12526,6 +13067,13 @@ "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", @@ -12553,6 +13101,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -12646,6 +13214,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", @@ -13474,6 +14052,89 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/vitest": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.0.tgz", + "integrity": "sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.0", + "@vitest/runner": "1.6.0", + "@vitest/snapshot": "1.6.0", + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.0", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.0", + "@vitest/ui": "1.6.0", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -13607,6 +14268,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", From dd88424e5d4b8676ef9de10d714ffe11e2ff1e7d Mon Sep 17 00:00:00 2001 From: Eureka Date: Sat, 31 Oct 2026 16:47:19 +0100 Subject: [PATCH 03/64] docs: Ratatoskr target and prose map (campaign ratatoskr) Design truths from the rulings in force, body facts from probes on 2026-10-31, the navigator authority map, the reconstructed Minds' collaboration plan for Atlas to import, and the cut sequence. Co-Authored-By: Claude Opus 5.5 --- docs/gamecult/ratatoskr-target.md | 308 ++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 docs/gamecult/ratatoskr-target.md diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md new file mode 100644 index 00000000..45447b23 --- /dev/null +++ b/docs/gamecult/ratatoskr-target.md @@ -0,0 +1,308 @@ +# 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). +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 +exists (`hand_off` is parked); after that every new ruling on it is admitted in +Void's mind. Nothing here is written into Void's mind before then. + +The mind holds the questions, rulings, specs, reports and verdicts. This file +holds what no document kind carries: the design truths, body facts, the model +page, rationale, the reconstructed collaboration plan, and the cut sequence. +All times are UTC. + +## Why + +GameCult is the only server this client talks to. A general Matrix client +treats it as one community among thousands, so the operator meets GameCult's +structure three clicks deep in an invite list of 50+ rooms (operator, `#swarm` +10-31 05:14: "The biggest pain is navigation. GameCult has a structure."). The +client should open on GameCult's estate as data: its spaces, its project rooms +grouped by steward, its Minds, and the person's own place in it. It should +also be a pleasant place to be in its own right: emotes, sound, and weather in +the background. + +## Design truths (from rulings in force) + +- **We own the interface, not the engine.** The fork keeps Cinny's engine + (matrix-js-sdk, Rust crypto, sync) and replaces the whole UI; every + user-facing string and its translation is ours, and upstream locale files + are not the source (`gamecult-chat:ruling:we-own-the-ui-and-translation`). +- **Mobile first.** On phones, inactive elements hide, vertical space is + cheaper than horizontal, and text never gets squeezed into narrow columns + (`eureka-body:ruling:chat-client-base-cinny-mobile-first`). +- **The first spec** (`eureka-body:ruling:chat-client-first-spec`): an emote + slot on every bubble (a pose id carried on the event); room and participant + lists tiered (category, then items) and reached from the header menu, not + side columns; participants grouped by estate (Minds, Members, Users, + Patrons); rooms grouped by kind (general, estates, projects, work rooms); + Minds indexed by stewardship, read from Matrix state projected from the + stewardship records in the Minds' own minds (the minds stay the truth); one + room per project, a Space child, with the steward joined. +- **Rooms are visible without a join dance.** No invite screen in the way; a + room the server lets you join shows in the same list as joined ones; opening + one shows its name and a one-tap Join; joining happens on that tap or a first + post, never silently; no peeking (operator, `#swarm` 10-31 04:08, 04:12 + "Peeking sounds like YAGNI to me", 04:14 "One tap is a reasonable cost"). + Rooms you may not enter are refused by the server, not hidden by the client + (Heimdall 04:10, accepted by Void 04:11 and 04:15). +- **Language.** A user's language is the BCP 47 tag in profile field + `org.gamecult.language`, owned by the user through the client's language + picker (`gamecult-chat:ruling:user-language-profile-field`). The client ships + localized; people talk with stewards in their own language; rooms are made + for a language, with no per-language mirrors + (`gamecult-chat:ruling:multilingual-client-stewards-language-rooms`). One + host Mind sits in a language room, Weksa for `#pt-br` + (`gamecult-chat:ruling:language-room-one-host-weksa`). The first person this + must work for is a non-English, non-technical patron (placeholder + `patron-pt1`) whose account we set up and walk through login + (`gamecult-chat:ruling:patron-in-his-own-language`). +- **Push.** `gamecult-chat:ruling:chat-push` rules out native mobile push for + now; Web Push to the installed web app is a different thing and is open + (question `ratatoskr:question:pwa-web-push`). +- **Collaboration is recorded, not chatted.** Work spanning Minds belongs in + Atlas (`eureka-body:ruling:atlas-records-collaborative-work`). Atlas is not + deployed, so the plan below is its interim home; Atlas imports it later. + +## Body facts + +Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`. + +1. **Branch.** `void/gamecult-theme-and-project-nav` is `b8e696c8` over main + `1e6412ed` (v4.12.8), 2 commits, 14 files, +1153/-32 (686 of it lockfile): + a GameCult theme as default, member drawer hidden by default, bubble + layout, and steward grouping in `src/app/pages/client/space/Space.tsx` + (`useStewardGroups` at 429-437, group headers at 505-524) over + `useSpaceJoinedHierarchy` (409). Read by `git diff`. +2. **Live estate shape** (Synapse on Yggdrasil, read as `admin` over the + client API, counts only). Root Space `#gamecult:gamecult.org`, room version + 12, has 9 children: 8 rooms and the `Projects` sub-Space. `Projects` has 52 + children; 50 readable rooms, all room version 12. Server `/hierarchy` from + the root returns 59 rooms, 2 of them spaces. **Every room's join rule is + `invite`**; none is world-readable. 50 rooms carry `org.gamecult.steward` + with content `{mind, repo, source, steward: }`. The root carries + `org.gamecult.stewardships`: Mind localpart to list of `Org/Repo`. +3. **Consequence of 1 and 2.** Project rooms are invite-only and Members are + invited, not joined (gamecult-ops `runbooks/gamecult-chat-host-yggdrasil.md` + 146-158: "Members and every Mind are invited; the steward alone joins"). + So a Member's project rooms are pending invites. Void's grouping reads + `useSpaceJoinedHierarchy` and a per-room state event that an invitee cannot + read, so for a typical Member it groups nothing and the rooms stay in the + invite list. This is the operator's "three clicks deep" complaint, seen from + the data. +4. **Provisioning contract.** gamecult-ops + `scripts/synapse-accounts-hand-managed.py` writes the rooms and both state + events from the Minds' stewardship documents; each project room's alias is + `#` (runbook 146-150). This is the only place the client + can learn a not-yet-joined room's steward today: hierarchy `canonical_alias` + joined to the root `org.gamecult.stewardships`. Follow-up + `ratatoskr:follow_up:stewardships-carry-room-ids` asks the projection to + name room ids so the client stops leaning on the alias convention. +5. **Sync and storage** (`src/client/initMatrix.ts`). The client uses + matrix-js-sdk 41.7.0 with `IndexedDBStore` (`web-sync-store`, 15-19) and the + Rust crypto store, and `startClient({ lazyLoadMembers: true })` (43-47): v2 + `/sync`, no sliding sync, no custom filter or `initialSyncLimit`. A cold + start is therefore an IndexedDB load plus an incremental sync from the + stored token, not a full initial sync. +6. **Server sync support.** `/_matrix/client/versions` on Synapse 1.164.1 + (127.0.0.1:8834) advertises `org.matrix.simplified_msc3575: true`: + simplified sliding sync (MSC4186) is available. Cinny 4.12.8 does not use it. +7. **Shell delivery.** `https://chat.gamecult.org/assets/index-DWIvG3QT.js` is + served at 3,736,520 bytes with no `Content-Encoding` even when gzip and br + are offered, and with no `Cache-Control` (only `ETag`/`Last-Modified`). The + service worker (`src/sw.ts`, 158 lines) handles only `install`, `activate`, + a session `message`, and authenticated media `fetch` (79-158); the + `vite-plugin-pwa` config sets `injectionPoint: undefined` + (`vite.config.ts` 101-113), so nothing is precached. +8. **No push path.** `src/sw.ts` has no `push` or `notificationclick` handler; + no code subscribes through `PushManager`; the only pusher the client sets is + the email pusher (`src/app/features/settings/notifications/SystemNotification.tsx` + 22-40). Notifications and sounds fire only from a live page: + `src/app/pages/client/ClientNonUIFeatures.tsx` 79-129 (invites) and 131-258 + (messages: `RoomEvent.Timeline` at 181-235, `new window.Notification` at + 156, `notification.ogg` played at 175-178). Nothing in `src/` listens for + `visibilitychange` or calls `retryImmediately`. +9. **Sygnal WebPush** (matrix-org/sygnal `docs/applications.md`, read + 2026-10-31; the page itself is marked partly out of date): a `webpush` app + takes `vapid_private_key`, `vapid_contact_email`, optional + `allowed_endpoints` and `ttl`; the client's pusher `data` carries + `endpoint`, `auth`, `p256dh` and optional `default_payload`; the push + carries `room_id`, `room_name`, `sender_display_name`, `content`, unread + counts and so on; browsers need a visible notification for each push + (`events_only` avoids event-less pushes). + +Not probed: phone frame times, the real resume timeline on the operator's +Android Chrome install (needs her device or remote DevTools), the Rust crypto +wasm size, and the router base under `/preview/` (its `config.json` sets +`hashRouter.basename` to `/`). + +## Why the PWA resumes slowly (diagnosis, moderate confidence) + +Android freezes a backgrounded installed web app and later discards it. On a +**discard**, returning is a full cold boot: 3.7 MB of uncompressed JS over a +phone link with no precache (fact 7), Rust crypto wasm init, the IndexedDB +store load, then a catch-up `/sync` over every room since the stored token +(fact 5). On a **freeze without discard**, the in-flight long-poll is dead, and +the SDK only notices on its own timers because the client never nudges it on +`visibilitychange` (fact 8). The catch-up sync grows with the number of rooms +and the swarm's chat volume, which is exactly where sliding sync helps +(fact 6), but the first two costs are cheaper to remove and should be measured +first. Without Web Push, a frozen page cannot notify at all, which is the +second half of the operator's report. + +## Model page: the estate + +| Kind | Named by | Over time | Who decides | +|---|---|---|---| +| Stewardship | `:stewardship:.n` in each Mind's mind | sequence advances on hand-over | the operator and the Minds; the minds are the truth | +| Estate projection | `org.gamecult.stewardships` on the root Space; `org.gamecult.steward` per project room | re-PUT on every provisioning run | gamecult-ops provisioning script (Eureka's slice) | +| Estate room list | the server's `/hierarchy` of the root Space for this user | changes with Space children and the user's invites and joins | Synapse (membership and join rules); the client derives | +| Room membership | the user's `m.room.member` | join on tap or first post | the user | +| Navigator view | derived per render | never stored | the client's estate index | + +## Authority map for the navigator + +- **Owner:** one estate index in the client (`src/app/features/estate/`): + `buildEstate()` is pure, `useEstate()` feeds it. It decides what rooms the + person sees, in which tier and group, and with what membership affordance. +- **Inputs:** the paged server `/hierarchy` of the root Space; root Space + state `org.gamecult.stewardships`; the person's membership per room from the + client store; unread and selection atoms for emphasis only; the `m.direct` + list for the direct-messages tier. +- **Outputs:** an ordered list of tiers (one per root room group and per + sub-Space, plus Direct), each holding steward groups or rooms, each room + marked joined, invited or joinable. +- **Derived:** steward group labels (Mind display names); counts; collapsed + state is per-device UI state only. +- **Demoted or dead:** the sidebar rail and its account-data ordering + (`useSidebarItems`) are dead; `useSpaceJoinedHierarchy` no longer decides + what is listed; Void's per-room steward reader is deleted, not kept beside + the index. +- **Shared paths:** every nav slot (home, direct, space routes) renders the + same navigator; tapping Join and sending a first post both go through + `mx.joinRoom`; deep links into a room still resolve through the existing + room routes. + +## Reconstructed collaboration plan (Atlas import) + +Planned in `#swarm` and `#minds` on 2026-10-31 and then lost to scroll. Status +as of 2026-10-31 ~15:00. + +**Who holds which part** + +| Part | Holder | Status | Evidence | +|---|---|---|---| +| The client: integration, navigator, emote slot, sound hookup, background canvas | Void (steward) | theme and steward grouping on branch; nothing else built | `void:stewardship:gamecult_-cinny.n1`; `#swarm` 10-31 14:54 Void report | +| Dings earcons: motif per event kind, one stable instrument per Mind | Aqua | not rendered: render env blocked, then .NET SDK installed and Faust scouted; AquaSynth PR #1 (Linux Faust loader, `faust-loader-platform-aware`) merged at 3b43b7bd | `aqua-body:follow_up:chat-s410-416`, `aqua-body:follow_up:chat-s410-416-render-env`, `aqua-body:resolution:follow_up.chat-s430-430.n1`, forge AquaSynth pull 1 | +| Ambient soundscape for the client | Aqua | idea only; no design recorded | operator `#swarm` 10-31 08:32 ("Motifs for everything"), `eureka-body:ruling:atlas-records-collaborative-work` | +| Background math, in CultMath: the public `fbm` gap, Phacelle erosion's Lipschitz bound, any new GLSL mirrors | Libby (steward of CultLib, which owns CultMath; holds `cultmath-tapes`) | sources located; nothing built | `#swarm` 10-31 06:34 and 06:46 Libby; `libby-body:follow_up:cultmath-ts-noise-absent`; `#swarm` 04:22 Libby accepts `cultmath-tapes` | +| Background scene and render: the raymarched terrain, the tree-and-squirrel intro scene, lighting and atmosphere, march and TAA tuning on the site-ground framework | Nibu (steward of Aetheria, whose volumetric nebula and cloud shaders she owns: `Raymarching/CloudShader.shader`, `Volumetric.cginc`) | proposed by the operator; not yet taken up by Nibu | operator to Self, 10-31: "If we want to spread the work around, Nibu owns the nebulae so she has shader expertise." | +| Animated bubbles, flourishes, corner avatar and XP bar | Void, with Kiko's offer on juice | advice only | `#swarm` 10-31 03:59 Kiko and Void, 06:45 Void | +| Attention bubbles | Eureka (Huginn heartbeat publishes), Void (render), Heimdall (scope rules) | advice only | `#swarm` 10-31 04:00 Eureka, 04:02 Heimdall | + +**Agreements** + +- Dings are pre-rendered clips shipped as static assets, no synth in the + browser: `dings//.ogg` plus a small manifest; an + unknown sender falls back to a neutral ding; ogg or mp3, not wav (Void + `#minds` 03:19, Aqua 07:41). The set is motif times instrument, and the + instrument says who spoke (operator 03:18, Eureka 03:19). Events that sound: + a mention, a DM, an invite, a message in an unmuted room; mentions and DMs + loud, ordinary messages quiet; everything else silent (Void `#minds` 03:19). + The operator wants far more than four motifs (08:32). The instrument per + Mind belongs in a typed document on the Mind side, not hardcoded (Eureka + 03:19, advice). Aqua's ear check comes before anything is called done, and + the operator's ear is ground truth (Aqua 07:41). Browsers block audio until + the first click, so the first ding after a fresh load may be silent. +- The background has its own canvas and budget, separate from the bubble + flourishes; capped at 30 fps, paused when hidden; under reduced motion one + static frame; an adaptive step-down (Aqua 04:17, Void 04:18 and 04:22). +- The background is an infinite raymarched terrain: CultMath noise plus the + Phacelle erosion filter, in the shader (operator 04:18 "painter's algorithm", + 05:24 "all we need is noise", 05:25 erosion, restated by the operator to + Self on 10-31: "use CultMath noise and erosion filter running in the shader + to have an infinite terrain. We can just raymarch that instead"). Voronoi + and Apollonian are dropped (05:24). +- The web math path is CultLib's own: HLSL source lowered by dxc and + SPIRV-Cross to WebGL2 GLSL ES (`packages/cultmath/shaders/CultMath.glsl`, + `.Phacelle.glsl`; `tools/glsl/build-glsl.sh`), and C# CPU math through + .NET wasm, with parity presumed for now (operator 06:23, 06:48; Libby + 06:34). No TS or WGSL port. The gamecult.org site ground already + concatenates `CultMath.glsl` in `ground.js` and runs an interval-culled, + TAA-reprojected per-pixel march (CultLib `docs/cultmath-interval-ground-cut.md`, + spec `cultmath-interval-ground-cut-site-ground.r3`): that framework is the + base, not new tape work. +- Gaps go to the owner: a plain public `fbm` (only `fbm_grad`, `span_fbm`, + `af_fbm` exist) is added in CultMath, not in the client + (`libby-body:follow_up:cultmath-ts-noise-absent`; Libby 07:57 "tell me what + you need and I'll fill it at the library"). +- Mood and flourish ride as metadata on the event, never guessed from text; + bounded per-frame budget; reduced-motion fallback (Void 03:59, Kiko 03:59). +- XP is two numbers, never one: a cosmetic, labelled game bar fed by things + worth doing, and contribution credit shown only once a real measure exists + (Void 06:45, with Nibu and Kiko). + +**Open items** + +- Libby: the raymarch step bound needs the field's Lipschitz bound. CultMath + tracks `SNOISE_LIPSCHITZ` for noise; erosion's bound is unknown and must be + probed before the march is trusted. +- Libby: public `fbm` in CultMath, with its GLSL mirror. +- Nibu (once she takes it): the terrain scene, lighting and atmosphere, and + march and TAA tuning on the site ground's framework at a phone budget; + waits on Libby's `fbm` and the erosion bound. Then the intro scene in the + same world. +- Aqua: render the first earcon set once Faust builds in her sandbox; then the + instrument-per-Mind list and the manifest; then the soundscape design. +- Void: the navigator (next cut); the Dings hook at + `ClientNonUIFeatures.tsx` 131-258; the background canvas after Libby's shader. +- Eureka: compression and caching for the shell on the chat host + (`ratatoskr:follow_up:chat-shell-compression`); the stewardship projection's + room ids; a Web Push gateway if the operator picks it. + +## Cut sequence + +`estate-navigator` is specced (`ratatoskr:cut_spec:cut-estate-navigator.r1`). +Each later cut is one line, with what it waits on. + +- `resume-fast`: precache the app shell in the service worker, nudge sync on + `visibilitychange`, and measure cold and warm resume on the operator's phone + before and after; waits on nothing; runs beside `estate-navigator` in its + own worktree, and pairs with the ops follow-up on compression. +- `pwa-web-push`: service worker `push` and `notificationclick` handlers, a + `webpush` pusher registered with VAPID keys, and a self-hosted Sygnal on the + chat host; waits on `ratatoskr:question:pwa-web-push`. +- `sliding-sync`: move the client to simplified sliding sync (MSC4186), which + the server already offers; waits on `resume-fast`'s measurements showing + catch-up sync dominates. +- `dings`: per-Mind earcons through the manifest at the existing notification + hook, and the same motifs in push notifications; waits on Aqua's renders. +- `emote-slot`: the pose-id emote on every bubble and the picker; waits on + `estate-navigator` (shared header work). +- `participant-tiers`: participants tiered by estate in the header menu, Minds + indexed by stewardship; waits on `estate-navigator`. +- `language`: our own strings and translations, the `org.gamecult.language` + picker, and the walk-through login for `patron-pt1`; waits on + `estate-navigator`. +- `terrain-background`: the raymarched CultMath noise and Phacelle terrain on + the site ground's march and TAA framework, in its own canvas and budget; + Nibu on the scene and render, Libby's CultMath underneath, Void + integrating; waits on Libby's `fbm` and the erosion Lipschitz probe (both in + CultLib), then a phone measurement. +- `yggdrasil-intro`: an animated tree-and-squirrel intro set in the same + terrain world, Nibu on the scene, Libby on any CultMath it needs, Void + integrating, Aqua's soundscape under it; it may play during + the cold start or resume sync but never holds the client up; waits on + `terrain-background` (operator sketch, not a ruling). +- `bubbles-and-juice`: animated bubbles and mood flourishes from event + metadata, the corner avatar and the cosmetic XP bar; waits on `emote-slot`. +- `attention-bubbles`: per-person attention bubbles from Huginn's typed + 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. From 841784ea64b018c25d78d469a9c450613e418013 Mon Sep 17 00:00:00 2001 From: Eureka Date: Sat, 31 Oct 2026 16:50:51 +0100 Subject: [PATCH 04/64] docs: Ratatoskr map: build baseline, two-pass scene render Co-Authored-By: Claude Opus 5.5 --- docs/gamecult/ratatoskr-target.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md index 45447b23..3f561575 100644 --- a/docs/gamecult/ratatoskr-target.md +++ b/docs/gamecult/ratatoskr-target.md @@ -133,6 +133,16 @@ Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`. counts and so on; browsers need a visible notification for each push (`events_only` avoids event-less pushes). +10. **Baseline at `dd88424e`** (Yggdrasil, `ygg-verify.sh`, image + `node:22-bookworm`, `npm ci --ignore-scripts`): `vitest run` passes (1 + file, 5 tests, Void's steward grouping); `vite build` succeeds, `dist/` 61 + MB with source maps, main chunk 3,739,716 bytes, Rust crypto wasm + 5,573,870 bytes, `dist/sw.mjs` 1.66 kB. `tsc --noEmit` fails at base with + 793 errors in 246 files (455 of them TS2614, named imports from + `matrix-js-sdk` 41.7.0 not resolved), so typecheck is not a usable gate as + is; a cut gates on zero errors in the files it adds and no rise in the + total. + Not probed: phone frame times, the real resume timeline on the operator's Android Chrome install (needs her device or remote DevTools), the Rust crypto wasm size, and the router base under `/preview/` (its `config.json` sets @@ -225,6 +235,15 @@ as of 2026-10-31 ~15:00. Self on 10-31: "use CultMath noise and erosion filter running in the shader to have an infinite terrain. We can just raymarch that instead"). Voronoi and Apollonian are dropped (05:24). +- The scene renders in two passes, not one loop (operator to Self, 10-31: + "The tape optimized solid surface march should run really fast, i imagine, + way faster than the volumetric version, so they should not be one loop"). + Pass 1, the solid-surface march (terrain, tree, squirrel), sphere-traces + with the field's Lipschitz bound and interval tile culling, at its own + resolution, and writes depth. Pass 2, the volumetric atmosphere march on + the site-ground framework (reduced resolution, TAA), is bounded by pass 1's + depth, so it marches only up to the surface hit. Each pass has its own + budget and its own measurement. Nibu owns both passes. - The web math path is CultLib's own: HLSL source lowered by dxc and SPIRV-Cross to WebGL2 GLSL ES (`packages/cultmath/shaders/CultMath.glsl`, `.Phacelle.glsl`; `tools/glsl/build-glsl.sh`), and C# CPU math through @@ -287,8 +306,10 @@ Each later cut is one line, with what it waits on. picker, and the walk-through login for `patron-pt1`; waits on `estate-navigator`. - `terrain-background`: the raymarched CultMath noise and Phacelle terrain on - the site ground's march and TAA framework, in its own canvas and budget; - Nibu on the scene and render, Libby's CultMath underneath, Void + the site ground's march and TAA framework, in its own canvas and budget, as + two passes (solid-surface sphere trace writing depth, then the volumetric + atmosphere bounded by it), each measured on a phone; Nibu on the scene and + both passes, Libby's CultMath underneath, Void integrating; waits on Libby's `fbm` and the erosion Lipschitz probe (both in CultLib), then a phone measurement. - `yggdrasil-intro`: an animated tree-and-squirrel intro set in the same From 2924a3f4f3b0a83ed3b0eb651cf8881d7099d11e Mon Sep 17 00:00:00 2001 From: Eureka Date: Sat, 31 Oct 2026 16:54:09 +0100 Subject: [PATCH 05/64] docs: Ratatoskr map: Yggdrasil scene, orbiting Personas, shared activity vocabulary Co-Authored-By: Claude Opus 5.5 --- docs/gamecult/ratatoskr-target.md | 33 ++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md index 3f561575..166c5c72 100644 --- a/docs/gamecult/ratatoskr-target.md +++ b/docs/gamecult/ratatoskr-target.md @@ -235,6 +235,23 @@ as of 2026-10-31 ~15:00. Self on 10-31: "use CultMath noise and erosion filter running in the shader to have an infinite terrain. We can just raymarch that instead"). Voronoi and Apollonian are dropped (05:24). +- Yggdrasil is the scene's centre (operator to Self, 10-31: "Yggdrasil should + be the star of the show now, so really the camera should just pull back a + bit when you login and the personas should be orbiting the tree, dancing + around each other in accordance with their activity"). On login the camera + pulls back from the tree; there is no separate intro to cut away from. The + Personas orbit the tree and move around each other by their live activity. +- One activity vocabulary drives both sound and motion: Aqua's activity-kinds + grammar (`aqua-body:follow_up:chat-s431-432`, "enumerate the kinds of agent + activity in typed state") is the shared contract between Aqua's motifs and + Nibu's orbits, not two vocabularies. The live feed behind it is the same + typed state the swarm dashboard projects + (`eureka-body:ruling:swarm-dashboard-first-thing2-surface`, Libby; + `libby-body:follow_up:chat-s354-359`): Run documents, the heartbeat trace. + What reaches the client is a member-safe projection: kind, Mind, time and + intensity, never briefs or mind contents. Matrix presence and typing cover + humans and are too coarse for Minds. Where the feed comes from is + `ratatoskr:question:activity-feed-source`. - The scene renders in two passes, not one loop (operator to Self, 10-31: "The tape optimized solid surface march should run really fast, i imagine, way faster than the volumetric version, so they should not be one loop"). @@ -275,6 +292,8 @@ as of 2026-10-31 ~15:00. same world. - Aqua: render the first earcon set once Faust builds in her sandbox; then the instrument-per-Mind list and the manifest; then the soundscape design. +- Aqua and Nibu: agree the activity vocabulary once (Aqua's grammar), so + motifs and orbits read the same kinds. - Void: the navigator (next cut); the Dings hook at `ClientNonUIFeatures.tsx` 131-258; the background canvas after Libby's shader. - Eureka: compression and caching for the shell on the chat host @@ -312,11 +331,15 @@ Each later cut is one line, with what it waits on. both passes, Libby's CultMath underneath, Void integrating; waits on Libby's `fbm` and the erosion Lipschitz probe (both in CultLib), then a phone measurement. -- `yggdrasil-intro`: an animated tree-and-squirrel intro set in the same - terrain world, Nibu on the scene, Libby on any CultMath it needs, Void - integrating, Aqua's soundscape under it; it may play during - the cold start or resume sync but never holds the client up; waits on - `terrain-background` (operator sketch, not a ruling). +- `yggdrasil-scene`: the tree and squirrel at the centre of the terrain world; + on login the camera pulls back from the tree, and it may play during the + cold start or resume sync but never holds the client up; Nibu on the scene, + Libby on any CultMath it needs, Void integrating, Aqua's soundscape under + it; waits on `terrain-background`. +- `orbiting-personas`: Personas orbit the tree and dance around each other by + live activity, from the shared activity vocabulary; waits on + `yggdrasil-scene`, Aqua's activity-kinds grammar, and + `ratatoskr:question:activity-feed-source`. - `bubbles-and-juice`: animated bubbles and mood flourishes from event metadata, the corner avatar and the cosmetic XP bar; waits on `emote-slot`. - `attention-bubbles`: per-person attention bubbles from Huginn's typed From f2e3223c5fb1fbf638e6229f6eca10ab2e4cf272 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:06:12 +0100 Subject: [PATCH 06/64] Delete the rail, its tabs, useSidebarItems, steward-groups and the Home, Direct and Space list panes The navigator that replaces them lands next. SpaceTombstone moves to its own file; the Lobby pin-to-sidebar state and the m.cinny.spaces writer go with the rail; ClientLayout loses its nav slot. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/lobby/HierarchyItemMenu.tsx | 18 - src/app/features/lobby/Lobby.tsx | 34 - src/app/features/lobby/SpaceHierarchy.tsx | 6 - .../steward-groups/stewardGroups.test.ts | 63 -- .../features/steward-groups/stewardGroups.ts | 91 -- .../steward-groups/useStewardGroups.ts | 71 -- src/app/hooks/useSidebarItems.ts | 138 --- src/app/pages/MobileFriendly.tsx | 22 - src/app/pages/client/ClientLayout.tsx | 13 +- src/app/pages/client/SidebarNav.tsx | 56 -- src/app/pages/client/direct/Direct.tsx | 280 ------ src/app/pages/client/direct/index.ts | 1 - src/app/pages/client/home/Home.tsx | 362 -------- src/app/pages/client/home/index.ts | 1 - src/app/pages/client/sidebar/CreateTab.tsx | 134 --- src/app/pages/client/sidebar/DirectTab.tsx | 138 --- src/app/pages/client/sidebar/ExploreTab.tsx | 64 -- src/app/pages/client/sidebar/HomeTab.tsx | 140 --- src/app/pages/client/sidebar/SearchTab.tsx | 23 - src/app/pages/client/sidebar/SpaceTabs.tsx | 866 ------------------ src/app/pages/client/sidebar/index.ts | 5 - src/app/pages/client/space/Space.tsx | 571 ------------ src/app/pages/client/space/SpaceTombstone.tsx | 79 ++ src/app/pages/client/space/index.ts | 2 +- src/types/matrix/accountData.ts | 1 - src/types/matrix/room.ts | 1 + 26 files changed, 83 insertions(+), 3097 deletions(-) delete mode 100644 src/app/features/steward-groups/stewardGroups.test.ts delete mode 100644 src/app/features/steward-groups/stewardGroups.ts delete mode 100644 src/app/features/steward-groups/useStewardGroups.ts delete mode 100644 src/app/hooks/useSidebarItems.ts delete mode 100644 src/app/pages/client/SidebarNav.tsx delete mode 100644 src/app/pages/client/direct/Direct.tsx delete mode 100644 src/app/pages/client/home/Home.tsx delete mode 100644 src/app/pages/client/sidebar/CreateTab.tsx delete mode 100644 src/app/pages/client/sidebar/DirectTab.tsx delete mode 100644 src/app/pages/client/sidebar/ExploreTab.tsx delete mode 100644 src/app/pages/client/sidebar/HomeTab.tsx delete mode 100644 src/app/pages/client/sidebar/SearchTab.tsx delete mode 100644 src/app/pages/client/sidebar/SpaceTabs.tsx delete mode 100644 src/app/pages/client/space/Space.tsx create mode 100644 src/app/pages/client/space/SpaceTombstone.tsx diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index c6aff741..1802e3b0 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -200,16 +200,12 @@ type HierarchyItemMenuProps = { joined: boolean; powerLevels?: IPowerLevels; canEditChild: boolean; - pinned?: boolean; - onTogglePin?: (roomId: string) => void; }; export function HierarchyItemMenu({ item, joined, powerLevels, canEditChild, - pinned, - onTogglePin, }: HierarchyItemMenuProps) { const mx = useMatrixClient(); const [menuAnchor, setMenuAnchor] = useState(); @@ -264,20 +260,6 @@ export function HierarchyItemMenu({ {joined && ( - {onTogglePin && ( - { - onTogglePin(item.roomId); - handleRequestClose(); - }} - > - - {pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'} - - - )} { - const sideSpaces = sidebarItems.flatMap((item) => { - if (typeof item === 'string') return item; - return item.content; - }); - - return new Set(sideSpaces); - }, [sidebarItems]); - const [spacesItems, setSpacesItem] = useState>(() => new Map()); useElementSizeObserver( @@ -415,18 +395,6 @@ export function Lobby() { navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); }; - const togglePinToSidebar = useCallback( - (rId: string) => { - const newItems = sidebarItemWithout(sidebarItems, rId); - if (!sidebarSpaces.has(rId)) { - newItems.push(rId); - } - const newSpacesContent = makeCinnySpacesContent(mx, newItems); - mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any); - }, - [mx, sidebarItems, sidebarSpaces] - ); - return ( @@ -499,8 +467,6 @@ export function Lobby() { disabledReorder={reordering} nextSpaceId={nextSpaceId} getRoom={getRoom} - pinned={sidebarSpaces.has(item.space.roomId)} - togglePinToSidebar={togglePinToSidebar} onSpacesFound={handleSpacesFound} onOpenRoom={handleOpenRoom} /> diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx index 280b8a5a..9a283378 100644 --- a/src/app/features/lobby/SpaceHierarchy.tsx +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -35,8 +35,6 @@ type SpaceHierarchyProps = { disabledReorder?: boolean; nextSpaceId?: string; getRoom: (roomId: string) => Room | undefined; - pinned: boolean; - togglePinToSidebar: (roomId: string) => void; onSpacesFound: (spaceItems: IHierarchyRoom[]) => void; onOpenRoom: MouseEventHandler; }; @@ -58,8 +56,6 @@ export const SpaceHierarchy = forwardRef( disabledReorder, nextSpaceId, getRoom, - pinned, - togglePinToSidebar, onOpenRoom, onSpacesFound, }, @@ -136,8 +132,6 @@ export const SpaceHierarchy = forwardRef( canEditChild={ !!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) } - pinned={pinned} - onTogglePin={togglePinToSidebar} /> ) } diff --git a/src/app/features/steward-groups/stewardGroups.test.ts b/src/app/features/steward-groups/stewardGroups.test.ts deleted file mode 100644 index d0d4b7c3..00000000 --- a/src/app/features/steward-groups/stewardGroups.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { groupByStewards, parseSteward, StewardEntry } from './stewardGroups'; - -const room = (roomId: string, parentId = '!projects') => ({ roomId, parentId }); -const space = (roomId: string) => ({ roomId, space: true as const }); - -const base = { - spaceId: '!top', - isGroupOpen: () => true, - keepVisible: () => false, -}; - -const names = (out: StewardEntry<{ roomId: string }>[]) => - out.map((e) => (e.type === 'group' ? `G:${e.label}:${e.count}` : e.item.roomId)); - -describe('groupByStewards', () => { - const stewards: Record = { '!a': 'Void', '!b': 'Eureka', '!c': 'Void' }; - const stewardOf = (id: string) => stewards[id]; - - it('groups by steward, sorted by name, with unstewarded rooms first', () => { - const out = groupByStewards([room('!a'), room('!x'), room('!b'), room('!c')], { - ...base, - stewardOf, - }); - expect(names(out)).toEqual(['!x', 'G:Eureka:1', '!b', 'G:Void:2', '!a', '!c']); - }); - - it('collapsed groups hide rooms except kept-visible ones', () => { - const out = groupByStewards([room('!a'), room('!b'), room('!c')], { - ...base, - stewardOf, - isGroupOpen: () => false, - keepVisible: (id) => id === '!c', - }); - expect(names(out)).toEqual(['G:Eureka:1', 'G:Void:2', '!c']); - }); - - it('degrades to the original order when no room has steward state', () => { - const out = groupByStewards([room('!1'), space('!s'), room('!2', '!s')], { - ...base, - stewardOf: () => undefined, - }); - expect(names(out)).toEqual(['!1', '!s', '!2']); - }); - - it('does not merge runs separated by a space header', () => { - const out = groupByStewards([room('!a'), space('!s'), room('!c', '!s')], { - ...base, - stewardOf, - }); - expect(out.filter((e) => e.type === 'group')).toHaveLength(2); - }); -}); - -describe('parseSteward', () => { - it('accepts a string and rejects junk', () => { - expect(parseSteward({ steward: ' Void ' })).toBe('Void'); - expect(parseSteward({ steward: 3 })).toBeUndefined(); - expect(parseSteward({ steward: ' ' })).toBeUndefined(); - expect(parseSteward(undefined)).toBeUndefined(); - expect(parseSteward(null)).toBeUndefined(); - }); -}); diff --git a/src/app/features/steward-groups/stewardGroups.ts b/src/app/features/steward-groups/stewardGroups.ts deleted file mode 100644 index c95dd935..00000000 --- a/src/app/features/steward-groups/stewardGroups.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Splits runs of rooms that carry an `org.gamecult.steward` state event into - * collapsible per-steward groups. Pure: the caller supplies the lookups, so the - * grouping is testable without a Matrix client. - */ -export const STEWARD_EVENT_TYPE = 'org.gamecult.steward'; - -export type HierarchyLike = { roomId: string; parentId?: string; space?: true }; - -export type StewardEntry = - | { type: 'item'; item: T } - | { type: 'group'; id: string; label: string; open: boolean; count: number }; - -export const makeStewardGroupId = (spaceId: string, parentId: string, steward: string) => - `${spaceId}|${parentId}|steward:${steward}`; - -export type StewardGroupOptions = { - spaceId: string; - /** steward name of a room, or undefined when the room has no (valid) steward state */ - stewardOf: (roomId: string) => string | undefined; - isGroupOpen: (groupId: string) => boolean; - /** rooms that stay visible inside a collapsed group (selected, unread, in a call) */ - keepVisible: (roomId: string) => boolean; -}; - -const byLabel = (a: string, b: string) => - a.localeCompare(b, undefined, { sensitivity: 'base' }) || a.localeCompare(b); - -export const groupByStewards = ( - items: T[], - { spaceId, stewardOf, isGroupOpen, keepVisible }: StewardGroupOptions -): StewardEntry[] => { - const out: StewardEntry[] = []; - - const flush = (run: T[]) => { - if (run.length === 0) return; - const groups = new Map(); - const loose: T[] = []; - run.forEach((item) => { - const steward = stewardOf(item.roomId); - if (steward === undefined) { - loose.push(item); - return; - } - const list = groups.get(steward); - if (list) list.push(item); - else groups.set(steward, [item]); - }); - - // No steward state anywhere in this run: leave the list exactly as Cinny built it. - if (groups.size === 0) { - run.forEach((item) => out.push({ type: 'item', item })); - return; - } - - loose.forEach((item) => out.push({ type: 'item', item })); - Array.from(groups.keys()) - .sort(byLabel) - .forEach((steward) => { - const rooms = groups.get(steward) ?? []; - const id = makeStewardGroupId(spaceId, rooms[0].parentId ?? spaceId, steward); - const open = isGroupOpen(id); - out.push({ type: 'group', id, label: steward, open, count: rooms.length }); - rooms - .filter((item) => open || keepVisible(item.roomId)) - .forEach((item) => out.push({ type: 'item', item })); - }); - }; - - let run: T[] = []; - items.forEach((item) => { - if (item.space) { - flush(run); - run = []; - out.push({ type: 'item', item }); - return; - } - run.push(item); - }); - flush(run); - return out; -}; - -/** Reads `steward` from raw state event content; anything unusable is "no steward". */ -export const parseSteward = (content: unknown): string | undefined => { - if (typeof content !== 'object' || content === null) return undefined; - const { steward } = content as { steward?: unknown }; - if (typeof steward !== 'string') return undefined; - const trimmed = steward.trim(); - return trimmed === '' ? undefined : trimmed; -}; diff --git a/src/app/features/steward-groups/useStewardGroups.ts b/src/app/features/steward-groups/useStewardGroups.ts deleted file mode 100644 index ae1d2a3d..00000000 --- a/src/app/features/steward-groups/useStewardGroups.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { MatrixClient, MatrixEvent, RoomStateEvent } from 'matrix-js-sdk'; -import { atomWithStorage } from 'jotai/utils'; -import { useAtom } from 'jotai'; -import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; -import { - groupByStewards, - HierarchyLike, - parseSteward, - StewardEntry, - STEWARD_EVENT_TYPE, -} from './stewardGroups'; - -// Explicit open/closed choices by group id. Absent = default: closed on phones, open elsewhere. -const stewardGroupChoicesAtom = atomWithStorage>( - 'gamecultStewardGroupOpen', - {} -); - -type Options = { - mx: MatrixClient; - spaceId: string; - items: T[]; - keepVisible: (roomId: string) => boolean; -}; - -export const useStewardGroups = ({ - mx, - spaceId, - items, - keepVisible, -}: Options) => { - const mobile = useScreenSizeContext() === ScreenSize.Mobile; - const [choices, setChoices] = useAtom(stewardGroupChoicesAtom); - const [stateVersion, setStateVersion] = useState(0); - - useEffect(() => { - const onState = (mEvent: MatrixEvent) => { - if (mEvent.getType() === STEWARD_EVENT_TYPE) setStateVersion((v) => v + 1); - }; - mx.on(RoomStateEvent.Events, onState); - return () => { - mx.removeListener(RoomStateEvent.Events, onState); - }; - }, [mx]); - - const stewardOf = useCallback( - (roomId: string) => - // stateVersion is read so a late-arriving steward event re-derives this lookup - stateVersion >= 0 - ? parseSteward( - mx.getRoom(roomId)?.currentState.getStateEvents(STEWARD_EVENT_TYPE, '')?.getContent() - ) - : undefined, - [mx, stateVersion] - ); - - const isGroupOpen = useCallback((id: string) => choices[id] ?? !mobile, [choices, mobile]); - - const entries: StewardEntry[] = useMemo( - () => groupByStewards(items, { spaceId, stewardOf, isGroupOpen, keepVisible }), - [items, spaceId, stewardOf, isGroupOpen, keepVisible] - ); - - const toggleGroup = useCallback( - (id: string) => setChoices((prev) => ({ ...prev, [id]: !(prev[id] ?? !mobile) })), - [setChoices, mobile] - ); - - return { entries, toggleGroup }; -}; diff --git a/src/app/hooks/useSidebarItems.ts b/src/app/hooks/useSidebarItems.ts deleted file mode 100644 index 16aba225..00000000 --- a/src/app/hooks/useSidebarItems.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'; -import { MatrixClient } from 'matrix-js-sdk'; -import { AccountDataEvent } from '../../types/matrix/accountData'; -import { useMatrixClient } from './useMatrixClient'; -import { getAccountData, isSpace } from '../utils/room'; -import { Membership } from '../../types/matrix/room'; -import { useAccountDataCallback } from './useAccountDataCallback'; - -export type ISidebarFolder = { - name?: string; - id: string; - content: string[]; -}; -export type TSidebarItem = string | ISidebarFolder; -export type SidebarItems = Array; - -export type InCinnySpacesContent = { - shortcut?: string[]; - sidebar?: SidebarItems; -}; - -export const parseSidebar = ( - mx: MatrixClient, - orphanSpaces: string[], - content?: InCinnySpacesContent -) => { - const sidebar = content?.sidebar ?? content?.shortcut ?? []; - const orphans = new Set(orphanSpaces); - - const items: SidebarItems = []; - - const safeToAdd = (spaceId: string): boolean => { - if (typeof spaceId !== 'string') return false; - const space = mx.getRoom(spaceId); - if (space?.getMyMembership() !== Membership.Join) return false; - return isSpace(space); - }; - - sidebar.forEach((item) => { - if (typeof item === 'string') { - if (safeToAdd(item) && !items.includes(item)) { - orphans.delete(item); - items.push(item); - } - return; - } - if ( - typeof item === 'object' && - typeof item.id === 'string' && - Array.isArray(item.content) && - !items.find((i) => (typeof i === 'string' ? false : i.id === item.id)) - ) { - const safeContent = item.content.filter(safeToAdd); - safeContent.forEach((i) => orphans.delete(i)); - items.push({ - ...item, - content: Array.from(new Set(safeContent)), - }); - } - }); - - orphans.forEach((spaceId) => items.push(spaceId)); - return items; -}; - -export const useSidebarItems = ( - orphanSpaces: string[] -): [SidebarItems, Dispatch>] => { - const mx = useMatrixClient(); - - const [sidebarItems, setSidebarItems] = useState(() => { - const inCinnySpacesContent = getAccountData( - mx, - AccountDataEvent.CinnySpaces - )?.getContent(); - return parseSidebar(mx, orphanSpaces, inCinnySpacesContent); - }); - - useEffect(() => { - const inCinnySpacesContent = getAccountData( - mx, - AccountDataEvent.CinnySpaces - )?.getContent(); - setSidebarItems(parseSidebar(mx, orphanSpaces, inCinnySpacesContent)); - }, [mx, orphanSpaces]); - - useAccountDataCallback( - mx, - useCallback( - (mEvent) => { - if (mEvent.getType() === AccountDataEvent.CinnySpaces) { - const newContent = mEvent.getContent(); - setSidebarItems(parseSidebar(mx, orphanSpaces, newContent)); - } - }, - [mx, orphanSpaces] - ) - ); - - return [sidebarItems, setSidebarItems]; -}; - -export const sidebarItemWithout = (items: SidebarItems, roomId: string) => { - const newItems: SidebarItems = items - .map((item) => { - if (typeof item === 'string') { - if (item === roomId) return null; - return item; - } - if (item.content.includes(roomId)) { - const newContent = item.content.filter((id) => id !== roomId); - if (newContent.length === 0) return null; - return { - ...item, - content: newContent, - }; - } - return item; - }) - .filter((item) => item !== null) as SidebarItems; - - return newItems; -}; - -export const makeCinnySpacesContent = ( - mx: MatrixClient, - items: SidebarItems -): InCinnySpacesContent => { - const currentInSpaces = - getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent() ?? {}; - - const newSpacesContent: InCinnySpacesContent = { - ...currentInSpaces, - sidebar: items, - }; - - return newSpacesContent; -}; diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index ca947ac6..225b1b80 100644 --- a/src/app/pages/MobileFriendly.tsx +++ b/src/app/pages/MobileFriendly.tsx @@ -1,28 +1,6 @@ import { ReactNode } from 'react'; import { useMatch } from 'react-router-dom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; -import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths'; - -type MobileFriendlyClientNavProps = { - children: ReactNode; -}; -export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) { - const screenSize = useScreenSizeContext(); - const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true }); - const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true }); - const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true }); - const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true }); - const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true }); - - if ( - screenSize === ScreenSize.Mobile && - !(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch) - ) { - return null; - } - - return children; -} type MobileFriendlyPageNavProps = { path: string; diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx index 4a077ba6..e1d83c95 100644 --- a/src/app/pages/client/ClientLayout.tsx +++ b/src/app/pages/client/ClientLayout.tsx @@ -1,15 +1,6 @@ import React, { ReactNode } from 'react'; import { Box } from 'folds'; -type ClientLayoutProps = { - nav: ReactNode; - children: ReactNode; -}; -export function ClientLayout({ nav, children }: ClientLayoutProps) { - return ( - - {nav} - {children} - - ); +export function ClientLayout({ children }: { children: ReactNode }) { + return {children}; } diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx deleted file mode 100644 index a2e1b682..00000000 --- a/src/app/pages/client/SidebarNav.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useRef } from 'react'; -import { Scroll } from 'folds'; - -import { - Sidebar, - SidebarContent, - SidebarStackSeparator, - SidebarStack, -} from '../../components/sidebar'; -import { - DirectTab, - HomeTab, - SpaceTabs, - InboxTab, - ExploreTab, - SettingsTab, - UnverifiedTab, - SearchTab, -} from './sidebar'; -import { CreateTab } from './sidebar/CreateTab'; - -export function SidebarNav() { - const scrollRef = useRef(null); - - return ( - - - - - - - - - - - - - - } - sticky={ - <> - - - - - - - - - } - /> - - ); -} diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx deleted file mode 100644 index 04edf109..00000000 --- a/src/app/pages/client/direct/Direct.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; -import { useAtom, useAtomValue } from 'jotai'; -import { - Avatar, - Box, - Button, - Icon, - IconButton, - Icons, - Menu, - MenuItem, - PopOut, - RectCords, - Text, - config, - toRem, -} from 'folds'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import FocusTrap from 'focus-trap-react'; -import { useNavigate } from 'react-router-dom'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { factoryRoomIdByActivity } from '../../../utils/sort'; -import { - NavButton, - NavCategory, - NavCategoryHeader, - NavEmptyCenter, - NavEmptyLayout, - NavItem, - NavItemContent, -} from '../../../components/nav'; -import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils'; -import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; -import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { VirtualTile } from '../../../components/virtualizer'; -import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -import { makeNavCategoryId } from '../../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; -import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; -import { useDirectRooms } from './useDirectRooms'; -import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; -import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { markAsRead } from '../../../utils/notifications'; -import { stopPropagation } from '../../../utils/keyboard'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../../hooks/useRoomsNotificationPreferences'; -import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected'; - -type DirectMenuProps = { - requestClose: () => void; -}; -const DirectMenu = forwardRef(({ requestClose }, ref) => { - const mx = useMatrixClient(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const orphanRooms = useDirectRooms(); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -function DirectHeader() { - const [menuAnchor, setMenuAnchor] = useState(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - <> - - - - - Direct Messages - - - - - - - - - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - - ); -} - -function DirectEmpty() { - const navigate = useNavigate(); - - return ( - - } - title={ - - No Direct Messages - - } - content={ - - You do not have any direct messages yet. - - } - options={ - - } - /> - - ); -} - -const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct'); -export function Direct() { - const mx = useMatrixClient(); - useNavToActivePathMapper('direct'); - const scrollRef = useRef(null); - const directs = useDirectRooms(); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const navigate = useNavigate(); - - const createDirectSelected = useDirectCreateSelected(); - - const selectedRoomId = useSelectedRoom(); - const noRoomToDisplay = directs.length === 0; - const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); - - const sortedDirects = useMemo(() => { - const items = Array.from(directs).sort(factoryRoomIdByActivity(mx)); - if (closedCategories.has(DEFAULT_CATEGORY_ID)) { - return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); - } - return items; - }, [mx, directs, closedCategories, roomToUnread, selectedRoomId]); - - const virtualizer = useVirtualizer({ - count: sortedDirects.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 38, - overscan: 10, - }); - - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); - - return ( - - - {noRoomToDisplay ? ( - - ) : ( - - - - - navigate(getDirectCreatePath())}> - - - - - - - - Create Chat - - - - - - - - - - - Chats - - -
- {virtualizer.getVirtualItems().map((vItem) => { - const roomId = sortedDirects[vItem.index]; - const room = mx.getRoom(roomId); - if (!room) return null; - const selected = selectedRoomId === roomId; - - return ( - - - - ); - })} -
-
-
-
- )} -
- ); -} diff --git a/src/app/pages/client/direct/index.ts b/src/app/pages/client/direct/index.ts index d247bbc0..aaa8c929 100644 --- a/src/app/pages/client/direct/index.ts +++ b/src/app/pages/client/direct/index.ts @@ -1,3 +1,2 @@ -export * from './Direct'; export * from './RoomProvider'; export * from './DirectCreate'; diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx deleted file mode 100644 index d87db963..00000000 --- a/src/app/pages/client/home/Home.tsx +++ /dev/null @@ -1,362 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - Avatar, - Box, - Button, - Icon, - IconButton, - Icons, - Menu, - MenuItem, - PopOut, - RectCords, - Text, - config, - toRem, -} from 'folds'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { useAtom, useAtomValue } from 'jotai'; -import FocusTrap from 'focus-trap-react'; -import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort'; -import { - NavButton, - NavCategory, - NavCategoryHeader, - NavEmptyCenter, - NavEmptyLayout, - NavItem, - NavItemContent, - NavLink, -} from '../../../components/nav'; -import { - encodeSearchParamValueArray, - getExplorePath, - getHomeCreatePath, - getHomeRoomPath, - getHomeSearchPath, - withSearchParam, -} from '../../pathUtils'; -import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; -import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { - useHomeCreateSelected, - useHomeSearchSelected, -} from '../../../hooks/router/useHomeSelected'; -import { useHomeRooms } from './useHomeRooms'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { VirtualTile } from '../../../components/virtualizer'; -import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -import { makeNavCategoryId } from '../../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; -import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; -import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { markAsRead } from '../../../utils/notifications'; -import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; -import { stopPropagation } from '../../../utils/keyboard'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../../hooks/useRoomsNotificationPreferences'; -import { UseStateProvider } from '../../../components/UseStateProvider'; -import { JoinAddressPrompt } from '../../../components/join-address-prompt'; -import { _RoomSearchParams } from '../../paths'; - -type HomeMenuProps = { - requestClose: () => void; -}; -const HomeMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useHomeRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -function HomeHeader() { - const [menuAnchor, setMenuAnchor] = useState(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - <> - - - - - Home - - - - - - - - - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - - ); -} - -function HomeEmpty() { - const navigate = useNavigate(); - - return ( - - } - title={ - - No Rooms - - } - content={ - - You do not have any rooms yet. - - } - options={ - <> - - - - } - /> - - ); -} - -const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room'); -export function Home() { - const mx = useMatrixClient(); - useNavToActivePathMapper('home'); - const scrollRef = useRef(null); - const rooms = useHomeRooms(); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const navigate = useNavigate(); - - const selectedRoomId = useSelectedRoom(); - const createRoomSelected = useHomeCreateSelected(); - const searchSelected = useHomeSearchSelected(); - const noRoomToDisplay = rooms.length === 0; - const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); - - const sortedRooms = useMemo(() => { - const items = Array.from(rooms).sort( - closedCategories.has(DEFAULT_CATEGORY_ID) - ? factoryRoomIdByActivity(mx) - : factoryRoomIdByAtoZ(mx) - ); - if (closedCategories.has(DEFAULT_CATEGORY_ID)) { - return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); - } - return items; - }, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]); - - const virtualizer = useVirtualizer({ - count: sortedRooms.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 38, - overscan: 10, - }); - - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); - - return ( - - - {noRoomToDisplay ? ( - - ) : ( - - - - - navigate(getHomeCreatePath())}> - - - - - - - - Create Room - - - - - - - - {(open, setOpen) => ( - <> - - setOpen(true)}> - - - - - - - - Join with Address - - - - - - - {open && ( - setOpen(false)} - onOpen={(roomIdOrAlias, viaServers, eventId) => { - setOpen(false); - const path = getHomeRoomPath(roomIdOrAlias, eventId); - navigate( - viaServers - ? withSearchParam<_RoomSearchParams>(path, { - viaServers: encodeSearchParamValueArray(viaServers), - }) - : path - ); - }} - /> - )} - - )} - - - - - - - - - - - Message Search - - - - - - - - - - - Rooms - - -
- {virtualizer.getVirtualItems().map((vItem) => { - const roomId = sortedRooms[vItem.index]; - const room = mx.getRoom(roomId); - if (!room) return null; - const selected = selectedRoomId === roomId; - - return ( - - - - ); - })} -
-
-
-
- )} -
- ); -} diff --git a/src/app/pages/client/home/index.ts b/src/app/pages/client/home/index.ts index 80764481..5669f658 100644 --- a/src/app/pages/client/home/index.ts +++ b/src/app/pages/client/home/index.ts @@ -1,3 +1,2 @@ -export * from './Home'; export * from './Search'; export * from './RoomProvider'; diff --git a/src/app/pages/client/sidebar/CreateTab.tsx b/src/app/pages/client/sidebar/CreateTab.tsx deleted file mode 100644 index e6575cb4..00000000 --- a/src/app/pages/client/sidebar/CreateTab.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React, { MouseEventHandler, useState } from 'react'; -import { Box, config, Icon, Icons, Menu, PopOut, RectCords, Text } from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { useNavigate } from 'react-router-dom'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; -import { stopPropagation } from '../../../utils/keyboard'; -import { SequenceCard } from '../../../components/sequence-card'; -import { SettingTile } from '../../../components/setting-tile'; -import { ContainerColor } from '../../../styles/ContainerColor.css'; -import { - encodeSearchParamValueArray, - getCreatePath, - getSpacePath, - withSearchParam, -} from '../../pathUtils'; -import { useCreateSelected } from '../../../hooks/router/useCreateSelected'; -import { JoinAddressPrompt } from '../../../components/join-address-prompt'; -import { _RoomSearchParams } from '../../paths'; - -export function CreateTab() { - const createSelected = useCreateSelected(); - - const navigate = useNavigate(); - const [menuCords, setMenuCords] = useState(); - const [joinAddress, setJoinAddress] = useState(false); - - const handleMenu: MouseEventHandler = (evt) => { - setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect()); - }; - - const handleCreateSpace = () => { - navigate(getCreatePath()); - setMenuCords(undefined); - }; - - const handleJoinWithAddress = () => { - setJoinAddress(true); - setMenuCords(undefined); - }; - - return ( - - - {(triggerRef) => ( - setMenuCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - - - }> - Create Space - - Build a space for your community. - - - - - }> - Join with Address - - Become a part of existing community. - - - - - - - } - > - - - - {joinAddress && ( - setJoinAddress(false)} - onOpen={(roomIdOrAlias, viaServers) => { - setJoinAddress(false); - const path = getSpacePath(roomIdOrAlias); - navigate( - viaServers - ? withSearchParam<_RoomSearchParams>(path, { - viaServers: encodeSearchParamValueArray(viaServers), - }) - : path - ); - }} - /> - )} - - )} - - - ); -} diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx deleted file mode 100644 index dcd44e2f..00000000 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { useAtomValue } from 'jotai'; -import { useDirects } from '../../../state/hooks/roomList'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { mDirectAtom } from '../../../state/mDirectList'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { getDirectPath, joinPathComponent } from '../../pathUtils'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { - SidebarAvatar, - SidebarItem, - SidebarItemBadge, - SidebarItemTooltip, -} from '../../../components/sidebar'; -import { useDirectSelected } from '../../../hooks/router/useDirectSelected'; -import { UnreadBadge } from '../../../components/unread-badge'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; -import { useDirectRooms } from '../direct/useDirectRooms'; -import { markAsRead } from '../../../utils/notifications'; -import { stopPropagation } from '../../../utils/keyboard'; -import { settingsAtom } from '../../../state/settings'; -import { useSetting } from '../../../state/hooks/settings'; - -type DirectMenuProps = { - requestClose: () => void; -}; -const DirectMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useDirectRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -export function DirectTab() { - const navigate = useNavigate(); - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - - const mDirects = useAtomValue(mDirectAtom); - const directs = useDirects(mx, allRoomsAtom, mDirects); - const directUnread = useRoomsUnread(directs, roomToUnreadAtom); - const [menuAnchor, setMenuAnchor] = useState(); - - const directSelected = useDirectSelected(); - - const handleDirectClick = () => { - const activePath = navToActivePath.get('direct'); - if (activePath && screenSize !== ScreenSize.Mobile) { - navigate(joinPathComponent(activePath)); - return; - } - - navigate(getDirectPath()); - }; - - const handleContextMenu: MouseEventHandler = (evt) => { - evt.preventDefault(); - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - return ( - - - {(triggerRef) => ( - - - - )} - - {directUnread && ( - 0}> - 0} count={directUnread.total} /> - - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - )} - - ); -} diff --git a/src/app/pages/client/sidebar/ExploreTab.tsx b/src/app/pages/client/sidebar/ExploreTab.tsx deleted file mode 100644 index a45b5830..00000000 --- a/src/app/pages/client/sidebar/ExploreTab.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import { Icon, Icons } from 'folds'; -import { useNavigate } from 'react-router-dom'; -import { useAtomValue } from 'jotai'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; -import { useExploreSelected } from '../../../hooks/router/useExploreSelected'; -import { - getExploreFeaturedPath, - getExplorePath, - getExploreServerPath, - joinPathComponent, -} from '../../pathUtils'; -import { useClientConfig } from '../../../hooks/useClientConfig'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { getMxIdServer } from '../../../utils/matrix'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; - -export function ExploreTab() { - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const clientConfig = useClientConfig(); - const navigate = useNavigate(); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - - const exploreSelected = useExploreSelected(); - - const handleExploreClick = () => { - if (screenSize === ScreenSize.Mobile) { - navigate(getExplorePath()); - return; - } - - const activePath = navToActivePath.get('explore'); - if (activePath) { - navigate(joinPathComponent(activePath)); - return; - } - - if (clientConfig.featuredCommunities?.openAsDefault) { - navigate(getExploreFeaturedPath()); - return; - } - const userId = mx.getUserId(); - const userServer = userId ? getMxIdServer(userId) : undefined; - if (userServer) { - navigate(getExploreServerPath(userServer)); - return; - } - navigate(getExplorePath()); - }; - - return ( - - - {(triggerRef) => ( - - - - )} - - - ); -} diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx deleted file mode 100644 index 778b729e..00000000 --- a/src/app/pages/client/sidebar/HomeTab.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; -import { useAtomValue } from 'jotai'; -import FocusTrap from 'focus-trap-react'; -import { useOrphanRooms } from '../../../state/hooks/roomList'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { mDirectAtom } from '../../../state/mDirectList'; -import { roomToParentsAtom } from '../../../state/room/roomToParents'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { getHomePath, joinPathComponent } from '../../pathUtils'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { - SidebarAvatar, - SidebarItem, - SidebarItemBadge, - SidebarItemTooltip, -} from '../../../components/sidebar'; -import { useHomeSelected } from '../../../hooks/router/useHomeSelected'; -import { UnreadBadge } from '../../../components/unread-badge'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; -import { useHomeRooms } from '../home/useHomeRooms'; -import { markAsRead } from '../../../utils/notifications'; -import { stopPropagation } from '../../../utils/keyboard'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; - -type HomeMenuProps = { - requestClose: () => void; -}; -const HomeMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useHomeRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -export function HomeTab() { - const navigate = useNavigate(); - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - - const mDirects = useAtomValue(mDirectAtom); - const roomToParents = useAtomValue(roomToParentsAtom); - const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents); - const homeUnread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const homeSelected = useHomeSelected(); - const [menuAnchor, setMenuAnchor] = useState(); - - const handleHomeClick = () => { - const activePath = navToActivePath.get('home'); - if (activePath && screenSize !== ScreenSize.Mobile) { - navigate(joinPathComponent(activePath)); - return; - } - - navigate(getHomePath()); - }; - - const handleContextMenu: MouseEventHandler = (evt) => { - evt.preventDefault(); - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - - - {(triggerRef) => ( - - - - )} - - {homeUnread && ( - 0}> - 0} count={homeUnread.total} /> - - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - )} - - ); -} diff --git a/src/app/pages/client/sidebar/SearchTab.tsx b/src/app/pages/client/sidebar/SearchTab.tsx deleted file mode 100644 index 7ceb5c49..00000000 --- a/src/app/pages/client/sidebar/SearchTab.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { Icon, Icons } from 'folds'; -import { useAtom } from 'jotai'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; -import { searchModalAtom } from '../../../state/searchModal'; - -export function SearchTab() { - const [opened, setOpen] = useAtom(searchModalAtom); - - const open = () => setOpen(true); - - return ( - - - {(triggerRef) => ( - - - - )} - - - ); -} diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx deleted file mode 100644 index 6fc528a1..00000000 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ /dev/null @@ -1,866 +0,0 @@ -import React, { - MouseEventHandler, - ReactNode, - RefObject, - forwardRef, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - Box, - Icon, - IconButton, - Icons, - Line, - Menu, - MenuItem, - PopOut, - RectCords, - Text, - config, - toRem, -} from 'folds'; -import { useAtom, useAtomValue } from 'jotai'; -import { Room } from 'matrix-js-sdk'; -import { - draggable, - dropTargetForElements, - monitorForElements, -} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import { - attachInstruction, - extractInstruction, - Instruction, -} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; -import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; -import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; -import FocusTrap from 'focus-trap-react'; -import { - useOrphanSpaces, - useRecursiveChildScopeFactory, - useSpaceChildren, -} from '../../../state/hooks/roomList'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { roomToParentsAtom } from '../../../state/room/roomToParents'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils'; -import { - SidebarAvatar, - SidebarItem, - SidebarItemBadge, - SidebarItemTooltip, - SidebarStack, - SidebarStackSeparator, - SidebarFolder, - SidebarFolderDropTarget, -} from '../../../components/sidebar'; -import { RoomUnreadProvider, RoomsUnreadProvider } from '../../../components/RoomUnreadProvider'; -import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace'; -import { UnreadBadge } from '../../../components/unread-badge'; -import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; -import { RoomAvatar } from '../../../components/room-avatar'; -import { nameInitials, randomStr } from '../../../utils/common'; -import { - ISidebarFolder, - SidebarItems, - TSidebarItem, - makeCinnySpacesContent, - parseSidebar, - sidebarItemWithout, - useSidebarItems, -} from '../../../hooks/useSidebarItems'; -import { AccountDataEvent } from '../../../../types/matrix/accountData'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; -import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder'; -import { usePowerLevels } from '../../../hooks/usePowerLevels'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { markAsRead } from '../../../utils/notifications'; -import { copyToClipboard } from '../../../utils/dom'; -import { stopPropagation } from '../../../utils/keyboard'; -import { getMatrixToRoom } from '../../../plugins/matrix-to'; -import { getViaServers } from '../../../plugins/via-servers'; -import { getRoomAvatarUrl } from '../../../utils/room'; -import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; -import { useRoomCreators } from '../../../hooks/useRoomCreators'; -import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; -import { InviteUserPrompt } from '../../../components/invite-user-prompt'; - -type SpaceMenuProps = { - room: Room; - requestClose: () => void; - onUnpin?: (roomId: string) => void; -}; -const SpaceMenu = forwardRef( - ({ room, requestClose, onUnpin }, ref) => { - const mx = useMatrixClient(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const roomToParents = useAtomValue(roomToParentsAtom); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); - - const permissions = useRoomPermissions(creators, powerLevels); - const canInvite = permissions.action('invite', mx.getSafeUserId()); - const openSpaceSettings = useOpenSpaceSettings(); - - const [invitePrompt, setInvitePrompt] = useState(false); - - const allChild = useSpaceChildren( - allRoomsAtom, - room.roomId, - useRecursiveChildScopeFactory(mx, roomToParents) - ); - const unread = useRoomsUnread(allChild, roomToUnreadAtom); - - const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); - requestClose(); - }; - - const handleUnpin = () => { - onUnpin?.(room.roomId); - requestClose(); - }; - - const handleCopyLink = () => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); - const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); - copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); - requestClose(); - }; - - const handleInvite = () => { - setInvitePrompt(true); - }; - - const handleRoomSettings = () => { - openSpaceSettings(room.roomId); - requestClose(); - }; - - return ( - - {invitePrompt && room && ( - { - setInvitePrompt(false); - requestClose(); - }} - /> - )} - - } - radii="300" - disabled={!unread} - > - - Mark as Read - - - {onUnpin && ( - } - > - - Unpin - - - )} - - - - } - radii="300" - aria-pressed={invitePrompt} - disabled={!canInvite} - > - - Invite - - - } - radii="300" - > - - Copy Link - - - } - radii="300" - > - - Space Settings - - - - - ); - } -); - -type InstructionType = Instruction['type']; -type FolderDraggable = { - folder: ISidebarFolder; - spaceId?: string; - open?: boolean; -}; -type SidebarDraggable = string | FolderDraggable; - -const useDraggableItem = ( - item: SidebarDraggable, - targetRef: RefObject, - onDragging: (item?: SidebarDraggable) => void, - dragHandleRef?: RefObject -): boolean => { - const [dragging, setDragging] = useState(false); - - useEffect(() => { - const target = targetRef.current; - const dragHandle = dragHandleRef?.current ?? undefined; - - return !target - ? undefined - : draggable({ - element: target, - dragHandle, - getInitialData: () => ({ item }), - onDragStart: () => { - setDragging(true); - onDragging?.(item); - }, - onDrop: () => { - setDragging(false); - onDragging?.(undefined); - }, - }); - }, [targetRef, dragHandleRef, item, onDragging]); - - return dragging; -}; - -const useDropTarget = ( - item: SidebarDraggable, - targetRef: RefObject -): Instruction | undefined => { - const [dropState, setDropState] = useState(); - - useEffect(() => { - const target = targetRef.current; - if (!target) return undefined; - - return dropTargetForElements({ - element: target, - canDrop: ({ source }) => { - const dragItem = source.data.item as SidebarDraggable; - return dragItem !== item; - }, - getData: ({ input, element }) => { - const block: Instruction['type'][] = ['reparent']; - if (typeof item === 'object' && item.spaceId) block.push('make-child'); - - const insData = attachInstruction( - {}, - { - input, - element, - currentLevel: 0, - indentPerLevel: 0, - mode: 'standard', - block, - } - ); - - const instruction: Instruction | null = extractInstruction(insData); - setDropState(instruction ?? undefined); - - return { - item, - instructionType: instruction ? instruction.type : undefined, - }; - }, - onDragLeave: () => setDropState(undefined), - onDrop: () => setDropState(undefined), - }); - }, [item, targetRef]); - - return dropState; -}; - -function useDropTargetInstruction( - item: SidebarDraggable, - targetRef: RefObject, - instructionType: T -): T | undefined { - const [dropState, setDropState] = useState(); - - useEffect(() => { - const target = targetRef.current; - if (!target) return undefined; - - return dropTargetForElements({ - element: target, - canDrop: ({ source }) => { - const dragItem = source.data.item as SidebarDraggable; - return dragItem !== item; - }, - getData: () => { - setDropState(instructionType); - - return { - item, - instructionType, - }; - }, - onDragLeave: () => setDropState(undefined), - onDrop: () => setDropState(undefined), - }); - }, [item, targetRef, instructionType]); - - return dropState; -} - -const useDnDMonitor = ( - scrollRef: RefObject, - onDragging: (dragItem?: SidebarDraggable) => void, - onReorder: ( - draggable: SidebarDraggable, - container: SidebarDraggable, - instruction: InstructionType - ) => void -) => { - useEffect(() => { - const scrollElement = scrollRef.current; - if (!scrollElement) { - throw Error('Scroll element ref not configured'); - } - - return combine( - monitorForElements({ - onDrop: ({ source, location }) => { - onDragging(undefined); - const { dropTargets } = location.current; - if (dropTargets.length === 0) return; - const item = source.data.item as SidebarDraggable; - const containerItem = dropTargets[0].data.item as SidebarDraggable; - const instructionType = dropTargets[0].data.instructionType as - | InstructionType - | undefined; - if (!instructionType) return; - onReorder(item, containerItem, instructionType); - }, - }), - autoScrollForElements({ - element: scrollElement, - }) - ); - }, [scrollRef, onDragging, onReorder]); -}; - -type SpaceTabProps = { - space: Room; - selected: boolean; - onClick: MouseEventHandler; - folder?: ISidebarFolder; - onDragging: (dragItem?: SidebarDraggable) => void; - disabled?: boolean; - onUnpin?: (roomId: string) => void; -}; -function SpaceTab({ - space, - selected, - onClick, - folder, - onDragging, - disabled, - onUnpin, -}: SpaceTabProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const targetRef = useRef(null); - - const spaceDraggable: SidebarDraggable = useMemo( - () => - folder - ? { - folder, - spaceId: space.roomId, - } - : space.roomId, - [folder, space] - ); - - useDraggableItem(spaceDraggable, targetRef, onDragging); - const dropState = useDropTarget(spaceDraggable, targetRef); - const dropType = dropState?.type; - - const [menuAnchor, setMenuAnchor] = useState(); - - const handleContextMenu: MouseEventHandler = (evt) => { - evt.preventDefault(); - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - - {(unread) => ( - - - {(triggerRef) => ( - - ( - {nameInitials(space.name, 2)} - )} - /> - - )} - - {unread && ( - 0}> - 0} count={unread.total} /> - - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} - onUnpin={onUnpin} - /> - - } - /> - )} - - )} - - ); -} - -type OpenedSpaceFolderProps = { - folder: ISidebarFolder; - onClose: MouseEventHandler; - children?: ReactNode; -}; -function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps) { - const aboveTargetRef = useRef(null); - const belowTargetRef = useRef(null); - - const spaceDraggable: SidebarDraggable = useMemo(() => ({ folder, open: true }), [folder]); - - const orderAbove = useDropTargetInstruction(spaceDraggable, aboveTargetRef, 'reorder-above'); - const orderBelow = useDropTargetInstruction(spaceDraggable, belowTargetRef, 'reorder-below'); - - return ( - - - - - - - - {children} - - - ); -} - -type ClosedSpaceFolderProps = { - folder: ISidebarFolder; - selected: boolean; - onOpen: MouseEventHandler; - onDragging: (dragItem?: SidebarDraggable) => void; - disabled?: boolean; -}; -function ClosedSpaceFolder({ - folder, - selected, - onOpen, - onDragging, - disabled, -}: ClosedSpaceFolderProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const handlerRef = useRef(null); - - const spaceDraggable: FolderDraggable = useMemo(() => ({ folder }), [folder]); - useDraggableItem(spaceDraggable, handlerRef, onDragging); - const dropState = useDropTarget(spaceDraggable, handlerRef); - const dropType = dropState?.type; - - const tooltipName = - folder.name ?? folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ') ?? 'Unnamed'; - - return ( - - {(unread) => ( - - - {(tooltipRef) => ( - - {folder.content.map((sId) => { - const space = mx.getRoom(sId); - if (!space) return null; - - return ( - - ( - - {nameInitials(space.name, 2)} - - )} - /> - - ); - })} - - )} - - {unread && ( - 0}> - 0} count={unread.total} /> - - )} - - )} - - ); -} - -type SpaceTabsProps = { - scrollRef: RefObject; -}; -export function SpaceTabs({ scrollRef }: SpaceTabsProps) { - const navigate = useNavigate(); - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const roomToParents = useAtomValue(roomToParentsAtom); - const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); - const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom()); - const [draggingItem, setDraggingItem] = useState(); - - useDnDMonitor( - scrollRef, - setDraggingItem, - useCallback( - (item, containerItem, instructionType) => { - const newItems: SidebarItems = []; - - const matchDest = (sI: TSidebarItem, dI: SidebarDraggable): boolean => { - if (typeof sI === 'string' && typeof dI === 'string') { - return sI === dI; - } - if (typeof sI === 'object' && typeof dI === 'object') { - return sI.id === dI.folder.id; - } - return false; - }; - const itemAsFolderContent = (i: SidebarDraggable): string[] => { - if (typeof i === 'string') { - return [i]; - } - if (i.spaceId) { - return [i.spaceId]; - } - return [...i.folder.content]; - }; - - sidebarItems.forEach((i) => { - const sameFolders = - typeof item === 'object' && - typeof containerItem === 'object' && - item.folder.id === containerItem.folder.id; - - // remove draggable space from current position or folder - if (!sameFolders && matchDest(i, item)) { - if (typeof item === 'object' && item.spaceId) { - const folderContent = item.folder.content.filter((s) => s !== item.spaceId); - if (folderContent.length === 0) { - // remove open state from local storage - setOpenedFolder({ type: 'DELETE', id: item.folder.id }); - return; - } - newItems.push({ - ...item.folder, - content: folderContent, - }); - } - return; - } - if (matchDest(i, containerItem)) { - // we can make child only if - // container item is space or closed folder - if (instructionType === 'make-child') { - const child: string[] = itemAsFolderContent(item); - if (typeof containerItem === 'string') { - const folder: ISidebarFolder = { - id: randomStr(), - content: [containerItem].concat(child), - }; - newItems.push(folder); - return; - } - newItems.push({ - ...containerItem.folder, - content: containerItem.folder.content.concat(child), - }); - return; - } - - // drop inside opened folder - // or reordering inside same folder - if (typeof containerItem === 'object' && containerItem.spaceId) { - const child = itemAsFolderContent(item); - const newContent: string[] = []; - containerItem.folder.content - .filter((sId) => !child.includes(sId)) - .forEach((sId) => { - if (sId === containerItem.spaceId) { - if (instructionType === 'reorder-below') { - newContent.push(sId, ...child); - } - if (instructionType === 'reorder-above') { - newContent.push(...child, sId); - } - return; - } - newContent.push(sId); - }); - const folder = { - ...containerItem.folder, - content: newContent, - }; - - newItems.push(folder); - return; - } - - // drop above or below space or closed/opened folder - if (typeof item === 'string') { - if (instructionType === 'reorder-below') newItems.push(i); - newItems.push(item); - if (instructionType === 'reorder-above') newItems.push(i); - } else if (item.spaceId) { - if (instructionType === 'reorder-above') { - newItems.push(item.spaceId); - } - if (sameFolders && typeof i === 'object') { - // remove from folder if placing around itself - const newI = { ...i, content: i.content.filter((sId) => sId !== item.spaceId) }; - if (newI.content.length > 0) newItems.push(newI); - } else { - newItems.push(i); - } - if (instructionType === 'reorder-below') { - newItems.push(item.spaceId); - } - } else { - if (instructionType === 'reorder-below') newItems.push(i); - newItems.push(item.folder); - if (instructionType === 'reorder-above') newItems.push(i); - } - return; - } - newItems.push(i); - }); - - const newSpacesContent = makeCinnySpacesContent(mx, newItems); - localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); - mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); - }, - [mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces] - ) - ); - - const selectedSpaceId = useSelectedSpace(); - - const handleSpaceClick: MouseEventHandler = (evt) => { - const target = evt.currentTarget; - const targetSpaceId = target.getAttribute('data-id'); - if (!targetSpaceId) return; - - const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)); - if (screenSize === ScreenSize.Mobile) { - navigate(spacePath); - return; - } - - const activePath = navToActivePath.get(targetSpaceId); - if (activePath && activePath.pathname.startsWith(spacePath)) { - navigate(joinPathComponent(activePath)); - return; - } - - navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId))); - }; - - const handleFolderToggle: MouseEventHandler = (evt) => { - const target = evt.currentTarget; - const targetFolderId = target.getAttribute('data-id'); - if (!targetFolderId) return; - - setOpenedFolder({ - type: openedFolder.has(targetFolderId) ? 'DELETE' : 'PUT', - id: targetFolderId, - }); - }; - - const handleUnpin = useCallback( - (roomId: string) => { - if (orphanSpaces.includes(roomId)) return; - const newItems = sidebarItemWithout(sidebarItems, roomId); - - const newSpacesContent = makeCinnySpacesContent(mx, newItems); - localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); - mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); - }, - [mx, sidebarItems, orphanSpaces, localEchoSidebarItem] - ); - - if (sidebarItems.length === 0) return null; - return ( - <> - - - {sidebarItems.map((item) => { - if (typeof item === 'object') { - if (openedFolder.has(item.id)) { - return ( - - {item.content.map((sId) => { - const space = mx.getRoom(sId); - if (!space) return null; - return ( - - ); - })} - - ); - } - - return ( - - ); - } - - const space = mx.getRoom(item); - if (!space) return null; - - return ( - - ); - })} - - - ); -} diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts index d44cfaa2..3cf570d9 100644 --- a/src/app/pages/client/sidebar/index.ts +++ b/src/app/pages/client/sidebar/index.ts @@ -1,8 +1,3 @@ -export * from './HomeTab'; -export * from './DirectTab'; -export * from './SpaceTabs'; export * from './InboxTab'; -export * from './ExploreTab'; export * from './SettingsTab'; export * from './UnverifiedTab'; -export * from './SearchTab'; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx deleted file mode 100644 index c822ff38..00000000 --- a/src/app/pages/client/space/Space.tsx +++ /dev/null @@ -1,571 +0,0 @@ -import React, { - MouseEventHandler, - forwardRef, - useCallback, - useMemo, - useRef, - useState, -} from 'react'; -import { useAtom, useAtomValue } from 'jotai'; -import { - Avatar, - Box, - Button, - Icon, - IconButton, - Icons, - Line, - Menu, - MenuItem, - PopOut, - RectCords, - Spinner, - Text, - color, - config, - toRem, -} from 'folds'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { JoinRule, Room } from 'matrix-js-sdk'; -import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; -import FocusTrap from 'focus-trap-react'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { mDirectAtom } from '../../../state/mDirectList'; -import { - NavCategory, - NavCategoryHeader, - NavItem, - NavItemContent, - NavLink, -} from '../../../components/nav'; -import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils'; -import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; -import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { - useSpaceLobbySelected, - useSpaceSearchSelected, -} from '../../../hooks/router/useSelectedSpace'; -import { useSpace } from '../../../hooks/useSpace'; -import { VirtualTile } from '../../../components/virtualizer'; -import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -import { makeNavCategoryId } from '../../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; -import { useStewardGroups } from '../../../features/steward-groups/useStewardGroups'; -import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; -import { useRoomName } from '../../../hooks/useRoomMeta'; -import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; -import { usePowerLevels } from '../../../hooks/usePowerLevels'; -import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList'; -import { roomToParentsAtom } from '../../../state/room/roomToParents'; -import { markAsRead } from '../../../utils/notifications'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { UseStateProvider } from '../../../components/UseStateProvider'; -import { LeaveSpacePrompt } from '../../../components/leave-space-prompt'; -import { copyToClipboard } from '../../../utils/dom'; -import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; -import { useStateEvent } from '../../../hooks/useStateEvent'; -import { Membership, StateEvent } from '../../../../types/matrix/room'; -import { stopPropagation } from '../../../utils/keyboard'; -import { getMatrixToRoom } from '../../../plugins/matrix-to'; -import { getViaServers } from '../../../plugins/via-servers'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../../hooks/useRoomsNotificationPreferences'; -import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; -import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; -import { useRoomCreators } from '../../../hooks/useRoomCreators'; -import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; -import { ContainerColor } from '../../../styles/ContainerColor.css'; -import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { BreakWord } from '../../../styles/Text.css'; -import { InviteUserPrompt } from '../../../components/invite-user-prompt'; -import { useCallEmbed } from '../../../hooks/useCallEmbed'; - -type SpaceMenuProps = { - room: Room; - requestClose: () => void; -}; -const SpaceMenu = forwardRef(({ room, requestClose }, ref) => { - const mx = useMatrixClient(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const [developerTools] = useSetting(settingsAtom, 'developerTools'); - const roomToParents = useAtomValue(roomToParentsAtom); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); - - const permissions = useRoomPermissions(creators, powerLevels); - const canInvite = permissions.action('invite', mx.getSafeUserId()); - const openSpaceSettings = useOpenSpaceSettings(); - const { navigateRoom } = useRoomNavigate(); - - const [invitePrompt, setInvitePrompt] = useState(false); - - const allChild = useSpaceChildren( - allRoomsAtom, - room.roomId, - useRecursiveChildScopeFactory(mx, roomToParents) - ); - const unread = useRoomsUnread(allChild, roomToUnreadAtom); - - const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); - requestClose(); - }; - - const handleCopyLink = () => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); - const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); - copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); - requestClose(); - }; - - const handleInvite = () => { - setInvitePrompt(true); - }; - - const handleRoomSettings = () => { - openSpaceSettings(room.roomId); - requestClose(); - }; - - const handleOpenTimeline = () => { - navigateRoom(room.roomId); - requestClose(); - }; - - return ( - - - {invitePrompt && room && ( - { - setInvitePrompt(false); - requestClose(); - }} - /> - )} - } - radii="300" - disabled={!unread} - > - - Mark as Read - - - - - - } - radii="300" - aria-pressed={invitePrompt} - disabled={!canInvite} - > - - Invite - - - } - radii="300" - > - - Copy Link - - - } - radii="300" - > - - Space Settings - - - {developerTools && ( - } - radii="300" - > - - Event Timeline - - - )} - - - - - {(promptLeave, setPromptLeave) => ( - <> - setPromptLeave(true)} - variant="Critical" - fill="None" - size="300" - after={} - radii="300" - aria-pressed={promptLeave} - > - - Leave Space - - - {promptLeave && ( - setPromptLeave(false)} - /> - )} - - )} - - - - ); -}); - -function SpaceHeader() { - const space = useSpace(); - const spaceName = useRoomName(space); - const [menuAnchor, setMenuAnchor] = useState(); - - const joinRules = useStateEvent( - space, - StateEvent.RoomJoinRules - )?.getContent(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - <> - - - - - {spaceName} - - {joinRules?.join_rule !== JoinRule.Public && } - - - - - - - - - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - )} - - ); -} - -type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; -export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { - const mx = useMatrixClient(); - const { navigateSpace } = useRoomNavigate(); - - const [joinState, handleJoin] = useAsyncCallback( - useCallback(() => { - const currentRoom = mx.getRoom(roomId); - const via = currentRoom ? getViaServers(currentRoom) : []; - return mx.joinRoom(replacementRoomId, { - viaServers: via, - }); - }, [mx, roomId, replacementRoomId]) - ); - const replacementRoom = mx.getRoom(replacementRoomId); - - const handleOpen = () => { - if (replacementRoom) navigateSpace(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId); - }; - - return ( - - - Space Upgraded - This space has been replaced and is no longer active. - {joinState.status === AsyncStatus.Error && ( - - {(joinState.error as any)?.message ?? 'Failed to join replacement space!'} - - )} - - - {replacementRoom?.getMyMembership() === Membership.Join || - joinState.status === AsyncStatus.Success ? ( - - ) : ( - - )} - - - ); -} - -export function Space() { - const mx = useMatrixClient(); - const space = useSpace(); - useNavToActivePathMapper(space.roomId); - const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId); - const scrollRef = useRef(null); - const mDirects = useAtomValue(mDirectAtom); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const allRooms = useAtomValue(allRoomsAtom); - const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - - const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone); - const selectedRoomId = useSelectedRoom(); - const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); - const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); - const callEmbed = useCallEmbed(); - - const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); - - const getRoom = useCallback( - (rId: string): Room | undefined => { - if (allJoinedRooms.has(rId)) { - return mx.getRoom(rId) ?? undefined; - } - return undefined; - }, - [mx, allJoinedRooms] - ); - - const hierarchy = useSpaceJoinedHierarchy( - space.roomId, - getRoom, - useCallback( - (parentId, roomId) => { - if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { - return false; - } - const showRoomAnyway = - roomToUnread.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId; - return !showRoomAnyway; - }, - [space.roomId, closedCategories, roomToUnread, selectedRoomId, callEmbed] - ), - useCallback( - (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), - [closedCategories, space.roomId] - ) - ); - - const { entries, toggleGroup } = useStewardGroups({ - mx, - spaceId: space.roomId, - items: hierarchy, - keepVisible: useCallback( - (rId: string) => roomToUnread.has(rId) || rId === selectedRoomId || callEmbed?.roomId === rId, - [roomToUnread, selectedRoomId, callEmbed] - ), - }); - - const virtualizer = useVirtualizer({ - count: entries.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 0, - overscan: 10, - }); - - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); - - const getToLink = (roomId: string) => - getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); - - return ( - - - - - {tombstoneEvent && ( - - )} - - - - - - - - - - - Lobby - - - - - - - - - - - - - - - - Message Search - - - - - - - - - {virtualizer.getVirtualItems().map((vItem) => { - const entry = entries[vItem.index]; - if (!entry) return null; - if (entry.type === 'group') { - return ( - - - toggleGroup(entry.id)} - closed={!entry.open} - > - {entry.label} ยท {entry.count} - - - - ); - } - const { roomId } = entry.item; - const room = mx.getRoom(roomId); - if (!room) return null; - - if (room.isSpaceRoom()) { - const categoryId = makeNavCategoryId(space.roomId, roomId); - - return ( - -
- - - {roomId === space.roomId ? 'Rooms' : room?.name} - - -
-
- ); - } - - return ( - - - - ); - })} -
-
-
-
- ); -} diff --git a/src/app/pages/client/space/SpaceTombstone.tsx b/src/app/pages/client/space/SpaceTombstone.tsx new file mode 100644 index 00000000..3e2d13bd --- /dev/null +++ b/src/app/pages/client/space/SpaceTombstone.tsx @@ -0,0 +1,79 @@ +import React, { useCallback } from 'react'; +import { Box, Button, Spinner, Text, color, config } from 'folds'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { Membership } from '../../../../types/matrix/room'; +import { getViaServers } from '../../../plugins/via-servers'; +import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { ContainerColor } from '../../../styles/ContainerColor.css'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { BreakWord } from '../../../styles/Text.css'; + +type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; +export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { + const mx = useMatrixClient(); + const { navigateSpace } = useRoomNavigate(); + + const [joinState, handleJoin] = useAsyncCallback( + useCallback(() => { + const currentRoom = mx.getRoom(roomId); + const via = currentRoom ? getViaServers(currentRoom) : []; + return mx.joinRoom(replacementRoomId, { + viaServers: via, + }); + }, [mx, roomId, replacementRoomId]) + ); + const replacementRoom = mx.getRoom(replacementRoomId); + + const handleOpen = () => { + if (replacementRoom) navigateSpace(replacementRoom.roomId); + if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId); + }; + + return ( + + + Space Upgraded + This space has been replaced and is no longer active. + {joinState.status === AsyncStatus.Error && ( + + {(joinState.error as any)?.message ?? 'Failed to join replacement space!'} + + )} + + + {replacementRoom?.getMyMembership() === Membership.Join || + joinState.status === AsyncStatus.Success ? ( + + ) : ( + + )} + + + ); +} + diff --git a/src/app/pages/client/space/index.ts b/src/app/pages/client/space/index.ts index 0e32d93b..9b79693e 100644 --- a/src/app/pages/client/space/index.ts +++ b/src/app/pages/client/space/index.ts @@ -1,4 +1,4 @@ export * from './SpaceProvider'; -export * from './Space'; +export * from './SpaceTombstone'; export * from './Search'; export * from './RoomProvider'; diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 98715996..1f5f0608 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -3,7 +3,6 @@ export enum AccountDataEvent { Direct = 'm.direct', IgnoredUserList = 'm.ignored_user_list', - CinnySpaces = 'in.cinny.spaces', ElementRecentEmoji = 'io.element.recent_emoji', diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index c73c0f0b..81d35bd0 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -36,6 +36,7 @@ export enum StateEvent { GroupCallMemberPrefix = 'org.matrix.msc3401.call.member', SpaceChild = 'm.space.child', + GameCultStewardships = 'org.gamecult.stewardships', SpaceParent = 'm.space.parent', PoniesRoomEmotes = 'im.ponies.room_emotes', From e90b70e966398444bfffd74971d6c2ea3f3ce003 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:06:44 +0100 Subject: [PATCH 07/64] Estate navigator: one structure-first room list from the Space hierarchy, with one-tap Join buildEstate is the only code that decides which rooms the navigator lists, in which tier and steward group, and whether each is joined, invited or joinable. The server hierarchy is the access rule. The Home, Direct and Space nav slots mount it through EstatePageNav. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 225 ++++++++++++++++++ src/app/features/estate/buildEstate.test.ts | 140 +++++++++++ src/app/features/estate/buildEstate.ts | 162 +++++++++++++ src/app/features/estate/index.ts | 1 + .../features/estate/joinEstateRoom.test.ts | 61 +++++ src/app/features/estate/joinEstateRoom.ts | 29 +++ src/app/features/estate/useEstate.ts | 110 +++++++++ src/app/hooks/useSpaceHierarchy.ts | 9 +- src/app/pages/Router.tsx | 30 +-- src/app/pages/client/EstatePageNav.tsx | 15 ++ 10 files changed, 757 insertions(+), 25 deletions(-) create mode 100644 src/app/features/estate/EstateNavigator.tsx create mode 100644 src/app/features/estate/buildEstate.test.ts create mode 100644 src/app/features/estate/buildEstate.ts create mode 100644 src/app/features/estate/index.ts create mode 100644 src/app/features/estate/joinEstateRoom.test.ts create mode 100644 src/app/features/estate/joinEstateRoom.ts create mode 100644 src/app/features/estate/useEstate.ts create mode 100644 src/app/pages/client/EstatePageNav.tsx diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx new file mode 100644 index 00000000..211f0c3a --- /dev/null +++ b/src/app/features/estate/EstateNavigator.tsx @@ -0,0 +1,225 @@ +import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAtom, useAtomValue } from 'jotai'; +import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; +import { MatrixClient } from 'matrix-js-sdk'; +import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; +import { NavCategory, NavCategoryHeader, NavItem, NavItemContent } from '../../components/nav'; +import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; +import { InboxTab, SettingsTab, UnverifiedTab } from '../../pages/client/sidebar'; +import { + getDirectRoomPath, + getHomeRoomPath, + getHomeSearchPath, + getSpaceRoomPath, +} from '../../pages/pathUtils'; +import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; +import { useCallEmbed } from '../../hooks/useCallEmbed'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { + getRoomNotificationMode, + useRoomsNotificationPreferencesContext, +} from '../../hooks/useRoomsNotificationPreferences'; +import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategories'; +import { makeNavCategoryId } from '../../state/closedNavCategories'; +import { roomToUnreadAtom } from '../../state/room/roomToUnread'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { EstateRoom, EstateTier } from './buildEstate'; +import { activateEstateRoom } from './joinEstateRoom'; +import { useEstate, useEstateRootId } from './useEstate'; + +const estateRoomPath = (mx: MatrixClient, room: EstateRoom): string => { + const alias = getCanonicalAliasOrRoomId(mx, room.roomId); + if (room.parentId === null) return getDirectRoomPath(alias); + // A Space the user has not joined has no Space route; the room still resolves from Home. + if (mx.getRoom(room.parentId)?.getMyMembership() === 'join') { + return getSpaceRoomPath(getCanonicalAliasOrRoomId(mx, room.parentId), alias); + } + return getHomeRoomPath(alias); +}; + +function JoinRow({ room }: { room: EstateRoom }) { + const mx = useMatrixClient(); + const navigate = useNavigate(); + const [state, join] = useAsyncCallback( + useCallback(async () => { + const result = await activateEstateRoom(mx, room); + if (result.kind === 'refused') throw new Error(result.message); + navigate(estateRoomPath(mx, room)); + }, [mx, navigate, room]) + ); + const busy = state.status === AsyncStatus.Loading; + + return ( + + + + + {room.name} + + + {state.status === AsyncStatus.Error && ( + + {state.error.message} + + )} + : } + onClick={join} + disabled={busy} + aria-label={`Join ${room.name}`} + > + Join + + + + ); +} + +function EstateRow({ room }: { room: EstateRoom }) { + const mx = useMatrixClient(); + const selectedRoomId = useSelectedRoom(); + const notificationPreferences = useRoomsNotificationPreferencesContext(); + const mxRoom = room.state === 'joined' ? mx.getRoom(room.roomId) : null; + + if (!mxRoom) return ; + return ( + + ); +} + +type CollapsibleProps = { + categoryId: string; + label: string; + rooms: EstateRoom[]; + children: ReactNode; +}; +function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) { + const mobile = useScreenSizeContext() === ScreenSize.Mobile; + const selectedRoomId = useSelectedRoom(); + const roomToUnread = useAtomValue(roomToUnreadAtom); + const callRoomId = useCallEmbed()?.roomId; + const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom()); + + const active = rooms.some( + (r) => r.roomId === selectedRoomId || r.roomId === callRoomId || roomToUnread.has(r.roomId) + ); + // Phones start collapsed unless the section holds something live; wide screens start open. + // A tap flips that default; the flip is remembered per device and per screen class. + const key = makeNavCategoryId('estate', mobile ? 'phone' : 'wide', categoryId); + const closed = (mobile && !active) !== toggled.has(key); + + const handleClick: MouseEventHandler = () => + setToggled({ type: toggled.has(key) ? 'DELETE' : 'PUT', categoryId: key }); + + return ( + + + + {label} + + + {!closed && children} + + ); +} + +function Tier({ tier }: { tier: EstateTier }) { + const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]); + return ( + + {tier.loose.map((r) => ( + + ))} + {tier.groups.map((g) => ( + + {g.rooms.map((r) => ( + + ))} + + ))} + + ); +} + +function EstateTiers({ rootId }: { rootId: string }) { + const { fetching, error, tiers } = useEstate(rootId); + const scrollRef = useRef(null); + + return ( + + + {tiers.map((tier) => ( + + ))} + {fetching && ( + + + + )} + {error && ( + + {error.message} + + )} + + + ); +} + +/** The single navigator: every nav slot that lists rooms mounts this. */ +export function EstateNavigator() { + const navigate = useNavigate(); + const { rootId, error } = useEstateRootId(); + const mx = useMatrixClient(); + const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; + + return ( + + + + + + {title} + + + navigate(getHomeSearchPath())} + > + + + + + {rootId ? ( + + ) : ( + + {error ? {error.message} : } + + )} + + + + + + + ); +} diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts new file mode 100644 index 00000000..0593e195 --- /dev/null +++ b/src/app/features/estate/buildEstate.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfRoom } from './buildEstate'; + +const ROOT = '!root:gamecult.org'; +const PROJECTS = '!projects:gamecult.org'; + +const space = (id: string, name: string, children: string[]): EstateHierarchyRoom => ({ + room_id: id, + name, + room_type: 'm.space', + children_state: children.map((c) => ({ state_key: c, content: { via: ['gamecult.org'] } })), +}); +const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => ({ + room_id: id, + name, + canonical_alias: alias, +}); + +const hierarchy = [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), + room('!general:gamecult.org', 'General', '#general:gamecult.org'), + space(PROJECTS, 'Projects', ['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org']), + room('!cultlib:gamecult.org', 'CultLib', '#cultlib:gamecult.org'), + room('!aeth:gamecult.org', 'Aetheria', '#Aetheria:gamecult.org'), + room('!misc:gamecult.org', 'Misc', '#misc:gamecult.org'), +]; + +const stewardships = { eureka: ['GameCult/CultLib'], void: ['GameCult/aetheria'] }; + +const input = (over: Partial = {}): EstateInput => ({ + rootId: ROOT, + hierarchy, + stewardships, + membership: new Map(), + directRoomIds: [], + ...over, +}); + +const find = (tiers: ReturnType, roomId: string) => + tiers + .flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)]) + .find((r) => r.roomId === roomId); + +describe('buildEstate', () => { + it('lists an invited project room as invited', () => { + const tiers = buildEstate(input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) })); + expect(find(tiers, '!cultlib:gamecult.org')?.state).toBe('invited'); + }); + + it('lists a hierarchy room the user has not joined as joinable, beside joined ones', () => { + const tiers = buildEstate(input({ membership: new Map([['!general:gamecult.org', 'join']]) })); + expect(find(tiers, '!general:gamecult.org')?.state).toBe('joined'); + expect(find(tiers, '!aeth:gamecult.org')?.state).toBe('joinable'); + expect(find(tiers, '!aeth:gamecult.org')?.via).toEqual(['gamecult.org']); + }); + + it('omits a room absent from the hierarchy even when the client store knows it', () => { + const hidden = '!estate-secret:gamecult.org'; + const tiers = buildEstate( + input({ + membership: new Map([[hidden, 'join']]), + // The root names the room as a child, but the server did not return it. + hierarchy: [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, hidden]), + ...hierarchy.slice(1), + ], + }) + ); + expect(find(tiers, hidden)).toBeUndefined(); + }); + + it('lists every room the server returned, whatever the user may join', () => { + const tiers = buildEstate(input()); + expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4); + }); + + it('groups rooms by steward Mind from stewardships plus canonical alias, case-insensitively', () => { + const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.roomId)])).toEqual([ + ['eureka', ['!cultlib:gamecult.org']], + ['void', ['!aeth:gamecult.org']], + ]); + }); + + it('sorts steward groups by display label', () => { + const projects = buildEstate( + input({ mindLabels: new Map([['eureka', 'Zed'], ['void', 'Alpha']]) }) + ).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'Zed']); + }); + + it('lists a room with no alias match loose in its tier', () => { + const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; + expect(projects.loose.map((r) => r.roomId)).toEqual(['!misc:gamecult.org']); + }); + + it('degrades to loose rows when the stewardships are unreadable', () => { + const projects = buildEstate(input({ stewardships: undefined })).find( + (t) => t.id === PROJECTS + )!; + expect(projects.groups).toEqual([]); + expect(projects.loose).toHaveLength(3); + }); + + it('makes one tier per sub-Space in hierarchy order, root rooms first, Direct last', () => { + const second = '!second:gamecult.org'; + const tiers = buildEstate( + input({ + hierarchy: [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, second]), + ...hierarchy.slice(1), + space(second, 'Workshop', ['!w1:gamecult.org']), + room('!w1:gamecult.org', 'Bench'), + ], + directRoomIds: ['!dm:gamecult.org'], + membership: new Map([['!dm:gamecult.org', 'join']]), + }) + ); + expect(tiers.map((t) => t.label)).toEqual(['GameCult', 'Projects', 'Workshop', 'Direct Messages']); + expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']); + expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null }); + }); + + it('is identical for identical inputs', () => { + expect(buildEstate(input())).toEqual(buildEstate(input())); + }); +}); + +describe('stewardOfRoom', () => { + it('matches the alias localpart to the repo name, ignoring case and the org prefix', () => { + expect(stewardOfRoom('#CULTLIB:gamecult.org', stewardships)).toBe('eureka'); + expect(stewardOfRoom('#aetheria:gamecult.org', stewardships)).toBe('void'); + }); + + it('has no steward without an alias, a match or readable stewardships', () => { + expect(stewardOfRoom(undefined, stewardships)).toBeUndefined(); + expect(stewardOfRoom('#unknown:gamecult.org', stewardships)).toBeUndefined(); + expect(stewardOfRoom('#cultlib:gamecult.org', { eureka: 'GameCult/CultLib' })).toBeUndefined(); + }); +}); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts new file mode 100644 index 00000000..096ea9ad --- /dev/null +++ b/src/app/features/estate/buildEstate.ts @@ -0,0 +1,162 @@ +/** + * The estate index: the only code that decides which rooms the navigator lists, + * in which tier and steward group, and whether each is joined, invited or joinable. + * + * The server's /hierarchy of the root Space is the access rule. A room the + * hierarchy does not return is never listed, whatever the client store knows; + * a room it returns is never hidden by the client. + */ + +export const STEWARDSHIPS_EVENT_TYPE = 'org.gamecult.stewardships'; + +export type EstateHierarchyRoom = { + room_id: string; + name?: string; + canonical_alias?: string; + room_type?: string; + children_state?: { state_key: string; content?: { via?: string[] } }[]; +}; + +/** Mind localpart to the repos she stewards, as projected on the root Space. */ +export type Stewardships = Record; + +export type EstateMembership = 'joined' | 'invited' | 'joinable'; + +export type EstateRoom = { + roomId: string; + name: string; + state: EstateMembership; + /** Servers to join through, from the hierarchy's children_state. */ + via: string[]; + /** The Space tier the room sits in; null for direct messages. */ + parentId: string | null; +}; + +export type EstateGroup = { + mind: string; + label: string; + rooms: EstateRoom[]; +}; + +export type EstateTier = { + id: string; + label: string; + /** Rooms without a steward. */ + loose: EstateRoom[]; + groups: EstateGroup[]; +}; + +export type EstateInput = { + rootId: string; + /** Rooms from the server's hierarchy of the root, in hierarchy order. */ + hierarchy: readonly EstateHierarchyRoom[]; + stewardships: Stewardships | undefined; + /** The user's own membership per room id, from the client store. */ + membership: ReadonlyMap; + directRoomIds: readonly string[]; + /** Display names for Minds, keyed by localpart. */ + mindLabels?: ReadonlyMap; +}; + +export const DIRECT_TIER_ID = 'direct'; + +const SPACE_TYPE = 'm.space'; + +const repoName = (repo: string): string => repo.split('/').pop()!.toLowerCase(); + +/** + * The single place a room's steward is derived: the canonical alias localpart, + * case-insensitively, against the repo names in the stewardships lists. + * (Follow-up ratatoskr:follow_up:stewardships-carry-room-ids swaps this to room ids.) + */ +export const stewardOfRoom = ( + canonicalAlias: string | undefined, + stewardships: Stewardships | undefined +): string | undefined => { + if (!canonicalAlias || !stewardships) return undefined; + const local = canonicalAlias.replace(/^#/, '').split(':')[0].toLowerCase(); + const mind = Object.keys(stewardships).find((m) => { + const repos = stewardships[m]; + return Array.isArray(repos) && repos.some((r) => typeof r === 'string' && repoName(r) === local); + }); + return mind; +}; + +const stateOf = ( + roomId: string, + membership: ReadonlyMap +): EstateMembership => { + const m = membership.get(roomId); + if (m === 'join') return 'joined'; + if (m === 'invite') return 'invited'; + return 'joinable'; +}; + +const roomLabel = (r: EstateHierarchyRoom): string => r.name || r.canonical_alias || r.room_id; + +export const buildEstate = ({ + rootId, + hierarchy, + stewardships, + membership, + directRoomIds, + mindLabels, +}: EstateInput): EstateTier[] => { + const direct = new Set(directRoomIds); + const byId = new Map(hierarchy.map((r) => [r.room_id, r])); + const spaces = hierarchy.filter((r) => r.room_type === SPACE_TYPE); + const root = byId.get(rootId); + + // Root first, then every sub-Space in hierarchy order. + const tierSpaces = root ? [root, ...spaces.filter((s) => s.room_id !== rootId)] : spaces; + + const tiers: EstateTier[] = tierSpaces.map((space) => { + const childIds = new Set((space.children_state ?? []).map((c) => c.state_key)); + const tier: EstateTier = { id: space.room_id, label: roomLabel(space), loose: [], groups: [] }; + const groups = new Map(); + + hierarchy.forEach((r) => { + if (r.room_type === SPACE_TYPE || !childIds.has(r.room_id) || direct.has(r.room_id)) return; + const via = space.children_state?.find((c) => c.state_key === r.room_id)?.content?.via ?? []; + const room: EstateRoom = { + roomId: r.room_id, + name: roomLabel(r), + state: stateOf(r.room_id, membership), + via, + parentId: space.room_id, + }; + const mind = stewardOfRoom(r.canonical_alias, stewardships); + if (!mind) { + tier.loose.push(room); + return; + } + let group = groups.get(mind); + if (!group) { + group = { mind, label: mindLabels?.get(mind) ?? mind, rooms: [] }; + groups.set(mind, group); + } + group.rooms.push(room); + }); + + tier.groups = [...groups.values()].sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) + ); + return tier; + }); + + const directTier: EstateTier = { + id: DIRECT_TIER_ID, + label: 'Direct Messages', + loose: directRoomIds.map((roomId) => ({ + roomId, + name: roomId, + state: stateOf(roomId, membership), + via: [], + parentId: null, + })), + groups: [], + }; + + const listed = tiers.filter((t) => t.loose.length > 0 || t.groups.length > 0); + return directTier.loose.length > 0 ? [...listed, directTier] : listed; +}; diff --git a/src/app/features/estate/index.ts b/src/app/features/estate/index.ts new file mode 100644 index 00000000..761586c4 --- /dev/null +++ b/src/app/features/estate/index.ts @@ -0,0 +1 @@ +export * from './EstateNavigator'; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts new file mode 100644 index 00000000..e063b337 --- /dev/null +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import { activateEstateRoom } from './joinEstateRoom'; + +const room = (state: 'joined' | 'invited' | 'joinable') => ({ + roomId: '!r:gamecult.org', + state, + via: ['gamecult.org'], +}); + +/** A client that records every call; anything but joinRoom is a peek or a side door. */ +const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:gamecult.org' })) => { + const calls: string[] = []; + const mx = new Proxy({} as Record, { + get: (_t, prop: string) => { + if (prop === 'joinRoom') { + return (...args: unknown[]) => { + calls.push('joinRoom'); + return joinRoom(...args); + }; + } + return (...args: unknown[]) => { + void args; + calls.push(prop); + throw new Error(`unexpected client call: ${prop}`); + }; + }, + }); + return { mx: mx as never, calls, joinRoom }; +}; + +describe('activateEstateRoom', () => { + it('opens a joined room without touching the client', async () => { + const { mx, calls } = recordingClient(); + expect(await activateEstateRoom(mx, room('joined'))).toEqual({ + kind: 'open', + roomId: '!r:gamecult.org', + }); + expect(calls).toEqual([]); + }); + + it.each(['invited', 'joinable'] as const)( + 'joins a %s room on the tap, through the hierarchy servers, and nothing else', + async (state) => { + const { mx, calls, joinRoom } = recordingClient(); + const result = await activateEstateRoom(mx, room(state)); + expect(result).toEqual({ kind: 'joined', roomId: '!r:gamecult.org' }); + expect(joinRoom).toHaveBeenCalledWith('!r:gamecult.org', { viaServers: ['gamecult.org'] }); + expect(calls).toEqual(['joinRoom']); // no peekInRoom, no read, no second path + } + ); + + it('reports the server refusal and does not decide access itself', async () => { + const refusal = Object.assign(new Error('M_FORBIDDEN'), { + data: { error: 'You are not invited to this room.' }, + }); + const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal)); + const result = await activateEstateRoom(mx, room('joinable')); + expect(result).toEqual({ kind: 'refused', message: 'You are not invited to this room.' }); + expect(joinRoom).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts new file mode 100644 index 00000000..aedfc70b --- /dev/null +++ b/src/app/features/estate/joinEstateRoom.ts @@ -0,0 +1,29 @@ +import { MatrixClient } from 'matrix-js-sdk'; +import { EstateRoom } from './buildEstate'; + +export type EstateActivation = + | { kind: 'open'; roomId: string } + | { kind: 'joined'; roomId: string } + | { kind: 'refused'; message: string }; + +type JoinClient = Pick; + +/** + * What a tap on an estate row does. A joined room opens; any other room is + * joined, and only here (plus a first post in the room view). Opening a room + * never joins it and the client has no peek path. The server decides whether + * the join is allowed; its refusal is reported, the row is not hidden. + */ +export const activateEstateRoom = async ( + mx: JoinClient, + room: Pick +): Promise => { + if (room.state === 'joined') return { kind: 'open', roomId: room.roomId }; + try { + const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); + return { kind: 'joined', roomId: joined.roomId }; + } catch (e) { + const err = e as { data?: { error?: string }; message?: string }; + return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; + } +}; diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts new file mode 100644 index 00000000..3968d974 --- /dev/null +++ b/src/app/features/estate/useEstate.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useAtomValue } from 'jotai'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { MatrixClient } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy'; +import { useForceUpdate } from '../../hooks/useForceUpdate'; +import { getStateEvent } from '../../utils/room'; +import { useStateEventCallback } from '../../hooks/useStateEventCallback'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { allInvitesAtom } from '../../state/room-list/inviteList'; +import { mDirectAtom } from '../../state/mDirectList'; +import { StateEvent } from '../../../types/matrix/room'; +import { buildEstate, EstateTier, Stewardships } from './buildEstate'; + +const HIERARCHY_DEPTH = 10; + +const findLoadedRoom = (mx: MatrixClient, alias: string): string | undefined => + mx + .getRooms() + .find((r) => r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias))?.roomId; + +/** The estate's root Space: featuredCommunities.spaces[0] from the client config. */ +export const useEstateRootId = (): { rootId?: string; error?: Error } => { + const mx = useMatrixClient(); + const alias = useClientConfig().featuredCommunities?.spaces?.[0]; + const loaded = alias && !alias.startsWith('#') ? alias : alias && findLoadedRoom(mx, alias); + const resolved = useQuery({ + queryKey: ['estate-root', alias], + queryFn: async () => (await mx.getRoomIdForAlias(alias!)).room_id, + enabled: !!alias && !loaded, + }); + if (!alias) return { error: new Error('No estate root Space is configured') }; + return { rootId: loaded || resolved.data, error: resolved.error ?? undefined }; +}; + +export type Estate = { + fetching: boolean; + error: Error | null; + tiers: EstateTier[]; +}; + +/** + * Feeds buildEstate: the server hierarchy of the root Space, the root's + * stewardships from the client store, the user's membership and m.direct. + * Recomputes on membership, Space child and stewardships changes. + */ +export const useEstate = (rootId: string): Estate => { + const mx = useMatrixClient(); + const queryClient = useQueryClient(); + const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(rootId, true, HIERARCHY_DEPTH); + const joined = useAtomValue(allRoomsAtom); + const invited = useAtomValue(allInvitesAtom); + const mDirects = useAtomValue(mDirectAtom); + + const [stewardshipsVersion, bumpStewardships] = useForceUpdate(); + + const refetchHierarchy = useCallback( + () => queryClient.invalidateQueries({ queryKey: [rootId, 'hierarchy_level'] }), + [queryClient, rootId] + ); + useStateEventCallback( + mx, + useCallback( + (event) => { + if (event.getType() === StateEvent.SpaceChild) refetchHierarchy(); + if (event.getType() === StateEvent.GameCultStewardships) bumpStewardships(); + }, + [refetchHierarchy, bumpStewardships] + ) + ); + // What the server lets this user see changes with invitations. + useEffect(() => { + refetchHierarchy(); + }, [invited.length, refetchHierarchy]); + + const stewardships = useMemo( + () => { + const rootRoom = mx.getRoom(rootId); + return rootRoom + ? getStateEvent(rootRoom, StateEvent.GameCultStewardships)?.getContent() + : undefined; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [mx, rootId, stewardshipsVersion, joined, invited] + ); + + const tiers = useMemo(() => { + const membership = new Map(); + invited.forEach((id) => membership.set(id, 'invite')); + joined.forEach((id) => membership.set(id, 'join')); + const domain = mx.getDomain(); + const mindLabels = new Map(); + Object.keys(stewardships ?? {}).forEach((mind) => { + const name = mx.getUser(`@${mind}:${domain}`)?.displayName; + if (name) mindLabels.set(mind, name); + }); + return buildEstate({ + rootId, + hierarchy: Array.from(rooms.values()), + stewardships, + membership, + directRoomIds: Array.from(mDirects).filter((id) => joined.includes(id)), + mindLabels, + }); + }, [mx, rootId, rooms, stewardships, joined, invited, mDirects]); + + return { fetching, error, tiers }; +}; diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index ad34e3f4..59889c1c 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -276,7 +276,8 @@ export type FetchSpaceHierarchyLevelData = { }; export const useFetchSpaceHierarchyLevel = ( roomId: string, - enable: boolean + enable: boolean, + maxDepth = 1 ): FetchSpaceHierarchyLevelData => { const mx = useMatrixClient(); const pageNoRef = useRef(0); @@ -286,13 +287,13 @@ export const useFetchSpaceHierarchyLevel = ( string[], string | undefined > = useCallback( - ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, 1, false, pageParam), - [roomId, mx] + ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, maxDepth, false, pageParam), + [roomId, maxDepth, mx] ); const queryResponse = useInfiniteQuery({ refetchOnMount: enable, - queryKey: [roomId, 'hierarchy_level'], + queryKey: [roomId, 'hierarchy_level', maxDepth], initialPageParam: undefined, queryFn: fetchLevel, getNextPageParam: (result) => { diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 4de42081..ae5eac70 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -40,19 +40,19 @@ import { getSpaceLobbyPath, } from './pathUtils'; import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; -import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; -import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; -import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; +import { HomeRouteRoomProvider, HomeSearch } from './client/home'; +import { DirectCreate, DirectRouteRoomProvider } from './client/direct'; +import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; import { Notifications, Inbox, Invites } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { Room } from '../features/room'; import { Lobby } from '../features/lobby'; import { WelcomePage } from './client/WelcomePage'; -import { SidebarNav } from './client/SidebarNav'; +import { EstatePageNav } from './client/EstatePageNav'; import { PageRoot } from '../components/page'; import { ScreenSize } from '../hooks/useScreenSize'; -import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly'; +import { MobileFriendlyPageNav } from './MobileFriendly'; import { ClientInitStorageAtom } from './client/ClientInitStorageAtom'; import { ClientNonUIFeatures } from './client/ClientNonUIFeatures'; import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; @@ -127,13 +127,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - - - } - > + @@ -159,9 +153,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={ - - + } > @@ -186,9 +178,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={ - - + } > @@ -212,9 +202,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - + } > diff --git a/src/app/pages/client/EstatePageNav.tsx b/src/app/pages/client/EstatePageNav.tsx new file mode 100644 index 00000000..470cbdd7 --- /dev/null +++ b/src/app/pages/client/EstatePageNav.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { MobileFriendlyPageNav } from '../MobileFriendly'; +import { EstateNavigator } from '../../features/estate'; + +/** + * Where the navigator mounts in a route's nav slot. The wide-screen placement + * question (ratatoskr:question:desktop-navigator-placement) is answered here. + */ +export function EstatePageNav({ path }: { path: string }) { + return ( + + + + ); +} From f810a83a281fc643e429cb794f13b84617c20f92 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:08:47 +0100 Subject: [PATCH 08/64] Estate: keep the new files free of unresolved-SDK type errors Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 5 ++--- src/app/features/estate/joinEstateRoom.ts | 5 +++-- src/app/features/estate/useEstate.ts | 22 +++++++++++++-------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 211f0c3a..811f531b 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -2,7 +2,6 @@ import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } fro import { useNavigate } from 'react-router-dom'; import { useAtom, useAtomValue } from 'jotai'; import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; -import { MatrixClient } from 'matrix-js-sdk'; import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; import { NavCategory, NavCategoryHeader, NavItem, NavItemContent } from '../../components/nav'; import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; @@ -30,7 +29,7 @@ import { EstateRoom, EstateTier } from './buildEstate'; import { activateEstateRoom } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; -const estateRoomPath = (mx: MatrixClient, room: EstateRoom): string => { +const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); if (room.parentId === null) return getDirectRoomPath(alias); // A Space the user has not joined has no Space route; the room still resolves from Home. @@ -62,7 +61,7 @@ function JoinRow({ room }: { room: EstateRoom }) { {state.status === AsyncStatus.Error && ( - {state.error.message} + {(state.error as Error).message} )} ; +type JoinClient = { + joinRoom(roomId: string, opts: { viaServers: string[] }): Promise<{ roomId: string }>; +}; /** * What a tap on an estate row does. A joined room opens; any other room is diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts index 3968d974..fc3d8009 100644 --- a/src/app/features/estate/useEstate.ts +++ b/src/app/features/estate/useEstate.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { MatrixClient } from 'matrix-js-sdk'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useClientConfig } from '../../hooks/useClientConfig'; import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy'; @@ -12,14 +11,19 @@ import { allRoomsAtom } from '../../state/room-list/roomList'; import { allInvitesAtom } from '../../state/room-list/inviteList'; import { mDirectAtom } from '../../state/mDirectList'; import { StateEvent } from '../../../types/matrix/room'; -import { buildEstate, EstateTier, Stewardships } from './buildEstate'; +import { buildEstate, EstateHierarchyRoom, EstateTier, Stewardships } from './buildEstate'; const HIERARCHY_DEPTH = 10; -const findLoadedRoom = (mx: MatrixClient, alias: string): string | undefined => - mx - .getRooms() - .find((r) => r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias))?.roomId; +type AliasedRoom = { roomId: string; getCanonicalAlias(): string | null; getAltAliases(): string[] }; + +const findLoadedRoom = ( + mx: ReturnType, + alias: string +): string | undefined => + (mx.getRooms() as AliasedRoom[]).find( + (r) => r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias) + )?.roomId; /** The estate's root Space: featuredCommunities.spaces[0] from the client config. */ export const useEstateRootId = (): { rootId?: string; error?: Error } => { @@ -79,7 +83,9 @@ export const useEstate = (rootId: string): Estate => { () => { const rootRoom = mx.getRoom(rootId); return rootRoom - ? getStateEvent(rootRoom, StateEvent.GameCultStewardships)?.getContent() + ? (getStateEvent(rootRoom, StateEvent.GameCultStewardships)?.getContent() as + | Stewardships + | undefined) : undefined; }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -98,7 +104,7 @@ export const useEstate = (rootId: string): Estate => { }); return buildEstate({ rootId, - hierarchy: Array.from(rooms.values()), + hierarchy: Array.from(rooms.values()) as EstateHierarchyRoom[], stewardships, membership, directRoomIds: Array.from(mDirects).filter((id) => joined.includes(id)), From 4d0ee789745a0153f82adc7a8bb53da0622c13ec Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:10:41 +0100 Subject: [PATCH 09/64] Estate: lint and type fixes in the new files Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/joinEstateRoom.test.ts | 3 +-- src/app/features/estate/useEstate.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index e063b337..8976f325 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -18,8 +18,7 @@ const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:game return joinRoom(...args); }; } - return (...args: unknown[]) => { - void args; + return () => { calls.push(prop); throw new Error(`unexpected client call: ${prop}`); }; diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts index fc3d8009..6ffccd1e 100644 --- a/src/app/features/estate/useEstate.ts +++ b/src/app/features/estate/useEstate.ts @@ -104,7 +104,7 @@ export const useEstate = (rootId: string): Estate => { }); return buildEstate({ rootId, - hierarchy: Array.from(rooms.values()) as EstateHierarchyRoom[], + hierarchy: Array.from(rooms.values()) as unknown as EstateHierarchyRoom[], stewardships, membership, directRoomIds: Array.from(mDirects).filter((id) => joined.includes(id)), From c848626caaf16e09acf9d0951138c2433fe925a2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:11:35 +0100 Subject: [PATCH 10/64] Estate: prettier, and tests that kill the surviving mutants Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/buildEstate.test.ts | 81 ++++++++++++++++++- src/app/features/estate/buildEstate.ts | 6 +- .../features/estate/joinEstateRoom.test.ts | 13 +++ src/app/features/estate/useEstate.ts | 6 +- src/app/pages/Router.tsx | 18 +---- src/app/pages/client/space/SpaceTombstone.tsx | 1 - src/types/matrix/accountData.ts | 1 - 7 files changed, 101 insertions(+), 25 deletions(-) diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 0593e195..27964a7b 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -19,7 +19,11 @@ const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => const hierarchy = [ space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), room('!general:gamecult.org', 'General', '#general:gamecult.org'), - space(PROJECTS, 'Projects', ['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org']), + space(PROJECTS, 'Projects', [ + '!cultlib:gamecult.org', + '!aeth:gamecult.org', + '!misc:gamecult.org', + ]), room('!cultlib:gamecult.org', 'CultLib', '#cultlib:gamecult.org'), room('!aeth:gamecult.org', 'Aetheria', '#Aetheria:gamecult.org'), room('!misc:gamecult.org', 'Misc', '#misc:gamecult.org'), @@ -43,7 +47,9 @@ const find = (tiers: ReturnType, roomId: string) => describe('buildEstate', () => { it('lists an invited project room as invited', () => { - const tiers = buildEstate(input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) })); + const tiers = buildEstate( + input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) }) + ); expect(find(tiers, '!cultlib:gamecult.org')?.state).toBe('invited'); }); @@ -84,7 +90,12 @@ describe('buildEstate', () => { it('sorts steward groups by display label', () => { const projects = buildEstate( - input({ mindLabels: new Map([['eureka', 'Zed'], ['void', 'Alpha']]) }) + input({ + mindLabels: new Map([ + ['eureka', 'Zed'], + ['void', 'Alpha'], + ]), + }) ).find((t) => t.id === PROJECTS)!; expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'Zed']); }); @@ -116,11 +127,73 @@ describe('buildEstate', () => { membership: new Map([['!dm:gamecult.org', 'join']]), }) ); - expect(tiers.map((t) => t.label)).toEqual(['GameCult', 'Projects', 'Workshop', 'Direct Messages']); + expect(tiers.map((t) => t.label)).toEqual([ + 'GameCult', + 'Projects', + 'Workshop', + 'Direct Messages', + ]); expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']); expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null }); }); + it('takes join servers from the matching children_state entry', () => { + const tiers = buildEstate( + input({ + hierarchy: [ + { + ...space(ROOT, 'GameCult', ['!a:gamecult.org', '!b:gamecult.org']), + children_state: [ + { state_key: '!a:gamecult.org', content: { via: ['one.example'] } }, + { state_key: '!b:gamecult.org', content: { via: ['two.example'] } }, + ], + }, + room('!a:gamecult.org', 'A'), + room('!b:gamecult.org', 'B'), + ], + }) + ); + expect(find(tiers, '!a:gamecult.org')?.via).toEqual(['one.example']); + expect(find(tiers, '!b:gamecult.org')?.via).toEqual(['two.example']); + }); + + it('puts every room of one Mind in a single group', () => { + const tiers = buildEstate( + input({ stewardships: { eureka: ['CultLib', 'Aetheria'] } }) + ); + const projects = tiers.find((t) => t.id === PROJECTS)!; + expect(projects.groups).toHaveLength(1); + expect(projects.groups[0].rooms.map((r) => r.roomId)).toEqual([ + '!cultlib:gamecult.org', + '!aeth:gamecult.org', + ]); + }); + + it('sorts group labels without regard to case', () => { + const projects = buildEstate( + input({ mindLabels: new Map([['eureka', 'beta'], ['void', 'Alpha']]) }) + ).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'beta']); + }); + + it('omits a tier whose Space returned no rooms and the Direct tier when there are no DMs', () => { + const tiers = buildEstate( + input({ + hierarchy: [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), + room('!general:gamecult.org', 'General'), + space(PROJECTS, 'Projects', ['!hidden:gamecult.org']), + ], + }) + ); + expect(tiers.map((t) => t.id)).toEqual([ROOT]); + }); + + it('puts direct messages in the final tier, off any Space', () => { + const tiers = buildEstate(input({ directRoomIds: ['!dm:gamecult.org'] })); + expect(tiers[tiers.length - 1]).toMatchObject({ id: 'direct', groups: [] }); + }); + it('is identical for identical inputs', () => { expect(buildEstate(input())).toEqual(buildEstate(input())); }); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts index 096ea9ad..9ca9048c 100644 --- a/src/app/features/estate/buildEstate.ts +++ b/src/app/features/estate/buildEstate.ts @@ -7,8 +7,6 @@ * a room it returns is never hidden by the client. */ -export const STEWARDSHIPS_EVENT_TYPE = 'org.gamecult.stewardships'; - export type EstateHierarchyRoom = { room_id: string; name?: string; @@ -77,7 +75,9 @@ export const stewardOfRoom = ( const local = canonicalAlias.replace(/^#/, '').split(':')[0].toLowerCase(); const mind = Object.keys(stewardships).find((m) => { const repos = stewardships[m]; - return Array.isArray(repos) && repos.some((r) => typeof r === 'string' && repoName(r) === local); + return ( + Array.isArray(repos) && repos.some((r) => typeof r === 'string' && repoName(r) === local) + ); }); return mind; }; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 8976f325..fae21e5b 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -48,6 +48,19 @@ describe('activateEstateRoom', () => { } ); + it('falls back to the error message, then to a generic one', async () => { + const named = recordingClient(vi.fn().mockRejectedValue(new Error('Boom'))); + expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ + kind: 'refused', + message: 'Boom', + }); + const bare = recordingClient(vi.fn().mockRejectedValue({})); + expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ + kind: 'refused', + message: 'Could not join', + }); + }); + it('reports the server refusal and does not decide access itself', async () => { const refusal = Object.assign(new Error('M_FORBIDDEN'), { data: { error: 'You are not invited to this room.' }, diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts index 6ffccd1e..99927ba8 100644 --- a/src/app/features/estate/useEstate.ts +++ b/src/app/features/estate/useEstate.ts @@ -15,7 +15,11 @@ import { buildEstate, EstateHierarchyRoom, EstateTier, Stewardships } from './bu const HIERARCHY_DEPTH = 10; -type AliasedRoom = { roomId: string; getCanonicalAlias(): string | null; getAltAliases(): string[] }; +type AliasedRoom = { + roomId: string; + getCanonicalAlias(): string | null; + getAltAliases(): string[]; +}; const findLoadedRoom = ( mx: ReturnType, diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index ae5eac70..159162dd 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -151,11 +151,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - } - > + }> } @@ -176,11 +172,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - } - > + }> } @@ -200,11 +192,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) path={SPACE_PATH} element={ - - } - > + }> diff --git a/src/app/pages/client/space/SpaceTombstone.tsx b/src/app/pages/client/space/SpaceTombstone.tsx index 3e2d13bd..16bcdf63 100644 --- a/src/app/pages/client/space/SpaceTombstone.tsx +++ b/src/app/pages/client/space/SpaceTombstone.tsx @@ -76,4 +76,3 @@ export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProp
); } - diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 1f5f0608..36c1e21b 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -3,7 +3,6 @@ export enum AccountDataEvent { Direct = 'm.direct', IgnoredUserList = 'm.ignored_user_list', - ElementRecentEmoji = 'io.element.recent_emoji', PoniesUserEmotes = 'im.ponies.user_emotes', From 067cb10a90097a300ce6f54dd875d4b084e3f46b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:13:32 +0100 Subject: [PATCH 11/64] Estate: prettier on the new tests Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/buildEstate.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 27964a7b..20129cbf 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -158,9 +158,7 @@ describe('buildEstate', () => { }); it('puts every room of one Mind in a single group', () => { - const tiers = buildEstate( - input({ stewardships: { eureka: ['CultLib', 'Aetheria'] } }) - ); + const tiers = buildEstate(input({ stewardships: { eureka: ['CultLib', 'Aetheria'] } })); const projects = tiers.find((t) => t.id === PROJECTS)!; expect(projects.groups).toHaveLength(1); expect(projects.groups[0].rooms.map((r) => r.roomId)).toEqual([ @@ -171,7 +169,12 @@ describe('buildEstate', () => { it('sorts group labels without regard to case', () => { const projects = buildEstate( - input({ mindLabels: new Map([['eureka', 'beta'], ['void', 'Alpha']]) }) + input({ + mindLabels: new Map([ + ['eureka', 'beta'], + ['void', 'Alpha'], + ]), + }) ).find((t) => t.id === PROJECTS)!; expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'beta']); }); From e8efd3ce760003f290828efd32fe55bc015d5ca4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:46:10 +0100 Subject: [PATCH 12/64] Delete SpaceTombstone: nothing renders it Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/client/space/SpaceTombstone.tsx | 78 ------------------- src/app/pages/client/space/index.ts | 1 - 2 files changed, 79 deletions(-) delete mode 100644 src/app/pages/client/space/SpaceTombstone.tsx diff --git a/src/app/pages/client/space/SpaceTombstone.tsx b/src/app/pages/client/space/SpaceTombstone.tsx deleted file mode 100644 index 16bcdf63..00000000 --- a/src/app/pages/client/space/SpaceTombstone.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { useCallback } from 'react'; -import { Box, Button, Spinner, Text, color, config } from 'folds'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { Membership } from '../../../../types/matrix/room'; -import { getViaServers } from '../../../plugins/via-servers'; -import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; -import { ContainerColor } from '../../../styles/ContainerColor.css'; -import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { BreakWord } from '../../../styles/Text.css'; - -type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; -export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { - const mx = useMatrixClient(); - const { navigateSpace } = useRoomNavigate(); - - const [joinState, handleJoin] = useAsyncCallback( - useCallback(() => { - const currentRoom = mx.getRoom(roomId); - const via = currentRoom ? getViaServers(currentRoom) : []; - return mx.joinRoom(replacementRoomId, { - viaServers: via, - }); - }, [mx, roomId, replacementRoomId]) - ); - const replacementRoom = mx.getRoom(replacementRoomId); - - const handleOpen = () => { - if (replacementRoom) navigateSpace(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId); - }; - - return ( - - - Space Upgraded - This space has been replaced and is no longer active. - {joinState.status === AsyncStatus.Error && ( - - {(joinState.error as any)?.message ?? 'Failed to join replacement space!'} - - )} - - - {replacementRoom?.getMyMembership() === Membership.Join || - joinState.status === AsyncStatus.Success ? ( - - ) : ( - - )} - - - ); -} diff --git a/src/app/pages/client/space/index.ts b/src/app/pages/client/space/index.ts index 9b79693e..477d60ff 100644 --- a/src/app/pages/client/space/index.ts +++ b/src/app/pages/client/space/index.ts @@ -1,4 +1,3 @@ export * from './SpaceProvider'; -export * from './SpaceTombstone'; export * from './Search'; export * from './RoomProvider'; From 41527f6ba387a4ee88d75997581b2031a45b6572 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:46:10 +0100 Subject: [PATCH 13/64] config.json: the GameCult Space is the featured root, so a wholesale deploy resolves the estate Co-Authored-By: Claude Sonnet 5.5 --- config.json | 12 +----------- src/app/features/estate/config.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 11 deletions(-) create mode 100644 src/app/features/estate/config.test.ts diff --git a/config.json b/config.json index a61bb966..e945d1c4 100644 --- a/config.json +++ b/config.json @@ -5,17 +5,7 @@ "featuredCommunities": { "openAsDefault": false, - "spaces": [ - "#cinny:matrix.org", - "#community:matrix.org", - "#space:unredacted.org", - "#librewolf-community:matrix.org", - "#stickers-and-emojis:tastytea.de", - "#videogames:waywardinn.com", - "#science-space:matrix.org", - "#libregaming-games:tchncs.de", - "#mathematics-on:matrix.org" - ], + "spaces": ["#gamecult:gamecult.org"], "rooms": [ "#tuwunel:grin.hu", "#freesoftware:matrix.org", diff --git a/src/app/features/estate/config.test.ts b/src/app/features/estate/config.test.ts new file mode 100644 index 00000000..4304e1c2 --- /dev/null +++ b/src/app/features/estate/config.test.ts @@ -0,0 +1,9 @@ +import { readFileSync } from 'fs'; +import { describe, expect, it } from 'vitest'; + +describe('repo config.json', () => { + it('names the GameCult Space first, so a wholesale deploy resolves the estate root', () => { + const config = JSON.parse(readFileSync('config.json', 'utf8')); + expect(config.featuredCommunities.spaces[0]).toBe('#gamecult:gamecult.org'); + }); +}); From 0b7ded9c991547fbd360fd8cf4ee1246eca2dbca Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:46:11 +0100 Subject: [PATCH 14/64] Estate: stewards from the hierarchy's Space child content, joins wait for the store, room finder on search, Create room and Join with address, Elsewhere group, invited DMs Steward groups read org.gamecult.steward from the m.space.child content the hierarchy returns, so an invited-only member gets them; root-state stewardships and GameCultStewardships are deleted. Tapping Join waits for the client store to hold the room before navigating (no invite-card frame). The header search opens the room finder on every width. Create room and Join with address are navigator actions; joined non-DM rooms outside the Space sit under Elsewhere; invited DMs list in Direct with Join. harness/estate holds the fake-homeserver Playwright pin. Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive.js | 76 +++++++++ harness/estate/fakehs.js | 108 ++++++++++++ harness/estate/run.sh | 15 ++ src/app/features/estate/EstateNavigator.tsx | 112 +++++++++++-- src/app/features/estate/buildEstate.test.ts | 154 +++++++++++++++--- src/app/features/estate/buildEstate.ts | 88 ++++++---- .../features/estate/joinEstateRoom.test.ts | 54 +++++- src/app/features/estate/joinEstateRoom.ts | 33 ++++ src/app/features/estate/useEstate.ts | 62 +++---- src/types/matrix/room.ts | 1 - 10 files changed, 601 insertions(+), 102 deletions(-) create mode 100644 harness/estate/drive.js create mode 100644 harness/estate/fakehs.js create mode 100644 harness/estate/run.sh diff --git a/harness/estate/drive.js b/harness/estate/drive.js new file mode 100644 index 00000000..674cb15e --- /dev/null +++ b/harness/estate/drive.js @@ -0,0 +1,76 @@ +// Drives the built client against fakehs.js on a phone viewport and asserts the estate navigator. +const { chromium } = require('playwright'); +const fs = require('fs'); +const out = (n) => `/out/${n}`; +const tl = []; +const fails = []; +const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) fails.push(what); }; +(async () => { + const b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); + const pg = await ctx.newPage(); + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await pg.goto('http://127.0.0.1:8080/#/home/'); + await pg.getByText('Create Room').first().waitFor({ timeout: 30000 }); + await pg.waitForTimeout(2000); + const text = () => pg.evaluate(() => document.body.innerText); + const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); }; + const open = async (name) => { const h = pg.getByText(name, { exact: true }); if (await h.count()) { await h.first().click(); await pg.waitForTimeout(300); } }; + + // Entry points + const t0 = await text(); + check(/Create Room/.test(t0) && /Join with Address/.test(t0), 'Create Room and Join with Address are navigator actions'); + await pg.getByText('Join with Address').first().click(); + await pg.waitForTimeout(400); + check((await pg.locator('input[name="addressInput"]').count()) === 1, 'Join with Address opens its prompt'); + await pg.keyboard.press('Escape'); + await pg.waitForTimeout(300); + if (await pg.locator('input[name="addressInput"]').count()) await pg.getByText('Cancel').first().click(); + + // Header search is the room finder, on a phone + await pg.locator('[aria-label="Find a room"]').first().click(); + await pg.waitForTimeout(500); + check((await pg.locator('input[placeholder="Search"]').count()) === 1 && !/#\/home\/search/.test(pg.url()), 'header search opens the room finder, not message search'); + await snap('finder'); + await pg.keyboard.press('Escape'); + await pg.waitForTimeout(300); + + // Groups for an invitee, from the hierarchy alone; Elsewhere; invited DM + for (const n of ['Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']) await open(n); + await snap('expanded'); + const t1 = (await text()).toLowerCase(); + check(t1.includes('nibu') && t1.includes('eureka'), 'steward groups render for an invitee who cannot read Space state'); + check(t1.includes('elsewhere') && t1.includes('meeting'), 'a joined room outside the Space is listed under Elsewhere'); + check(t1.includes('direct messages') && (await pg.locator('[aria-label="Join Void"]').count()) === 1, 'an invited DM is listed with Join'); + + // Join: no invite card at any frame, then the room + await pg.locator('[aria-label="Join Aetheria"]').first().click(); + const t = Date.now(); + let card = 0, room = null; + while (Date.now() - t < 6000) { + const body = await text(); + if (/Summary card/.test(body)) card++; + if (room === null && /welcome to Aetheria/.test(body)) room = Date.now() - t; + await pg.waitForTimeout(40); + } + await snap('joined'); + check(card === 0, `no invite card at any frame after the tap (saw ${card})`); + check(room !== null, `the room opened (after ${room} ms)`); + + // Join the DM: lands in the room too + await pg.goto('http://127.0.0.1:8080/#/home/'); + await pg.waitForTimeout(3000); + await open('Direct Messages'); + if (await pg.locator('[aria-label="Join Void"]').count()) { + await pg.locator('[aria-label="Join Void"]').first().click(); + let dmCard = 0; const t2 = Date.now(); + while (Date.now() - t2 < 5000) { if (/Summary card/.test(await text())) dmCard++; await pg.waitForTimeout(40); } + check(dmCard === 0 && /\/direct\//.test(pg.url()), 'the invited DM opens in Direct with no invite card'); + } + await b.close(); +})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); fails.push('error'); }).finally(() => { + fs.writeFileSync(out('timeline.txt'), tl.join('\n')); + console.log(tl.join('\n')); + process.exitCode = fails.length ? 1 : 0; +}); diff --git a/harness/estate/fakehs.js b/harness/estate/fakehs.js new file mode 100644 index 00000000..641a21f4 --- /dev/null +++ b/harness/estate/fakehs.js @@ -0,0 +1,108 @@ +// Fake Matrix homeserver + static server for the built client (harness/estate/run.sh). +// Scenario: an invitee to the GameCult Space and 6 project rooms (never a member of the +// Space, so the client cannot read its state), joined to #general and to #meeting, a room +// outside the Space, and invited to a DM. The steward of each project room is carried only +// in the m.space.child content of the hierarchy, as the accounts script writes it. +const http = require('http'), fs = require('fs'), path = require('path'); +const DIST = process.argv[2], PORT = 8080, ME = '@t:test'; +const log = []; +const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin']; +const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' }; +const DM = '!dm:test', MEETING = '!meeting:test'; +const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1) })); +const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test'; +const pendingJoin = []; +let ev = 0; +const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); +const strip = (id, name, space, dm) => [ + { type: 'm.room.create', state_key: '', sender: '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, + ...(name ? [{ type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }] : []), + ...(dm ? [{ type: 'm.room.member', state_key: '@void:test', sender: '@void:test', content: { membership: 'join', displayname: 'Void' } }] : []), + { type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } }, + { type: 'm.room.member', state_key: ME, sender: dm ? '@void:test' : '@admin:test', content: dm ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, +]; +const joinedRoom = (id, name, extra = [], space = false) => ({ + state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] }, + timeline: { events: [{ type: 'm.room.message', sender: '@admin:test', content: { msgtype: 'm.text', body: `welcome to ${name}` }, event_id: `$m${++ev}`, origin_server_ts: Date.now() }], limited: false }, + ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {}, +}); +const invites = () => { + const o = {}; + o[ROOT] = { invite_state: { events: strip(ROOT, 'GameCult', true) } }; + o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } }; + o[OP] = { invite_state: { events: strip(OP, 'Operator') } }; + PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } })); + o[DM] = { invite_state: { events: strip(DM, null, false, true) } }; + return o; +}; +const child = (id, alias) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(STEWARD[alias] ? { 'org.gamecult.steward': { repo: 'GameCult/' + alias, steward: '@' + STEWARD[alias] + ':test', mind: STEWARD[alias], source: 'x' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); +const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra }); +const hierarchy = () => [ + hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PSP)] }), + hroom(GEN, 'General', '#general:test'), + hroom(OP, 'Operator', '#operator:test'), + hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, r.alias.slice(1).split(':')[0])) }), + ...PROJ.map((r) => hroom(r.id, r.name, r.alias)), +]; +let syncN = 1; +const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' }); res.end(JSON.stringify(body)); }; +const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' }; +const flush = () => fs.writeFileSync(process.env.HSLOG, log.join('\n')); +http.createServer((req, res) => { + const u = new URL(req.url, 'http://x'); const p = decodeURIComponent(u.pathname); + if (req.method === 'OPTIONS') return json(res, 200, {}); + if (!p.startsWith('/_matrix') && !p.startsWith('/.well-known')) { + let f = path.join(DIST, p === '/' ? 'index.html' : p); + if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) f = path.join(DIST, 'index.html'); + res.writeHead(200, { 'Content-Type': mime[path.extname(f)] || 'application/octet-stream' }); + return fs.createReadStream(f).pipe(res); + } + let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { + log.push(`${Date.now()} ${req.method} ${p}`); + if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} }); + if (p.endsWith('/sync')) { + if (!u.searchParams.get('since')) log.push(`${Date.now()} INITIAL SYNC`); + const since = u.searchParams.get('since'); + if (!since) { + const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[MEETING] = joinedRoom(MEETING, 'Meeting'); + const inv = invites(); + return json(res, 200, { next_batch: 's1', rooms: { join, invite: inv, leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); + } + const wait = () => { + if (pendingJoin.length) { + const id = pendingJoin.shift(); const r = PROJ.find((x) => x.id === id) || { name: id === DM ? 'Void' : id }; + const join = {}; join[id] = joinedRoom(id, r.name); + log.push(`${Date.now()} SYNC delivers join ${id}`); + return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} } }); + } + return json(res, 200, { next_batch: since, rooms: {} }); + }; + return setTimeout(wait, pendingJoin.length ? 2500 : 1500); + } + const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/); + if (req.method === 'POST' && jm) { + const id = jm[1]; + log.push(`${Date.now()} JOIN ${id}`); + if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); + pendingJoin.push(id); + return json(res, 200, { room_id: id }); + } + if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); + if (p.includes('/directory/room/')) return json(res, 200, { room_id: ROOT, servers: ['test'] }); + if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } }); + if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} }); + if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); + if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } }); + if (p.includes('/filter')) return json(res, 200, { filter_id: '1' }); + if (p.includes('/account_data/')) return req.method === 'GET' ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }) : json(res, 200, {}); + if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' }); + if (p.includes('/members')) return json(res, 200, { chunk: [] }); + if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' }); + if (p.includes('/capabilities')) return json(res, 200, { capabilities: {} }); + if (p.includes('/devices')) return json(res, 200, { devices: [] }); + if (p.includes('summary')) return json(res, 200, { room_id: p.split('/').pop(), name: 'Summary card', num_joined_members: 1, membership: 'invite', join_rule: 'invite' }); + if (p.includes('/login')) return json(res, 200, { flows: [{ type: 'm.login.password' }] }); + return json(res, 200, {}); + }); +}).listen(PORT, () => console.log('fakehs up')); +setInterval(flush, 500); diff --git a/harness/estate/run.sh b/harness/estate/run.sh new file mode 100644 index 00000000..085b17dc --- /dev/null +++ b/harness/estate/run.sh @@ -0,0 +1,15 @@ +# Run inside playwright:v1.48.2-jammy with the repo at /src and /out writable: +# bash /src/harness/estate/run.sh +set -e +cd /src +mkdir -p /out +npm ci --no-audit --no-fund --ignore-scripts >/out/npmci.log 2>&1 +npm run build > /out/build.log 2>&1; echo BUILD_RC=$? >> /out/build.log +printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json +mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/out/pw.log 2>&1 +cp /src/harness/estate/drive.js /tmp/pw/ +HSLOG=/out/hslog.txt node /src/harness/estate/fakehs.js /src/dist > /out/hs.out 2>&1 & +HS=$! +sleep 2 +node /tmp/pw/drive.js; echo DRIVE_RC=$? +kill $HS diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 811f531b..90b3e529 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -1,18 +1,35 @@ import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAtom, useAtomValue } from 'jotai'; -import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; -import { NavCategory, NavCategoryHeader, NavItem, NavItemContent } from '../../components/nav'; +import { + NavButton, + NavCategory, + NavCategoryHeader, + NavItem, + NavItemContent, +} from '../../components/nav'; +import { UseStateProvider } from '../../components/UseStateProvider'; +import { JoinAddressPrompt } from '../../components/join-address-prompt'; import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; import { InboxTab, SettingsTab, UnverifiedTab } from '../../pages/client/sidebar'; import { + encodeSearchParamValueArray, getDirectRoomPath, + getHomeCreatePath, getHomeRoomPath, - getHomeSearchPath, getSpaceRoomPath, + withSearchParam, } from '../../pages/pathUtils'; -import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; +import { _RoomSearchParams } from '../../pages/paths'; +import { searchModalAtom } from '../../state/searchModal'; +import { isDirectInvite } from '../../utils/room'; +import { + addRoomIdToMDirect, + getCanonicalAliasOrRoomId, + guessDmRoomUserId, +} from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useCallEmbed } from '../../hooks/useCallEmbed'; @@ -26,12 +43,15 @@ import { makeNavCategoryId } from '../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { EstateRoom, EstateTier } from './buildEstate'; -import { activateEstateRoom } from './joinEstateRoom'; +import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; + +/** How long a tap waits for the client store to hold the room it just joined. */ +const JOIN_ARRIVAL_TIMEOUT_MS = 20000; import { useEstate, useEstateRootId } from './useEstate'; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); - if (room.parentId === null) return getDirectRoomPath(alias); + if (room.direct) return getDirectRoomPath(alias); // A Space the user has not joined has no Space route; the room still resolves from Home. if (mx.getRoom(room.parentId)?.getMyMembership() === 'join') { return getSpaceRoomPath(getCanonicalAliasOrRoomId(mx, room.parentId), alias); @@ -44,8 +64,19 @@ function JoinRow({ room }: { room: EstateRoom }) { const navigate = useNavigate(); const [state, join] = useAsyncCallback( useCallback(async () => { + const userId = mx.getSafeUserId(); + const dmRoom = mx.getRoom(room.roomId); + const dmUserId = + room.direct && dmRoom && isDirectInvite(dmRoom, userId) + ? guessDmRoomUserId(dmRoom, userId) + : undefined; const result = await activateEstateRoom(mx, room); if (result.kind === 'refused') throw new Error(result.message); + // joinRoom resolves before the store holds the room; opening it now shows the invite card. + if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { + throw new Error('Joined, but the room has not arrived yet. Tap Join again in a moment.'); + } + if (dmUserId) await addRoomIdToMDirect(mx, room.roomId, dmUserId); navigate(estateRoomPath(mx, room)); }, [mx, navigate, room]) ); @@ -91,7 +122,7 @@ function EstateRow({ room }: { room: EstateRoom }) { @@ -157,6 +188,64 @@ function Tier({ tier }: { tier: EstateTier }) { ); } +function EntryPoints() { + const navigate = useNavigate(); + return ( + + + navigate(getHomeCreatePath())}> + + + + + + + Create Room + + + + + + + {(open, setOpen) => ( + <> + + setOpen(true)}> + + + + + + + Join with Address + + + + + + {open && ( + setOpen(false)} + onOpen={(roomIdOrAlias, viaServers, eventId) => { + setOpen(false); + const path = getHomeRoomPath(roomIdOrAlias, eventId); + navigate( + viaServers + ? withSearchParam<_RoomSearchParams>(path, { + viaServers: encodeSearchParamValueArray(viaServers), + }) + : path + ); + }} + /> + )} + + )} + + + ); +} + function EstateTiers({ rootId }: { rootId: string }) { const { fetching, error, tiers } = useEstate(rootId); const scrollRef = useRef(null); @@ -164,6 +253,7 @@ function EstateTiers({ rootId }: { rootId: string }) { return ( + {tiers.map((tier) => ( ))} @@ -184,7 +274,7 @@ function EstateTiers({ rootId }: { rootId: string }) { /** The single navigator: every nav slot that lists rooms mounts this. */ export function EstateNavigator() { - const navigate = useNavigate(); + const openRoomFinder = useSetAtom(searchModalAtom); const { rootId, error } = useEstateRootId(); const mx = useMatrixClient(); const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; @@ -200,8 +290,8 @@ export function EstateNavigator() { navigate(getHomeSearchPath())} + aria-label="Find a room" + onClick={() => openRoomFinder(true)} > diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 20129cbf..bdc47e5c 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -1,14 +1,26 @@ import { describe, expect, it } from 'vitest'; -import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfRoom } from './buildEstate'; +import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfChild } from './buildEstate'; const ROOT = '!root:gamecult.org'; const PROJECTS = '!projects:gamecult.org'; -const space = (id: string, name: string, children: string[]): EstateHierarchyRoom => ({ +/** A Space whose child entries may carry the steward Mind, as the accounts script writes it. */ +const space = ( + id: string, + name: string, + children: string[], + stewards: Record = {} +): EstateHierarchyRoom => ({ room_id: id, name, room_type: 'm.space', - children_state: children.map((c) => ({ state_key: c, content: { via: ['gamecult.org'] } })), + children_state: children.map((c) => ({ + state_key: c, + content: { + via: ['gamecult.org'], + ...(stewards[c] ? { 'org.gamecult.steward': { mind: stewards[c], repo: 'x' } } : {}), + }, + })), }); const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => ({ room_id: id, @@ -19,22 +31,20 @@ const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => const hierarchy = [ space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), room('!general:gamecult.org', 'General', '#general:gamecult.org'), - space(PROJECTS, 'Projects', [ - '!cultlib:gamecult.org', - '!aeth:gamecult.org', - '!misc:gamecult.org', - ]), + space( + PROJECTS, + 'Projects', + ['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org'], + { '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'void' } + ), room('!cultlib:gamecult.org', 'CultLib', '#cultlib:gamecult.org'), room('!aeth:gamecult.org', 'Aetheria', '#Aetheria:gamecult.org'), room('!misc:gamecult.org', 'Misc', '#misc:gamecult.org'), ]; -const stewardships = { eureka: ['GameCult/CultLib'], void: ['GameCult/aetheria'] }; - const input = (over: Partial = {}): EstateInput => ({ rootId: ROOT, hierarchy, - stewardships, membership: new Map(), directRoomIds: [], ...over, @@ -80,7 +90,7 @@ describe('buildEstate', () => { expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4); }); - it('groups rooms by steward Mind from stewardships plus canonical alias, case-insensitively', () => { + it('groups rooms by steward Mind from the steward on each room's Space child entry', () => { const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.roomId)])).toEqual([ ['eureka', ['!cultlib:gamecult.org']], @@ -105,12 +115,35 @@ describe('buildEstate', () => { expect(projects.loose.map((r) => r.roomId)).toEqual(['!misc:gamecult.org']); }); - it('degrades to loose rows when the stewardships are unreadable', () => { - const projects = buildEstate(input({ stewardships: undefined })).find( - (t) => t.id === PROJECTS - )!; - expect(projects.groups).toEqual([]); - expect(projects.loose).toHaveLength(3); + it('groups an invited-only user by the same child-entry stewards, with no root state', () => { + const invitee = new Map([ + [ROOT, 'invite'], + [PROJECTS, 'invite'], + ['!cultlib:gamecult.org', 'invite'], + ['!aeth:gamecult.org', 'invite'], + ]); + const projects = buildEstate(input({ membership: invitee })).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.state)])).toEqual([ + ['eureka', ['invited']], + ['void', ['invited']], + ]); + }); + + it('ignores a steward that is not a non-empty string', () => { + const odd = (steward: unknown): EstateHierarchyRoom[] => [ + { + ...space(ROOT, 'GameCult', ['!a:gamecult.org']), + children_state: [ + { state_key: '!a:gamecult.org', content: { 'org.gamecult.steward': { mind: steward } } }, + ], + }, + room('!a:gamecult.org', 'A'), + ]; + [42, '', undefined, null].forEach((m) => { + const t = buildEstate(input({ hierarchy: odd(m) }))[0]; + expect(t.groups).toEqual([]); + expect(t.loose).toHaveLength(1); + }); }); it('makes one tier per sub-Space in hierarchy order, root rooms first, Direct last', () => { @@ -133,6 +166,7 @@ describe('buildEstate', () => { 'Workshop', 'Direct Messages', ]); + expect(tiers[3].loose[0].direct).toBe(true); expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']); expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null }); }); @@ -158,7 +192,20 @@ describe('buildEstate', () => { }); it('puts every room of one Mind in a single group', () => { - const tiers = buildEstate(input({ stewardships: { eureka: ['CultLib', 'Aetheria'] } })); + const tiers = buildEstate( + input({ + hierarchy: [ + ...hierarchy.slice(0, 2), + space( + PROJECTS, + 'Projects', + ['!cultlib:gamecult.org', '!aeth:gamecult.org'], + { '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'eureka' } + ), + ...hierarchy.slice(3, 5), + ], + }) + ); const projects = tiers.find((t) => t.id === PROJECTS)!; expect(projects.groups).toHaveLength(1); expect(projects.groups[0].rooms.map((r) => r.roomId)).toEqual([ @@ -202,15 +249,68 @@ describe('buildEstate', () => { }); }); -describe('stewardOfRoom', () => { - it('matches the alias localpart to the repo name, ignoring case and the org prefix', () => { - expect(stewardOfRoom('#CULTLIB:gamecult.org', stewardships)).toBe('eureka'); - expect(stewardOfRoom('#aetheria:gamecult.org', stewardships)).toBe('void'); +describe('stewardOfChild', () => { + it('reads the Mind from the org.gamecult.steward key of the child content', () => { + expect(stewardOfChild({ via: ['x'], 'org.gamecult.steward': { mind: 'void' } })).toBe('void'); }); - it('has no steward without an alias, a match or readable stewardships', () => { - expect(stewardOfRoom(undefined, stewardships)).toBeUndefined(); - expect(stewardOfRoom('#unknown:gamecult.org', stewardships)).toBeUndefined(); - expect(stewardOfRoom('#cultlib:gamecult.org', { eureka: 'GameCult/CultLib' })).toBeUndefined(); + it('has no steward without the key, content or a string Mind', () => { + expect(stewardOfChild(undefined)).toBeUndefined(); + expect(stewardOfChild({ via: ['x'] })).toBeUndefined(); + expect(stewardOfChild({ 'org.gamecult.steward': {} })).toBeUndefined(); + }); +}); + +describe('Elsewhere', () => { + const strays = (over: Partial) => + buildEstate(input({ membership: new Map([['!adhoc:x', 'join']]), ...over })).find( + (t) => t.id === 'elsewhere' + ); + + it('lists a joined non-DM room the hierarchy does not return, named from the store', () => { + const t = strays({ strayRoomIds: ['!adhoc:x'], roomNames: new Map([['!adhoc:x', 'Meeting']]) })!; + expect(t.label).toBe('Elsewhere'); + expect(t.loose).toEqual([ + { roomId: '!adhoc:x', name: 'Meeting', state: 'joined', via: [], parentId: null, direct: false }, + ]); + }); + + it('does not repeat a room the hierarchy lists or a DM, and is last before Direct', () => { + const tiers = buildEstate( + input({ + membership: new Map([ + ['!general:gamecult.org', 'join'], + ['!dm:x', 'join'], + ['!adhoc:x', 'join'], + ]), + strayRoomIds: ['!general:gamecult.org', '!dm:x', '!adhoc:x'], + directRoomIds: ['!dm:x'], + }) + ); + expect(tiers.map((t) => t.id)).toEqual([ROOT, PROJECTS, 'elsewhere', 'direct']); + expect(tiers[2].loose.map((r) => r.roomId)).toEqual(['!adhoc:x']); + }); + + it('never lists a room the user has not joined', () => { + expect(strays({ membership: new Map([['!adhoc:x', 'invite']]), strayRoomIds: ['!adhoc:x'] })).toBeUndefined(); + }); + + it('is absent when there are no strays', () => { + expect(strays({})).toBeUndefined(); + }); +}); + +describe('direct invites', () => { + it('lists an invited DM in the Direct tier as invited, named from the store', () => { + const tiers = buildEstate( + input({ + membership: new Map([['!dm:x', 'invite']]), + directRoomIds: ['!dm:x'], + roomNames: new Map([['!dm:x', 'Void']]), + }) + ); + expect(tiers[tiers.length - 1].loose).toEqual([ + { roomId: '!dm:x', name: 'Void', state: 'invited', via: [], parentId: null, direct: true }, + ]); }); }); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts index 9ca9048c..9edbf548 100644 --- a/src/app/features/estate/buildEstate.ts +++ b/src/app/features/estate/buildEstate.ts @@ -4,7 +4,12 @@ * * The server's /hierarchy of the root Space is the access rule. A room the * hierarchy does not return is never listed, whatever the client store knows; - * a room it returns is never hidden by the client. + * a room it returns is never hidden by the client. The one licensed addition is + * the user's own joined rooms that sit outside the Space (the Elsewhere tier). + * + * A room's steward is read from the hierarchy too: the content of the + * m.space.child entry that lists the room carries org.gamecult.steward, which an + * invited-only member can read (she cannot read the Space's state). */ export type EstateHierarchyRoom = { @@ -12,11 +17,16 @@ export type EstateHierarchyRoom = { name?: string; canonical_alias?: string; room_type?: string; - children_state?: { state_key: string; content?: { via?: string[] } }[]; + children_state?: { state_key: string; content?: SpaceChildContent }[]; }; -/** Mind localpart to the repos she stewards, as projected on the root Space. */ -export type Stewardships = Record; +export const STEWARD_KEY = 'org.gamecult.steward'; + +export type SpaceChildContent = { + via?: string[]; + /** The Mind who stewards the room, as the accounts script writes it. */ + [STEWARD_KEY]?: { mind?: unknown }; +}; export type EstateMembership = 'joined' | 'invited' | 'joinable'; @@ -26,8 +36,9 @@ export type EstateRoom = { state: EstateMembership; /** Servers to join through, from the hierarchy's children_state. */ via: string[]; - /** The Space tier the room sits in; null for direct messages. */ + /** The Space tier the room sits in; null for direct messages and Elsewhere rooms. */ parentId: string | null; + direct: boolean; }; export type EstateGroup = { @@ -48,38 +59,27 @@ export type EstateInput = { rootId: string; /** Rooms from the server's hierarchy of the root, in hierarchy order. */ hierarchy: readonly EstateHierarchyRoom[]; - stewardships: Stewardships | undefined; /** The user's own membership per room id, from the client store. */ membership: ReadonlyMap; + /** Joined and invited direct messages. */ directRoomIds: readonly string[]; + /** Joined non-DM, non-Space rooms, whether or not the hierarchy lists them. */ + strayRoomIds?: readonly string[]; + /** Names for rooms the hierarchy does not name (direct and stray rooms). */ + roomNames?: ReadonlyMap; /** Display names for Minds, keyed by localpart. */ mindLabels?: ReadonlyMap; }; export const DIRECT_TIER_ID = 'direct'; +export const ELSEWHERE_TIER_ID = 'elsewhere'; const SPACE_TYPE = 'm.space'; -const repoName = (repo: string): string => repo.split('/').pop()!.toLowerCase(); - -/** - * The single place a room's steward is derived: the canonical alias localpart, - * case-insensitively, against the repo names in the stewardships lists. - * (Follow-up ratatoskr:follow_up:stewardships-carry-room-ids swaps this to room ids.) - */ -export const stewardOfRoom = ( - canonicalAlias: string | undefined, - stewardships: Stewardships | undefined -): string | undefined => { - if (!canonicalAlias || !stewardships) return undefined; - const local = canonicalAlias.replace(/^#/, '').split(':')[0].toLowerCase(); - const mind = Object.keys(stewardships).find((m) => { - const repos = stewardships[m]; - return ( - Array.isArray(repos) && repos.some((r) => typeof r === 'string' && repoName(r) === local) - ); - }); - return mind; +/** The Mind a Space child entry names as the room's steward, if any. */ +export const stewardOfChild = (content: SpaceChildContent | undefined): string | undefined => { + const mind = content?.[STEWARD_KEY]?.mind; + return typeof mind === 'string' && mind !== '' ? mind : undefined; }; const stateOf = ( @@ -97,9 +97,10 @@ const roomLabel = (r: EstateHierarchyRoom): string => r.name || r.canonical_alia export const buildEstate = ({ rootId, hierarchy, - stewardships, membership, directRoomIds, + strayRoomIds = [], + roomNames, mindLabels, }: EstateInput): EstateTier[] => { const direct = new Set(directRoomIds); @@ -117,15 +118,16 @@ export const buildEstate = ({ hierarchy.forEach((r) => { if (r.room_type === SPACE_TYPE || !childIds.has(r.room_id) || direct.has(r.room_id)) return; - const via = space.children_state?.find((c) => c.state_key === r.room_id)?.content?.via ?? []; + const content = space.children_state?.find((c) => c.state_key === r.room_id)?.content; const room: EstateRoom = { roomId: r.room_id, name: roomLabel(r), state: stateOf(r.room_id, membership), - via, + via: content?.via ?? [], parentId: space.room_id, + direct: false, }; - const mind = stewardOfRoom(r.canonical_alias, stewardships); + const mind = stewardOfChild(content); if (!mind) { tier.loose.push(room); return; @@ -149,14 +151,36 @@ export const buildEstate = ({ label: 'Direct Messages', loose: directRoomIds.map((roomId) => ({ roomId, - name: roomId, + name: roomNames?.get(roomId) ?? roomId, state: stateOf(roomId, membership), via: [], parentId: null, + direct: true, })), groups: [], }; + const listedIds = new Set(hierarchy.map((r) => r.room_id)); + const elsewhereTier: EstateTier = { + id: ELSEWHERE_TIER_ID, + label: 'Elsewhere', + loose: strayRoomIds + .filter((id) => !listedIds.has(id) && !direct.has(id) && membership.get(id) === 'join') + .map((roomId) => ({ + roomId, + name: roomNames?.get(roomId) ?? roomId, + state: 'joined' as const, + via: [], + parentId: null, + direct: false, + })), + groups: [], + }; + const listed = tiers.filter((t) => t.loose.length > 0 || t.groups.length > 0); - return directTier.loose.length > 0 ? [...listed, directTier] : listed; + return [ + ...listed, + ...(elsewhereTier.loose.length > 0 ? [elsewhereTier] : []), + ...(directTier.loose.length > 0 ? [directTier] : []), + ]; }; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index fae21e5b..9bfddf1a 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { activateEstateRoom } from './joinEstateRoom'; +import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; const room = (state: 'joined' | 'invited' | 'joinable') => ({ roomId: '!r:gamecult.org', @@ -71,3 +71,55 @@ describe('activateEstateRoom', () => { expect(joinRoom).toHaveBeenCalledTimes(1); }); }); + +describe('awaitMyJoin', () => { + /** A store that gains the room when the test says so, emitting like the SDK. */ + const store = () => { + let membership: string | null = null; + const handlers = new Set<(room: { roomId: string }, m: string) => void>(); + return { + mx: { + getRoom: () => (membership ? { getMyMembership: () => membership! } : null), + on: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.add(h), + off: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.delete(h), + }, + handlers, + become: (roomId: string, m: string) => { + membership = m; + handlers.forEach((h) => h({ roomId }, m)); + }, + }; + }; + + it('resolves at once when the store already holds the joined room', async () => { + const s = store(); + s.become('!r:x', 'join'); + expect(await awaitMyJoin(s.mx, '!r:x', 1000)).toBe(true); + }); + + it('stays pending while the store still shows an invite, then resolves on join', async () => { + const s = store(); + let settled = false; + const p = awaitMyJoin(s.mx, '!r:x', 1000).then((v) => { + settled = true; + return v; + }); + s.become('!r:x', 'invite'); + s.become('!other:x', 'join'); + await Promise.resolve(); + expect(settled).toBe(false); + s.become('!r:x', 'join'); + expect(await p).toBe(true); + expect(s.handlers.size).toBe(0); + }); + + it('gives up after the timeout and stops listening', async () => { + vi.useFakeTimers(); + const s = store(); + const p = awaitMyJoin(s.mx, '!r:x', 500); + await vi.advanceTimersByTimeAsync(500); + expect(await p).toBe(false); + expect(s.handlers.size).toBe(0); + vi.useRealTimers(); + }); +}); diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index 2a5ed0d8..bec13126 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -28,3 +28,36 @@ export const activateEstateRoom = async ( return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; } }; + +type MembershipSource = { + getRoom(roomId: string): { getMyMembership(): string } | null; + on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; + off(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; +}; + +/** + * joinRoom resolves when the server accepts the join, before the client store + * holds the room; navigating then lands on the invite card. Resolves true once + * the store says the user's membership is join, false after timeoutMs. + */ +export const awaitMyJoin = ( + mx: MembershipSource, + roomId: string, + timeoutMs: number +): Promise => + new Promise((resolve) => { + if (mx.getRoom(roomId)?.getMyMembership() === 'join') { + resolve(true); + return; + } + const finish = (joined: boolean) => { + clearTimeout(timer); + mx.off('Room.myMembership', onMembership); + resolve(joined); + }; + const onMembership = (room: { roomId: string }, membership: string) => { + if (room.roomId === roomId && membership === 'join') finish(true); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + mx.on('Room.myMembership', onMembership); + }); diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts index 99927ba8..d27d4710 100644 --- a/src/app/features/estate/useEstate.ts +++ b/src/app/features/estate/useEstate.ts @@ -4,14 +4,13 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useClientConfig } from '../../hooks/useClientConfig'; import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy'; -import { useForceUpdate } from '../../hooks/useForceUpdate'; -import { getStateEvent } from '../../utils/room'; +import { isDirectInvite, isRoom, isUnsupportedRoom } from '../../utils/room'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { allRoomsAtom } from '../../state/room-list/roomList'; import { allInvitesAtom } from '../../state/room-list/inviteList'; import { mDirectAtom } from '../../state/mDirectList'; import { StateEvent } from '../../../types/matrix/room'; -import { buildEstate, EstateHierarchyRoom, EstateTier, Stewardships } from './buildEstate'; +import { buildEstate, EstateHierarchyRoom, EstateTier, stewardOfChild } from './buildEstate'; const HIERARCHY_DEPTH = 10; @@ -50,9 +49,9 @@ export type Estate = { }; /** - * Feeds buildEstate: the server hierarchy of the root Space, the root's - * stewardships from the client store, the user's membership and m.direct. - * Recomputes on membership, Space child and stewardships changes. + * Feeds buildEstate: the server hierarchy of the root Space (which also carries + * each room's steward), the user's membership, m.direct and direct invites, and + * her joined rooms outside the Space. Recomputes on membership and Space child changes. */ export const useEstate = (rootId: string): Estate => { const mx = useMatrixClient(); @@ -62,8 +61,6 @@ export const useEstate = (rootId: string): Estate => { const invited = useAtomValue(allInvitesAtom); const mDirects = useAtomValue(mDirectAtom); - const [stewardshipsVersion, bumpStewardships] = useForceUpdate(); - const refetchHierarchy = useCallback( () => queryClient.invalidateQueries({ queryKey: [rootId, 'hierarchy_level'] }), [queryClient, rootId] @@ -73,9 +70,8 @@ export const useEstate = (rootId: string): Estate => { useCallback( (event) => { if (event.getType() === StateEvent.SpaceChild) refetchHierarchy(); - if (event.getType() === StateEvent.GameCultStewardships) bumpStewardships(); }, - [refetchHierarchy, bumpStewardships] + [refetchHierarchy] ) ); // What the server lets this user see changes with invitations. @@ -83,38 +79,44 @@ export const useEstate = (rootId: string): Estate => { refetchHierarchy(); }, [invited.length, refetchHierarchy]); - const stewardships = useMemo( - () => { - const rootRoom = mx.getRoom(rootId); - return rootRoom - ? (getStateEvent(rootRoom, StateEvent.GameCultStewardships)?.getContent() as - | Stewardships - | undefined) - : undefined; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [mx, rootId, stewardshipsVersion, joined, invited] - ); - const tiers = useMemo(() => { const membership = new Map(); invited.forEach((id) => membership.set(id, 'invite')); joined.forEach((id) => membership.set(id, 'join')); + const hierarchy = Array.from(rooms.values()) as unknown as EstateHierarchyRoom[]; const domain = mx.getDomain(); const mindLabels = new Map(); - Object.keys(stewardships ?? {}).forEach((mind) => { - const name = mx.getUser(`@${mind}:${domain}`)?.displayName; - if (name) mindLabels.set(mind, name); + hierarchy.forEach((r) => + r.children_state?.forEach((c) => { + const mind = stewardOfChild(c.content); + const name = mind && mx.getUser(`@${mind}:${domain}`)?.displayName; + if (mind && name) mindLabels.set(mind, name); + }) + ); + const userId = mx.getUserId(); + const directInvites = invited.filter((id) => isDirectInvite(mx.getRoom(id), userId)); + const directRoomIds = [ + ...new Set([...Array.from(mDirects).filter((id) => joined.includes(id)), ...directInvites]), + ]; + const strayRoomIds = joined.filter((id) => { + const r = mx.getRoom(id); + return !mDirects.has(id) && isRoom(r) && !isUnsupportedRoom(r); + }); + const roomNames = new Map(); + [...directInvites, ...strayRoomIds].forEach((id) => { + const name = mx.getRoom(id)?.name; + if (name) roomNames.set(id, name); }); return buildEstate({ rootId, - hierarchy: Array.from(rooms.values()) as unknown as EstateHierarchyRoom[], - stewardships, + hierarchy, membership, - directRoomIds: Array.from(mDirects).filter((id) => joined.includes(id)), + directRoomIds, + strayRoomIds, + roomNames, mindLabels, }); - }, [mx, rootId, rooms, stewardships, joined, invited, mDirects]); + }, [mx, rootId, rooms, joined, invited, mDirects]); return { fetching, error, tiers }; }; diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 81d35bd0..c73c0f0b 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -36,7 +36,6 @@ export enum StateEvent { GroupCallMemberPrefix = 'org.matrix.msc3401.call.member', SpaceChild = 'm.space.child', - GameCultStewardships = 'org.gamecult.stewardships', SpaceParent = 'm.space.parent', PoniesRoomEmotes = 'im.ponies.room_emotes', From 1dfe38a33d2400e1b436fa49db08176ffb267789 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:48:31 +0100 Subject: [PATCH 15/64] Estate: lint order fixes, test title, harness out of prettier Co-Authored-By: Claude Sonnet 5.5 --- .prettierignore | 2 +- src/app/features/estate/EstateNavigator.tsx | 2 +- src/app/features/estate/buildEstate.test.ts | 2 +- src/app/features/estate/buildEstate.ts | 16 ++++++++-------- src/app/features/estate/joinEstateRoom.ts | 11 ++++++----- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.prettierignore b/.prettierignore index e0aa18a1..3a4d7da1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,4 @@ dist node_modules package.json package-lock.json -LICENSE \ No newline at end of file +LICENSEharness diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 90b3e529..1087c015 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -44,10 +44,10 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { EstateRoom, EstateTier } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; +import { useEstate, useEstateRootId } from './useEstate'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; -import { useEstate, useEstateRootId } from './useEstate'; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index bdc47e5c..29c58048 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -90,7 +90,7 @@ describe('buildEstate', () => { expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4); }); - it('groups rooms by steward Mind from the steward on each room's Space child entry', () => { + it('groups rooms by steward Mind from the steward on each Space child entry', () => { const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.roomId)])).toEqual([ ['eureka', ['!cultlib:gamecult.org']], diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts index 9edbf548..b9232beb 100644 --- a/src/app/features/estate/buildEstate.ts +++ b/src/app/features/estate/buildEstate.ts @@ -12,14 +12,6 @@ * invited-only member can read (she cannot read the Space's state). */ -export type EstateHierarchyRoom = { - room_id: string; - name?: string; - canonical_alias?: string; - room_type?: string; - children_state?: { state_key: string; content?: SpaceChildContent }[]; -}; - export const STEWARD_KEY = 'org.gamecult.steward'; export type SpaceChildContent = { @@ -28,6 +20,14 @@ export type SpaceChildContent = { [STEWARD_KEY]?: { mind?: unknown }; }; +export type EstateHierarchyRoom = { + room_id: string; + name?: string; + canonical_alias?: string; + room_type?: string; + children_state?: { state_key: string; content?: SpaceChildContent }[]; +}; + export type EstateMembership = 'joined' | 'invited' | 'joinable'; export type EstateRoom = { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index bec13126..df5284e3 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -50,14 +50,15 @@ export const awaitMyJoin = ( resolve(true); return; } - const finish = (joined: boolean) => { - clearTimeout(timer); - mx.off('Room.myMembership', onMembership); - resolve(joined); - }; + let finish: (joined: boolean) => void = () => undefined; const onMembership = (room: { roomId: string }, membership: string) => { if (room.roomId === roomId && membership === 'join') finish(true); }; const timer = setTimeout(() => finish(false), timeoutMs); + finish = (joined) => { + clearTimeout(timer); + mx.off('Room.myMembership', onMembership); + resolve(joined); + }; mx.on('Room.myMembership', onMembership); }); From 6eae929cd9cb7f9d1b54b8d64333e0ffb020b875 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:50:52 +0100 Subject: [PATCH 16/64] Estate: narrow parentId before the Space route lookup; prettier; ignore harness Co-Authored-By: Claude Sonnet 5.5 --- .prettierignore | 3 ++- src/app/features/estate/EstateNavigator.tsx | 2 +- src/app/features/estate/buildEstate.test.ts | 28 ++++++++++++++------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.prettierignore b/.prettierignore index 3a4d7da1..036ee8bc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,5 @@ dist node_modules package.json package-lock.json -LICENSEharness +LICENSE +harness diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 1087c015..f65ba827 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -53,7 +53,7 @@ const estateRoomPath = (mx: ReturnType, room: EstateRoom const alias = getCanonicalAliasOrRoomId(mx, room.roomId); if (room.direct) return getDirectRoomPath(alias); // A Space the user has not joined has no Space route; the room still resolves from Home. - if (mx.getRoom(room.parentId)?.getMyMembership() === 'join') { + if (room.parentId && mx.getRoom(room.parentId)?.getMyMembership() === 'join') { return getSpaceRoomPath(getCanonicalAliasOrRoomId(mx, room.parentId), alias); } return getHomeRoomPath(alias); diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 29c58048..8f898a0f 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -196,12 +196,10 @@ describe('buildEstate', () => { input({ hierarchy: [ ...hierarchy.slice(0, 2), - space( - PROJECTS, - 'Projects', - ['!cultlib:gamecult.org', '!aeth:gamecult.org'], - { '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'eureka' } - ), + space(PROJECTS, 'Projects', ['!cultlib:gamecult.org', '!aeth:gamecult.org'], { + '!cultlib:gamecult.org': 'eureka', + '!aeth:gamecult.org': 'eureka', + }), ...hierarchy.slice(3, 5), ], }) @@ -268,10 +266,20 @@ describe('Elsewhere', () => { ); it('lists a joined non-DM room the hierarchy does not return, named from the store', () => { - const t = strays({ strayRoomIds: ['!adhoc:x'], roomNames: new Map([['!adhoc:x', 'Meeting']]) })!; + const t = strays({ + strayRoomIds: ['!adhoc:x'], + roomNames: new Map([['!adhoc:x', 'Meeting']]), + })!; expect(t.label).toBe('Elsewhere'); expect(t.loose).toEqual([ - { roomId: '!adhoc:x', name: 'Meeting', state: 'joined', via: [], parentId: null, direct: false }, + { + roomId: '!adhoc:x', + name: 'Meeting', + state: 'joined', + via: [], + parentId: null, + direct: false, + }, ]); }); @@ -292,7 +300,9 @@ describe('Elsewhere', () => { }); it('never lists a room the user has not joined', () => { - expect(strays({ membership: new Map([['!adhoc:x', 'invite']]), strayRoomIds: ['!adhoc:x'] })).toBeUndefined(); + expect( + strays({ membership: new Map([['!adhoc:x', 'invite']]), strayRoomIds: ['!adhoc:x'] }) + ).toBeUndefined(); }); it('is absent when there are no strays', () => { From 1cb18a279533afd1b73f4eb4df4404080d2dc646 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:55:38 +0100 Subject: [PATCH 17/64] harness: CommonJS scripts under a type-module package Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/{drive.js => drive.cjs} | 0 harness/estate/{fakehs.js => fakehs.cjs} | 0 harness/estate/run.sh | 6 +++--- 3 files changed, 3 insertions(+), 3 deletions(-) rename harness/estate/{drive.js => drive.cjs} (100%) rename harness/estate/{fakehs.js => fakehs.cjs} (100%) diff --git a/harness/estate/drive.js b/harness/estate/drive.cjs similarity index 100% rename from harness/estate/drive.js rename to harness/estate/drive.cjs diff --git a/harness/estate/fakehs.js b/harness/estate/fakehs.cjs similarity index 100% rename from harness/estate/fakehs.js rename to harness/estate/fakehs.cjs diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 085b17dc..b37d3524 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -7,9 +7,9 @@ npm ci --no-audit --no-fund --ignore-scripts >/out/npmci.log 2>&1 npm run build > /out/build.log 2>&1; echo BUILD_RC=$? >> /out/build.log printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/out/pw.log 2>&1 -cp /src/harness/estate/drive.js /tmp/pw/ -HSLOG=/out/hslog.txt node /src/harness/estate/fakehs.js /src/dist > /out/hs.out 2>&1 & +cp /src/harness/estate/drive.cjs /tmp/pw/ +HSLOG=/out/hslog.txt node /src/harness/estate/fakehs.cjs /src/dist > /out/hs.out 2>&1 & HS=$! sleep 2 -node /tmp/pw/drive.js; echo DRIVE_RC=$? +node /tmp/pw/drive.cjs; echo DRIVE_RC=$? kill $HS From 11f0a4d325ae85152605a230eb541a36e393869c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:58:01 +0100 Subject: [PATCH 18/64] harness: reload instead of dismissing the address prompt Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive.cjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 674cb15e..126781f3 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -24,9 +24,9 @@ const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!o await pg.getByText('Join with Address').first().click(); await pg.waitForTimeout(400); check((await pg.locator('input[name="addressInput"]').count()) === 1, 'Join with Address opens its prompt'); - await pg.keyboard.press('Escape'); - await pg.waitForTimeout(300); - if (await pg.locator('input[name="addressInput"]').count()) await pg.getByText('Cancel').first().click(); + await pg.reload(); + await pg.getByText('Create Room').first().waitFor({ timeout: 30000 }); + await pg.waitForTimeout(2000); // Header search is the room finder, on a phone await pg.locator('[aria-label="Find a room"]').first().click(); From d07c7f2327810084335b02f8cd7612bd5e0c2a9b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:59:53 +0100 Subject: [PATCH 19/64] harness: expand Elsewhere and Direct only when still collapsed Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive.cjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 126781f3..6560b4e8 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -38,6 +38,8 @@ const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!o // Groups for an invitee, from the hierarchy alone; Elsewhere; invited DM for (const n of ['Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']) await open(n); + // A tier that was not rendered yet when its header was tapped is still collapsed. + if (!(await text()).toLowerCase().includes('meeting')) await open('Elsewhere'); await snap('expanded'); const t1 = (await text()).toLowerCase(); check(t1.includes('nibu') && t1.includes('eureka'), 'steward groups render for an invitee who cannot read Space state'); @@ -61,7 +63,8 @@ const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!o // Join the DM: lands in the room too await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); - await open('Direct Messages'); + if (!(await pg.locator('[aria-label="Join Void"]').count())) await open('Direct Messages'); + check((await pg.locator('[aria-label="Join Void"]').count()) === 1, 'the DM invite is still listed after the other join'); if (await pg.locator('[aria-label="Join Void"]').count()) { await pg.locator('[aria-label="Join Void"]').first().click(); let dmCard = 0; const t2 = Date.now(); From a058d2a2c6e2b075d3549f1d072d304d989d7a75 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:01:32 +0100 Subject: [PATCH 20/64] harness: the fake server echoes account data in the next sync Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/fakehs.cjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index 641a21f4..f528f903 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -12,6 +12,7 @@ const DM = '!dm:test', MEETING = '!meeting:test'; const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1) })); const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test'; const pendingJoin = []; +const pendingAccountData = []; let ev = 0; const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); const strip = (id, name, space, dm) => [ @@ -75,6 +76,7 @@ http.createServer((req, res) => { log.push(`${Date.now()} SYNC delivers join ${id}`); return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} } }); } + if (pendingAccountData.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: {}, account_data: { events: pendingAccountData.splice(0) } }); return json(res, 200, { next_batch: since, rooms: {} }); }; return setTimeout(wait, pendingJoin.length ? 2500 : 1500); @@ -94,6 +96,12 @@ http.createServer((req, res) => { if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } }); if (p.includes('/filter')) return json(res, 200, { filter_id: '1' }); + if (p.includes('/account_data/') && req.method === 'PUT') { + const type = decodeURIComponent(p.split('/account_data/')[1]); + try { pendingAccountData.push({ type, content: JSON.parse(body) }); } catch (e) { /* ignore */ } + log.push(`${Date.now()} PUT account_data ${type}`); + return json(res, 200, {}); + } if (p.includes('/account_data/')) return req.method === 'GET' ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }) : json(res, 200, {}); if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' }); if (p.includes('/members')) return json(res, 200, { chunk: [] }); From 0091e5e90d73d57a981d2b26a3a9eebd99d4c572 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:26:18 +0100 Subject: [PATCH 21/64] harness: Soul's s2 scenarios (unresolved root, slow DM and room joins, tap-target inventory) --- harness/estate/drive.cjs | 135 +++++++++++++++++++------------------- harness/estate/fakehs.cjs | 89 +++++++++++++------------ harness/estate/run.sh | 15 +++-- 3 files changed, 123 insertions(+), 116 deletions(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 6560b4e8..0dd254de 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,79 +1,82 @@ -// Drives the built client against fakehs.js on a phone viewport and asserts the estate navigator. const { chromium } = require('playwright'); const fs = require('fs'); -const out = (n) => `/out/${n}`; +const TAG = process.env.TAG; +const out = (n) => `/out/${TAG}-${n}`; const tl = []; -const fails = []; -const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) fails.push(what); }; +const T0 = Date.now(); +const t = () => `+${Date.now() - T0}ms`; +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; (async () => { const b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); const pg = await ctx.newPage(); + pg.on('console', (m) => { if (m.type() === 'error' && !/OIDC|issuer|endpoint|supported|ERR_NAME|404/.test(m.text())) tl.push('console.error ' + m.text().slice(0, 200)); }); await pg.goto('http://127.0.0.1:8080/'); await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); await pg.goto('http://127.0.0.1:8080/#/home/'); - await pg.getByText('Create Room').first().waitFor({ timeout: 30000 }); - await pg.waitForTimeout(2000); + await pg.waitForTimeout(12000); const text = () => pg.evaluate(() => document.body.innerText); - const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); }; - const open = async (name) => { const h = pg.getByText(name, { exact: true }); if (await h.count()) { await h.first().click(); await pg.waitForTimeout(300); } }; - - // Entry points - const t0 = await text(); - check(/Create Room/.test(t0) && /Join with Address/.test(t0), 'Create Room and Join with Address are navigator actions'); - await pg.getByText('Join with Address').first().click(); - await pg.waitForTimeout(400); - check((await pg.locator('input[name="addressInput"]').count()) === 1, 'Join with Address opens its prompt'); - await pg.reload(); - await pg.getByText('Create Room').first().waitFor({ timeout: 30000 }); - await pg.waitForTimeout(2000); - - // Header search is the room finder, on a phone - await pg.locator('[aria-label="Find a room"]').first().click(); - await pg.waitForTimeout(500); - check((await pg.locator('input[placeholder="Search"]').count()) === 1 && !/#\/home\/search/.test(pg.url()), 'header search opens the room finder, not message search'); - await snap('finder'); - await pg.keyboard.press('Escape'); - await pg.waitForTimeout(300); - - // Groups for an invitee, from the hierarchy alone; Elsewhere; invited DM - for (const n of ['Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']) await open(n); - // A tier that was not rendered yet when its header was tapped is still collapsed. - if (!(await text()).toLowerCase().includes('meeting')) await open('Elsewhere'); - await snap('expanded'); - const t1 = (await text()).toLowerCase(); - check(t1.includes('nibu') && t1.includes('eureka'), 'steward groups render for an invitee who cannot read Space state'); - check(t1.includes('elsewhere') && t1.includes('meeting'), 'a joined room outside the Space is listed under Elsewhere'); - check(t1.includes('direct messages') && (await pg.locator('[aria-label="Join Void"]').count()) === 1, 'an invited DM is listed with Join'); - - // Join: no invite card at any frame, then the room - await pg.locator('[aria-label="Join Aetheria"]').first().click(); - const t = Date.now(); - let card = 0, room = null; - while (Date.now() - t < 6000) { - const body = await text(); - if (/Summary card/.test(body)) card++; - if (room === null && /welcome to Aetheria/.test(body)) room = Date.now() - t; - await pg.waitForTimeout(40); - } - await snap('joined'); - check(card === 0, `no invite card at any frame after the tap (saw ${card})`); - check(room !== null, `the room opened (after ${room} ms)`); - - // Join the DM: lands in the room too - await pg.goto('http://127.0.0.1:8080/#/home/'); - await pg.waitForTimeout(3000); - if (!(await pg.locator('[aria-label="Join Void"]').count())) await open('Direct Messages'); - check((await pg.locator('[aria-label="Join Void"]').count()) === 1, 'the DM invite is still listed after the other join'); - if (await pg.locator('[aria-label="Join Void"]').count()) { - await pg.locator('[aria-label="Join Void"]').first().click(); - let dmCard = 0; const t2 = Date.now(); - while (Date.now() - t2 < 5000) { if (/Summary card/.test(await text())) dmCard++; await pg.waitForTimeout(40); } - check(dmCard === 0 && /\/direct\//.test(pg.url()), 'the invited DM opens in Direct with no invite card'); + const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); tl.push(`--- ${n} ${t()} url=${pg.url()}\n${await text()}`); }; + const buttons = async () => tl.push('buttons ' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('button')].map((e) => { const r = e.getBoundingClientRect(); return [e.getAttribute('aria-label') || e.innerText.slice(0, 30), Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)]; })))); + const small = async () => tl.push('SMALL ' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('button, a, [role="button"]')].filter((e) => e.getClientRects().length).map((e) => [e.getAttribute('aria-label') || e.innerText.slice(0, 30), Math.round(e.getBoundingClientRect().width), Math.round(e.getBoundingClientRect().height)]).filter((r) => r[2] < 44)))); + const layout = async () => tl.push('layout ' + JSON.stringify(await pg.evaluate(() => ({ vw: innerWidth, scrollW: document.documentElement.scrollWidth, bodyScrollW: document.body.scrollWidth })))); + await snap('1-landing'); await buttons(); await layout(); await small(); + if (process.env.NOROOT === '1') { + tl.push('has Create Room: ' + (await pg.getByText('Create Room', { exact: true }).count()) + ' Join with Address: ' + (await pg.getByText('Join with Address', { exact: true }).count()) + ' finder: ' + (await pg.locator('[aria-label="Find a room"]').count())); + await b.close(); return; } + const reveal = async (sel) => { + const loc = pg.locator(sel); + if (await loc.count()) return loc; + for (let round = 0; round < 2; round++) for (const h of HEADERS) { + const hl = pg.getByText(h, { exact: true }); + if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) { tl.push(`revealed ${sel} via ${h}`); return loc; } } + } + tl.push(`could not reveal ${sel}`); return loc; + }; + // expand everything once for the inventory + for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); tl.push('clicked header ' + h); } } + await snap('2-expanded'); await buttons(); await layout(); await small(); + // room finder + const finder = pg.locator('[aria-label="Find a room"], [aria-label="Search"]').first(); + await finder.click(); await pg.waitForTimeout(800); + tl.push('after finder url=' + pg.url() + ' dialogs=' + (await pg.locator('[role="dialog"]').count()) + ' inputs=' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('input')].map((i) => i.placeholder)))); + await snap('3-finder'); + await pg.keyboard.press('Escape'); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); + // join with frame poll + const joinPoll = async (name, ms) => { + const loc = await reveal(`[aria-label="Join ${name}"]`); + if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } + const u0 = pg.url(); await loc.first().click(); const c = Date.now(); tl.push(`CLICK Join ${name} ${t()}`); + let card = 0, frames = 0, urlAt = null, firstCardAt = null; + while (Date.now() - c < ms) { + const s = await text(); frames++; + if (s.includes('Summary card')) { card++; if (firstCardAt === null) firstCardAt = Date.now() - c; } + if (urlAt === null && pg.url() !== u0) urlAt = Date.now() - c; + await pg.waitForTimeout(40); + } + tl.push(`POLL ${name}: frames=${frames} cardFrames=${card} firstCardAt=${firstCardAt} urlChangedAt=${urlAt} url=${pg.url()}`); + await snap(`4-join-${name.replace(/\W/g, '')}`); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); + }; + await joinPoll('Commons', 6000); + await joinPoll('Aetheria', 6000); + await joinPoll('Friend DM', 6000); + // timeout path, room + const slow = async (name) => { + const loc = await reveal(`[aria-label="Join ${name}"]`); + if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } + await loc.first().click(); tl.push(`CLICK Join ${name} ${t()}`); + await pg.waitForTimeout(21500); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); + tl.push(`timeout msg shown: ${(await text()).includes('has not arrived yet')}`); + await pg.waitForTimeout(5500); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); + tl.push(`after arrival: Join ${name} still present=${await pg.locator(`[aria-label="Join ${name}"]`).count()} url=${pg.url()}`); + }; + await slow('Odin'); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); + await slow('Slow DM'); + // wide: tier placement (wide tiers start open) + await pg.setViewportSize({ width: 1280, height: 800 }); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(6000); await snap('6-wide'); await b.close(); -})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); fails.push('error'); }).finally(() => { - fs.writeFileSync(out('timeline.txt'), tl.join('\n')); - console.log(tl.join('\n')); - process.exitCode = fails.length ? 1 : 0; -}); +})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); process.exitCode = 1; }).finally(() => fs.writeFileSync(out('timeline.txt'), tl.join('\n'))); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index f528f903..3b9ed641 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -1,26 +1,26 @@ -// Fake Matrix homeserver + static server for the built client (harness/estate/run.sh). -// Scenario: an invitee to the GameCult Space and 6 project rooms (never a member of the -// Space, so the client cannot read its state), joined to #general and to #meeting, a room -// outside the Space, and invited to a DM. The steward of each project room is carried only -// in the m.space.child content of the hierarchy, as the accounts script writes it. +// Soul s2 fake homeserver. Invitee with no root state; stewards only in m.space.child content. +// Rooms: GEN joined; OP invited; 6 project rooms invited (odin slow: join arrives at +24 s); +// PUB public joinable, not in store; STRAY joined outside hierarchy; DM1 invited direct (fast); +// DM2 invited direct (slow, +24 s). NOROOT=1: the root alias does not resolve. const http = require('http'), fs = require('fs'), path = require('path'); const DIST = process.argv[2], PORT = 8080, ME = '@t:test'; +const NOROOT = process.env.NOROOT === '1'; const log = []; const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin']; const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' }; -const DM = '!dm:test', MEETING = '!meeting:test'; -const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1) })); -const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test'; -const pendingJoin = []; -const pendingAccountData = []; -let ev = 0; +const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p })); +const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test'; +const SLOW = new Set([PROJ[5].id, DM2]); +const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [STRAY]: 'Adhoc' }; +PROJ.forEach((r) => (NAMES[r.id] = r.name)); +const pending = []; const pendingAD = []; let ev = 0; const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); -const strip = (id, name, space, dm) => [ - { type: 'm.room.create', state_key: '', sender: '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, - ...(name ? [{ type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }] : []), - ...(dm ? [{ type: 'm.room.member', state_key: '@void:test', sender: '@void:test', content: { membership: 'join', displayname: 'Void' } }] : []), +const strip = (id, name, space, direct) => [ + { type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, + { type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }, { type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } }, - { type: 'm.room.member', state_key: ME, sender: dm ? '@void:test' : '@admin:test', content: dm ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, + ...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []), + { type: 'm.room.member', state_key: ME, sender: direct ? '@friend:test' : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, ]; const joinedRoom = (id, name, extra = [], space = false) => ({ state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] }, @@ -33,16 +33,18 @@ const invites = () => { o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } }; o[OP] = { invite_state: { events: strip(OP, 'Operator') } }; PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } })); - o[DM] = { invite_state: { events: strip(DM, null, false, true) } }; + o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } }; + o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } }; return o; }; -const child = (id, alias) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(STEWARD[alias] ? { 'org.gamecult.steward': { repo: 'GameCult/' + alias, steward: '@' + STEWARD[alias] + ':test', mind: STEWARD[alias], source: 'x' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); +const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra }); const hierarchy = () => [ - hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PSP)] }), + hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PUB), child(PSP)] }), hroom(GEN, 'General', '#general:test'), hroom(OP, 'Operator', '#operator:test'), - hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, r.alias.slice(1).split(':')[0])) }), + hroom(PUB, 'Commons', '#commons:test', { join_rule: 'public' }), + hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, STEWARD[r.key])) }), ...PROJ.map((r) => hroom(r.id, r.name, r.alias)), ]; let syncN = 1; @@ -59,50 +61,47 @@ http.createServer((req, res) => { return fs.createReadStream(f).pipe(res); } let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { - log.push(`${Date.now()} ${req.method} ${p}`); + log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`); if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} }); if (p.endsWith('/sync')) { - if (!u.searchParams.get('since')) log.push(`${Date.now()} INITIAL SYNC`); const since = u.searchParams.get('since'); if (!since) { - const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[MEETING] = joinedRoom(MEETING, 'Meeting'); - const inv = invites(); - return json(res, 200, { next_batch: 's1', rooms: { join, invite: inv, leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); + const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc'); + return json(res, 200, { next_batch: 's1', rooms: { join, invite: invites(), leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); } - const wait = () => { - if (pendingJoin.length) { - const id = pendingJoin.shift(); const r = PROJ.find((x) => x.id === id) || { name: id === DM ? 'Void' : id }; - const join = {}; join[id] = joinedRoom(id, r.name); - log.push(`${Date.now()} SYNC delivers join ${id}`); - return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} } }); + const respond = () => { + const now = Date.now(); const join = {}; + for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) { + const id = pending[i].id; pending.splice(i, 1); + const extra = id === DM1 || id === DM2 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : []; + join[id] = joinedRoom(id, NAMES[id] || id, extra); log.push(`${now} SYNC delivers join ${id}`); } - if (pendingAccountData.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: {}, account_data: { events: pendingAccountData.splice(0) } }); + const ad = pendingAD.splice(0); + if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } }); return json(res, 200, { next_batch: since, rooms: {} }); }; - return setTimeout(wait, pendingJoin.length ? 2500 : 1500); + return setTimeout(respond, 300); } const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/); if (req.method === 'POST' && jm) { - const id = jm[1]; - log.push(`${Date.now()} JOIN ${id}`); + const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`); if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); - pendingJoin.push(id); + pending.push({ id, at: Date.now() + (SLOW.has(id) ? 24000 : 1000) }); return json(res, 200, { room_id: id }); } + if (p.includes('/account_data/') && req.method === 'PUT') { + const type = decodeURIComponent(p.split('/account_data/')[1]); + try { pendingAD.push({ type, content: JSON.parse(body) }); } catch (e) {} + return json(res, 200, {}); + } if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); - if (p.includes('/directory/room/')) return json(res, 200, { room_id: ROOT, servers: ['test'] }); + if (p.includes('/directory/room/')) return NOROOT ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'Room alias not found' }) : json(res, 200, { room_id: ROOT, servers: ['test'] }); if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } }); if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} }); if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } }); if (p.includes('/filter')) return json(res, 200, { filter_id: '1' }); - if (p.includes('/account_data/') && req.method === 'PUT') { - const type = decodeURIComponent(p.split('/account_data/')[1]); - try { pendingAccountData.push({ type, content: JSON.parse(body) }); } catch (e) { /* ignore */ } - log.push(`${Date.now()} PUT account_data ${type}`); - return json(res, 200, {}); - } - if (p.includes('/account_data/')) return req.method === 'GET' ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }) : json(res, 200, {}); + if (p.includes('/account_data/')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' }); if (p.includes('/members')) return json(res, 200, { chunk: [] }); if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' }); @@ -113,4 +112,4 @@ http.createServer((req, res) => { return json(res, 200, {}); }); }).listen(PORT, () => console.log('fakehs up')); -setInterval(flush, 500); +setInterval(flush, 300); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index b37d3524..af8bb5db 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -1,5 +1,6 @@ # Run inside playwright:v1.48.2-jammy with the repo at /src and /out writable: # bash /src/harness/estate/run.sh +# Scenario A: root resolves. Scenario B (NOROOT=1): the root alias 404s. set -e cd /src mkdir -p /out @@ -8,8 +9,12 @@ npm run build > /out/build.log 2>&1; echo BUILD_RC=$? >> /out/build.log printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/out/pw.log 2>&1 cp /src/harness/estate/drive.cjs /tmp/pw/ -HSLOG=/out/hslog.txt node /src/harness/estate/fakehs.cjs /src/dist > /out/hs.out 2>&1 & -HS=$! -sleep 2 -node /tmp/pw/drive.cjs; echo DRIVE_RC=$? -kill $HS +runone () { # tag noroot + HSLOG=/out/hslog-$1.txt NOROOT=$2 node /src/harness/estate/fakehs.cjs /src/dist > /out/hs-$1.out 2>&1 & + HS=$! + sleep 2 + TAG=$1 NOROOT=$2 node /tmp/pw/drive.cjs; echo "DRIVE $1 RC=$?" + kill $HS +} +runone root 0 +runone noroot 1 From 44774a3ba16165374a3f99ae035b8f3a40670466 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:26:45 +0100 Subject: [PATCH 22/64] estate navigator: entry points belong to the navigator, not the tiers; short wrapping errors; a DM joined after the timeout is filed on arrival; 44 px join targets on phones --- src/app/features/estate/EstateNavigator.tsx | 92 ++++++++++++++------- 1 file changed, 64 insertions(+), 28 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index f65ba827..63d0c0c2 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -48,6 +48,10 @@ import { useEstate, useEstateRootId } from './useEstate'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; +/** How long after a timed-out tap the store may still deliver the room and have its DM filed. */ +const LATE_ARRIVAL_WINDOW_MS = 10 * 60 * 1000; +/** Minimum tap target on phones. */ +const TOUCH_TARGET = 44; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); @@ -62,6 +66,7 @@ const estateRoomPath = (mx: ReturnType, room: EstateRoom function JoinRow({ room }: { room: EstateRoom }) { const mx = useMatrixClient(); const navigate = useNavigate(); + const mobile = useScreenSizeContext() === ScreenSize.Mobile; const [state, join] = useAsyncCallback( useCallback(async () => { const userId = mx.getSafeUserId(); @@ -74,7 +79,14 @@ function JoinRow({ room }: { room: EstateRoom }) { if (result.kind === 'refused') throw new Error(result.message); // joinRoom resolves before the store holds the room; opening it now shows the invite card. if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { - throw new Error('Joined, but the room has not arrived yet. Tap Join again in a moment.'); + // The join is accepted; when the room arrives the row becomes a joined row by itself, + // so a DM is filed then (a failed write leaves it under Elsewhere, where it can be found). + if (dmUserId) { + awaitMyJoin(mx, room.roomId, LATE_ARRIVAL_WINDOW_MS) + .then((arrived) => arrived && addRoomIdToMDirect(mx, room.roomId, dmUserId)) + .catch(() => undefined); + } + throw new Error('Joined. The room is still on its way and will open from this list.'); } if (dmUserId) await addRoomIdToMDirect(mx, room.roomId, dmUserId); navigate(estateRoomPath(mx, room)); @@ -84,14 +96,23 @@ function JoinRow({ room }: { room: EstateRoom }) { return ( - + {room.name} {state.status === AsyncStatus.Error && ( - + {(state.error as Error).message} )} @@ -102,6 +123,7 @@ function JoinRow({ room }: { room: EstateRoom }) { before={busy ? : } onClick={join} disabled={busy} + style={mobile ? { minHeight: TOUCH_TARGET, minWidth: TOUCH_TARGET } : undefined} aria-label={`Join ${room.name}`} > Join @@ -248,27 +270,33 @@ function EntryPoints() { function EstateTiers({ rootId }: { rootId: string }) { const { fetching, error, tiers } = useEstate(rootId); - const scrollRef = useRef(null); return ( - - - - {tiers.map((tier) => ( - - ))} - {fetching && ( - - - - )} - {error && ( - - {error.message} - - )} - - + <> + {tiers.map((tier) => ( + + ))} + {fetching && ( + + + + )} + {error && ( + + )} + + ); +} + +/** A short message that wraps inside the navigator; never a raw request error. */ +function EstateError({ message }: { message: string }) { + return ( + + {message} + ); } @@ -276,6 +304,7 @@ function EstateTiers({ rootId }: { rootId: string }) { export function EstateNavigator() { const openRoomFinder = useSetAtom(searchModalAtom); const { rootId, error } = useEstateRootId(); + const scrollRef = useRef(null); const mx = useMatrixClient(); const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; @@ -297,13 +326,20 @@ export function EstateNavigator() { - {rootId ? ( - - ) : ( - - {error ? {error.message} : } + + + + {rootId && } + {!rootId && error && ( + + )} + {!rootId && !error && ( + + + + )} - )} + From 2e179a77687548c95d6374c8870413de412769c5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:26:52 +0100 Subject: [PATCH 23/64] harness: print the verdict lines --- harness/estate/run.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/harness/estate/run.sh b/harness/estate/run.sh index af8bb5db..c499cc5b 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -18,3 +18,10 @@ runone () { # tag noroot } runone root 0 runone noroot 1 +for t in root noroot; do + echo "=== $t" + grep -E '^(SMALL|layout|has Create|CLICK|POLL|timeout|after arrival|revealed|--- )' /out/$t-timeline.txt | cut -c1-1800 || true +done +echo "=== m.direct and joins (root)" +grep -E 'direct|JOIN' /out/hslog-root.txt | cut -c1-220 || true +tail -1 /out/build.log From d7afdc520805e0fa4bc4391c79ed2a64c663c3b3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:30:54 +0100 Subject: [PATCH 24/64] estate navigator: 44 px tap targets on phones from one stylesheet rule over the navigator --- harness/estate/drive.cjs | 2 +- harness/estate/run.sh | 1 + .../features/estate/EstateNavigator.css.ts | 14 +++++++++++ src/app/features/estate/EstateNavigator.tsx | 25 +++++++++---------- 4 files changed, 28 insertions(+), 14 deletions(-) create mode 100644 src/app/features/estate/EstateNavigator.css.ts diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 0dd254de..fae012a6 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -68,7 +68,7 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } await loc.first().click(); tl.push(`CLICK Join ${name} ${t()}`); await pg.waitForTimeout(21500); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); - tl.push(`timeout msg shown: ${(await text()).includes('has not arrived yet')}`); + tl.push(`timeout msg shown: ${(await text()).includes('still on its way')}`); await pg.waitForTimeout(5500); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); tl.push(`after arrival: Join ${name} still present=${await pg.locator(`[aria-label="Join ${name}"]`).count()} url=${pg.url()}`); }; diff --git a/harness/estate/run.sh b/harness/estate/run.sh index c499cc5b..e044d07d 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -22,6 +22,7 @@ for t in root noroot; do echo "=== $t" grep -E '^(SMALL|layout|has Create|CLICK|POLL|timeout|after arrival|revealed|--- )' /out/$t-timeline.txt | cut -c1-1800 || true done +ls -la /out | head -30 echo "=== m.direct and joins (root)" grep -E 'direct|JOIN' /out/hslog-root.txt | cut -c1-220 || true tail -1 /out/build.log diff --git a/src/app/features/estate/EstateNavigator.css.ts b/src/app/features/estate/EstateNavigator.css.ts new file mode 100644 index 00000000..157d69cd --- /dev/null +++ b/src/app/features/estate/EstateNavigator.css.ts @@ -0,0 +1,14 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; + +/** On phones every control inside the navigator is at least 44 px square to tap. */ +export const Targets = style({}); + +globalStyle(`${Targets} button, ${Targets} a`, { + '@media': { + [`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: { + minHeight: 44, + minWidth: 44, + }, + }, +}); diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 63d0c0c2..e36bfb04 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -45,13 +45,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { EstateRoom, EstateTier } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; +import * as css from './EstateNavigator.css'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; /** How long after a timed-out tap the store may still deliver the room and have its DM filed. */ const LATE_ARRIVAL_WINDOW_MS = 10 * 60 * 1000; -/** Minimum tap target on phones. */ -const TOUCH_TARGET = 44; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); @@ -66,7 +65,6 @@ const estateRoomPath = (mx: ReturnType, room: EstateRoom function JoinRow({ room }: { room: EstateRoom }) { const mx = useMatrixClient(); const navigate = useNavigate(); - const mobile = useScreenSizeContext() === ScreenSize.Mobile; const [state, join] = useAsyncCallback( useCallback(async () => { const userId = mx.getSafeUserId(); @@ -96,12 +94,7 @@ function JoinRow({ room }: { room: EstateRoom }) { return ( - + {room.name} @@ -123,7 +116,6 @@ function JoinRow({ room }: { room: EstateRoom }) { before={busy ? : } onClick={join} disabled={busy} - style={mobile ? { minHeight: TOUCH_TARGET, minWidth: TOUCH_TARGET } : undefined} aria-label={`Join ${room.name}`} > Join @@ -311,7 +303,7 @@ export function EstateNavigator() { return ( - + {title} @@ -327,7 +319,7 @@ export function EstateNavigator() { - + {rootId && } {!rootId && error && ( @@ -340,7 +332,14 @@ export function EstateNavigator() { )} - + From 98d74d564d8297db06840cc701877b88f48a0c47 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:35:36 +0100 Subject: [PATCH 25/64] estate navigator: define EstateError before its use --- src/app/features/estate/EstateNavigator.tsx | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index e36bfb04..e81f5a67 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -260,6 +260,18 @@ function EntryPoints() { ); } +/** A short message that wraps inside the navigator; never a raw request error. */ +function EstateError({ message }: { message: string }) { + return ( + + {message} + + ); +} + function EstateTiers({ rootId }: { rootId: string }) { const { fetching, error, tiers } = useEstate(rootId); @@ -280,18 +292,6 @@ function EstateTiers({ rootId }: { rootId: string }) { ); } -/** A short message that wraps inside the navigator; never a raw request error. */ -function EstateError({ message }: { message: string }) { - return ( - - {message} - - ); -} - /** The single navigator: every nav slot that lists rooms mounts this. */ export function EstateNavigator() { const openRoomFinder = useSetAtom(searchModalAtom); From eeb9de8f401b2158ebc02cf72bb77a2c52d07561 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:36:44 +0100 Subject: [PATCH 26/64] estate navigator: the late DM filing returns void --- src/app/features/estate/EstateNavigator.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index e81f5a67..68575932 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -81,7 +81,9 @@ function JoinRow({ room }: { room: EstateRoom }) { // so a DM is filed then (a failed write leaves it under Elsewhere, where it can be found). if (dmUserId) { awaitMyJoin(mx, room.roomId, LATE_ARRIVAL_WINDOW_MS) - .then((arrived) => arrived && addRoomIdToMDirect(mx, room.roomId, dmUserId)) + .then(async (arrived) => { + if (arrived) await addRoomIdToMDirect(mx, room.roomId, dmUserId); + }) .catch(() => undefined); } throw new Error('Joined. The room is still on its way and will open from this list.'); From bfb753e3e5e1562aa1bd11d2651adcd9a645d073 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:38:43 +0100 Subject: [PATCH 27/64] harness: print the unresolved-root message --- harness/estate/run.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/harness/estate/run.sh b/harness/estate/run.sh index e044d07d..d4dd7cf4 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -23,6 +23,7 @@ for t in root noroot; do grep -E '^(SMALL|layout|has Create|CLICK|POLL|timeout|after arrival|revealed|--- )' /out/$t-timeline.txt | cut -c1-1800 || true done ls -la /out | head -30 +cat /out/noroot-timeline.txt | grep -m3 -i "could not" || true echo "=== m.direct and joins (root)" grep -E 'direct|JOIN' /out/hslog-root.txt | cut -c1-220 || true tail -1 /out/build.log From c91d0339b506131938dc775a3123b696c3c376ce Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:44:39 +0100 Subject: [PATCH 28/64] harness: read the timeout message later in the pre-arrival window --- harness/estate/drive.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index fae012a6..5d69d02a 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -67,9 +67,9 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El const loc = await reveal(`[aria-label="Join ${name}"]`); if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } await loc.first().click(); tl.push(`CLICK Join ${name} ${t()}`); - await pg.waitForTimeout(21500); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); + await pg.waitForTimeout(22800); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); tl.push(`timeout msg shown: ${(await text()).includes('still on its way')}`); - await pg.waitForTimeout(5500); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); + await pg.waitForTimeout(5000); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); tl.push(`after arrival: Join ${name} still present=${await pg.locator(`[aria-label="Join ${name}"]`).count()} url=${pg.url()}`); }; await slow('Odin'); From 3d4fb161c3cc98c036ac07f215e8efa10af20a70 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 19:41:38 +0100 Subject: [PATCH 29/64] one filer for direct messages: any joined room whose invite was marked is_direct is written to m.direct in one serialized write, on startup and on every sync; the per-tap late filer is gone (Soul s3 late-dm-filings-overwrite-each-other) Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 32 +--- src/app/hooks/useCommands.ts | 12 +- src/app/hooks/useFileDirectRooms.ts | 20 +++ src/app/pages/client/ClientNonUIFeatures.tsx | 2 + src/app/utils/mDirect.test.ts | 154 +++++++++++++++++++ src/app/utils/matrix.ts | 121 +++++++++------ src/app/utils/room.ts | 12 ++ 7 files changed, 281 insertions(+), 72 deletions(-) create mode 100644 src/app/hooks/useFileDirectRooms.ts create mode 100644 src/app/utils/mDirect.test.ts diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 68575932..bf5cfc24 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -24,12 +24,7 @@ import { } from '../../pages/pathUtils'; import { _RoomSearchParams } from '../../pages/paths'; import { searchModalAtom } from '../../state/searchModal'; -import { isDirectInvite } from '../../utils/room'; -import { - addRoomIdToMDirect, - getCanonicalAliasOrRoomId, - guessDmRoomUserId, -} from '../../utils/matrix'; +import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useCallEmbed } from '../../hooks/useCallEmbed'; @@ -49,8 +44,6 @@ import * as css from './EstateNavigator.css'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; -/** How long after a timed-out tap the store may still deliver the room and have its DM filed. */ -const LATE_ARRIVAL_WINDOW_MS = 10 * 60 * 1000; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); @@ -67,28 +60,14 @@ function JoinRow({ room }: { room: EstateRoom }) { const navigate = useNavigate(); const [state, join] = useAsyncCallback( useCallback(async () => { - const userId = mx.getSafeUserId(); - const dmRoom = mx.getRoom(room.roomId); - const dmUserId = - room.direct && dmRoom && isDirectInvite(dmRoom, userId) - ? guessDmRoomUserId(dmRoom, userId) - : undefined; const result = await activateEstateRoom(mx, room); if (result.kind === 'refused') throw new Error(result.message); // joinRoom resolves before the store holds the room; opening it now shows the invite card. if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { - // The join is accepted; when the room arrives the row becomes a joined row by itself, - // so a DM is filed then (a failed write leaves it under Elsewhere, where it can be found). - if (dmUserId) { - awaitMyJoin(mx, room.roomId, LATE_ARRIVAL_WINDOW_MS) - .then(async (arrived) => { - if (arrived) await addRoomIdToMDirect(mx, room.roomId, dmUserId); - }) - .catch(() => undefined); - } + // The join is accepted; when the room arrives the row becomes a joined row by itself + // and a direct message is filed by useFileDirectRooms. throw new Error('Joined. The room is still on its way and will open from this list.'); } - if (dmUserId) await addRoomIdToMDirect(mx, room.roomId, dmUserId); navigate(estateRoomPath(mx, room)); }, [mx, navigate, room]) ); @@ -265,10 +244,7 @@ function EntryPoints() { /** A short message that wraps inside the navigator; never a raw request error. */ function EstateError({ message }: { message: string }) { return ( - + {message} ); diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index c02b0e94..798423c4 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -24,7 +24,7 @@ import { } from '../utils/matrix'; import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; -import { getStateEvent } from '../utils/room'; +import { getStateEvent, wasInvitedAsDirect } from '../utils/room'; import { splitWithSpace } from '../utils/common'; import { createRoomEncryptionState } from '../components/create-room'; @@ -411,6 +411,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { description: 'Convert direct message to room', exe: async () => { await removeRoomIdFromMDirect(mx, room.roomId); + // The filer reads the invite's mark; clear it so the room stays a room. + const me = mx.getSafeUserId(); + if (wasInvitedAsDirect(room, me)) { + await mx.sendStateEvent( + room.roomId, + StateEvent.RoomMember as any, + { ...room.getMember(me)?.events?.member?.getContent(), is_direct: false }, + me + ); + } }, }, [Command.Delete]: { diff --git a/src/app/hooks/useFileDirectRooms.ts b/src/app/hooks/useFileDirectRooms.ts new file mode 100644 index 00000000..023316b5 --- /dev/null +++ b/src/app/hooks/useFileDirectRooms.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { useMatrixClient } from './useMatrixClient'; +import { fileDirectRooms } from '../utils/matrix'; + +/** Keeps direct messages filed in m.direct: on startup, on every sync, on every membership change. */ +export const useFileDirectRooms = (): void => { + const mx = useMatrixClient(); + useEffect(() => { + const file = () => { + fileDirectRooms(mx).catch(() => undefined); + }; + file(); + mx.on('sync' as never, file); + mx.on('Room.myMembership' as never, file); + return () => { + mx.off('sync' as never, file); + mx.off('Room.myMembership' as never, file); + }; + }, [mx]); +}; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index ce952bfc..5af097e4 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -26,6 +26,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useFileDirectRooms } from '../../hooks/useFileDirectRooms'; function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); @@ -258,6 +259,7 @@ type ClientNonUIFeaturesProps = { }; export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + useFileDirectRooms(); return ( <> diff --git a/src/app/utils/mDirect.test.ts b/src/app/utils/mDirect.test.ts new file mode 100644 index 00000000..55fc8602 --- /dev/null +++ b/src/app/utils/mDirect.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { addRoomIdToMDirect, fileDirectRooms, removeRoomIdFromMDirect } from './matrix'; +import { wasInvitedAsDirect } from './room'; + +const ME = '@me:test'; +type Content = Record; + +type FakeRoomOpts = { + membership?: string; + content?: Record; + prev?: Record; + friend?: string | null; +}; + +/** A joined room as the store holds it: my member event, and the friend once synced. */ +const fakeRoom = (roomId: string, o: FakeRoomOpts = {}) => { + const memberEvent = { + getContent: () => o.content ?? { membership: 'join' }, + getPrevContent: () => o.prev ?? {}, + }; + const friend = o.friend === undefined ? '@friend:test' : o.friend; + return { + roomId, + getMyMembership: () => o.membership ?? 'join', + getMember: () => ({ events: { member: memberEvent } }), + getJoinedMembers: () => + friend ? [{ userId: friend, events: { member: { getTs: () => 1 } } }] : [], + getLiveTimeline: () => ({ getState: () => undefined }), + }; +}; +const lateDm = (roomId: string, o: FakeRoomOpts = {}) => + fakeRoom(roomId, { prev: { membership: 'invite', is_direct: true }, ...o }); + +/** A client whose account-data write takes a while and lands only when it completes. */ +const fakeClient = (rooms: ReturnType[], initial?: Content) => { + let direct: Content | undefined = initial; + const writes: Content[] = []; + const mx = { + getSafeUserId: () => ME, + getRooms: () => rooms, + getAccountData: () => (direct ? { getContent: () => direct } : undefined), + setAccountData: async (_type: string, content: Content) => { + await new Promise((r) => { + setTimeout(r, 5); + }); + writes.push(structuredClone(content)); + direct = structuredClone(content); + }, + }; + return { mx: mx as never, raw: mx, writes, current: () => direct }; +}; + +describe('wasInvitedAsDirect', () => { + it('reads the mark from the join event or from the invite it replaced', () => { + expect(wasInvitedAsDirect(fakeRoom('!a', { content: { is_direct: true } }) as never, ME)).toBe( + true + ); + expect(wasInvitedAsDirect(lateDm('!b') as never, ME)).toBe(true); + expect(wasInvitedAsDirect(fakeRoom('!c') as never, ME)).toBe(false); + }); + + it('ignores a mark on a state the member event has since left', () => { + const room = fakeRoom('!d', { + prev: { membership: 'join', is_direct: true }, + content: { is_direct: false }, + }); + expect(wasInvitedAsDirect(room as never, ME)).toBe(false); + }); +}); + +describe('fileDirectRooms', () => { + it('files two DMs that arrive in the same sync, none lost', async () => { + const c = fakeClient([lateDm('!dm2:test'), lateDm('!dm3:test')]); + await fileDirectRooms(c.mx); + expect(c.writes).toHaveLength(1); + expect(c.current()).toEqual({ '@friend:test': ['!dm2:test', '!dm3:test'] }); + }); + + it('serializes overlapping runs so a later write builds on the earlier one', async () => { + const c = fakeClient([lateDm('!dm2:test'), fakeRoom('!room:test')]); + await Promise.all([ + fileDirectRooms(c.mx), + addRoomIdToMDirect(c.mx, '!made:test', '@other:test'), + fileDirectRooms(c.mx), + ]); + expect(c.current()).toEqual({ '@friend:test': ['!dm2:test'], '@other:test': ['!made:test'] }); + expect(c.writes).toHaveLength(2); + }); + + it('files a DM found at startup, after a reload while the join was in flight', async () => { + const reloaded = fakeClient([lateDm('!dm3:test')], { '@friend:test': ['!dm1:test'] }); + await fileDirectRooms(reloaded.mx); + expect(reloaded.current()).toEqual({ '@friend:test': ['!dm1:test', '!dm3:test'] }); + }); + + it('writes nothing when every marked room is already filed', async () => { + const c = fakeClient([lateDm('!dm2:test')], { '@friend:test': ['!dm2:test'] }); + await fileDirectRooms(c.mx); + expect(c.writes).toHaveLength(0); + }); + + it('skips rooms that are not joined, not marked, or whose other party has not synced', async () => { + const c = fakeClient([ + lateDm('!invited:test', { membership: 'invite' }), + fakeRoom('!plain:test'), + lateDm('!nofriend:test', { friend: null }), + ]); + await fileDirectRooms(c.mx); + expect(c.writes).toHaveLength(0); + // the next sync delivers the friend; the same call files it + const later = fakeClient([lateDm('!nofriend:test')]); + await fileDirectRooms(later.mx); + expect(later.current()).toEqual({ '@friend:test': ['!nofriend:test'] }); + }); + + it('does not file a room whose mark was cleared when it was converted to a room', async () => { + const converted = fakeRoom('!conv:test', { + content: { membership: 'join', is_direct: false }, + prev: { membership: 'join', is_direct: true }, + }); + const c = fakeClient([converted]); + await fileDirectRooms(c.mx); + expect(c.writes).toHaveLength(0); + }); + + it('keeps going after a failed write', async () => { + const c = fakeClient([lateDm('!dm2:test')]); + const original = c.raw.setAccountData; + let fail = true; + c.raw.setAccountData = async (...a: [string, Content]) => { + if (fail) { + fail = false; + throw new Error('offline'); + } + return original(...a); + }; + await expect(fileDirectRooms(c.mx)).rejects.toThrow('offline'); + await fileDirectRooms(c.mx); + expect(c.current()).toEqual({ '@friend:test': ['!dm2:test'] }); + }); +}); + +describe('m.direct edits', () => { + it('moves a room between users and removes it, one write at a time', async () => { + const c = fakeClient([], { '@a:test': ['!r:test'] }); + await Promise.all([ + addRoomIdToMDirect(c.mx, '!r:test', '@b:test'), + removeRoomIdFromMDirect(c.mx, '!gone:test'), + ]); + expect(c.current()).toEqual({ '@a:test': [], '@b:test': ['!r:test'] }); + await removeRoomIdFromMDirect(c.mx, '!r:test'); + expect(c.current()).toEqual({ '@a:test': [], '@b:test': [] }); + }); +}); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 4c86c4e2..f176764a 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -16,7 +16,7 @@ import { import to from 'await-to-js'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; import { AccountDataEvent } from '../../types/matrix/accountData'; -import { getStateEvent } from './room'; +import { getStateEvent, wasInvitedAsDirect } from './room'; import { Membership, StateEvent } from '../../types/matrix/room'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; @@ -225,57 +225,92 @@ export const guessDmRoomUserId = (room: Room, myUserId: string): string => { return member1?.userId ?? myUserId; }; -export const addRoomIdToMDirect = async ( +type MDirectContent = Record; +type DirectEntry = { roomId: string; userId: string }; + +const mDirectWrites = new WeakMap>(); + +/** + * The only writer of m.direct. m.direct is one account-data event that every + * edit replaces whole, so edits run one at a time, each reading the content the + * previous one wrote. `edit` returns the new content, or undefined for no write. + */ +export const updateMDirect = ( + mx: MatrixClient, + edit: (current: MDirectContent) => MDirectContent | undefined +): Promise => { + const run = (mDirectWrites.get(mx) ?? Promise.resolve()).then(async () => { + const event = mx.getAccountData(AccountDataEvent.Direct as any); + const next = edit(event ? structuredClone(event.getContent()) : {}); + if (next) await mx.setAccountData(AccountDataEvent.Direct as any, next as any); + }); + mDirectWrites.set( + mx, + run.catch(() => undefined) + ); + return run; +}; + +/** A room can be a DM for one person only: it leaves every other user's list. */ +export const withDirectEntries = ( + current: MDirectContent, + entries: DirectEntry[] +): MDirectContent => { + const next = structuredClone(current); + entries.forEach(({ roomId, userId }) => { + Object.keys(next).forEach((targetUserId) => { + if (targetUserId !== userId) { + next[targetUserId] = next[targetUserId].filter((id) => id !== roomId); + } + }); + const roomIds = next[userId] ?? []; + if (!roomIds.includes(roomId)) roomIds.push(roomId); + next[userId] = roomIds; + }); + return next; +}; + +export const addRoomIdToMDirect = ( mx: MatrixClient, roomId: string, userId: string -): Promise => { - const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); - let userIdToRoomIds: Record = {}; +): Promise => + updateMDirect(mx, (current) => withDirectEntries(current, [{ roomId, userId }])); - if (typeof mDirectsEvent !== 'undefined') - userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); - - // remove it from the lists of any others users - // (it can only be a DM room for one person) - Object.keys(userIdToRoomIds).forEach((targetUserId) => { - const roomIds = userIdToRoomIds[targetUserId]; - - if (targetUserId !== userId) { - const indexOfRoomId = roomIds.indexOf(roomId); - if (indexOfRoomId > -1) { - roomIds.splice(indexOfRoomId, 1); - } - } +/** + * The one filer of direct messages. Files every joined room whose invite was + * marked is_direct and which m.direct does not list, in a single write, once + * the other party is known. Run on startup and on every sync: a DM that + * arrives late, in a burst or after a reload is filed by the same check. + */ +export const fileDirectRooms = (mx: MatrixClient): Promise => { + const me = mx.getSafeUserId(); + return updateMDirect(mx, (current) => { + const filed = new Set(Object.values(current).flat()); + const entries = mx + .getRooms() + .filter( + (room: Room) => + room.getMyMembership() === 'join' && + !filed.has(room.roomId) && + wasInvitedAsDirect(room, me) + ) + .map((room: Room) => ({ roomId: room.roomId, userId: guessDmRoomUserId(room, me) })) + // The other party's membership has not synced yet; a later sync files it. + .filter((entry: DirectEntry) => entry.userId !== me); + return entries.length > 0 ? withDirectEntries(current, entries) : undefined; }); - - const roomIds = userIdToRoomIds[userId] || []; - if (roomIds.indexOf(roomId) === -1) { - roomIds.push(roomId); - } - userIdToRoomIds[userId] = roomIds; - - await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); }; -export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise => { - const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); - let userIdToRoomIds: Record = {}; - - if (typeof mDirectsEvent !== 'undefined') - userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); - - Object.keys(userIdToRoomIds).forEach((targetUserId) => { - const roomIds = userIdToRoomIds[targetUserId]; - const indexOfRoomId = roomIds.indexOf(roomId); - if (indexOfRoomId > -1) { - roomIds.splice(indexOfRoomId, 1); - } +export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise => + updateMDirect(mx, (current) => { + const next: MDirectContent = {}; + Object.keys(current).forEach((userId) => { + next[userId] = current[userId].filter((id) => id !== roomId); + }); + return next; }); - await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); -}; - export const mxcUrlToHttp = ( mx: MatrixClient, mxcUrl: string, diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 05dfeb29..d4cd757c 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -72,6 +72,18 @@ export const isDirectInvite = (room: Room | null, myUserId: string | null): bool return content?.is_direct === true; }; +/** + * Whether the invite that brought this user into the room was marked is_direct. + * The join event keeps the mark in its own content when the server copies it, + * and otherwise in the invite it replaced (unsigned.prev_content). + */ +export const wasInvitedAsDirect = (room: Room, myUserId: string): boolean => { + const event = room.getMember(myUserId)?.events?.member; + if (event?.getContent()?.is_direct === true) return true; + const prev = event?.getPrevContent(); + return prev?.membership === 'invite' && prev.is_direct === true; +}; + export const isSpace = (room: Room | null): boolean => { if (!room) return false; const event = getStateEvent(room, StateEvent.RoomCreate); From 282769943931fd1f3cf91c7f02803dcda1789a0b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 20:11:45 +0100 Subject: [PATCH 30/64] direct messages are listed in m.direct before the join is sent, from the invite event's is_direct; the arrival-time filer, its hook and the member-flag rewrite are deleted (Soul s4 converttoroom-refiled-by-filer, dm-mark-lost-after-member-rewrite) Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 3 +- .../features/estate/joinEstateRoom.test.ts | 63 +++++++ src/app/features/estate/joinEstateRoom.ts | 28 +++- src/app/hooks/useCommands.ts | 12 +- src/app/hooks/useFileDirectRooms.ts | 20 --- src/app/pages/client/ClientNonUIFeatures.tsx | 2 - src/app/utils/mDirect.test.ts | 155 ++++-------------- src/app/utils/matrix.ts | 51 +----- src/app/utils/room.ts | 12 -- 9 files changed, 128 insertions(+), 218 deletions(-) delete mode 100644 src/app/hooks/useFileDirectRooms.ts diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index bf5cfc24..6fa4bf48 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -64,8 +64,7 @@ function JoinRow({ room }: { room: EstateRoom }) { if (result.kind === 'refused') throw new Error(result.message); // joinRoom resolves before the store holds the room; opening it now shows the invite card. if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { - // The join is accepted; when the room arrives the row becomes a joined row by itself - // and a direct message is filed by useFileDirectRooms. + // The join is accepted; when the room arrives the row becomes a joined row by itself. throw new Error('Joined. The room is still on its way and will open from this list.'); } navigate(estateRoomPath(mx, room)); diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 9bfddf1a..bf96a4af 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -18,6 +18,8 @@ const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:game return joinRoom(...args); }; } + if (prop === 'getRoom') return () => null; + if (prop === 'getSafeUserId') return () => '@me:test'; return () => { calls.push(prop); throw new Error(`unexpected client call: ${prop}`); @@ -72,6 +74,67 @@ describe('activateEstateRoom', () => { }); }); +/** A client holding DM invites as the server sends them: the mark is in the invite event only. */ +const dmClient = (opts: { refuse?: boolean; invites: Record }) => { + let direct: Record | undefined; + const writes: Record[] = []; + const joined: string[] = []; + const mx = { + getSafeUserId: () => '@me:test', + getRoom: (roomId: string) => ({ + getMember: () => ({ + events: { + member: { + getContent: () => ({ membership: 'invite', is_direct: true }), + getSender: () => opts.invites[roomId], + }, + }, + }), + }), + getAccountData: () => (direct ? { getContent: () => direct } : undefined), + setAccountData: async (_t: string, content: Record) => { + await new Promise((r) => { + setTimeout(r, 5); + }); + writes.push(structuredClone(content)); + direct = structuredClone(content); + }, + joinRoom: async (roomId: string) => { + // the join lands only after m.direct has been written: record what it held + joined.push(JSON.stringify(direct ?? {})); + if (opts.refuse) throw new Error('M_FORBIDDEN'); + return { roomId }; + }, + }; + return { mx: mx as never, writes, joined, current: () => direct }; +}; +const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] }); + +describe('activateEstateRoom for a direct message invite', () => { + it('lists the room under the inviter before the join is sent', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); + }); + + it('keeps both rooms when two DM joins are tapped together', async () => { + const c = dmClient({ invites: { '!dm2:test': '@friend:test', '!dm3:test': '@friend:test' } }); + const results = await Promise.all([ + activateEstateRoom(c.mx, tap('!dm2:test')), + activateEstateRoom(c.mx, tap('!dm3:test')), + ]); + expect(results.map((r) => r.kind)).toEqual(['joined', 'joined']); + expect(c.current()).toEqual({ '@friend:test': ['!dm2:test', '!dm3:test'] }); + }); + + it('takes the room out again when the join is refused', async () => { + const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result).toEqual({ kind: 'refused', message: 'M_FORBIDDEN' }); + expect(c.current()).toEqual({ '@friend:test': [] }); + }); +}); + describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index df5284e3..36a9166e 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -1,31 +1,49 @@ import { EstateRoom } from './buildEstate'; +import { addRoomIdToMDirect, removeRoomIdFromMDirect } from '../../utils/matrix'; +import { isDirectInvite } from '../../utils/room'; export type EstateActivation = | { kind: 'open'; roomId: string } | { kind: 'joined'; roomId: string } | { kind: 'refused'; message: string }; -type JoinClient = { - joinRoom(roomId: string, opts: { viaServers: string[] }): Promise<{ roomId: string }>; -}; +type JoinClient = Parameters[0]; /** * What a tap on an estate row does. A joined room opens; any other room is * joined, and only here (plus a first post in the room view). Opening a room * never joins it and the client has no peek path. The server decides whether * the join is allowed; its refusal is reported, the row is not hidden. + * + * An invite marked is_direct carries the mark in the invite event the client + * already holds, and the join event does not repeat it. So the room is listed + * in m.direct under the inviter before the join is sent, and taken out again if + * the join is refused. Nothing needs to recognise the DM after it arrives. */ export const activateEstateRoom = async ( mx: JoinClient, room: Pick ): Promise => { if (room.state === 'joined') return { kind: 'open', roomId: room.roomId }; + const refusal = (e: unknown): EstateActivation => { + const err = e as { data?: { error?: string }; message?: string }; + return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; + }; + const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; + const inviter = isDirectInvite(invite, mx.getSafeUserId()) + ? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender() + : undefined; + try { + if (inviter) await addRoomIdToMDirect(mx, room.roomId, inviter); + } catch (e) { + return refusal(e); + } try { const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); return { kind: 'joined', roomId: joined.roomId }; } catch (e) { - const err = e as { data?: { error?: string }; message?: string }; - return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; + if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined); + return refusal(e); } }; diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 798423c4..c02b0e94 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -24,7 +24,7 @@ import { } from '../utils/matrix'; import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; -import { getStateEvent, wasInvitedAsDirect } from '../utils/room'; +import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; import { createRoomEncryptionState } from '../components/create-room'; @@ -411,16 +411,6 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { description: 'Convert direct message to room', exe: async () => { await removeRoomIdFromMDirect(mx, room.roomId); - // The filer reads the invite's mark; clear it so the room stays a room. - const me = mx.getSafeUserId(); - if (wasInvitedAsDirect(room, me)) { - await mx.sendStateEvent( - room.roomId, - StateEvent.RoomMember as any, - { ...room.getMember(me)?.events?.member?.getContent(), is_direct: false }, - me - ); - } }, }, [Command.Delete]: { diff --git a/src/app/hooks/useFileDirectRooms.ts b/src/app/hooks/useFileDirectRooms.ts deleted file mode 100644 index 023316b5..00000000 --- a/src/app/hooks/useFileDirectRooms.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useEffect } from 'react'; -import { useMatrixClient } from './useMatrixClient'; -import { fileDirectRooms } from '../utils/matrix'; - -/** Keeps direct messages filed in m.direct: on startup, on every sync, on every membership change. */ -export const useFileDirectRooms = (): void => { - const mx = useMatrixClient(); - useEffect(() => { - const file = () => { - fileDirectRooms(mx).catch(() => undefined); - }; - file(); - mx.on('sync' as never, file); - mx.on('Room.myMembership' as never, file); - return () => { - mx.off('sync' as never, file); - mx.off('Room.myMembership' as never, file); - }; - }, [mx]); -}; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 5af097e4..ce952bfc 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -26,7 +26,6 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { useFileDirectRooms } from '../../hooks/useFileDirectRooms'; function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); @@ -259,7 +258,6 @@ type ClientNonUIFeaturesProps = { }; export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { - useFileDirectRooms(); return ( <> diff --git a/src/app/utils/mDirect.test.ts b/src/app/utils/mDirect.test.ts index 55fc8602..1deaf37e 100644 --- a/src/app/utils/mDirect.test.ts +++ b/src/app/utils/mDirect.test.ts @@ -1,43 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { addRoomIdToMDirect, fileDirectRooms, removeRoomIdFromMDirect } from './matrix'; -import { wasInvitedAsDirect } from './room'; +import { addRoomIdToMDirect, removeRoomIdFromMDirect } from './matrix'; -const ME = '@me:test'; type Content = Record; -type FakeRoomOpts = { - membership?: string; - content?: Record; - prev?: Record; - friend?: string | null; -}; - -/** A joined room as the store holds it: my member event, and the friend once synced. */ -const fakeRoom = (roomId: string, o: FakeRoomOpts = {}) => { - const memberEvent = { - getContent: () => o.content ?? { membership: 'join' }, - getPrevContent: () => o.prev ?? {}, - }; - const friend = o.friend === undefined ? '@friend:test' : o.friend; - return { - roomId, - getMyMembership: () => o.membership ?? 'join', - getMember: () => ({ events: { member: memberEvent } }), - getJoinedMembers: () => - friend ? [{ userId: friend, events: { member: { getTs: () => 1 } } }] : [], - getLiveTimeline: () => ({ getState: () => undefined }), - }; -}; -const lateDm = (roomId: string, o: FakeRoomOpts = {}) => - fakeRoom(roomId, { prev: { membership: 'invite', is_direct: true }, ...o }); - /** A client whose account-data write takes a while and lands only when it completes. */ -const fakeClient = (rooms: ReturnType[], initial?: Content) => { +const fakeClient = (initial?: Content) => { let direct: Content | undefined = initial; const writes: Content[] = []; const mx = { - getSafeUserId: () => ME, - getRooms: () => rooms, getAccountData: () => (direct ? { getContent: () => direct } : undefined), setAccountData: async (_type: string, content: Content) => { await new Promise((r) => { @@ -47,102 +17,12 @@ const fakeClient = (rooms: ReturnType[], initial?: Content) => direct = structuredClone(content); }, }; - return { mx: mx as never, raw: mx, writes, current: () => direct }; + return { mx: mx as never, writes, current: () => direct }; }; -describe('wasInvitedAsDirect', () => { - it('reads the mark from the join event or from the invite it replaced', () => { - expect(wasInvitedAsDirect(fakeRoom('!a', { content: { is_direct: true } }) as never, ME)).toBe( - true - ); - expect(wasInvitedAsDirect(lateDm('!b') as never, ME)).toBe(true); - expect(wasInvitedAsDirect(fakeRoom('!c') as never, ME)).toBe(false); - }); - - it('ignores a mark on a state the member event has since left', () => { - const room = fakeRoom('!d', { - prev: { membership: 'join', is_direct: true }, - content: { is_direct: false }, - }); - expect(wasInvitedAsDirect(room as never, ME)).toBe(false); - }); -}); - -describe('fileDirectRooms', () => { - it('files two DMs that arrive in the same sync, none lost', async () => { - const c = fakeClient([lateDm('!dm2:test'), lateDm('!dm3:test')]); - await fileDirectRooms(c.mx); - expect(c.writes).toHaveLength(1); - expect(c.current()).toEqual({ '@friend:test': ['!dm2:test', '!dm3:test'] }); - }); - - it('serializes overlapping runs so a later write builds on the earlier one', async () => { - const c = fakeClient([lateDm('!dm2:test'), fakeRoom('!room:test')]); - await Promise.all([ - fileDirectRooms(c.mx), - addRoomIdToMDirect(c.mx, '!made:test', '@other:test'), - fileDirectRooms(c.mx), - ]); - expect(c.current()).toEqual({ '@friend:test': ['!dm2:test'], '@other:test': ['!made:test'] }); - expect(c.writes).toHaveLength(2); - }); - - it('files a DM found at startup, after a reload while the join was in flight', async () => { - const reloaded = fakeClient([lateDm('!dm3:test')], { '@friend:test': ['!dm1:test'] }); - await fileDirectRooms(reloaded.mx); - expect(reloaded.current()).toEqual({ '@friend:test': ['!dm1:test', '!dm3:test'] }); - }); - - it('writes nothing when every marked room is already filed', async () => { - const c = fakeClient([lateDm('!dm2:test')], { '@friend:test': ['!dm2:test'] }); - await fileDirectRooms(c.mx); - expect(c.writes).toHaveLength(0); - }); - - it('skips rooms that are not joined, not marked, or whose other party has not synced', async () => { - const c = fakeClient([ - lateDm('!invited:test', { membership: 'invite' }), - fakeRoom('!plain:test'), - lateDm('!nofriend:test', { friend: null }), - ]); - await fileDirectRooms(c.mx); - expect(c.writes).toHaveLength(0); - // the next sync delivers the friend; the same call files it - const later = fakeClient([lateDm('!nofriend:test')]); - await fileDirectRooms(later.mx); - expect(later.current()).toEqual({ '@friend:test': ['!nofriend:test'] }); - }); - - it('does not file a room whose mark was cleared when it was converted to a room', async () => { - const converted = fakeRoom('!conv:test', { - content: { membership: 'join', is_direct: false }, - prev: { membership: 'join', is_direct: true }, - }); - const c = fakeClient([converted]); - await fileDirectRooms(c.mx); - expect(c.writes).toHaveLength(0); - }); - - it('keeps going after a failed write', async () => { - const c = fakeClient([lateDm('!dm2:test')]); - const original = c.raw.setAccountData; - let fail = true; - c.raw.setAccountData = async (...a: [string, Content]) => { - if (fail) { - fail = false; - throw new Error('offline'); - } - return original(...a); - }; - await expect(fileDirectRooms(c.mx)).rejects.toThrow('offline'); - await fileDirectRooms(c.mx); - expect(c.current()).toEqual({ '@friend:test': ['!dm2:test'] }); - }); -}); - describe('m.direct edits', () => { it('moves a room between users and removes it, one write at a time', async () => { - const c = fakeClient([], { '@a:test': ['!r:test'] }); + const c = fakeClient({ '@a:test': ['!r:test'] }); await Promise.all([ addRoomIdToMDirect(c.mx, '!r:test', '@b:test'), removeRoomIdFromMDirect(c.mx, '!gone:test'), @@ -151,4 +31,31 @@ describe('m.direct edits', () => { await removeRoomIdFromMDirect(c.mx, '!r:test'); expect(c.current()).toEqual({ '@a:test': [], '@b:test': [] }); }); + + it('serializes overlapping edits so each builds on the content the last one wrote', async () => { + const c = fakeClient(); + await Promise.all([ + addRoomIdToMDirect(c.mx, '!dm2:test', '@friend:test'), + addRoomIdToMDirect(c.mx, '!dm3:test', '@friend:test'), + addRoomIdToMDirect(c.mx, '!dm4:test', '@other:test'), + ]); + expect(c.current()).toEqual({ + '@friend:test': ['!dm2:test', '!dm3:test'], + '@other:test': ['!dm4:test'], + }); + expect(c.writes).toHaveLength(3); + }); + + it('keeps going after a failed write', async () => { + const c = fakeClient(); + const raw = c.mx as unknown as { setAccountData: (t: string, x: Content) => Promise }; + const original = raw.setAccountData; + raw.setAccountData = async () => { + raw.setAccountData = original; + throw new Error('offline'); + }; + await expect(addRoomIdToMDirect(c.mx, '!a:test', '@f:test')).rejects.toThrow('offline'); + await addRoomIdToMDirect(c.mx, '!a:test', '@f:test'); + expect(c.current()).toEqual({ '@f:test': ['!a:test'] }); + }); }); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index f176764a..2859dbc3 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -16,7 +16,7 @@ import { import to from 'await-to-js'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; import { AccountDataEvent } from '../../types/matrix/accountData'; -import { getStateEvent, wasInvitedAsDirect } from './room'; +import { getStateEvent } from './room'; import { Membership, StateEvent } from '../../types/matrix/room'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; @@ -226,7 +226,6 @@ export const guessDmRoomUserId = (room: Room, myUserId: string): string => { }; type MDirectContent = Record; -type DirectEntry = { roomId: string; userId: string }; const mDirectWrites = new WeakMap>(); @@ -252,12 +251,13 @@ export const updateMDirect = ( }; /** A room can be a DM for one person only: it leaves every other user's list. */ -export const withDirectEntries = ( - current: MDirectContent, - entries: DirectEntry[] -): MDirectContent => { - const next = structuredClone(current); - entries.forEach(({ roomId, userId }) => { +export const addRoomIdToMDirect = ( + mx: MatrixClient, + roomId: string, + userId: string +): Promise => + updateMDirect(mx, (current) => { + const next = structuredClone(current); Object.keys(next).forEach((targetUserId) => { if (targetUserId !== userId) { next[targetUserId] = next[targetUserId].filter((id) => id !== roomId); @@ -266,41 +266,8 @@ export const withDirectEntries = ( const roomIds = next[userId] ?? []; if (!roomIds.includes(roomId)) roomIds.push(roomId); next[userId] = roomIds; + return next; }); - return next; -}; - -export const addRoomIdToMDirect = ( - mx: MatrixClient, - roomId: string, - userId: string -): Promise => - updateMDirect(mx, (current) => withDirectEntries(current, [{ roomId, userId }])); - -/** - * The one filer of direct messages. Files every joined room whose invite was - * marked is_direct and which m.direct does not list, in a single write, once - * the other party is known. Run on startup and on every sync: a DM that - * arrives late, in a burst or after a reload is filed by the same check. - */ -export const fileDirectRooms = (mx: MatrixClient): Promise => { - const me = mx.getSafeUserId(); - return updateMDirect(mx, (current) => { - const filed = new Set(Object.values(current).flat()); - const entries = mx - .getRooms() - .filter( - (room: Room) => - room.getMyMembership() === 'join' && - !filed.has(room.roomId) && - wasInvitedAsDirect(room, me) - ) - .map((room: Room) => ({ roomId: room.roomId, userId: guessDmRoomUserId(room, me) })) - // The other party's membership has not synced yet; a later sync files it. - .filter((entry: DirectEntry) => entry.userId !== me); - return entries.length > 0 ? withDirectEntries(current, entries) : undefined; - }); -}; export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise => updateMDirect(mx, (current) => { diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index d4cd757c..05dfeb29 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -72,18 +72,6 @@ export const isDirectInvite = (room: Room | null, myUserId: string | null): bool return content?.is_direct === true; }; -/** - * Whether the invite that brought this user into the room was marked is_direct. - * The join event keeps the mark in its own content when the server copies it, - * and otherwise in the invite it replaced (unsigned.prev_content). - */ -export const wasInvitedAsDirect = (room: Room, myUserId: string): boolean => { - const event = room.getMember(myUserId)?.events?.member; - if (event?.getContent()?.is_direct === true) return true; - const prev = event?.getPrevContent(); - return prev?.membership === 'invite' && prev.is_direct === true; -}; - export const isSpace = (room: Room | null): boolean => { if (!room) return false; const event = getStateEvent(room, StateEvent.RoomCreate); From e59cc93a2acde819d20fa79690d5396bf3d0b81f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 20:14:19 +0100 Subject: [PATCH 31/64] estate harness: Soul's fake homeserver with member-state echo, and the twodm, reload and convert direct-message scenarios Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive.cjs | 103 ++++++++++++-------------------------- harness/estate/fakehs.cjs | 24 +++++---- harness/estate/run.sh | 39 ++++++--------- 3 files changed, 61 insertions(+), 105 deletions(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 5d69d02a..ed9950ce 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,82 +1,41 @@ const { chromium } = require('playwright'); -const fs = require('fs'); -const TAG = process.env.TAG; -const out = (n) => `/out/${TAG}-${n}`; -const tl = []; -const T0 = Date.now(); -const t = () => `+${Date.now() - T0}ms`; +const MODE = process.env.MODE; // twodm | reload +const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; (async () => { const b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); - const pg = await ctx.newPage(); - pg.on('console', (m) => { if (m.type() === 'error' && !/OIDC|issuer|endpoint|supported|ERR_NAME|404/.test(m.text())) tl.push('console.error ' + m.text().slice(0, 200)); }); + const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error' || /command|direct/i.test(m.text())) console.log('[console]', m.type(), m.text().slice(0, 200)); }); + const open = async () => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); }; await pg.goto('http://127.0.0.1:8080/'); await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); - await pg.goto('http://127.0.0.1:8080/#/home/'); - await pg.waitForTimeout(12000); + await open(); const text = () => pg.evaluate(() => document.body.innerText); - const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); tl.push(`--- ${n} ${t()} url=${pg.url()}\n${await text()}`); }; - const buttons = async () => tl.push('buttons ' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('button')].map((e) => { const r = e.getBoundingClientRect(); return [e.getAttribute('aria-label') || e.innerText.slice(0, 30), Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)]; })))); - const small = async () => tl.push('SMALL ' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('button, a, [role="button"]')].filter((e) => e.getClientRects().length).map((e) => [e.getAttribute('aria-label') || e.innerText.slice(0, 30), Math.round(e.getBoundingClientRect().width), Math.round(e.getBoundingClientRect().height)]).filter((r) => r[2] < 44)))); - const layout = async () => tl.push('layout ' + JSON.stringify(await pg.evaluate(() => ({ vw: innerWidth, scrollW: document.documentElement.scrollWidth, bodyScrollW: document.body.scrollWidth })))); - await snap('1-landing'); await buttons(); await layout(); await small(); - if (process.env.NOROOT === '1') { - tl.push('has Create Room: ' + (await pg.getByText('Create Room', { exact: true }).count()) + ' Join with Address: ' + (await pg.getByText('Join with Address', { exact: true }).count()) + ' finder: ' + (await pg.locator('[aria-label="Find a room"]').count())); - await b.close(); return; + const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; + const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); + const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; + if (MODE === 'twodm') { + await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM'); + await pg.waitForTimeout(45000); + } else if (MODE === 'convert') { + await tap('Friend DM'); await pg.waitForTimeout(6000); + log('TIER before', await tierOf('Friend DM')); + await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000); + const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url()); + await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500); + log('ED_TEXT', JSON.stringify(await ed.first().innerText())); log('BODY', JSON.stringify((await text()).slice(-600))); + await pg.getByText('Convert direct message to room').first().click(); await pg.waitForTimeout(400); log('ED_PICKED', JSON.stringify(await ed.first().innerHTML()).slice(0, 300)); + await pg.keyboard.press('Enter'); log('SENT', t()); await pg.waitForTimeout(800); log('ED_TEXT2', JSON.stringify(await ed.first().innerText().catch(()=>''))); + if (await ed.first().innerText().catch(() => '')) { await pg.keyboard.press('Enter'); log('SENT2', t()); } + await pg.waitForTimeout(8000); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); + } else { + await tap('Slow DM'); await pg.waitForTimeout(3000); + log('RELOAD', t()); await open(); + await pg.waitForTimeout(30000); } - const reveal = async (sel) => { - const loc = pg.locator(sel); - if (await loc.count()) return loc; - for (let round = 0; round < 2; round++) for (const h of HEADERS) { - const hl = pg.getByText(h, { exact: true }); - if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) { tl.push(`revealed ${sel} via ${h}`); return loc; } } - } - tl.push(`could not reveal ${sel}`); return loc; - }; - // expand everything once for the inventory - for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); tl.push('clicked header ' + h); } } - await snap('2-expanded'); await buttons(); await layout(); await small(); - // room finder - const finder = pg.locator('[aria-label="Find a room"], [aria-label="Search"]').first(); - await finder.click(); await pg.waitForTimeout(800); - tl.push('after finder url=' + pg.url() + ' dialogs=' + (await pg.locator('[role="dialog"]').count()) + ' inputs=' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('input')].map((i) => i.placeholder)))); - await snap('3-finder'); - await pg.keyboard.press('Escape'); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); - // join with frame poll - const joinPoll = async (name, ms) => { - const loc = await reveal(`[aria-label="Join ${name}"]`); - if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } - const u0 = pg.url(); await loc.first().click(); const c = Date.now(); tl.push(`CLICK Join ${name} ${t()}`); - let card = 0, frames = 0, urlAt = null, firstCardAt = null; - while (Date.now() - c < ms) { - const s = await text(); frames++; - if (s.includes('Summary card')) { card++; if (firstCardAt === null) firstCardAt = Date.now() - c; } - if (urlAt === null && pg.url() !== u0) urlAt = Date.now() - c; - await pg.waitForTimeout(40); - } - tl.push(`POLL ${name}: frames=${frames} cardFrames=${card} firstCardAt=${firstCardAt} urlChangedAt=${urlAt} url=${pg.url()}`); - await snap(`4-join-${name.replace(/\W/g, '')}`); - await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); - }; - await joinPoll('Commons', 6000); - await joinPoll('Aetheria', 6000); - await joinPoll('Friend DM', 6000); - // timeout path, room - const slow = async (name) => { - const loc = await reveal(`[aria-label="Join ${name}"]`); - if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } - await loc.first().click(); tl.push(`CLICK Join ${name} ${t()}`); - await pg.waitForTimeout(22800); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); - tl.push(`timeout msg shown: ${(await text()).includes('still on its way')}`); - await pg.waitForTimeout(5000); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); - tl.push(`after arrival: Join ${name} still present=${await pg.locator(`[aria-label="Join ${name}"]`).count()} url=${pg.url()}`); - }; - await slow('Odin'); - await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); - await slow('Slow DM'); - // wide: tier placement (wide tiers start open) - await pg.setViewportSize({ width: 1280, height: 800 }); - await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(6000); await snap('6-wide'); + for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } + log('TIER Slow DM', await tierOf('Slow DM'), '| Late DM', await tierOf('Late DM'), '| Friend DM', await tierOf('Friend DM')); await b.close(); -})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); process.exitCode = 1; }).finally(() => fs.writeFileSync(out('timeline.txt'), tl.join('\n'))); +})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; }); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index 3b9ed641..33deaa64 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -9,11 +9,11 @@ const log = []; const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin']; const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' }; const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p })); -const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test'; -const SLOW = new Set([PROJ[5].id, DM2]); -const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [STRAY]: 'Adhoc' }; +const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test', DM3 = '!dm3:test'; +const SLOW = new Set([PROJ[5].id, DM2, DM3]); +const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' }; PROJ.forEach((r) => (NAMES[r.id] = r.name)); -const pending = []; const pendingAD = []; let ev = 0; +const held = []; const pendingState = []; const pending = []; const pendingAD = []; let ev = 0; const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); const strip = (id, name, space, direct) => [ { type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, @@ -35,6 +35,7 @@ const invites = () => { PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } })); o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } }; o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } }; + o[DM3] = { invite_state: { events: strip(DM3, NAMES[DM3], false, true) } }; return o; }; const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); @@ -61,21 +62,23 @@ http.createServer((req, res) => { return fs.createReadStream(f).pipe(res); } let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { + if (req.method === 'PUT' && p.includes('m.direct')) console.log('MDIRECT_PUT ' + (Date.now()) + ' ' + body); log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`); if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} }); if (p.endsWith('/sync')) { const since = u.searchParams.get('since'); if (!since) { const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc'); - return json(res, 200, { next_batch: 's1', rooms: { join, invite: invites(), leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); + return json(res, 200, { next_batch: 's1', rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); } const respond = () => { const now = Date.now(); const join = {}; for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) { const id = pending[i].id; pending.splice(i, 1); - const extra = id === DM1 || id === DM2 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : []; - join[id] = joinedRoom(id, NAMES[id] || id, extra); log.push(`${now} SYNC delivers join ${id}`); + const extra = id === DM1 || id === DM2 || id === DM3 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : []; + join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id); } + for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); } const ad = pendingAD.splice(0); if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } }); return json(res, 200, { next_batch: since, rooms: {} }); @@ -86,12 +89,15 @@ http.createServer((req, res) => { if (req.method === 'POST' && jm) { const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`); if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); - pending.push({ id, at: Date.now() + (SLOW.has(id) ? 24000 : 1000) }); + const prior = pending.find((q) => q.id === DM2 || q.id === DM3); + pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) }); return json(res, 200, { room_id: id }); } + const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/); + if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); } if (p.includes('/account_data/') && req.method === 'PUT') { const type = decodeURIComponent(p.split('/account_data/')[1]); - try { pendingAD.push({ type, content: JSON.parse(body) }); } catch (e) {} + try { const c = JSON.parse(body); pendingAD.push({ type, content: c }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} return json(res, 200, {}); } if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index d4dd7cf4..f426895f 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -1,29 +1,20 @@ -# Run inside playwright:v1.48.2-jammy with the repo at /src and /out writable: -# bash /src/harness/estate/run.sh -# Scenario A: root resolves. Scenario B (NOROOT=1): the root alias 404s. +#!/usr/bin/env bash +# Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src: +# builds the client, then drives three direct-message scenarios against fakehs.cjs. set -e cd /src -mkdir -p /out -npm ci --no-audit --no-fund --ignore-scripts >/out/npmci.log 2>&1 -npm run build > /out/build.log 2>&1; echo BUILD_RC=$? >> /out/build.log +npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; } +npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } +echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json -mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/out/pw.log 2>&1 +mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 cp /src/harness/estate/drive.cjs /tmp/pw/ -runone () { # tag noroot - HSLOG=/out/hslog-$1.txt NOROOT=$2 node /src/harness/estate/fakehs.cjs /src/dist > /out/hs-$1.out 2>&1 & - HS=$! - sleep 2 - TAG=$1 NOROOT=$2 node /tmp/pw/drive.cjs; echo "DRIVE $1 RC=$?" - kill $HS -} -runone root 0 -runone noroot 1 -for t in root noroot; do - echo "=== $t" - grep -E '^(SMALL|layout|has Create|CLICK|POLL|timeout|after arrival|revealed|--- )' /out/$t-timeline.txt | cut -c1-1800 || true +set +e +for m in twodm reload convert; do + HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & + HS=$!; sleep 2 + MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?" + kill $HS; sleep 1 + echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt done -ls -la /out | head -30 -cat /out/noroot-timeline.txt | grep -m3 -i "could not" || true -echo "=== m.direct and joins (root)" -grep -E 'direct|JOIN' /out/hslog-root.txt | cut -c1-220 || true -tail -1 /out/build.log +echo DONE From 44395328e9f1276d07d5479012b7a3d23d641d9d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 20:20:37 +0100 Subject: [PATCH 32/64] estate harness fake: account data is answered by the client's sync token, as a server does Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/fakehs.cjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index 33deaa64..c582d3ee 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -13,7 +13,7 @@ const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test const SLOW = new Set([PROJ[5].id, DM2, DM3]); const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' }; PROJ.forEach((r) => (NAMES[r.id] = r.name)); -const held = []; const pendingState = []; const pending = []; const pendingAD = []; let ev = 0; +const held = []; const pendingState = []; const pending = []; const adLog = []; let ev = 0; const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); const strip = (id, name, space, direct) => [ { type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, @@ -69,7 +69,7 @@ http.createServer((req, res) => { const since = u.searchParams.get('since'); if (!since) { const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc'); - return json(res, 200, { next_batch: 's1', rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); + return json(res, 200, { next_batch: `s${syncN}`, rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); } const respond = () => { const now = Date.now(); const join = {}; @@ -79,7 +79,7 @@ http.createServer((req, res) => { join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id); } for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); } - const ad = pendingAD.splice(0); + const k = Number(String(since).replace(/^s/, '')) || 0; const ad = adLog.filter((a) => a.after >= k).map((a) => ({ type: a.type, content: a.content })); // a server answers with what changed since the client's token, whichever client asks if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } }); return json(res, 200, { next_batch: since, rooms: {} }); }; @@ -97,7 +97,7 @@ http.createServer((req, res) => { if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); } if (p.includes('/account_data/') && req.method === 'PUT') { const type = decodeURIComponent(p.split('/account_data/')[1]); - try { const c = JSON.parse(body); pendingAD.push({ type, content: c }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} + try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} return json(res, 200, {}); } if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); From 719f062ef1214991b9b68a5727aeda2c399b2af3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 21:10:06 +0100 Subject: [PATCH 33/64] estate: the inbox Accept joins through activateEstateRoom; a join with no server answer keeps the DM listed Co-Authored-By: Claude Sonnet 5.5 --- .../features/estate/joinEstateRoom.test.ts | 20 ++++++++++++++---- src/app/features/estate/joinEstateRoom.ts | 10 ++++++++- src/app/pages/client/inbox/Invites.tsx | 21 +++++++------------ 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index bf96a4af..19020db2 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -51,12 +51,12 @@ describe('activateEstateRoom', () => { ); it('falls back to the error message, then to a generic one', async () => { - const named = recordingClient(vi.fn().mockRejectedValue(new Error('Boom'))); + const named = recordingClient(vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 }))); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', }); - const bare = recordingClient(vi.fn().mockRejectedValue({})); + const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 500 })); expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Could not join', @@ -65,6 +65,7 @@ describe('activateEstateRoom', () => { it('reports the server refusal and does not decide access itself', async () => { const refusal = Object.assign(new Error('M_FORBIDDEN'), { + httpStatus: 403, data: { error: 'You are not invited to this room.' }, }); const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal)); @@ -75,7 +76,7 @@ describe('activateEstateRoom', () => { }); /** A client holding DM invites as the server sends them: the mark is in the invite event only. */ -const dmClient = (opts: { refuse?: boolean; invites: Record }) => { +const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record }) => { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; @@ -102,7 +103,8 @@ const dmClient = (opts: { refuse?: boolean; invites: Record }) = joinRoom: async (roomId: string) => { // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); - if (opts.refuse) throw new Error('M_FORBIDDEN'); + if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403 }); + if (opts.drop) throw new Error('fetch failed'); return { roomId }; }, }; @@ -135,6 +137,16 @@ describe('activateEstateRoom for a direct message invite', () => { }); }); +describe('activateEstateRoom when the join gets no answer', () => { + it('keeps the room listed and shows a retryable message', async () => { + const c = dmClient({ drop: true, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result.kind).toBe('refused'); + expect((result as { message: string }).message).toMatch(/tap again to retry/); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); +}); + describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index 36a9166e..a3be428b 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -7,6 +7,9 @@ export type EstateActivation = | { kind: 'joined'; roomId: string } | { kind: 'refused'; message: string }; +const LOST_JOIN_RESPONSE = + 'The connection dropped before the server answered. The join may have gone through; tap again to retry.'; + type JoinClient = Parameters[0]; /** @@ -18,7 +21,10 @@ type JoinClient = Parameters[0]; * An invite marked is_direct carries the mark in the invite event the client * already holds, and the join event does not repeat it. So the room is listed * in m.direct under the inviter before the join is sent, and taken out again if - * the join is refused. Nothing needs to recognise the DM after it arrives. + * the join is refused by a server answer. A join that fails without one (lost + * response, dropped connection) may still have been accepted, so the entry + * stays and the tap is retryable. Nothing needs to recognise the DM after it + * arrives. The inbox Accept goes through here too: one owner for joining. */ export const activateEstateRoom = async ( mx: JoinClient, @@ -29,6 +35,7 @@ export const activateEstateRoom = async ( const err = e as { data?: { error?: string }; message?: string }; return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; }; + const answered = (e: unknown) => typeof (e as { httpStatus?: unknown }).httpStatus === 'number'; const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; const inviter = isDirectInvite(invite, mx.getSafeUserId()) ? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender() @@ -42,6 +49,7 @@ export const activateEstateRoom = async ( const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); return { kind: 'joined', roomId: joined.roomId }; } catch (e) { + if (!answered(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined); return refusal(e); } diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index 20518e56..39dfcc72 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -46,11 +46,10 @@ import { import { nameInitials } from '../../../utils/common'; import { RoomAvatar } from '../../../components/room-avatar'; import { - addRoomIdToMDirect, getMxIdLocalPart, - guessDmRoomUserId, rateLimitedActions, } from '../../../utils/matrix'; +import { activateEstateRoom } from '../../../features/estate/joinEstateRoom'; import { Time } from '../../../components/message'; import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver'; import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard'; @@ -160,24 +159,20 @@ function InviteCard({ hideAvatar, }: InviteCardProps) { const mx = useMatrixClient(); - const userId = mx.getSafeUserId(); - const [viewTopic, setViewTopic] = useState(false); const closeTopic = () => setViewTopic(false); const openTopic = () => setViewTopic(true); const [joinState, join] = useAsyncCallback( useCallback(async () => { - const dmUserId = isDirectInvite(invite.room, userId) - ? guessDmRoomUserId(invite.room, userId) - : undefined; - - await mx.joinRoom(invite.roomId); - if (dmUserId) { - await addRoomIdToMDirect(mx, invite.roomId, dmUserId); - } + const result = await activateEstateRoom(mx, { + roomId: invite.roomId, + state: 'invited', + via: [], + }); + if (result.kind === 'refused') throw new Error(result.message); onNavigate(invite.roomId, invite.isSpace); - }, [mx, invite, userId, onNavigate]) + }, [mx, invite, onNavigate]) ); const [leaveState, leave] = useAsyncCallback, MatrixError, []>( useCallback(() => mx.leave(invite.roomId), [mx, invite]) From f6244a478104c233e7638278e6931891b4fd8101 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 21:10:50 +0100 Subject: [PATCH 34/64] estate harness: the join-failure and inbox Accept scenarios run with the committed estate run Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive-failures.cjs | 58 +++++++++++++++++++++++++++++++ harness/estate/fakehs.cjs | 7 +++- harness/estate/run.sh | 16 +++++++-- 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 harness/estate/drive-failures.cjs diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs new file mode 100644 index 00000000..0504e8e6 --- /dev/null +++ b/harness/estate/drive-failures.cjs @@ -0,0 +1,58 @@ +// Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh +// against fakehs.cjs with the env flag each mode needs. +const { chromium } = require('playwright'); +const MODE = process.env.MODE; +const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +setTimeout(() => { console.log('DRIVER_TIMEOUT'); process.exit(3); }, 150000); +let b; +(async () => { + b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); + const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error') console.log('[console]', m.type(), m.text().slice(0, 160)); }); + const open = async (h = '#/home/') => { await pg.goto('http://127.0.0.1:8080/' + h); await pg.reload(); await pg.waitForTimeout(9000); }; + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await open(); + const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); + const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; + const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); + const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM'), '| Slow DM', await tierOf('Slow DM')); }; + const acceptInInbox = async (name) => { + // reach the inbox through the navigator's own Inbox tab + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000); + const tab = pg.locator('[aria-label="Inbox"], [aria-label*="nbox"]'); log('INBOX_TAB', await tab.count()); + if (await tab.count()) { await tab.first().click(); await pg.waitForTimeout(1500); } + const inv = pg.getByText('Invites', { exact: true }); if (await inv.count()) { await inv.first().click(); await pg.waitForTimeout(1500); } + log('URL', pg.url()); + if (!/invites/.test(pg.url())) { await pg.goto('http://127.0.0.1:8080/#/inbox/invites/'); await pg.waitForTimeout(3000); log('URL_FALLBACK', pg.url()); } + { const pub = pg.getByText('Public', { exact: true }); log('PUBLIC_FILTER', await pub.count()); if (await pub.count()) { await pub.first().click(); await pg.waitForTimeout(1500); } } + for (let r = 0; r < 3; r++) { const v = pg.getByText('View All', { exact: true }); if (!(await v.count())) break; await v.first().click(); await pg.waitForTimeout(800); } + const b0 = await body(); const i = b0.indexOf(name); log('INBOX has', name, i >= 0, JSON.stringify(b0.slice(Math.max(0, i - 120), i + 120))); + const card = pg.locator('div').filter({ hasText: name }).filter({ has: pg.getByText('Accept', { exact: true }) }).last(); + await card.getByText('Accept', { exact: true }).first().click(); log('ACCEPT', name, t()); + }; + if (MODE === 'pjfail') { + await tap('Friend DM'); await pg.waitForTimeout(3000); + const bb = await body(); const k = bb.search(/Internal server error|Could not join|Joined\./); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); + await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('after retry'); + } else if (MODE === 'jdrop') { + await tap('Friend DM'); await pg.waitForTimeout(4000); + const bb = await body(); const k = bb.search(/fetch|Failed|network|Could not join|Joined\./i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); + await pg.waitForTimeout(6000); await tiers('after drop'); await open(); await tiers('after reload'); + } else if (MODE === 'admin') { + await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin estate'); + } else if (MODE === 'inboxadmin') { + await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin inbox'); + } else if (MODE === 'inboxreload') { + await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/'); + await pg.waitForTimeout(30000); await tiers('inbox reload'); + } else if (MODE === 'pjreload') { + await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); + await pg.waitForTimeout(8000); await tiers('pjreload'); + const l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count()); + } + await b.close(); +})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); Promise.resolve(b && b.close()).catch(() => undefined).finally(() => process.exit(1)); }); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index c582d3ee..a8180204 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -20,7 +20,7 @@ const strip = (id, name, space, direct) => [ { type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }, { type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } }, ...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []), - { type: 'm.room.member', state_key: ME, sender: direct ? '@friend:test' : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, + { type: 'm.room.member', state_key: ME, sender: direct ? (process.env.INVITER_ADMIN === '1' && id === DM1 ? '@admin:test' : '@friend:test') : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, ]; const joinedRoom = (id, name, extra = [], space = false) => ({ state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] }, @@ -91,13 +91,18 @@ http.createServer((req, res) => { if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); const prior = pending.find((q) => q.id === DM2 || q.id === DM3); pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) }); + if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); } + if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); } return json(res, 200, { room_id: id }); } const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/); if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); } + if (p.includes('/account_data/') && req.method === 'PUT' && p.includes('m.direct') && process.env.FAIL_MDIRECT === '1' && !global.failedOnce) { global.failedOnce = 1; console.log('MDIRECT_FAIL ' + Date.now()); return json(res, 500, { errcode: 'M_UNKNOWN', error: 'Internal server error' }); } if (p.includes('/account_data/') && req.method === 'PUT') { + const DELAY = p.includes('m.direct') && process.env.DELAY_MDIRECT === '1'; const type = decodeURIComponent(p.split('/account_data/')[1]); try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} + if (DELAY) { console.log('MDIRECT_DELAYED ' + Date.now()); return setTimeout(() => json(res, 200, {}), 6000); } return json(res, 200, {}); } if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index f426895f..ef0fa3cd 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src: -# builds the client, then drives three direct-message scenarios against fakehs.cjs. +# builds the client, then drives the direct-message and join-failure scenarios against fakehs.cjs. set -e cd /src npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; } @@ -8,7 +8,7 @@ npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 -cp /src/harness/estate/drive.cjs /tmp/pw/ +cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /tmp/pw/ set +e for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & @@ -17,4 +17,16 @@ for m in twodm reload convert; do kill $HS; sleep 1 echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt done +failure() { m=$1; shift + env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & + HS=$!; sleep 2 + MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?" + kill $HS; sleep 1 + echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } +failure pjfail FAIL_MDIRECT=1 +failure pjreload DELAY_MDIRECT=1 +failure jdrop JOIN_DROP=1 +failure admin INVITER_ADMIN=1 +failure inboxadmin INVITER_ADMIN=1 +failure inboxreload JOIN_DELAY=1 echo DONE From cdfd43fe9ffd7e3f591babaa7b3d03582246f571 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 21:29:03 +0100 Subject: [PATCH 35/64] estate harness: the failure driver closes its browser on timeout; the run waits for each fake server to exit; prettier Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive-failures.cjs | 2 +- harness/estate/run.sh | 4 ++-- src/app/features/estate/joinEstateRoom.test.ts | 4 +++- src/app/pages/client/inbox/Invites.tsx | 5 +---- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index 0504e8e6..e3e71449 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -5,8 +5,8 @@ const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; -setTimeout(() => { console.log('DRIVER_TIMEOUT'); process.exit(3); }, 150000); let b; +setTimeout(() => { console.log('DRIVER_TIMEOUT'); Promise.resolve(b && b.close()).catch(() => undefined).finally(() => process.exit(3)); }, 240000); (async () => { b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index ef0fa3cd..26b026b8 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -14,14 +14,14 @@ for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?" - kill $HS; sleep 1 + kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt done failure() { m=$1; shift env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?" - kill $HS; sleep 1 + kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } failure pjfail FAIL_MDIRECT=1 failure pjreload DELAY_MDIRECT=1 diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 19020db2..d206a608 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -51,7 +51,9 @@ describe('activateEstateRoom', () => { ); it('falls back to the error message, then to a generic one', async () => { - const named = recordingClient(vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 }))); + const named = recordingClient( + vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 })) + ); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index 39dfcc72..ca26bac3 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -45,10 +45,7 @@ import { } from '../../../utils/room'; import { nameInitials } from '../../../utils/common'; import { RoomAvatar } from '../../../components/room-avatar'; -import { - getMxIdLocalPart, - rateLimitedActions, -} from '../../../utils/matrix'; +import { getMxIdLocalPart, rateLimitedActions } from '../../../utils/matrix'; import { activateEstateRoom } from '../../../features/estate/joinEstateRoom'; import { Time } from '../../../components/message'; import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver'; From 2c04bbefa327f56738fdb6e3e60029aa94136d3f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 21:51:32 +0100 Subject: [PATCH 36/64] estate harness: the failure driver never waits on a hung browser close Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive-failures.cjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index e3e71449..9c81424a 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -6,7 +6,9 @@ const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; let b; -setTimeout(() => { console.log('DRIVER_TIMEOUT'); Promise.resolve(b && b.close()).catch(() => undefined).finally(() => process.exit(3)); }, 240000); +// Close the browser, but never wait on it: a hung close must not hang the run. +const quit = (code) => Promise.race([Promise.resolve(b && b.close()).catch(() => undefined), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(code)); +setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); (async () => { b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); @@ -54,5 +56,5 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); Promise.resolve(b && b.close() await pg.waitForTimeout(8000); await tiers('pjreload'); const l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count()); } - await b.close(); -})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); Promise.resolve(b && b.close()).catch(() => undefined).finally(() => process.exit(1)); }); + await quit(0); +})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); From 7453a0bd323da8861ef015221320224eb5fb79a9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:20:28 +0100 Subject: [PATCH 37/64] estate: every join of a direct invite goes through the pre-join filing; only a Matrix 4xx with an errcode is a refusal The room card's Join (deep links, permalinks, history) and /join joined through mx.joinRoom and never filed the DM. They now go through activateEstateRoom. A gateway 5xx, or a status with no errcode, has an unknown outcome like a dropped connection: the entry stays and the tap is retryable. The harness gains Soul's s6 deeplink and gw504 drives. Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive-routes.cjs | 33 +++++++++++++++ harness/estate/fakehs.cjs | 1 + harness/estate/run.sh | 6 ++- src/app/components/room-card/RoomCard.tsx | 10 +++-- .../features/estate/joinEstateRoom.test.ts | 42 ++++++++++++++++--- src/app/features/estate/joinEstateRoom.ts | 38 +++++++++++++---- src/app/hooks/useCommands.ts | 3 +- 7 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 harness/estate/drive-routes.cjs diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs new file mode 100644 index 00000000..05e89df1 --- /dev/null +++ b/harness/estate/drive-routes.cjs @@ -0,0 +1,33 @@ +const { chromium } = require('playwright'); +const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c)); +setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); +(async () => { + b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + const pg = await ctx.newPage(); + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); + const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); + const tierOf = (n) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, n); + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM')); }; + if (MODE === 'deeplink') { + for (const route of ['direct', 'home']) { + await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000); + const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300))); + const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count()); + if (await j.count()) { await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } + } + await tiers('after deeplink join'); + } else if (MODE === 'gw504') { + const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); + for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } + log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); await pg.waitForTimeout(4000); + const bb = await body(); const k = bb.search(/504|Gateway|connection dropped|Could not join/i); log('AFTER_504', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 120) : 'no message'); + await pg.waitForTimeout(4000); await tiers('after 504 (join delivered by sync)'); + } + await quit(0); +})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index a8180204..4a6875e0 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -91,6 +91,7 @@ http.createServer((req, res) => { if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); const prior = pending.find((q) => q.id === DM2 || q.id === DM3); pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) }); + if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('504 Gateway Time-out'); } if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); } if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); } return json(res, 200, { room_id: id }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 26b026b8..70fa5d13 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -8,7 +8,7 @@ npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 -cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /tmp/pw/ +cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ set +e for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & @@ -20,7 +20,7 @@ done failure() { m=$1; shift env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 - MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?" + MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; echo "DRIVE $m RC=$?" kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } failure pjfail FAIL_MDIRECT=1 @@ -30,3 +30,5 @@ failure admin INVITER_ADMIN=1 failure inboxadmin INVITER_ADMIN=1 failure inboxreload JOIN_DELAY=1 echo DONE +DRIVER=drive-routes failure deeplink +DRIVER=drive-routes failure gw504 JOIN_504=1 diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 34a7e24b..a5e9b3a9 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react'; -import { MatrixError, Room } from 'matrix-js-sdk'; +import { MatrixError } from 'matrix-js-sdk'; import { Avatar, Badge, @@ -31,6 +31,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; +import { joinEstateRoomIdOrAlias } from '../../features/estate/joinEstateRoom'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -197,8 +198,11 @@ export const RoomCard = as<'div', RoomCardProps>( ) ); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers]) + const [joinState, join] = useAsyncCallback( + useCallback(async () => { + const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, viaServers); + if (result.kind === 'refused') throw new Error(result.message); + }, [mx, roomIdOrAlias, viaServers]) ); const joining = joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index d206a608..6fccae10 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; +import { activateEstateRoom, awaitMyJoin, joinEstateRoomIdOrAlias } from './joinEstateRoom'; const room = (state: 'joined' | 'invited' | 'joinable') => ({ roomId: '!r:gamecult.org', @@ -52,13 +52,13 @@ describe('activateEstateRoom', () => { it('falls back to the error message, then to a generic one', async () => { const named = recordingClient( - vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 })) + vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' })) ); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', }); - const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 500 })); + const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' })); expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Could not join', @@ -78,7 +78,7 @@ describe('activateEstateRoom', () => { }); /** A client holding DM invites as the server sends them: the mark is in the invite event only. */ -const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record }) => { +const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; drop?: boolean; invites: Record }) => { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; @@ -105,7 +105,8 @@ const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record { // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); - if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403 }); + if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); + if (opts.status) throw Object.assign(new Error('Gateway Time-out'), { httpStatus: opts.status, errcode: opts.errcode }); if (opts.drop) throw new Error('fetch failed'); return { roomId }; }, @@ -149,6 +150,37 @@ describe('activateEstateRoom when the join gets no answer', () => { }); }); +describe('activateEstateRoom when a gateway answers instead of the server', () => { + it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => { + const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result.kind).toBe('refused'); + expect((result as { message: string }).message).toMatch(/tap again to retry/); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); + + it('keeps the room listed on a 5xx even when it carries an errcode', async () => { + const c = dmClient({ status: 500, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); + await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); +}); + +describe('joinEstateRoomIdOrAlias, the room card and /join', () => { + it('files a direct invite opened by its link before the join is sent', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + const result = await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['test']); + expect(result).toEqual({ kind: 'joined', roomId: '!dm1:test' }); + expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); + }); + + it('joins an alias or an unheld room without touching m.direct', async () => { + const { mx, calls } = recordingClient(); + await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org'); + expect(calls).toEqual(['joinRoom']); + }); +}); + describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index a3be428b..3cd3bf6d 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -8,7 +8,7 @@ export type EstateActivation = | { kind: 'refused'; message: string }; const LOST_JOIN_RESPONSE = - 'The connection dropped before the server answered. The join may have gone through; tap again to retry.'; + 'The server did not answer the join. It may have gone through; tap again to retry.'; type JoinClient = Parameters[0]; @@ -21,21 +21,28 @@ type JoinClient = Parameters[0]; * An invite marked is_direct carries the mark in the invite event the client * already holds, and the join event does not repeat it. So the room is listed * in m.direct under the inviter before the join is sent, and taken out again if - * the join is refused by a server answer. A join that fails without one (lost - * response, dropped connection) may still have been accepted, so the entry - * stays and the tap is retryable. Nothing needs to recognise the DM after it - * arrives. The inbox Accept goes through here too: one owner for joining. + * the join is refused by a Matrix error answer (a 4xx carrying an errcode). A + * join that fails any other way (lost response, dropped connection, a gateway's + * 5xx or a status with no errcode) may still have been accepted upstream, so the + * entry stays and the tap is retryable. Nothing needs to recognise the DM after it + * arrives. Every join of a room the client holds an invite for goes through + * here: the inbox Accept, the room card, /join. One owner for joining. */ export const activateEstateRoom = async ( mx: JoinClient, - room: Pick + room: Pick & { via?: string[] } ): Promise => { if (room.state === 'joined') return { kind: 'open', roomId: room.roomId }; const refusal = (e: unknown): EstateActivation => { const err = e as { data?: { error?: string }; message?: string }; return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; }; - const answered = (e: unknown) => typeof (e as { httpStatus?: unknown }).httpStatus === 'number'; + const refusedByServer = (e: unknown) => { + const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } }; + const status = err.httpStatus; + const errcode = err.errcode ?? err.data?.errcode; + return typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string'; + }; const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; const inviter = isDirectInvite(invite, mx.getSafeUserId()) ? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender() @@ -49,12 +56,27 @@ export const activateEstateRoom = async ( const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); return { kind: 'joined', roomId: joined.roomId }; } catch (e) { - if (!answered(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; + if (!refusedByServer(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined); return refusal(e); } }; +/** + * activateEstateRoom for a caller that holds only an id or alias (room card, + * /join): a room the client has an invite for is an invited room. + */ +export const joinEstateRoomIdOrAlias = ( + mx: JoinClient, + roomIdOrAlias: string, + via?: string[] +): Promise => + activateEstateRoom(mx, { + roomId: roomIdOrAlias, + state: mx.getRoom(roomIdOrAlias)?.getMyMembership() === 'invite' ? 'invited' : 'joinable', + via, + }); + type MembershipSource = { getRoom(roomId: string): { getMyMembership(): string } | null; on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index c02b0e94..15a21d1e 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -26,6 +26,7 @@ import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; +import { joinEstateRoomIdOrAlias } from '../features/estate/joinEstateRoom'; import { createRoomEncryptionState } from '../components/create-room'; export const SHRUG = 'ยฏ\\_(ใƒ„)_/ยฏ'; @@ -233,7 +234,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) ); roomIdOrAliases.forEach(async (idOrAlias) => { - await mx.joinRoom(idOrAlias); + await joinEstateRoomIdOrAlias(mx, idOrAlias); }); }, }, From 873c0e30a2b84d3a686cf085db0b30cdaa7fc409 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:20:42 +0100 Subject: [PATCH 38/64] estate harness: run the s6 route drives last --- harness/estate/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 70fa5d13..379d28ca 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -29,6 +29,6 @@ failure jdrop JOIN_DROP=1 failure admin INVITER_ADMIN=1 failure inboxadmin INVITER_ADMIN=1 failure inboxreload JOIN_DELAY=1 -echo DONE DRIVER=drive-routes failure deeplink DRIVER=drive-routes failure gw504 JOIN_504=1 +echo DONE From 00ec686b1b49a8cf2063de507fbd793a4f598c0c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:33:06 +0100 Subject: [PATCH 39/64] estate tests: the server-refusal and invited-room fixtures carry an errcode and a membership Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/joinEstateRoom.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 6fccae10..07ed43ea 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -68,6 +68,7 @@ describe('activateEstateRoom', () => { it('reports the server refusal and does not decide access itself', async () => { const refusal = Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, + errcode: 'M_FORBIDDEN', data: { error: 'You are not invited to this room.' }, }); const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal)); @@ -85,6 +86,7 @@ const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; d const mx = { getSafeUserId: () => '@me:test', getRoom: (roomId: string) => ({ + getMyMembership: () => 'invite', getMember: () => ({ events: { member: { From b20b95876252d0d60c0990308ac4e931b20e1521 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:34:49 +0100 Subject: [PATCH 40/64] estate: prettier Co-Authored-By: Claude Sonnet 5.5 --- .../features/estate/joinEstateRoom.test.ts | 33 +++++++++++++++---- src/app/features/estate/joinEstateRoom.ts | 4 ++- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 07ed43ea..8e009ef0 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -52,13 +52,19 @@ describe('activateEstateRoom', () => { it('falls back to the error message, then to a generic one', async () => { const named = recordingClient( - vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' })) + vi + .fn() + .mockRejectedValue( + Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ) ); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', }); - const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' })); + const bare = recordingClient( + vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ); expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Could not join', @@ -79,7 +85,13 @@ describe('activateEstateRoom', () => { }); /** A client holding DM invites as the server sends them: the mark is in the invite event only. */ -const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; drop?: boolean; invites: Record }) => { +const dmClient = (opts: { + status?: number; + errcode?: string; + refuse?: boolean; + drop?: boolean; + invites: Record; +}) => { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; @@ -107,8 +119,13 @@ const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; d joinRoom: async (roomId: string) => { // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); - if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); - if (opts.status) throw Object.assign(new Error('Gateway Time-out'), { httpStatus: opts.status, errcode: opts.errcode }); + if (opts.refuse) + throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); + if (opts.status) + throw Object.assign(new Error('Gateway Time-out'), { + httpStatus: opts.status, + errcode: opts.errcode, + }); if (opts.drop) throw new Error('fetch failed'); return { roomId }; }, @@ -162,7 +179,11 @@ describe('activateEstateRoom when a gateway answers instead of the server', () = }); it('keeps the room listed on a 5xx even when it carries an errcode', async () => { - const c = dmClient({ status: 500, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); + const c = dmClient({ + status: 500, + errcode: 'M_UNKNOWN', + invites: { '!dm1:test': '@friend:test' }, + }); await activateEstateRoom(c.mx, tap('!dm1:test')); expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); }); diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index 3cd3bf6d..d69df82c 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -41,7 +41,9 @@ export const activateEstateRoom = async ( const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } }; const status = err.httpStatus; const errcode = err.errcode ?? err.data?.errcode; - return typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string'; + return ( + typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string' + ); }; const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; const inviter = isDirectInvite(invite, mx.getSafeUserId()) From e377d9f722670462b14b8dc23e3aefad31dc0aae Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 31 Oct 2026 21:43:40 +0000 Subject: [PATCH 41/64] Background scene: two-pass WebGL2 terrain and world tree with login pull-back Pass 1 sphere-traces CultMath noise terrain (fbm plus Phacelle erosion) and the world tree with a conservative Lipschitz step, writing colour and depth. Pass 2 marches fog and clouds only up to that depth at low resolution with TAA. The camera pulls back from the tree on login. Vendors CultLib's generated GLSL (658c09e). The Phacelle gradient bound is an empirical probe, not a proof. Headless harness compiles, renders and times it; nothing run on a GPU. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/README.md | 19 + harness/scene/bundle.cjs | 35 + harness/scene/drive.cjs | 162 +++ harness/scene/entry.ts | 87 ++ src/app/features/scene/README.md | 54 + src/app/features/scene/Scene.ts | 414 +++++++ src/app/features/scene/budget.test.ts | 34 + src/app/features/scene/budget.ts | 49 + src/app/features/scene/camera.test.ts | 42 + src/app/features/scene/camera.ts | 56 + src/app/features/scene/palette.ts | 27 + src/app/features/scene/shaderSource.ts | 29 + .../scene/shaders/CultMath.Phacelle.glsl | 99 ++ src/app/features/scene/shaders/CultMath.glsl | 1035 +++++++++++++++++ src/app/features/scene/shaders/README.md | 15 + .../scene/shaders/atmosphere.frag.glsl | 90 ++ src/app/features/scene/shaders/common.glsl | 31 + .../scene/shaders/composite.frag.glsl | 26 + .../scene/shaders/fullscreen.vert.glsl | 8 + .../features/scene/shaders/resolve.frag.glsl | 41 + .../features/scene/shaders/terrain.frag.glsl | 180 +++ src/app/features/scene/world.test.ts | 40 + src/app/features/scene/world.ts | 114 ++ 23 files changed, 2687 insertions(+) create mode 100644 harness/scene/README.md create mode 100644 harness/scene/bundle.cjs create mode 100644 harness/scene/drive.cjs create mode 100644 harness/scene/entry.ts create mode 100644 src/app/features/scene/README.md create mode 100644 src/app/features/scene/Scene.ts create mode 100644 src/app/features/scene/budget.test.ts create mode 100644 src/app/features/scene/budget.ts create mode 100644 src/app/features/scene/camera.test.ts create mode 100644 src/app/features/scene/camera.ts create mode 100644 src/app/features/scene/palette.ts create mode 100644 src/app/features/scene/shaderSource.ts create mode 100644 src/app/features/scene/shaders/CultMath.Phacelle.glsl create mode 100644 src/app/features/scene/shaders/CultMath.glsl create mode 100644 src/app/features/scene/shaders/README.md create mode 100644 src/app/features/scene/shaders/atmosphere.frag.glsl create mode 100644 src/app/features/scene/shaders/common.glsl create mode 100644 src/app/features/scene/shaders/composite.frag.glsl create mode 100644 src/app/features/scene/shaders/fullscreen.vert.glsl create mode 100644 src/app/features/scene/shaders/resolve.frag.glsl create mode 100644 src/app/features/scene/shaders/terrain.frag.glsl create mode 100644 src/app/features/scene/world.test.ts create mode 100644 src/app/features/scene/world.ts diff --git a/harness/scene/README.md b/harness/scene/README.md new file mode 100644 index 00000000..382819ec --- /dev/null +++ b/harness/scene/README.md @@ -0,0 +1,19 @@ +# Scene harness + +Compiles, renders and times the background scene (`src/app/features/scene/`) in headless +Chromium. Not shipped, not run by CI. + + node bundle.cjs # esbuild bundle of entry.ts plus an index.html + node drive.cjs compile # compile and link each stage, JSON report + node drive.cjs shots # rest, mid pull-back and pulled-back screenshots + node drive.cjs measure # GPU time per pass by timer query, two viewports, two levels + node drive.cjs variants # the same with source patches (no erosion, no tree, value-only fbm) + node drive.cjs phacelle # the Phacelle gradient probe behind world.ts `phacelleGradBound` + +Needs `esbuild` and `playwright@1.48.2` on `NODE_PATH` and a Chromium. The first run was done on a +host with no GPU, so `drive.cjs` launches with `--use-angle=swiftshader` (software Vulkan). On a +machine with a GPU, remove those flags to measure the real device. `bundle.cjs` must run from a +directory whose ancestors hold no empty `package.json` (esbuild reads them). + +Timings from SwiftShader are CPU time: use them for the split between passes and for before and +after of one change, never as a phone frame time. diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs new file mode 100644 index 00000000..62a47078 --- /dev/null +++ b/harness/scene/bundle.cjs @@ -0,0 +1,35 @@ +// Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html. +// usage: node bundle.cjs (esbuild from NODE_PATH) +const esbuild = require('esbuild'); +const fs = require('fs'); +const path = require('path'); +const out = process.argv[2]; +fs.mkdirSync(out, { recursive: true }); +const rawPlugin = { + name: 'raw', + setup(b) { + b.onResolve({ filter: /\?raw$/ }, (a) => ({ + path: path.resolve(a.resolveDir, a.path.slice(0, -4)), + namespace: 'raw', + })); + b.onLoad({ filter: /.*/, namespace: 'raw' }, (a) => ({ + contents: fs.readFileSync(a.path, 'utf8'), + loader: 'text', + })); + }, +}; +esbuild + .build({ + entryPoints: [path.join(__dirname, 'entry.ts')], + bundle: true, + outfile: path.join(out, 'bundle.js'), + plugins: [rawPlugin], + format: 'iife', + target: 'es2020', + }) + .then(() => { + fs.writeFileSync( + path.join(out, 'index.html'), + '' + ); + }); diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs new file mode 100644 index 00000000..3b41c567 --- /dev/null +++ b/harness/scene/drive.cjs @@ -0,0 +1,162 @@ +// Drives the scene in headless Chromium (software GL unless the host has a GPU). +// usage: node drive.cjs [outdir] modes: compile | shots | measure +// Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report. +const { chromium } = require('playwright'); +const path = require('path'); +const [, , dir, mode, outdir = '.'] = process.argv; + +(async () => { + const b = await chromium.launch({ + args: [ + '--use-gl=angle', + '--use-angle=swiftshader', + '--enable-unsafe-swiftshader', + '--ignore-gpu-blocklist', + ], + }); + const page = await b.newPage({ viewport: { width: 960, height: 540 } }); + page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`)); + await page.goto('file://' + path.resolve(dir, 'index.html')); + const report = { mode }; + report.renderer = await page.evaluate(() => { + const g = document.createElement('canvas').getContext('webgl2'); + const e = g.getExtension('WEBGL_debug_renderer_info'); + return g.getParameter(e ? e.UNMASKED_RENDERER_WEBGL : g.RENDERER); + }); + + if (mode === 'compile') { + report.stages = await page.evaluate(() => { + const gl = document.createElement('canvas').getContext('webgl2'); + const S = window.__scene; + return ['terrain', 'atmosphere', 'resolve', 'composite'].map((stage) => { + const t0 = performance.now(); + try { + const p = S.linkStage(gl, stage); + return { + stage, + ok: true, + ms: +(performance.now() - t0).toFixed(1), + sourceBytes: S.fragmentSource(stage).length, + log: gl.getProgramInfoLog(p), + }; + } catch (e) { + return { + stage, + ok: false, + ms: +(performance.now() - t0).toFixed(1), + log: String(e.log || e), + }; + } + }); + }); + } + + if (mode === 'phacelle') { + report.probe = await page.evaluate(() => + window.__probePhacelle( + window.__scene.WORLD.erodeSide.slice(), + window.__scene.WORLD.erodeNorm, + 4 + ) + ); + } + + if (mode === 'shots') { + await page.evaluate(() => { + const c = document.getElementById('c'); + window.__s = window.__scene.createScene({ + canvas: c, + unthrottled: true, + fixedLevel: window.__scene.LEVELS[0], + }); + }); + const wait = (ms) => page.waitForTimeout(ms); + await wait(4000); + await page.screenshot({ path: path.join(outdir, 'rest.png') }); + await page.evaluate(() => window.__s.login()); + await wait(3500); + await page.screenshot({ path: path.join(outdir, 'mid-pullback.png') }); + await wait(9000); + await page.screenshot({ path: path.join(outdir, 'pulled-back.png') }); + report.shots = ['rest.png', 'mid-pullback.png', 'pulled-back.png']; + } + + if (mode === 'measure' || mode === 'variants') { + const VARIANTS = { + baseline: null, + // What a public value-only fbm would cost: the same octaves without the gradient work. + valueOnlyFbm: [ + 'float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }', + 'float terrainFbm(vec2 xz) { float a = 1.0, fr = 1.0, v = 0.0; for (int i = 0; i < W_OCTAVES; i++) { v += a * cultmath_snoise(vec3(xz * W_FREQ * fr, 0.0)); fr *= W_LACUNARITY; a *= W_GAIN; } return v; }', + ], + noErosion: ['#define W_ERODE_ENABLED 1', '#define W_ERODE_ENABLED 0'], + noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'], + }; + const wanted = mode === 'variants' ? Object.keys(VARIANTS) : ['baseline']; + const sizes = [ + [960, 540], + [540, 1200], + ]; + report.runs = []; + for (const [w, h] of sizes) { + for (const [li, variant] of wanted.flatMap((v) => + mode === 'variants' + ? [[0, v]] + : [ + [0, v], + [3, v], + ] + )) { + await page.setViewportSize({ width: w, height: h }); + const r = await page.evaluate( + ({ li, rep }) => + new Promise((res) => { + const rows = []; + const c = document.getElementById('c'); + const s = window.__scene.createScene({ + canvas: c, + unthrottled: true, + fixedLevel: window.__scene.LEVELS[li], + patchSource: rep + ? (st, src) => + st === 'terrain' + ? rep[0].startsWith('#define') + ? src.replace(/#define W_ERODE_ENABLED \d/, rep[1]) + : src.split(rep[0]).join(rep[1]) + : src + : undefined, + onStats: (st) => { + rows.push(st); + if (rows.length === 40) { + s.dispose(); + const keep = rows.slice(10); + const med = (k) => + keep.map((x) => x.passMs[k]).sort((a, b) => a - b)[keep.length >> 1]; + res({ + solid: keep[0].solidSize, + atm: keep[0].atmosphereSize, + medianMs: { + terrain: med('terrain'), + atmosphere: med('atmosphere'), + resolve: med('resolve'), + composite: med('composite'), + }, + }); + } + }, + }); + if (!s) res({ error: 'createScene returned null' }); + }), + { li, rep: VARIANTS[variant] } + ); + report.runs.push({ viewport: [w, h], level: li, variant, ...r }); + } + } + } + + console.log(JSON.stringify(report, null, 1)); + await b.close(); +})().catch((e) => { + console.log('ERR', String(e).slice(0, 2000)); + process.exit(1); +}); diff --git a/harness/scene/entry.ts b/harness/scene/entry.ts new file mode 100644 index 00000000..d43a5453 --- /dev/null +++ b/harness/scene/entry.ts @@ -0,0 +1,87 @@ +// Browser entry for the harness: exposes the scene's seams to the driver. Not shipped. +import { createScene, linkStage, ShaderCompileError } from '../../src/app/features/scene/Scene'; +import { LEVELS } from '../../src/app/features/scene/budget'; +import { fragmentSource } from '../../src/app/features/scene/shaderSource'; +import { WORLD } from '../../src/app/features/scene/world'; + +(window as unknown as Record).__scene = { + createScene, + linkStage, + ShaderCompileError, + LEVELS, + fragmentSource, + WORLD, +}; + +// ---- Phacelle gradient probe ----------------------------------------------------------------- +import cultmath from '../../src/app/features/scene/shaders/CultMath.glsl?raw'; +import phacelle from '../../src/app/features/scene/shaders/CultMath.Phacelle.glsl?raw'; +import { vertexSource } from '../../src/app/features/scene/shaderSource'; + +const PROBE_FRAG = `#version 300 es +precision highp float; precision highp int; +${cultmath} +${phacelle} +uniform float u_seed; uniform vec3 u_side; uniform float u_norm; uniform float u_span; +in vec2 v_uv; layout(location=0) out vec4 o; +void main() { + // The scene samples the z = 0 slice: random points in a u_span square of it. + vec3 h = vec3(cultmath_pcg3d(vec3(gl_FragCoord.xy, u_seed))) ; + vec2 xy = vec2(float(uint(int(h.x)) >> 8), float(uint(int(h.y)) >> 8)) * (1.0 / 16777216.0) * u_span; + CultPhasor ph = cultmath_phacelle(vec3(xy, 0.0), u_side, 0.0, u_norm); + o = vec4(length(ph.cos.xyz), length(ph.sin.xyz), ph.cos.w, ph.sin.w); +}`; + +/** Max |grad| of the phasor over `draws * 1024^2` random points; min/max of the cos component. */ +(window as unknown as Record).__probePhacelle = ( + side: number[], + norm: number, + draws: number +) => { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl2')!; + gl.getExtension('EXT_color_buffer_float'); + const mk = (t: number, s: string) => { + const x = gl.createShader(t)!; + gl.shaderSource(x, s); + gl.compileShader(x); + if (!gl.getShaderParameter(x, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(x)!); + return x; + }; + const p = gl.createProgram()!; + gl.attachShader(p, mk(gl.VERTEX_SHADER, vertexSource)); + gl.attachShader(p, mk(gl.FRAGMENT_SHADER, PROBE_FRAG)); + gl.linkProgram(p); + gl.useProgram(p); + const N = 1024; + const tex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, N, N); + const fbo = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); + gl.viewport(0, 0, N, N); + gl.bindVertexArray(gl.createVertexArray()); + gl.uniform3fv(gl.getUniformLocation(p, 'u_side'), side); + gl.uniform1f(gl.getUniformLocation(p, 'u_norm'), norm); + gl.uniform1f(gl.getUniformLocation(p, 'u_span'), 400); + const buf = new Float32Array(N * N * 4); + let gradCos = 0, + gradSin = 0, + vMin = 1e9, + vMax = -1e9, + n = 0; + for (let d = 0; d < draws; d++) { + gl.uniform1f(gl.getUniformLocation(p, 'u_seed'), d + 1); + gl.drawArrays(gl.TRIANGLES, 0, 3); + gl.readPixels(0, 0, N, N, gl.RGBA, gl.FLOAT, buf); + for (let i = 0; i < buf.length; i += 4) { + if (buf[i] > gradCos) gradCos = buf[i]; + if (buf[i + 1] > gradSin) gradSin = buf[i + 1]; + if (buf[i + 2] < vMin) vMin = buf[i + 2]; + if (buf[i + 2] > vMax) vMax = buf[i + 2]; + n++; + } + } + return { samples: n, maxGradCos: gradCos, maxGradSin: gradSin, minCos: vMin, maxCos: vMax }; +}; diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md new file mode 100644 index 00000000..deed806a --- /dev/null +++ b/src/app/features/scene/README.md @@ -0,0 +1,54 @@ +# Background scene + +A two-pass WebGL2 scene for the client's background canvas: an infinite terrain of CultMath +noise with a world tree at its centre, dusk atmosphere and clouds, and a camera that pulls back +from the tree on login. It is a self-contained module; nothing in the client imports it yet. +Integration (a canvas under the app, `createScene`, `login()` when the session starts, `dispose()`) +is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel are later cuts. + +## Entry points + +- `Scene.ts` `createScene({ canvas, ... })` returns `{ login, dispose }`, or `null` when WebGL2 or + `EXT_color_buffer_float` is missing (keep the CSS ground then). It caps at 30 fps, pauses when + the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion`, and steps down a + quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist. +- `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`). +- `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL + `#define`s are generated from it; no number is written twice. +- `shaders/` pass 1 `terrain.frag.glsl`, pass 2 `atmosphere.frag.glsl`, `resolve.frag.glsl` (TAA), + `composite.frag.glsl`, plus CultLib's vendored GLSL (`shaders/README.md`). + +## How it renders + +1. **Solid pass** (`terrain.frag.glsl`, 0.4-0.75 of canvas resolution): sphere trace of + `min(terrain, tree)`. Terrain height is `mask(r) * (A * fbm + E * phacelle)`, with + `cultmath_fbm_grad(...).w` and `cultmath_phacelle(...).cos.w`. It writes lit colour with + coverage and the hit distance (second render target). +2. **Atmosphere pass** (0.25-0.5 of canvas): closed-form exponential height fog plus a cloud + slab marched with a few jittered steps, both only up to the hit distance from pass 1. +3. **Resolve**: TAA on the atmosphere (Halton jitter, history reprojected by the pass-1 depth, + 3x3 neighbourhood clamp, 1/8 blend; reset on resize, level change and wake). +4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES + tonemap, dither. + +## The step bound, and what is not proven + +The trace steps by `(y - h(x, z)) / sqrt(1 + G^2)`, `G` the Lipschitz bound of the height field, +computed from `SNOISE_LIPSCHITZ` (CultMath's constant for `snoise`) in `world.ts`. Above the +envelope (fbm plus the erosion amplitude) the step uses the envelope's bound alone and Phacelle is +not evaluated. + +- The Phacelle gradient bound `phacelleGradBound` (6.3) is **an empirical probe, not a proof**: + the maximum `|grad|` of the cos component over 4,194,304 random points (5.04) times 1.25 + (`harness/scene/drive.cjs phacelle`). Replace it with Libby's bound; until then the march is + only as safe as that probe. +- The bound makes grazing rays crawl. A ray that exhausts its steps while still below the highest + terrain is ended as a distant surface (fog hides it). That is a visible approximation, not a + hit proven by the march. +- The low-resolution atmosphere samples pass-1 depth nearest, so silhouettes of near objects + carry a thin fog halo. + +## Measured and not measured + +See the pull request or `harness/scene/README.md` for the numbers and how they were taken. They +come from a software rasterizer, not a GPU, and nothing has been run on a phone. diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts new file mode 100644 index 00000000..367e6cc9 --- /dev/null +++ b/src/app/features/scene/Scene.ts @@ -0,0 +1,414 @@ +import { fragmentSource, vertexSource, Stage } from './shaderSource'; +import { cameraPose, pullProgress, CameraPose } from './camera'; +import { BudgetLadder, Level } from './budget'; +import { PALETTE } from './palette'; + +/** Phone GPU budget for the whole scene, from the ground-cut doc's budget table. */ +export const GPU_BUDGET_MS = 4.0; +const FRAME_INTERVAL_MS = 1000 / 30; +const REDUCED_MOTION_FRAMES = 32; +const MAX_DPR = 1.5; + +export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; +const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite']; + +export interface SceneStats { + /** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */ + passMs: Record; + level: number; + solidSize: [number, number]; + atmosphereSize: [number, number]; +} + +export interface SceneOptions { + canvas: HTMLCanvasElement; + /** Called per finished frame when timer queries exist. */ + onStats?: (s: SceneStats) => void; + /** Weather knob, 0 clear .. 1 overcast. */ + coverage?: number; + /** Fixed level instead of the adaptive ladder (measurement). */ + fixedLevel?: Level; + /** Ignore the 30 fps cap and the visibility pause (measurement). */ + unthrottled?: boolean; + patchSource?: SourcePatch; +} + +export interface Scene { + /** Start the camera pull-back from the tree. */ + login(): void; + dispose(): void; +} + +/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */ +export type SourcePatch = (stage: Stage, source: string) => string; + +export class ShaderCompileError extends Error { + constructor(readonly stage: Stage | 'vertex', readonly log: string) { + super(`${stage} shader failed to compile:\n${log}`); + } +} + +function compile( + gl: WebGL2RenderingContext, + type: number, + source: string, + stage: Stage | 'vertex' +): WebGLShader { + const sh = gl.createShader(type)!; + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(sh) ?? ''; + gl.deleteShader(sh); + throw new ShaderCompileError(stage, log); + } + return sh; +} + +export function linkStage( + gl: WebGL2RenderingContext, + stage: Stage, + patch?: SourcePatch +): WebGLProgram { + const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex'); + const fs = compile( + gl, + gl.FRAGMENT_SHADER, + patch ? patch(stage, fragmentSource(stage)) : fragmentSource(stage), + stage + ); + const prog = gl.createProgram()!; + gl.attachShader(prog, vs); + gl.attachShader(prog, fs); + gl.linkProgram(prog); + gl.deleteShader(vs); + gl.deleteShader(fs); + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(prog) ?? ''; + gl.deleteProgram(prog); + throw new ShaderCompileError(stage, log); + } + return prog; +} + +interface Target { + tex: WebGLTexture; + w: number; + h: number; +} + +function halton(i: number, base: number): number { + let f = 1; + let r = 0; + let n = i; + while (n > 0) { + f /= base; + r += f * (n % base); + n = Math.floor(n / base); + } + return r; +} + +/** Null when WebGL2 or float colour buffers are missing; the host keeps its CSS ground. */ +export function createScene(opts: SceneOptions): Scene | null { + const { canvas } = opts; + const gl = canvas.getContext('webgl2', { + antialias: false, + alpha: false, + depth: false, + stencil: false, + powerPreference: 'low-power', + }); + if (!gl || !gl.getExtension('EXT_color_buffer_float')) return null; + gl.getExtension('OES_texture_float_linear'); + const timer = gl.getExtension('EXT_disjoint_timer_query_webgl2') as { + TIME_ELAPSED_EXT: number; + GPU_DISJOINT_EXT: number; + } | null; + + const programs = {} as Record; + for (const p of PASSES) programs[p] = linkStage(gl, p, opts.patchSource); + const uniforms = new Map>(); + const loc = (p: WebGLProgram, name: string) => { + let m = uniforms.get(p); + if (!m) uniforms.set(p, (m = new Map())); + if (!m.has(name)) m.set(name, gl.getUniformLocation(p, name)); + return m.get(name) ?? null; + }; + + const vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + + const makeTarget = (w: number, h: number, format: number, filter: number): Target => { + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texStorage2D(gl.TEXTURE_2D, 1, format, w, h); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + return { tex, w, h }; + }; + + const fbos = { + solid: gl.createFramebuffer()!, + atm: gl.createFramebuffer()!, + hist: [gl.createFramebuffer()!, gl.createFramebuffer()!], + }; + let solid: Target; + let depth: Target; + let atm: Target; + let hist: [Target, Target]; + const freeTargets = () => { + for (const t of [solid, depth, atm, hist?.[0], hist?.[1]]) if (t) gl.deleteTexture(t.tex); + }; + + const attach = (fbo: WebGLFramebuffer, targets: Target[]) => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + targets.forEach((t, i) => + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, t.tex, 0) + ); + gl.drawBuffers(targets.map((_, i) => gl.COLOR_ATTACHMENT0 + i)); + }; + + const ladder = new BudgetLadder(GPU_BUDGET_MS); + const level = (): Level => opts.fixedLevel ?? ladder.current; + + let canvasW = 0; + let canvasH = 0; + let sizedFor: Level | undefined; + let resetHistory = true; + + const ensureSize = () => { + const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); + const w = Math.max(2, Math.round(canvas.clientWidth * dpr)); + const h = Math.max(2, Math.round(canvas.clientHeight * dpr)); + const lv = level(); + if (w === canvasW && h === canvasH && lv === sizedFor) return; + canvasW = w; + canvasH = h; + sizedFor = lv; + canvas.width = w; + canvas.height = h; + freeTargets(); + const sw = Math.max(2, Math.round(w * lv.solidScale)); + const sh = Math.max(2, Math.round(h * lv.solidScale)); + const aw = Math.max(2, Math.round(w * lv.atmosphereScale)); + const ah = Math.max(2, Math.round(h * lv.atmosphereScale)); + solid = makeTarget(sw, sh, gl.RGBA16F, gl.LINEAR); + depth = makeTarget(sw, sh, gl.R16F, gl.NEAREST); + atm = makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR); + hist = [makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR), makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR)]; + attach(fbos.solid, [solid, depth]); + attach(fbos.atm, [atm]); + attach(fbos.hist[0], [hist[0]]); + attach(fbos.hist[1], [hist[1]]); + resetHistory = true; + }; + + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + const start = performance.now(); + let loginAt: number | undefined; + let frame = 0; + let histIndex = 0; + let prev: CameraPose | undefined; + let raf = 0; + let lastRendered = -Infinity; + let framesLeft = Infinity; // reduced motion bounds this + let disposed = false; + + // Timer queries in flight: each holds one pass's query. + const pending: Array<{ q: WebGLQuery; pass: PassName; frame: number }> = []; + const finished = new Map>>(); + + const timed = (pass: PassName, f: number, body: () => void) => { + if (!timer || !opts.onStats) return body(); + const q = gl.createQuery()!; + gl.beginQuery(timer.TIME_ELAPSED_EXT, q); + body(); + gl.endQuery(timer.TIME_ELAPSED_EXT); + pending.push({ q, pass, frame: f }); + }; + + const collect = () => { + if (!timer) return; + while (pending.length && gl.getQueryParameter(pending[0].q, gl.QUERY_RESULT_AVAILABLE)) { + const { q, pass, frame: f } = pending.shift()!; + const disjoint = gl.getParameter(timer.GPU_DISJOINT_EXT); + const ns = gl.getQueryParameter(q, gl.QUERY_RESULT) as number; + gl.deleteQuery(q); + const row = finished.get(f) ?? {}; + if (!disjoint) row[pass] = ns / 1e6; + finished.set(f, row); + if (Object.keys(row).length === PASSES.length) { + finished.delete(f); + const passMs = row as Record; + const total = PASSES.reduce((s, p) => s + passMs[p], 0); + if (!opts.fixedLevel && ladder.push(total)) sizedFor = undefined; + opts.onStats?.({ + passMs, + level: ladder.index, + solidSize: [solid.w, solid.h], + atmosphereSize: [atm.w, atm.h], + }); + } + } + }; + + const setCommon = (p: WebGLProgram, pose: CameraPose, time: number) => { + const aspect = canvasW / canvasH; + const th = Math.tan(pose.fov / 2); + gl.uniform3fv(loc(p, 'u_camPos'), pose.position as unknown as number[]); + gl.uniformMatrix3fv(loc(p, 'u_camBasis'), false, pose.basis as unknown as number[]); + gl.uniform2f(loc(p, 'u_tanHalf'), th * aspect, th); + gl.uniform1f(loc(p, 'u_time'), time); + gl.uniform3fv(loc(p, 'u_sunDir'), PALETTE.sunDir as unknown as number[]); + gl.uniform3fv(loc(p, 'u_sunColor'), PALETTE.sunColor as unknown as number[]); + gl.uniform3fv(loc(p, 'u_skyTop'), PALETTE.skyTop as unknown as number[]); + gl.uniform3fv(loc(p, 'u_skyHorizon'), PALETTE.skyHorizon as unknown as number[]); + gl.uniform3fv(loc(p, 'u_fogColor'), PALETTE.fogColor as unknown as number[]); + }; + + const bindTex = (unit: number, t: Target, p: WebGLProgram, name: string) => { + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, t.tex); + gl.uniform1i(loc(p, name), unit); + }; + + const draw = () => gl.drawArrays(gl.TRIANGLES, 0, 3); + + const render = (now: number) => { + ensureSize(); + const lv = level(); + const frozen = reducedMotion?.matches ?? false; + const time = frozen ? 0 : (now - start) / 1000; + const elapsed = loginAt === undefined ? undefined : frozen ? 1e9 : (now - loginAt) / 1000; + const pose = cameraPose(pullProgress(elapsed), time); + const f = frame++; + const jx = halton((f % 16) + 1, 2) - 0.5; + const jy = halton((f % 16) + 1, 3) - 0.5; + + // Pass 1: solid surfaces, writes colour and depth. + timed('terrain', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); + gl.viewport(0, 0, solid.w, solid.h); + const p = programs.terrain; + gl.useProgram(p); + setCommon(p, pose, time); + gl.uniform3fv(loc(p, 'u_grass'), PALETTE.grass as unknown as number[]); + gl.uniform3fv(loc(p, 'u_rock'), PALETTE.rock as unknown as number[]); + gl.uniform3fv(loc(p, 'u_bark'), PALETTE.bark as unknown as number[]); + gl.uniform3fv(loc(p, 'u_leaf'), PALETTE.leaf as unknown as number[]); + gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps); + draw(); + }); + + // Pass 2: atmosphere up to pass 1's depth, low-res. + timed('atmosphere', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.atm); + gl.viewport(0, 0, atm.w, atm.h); + const p = programs.atmosphere; + gl.useProgram(p); + setCommon(p, pose, time); + bindTex(0, depth, p, 'u_depth'); + gl.uniform1i(loc(p, 'u_steps'), lv.cloudSteps); + gl.uniform1f(loc(p, 'u_frame'), f); + gl.uniform2f(loc(p, 'u_jitter'), jx, jy); + gl.uniform2f(loc(p, 'u_size'), atm.w, atm.h); + gl.uniform1f(loc(p, 'u_coverage'), opts.coverage ?? 0.45); + draw(); + }); + + // TAA resolve into the history that is not being read. + const write = histIndex ^ 1; + timed('resolve', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.hist[write]); + gl.viewport(0, 0, atm.w, atm.h); + const p = programs.resolve; + gl.useProgram(p); + setCommon(p, pose, time); + bindTex(0, atm, p, 'u_cur'); + bindTex(1, hist[histIndex], p, 'u_history'); + bindTex(2, depth, p, 'u_depth'); + const pp = prev ?? pose; + const th = Math.tan(pp.fov / 2); + gl.uniform3fv(loc(p, 'u_prevPos'), pp.position as unknown as number[]); + gl.uniformMatrix3fv(loc(p, 'u_prevBasis'), false, pp.basis as unknown as number[]); + gl.uniform2f(loc(p, 'u_prevTanHalf'), th * (canvasW / canvasH), th); + gl.uniform1f(loc(p, 'u_blend'), resetHistory ? 1 : 0.125); + gl.uniform2f(loc(p, 'u_curSize'), atm.w, atm.h); + draw(); + }); + + // Full-resolution composite to the canvas. + timed('composite', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, canvasW, canvasH); + const p = programs.composite; + gl.useProgram(p); + setCommon(p, pose, time); + bindTex(0, solid, p, 'u_solid'); + bindTex(1, hist[write], p, 'u_atm'); + gl.uniform1f(loc(p, 'u_frame'), f); + draw(); + }); + + histIndex = write; + prev = pose; + resetHistory = false; + collect(); + }; + + const tick = (now: number) => { + raf = 0; + if (disposed) return; + const due = opts.unthrottled || now - lastRendered >= FRAME_INTERVAL_MS - 2; + if (due) { + if ( + !opts.fixedLevel && + !timer && + lastRendered > 0 && + now - lastRendered > FRAME_INTERVAL_MS * 1.5 + ) + ladder.push(GPU_BUDGET_MS * 2) && (sizedFor = undefined); // no timer: a missed cap counts as over budget + lastRendered = now; + render(now); + framesLeft -= 1; + } + if (framesLeft > 0 && (opts.unthrottled || document.visibilityState === 'visible')) + raf = requestAnimationFrame(tick); + }; + + const wake = () => { + if (disposed || raf) return; + if (reducedMotion?.matches && framesLeft <= 0) return; + resetHistory = true; // the first frame after a pause has a real elapsed dt + raf = requestAnimationFrame(tick); + }; + const onVisibility = () => { + if (document.visibilityState === 'visible') wake(); + }; + const onResize = () => wake(); + if (!opts.unthrottled) document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('resize', onResize); + if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; + wake(); + + return { + login() { + loginAt = performance.now(); + if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; + wake(); + }, + dispose() { + disposed = true; + if (raf) cancelAnimationFrame(raf); + document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('resize', onResize); + freeTargets(); + for (const p of PASSES) gl.deleteProgram(programs[p]); + gl.deleteVertexArray(vao); + for (const f of [fbos.solid, fbos.atm, ...fbos.hist]) gl.deleteFramebuffer(f); + }, + }; +} diff --git a/src/app/features/scene/budget.test.ts b/src/app/features/scene/budget.test.ts new file mode 100644 index 00000000..27dd0487 --- /dev/null +++ b/src/app/features/scene/budget.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { BudgetLadder, LEVELS } from './budget'; + +describe('budget ladder', () => { + it('steps down after a whole window over budget and never back up', () => { + const ladder = new BudgetLadder(4, 5); + for (let i = 0; i < 4; i++) expect(ladder.push(9)).toBe(false); + expect(ladder.push(9)).toBe(true); + expect(ladder.index).toBe(1); + for (let i = 0; i < 100; i++) ladder.push(0.1); // fast frames never raise the level + expect(ladder.index).toBe(1); + }); + + it('judges the median, so one slow frame does not step down', () => { + const ladder = new BudgetLadder(4, 5); + for (const ms of [1, 1, 50, 1, 1]) ladder.push(ms); + expect(ladder.index).toBe(0); + }); + + it('stops at the last level', () => { + const ladder = new BudgetLadder(4, 1); + for (let i = 0; i < 20; i++) ladder.push(99); + expect(ladder.index).toBe(LEVELS.length - 1); + }); + + it('every level is cheaper than the one before in each cost axis', () => { + for (let i = 1; i < LEVELS.length; i++) { + expect(LEVELS[i].solidScale).toBeLessThan(LEVELS[i - 1].solidScale); + expect(LEVELS[i].atmosphereScale).toBeLessThan(LEVELS[i - 1].atmosphereScale); + expect(LEVELS[i].cloudSteps).toBeLessThan(LEVELS[i - 1].cloudSteps); + expect(LEVELS[i].traceSteps).toBeLessThan(LEVELS[i - 1].traceSteps); + } + }); +}); diff --git a/src/app/features/scene/budget.ts b/src/app/features/scene/budget.ts new file mode 100644 index 00000000..9550571c --- /dev/null +++ b/src/app/features/scene/budget.ts @@ -0,0 +1,49 @@ +// The adaptive step-down. The ladder only descends within a session; nothing here ever steps up. + +export interface Level { + /** Pass 1 resolution as a fraction of the canvas. */ + solidScale: number; + /** Pass 2 resolution as a fraction of the canvas. */ + atmosphereScale: number; + /** Cloud march steps. */ + cloudSteps: number; + /** Sphere-trace step cap. */ + traceSteps: number; +} + +export const LEVELS: readonly Level[] = [ + { solidScale: 0.75, atmosphereScale: 0.5, cloudSteps: 12, traceSteps: 96 }, + { solidScale: 0.6, atmosphereScale: 0.4, cloudSteps: 8, traceSteps: 80 }, + { solidScale: 0.5, atmosphereScale: 0.33, cloudSteps: 6, traceSteps: 64 }, + { solidScale: 0.4, atmosphereScale: 0.25, cloudSteps: 4, traceSteps: 48 }, +]; + +/** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */ +export class BudgetLadder { + private level = 0; + private recent: number[] = []; + + constructor(private readonly budgetMs: number, private readonly window = 45) {} + + get current(): Level { + return LEVELS[this.level]; + } + + get index(): number { + return this.level; + } + + /** Feed one frame's GPU (or cadence) time. Returns true when the level changed. */ + push(ms: number): boolean { + this.recent.push(ms); + if (this.recent.length < this.window) return false; + const sorted = [...this.recent].sort((a, b) => a - b); + const median = sorted[sorted.length >> 1]; + this.recent = []; + if (median > this.budgetMs && this.level < LEVELS.length - 1) { + this.level += 1; + return true; + } + return false; + } +} diff --git a/src/app/features/scene/camera.test.ts b/src/app/features/scene/camera.test.ts new file mode 100644 index 00000000..f7199a65 --- /dev/null +++ b/src/app/features/scene/camera.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { cameraPose, pullProgress, PULL_BACK_SECONDS } from './camera'; +import { WORLD } from './world'; + +const dist = (p: readonly [number, number, number]) => Math.hypot(p[0], p[2]); + +describe('login pull-back', () => { + it('is at rest before login and complete after PULL_BACK_SECONDS', () => { + expect(pullProgress(undefined)).toBe(0); + expect(pullProgress(0)).toBe(0); + expect(pullProgress(PULL_BACK_SECONDS)).toBe(1); + expect(pullProgress(PULL_BACK_SECONDS * 10)).toBe(1); + }); + + it('only moves away from the tree and never reverses', () => { + let last = -1; + for (let s = 0; s <= PULL_BACK_SECONDS; s += 0.1) { + const d = dist(cameraPose(pullProgress(s), 0).position); + expect(d).toBeGreaterThanOrEqual(last); + last = d; + } + }); + + it('ends over the flat ground so it cannot be inside the terrain, whatever the orbit angle', () => { + for (let t = 0; t < 2000; t += 37) { + expect(dist(cameraPose(1, t).position)).toBeLessThan(WORLD.plateauInner); + } + }); + + it('looks at the tree: forward points at the axis and the basis is orthonormal', () => { + const pose = cameraPose(0.5, 12); + const [rx, ry, rz, ux, uy, uz, fx, fy, fz] = pose.basis; + const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + expect(dot([rx, ry, rz], [ux, uy, uz])).toBeCloseTo(0, 6); + expect(dot([rx, ry, rz], [fx, fy, fz])).toBeCloseTo(0, 6); + expect(dot([ux, uy, uz], [fx, fy, fz])).toBeCloseTo(0, 6); + expect(Math.hypot(fx, fy, fz)).toBeCloseTo(1, 6); + // Forward points back across the tree axis, not away from it. + const [px, , pz] = pose.position; + expect(fx * px + fz * pz).toBeLessThan(0); + }); +}); diff --git a/src/app/features/scene/camera.ts b/src/app/features/scene/camera.ts new file mode 100644 index 00000000..2b08f0d1 --- /dev/null +++ b/src/app/features/scene/camera.ts @@ -0,0 +1,56 @@ +// The scene's camera: close under the tree at rest, pulled back on login, drifting after. +// Pure functions of time so the pull-back is testable without a GL context. + +export type Vec3 = readonly [number, number, number]; + +export interface CameraPose { + position: Vec3; + /** Column-major right, up, forward: the layout of the shader's mat3 uniform. */ + basis: readonly [number, number, number, number, number, number, number, number, number]; + /** Vertical field of view in radians. */ + fov: number; +} + +export const PULL_BACK_SECONDS = 6; + +// Keep the end distance inside WORLD.plateauInner so the camera is always over flat ground. +const NEAR = { radius: 20, camY: 5, targetY: 34, fov: 1.15 }; +const FAR = { radius: 110, camY: 50, targetY: 36, fov: 0.82 }; +const DRIFT_RAD_PER_SEC = 0.025; + +const smoother = (x: number) => x * x * x * (x * (x * 6 - 15) + 10); +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + +/** 0 before login, easing to 1 over PULL_BACK_SECONDS. `elapsed` is seconds since login. */ +export function pullProgress(elapsed: number | undefined): number { + if (elapsed === undefined || elapsed <= 0) return 0; + return smoother(Math.min(1, elapsed / PULL_BACK_SECONDS)); +} + +function norm(v: Vec3): Vec3 { + const l = Math.hypot(v[0], v[1], v[2]) || 1; + return [v[0] / l, v[1] / l, v[2] / l]; +} +function cross(a: Vec3, b: Vec3): Vec3 { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +} + +/** + * @param pull 0..1 from pullProgress + * @param time seconds since the scene started, for the slow orbit + */ +export function cameraPose(pull: number, time: number): CameraPose { + const radius = lerp(NEAR.radius, FAR.radius, pull); + const camY = lerp(NEAR.camY, FAR.camY, pull); + const targetY = lerp(NEAR.targetY, FAR.targetY, pull); + const azimuth = 0.9 + time * DRIFT_RAD_PER_SEC; + const position: Vec3 = [radius * Math.cos(azimuth), camY, radius * Math.sin(azimuth)]; + const forward = norm([-position[0], targetY - position[1], -position[2]]); + const right = norm(cross([0, 1, 0], forward)); + const up = cross(forward, right); + return { + position, + basis: [...right, ...up, ...forward] as unknown as CameraPose['basis'], + fov: lerp(NEAR.fov, FAR.fov, pull), + }; +} diff --git a/src/app/features/scene/palette.ts b/src/app/features/scene/palette.ts new file mode 100644 index 00000000..54a84ad3 --- /dev/null +++ b/src/app/features/scene/palette.ts @@ -0,0 +1,27 @@ +// The scene's colours, as linear RGB uniforms. The sky tints are GameCult's three nebula inputs +// (orange 255 138 42, violet 109 96 255, sky 89 183 255) over the brand base #07111a, so the +// background reads as the same dusk as gamecult.org. The shaders hold no colour literal. + +export type Rgb = readonly [number, number, number]; + +const srgb = (r: number, g: number, b: number): Rgb => [ + (r / 255) ** 2.2, + (g / 255) ** 2.2, + (b / 255) ** 2.2, +]; + +export const PALETTE = { + sunColor: srgb(255, 150, 70).map((c) => c * 3.2) as unknown as Rgb, + skyTop: srgb(30, 36, 92), + skyHorizon: srgb(255, 138, 42).map((c) => c * 0.55) as unknown as Rgb, + fogColor: srgb(109, 96, 255).map((c) => c * 0.12) as unknown as Rgb, + grass: srgb(72, 112, 86), + rock: srgb(112, 104, 122), + bark: srgb(62, 46, 40), + leaf: srgb(89, 183, 255).map((c) => c * 0.7) as unknown as Rgb, + sunDir: (() => { + const v = [0.55, 0.16, -0.82]; + const l = Math.hypot(v[0], v[1], v[2]); + return [v[0] / l, v[1] / l, v[2] / l] as Rgb; + })(), +} as const; diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts new file mode 100644 index 00000000..aa44d9b2 --- /dev/null +++ b/src/app/features/scene/shaderSource.ts @@ -0,0 +1,29 @@ +import cultmath from './shaders/CultMath.glsl?raw'; +import phacelle from './shaders/CultMath.Phacelle.glsl?raw'; +import common from './shaders/common.glsl?raw'; +import fullscreenVert from './shaders/fullscreen.vert.glsl?raw'; +import terrainFrag from './shaders/terrain.frag.glsl?raw'; +import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw'; +import resolveFrag from './shaders/resolve.frag.glsl?raw'; +import compositeFrag from './shaders/composite.frag.glsl?raw'; +import { WORLD, worldDefines } from './world'; + +export type Stage = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; + +const HEADER = + '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; + +export const vertexSource = HEADER + fullscreenVert; + +const stages: Record = { + terrain: terrainFrag, + atmosphere: atmosphereFrag, + resolve: resolveFrag, + composite: compositeFrag, +}; + +/** Fragment source for a stage: header, world defines, CultMath, the shared block, the stage. */ +export function fragmentSource(stage: Stage): string { + const erosion = stage === 'terrain' && WORLD.phacelleGradBound > 0 ? phacelle : ''; + return `${HEADER}${worldDefines()}\n${cultmath}\n${erosion}\n${common}\n${stages[stage]}`; +} diff --git a/src/app/features/scene/shaders/CultMath.Phacelle.glsl b/src/app/features/scene/shaders/CultMath.Phacelle.glsl new file mode 100644 index 00000000..11405fa2 --- /dev/null +++ b/src/app/features/scene/shaders/CultMath.Phacelle.glsl @@ -0,0 +1,99 @@ +// Generated from shaders/CultMath.Phacelle.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MPL-2.0; requires CultMath.glsl before it. +#ifndef CULTMATH_PHACELLE_GLSL +#define CULTMATH_PHACELLE_GLSL +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the +// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke +// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact +// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after +// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md). + +// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit +// against CultMath.CultPhasor, same field order. +struct CultPhasor +{ + vec4 cos; + vec4 sin; +}; + +// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave +// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors +// the C# body operation for operation; the C#-only internal overload that switches the prune off +// has no shader twin. +CultPhasor cultmath_phacelle(vec3 p, vec3 side, float offset, float normalization) +{ + vec3 cell = floor(p); + vec3 local = p - cell; + float phaseOffset = offset * CULTMATH_TAU; + float sumW = 0.0; + float sumCos = 0.0; + float sumSin = 0.0; + vec3 gradW = vec3(0.0, 0.0, 0.0); + vec3 gradCos = vec3(0.0, 0.0, 0.0); + vec3 gradSin = vec3(0.0, 0.0, 0.0); + + for (int dz = -1; dz <= 2; dz++) + { + float gz = max(0.0, abs(local.z - float(dz)) - 0.5); + for (int dy = -1; dy <= 2; dy++) + { + float gy = max(0.0, abs(local.y - float(dy)) - 0.5); + for (int dx = -1; dx <= 2; dx++) + { + float gx = max(0.0, abs(local.x - float(dx)) - 0.5); + if (gx * gx + gy * gy + gz * gz >= 2.25) + continue; + + vec3 gridStep = vec3(dx, dy, dz); + ivec3 hash = cultmath_pcg3d(ivec3(cell + gridStep)); + vec3 jitter = vec3( + float(uint(hash.x) >> 8) * (1.0 / 16777216.0) - 0.5, + float(uint(hash.y) >> 8) * (1.0 / 16777216.0) - 0.5, + float(uint(hash.z) >> 8) * (1.0 / 16777216.0) - 0.5); + vec3 v = local - gridStep - jitter; + float falloff = exp(-2.0 * dot(v, v)); + float w = max(0.0, falloff - 0.01111); + vec3 dw = w > 0.0 ? (-4.0 * falloff) * v : vec3(0.0, 0.0, 0.0); + float phase = dot(v, side) + phaseOffset; + float c = cos(phase); + float s = sin(phase); + + sumW += w; + sumCos += w * c; + sumSin += w * s; + gradW += dw; + gradCos += dw * c - (w * s) * side; + gradSin += dw * s + (w * c) * side; + } + } + } + + float invW = 1.0 / sumW; + float rawCos = sumCos * invW; + float rawSin = sumSin * invW; + vec3 gradRawCos = (gradCos - rawCos * gradW) * invW; + vec3 gradRawSin = (gradSin - rawSin * gradW) * invW; + float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin); + float floorLength = 1.0 - normalization; + + CultPhasor result; + if (rawLength > floorLength) + { + float outCos = rawCos / rawLength; + float outSin = rawSin / rawLength; + vec3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength; + result.cos = vec4((gradRawCos - outCos * gradLength) / rawLength, outCos); + result.sin = vec4((gradRawSin - outSin * gradLength) / rawLength, outSin); + } + else + { + result.cos = vec4(gradRawCos / floorLength, rawCos / floorLength); + result.sin = vec4(gradRawSin / floorLength, rawSin / floorLength); + } + + return result; +} + +#endif diff --git a/src/app/features/scene/shaders/CultMath.glsl b/src/app/features/scene/shaders/CultMath.glsl new file mode 100644 index 00000000..8a380a96 --- /dev/null +++ b/src/app/features/scene/shaders/CultMath.glsl @@ -0,0 +1,1035 @@ +// Generated from shaders/CultMath.hlsl and CultMath.Interval.hlsl and CultMath.Affine.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MIT. cultmath_phacelle is in CultMath.Phacelle.glsl, to be concatenated after this file by consumers that call it. cultmath_cycles_divide, cultmath_cycles_power, cultmath_cycles_logarithm, cultmath_cycles_sqrt, cultmath_cycles_inverse_sqrt, cultmath_cycles_modulo, cultmath_cycles_floored_modulo, cultmath_cycles_wrap, cultmath_cycles_snap, cultmath_cycles_pingpong, cultmath_cycles_smooth_min, cultmath_cycles_smooth_max, cultmath_cycles_compare, cultmath_cycles_round, cultmath_cycles_trunc, cultmath_cycles_fract, cultmath_cycles_sign, cultmath_cycles_arcsine, cultmath_cycles_arccosine, cultmath_cycles_arctan2, cultmath_cycles_map_range_linear, cultmath_cycles_map_range_stepped, cultmath_cycles_map_range_smoothstep, cultmath_cycles_map_range_smootherstep, cultmath_cycles_clamp_minmax, cultmath_cycles_clamp_range, cultmath_cycles_mix_float, cultmath_cycles_mix_vector, cultmath_cycles_mix_blend, cultmath_cycles_mix_add, cultmath_cycles_mix_mul, cultmath_cycles_mix_screen, cultmath_cycles_mix_overlay, cultmath_cycles_mix_sub, cultmath_cycles_mix_div, cultmath_cycles_mix_diff, cultmath_cycles_mix_exclusion, cultmath_cycles_mix_dark, cultmath_cycles_mix_light, cultmath_cycles_mix_dodge, cultmath_cycles_mix_burn, cultmath_cycles_rgb_to_hsv, cultmath_cycles_hsv_to_rgb, cultmath_cycles_mix_hue, cultmath_cycles_mix_sat, cultmath_cycles_mix_val, cultmath_cycles_mix_color, cultmath_cycles_mix_soft, cultmath_cycles_mix_linear, cultmath_cycles_rgb_to_bw, cultmath_cycles_ramp_index is in CultMath.Cycles.glsl, to be concatenated after this file by consumers that call it. +#ifndef CULTMATH_GLSL +#define CULTMATH_GLSL +// CultMath HLSL mirror. +// +// HLSL already owns the vec2/vec3/vec4 type names and most primitive +// intrinsics. This include names the CultMath semantic surface explicitly so +// shader code, C# wrappers, and Rust/native parity fixtures can share the same +// small vocabulary without local helper drift. + +const float CULTMATH_PI = 3.14159265358979323846; +const float CULTMATH_TAU = 6.28318530717958647692; +const float CULTMATH_HALF_PI = 1.57079632679489661923; + +float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); } +float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); } + +float cultmath_frac(float value) { return value - floor(value); } +vec2 cultmath_frac(vec2 value) { return value - floor(value); } +vec3 cultmath_frac(vec3 value) { return value - floor(value); } +vec4 cultmath_frac(vec4 value) { return value - floor(value); } + +float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); } +vec2 cultmath_clamp(vec2 value, vec2 minimum, vec2 maximum) { return min(max(value, minimum), maximum); } +vec3 cultmath_clamp(vec3 value, vec3 minimum, vec3 maximum) { return min(max(value, minimum), maximum); } +vec4 cultmath_clamp(vec4 value, vec4 minimum, vec4 maximum) { return min(max(value, minimum), maximum); } + +// DXIL Saturate is FMin(1, FMax(0, x)); dxc lowers min/max to the same FMin/FMax, NaN rules included. +float cultmath_saturate(float value) { return min(1.0, max(0.0, value)); } +vec2 cultmath_saturate(vec2 value) { return min(vec2(1.0, 1.0), max(vec2(0.0, 0.0), value)); } +vec3 cultmath_saturate(vec3 value) { return min(vec3(1.0, 1.0, 1.0), max(vec3(0.0, 0.0, 0.0), value)); } +vec4 cultmath_saturate(vec4 value) { return min(vec4(1.0, 1.0, 1.0, 1.0), max(vec4(0.0, 0.0, 0.0, 0.0), value)); } + +float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; } +vec2 cultmath_lerp(vec2 start, vec2 end, vec2 amount) { return start + (end - start) * amount; } +vec3 cultmath_lerp(vec3 start, vec3 end, vec3 amount) { return start + (end - start) * amount; } +vec4 cultmath_lerp(vec4 start, vec4 end, vec4 amount) { return start + (end - start) * amount; } + +float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; } +vec2 cultmath_step(vec2 edge, vec2 value) { return vec2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); } +vec3 cultmath_step(vec3 edge, vec3 value) { return vec3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); } +vec4 cultmath_step(vec4 edge, vec4 value) { return vec4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); } + +float cultmath_smoothstep(float minimum, float maximum, float value) +{ + float t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0 - 2.0 * t); +} + +vec2 cultmath_smoothstep(vec2 minimum, vec2 maximum, vec2 value) +{ + vec2 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (vec2(3.0, 3.0) - vec2(2.0, 2.0) * t); +} + +vec3 cultmath_smoothstep(vec3 minimum, vec3 maximum, vec3 value) +{ + vec3 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (vec3(3.0, 3.0, 3.0) - vec3(2.0, 2.0, 2.0) * t); +} + +vec4 cultmath_smoothstep(vec4 minimum, vec4 maximum, vec4 value) +{ + vec4 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (vec4(3.0, 3.0, 3.0, 3.0) - vec4(2.0, 2.0, 2.0, 2.0) * t); +} + +float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); } + +float cultmath_smootherstep(float value) +{ + value = cultmath_saturate(value); + return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); +} + +vec2 cultmath_snoise_mod289(vec2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +vec3 cultmath_snoise_mod289(vec3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +vec4 cultmath_snoise_mod289(vec4 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +vec3 cultmath_snoise_permute(vec3 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } +vec4 cultmath_snoise_permute(vec4 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } + +// Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation +// order as the C# math.snoise(vec3) mirror. Ties resolve in simplexnoise1234's +// total order; see math.snoise. +float cultmath_snoise(vec3 value) +{ + vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); + vec3 i = floor(value + dot(value, c.yyy)); + vec3 x0 = value - i + dot(i, c.xxx); + vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + c.xxx; + vec3 x2 = x0 - i2 + c.yyy; + vec3 x3 = x0 - 0.5; + + i = cultmath_snoise_mod289(i); + vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + + const float n_ = 0.142857142857; + vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + vec4 m = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + m = m * m; + return 105.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); +} + +float cultmath_snoise(vec2 value) +{ + vec4 c = vec4( + 0.211324865405187, + 0.366025403784439, + -0.577350269189626, + 0.024390243902439); + vec2 i = floor(value + dot(value, c.yy)); + vec2 x0 = value - i + dot(i, c.xx); + vec2 i1 = x0.x > x0.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0); + vec4 x12 = vec4(x0.x + c.x - i1.x, x0.y + c.x - i1.y, x0.x + c.z, x0.y + c.z); + + i = cultmath_snoise_mod289(i); + vec3 p = cultmath_snoise_permute( + cultmath_snoise_permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0)); + vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); + m *= m; + m *= m; + + vec3 x = 2.0 * fract(p * c.w) - 1.0; + vec3 h = abs(x) - 0.5; + vec3 ox = floor(x + 0.5); + vec3 a0 = x - ox; + m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); + + vec3 g = vec3( + a0.x * x0.x + h.x * x0.y, + a0.y * x12.x + h.y * x12.y, + a0.z * x12.z + h.z * x12.w); + return 130.0 * dot(m, g); +} + +// Analytic gradient of cultmath_snoise(vec3) (invariant 8), following webgl-noise +// src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT); same kernel, permutation and scale as +// cultmath_snoise(vec3) (stegu/webgl-noise 22434e04d7). See the C# math.snoise_grad comment for the +// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise(vec3)'s own derivation +// verbatim. +vec4 cultmath_snoise_grad(vec3 value) +{ + vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); + vec3 i = floor(value + dot(value, c.yyy)); + vec3 x0 = value - i + dot(i, c.xxx); + vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + c.xxx; + vec3 x2 = x0 - i2 + c.yyy; + vec3 x3 = x0 - 0.5; + + i = cultmath_snoise_mod289(i); + vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + + const float n_ = 0.142857142857; + vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + vec4 m0 = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + vec4 m2 = m0 * m0; + vec4 m3 = m2 * m0; + vec4 m4 = m2 * m2; + + vec4 px = vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); + float value2 = 105.0 * dot(m4, px); + vec3 grad = -8.0 * m3.x * x0 * px.x + m4.x * p0 + + -8.0 * m3.y * x1 * px.y + m4.y * p1 + + -8.0 * m3.z * x2 * px.z + m4.z * p2 + + -8.0 * m3.w * x3 * px.w + m4.w * p3; + grad *= 105.0; + + return vec4(grad, value2); +} + +// Analytic Hessian of cultmath_snoise(vec3); see the C# math.snoise_hessian comment for the +// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise_grad's own. The matrix is +// built from nine scalars, which read as rows in HLSL and as columns in GLSL; it is symmetric, so the +// two agree. +mat3 cultmath_snoise_hessian(vec3 value) +{ + vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); + vec3 i = floor(value + dot(value, c.yyy)); + vec3 x0 = value - i + dot(i, c.xxx); + vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + c.xxx; + vec3 x2 = x0 - i2 + c.yyy; + vec3 x3 = x0 - 0.5; + + i = cultmath_snoise_mod289(i); + vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + + const float n_ = 0.142857142857; + vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + vec4 m0 = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + vec4 m2 = m0 * m0; + vec4 m3 = m2 * m0; + vec4 px = vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); + vec4 wide = 48.0 * m2 * px; + vec4 narrow = 8.0 * m3; + + vec4 cxx = vec4(x0.x, x1.x, x2.x, x3.x); + vec4 cxy = vec4(x0.y, x1.y, x2.y, x3.y); + vec4 cxz = vec4(x0.z, x1.z, x2.z, x3.z); + vec4 cpx = vec4(p0.x, p1.x, p2.x, p3.x); + vec4 cpy = vec4(p0.y, p1.y, p2.y, p3.y); + vec4 cpz = vec4(p0.z, p1.z, p2.z, p3.z); + + float hxx = 105.0 * (dot(wide * cxx, cxx) - dot(narrow, 2.0 * cpx * cxx + px)); + float hyy = 105.0 * (dot(wide * cxy, cxy) - dot(narrow, 2.0 * cpy * cxy + px)); + float hzz = 105.0 * (dot(wide * cxz, cxz) - dot(narrow, 2.0 * cpz * cxz + px)); + float hxy = 105.0 * (dot(wide * cxx, cxy) - dot(narrow, cpx * cxy + cpy * cxx)); + float hxz = 105.0 * (dot(wide * cxx, cxz) - dot(narrow, cpx * cxz + cpz * cxx)); + float hyz = 105.0 * (dot(wide * cxy, cxz) - dot(narrow, cpy * cxz + cpz * cxy)); + return mat3(hxx, hxy, hxz, hxy, hyy, hyz, hxz, hyz, hzz); +} + +// The integral over [t0, t1] of clamp(a + b t, lo, hi); see the C# math.integral_clamp_linear comment. +float cultmath_integral_clamp_linear(float a, float b, float lo, float hi, float t0, float t1) +{ + lo = min(lo, hi); + if (b == 0.0) + return t1 != t0 ? clamp(a, lo, hi) * (t1 - t0) : 0.0; + + float direction = t1 < t0 ? -1.0 : 1.0; + float start = min(t0, t1); + float stop = max(t0, t1); + float atLo = (lo - a) / b; + float atHi = (hi - a) / b; + float enter = clamp(min(atLo, atHi), start, stop); + float leave = clamp(max(atLo, atHi), start, stop); + float before = b > 0.0 ? lo : hi; + float after = b > 0.0 ? hi : lo; + float held = enter > start ? before * (enter - start) : 0.0; + float kept = stop > leave ? after * (stop - leave) : 0.0; + float total = held + (a + b * (0.5 * (enter + leave))) * (leave - enter) + kept; + return direction * total; +} + +// Octave sum of cultmath_snoise_grad (invariant 8); see the C# math.fbm_grad comment for the +// per-octave frequency/amplitude derivation and why octaves is clamped. +vec4 cultmath_fbm_grad(vec3 p, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + float value = 0.0; + vec3 gradient = vec3(0.0, 0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + vec4 n = cultmath_snoise_grad(p * frequency); + value += amplitude * n.w; + gradient += amplitude * frequency * n.xyz; + frequency *= lacunarity; + amplitude *= gain; + } + + return vec4(gradient, value); +} + +// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad +// comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped. +vec4 cultmath_ridged_grad(vec3 p, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + float value = 0.0; + vec3 gradient = vec3(0.0, 0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + vec4 n = cultmath_snoise_grad(p * frequency); + float s = sign(n.w); + value += amplitude * (1.0 - abs(n.w)); + gradient += -amplitude * frequency * s * n.xyz; + frequency *= lacunarity; + amplitude *= gain; + } + + return vec4(gradient, value); +} + +float cultmath_lengthsq(vec2 value) { return dot(value, value); } +float cultmath_lengthsq(vec3 value) { return dot(value, value); } +float cultmath_lengthsq(vec4 value) { return dot(value, value); } + +float cultmath_distance(vec2 left, vec2 right) { return length(left - right); } +float cultmath_distance(vec3 left, vec3 right) { return length(left - right); } +float cultmath_distance(vec4 left, vec4 right) { return length(left - right); } + +vec2 cultmath_reflect(vec2 incident, vec2 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +vec3 cultmath_reflect(vec3 incident, vec3 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +vec2 cultmath_rotate(vec2 value, float radians_value) +{ + float s = sin(radians_value); + float c = cos(radians_value); + return vec2(value.x * c - value.y * s, value.x * s + value.y * c); +} + +vec2 cultmath_rotate_degrees(vec2 value, float degrees_value) +{ + return cultmath_rotate(value, cultmath_radians(degrees_value)); +} + +float cultmath_csum(vec2 value) { return value.x + value.y; } +float cultmath_csum(vec3 value) { return value.x + value.y + value.z; } +float cultmath_csum(vec4 value) { return value.x + value.y + value.z + value.w; } + +float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +vec2 cultmath_decay(vec2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +vec3 cultmath_decay(vec3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +vec4 cultmath_decay(vec4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } + +float cultmath_damp(float start, float end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +vec2 cultmath_damp(vec2 start, vec2 end, float lambda_value, float dt) +{ + float amount = 1.0 - exp(-lambda_value * dt); + return cultmath_lerp(start, end, vec2(amount, amount)); +} + +vec3 cultmath_damp(vec3 start, vec3 end, float lambda_value, float dt) +{ + float amount = 1.0 - exp(-lambda_value * dt); + return cultmath_lerp(start, end, vec3(amount, amount, amount)); +} + +vec4 cultmath_damp(vec4 start, vec4 end, float lambda_value, float dt) +{ + float amount = 1.0 - exp(-lambda_value * dt); + return cultmath_lerp(start, end, vec4(amount, amount, amount, amount)); +} + +float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +vec2 cultmath_catmullrom(vec2 p0, vec2 p1, vec2 p2, vec2 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +vec3 cultmath_catmullrom(vec3 p0, vec3 p1, vec3 p2, vec3 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +vec4 cultmath_catmullrom(vec4 p0, vec4 p1, vec4 p2, vec4 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float cultmath_quadratic_bezier(float p0, float p1, float p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +vec3 cultmath_quadratic_bezier(vec3 p0, vec3 p1, vec3 p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +vec3 cultmath_cubic_bezier(vec3 p0, vec3 p1, vec3 p2, vec3 p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453); } +float cultmath_hash(vec2 value) { return cultmath_hash(dot(value, vec2(127.1, 311.7))); } +float cultmath_hash(vec3 value) { return cultmath_hash(dot(value, vec3(127.1, 311.7, 74.7))); } + +// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's ivec3/ivec4 carry the bits. +uint cultmath_pcg(uint value) +{ + uint state = value * 747796405u + 2891336453u; + uint word = ((state >> int((state >> 28) + 4u)) ^ state) * 277803737u; + return (word >> 22) ^ word; +} + +ivec3 cultmath_pcg3d(ivec3 value) +{ + uint x = uint(value.x) * 1664525u + 1013904223u, y = uint(value.y) * 1664525u + 1013904223u, z = uint(value.z) * 1664525u + 1013904223u; + x += y * z; y += z * x; z += x * y; + x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; + x += y * z; y += z * x; z += x * y; + return ivec3(int(x), int(y), int(z)); +} + +ivec3 cultmath_pcg3d(vec3 value) { return cultmath_pcg3d(ivec3(int(floatBitsToUint(value.x)), int(floatBitsToUint(value.y)), int(floatBitsToUint(value.z)))); } +ivec3 cultmath_pcg3d(vec2 value) { return cultmath_pcg3d(vec3(value, 0.0)); } + +ivec4 cultmath_pcg4d(ivec4 value) +{ + uint x = uint(value.x) * 1664525u + 1013904223u, y = uint(value.y) * 1664525u + 1013904223u; + uint z = uint(value.z) * 1664525u + 1013904223u, w = uint(value.w) * 1664525u + 1013904223u; + x += y * w; y += z * x; z += x * y; w += y * z; + x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16; + x += y * w; y += z * x; z += x * y; w += y * z; + return ivec4(int(x), int(y), int(z), int(w)); +} + +ivec4 cultmath_pcg4d(vec4 value) { return cultmath_pcg4d(ivec4(int(floatBitsToUint(value.x)), int(floatBitsToUint(value.y)), int(floatBitsToUint(value.z)), int(floatBitsToUint(value.w)))); } + +// Inigo Quilez, "smooth minimum": quadratic-polynomial smin carried to value-and-gradient form. See +// math.smin_grad's comment (math.cs) for why the gradient blend is exact, not an approximation. +vec4 cultmath_smin_grad(vec4 a, vec4 b, float k) +{ + float h = clamp(0.5 + 0.5 * (b.w - a.w) / k, 0.0, 1.0); + float value = mix(b.w, a.w, h) - k * h * (1.0 - h); + vec3 gradient = mix(b.xyz, a.xyz, h); + return vec4(gradient, value); +} + +// Invariant 8's one struct return shape (design.md, "HLSL Target: Source Transformations"): +// compared field by field and bit for bit against CultMath.CultCellular, same field order. +struct CultCellular +{ + vec4 nearest; + vec4 edge; + float id; +}; + +// Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996). See math.cellular's comment +// (math.cs) for the hashing, gradient, search-radius exactness proof, id source and +// degenerate-point conventions this mirrors exactly, including the 5x5x5 lower-bound prune. +CultCellular cultmath_cellular(vec3 p) +{ + vec3 cell = floor(p); + float f1 = 1.0e30; + float f2 = 1.0e30; + vec3 c1 = vec3(0.0, 0.0, 0.0); + vec3 c2 = vec3(0.0, 0.0, 0.0); + vec3 cellId = vec3(0.0, 0.0, 0.0); + + for (int dz = -2; dz <= 2; dz++) + { + float lz = max(0.0, abs(float(dz)) - 1.0); + for (int dy = -2; dy <= 2; dy++) + { + float ly = max(0.0, abs(float(dy)) - 1.0); + for (int dx = -2; dx <= 2; dx++) + { + float lx = max(0.0, abs(float(dx)) - 1.0); + float lowerBound = sqrt(lx * lx + ly * ly + lz * lz); + if (lowerBound >= f2) + continue; + + vec3 neighbor = cell + vec3(dx, dy, dz); + ivec3 hash = cultmath_pcg3d(ivec3(neighbor)); + vec3 jitter = vec3( + float(uint(hash.x) >> 8) * (1.0 / 16777216.0), + float(uint(hash.y) >> 8) * (1.0 / 16777216.0), + float(uint(hash.z) >> 8) * (1.0 / 16777216.0)); + vec3 feature = neighbor + jitter; + float d = cultmath_distance(p, feature); + + if (d < f1) + { + f2 = f1; c2 = c1; + f1 = d; c1 = feature; cellId = neighbor; + } + else if (d < f2) + { + f2 = d; c2 = feature; + } + } + } + } + + // F1 = 0 and F2 = 0 share the same degenerate-gradient guard; see math.cellular's comment + // (math.cs) for why F2 = 0 is reachable past roughly |p| = 2^23. + vec3 grad1 = f1 > 0.0 ? (p - c1) / f1 : vec3(0.0, 0.0, 0.0); + vec3 grad2 = f2 > 0.0 ? (p - c2) / f2 : vec3(0.0, 0.0, 0.0); + + // id comes from pcg4d over the winning cell's integer coordinate alone (same int-hashing rule + // as the jitter above), never from the pcg3d hash that produced its jitter (F4). + ivec4 idHash = cultmath_pcg4d(ivec4(ivec3(cellId), 0)); + + CultCellular result; + result.nearest = vec4(grad1, f1); + result.edge = vec4(grad2 - grad1, f2 - f1); + result.id = float(uint(idHash.w) >> 8) * (1.0 / 16777216.0); + return result; +} + + + + +// Interval arithmetic over vec2(lo, hi), lo <= hi, both finite. Every cultmath_iv_* function returns +// an interval containing f(x) for every x in its input intervals, mirroring math.Interval.cs bit for +// bit; see that file and docs/design.md ("Intervals") for each op's enclosure and its ulp widening. +// Included by CultMath.hlsl after its own definitions of cultmath_smoothstep and cultmath_snoise. + +// Lipschitz constant of cultmath_snoise(vec3); provenance in math.Interval.cs (SNOISE_LIPSCHITZ). +const float CULTMATH_SNOISE_LIPSCHITZ = 7.9640074; + +// Lipschitz constant of cultmath_snoise(vec2); provenance in math.Interval.cs (SNOISE2_LIPSCHITZ). +const float CULTMATH_SNOISE2_LIPSCHITZ = 8.117002; + +vec2 cultmath_iv_point(float value) { return vec2(value, value); } +vec2 cultmath_iv_add(vec2 a, vec2 b) { return vec2(a.x + b.x, a.y + b.y); } +vec2 cultmath_iv_sub(vec2 a, vec2 b) { return vec2(a.x - b.y, a.y - b.x); } +vec2 cultmath_iv_neg(vec2 a) { return vec2(-a.y, -a.x); } + +vec2 cultmath_iv_mul(vec2 a, vec2 b) +{ + float p0 = a.x * b.x; + float p1 = a.x * b.y; + float p2 = a.y * b.x; + float p3 = a.y * b.y; + return vec2(min(min(p0, p1), min(p2, p3)), max(max(p0, p1), max(p2, p3))); +} + +vec2 cultmath_iv_scale(vec2 a, float s) { return s >= 0.0 ? vec2(a.x * s, a.y * s) : vec2(a.y * s, a.x * s); } + +vec2 cultmath_iv_abs(vec2 a) +{ + if (a.x >= 0.0) + return a; + if (a.y <= 0.0) + return vec2(-a.y, -a.x); + return vec2(0.0, max(-a.x, a.y)); +} + +vec2 cultmath_iv_min(vec2 a, vec2 b) { return vec2(min(a.x, b.x), min(a.y, b.y)); } +vec2 cultmath_iv_max(vec2 a, vec2 b) { return vec2(max(a.x, b.x), max(a.y, b.y)); } + +vec2 cultmath_iv_sqr(vec2 a) +{ + float l = a.x * a.x; + float h = a.y * a.y; + if (a.x >= 0.0) + return vec2(l, h); + if (a.y <= 0.0) + return vec2(h, l); + return vec2(0.0, max(l, h)); +} + +vec2 cultmath_iv_sqrt(vec2 a) { return vec2(sqrt(max(a.x, 0.0)), sqrt(max(a.y, 0.0))); } + +// Widened by one ulp on each side: exp is not required to be correctly rounded. +vec2 cultmath_iv_exp(vec2 a) +{ + float lo = exp(a.x); + float hi = exp(a.y); + return vec2(lo > 0.0 ? uintBitsToFloat(floatBitsToUint(lo) - 1u) : 0.0, uintBitsToFloat(floatBitsToUint(hi) + 1u)); +} + +vec2 cultmath_iv_clamp(vec2 a, float minimum, float maximum) +{ + return vec2(cultmath_clamp(a.x, minimum, maximum), cultmath_clamp(a.y, minimum, maximum)); +} + +vec2 cultmath_iv_saturate(vec2 a) { return vec2(cultmath_saturate(a.x), cultmath_saturate(a.y)); } + +// The endpoints, ordered, widened by one ulp inside [0, 1]: the float32 evaluation of smoothstep is +// monotone only to within one ulp. +vec2 cultmath_iv_smoothstep(float minimum, float maximum, vec2 a) +{ + float s0 = cultmath_smoothstep(minimum, maximum, a.x); + float s1 = cultmath_smoothstep(minimum, maximum, a.y); + float lo = min(s0, s1); + float hi = max(s0, s1); + return vec2(lo > 0.0 ? uintBitsToFloat(floatBitsToUint(lo) - 1u) : 0.0, hi < 1.0 ? uintBitsToFloat(floatBitsToUint(hi) + 1u) : 1.0); +} + +// The natural interval extension of x + (y - x) * amount, which is how cultmath_lerp evaluates. +vec2 cultmath_iv_lerp(vec2 a, vec2 b, float amount) { return cultmath_iv_add(a, cultmath_iv_scale(cultmath_iv_sub(b, a), amount)); } + +// [n - L r, n + L r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only. +vec2 cultmath_iv_snoise_ball(vec3 centre, float radius) +{ + float n = cultmath_snoise(centre); + float e = CULTMATH_SNOISE_LIPSCHITZ * radius; + return vec2(max(n - e, -1.0), min(n + e, 1.0)); +} + +// The 2D overload: [n - L2 r, n + L2 r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only. +vec2 cultmath_iv_snoise_ball(vec2 centre, float radius) +{ + float n = cultmath_snoise(centre); + float e = CULTMATH_SNOISE2_LIPSCHITZ * radius; + return vec2(max(n - e, -1.0), min(n + e, 1.0)); +} + +// Octave sum of cultmath_iv_snoise_ball, compounding frequency and amplitude as the value-and-gradient fBm does. +vec2 cultmath_iv_fbm_ball(vec3 centre, float radius, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + vec2 sum = vec2(0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + sum = cultmath_iv_add(sum, cultmath_iv_scale(cultmath_iv_snoise_ball(centre * frequency, radius * abs(frequency)), amplitude)); + frequency *= lacunarity; + amplitude *= gain; + } + + return sum; +} + +// A ball (xyz centre, w radius) enclosing every point of every ray of a screen tile over the depth segment +// [z0, z1], warped by at most warp. Camera frame: pinhole at the origin looking down +z, ray (m z, z). +// footprintPerDepth = N / (sqrt(2) f) for an N x N tile at focal length f. The radius is widened by +// (|c|_1 + radius) 2^-20, which covers the ball's own float32 rounding. Derivation in math.Interval.cs. +vec4 cultmath_iv_frustum_ball(vec2 centreSlope, float z0, float z1, float footprintPerDepth, float warp) +{ + float zm = (z0 + z1) * 0.5; + float axis = sqrt(centreSlope.x * centreSlope.x + centreSlope.y * centreSlope.y + 1.0); + float cx = centreSlope.x * zm; + float cy = centreSlope.y * zm; + float radius = (z1 - z0) * 0.5 * axis + z1 * footprintPerDepth + warp; + radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7; + return vec4(cx, cy, zm, radius); +} + + + + + + +// Reduced affine forms with one shared symbol over vec3(x0, a, e): the set { x0 + a eps + e delta : +// delta in [-1, 1] } at the region's shared eps in [-1, 1], e >= 0. Every cultmath_af_* function mirrors +// math.Affine.cs bit for bit; see that file and docs/design.md ("Affine forms") for each op's enclosure +// and the float32 rounding allowance it folds into e. Included by CultMath.hlsl after +// CultMath.Interval.hlsl, whose CULTMATH_SNOISE_LIPSCHITZ it shares, and after its own +// definition of cultmath_snoise_grad. + +// An upper bound on the spectral norm of the Hessian of cultmath_snoise(vec3); provenance in +// math.Affine.cs (SNOISE_HESSIAN). +const float CULTMATH_SNOISE_HESSIAN = 55.642143; + +// The absolute rounding of one float32 read of cultmath_snoise_grad, value and gradient; the owner is +// math.Affine.cs (SNOISE_READ_VALUE, SNOISE_READ_GRADIENT), read here by cultmath_af_snoise and in +// CultMath.hlsl by cultmath_span_slack. +const float CULTMATH_SNOISE_READ_VALUE = 6.103515625e-5; +const float CULTMATH_SNOISE_READ_GRADIENT = 2.44140625e-4; + +// An upper bound on the operator norm of the third-derivative tensor of cultmath_snoise(vec3), the +// next order after CULTMATH_SNOISE_HESSIAN; provenance in math.Span.cs (SNOISE_THIRD). +const float CULTMATH_SNOISE_THIRD = 535.8009; + +vec3 cultmath_af_point(float value) { return vec3(value, 0.0, 0.0); } +vec3 cultmath_af_symbol(float x0, float a) { return vec3(x0, a, 0.0); } + +vec3 cultmath_af_from_iv(vec2 interval) +{ + float x0 = interval.x * 0.5 + interval.y * 0.5; + float e = interval.y * 0.5 - interval.x * 0.5; + return vec3(x0, 0.0, e + (abs(x0) + e) * 9.5367431640625e-7 + 1.17549435e-38); +} + +// An endpoint that would be NaN is the infinity on its side. NaN is found by its bit pattern: dxc marks +// compares fast, so a test that needs NaN to make `lo == lo` false is deleted under its default flags. +vec2 cultmath_af_range(vec3 x) +{ + float r = abs(x.y) + x.z; + float w = abs(x.x) * 9.5367431640625e-7 + r * 9.5367431640625e-7; + float lo = x.x - r - w; + float hi = x.x + r + w; + return vec2((floatBitsToUint(lo) & 0x7fffffffu) > 0x7f800000u ? uintBitsToFloat(0xff800000u) : lo, + (floatBitsToUint(hi) & 0x7fffffffu) > 0x7f800000u ? uintBitsToFloat(0x7f800000u) : hi); +} + +vec3 cultmath_af_add(vec3 x, vec3 y) +{ + float x0 = x.x + y.x; + float a = x.y + y.y; + float e = x.z + y.z; + return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7); +} + +vec3 cultmath_af_sub(vec3 x, vec3 y) +{ + float x0 = x.x - y.x; + float a = x.y - y.y; + float e = x.z + y.z; + return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7); +} + +vec3 cultmath_af_neg(vec3 x) { return vec3(-x.x, -x.y, x.z); } + +vec3 cultmath_af_scale(vec3 x, float s) +{ + float x0 = x.x * s; + float a = x.y * s; + float e = x.z * abs(s); + return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7 + 1.17549435e-38); +} + +vec3 cultmath_af_add_iv(vec3 x, vec2 interval) { return cultmath_af_add(x, cultmath_af_from_iv(interval)); } + +vec3 cultmath_af_mul(vec3 x, vec3 y) +{ + float x0 = x.x * y.x; + float p = x.x * y.y; + float q = y.x * x.y; + float a = p + q; + float e = abs(x.x) * y.z + abs(y.x) * x.z + (abs(x.y) + x.z) * (abs(y.y) + y.z); + return vec3(x0, a, e + (abs(x0) + abs(p) + abs(q) + e) * 9.5367431640625e-7 + 1.17549435e-38); +} + +// The narrower of the Lipschitz form and the centred (Taylor) form over every p = centre + axis eps + w, +// |w| <= radius, from one cultmath_snoise_grad at the centre; the allowance for the float32 evaluation is +// in math.Affine.cs. +vec3 cultmath_af_snoise(vec3 centre, vec3 axis, float radius) +{ + vec4 g = cultmath_snoise_grad(centre); + vec3 gradient = vec3(g.x, g.y, g.z); + float reach = length(axis) + radius; + float lipschitz = CULTMATH_SNOISE_LIPSCHITZ * reach; + float a = dot(gradient, axis); + float remainder = length(gradient) * radius + 0.5 * CULTMATH_SNOISE_HESSIAN * reach * reach; + float allowance = CULTMATH_SNOISE_READ_VALUE + CULTMATH_SNOISE_READ_GRADIENT * reach + + CULTMATH_SNOISE_LIPSCHITZ * 9.5367431640625e-7 * (abs(centre.x) + abs(centre.y) + abs(centre.z) + reach); + bool centred = abs(a) + remainder < lipschitz; + float slope = centred ? a : 0.0; + float e = (centred ? remainder : lipschitz) + allowance; + return vec3(g.w, slope, e + (abs(g.w) + abs(slope) + e) * 9.5367431640625e-7); +} + +// Octave sum of cultmath_af_snoise, compounding frequency and amplitude as the value-and-gradient fBm does. +vec3 cultmath_af_fbm(vec3 centre, vec3 axis, float radius, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + vec3 sum = vec3(0.0, 0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + sum = cultmath_af_add(sum, cultmath_af_scale(cultmath_af_snoise(centre * frequency, axis * frequency, radius * abs(frequency)), amplitude)); + frequency *= lacunarity; + amplitude *= gain; + } + + return sum; +} + +// The axis of a screen tile's slice over the depth segment [z0, z1]; camera frame of +// cultmath_iv_frustum_ball. +vec3 cultmath_af_frustum_axis(vec2 centreSlope, float z0, float z1) +{ + float h = (z1 - z0) * 0.5; + return vec3(centreSlope.x * h, centreSlope.y * h, h); +} + +// The affine slice of a screen tile over [z0, z1]: centre xyz, radius w. Derivation in math.Affine.cs. +vec4 cultmath_af_frustum_ball(vec2 centreSlope, float z0, float z1, float footprintPerDepth, float warpVariation) +{ + float zm = (z0 + z1) * 0.5; + float h = (z1 - z0) * 0.5; + float cx = centreSlope.x * zm; + float cy = centreSlope.y * zm; + float spread = (abs(centreSlope.x) + abs(centreSlope.y) + 1.0) * h; + float radius = z1 * footprintPerDepth + warpVariation; + radius += (abs(cx) + abs(cy) + abs(zm) + spread + radius) * 9.5367431640625e-7; + return vec4(cx, cy, zm, radius); +} + + + + +// Span stages (invariant 8): the SpanPath, SpanStage and SpanNoise of math.Span.cs, field for field in +// C#'s order, and cultmath_span_stage, its octave stage, statement for statement so the text is a bit-for-bit +// mirror. They follow the includes because the bounds read CULTMATH_SNOISE_LIPSCHITZ, _HESSIAN and _THIRD. +// A shader caller builds a SpanStage itself (SpanStage(1.0, 1.0, vec3(0.0), vec3(0.0), vec3(0.0), vec3(0.0)) +// is the identity of math.SpanStage.Identity, which has no shader form); see math.span_stage's comments for +// the span, the transform, the bounds and the allowances. cultmath_span_rounding and cultmath_span_slack +// are math.span_rounding and math.span_slack, the owners of S_o and of the three allowances. +struct SpanPath +{ + vec3 midpoint; + vec3 velocity; + float speed; + float acceleration; + float halfLength; +}; + +struct SpanStage +{ + float frequency; + float stretch; + vec3 axis; + vec3 shift; + vec3 offset; + vec3 octaveStep; +}; + +struct SpanNoise +{ + vec4 field; + float curvature; + float fold; +}; + +float cultmath_span_rounding(float scale, vec3 mq, SpanStage stage, vec3 octaveShift, float nu, float halfLength) +{ + return 9.5367431640625e-7 * (abs(scale) * (cultmath_csum(abs(mq)) + cultmath_csum(abs(stage.shift))) + cultmath_csum(abs(stage.offset)) + cultmath_csum(abs(octaveShift)) + 1.7320508 * nu * halfLength); +} + +vec3 cultmath_span_slack(float rounding) +{ + return vec3(CULTMATH_SNOISE_READ_VALUE + CULTMATH_SNOISE_LIPSCHITZ * rounding, + CULTMATH_SNOISE_READ_GRADIENT + CULTMATH_SNOISE_HESSIAN * rounding, + 9.765625e-4 + CULTMATH_SNOISE_THIRD * rounding); +} + +// The octave stage behind cultmath_span_fbm and cultmath_span_ridged, as math.span_stage is behind span_fbm +// and span_ridged. The Frobenius norm of the Hessian is written out in C#'s order (GLSL has no _mRC member +// access); the C# local named step is octaveShift here (it would shadow GLSL's step()). +SpanNoise cultmath_span_stage(bool ridged, SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) +{ + vec3 q = path.midpoint; + float halfLength = path.halfLength; + octaves = clamp(octaves, 0, 16); + bool stretched = dot(stage.axis, stage.axis) > 0.0 && stage.stretch != 1.0; + vec3 direction = stretched ? normalize(stage.axis) : vec3(0.0, 0.0, 0.0); + float squeeze = stretched ? 1.0 / stage.stretch - 1.0 : 0.0; + float norm = abs(stage.frequency) * (stretched ? max(1.0, 1.0 / stage.stretch) : 1.0); + vec3 mq = stretched ? stage.frequency * (q + squeeze * dot(direction, q) * direction) : stage.frequency * q; + vec3 mv = stretched ? stage.frequency * (path.velocity + squeeze * dot(direction, path.velocity) * direction) : stage.frequency * path.velocity; + float amplitude = 1.0; + float scale = 1.0; + float value = 0.0; + vec3 gradient = vec3(0.0, 0.0, 0.0); + float curvature = 0.0; + float fold = 0.0; + for (int i = 0; i < octaves; i++) + { + vec3 octaveShift = float(i) * stage.octaveStep; + vec3 x = scale * (mq + stage.shift) + (stage.offset + octaveShift); + vec4 n = cultmath_snoise_grad(x); + vec3 g = vec3(n.x, n.y, n.z); + vec3 mg = stretched ? stage.frequency * (g + squeeze * dot(direction, g) * direction) : stage.frequency * g; + if (ridged) + { + float s = sign(n.w); + value += amplitude * (1.0 - abs(n.w)); + gradient += -amplitude * scale * s * mg; + } + else + { + value += amplitude * n.w; + gradient += amplitude * scale * mg; + } + + float weight = abs(amplitude); + float factor = abs(scale) * norm; + float nu = factor * path.speed; + float rounding = cultmath_span_rounding(scale, mq, stage, octaveShift, nu, halfLength); + vec3 slack = cultmath_span_slack(rounding); + float valueSlack = slack.x; + float gradientSlack = slack.y; + float hessianSlack = slack.z; + mat3 hm = cultmath_snoise_hessian(x); + float frobenius = sqrt(hm[0][0] * hm[0][0] + hm[0][1] * hm[0][1] + hm[0][2] * hm[0][2] + hm[1][0] * hm[1][0] + hm[1][1] * hm[1][1] + hm[1][2] * hm[1][2] + + hm[2][0] * hm[2][0] + hm[2][1] * hm[2][1] + hm[2][2] * hm[2][2]); + float hessianBound = min(CULTMATH_SNOISE_HESSIAN, frobenius + hessianSlack + CULTMATH_SNOISE_THIRD * nu * halfLength); + curvature += weight * factor * factor * hessianBound; + if (ridged) + { + vec3 w = scale * mv; + float reach = hessianBound * nu * halfLength; + float gl = min(CULTMATH_SNOISE_LIPSCHITZ, length(g) + gradientSlack + reach); + float travel = (abs(dot(g, w)) + gradientSlack * length(w)) * halfLength + (hessianBound * nu * length(w) + gl * factor * path.acceleration) * (0.5 * halfLength * halfLength) + 2.0 * valueSlack; + fold += 2.0 * weight * max(travel - abs(n.w), 0.0); + } + + scale *= lacunarity; + amplitude *= gain; + } + + SpanNoise result; + result.field = vec4(gradient, value); + result.curvature = curvature; + result.fold = fold; + return result; +} + +SpanNoise cultmath_span_fbm(SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) +{ + return cultmath_span_stage(false, path, stage, octaves, lacunarity, gain); +} + +SpanNoise cultmath_span_ridged(SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) +{ + return cultmath_span_stage(true, path, stage, octaves, lacunarity, gain); +} + +float cultmath_value_noise(vec2 position) +{ + vec2 cell = floor(position); + vec2 local = cultmath_frac(position); + vec2 u = local * local * (vec2(3.0, 3.0) - vec2(2.0, 2.0) * local); + + float a = cultmath_hash(cell); + float b = cultmath_hash(cell + vec2(1.0, 0.0)); + float c = cultmath_hash(cell + vec2(0.0, 1.0)); + float d = cultmath_hash(cell + vec2(1.0, 1.0)); + + return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y); +} + +float cultmath_value_noise_bicubic(vec2 position) +{ + vec2 cell = floor(position); + vec2 local = cultmath_frac(position); + + float y0 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, -1.0)), cultmath_hash(cell + vec2(0.0, -1.0)), cultmath_hash(cell + vec2(1.0, -1.0)), cultmath_hash(cell + vec2(2.0, -1.0)), local.x); + float y1 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 0.0)), cultmath_hash(cell + vec2(0.0, 0.0)), cultmath_hash(cell + vec2(1.0, 0.0)), cultmath_hash(cell + vec2(2.0, 0.0)), local.x); + float y2 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 1.0)), cultmath_hash(cell + vec2(0.0, 1.0)), cultmath_hash(cell + vec2(1.0, 1.0)), cultmath_hash(cell + vec2(2.0, 1.0)), local.x); + float y3 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 2.0)), cultmath_hash(cell + vec2(0.0, 2.0)), cultmath_hash(cell + vec2(1.0, 2.0)), cultmath_hash(cell + vec2(2.0, 2.0)), local.x); + + return cultmath_catmullrom(y0, y1, y2, y3, local.y); +} + +#endif diff --git a/src/app/features/scene/shaders/README.md b/src/app/features/scene/shaders/README.md new file mode 100644 index 00000000..617644fb --- /dev/null +++ b/src/app/features/scene/shaders/README.md @@ -0,0 +1,15 @@ +# Vendored CultMath GLSL + +`CultMath.glsl` and `CultMath.Phacelle.glsl` are CultLib's generated WebGL2 +lowering of `packages/cultmath/shaders/CultMath.hlsl` and +`CultMath.Phacelle.hlsl`, copied unedited from CultLib branch +`eureka/cultmath-tapes-interval-ops-r5` at `658c09e` (the branch that carries +the interval ops and `CULTMATH_SNOISE2_LIPSCHITZ`; the repo default branch +does not carry the GLSL yet). Never edit them here: regenerate in CultLib and +re-copy. `CultMath.glsl` is MIT; `CultMath.Phacelle.glsl` is MPL-2.0 (Rune +Skovbo Johansen's Phacelle noise, see its header), so that file keeps its +header and any change to it is published under MPL-2.0. + +The other `*.glsl` files here are the scene's own stages. They are +concatenated by `../shaderSource.ts` after a prelude; they use no colour +literal (the palette is uniforms) and define no noise. diff --git a/src/app/features/scene/shaders/atmosphere.frag.glsl b/src/app/features/scene/shaders/atmosphere.frag.glsl new file mode 100644 index 00000000..adbf1b5b --- /dev/null +++ b/src/app/features/scene/shaders/atmosphere.frag.glsl @@ -0,0 +1,90 @@ +// Pass 2: atmosphere, marched only up to pass 1's depth. Height fog is closed-form; the cloud +// slab is marched with a few jittered steps. Output: rgb in-scattered light, a transmittance. +uniform sampler2D u_depth; // pass 1 hit distance, nearest +uniform int u_steps; // cloud march steps +uniform float u_frame; +uniform vec2 u_jitter; // sub-pixel jitter in pixels of this target +uniform vec2 u_size; // this target's size in pixels +uniform float u_coverage; // weather knob: 0 clear .. 1 overcast + +in vec2 v_uv; +layout(location = 0) out vec4 o_atm; + +float hg(float cosTheta, float g) +{ + float g2 = g * g; + return (1.0 - g2) / (12.566370614 * pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5)); +} + +float cloudDensity(vec3 p) +{ + float wind = u_time * 2.5; + float f = cultmath_fbm_grad(vec3(p.x * 0.0042 + wind * 0.0042, p.y * 0.0075, p.z * 0.0042), 3, 2.0, 0.5).w; + float h = (p.y - W_CLOUD_Y0) / (W_CLOUD_Y1 - W_CLOUD_Y0); + float profile = smoothstep(0.0, 0.2, h) * smoothstep(1.0, 0.55, h); + return clamp((f + 0.55 * u_coverage - 0.25) * 2.4, 0.0, 1.0) * profile; +} + +void main() +{ + vec2 jittered = (gl_FragCoord.xy + u_jitter) / u_size; + vec3 rd = camRay(jittered); + vec3 ro = u_camPos; + float depth = texture(u_depth, v_uv).r; + float tEnd = min(depth, W_ATM_FAR); + + // Exponential height fog, closed form: optical depth of density d0 * exp(-k y) along the ray. + float k = W_FOG_FALLOFF; + float dy = rd.y * tEnd; + float tau = W_FOG_DENSITY * exp(-k * ro.y) * (abs(dy) > 1e-3 ? (1.0 - exp(-k * dy)) / (k * rd.y) : tEnd); + float fogT = exp(-max(tau, 0.0)); + float cosSun = dot(rd, u_sunDir); + vec3 fogLight = u_fogColor + u_sunColor * hg(cosSun, 0.55) * 0.9; + vec3 inscatter = fogLight * (1.0 - fogT); + float trans = fogT; + + // Clouds: only the part of the ray inside the slab and in front of the surface. + float ta = 0.0; + float tb = tEnd; + if (abs(rd.y) > 1e-4) + { + float t0 = (W_CLOUD_Y0 - ro.y) / rd.y; + float t1 = (W_CLOUD_Y1 - ro.y) / rd.y; + ta = max(0.0, min(t0, t1)); + tb = min(tEnd, max(t0, t1)); + } + else if (ro.y < W_CLOUD_Y0 || ro.y > W_CLOUD_Y1) + tb = 0.0; + + if (tb > ta && u_steps > 0) + { + float dt = (tb - ta) / float(u_steps); + float t = ta + dt * ign(gl_FragCoord.xy, u_frame); + float T = 1.0; + vec3 L = vec3(0.0); + float phase = hg(cosSun, 0.6) + 0.35 * hg(cosSun, -0.2); + for (int i = 0; i < u_steps; i++) + { + vec3 p = ro + rd * t; + float d = cloudDensity(p); + if (d > 0.01) + { + // One sample toward the sun stands in for self-shadowing. + float shade = exp(-cloudDensity(p + u_sunDir * 38.0) * 2.2); + vec3 light = u_sunColor * (phase * 6.0) * shade + mix(u_skyHorizon, u_skyTop, 0.6) * 0.35; + float ext = d * dt * 0.035; + float stepT = exp(-ext); + L += T * (1.0 - stepT) * light; + T *= stepT; + if (T < 0.03) + break; + } + t += dt; + } + // Fog in front of the slab is already in `inscatter`; clouds sit behind it. + inscatter += trans * L; + trans *= T; + } + + o_atm = vec4(inscatter, trans); +} diff --git a/src/app/features/scene/shaders/common.glsl b/src/app/features/scene/shaders/common.glsl new file mode 100644 index 00000000..ac3bb784 --- /dev/null +++ b/src/app/features/scene/shaders/common.glsl @@ -0,0 +1,31 @@ +// Shared by every fragment stage; concatenated after the prelude, CultMath and the world defines. +uniform vec3 u_camPos; +uniform mat3 u_camBasis; // columns: right, up, forward +uniform vec2 u_tanHalf; // x: tan(fov/2) * aspect, y: tan(fov/2) +uniform float u_time; +uniform vec3 u_sunDir; // unit, towards the sun +uniform vec3 u_sunColor; +uniform vec3 u_skyTop; +uniform vec3 u_skyHorizon; +uniform vec3 u_fogColor; + +vec3 camRay(vec2 uv) +{ + vec2 ndc = uv * 2.0 - 1.0; + return normalize(u_camBasis * vec3(ndc * u_tanHalf, 1.0)); +} + +float ign(vec2 p, float frame) +{ + p += 5.588238 * mod(frame, 64.0); + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + +vec3 skyColor(vec3 rd) +{ + float h = clamp(rd.y, 0.0, 1.0); + vec3 c = mix(u_skyHorizon, u_skyTop, pow(h, 0.45)); + float s = max(dot(rd, u_sunDir), 0.0); + c += u_sunColor * (0.25 * pow(s, 8.0) + 1.5 * pow(s, 256.0)); + return c; +} diff --git a/src/app/features/scene/shaders/composite.frag.glsl b/src/app/features/scene/shaders/composite.frag.glsl new file mode 100644 index 00000000..9dce31f1 --- /dev/null +++ b/src/app/features/scene/shaders/composite.frag.glsl @@ -0,0 +1,26 @@ +// Full-resolution composite to the canvas: solid over sky, then the accumulated atmosphere. +uniform sampler2D u_solid; // rgb lit, a coverage (bilinear) +uniform sampler2D u_atm; // TAA'd atmosphere (bilinear) +uniform float u_frame; + +in vec2 v_uv; +out vec4 o_frag; + +vec3 tonemap(vec3 x) +{ + // Narkowicz ACES fit. + return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); +} + +void main() +{ + vec3 rd = camRay(v_uv); + vec4 solid = texture(u_solid, v_uv); + vec4 atm = texture(u_atm, v_uv); + vec3 base = mix(skyColor(rd), solid.rgb, solid.a); + vec3 col = base * atm.a + atm.rgb; + col = tonemap(col); + col = pow(col, vec3(1.0 / 2.2)); + col += (ign(gl_FragCoord.xy, u_frame) - 0.5) / 255.0; + o_frag = vec4(col, 1.0); +} diff --git a/src/app/features/scene/shaders/fullscreen.vert.glsl b/src/app/features/scene/shaders/fullscreen.vert.glsl new file mode 100644 index 00000000..cab0e527 --- /dev/null +++ b/src/app/features/scene/shaders/fullscreen.vert.glsl @@ -0,0 +1,8 @@ +out vec2 v_uv; +void main() +{ + // One oversized triangle; uv spans 0..1 over the visible part. + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + v_uv = p; + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/src/app/features/scene/shaders/resolve.frag.glsl b/src/app/features/scene/shaders/resolve.frag.glsl new file mode 100644 index 00000000..cc6dd2e0 --- /dev/null +++ b/src/app/features/scene/shaders/resolve.frag.glsl @@ -0,0 +1,41 @@ +// TAA resolve for the atmosphere target: reproject history by the pass-1 depth, clamp it to the +// current frame's 3x3 neighbourhood, blend. +uniform sampler2D u_cur; +uniform sampler2D u_history; +uniform sampler2D u_depth; +uniform vec3 u_prevPos; +uniform mat3 u_prevBasis; +uniform vec2 u_prevTanHalf; +uniform float u_blend; // weight of the current frame; 1 resets +uniform vec2 u_curSize; + +in vec2 v_uv; +layout(location = 0) out vec4 o_hist; + +void main() +{ + vec2 px = 1.0 / u_curSize; + vec4 cur = texture(u_cur, v_uv); + vec4 lo = cur; + vec4 hi = cur; + for (int y = -1; y <= 1; y++) + for (int x = -1; x <= 1; x++) + { + vec4 s = texture(u_cur, v_uv + vec2(float(x), float(y)) * px); + lo = min(lo, s); + hi = max(hi, s); + } + + // Where this pixel's ray ended, reprojected into last frame's camera. + vec3 rd = camRay(v_uv); + float t = min(texture(u_depth, v_uv).r, 600.0); + vec3 v = u_camPos + rd * t - u_prevPos; + vec3 local = transpose(u_prevBasis) * v; + vec2 prevNdc = local.z > 0.0 ? local.xy / (local.z * u_prevTanHalf) : vec2(2.0); + vec2 prevUv = prevNdc * 0.5 + 0.5; + bool inside = all(greaterThanEqual(prevUv, vec2(0.0))) && all(lessThanEqual(prevUv, vec2(1.0))); + + float a = (inside && u_blend < 1.0) ? u_blend : 1.0; + vec4 hist = clamp(texture(u_history, prevUv), lo, hi); + o_hist = mix(hist, cur, a); +} diff --git a/src/app/features/scene/shaders/terrain.frag.glsl b/src/app/features/scene/shaders/terrain.frag.glsl new file mode 100644 index 00000000..770325b0 --- /dev/null +++ b/src/app/features/scene/shaders/terrain.frag.glsl @@ -0,0 +1,180 @@ +// Pass 1: solid surfaces (terrain + world tree), sphere-traced. Writes lit colour with coverage +// and, separately, the hit distance that bounds pass 2. +uniform vec3 u_grass; +uniform vec3 u_rock; +uniform vec3 u_bark; +uniform vec3 u_leaf; +uniform int u_maxSteps; + +in vec2 v_uv; +layout(location = 0) out vec4 o_color; // rgb lit, a coverage +layout(location = 1) out float o_depth; // hit distance along the ray, W_FAR on a miss + +// ---- terrain ------------------------------------------------------------------------------- + +float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); } + +// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap. +float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; } + +#if W_ERODE_ENABLED +CultPhasor terrainPhacelle(vec2 xz) +{ + // A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion. + return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM); +} +#endif + +// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the +// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated. +float terrainDistance(vec3 p) +{ + float m = terrainMask(p.xz); + float hb = m * W_AMP * terrainFbm(p.xz); + float above = p.y - (hb + m * W_ERODE_AMP); + if (above > 0.0) + return above * W_SLOPE_ENV_INV; +#if W_ERODE_ENABLED + hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w; +#endif + return (p.y - hb) * W_SLOPE_FULL_INV; +} + +vec3 terrainNormal(vec2 xz) +{ + float r = length(xz); + float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r); + float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0); + float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0; + vec2 dir = r > 0.0 ? xz / r : vec2(0.0); + vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); + float f = fg.w; + vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir; +#if W_ERODE_ENABLED + CultPhasor ph = terrainPhacelle(xz); + g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir; +#endif + return normalize(vec3(-g.x, 1.0, -g.y)); +} + +// ---- world tree ---------------------------------------------------------------------------- + +float smin(float a, float b, float k) +{ + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h * h * k * 0.25; +} + +// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz. +float treeDistance(vec3 p, out float leafness) +{ + vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0); + float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y); + leafness = 0.0; + if (bound > 8.0) + return bound; + + float ang = atan(p.z, p.x); + float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0); + float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15)); + float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7; + + vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R; + float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0)); + float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp; + canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L) + + float d = smin(trunk, canopy, 6.0); + leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0); + return d; +} + +// ---- trace --------------------------------------------------------------------------------- + +float sceneDistance(vec3 p, out float leafness, out bool onTree) +{ + float dt = terrainDistance(p); + float dr = treeDistance(p, leafness); + onTree = dr < dt; + return min(dt, dr); +} + +void main() +{ + vec3 ro = u_camPos; + vec3 rd = camRay(v_uv); + + float t = 0.0; + // Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. + if (ro.y > W_MAX_HEIGHT) + { + if (rd.y >= 0.0) + { + o_color = vec4(0.0); + o_depth = W_FAR; + return; + } + t = (W_MAX_HEIGHT - ro.y) / rd.y; + } + + bool hit = false; + bool onTree = false; + float leafness = 0.0; + int i = 0; + for (; i < u_maxSteps; i++) + { + vec3 p = ro + rd * t; + float d = sceneDistance(p, leafness, onTree); + if (d < 0.0006 * t + 0.01) + { + hit = true; + break; + } + t += d; + if (t > W_FAR || (p.y > W_MAX_HEIGHT && rd.y > 0.0)) + break; + } + // The conservative bound makes grazing rays crawl: a ray that ran out of steps while still + // below the highest terrain is a distant surface under thick fog, so it ends there. + if (!hit && i == u_maxSteps && t < W_FAR) + { + hit = true; + sceneDistance(ro + rd * t, leafness, onTree); + } + + if (!hit) + { + o_color = vec4(0.0); + o_depth = W_FAR; + return; + } + + vec3 p = ro + rd * t; + vec3 n; + vec3 albedo; + if (onTree) + { + float e = 0.35; + float l; + n = normalize(vec3( + treeDistance(p + vec3(e, 0.0, 0.0), l) - treeDistance(p - vec3(e, 0.0, 0.0), l), + treeDistance(p + vec3(0.0, e, 0.0), l) - treeDistance(p - vec3(0.0, e, 0.0), l), + treeDistance(p + vec3(0.0, 0.0, e), l) - treeDistance(p - vec3(0.0, 0.0, e), l))); + float speckle = 0.75 + 0.25 * cultmath_snoise(p * 0.8); + albedo = mix(u_bark, u_leaf * speckle, leafness); + } + else + { + n = terrainNormal(p.xz); + float rockness = smoothstep(0.78, 0.55, n.y); + albedo = mix(u_grass, u_rock, rockness); + } + + float ndl = dot(n, u_sunDir); + float wrap = clamp(ndl * 0.6 + 0.4, 0.0, 1.0); // half-lambert: no shadow ray in the cheap pass + vec3 ambient = mix(u_skyHorizon, u_skyTop, 0.5 + 0.5 * n.y) * 0.45; + vec3 col = albedo * (u_sunColor * wrap * wrap + ambient); + + o_color = vec4(col, 1.0); + o_depth = t; +} diff --git a/src/app/features/scene/world.test.ts b/src/app/features/scene/world.test.ts new file mode 100644 index 00000000..87fa1983 --- /dev/null +++ b/src/app/features/scene/world.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import common from './shaders/common.glsl?raw'; +import terrain from './shaders/terrain.frag.glsl?raw'; +import atmosphere from './shaders/atmosphere.frag.glsl?raw'; +import resolve from './shaders/resolve.frag.glsl?raw'; +import composite from './shaders/composite.frag.glsl?raw'; +import { terrainBounds, WORLD, worldDefines } from './world'; + +describe('world defines', () => { + it('define every W_ name any scene shader uses', () => { + const defined = new Set([...worldDefines().matchAll(/#define (W_\w+)/g)].map((m) => m[1])); + for (const src of [common, terrain, atmosphere, resolve, composite]) { + for (const m of src.matchAll(/\bW_[A-Z0-9_]+\b/g)) expect(defined, m[0]).toContain(m[0]); + } + }); + + it('emit only valid GLSL numbers', () => { + expect(worldDefines()).not.toMatch(/NaN|Infinity|undefined/); + }); +}); + +describe('terrain step bound', () => { + it('is more conservative with erosion than without, and without erosion carries no phacelle term', () => { + const b = terrainBounds(); + expect(b.slopeFull).toBeGreaterThan(b.slopeEnvelope); + const off = terrainBounds({ ...WORLD, phacelleGradBound: 0 }); + expect(off.slopeFull).toBe(off.slopeEnvelope); + }); + + it('puts every surface under the height the tracer skips to', () => { + const maxHeight = Number(/#define W_MAX_HEIGHT (\S+)/.exec(worldDefines())![1]); + expect(maxHeight).toBeGreaterThanOrEqual(terrainBounds().maxHeight); + expect(maxHeight).toBeGreaterThanOrEqual(WORLD.tree.canopyY + WORLD.tree.canopyRadius[1]); + }); + + it('leaves the erosion bound turned on only when a bound exists', () => { + expect(worldDefines()).toContain('#define W_ERODE_ENABLED 1'); + expect(worldDefines({ ...WORLD, phacelleGradBound: 0 })).toContain('#define W_ERODE_ENABLED 0'); + }); +}); diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts new file mode 100644 index 00000000..82afdf94 --- /dev/null +++ b/src/app/features/scene/world.ts @@ -0,0 +1,114 @@ +// The scene's world constants. One owner: the shader prelude below is generated from this +// object, so a number lives here and nowhere else, and the Lipschitz bounds that make the +// sphere-trace step safe are computed beside the numbers they bound. + +// Lipschitz constant of cultmath_snoise(vec3), CULTMATH_SNOISE_LIPSCHITZ in the vendored +// CultMath.glsl (provenance in CultLib math.Interval.cs). +export const SNOISE_LIPSCHITZ = 7.9640074; + +export const WORLD = { + far: 1400, + // Terrain: height = mask(r) * (amp * fbm(xz * freq) + erodeAmp * phacelle(xz * erodeFreq)). + amp: 70, + freq: 1 / 520, + octaves: 4, + lacunarity: 2.0, + gain: 0.45, + // Flat ground (height 0) out to plateauInner, full terrain from plateauOuter. The login + // camera stays inside plateauInner, so it can never end up inside the terrain. + plateauInner: 135, + plateauOuter: 380, + erodeAmp: 2.5, + erodeFreq: 1 / 38, + // The stripe wave vector and normalization the terrain feeds cultmath_phacelle. + erodeSide: [1.7, 0, 1] as const, + erodeNorm: 0.5, + // Lipschitz bound of the phasor's cos component, NOT a proof: max |grad| over 4,194,304 random + // points of the z = 0 slice with the side and norm above was 5.04 (`drive.cjs phacelle`, + // 2026-10-31), x 1.25. |value| <= 1 holds by construction (the output is a normalized phasor). + // Replace with the bound Libby supplies; until then the march is as safe as that probe. + phacelleGradBound: 6.3, + // World tree at the origin. + tree: { + trunkHeight: 38, + baseRadius: 3.4, + topRadius: 1.9, + canopyY: 52, + canopyRadius: [30, 17, 30] as const, + }, + // Atmosphere. + atmosphereFar: 1400, + cloudBase: 130, + cloudTop: 230, + fogDensity: 0.0028, + fogFalloff: 0.011, +} as const; + +const f = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`); + +/** Sum over octaves of (lacunarity * gain)^i: the slope bound's octave factor. */ +function slopeSum(octaves: number, lacunarity: number, gain: number): number { + let s = 0; + for (let i = 0; i < octaves; i += 1) s += (lacunarity * gain) ** i; + return s; +} + +/** Sum over octaves of gain^i: the fbm value bound. */ +function valueSum(octaves: number, gain: number): number { + let s = 0; + for (let i = 0; i < octaves; i += 1) s += gain ** i; + return s; +} + +export interface TerrainBounds { + /** Lipschitz bound of the envelope surface (no erosion). */ + slopeEnvelope: number; + /** Lipschitz bound of the eroded surface. */ + slopeFull: number; + /** Highest point the terrain can reach. */ + maxHeight: number; +} + +export function terrainBounds(w: typeof WORLD = WORLD): TerrainBounds { + const fbmMax = valueSum(w.octaves, w.gain); + const maskSlope = 1.5 / (w.plateauOuter - w.plateauInner); // smoothstep's max slope + const fbmSlope = w.amp * w.freq * SNOISE_LIPSCHITZ * slopeSum(w.octaves, w.lacunarity, w.gain); + const slopeEnvelope = fbmSlope + (w.amp * fbmMax + w.erodeAmp) * maskSlope; + const slopeFull = slopeEnvelope + w.erodeAmp * w.erodeFreq * w.phacelleGradBound; + return { slopeEnvelope, slopeFull, maxHeight: w.amp * fbmMax + w.erodeAmp }; +} + +/** The #defines every stage sees. */ +export function worldDefines(w: typeof WORLD = WORLD): string { + const b = terrainBounds(w); + const t = w.tree; + const lines: Array<[string, string]> = [ + ['W_FAR', f(w.far)], + ['W_AMP', f(w.amp)], + ['W_FREQ', f(w.freq)], + ['W_OCTAVES', String(w.octaves)], + ['W_LACUNARITY', f(w.lacunarity)], + ['W_GAIN', f(w.gain)], + ['W_PLATEAU_IN', f(w.plateauInner)], + ['W_PLATEAU_OUT', f(w.plateauOuter)], + ['W_ERODE_AMP', f(w.erodeAmp)], + ['W_ERODE_FREQ', f(w.erodeFreq)], + ['W_ERODE_SIDE', `vec3(${w.erodeSide.map(f).join(', ')})`], + ['W_ERODE_NORM', f(w.erodeNorm)], + ['W_ERODE_ENABLED', w.phacelleGradBound > 0 ? '1' : '0'], + ['W_SLOPE_ENV_INV', f(1 / Math.sqrt(1 + b.slopeEnvelope ** 2))], + ['W_SLOPE_FULL_INV', f(1 / Math.sqrt(1 + b.slopeFull ** 2))], + ['W_MAX_HEIGHT', f(Math.max(b.maxHeight, t.canopyY + t.canopyRadius[1] + 6))], + ['W_TRUNK_H', f(t.trunkHeight)], + ['W_TRUNK_R0', f(t.baseRadius)], + ['W_TRUNK_R1', f(t.topRadius)], + ['W_CANOPY_Y', f(t.canopyY)], + ['W_CANOPY_R', `vec3(${t.canopyRadius.map(f).join(', ')})`], + ['W_ATM_FAR', f(w.atmosphereFar)], + ['W_CLOUD_Y0', f(w.cloudBase)], + ['W_CLOUD_Y1', f(w.cloudTop)], + ['W_FOG_DENSITY', f(w.fogDensity)], + ['W_FOG_FALLOFF', f(w.fogFalloff)], + ]; + return lines.map(([k, v]) => `#define ${k} ${v}`).join('\n'); +} From 8f1bb54b4b1d48bb1c0b7a95663b6219aa162c71 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:35:35 +0100 Subject: [PATCH 42/64] estate: every client join goes through joinEstateRoomIdOrAlias; /join reports refusals RoomTombstone, RoomIntro and the lobby RoomItem/SpaceItem joined with mx.joinRoom beside the pre-join filing; they now call joinEstateRoomOrThrow, which delegates to the one path. /join hands each refusal to the user instead of discarding it. Tests pin the via pass-through and the 400/429/499 refusal bounds. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/room-card/RoomCard.tsx | 5 +- src/app/components/room-intro/RoomIntro.tsx | 3 +- .../features/estate/joinEstateRoom.test.ts | 68 ++++++++++++++++++- src/app/features/estate/joinEstateRoom.ts | 43 +++++++++++- src/app/features/lobby/RoomItem.tsx | 12 ++-- src/app/features/lobby/SpaceItem.tsx | 7 +- src/app/features/room/RoomTombstone.tsx | 7 +- src/app/hooks/useCommands.ts | 13 +--- 8 files changed, 124 insertions(+), 34 deletions(-) diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index a5e9b3a9..64aaaacb 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -31,7 +31,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; -import { joinEstateRoomIdOrAlias } from '../../features/estate/joinEstateRoom'; +import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -200,8 +200,7 @@ export const RoomCard = as<'div', RoomCardProps>( const [joinState, join] = useAsyncCallback( useCallback(async () => { - const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, viaServers); - if (result.kind === 'refused') throw new Error(result.message); + await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers); }, [mx, roomIdOrAlias, viaServers]) ); const joining = diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index ce550992..2e4e3ebc 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -17,6 +17,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { InviteUserPrompt } from '../invite-user-prompt'; +import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; export type RoomIntroProps = { room: Room; @@ -43,7 +44,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => const prevRoomId = createContent?.predecessor?.room_id; const [prevRoomState, joinPrevRoom] = useAsyncCallback( - useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]) + useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx]) ); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 8e009ef0..cc1d232f 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { activateEstateRoom, awaitMyJoin, joinEstateRoomIdOrAlias } from './joinEstateRoom'; +import { + activateEstateRoom, + awaitMyJoin, + joinEstateRoomIdOrAlias, + joinEstateRoomOrThrow, + runJoinCommand, +} from './joinEstateRoom'; const room = (state: 'joined' | 'invited' | 'joinable') => ({ roomId: '!r:gamecult.org', @@ -95,6 +101,7 @@ const dmClient = (opts: { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; + const joinOpts: unknown[] = []; const mx = { getSafeUserId: () => '@me:test', getRoom: (roomId: string) => ({ @@ -116,7 +123,8 @@ const dmClient = (opts: { writes.push(structuredClone(content)); direct = structuredClone(content); }, - joinRoom: async (roomId: string) => { + joinRoom: async (roomId: string, opts?: unknown) => { + joinOpts.push(opts); // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); if (opts.refuse) @@ -130,7 +138,7 @@ const dmClient = (opts: { return { roomId }; }, }; - return { mx: mx as never, writes, joined, current: () => direct }; + return { mx: mx as never, writes, joined, joinOpts, current: () => direct }; }; const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] }); @@ -169,6 +177,22 @@ describe('activateEstateRoom when the join gets no answer', () => { }); }); +describe('activateEstateRoom refusal bounds', () => { + it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { + const c = dmClient({ status, errcode: 'M_LIMIT_EXCEEDED', invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result.kind).toBe('refused'); + expect((result as { message: string }).message).not.toMatch(/tap again to retry/); + expect(c.current()).toEqual({}); + }); + + it.each([399, 500])('keeps the room listed on a %i that carries an errcode', async (status) => { + const c = dmClient({ status, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); + await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); +}); + describe('activateEstateRoom when a gateway answers instead of the server', () => { it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => { const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } }); @@ -197,6 +221,12 @@ describe('joinEstateRoomIdOrAlias, the room card and /join', () => { expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); }); + it('hands the via servers to the join', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['other.org']); + expect(c.joinOpts).toEqual([{ viaServers: ['other.org'] }]); + }); + it('joins an alias or an unheld room without touching m.direct', async () => { const { mx, calls } = recordingClient(); await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org'); @@ -204,6 +234,38 @@ describe('joinEstateRoomIdOrAlias, the room card and /join', () => { }); }); +describe('joinEstateRoomOrThrow, the lobby, tombstone and predecessor buttons', () => { + it('files a direct invite and answers the joined room id', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test', ['test'])).resolves.toBe('!dm1:test'); + expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); + }); + + it('throws the refusal and unfiles the room', async () => { + const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); + await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test')).rejects.toThrow('M_FORBIDDEN'); + expect(c.current()).toEqual({}); + }); +}); + +describe('runJoinCommand, /join', () => { + it('reports each refusal and joins the others', async () => { + const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); + const reported: string[] = []; + await runJoinCommand(c.mx, '!dm1:test notanaddress', (m) => reported.push(m)); + expect(reported).toEqual(['M_FORBIDDEN']); + expect(c.joined).toHaveLength(1); + }); + + it('reports nothing when the join is accepted', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + const reported: string[] = []; + await runJoinCommand(c.mx, '!dm1:test', (m) => reported.push(m)); + expect(reported).toEqual([]); + expect(c.joined).toHaveLength(1); + }); +}); + describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index d69df82c..4e1629d0 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -1,5 +1,11 @@ import { EstateRoom } from './buildEstate'; -import { addRoomIdToMDirect, removeRoomIdFromMDirect } from '../../utils/matrix'; +import { + addRoomIdToMDirect, + isRoomAlias, + isRoomId, + removeRoomIdFromMDirect, +} from '../../utils/matrix'; +import { splitWithSpace } from '../../utils/common'; import { isDirectInvite } from '../../utils/room'; export type EstateActivation = @@ -25,8 +31,9 @@ type JoinClient = Parameters[0]; * join that fails any other way (lost response, dropped connection, a gateway's * 5xx or a status with no errcode) may still have been accepted upstream, so the * entry stays and the tap is retryable. Nothing needs to recognise the DM after it - * arrives. Every join of a room the client holds an invite for goes through - * here: the inbox Accept, the room card, /join. One owner for joining. + * arrives. Every client join goes through here: the estate tap, the inbox Accept, + * the room card, /join, the lobby and space Join buttons, the tombstone and + * predecessor links. One owner for joining. */ export const activateEstateRoom = async ( mx: JoinClient, @@ -79,6 +86,36 @@ export const joinEstateRoomIdOrAlias = ( via, }); +/** + * joinEstateRoomIdOrAlias for a caller that reports failure by throwing (a + * useAsyncCallback button): the joined room id, or an Error carrying the refusal. + */ +export const joinEstateRoomOrThrow = async ( + mx: JoinClient, + roomIdOrAlias: string, + via?: string[] +): Promise => { + const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, via); + if (result.kind === 'refused') throw new Error(result.message); + return result.roomId; +}; + +/** + * The /join command: every room id or alias in the payload is joined, and each + * refusal is handed to report, never dropped. + */ +export const runJoinCommand = async ( + mx: JoinClient, + payload: string, + report: (message: string) => void +): Promise => { + const addresses = splitWithSpace(payload).filter((a) => isRoomId(a) || isRoomAlias(a)); + const results = await Promise.all(addresses.map((a) => joinEstateRoomIdOrAlias(mx, a))); + results.forEach((r) => { + if (r.kind === 'refused') report(r.message); + }); +}; + type MembershipSource = { getRoom(roomId: string): { getMyMembership(): string } | null; on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index 7de59acd..132aaf8c 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -19,7 +19,7 @@ import { toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; +import { JoinRule, Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { SequenceCard } from '../../components/sequence-card'; @@ -38,6 +38,7 @@ import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { ItemDraggableTarget, useDraggableItem } from './DnD'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomJoinButtonProps = { roomId: string; @@ -46,8 +47,8 @@ type RoomJoinButtonProps = { function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; @@ -60,9 +61,8 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { - {joinState.error.data?.error || joinState.error.message} + {joinState.error.message} - {joinState.error.name} } @@ -75,7 +75,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { size="400" filled tabIndex={0} - aria-label={joinState.error.data?.error || joinState.error.message} + aria-label={joinState.error.message} /> )} diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index ed3eaff9..e1ffbfe5 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -18,7 +18,7 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; -import { MatrixError, Room } from 'matrix-js-sdk'; +import { Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -38,6 +38,7 @@ import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal'; import { AddExistingModal } from '../add-existing'; import { CreateRoomType } from '../../components/create-room/types'; import { BetaNoticeBadge } from '../../components/BetaNoticeBadge'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; function SpaceProfileLoading() { return ( @@ -112,8 +113,8 @@ function UnjoinedSpaceProfile({ }: UnjoinedSpaceProfileProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; diff --git a/src/app/features/room/RoomTombstone.tsx b/src/app/features/room/RoomTombstone.tsx index 24f0b80e..3556e6fc 100644 --- a/src/app/features/room/RoomTombstone.tsx +++ b/src/app/features/room/RoomTombstone.tsx @@ -8,6 +8,7 @@ import { Membership } from '../../../types/matrix/room'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { getViaServers } from '../../plugins/via-servers'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string }; export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) { @@ -18,16 +19,14 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone useCallback(() => { const currentRoom = mx.getRoom(roomId); const via = currentRoom ? getViaServers(currentRoom) : []; - return mx.joinRoom(replacementRoomId, { - viaServers: via, - }); + return joinEstateRoomOrThrow(mx, replacementRoomId, via); }, [mx, roomId, replacementRoomId]) ); const replacementRoom = mx.getRoom(replacementRoomId); const handleOpen = () => { if (replacementRoom) navigateRoom(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId); + if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data); }; return ( diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 15a21d1e..11f379dc 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -15,7 +15,6 @@ import { addRoomIdToMDirect, getDMRoomFor, guessDmRoomUserId, - isRoomAlias, isRoomId, isServerName, isUserId, @@ -26,7 +25,7 @@ import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; -import { joinEstateRoomIdOrAlias } from '../features/estate/joinEstateRoom'; +import { runJoinCommand } from '../features/estate/joinEstateRoom'; import { createRoomEncryptionState } from '../components/create-room'; export const SHRUG = 'ยฏ\\_(ใƒ„)_/ยฏ'; @@ -228,15 +227,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { [Command.Join]: { name: Command.Join, description: 'Join room with address. Example: /join address1 address2', - exe: async (payload) => { - const rawIds = splitWithSpace(payload); - const roomIdOrAliases = rawIds.filter( - (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) - ); - roomIdOrAliases.forEach(async (idOrAlias) => { - await joinEstateRoomIdOrAlias(mx, idOrAlias); - }); - }, + exe: (payload) => runJoinCommand(mx, payload, (message) => window.alert(message)), }, [Command.Leave]: { name: Command.Leave, From 6950da865e12e7f3703cf7915ef3f2e153184f83 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:37:35 +0100 Subject: [PATCH 43/64] estate harness: every drive asserts the outcome it shows and run.sh fails when one does not Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/check.cjs | 18 +++++++++++++++++ harness/estate/drive-failures.cjs | 33 +++++++++++++++++++++---------- harness/estate/drive-routes.cjs | 20 ++++++++++++++----- harness/estate/drive.cjs | 14 ++++++++++--- harness/estate/run.sh | 9 +++++---- 5 files changed, 72 insertions(+), 22 deletions(-) create mode 100644 harness/estate/check.cjs diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs new file mode 100644 index 00000000..172fa301 --- /dev/null +++ b/harness/estate/check.cjs @@ -0,0 +1,18 @@ +// Assertions for the estate drives: every drive states the outcome it exists to show and exits +// non-zero when the page or the fake homeserver's log says otherwise. +const fs = require('fs'); +const fails = []; +exports.expect = (label, ok, detail = '') => { + console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`); + if (!ok) fails.push(label); +}; +/** Exit code for the drive: 4 when any assertion failed. */ +exports.done = () => (fails.length ? 4 : 0); +/** The fake homeserver's request log (HSLOG is shared with the server process). */ +exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n'); +/** Bodies of the account-data PUTs for m.direct, in order. */ +exports.mDirectPuts = () => + exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]); +/** Join requests the server saw for a room id. */ +exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length; +exports.isDm = (tier) => /^direct messages$/i.test(tier); diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index 9c81424a..1172c853 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -1,6 +1,7 @@ // Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh // against fakehs.cjs with the env flag each mode needs. const { chromium } = require('playwright'); +const { expect, done, isDm, mDirectPuts, joinsOf } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); @@ -21,7 +22,8 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM'), '| Slow DM', await tierOf('Slow DM')); }; + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; + const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test')); const acceptInInbox = async (name) => { // reach the inbox through the navigator's own Inbox tab await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000); @@ -38,23 +40,34 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); }; if (MODE === 'pjfail') { await tap('Friend DM'); await pg.waitForTimeout(3000); - const bb = await body(); const k = bb.search(/Internal server error|Could not join|Joined\./); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); - await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('after retry'); + const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); + expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200)); + expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test'))); + await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry'); + expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test'))); + expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend); } else if (MODE === 'jdrop') { await tap('Friend DM'); await pg.waitForTimeout(4000); - const bb = await body(); const k = bb.search(/fetch|Failed|network|Could not join|Joined\./i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); - await pg.waitForTimeout(6000); await tiers('after drop'); await open(); await tiers('after reload'); + const bb = await body(); const k = bb.search(/tap again to retry/i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); + expect('a dropped join shows the retryable message', k >= 0, bb.slice(0, 200)); + await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload'); + expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend); + expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend); } else if (MODE === 'admin') { - await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin estate'); + await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate'); + expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend); } else if (MODE === 'inboxadmin') { - await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin inbox'); + await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox'); + expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend); } else if (MODE === 'inboxreload') { await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/'); - await pg.waitForTimeout(30000); await tiers('inbox reload'); + await pg.waitForTimeout(30000); const r = await tiers('inbox reload'); + expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow); } else if (MODE === 'pjreload') { await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); await pg.waitForTimeout(8000); await tiers('pjreload'); - const l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count()); + const l = pg.locator('[aria-label="Join Slow DM"]'); const n = await l.count(); log('SLOW_DM_STILL_JOINABLE', n); + expect('a join cut off by a reload stays joinable', n === 1, String(n)); } - await quit(0); + await quit(done()); })().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs index 05e89df1..5def6635 100644 --- a/harness/estate/drive-routes.cjs +++ b/harness/estate/drive-routes.cjs @@ -1,4 +1,5 @@ const { chromium } = require('playwright'); +const { expect, done, isDm, mDirectPuts, joinsOf, hsLog } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; @@ -13,21 +14,30 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); const tierOf = (n) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, n); - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM')); }; + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const f = await tierOf('Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; + let joined = false; if (MODE === 'deeplink') { for (const route of ['direct', 'home']) { await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000); const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300))); const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count()); - if (await j.count()) { await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } + if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } } - await tiers('after deeplink join'); + const f = await tiers('after deeplink join'); + const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0])); + const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0]; + expect('the deep link offers one Join button', joined, 'no Join button'); + expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`); + expect('the DM is filed under Direct Messages', isDm(f), f); } else if (MODE === 'gw504') { const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); await pg.waitForTimeout(4000); const bb = await body(); const k = bb.search(/504|Gateway|connection dropped|Could not join/i); log('AFTER_504', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 120) : 'no message'); - await pg.waitForTimeout(4000); await tiers('after 504 (join delivered by sync)'); + expect('a gateway timeout shows the retryable message', /tap again to retry/i.test(bb), bb.slice(0, 200)); + await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)'); + expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts())); + expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f); } - await quit(0); + await quit(done()); })().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index ed9950ce..cf794323 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,8 +1,10 @@ const { chromium } = require('playwright'); +const { expect, done, isDm } = require('./check.cjs'); const MODE = process.env.MODE; // twodm | reload const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +let tierBefore; (async () => { const b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); @@ -20,7 +22,7 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El await pg.waitForTimeout(45000); } else if (MODE === 'convert') { await tap('Friend DM'); await pg.waitForTimeout(6000); - log('TIER before', await tierOf('Friend DM')); + tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore); await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000); const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url()); await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500); @@ -36,6 +38,12 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El await pg.waitForTimeout(30000); } for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } - log('TIER Slow DM', await tierOf('Slow DM'), '| Late DM', await tierOf('Late DM'), '| Friend DM', await tierOf('Friend DM')); - await b.close(); + const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM'); + log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend); + if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); } + else if (MODE === 'convert') { + expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore); + expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend); + } else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow); + await b.close(); process.exitCode = done(); })().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 379d28ca..65242ab0 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -8,19 +8,19 @@ npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 -cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ +cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ set +e for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 - MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?" + HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt done failure() { m=$1; shift env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 - MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; echo "DRIVE $m RC=$?" + HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } failure pjfail FAIL_MDIRECT=1 @@ -31,4 +31,5 @@ failure inboxadmin INVITER_ADMIN=1 failure inboxreload JOIN_DELAY=1 DRIVER=drive-routes failure deeplink DRIVER=drive-routes failure gw504 JOIN_504=1 -echo DONE +echo "FAILED_DRIVES:${FAILED:- none}" +[ -z "$FAILED" ] From 9b66b2b25075a469fe36239a81c560b89d468505 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:52:45 +0100 Subject: [PATCH 44/64] estate harness: read a row's tier from a fresh visit without toggling tiers closed; catch the retryable message before sync replaces the row Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/check.cjs | 28 +++++++++++++++++++ harness/estate/drive-failures.cjs | 13 ++++----- harness/estate/drive-routes.cjs | 11 ++++---- harness/estate/drive.cjs | 5 ++-- .../features/estate/joinEstateRoom.test.ts | 10 +++++-- 5 files changed, 48 insertions(+), 19 deletions(-) diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs index 172fa301..656d532e 100644 --- a/harness/estate/check.cjs +++ b/harness/estate/check.cjs @@ -15,4 +15,32 @@ exports.mDirectPuts = () => exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]); /** Join requests the server saw for a room id. */ exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length; +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */ +const tierOf = (pg, name) => + pg.evaluate((n) => { + const s = document.body.innerText.split('\n').map((x) => x.trim()); + const i = s.indexOf(n); + if (i < 0) return 'absent'; + for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; + return 'none-above'; + }, name); +/** + * The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is + * opened only if the row is not already showing, so reading never closes what is open. + */ +exports.revealedTier = async (pg, name) => { + await pg.goto('http://127.0.0.1:8080/#/home/'); + await pg.waitForTimeout(2500); + let t = await tierOf(pg, name); + for (const h of HEADERS) { + if (t !== 'absent') break; + const hl = pg.getByText(h, { exact: true }); + if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); } + } + return t; +}; +/** True when the page text matches re within ms of now (a message can be replaced by a row change). */ +exports.sawText = (pg, re, ms) => + pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false); exports.isDm = (tier) => /^direct messages$/i.test(tier); diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index 1172c853..d53d88a4 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -1,7 +1,7 @@ // Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh // against fakehs.cjs with the env flag each mode needs. const { chromium } = require('playwright'); -const { expect, done, isDm, mDirectPuts, joinsOf } = require('./check.cjs'); +const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); @@ -20,9 +20,9 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); await open(); const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; - const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); + const tierOf = (name) => revealedTier(pg, name); const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; + const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test')); const acceptInInbox = async (name) => { // reach the inbox through the navigator's own Inbox tab @@ -47,9 +47,8 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test'))); expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend); } else if (MODE === 'jdrop') { - await tap('Friend DM'); await pg.waitForTimeout(4000); - const bb = await body(); const k = bb.search(/tap again to retry/i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); - expect('a dropped join shows the retryable message', k >= 0, bb.slice(0, 200)); + await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw); + expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200)); await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload'); expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend); expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend); @@ -66,7 +65,7 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); } else if (MODE === 'pjreload') { await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); await pg.waitForTimeout(8000); await tiers('pjreload'); - const l = pg.locator('[aria-label="Join Slow DM"]'); const n = await l.count(); log('SLOW_DM_STILL_JOINABLE', n); + const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n); expect('a join cut off by a reload stays joinable', n === 1, String(n)); } await quit(done()); diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs index 5def6635..75c9a8df 100644 --- a/harness/estate/drive-routes.cjs +++ b/harness/estate/drive-routes.cjs @@ -1,5 +1,5 @@ const { chromium } = require('playwright'); -const { expect, done, isDm, mDirectPuts, joinsOf, hsLog } = require('./check.cjs'); +const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; @@ -13,8 +13,7 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); - const tierOf = (n) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, n); - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const f = await tierOf('Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; + const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; let joined = false; if (MODE === 'deeplink') { for (const route of ['direct', 'home']) { @@ -32,9 +31,9 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); } else if (MODE === 'gw504') { const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } - log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); await pg.waitForTimeout(4000); - const bb = await body(); const k = bb.search(/504|Gateway|connection dropped|Could not join/i); log('AFTER_504', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 120) : 'no message'); - expect('a gateway timeout shows the retryable message', /tap again to retry/i.test(bb), bb.slice(0, 200)); + log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); + const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw); + expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200)); await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)'); expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts())); expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f); diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index cf794323..03bd2ad0 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,5 +1,5 @@ const { chromium } = require('playwright'); -const { expect, done, isDm } = require('./check.cjs'); +const { expect, done, isDm, revealedTier } = require('./check.cjs'); const MODE = process.env.MODE; // twodm | reload const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); @@ -15,7 +15,7 @@ let tierBefore; await open(); const text = () => pg.evaluate(() => document.body.innerText); const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; - const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); + const tierOf = (name) => revealedTier(pg, name); const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; if (MODE === 'twodm') { await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM'); @@ -37,7 +37,6 @@ let tierBefore; log('RELOAD', t()); await open(); await pg.waitForTimeout(30000); } - for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM'); log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend); if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); } diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index cc1d232f..f3f868da 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -123,8 +123,8 @@ const dmClient = (opts: { writes.push(structuredClone(content)); direct = structuredClone(content); }, - joinRoom: async (roomId: string, opts?: unknown) => { - joinOpts.push(opts); + joinRoom: async (roomId: string, joinArgs?: unknown) => { + joinOpts.push(joinArgs); // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); if (opts.refuse) @@ -179,7 +179,11 @@ describe('activateEstateRoom when the join gets no answer', () => { describe('activateEstateRoom refusal bounds', () => { it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { - const c = dmClient({ status, errcode: 'M_LIMIT_EXCEEDED', invites: { '!dm1:test': '@friend:test' } }); + const c = dmClient({ + status, + errcode: 'M_LIMIT_EXCEEDED', + invites: { '!dm1:test': '@friend:test' }, + }); const result = await activateEstateRoom(c.mx, tap('!dm1:test')); expect(result.kind).toBe('refused'); expect((result as { message: string }).message).not.toMatch(/tap again to retry/); From a4da78edc5dcc14e684a2c85780ab8975f50cd05 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 02:00:38 +0100 Subject: [PATCH 45/64] estate tests: an unfiled room leaves its inviter's list empty, not the map Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/joinEstateRoom.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index f3f868da..0bc61a9f 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -187,7 +187,7 @@ describe('activateEstateRoom refusal bounds', () => { const result = await activateEstateRoom(c.mx, tap('!dm1:test')); expect(result.kind).toBe('refused'); expect((result as { message: string }).message).not.toMatch(/tap again to retry/); - expect(c.current()).toEqual({}); + expect(c.current()).toEqual({ '@friend:test': [] }); }); it.each([399, 500])('keeps the room listed on a %i that carries an errcode', async (status) => { @@ -248,7 +248,7 @@ describe('joinEstateRoomOrThrow, the lobby, tombstone and predecessor buttons', it('throws the refusal and unfiles the room', async () => { const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test')).rejects.toThrow('M_FORBIDDEN'); - expect(c.current()).toEqual({}); + expect(c.current()).toEqual({ '@friend:test': [] }); }); }); From b33d8de95638b2057dc3f731a00ad8587f5757ea Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 02:04:14 +0100 Subject: [PATCH 46/64] estate tests: pin the failed-filing refusal, a non-DM refusal leaving m.direct alone, and awaitMyJoin's event name Co-Authored-By: Claude Sonnet 5.5 --- .../features/estate/joinEstateRoom.test.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 0bc61a9f..932e7cce 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -96,6 +96,7 @@ const dmClient = (opts: { errcode?: string; refuse?: boolean; drop?: boolean; + failWrite?: boolean; invites: Record; }) => { let direct: Record | undefined; @@ -117,6 +118,7 @@ const dmClient = (opts: { }), getAccountData: () => (direct ? { getContent: () => direct } : undefined), setAccountData: async (_t: string, content: Record) => { + if (opts.failWrite) throw new Error('account data write failed'); await new Promise((r) => { setTimeout(r, 5); }); @@ -177,6 +179,29 @@ describe('activateEstateRoom when the join gets no answer', () => { }); }); +describe('activateEstateRoom when the filing cannot be written', () => { + it('reports the failure and sends no join', async () => { + const c = dmClient({ failWrite: true, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result).toEqual({ kind: 'refused', message: 'account data write failed' }); + expect(c.joined).toEqual([]); + }); +}); + +describe('activateEstateRoom when a room that is not a direct message refuses', () => { + it('reports the refusal and leaves m.direct alone', async () => { + const joinRoom = vi + .fn() + .mockRejectedValue( + Object.assign(new Error('no'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ); + const { mx, calls } = recordingClient(joinRoom); + const result = await activateEstateRoom(mx, room('joinable')); + expect(result.kind).toBe('refused'); + expect(calls).toEqual(['joinRoom']); + }); +}); + describe('activateEstateRoom refusal bounds', () => { it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { const c = dmClient({ @@ -278,8 +303,12 @@ describe('awaitMyJoin', () => { return { mx: { getRoom: () => (membership ? { getMyMembership: () => membership! } : null), - on: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.add(h), - off: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.delete(h), + on: (e: string, h: (room: { roomId: string }, m: string) => void) => { + if (e === 'Room.myMembership') handlers.add(h); + }, + off: (e: string, h: (room: { roomId: string }, m: string) => void) => { + if (e === 'Room.myMembership') handlers.delete(h); + }, }, handlers, become: (roomId: string, m: string) => { From b2d6d32cc4d6f14502230e69488162b8789d47ec Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 1 Nov 2026 06:37:01 +0000 Subject: [PATCH 47/64] Background scene: phacelleGradBound 6.3 -> 7.0 from Libby's measured sup Libby measured sup |grad| 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. The shader passes roughness 0 as a literal, so no 1/(1-n) factor is needed; the comment records it. Pass 1 under SwiftShader: terrain 288.8 ms vs 287.5 at 960x540 level 0, 75.3 vs 75.7 at level 3 (noise). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 7 +++---- src/app/features/scene/world.ts | 11 ++++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index deed806a..d265c332 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -38,10 +38,9 @@ computed from `SNOISE_LIPSCHITZ` (CultMath's constant for `snoise`) in `world.ts envelope (fbm plus the erosion amplitude) the step uses the envelope's bound alone and Phacelle is not evaluated. -- The Phacelle gradient bound `phacelleGradBound` (6.3) is **an empirical probe, not a proof**: - the maximum `|grad|` of the cos component over 4,194,304 random points (5.04) times 1.25 - (`harness/scene/drive.cjs phacelle`). Replace it with Libby's bound; until then the march is - only as safe as that probe. +- The Phacelle gradient bound `phacelleGradBound` (7.0) is Libby's measured sup (6.63 at + |side| = 1.5, n = 0, CultLib PR #1 head 990521b3) plus margin: a measurement, not a proof. + The shader fixes roughness n = 0; a nonzero n needs the bound divided by (1 - n). - The bound makes grazing rays crawl. A ray that exhausts its steps while still below the highest terrain is ended as a distant surface (fog hides it). That is a visible approximation, not a hit proven by the march. diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts index 82afdf94..a1c6cb57 100644 --- a/src/app/features/scene/world.ts +++ b/src/app/features/scene/world.ts @@ -23,11 +23,12 @@ export const WORLD = { // The stripe wave vector and normalization the terrain feeds cultmath_phacelle. erodeSide: [1.7, 0, 1] as const, erodeNorm: 0.5, - // Lipschitz bound of the phasor's cos component, NOT a proof: max |grad| over 4,194,304 random - // points of the z = 0 slice with the side and norm above was 5.04 (`drive.cjs phacelle`, - // 2026-10-31), x 1.25. |value| <= 1 holds by construction (the output is a normalized phasor). - // Replace with the bound Libby supplies; until then the march is as safe as that probe. - phacelleGradBound: 6.3, + // Bound on |grad| of the phasor's cos component for the side and norm above. Libby measured a + // sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. The + // shader passes roughness n = 0 as a literal, so the doc envelope max(10, 1.25|side|)/(1-n) + // needs no 1/(1-n) factor here; if n ever becomes nonzero, divide this bound by (1 - n). + // |value| <= 1 holds by construction (the output is a normalized phasor). + phacelleGradBound: 7.0, // World tree at the origin. tree: { trunkHeight: 38, From 7a498584e6cffb2863c3f5d7c5cf8e7e4b041c51 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 1 Nov 2026 08:08:45 +0000 Subject: [PATCH 48/64] Background scene: sky costs no noise; one sun term Sky rays were ~90% of pass 1 (noSkyMarch variant: 292 -> 26 ms, SwiftShader 960x540 L0). terrainDistance now returns early where the plateau mask is 0 (height exactly 0), skipping fbm and Phacelle there: pass 1 292 -> 159 ms, same step values. skyColor keeps one sun term (the wide glow is gone; screenshots differ by that and by TAA/time, not compared pixelwise). Harness: noSkyMarch variant, ONLY= selects variants. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/drive.cjs | 4 +++- src/app/features/scene/shaders/common.glsl | 2 +- src/app/features/scene/shaders/terrain.frag.glsl | 4 ++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs index 3b41c567..74c4a8b0 100644 --- a/harness/scene/drive.cjs +++ b/harness/scene/drive.cjs @@ -90,9 +90,11 @@ const [, , dir, mode, outdir = '.'] = process.argv; 'float terrainFbm(vec2 xz) { float a = 1.0, fr = 1.0, v = 0.0; for (int i = 0; i < W_OCTAVES; i++) { v += a * cultmath_snoise(vec3(xz * W_FREQ * fr, 0.0)); fr *= W_LACUNARITY; a *= W_GAIN; } return v; }', ], noErosion: ['#define W_ERODE_ENABLED 1', '#define W_ERODE_ENABLED 0'], + // Upper bound on what skipping sky rays would save: every upward ray misses at once. + noSkyMarch: [' bool hit = false;\n bool onTree = false;', ' if (rd.y > 0.0) { o_color = vec4(0.0); o_depth = W_FAR; return; }\n bool hit = false;\n bool onTree = false;'], noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'], }; - const wanted = mode === 'variants' ? Object.keys(VARIANTS) : ['baseline']; + const wanted = mode === "variants" ? (process.env.ONLY ? process.env.ONLY.split(",") : Object.keys(VARIANTS)) : ['baseline']; const sizes = [ [960, 540], [540, 1200], diff --git a/src/app/features/scene/shaders/common.glsl b/src/app/features/scene/shaders/common.glsl index ac3bb784..6378a2e6 100644 --- a/src/app/features/scene/shaders/common.glsl +++ b/src/app/features/scene/shaders/common.glsl @@ -26,6 +26,6 @@ vec3 skyColor(vec3 rd) float h = clamp(rd.y, 0.0, 1.0); vec3 c = mix(u_skyHorizon, u_skyTop, pow(h, 0.45)); float s = max(dot(rd, u_sunDir), 0.0); - c += u_sunColor * (0.25 * pow(s, 8.0) + 1.5 * pow(s, 256.0)); + c += u_sunColor * (1.5 * pow(s, 256.0)); return c; } diff --git a/src/app/features/scene/shaders/terrain.frag.glsl b/src/app/features/scene/shaders/terrain.frag.glsl index 770325b0..b8f2f718 100644 --- a/src/app/features/scene/shaders/terrain.frag.glsl +++ b/src/app/features/scene/shaders/terrain.frag.glsl @@ -30,6 +30,10 @@ CultPhasor terrainPhacelle(vec2 xz) float terrainDistance(vec3 p) { float m = terrainMask(p.xz); + // Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most + // of their steps here. + if (m <= 0.0) + return p.y * W_SLOPE_ENV_INV; float hb = m * W_AMP * terrainFbm(p.xz); float above = p.y - (hb + m * W_ERODE_AMP); if (above > 0.0) From 093230a2c118f635c34617ffc6673e176466e988 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 06:20:53 +0000 Subject: [PATCH 49/64] Background scene: lint and type clean, integration contract Fixes the 21 eslint errors and 2 tsc errors Void's review found in features/scene (WORLD.phacelleGradBound is typed number, not the literal 7). Adds the contract the host needs: client config backgroundScene.tier as flag and ceiling (off by default, lowered by saveData / memory / cores), lazy startBackgroundScene, still and low tiers, onCompileFail (createScene frees linked programs and returns null), webglcontextlost handling, and creatureScale where neutral overrides sizeBucket. Scene.ts gets its first tests, on a fake GL. Co-Authored-By: Claude Sonnet 5.5 --- config.json | 4 + src/app/features/scene/README.md | 22 ++- src/app/features/scene/Scene.test.ts | 218 +++++++++++++++++++++++ src/app/features/scene/Scene.ts | 121 +++++++++---- src/app/features/scene/budget.test.ts | 27 +-- src/app/features/scene/budget.ts | 16 +- src/app/features/scene/creatures.test.ts | 23 +++ src/app/features/scene/creatures.ts | 26 +++ src/app/features/scene/index.ts | 23 +++ src/app/features/scene/tier.test.ts | 26 +++ src/app/features/scene/tier.ts | 49 +++++ src/app/features/scene/world.test.ts | 6 +- src/app/features/scene/world.ts | 3 +- src/app/hooks/useClientConfig.ts | 3 + 14 files changed, 513 insertions(+), 54 deletions(-) create mode 100644 src/app/features/scene/Scene.test.ts create mode 100644 src/app/features/scene/creatures.test.ts create mode 100644 src/app/features/scene/creatures.ts create mode 100644 src/app/features/scene/index.ts create mode 100644 src/app/features/scene/tier.test.ts create mode 100644 src/app/features/scene/tier.ts diff --git a/config.json b/config.json index e945d1c4..d1c461a4 100644 --- a/config.json +++ b/config.json @@ -17,5 +17,9 @@ "hashRouter": { "enabled": false, "basename": "/" + }, + + "backgroundScene": { + "tier": "off" } } diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index d265c332..0d49d57d 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -3,15 +3,29 @@ A two-pass WebGL2 scene for the client's background canvas: an infinite terrain of CultMath noise with a world tree at its centre, dusk atmosphere and clouds, and a camera that pulls back from the tree on login. It is a self-contained module; nothing in the client imports it yet. -Integration (a canvas under the app, `createScene`, `login()` when the session starts, `dispose()`) -is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel are later cuts. +Integration (a canvas under the app, `startBackgroundScene`, `login()` when the session starts, +`dispose()`) is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel +are later cuts. This PR is the render core plus the contract the host needs: flag, tier, failure +callbacks and the creature sizing rule. Nothing has drawn a creature. ## Entry points -- `Scene.ts` `createScene({ canvas, ... })` returns `{ login, dispose }`, or `null` when WebGL2 or - `EXT_color_buffer_float` is missing (keep the CSS ground then). It caps at 30 fps, pauses when +- `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's + door. The client config's `backgroundScene.tier` (`off` default, `still`, `low`, `full`) is the + feature flag and the ceiling; `tier.ts` lowers it for `saveData`, low memory or few cores. At + `off` nothing is imported and no canvas context is requested; otherwise `Scene.ts` loads on + demand, so it stays out of the main chunk. +- `Scene.ts` `createScene({ canvas, tier, onCompileFail, onContextLost, ... })` returns + `{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 or + `EXT_color_buffer_float` is missing, or a shader fails to compile (`onCompileFail` gets the + `ShaderCompileError`; programs already linked are freed). `still` draws one frame at the top + level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context + stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to + show. It caps at 30 fps, pauses when the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion`, and steps down a quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist. +- `creatures.ts` the creature record and `creatureScale`: a neutral creature is one fixed size and + overrides `sizeBucket`. The bucket names and scales are placeholders until the persona cut owns them. - `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`). - `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL `#define`s are generated from it; no number is written twice. diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts new file mode 100644 index 00000000..97988687 --- /dev/null +++ b/src/app/features/scene/Scene.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { createScene, ShaderCompileError } from './Scene'; +import { LEVELS } from './budget'; +import { startBackgroundScene } from './index'; + +type Fn = Mock; + +/** A GL context where every call is a spy and every constant is 1. Compiles succeed until `failFrom`. */ +function fakeGl(failFrom = Infinity) { + let compiles = 0; + const calls: Record = {}; + const spy = (name: string, impl: () => unknown = () => ({})): Fn => { + if (!calls[name]) calls[name] = vi.fn(impl); + return calls[name]; + }; + const gl = new Proxy({} as Record, { + get(_, prop: string) { + if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 1; + if (prop === 'compileShader') { + return spy(prop, () => { + compiles += 1; + }); + } + if (prop === 'getShaderParameter') return spy(prop, () => compiles < failFrom); + if (prop === 'getShaderInfoLog') return spy(prop, () => 'ERROR: 0:1: bad'); + if (prop === 'getProgramParameter') return spy(prop, () => true); + return spy(prop); + }, + }); + return { gl: gl as unknown as WebGL2RenderingContext, calls }; +} + +function fakeCanvas(gl: WebGL2RenderingContext | null) { + const canvas = new EventTarget() as EventTarget & { + clientWidth: number; + clientHeight: number; + width: number; + height: number; + getContext: Fn; + }; + canvas.clientWidth = 400; + canvas.clientHeight = 300; + canvas.width = 0; + canvas.height = 0; + canvas.getContext = vi.fn(() => gl); + return canvas; +} + +let rafQueue: Array<(t: number) => void>; +let cancelled: number; +let win: EventTarget; +let doc: EventTarget & { visibilityState: string }; + +beforeEach(() => { + rafQueue = []; + cancelled = 0; + win = Object.assign(new EventTarget(), { + devicePixelRatio: 1, + matchMedia: () => ({ matches: false }), + }); + doc = Object.assign(new EventTarget(), { visibilityState: 'visible' }); + vi.stubGlobal('window', win); + vi.stubGlobal('document', doc); + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb)); + vi.stubGlobal('cancelAnimationFrame', () => { + cancelled += 1; + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const runFrame = (now: number) => { + const cb = rafQueue.shift(); + if (!cb) throw new Error('no frame was scheduled'); + cb(now); +}; +const asCanvas = (c: ReturnType) => c as unknown as HTMLCanvasElement; + +describe('createScene tiers', () => { + it("'off' returns null without asking the canvas for a context", () => { + const { gl } = fakeGl(); + const canvas = fakeCanvas(gl); + expect(createScene({ canvas: asCanvas(canvas), tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + expect(rafQueue).toHaveLength(0); + }); + + it("'still' draws one frame and schedules no more", () => { + const { gl, calls } = fakeGl(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); + expect(scene).not.toBeNull(); + runFrame(1000); + expect(calls.drawArrays).toHaveBeenCalledTimes(4); // four passes, one frame + expect(rafQueue).toHaveLength(0); + }); + + it("'still' ignores login(): the pull-back is for a moving scene", () => { + const { gl } = fakeGl(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); + runFrame(1000); + scene?.login(); + expect(rafQueue).toHaveLength(0); + }); + + it("'full' keeps scheduling frames", () => { + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'full' }); + runFrame(1000); + expect(rafQueue).toHaveLength(1); + }); + + it("'low' traces with a cheaper level than 'full'", () => { + const traceSteps = (tier: 'low' | 'full') => { + rafQueue.length = 0; // each call is a fresh scene + const { gl, calls } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), tier }); + runFrame(1000); + // u_maxSteps is the only uniform1i whose value is a trace-step count + return calls.uniform1i.mock.calls.map((c) => c[1]).filter((v) => v >= 40); + }; + expect(traceSteps('full')).toContain(LEVELS[0].traceSteps); + expect(traceSteps('low')).toContain(LEVELS[2].traceSteps); + expect(traceSteps('low')).not.toContain(LEVELS[0].traceSteps); + }); + + it('returns null when WebGL2 is missing, so the host keeps its ground', () => { + expect(createScene({ canvas: asCanvas(fakeCanvas(null)) })).toBeNull(); + }); +}); + +describe('shader compile failure', () => { + it('reports through onCompileFail, returns null, and frees the programs already linked', () => { + const { gl, calls } = fakeGl(4); // terrain compiles both shaders; atmosphere's fragment is the 4th and fails + const onCompileFail = vi.fn(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onCompileFail }); + expect(scene).toBeNull(); + expect(onCompileFail).toHaveBeenCalledTimes(1); + const err = onCompileFail.mock.calls[0][0] as ShaderCompileError; + expect(err).toBeInstanceOf(ShaderCompileError); + expect(err.stage).toBe('atmosphere'); + expect(err.log).toContain('bad'); + expect(calls.deleteProgram).toHaveBeenCalled(); // the terrain program that did link + expect(rafQueue).toHaveLength(0); + }); + + it('does not throw out of createScene when no callback is given', () => { + const { gl } = fakeGl(0); + expect(() => createScene({ canvas: asCanvas(fakeCanvas(gl)) })).not.toThrow(); + }); +}); + +describe('context loss', () => { + it('stops the loop, tells the host, and detaches every listener', () => { + const { gl } = fakeGl(); + const canvas = fakeCanvas(gl); + const onContextLost = vi.fn(); + createScene({ canvas: asCanvas(canvas), onContextLost }); + const lost = new Event('webglcontextlost', { cancelable: true }); + canvas.dispatchEvent(lost); + expect(lost.defaultPrevented).toBe(true); + expect(onContextLost).toHaveBeenCalledTimes(1); + expect(cancelled).toBe(1); + // listeners are gone: a second loss, a resize and a visibility change do nothing + canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + win.dispatchEvent(new Event('resize')); + doc.dispatchEvent(new Event('visibilitychange')); + expect(onContextLost).toHaveBeenCalledTimes(1); + expect(rafQueue.length).toBe(1); // only the frame queued before the loss, never run + }); + + it('does not render a queued frame after the loss', () => { + const { gl, calls } = fakeGl(); + const canvas = fakeCanvas(gl); + createScene({ canvas: asCanvas(canvas) }); + canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + runFrame(1000); + expect(calls.drawArrays).toBeUndefined(); + }); +}); + +describe('startBackgroundScene', () => { + it("does not request a context when the config leaves the tier 'off'", async () => { + const { gl } = fakeGl(); + const canvas = fakeCanvas(gl); + expect(await startBackgroundScene(undefined, {}, { canvas: asCanvas(canvas) })).toBeNull(); + expect( + await startBackgroundScene({ tier: 'off' }, {}, { canvas: asCanvas(canvas) }) + ).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('applies the device ceiling: saveData runs a still, one frame', async () => { + const { gl, calls } = fakeGl(); + const scene = await startBackgroundScene( + { tier: 'full' }, + { saveData: true }, + { canvas: asCanvas(fakeCanvas(gl)) } + ); + expect(scene).not.toBeNull(); + runFrame(1000); + expect(calls.drawArrays).toHaveBeenCalledTimes(4); + expect(rafQueue).toHaveLength(0); + }); + + it('hands compile failure to the host and returns null, the fall to the CSS ground', async () => { + const { gl } = fakeGl(0); + const onCompileFail = vi.fn(); + const scene = await startBackgroundScene( + { tier: 'full' }, + {}, + { canvas: asCanvas(fakeCanvas(gl)), onCompileFail } + ); + expect(scene).toBeNull(); + expect(onCompileFail).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 367e6cc9..10dacc58 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -1,6 +1,7 @@ import { fragmentSource, vertexSource, Stage } from './shaderSource'; import { cameraPose, pullProgress, CameraPose } from './camera'; -import { BudgetLadder, Level } from './budget'; +import { BudgetLadder, Level, LEVELS } from './budget'; +import type { SceneTier } from './tier'; import { PALETTE } from './palette'; /** Phone GPU budget for the whole scene, from the ground-cut doc's budget table. */ @@ -20,8 +21,31 @@ export interface SceneStats { atmosphereSize: [number, number]; } +/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */ +export type SourcePatch = (stage: Stage, source: string) => string; + +export class ShaderCompileError extends Error { + constructor(readonly stage: Stage | 'vertex', readonly log: string) { + super(`${stage} shader failed to compile:\n${log}`); + } +} + +/** Ladder level a 'low' scene starts at: the cost of a weak device's first 45 frames is never paid at full. */ +const LOW_START_LEVEL = 2; +/** The 'still' tier draws one frame at this level and stops. */ +const STILL_LEVEL = LEVELS[0]; + export interface SceneOptions { canvas: HTMLCanvasElement; + /** + * 'off' returns null without touching the canvas. 'still' draws one frame and stops. 'low' starts + * the adaptive ladder low. 'full' starts it at the top. Default 'full'. + */ + tier?: SceneTier; + /** A shader failed to compile or link. createScene frees what it built and returns null. */ + onCompileFail?: (e: ShaderCompileError) => void; + /** The WebGL context was lost. The scene stops for good; the host keeps its ground. */ + onContextLost?: () => void; /** Called per finished frame when timer queries exist. */ onStats?: (s: SceneStats) => void; /** Weather knob, 0 clear .. 1 overcast. */ @@ -39,15 +63,6 @@ export interface Scene { dispose(): void; } -/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */ -export type SourcePatch = (stage: Stage, source: string) => string; - -export class ShaderCompileError extends Error { - constructor(readonly stage: Stage | 'vertex', readonly log: string) { - super(`${stage} shader failed to compile:\n${log}`); - } -} - function compile( gl: WebGL2RenderingContext, type: number, @@ -112,6 +127,8 @@ function halton(i: number, base: number): number { /** Null when WebGL2 or float colour buffers are missing; the host keeps its CSS ground. */ export function createScene(opts: SceneOptions): Scene | null { const { canvas } = opts; + const tier = opts.tier ?? 'full'; + if (tier === 'off') return null; const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, @@ -127,7 +144,18 @@ export function createScene(opts: SceneOptions): Scene | null { } | null; const programs = {} as Record; - for (const p of PASSES) programs[p] = linkStage(gl, p, opts.patchSource); + try { + PASSES.forEach((p) => { + programs[p] = linkStage(gl, p, opts.patchSource); + }); + } catch (e) { + PASSES.forEach((p) => { + if (programs[p]) gl.deleteProgram(programs[p]); + }); + if (!(e instanceof ShaderCompileError)) throw e; + opts.onCompileFail?.(e); + return null; + } const uniforms = new Map>(); const loc = (p: WebGLProgram, name: string) => { let m = uniforms.get(p); @@ -160,7 +188,9 @@ export function createScene(opts: SceneOptions): Scene | null { let atm: Target; let hist: [Target, Target]; const freeTargets = () => { - for (const t of [solid, depth, atm, hist?.[0], hist?.[1]]) if (t) gl.deleteTexture(t.tex); + [solid, depth, atm, hist?.[0], hist?.[1]].forEach((t) => { + if (t) gl.deleteTexture(t.tex); + }); }; const attach = (fbo: WebGLFramebuffer, targets: Target[]) => { @@ -171,8 +201,9 @@ export function createScene(opts: SceneOptions): Scene | null { gl.drawBuffers(targets.map((_, i) => gl.COLOR_ATTACHMENT0 + i)); }; - const ladder = new BudgetLadder(GPU_BUDGET_MS); - const level = (): Level => opts.fixedLevel ?? ladder.current; + const still = tier === 'still'; + const ladder = new BudgetLadder(GPU_BUDGET_MS, 45, tier === 'low' ? LOW_START_LEVEL : 0); + const level = (): Level => opts.fixedLevel ?? (still ? STILL_LEVEL : ladder.current); let canvasW = 0; let canvasH = 0; @@ -214,7 +245,7 @@ export function createScene(opts: SceneOptions): Scene | null { let prev: CameraPose | undefined; let raf = 0; let lastRendered = -Infinity; - let framesLeft = Infinity; // reduced motion bounds this + let framesLeft = still ? 1 : Infinity; // a still frame, or reduced motion, bounds this let disposed = false; // Timer queries in flight: each holds one pass's query. @@ -222,7 +253,10 @@ export function createScene(opts: SceneOptions): Scene | null { const finished = new Map>>(); const timed = (pass: PassName, f: number, body: () => void) => { - if (!timer || !opts.onStats) return body(); + if (!timer || !opts.onStats) { + body(); + return; + } const q = gl.createQuery()!; gl.beginQuery(timer.TIME_ELAPSED_EXT, q); body(); @@ -282,9 +316,11 @@ export function createScene(opts: SceneOptions): Scene | null { const lv = level(); const frozen = reducedMotion?.matches ?? false; const time = frozen ? 0 : (now - start) / 1000; - const elapsed = loginAt === undefined ? undefined : frozen ? 1e9 : (now - loginAt) / 1000; + let elapsed: number | undefined; + if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000; const pose = cameraPose(pullProgress(elapsed), time); - const f = frame++; + const f = frame; + frame += 1; const jx = halton((f % 16) + 1, 2) - 0.5; const jy = halton((f % 16) + 1, 3) - 0.5; @@ -320,7 +356,7 @@ export function createScene(opts: SceneOptions): Scene | null { }); // TAA resolve into the history that is not being read. - const write = histIndex ^ 1; + const write = histIndex === 0 ? 1 : 0; timed('resolve', f, () => { gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.hist[write]); gl.viewport(0, 0, atm.w, atm.h); @@ -369,8 +405,10 @@ export function createScene(opts: SceneOptions): Scene | null { !timer && lastRendered > 0 && now - lastRendered > FRAME_INTERVAL_MS * 1.5 - ) - ladder.push(GPU_BUDGET_MS * 2) && (sizedFor = undefined); // no timer: a missed cap counts as over budget + ) { + // no timer: a missed cap counts as over budget + if (ladder.push(GPU_BUDGET_MS * 2)) sizedFor = undefined; + } lastRendered = now; render(now); framesLeft -= 1; @@ -385,30 +423,47 @@ export function createScene(opts: SceneOptions): Scene | null { resetHistory = true; // the first frame after a pause has a real elapsed dt raf = requestAnimationFrame(tick); }; - const onVisibility = () => { - if (document.visibilityState === 'visible') wake(); + const undo: Array<() => void> = []; + const listen = (target: EventTarget, type: string, handler: (e: Event) => void) => { + target.addEventListener(type, handler); + undo.push(() => target.removeEventListener(type, handler)); }; - const onResize = () => wake(); - if (!opts.unthrottled) document.addEventListener('visibilitychange', onVisibility); - window.addEventListener('resize', onResize); - if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; + const stop = () => { + disposed = true; + if (raf) cancelAnimationFrame(raf); + undo.forEach((f) => f()); + }; + if (!opts.unthrottled) { + listen(document, 'visibilitychange', () => { + if (document.visibilityState === 'visible') wake(); + }); + } + listen(window, 'resize', () => wake()); + // A lost context takes every texture and program with it and nothing here rebuilds them, so the + // scene ends and the host decides what to show. preventDefault: otherwise the browser never + // restores the context, which is what we want, and a restore would find a dead scene. + listen(canvas, 'webglcontextlost', (e) => { + e.preventDefault(); + stop(); + opts.onContextLost?.(); + }); + if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES; wake(); return { login() { + if (still || disposed) return; // a still has no pull-back loginAt = performance.now(); if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; wake(); }, dispose() { - disposed = true; - if (raf) cancelAnimationFrame(raf); - document.removeEventListener('visibilitychange', onVisibility); - window.removeEventListener('resize', onResize); + if (disposed) return; + stop(); freeTargets(); - for (const p of PASSES) gl.deleteProgram(programs[p]); + PASSES.forEach((p) => gl.deleteProgram(programs[p])); gl.deleteVertexArray(vao); - for (const f of [fbos.solid, fbos.atm, ...fbos.hist]) gl.deleteFramebuffer(f); + [fbos.solid, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb)); }, }; } diff --git a/src/app/features/scene/budget.test.ts b/src/app/features/scene/budget.test.ts index 27dd0487..9ac8f0af 100644 --- a/src/app/features/scene/budget.test.ts +++ b/src/app/features/scene/budget.test.ts @@ -4,31 +4,38 @@ import { BudgetLadder, LEVELS } from './budget'; describe('budget ladder', () => { it('steps down after a whole window over budget and never back up', () => { const ladder = new BudgetLadder(4, 5); - for (let i = 0; i < 4; i++) expect(ladder.push(9)).toBe(false); + Array.from({ length: 4 }).forEach(() => expect(ladder.push(9)).toBe(false)); expect(ladder.push(9)).toBe(true); expect(ladder.index).toBe(1); - for (let i = 0; i < 100; i++) ladder.push(0.1); // fast frames never raise the level + Array.from({ length: 100 }).forEach(() => ladder.push(0.1)); // fast frames never raise the level expect(ladder.index).toBe(1); }); it('judges the median, so one slow frame does not step down', () => { const ladder = new BudgetLadder(4, 5); - for (const ms of [1, 1, 50, 1, 1]) ladder.push(ms); + [1, 1, 50, 1, 1].forEach((ms) => ladder.push(ms)); expect(ladder.index).toBe(0); }); it('stops at the last level', () => { const ladder = new BudgetLadder(4, 1); - for (let i = 0; i < 20; i++) ladder.push(99); + Array.from({ length: 20 }).forEach(() => ladder.push(99)); expect(ladder.index).toBe(LEVELS.length - 1); }); it('every level is cheaper than the one before in each cost axis', () => { - for (let i = 1; i < LEVELS.length; i++) { - expect(LEVELS[i].solidScale).toBeLessThan(LEVELS[i - 1].solidScale); - expect(LEVELS[i].atmosphereScale).toBeLessThan(LEVELS[i - 1].atmosphereScale); - expect(LEVELS[i].cloudSteps).toBeLessThan(LEVELS[i - 1].cloudSteps); - expect(LEVELS[i].traceSteps).toBeLessThan(LEVELS[i - 1].traceSteps); - } + LEVELS.slice(1).forEach((lv, i) => { + const before = LEVELS[i]; + expect(lv.solidScale).toBeLessThan(before.solidScale); + expect(lv.atmosphereScale).toBeLessThan(before.atmosphereScale); + expect(lv.cloudSteps).toBeLessThan(before.cloudSteps); + expect(lv.traceSteps).toBeLessThan(before.traceSteps); + }); + }); + + it('starts at the level it is given, clamped into the ladder', () => { + expect(new BudgetLadder(4, 5, 2).index).toBe(2); + expect(new BudgetLadder(4, 5, 99).index).toBe(LEVELS.length - 1); + expect(new BudgetLadder(4, 5, -3).index).toBe(0); }); }); diff --git a/src/app/features/scene/budget.ts b/src/app/features/scene/budget.ts index 9550571c..3b373d86 100644 --- a/src/app/features/scene/budget.ts +++ b/src/app/features/scene/budget.ts @@ -20,10 +20,20 @@ export const LEVELS: readonly Level[] = [ /** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */ export class BudgetLadder { - private level = 0; + private level: number; + private recent: number[] = []; - constructor(private readonly budgetMs: number, private readonly window = 45) {} + private readonly budgetMs: number; + + private readonly window: number; + + /** `startLevel` lets a weak device begin below level 0; it is clamped into the ladder. */ + constructor(budgetMs: number, window = 45, startLevel = 0) { + this.budgetMs = budgetMs; + this.window = window; + this.level = Math.min(Math.max(startLevel, 0), LEVELS.length - 1); + } get current(): Level { return LEVELS[this.level]; @@ -38,7 +48,7 @@ export class BudgetLadder { this.recent.push(ms); if (this.recent.length < this.window) return false; const sorted = [...this.recent].sort((a, b) => a - b); - const median = sorted[sorted.length >> 1]; + const median = sorted[Math.floor(sorted.length / 2)]; this.recent = []; if (median > this.budgetMs && this.level < LEVELS.length - 1) { this.level += 1; diff --git a/src/app/features/scene/creatures.test.ts b/src/app/features/scene/creatures.test.ts new file mode 100644 index 00000000..8e59175b --- /dev/null +++ b/src/app/features/scene/creatures.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { BUCKET_SCALE, creatureScale, NEUTRAL_SCALE, SizeBucket } from './creatures'; + +const buckets: SizeBucket[] = ['small', 'medium', 'large']; + +describe('creature scale', () => { + it('follows sizeBucket for a creature with an estate', () => { + buckets.forEach((sizeBucket) => + expect(creatureScale({ sizeBucket, neutral: false })).toBe(BUCKET_SCALE[sizeBucket]) + ); + }); + + it('is one fixed size for every neutral creature, whatever its sizeBucket', () => { + buckets.forEach((sizeBucket) => + expect(creatureScale({ sizeBucket, neutral: true })).toBe(NEUTRAL_SCALE) + ); + }); + + it('is only visible as an override where the bucket would have said something else', () => { + expect(BUCKET_SCALE.small).not.toBe(NEUTRAL_SCALE); + expect(BUCKET_SCALE.large).not.toBe(NEUTRAL_SCALE); + }); +}); diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts new file mode 100644 index 00000000..7bb5d440 --- /dev/null +++ b/src/app/features/scene/creatures.ts @@ -0,0 +1,26 @@ +// Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut. + +export type SizeBucket = 'small' | 'medium' | 'large'; + +export interface Creature { + id: string; + estate: string; + sizeBucket: SizeBucket; + /** A neutral creature (no estate allegiance) is drawn at one fixed size whatever its bucket. */ + neutral: boolean; + activity: 'idle' | 'active'; +} + +export const BUCKET_SCALE: Readonly> = { + small: 0.7, + medium: 1, + large: 1.4, +}; + +/** Neutral creatures are medium sized, not necessarily their bucket's size. */ +export const NEUTRAL_SCALE = BUCKET_SCALE.medium; + +/** Neutral overrides sizeBucket. */ +export function creatureScale(c: Pick): number { + return c.neutral ? NEUTRAL_SCALE : BUCKET_SCALE[c.sizeBucket]; +} diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts new file mode 100644 index 00000000..f4109796 --- /dev/null +++ b/src/app/features/scene/index.ts @@ -0,0 +1,23 @@ +import type { Scene, SceneOptions } from './Scene'; +import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier'; + +export type { Scene, SceneStats } from './Scene'; +export type { SceneTier } from './tier'; + +export type StartOptions = Omit; + +/** + * The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no + * canvas context is requested; otherwise the scene code loads on demand, so it stays out of the + * main chunk. Null means "keep the CSS ground". + */ +export async function startBackgroundScene( + config: BackgroundSceneConfig | undefined, + capabilities: Capabilities, + options: StartOptions +): Promise { + const tier = resolveTier(config, capabilities); + if (tier === 'off') return null; + const { createScene } = await import('./Scene'); + return createScene({ ...options, tier }); +} diff --git a/src/app/features/scene/tier.test.ts b/src/app/features/scene/tier.test.ts new file mode 100644 index 00000000..d35b6bb6 --- /dev/null +++ b/src/app/features/scene/tier.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { resolveTier } from './tier'; + +describe('scene tier', () => { + it('is off unless the config turns it on, which is the feature flag', () => { + expect(resolveTier(undefined)).toBe('off'); + expect(resolveTier({})).toBe('off'); + expect(resolveTier({ tier: 'off' }, { deviceMemory: 16 })).toBe('off'); + expect(resolveTier({ tier: 'full' })).toBe('full'); + }); + + it('lets the device lower the configured tier and never raise it', () => { + expect(resolveTier({ tier: 'full' }, { saveData: true })).toBe('still'); + expect(resolveTier({ tier: 'full' }, { deviceMemory: 2 })).toBe('low'); + expect(resolveTier({ tier: 'full' }, { hardwareConcurrency: 4 })).toBe('low'); + expect(resolveTier({ tier: 'low' }, { saveData: true })).toBe('still'); + expect(resolveTier({ tier: 'still' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe( + 'still' + ); + expect(resolveTier({ tier: 'low' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe('low'); + }); + + it('treats unknown capabilities as capable', () => { + expect(resolveTier({ tier: 'full' }, {})).toBe('full'); + }); +}); diff --git a/src/app/features/scene/tier.ts b/src/app/features/scene/tier.ts new file mode 100644 index 00000000..1c1668c7 --- /dev/null +++ b/src/app/features/scene/tier.ts @@ -0,0 +1,49 @@ +// Which quality the background scene runs at. The client config's `backgroundScene.tier` is both +// the feature flag (absent or 'off' means the scene is not even imported) and the ceiling; the +// device can only lower it. + +export type SceneTier = 'off' | 'still' | 'low' | 'full'; + +/** Cheapest first. */ +export const TIERS: readonly SceneTier[] = ['off', 'still', 'low', 'full']; + +export interface Capabilities { + /** `navigator.connection.saveData`. */ + saveData?: boolean; + /** `navigator.deviceMemory` in GiB (Chromium only). */ + deviceMemory?: number; + hardwareConcurrency?: number; +} + +export interface BackgroundSceneConfig { + tier?: SceneTier; +} + +export function readCapabilities(nav: Navigator): Capabilities { + const n = nav as Navigator & { + deviceMemory?: number; + connection?: { saveData?: boolean }; + }; + return { + saveData: n.connection?.saveData, + deviceMemory: n.deviceMemory, + hardwareConcurrency: n.hardwareConcurrency, + }; +} + +/** The most the device is allowed to run, before the configured ceiling is applied. */ +function deviceCeiling(c: Capabilities): SceneTier { + if (c.saveData) return 'still'; + if ((c.deviceMemory ?? Infinity) <= 2 || (c.hardwareConcurrency ?? Infinity) <= 4) return 'low'; + return 'full'; +} + +/** The tier to run: the configured one (default 'off'), lowered by what the device can carry. */ +export function resolveTier( + config: BackgroundSceneConfig | undefined, + capabilities: Capabilities = {} +): SceneTier { + const configured = config?.tier ?? 'off'; + const ceiling = deviceCeiling(capabilities); + return TIERS.indexOf(configured) <= TIERS.indexOf(ceiling) ? configured : ceiling; +} diff --git a/src/app/features/scene/world.test.ts b/src/app/features/scene/world.test.ts index 87fa1983..caa04fad 100644 --- a/src/app/features/scene/world.test.ts +++ b/src/app/features/scene/world.test.ts @@ -9,9 +9,9 @@ import { terrainBounds, WORLD, worldDefines } from './world'; describe('world defines', () => { it('define every W_ name any scene shader uses', () => { const defined = new Set([...worldDefines().matchAll(/#define (W_\w+)/g)].map((m) => m[1])); - for (const src of [common, terrain, atmosphere, resolve, composite]) { - for (const m of src.matchAll(/\bW_[A-Z0-9_]+\b/g)) expect(defined, m[0]).toContain(m[0]); - } + [common, terrain, atmosphere, resolve, composite] + .flatMap((src) => [...src.matchAll(/\bW_[A-Z0-9_]+\b/g)]) + .forEach((m) => expect(defined, m[0]).toContain(m[0])); }); it('emit only valid GLSL numbers', () => { diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts index a1c6cb57..ba9aec53 100644 --- a/src/app/features/scene/world.ts +++ b/src/app/features/scene/world.ts @@ -28,7 +28,8 @@ export const WORLD = { // shader passes roughness n = 0 as a literal, so the doc envelope max(10, 1.25|side|)/(1-n) // needs no 1/(1-n) factor here; if n ever becomes nonzero, divide this bound by (1 - n). // |value| <= 1 holds by construction (the output is a normalized phasor). - phacelleGradBound: 7.0, + // Typed number, not the literal 7, so callers can build variants such as { ...WORLD, phacelleGradBound: 0 }. + phacelleGradBound: 7.0 as number, // World tree at the origin. tree: { trunkHeight: 38, diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index e5fc6cc6..7a0586eb 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -1,4 +1,5 @@ import { createContext, useContext } from 'react'; +import type { BackgroundSceneConfig } from '../features/scene/tier'; export type HashRouterConfig = { enabled?: boolean; @@ -18,6 +19,8 @@ export type ClientConfig = { }; hashRouter?: HashRouterConfig; + + backgroundScene?: BackgroundSceneConfig; }; const ClientConfigContext = createContext(null); From 8c7113bcafab90fed550e41c5666da4b7823ab79 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 07:22:58 +0000 Subject: [PATCH 50/64] creatures: cite the Bifrost creature-props contract Header names heimdall-body/ratatoskr-creature-projection, the shape, invariants and consent rule, and marks the bucket scales as placeholders. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/creatures.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index 7bb5d440..2daa541e 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -1,4 +1,15 @@ // Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut. +// +// Props contract: heimdall-body/ratatoskr-creature-projection (against GameCult/Bifrost). +// Nothing here is binding until Bifrost's steward cuts that contract. +// +// Shape: {id, estate, sizeBucket, neutral, activity}. +// Invariants: +// - Exact vote weights stay in Bifrost; Ratatoskr only ever sees a bucket. +// - neutral=true overrides any bucket, and no size is drawn. +// - activity comes from the Heimdall session. +// Consent: humans get a coarse bucket by default plus a typed opt-out; Minds and Personas may show finer buckets. +// Humans have three coarse buckets. The scales below (0.7 / 1 / 1.4) are placeholders, not fixed. export type SizeBucket = 'small' | 'medium' | 'large'; From 0ec34ff19fe9c0de3d45e8532ef25931de09d439 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 10:14:52 +0000 Subject: [PATCH 51/64] creatures: cite creature-projection-v2 Header cites heimdall-body:follow_up:ratatoskr-creature-projection-v2, which supersedes heimdall-body/ratatoskr-creature-projection. Citation only. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/creatures.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index 2daa541e..5026d2e2 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -1,6 +1,6 @@ // Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut. // -// Props contract: heimdall-body/ratatoskr-creature-projection (against GameCult/Bifrost). +// Props contract: heimdall-body:follow_up:ratatoskr-creature-projection-v2 (against GameCult/Bifrost; supersedes heimdall-body/ratatoskr-creature-projection). // Nothing here is binding until Bifrost's steward cuts that contract. // // Shape: {id, estate, sizeBucket, neutral, activity}. From c4c00d7127d9c29582f591431278ddc47b69360c Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 2 Nov 2026 14:39:00 +0000 Subject: [PATCH 52/64] soundscape: audio socket over Aqua's activity frame (flag off, muted, untested by ear) Reads the grammar frame (AquaSynth docs/soundscape-grammar.md @ aeae510) into a per-mind table, plans voices (neutral overrides all, Muted, cap, master mute, weak-device off) and the drawn set. No activity source exists yet (ratatoskr:question:activity-feed-source) and no audio assets, so nothing sounds. Cites heimdall-body:follow_up:ratatoskr-creature-projection-v2. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/soundscape/device.ts | 12 ++ src/app/features/soundscape/frame.ts | 95 ++++++++++ src/app/features/soundscape/scene.ts | 8 + src/app/features/soundscape/socket.ts | 47 +++++ .../features/soundscape/soundscape.test.ts | 171 ++++++++++++++++++ src/app/features/soundscape/voices.ts | 49 +++++ src/app/state/settings.ts | 11 ++ 7 files changed, 393 insertions(+) create mode 100644 src/app/features/soundscape/device.ts create mode 100644 src/app/features/soundscape/frame.ts create mode 100644 src/app/features/soundscape/scene.ts create mode 100644 src/app/features/soundscape/socket.ts create mode 100644 src/app/features/soundscape/soundscape.test.ts create mode 100644 src/app/features/soundscape/voices.ts diff --git a/src/app/features/soundscape/device.ts b/src/app/features/soundscape/device.ts new file mode 100644 index 00000000..ecdf1593 --- /dev/null +++ b/src/app/features/soundscape/device.ts @@ -0,0 +1,12 @@ +export interface DeviceHints { + deviceMemory?: number; + hardwareConcurrency?: number; +} + +/** + * Off on weak phones. The thresholds are placeholders: no phone has been measured (grammar + * section 7, item 4). Unknown hints (Safari reports neither) do not count as weak. + */ +export const isWeakDevice = ({ deviceMemory, hardwareConcurrency }: DeviceHints): boolean => + (deviceMemory !== undefined && deviceMemory < 4) || + (hardwareConcurrency !== undefined && hardwareConcurrency < 4); diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts new file mode 100644 index 00000000..7d7b2476 --- /dev/null +++ b/src/app/features/soundscape/frame.ts @@ -0,0 +1,95 @@ +// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar.md at aeae510, +// section 2). The Heimdall session produces it; this file only reads it. Shape follows +// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. + +export type Estate = 'human' | 'mind' | 'persona'; +export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert'; +export type Energy = 'Low' | 'Mid' | 'High' | 'Peak'; +export type Size = 'S' | 'M' | 'L'; +export type Override = 'None' | 'Muted'; + +export interface ActivityFrame { + mind: string; + estate: Estate; + kind: Kind; + energy: Energy; + size: Size; + neutral: boolean; + override: Override; + seq: number; + /** Producer observation time, whole seconds (grammar: 1 s resolution). */ + at: number; +} + +const ESTATES: readonly Estate[] = ['human', 'mind', 'persona']; +const KINDS: readonly Kind[] = [ + 'Idle', + 'Reading', + 'Thinking', + 'Working', + 'Speaking', + 'Waiting', + 'Alert', +]; +const ENERGIES: readonly Energy[] = ['Low', 'Mid', 'High', 'Peak']; +const SIZES: readonly Size[] = ['S', 'M', 'L']; + +/** Grammar S2 (proposed, unmeasured): a frame older than this is absent. */ +export const STALE_AFTER_S = 10; + +const oneOf = (set: readonly T[], v: unknown): T | undefined => + set.find((s) => s === v); + +/** Grammar estate rule: a consumer clamps a human's energy to Low/Mid/High. */ +export const clampEnergy = (estate: Estate, energy: Energy): Energy => + estate === 'human' && energy === 'Peak' ? 'High' : energy; + +/** + * Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a + * room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3). + * Returns undefined for anything malformed; the caller treats that as no frame (A4). + */ +export const readFrame = (raw: unknown): ActivityFrame | undefined => { + if (typeof raw !== 'object' || raw === null) return undefined; + const r = raw as Record; + const estate = oneOf(ESTATES, r.estate); + const kind = oneOf(KINDS, r.kind); + const energy = oneOf(ENERGIES, r.energy); + const size = oneOf(SIZES, r.size); + const override = + r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override); + if (!estate || !kind || !energy || !size || !override) return undefined; + if (typeof r.mind !== 'string' || r.mind === '' || typeof r.neutral !== 'boolean') + return undefined; + if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined; + return { + mind: r.mind, + estate, + kind, + energy: clampEnergy(estate, energy), + size, + neutral: r.neutral, + override, + seq: r.seq as number, + at: r.at as number, + }; +}; + +/** Latest frame per mind (S1: consumers drop seq <= last). */ +export class FrameTable { + private frames = new Map(); + + offer(raw: unknown): boolean { + const f = readFrame(raw); + if (!f) return false; + const last = this.frames.get(f.mind); + if (last && f.seq <= last.seq) return false; + this.frames.set(f.mind, f); + return true; + } + + /** Frames not stale at `nowS`; a missing or stale frame is simply absent (A4), not neutral. */ + fresh(nowS: number): ActivityFrame[] { + return [...this.frames.values()].filter((f) => nowS - f.at <= STALE_AFTER_S); + } +} diff --git a/src/app/features/soundscape/scene.ts b/src/app/features/soundscape/scene.ts new file mode 100644 index 00000000..027cc27a --- /dev/null +++ b/src/app/features/soundscape/scene.ts @@ -0,0 +1,8 @@ +import type { ActivityFrame, Size } from './frame'; + +/** Placeholders owned by the orbit renderer, not the frame (grammar, Estate section). Unmeasured. */ +export const PLACEHOLDER_SIZE_SCALE: Record = { S: 0.7, M: 1, L: 1.4 }; + +/** O1: a neutral participant gets no orbit. There is no neutral pose; it is not in the list. */ +export const drawnCreatures = (frames: readonly ActivityFrame[]): ActivityFrame[] => + frames.filter((f) => !f.neutral); diff --git a/src/app/features/soundscape/socket.ts b/src/app/features/soundscape/socket.ts new file mode 100644 index 00000000..e1d23010 --- /dev/null +++ b/src/app/features/soundscape/socket.ts @@ -0,0 +1,47 @@ +import { FrameTable } from './frame'; +import { drawnCreatures } from './scene'; +import { planVoices, type SoundscapePrefs, type VoiceCommand } from './voices'; +import { isWeakDevice, type DeviceHints } from './device'; + +/** + * Where frames come from is `ratatoskr:question:activity-feed-source`, still open. Until the + * Heimdall session feed exists the only source is `silentSource`. A source never reads a room's + * member list (A1): being in a room says nothing about being active. + */ +export interface ActivitySource { + subscribe(onFrame: (raw: unknown) => void): () => void; +} +const noop = () => undefined; +export const silentSource: ActivitySource = { subscribe: () => noop }; + +/** Receives the plan. No audio assets exist yet, so nothing in this repo sounds. */ +export interface VoiceSink { + play(commands: readonly VoiceCommand[]): void; +} + +export interface AudioSocketDeps { + source: ActivitySource; + sink: VoiceSink; + prefs: () => SoundscapePrefs; + device: () => DeviceHints; + /** Whole seconds. */ + nowS: () => number; +} + +export const createAudioSocket = (deps: AudioSocketDeps) => { + const table = new FrameTable(); + const unsubscribe = deps.source.subscribe((raw) => table.offer(raw)); + return { + /** Called by the scheduler tick; a hidden page simply stops calling it (S5). */ + tick(): VoiceCommand[] { + const prefs = deps.prefs(); + const effective = { ...prefs, enabled: prefs.enabled && !isWeakDevice(deps.device()) }; + const commands = planVoices(table.fresh(deps.nowS()), effective); + deps.sink.play(commands); + return commands; + }, + /** What the orbit renderer draws: the same table, neutral removed. */ + drawn: () => drawnCreatures(table.fresh(deps.nowS())), + close: unsubscribe, + }; +}; diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts new file mode 100644 index 00000000..e07db5b9 --- /dev/null +++ b/src/app/features/soundscape/soundscape.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame } from './frame'; +import { drawnCreatures, PLACEHOLDER_SIZE_SCALE } from './scene'; +import { DEFAULT_VOICE_CAP, planVoices, type SoundscapePrefs } from './voices'; +import { isWeakDevice } from './device'; +import { createAudioSocket, silentSource, type ActivitySource } from './socket'; + +const frame = (o: Partial = {}): ActivityFrame => ({ + mind: 'void', + estate: 'mind', + kind: 'Working', + energy: 'Mid', + size: 'M', + neutral: false, + override: 'None', + seq: 1, + at: 100, + ...o, +}); +const noop = () => undefined; +const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 }; + +describe('neutral overrides everything', () => { + it('draws nothing and sounds nothing for any kind, energy, size or estate', () => { + const cases = (['human', 'mind', 'persona'] as const).flatMap((estate) => + (['Speaking', 'Alert', 'Working'] as const).flatMap((kind) => + (['High', 'Peak'] as const).flatMap((energy) => + (['S', 'M', 'L'] as const).map((size) => + frame({ neutral: true, estate, kind, energy, size }) + ) + ) + ) + ); + expect(cases).toHaveLength(54); + cases.forEach((f) => { + expect(drawnCreatures([f])).toEqual([]); + expect(planVoices([f], on)).toEqual([]); + }); + }); + it('is not the same as an absent frame: absent reads as idle, neutral as hidden', () => { + const t = new FrameTable(); + t.offer(frame({ at: 100 })); + expect(drawnCreatures(t.fresh(100 + STALE_AFTER_S + 1))).toEqual([]); + expect(drawnCreatures(t.fresh(100)).length).toBe(1); + }); +}); + +describe('human energy is clamped to three buckets by the consumer', () => { + it('turns Peak into High for humans only', () => { + expect(readFrame(frame({ estate: 'human', energy: 'Peak' }))?.energy).toBe('High'); + expect(readFrame(frame({ estate: 'mind', energy: 'Peak' }))?.energy).toBe('Peak'); + expect(readFrame(frame({ estate: 'persona', energy: 'Peak' }))?.energy).toBe('Peak'); + }); + it('keeps the clamp through the voice plan', () => { + const t = new FrameTable(); + t.offer(frame({ estate: 'human', energy: 'Peak' })); + expect(planVoices(t.fresh(100), on)[0].energy).toBe('High'); + }); +}); + +describe('activity is a stub, never the room member list', () => { + it('the silent source and the socket yield nothing, however many members a room has', () => { + const members = ['@a:x', '@b:x', '@c:x']; + const sock = createAudioSocket({ + source: silentSource, + sink: { play: noop }, + prefs: () => on, + device: () => ({}), + nowS: () => 100, + }); + expect(members.length).toBeGreaterThan(0); + expect(sock.tick()).toEqual([]); + expect(sock.drawn()).toEqual([]); + }); + it('only frames the source pushes produce voices', () => { + const pushes: ((raw: unknown) => void)[] = []; + const source: ActivitySource = { + subscribe: (cb) => { + pushes.push(cb); + return noop; + }, + }; + const sock = createAudioSocket({ + source, + sink: { play: noop }, + prefs: () => on, + device: () => ({}), + nowS: () => 100, + }); + pushes[0](frame({ mind: 'aqua' })); + expect(sock.tick().map((v) => v.mind)).toEqual(['aqua']); + }); +}); + +describe('no vote weights', () => { + it('drops fields the grammar does not define, and the plan carries only user volume as gain', () => { + const f = readFrame({ ...frame(), weight: 9, votes: 40, roomId: '!r:x', brief: 'secret' }); + expect(Object.keys(f ?? {}).sort()).toEqual( + ['at', 'energy', 'estate', 'kind', 'mind', 'neutral', 'override', 'seq', 'size'].sort() + ); + const t = new FrameTable(); + t.offer({ ...frame(), weight: 9 }); + expect(planVoices(t.fresh(100), on)[0].gain).toBe(0.5); + }); +}); + +describe('size scales are placeholders', () => { + it('live in the renderer table, not in the frame, and change nothing about voices', () => { + expect(PLACEHOLDER_SIZE_SCALE).toEqual({ S: 0.7, M: 1, L: 1.4 }); + const s = planVoices([frame({ size: 'S' })], on); + const l = planVoices([frame({ size: 'L' })], on); + expect(s).toEqual(l); + }); +}); + +describe('socket defaults and limits', () => { + it('ships off, muted, with the proposed cap', async () => { + vi.stubGlobal('localStorage', { getItem: () => null }); + const { getSettings } = await import('../../state/settings'); + const s = getSettings(); + expect(s.soundscapeEnabled).toBe(false); + expect(s.soundscapeMuted).toBe(true); + expect(s.soundscapeVoiceCap).toBe(DEFAULT_VOICE_CAP); + }); + it('master mute, disabled flag and zero volume all silence', () => { + const f = [frame()]; + expect(planVoices(f, { ...on, muted: true })).toEqual([]); + expect(planVoices(f, { ...on, enabled: false })).toEqual([]); + expect(planVoices(f, { ...on, volume: 0 })).toEqual([]); + }); + it('caps voices, dropping the lowest energy first', () => { + const f = ['a', 'b', 'c', 'd', 'e'].map((mind, i) => + frame({ mind, energy: (['Low', 'Mid', 'High', 'Peak', 'Mid'] as const)[i] }) + ); + const voices = planVoices(f, { ...on, voiceCap: 4 }).map((v) => v.mind); + expect(voices).toHaveLength(4); + expect(voices).not.toContain('a'); + }); + it('Muted silences the voice but the creature is still drawn; Idle/Waiting are silent', () => { + const f = [frame({ override: 'Muted' }), frame({ mind: 'x', kind: 'Idle' })]; + expect(planVoices(f, on)).toEqual([]); + expect(drawnCreatures(f)).toHaveLength(2); + }); + it('is off on weak phones even when the flag is on, and unknown hints are not weak', () => { + expect(isWeakDevice({ deviceMemory: 2 })).toBe(true); + expect(isWeakDevice({ hardwareConcurrency: 2 })).toBe(true); + expect(isWeakDevice({})).toBe(false); + const played: number[] = []; + const sock = createAudioSocket({ + source: { + subscribe: (cb) => { + cb(frame()); + return noop; + }, + }, + sink: { play: (c) => played.push(c.length) }, + prefs: () => on, + device: () => ({ deviceMemory: 1 }), + nowS: () => 100, + }); + expect(sock.tick()).toEqual([]); + expect(played).toEqual([0]); + }); + it('drops frames with seq <= last, and malformed frames', () => { + const t = new FrameTable(); + expect(t.offer(frame({ seq: 5 }))).toBe(true); + expect(t.offer(frame({ seq: 5 }))).toBe(false); + expect(t.offer(frame({ seq: 4 }))).toBe(false); + expect(t.offer({ ...frame(), kind: 'Dancing' })).toBe(false); + }); +}); diff --git a/src/app/features/soundscape/voices.ts b/src/app/features/soundscape/voices.ts new file mode 100644 index 00000000..d20e55ad --- /dev/null +++ b/src/app/features/soundscape/voices.ts @@ -0,0 +1,49 @@ +import type { ActivityFrame, Energy, Kind } from './frame'; + +/** Grammar V3 (proposed, unmeasured): voices audible at once. */ +export const DEFAULT_VOICE_CAP = 4; + +/** Local listener preferences. Never written back into a frame (O3). */ +export interface SoundscapePrefs { + /** The feature flag. Default off. */ + enabled: boolean; + /** Master mute. Default on. */ + muted: boolean; + /** 0..1 */ + volume: number; + voiceCap: number; +} + +export interface VoiceCommand { + mind: string; + kind: Kind; + energy: Energy; + gain: number; +} + +const ENERGY_RANK: Record = { Low: 0, Mid: 1, High: 2, Peak: 3 }; +/** Grammar section 3: Idle and Waiting default silent. */ +const SILENT_KINDS: ReadonlySet = new Set(['Idle', 'Waiting']); + +const clamp01 = (v: number) => (Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0); + +/** + * Which voices speak now. Neutral overrides everything (O1); `Muted` silences one Mind (O2); + * over the cap the lowest energy drops first (V3, ties by mind id so the result is stable). + * Identity is the mind id only (V1); `gain` is the user's volume, not a vote weight. + * Lead exemption in V3 is not implemented: the grammar does not say how a lead is chosen. + */ +export const planVoices = ( + frames: readonly ActivityFrame[], + prefs: SoundscapePrefs +): VoiceCommand[] => { + if (!prefs.enabled || prefs.muted) return []; + const gain = clamp01(prefs.volume); + if (gain === 0) return []; + const cap = Math.max(0, Math.floor(prefs.voiceCap)); + return frames + .filter((f) => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind)) + .sort((a, b) => ENERGY_RANK[b.energy] - ENERGY_RANK[a.energy] || a.mind.localeCompare(b.mind)) + .slice(0, cap) + .map((f) => ({ mind: f.mind, kind: f.kind, energy: f.energy, gain })); +}; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 6a894350..d4deb39c 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -47,6 +47,12 @@ export interface Settings { dateFormatString: string; developerTools: boolean; + + /** Soundscape flag: default off, and muted by default even when on. */ + soundscapeEnabled: boolean; + soundscapeMuted: boolean; + soundscapeVolume: number; + soundscapeVoiceCap: number; } const defaultSettings: Settings = { @@ -81,6 +87,11 @@ const defaultSettings: Settings = { dateFormatString: 'D MMM YYYY', developerTools: false, + + soundscapeEnabled: false, + soundscapeMuted: true, + soundscapeVolume: 0.5, + soundscapeVoiceCap: 4, }; export const getSettings = () => { From 1f4341dab64d6c90fe07ba4f98056200314142d7 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 14:43:33 +0000 Subject: [PATCH 53/64] scene: live reduced-motion, chunk-load failure, per-stage CultMath - matchMedia 'change' listener freezes/thaws a running scene; removed on dispose and context loss - startBackgroundScene catches a failed scene chunk import: onCompileFail, null, no throw - shaderSource compiles each stage with only the CultMath declarations it reaches (cultmathTrim.ts); Phacelle stays a separate file, terrain only - README: 'still' is a live rendered frame Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 10 ++- src/app/features/scene/Scene.test.ts | 89 ++++++++++++++++++++- src/app/features/scene/Scene.ts | 7 ++ src/app/features/scene/cultmathTrim.test.ts | 82 +++++++++++++++++++ src/app/features/scene/cultmathTrim.ts | 81 +++++++++++++++++++ src/app/features/scene/index.ts | 16 +++- src/app/features/scene/shaderSource.ts | 9 ++- src/app/features/scene/shaders/README.md | 4 + 8 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 src/app/features/scene/cultmathTrim.test.ts create mode 100644 src/app/features/scene/cultmathTrim.ts diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 0d49d57d..be117a0a 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -14,15 +14,17 @@ callbacks and the creature sizing rule. Nothing has drawn a creature. door. The client config's `backgroundScene.tier` (`off` default, `still`, `low`, `full`) is the feature flag and the ceiling; `tier.ts` lowers it for `saveData`, low memory or few cores. At `off` nothing is imported and no canvas context is requested; otherwise `Scene.ts` loads on - demand, so it stays out of the main chunk. + demand, so it stays out of the main chunk; if that chunk fails to load the error goes to + `onCompileFail` and the door returns null, it does not throw. - `Scene.ts` `createScene({ canvas, tier, onCompileFail, onContextLost, ... })` returns `{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 or `EXT_color_buffer_float` is missing, or a shader fails to compile (`onCompileFail` gets the - `ShaderCompileError`; programs already linked are freed). `still` draws one frame at the top - level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context + `ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a + baked image: it runs the full pipeline once at the top level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to show. It caps at 30 fps, pauses when - the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion`, and steps down a + the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion` (a change of the + preference while it runs freezes or thaws it), and steps down a quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist. - `creatures.ts` the creature record and `creatureScale`: a neutral creature is one fixed size and overrides `sizeBucket`. The bucket names and scales are placeholders until the persona cut owns them. diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index 97988687..25b7f08d 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -49,14 +49,16 @@ function fakeCanvas(gl: WebGL2RenderingContext | null) { let rafQueue: Array<(t: number) => void>; let cancelled: number; let win: EventTarget; +let motion: EventTarget & { matches: boolean }; let doc: EventTarget & { visibilityState: string }; beforeEach(() => { rafQueue = []; cancelled = 0; + motion = Object.assign(new EventTarget(), { matches: false }); win = Object.assign(new EventTarget(), { devicePixelRatio: 1, - matchMedia: () => ({ matches: false }), + matchMedia: () => motion, }); doc = Object.assign(new EventTarget(), { visibilityState: 'visible' }); vi.stubGlobal('window', win); @@ -180,6 +182,69 @@ describe('context loss', () => { }); }); +describe('prefers-reduced-motion', () => { + const runAll = (t0: number) => { + let t = t0; + let n = 0; + while (rafQueue.length && n < 200) { + runFrame(t); + t += 40; + n += 1; + } + return n; + }; + + it('freezes to a bounded run when the preference turns on mid-flight', () => { + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)) }); + runFrame(1000); + expect(rafQueue).toHaveLength(1); // moving: keeps going + motion.matches = true; + motion.dispatchEvent(new Event('change')); + expect(runAll(1040)).toBeLessThan(200); // ends + expect(rafQueue).toHaveLength(0); + }); + + it('thaws a frozen scene when the preference turns off', () => { + motion.matches = true; + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)) }); + runAll(1000); + expect(rafQueue).toHaveLength(0); + motion.matches = false; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(1); + runFrame(5000); + expect(rafQueue).toHaveLength(1); // unbounded again + }); + + it('removes the listener on dispose and on context loss', () => { + const { gl } = fakeGl(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)) }); + scene?.dispose(); + rafQueue.length = 0; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(0); + + const lostCanvas = fakeCanvas(fakeGl().gl); + createScene({ canvas: asCanvas(lostCanvas) }); + lostCanvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + rafQueue.length = 0; + motion.matches = true; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(0); + }); + + it('a still does not listen: one frame stays one frame', () => { + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); + runFrame(1000); + motion.matches = false; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(0); + }); +}); + describe('startBackgroundScene', () => { it("does not request a context when the config leaves the tier 'off'", async () => { const { gl } = fakeGl(); @@ -204,6 +269,28 @@ describe('startBackgroundScene', () => { expect(rafQueue).toHaveLength(0); }); + it('reports a failed scene chunk to onCompileFail and returns null instead of throwing', async () => { + vi.resetModules(); + vi.doMock('./Scene', () => { + throw new Error('Failed to fetch dynamically imported module'); + }); + try { + const { startBackgroundScene: start } = await import('./index'); + const onCompileFail = vi.fn(); + const canvas = fakeCanvas(fakeGl().gl); + const scene = await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas), onCompileFail }); + expect(scene).toBeNull(); + expect(onCompileFail).toHaveBeenCalledTimes(1); + expect(onCompileFail.mock.calls[0][0]).toBeInstanceOf(Error); + expect(canvas.getContext).not.toHaveBeenCalled(); + // and with no callback it still does not throw + expect(await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas) })).toBeNull(); + } finally { + vi.doUnmock('./Scene'); + vi.resetModules(); + } + }); + it('hands compile failure to the host and returns null, the fall to the CSS ground', async () => { const { gl } = fakeGl(0); const onCompileFail = vi.fn(); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 10dacc58..40ce300d 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -448,6 +448,13 @@ export function createScene(opts: SceneOptions): Scene | null { opts.onContextLost?.(); }); if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES; + // The preference can flip while the scene lives: freeze to a bounded run, or thaw to a free one. + if (reducedMotion && !still) { + listen(reducedMotion, 'change', () => { + framesLeft = reducedMotion.matches ? REDUCED_MOTION_FRAMES : Infinity; + wake(); + }); + } wake(); return { diff --git a/src/app/features/scene/cultmathTrim.test.ts b/src/app/features/scene/cultmathTrim.test.ts new file mode 100644 index 00000000..cef6b281 --- /dev/null +++ b/src/app/features/scene/cultmathTrim.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { trimGlsl } from './cultmathTrim'; +import { fragmentSource, Stage } from './shaderSource'; +import cultmath from './shaders/CultMath.glsl?raw'; + +const LIB = `// header +#ifndef LIB_GLSL +#define LIB_GLSL +const float LIB_PI = 3.14; + +float lib_half(float v) { return v * 0.5; } +vec2 lib_half(vec2 v) { return v * 0.5; } + +// uses the constant and an overload set +float lib_pi_half(float v) { return lib_half(v) * LIB_PI; } + +struct LibPair { float a; float b; }; + +LibPair lib_pair(float v) { LibPair p; p.a = v; p.b = lib_half(v); return p; } +float lib_unused(float v) { return v; } +#endif +`; + +describe('trimGlsl', () => { + it('keeps what a root names and what that reaches, and drops the rest', () => { + const out = trimGlsl(LIB, ['float x = lib_pi_half(1.0);']); + expect(out).toContain('lib_pi_half'); + expect(out).toContain('LIB_PI'); + expect(out).toContain('float lib_half'); + expect(out).toContain('vec2 lib_half'); // overloads stay together + expect(out).not.toContain('lib_unused'); + expect(out).not.toContain('LibPair'); + expect(out).not.toContain('lib_pair'); + }); + + it('keeps a struct that a kept function returns', () => { + const out = trimGlsl(LIB, ['LibPair p = lib_pair(1.0);']); + expect(out).toContain('struct LibPair'); + expect(out).toContain('lib_half'); + expect(out).not.toContain('lib_pi_half'); + }); + + it('keeps the include guard balanced', () => { + const out = trimGlsl(LIB, ['lib_half(1.0)']); + expect(out).toMatch(/^#ifndef LIB_GLSL\n#define LIB_GLSL\n/); + expect(out.trimEnd().endsWith('#endif')).toBe(true); + }); + + it('keeps nothing but the guard for a root that names nothing', () => { + const out = trimGlsl(LIB, ['void main() {}']); + expect(out).not.toContain('lib_'); + expect(out).toContain('#ifndef LIB_GLSL'); + }); +}); + +describe('CultMath per shader stage', () => { + const stages: Stage[] = ['terrain', 'atmosphere', 'resolve', 'composite']; + const cultmathNames = (text: string) => new Set(text.match(/\bcultmath_\w+/g) ?? []); + const defined = (src: string, name: string) => + new RegExp(`^\\s*(?:float|vec[234]|mat3|int|\\w+)\\s+${name}\\s*\\(`, 'm').test(src); + + it.each(stages)('%s defines every CultMath function it, or Phacelle, calls', (stage) => { + const src = fragmentSource(stage); + cultmathNames(src).forEach((n) => expect(defined(src, n), n).toBe(true)); + }); + + it.each(stages)('%s is smaller than the whole library', (stage) => { + expect(fragmentSource(stage).length).toBeLessThan(cultmath.length / 2); + }); + + it('puts the noise a stage calls in that stage and not in the others', () => { + expect(fragmentSource('terrain')).toContain('cultmath_snoise'); + expect(fragmentSource('composite')).not.toContain('cultmath_snoise'); + }); + + it('adds Phacelle to terrain alone, as its own file', () => { + expect(fragmentSource('terrain')).toContain('cultmath_phacelle'); + stages + .filter((s) => s !== 'terrain') + .forEach((s) => expect(fragmentSource(s)).not.toContain('cultmath_phacelle')); + }); +}); diff --git a/src/app/features/scene/cultmathTrim.ts b/src/app/features/scene/cultmathTrim.ts new file mode 100644 index 00000000..fc246e5c --- /dev/null +++ b/src/app/features/scene/cultmathTrim.ts @@ -0,0 +1,81 @@ +/** + * Keeps the part of CultMath's GLSL that a stage can reach. The vendored file is whole and never + * edited; each stage compiles only the declarations its own source (and the source concatenated + * beside it) names, directly or through other kept declarations. Overloads share a name and are + * kept together. + */ + +interface Decl { + name: string; + text: string; +} + +const GUARD_OPEN = /^#ifndef\s+\w+\s*\n#define\s+\w+\s*\n/m; +const GUARD_CLOSE = /\n#endif\s*$/; + +/** Name a top-level declaration defines: function, struct or const. */ +function declName(text: string): string | undefined { + const code = text.replace(/\/\/.*$/gm, ''); + if (!code.includes('{')) return /\b(\w+)\s*=/.exec(code)?.[1]; + return /\bstruct\s+(\w+)/.exec(code)?.[1] ?? /\b(\w+)\s*\(/.exec(code)?.[1]; +} + +/** Splits at brace depth 0: a declaration ends at the `;` or `}` that returns to depth 0. */ +function declarations(body: string): Decl[] { + const out: Decl[] = []; + let depth = 0; + let start = 0; + let i = 0; + while (i < body.length) { + const c = body[i]; + if (c === '/' && body[i + 1] === '/') { + const eol = body.indexOf('\n', i); + i = eol < 0 ? body.length : eol; + } else if (c === '{') { + depth += 1; + } else if ((c === '}' || c === ';') && depth - (c === '}' ? 1 : 0) === 0) { + if (c === '}') depth -= 1; + // a struct's closing brace is followed by `;` + const end = c === '}' && /^\s*;/.test(body.slice(i + 1)) ? body.indexOf(';', i) : i; + const text = body.slice(start, end + 1); + const name = declName(text); + if (name) out.push({ name, text }); + start = end + 1; + i = end; + } else if (c === '}') { + depth -= 1; + } + i += 1; + } + return out; +} + +const idents = (text: string): string[] => text.match(/\b[A-Za-z_]\w*\b/g) ?? []; + +/** `library` trimmed to what `roots` reach; the include guard is kept. */ +export function trimGlsl(library: string, roots: string[]): string { + const open = GUARD_OPEN.exec(library)?.[0] ?? ''; + const close = GUARD_CLOSE.test(library) ? '\n#endif\n' : ''; + const body = library + .slice(open ? library.indexOf(open) + open.length : 0) + .replace(GUARD_CLOSE, ''); + const decls = declarations(body); + const byName = new Map(); + decls.forEach((d) => byName.set(d.name, [...(byName.get(d.name) ?? []), d])); + + const kept = new Set(); + const queue = roots.flatMap(idents); + while (queue.length) { + const group = byName.get(queue.pop() ?? ''); + group?.forEach((d) => { + if (kept.has(d)) return; + kept.add(d); + queue.push(...idents(d.text)); + }); + } + const text = decls + .filter((d) => kept.has(d)) + .map((d) => d.text.replace(/^\s*\n/, '')) + .join('\n'); + return `${open}${text}${close}`; +} diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index f4109796..c896649e 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -4,12 +4,16 @@ import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier'; export type { Scene, SceneStats } from './Scene'; export type { SceneTier } from './tier'; -export type StartOptions = Omit; +export type StartOptions = Omit & { + /** A shader failed to compile, or the scene chunk failed to load. The host keeps its ground. */ + onCompileFail?: (e: Error) => void; +}; /** * The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no * canvas context is requested; otherwise the scene code loads on demand, so it stays out of the - * main chunk. Null means "keep the CSS ground". + * main chunk. Null means "keep the CSS ground", including when that chunk fails to load (offline, + * stale deploy): the failure goes to onCompileFail and nothing throws. */ export async function startBackgroundScene( config: BackgroundSceneConfig | undefined, @@ -18,6 +22,12 @@ export async function startBackgroundScene( ): Promise { const tier = resolveTier(config, capabilities); if (tier === 'off') return null; - const { createScene } = await import('./Scene'); + let createScene: typeof import('./Scene').createScene; + try { + ({ createScene } = await import('./Scene')); + } catch (e) { + options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); + return null; + } return createScene({ ...options, tier }); } diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts index aa44d9b2..c7a2a03f 100644 --- a/src/app/features/scene/shaderSource.ts +++ b/src/app/features/scene/shaderSource.ts @@ -7,6 +7,7 @@ import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw'; import resolveFrag from './shaders/resolve.frag.glsl?raw'; import compositeFrag from './shaders/composite.frag.glsl?raw'; import { WORLD, worldDefines } from './world'; +import { trimGlsl } from './cultmathTrim'; export type Stage = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; @@ -22,8 +23,12 @@ const stages: Record = { composite: compositeFrag, }; -/** Fragment source for a stage: header, world defines, CultMath, the shared block, the stage. */ +/** + * Fragment source for a stage: header, world defines, the part of CultMath the stage reaches, the + * shared block, the stage. Phacelle stays a separate file, added only where it is called. + */ export function fragmentSource(stage: Stage): string { const erosion = stage === 'terrain' && WORLD.phacelleGradBound > 0 ? phacelle : ''; - return `${HEADER}${worldDefines()}\n${cultmath}\n${erosion}\n${common}\n${stages[stage]}`; + const math = trimGlsl(cultmath, [erosion, common, stages[stage]]); + return `${HEADER}${worldDefines()}\n${math}\n${erosion}\n${common}\n${stages[stage]}`; } diff --git a/src/app/features/scene/shaders/README.md b/src/app/features/scene/shaders/README.md index 617644fb..b970f82b 100644 --- a/src/app/features/scene/shaders/README.md +++ b/src/app/features/scene/shaders/README.md @@ -13,3 +13,7 @@ header and any change to it is published under MPL-2.0. The other `*.glsl` files here are the scene's own stages. They are concatenated by `../shaderSource.ts` after a prelude; they use no colour literal (the palette is uniforms) and define no noise. + +`../shaderSource.ts` compiles each stage with only the part of `CultMath.glsl` that stage reaches +(`../cultmathTrim.ts`); the vendored file itself stays whole. Phacelle is added to the terrain stage +alone. From cc99a3439d21cca1cbe870175e272246d5be2a1a Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 2 Nov 2026 15:46:03 +0000 Subject: [PATCH 54/64] soundscape: neutral read first; human kind subset (provisional) per grammar 5e2eaa9 A neutral frame yields no kind and no energy: nothing drawn, nothing sounded, never an Idle. For a non-neutral human, a kind outside {Idle, Working, Speaking} or omitted becomes Idle and Peak reads as High. Minds and personas keep all seven kinds. The subset is provisional until Bifrost's steward cuts the list. Cites heimdall-body:follow_up:ratatoskr-creature-projection-v2. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/soundscape/frame.ts | 61 ++++++++++-------- .../features/soundscape/soundscape.test.ts | 62 +++++++++++++++++-- src/app/features/soundscape/voices.ts | 6 +- 3 files changed, 97 insertions(+), 32 deletions(-) diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts index 7d7b2476..87a7d2da 100644 --- a/src/app/features/soundscape/frame.ts +++ b/src/app/features/soundscape/frame.ts @@ -1,6 +1,7 @@ -// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar.md at aeae510, +// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar at 5e2eaa9, // section 2). The Heimdall session produces it; this file only reads it. Shape follows -// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. +// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. The human +// subset below is PROVISIONAL until Bifrost's steward cuts the list. export type Estate = 'human' | 'mind' | 'persona'; export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert'; @@ -8,18 +9,27 @@ export type Energy = 'Low' | 'Mid' | 'High' | 'Peak'; export type Size = 'S' | 'M' | 'L'; export type Override = 'None' | 'Muted'; -export interface ActivityFrame { +interface FrameBase { mind: string; estate: Estate; - kind: Kind; - energy: Energy; size: Size; - neutral: boolean; override: Override; seq: number; /** Producer observation time, whole seconds (grammar: 1 s resolution). */ at: number; } +/** O1: a neutral frame carries no kind and no energy; it is not an Idle and nothing reads one. */ +export interface NeutralFrame extends FrameBase { + neutral: true; + kind?: undefined; + energy?: undefined; +} +export interface LiveFrame extends FrameBase { + neutral: false; + kind: Kind; + energy: Energy; +} +export type ActivityFrame = NeutralFrame | LiveFrame; const ESTATES: readonly Estate[] = ['human', 'mind', 'persona']; const KINDS: readonly Kind[] = [ @@ -40,39 +50,40 @@ export const STALE_AFTER_S = 10; const oneOf = (set: readonly T[], v: unknown): T | undefined => set.find((s) => s === v); -/** Grammar estate rule: a consumer clamps a human's energy to Low/Mid/High. */ -export const clampEnergy = (estate: Estate, energy: Energy): Energy => +/** PROVISIONAL (grammar section 2, awaiting the projection-v2 field list): a human's kinds. */ +export const HUMAN_KINDS: readonly Kind[] = ['Idle', 'Working', 'Speaking']; + +/** Estate clamp, only ever revealing less: a human's out-of-subset or omitted kind is Idle. */ +const clampKind = (estate: Estate, kind: unknown): Kind | undefined => + estate === 'human' ? oneOf(HUMAN_KINDS, kind) ?? 'Idle' : oneOf(KINDS, kind); + +/** A human's energy is three buckets: Peak reads as High. */ +const clampEnergy = (estate: Estate, energy: Energy): Energy => estate === 'human' && energy === 'Peak' ? 'High' : energy; /** * Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a * room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3). - * Returns undefined for anything malformed; the caller treats that as no frame (A4). + * `neutral` is read first: a neutral frame yields no kind and no energy even if the producer sent + * them (O1). Returns undefined for anything malformed; the caller treats that as no frame (A4). */ export const readFrame = (raw: unknown): ActivityFrame | undefined => { if (typeof raw !== 'object' || raw === null) return undefined; const r = raw as Record; + if (typeof r.neutral !== 'boolean') return undefined; const estate = oneOf(ESTATES, r.estate); - const kind = oneOf(KINDS, r.kind); - const energy = oneOf(ENERGIES, r.energy); const size = oneOf(SIZES, r.size); const override = r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override); - if (!estate || !kind || !energy || !size || !override) return undefined; - if (typeof r.mind !== 'string' || r.mind === '' || typeof r.neutral !== 'boolean') - return undefined; + if (!estate || !size || !override) return undefined; + if (typeof r.mind !== 'string' || r.mind === '') return undefined; if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined; - return { - mind: r.mind, - estate, - kind, - energy: clampEnergy(estate, energy), - size, - neutral: r.neutral, - override, - seq: r.seq as number, - at: r.at as number, - }; + const base = { mind: r.mind, estate, size, override, seq: r.seq as number, at: r.at as number }; + if (r.neutral) return { ...base, neutral: true }; + const kind = clampKind(estate, r.kind); + const energy = oneOf(ENERGIES, r.energy); + if (!kind || !energy) return undefined; + return { ...base, neutral: false, kind, energy: clampEnergy(estate, energy) }; }; /** Latest frame per mind (S1: consumers drop seq <= last). */ diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts index e07db5b9..c292222a 100644 --- a/src/app/features/soundscape/soundscape.test.ts +++ b/src/app/features/soundscape/soundscape.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame } from './frame'; +import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame, type LiveFrame } from './frame'; import { drawnCreatures, PLACEHOLDER_SIZE_SCALE } from './scene'; import { DEFAULT_VOICE_CAP, planVoices, type SoundscapePrefs } from './voices'; import { isWeakDevice } from './device'; import { createAudioSocket, silentSource, type ActivitySource } from './socket'; -const frame = (o: Partial = {}): ActivityFrame => ({ +const frame = (o: Partial = {}): LiveFrame => ({ mind: 'void', estate: 'mind', kind: 'Working', @@ -17,6 +17,11 @@ const frame = (o: Partial = {}): ActivityFrame => ({ at: 100, ...o, }); +const neutralFrame = (o: Partial = {}): ActivityFrame => + ({ ...frame(), ...o, neutral: true } as unknown as ActivityFrame); +/** A raw frame as a producer sends it, with the named keys omitted. */ +const without = (f: object, ...keys: string[]) => + Object.fromEntries(Object.entries(f).filter(([k]) => !keys.includes(k))); const noop = () => undefined; const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 }; @@ -25,9 +30,7 @@ describe('neutral overrides everything', () => { const cases = (['human', 'mind', 'persona'] as const).flatMap((estate) => (['Speaking', 'Alert', 'Working'] as const).flatMap((kind) => (['High', 'Peak'] as const).flatMap((energy) => - (['S', 'M', 'L'] as const).map((size) => - frame({ neutral: true, estate, kind, energy, size }) - ) + (['S', 'M', 'L'] as const).map((size) => neutralFrame({ estate, kind, energy, size })) ) ) ); @@ -45,6 +48,55 @@ describe('neutral overrides everything', () => { }); }); +describe('neutral is read first', () => { + it('yields no kind and no energy, even when the producer sent them', () => { + (['human', 'mind', 'persona'] as const).forEach((estate) => { + const f = readFrame({ + ...frame({ estate, kind: 'Speaking', energy: 'High' }), + neutral: true, + }); + expect(f).toBeDefined(); + expect(f?.neutral).toBe(true); + expect(f?.kind).toBeUndefined(); + expect(f?.energy).toBeUndefined(); + expect(Object.keys(f ?? {})).not.toContain('kind'); + expect(Object.keys(f ?? {})).not.toContain('energy'); + }); + }); + it('a neutral human with kind omitted is not an Idle', () => { + const bare = without(frame({ estate: 'human' }), 'kind', 'energy'); + const f = readFrame({ ...bare, neutral: true }); + expect(f?.kind).toBeUndefined(); + expect(drawnCreatures([f as ActivityFrame])).toEqual([]); + expect(planVoices([f as ActivityFrame], on)).toEqual([]); + }); +}); + +describe('human kinds are the provisional subset', () => { + it.each(['Reading', 'Thinking', 'Waiting', 'Alert'] as const)('clamps %s to Idle', (kind) => { + expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe('Idle'); + }); + it('keeps Idle, Working and Speaking', () => { + (['Idle', 'Working', 'Speaking'] as const).forEach((kind) => + expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe(kind) + ); + }); + it('an omitted kind is Idle for a non-neutral human only', () => { + const noKind = without(frame({ estate: 'human' }), 'kind'); + expect(readFrame(noKind)?.kind).toBe('Idle'); + expect(readFrame({ ...noKind, neutral: true })?.kind).toBeUndefined(); + }); + it('minds and personas keep all seven kinds, and omitted is malformed for them', () => { + (['mind', 'persona'] as const).forEach((estate) => { + (['Idle', 'Reading', 'Thinking', 'Working', 'Speaking', 'Waiting', 'Alert'] as const).forEach( + (kind) => expect(readFrame(frame({ estate, kind }))?.kind).toBe(kind) + ); + const noKind = without(frame({ estate }), 'kind'); + expect(readFrame(noKind)).toBeUndefined(); + }); + }); +}); + describe('human energy is clamped to three buckets by the consumer', () => { it('turns Peak into High for humans only', () => { expect(readFrame(frame({ estate: 'human', energy: 'Peak' }))?.energy).toBe('High'); diff --git a/src/app/features/soundscape/voices.ts b/src/app/features/soundscape/voices.ts index d20e55ad..676ae853 100644 --- a/src/app/features/soundscape/voices.ts +++ b/src/app/features/soundscape/voices.ts @@ -1,4 +1,4 @@ -import type { ActivityFrame, Energy, Kind } from './frame'; +import type { ActivityFrame, Energy, Kind, LiveFrame } from './frame'; /** Grammar V3 (proposed, unmeasured): voices audible at once. */ export const DEFAULT_VOICE_CAP = 4; @@ -42,7 +42,9 @@ export const planVoices = ( if (gain === 0) return []; const cap = Math.max(0, Math.floor(prefs.voiceCap)); return frames - .filter((f) => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind)) + .filter( + (f): f is LiveFrame => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind) + ) .sort((a, b) => ENERGY_RANK[b.energy] - ENERGY_RANK[a.energy] || a.mind.localeCompare(b.mind)) .slice(0, cap) .map((f) => ({ mind: f.mind, kind: f.kind, energy: f.energy, gain })); From fff46492caf4e1ed4dcdc53132222342009439b0 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 17:09:42 +0000 Subject: [PATCH 55/64] scene: Phacelle bound says measured-not-proved; neutral creature draws nothing world.ts already carries Libby's 7.0 (CultLib PR #1 990521b3); the comment now states the 6.63 sup was measured, not proved. creatures.ts follows creature-projection-v2: a neutral creature publishes no sizeBucket, and one that arrives is ignored. creatureScale returns null (draw nothing) for neutral, absent or unknown buckets; it never defaults to medium. NEUTRAL_SCALE is removed. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/creatures.test.ts | 18 +++++++++++------- src/app/features/scene/creatures.ts | 20 ++++++++++---------- src/app/features/scene/world.ts | 3 ++- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/app/features/scene/creatures.test.ts b/src/app/features/scene/creatures.test.ts index 8e59175b..011f19e3 100644 --- a/src/app/features/scene/creatures.test.ts +++ b/src/app/features/scene/creatures.test.ts @@ -10,14 +10,18 @@ describe('creature scale', () => { ); }); - it('is one fixed size for every neutral creature, whatever its sizeBucket', () => { - buckets.forEach((sizeBucket) => - expect(creatureScale({ sizeBucket, neutral: true })).toBe(NEUTRAL_SCALE) - ); + it('draws nothing for a neutral creature, whatever sizeBucket it was sent', () => { + expect(creatureScale({ neutral: true })).toBeNull(); + buckets.forEach((sizeBucket) => expect(creatureScale({ sizeBucket, neutral: true })).toBeNull()); }); - it('is only visible as an override where the bucket would have said something else', () => { - expect(BUCKET_SCALE.small).not.toBe(NEUTRAL_SCALE); - expect(BUCKET_SCALE.large).not.toBe(NEUTRAL_SCALE); + it('does not crash or default to medium when a non-neutral creature has no sizeBucket', () => { + expect(creatureScale({ neutral: false })).toBeNull(); + expect(creatureScale({ sizeBucket: undefined, neutral: false })).not.toBe(BUCKET_SCALE.medium); + }); + + it('draws nothing for an unknown bucket, including inherited property names', () => { + expect(creatureScale({ sizeBucket: 'huge' as SizeBucket, neutral: false })).toBeNull(); + expect(creatureScale({ sizeBucket: 'toString' as SizeBucket, neutral: false })).toBeNull(); }); }); diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index 5026d2e2..b67a423c 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -3,10 +3,12 @@ // Props contract: heimdall-body:follow_up:ratatoskr-creature-projection-v2 (against GameCult/Bifrost; supersedes heimdall-body/ratatoskr-creature-projection). // Nothing here is binding until Bifrost's steward cuts that contract. // -// Shape: {id, estate, sizeBucket, neutral, activity}. +// Shape: {id, estate, neutral, activity} plus sizeBucket when neutral is false. // Invariants: // - Exact vote weights stay in Bifrost; Ratatoskr only ever sees a bucket. -// - neutral=true overrides any bucket, and no size is drawn. +// - neutral=true means Bifrost publishes no sizeBucket, no kind and no energy. A received value is +// ignored, and a neutral creature is not drawn. +// - A missing or unknown sizeBucket is not drawn either; no size is ever defaulted. // - activity comes from the Heimdall session. // Consent: humans get a coarse bucket by default plus a typed opt-out; Minds and Personas may show finer buckets. // Humans have three coarse buckets. The scales below (0.7 / 1 / 1.4) are placeholders, not fixed. @@ -16,8 +18,8 @@ export type SizeBucket = 'small' | 'medium' | 'large'; export interface Creature { id: string; estate: string; - sizeBucket: SizeBucket; - /** A neutral creature (no estate allegiance) is drawn at one fixed size whatever its bucket. */ + /** Absent when neutral. Read it only through creatureScale. */ + sizeBucket?: SizeBucket; neutral: boolean; activity: 'idle' | 'active'; } @@ -28,10 +30,8 @@ export const BUCKET_SCALE: Readonly> = { large: 1.4, }; -/** Neutral creatures are medium sized, not necessarily their bucket's size. */ -export const NEUTRAL_SCALE = BUCKET_SCALE.medium; - -/** Neutral overrides sizeBucket. */ -export function creatureScale(c: Pick): number { - return c.neutral ? NEUTRAL_SCALE : BUCKET_SCALE[c.sizeBucket]; +/** The draw scale, or null when nothing is drawn: a neutral creature, or a bucket that is absent or unknown. */ +export function creatureScale(c: Pick): number | null { + if (c.neutral || c.sizeBucket === undefined) return null; + return Object.hasOwn(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; } diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts index ba9aec53..ba366a6e 100644 --- a/src/app/features/scene/world.ts +++ b/src/app/features/scene/world.ts @@ -24,7 +24,8 @@ export const WORLD = { erodeSide: [1.7, 0, 1] as const, erodeNorm: 0.5, // Bound on |grad| of the phasor's cos component for the side and norm above. Libby measured a - // sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. The + // sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. + // The sup was measured, not proved. The // shader passes roughness n = 0 as a literal, so the doc envelope max(10, 1.25|side|)/(1-n) // needs no 1/(1-n) factor here; if n ever becomes nonzero, divide this bound by (1 - n). // |value| <= 1 holds by construction (the output is a normalized phasor). From 5e79a1853a5076699bfa724f7185d855e4e46c3f Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 2 Nov 2026 18:29:34 +0000 Subject: [PATCH 56/64] soundscape: neutral carries no size; cite grammar head 0091d01 NeutralFrame drops size alongside kind and energy; the reader ignores a size a producer sends on a neutral frame, and a live frame without a valid size is malformed. The human kind subset (Idle/Working/Speaking) stays PROVISIONAL, still proposed. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/soundscape/frame.ts | 24 ++++++++++--------- .../features/soundscape/soundscape.test.ts | 12 +++++++++- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts index 87a7d2da..947d2bb5 100644 --- a/src/app/features/soundscape/frame.ts +++ b/src/app/features/soundscape/frame.ts @@ -1,7 +1,8 @@ -// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar at 5e2eaa9, -// section 2). The Heimdall session produces it; this file only reads it. Shape follows +// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar, grammar head +// 0091d01, section 2). The Heimdall session produces it; this file only reads it. Shape follows // heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. The human -// subset below is PROVISIONAL until Bifrost's steward cuts the list. +// kind subset (Idle/Working/Speaking) below is PROVISIONAL: still proposed, not ruled, until +// Bifrost's steward cuts the list. export type Estate = 'human' | 'mind' | 'persona'; export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert'; @@ -12,20 +13,21 @@ export type Override = 'None' | 'Muted'; interface FrameBase { mind: string; estate: Estate; - size: Size; override: Override; seq: number; /** Producer observation time, whole seconds (grammar: 1 s resolution). */ at: number; } -/** O1: a neutral frame carries no kind and no energy; it is not an Idle and nothing reads one. */ +/** O1: a neutral frame carries no size, kind or energy; it is not an Idle and nothing reads one. */ export interface NeutralFrame extends FrameBase { neutral: true; + size?: undefined; kind?: undefined; energy?: undefined; } export interface LiveFrame extends FrameBase { neutral: false; + size: Size; kind: Kind; energy: Energy; } @@ -64,7 +66,7 @@ const clampEnergy = (estate: Estate, energy: Energy): Energy => /** * Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a * room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3). - * `neutral` is read first: a neutral frame yields no kind and no energy even if the producer sent + * `neutral` is read first: a neutral frame yields no size, kind or energy even if the producer sent * them (O1). Returns undefined for anything malformed; the caller treats that as no frame (A4). */ export const readFrame = (raw: unknown): ActivityFrame | undefined => { @@ -72,18 +74,18 @@ export const readFrame = (raw: unknown): ActivityFrame | undefined => { const r = raw as Record; if (typeof r.neutral !== 'boolean') return undefined; const estate = oneOf(ESTATES, r.estate); - const size = oneOf(SIZES, r.size); const override = r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override); - if (!estate || !size || !override) return undefined; + if (!estate || !override) return undefined; if (typeof r.mind !== 'string' || r.mind === '') return undefined; if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined; - const base = { mind: r.mind, estate, size, override, seq: r.seq as number, at: r.at as number }; + const base = { mind: r.mind, estate, override, seq: r.seq as number, at: r.at as number }; if (r.neutral) return { ...base, neutral: true }; const kind = clampKind(estate, r.kind); const energy = oneOf(ENERGIES, r.energy); - if (!kind || !energy) return undefined; - return { ...base, neutral: false, kind, energy: clampEnergy(estate, energy) }; + const size = oneOf(SIZES, r.size); + if (!kind || !energy || !size) return undefined; + return { ...base, neutral: false, size, kind, energy: clampEnergy(estate, energy) }; }; /** Latest frame per mind (S1: consumers drop seq <= last). */ diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts index c292222a..294456a1 100644 --- a/src/app/features/soundscape/soundscape.test.ts +++ b/src/app/features/soundscape/soundscape.test.ts @@ -49,7 +49,7 @@ describe('neutral overrides everything', () => { }); describe('neutral is read first', () => { - it('yields no kind and no energy, even when the producer sent them', () => { + it('yields no size, kind or energy, even when the producer sent them', () => { (['human', 'mind', 'persona'] as const).forEach((estate) => { const f = readFrame({ ...frame({ estate, kind: 'Speaking', energy: 'High' }), @@ -57,6 +57,8 @@ describe('neutral is read first', () => { }); expect(f).toBeDefined(); expect(f?.neutral).toBe(true); + expect(f?.size).toBeUndefined(); + expect(Object.keys(f ?? {})).not.toContain('size'); expect(f?.kind).toBeUndefined(); expect(f?.energy).toBeUndefined(); expect(Object.keys(f ?? {})).not.toContain('kind'); @@ -221,3 +223,11 @@ describe('socket defaults and limits', () => { expect(t.offer({ ...frame(), kind: 'Dancing' })).toBe(false); }); }); + +describe('neutral carries no size', () => { + it('reads without a size, and a live frame without one is malformed', () => { + const raw = frame(); + expect(readFrame({ ...without(raw, 'size', 'kind', 'energy'), neutral: true })).toBeDefined(); + expect(readFrame(without(raw, 'size'))).toBeUndefined(); + }); +}); From 5eb0a63caa254824dbddc6c0e3319c35bf3a65de Mon Sep 17 00:00:00 2001 From: Nibu Date: Tue, 3 Nov 2026 08:59:54 +0000 Subject: [PATCH 57/64] scene: ES2016-safe hasOwnProperty, tighten undefined-bucket assertion, reflow bound comment Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/creatures.test.ts | 4 ++-- src/app/features/scene/creatures.ts | 2 +- src/app/features/scene/world.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/features/scene/creatures.test.ts b/src/app/features/scene/creatures.test.ts index 011f19e3..938c217f 100644 --- a/src/app/features/scene/creatures.test.ts +++ b/src/app/features/scene/creatures.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { BUCKET_SCALE, creatureScale, NEUTRAL_SCALE, SizeBucket } from './creatures'; +import { BUCKET_SCALE, creatureScale, SizeBucket } from './creatures'; const buckets: SizeBucket[] = ['small', 'medium', 'large']; @@ -17,7 +17,7 @@ describe('creature scale', () => { it('does not crash or default to medium when a non-neutral creature has no sizeBucket', () => { expect(creatureScale({ neutral: false })).toBeNull(); - expect(creatureScale({ sizeBucket: undefined, neutral: false })).not.toBe(BUCKET_SCALE.medium); + expect(creatureScale({ sizeBucket: undefined, neutral: false })).toBeNull(); }); it('draws nothing for an unknown bucket, including inherited property names', () => { diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index b67a423c..a0b2bd29 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -33,5 +33,5 @@ export const BUCKET_SCALE: Readonly> = { /** The draw scale, or null when nothing is drawn: a neutral creature, or a bucket that is absent or unknown. */ export function creatureScale(c: Pick): number | null { if (c.neutral || c.sizeBucket === undefined) return null; - return Object.hasOwn(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; + return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; } diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts index ba366a6e..e9594a68 100644 --- a/src/app/features/scene/world.ts +++ b/src/app/features/scene/world.ts @@ -25,9 +25,9 @@ export const WORLD = { erodeNorm: 0.5, // Bound on |grad| of the phasor's cos component for the side and norm above. Libby measured a // sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. - // The sup was measured, not proved. The - // shader passes roughness n = 0 as a literal, so the doc envelope max(10, 1.25|side|)/(1-n) - // needs no 1/(1-n) factor here; if n ever becomes nonzero, divide this bound by (1 - n). + // The sup was measured, not proved. The shader passes roughness n = 0 as a literal, so the doc + // envelope max(10, 1.25|side|)/(1-n) needs no 1/(1-n) factor here; if n ever becomes nonzero, + // divide this bound by (1 - n). // |value| <= 1 holds by construction (the output is a normalized phasor). // Typed number, not the literal 7, so callers can build variants such as { ...WORLD, phacelleGradBound: 0 }. phacelleGradBound: 7.0 as number, From 9e12f6cbe4364adc44ea95b12bf7352b304a47a6 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 4 Nov 2026 15:53:30 +0000 Subject: [PATCH 58/64] brand: Ratatoskr replaces Cinny in the user-facing surface Squirrel logo, favicons and app icons generated from shapes by scripts/gen-brand.mjs; titles, meta, manifest, device names, welcome, auth, splash and About are Ratatoskr. About, the auth footer and the welcome page credit Cinny (AGPL-3.0) with source links. LICENSE and notices untouched; storage keys and protocol names are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- index.html | 20 +- public/favicon.ico | Bin 33310 -> 2338 bytes public/manifest.json | 6 +- public/res/android/android-chrome-144x144.png | Bin 4499 -> 3096 bytes public/res/android/android-chrome-192x192.png | Bin 6264 -> 4140 bytes public/res/android/android-chrome-256x256.png | Bin 8384 -> 5684 bytes public/res/android/android-chrome-36x36.png | Bin 1073 -> 817 bytes public/res/android/android-chrome-384x384.png | Bin 13069 -> 9055 bytes public/res/android/android-chrome-48x48.png | Bin 1487 -> 1039 bytes public/res/android/android-chrome-512x512.png | Bin 17829 -> 12620 bytes public/res/android/android-chrome-72x72.png | Bin 2216 -> 1528 bytes public/res/android/android-chrome-96x96.png | Bin 2991 -> 2022 bytes public/res/apple/apple-touch-icon-114x114.png | Bin 4902 -> 2340 bytes public/res/apple/apple-touch-icon-120x120.png | Bin 5100 -> 2472 bytes public/res/apple/apple-touch-icon-144x144.png | Bin 6075 -> 3096 bytes public/res/apple/apple-touch-icon-152x152.png | Bin 6665 -> 3212 bytes public/res/apple/apple-touch-icon-167x167.png | Bin 7325 -> 3555 bytes public/res/apple/apple-touch-icon-180x180.png | Bin 7889 -> 3884 bytes public/res/apple/apple-touch-icon-57x57.png | Bin 2347 -> 1188 bytes public/res/apple/apple-touch-icon-60x60.png | Bin 2482 -> 1261 bytes public/res/apple/apple-touch-icon-72x72.png | Bin 2951 -> 1528 bytes public/res/apple/apple-touch-icon-76x76.png | Bin 3124 -> 1606 bytes public/res/svg/cinny-highlight.svg | 13 -- public/res/svg/cinny-unread.svg | 13 -- public/res/svg/cinny.svg | 19 -- public/res/svg/ratatoskr-highlight.svg | 17 ++ public/res/svg/ratatoskr-unread.svg | 17 ++ public/res/svg/ratatoskr.svg | 15 ++ scripts/gen-brand.mjs | 202 ++++++++++++++++++ .../components/splash-screen/SplashScreen.tsx | 2 +- src/app/features/settings/about/About.tsx | 37 ++-- .../features/settings/devices/LocalBackup.tsx | 2 +- .../notifications/SystemNotification.tsx | 2 +- src/app/pages/auth/AuthFooter.tsx | 16 +- src/app/pages/auth/AuthLayout.tsx | 6 +- .../pages/auth/login/PasswordLoginForm.tsx | 6 +- src/app/pages/auth/login/TokenLogin.tsx | 2 +- .../auth/register/PasswordRegisterForm.tsx | 4 +- src/app/pages/client/ClientNonUIFeatures.tsx | 6 +- src/app/pages/client/WelcomePage.tsx | 26 +-- 40 files changed, 313 insertions(+), 118 deletions(-) delete mode 100644 public/res/svg/cinny-highlight.svg delete mode 100644 public/res/svg/cinny-unread.svg delete mode 100644 public/res/svg/cinny.svg create mode 100644 public/res/svg/ratatoskr-highlight.svg create mode 100644 public/res/svg/ratatoskr-unread.svg create mode 100644 public/res/svg/ratatoskr.svg create mode 100644 scripts/gen-brand.mjs diff --git a/index.html b/index.html index 2eefeee2..7242f309 100644 --- a/index.html +++ b/index.html @@ -4,21 +4,17 @@ - Cinny - - + Ratatoskr + + - + - - - + + - - + + diff --git a/public/favicon.ico b/public/favicon.ico index 916713065b069f0e59a63d520178ea8101818913..cfce994377be2daa71cd95204142fc269980eb34 100644 GIT binary patch literal 2338 zcmah}dpOg58~^^cHe-g$A|orI#H@&%+Q{UT<{YXmr*cXTAt{rPLJo;2X<0!|O`i&orNTLc7WT+Z z3-s+BS4XhE3qsQxtUN6sWSKTune+jqb5}4Mge1*m#CWj@L8y)h;M16Xu>P3a`lG_3 zRlZPXoz^9tQ9(0Hm=qQ+Zms#&W#HBln_;4gAK_ zEYibFkqtMe(R!m3x}>UmNw@6i!?a))pBGRXHw>tZILSJX0GQrro2+sbROy1fa7(KB zd_Qt&y+eMbp|R$s+Tl117?jj$Z0krlWqomDD1o*jyz75ODE*t?l&4)jqh_+b2i?2$ zOxtQEHnwhOZpCRwWk#nt3J%7NJ6)KD16`sw11pN1d4(DM!v7!xFB;?jB_k|4`a1xy zzCUEleChNVS~x}^Q1}R9)n4@ zL8P-m*}aZ_sDBOPq+{DOtnPsSo^j4D!Mh}{%TQexc62AZsWiLb*xwX*@If4&K7cwW z^>^M>7J_r6IKD^A!Zf+?DgFf_=X!1tM}FToO_q9)ViqPnxI2Jf2Ho^{r%K6++^iB~ z{0#ftqijJVaP!Do{QUT5s4yUe4=r>4bG`yP7P(l8SV|d^=5HB`y14dblpHiewW4G>eQZXu-qs0IS{!kf~Piq zKneq2P<6))_HN@ZU%NQMOBy}8xc8k($cAmOrHJCRl=iD3qmlBVU{`Y3g1*3vbBZR$ za>BV1)-+6A*5lcLnx+6F1Za z(jko)>ztzYDYj+Xj6ev8Sbc(#@X;MfC%Cl86O02d(!AmqOa?86>P)cEDB_}rt%js0 zH~f=U+Tr*o>YW{+wV7Pm?7&CC8N;bavTBRe1c;jH1tl(7>FB}DBzSsonnL}wqZFh0 z_VvU@sy9+9Igu)sPBE;tV(4$pQoBY zDJ`c+Xavuatx@EyIITqFraex0t!?xZY-w_TcW-+IHQV`GpXwmys@=90>iQrf{M+%D zQ!VZx6zIg*E9TM}(e*Z6xaC`^Ue!4JJS4hbhG`FA$@;Pji=VEf)MT zS+r}dVx9QS#IE&+A7fB)DEP%8vOK+N@3xkWHn+z;C7E%FTgmnM1>IVwGQIZTVB@*k zwpVbE!LQ2}9cpfiM!trh&ln#^GqhFG6u35T8eVJ<@OT)%YkgPnBN%8Zip8oVdunr1Jr!KpY_(hSvg&Rf=7$*#)w6GTW#2eCiN?Q&oGb;!-=^l~ zU*?@rJSjMP#!)emi!~{?R*#I4i2xRd0Q2f(2ls7s?5RZVjT?mWIJD}f*MQRt8)$?M zCCRZX^($HJJi+4O8jUQ=-fFX&$brcpz_PkyQOjoC=agm``AZcjbfB#^O7GNQXYVVMR$-kDD-5aKtDC(hH@K9o7xXby zVXi_cSfa3<)EnL%JI6HVO)vVql{=$#Lk?CYG06V$GLgHYAjg?0-*{bvw~;v<-oF#m z_~b>kMz#AMb(c~Cpw6zgHOPSN=?KQEpP9xZv_1W{iq3oXDa$d)J;LSy+CRke4b&75co|wF1dlmpv%$9Wv(%+P_F@GIM-L z47W?&){pLLeqUX6JF8KRGJ7}sX%@!Ad(<0>A;@co2){p&mBrhngRZq>y{0G$Nd@If zTI?Dt8l{YDfAbtVF3mh^@_TE>CZ4+_|4WEL`4Czu`*EN6a3lfvJO@PeG XiDcG~5jY>_@MTBI^kA6we|yS5V(}+4 literal 33310 zcmds=39uDKmWD41Zh#;vi?Te&jSgf)fe5#7iEs_K7-bc0ca0fVQQL(<(1~z#P}xOo zffki;3)exq8(Txi1sUl<9R+3_z%4`&ci_Dz@8N_@)vbGhJ`v|--mH`7 zoII6#o~&0Wv?{bIbnIAASXb!Xx=2=oYpw6#Dh^e;-mP z{JBS=aPrCiZ-rYY~UjHYwh?%PzZ^Hf`E0QrzRAupr%Lj~h3Ry!A~@O{Srt zf&Au^Pd+J%ug!1u*a{C{53=_kFkrwokvDGJwyg*%ZqXce*kLwbm`@{R!5*^J%sF%B zAg<`kEeiN;(B+IX&an9j;Xz1Q3^v_opMBQNm@xx#FB6#s{Py75Yp?b4OP1vm=)rrD z&FgpFbyqWV=+NSqUw-LjR2hur@xlu)*nH?~sB*r)F{)hUq5s3!3P&* zdoRCVuV#^7_VjFW=9y;}*RNk6$euQBnpwPfvAOQL>&&gU-da5R=%dlWqupbVJ-j-} zyHHvKc2HP6<&;y34?XmddFrXB%-wh2ZQ8bN>!tHdNZfmW3cdQQ=d-C3`n}cXVd)9I zI5PZqZtD+vTWCj}^z94pCSegfDcuW2h|UeA^;hjIXg{*cY-ETo5_yKMdfu%4gm@Gr zbAamAEZ&P`H-NsKBik?igu0r7x)ZPeQBWwT&q)bAML$Zimgt?Vkqx(i#Fh3(JsT

2R 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 j*?{u}e9Z0;a@O`|A?J>z-vJS{_8o z-v&N>;MteH)p2q0P8wtNtO*%EGWYM;ew1YX(f;~e02!7Qo_k->vib{>mS@|c02b`* z{8p41bM`P_p`_;=JuEiTJTZW!7MrFh`kwxfUfCP)wx6R)2L}vo2j1_vQG@SW2_Mi` zs@?3V-}G-8*EvNU<~Jj-2HExH;Vky57CS5Cs@INb`&nXr@49lEptvwusvV$$u!lVc z^3o~JBvGgWL_ZSbYE*&dpqAt`nl5uY^eVSu_i=r~kr{$1tqS;zlW z^hj+D_b%4+tYrQCO#5e(^wZ#zRAO&P$Mik5A+7x;Tm@PyItI!mlI>UNF(JShHhBUX zP=FZZIscRNYLn#8-H2Y7tm-`7)BUY*R9xoQ*L9D|;N0C-27=`NExoYGLcDL-q*t6Y zGJi8#*4CQ7J9)>_H9+5OeLtu#=&3luAhs0RIeT%c(h+}J8HAzhRpW$Yd9X60@E70$ zhY9BFMS~K02G831+=X8wJBO(#JxC&~zyeTcoa7;K>Ws=wfZj<{{e~HC^S?Cm5^oI| z$uAGCWWlXn&4H@Y7L?s*sbvnYc?468q?hF|(l@TdOjRNl&!4flYqF}B(;vbn*PgE? zpWnSkPkSX!e+?2+3H7cbylRzuiq^4p{R9$o$anJER=^EBdMp|-iNa@m_DvRz*qX;O z?$yL@*3?D^X|QZw&O7o>Tf^3ZZg;y`=9Tb>ir*rlC2oMyg?C_|y14XlWK@GDFO<#4HO&8Q3)GjX~4$Eg+`( z4ZuiLagd@aJON0wKKo^(3|a-Jyc(0`AwSUo*ucbBj386TCcn&hNZ&AkNsiA6j5^Sn zx_=&Tg?hfJJKMAb-w*bCuGw65anOD%3@N#wcwNYQ#VDLO`uo~~;t45zmd~%+HIr#Ts?~FEuTt)Zs0Z-A zt+&JODTa}u#A6SXSvBCqI`0UCgEoeXJNsLTH;T8-ssW{B~#H>#pQR) z$zqR=t+DZlIJZs3_cp|~uU~W8S`!l!Us`*W|6x&O*ejwUWa>VLYM3{0+cX9vQ>2U) zlY_t^i23>XmwOo-4v00{tcpBD>5>!iZe<|v!xvAWk4(X9Lw_lXfOrNRT2Sk6$mD-U z(gKJe%zD8!*w3jY7JbveK==)vB>7MV2=J!8XvCg0#UI%zcPAkMxlwKySU~k4biSl{ zNCreribxzhRJrPKU4u@#9i0n$_2Shs>P#)oN9apXq6B zhOthCJBDhEDeHW0F3JWj6qF(|)NO zp@#0zp;2OiH;;>Md1Cn>bvNO81(y*7jTl zVV;?8;KATye;aB-bM3}a!}$XSj}t4BlhUwOrzta>Ga;Fmr<>6n(NPG}S8Q}ZA$;=( zab$xF=MU?8yzZTO+06jf?SmEYcEQoh5;A|cJNgFOHGY~%$U=Fx9=traHN2FuFk)JS zsS^_)&0Q9rMY8^c+xZa1dcto{RmebQMMbUwKqB^1PmPI)Y*f{~ffWM@4mM$N((-+$ zV~^`~SjP2KJ6NW8H6!84jYr~-9Uxi|T^2P-2yn<>smNV;-E?2g<2_i28Nyx&cIPA{v)!G>T@S!QkRG6pj zPQc_dE0PI4tpBQy7waukcfn7I51sXZJiQw*i-noM|MD|i9NSA~b)MK7BprU&U%;tZ{KGIi)hgEPk<`|NG$(>1q!xNxZRtCOz*iX9&b)!q5hbiUdhRS zqU1M51-fI{T1)E&W<_OSZ2e`MAoZfg0K=i}K76*7lVIlx-8zj?nkitW<*CQsIF=dr zwsZwyJkt~AG{J*?&OmPkd_sllRGWCOb%n$pBd4((d%nIw4|$myS{U8jdysxeF8wW} zzQb+DZ!X~(AXyFdB1-I$&S=O}4U+Y;!h6-^DM7Vi3_%gH-iy5@IPfuy@o2cR^#t@V zQ_QP#cB5!fjYb@Xyt}ez=3V_M0Ex^m_OBv6jS%+V?A2>=w%7L z51vo7;mfh?;AWwB#j-yI&0+VS;sT8k1{iFlDVLSRrvW6hhO>k@0+M$X8975boBW;7 z;4e0``_&BmP=}&yF)3GyE{v8%Ep$hH)2z?j7*m!U)$DNAdqdt%2s+R>PxQkf+ zBIe2TmtCK`d(9RnNG53wLq$a@*F#7MvZsNCpXk^YkvNS3G*-FYE41KtIO3zKx$6!uu$BG zKR##J5oD8znBL)Y+1?l!H*~QHJ{8!`)S7zSf{Zp_th)tu(^mc=<)NkdyC;aBTopmE zyuEl;A*E)>rrz(~?|eoq+66>K1F_I#tT?k5-BjXx*Xq=aW=omPlL>7@iTYP6DYZN4 zbZv2V%mD=yUdrRmZ$0j|yCi()&JVKWa$L*b{8cZXw6I(?2jq!#7msuv42PJ8;1X7k zy{@m)!KG=&PYfzdL^$gO=BhhRB4D&O>_!qI5jtu)SPfHtI`gnR*AzQFf6EfsGJ+kTbZ%e z2>`X>LqEBT;SPUHr>R8Q+Va?SSD~dK#*lvZI57Z36Yx4C2LIj?FXsou}xIty6 zrKW5YfVb0i(g=dQYQaGs;XPBP||pAIion&{khtx!iyqk3%I{1OM@$_n}^h5Jc;Rfd3gz z=|GyyiHpTDCGq%yD)b|z%B}46eCl7CwC#1w?RB@zlhJZ+AIP;}$@4OW6gX7wCdyE} z$`XNYL`0j`St3bbX~&yM9&6q{RLo7b(rKK`?c6Vw&bN`Ir0O0~y-j@QOcURj+~@}I z_shv*>u9-dy~3JhLwa$PHQhTc2HpWQNp=zy zy;!ARBpd{PS8fkE%ZEicQPM@?*EvI2)hKw`%wwkzcl!x#N2D2(JhY{=H%_Q7`7{V^ zdxlQv+|>|poeE*PE0laDBLAml0_r65bzvQWVy|eXUjEN)q!#0yjR5k^t9vO`zFSlP%L=K;)>NLAaO--i7@bJIlBo8lkV9i5K+&1 zy7$?u6^W$^$K*pQ#a>`z_C@95l;DQ9geiGe)sF+6wP6J!Z7nN9&ES6I2+8oPOLYl#-S3~fSv)4GJUqMC_+4ws61a64B;y7ZNG}3Op_(z z7$~{7n}WH!tFAb}zP{P0en-WCS{?F!fH%=#ekvMm8q+ZsRZaDH$OL49*)aSEI8!}%FA0y-9Emeqoez`S(x-|p<&>7=S%hD z1`T)j8rWZUdaf-9r%PG{>*_R$CwH@=vgW@1AZ5CBSnGI>tSpo*^iPW9a>%FEKJ-|g z;__^@Id?`bW1N5{rBprTQor=-!z5PuuYo^uV#t_Fj4MsHRDFFLC6@wvinLyh=E|D~ z2DZZLSmh~w86&uuk^ikesI}^pI~e35BENkXf={+_@poC${i~E=$wPe;lRGy_yEtR1 zr0P>Q`N^}rhk5%GSzLQxQmMR6?ytA$Ap#3&pyM}r;CXN5ek_$=0K*E0*OSS759di$4UZB(OPO zSIF$%(lwmQzwzUTwvv+_{_zdc(w(h3JryAe9^)OXA~66P&LuFTxJ9mKjAI;~zITE;K#MI-F3nM$-%4 z-io5p^&AtQ3)pS_K}$~$7j}NL0tTqM)z3*8x!b;{pm@GQAzsoM{I_i#qdl4-s%d4F z+tDF6l76q*b47(qBYv_Y@OiAuVq0TZBypDX^OA!D@0&%Uj`@S7-8eT|qBvy&BGTXO z%NM$ogwRim{>wZ6e&qR=S+@J}3JFO(on()Fm6ldwH;_m#Xa*D;nU z{;9&4pRKa24C}G1z*bpNVW6k?WFV16!ehy8vCVgQc(^zDE#sacRi0}Sx;HuOnF&VB~Y3d z*495=V<{VY`p4_bv*y-=VSZyHqj{`Ns$@)sM;fNBh?h_=qaMvebL^*0Vpx4WV%?JVfkX$e=%sg+N{aV8PpiAtu2mh3K+$U zh?H3i75P5*{<5)s4`(h=R#VgDwWh(gJzJf{4(ZJV#i6O#E(I^8O0D0r{aX{|p&!u@L*7OQ7t^Y-HGFF!BuRK0`t zSeE2mo3ATRjt>U3{`SXWw2)<+)OJ-vcKGE#phH$r^mgmZgY^C+w%U#k3xc1KfmFny zhViW@t7zY!AJq#gz}js4NE)0bn%oxdx3;#r7+P3VwX`6>JV~8ryY1vNU;+jU&xjGB z_R)9FpGLE#KOJn0h`-PlzPFm6YdiV^j{sHx{?hx49S@y6g~bw`tm$T;a?b@b;v-r+q0T=BsG@>`?pKc#oJZe?`0aizucM`)}$uv9noLz~8=as1E8w~+xR#>NgnVUFY3yj{hblj`x#3-!eT z65i_%GBPqSADB3NDDA*L@>(S@OX`AVhfQ%r9os&^y)= zGm+M#KY^J?R@R)_g-ZFkj@$2VT_q1Q3P3-uj)98BGl^1_y-$59~{`z2nqUa&mTZ{FhP+gprF-rJ$g;a>LTC<@fK^ zM4x>Y2f~;wgCL2u+DDanqF#$E3s^59RYQ(AN@+qSCan;WDj~~`%81Jnz;Y2 z#o6Dz^aGS7m78xia0&fF4JO5~4p3ycJ-{gDAVZL9U78#aMl|%)2f!qCGcGQ!%A(a^ z*vXq~(3cc6bl&Bk(*Ag+LUlAfMp;)k5rC36ZwUP)vT(XklS0&S)DvJFey7r;8gszS z%sC%{@1utXv~S(6X_x!`fgn@TLujGn@^srkp@HT-okmSkT)mo{0M+ zV`HfY+*fmW9K@XkMF0N%yF6}wujLpR7-)c77blLo{W_`IB{?TYFyq>a{yk)El4W0i zJ~G1&RB@kQNGRvBd~lP!k(n8vx_IvXe;n3Lbd}RjR(nxEIrfIY()(NkmKmZ#W(36N-#NeH z)=qmMp{}lOFzWp>xi&L0)a7?C&7%hrUz(o1T8|T%<50o5-1}ax?C00i=sE|dH#RoD zo_EOM?I4E=xd#SnWNHd9cm_iwk@%^pshF>@SSs&;%6$w}z@lX`%LB*Dnb5#montPfJ^ukU=L>_q&$o5tpOQG zEiS{ad8u`P=wL?CvNIszbwoP5?7zx8?*(xR76+EjZ&H;Ub;mA81_Z+^H5`;4z7S{ah^cKMdaf+n?AJ0)4}7|8e046d6OH`KT8u5Suxq?wIo zijM#*>T7r^=Cafgh6X;yz85xQCA6N zP$MIw!NEZ+aLE0;qN0Y>sMX+;*g{%wx3o2z#`(_RW;_C-!Lc#AMgvxWSQ2oQ1p2y<|7z^F;Edd90HTGC!6$~9iA0t_%;XBUew zwX$+QSRcZu3Arn!f`A z6Rq(~R;;N^BD=d)uJZ^O?>eg?fc_jeC*NM@x-R2y*4SY$l1jF~`4|q!^pj;===ZU# zQvbarVDNpI0k-h39f60i7MFA4@0G+hM^0pZc3895X=&F22O+5C&qwV(qiW8+f)%z&U!9=FtLcnnt?tv1EbDVnzsL{AGtP2yKA}wy2AD7 zPADEuDRo0ngJmriO4YN={$HFtMHDRh-mRdeEU^7A4AP{ zzn%MFb82y?6&@jL%rd@PMY!{m0mWkwrL>g7yPNBbxgf4`#Q`K7A-5uAK7)w?&4ahA zo3o#fOY6$+hz;1SbVu*DZ42GJ6G!YN3Kzp*`;8Me0+=p5>kEG`1@C3Z_*R%&1@?i| z_Ul)}1U+FyVI8PRBr$~v*p`TnBv%od8K^$u(_qg-pFTxtc$TDx1aJyq&3t@(*@53P z%o<&aCAhh{ug@TolBW1W-7^8E;NaRB-bLu+9pAkIw0_D2qJnE}rwJev3m-HA3f_X~ zchgoj?8SeXdh8O}@v&Z_-HEEeX8vr?JpxhwXm5GB^+)cu-E~gl$qi7!FYC7dissZ2 zpfnF!)&FDoNLTT~XR+P??<)Ep{D$-i)hvoCV%~?0RqYWtf1{@mFmV1hg{XS5wQ1{a zdnx9s8aU|52mxnZ%M{$zAuMR_ZuHx(5m@o)T@GEI*~_oWY%p?x%@q^6=Xzb&InN(n*LAK`yp`EexELG&fTI@X zCbqwC`X9kKf1kD=1GxZzZ{5NKXCGOzQo5JrFoEd(Wx#lMe?UEgQ^HeA^Bp%#H9^i- z)uGAA&L7@W3%d{X0XLrusB*08w5+LmQgd)WwdUTvMfr!WfvNU3nrGmh0<5<>D~->0 zJkei>&lYCZo&Rd=81_^s=l>Qm{CQ?mkF?S}Ikk}Rq3`dE`$_%KQ6xnjuf_Pmwn$b*$wJRotCzWcOs%tdPp@D1Rj&GB!ll%|)|p8(CwkXbxqPPlM=V zJW&e%HwIK368>c3>47b2CtQh&EBP<*M*?3R^ieII^(MuO*k7Lf# z>zw@3v=bbo;v7&oPdL8IKWx6gO#Ay-q)$MEvCQJ3HR>lEAC^J!nDLsh%#HwmL{~iy zV<30d)1(OJl;yB9+6i*KR7=G^aS(CBtkzhRJ!nFY#_b-8A=mQts2`dDC~E zaKh}^EYTLIlpF=k9!WQEQS1{--*d{;<(k|ox>T&D)w}k~NR4Uma(P$0F~^LoVxLeV zM8DNOtH|vNWuQ>6bsFZ(ba}tG1Xmm1HvkTV<#jm?!ewsRd4if)AHDFR{>l^h0q#S1^J zz+uOD9VelgVSD^Wk* z#=kE>J?&5<_$3LT?!Sk4f_sM zh@LDE@#%17&~C8T;M(c(71uDS@`E|0_3oEW#q!Ha1^S%5mF!j2^H+(68Nq|cr>OuA zQjWq|!N{+*GU=GGh(^5~_T6I6SS=ZF?1S$tVk3BhWZU)ZrNn#hxEIBU61TWdgCym4 zL10JAiCMn8xIfS>EiI5%ZCc2OuQKX#*LOM7Hyz&sUu3g9frbs_aDJDld2yPAh@tW2 z*}T?YH&~3JJh(+~_9U1%exT8%_!Y*%c+X$+ z5?$Lr-4|^9jM8hEbU9<`UUoFZYZz0;$}SWIXHRv z8Z!Cnq}3-kJOWMAkB1a!h)fI&DefFdaZSl(M!z|t;eiR}&s?l`QM*_BCt4}5@u z9Rze#3~|PGcrZ)h z1?GIg$$-9`h0Uf&q~yp93~LB7E$541nlYTmD}d|%(_jU=@_dw28N&P9Kv72H*C<2# zrb>7>A@}5ezJx%-d^@4$P#b11*VNeaa(CTNWok_j4F;i%H_R ztp1UIBM{FQJ)y7*<>s%OQFzn z$HJ#A1O9tA^||;3fnR&J;4hNUQOo=O%8wE3HB5-z7&(~Bk9_*=vUg?^<7cL+Hg`pD z?bLkFA19QZ;uWEN2Jp%iouG;%X*6`hRul*3joEPh#TuE3|Avf72Y}S!euk4z-l#j; zv?d5G2dd{Q03H+Vq%mWe$ON;LZx513rHG8zyt@}hgcdHbZ(;bG^F zL=BMa^)bH;oY9UyL+#+Fs5ns2QP;{Z&^T>^ZzZJhU1pm7xapU;?R3}=BWqTMF*)i? z35E+#uJUi*1XOe+z$SS<16t4p-J{%d&+c`r)CAR_F(N7O>?AERhoQbb3_!lQBi4 znH?W{^nu6Jr!v{@xC2)4X!qf&wDkHx|0y+K(^$jZg@;{d5sa5-LpdUv7}T9wZe8v<%NZgPTrK40dCEwXgoF8&5wTDRl6 zC$MBoMG1Yjj)<+-0Go4NbIJb6Zr^NO2jhb?U$yF8YoHw(JEd|3yNu-7PtZEGz`reV z_fIouj_HhK9)s$BNfKmR!SI*f%euBH&aLcKr*U1E8i`J(G|oXLc|cU;i(J$T>9aTM zBe4rr@GSjJS77OZiKS|a&)nX-ZLU`w`lrrbBRHP`q059$K9pk2Pk5PuxFEoL!y;Q? z75U0ub~|f+c|Jo$9@soX57*nMKsJi-&1q6*q;0?U>Vc?k6+sVhYeA(ajZj;atT)Zdh9ML4Yf4K#1vKLC5?0xP2e-Fol z1YQOFi_)$ky(rCCV>cju1>yB@PrW<_tVjDUPWgQ;R@35Pe`(XeRAK+yg9}&ks7Y<50 z)31zT^-7-eCYdfChyAv3#TIP_THWGmpv6Is4d?c5uy7Iif|S?9EMQWZu|9zb5{3og z1}s2mrI*K{RFqLk_i#r|5X3QUzIT)5(v>d+Y)GM-<48ZTfAl1RGQ zn*yty@L46romf6tNonL^bb2oidqTY>pwyd5XlPUZfeEGfbZqn=h~}77#)izQKLB*f zmIS#5Z>W$Hi5X!39OdUE$OE8cc=IafhiYx;b>RjZpm?&N1|9**pM%I z0F1>|wE|p_6sh?`^}H6cq%hfdaX@eovj_*7@mnVflqLgpm=MDp*<1pZVfp z9NvZd**~8ZASsSX&7wm-I>TR!_(d)zKMRj95XMg5le`^jBoTk>QQd@WgSf8(6t?m@ zvJpbnXg-hZ*2fjxNuf!b8v!3t5+B6KcfsSwCpmK|iNbP~8L&tNq25c`3J@&9JUDF| zwj}<2I{ubcrCA}8vGzTHc_^`j6v6t+9swskGbniq_7+EE>ZHn5m>?i9jl*qM@;_BO zShsiS@*?!`Q~ayku#oe@Yf!32qafjy{?Xj;oJTc&)g%Gh)ujpwV=_OcF4+Nf;N-#s zds9F}fzrS^6_`TuoeEo0az{l93C{Se4uAx=K`W<*y-1w zDv=OQrL9@?@M*}`Hek}-``=x+R~T^-YC9mHV|QPq zb80MdLpdKZj(WKl9xQTr#$EO}=qkaKW5^8!Qer#{2I&?&D}>8#7U>1mvI(EzIBx!U zDY>fx#0uVT60CVj2iX!xBP0bdX}|&?(0{f{Dn2W=^2sHJ(zO&# z&yM=ZA0F?w zy)AFw&iXGY5tkJ<6j zxSsCjn1u_uSw}AV=vhsS$s)+yUq(y7KfLq~BtY3>Argem4UM@qMWhNK^bt(46c%A@ z|JzFgpmirQcCrAdf!?!*`_tv zRJ=)i&M3RCjBHt1*&IqoHkoIWtZYKaUWLs6 zbN|=t?E607=lOg-@AdUo=ZPxG9mYF1ZrmVIS5wvlU)yjWgtx(Gy*XEc8#m|`)Rp0g z7dLm>iL!q9zIyO;{qpNx%-s?*RDO769{OiZMwJnNX*3@MYt3rSw-b6@yQ;g!&6~;) zHq*g)%SMsoZFqiUJ!upn|1yM=e(7d+Fd?T1vv+fObdFBL!t=H4rC^7aVJfPp8dTL? zX`a+Gxl$Pg;B`Z5J30Qg-wcYykH6dSV^6@x9%*O~CA3|Snz#(gt%kJqQB(yl=ICPQ zAGe_oDb@-IRZi6ss@_|Ll6JP{5??`h{(JXzq0@D+!xgHmhf#&K(}`oJ>&fRm-^|rA z-iJZ7tzQ3Rvq5Py$L;0PJyBKML4?!6h~ljpq=kxionu>m$9{_}HRa#u;I`o~EPrjY znM$~Bb)zL#ww*k5tC=3ZVae_&@ySt2{tc1^^yd8XtLqa*_tGKv{EsDjd^%~PK8b?1e# zBgIrh#oCV02}L$lNYe`F=!BQ>2z2INP0+>Xz0IvCjSo2$Snzv^WqE+NulkEIvDF?8KoJYx6Hr9RO+Gs_2hz}3o z@ag$tRGfh|$Pr!%{jN-ocyep8!}e51JhGg12!^M62US)Y?|r~t+lQ$ij20m(Awms0 zzS7@ke<#W-bQBVMg<}8G;#$H0e)`h5(h7E%ej+~DV`-;$&wFM`JCX>sNzobXD4@iV zp4gW2-H06V`Srn#Wl?sa`|jOEs9byIx1xgxmBg~G7)wG_hB%=EI$v#KyypQH0w2<_ zg5E*E%_io;i6QN;Mwlsz%L~<3vtQ;AqVTp#9;kDv@bkQa^-|#z7~bNfKoWVSc#F3P z6ILlPee5V7rB=7pze|Zfy^3_?*7Dk5iuz29+SYp+Br8ag_{XR120S6VqI+bNTz%Lh zfrqe4pVd7n{LOfFR5boIPomm`f4`D^x2Hx%RM|cSpC6MdTzFbmTX){>>gtkkpL1O4 zjy0?>+Zd8u{L{_kak}4024R?_;#t<>Q&XF_!j4*gE4d)zHX{}odY8s2`IN{uaCyW5N=bsj5@-3#6eJUiYg*DrZerm3P5 zHvPpRQ_SgI1ZzNVe~JJkB^8T=O@?3#?fDx^m7Yj%+?%ur{3IWI);m0mOse%*bl;w; z3^{39^k2_@j-PUf-#8op{!)fiC9)Jx+UK`bZ^C~JcJtAoNbGR>7^hl2D5wQ3qEOkblU+#r|R!`!tGXG*fWac+*mm%TieD-I9pPwK9Jh@Hv zy_4|ru~1y4V?M3(L|ICye#z$aml7i=Hxg?DE6E9I{4y**o1`u8G>|HY3O*AJ|IdkR z`M84VD#rwE+Y@I$_V+RdF4J4s~*1wJ?DYh_AQjSQyW zzdW04J)ZL(p02cwNEI~Q94m>>bZ=DO`tfOJxM{CtE9U+;jNgt)JF9tYQ!uzMNmrut z+uJ0Jyin+POK{-yZ?e$S*Db;40iegpX=&%m>gsPByw@{CtWoasjZV{5MZr!#KBnty z%T1gftQa5=gnnb>C+Dd8d6qaC3VqEOHU*2x3Uj-HG|LdH2GWF)2*lH+jF%!7s(liTUx)?ZPxzk;@^jmGbixI!?jG0epvPhJ=;G-$9gaxQB9k%Lv~T%$!-<6 zZU<_FX}bzq6|hW<^V^x}s}A_PDYa8QmgP6&beojfabvWIHE&v~Z#n#qv5++<*U1zG*X=fw(;Z6WS;yQ_Lt=`T@c^3J<1lEynZlTYeOKJREb zQW$>LKQQnWoa963l|M0^(4XC=JzP1VmMvz|hlhvNPGj8^s?2$y2657xygWHURr*h) zx|Sl?+`+0Le+t+d)-qgV&wdw>CVZ1m!RS*l3-o0Buced9{n3Ft8-BLf$%|msGcx)e zMrVQXT6tt(U}ECj_l_$SYP<}7wQu~%wC;HL`eOLogO$5@1f)I_2FQO0@u98<%U#Y> z2aa#Fh-7?~@Aa6!SPDZ03kVBK0yVJhj)B?rQx_E#Irof-m};$Fb4OX8eg37Iz%9)AP~toK@(do~~_fBF+|+Nvvx=IT$0 zg49Fl<*ql`(tTbK2;_$Jbh|2VfvVqtTvYT9OQ+ zPa~tE?zCIfD0kw|ltE@{TuLgy(I#6%L&;?KU$S$#Q9aNQ^z8jF0zClW`Ul(w@Dha2 zC9^GU{MO&CNhH#3vBf+`K3Ep0463?1_WL8j@40Y1UtQ63pVbs(z{Noi#7Rb0(Yxj$ zAmj;^x_Q4$0@fD~b@+xjt96Kh5cm!q4J+)Wnz~aUOpXYL@TDAvGQ@>iPHn(wqcmJt z^BR~Z!HWOC87L#t%quKK0C}gZ(EnEKD(3#M?AUvlZMFNni8;)5N(87c4Z^qp|8O<8 zJC>>G?+??<->M3fDv6CY?I?(Q^T8wdBj)6XEzI8S5W4#H4}r{HSc=VefS(pGcb~h> zHyR{R5O5_3t1<&zhTmndX?uBdGvB&1JY6BQh28d$sy7X<<=>D8qtvrP3AY(^G??qv zAEq8OPsiaz#uZLu#UV$Ti}WH^FB`WTw<`3_%%Hs=@%?fi(r!+E&H?M-NWRhaTb2~} zosL z`vUWtR9Y<|0~iH<0RbvR(+QPPKBLd(cwg|z{Ccwf6N%%n$0NCjpowst3web{ za5g<0!YwSUo$z6FRBxD3J@dc zLMxFo)>J+fqffz4J{_+_Duo4u%SjteBsmIpt* z#%_QXUK2Rupe(=V#D}vaPeu89vjk0R3Crfiawz^aD<-SAj}~bTrQcWm|G7}k22l>d zkA59VEOY(;0(S3O_j!J_;g`xj2W7t#I=+ktUipWI&*1jS$;pIe6PzvVRZXfo|3S(P zKZyeAf!n*z)@tdv^~3t;l>O?M!QRoj1^DT@xcy1ouNASBQEPAm9-(`2&`q)FLeCXn+W^@H@bPE9I45j zgmONh|1tBrcVS*Y1=K~{Ygrk-`pKwLQOB)&D!O+;F1A&3#Qga@7k~o41K?tmb0u}y zF-O(&)p}&qLeP;GC?7iaS*oz55SsQRzr#ZL*bTo{V;8Uc*jXr1e{D7-u6C+j)+ zbX^U-unKVJBDlMp0UyNOTkmD$z{YVdNll|C=J=i6&dv_?rx`#U6{E4-x~=;3ns$kg z&!qW9C5{~v8bQ^73Q|8EUpXzmj4WKEy;t;mUE#VRmPzuC-WSIa4j{iYPajj(sl)Gy zf3_*3{$%lW=4bnBNa_EHs(`iAj4!g#o&24iSkS5G(b^Ez_Tt6ABM3th)`~BxaM$;7 zDy}m~%itQ&=BYXWT&)CHywwM4l~Hd_jUq=dgWYi~0#i*aGJfyHT~gUO{eIHnLYL(M zfzxT9c8CpdP$;>;jiNMMb48b1F-D9W8%#02dI&SK?WzGGeG3brsd+Gr0=<#q^;#Jm z8?!IlNB293g>l{j(iC_+?Eqnb5q6DImu6ACFkr;lvV~@Ul(~e*0*)b3{DBMw28xBx z`n_oS3mwmocZ3>#QJU7eQf&VLafMG%kOra7cRN>+OPPO9>uyRogqXG-+!?6EO)~k@ z+}!@~Ts1D0vNc0!YPc#}@c2&MVnv03kPsklF?b5J!>x>6Pu%ptJXxc&?G>Kf0hVgB z3gnsTTDQ#H(f)loSA9rgSs+cFx)K;LO`!%X3PLRL9_UQiA`Cj1Gr@BAD&v7K`FabHi z$tG}hgrgFW>$t+ZQ3K8QikD{{|09W}6+8r1aSJ4~i;JL|p+6gABrMX1gxaura#K4a zv%gbOJYFzHwNAkLB^ZoOJgeOJXG=@B!`EyVZSS}=@tuq#JgmZ;$&tYl%l>94Stp}P zn=s(dM(bKH-N3jSGU03yD7}!{fZ>I}S~<7ZgbRc=kB-C1sLAhBUlNbZUh`2ku+&23 zX!LiWcP>9ZQWSn<4i+&Nk17xGu;pvyGeih%H)ONE#H=Ow-c*?x#j!FVnyCh=(hXz+Fb9x-)i zA^5}`co}^Y6MnV6VVE*WRJs2b%UI> zeDzb^0?x++D~$S@fg-Nd0FTsIXZP7_mzlAMQ83h6A7rQT$YgVKbK8K3gKH*mx&NEO zbr66I`L-@)YnQC-A+{NP4-&pZdgq<#SavtTDOUV zQm1{Duz~h_a&s7fOy~^CBmw3KjMsON(6utebZJynR058-OM!&$OBK<-xjDtF*=1LE z&we>{-&ZW{CQx+HExXhGC4g8lw;2RbCD3Y?t;x@mH7-cp^ag$7FpG>xNba(Erh4=C za=@=X4Dg8?^G$UjmuDMu4PT6)_Ljg<1H5Yilm3%U1BPE`oykm|6!@ggF{>;iXhYL- zw9dnl&fF()Ok2U6BXQrj@2TGP)g?gGlYKH_+Z$2865h{I_uQC^qAdfVD>f_mc>0^% zi5c*;6$SC~Gc}VITNO>alovTQ^pPXz1Y4VDv+eY6$`ea&S36JigVX@=v!V|PvytL2 z_H&H4Ea0(0Z6;$mPa(7eUa*>h1Nt|6?A*Q8igPa~Cn&*V;8OZX4-QI%3gknZ@Ii(N zcd?&K#C2N=T3D^e?=g1gg{dHozGOAFbxDJnZz`>*p6W&Z>P%onNo2hQdLFLW<$d;A#NX;eZQMI`SeOGk%=FM*<$wFY_{or#gnOImX=*gTIj#s~)pY&Sz$` t7WiQ+_rsPY)s|?Xhtib-F@1e&)iosIRv?1`_>aO3b(JT|6^d4<{{gRJME3vy diff --git a/public/res/android/android-chrome-256x256.png b/public/res/android/android-chrome-256x256.png index 57378b413191cabc6c5f86714ffdff47a60ad16c..2f61f46b2d1009ab8831a57cfbf78c6b51fb6950 100644 GIT binary patch literal 5684 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 z zA$U@>ln8_OA=bXvAt>S~!o(70L4*qvmvYftUq;&s{dgL1dhONhYKT09c2}5dUbvT% z!yLTPj%_ztja{Oo+f*l-{cfk;ejSk#Nr-t8$P`xPCTLnRBimM*_R6SIMcauDl`+a> z?sola9($h1m{Qv7wY1ldl?jmw;1xULl9Xphf)rC2Ot2~0h8Jq}-z#=X@QNMAY?pt7 zOo^Zx@uIq;83_is|K85aeq(c_E~pNN&5S6$sBXQq4-Fi+_460a&v>VXCQ1aeScSQ) zYWz2@DJOYB_k<|3fY|;@GFEm12&($<3QzgvtlwMt**fr_3D;B}YbumIvO@hIlr1g9 diff --git a/public/res/android/android-chrome-36x36.png b/public/res/android/android-chrome-36x36.png index 507c443941225e2c73003d85eeac2fba6cc78285..91b26af2a772be5645e5ebe4a11c812937766f46 100644 GIT binary patch delta 795 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 KSv3!_dhkEtONq1q diff --git a/public/res/android/android-chrome-48x48.png b/public/res/android/android-chrome-48x48.png index 489cfd32175ddeaba504eee8aba83de8bb97811a..f8fafcd9fc6a11d325808844c6bc83ea2c3503ed 100644 GIT binary patch delta 1018 zcmVB!I{g zz@Uu?0t%u9MG~+Hl875btqVK|FOs;#gv2Fri5lWAfnY!paRKWReLdz(n5NU-JMEO2 zjwF5f(lU2Ce80Kpo^$UBPz8m+a#n!S2K)d@8}I`tZNLwpw10tR1E5(Ey1W5e)@mR{ z8zwluPEQ|K9Kf2#!0ieh5h6EM4Uc!|pd?W(F8~u93bh&Ga4=KDsvyDhe~18R@>;2u z7fvziP!bkC&o2xM@pmNkjO?;M2Vb%c}!01Y}DcUDJ^ zM(`P&$tR`=cz?bp3I;2p;e2rvj8;e5$8dHe2f(l)y?K&)w$R@o8ho=a2BzAQV7h%3 zPZQ>NJBZ`}81p2vlLrhibvTjzbi9tC=KKr{m$7D}i*aX-ROv-$BDVlHE441>r`wa^ z$3Tf)GnRDw7|xb4oS*YsH|pJ*N4;)I0wzR405n2+`+v4eMLYm!2x}ofl+YOUt=Agi zQDeOQZ-kW$Alne;VjjcB**qs4s&S zu!uK#Y#vpW5#hCYj{r!uKxiFcMW&hkHr&j{J!tZL6db2s1{FSq*++77I*}Jp+f>dVJKm> zmVXt1HVf}sjJ!~y0nmyT3gLVLKq_k7*?@ysu;zOOAodL46#xp)y|mLAt_c*WfC&M- z>Ti^P5xWZ|=dki5193XhROVX%+#(A>Of z$^4Q4REtFpL2yP;coyc4l6R^NZ2#w3jdPyWm^m42_j_66v%~E$)|JmTvW{kZ*AtZl zfF|s5a?L77y{wOh@zxaL7`7Py8vrzeT{cI9*#F$MnjwyP(^8ZbfD?A2F+4~i!d(|? oxhoFf3;6+*mLEWA`2m#HKf>U9L-MBTX#fBK07*qoM6N<$g358uz5oCK delta 1470 zcmV;v1ws0c2+s?UBYy>!Nklun^=@ znsT7@BBf`Cv<9e@_OyQ)-xr9+FYzi~#4~sT*?2^odeq_+zJI|1?8OgyDbRDdn1dob zuV=o-->?}M^*|tfPhdF~V1Q^e;S;RI_tFsPzJXYd<;c)CZs1S&1DbS0pnLN1cl^qO zaRM{(A6?|1kJ{XBU6FarVh=|o)TR^USf{(k~Y#&!J5 zJ;dGH0{j_)hkwIsIEgA9#Qa_~UD+mJj zQ(awc&YU^E`synYXu{*T6xU`k-js&y>}-4X>`_=)CF@HjKcDDNZdN*#|kcJ%mCB#TPFAcM2&sJDis5=sggj*7cgj>Qew15A8@pxQE zRaI5W%gdFQmpgm*tS*eh@Hl>h-%7*g&70-qf3L>b?n$N=gyszmzQVZ!iDnl^Bp~U)Rik&bblhip%5=h!3!*LC8h5Kl|P`0?ZQR!K>TRjXEMXlT%x zmX;PrjvUd2r$am`4LLbEdSm6vm5PgtMdZmC21~50piFI&2FsfY~%E`&mQ>#|3a`ECt5le=s$58h$Y}hdC z)~)l_TW{%&nwlD)efF6?a4p0Y4AntVQIV385<`a$)mxu_`l+U-CVk*ah?5wt-SFYV ztzW<1^y$+@W%usg`pADnoWN^Rii(PqmVcJY%E}Uz*49@0_wUz7zKP+Wb_E3mva+&7 zskXLOLqmf;QW?Wu?Y3>(CTcY`HTp;r`(ikUuchqUw@)IG5T)AMT7BdYF2uCiB&E5z zSy@?`C|$mMSs&RXC8o_LG)XBbDbdu_Br0`vb^1Ufwn&L-a}l3NIe-4Vva&K!Ie&iq zxIXY#T#^!V+gdb9dGEdVR99DvLS<#8h&AE^?P6~G9&4psyLQdu#fv4ANxjkB-0aYy zLn8J*&S)2Nw_==-vUBH7t5>hq8x<858XFr$=omh5hq$}7;0r83zc0W1Qf_Xp7hZTl zPrUQaJ5HTCB_d6D1Lxc!?&mJy41Z=I*tKhykt0VMH*TDCoH%j9yYId$nM{g667#V~ z2XXgSg<8DX+S;n3qC#C=ox;LGK@jMUdGqG^?z`_q3vt4<77(wr}5V&6+hLki-gns1tGbcL1mG2Kt>mdD4ar8)Rl?%FoXihJT@s>gwuD zn>I}%kgN zsF#l6b^KEo0^OH^o;Ue7Hem}c>488ra`7r&!ZUaRzd)uo^{ByVe1ikniwk<` Yf1@~ diff --git a/public/res/android/android-chrome-512x512.png b/public/res/android/android-chrome-512x512.png index d312b532c57b74fa511e6045500d9a83bb5deec4..2c584f3d3ed920f9ade75b67aa2d03fcf484e871 100644 GIT binary patch literal 12620 zcmeHt`8(9#`~P{)48}fX8I@(Usbpy*>kQKJDocrEr$X5(*%I?qNl{TLm24@yvhU2a z$QlaSjYMP{5o4Q~@6qf1d0*G}PxxHd=a=Dno^#*lKKHrT$2oUQjSmS6tQP6o z-~<2|_%8=HY2K<}jgi^;ype4BQkk>4vaiFHyfAKJ0{#3P5LB%;N& z(3{672Qa)p(A`D*j42dC^$qduoAV4+#doVYh=m9rS+~{P^arN=8Z#Xa%FSHjPOdaOp0$#vu&ttWF#sKnXTPtI zw7?8r9eP-r%~~vboIm`Q=NB}Z+izq59{KIg{-9CQ|Kn{OS)QtMSPlV{lSMoG)&ciB z&vnLf-0NtwIz^AcoSXgy2Nr6i?imr~kgfT=q*A10M(g9N4!L z5HR)Q#Z1N>V<2Pz0O7F&%Mn56>6e!#ms|PK-Kzq0dw=A~2mRsrJu#?&*2i5e z<^cu3yDC}AvMieCD!P-#WXQa1dn@+~dd%$>Wr3_be)yJVOGR1uN z^O=l$*vF$`@F1S+vET-HH~2AV8{)vfpN@Y5k@aSQ4v&Mlo={l1zofiCMV|DrimITA zw1`3-?M?2-njV~*43VE_morr6ns4rfj(fn&Sx8l$)LTVWpKA{N`$bFY`(KU?hs<&a<_?@S}FC zicGsIN?B**A$qs;uficEy>_ed{&dSd8u`ONy?+W7UaHReMh^UT{b$gw`$Z=E@8e@U)H!Ju^#RStD zxNoSRdtkt(d^F7ez3BJ4d)^V5r#?ITkg!QLAm+j4KnL4pPTwewqK-ig=3jXj7Bwlb zFMR-t*w3$pK>97TjP-ZBo;YwQE2^6lxWi;QIj_SlQ(Sth`x7Y^h<}S} zjEqc+uvIq?OuYQB!5jd!w+ovB{Kw?ZHdl)k5Vq>uPMp9a+b5R&UWx%o|19U+v@~m} z#*uj=`+6ExA-+98lycgqkS`?Npw?D1DZ*G*tAp_+Q_g>YKfRiWuLeMndGU%Fd^_Hl(md0Pml%rKN%Le$K^27VB@ z62htbzip=@@BB}c@Fm_QOonWShQQ4lYv%h(0j~IPslPXS{3uUz1UV zI?%nf(pJ;N)3QVARvF!V--SR2v;R^SbomsIy&Sz43f`=HV{AJxhDVHg{zd?20{D8) z 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 B{%rsN literal 17829 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 N002ovPDHLkV1hlW;${E< delta 2205 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 delta 2986 zcmV;b3sv;y53d)HBYz8aNkl4IaI{(5Kw_2 z1vD=b2xJcjJK`E3B)iGK%J&7rs}uU-yXcF)=!y1dk28_wsDCPahH6yfV{F6*Y`{i* zDue>z(G~xQf5I?ah(Pje!6Gce66_HIf!5Uxc^Hqu2(*lPl;Dpj#BQx5(DE`c1~*_h zVp`WB{14`00qV4%K+8D;KgNCNBb+|O6PSYmpqI$&WeVD8Kup8_9p`Fg9XfO{aNt0F`t)(e8E5!hWo4!E@^ag^ zZ}w*g|5RFD1&w&F6tXZ?h%P+rd z{`~nq_}~K}HVM;lz%c?}cN?Z@VHaL_p$8v)(0|C0BLzX=RMpqlD=sef$Rm$<^UXJf z)UBB17=f?Kz((X~A?@3@ci(;Y`Nc1OAsUTpu6R6d-n@D4yz@?b_wE%kThSMXd?D(q z#^PVKkV`JP#Ij|}j2t;qBodJfK@d3q{PSIR-F22OU24~^T|%T2)?vLbM19q-ah?`1 zW`E2W#l^)sb?PJ)I(F=6{P^+Ku3c->rcFYmEuQm*s4sQHbVQ_BZf>p@UU)%fW~O9V zwQ7~QbLZNzV~76z`-?;(K9`x9>Dp_rwQSikAAR(ZkT@5!u-_3;Uz&g`rP!cBgDhOQ zP}{a`B}0CGzR{ycTfBI&7hinQ+O=!t<$vV~g23l8GBR9q%{2-O3ze6b3xOy;#%f2z zeCc{A)}~Dx1qB7Nva%%0{rBH59*_IG;^Ja!)~qpj@L+#;=9y>8&(Ak}_;CIE_jm5O z=W5%wtqvVJ_*`vmt(uw|wY9a%%F2|Mma3|%l0w&Gsv}~K>WaZq?5?}+GGM>}$$wN; zRV7hnWu-(zh79r9XP;@;uAP&qudnysd+&Mq<(Dm3u)vx%Yb4qC(HZ-qj=B=#q|jMs zo#llWUXYoYDVfU3%B)2YSk*6 zHf^$b^Jdl6)ym7ueXe82jxsYd_2|*VIp>_CZ{NQ9_3NjgpuoNN-fPdEJ%5^L9R|kG z3H_ze;K76CyisffTyzvde_Uo;`a!^2j4*%$VW8fdiVNuC7i=Nr{q@63q~xUkv@E*hLpzBn%cT zSm5THZ}#b@p9+b7G4z#UeSiA&(VF7%xV!GU%jC(Eg-qWV`bn{~&pumgsjsg$apFX? zXU`Tw{UYcg#oD!Nr?ourzyoH_o-L$$M35!LqS2_<@X9N%c<{jog;;h3*;44LtFF?% zeS0l!`}XZ_y6GnM_4Pt5JBBPN(4|WkQ>IKYe*AbXEgp}%@x~kN+kdxD$YsZnEy+fW z8fDh3S$g#7p*7^^=UcL5i8PQMlSF5qeYR=SrWreStkzOdQQ_{p@0KoNs6uDWG#q5f?d0IdFP#Xq=U-JN@-|`BO;Dkih2o(ii)Iz>gsA~rXH_5BI2mM zD3PGBuuwc67k_p&H8s-AYJBR5h%fz7f)76Uz={(&Xm1^92r5piO7V7~-QmMn44J@;sBMMXv8 z@wgDH#?PEU#EEUg&o$t&#~$AytKcMukR*YP?-ojXXphWB>mB3JMDR;0Hg@t5+|n_RvEQ zdF{2=gisx>#d3`lb!xWYLtKr(F%BL)X#V{9diCmM;J|@WrL3&X_19mouC7kV#PKg! z;C~cEow|2Wjo}g;I&{cOFTG^{{{4my9V!xuNVZ@8@|RYuSRsTaVUAN2)iiIQ3O__3 z!SdzHEnBwC(4j+h?ATGVtX#R$ZMWSf9*+x=I40o(bHGj`LD8bdpl%Te@)=MwFWcl*tF249;UAuPG zRQKP1zoMcdAyS2_@h8m?)qI<=5JS;L1Ga40V$Pg7_U+r}d*Az>tgI}jV)5d|Zn@gwt&U%uSbsZ*_AzutGg^Bp~V_7ntxL0t*ZV-{+qP@v^x zA`kcCJmItrQ!pE~T0o%X#c&O7#0X?)U3K^~=3xO2X+eS3br$k)J-&~KmQjyYC_o|h zXeEL0$i~GOhGDo6f#lhOMOcK_uvZ8K!s|@*MPGauebED1$U-NycT^QVLn9TcupJw* g0UNLpHKgtA8Ng(ZX{ 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℞ZfYDRMD;XuxOAmQZJsVwXID#UB9|}Babu=M>*h@% zQRD0xK-nKy6+kLD-EIdBfDq28@*kt$El32WrYY4>ik6O! zCrw4<@zDq`tGPF|9YU=8L#36!PI)tTBpXGP# zg=F@rtIHWir;~bl@Ey2!4GmR6o6bPCcLA$BDMFi4APVo_t6e$THX~!Dtxc(MFy80w z>zh{v2?BvYkVl&DXf|Do*H$e6a|_%i$cgA`sm2pwV~|Lh!Py~86;xKrKk^FNn4usS zI6vtMLRFQw)FLiFA1U*GExf1 z;XzhF!`$55(aX-xZg1%Mv5uWxc8+PW>_y?D55vQ0K>hN7Rhz+~pKCEbjS(UuPgE6E*+&=L9^05UxW`SV#Y2 zxuI2D7P``2+tsnu*nECONj+BOb~bQ*`eQad?;!rg~XgT3A||P+zY?fB+8;HbWvkE-8^DBSjz( vU__dqpN~pPN>X(HA_fLeb(|Dobf^()Arj5Q&s_ocUndN7jdk8=IY#~;A=-bXq8)`WNB9*fw$$R>4c!AM4r=P^_ar)Db0Uh*i;Jh4>^E74GYzA9gROf4 z=CI(Cg@x4(*|^r#P5vI6m&>C)S(aUye0Hg<;&h!AC0mqU3|t|B2eL%z&BlbDVnFw} z6fTkk-orw@{gE|uBNVxu|5chQe8B{|`j{Uz6P%=S&)Em_#6@n^PLwl>fBp+ePV0D< zgFRhwKun2j`0~>$EL2c?5~MUbe-4kv>dv>%dvS@Ez&exi!-Wdse|u(Wgwp$aXvmsS z--0t6x^v8VQ1*ri?_DHe2w6ky9H3JQd>2B;?B}a|)%+6%gjAnCJ1c+xZl+fuRW z2B}Sz%QNV6s+j6^Yc))7MoW@ob|Fx%X><#>t0KO!>y7DyK{buYpS*`Iln+svXI;2+ zL-*hW9FKM_q=s4_?bulTT>05hlrY!W-USgG-+smsDyr^XY%{;!2d=-Y!yGii|Bzkfx-t zNBO$5BpahU-CNK)mE2L-dVd5K*5C6!sOU;}W?I=*j$72lpt4A*s_3ZI0U$|v0aHpm z(54}js#!Gz{utHXIKZ>|1bP)f&j;-kZT-7guDEg-+4V{Sw^eZr^E-Y_AH2S zcNa_g+s7`i_x#C#UyjA~yO)B+Y%)&JZmDmU^#&{7ZUA zWUTTpQk>N(y;A9+sIj;krNh^m&M%$MiD460U&Imp)I2*$dC)?O+>NC9K2C@9dU?y zxH6|a%I$L`PK&S^;|t78ok~(Na0c^x(WT^F`XQEl3g(4;R{C;J&M&@7iMt5o^e=(;f=W4a9mz>|F#QdpRRh(B#Y*D$j8D zTlxD1WYi2^>QEOm>~uPOKxmPsMiqj0awfm4da`Vx9V3>vrY0`ft3Hj(`SyLVggdhx znDoQ^kMTJEaH2-9$>y$2G`e>vy^c~L$p*UDZGm1~%&R^;XZgO zWExz5iM7(j7#%Hy;t>9(wd47r7yG63{c&MNHNpHFSIRUbRw{svfwHfOYN

    yNan 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

    @dAFv$W*lSH4*UY=@f4kiK%3hgn5lQ&UsC z!lEy_zM-KyWYgXGLslg5FQre`H(=oW?&-8QZ`AYh@+go~v{q*!cfs-3*4A26AD<8B z!!9l?NO++6w2OK_J<*{pw`>Yg6cQ3r$%dX3xT>XfM~fp>vaPMn(tmA6TL4;tH~F-< zcnnxe%-y^8{U9433>O!dO3i}@Y-FTc;jlXikoL^r@+L&>jO)_Ul8-sSHsub@k$S_T zcvJ#{mUb|0X~`Y1$rt_l=2W{WEj_(hf@n~Dm@JU9M<97JUaZ>kG?vn?{vfPGhN6J9cK)oI7(&loed)?>5C}*n5Z}_GS^1X2bcWq65|wb;vg(ir(BAm? zI4X6}wWz+H2V3LVi^P}~Nr+k)WutLh>+5aV%F|&SP_Q9RPR>tbWA}(_8ygPoF^raC z_PUQ`tYd)9%R#ua`+ugfZofk`lINpY&$ssWGJ0Uh4!_2#V`5`(^e+vU_4f7Y;HcL{ z&z*Z*Rdtq(ou@g!x26NFb_Sl5QU3Ydi3zKRtuSK!{%I-@31imn{bgG`TPpp}udiiS zn|EbK9%g3BJ9JS)^c%35ojkz@ai3cjCa*YpcDLA|Z+L9PBEhA~8LSE#$8Pn1Wv~XZ z*A_)9Um-HYB$Z*HZ(@QiHoMWECSlm~{(Za66u>DAQ4fua%|Hk&*I^2=(k{xNW9Dgv zh;b>Wt}PoKpMKKBK4s|hXBgQ~Q*pu(C|03!StCe11F*8Bl$1eN0<7L9V%sSpA)!jy zWAueC$tHaBG9Jc)!>ro#C%ne0w5j_TF}JXw3>nIr!V_=$w7mIP)t^pc14;_+e3%tC ze`5b{w&;;V zb}_xRg-5pbhTSQ`C#9v276afoIy=kTwH^W(7h_^!kp%29<*Z)Z*<4QKQ%rzudpgaw z$5K%+zOV--RR(6}c~h8pX4yAar-e;SOjM<%4O< z7BSC9URd`5%;5zf<%5}Z;#jyE14vOel|Ld5wg+zektbF4lK5f`iVK?v{mv1)U#HEk zUX4dqz#xdo>P4K?JPnvGg6w^!8D*1dvDW_U5(#c1C?41HY?o>slTlgzVmw)O6~mbU?2KDcef#8UrjymS3yC+9awn5plk4T z5GGZJsQCPRVO3RCDzelqa!$u6Es{vRze%4@ojH@yva=)|Nna1$%dg})GyU&CIhyX+ z1>)cu>iq661yDg!8U}-TkdYA!0JG9|s+n=6wX-u8EQb;7mGUG1whS1gk7mv3|IE!h zx{~|D@>Eg?`R&?#u;cNtb$UAg_THRuqL7AFK`wPN z&V{I=>?~J8zI<$l{()cZ0O>mn@{r!Z*lfC~!f?jmQQ1T-<<`gH;f(rv z6(H^fA&n4*fJ53hFVIV!IkV8OJnaa0bdE_`Ss8|UUR6JFutG;Tde|eG+;0DtB&be3 z4uCuU)Q_4(@Q=Or7KV`w<~acZR9ZM}jpoLU8)H#1Nr}7)cc=Fjl^q=&*&u`){D1g} z!^q+Sf~Ss5IAopZ4V^-ie8gTbR1RxlalG%5?4uN6O_>5aXe`jD-2We8sEaThcIZmH z0i@mzX$4@H`iDFv4PtxP3Ol)Xj{!gN(yjfzZ1X*h)GDfZJI?o{MnS{C5zWfi*Xil$ zIyyQ>wlHjnyR5=8Cb?=EFo`j_zp`p&!Hu1|a^*@_itqr80u5o?@@l!s|A(K?UcBf! zJ~=hDFf(Hp6&)Q5lT66(pC{~oh;tUW4CHlmbg*%7M0eWH{u-|ZkS!ctR>f0_!WQ0h zMF!45BGV%f3@1*UfQ+Sjz!OFT(I;mMQIM{K?yow!x`r^+!>y{S2awW0ee)EG-xClG z#47OHv$8TG&~0O*8riIDSVdKJohZD|kff&r`z8$%>47=?^3R9IMC|X#`OIiF2Cd2z c`23@95(&yCI&J)hQ@lesU1Q9P3wBZe15=6F-T(jq diff --git a/public/res/apple/apple-touch-icon-144x144.png b/public/res/apple/apple-touch-icon-144x144.png index b6a731eba6e00ff3a70fd3d660c9ec3857200284..d76ed356c9aeb674b40950163dbd422d61b84ade 100644 GIT binary patch literal 3096 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 Wvb$fUaT12|$F$WkYR^<{!u}66`NA&% diff --git a/public/res/apple/apple-touch-icon-152x152.png b/public/res/apple/apple-touch-icon-152x152.png index 968ae2f01e93c14da064c885111549a06f4c4a9a..76590ddf8e3f12c362d88e43b06998fea08bce40 100644 GIT binary patch literal 3212 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 literal 6665 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 YmO1|=A>%K6?RZW_K~ug|&cyeB0DR9&6#xJL diff --git a/public/res/apple/apple-touch-icon-180x180.png b/public/res/apple/apple-touch-icon-180x180.png index a651cd1c8b2d26a4af969c22b43f7076f6f9bf2c..ed4983706678ecba79a6081b8a869a973cce03c9 100644 GIT binary patch literal 3884 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 KLTbRSul@(HS$PEj literal 7889 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 x3`!r4(8cMY@keM=BhP0Ihxh9iME+VCD~?L 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 Hu0mjf!HtfB diff --git a/public/res/apple/apple-touch-icon-60x60.png b/public/res/apple/apple-touch-icon-60x60.png index 47fafb07aacb8c427095194e8c4cee18e2268e5a..87c8fc2ba5dda34518d6b44db4417927a3382ab5 100644 GIT binary patch delta 1242 zcmV<01SR{j6YU9*BYy<6NklyjoN`h7~)u0VV z(wY_{+Mp&%Jn+a?u?8ch#e=q9LB$_RX{#WJkWw$udZD6GlxU1sZDUPU?5~e}C%((< z?EAj+PG)vzo6HYi!tDFboBix_yw7`#@?~bxNYW^`zkmnq0e^eI9?PfjkUK{@_&@10&kcRpu4N`xw_h% zr-2U@7=lGvJAdL<-)83ejswGzB3QjT=LeOn&0~|A zW$N8j5q~#FvoR~j2TWm)HWgDwMg~QrQEIEni)%|Nu&B^@xMh+lFg!s24l7D#l?IjE zcTr`IX5|ZO%Hlr9`zZ$&#jjDw;Kpgn47jhOie7eg(3=}S(tkZC>Fw=f+VgL#Bbv3X zmar)WHYPCC;TTs2`}*=h4b-SP)m56CXJ(F0Uw>ezXMIuZJ>&$8`~9ayT;OMW>bbDc zsh@XHX(5u|*wh0fA*ljC?x>8tcW!xzx>l44E&TYKnL=P_+LX!3OxT@h3@n?6Re>Q@SfhIb9)Fk2D6s{r42+DZT2_kGzjyACswxPv zUVj=?Ut`+__DNPssHV(d2M&KieOn_#f^`biGO%djuM)?9eK%VP4$~15meIPd0LvEt zvU-|jXfx1((>r3x3B|^f-Gd>3dfOFX2(_@)`55kOW?5d5fsD+cQFprnEOP66YeO_} zsGbY_Vt=g^w*S~V;e7k*{2r~(;;MXifPbeHh^Hr!mCb-ztt=9(Y-jyz3)p9I=ff;v z0Y(N3!2?_cmRT8vh0(QT7Dho~1`M}$6&S*%oGP>oSQfu}VAo=p36+;@%N8r6KX=X? z%+iPIZ3DZOrBz+qs0Uy2S4OgBAXE3R?T*3P0`@8FRqHJ-Dmcu5cdh*_eSob%_kV7# z;6lS?MX=AWAEmNTVR`}k-Y*}`j$y8~j<@XA9UGTB3hNlK?|mY}&Ik`26&m)juN(%(K{eXxdDKL2CGB*-Nm*K0}02U(3PbH z)VI4%+m*ca)okJMY1#sd#DismwNuZwlnC3MxX_YvU|eT_wRnfz>iCJmBFj4|urC10L>r2TWBkZ1(EZ6#xJL07*qoM6N<$ Eg4I=HDgXcg delta 2473 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 N002ovPDHLkV1k}F;!6Mk literal 2951 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 literal 3124 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 - - - - - - - - - - - - diff --git a/public/res/svg/cinny-unread.svg b/public/res/svg/cinny-unread.svg deleted file mode 100644 index be301375..00000000 --- a/public/res/svg/cinny-unread.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/public/res/svg/cinny.svg b/public/res/svg/cinny.svg deleted file mode 100644 index 8701d67b..00000000 --- a/public/res/svg/cinny.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/public/res/svg/ratatoskr-highlight.svg b/public/res/svg/ratatoskr-highlight.svg new file mode 100644 index 00000000..661442d4 --- /dev/null +++ b/public/res/svg/ratatoskr-highlight.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/res/svg/ratatoskr-unread.svg b/public/res/svg/ratatoskr-unread.svg new file mode 100644 index 00000000..69a08c95 --- /dev/null +++ b/public/res/svg/ratatoskr-unread.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/res/svg/ratatoskr.svg b/public/res/svg/ratatoskr.svg new file mode 100644 index 00000000..3ca98122 --- /dev/null +++ b/public/res/svg/ratatoskr.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/scripts/gen-brand.mjs b/scripts/gen-brand.mjs new file mode 100644 index 00000000..fa44bfde --- /dev/null +++ b/scripts/gen-brand.mjs @@ -0,0 +1,202 @@ +// Draws the Ratatoskr squirrel once, as shapes, and writes the SVG logos plus the +// favicon and app-icon PNGs from the same shapes (no image tools needed). +// Run: node scripts/gen-brand.mjs +import { deflateSync } from 'node:zlib'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const BG = '#1c1b22'; + +// Squirrel facing right on a 64x64 grid. Later shapes draw over earlier ones. +const squirrel = [ + { t: 'ellipse', cx: 19, cy: 33, rx: 13, ry: 22, rot: -15, fill: '#d9742b' }, + { t: 'ellipse', cx: 21, cy: 33, rx: 7, ry: 15, rot: -15, fill: '#f0a45d' }, + { t: 'ellipse', cx: 36, cy: 43, rx: 11, ry: 14, rot: 0, fill: '#e0833a' }, + { t: 'ellipse', cx: 40, cy: 46, rx: 6, ry: 9, rot: 0, fill: '#f6dcb8' }, + { + t: 'poly', + pts: [ + [35, 19], + [36, 7], + [43, 15], + ], + fill: '#c9672a', + }, + { + t: 'poly', + pts: [ + [44, 15], + [50, 7], + [52, 19], + ], + fill: '#c9672a', + }, + { t: 'circle', cx: 43, cy: 25, r: 10, fill: '#e0833a' }, + { t: 'ellipse', cx: 47, cy: 29, rx: 5, ry: 3.5, rot: 0, fill: '#f6dcb8' }, + { t: 'circle', cx: 46.5, cy: 23.5, r: 1.8, fill: '#1a1a1a' }, + { t: 'circle', cx: 47, cy: 23, r: 0.6, fill: '#ffffff' }, + { t: 'circle', cx: 52, cy: 27.5, r: 1.5, fill: '#1a1a1a' }, + { t: 'ellipse', cx: 33, cy: 56, rx: 5, ry: 2.5, rot: 0, fill: '#c9672a' }, + { t: 'ellipse', cx: 44, cy: 56, rx: 4.5, ry: 2.5, rot: 0, fill: '#c9672a' }, +]; +const badge = (fill) => [ + { t: 'circle', cx: 52, cy: 52, r: 11, fill: '#ffffff' }, + { t: 'circle', cx: 52, cy: 52, r: 8, fill }, +]; +const background = [{ t: 'rect', x: 0, y: 0, w: 64, h: 64, fill: BG }]; + +const f = (n) => +n.toFixed(2); +const svgOf = (shapes) => + shapes + .map((s) => { + if (s.t === 'circle') + return ``; + if (s.t === 'rect') return ``; + if (s.t === 'poly') + return ``; + return ``; + }) + .join('\n'); +const writeSvg = (name, shapes) => + writeFileSync( + join(root, 'public/res/svg', name), + `\n${svgOf( + shapes + )}\n\n` + ); + +const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); +const inside = (s, x, y) => { + if (s.t === 'rect') return x >= s.x && x < s.x + s.w && y >= s.y && y < s.y + s.h; + if (s.t === 'circle') return (x - s.cx) ** 2 + (y - s.cy) ** 2 <= s.r ** 2; + if (s.t === 'ellipse') { + const a = (-s.rot * Math.PI) / 180; + const dx = x - s.cx; + const dy = y - s.cy; + const u = dx * Math.cos(a) - dy * Math.sin(a); + const v = dx * Math.sin(a) + dy * Math.cos(a); + return (u / s.rx) ** 2 + (v / s.ry) ** 2 <= 1; + } + let c = false; + for (let i = 0, j = s.pts.length - 1; i < s.pts.length; j = i++) { + const [xi, yi] = s.pts[i]; + const [xj, yj] = s.pts[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c; + } + return c; +}; + +const crcTable = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); +const crc = (b) => { + let c = 0xffffffff; + for (const x of b) c = crcTable[(c ^ x) & 255] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +}; +const chunk = (type, data) => { + const out = Buffer.alloc(12 + data.length); + out.writeUInt32BE(data.length, 0); + out.write(type, 4, 'ascii'); + data.copy(out, 8); + out.writeUInt32BE(crc(out.subarray(4, 8 + data.length)), 8 + data.length); + return out; +}; + +// Rasterise with 4x4 supersampling; `scale` shrinks the drawing about the centre. +const png = (shapes, size, scale = 1) => { + const rgba = Buffer.alloc(size * size * 4); + const SS = 4; + const colours = shapes.map((s) => hex(s.fill)); + for (let py = 0; py < size; py += 1) { + for (let px = 0; px < size; px += 1) { + let r = 0; + let g = 0; + let b = 0; + let a = 0; + for (let sy = 0; sy < SS; sy += 1) { + for (let sx = 0; sx < SS; sx += 1) { + const gx = ((px + (sx + 0.5) / SS) / size) * 64; + const gy = ((py + (sy + 0.5) / SS) / size) * 64; + // the background rect covers the whole canvas and ignores `scale` + let hit = -1; + shapes.forEach((s, i) => { + const x = s.t === 'rect' ? gx : (gx - 32) / scale + 32; + const y = s.t === 'rect' ? gy : (gy - 32) / scale + 32; + if (inside(s, x, y)) hit = i; + }); + if (hit >= 0) { + [r, g, b] = [r + colours[hit][0], g + colours[hit][1], b + colours[hit][2]]; + a += 1; + } + } + } + const o = (py * size + px) * 4; + if (a > 0) { + rgba[o] = Math.round(r / a); + rgba[o + 1] = Math.round(g / a); + rgba[o + 2] = Math.round(b / a); + } + rgba[o + 3] = Math.round((a / (SS * SS)) * 255); + } + } + const raw = Buffer.alloc(size * (size * 4 + 1)); + for (let y = 0; y < size; y += 1) { + raw[y * (size * 4 + 1)] = 0; + rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr.set([8, 6, 0, 0, 0], 8); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]); +}; + +// ICO container holding PNG images. +const ico = (images) => { + const head = Buffer.alloc(6 + images.length * 16); + head.writeUInt16LE(1, 2); + head.writeUInt16LE(images.length, 4); + let offset = head.length; + images.forEach(({ size, data }, i) => { + const e = 6 + i * 16; + head[e] = size; + head[e + 1] = size; + head.writeUInt16LE(1, e + 4); + head.writeUInt16LE(32, e + 6); + head.writeUInt32LE(data.length, e + 8); + head.writeUInt32LE(offset, e + 12); + offset += data.length; + }); + return Buffer.concat([head, ...images.map((i) => i.data)]); +}; + +writeSvg('ratatoskr.svg', squirrel); +writeSvg('ratatoskr-unread.svg', [...squirrel, ...badge('#989898')]); +writeSvg('ratatoskr-highlight.svg', [...squirrel, ...badge('#45B83B')]); + +const icon = [...background, ...squirrel]; +const put = (dir, name, size) => { + mkdirSync(join(root, 'public/res', dir), { recursive: true }); + writeFileSync(join(root, 'public/res', dir, name), png(icon, size, 0.82)); +}; +[36, 48, 72, 96, 144, 192, 256, 384, 512].forEach((s) => + put('android', `android-chrome-${s}x${s}.png`, s) +); +[57, 60, 72, 76, 114, 120, 144, 152, 167, 180].forEach((s) => + put('apple', `apple-touch-icon-${s}x${s}.png`, s) +); +writeFileSync( + join(root, 'public/favicon.ico'), + ico([16, 32, 48].map((size) => ({ size, data: png(icon, size, 0.9) }))) +); +console.log('wrote svg logos, favicon.ico, android and apple icons'); diff --git a/src/app/components/splash-screen/SplashScreen.tsx b/src/app/components/splash-screen/SplashScreen.tsx index 27adadba..8e8f05b6 100644 --- a/src/app/components/splash-screen/SplashScreen.tsx +++ b/src/app/components/splash-screen/SplashScreen.tsx @@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) { justifyContent="Center" > - Cinny + Ratatoskr diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index d3dcde96..6b304965 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -4,7 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import CinnySVG from '../../../../../public/res/svg/cinny.svg'; +import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg'; import { clearCacheAndReload } from '../../../../client/initMatrix'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -38,23 +38,23 @@ export function About({ requestClose }: AboutProps) { Cinny logo - Cinny + Ratatoskr v4.12.8 - Yet another matrix client. + GameCult's Matrix client. - + + Based on{' '} + + Cinny + {' '} + 4.12.8 by Ajay Bura and contributors. Ratatoskr is free software under the GNU + Affero General Public License v3; its source is at the Source Code link above. + diff --git a/src/app/features/settings/devices/LocalBackup.tsx b/src/app/features/settings/devices/LocalBackup.tsx index 00128c8f..4fcdacdb 100644 --- a/src/app/features/settings/devices/LocalBackup.tsx +++ b/src/app/features/settings/devices/LocalBackup.tsx @@ -28,7 +28,7 @@ function ExportKeys() { const blob = new Blob([encKeys], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'cinny-keys.txt'); + FileSaver.saveAs(blob, 'ratatoskr-keys.txt'); }, [mx] ) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index e0df06df..38115980 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -27,7 +27,7 @@ function EmailNotification() { device_display_name: email, lang: 'en', data: { - brand: 'Cinny', + brand: 'Ratatoskr', }, append: true, }); diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index b0b94319..2b888f55 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -5,20 +5,26 @@ import * as css from './styles.css'; export function AuthFooter() { return ( - + About - v4.12.8 + Source - - Twitter + + Based on Cinny Powered by Matrix diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index 3943f42d..c53e5a65 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -20,7 +20,7 @@ import { } from '../../hooks/useClientConfig'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths'; -import CinnySVG from '../../../../public/res/svg/cinny.svg'; +import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; import { ServerPicker } from './ServerPicker'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; @@ -134,8 +134,8 @@ export function AuthLayout() {

    - Cinny Logo - Cinny + Ratatoskr logo + Ratatoskr
    diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 62f46dd2..3c8641d9 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -133,7 +133,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog user: username, }, password, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }; @@ -151,7 +151,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog user: mxIdUsername, }, password, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }; const handleEmailLogin = (email: string, password: string) => { @@ -163,7 +163,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog address: email, }, password, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }; diff --git a/src/app/pages/auth/login/TokenLogin.tsx b/src/app/pages/auth/login/TokenLogin.tsx index 761d5dc5..0541742d 100644 --- a/src/app/pages/auth/login/TokenLogin.tsx +++ b/src/app/pages/auth/login/TokenLogin.tsx @@ -57,7 +57,7 @@ export function TokenLogin({ token }: TokenLoginProps) { startLogin(baseUrl, { type: 'm.login.token', token, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }, [baseUrl, token, startLogin]); diff --git a/src/app/pages/auth/register/PasswordRegisterForm.tsx b/src/app/pages/auth/register/PasswordRegisterForm.tsx index 9f173422..c9b1924c 100644 --- a/src/app/pages/auth/register/PasswordRegisterForm.tsx +++ b/src/app/pages/auth/register/PasswordRegisterForm.tsx @@ -109,7 +109,7 @@ function RegisterUIAFlow({ auth: authDict, password, username, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }, [onRegister, formData] @@ -250,7 +250,7 @@ export function PasswordRegisterForm({ auth: { session: authData.session, }, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index ce952bfc..db3eefc2 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -3,9 +3,9 @@ import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; -import LogoSVG from '../../../../public/res/svg/cinny.svg'; -import LogoUnreadSVG from '../../../../public/res/svg/cinny-unread.svg'; -import LogoHighlightSVG from '../../../../public/res/svg/cinny-highlight.svg'; +import LogoSVG from '../../../../public/res/svg/ratatoskr.svg'; +import LogoUnreadSVG from '../../../../public/res/svg/ratatoskr-unread.svg'; +import LogoHighlightSVG from '../../../../public/res/svg/ratatoskr-highlight.svg'; import NotificationSound from '../../../../public/sound/notification.ogg'; import InviteSound from '../../../../public/sound/invite.ogg'; import { notificationPermission, setFavicon } from '../../utils/dom'; diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 6a25392c..74869c3f 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds'; import { Page, PageHero, PageHeroSection } from '../../components/page'; -import CinnySVG from '../../../../public/res/svg/cinny.svg'; +import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; export function WelcomePage() { return ( @@ -14,17 +14,17 @@ export function WelcomePage() { > } - title="Welcome to Cinny" + icon={Ratatoskr logo} + title="Welcome to Ratatoskr" subTitle={ - Yet another matrix client.{' '} + GameCult's Matrix client, based on{' '} - v4.12.8 + Cinny } @@ -33,7 +33,7 @@ export function WelcomePage() { - From 87c9ca60301b5f75eba6198b35db031b3421a4bf Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 18:26:23 +0000 Subject: [PATCH 59/64] scene: cone prepass starts pass-1 rays past empty space; opt-in steps/pixel readout Pass 0 marches one ray per 8x8 tile of pass 1, each step shrunk by the widest any ray of the tile strays from it, and stores the distance no ray of the tile can have hit before (W_FAR when the whole tile climbs out above the highest geometry). Pass 1 starts from it. The distance bound moves to sdf.glsl so both passes step by the same function. onTraceStats adds a step-count render target to pass 1 and reads it back every 8th frame through a pixel-pack buffer and fence, outside the timed span. Unset, neither the output nor the target exists. prepass:false is the baseline. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 11 +- src/app/features/scene/Scene.test.ts | 43 ++++- src/app/features/scene/Scene.ts | 153 ++++++++++++++++-- src/app/features/scene/shaderSource.ts | 19 ++- .../features/scene/shaders/prepass.frag.glsl | 49 ++++++ src/app/features/scene/shaders/sdf.glsl | 95 +++++++++++ .../features/scene/shaders/terrain.frag.glsl | 122 +++----------- 7 files changed, 369 insertions(+), 123 deletions(-) create mode 100644 src/app/features/scene/shaders/prepass.frag.glsl create mode 100644 src/app/features/scene/shaders/sdf.glsl diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index be117a0a..a189b803 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -31,11 +31,16 @@ callbacks and the creature sizing rule. Nothing has drawn a creature. - `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`). - `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL `#define`s are generated from it; no number is written twice. -- `shaders/` pass 1 `terrain.frag.glsl`, pass 2 `atmosphere.frag.glsl`, `resolve.frag.glsl` (TAA), +- `shaders/` `sdf.glsl` (the distance bound pass 0 and pass 1 share), pass 0 `prepass.frag.glsl`, pass 1 `terrain.frag.glsl`, pass 2 `atmosphere.frag.glsl`, `resolve.frag.glsl` (TAA), `composite.frag.glsl`, plus CultLib's vendored GLSL (`shaders/README.md`). ## How it renders +0. **Cone prepass** (`prepass.frag.glsl`, one texel per 8x8 tile of pass 1): marches one ray per + tile, shrinking each step by the widest any ray of the tile strays from it, so every step is + free for the whole tile. It stores the distance no ray of the tile can have hit before, or + `W_FAR` when the whole tile climbs out above the highest geometry. Pass 1 starts its rays + there. `createScene({ prepass: false })` skips it (the measurement baseline). 1. **Solid pass** (`terrain.frag.glsl`, 0.4-0.75 of canvas resolution): sphere trace of `min(terrain, tree)`. Terrain height is `mask(r) * (A * fbm + E * phacelle)`, with `cultmath_fbm_grad(...).w` and `cultmath_phacelle(...).cos.w`. It writes lit colour with @@ -47,6 +52,10 @@ callbacks and the creature sizing rule. Nothing has drawn a creature. 4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES tonemap, dither. +`createScene({ onTraceStats })` measures the trace: pass 1 gets a step-count output and an extra +render target, read back every 8th frame through a pixel-pack buffer and a fence (never waited +on), and reports mean steps per pixel. Unset, neither the output nor the target exists. + ## The step bound, and what is not proven The trace steps by `(y - h(x, z)) / sqrt(1 + G^2)`, `G` the Lipschitz bound of the height field, diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index 25b7f08d..c90d1bba 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { createScene, ShaderCompileError } from './Scene'; import { LEVELS } from './budget'; +import { fragmentSource } from './shaderSource'; import { startBackgroundScene } from './index'; type Fn = Mock; @@ -94,7 +95,7 @@ describe('createScene tiers', () => { const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); expect(scene).not.toBeNull(); runFrame(1000); - expect(calls.drawArrays).toHaveBeenCalledTimes(4); // four passes, one frame + expect(calls.drawArrays).toHaveBeenCalledTimes(5); // prepass and four passes, one frame expect(rafQueue).toHaveLength(0); }); @@ -132,6 +133,44 @@ describe('createScene tiers', () => { }); }); +describe('cone prepass', () => { + const programsFor = (prepass?: boolean) => { + rafQueue.length = 0; + const { gl, calls } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass }); + return calls.createProgram.mock.calls.length; + }; + + it('is one more program than the baseline, and off means off', () => { + expect(programsFor(true)).toBe(programsFor(false) + 1); + expect(programsFor(undefined)).toBe(programsFor(true)); + }); + + it('draws before pass 1 and only when on', () => { + const drawsFor = (prepass: boolean) => { + rafQueue.length = 0; + const { gl, calls } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass }); + runFrame(1000); + return calls.drawArrays.mock.calls.length; + }; + expect(drawsFor(true)).toBe(drawsFor(false) + 1); + }); + + it('the trace-step output exists in pass 1 only when the host asked to measure', () => { + expect(fragmentSource('terrain')).not.toContain('TRACE_STATS 1'); + expect(fragmentSource('terrain', '#define TRACE_STATS 1\n')).toContain('#define TRACE_STATS 1'); + }); + + it('shares one distance bound between pass 1 and the prepass', () => { + const bound = (stage: 'terrain' | 'prepass') => + fragmentSource(stage).match(/float sceneDistance\([^)]*\)\s*\{[^}]*\}/)?.[0]; + expect(bound('prepass')).toBeDefined(); + expect(bound('prepass')).toBe(bound('terrain')); + expect(fragmentSource('atmosphere')).not.toContain('sceneDistance'); + }); +}); + describe('shader compile failure', () => { it('reports through onCompileFail, returns null, and frees the programs already linked', () => { const { gl, calls } = fakeGl(4); // terrain compiles both shaders; atmosphere's fragment is the 4th and fails @@ -265,7 +304,7 @@ describe('startBackgroundScene', () => { ); expect(scene).not.toBeNull(); runFrame(1000); - expect(calls.drawArrays).toHaveBeenCalledTimes(4); + expect(calls.drawArrays).toHaveBeenCalledTimes(5); expect(rafQueue).toHaveLength(0); }); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 40ce300d..9707a56b 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -10,8 +10,10 @@ const FRAME_INTERVAL_MS = 1000 / 30; const REDUCED_MOTION_FRAMES = 32; const MAX_DPR = 1.5; -export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; -const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite']; +export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite' | 'prepass'; +const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite', 'prepass']; +/** Pass 1 pixels per prepass tile edge. */ +const TILE = 8; export interface SceneStats { /** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */ @@ -21,6 +23,16 @@ export interface SceneStats { atmosphereSize: [number, number]; } +/** Trace cost over one finished readback: mean distance evaluations per pass-1 pixel. */ +export interface TraceStats { + /** Mean pass-1 steps per pixel. */ + traceSteps: number; + /** Mean prepass steps per tile; 0 without the prepass. */ + prepassSteps: number; + /** traceSteps plus the prepass's steps spread over the pixels of each tile. */ + stepsPerPixel: number; +} + /** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */ export type SourcePatch = (stage: Stage, source: string) => string; @@ -48,10 +60,19 @@ export interface SceneOptions { onContextLost?: () => void; /** Called per finished frame when timer queries exist. */ onStats?: (s: SceneStats) => void; + /** + * Measurement: mean sphere-trace steps per pass-1 pixel, read back every few frames. Setting it + * compiles pass 1 with a step-count output and an extra render target; unset, neither exists. + */ + onTraceStats?: (s: TraceStats) => void; /** Weather knob, 0 clear .. 1 overcast. */ coverage?: number; /** Fixed level instead of the adaptive ladder (measurement). */ fixedLevel?: Level; + /** Cone prepass that starts pass-1 rays past empty space. Default true; false is the measurement baseline. */ + prepass?: boolean; + /** Hold the camera at this pull-back progress, 0..1, instead of following login (measurement). */ + fixedPull?: number; /** Ignore the 30 fps cap and the visibility pause (measurement). */ unthrottled?: boolean; patchSource?: SourcePatch; @@ -83,13 +104,14 @@ function compile( export function linkStage( gl: WebGL2RenderingContext, stage: Stage, - patch?: SourcePatch + patch?: SourcePatch, + defines?: string ): WebGLProgram { const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex'); const fs = compile( gl, gl.FRAGMENT_SHADER, - patch ? patch(stage, fragmentSource(stage)) : fragmentSource(stage), + patch ? patch(stage, fragmentSource(stage, defines)) : fragmentSource(stage, defines), stage ); const prog = gl.createProgram()!; @@ -143,13 +165,20 @@ export function createScene(opts: SceneOptions): Scene | null { GPU_DISJOINT_EXT: number; } | null; + const usePrepass = opts.prepass ?? true; + const passes = usePrepass ? PASSES : PASSES.filter((p) => p !== 'prepass'); const programs = {} as Record; try { - PASSES.forEach((p) => { - programs[p] = linkStage(gl, p, opts.patchSource); + passes.forEach((p) => { + programs[p] = linkStage( + gl, + p, + opts.patchSource, + p === 'terrain' && opts.onTraceStats ? '#define TRACE_STATS 1\n' : undefined + ); }); } catch (e) { - PASSES.forEach((p) => { + passes.forEach((p) => { if (programs[p]) gl.deleteProgram(programs[p]); }); if (!(e instanceof ShaderCompileError)) throw e; @@ -180,15 +209,18 @@ export function createScene(opts: SceneOptions): Scene | null { const fbos = { solid: gl.createFramebuffer()!, + pre: gl.createFramebuffer()!, atm: gl.createFramebuffer()!, hist: [gl.createFramebuffer()!, gl.createFramebuffer()!], }; let solid: Target; let depth: Target; + let steps: Target | undefined; + let startTex: Target; let atm: Target; let hist: [Target, Target]; const freeTargets = () => { - [solid, depth, atm, hist?.[0], hist?.[1]].forEach((t) => { + [solid, depth, steps, startTex, atm, hist?.[0], hist?.[1]].forEach((t) => { if (t) gl.deleteTexture(t.tex); }); }; @@ -205,6 +237,71 @@ export function createScene(opts: SceneOptions): Scene | null { const ladder = new BudgetLadder(GPU_BUDGET_MS, 45, tier === 'low' ? LOW_START_LEVEL : 0); const level = (): Level => opts.fixedLevel ?? (still ? STILL_LEVEL : ladder.current); + // Trace-step readback: one pixel-pack read in flight at a time, polled by fence, never waited on. + // Pass 1's step bytes and the prepass's RGBA floats (g: steps) come back together. + const READBACK_EVERY = 8; + interface Readback { + fence: WebGLSync; + pixels: number; + tiles: number; + } + let readback: Readback | undefined; + const packSteps = opts.onTraceStats ? gl.createBuffer() : null; + const packStart = opts.onTraceStats && usePrepass ? gl.createBuffer() : null; + const discardReadback = () => { + if (readback) gl.deleteSync(readback.fence); + readback = undefined; + }; + const startReadback = () => { + if (!steps || readback) return; + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps); + gl.bufferData(gl.PIXEL_PACK_BUFFER, steps.w * steps.h, gl.STREAM_READ); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); + gl.readBuffer(gl.COLOR_ATTACHMENT0 + 2); + gl.readPixels(0, 0, steps.w, steps.h, gl.RED, gl.UNSIGNED_BYTE, 0); + let tiles = 0; + if (packStart) { + tiles = startTex.w * startTex.h; + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart); + gl.bufferData(gl.PIXEL_PACK_BUFFER, tiles * 16, gl.STREAM_READ); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre); + gl.readPixels(0, 0, startTex.w, startTex.h, gl.RGBA, gl.FLOAT, 0); + } + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); + readback = { + fence: gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0)!, + pixels: steps.w * steps.h, + tiles, + }; + }; + const pollReadback = () => { + if (!readback) return; + const status = gl.clientWaitSync(readback.fence, 0, 0); + if (status !== gl.ALREADY_SIGNALED && status !== gl.CONDITION_SATISFIED) return; + const { pixels, tiles } = readback; + const bytes = new Uint8Array(pixels); + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps); + gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, bytes); + let preSum = 0; + if (packStart) { + const floats = new Float32Array(tiles * 4); + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart); + gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, floats); + for (let i = 1; i < floats.length; i += 4) preSum += floats[i]; + } + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); + let sum = 0; + for (let i = 0; i < bytes.length; i += 1) sum += bytes[i]; + discardReadback(); + const traceSteps = sum / pixels; + const prepassSteps = tiles ? preSum / tiles : 0; + opts.onTraceStats?.({ + traceSteps, + prepassSteps, + stepsPerPixel: traceSteps + (prepassSteps * tiles) / pixels, + }); + }; + let canvasW = 0; let canvasH = 0; let sizedFor: Level | undefined; @@ -228,9 +325,16 @@ export function createScene(opts: SceneOptions): Scene | null { const ah = Math.max(2, Math.round(h * lv.atmosphereScale)); solid = makeTarget(sw, sh, gl.RGBA16F, gl.LINEAR); depth = makeTarget(sw, sh, gl.R16F, gl.NEAREST); + // Without the prepass pass 1 reads a 1x1 zero start: every ray from the camera, as before. + startTex = usePrepass + ? makeTarget(Math.ceil(sw / TILE), Math.ceil(sh / TILE), gl.RGBA32F, gl.NEAREST) + : makeTarget(1, 1, gl.RGBA32F, gl.NEAREST); + steps = opts.onTraceStats ? makeTarget(sw, sh, gl.R8, gl.NEAREST) : undefined; atm = makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR); hist = [makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR), makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR)]; - attach(fbos.solid, [solid, depth]); + attach(fbos.solid, steps ? [solid, depth, steps] : [solid, depth]); + discardReadback(); + attach(fbos.pre, [startTex]); attach(fbos.atm, [atm]); attach(fbos.hist[0], [hist[0]]); attach(fbos.hist[1], [hist[1]]); @@ -274,10 +378,10 @@ export function createScene(opts: SceneOptions): Scene | null { const row = finished.get(f) ?? {}; if (!disjoint) row[pass] = ns / 1e6; finished.set(f, row); - if (Object.keys(row).length === PASSES.length) { + if (Object.keys(row).length === passes.length) { finished.delete(f); - const passMs = row as Record; - const total = PASSES.reduce((s, p) => s + passMs[p], 0); + const passMs = { prepass: 0, ...row } as Record; + const total = passes.reduce((s, p) => s + passMs[p], 0); if (!opts.fixedLevel && ladder.push(total)) sizedFor = undefined; opts.onStats?.({ passMs, @@ -318,12 +422,26 @@ export function createScene(opts: SceneOptions): Scene | null { const time = frozen ? 0 : (now - start) / 1000; let elapsed: number | undefined; if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000; - const pose = cameraPose(pullProgress(elapsed), time); + const pose = cameraPose(opts.fixedPull ?? pullProgress(elapsed), time); const f = frame; frame += 1; const jx = halton((f % 16) + 1, 2) - 0.5; const jy = halton((f % 16) + 1, 3) - 0.5; + // Pass 0: cone march at one ray per 8x8 tile of pass 1, the start distance for its rays. + if (usePrepass) { + timed('prepass', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre); + gl.viewport(0, 0, startTex.w, startTex.h); + const p = programs.prepass; + gl.useProgram(p); + setCommon(p, pose, time); + gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps); + gl.uniform2f(loc(p, 'u_tileHalf'), 0.5 / startTex.w, 0.5 / startTex.h); + draw(); + }); + } + // Pass 1: solid surfaces, writes colour and depth. timed('terrain', f, () => { gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); @@ -336,8 +454,10 @@ export function createScene(opts: SceneOptions): Scene | null { gl.uniform3fv(loc(p, 'u_bark'), PALETTE.bark as unknown as number[]); gl.uniform3fv(loc(p, 'u_leaf'), PALETTE.leaf as unknown as number[]); gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps); + bindTex(0, startTex, p, 'u_start'); draw(); }); + if (f % READBACK_EVERY === 0) startReadback(); // outside the timed span: not pass 1's cost // Pass 2: atmosphere up to pass 1's depth, low-res. timed('atmosphere', f, () => { @@ -393,6 +513,7 @@ export function createScene(opts: SceneOptions): Scene | null { prev = pose; resetHistory = false; collect(); + pollReadback(); }; const tick = (now: number) => { @@ -468,9 +589,11 @@ export function createScene(opts: SceneOptions): Scene | null { if (disposed) return; stop(); freeTargets(); - PASSES.forEach((p) => gl.deleteProgram(programs[p])); + passes.forEach((p) => gl.deleteProgram(programs[p])); + discardReadback(); + [packSteps, packStart].forEach((b) => b && gl.deleteBuffer(b)); gl.deleteVertexArray(vao); - [fbos.solid, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb)); + [fbos.solid, fbos.pre, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb)); }, }; } diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts index c7a2a03f..42631d57 100644 --- a/src/app/features/scene/shaderSource.ts +++ b/src/app/features/scene/shaderSource.ts @@ -2,6 +2,8 @@ import cultmath from './shaders/CultMath.glsl?raw'; import phacelle from './shaders/CultMath.Phacelle.glsl?raw'; import common from './shaders/common.glsl?raw'; import fullscreenVert from './shaders/fullscreen.vert.glsl?raw'; +import sdf from './shaders/sdf.glsl?raw'; +import prepassFrag from './shaders/prepass.frag.glsl?raw'; import terrainFrag from './shaders/terrain.frag.glsl?raw'; import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw'; import resolveFrag from './shaders/resolve.frag.glsl?raw'; @@ -9,7 +11,7 @@ import compositeFrag from './shaders/composite.frag.glsl?raw'; import { WORLD, worldDefines } from './world'; import { trimGlsl } from './cultmathTrim'; -export type Stage = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; +export type Stage = 'prepass' | 'terrain' | 'atmosphere' | 'resolve' | 'composite'; const HEADER = '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; @@ -17,6 +19,7 @@ const HEADER = export const vertexSource = HEADER + fullscreenVert; const stages: Record = { + prepass: prepassFrag, terrain: terrainFrag, atmosphere: atmosphereFrag, resolve: resolveFrag, @@ -25,10 +28,14 @@ const stages: Record = { /** * Fragment source for a stage: header, world defines, the part of CultMath the stage reaches, the - * shared block, the stage. Phacelle stays a separate file, added only where it is called. + * shared block, the distance bound (stages that trace), the stage. `defines` is whole `#define` + * lines (measurement flags) ahead of the world's. Phacelle stays a separate file, added only where + * it is called. */ -export function fragmentSource(stage: Stage): string { - const erosion = stage === 'terrain' && WORLD.phacelleGradBound > 0 ? phacelle : ''; - const math = trimGlsl(cultmath, [erosion, common, stages[stage]]); - return `${HEADER}${worldDefines()}\n${math}\n${erosion}\n${common}\n${stages[stage]}`; +export function fragmentSource(stage: Stage, defines = ''): string { + const solid = stage === 'terrain' || stage === 'prepass'; + const erosion = solid && WORLD.phacelleGradBound > 0 ? phacelle : ''; + const world = solid ? sdf : ''; + const math = trimGlsl(cultmath, [erosion, common, world, stages[stage]]); + return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${stages[stage]}`; } diff --git a/src/app/features/scene/shaders/prepass.frag.glsl b/src/app/features/scene/shaders/prepass.frag.glsl new file mode 100644 index 00000000..3fe840fc --- /dev/null +++ b/src/app/features/scene/shaders/prepass.frag.glsl @@ -0,0 +1,49 @@ +// Pass 0: a cone march at one ray per tile of pass-1 pixels. Writes the distance along the tile's +// centre ray that no ray in the tile can have hit before, so pass 1 starts there instead of at the +// camera. Every step is safe for every ray of the tile: the cone's radius at t is the widest any +// ray strays from the centre ray, and the centre's distance bound minus that radius is a free ball +// around each ray's own point. +uniform int u_maxSteps; +uniform vec2 u_tileHalf; // half a tile in uv: the footprint of this texel, as pass 1 samples it + +in vec2 v_uv; +layout(location = 0) out vec4 o_start; // r: start distance, W_FAR when the tile misses; g: steps spent + +void main() +{ + vec3 ro = u_camPos; + vec3 rd = camRay(v_uv); + + // Widest stray of any ray in the tile from the centre ray, per unit t. The rays are the unit + // directions through a rectangle on the view plane, whose sublevel sets about rd are convex, so + // the maximum sits at a corner. The margin is for float rounding. + float spread = 0.0; + for (int c = 0; c < 4; c++) + { + vec2 corner = vec2(c & 1, c >> 1) * 2.0 - 1.0; + spread = max(spread, length(camRay(v_uv + corner * u_tileHalf) - rd)); + } + spread *= 1.05; + + // Past tEnd every ray of the tile is above W_MAX_HEIGHT and still rising (or beyond W_FAR): + // none can hit anything. + float rise = rd.y - spread; + float tEnd = W_FAR; + if (rise > 0.0) + tEnd = min(W_FAR, max(0.0, (W_MAX_HEIGHT - ro.y) / rise)); + + float t = 0.0; + float leafness; + bool onTree; + int evals = 0; + for (int i = 0; i < u_maxSteps && t < tEnd; i++) + { + evals++; + // Pass 1's hit tolerance is kept clear, so a ray never starts inside its own hit radius. + float s = sceneDistance(ro + rd * t, leafness, onTree) - spread * t - (0.0006 * t + 0.01); + if (s <= 0.0) + break; + t += s; + } + o_start = vec4(t >= tEnd ? W_FAR : t, float(evals), 0.0, 0.0); +} diff --git a/src/app/features/scene/shaders/sdf.glsl b/src/app/features/scene/shaders/sdf.glsl new file mode 100644 index 00000000..95e96bc2 --- /dev/null +++ b/src/app/features/scene/shaders/sdf.glsl @@ -0,0 +1,95 @@ +// The solid world as a distance bound: terrain graph plus tree. Shared by the pass-1 trace and the +// cone prepass, so both step by the same bound. + +// ---- terrain ------------------------------------------------------------------------------- + +float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); } + +// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap. +float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; } + +#if W_ERODE_ENABLED +CultPhasor terrainPhacelle(vec2 xz) +{ + // A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion. + return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM); +} +#endif + +// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the +// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated. +float terrainDistance(vec3 p) +{ + float m = terrainMask(p.xz); + // Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most + // of their steps here. + if (m <= 0.0) + return p.y * W_SLOPE_ENV_INV; + float hb = m * W_AMP * terrainFbm(p.xz); + float above = p.y - (hb + m * W_ERODE_AMP); + if (above > 0.0) + return above * W_SLOPE_ENV_INV; +#if W_ERODE_ENABLED + hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w; +#endif + return (p.y - hb) * W_SLOPE_FULL_INV; +} + +vec3 terrainNormal(vec2 xz) +{ + float r = length(xz); + float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r); + float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0); + float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0; + vec2 dir = r > 0.0 ? xz / r : vec2(0.0); + vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); + float f = fg.w; + vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir; +#if W_ERODE_ENABLED + CultPhasor ph = terrainPhacelle(xz); + g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir; +#endif + return normalize(vec3(-g.x, 1.0, -g.y)); +} + +// ---- world tree ---------------------------------------------------------------------------- + +float smin(float a, float b, float k) +{ + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h * h * k * 0.25; +} + +// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz. +float treeDistance(vec3 p, out float leafness) +{ + vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0); + float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y); + leafness = 0.0; + if (bound > 8.0) + return bound; + + float ang = atan(p.z, p.x); + float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0); + float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15)); + float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7; + + vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R; + float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0)); + float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp; + canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L) + + float d = smin(trunk, canopy, 6.0); + leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0); + return d; +} + +// ---- trace --------------------------------------------------------------------------------- + +float sceneDistance(vec3 p, out float leafness, out bool onTree) +{ + float dt = terrainDistance(p); + float dr = treeDistance(p, leafness); + onTree = dr < dt; + return min(dt, dr); +} diff --git a/src/app/features/scene/shaders/terrain.frag.glsl b/src/app/features/scene/shaders/terrain.frag.glsl index b8f2f718..5946cc4a 100644 --- a/src/app/features/scene/shaders/terrain.frag.glsl +++ b/src/app/features/scene/shaders/terrain.frag.glsl @@ -5,128 +5,49 @@ uniform vec3 u_rock; uniform vec3 u_bark; uniform vec3 u_leaf; uniform int u_maxSteps; +uniform sampler2D u_start; // prepass: r = distance no ray of this pixel's tile hits before; W_FAR = tile misses in vec2 v_uv; layout(location = 0) out vec4 o_color; // rgb lit, a coverage layout(location = 1) out float o_depth; // hit distance along the ray, W_FAR on a miss - -// ---- terrain ------------------------------------------------------------------------------- - -float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); } - -// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap. -float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; } - -#if W_ERODE_ENABLED -CultPhasor terrainPhacelle(vec2 xz) -{ - // A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion. - return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM); -} +#ifdef TRACE_STATS +layout(location = 2) out float o_steps; // distance evaluations this ray spent, over 255 (measurement only) #endif -// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the -// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated. -float terrainDistance(vec3 p) -{ - float m = terrainMask(p.xz); - // Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most - // of their steps here. - if (m <= 0.0) - return p.y * W_SLOPE_ENV_INV; - float hb = m * W_AMP * terrainFbm(p.xz); - float above = p.y - (hb + m * W_ERODE_AMP); - if (above > 0.0) - return above * W_SLOPE_ENV_INV; -#if W_ERODE_ENABLED - hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w; -#endif - return (p.y - hb) * W_SLOPE_FULL_INV; -} - -vec3 terrainNormal(vec2 xz) -{ - float r = length(xz); - float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r); - float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0); - float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0; - vec2 dir = r > 0.0 ? xz / r : vec2(0.0); - vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); - float f = fg.w; - vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir; -#if W_ERODE_ENABLED - CultPhasor ph = terrainPhacelle(xz); - g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir; -#endif - return normalize(vec3(-g.x, 1.0, -g.y)); -} - -// ---- world tree ---------------------------------------------------------------------------- - -float smin(float a, float b, float k) -{ - float h = max(k - abs(a - b), 0.0) / k; - return min(a, b) - h * h * k * 0.25; -} - -// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz. -float treeDistance(vec3 p, out float leafness) -{ - vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0); - float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y); - leafness = 0.0; - if (bound > 8.0) - return bound; - - float ang = atan(p.z, p.x); - float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0); - float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15)); - float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7; - - vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R; - float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0)); - float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp; - canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L) - - float d = smin(trunk, canopy, 6.0); - leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0); - return d; -} - // ---- trace --------------------------------------------------------------------------------- -float sceneDistance(vec3 p, out float leafness, out bool onTree) -{ - float dt = terrainDistance(p); - float dr = treeDistance(p, leafness); - onTree = dr < dt; - return min(dt, dr); -} - void main() { vec3 ro = u_camPos; vec3 rd = camRay(v_uv); - float t = 0.0; - // Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. + float t = texture(u_start, v_uv).r; + // Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. A tile the + // prepass proved empty arrives as W_FAR. + bool missAtOnce = t >= W_FAR; if (ro.y > W_MAX_HEIGHT) { - if (rd.y >= 0.0) - { - o_color = vec4(0.0); - o_depth = W_FAR; - return; - } - t = (W_MAX_HEIGHT - ro.y) / rd.y; + missAtOnce = missAtOnce || rd.y >= 0.0; + t = max(t, (W_MAX_HEIGHT - ro.y) / rd.y); + } + if (missAtOnce) + { + o_color = vec4(0.0); + o_depth = W_FAR; +#ifdef TRACE_STATS + o_steps = 0.0; +#endif + return; } bool hit = false; bool onTree = false; float leafness = 0.0; int i = 0; + int evals = 0; for (; i < u_maxSteps; i++) { + evals++; vec3 p = ro + rd * t; float d = sceneDistance(p, leafness, onTree); if (d < 0.0006 * t + 0.01) @@ -145,6 +66,9 @@ void main() hit = true; sceneDistance(ro + rd * t, leafness, onTree); } +#ifdef TRACE_STATS + o_steps = float(evals) / 255.0; +#endif if (!hit) { From b055b2a5af2080a64b62a8642d4b0482f54d3714 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 18:26:23 +0000 Subject: [PATCH 60/64] harness: trace and diff modes, standalone demo page with steps/pixel readout trace: steps per pixel and pass ms at three fixed camera poses, PREPASS=0,1 arms. diff: one frozen frame with and without the prepass, against a 512-step render. bundle.cjs demo: full-window page with a prepass switch and the readout. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/README.md | 2 + harness/scene/bundle.cjs | 11 ++- harness/scene/demo.ts | 59 ++++++++++++++++ harness/scene/drive.cjs | 145 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 harness/scene/demo.ts diff --git a/harness/scene/README.md b/harness/scene/README.md index 382819ec..ef735d2e 100644 --- a/harness/scene/README.md +++ b/harness/scene/README.md @@ -8,6 +8,8 @@ Chromium. Not shipped, not run by CI. node drive.cjs shots # rest, mid pull-back and pulled-back screenshots node drive.cjs measure # GPU time per pass by timer query, two viewports, two levels node drive.cjs variants # the same with source patches (no erosion, no tree, value-only fbm) + node drive.cjs trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms + node drive.cjs diff # one frozen frame with and without the prepass, pixel diff and both PNGs node drive.cjs phacelle # the Phacelle gradient probe behind world.ts `phacelleGradBound` Needs `esbuild` and `playwright@1.48.2` on `NODE_PATH` and a Chromium. The first run was done on a diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs index 62a47078..36e2307a 100644 --- a/harness/scene/bundle.cjs +++ b/harness/scene/bundle.cjs @@ -1,9 +1,10 @@ // Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html. -// usage: node bundle.cjs (esbuild from NODE_PATH) +// usage: node bundle.cjs [demo] (esbuild from NODE_PATH). `demo` bundles the standalone page. const esbuild = require('esbuild'); const fs = require('fs'); const path = require('path'); const out = process.argv[2]; +const demo = process.argv[3] === 'demo'; fs.mkdirSync(out, { recursive: true }); const rawPlugin = { name: 'raw', @@ -20,7 +21,7 @@ const rawPlugin = { }; esbuild .build({ - entryPoints: [path.join(__dirname, 'entry.ts')], + entryPoints: [path.join(__dirname, demo ? 'demo.ts' : 'entry.ts')], bundle: true, outfile: path.join(out, 'bundle.js'), plugins: [rawPlugin], @@ -30,6 +31,10 @@ esbuild .then(() => { fs.writeFileSync( path.join(out, 'index.html'), - '' + 'Ratatoskr scene: trace prepass' + + (demo + ? '
    ' + : '') + + '' ); }); diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts new file mode 100644 index 00000000..507692d7 --- /dev/null +++ b/harness/scene/demo.ts @@ -0,0 +1,59 @@ +// Standalone page: the scene full-window with a readout of pass ms and trace steps per pixel, and +// a switch for the cone prepass. Not shipped. +import { createScene, Scene, SceneStats, TraceStats } from '../../src/app/features/scene/Scene'; +import { LEVELS } from '../../src/app/features/scene/budget'; + +const canvas = document.getElementById('c') as HTMLCanvasElement; +const out = document.getElementById('out') as HTMLPreElement; +const box = document.getElementById('prepass') as HTMLInputElement; +const pull = document.getElementById('pull') as HTMLInputElement; +const timerless = (() => { + const g = document.createElement('canvas').getContext('webgl2'); + return !g?.getExtension('EXT_disjoint_timer_query_webgl2'); +})(); + +let scene: Scene | null = null; +let stats: SceneStats | undefined; +let trace: TraceStats | undefined; + +function start() { + scene?.dispose(); + stats = trace = undefined; + // A fresh canvas: a context cannot change its attributes, and dispose leaves the old one bound. + const fresh = canvas.cloneNode() as HTMLCanvasElement; + canvas.replaceWith(fresh); + scene = createScene({ + canvas: fresh, + unthrottled: true, + fixedLevel: LEVELS[0], + fixedPull: +pull.value, + prepass: box.checked, + onStats: (s) => (stats = s), + onTraceStats: (t) => (trace = t), + }); + if (!scene) out.textContent = 'createScene returned null: no WebGL2 or float colour buffers'; +} + +setInterval(() => { + if (!scene) return; + const lines = [`prepass ${box.checked ? 'on' : 'off'} pull ${pull.value}`]; + if (stats) { + const ms = stats.passMs; + const total = Object.values(ms).reduce((a, b) => a + b, 0); + lines.push( + `gpu ms prepass ${ms.prepass.toFixed(2)} terrain ${ms.terrain.toFixed(2)} atmosphere ${ms.atmosphere.toFixed(2)} resolve ${ms.resolve.toFixed(2)} composite ${ms.composite.toFixed(2)} total ${total.toFixed(2)}`, + `solid ${stats.solidSize.join('x')}` + ); + } else if (timerless) { + lines.push('gpu ms: this browser has no EXT_disjoint_timer_query_webgl2'); + } + if (trace) + lines.push( + `steps/pixel ${trace.stepsPerPixel.toFixed(2)} (pass 1 ${trace.traceSteps.toFixed(2)}, prepass ${trace.prepassSteps.toFixed(1)} per tile)` + ); + out.textContent = lines.join('\n'); +}, 500); + +box.addEventListener('change', start); +pull.addEventListener('input', start); +start(); diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs index 74c4a8b0..0f34191f 100644 --- a/harness/scene/drive.cjs +++ b/harness/scene/drive.cjs @@ -3,6 +3,7 @@ // Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report. const { chromium } = require('playwright'); const path = require('path'); +const fs = require('fs'); const [, , dir, mode, outdir = '.'] = process.argv; (async () => { @@ -15,6 +16,7 @@ const [, , dir, mode, outdir = '.'] = process.argv; ], }); const page = await b.newPage({ viewport: { width: 960, height: 540 } }); + page.on('pageerror', (e) => process.stderr.write(`[pageerror] ${e}\n`)); page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`)); await page.goto('file://' + path.resolve(dir, 'index.html')); const report = { mode }; @@ -156,6 +158,149 @@ const [, , dir, mode, outdir = '.'] = process.argv; } } + if (mode === 'trace') { + // Steps per pixel and pass ms at fixed camera poses, one scene per run. PREPASS=0,1 picks the arms. + const arms = (process.env.PREPASS || '1').split(',').map((x) => x === '1'); + report.runs = []; + for (const [w, h] of process.env.PORTRAIT === '0' + ? [[960, 540]] + : [ + [960, 540], + [540, 1200], + ]) { + for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) { + for (const prepass of arms) { + await page.setViewportSize({ width: w, height: h }); + const r = await page.evaluate( + ({ pull, prepass, FRAMES }) => + new Promise((res) => { + const rows = []; + const trace = []; + let done = false; + const finish = () => { + if (done || rows.length < FRAMES || trace.length < 4) return; + done = true; + s.dispose(); + const keep = rows.slice(FRAMES >> 2); + const med = (xs) => xs.sort((a, b) => a - b)[xs.length >> 1]; + const tk = trace.slice(1); + const mean = (k) => tk.reduce((a, t) => a + t[k], 0) / tk.length; + res({ + solid: keep[0].solidSize, + readbacks: tk.length, + traceSteps: +mean('traceSteps').toFixed(2), + prepassSteps: +mean('prepassSteps').toFixed(2), + stepsPerPixel: +mean('stepsPerPixel').toFixed(2), + medianMs: Object.fromEntries( + Object.keys(keep[0].passMs).map((k) => [k, +med(keep.map((x) => x.passMs[k])).toFixed(2)]) + ), + }); + }; + const s = window.__scene.createScene({ + canvas: document.getElementById('c'), + unthrottled: true, + fixedLevel: window.__scene.LEVELS[0], + fixedPull: pull, + prepass, + onTraceStats: (t) => { + trace.push(t); + finish(); + }, + onStats: (st) => { + rows.push(st); + finish(); + }, + }); + if (!s) res({ error: 'createScene returned null' }); + }), + { pull, prepass, FRAMES: +(process.env.FRAMES || 64) } + ); + report.runs.push({ viewport: [w, h], pull, prepass, ...r }); + } + } + } + } + + if (mode === 'diff') { + // Same frozen frame (reduced motion: time 0, 32 frames) with and without the prepass, compared + // pixel by pixel. The prepass is meant to change cost, not the picture. + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 640, height: 360 }); + const shoot = async (pull, prepass, steps) => { + await page.evaluate( + ({ pull, prepass, steps }) => { + // Reduced motion bounds the scene to 32 frames; each asks for the next with rAF, so the + // count stops moving when it is done. Timer-query callbacks lag too far to count on. + window.__raf = 0; + if (!window.__rafPatched) { + window.__rafPatched = true; + const raf = window.requestAnimationFrame.bind(window); + window.requestAnimationFrame = (cb) => (window.__raf++, raf(cb)); + } + window.__d = window.__scene.createScene({ + canvas: document.getElementById('c'), + fixedLevel: { ...window.__scene.LEVELS[0], traceSteps: steps }, + fixedPull: pull, + prepass, + }); + }, + { pull, prepass, steps } + ); + for (let last = -1, still = 0; still < 3; await page.waitForTimeout(1000)) { + const n = await page.evaluate(() => window.__raf); + still = n === last ? still + 1 : 0; + last = n; + } + const png = await page.screenshot(); + await page.evaluate(() => window.__d.dispose()); + return png.toString('base64'); + }; + // The step cap makes a grazing ray give up and count as a hit, so the baseline is compared + // with a 512-step render of the same frame as well: whichever is closer to it is the more + // faithful one. + const decode = (x) => + page.evaluate( + (x) => + new Promise((r) => { + const i = new Image(); + i.onload = () => { + const c = document.createElement('canvas'); + c.width = i.width; + c.height = i.height; + const g = c.getContext('2d'); + g.drawImage(i, 0, 0); + r(Array.from(g.getImageData(0, 0, i.width, i.height).data)); + }; + i.src = 'data:image/png;base64,' + x; + }), + x + ); + const compare = (da, db) => { + let max = 0, over = 0, sum = 0; + const px = da.length / 4; + for (let i = 0; i < da.length; i += 4) { + let m = 0; + for (let k = 0; k < 3; k++) m = Math.max(m, Math.abs(da[i + k] - db[i + k])); + sum += m; + if (m > max) max = m; + if (m > 2) over++; + } + return { maxChannelDiff: max, pixelsOver2of255: over, ofPixels: px, meanMaxChannelDiff: +(sum / px).toFixed(3) }; + }; + report.diffs = []; + for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) { + const png = { + off: await shoot(pull, false, 96), + on: await shoot(pull, true, 96), + ref: await shoot(pull, false, 512), + off2: await shoot(pull, false, 96), + }; + for (const k of Object.keys(png)) fs.writeFileSync(path.join(outdir, `diff-${k}-${pull}.png`), Buffer.from(png[k], 'base64')); + const [off, on, ref, off2] = [await decode(png.off), await decode(png.on), await decode(png.ref), await decode(png.off2)]; + report.diffs.push({ pull, offVsOffAgain: compare(off, off2), offVsOn: compare(off, on), offVsRef512: compare(off, ref), onVsRef512: compare(on, ref) }); + } + } + console.log(JSON.stringify(report, null, 1)); await b.close(); })().catch((e) => { From 12469671c9870f801f04ada621768b6207aa6fb0 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 18:35:11 +0000 Subject: [PATCH 61/64] scene: pack step bytes tightly; keep the hit tolerance clear at the far end of a prepass step Soul pass on 87c9ca60: the R8 steps read used the default PACK_ALIGNMENT of 4, so at widths not divisible by 4 the buffer was too small (readPixels fails) and padding would be summed. Portrait runs read 0 steps/pixel. The prepass step now divides by 1.0006 so pass 1's tolerance, which grows with t, stays clear at the end of the step. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/Scene.test.ts | 14 ++++++++++++++ src/app/features/scene/Scene.ts | 2 ++ src/app/features/scene/shaders/prepass.frag.glsl | 5 +++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index c90d1bba..c403018a 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -162,6 +162,20 @@ describe('cone prepass', () => { expect(fragmentSource('terrain', '#define TRACE_STATS 1\n')).toContain('#define TRACE_STATS 1'); }); + it('reads the step bytes tightly packed, whatever the width', () => { + rafQueue.length = 0; + const { gl, calls } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), onTraceStats: () => {} }); + runFrame(1000); + // fake constants are all 1: PACK_ALIGNMENT is 1, so a restore to 4 is the only other call + const packs = calls.pixelStorei.mock.calls.map((c) => c[1]); + expect(packs[0]).toBe(1); + expect(packs).toContain(4); + expect(calls.readPixels.mock.invocationCallOrder[0]).toBeGreaterThan( + calls.pixelStorei.mock.invocationCallOrder[0] + ); + }); + it('shares one distance bound between pass 1 and the prepass', () => { const bound = (stage: 'terrain' | 'prepass') => fragmentSource(stage).match(/float sceneDistance\([^)]*\)\s*\{[^}]*\}/)?.[0]; diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 9707a56b..740c5c0c 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -254,6 +254,7 @@ export function createScene(opts: SceneOptions): Scene | null { }; const startReadback = () => { if (!steps || readback) return; + gl.pixelStorei(gl.PACK_ALIGNMENT, 1); // R8 rows are not 4-byte multiples at every width gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps); gl.bufferData(gl.PIXEL_PACK_BUFFER, steps.w * steps.h, gl.STREAM_READ); gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); @@ -268,6 +269,7 @@ export function createScene(opts: SceneOptions): Scene | null { gl.readPixels(0, 0, startTex.w, startTex.h, gl.RGBA, gl.FLOAT, 0); } gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); + gl.pixelStorei(gl.PACK_ALIGNMENT, 4); readback = { fence: gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0)!, pixels: steps.w * steps.h, diff --git a/src/app/features/scene/shaders/prepass.frag.glsl b/src/app/features/scene/shaders/prepass.frag.glsl index 3fe840fc..691cfba9 100644 --- a/src/app/features/scene/shaders/prepass.frag.glsl +++ b/src/app/features/scene/shaders/prepass.frag.glsl @@ -39,8 +39,9 @@ void main() for (int i = 0; i < u_maxSteps && t < tEnd; i++) { evals++; - // Pass 1's hit tolerance is kept clear, so a ray never starts inside its own hit radius. - float s = sceneDistance(ro + rd * t, leafness, onTree) - spread * t - (0.0006 * t + 0.01); + // Pass 1's hit tolerance, 0.0006 t + 0.01, is kept clear at the far end of the step too, so + // no ray of the tile starts inside its own hit radius. + float s = (sceneDistance(ro + rd * t, leafness, onTree) - spread * t - (0.0006 * t + 0.01)) / 1.0006; if (s <= 0.0) break; t += s; From 50ffd905602fcbbeae564caf31978e6a4912c208 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 18:47:12 +0000 Subject: [PATCH 62/64] harness: demo page keeps hold of the canvas it swaps in, so the prepass switch works twice Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/demo.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts index 507692d7..ba88e507 100644 --- a/harness/scene/demo.ts +++ b/harness/scene/demo.ts @@ -3,7 +3,7 @@ import { createScene, Scene, SceneStats, TraceStats } from '../../src/app/features/scene/Scene'; import { LEVELS } from '../../src/app/features/scene/budget'; -const canvas = document.getElementById('c') as HTMLCanvasElement; +let canvas = document.getElementById('c') as HTMLCanvasElement; const out = document.getElementById('out') as HTMLPreElement; const box = document.getElementById('prepass') as HTMLInputElement; const pull = document.getElementById('pull') as HTMLInputElement; @@ -22,6 +22,7 @@ function start() { // A fresh canvas: a context cannot change its attributes, and dispose leaves the old one bound. const fresh = canvas.cloneNode() as HTMLCanvasElement; canvas.replaceWith(fresh); + canvas = fresh; scene = createScene({ canvas: fresh, unthrottled: true, From 7e0586643757bd798091bdfdac5a419293b2566d Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 20:39:22 +0000 Subject: [PATCH 63/64] scene: half-float prepass start target where EXT_color_buffer_float is missing; say why createScene is null Phone Chrome without EXT_color_buffer_float returned null with one message. Rendering to RGBA16F only needs EXT_color_buffer_half_float, and every other target is already a half, so only the prepass start (RGBA32F) needed a fallback. In half mode the prepass writes t*(1-2^-10) - 2^-15, which stays below t after the half rounds to nearest, so pass 1 still starts at or before the true distance. W_FAR stays exact. onUnavailable names no-webgl2 vs neither float extension; Scene.startFormat and options.halfStart (force, for tests and the harness). The standalone page shows the reason, renderer, vendor, UA and extension list. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/bundle.cjs | 2 +- harness/scene/demo.ts | 48 +++++++++- src/app/features/scene/Scene.test.ts | 91 ++++++++++++++++++- src/app/features/scene/Scene.ts | 57 ++++++++++-- .../features/scene/shaders/prepass.frag.glsl | 11 ++- 5 files changed, 190 insertions(+), 19 deletions(-) diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs index 36e2307a..027a2006 100644 --- a/harness/scene/bundle.cjs +++ b/harness/scene/bundle.cjs @@ -33,7 +33,7 @@ esbuild path.join(out, 'index.html'), 'Ratatoskr scene: trace prepass' + (demo - ? '
    ' + ? '
    device
    ' : '') + '' ); diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts index ba88e507..40424ff5 100644 --- a/harness/scene/demo.ts +++ b/harness/scene/demo.ts @@ -1,17 +1,55 @@ // Standalone page: the scene full-window with a readout of pass ms and trace steps per pixel, and // a switch for the cone prepass. Not shipped. -import { createScene, Scene, SceneStats, TraceStats } from '../../src/app/features/scene/Scene'; +import { + createScene, + Scene, + SceneStats, + SceneUnavailable, + TraceStats, +} from '../../src/app/features/scene/Scene'; import { LEVELS } from '../../src/app/features/scene/budget'; let canvas = document.getElementById('c') as HTMLCanvasElement; const out = document.getElementById('out') as HTMLPreElement; const box = document.getElementById('prepass') as HTMLInputElement; const pull = document.getElementById('pull') as HTMLInputElement; +const half = document.getElementById('half') as HTMLInputElement; +const dev = document.getElementById('dev') as HTMLDetailsElement; +const info = document.getElementById('info') as HTMLPreElement; const timerless = (() => { const g = document.createElement('canvas').getContext('webgl2'); return !g?.getExtension('EXT_disjoint_timer_query_webgl2'); })(); +const WHY: Record = { + 'no-webgl2': 'this browser gave no WebGL2 context', + 'no-float-or-half-float-colour-buffer': + 'WebGL2 is there, but neither EXT_color_buffer_float nor EXT_color_buffer_half_float is', +}; + +// What the browser says about itself, read from a scratch context so it shows even when the scene +// could not start. A canvas that never got a WebGL2 context is asked for WebGL1 to name the GPU. +function describeDevice(): string { + const c = document.createElement('canvas'); + const gl2 = c.getContext('webgl2'); + const gl = gl2 ?? (document.createElement('canvas').getContext('webgl') as WebGLRenderingContext | null); + const lines = [`webgl2 context: ${gl2 ? 'yes' : 'NO'}`]; + if (!gl) return [...lines, 'webgl1 context: NO'].join('\n'); + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + lines.push( + `renderer: ${gl.getParameter(dbg ? dbg.UNMASKED_RENDERER_WEBGL : gl.RENDERER)}`, + `vendor: ${gl.getParameter(dbg ? dbg.UNMASKED_VENDOR_WEBGL : gl.VENDOR)}`, + `version: ${gl.getParameter(gl.VERSION)}`, + `user agent: ${navigator.userAgent}` + ); + const ext = gl.getSupportedExtensions() ?? []; + for (const n of ['EXT_color_buffer_float', 'EXT_color_buffer_half_float', 'OES_texture_float_linear', 'EXT_disjoint_timer_query_webgl2']) + lines.push(`${n}: ${ext.includes(n) ? 'yes' : 'NO'}`); + lines.push(`extensions (${ext.length}): ${ext.join(' ')}`); + return lines.join('\n'); +} +info.textContent = describeDevice(); + let scene: Scene | null = null; let stats: SceneStats | undefined; let trace: TraceStats | undefined; @@ -29,15 +67,18 @@ function start() { fixedLevel: LEVELS[0], fixedPull: +pull.value, prepass: box.checked, + halfStart: half.checked, + onUnavailable: (r) => (out.textContent = `createScene returned null: ${WHY[r]}`), + onCompileFail: (e) => (out.textContent = `createScene returned null: ${e.message}`), onStats: (s) => (stats = s), onTraceStats: (t) => (trace = t), }); - if (!scene) out.textContent = 'createScene returned null: no WebGL2 or float colour buffers'; + dev.open = !scene; } setInterval(() => { if (!scene) return; - const lines = [`prepass ${box.checked ? 'on' : 'off'} pull ${pull.value}`]; + const lines = [`prepass ${box.checked ? 'on' : 'off'} start target ${scene.startFormat} pull ${pull.value}`]; if (stats) { const ms = stats.passMs; const total = Object.values(ms).reduce((a, b) => a + b, 0); @@ -56,5 +97,6 @@ setInterval(() => { }, 500); box.addEventListener('change', start); +half.addEventListener('change', start); pull.addEventListener('input', start); start(); diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index c403018a..6493ad88 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { createScene, ShaderCompileError } from './Scene'; +import { createScene, ShaderCompileError, HALF_START_BIAS, HALF_START_SCALE } from './Scene'; +import { WORLD } from './world'; import { LEVELS } from './budget'; import { fragmentSource } from './shaderSource'; import { startBackgroundScene } from './index'; @@ -7,16 +8,21 @@ import { startBackgroundScene } from './index'; type Fn = Mock; /** A GL context where every call is a spy and every constant is 1. Compiles succeed until `failFrom`. */ -function fakeGl(failFrom = Infinity) { +function fakeGl(failFrom = Infinity, missing: string[] = []) { let compiles = 0; const calls: Record = {}; - const spy = (name: string, impl: () => unknown = () => ({})): Fn => { + const spy = (name: string, impl: (...a: any[]) => unknown = () => ({})): Fn => { if (!calls[name]) calls[name] = vi.fn(impl); return calls[name]; }; const gl = new Proxy({} as Record, { get(_, prop: string) { + // the two start-target formats must be told apart; every other constant is 1 + if (prop === 'RGBA32F') return 32; + if (prop === 'RGBA16F') return 16; if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 1; + if (prop === 'getExtension') + return spy(prop, (n: string) => (missing.includes(n) ? null : {})); if (prop === 'compileShader') { return spy(prop, () => { compiles += 1; @@ -185,6 +191,85 @@ describe('cone prepass', () => { }); }); +describe('device without EXT_color_buffer_float', () => { + const FLOAT = 'EXT_color_buffer_float'; + const HALF = 'EXT_color_buffer_half_float'; + const build = (missing: string[], extra: Partial[0]> = {}) => { + rafQueue.length = 0; + const { gl, calls } = fakeGl(Infinity, missing); + const onUnavailable = vi.fn(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onUnavailable, ...extra }); + if (scene) runFrame(1000); // targets are allocated on the first frame + const formats = calls.texStorage2D?.mock.calls.map((c) => c[2]) ?? []; + const sources = (calls.shaderSource?.mock.calls ?? []).map((c) => c[1] as string); + return { + scene, + onUnavailable, + formats, + prepassSource: sources.find((x) => x.includes('o_start')), + }; + }; + + it('names why the scene is unavailable: no WebGL2, or neither float nor half-float colour buffers', () => { + const none = vi.fn(); + createScene({ canvas: asCanvas(fakeCanvas(null)), onUnavailable: none }); + expect(none).toHaveBeenCalledTimes(1); + expect(none).toHaveBeenCalledWith('no-webgl2'); + const r = build([FLOAT, HALF]); + expect(r.scene).toBeNull(); + expect(r.onUnavailable).toHaveBeenCalledTimes(1); + expect(r.onUnavailable).toHaveBeenCalledWith('no-float-or-half-float-colour-buffer'); + expect(build([]).onUnavailable).not.toHaveBeenCalled(); + }); + + it('with only half-float colour buffers, starts the prepass in an RGBA16F target with the rounding-down write', () => { + const r = build([FLOAT]); + expect(r.scene?.startFormat).toBe('RGBA16F'); + expect(r.formats).not.toContain(32); + expect(r.prepassSource).toContain('#define START_HALF 1'); + expect(r.prepassSource).toContain(`#define START_HALF_SCALE ${HALF_START_SCALE}`); + }); + + it('keeps RGBA32F and the plain write where it is renderable, unless asked for half', () => { + const wide = build([HALF]); + expect(wide.scene?.startFormat).toBe('RGBA32F'); + expect(wide.formats).toContain(32); + expect(wide.prepassSource).not.toContain('#define START_HALF'); + expect(build([], { halfStart: true }).scene?.startFormat).toBe('RGBA16F'); + }); + + // The shader writes max(0, t*SCALE - BIAS) into a half, which rounds to nearest (even); this is + // that rounding. The GPU's rounding of a half write is the unknown a phone must confirm. + const toHalf = (x: number) => { + if (x === 0) return 0; + let e = Math.max(Math.floor(Math.log2(x)), -14); + if (2 ** e > x) e -= 1; + const ulp = 2 ** (e - 10); + const q = x / ulp; + let r = Math.floor(q); + const d = q - r; + if (d > 0.5 || (d === 0.5 && r % 2 === 1)) r += 1; + return r * ulp; + }; + const stored = (t: number) => toHalf(Math.max(0, t * HALF_START_SCALE - HALF_START_BIAS)); + + it('stores a start distance never past the true one, and not much short of it', () => { + let seed = 7; + const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647; + const ts = [0, 1e-6, 1e-4, 6.1e-5, 0.01, 1, 1023.9, 1024, 2047.9]; + for (let i = 0; i < 20000; i++) ts.push(rand() * WORLD.far, rand() * rand() * 4); + for (let t = 0; t <= WORLD.far; t += 0.37) ts.push(t); + for (const t of ts) { + expect(stored(t)).toBeLessThanOrEqual(t); + expect(t - stored(t)).toBeLessThanOrEqual(t * 0.0021 + 6e-5); + } + }); + + it('holds the miss sentinel W_FAR exactly, so pass 1 still skips a tile the prepass proved empty', () => { + expect(toHalf(WORLD.far)).toBe(WORLD.far); + }); +}); + describe('shader compile failure', () => { it('reports through onCompileFail, returns null, and frees the programs already linked', () => { const { gl, calls } = fakeGl(4); // terrain compiles both shaders; atmosphere's fragment is the 4th and fails diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 740c5c0c..70580ab8 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -42,6 +42,23 @@ export class ShaderCompileError extends Error { } } +/** + * Why createScene returned null for want of a device feature. Rendering to half floats needs + * EXT_color_buffer_float or EXT_color_buffer_half_float; the prepass start target is RGBA32F with + * the first and RGBA16F with only the second. + */ +export type SceneUnavailable = 'no-webgl2' | 'no-float-or-half-float-colour-buffer'; + +/** + * The RGBA16F prepass start is written as `t * HALF_START_SCALE - HALF_START_BIAS`. A half rounds + * to nearest with relative error at most 2^-11, so shaving 2^-10 of t leaves the stored value + * below t, and the bias covers the absolute error where halves go subnormal. The shader reads both + * through #defines, so this is the one copy. + */ +export const HALF_START_SCALE = 1 - 1 / 1024; +export const HALF_START_BIAS = 1 / 32768; +const HALF_START_DEFINES = `#define START_HALF 1\n#define START_HALF_SCALE ${HALF_START_SCALE}\n#define START_HALF_BIAS ${HALF_START_BIAS}\n`; + /** Ladder level a 'low' scene starts at: the cost of a weak device's first 45 frames is never paid at full. */ const LOW_START_LEVEL = 2; /** The 'still' tier draws one frame at this level and stops. */ @@ -54,6 +71,10 @@ export interface SceneOptions { * the adaptive ladder low. 'full' starts it at the top. Default 'full'. */ tier?: SceneTier; + /** createScene is about to return null because the device lacks a feature, and which one. */ + onUnavailable?: (reason: SceneUnavailable) => void; + /** Force the RGBA16F prepass start target even where RGBA32F is renderable (measurement, tests). */ + halfStart?: boolean; /** A shader failed to compile or link. createScene frees what it built and returns null. */ onCompileFail?: (e: ShaderCompileError) => void; /** The WebGL context was lost. The scene stops for good; the host keeps its ground. */ @@ -79,6 +100,8 @@ export interface SceneOptions { } export interface Scene { + /** Texture format of the prepass start target. */ + readonly startFormat: 'RGBA32F' | 'RGBA16F'; /** Start the camera pull-back from the tree. */ login(): void; dispose(): void; @@ -146,7 +169,7 @@ function halton(i: number, base: number): number { return r; } -/** Null when WebGL2 or float colour buffers are missing; the host keeps its CSS ground. */ +/** Null when WebGL2 or a float colour buffer is missing (`onUnavailable` says which); the host keeps its CSS ground. */ export function createScene(opts: SceneOptions): Scene | null { const { canvas } = opts; const tier = opts.tier ?? 'full'; @@ -158,7 +181,17 @@ export function createScene(opts: SceneOptions): Scene | null { stencil: false, powerPreference: 'low-power', }); - if (!gl || !gl.getExtension('EXT_color_buffer_float')) return null; + if (!gl) { + opts.onUnavailable?.('no-webgl2'); + return null; + } + const wide = !!gl.getExtension('EXT_color_buffer_float'); + if (!wide && !gl.getExtension('EXT_color_buffer_half_float')) { + opts.onUnavailable?.('no-float-or-half-float-colour-buffer'); + return null; + } + // Every other target is a half; only the prepass start can ask for 32 bits. + const halfStart = opts.halfStart || !wide; gl.getExtension('OES_texture_float_linear'); const timer = gl.getExtension('EXT_disjoint_timer_query_webgl2') as { TIME_ELAPSED_EXT: number; @@ -170,12 +203,13 @@ export function createScene(opts: SceneOptions): Scene | null { const programs = {} as Record; try { passes.forEach((p) => { - programs[p] = linkStage( - gl, - p, - opts.patchSource, - p === 'terrain' && opts.onTraceStats ? '#define TRACE_STATS 1\n' : undefined - ); + const defines = + p === 'terrain' && opts.onTraceStats + ? '#define TRACE_STATS 1\n' + : p === 'prepass' && halfStart + ? HALF_START_DEFINES + : undefined; + programs[p] = linkStage(gl, p, opts.patchSource, defines); }); } catch (e) { passes.forEach((p) => { @@ -196,6 +230,8 @@ export function createScene(opts: SceneOptions): Scene | null { const vao = gl.createVertexArray(); gl.bindVertexArray(vao); + const startFormat = halfStart ? gl.RGBA16F : gl.RGBA32F; + const makeTarget = (w: number, h: number, format: number, filter: number): Target => { const tex = gl.createTexture()!; gl.bindTexture(gl.TEXTURE_2D, tex); @@ -329,8 +365,8 @@ export function createScene(opts: SceneOptions): Scene | null { depth = makeTarget(sw, sh, gl.R16F, gl.NEAREST); // Without the prepass pass 1 reads a 1x1 zero start: every ray from the camera, as before. startTex = usePrepass - ? makeTarget(Math.ceil(sw / TILE), Math.ceil(sh / TILE), gl.RGBA32F, gl.NEAREST) - : makeTarget(1, 1, gl.RGBA32F, gl.NEAREST); + ? makeTarget(Math.ceil(sw / TILE), Math.ceil(sh / TILE), startFormat, gl.NEAREST) + : makeTarget(1, 1, startFormat, gl.NEAREST); steps = opts.onTraceStats ? makeTarget(sw, sh, gl.R8, gl.NEAREST) : undefined; atm = makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR); hist = [makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR), makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR)]; @@ -581,6 +617,7 @@ export function createScene(opts: SceneOptions): Scene | null { wake(); return { + startFormat: halfStart ? 'RGBA16F' : 'RGBA32F', login() { if (still || disposed) return; // a still has no pull-back loginAt = performance.now(); diff --git a/src/app/features/scene/shaders/prepass.frag.glsl b/src/app/features/scene/shaders/prepass.frag.glsl index 691cfba9..4ca21a2c 100644 --- a/src/app/features/scene/shaders/prepass.frag.glsl +++ b/src/app/features/scene/shaders/prepass.frag.glsl @@ -7,7 +7,7 @@ uniform int u_maxSteps; uniform vec2 u_tileHalf; // half a tile in uv: the footprint of this texel, as pass 1 samples it in vec2 v_uv; -layout(location = 0) out vec4 o_start; // r: start distance, W_FAR when the tile misses; g: steps spent +layout(location = 0) out vec4 o_start; // r: start distance, W_FAR when the tile misses; g: steps spent (a half holds both) void main() { @@ -46,5 +46,12 @@ void main() break; t += s; } - o_start = vec4(t >= tEnd ? W_FAR : t, float(evals), 0.0, 0.0); + float start = t; +#ifdef START_HALF + // The target is RGBA16F: the write rounds to a half, and the round may go up. Any start past + // the true one is a ray that could skip a surface, so shave more than half a ulp off first + // (START_HALF_SCALE, START_HALF_BIAS: Scene.ts). W_FAR stays exact, an integer a half holds. + start = max(0.0, t * START_HALF_SCALE - START_HALF_BIAS); +#endif + o_start = vec4(t >= tEnd ? W_FAR : start, float(evals), 0.0, 0.0); } From 293885089a5af6790a3e1a69e8daf2d9181e17ef Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 20:39:58 +0000 Subject: [PATCH 64/64] scene README: createScene needs either float colour-buffer extension Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index a189b803..ad4c13b1 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -17,8 +17,9 @@ callbacks and the creature sizing rule. Nothing has drawn a creature. demand, so it stays out of the main chunk; if that chunk fails to load the error goes to `onCompileFail` and the door returns null, it does not throw. - `Scene.ts` `createScene({ canvas, tier, onCompileFail, onContextLost, ... })` returns - `{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 or - `EXT_color_buffer_float` is missing, or a shader fails to compile (`onCompileFail` gets the + `{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 is missing, neither + `EXT_color_buffer_float` nor `EXT_color_buffer_half_float` exists (`onUnavailable` says which; with + only the half extension the prepass start target is RGBA16F, written rounded down), or a shader fails to compile (`onCompileFail` gets the `ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a baked image: it runs the full pipeline once at the top level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to