From 5bb78b21cf0d27c9f5b7498f28aa9138428bfdbe Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 18:22:43 +0000 Subject: [PATCH 01/17] preview demo: drop a stored login for a real homeserver before the app reads it Every preview shares the origin minds.gamecult.org and so one localStorage. A login another preview left (cinny_hs_base_url of a real homeserver) made the demo connect to that server, which the guard refuses, so SpecVersions showed 'Unable to connect to the homeserver'. installDemo now drops any stored session whose base is not the demo origin, and the service worker is registered after that so it is never handed the dropped session. Replaces the foreign-session banner text. Co-Authored-By: Claude Sonnet 5.5 --- src/app/state/sessions.ts | 20 ++++++------ src/index.tsx | 46 ++++++++++++++------------ src/preview-demo/install.ts | 15 ++++++--- src/preview-demo/server.test.ts | 57 ++++++++++++++++++++++++++++++++- 4 files changed, 103 insertions(+), 35 deletions(-) diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts index a9c34ce9..19f2212d 100644 --- a/src/app/state/sessions.ts +++ b/src/app/state/sessions.ts @@ -40,17 +40,17 @@ export function setFallbackSession( localStorage.setItem('cinny_user_id', userId); localStorage.setItem('cinny_hs_base_url', baseUrl); } -export const removeFallbackSession = () => { - localStorage.removeItem('cinny_hs_base_url'); - localStorage.removeItem('cinny_user_id'); - localStorage.removeItem('cinny_device_id'); - localStorage.removeItem('cinny_access_token'); +export const removeFallbackSession = (storage: Storage = localStorage) => { + storage.removeItem('cinny_hs_base_url'); + storage.removeItem('cinny_user_id'); + storage.removeItem('cinny_device_id'); + storage.removeItem('cinny_access_token'); }; -export const getFallbackSession = (): Session | undefined => { - const baseUrl = localStorage.getItem('cinny_hs_base_url'); - const userId = localStorage.getItem('cinny_user_id'); - const deviceId = localStorage.getItem('cinny_device_id'); - const accessToken = localStorage.getItem('cinny_access_token'); +export const getFallbackSession = (storage: Storage = localStorage): Session | undefined => { + const baseUrl = storage.getItem('cinny_hs_base_url'); + const userId = storage.getItem('cinny_user_id'); + const deviceId = storage.getItem('cinny_device_id'); + const accessToken = storage.getItem('cinny_access_token'); if (baseUrl && userId && deviceId && accessToken) { const session: Session = { diff --git a/src/index.tsx b/src/index.tsx index 6c7a3bc2..51e9760f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,29 +30,32 @@ import { getFallbackSession } from './app/state/sessions'; document.body.classList.add(configClass, varsClass); -// Register Service Worker -if ('serviceWorker' in navigator) { - const swUrl = - import.meta.env.MODE === 'production' - ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` - : `/dev-sw.js?dev-sw`; +// Register Service Worker. After the preview demo has settled the stored session: the worker is +// handed that session, and the demo may drop one it cannot serve. +const registerServiceWorker = () => { + if ('serviceWorker' in navigator) { + const swUrl = + import.meta.env.MODE === 'production' + ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` + : `/dev-sw.js?dev-sw`; - const sendSessionToSW = () => { - const session = getFallbackSession(); - pushSessionToSW(session?.baseUrl, session?.accessToken); - }; + const sendSessionToSW = () => { + const session = getFallbackSession(); + pushSessionToSW(session?.baseUrl, session?.accessToken); + }; - navigator.serviceWorker.register(swUrl).then(sendSessionToSW); - navigator.serviceWorker.ready.then(sendSessionToSW); + navigator.serviceWorker.register(swUrl).then(sendSessionToSW); + navigator.serviceWorker.ready.then(sendSessionToSW); - navigator.serviceWorker.addEventListener('message', (ev) => { - const { type } = ev.data ?? {}; + navigator.serviceWorker.addEventListener('message', (ev) => { + const { type } = ev.data ?? {}; - if (type === 'requestSession') { - sendSessionToSW(); - } - }); -} + if (type === 'requestSession') { + sendSessionToSW(); + } + }); + } +}; // The pretend homeserver of the dev preview: built in only with VITE_PREVIEW_DEMO=1 (the dev // workflow) and switched on by `previewDemo: true` in config.json. Any other build drops it. @@ -76,4 +79,7 @@ const mountApp = () => { root.render(); }; -installPreviewDemo().finally(mountApp); +installPreviewDemo().finally(() => { + registerServiceWorker(); + mountApp(); +}); diff --git a/src/preview-demo/install.ts b/src/preview-demo/install.ts index a7b66de9..10b9a3ee 100644 --- a/src/preview-demo/install.ts +++ b/src/preview-demo/install.ts @@ -4,6 +4,7 @@ // only by a build with VITE_PREVIEW_DEMO=1 whose config.json says previewDemo: true (src/index.tsx). // Own-origin requests (the app's static files) pass; the demo origin is answered in-page; any // other origin is refused, so no real homeserver can be contacted while this is on. +import { removeFallbackSession } from '../app/state/sessions'; import { DEMO_ORIGIN } from './fixtures'; import { createDemoServer, DemoServer } from './server'; @@ -27,8 +28,6 @@ const refusal = (what: string, url: string) => new Error(`preview: pretend server only; ${what} to ${url} refused`); const BANNER_TEXT = 'preview: pretend server. Any password works.'; -const FOREIGN_SESSION_TEXT = - 'preview: pretend server. A login for another server is stored here and cannot be used.'; // In flow, above the app: body becomes a column and the app root takes the rest, so the banner // never covers the room header or the connection strip. @@ -78,7 +77,16 @@ export const guardedFetch = }); }; +// Every preview of this site shares one origin, so one localStorage: a login another preview left +// (a real homeserver) would make the app connect to that server, which the guard refuses. The +// demo has one server, so a stored login for any other is dropped before the app reads it. +export const dropForeignSession = (storage: Storage) => { + const base = storage.getItem('cinny_hs_base_url'); + if (base !== null && base !== DEMO_ORIGIN) removeFallbackSession(storage); +}; + export const installDemo = (win: Window & typeof globalThis = window) => { + dropForeignSession(win.localStorage); const server = createDemoServer(); const { origin } = win.location; const realFetch = win.fetch.bind(win); @@ -111,7 +119,6 @@ export const installDemo = (win: Window & typeof globalThis = window) => { routeOf(String(url), origin) === 'own' ? realBeacon(url, data) : false; } - const stored = win.localStorage.getItem('cinny_hs_base_url'); - showBanner(stored !== null && stored !== DEMO_ORIGIN ? FOREIGN_SESSION_TEXT : BANNER_TEXT); + showBanner(BANNER_TEXT); return server; }; diff --git a/src/preview-demo/server.test.ts b/src/preview-demo/server.test.ts index d277b97b..ec9031c1 100644 --- a/src/preview-demo/server.test.ts +++ b/src/preview-demo/server.test.ts @@ -2,9 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ClientEvent, MatrixClient, SyncState, createClient } from 'matrix-js-sdk'; import { DEMO_ORIGIN, ME, ROOMS, GENERAL, COMMONS } from './fixtures'; -import { guardedFetch, installDemo, routeOf } from './install'; +import { dropForeignSession, guardedFetch, installDemo, routeOf } from './install'; import { createDemoServer, DemoServer } from './server'; import { seedDemoSettings } from './seed'; +import { getFallbackSession } from '../app/state/sessions'; import { mindOfSender, POSE_KEY, resolvePose } from '../app/components/message/pose/poses'; import { parseRoomNotice, roomTrainingLine } from '../app/utils/trainingNotice'; @@ -233,6 +234,7 @@ describe('the guard', () => { localStorage: { getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), }, } as unknown as Window & typeof globalThis; vi.stubGlobal('document', { @@ -254,6 +256,59 @@ describe('the guard', () => { vi.unstubAllGlobals(); }); + it('drops a stored login for a real homeserver before the app reads it, keeps the demo one', () => { + const SESSION = { + cinny_access_token: 'tok', + cinny_device_id: 'DEV', + cinny_user_id: '@a:b', + cinny_hs_base_url: 'https://chat.gamecult.org', + }; + const store = new Map(Object.entries({ ...SESSION, settings: '{}' })); + const storage = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + } as Storage; + dropForeignSession(storage); + expect(getFallbackSession(storage)).toBeUndefined(); + expect([...store.keys()]).toEqual(['settings']); + + Object.entries({ ...SESSION, cinny_hs_base_url: DEMO_ORIGIN }).forEach(([k, v]) => + store.set(k, v) + ); + dropForeignSession(storage); + expect(getFallbackSession(storage)?.baseUrl).toBe(DEMO_ORIGIN); + }); + + it('installDemo clears a foreign login so the app starts at the login screen', () => { + const store = new Map([ + ['cinny_hs_base_url', 'https://chat.gamecult.org'], + ['cinny_access_token', 'tok'], + ['cinny_device_id', 'DEV'], + ['cinny_user_id', '@a:b'], + ]); + const win = { + location: { origin: PAGE }, + fetch: never, + WebSocket: class {}, + XMLHttpRequest: { prototype: { open: vi.fn() } }, + navigator: {}, + localStorage: { + getItem: (k: string) => store.get(k) ?? null, + removeItem: (k: string) => void store.delete(k), + }, + } as unknown as Window & typeof globalThis; + vi.stubGlobal('document', { + createElement: () => ({ style: {}, setAttribute: vi.fn() }), + getElementById: () => null, + body: { style: {}, insertBefore: vi.fn() }, + }); + const server = installDemo(win); + live.push({ server, mx: clientFor(server) }); + expect(store.size).toBe(0); + vi.unstubAllGlobals(); + }); + it('seeds pose portraits on for a first visit and keeps what a visitor already chose', () => { const fresh = new Map(); seedDemoSettings({ From 06d530d061340b73d01c1204ab7ef92125f19379 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 18:12:36 +0000 Subject: [PATCH 02/17] Phone: light ink for the training notice over the dark ridge (day, dusk) Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomTrainingLine.tsx | 8 ++++++- src/app/features/scene/skyInk.test.ts | 27 ++++++++++++++++++++++ src/app/features/scene/skyInk.ts | 17 ++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTrainingLine.tsx b/src/app/features/room/RoomTrainingLine.tsx index 8f09fdcf..fd410979 100644 --- a/src/app/features/room/RoomTrainingLine.tsx +++ b/src/app/features/room/RoomTrainingLine.tsx @@ -3,6 +3,8 @@ import { Room } from 'matrix-js-sdk'; import React from 'react'; import { useStateEvent } from '../../hooks/useStateEvent'; import { useClientConfig } from '../../hooks/useClientConfig'; +import { useSkyInk } from '../../hooks/useTheme'; +import { noticeInk } from '../scene/skyInk'; import { ROOM_NOTICE_EVENT_TYPE, parseRoomNotice, @@ -14,12 +16,16 @@ import { export function RoomTrainingLine({ room }: { room: Room }) { const event = useStateEvent(room, ROOM_NOTICE_EVENT_TYPE); const { previewDemo } = useClientConfig(); + const skyInk = useSkyInk(); const line = roomTrainingLine(parseRoomNotice(event?.getContent()), previewDemo); if (!line) return null; return ( {line} diff --git a/src/app/features/scene/skyInk.test.ts b/src/app/features/scene/skyInk.test.ts index 4cf2a6c4..3a583090 100644 --- a/src/app/features/scene/skyInk.test.ts +++ b/src/app/features/scene/skyInk.test.ts @@ -7,8 +7,10 @@ import type { FixedSkyState } from './skyBand'; import { LIGHTEST_PATCH, NIGHT_INK_MAX_L, + NOTICE_GROUND, inkForState, labelInk, + noticeInk, sceneInk, userColorInk, } from './skyInk'; @@ -175,3 +177,28 @@ describe('labelInk', () => { expect(hueGap(dark, ink)).toBeLessThan(2); }); }); + +describe('noticeInk', () => { + const ratio = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + + it('is light enough to clear AA on the dark ridge wherever the theme ink does not', () => { + (['day', 'dusk'] as FixedSkyState[]).forEach((state) => { + const ink = noticeInk({ state, phone: true }) as string; + expect(ratio(luminance(ink), NOTICE_GROUND)).toBeGreaterThanOrEqual(AA); + // the dark theme ink it replaces does not + expect( + ratio(luminance(baked.noonday.tokens.SurfaceVariant.OnContainer), NOTICE_GROUND) + ).toBeLessThan(AA); + }); + }); + + it('leaves night (already light) and every desktop state to the theme', () => { + expect(noticeInk({ state: 'night', phone: true })).toBeUndefined(); + (['day', 'dusk', 'night'] as FixedSkyState[]).forEach((state) => + expect(noticeInk({ state, phone: false })).toBeUndefined() + ); + expect( + ratio(luminance(baked.midnight.tokens.SurfaceVariant.OnContainer), NOTICE_GROUND) + ).toBeGreaterThanOrEqual(AA); + }); +}); diff --git a/src/app/features/scene/skyInk.ts b/src/app/features/scene/skyInk.ts index 7219159c..b7a3667f 100644 --- a/src/app/features/scene/skyInk.ts +++ b/src/app/features/scene/skyInk.ts @@ -93,3 +93,20 @@ export function userColorInk(userId: string, ink: SkyInk): string { */ export const labelInk = ({ state, phone }: SkyInk): string | undefined => state === 'dusk' && phone ? '#29221a' : undefined; + +/** + * WCAG relative luminance of the lightest pixel under the notice line above the composer on a phone: the dark + * foreground ridge, the same in every state (measured 2026-11-10 from the Starfire phone shots at 390x844, + * ratatoskr-dev-preview/phone-room-{day,dusk,night}.png, text region x 15-355 CSS px: median 0.0052, 90th percentile + * 0.0089, rounded up to 0.01). The labelInk patches above are the lightest terrain behind the timeline; here the + * ground is dark, so the ink must be light. + */ +export const NOTICE_GROUND = 0.01; + +/** + * The ink of the training-notice line above the composer where the theme's own does not clear AA there. On a phone the + * line sits on the dark ridge in every state, but Noonday (day, dusk) wears dark ink, 1.2:1 at dusk by the numbers + * above; Midnight's (night) is already light and stays (undefined). Desktop is unmeasured and stays. + */ +export const noticeInk = ({ state, phone }: SkyInk): string | undefined => + phone && state !== 'night' ? baked.midnight.tokens.SurfaceVariant.OnContainer : undefined; From de6f1849eaaed050c1bc792c0bc2062187fcc0c3 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 18:14:36 +0000 Subject: [PATCH 03/17] Phone: 44 px hit areas for sender names, room topic and sign-in links, no layout shift Co-Authored-By: Claude Sonnet 5.5 --- .../components/message/layout/layout.css.ts | 7 ++ src/app/features/room/RoomViewHeader.css.ts | 11 +++ src/app/pages/auth/styles.css.ts | 13 +++ src/app/phoneTapTargets.test.ts | 84 +++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 src/app/phoneTapTargets.test.ts diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index a724241c..9c6b6635 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -1,6 +1,7 @@ import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css'; import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; +import { MOBILE_BREAKPOINT } from '../../../hooks/useScreenSize'; export const StickySection = style({ position: 'sticky', @@ -350,6 +351,9 @@ export const BubbleLeftArrow = style({ zIndex: 1, }); +// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the +// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves. +const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; export const Username = style({ overflow: 'hidden', whiteSpace: 'nowrap', @@ -357,6 +361,9 @@ export const Username = style({ selectors: { 'button&': { cursor: 'pointer', + '@media': { + [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 }, + }, }, 'button&:hover, button&:focus-visible': { textDecoration: 'underline', diff --git a/src/app/features/room/RoomViewHeader.css.ts b/src/app/features/room/RoomViewHeader.css.ts index 19e1afea..cff21a99 100644 --- a/src/app/features/room/RoomViewHeader.css.ts +++ b/src/app/features/room/RoomViewHeader.css.ts @@ -1,7 +1,18 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; +import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; +// Phone tap target: the topic is an 18 px line under the room name; its button grows 9 px up and 17 px down +// (44 px to tap; more below because the name is above) and the negative margin keeps the layout where it was. export const HeaderTopic = style({ + '@media': { + [`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: { + paddingTop: 9, + paddingBottom: 17, + marginTop: -9, + marginBottom: -17, + }, + }, ':hover': { cursor: 'pointer', opacity: config.opacity.P500, diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 5b6b6588..e762207b 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -1,5 +1,6 @@ import { globalStyle, style } from '@vanilla-extract/css'; import { DefaultReset, color, config, toRem } from 'folds'; +import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; import { FOOTER_TINT, PLATE_TINT } from '../../features/scene/skyPlate'; export const AuthLayout = style({ @@ -127,3 +128,15 @@ globalStyle(`${scene} ${AuthFooter}`, { globalStyle(`${scene} ${AuthFooter} a`, { color: 'var(--sky-bubble-text, #b7c7d9)', }); + +// Phone tap targets for the sign-in links, hit area only (every link is under 44 px tall: Forget Password? 16, +// Register 20, the footer's 22): vertical padding grows each link's box and the negative margin takes the growth +// back out of the layout, so nothing moves and nothing is drawn. An inline link ignores the margin and its padding +// never moves a line, which is the same result. +const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; +globalStyle(`${AuthCard} a`, { + '@media': { [SCREEN_PHONE]: { paddingBlock: 14, marginBlock: -14 } }, +}); +globalStyle(`${AuthFooter} a`, { + '@media': { [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 } }, +}); diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts new file mode 100644 index 00000000..57a40bfc --- /dev/null +++ b/src/app/phoneTapTargets.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import zlib from 'zlib'; +import { compile, processVanillaFile } from '@vanilla-extract/integration'; +import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; + +// The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does; +// vanilla-extract gzips the inlined source only when it is large, so both forms are read). +const emitted = async (file: string) => { + const filePath = `${process.cwd()}/src/app/${file}`; + const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); + const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); + const bytes = Buffer.from( + (code.match(/source=#?([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1], + 'base64' + ); + return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); +}; + +type Decls = Record; +const parse = (body: string): Decls => + Object.fromEntries( + body + .split('\n') + .map((l) => l.trim().replace(/;$/, '')) + .filter((l) => /^[a-z-]+: -?\d+px$/.test(l)) + .map((l) => [l.split(':')[0], Number(l.split(': ')[1].replace('px', ''))]) + ); + +/** Pixel declarations of the rules whose selector matches, inside or outside a media query. */ +const rulesFor = (css: string, selector: RegExp, media: string | undefined): Decls[] => { + const scope = media + ? [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)] + .filter((m) => m[1] === media) + .map((m) => m[2]) + .join('\n') + : css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, ''); + return [...scope.matchAll(/^\s*([^{}\n]+) \{\n([^}]*)\}/gm)] + .filter((m) => selector.test(m[1])) + .map((m) => parse(m[2])); +}; + +const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; +const TAP = 44; + +// [target, css file, selector, measured height in CSS px on the Starfire phone run (390x844)]. +const targets: [string, string, RegExp, number][] = [ + [ + 'sender-name button', + 'components/message/layout/layout.css.ts', + /^button\.layout_Username__/, + 23, + ], + [ + 'room-topic button', + 'features/room/RoomViewHeader.css.ts', + /RoomViewHeader_HeaderTopic__\w+$/, + 18, + ], + ['sign-in card links', 'pages/auth/styles.css.ts', /styles_AuthCard__\w+ a$/, 16], + ['sign-in footer links', 'pages/auth/styles.css.ts', /styles_AuthFooter__\w+ a$/, 22], +]; + +describe.each(targets)('phone tap target: %s', (_name, file, selector, height) => { + it(`grows its hit area to ${TAP} px without moving layout`, async () => { + const css = await emitted(file); + const [phone] = rulesFor(css, selector, PHONE); + expect(phone, 'a phone rule exists').toBeDefined(); + const top = phone['padding-top'] ?? phone['padding-block']; + const bottom = phone['padding-bottom'] ?? phone['padding-block']; + const marginTop = phone['margin-top'] ?? phone['margin-block']; + const marginBottom = phone['margin-bottom'] ?? phone['margin-block']; + expect(height + top + bottom).toBeGreaterThanOrEqual(TAP); + // the margin takes back exactly what the padding added, so the target does not move or push its neighbours + expect(marginTop).toBe(-top); + expect(marginBottom).toBe(-bottom); + }); + + it('leaves every other screen as it was: no padding or margin outside the phone query', async () => { + const css = await emitted(file); + rulesFor(css, selector, undefined).forEach((decls) => + expect(Object.keys(decls).filter((k) => /^(padding|margin)/.test(k))).toEqual([]) + ); + }); +}); From 0351f1f6e8e202fa83282ee36583dc0af18e44f2 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 18:15:07 +0000 Subject: [PATCH 04/17] Skip auth-metadata validation when the homeserver returned none Co-Authored-By: Claude Sonnet 5.5 --- .../components/ServerConfigsLoader.test.ts | 32 ++++++++++ src/app/components/ServerConfigsLoader.tsx | 62 +++++++++++-------- 2 files changed, 69 insertions(+), 25 deletions(-) create mode 100644 src/app/components/ServerConfigsLoader.test.ts diff --git a/src/app/components/ServerConfigsLoader.test.ts b/src/app/components/ServerConfigsLoader.test.ts new file mode 100644 index 00000000..527895a0 --- /dev/null +++ b/src/app/components/ServerConfigsLoader.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { loadServerConfigs } from './ServerConfigsLoader'; + +const server = (authMetadata: () => Promise) => + ({ + getCapabilities: async () => ({}), + getMediaConfig: async () => ({ 'm.upload.size': 1 }), + getAuthMetadata: authMetadata, + } as unknown as Parameters[0]); + +describe('loadServerConfigs', () => { + afterEach(() => vi.restoreAllMocks()); + + it('logs nothing when the homeserver has no OIDC (no auth metadata came back)', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const configs = await loadServerConfigs( + server(async () => { + throw new Error('M_UNRECOGNIZED'); + }) + ); + expect(error).not.toHaveBeenCalled(); + expect(configs.authMetadata).toBeUndefined(); + expect(configs.mediaConfig).toEqual({ 'm.upload.size': 1 }); + }); + + it('still logs and drops auth metadata that came back malformed', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const configs = await loadServerConfigs(server(async () => ({ issuer: 'https://x' }))); + expect(error).toHaveBeenCalled(); + expect(configs.authMetadata).toBeUndefined(); + }); +}); diff --git a/src/app/components/ServerConfigsLoader.tsx b/src/app/components/ServerConfigsLoader.tsx index 3c8ce8eb..ea56d8d2 100644 --- a/src/app/components/ServerConfigsLoader.tsx +++ b/src/app/components/ServerConfigsLoader.tsx @@ -1,5 +1,10 @@ import { ReactNode, useCallback, useMemo } from 'react'; -import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk'; +import { + Capabilities, + MatrixClient, + validateAuthMetadata, + ValidatedAuthMetadata, +} from 'matrix-js-sdk'; import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback'; import { useMatrixClient } from '../hooks/useMatrixClient'; import { MediaConfig } from '../hooks/useMediaConfig'; @@ -14,35 +19,42 @@ export type ServerConfigs = { type ServerConfigsLoaderProps = { children: (configs: ServerConfigs) => ReactNode; }; +/** + * The homeserver's capabilities, media config and OIDC metadata. A homeserver with no OIDC (and the demo's pretend + * server) returns no auth metadata: there is nothing to validate, so nothing is logged. Metadata that did come back + * and fails validation is logged and dropped. + */ +export async function loadServerConfigs( + mx: Pick +): Promise { + const result = await Promise.allSettled([ + mx.getCapabilities(), + mx.getMediaConfig(), + mx.getAuthMetadata(), + ]); + + const capabilities = promiseFulfilledResult(result[0]); + const mediaConfig = promiseFulfilledResult(result[1]); + const authMetadata = promiseFulfilledResult(result[2]); + let validatedAuthMetadata: ValidatedAuthMetadata | undefined; + + if (authMetadata) { + try { + validatedAuthMetadata = validateAuthMetadata(authMetadata); + } catch (e) { + console.error(e); + } + } + + return { capabilities, mediaConfig, authMetadata: validatedAuthMetadata }; +} + export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) { const mx = useMatrixClient(); const fallbackConfigs = useMemo(() => ({}), []); const [configsState] = useAsyncCallbackValue( - useCallback(async () => { - const result = await Promise.allSettled([ - mx.getCapabilities(), - mx.getMediaConfig(), - mx.getAuthMetadata(), - ]); - - const capabilities = promiseFulfilledResult(result[0]); - const mediaConfig = promiseFulfilledResult(result[1]); - const authMetadata = promiseFulfilledResult(result[2]); - let validatedAuthMetadata: ValidatedAuthMetadata | undefined; - - try { - validatedAuthMetadata = validateAuthMetadata(authMetadata); - } catch (e) { - console.error(e); - } - - return { - capabilities, - mediaConfig, - authMetadata: validatedAuthMetadata, - }; - }, [mx]) + useCallback(() => loadServerConfigs(mx), [mx]) ); const configs: ServerConfigs = From 8dea8717ea4950f3f191a886ebf91f807c2389c1 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 18:10:31 +0000 Subject: [PATCH 05/17] desk: read-only Mind desk slice 1 (typed projections, buildDesk, deskMood, DeskView, navigator entry, demo forks) Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/desk/Desk.tsx | 45 ++++++ src/app/features/desk/DeskEntry.tsx | 17 +++ src/app/features/desk/DeskView.css.ts | 99 ++++++++++++ src/app/features/desk/DeskView.test.tsx | 110 +++++++++++++ src/app/features/desk/DeskView.tsx | 161 ++++++++++++++++++++ src/app/features/desk/buildDesk.test.ts | 127 +++++++++++++++ src/app/features/desk/buildDesk.ts | 153 +++++++++++++++++++ src/app/features/desk/deskMood.test.ts | 59 +++++++ src/app/features/desk/deskMood.ts | 75 +++++++++ src/app/features/desk/feedback.ts | 70 +++++++++ src/app/features/desk/fixtures.ts | 100 ++++++++++++ src/app/features/desk/index.ts | 3 + src/app/features/desk/source.ts | 13 ++ src/app/features/estate/EstateNavigator.tsx | 13 +- src/app/pages/Router.tsx | 11 ++ src/app/pages/pathUtils.ts | 4 + src/app/pages/paths.ts | 3 + src/preview-demo/deskForks.test.ts | 30 ++++ src/preview-demo/deskForks.ts | 72 +++++++++ src/preview-demo/install.ts | 3 + 20 files changed, 1166 insertions(+), 2 deletions(-) create mode 100644 src/app/features/desk/Desk.tsx create mode 100644 src/app/features/desk/DeskEntry.tsx create mode 100644 src/app/features/desk/DeskView.css.ts create mode 100644 src/app/features/desk/DeskView.test.tsx create mode 100644 src/app/features/desk/DeskView.tsx create mode 100644 src/app/features/desk/buildDesk.test.ts create mode 100644 src/app/features/desk/buildDesk.ts create mode 100644 src/app/features/desk/deskMood.test.ts create mode 100644 src/app/features/desk/deskMood.ts create mode 100644 src/app/features/desk/feedback.ts create mode 100644 src/app/features/desk/fixtures.ts create mode 100644 src/app/features/desk/index.ts create mode 100644 src/app/features/desk/source.ts create mode 100644 src/preview-demo/deskForks.test.ts create mode 100644 src/preview-demo/deskForks.ts diff --git a/src/app/features/desk/Desk.tsx b/src/app/features/desk/Desk.tsx new file mode 100644 index 00000000..9992be64 --- /dev/null +++ b/src/app/features/desk/Desk.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; +import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { getHomePath } from '../../pages/pathUtils'; +import { DeskView } from './DeskView'; +import { deskFor } from './source'; + +const labelOf = (mind: string) => mind.charAt(0).toUpperCase() + mind.slice(1); + +/** The route's page: one Mind's desk under the navigator. */ +export function Desk() { + const { mind = '' } = useParams(); + const navigate = useNavigate(); + const phone = useScreenSizeContext() === ScreenSize.Mobile; + const label = labelOf(mind); + + return ( + + + + + {phone && ( + navigate(getHomePath())}> + + + )} + + {`${label}'s desk`} + + + + + + + + + + + + + + ); +} diff --git a/src/app/features/desk/DeskEntry.tsx b/src/app/features/desk/DeskEntry.tsx new file mode 100644 index 00000000..1d3068ab --- /dev/null +++ b/src/app/features/desk/DeskEntry.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Text } from 'folds'; +import { getDeskPath } from '../../pages/pathUtils'; +import { countOpen } from './buildDesk'; +import { deskFor } from './source'; + +/** The way into a Mind's desk, set in its navigator group header; nothing at all when nothing is open. */ +export function DeskEntry({ mind }: { mind: string }) { + const open = countOpen(deskFor(mind)); + if (open === 0) return null; + return ( + + {`Desk · ${open} open`} + + ); +} diff --git a/src/app/features/desk/DeskView.css.ts b/src/app/features/desk/DeskView.css.ts new file mode 100644 index 00000000..1e93f51b --- /dev/null +++ b/src/app/features/desk/DeskView.css.ts @@ -0,0 +1,99 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; +import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; + +const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; + +/** Rows and booth side by side on wide screens. */ +export const Layout = style({ + display: 'flex', + gap: config.space.S500, + alignItems: 'flex-start', + '@media': { [PHONE]: { display: 'block', paddingBottom: toRem(120) } }, +}); + +export const Rows = style({ flex: 1, minWidth: 0, margin: 0, padding: 0, listStyle: 'none' }); + +/** A hairline splits rows; it is the only chrome. */ +export const Row = style({ + padding: `${config.space.S300} 0`, + borderTop: `1px solid ${color.Background.ContainerLine}`, +}); + +export const Title = style({ + margin: 0, + font: 'inherit', + fontWeight: 600, +}); + +/** The fold button reads as the title itself, not as a control. */ +export const Fold = style({ + all: 'unset', + cursor: 'pointer', + display: 'block', + width: '100%', + minHeight: 44, + boxSizing: 'border-box', +}); + +export const Quiet = style({ opacity: config.opacity.P300, margin: `${config.space.S100} 0` }); +export const Body = style({ whiteSpace: 'pre-wrap', margin: `${config.space.S200} 0` }); +export const Options = style({ margin: 0, padding: 0, listStyle: 'none' }); +export const Option = style({ padding: `${config.space.S100} 0` }); + +export const Note = style({ + display: 'block', + width: '100%', + boxSizing: 'border-box', + minHeight: 44, + margin: `${config.space.S200} 0`, + font: 'inherit', + color: 'inherit', + background: 'none', + border: `1px solid ${color.Background.ContainerLine}`, + opacity: config.opacity.Disabled, +}); + +/** Sticky beside the rows on wide screens; a small fixed corner image on phones. */ +export const Booth = style({ + position: 'sticky', + top: config.space.S400, + width: toRem(168), + flexShrink: 0, + '@media': { + [PHONE]: { + position: 'fixed', + top: 'auto', + right: config.space.S200, + bottom: config.space.S200, + width: toRem(88), + }, + }, +}); + +// PoseAvatar's button is laid out for message gutters; the booth takes it back into flow. +globalStyle(`${Booth} > button`, { + position: 'static', + float: 'none', + display: 'block', + width: '100%', + height: 'auto', + margin: 0, + maskImage: 'none', + WebkitMaskImage: 'none', + cursor: 'default', +}); + +export const BoothLine = style({ + '@media': { + // Still read out by the live region; only the picture shows in the corner. + [PHONE]: { + position: 'absolute', + width: 1, + height: 1, + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + whiteSpace: 'nowrap', + }, + }, +}); diff --git a/src/app/features/desk/DeskView.test.tsx b/src/app/features/desk/DeskView.test.tsx new file mode 100644 index 00000000..f1f09c86 --- /dev/null +++ b/src/app/features/desk/DeskView.test.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize'; +import { DeskEntry } from './DeskEntry'; +import { DeskView, SIGN_IN_NOTE } from './DeskView'; +import { NOW, STATES } from './fixtures'; +import { setDeskSource } from './source'; + +const view = (state: keyof typeof STATES, size: ScreenSize, mind = 'void') => + renderToStaticMarkup( + + + + ); + +describe('DeskView', () => { + it('folds a row behind its title on a phone, with the fold button saying so', () => { + const html = view('noAnswers', ScreenSize.Mobile); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain('Question plain'); + expect(html).not.toContain('Context for plain'); + expect(html).not.toContain('First way'); + }); + it('shows the row open, with no fold button, on a wide screen', () => { + const html = view('noAnswers', ScreenSize.Desktop); + expect(html).not.toContain('aria-expanded'); + expect(html).toContain('Context for plain'); + expect(html).toContain('First way'); + }); + it('states the recommendation in words, on that option only', () => { + const html = view('noAnswers', ScreenSize.Desktop); + expect(html.match(/\(recommended\)/g)).toHaveLength(1); + expect(html).toContain('B Second way (recommended)'); + }); + it('shows the depends line with the dependency status', () => { + expect(view('blocked', ScreenSize.Desktop)).toContain( + 'Waits on: "Needs the first one settled" (open)' + ); + }); + it('keeps the note disabled and says why in a sentence the field points at', () => { + const html = view('noAnswers', ScreenSize.Desktop); + expect(html).toMatch(/]* disabled=""/); + expect(html).toContain(SIGN_IN_NOTE); + expect(SIGN_IN_NOTE).toMatch(/sign-in/); + expect(html).toMatch(/aria-describedby="note-[^"]+-why"/); + }); + it('says No answers yet on a row without answers', () => { + expect(view('noAnswers', ScreenSize.Desktop)).toContain('No answers yet.'); + }); + it('shows how long a long-open question has waited', () => { + expect(view('longOpen', ScreenSize.Desktop)).toContain('Open for 21 days'); + }); + it('lists settled questions as recorded rulings with the ruling id', () => { + const html = view('settled', ScreenSize.Desktop); + expect(html).toContain('Recorded as rulings'); + expect(html).toContain('Question done (ruling:done-1)'); + expect(html).not.toContain('Question shut'); + }); + it('says it may be out of date when stale, and says nothing of the kind when fresh', () => { + expect(view('stale', ScreenSize.Desktop)).toContain('may be out of date'); + expect(view('noAnswers', ScreenSize.Desktop)).not.toContain('out of date'); + }); + it('says nothing is waiting when empty, and that nothing is published when it never arrived', () => { + expect(view('empty', ScreenSize.Desktop)).toContain('Nothing is waiting on you from Void.'); + expect(view('unpublished', ScreenSize.Desktop)).toContain( + 'Void's questions have not been published yet.' + ); + }); + it('shows hidden and erased rows as withheld stubs', () => { + expect(view('hidden', ScreenSize.Desktop)).toContain('hidden by Kiko'); + expect(view('erased', ScreenSize.Desktop)).toContain('erased account'); + }); + it('seats the Mind in the booth with the pose for the desk mood', () => { + const html = view('noAnswers', ScreenSize.Desktop); + expect(html).toContain('data-pose="thinking"'); + expect(html).toContain('Just one fork on the desk.'); + }); + it('shows no booth for a Mind without art rather than another face', () => { + expect(view('noAnswers', ScreenSize.Desktop, 'nobody')).not.toContain('data-pose'); + }); + it('shows no booth before anything is published', () => { + expect(view('unpublished', ScreenSize.Desktop)).not.toContain('data-pose'); + }); +}); + +describe('DeskEntry', () => { + afterEach(() => setDeskSource(() => undefined)); + const entry = (mind: string) => + renderToStaticMarkup( + + + + ); + it('reads "Desk · N open" and links to that Mind\'s desk', () => { + setDeskSource(() => STATES.blocked); + const html = entry('void'); + expect(html).toContain('Desk · 2 open'); + expect(html).toContain('href="/desk/void"'); + }); + it('is absent at zero open, when nothing is published, and when only settled forks exist', () => { + setDeskSource(() => STATES.empty); + expect(entry('void')).toBe(''); + setDeskSource(() => undefined); + expect(entry('void')).toBe(''); + setDeskSource(() => ({ ...STATES.settled, questions: STATES.settled.questions.slice(0, 2) })); + expect(entry('void')).toBe(''); + }); +}); diff --git a/src/app/features/desk/DeskView.tsx b/src/app/features/desk/DeskView.tsx new file mode 100644 index 00000000..80c22828 --- /dev/null +++ b/src/app/features/desk/DeskView.tsx @@ -0,0 +1,161 @@ +import React, { useState } from 'react'; +import { Text } from 'folds'; +import { PoseAvatar } from '../../components/message/pose/PoseAvatar'; +import { MINDS } from '../../components/message/pose/poses'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { AnswerRow, Desk, DeskRow, buildDesk } from './buildDesk'; +import { DeskProjection } from './feedback'; +import { deskMood } from './deskMood'; +import * as css from './DeskView.css'; + +/** Slice 1 has no session: every viewer is signed out, and the note says why it cannot be used. */ +export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can read the questions.'; + +const date = (iso: string) => iso.slice(0, 10); + +const age = (row: Extract) => { + if (row.longOpen) return `Open for ${row.ageDays} days`; + if (row.ageDays === 0) return 'Asked today'; + return row.ageDays === 1 ? 'Asked 1 day ago' : `Asked ${row.ageDays} days ago`; +}; + +function Answers({ answers }: { answers: AnswerRow[] }) { + if (answers.length === 0) return

No answers yet.

; + return ( +
    + {answers.map((a) => ( +
  • + {`${a.who}, ${a.kind.toLowerCase()}: `} + {a.withheld ?? [a.choice, a.text && `"${a.text}"`].filter(Boolean).join(' ')} + {` (${date(a.at)})`} +
  • + ))} +
+ ); +} + +function Row({ row }: { row: DeskRow }) { + const phone = useScreenSizeContext() === ScreenSize.Mobile; + // Phones list titles; a tap opens one. Wide screens show every row open. + const [open, setOpen] = useState(!phone); + if (row.kind === 'hidden') { + return ( +
  • +

    + {`A question here is hidden by ${row.hidden.by}, ${date(row.hidden.at)}, ${ + row.hidden.reason + }.`} +

    +
  • + ); + } + const noteId = `note-${row.forkId}`; + return ( +
  • +

    + {phone ? ( + + ) : ( + row.title + )} +

    +

    {age(row)}

    + {open && ( + <> +

    {row.body}

    +
      + {row.options.map((o) => ( +
    • + {`${o.id} ${o.text}`} + {o.recommended && ' (recommended)'} +
    • + ))} +
    + {row.depends.map((d) => ( +

    + {`Waits on: "${d.text}"${d.status ? ` (${d.status})` : ''}`} +

    + ))} + Your note +