Merge pull request 'Sky-state control in the estate title bar; the state picks the theme' (#45) from void/sky-control into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m45s

This commit is contained in:
Void 2026-11-09 22:36:52 +00:00
commit 913a66ebf0
12 changed files with 333 additions and 16 deletions

View file

@ -1,4 +1,4 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { style } from '@vanilla-extract/css';
import { color, config, DefaultReset, toRem } from 'folds';
export const Editor = style([
@ -12,15 +12,6 @@ export const Editor = style([
},
]);
// Over the background scene (html[data-scene]) the composer is a bubble too: half strength, blurred.
globalStyle(`:root[data-scene] ${Editor}`, {
backgroundColor: [
color.SurfaceVariant.Container,
`color-mix(in srgb, ${color.SurfaceVariant.Container} 50%, transparent)`,
],
backdropFilter: 'blur(12px)',
});
export const EditorOptions = style([
DefaultReset,
{

View file

@ -1,7 +1,9 @@
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';
@ -23,7 +25,7 @@ export type RoomIntroProps = {
room: Room;
};
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
export const RoomIntro = as<'div', RoomIntroProps>(({ room, className, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate();
@ -50,7 +52,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
return (
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
<Box direction="Column" grow="Yes" gap="500" className={classNames(SkyPlate, className)} {...props} ref={ref}>
<Box>
<Avatar size="500">
<RoomAvatar

View file

@ -1,5 +1,7 @@
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';
@ -162,7 +164,7 @@ export function CallView() {
return (
<Box
className={ContainerColor({ variant: 'Surface' })}
className={classNames(ContainerColor({ variant: 'Surface' }), SkyPlate)}
style={{ minWidth: toRem(280) }}
grow="Yes"
>

View file

@ -42,6 +42,7 @@ import { EstateRoom, EstateTier } from './buildEstate';
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
import { useEstate, useEstateRootId } from './useEstate';
import * as css from './EstateNavigator.css';
import { SkyStateControl } from '../scene/SkyStateControl';
/** How long a tap waits for the client store to hold the room it just joined. */
const JOIN_ARRIVAL_TIMEOUT_MS = 20000;
@ -295,6 +296,7 @@ export function EstateNavigator() {
{title}
</Text>
</Box>
<SkyStateControl />
<IconButton
variant="Background"
aria-label="Find a room"

View file

@ -0,0 +1,82 @@
import React, { MouseEventHandler, useState } from 'react';
import { Box, config, Icon, IconButton, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../../utils/keyboard';
import { useSkyState } from '../../hooks/useSkyState';
import { SKY_STATES, setSkyState, type SkyState } from './skyState';
const LABEL: Record<SkyState, string> = {
auto: 'Auto',
day: 'Day',
dusk: 'Dusk',
night: 'Night',
};
/**
* One title-bar button that opens the four sky states. A single button, not a row, so the bar
* still fits a 320 px phone beside the search button; the menu items carry the state names.
*/
export function SkyStateControl() {
const state = useSkyState();
const [cords, setCords] = useState<RectCords>();
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
return (
<PopOut
anchor={cords}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: '[aria-checked="true"]',
returnFocusOnDeactivate: true,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu role="menu" aria-label="Sky">
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{SKY_STATES.map((s) => (
<MenuItem
key={s}
size="300"
variant="Surface"
radii="300"
role="menuitemradio"
aria-checked={state === s}
onClick={() => {
setSkyState(s);
setCords(undefined);
}}
after={state === s ? <Icon size="100" src={Icons.Check} /> : undefined}
>
<Box grow="Yes">
<Text size="T300">{LABEL[s]}</Text>
</Box>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
>
<IconButton
variant="Background"
aria-label={`Sky: ${LABEL[state]}`}
title={`Sky: ${LABEL[state]}`}
aria-haspopup="menu"
aria-expanded={!!cords}
onClick={open}
>
<Icon src={Icons.Bulb} size="200" />
</IconButton>
</PopOut>
);
}

View file

@ -0,0 +1,69 @@
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';
// 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.
const scene = ':root[data-scene]';
const fill = `var(--sky-bubble, ${SKY_PLATE_FALLBACK.fill})`;
const text = `var(--sky-bubble-text, ${SKY_PLATE_FALLBACK.text})`;
/** A region with no class of its own (the room intro, the call view) joins the plate with this. */
export const SkyPlate = style({});
// A recipe's first class is its base style.
const base = (recipe: () => string) => `.${recipe().split(' ')[0]}`;
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,
});

View file

@ -0,0 +1,9 @@
// 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

@ -0,0 +1,122 @@
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 { SKY_STATES, skyBackdrop, type SkyBackdrop, type SkyState } from './skyState';
import { skyThemeId } from './skyTheme';
const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const;
const fixed = (['day', 'dusk', 'night'] as const).flatMap((s) => {
const id = skyThemeId(s);
return id ? [[s, id] as const] : [];
});
// WCAG contrast of an ink against the sky it is worst on, measured as skyState.test.ts does: the
// sky can show any luminance between its darkest and lightest, and contrast is lowest where the
// backdrop is closest to the ink, which is the ink's own luminance clamped into that range.
const against = (ink: string, bg: number) => {
const li = luminance(ink);
const [hi, lo] = li > bg ? [li, bg] : [bg, li];
return (hi + 0.05) / (lo + 0.05);
};
const ratio = (ink: string, b: SkyBackdrop) =>
against(ink, Math.min(b.lightest, Math.max(b.darkest, luminance(ink))));
const r2 = (n: number) => n.toFixed(2);
describe('skyThemeId', () => {
it('leaves the theme alone in auto and picks one for every fixed state', () => {
expect(skyThemeId('auto')).toBeUndefined();
expect(SKY_STATES.filter((s) => s !== 'auto').map((s) => skyThemeId(s))).not.toContain(
undefined
);
});
it('lights day and dusk with dark ink and night with light ink', () => {
expect(skyThemeId('day')).toBe('noonday-sky');
expect(skyThemeId('dusk')).toBe('noonday-sky');
expect(skyThemeId('night')).toBe('midnight-sky');
});
});
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.
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'],
['night', 'midnight-sky', '#aeccff', '5.05', '#609385', '2.36'],
];
it.each(cells)('%s with %s: ink %s is %s, link %s is %s', (state, id, ink, want, link, wantLink) => {
const sky = SKY[id as keyof typeof SKY];
expect(sky.tokens.Background.OnContainer).toBe(ink);
expect(sky.link).toBe(link);
expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want);
expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink);
});
it('night is the only state whose text clears 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']);
});
});
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'],
] 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('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('every container of the picked themes carries its text at AA', () => {
fixed.forEach(([, id]) => {
Object.values(SKY[id].tokens).forEach((g) => {
expect(contrast(g.OnContainer, g.Container)).toBeGreaterThanOrEqual(AA);
});
});
});
});
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

@ -0,0 +1,19 @@
// Which theme a sky state lights the page with. 'auto' picks none: the page keeps the theme it
// 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.
import type { SkyState } from './skyState';
export type SkyThemeId = 'midnight-sky' | 'noonday-sky';
const BY_STATE: Record<Exclude<SkyState, 'auto'>, SkyThemeId> = {
day: 'noonday-sky',
dusk: 'noonday-sky',
night: 'midnight-sky',
};
export const skyThemeId = (state: SkyState): SkyThemeId | undefined =>
state === 'auto' ? undefined : BY_STATE[state];

View file

@ -0,0 +1,4 @@
import { useSyncExternalStore } from 'react';
import { getSkyState, subscribe, type SkyState } from '../features/scene/skyState';
export const useSkyState = (): SkyState => useSyncExternalStore(subscribe, getSkyState);

View file

@ -12,6 +12,8 @@ import '../../gamecult.css';
import '../../sky.css';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
import { skyThemeId } from '../features/scene/skyTheme';
import { useSkyState } from './useSkyState';
export enum ThemeKind {
Light = 'light',
@ -100,7 +102,14 @@ export const useSystemThemeKind = (): ThemeKind => {
return themeKind;
};
/** The theme the sky state asks for, or undefined while it is 'auto'. */
export const useSkyTheme = (): Theme | undefined => {
const id = skyThemeId(useSkyState());
return id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme;
};
export const useActiveTheme = (): Theme => {
const skyTheme = useSkyTheme();
const systemThemeKind = useSystemThemeKind();
const themes = useThemes();
const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme');
@ -108,6 +117,8 @@ export const useActiveTheme = (): Theme => {
const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId');
const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId');
if (skyTheme) return skyTheme;
if (!systemTheme) {
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? MidnightSkyTheme;

View file

@ -4,17 +4,21 @@ import {
MidnightSkyTheme,
ThemeContextProvider,
useActiveTheme,
useSkyTheme,
} from '../hooks/useTheme';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
export function UnAuthRouteThemeManager() {
// Signed-out pages are Midnight Sky unless the sky state picks otherwise; they ignore the
// system scheme.
const theme = useSkyTheme() ?? MidnightSkyTheme;
useEffect(() => {
document.body.className = '';
document.body.classList.add(configClass, varsClass);
// Signed-out pages are always Midnight Sky and ignore the system scheme.
document.body.classList.add(...MidnightSkyTheme.classNames);
}, []);
document.body.classList.add(...theme.classNames);
}, [theme]);
return null;
}