Phone: notice contrast, 44px tap hit areas, skip empty OIDC validation #78
9 changed files with 235 additions and 26 deletions
32
src/app/components/ServerConfigsLoader.test.ts
Normal file
32
src/app/components/ServerConfigsLoader.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
});
|
||||
|
|
|
|||
84
src/app/phoneTapTargets.test.ts
Normal file
84
src/app/phoneTapTargets.test.ts
Normal 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([])
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue