From 06d530d061340b73d01c1204ab7ef92125f19379 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 18:12:36 +0000 Subject: [PATCH 1/3] 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; -- 2.47.3 From de6f1849eaaed050c1bc792c0bc2062187fcc0c3 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 18:14:36 +0000 Subject: [PATCH 2/3] 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([]) + ); + }); +}); -- 2.47.3 From 0351f1f6e8e202fa83282ee36583dc0af18e44f2 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 18:15:07 +0000 Subject: [PATCH 3/3] 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 = -- 2.47.3