Sky text: bubble treatment instead of plates; Noonday ink toward black (replaces #49) #51

Closed
void wants to merge 1 commit from void/plate-redesign into dev AGit
10 changed files with 116 additions and 147 deletions

View file

@ -1,9 +1,7 @@
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { Avatar, Box, Button, Spinner, Text, as } from 'folds';
import { Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { SkyPlate } from '../../features/scene/skyPlate.css';
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@ -25,7 +23,7 @@ export type RoomIntroProps = {
room: Room;
};
export const RoomIntro = as<'div', RoomIntroProps>(({ room, className, ...props }, ref) => {
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate();
@ -52,7 +50,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, className, ...props
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
return (
<Box direction="Column" grow="Yes" gap="500" className={classNames(SkyPlate, className)} {...props} ref={ref}>
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
<Box>
<Avatar size="500">
<RoomAvatar

View file

@ -1,7 +1,5 @@
import React, { RefObject, useRef } from 'react';
import classNames from 'classnames';
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
import { SkyPlate } from '../scene/skyPlate.css';
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { PrescreenControls } from './PrescreenControls';
@ -164,7 +162,7 @@ export function CallView() {
return (
<Box
className={classNames(ContainerColor({ variant: 'Surface' }), SkyPlate)}
className={ContainerColor({ variant: 'Surface' })}
style={{ minWidth: toRem(280) }}
grow="Yes"
>

View file

@ -1,69 +1,38 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config } from 'folds';
import { MessageBase, BubbleContent } from '../../components/message/layout/layout.css';
import { PageContent, PageNavContent } from '../../components/page/style.css';
import { Editor } from '../../components/editor/Editor.css';
import { MembersDrawer } from '../room/MembersDrawer.css';
import { CallStatus } from '../call-status/styles.css';
import { RoomViewFollowing } from '../room/RoomViewFollowing.css';
import { RoomViewTyping } from '../room/RoomViewTyping.css';
import { SKY_PLATE_FALLBACK } from './skyPlate';
import { globalStyle } from '@vanilla-extract/css';
import { config } from 'folds';
import { CategoryButton } from '../room-nav/styles.css';
import { surfaceInk } from './sceneShell.css';
// Over the scene (html[data-scene]) the signed-in shell is clear (sceneShell.css.ts), so text
// outside a chat bubble would sit on bare sky, which a sky state can make light, dark or both
// (dusk): no ink clears 4.5:1 on all of that (skyTheme.test.ts). Every such region is a plate
// instead, the sign-in card's opaque --sky-bubble fill, with its own ink and link, so what was
// checked for the card holds everywhere. One rule, below; a region joins it by class or by tag.
// Over the scene (html[data-scene]) the signed-in shell is clear (sceneShell.css.ts), so app text
// sits on sky. No pane and no slab is drawn behind it. Each run of text gets what a chat bubble
// gets (message/layout/layout.css.ts): a backdrop blur and a tint of the theme's own surface at half
// strength, translucent, so the sky still shows. The ink is the theme's own and is not changed.
// What that reaches against each state's sky is pinned in skyTheme.test.ts; where it falls short the
// sky bands have to move (the numbers are in the test), not this rule.
const scene = ':root[data-scene]';
const fill = `var(--sky-bubble, ${SKY_PLATE_FALLBACK.fill})`;
const text = `var(--sky-bubble-text, ${SKY_PLATE_FALLBACK.text})`;
const tint = `color-mix(in srgb, ${surfaceInk} 50%, transparent)`;
/** A region with no class of its own (the room intro, the call view) joins the plate with this. */
export const SkyPlate = style({});
// Leaf text in the shell. Menus and dialogs render outside #root with their own themed fill, and
// buttons, fields and the composer keep their own surfaces, so they are left out; the room list's
// section headings are buttons whose text is bare, so they are let back in.
const leaf = ':is(span, p, a, h1, h2, h3, h4, h5, h6, label, small, strong, b, em, i, time, li)';
const bare = ':not(:has(*:not(br, wbr)))';
const outside = ':not(button *, [role="button"] *, [contenteditable] *, input, textarea)';
// A recipe's first class is its base style.
const base = (recipe: () => string) => `.${recipe().split(' ')[0]}`;
globalStyle(
`${scene} #root ${leaf}${bare}${outside}, ${scene} #root .${CategoryButton} ${leaf}${bare}`,
{
backgroundColor: [surfaceInk, tint],
backdropFilter: 'blur(12px)',
padding: `0 ${config.space.S100}`,
boxDecorationBreak: 'clone',
WebkitBoxDecorationBreak: 'clone',
}
);
export const plateRegions = [
`.${SkyPlate}`,
// Title bars: the room header, the room list's header, every page header.
'#root header',
// The room list, and the pages that are lists (explore, inbox, search).
`.${PageNavContent}`,
`.${PageContent}`,
// The composer, the members drawer, and the call status bar.
`.${Editor}`,
`.${MembersDrawer}`,
`.${CallStatus}`,
// The bars over the composer.
`.${RoomViewTyping}`,
base(RoomViewFollowing),
// A message that is not a bubble (Modern, Compact) is plain text on the scene.
`${base(MessageBase)}:not(:has(.${BubbleContent}))`,
];
// Only inside #root: menus and dialogs render in #portalContainer with their own themed fill.
const within = (ancestor: string) =>
plateRegions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', ');
globalStyle(within(scene), {
backgroundColor: fill,
color: text,
// Themed ink was chosen for the clear shell; inside the plate every token reads the plate's.
vars: {
[color.Background.OnContainer]: text,
[color.Surface.OnContainer]: text,
[color.SurfaceVariant.OnContainer]: text,
},
});
globalStyle(`${scene} #root header`, { borderRadius: config.radii.R400 });
// Links read against the plate. The Midnight and Noonday themes set their own --tc-link for it;
// the other themes take the fallback, as the sign-in card does.
globalStyle(within(`${scene} body:not(.gamecult-theme)`), {
vars: { '--tc-link': SKY_PLATE_FALLBACK.link },
});
globalStyle(`${scene} #root .${SkyPlate}`, {
borderRadius: config.radii.R400,
// A link keeps the text's ink and is underlined: the theme's link colour was chosen for a solid
// ground and falls short on a translucent one (Midnight Sky 2.36 on its own sky).
globalStyle(`${scene} #root a${bare}${outside}`, {
color: 'inherit',
textDecorationLine: 'underline',
});

View file

@ -1,9 +0,0 @@
// The plate: the opaque surface text sits on wherever the shell is clear over the background
// scene and a sky state can make the sky behind it anything. It is the sign-in card's colour
// (--sky-bubble, set by the Midnight and Noonday themes in sky.css.ts), so what was checked for
// the card holds here. The other themes take these fallbacks.
export const SKY_PLATE_FALLBACK = {
fill: '#0d1a26',
text: '#b7c7d9',
link: '#99c7ff',
} as const;

View file

@ -182,6 +182,7 @@ describe('login ink against the sky backdrop', () => {
'night|Light/Silver/Dark/Butter|text',
'night|Light/Silver/Dark/Butter|link',
'night|Midnight Sky|text',
'day|Noonday Sky|text',
]);
describe.each(rows)('$theme $role in $state', (r) => {
const holds = clears.has(`${r.state}|${r.theme}|${r.role}`);

View file

@ -1,13 +1,6 @@
import { describe, expect, it } from 'vitest';
import { baked } from '../../../sky/baked';
import { AA, contrast, luminance } from '../../../sky/skyTokens.js';
import { Editor } from '../../components/editor/Editor.css';
import { PageContent, PageNavContent } from '../../components/page/style.css';
import { CallStatus } from '../call-status/styles.css';
import { MembersDrawer } from '../room/MembersDrawer.css';
import { RoomViewTyping } from '../room/RoomViewTyping.css';
import { SKY_PLATE_FALLBACK } from './skyPlate';
import { SkyPlate, plateRegions } from './skyPlate.css';
import { AA, contrast, luminance, toHex } from '../../../sky/skyTokens.js';
import { SKY_STATES, skyBackdrop, type SkyBackdrop, type SkyState } from './skyState';
import { skyThemeId } from './skyTheme';
@ -46,12 +39,12 @@ describe('skyThemeId', () => {
describe('ink of the theme a state picks, on bare sky', () => {
// Per state: the picked theme, its ink and link, and their ratios on bare sky, over the union
// of the skies the scenes draw (skyBackdrop). Only night text passes (5.05); day's sky reaches
// 0.919 and dusk's 0.017 to 0.965, so no theme's ink holds there. No link passes anywhere
// (night 2.36): nothing rests on bare sky, everything sits on the plate below.
// of the skies the scenes draw (skyBackdrop), strict whole range. Day (4.73) and night (5.05)
// text pass; dusk (0.017 to 0.965) does not. The theme link never passes on bare sky (night
// 2.36), so shell links wear the ink.
const cells: [SkyState, string, string, string, string, string][] = [
['day', 'noonday-sky', '#342d25', '3.71', '#2a4f27', '2.56'],
['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27', '1.00'],
['day', 'noonday-sky', '#1f1a15', '4.73', '#2a4f27', '2.56'],
['dusk', 'noonday-sky', '#1f1a15', '1.10', '#2a4f27', '1.00'],
['night', 'midnight-sky', '#aeccff', '5.05', '#609385', '2.36'],
];
@ -63,32 +56,71 @@ describe('ink of the theme a state picks, on bare sky', () => {
expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink);
});
it('night is the only state whose text clears AA on bare sky', () => {
it('day and night text clear AA on bare sky', () => {
const passing = fixed.filter(
([s, id]) => ratio(SKY[id].tokens.Background.OnContainer, skyBackdrop(s)) >= AA
);
expect(passing.map(([s]) => s)).toEqual(['night']);
expect(passing.map(([s]) => s)).toEqual(['day', 'night']);
});
});
describe('the plate, where text cannot rest on bare sky', () => {
// Each theme's plate: fill, ink, link and the WCAG ratios of ink and link on the fill.
const plates = [
['midnight-sky', baked.midnight, '12.17', '5.68'],
['noonday-sky', baked.noonday, '6.70', '4.62'],
describe('app text on the sky, with the bubble treatment (skyPlate.css.ts)', () => {
// Shell text is the theme's own ink on a half-strength tint of the theme's own surface over the
// sky, as a chat bubble is. The sky behind is a grey of the state's darkest or lightest
// luminance (skyBackdrop); the blend is in sRGB, as the browser does it. Links wear the ink.
const grey = (lum: number) => {
let [lo, hi] = [0, 1];
for (let i = 0; i < 40; i += 1) {
const mid = (lo + hi) / 2;
if (luminance(toHex([mid, mid, mid])) < lum) lo = mid;
else hi = mid;
}
return toHex([lo, lo, lo]);
};
const enc = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
const over = (tint: string, sky: string, a = 0.5) =>
`#${enc(tint)
.map((v, i) =>
Math.round((v * a + enc(sky)[i] * (1 - a)) * 255)
.toString(16)
.padStart(2, '0')
)
.join('')}`;
const worst = (ink: string, tint: string, b: SkyBackdrop) =>
Math.min(
...[b.darkest, b.lightest].map((l) => contrast(ink, over(tint, grey(l))))
);
// [state, theme, ratio of ink on the tinted sky]: Day clears AA; Dusk falls short at its dark
// end (0.017) and Nibu's sky bands have to move: darkest luminance needed is pinned below.
const rows = [
['day', 'noonday-sky', '7.68'],
['dusk', 'noonday-sky', '4.08'],
['night', 'midnight-sky', '8.07'],
] as const;
it.each(plates)('%s: ink and link on the fill', (_id, sky, inkRatio, linkRatio) => {
expect(r2(contrast(sky.bubbles.OtherText, sky.bubbles.Other))).toBe(inkRatio);
expect(r2(contrast(sky.link, sky.bubbles.Other))).toBe(linkRatio);
expect(contrast(sky.bubbles.OtherText, sky.bubbles.Other)).toBeGreaterThanOrEqual(AA);
expect(contrast(sky.link, sky.bubbles.Other)).toBeGreaterThanOrEqual(AA);
it.each(rows)('%s with %s: ink on the tinted sky measures %s', (state, id, want) => {
const sky = SKY[id];
expect(r2(worst(sky.tokens.Background.OnContainer, sky.tokens.Surface.Container, skyBackdrop(state)))).toBe(want);
});
it('the other themes take fallback colours that clear AA', () => {
const { fill, text, link } = SKY_PLATE_FALLBACK;
expect(r2(contrast(text, fill))).toBe('10.21');
expect(r2(contrast(link, fill))).toBe('10.03');
it('only dusk falls short, and by how much the dark end must lift', () => {
const short = rows.filter(([s, id]) => {
const sky = SKY[id];
return worst(sky.tokens.Background.OnContainer, sky.tokens.Surface.Container, skyBackdrop(s)) < AA;
});
expect(short.map(([s]) => s)).toEqual(['dusk']);
// Smallest darkest-sky luminance at which Noonday ink clears AA on the tinted sky.
const sky = SKY['noonday-sky'];
let [lo, hi] = [0, 1];
for (let i = 0; i < 30; i += 1) {
const mid = (lo + hi) / 2;
const ok =
contrast(sky.tokens.Background.OnContainer, over(sky.tokens.Surface.Container, grey(mid))) >= AA;
if (ok) hi = mid;
else lo = mid;
}
expect(r2(hi)).toBe('0.03');
});
it('every container of the picked themes carries its text at AA', () => {
@ -99,24 +131,3 @@ describe('the plate, where text cannot rest on bare sky', () => {
});
});
});
describe('regions that sit on the plate', () => {
// Every region whose text would otherwise rest on bare sky joins the one plate rule. The ink
// and link of that plate are pinned above; this pins who is on it.
const on = (cls: string) => expect(plateRegions).toContain(cls);
it('plates the composer, the members drawer, the call status bar and the call view', () => {
on(`.${Editor}`);
on(`.${MembersDrawer}`);
on(`.${CallStatus}`);
on(`.${SkyPlate}`);
});
it('plates the room list, list pages, typing and Following bars and every header', () => {
on(`.${PageNavContent}`);
on(`.${PageContent}`);
on(`.${RoomViewTyping}`);
on('#root header');
expect(plateRegions.some((r) => r.includes(':not(:has('))).toBe(true);
});
});

View file

@ -2,9 +2,9 @@
// would have had (the user's choice when signed in, Midnight Sky when signed out).
//
// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. On bare sky
// only Night's text clears 4.5:1 (5.05); Day's sky reaches 0.919 luminance and Dusk's spans
// 0.017 to 0.965, so no ink holds there. Text therefore never rests on bare sky: every region
// that would is a plate (skyPlate.css.ts), and skyTheme.test.ts pins the numbers.
// Day (4.73) and Night (5.05) text clear 4.5:1 over the whole of their skies; Dusk (0.017 to 0.965)
// does not (1.10), and its text relies on the bubble treatment of skyPlate.css.ts, which still falls
// short at Dusk's dark end (skyTheme.test.ts pins the numbers and the luminance the band must reach).
import type { SkyState } from './skyState';
export type SkyThemeId = 'midnight-sky' | 'noonday-sky';

View file

@ -10,6 +10,7 @@ import {
} from '../../colors.css';
import '../../gamecult.css';
import '../../sky.css';
import '../features/scene/skyPlate.css';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
import { skyThemeId } from '../features/scene/skyTheme';

View file

@ -138,21 +138,21 @@ export const baked = {
ContainerHover: '#b6b3ac',
ContainerActive: '#b0ada6',
ContainerLine: '#a6a39d',
OnContainer: '#342d25',
OnContainer: '#1f1a15',
},
Surface: {
Container: '#d7d4cb',
ContainerHover: '#d0cdc5',
ContainerActive: '#c9c6be',
ContainerLine: '#bebbb4',
OnContainer: '#342d25',
OnContainer: '#1f1a15',
},
SurfaceVariant: {
Container: '#cac7bf',
ContainerHover: '#c4c0b9',
ContainerActive: '#bdbab2',
ContainerLine: '#b3b0a9',
OnContainer: '#342d25',
OnContainer: '#1f1a15',
},
Primary: {
Main: '#335d2f',
@ -164,7 +164,7 @@ export const baked = {
ContainerHover: '#b3b5a9',
ContainerActive: '#adafa3',
ContainerLine: '#a4a59a',
OnContainer: '#342d25',
OnContainer: '#1f1a15',
},
Secondary: {
Main: '#392d23',
@ -176,7 +176,7 @@ export const baked = {
ContainerHover: '#b4b0a8',
ContainerActive: '#adaaa2',
ContainerLine: '#a4a09a',
OnContainer: '#342d25',
OnContainer: '#1f1a15',
},
Success: {
Main: '#335d2f',
@ -188,7 +188,7 @@ export const baked = {
ContainerHover: '#b3b5a9',
ContainerActive: '#adafa3',
ContainerLine: '#a4a59a',
OnContainer: '#342d25',
OnContainer: '#1f1a15',
},
Warning: {
Main: '#776530',
@ -200,7 +200,7 @@ export const baked = {
ContainerHover: '#beb7a9',
ContainerActive: '#b7b1a4',
ContainerLine: '#ada79b',
OnContainer: '#342d25',
OnContainer: '#1f1a15',
},
Critical: {
Main: '#7a4238',
@ -212,14 +212,14 @@ export const baked = {
ContainerHover: '#bdb2aa',
ContainerActive: '#b6aba4',
ContainerLine: '#aca29b',
OnContainer: '#342d25',
OnContainer: '#1f1a15',
},
},
bubbles: {
Other: '#b9b6af',
OtherText: '#342d25',
OtherText: '#1f1a15',
Own: '#acb99a',
OwnText: '#342d25',
OwnText: '#1f1a15',
},
userColors: [
'#2b4675',

View file

@ -83,7 +83,7 @@ const LIGHT = {
skyKey: 0.55,
main: 0.55,
container: 1.0,
text: 1.0,
text: 0.4, // ink toward black: bare Day text must clear AA on the Day sky (0.238 and up)
steps: { Hover: 0.93, Active: 0.86, Line: 0.76 },
};