Phone: notice contrast, 44px tap hit areas, skip empty OIDC validation #78

Merged
void merged 3 commits from void/phone-contrast-taps into dev 2026-11-10 18:29:17 +00:00
9 changed files with 235 additions and 26 deletions

View file

@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { loadServerConfigs } from './ServerConfigsLoader';
const server = (authMetadata: () => Promise<unknown>) =>
({
getCapabilities: async () => ({}),
getMediaConfig: async () => ({ 'm.upload.size': 1 }),
getAuthMetadata: authMetadata,
} as unknown as Parameters<typeof loadServerConfigs>[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();
});
});

View file

@ -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<MatrixClient, 'getCapabilities' | 'getMediaConfig' | 'getAuthMetadata'>
): Promise<ServerConfigs> {
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<ServerConfigs, unknown>(
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 =

View file

@ -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',

View file

@ -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 (
<Text
size="T200"
style={{ padding: `${config.space.S100} 0`, color: color.SurfaceVariant.OnContainer }}
style={{
padding: `${config.space.S100} 0`,
color: (skyInk && noticeInk(skyInk)) ?? color.SurfaceVariant.OnContainer,
}}
>
{line}
</Text>

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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 } },
});

View file

@ -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<string, number>;
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([])
);
});
});