From c1803854767b7ab2da40353512ff80002dab75a8 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:10:11 +0000 Subject: [PATCH 1/5] Sky-state control in the estate title bar; the state picks the theme One button beside Find a room opens Auto / Day / Dusk / Night (fits a 320 px bar). Day and Dusk take Noonday Sky's dark ink, Night Midnight Sky's light ink, Auto keeps the theme the page would have had; signed-out pages follow the state too. skyTheme.test.ts checks contrast from the baked tokens: day and night ink clear AA on bare sky, every container carries its text, links clear AA on the surface, and dusk text on bare sky does not, so it stays on surfaces. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 2 + src/app/features/scene/SkyStateControl.tsx | 80 +++++++++++++++++++++ src/app/features/scene/skyTheme.test.ts | 59 +++++++++++++++ src/app/features/scene/skyTheme.ts | 18 +++++ src/app/hooks/useSkyState.ts | 4 ++ src/app/hooks/useTheme.ts | 11 +++ src/app/pages/ThemeManager.tsx | 10 ++- 7 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 src/app/features/scene/SkyStateControl.tsx create mode 100644 src/app/features/scene/skyTheme.test.ts create mode 100644 src/app/features/scene/skyTheme.ts create mode 100644 src/app/hooks/useSkyState.ts diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 1a9ca8ee..2cbbfe4a 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -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} + = { + 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(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + {SKY_STATES.map((s) => ( + { + setSkyState(s); + setCords(undefined); + }} + after={state === s ? : undefined} + > + + {LABEL[s]} + + + ))} + + + + } + > + + + + + ); +} diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts new file mode 100644 index 00000000..74b3280b --- /dev/null +++ b/src/app/features/scene/skyTheme.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../sky/baked'; +import { AA, contrast, luminance } from '../../../sky/skyTokens.js'; +import { SKY_STATES, skyBackdrop, type SkyState } from './skyState'; +import { skyThemeId } from './skyTheme'; + +const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; +const ratio = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + +describe('skyThemeId', () => { + it('leaves the theme alone in auto and picks one for every fixed state', () => { + expect(skyThemeId('auto')).toBeUndefined(); + SKY_STATES.filter((s) => s !== 'auto').forEach((s) => { + expect(skyThemeId(s)).toBeDefined(); + }); + }); + + 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('contrast of the theme a state picks', () => { + const picked = SKY_STATES.flatMap((s) => { + const id = skyThemeId(s); + return id ? [[s, SKY[id]] as const] : []; + }); + + it.each(picked)('%s: every container carries its text at AA', (_state, sky) => { + Object.values(sky.tokens).forEach((group) => { + expect(contrast(group.OnContainer, group.Container)).toBeGreaterThanOrEqual(AA); + }); + }); + + it.each(picked)('%s: links read at AA on the background surface', (_state, sky) => { + expect(contrast(sky.link, sky.tokens.Background.Container)).toBeGreaterThanOrEqual(AA); + }); + + // Bare sky has no surface under it. Text may sit there only where the ink clears AA against + // both the darkest and the lightest sky the state can show; anywhere else it needs a surface. + const onBareSky = (state: SkyState): number => { + const id = skyThemeId(state); + if (!id) return 0; + const ink = luminance(SKY[id].tokens.Background.OnContainer); + const { darkest, lightest } = skyBackdrop(state); + return Math.min(ratio(ink, darkest), ratio(ink, lightest)); + }; + + it('day and night text on bare sky clears AA', () => { + expect(onBareSky('day')).toBeGreaterThanOrEqual(AA); + expect(onBareSky('night')).toBeGreaterThanOrEqual(AA); + }); + + it('dusk text on bare sky does not, so dusk text stays on surfaces', () => { + expect(onBareSky('dusk')).toBeLessThan(AA); + }); +}); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts new file mode 100644 index 00000000..ebd4504f --- /dev/null +++ b/src/app/features/scene/skyTheme.ts @@ -0,0 +1,18 @@ +// 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. Dusk's +// gradient runs from near-black to bright orange, so no single ink reads on all of it; Dusk +// text therefore only ever sits on a theme surface, never on bare sky (skyTheme.test.ts). +import type { SkyState } from './skyState'; + +export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; + +const BY_STATE: Record, SkyThemeId> = { + day: 'noonday-sky', + dusk: 'noonday-sky', + night: 'midnight-sky', +}; + +export const skyThemeId = (state: SkyState): SkyThemeId | undefined => + state === 'auto' ? undefined : BY_STATE[state]; diff --git a/src/app/hooks/useSkyState.ts b/src/app/hooks/useSkyState.ts new file mode 100644 index 00000000..985e2da2 --- /dev/null +++ b/src/app/hooks/useSkyState.ts @@ -0,0 +1,4 @@ +import { useSyncExternalStore } from 'react'; +import { getSkyState, subscribe, type SkyState } from '../features/scene/skyState'; + +export const useSkyState = (): SkyState => useSyncExternalStore(subscribe, getSkyState); diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index d3663283..bf556dd0 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -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; diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index e730b00d..b7c50d93 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -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; } -- 2.47.3 From 3be90875ec37b23ad1abcc4aae8b2f819500691b Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:19:58 +0000 Subject: [PATCH 2/5] Sky control: menu semantics, plates under bare-sky text, pinned colours The menu is role=menu with menuitemradio items (aria-checked), the button carries aria-expanded, focus goes to the checked item on open and back to the button on close. Over the scene the estate title bar and any non-bubble message sit on the sign-in card's plate (--sky-bubble) instead of bare sky. skyTheme.test.ts pins the ink colours and WCAG ratios of the picked themes, measured with the ink clamped into the sky range as skyState.test.ts does; the pass/fail set is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 4 +- src/app/features/scene/SkyStateControl.tsx | 10 +- src/app/features/scene/skyPlate.css.ts | 32 ++++++ src/app/features/scene/skyPlate.ts | 9 ++ src/app/features/scene/skyTheme.test.ts | 105 +++++++++++++------- 5 files changed, 119 insertions(+), 41 deletions(-) create mode 100644 src/app/features/scene/skyPlate.css.ts create mode 100644 src/app/features/scene/skyPlate.ts diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 2cbbfe4a..8e5cc50c 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -2,6 +2,7 @@ import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } fro import { useNavigate } from 'react-router-dom'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color, config } from 'folds'; +import classNames from 'classnames'; import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; import { NavButton, @@ -42,6 +43,7 @@ import { EstateRoom, EstateTier } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; import * as css from './EstateNavigator.css'; +import { SkyPlate } from '../scene/skyPlate.css'; import { SkyStateControl } from '../scene/SkyStateControl'; /** How long a tap waits for the client store to hold the room it just joined. */ @@ -290,7 +292,7 @@ export function EstateNavigator() { return ( - + {title} diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx index 64a747fb..5a9d84d6 100644 --- a/src/app/features/scene/SkyStateControl.tsx +++ b/src/app/features/scene/SkyStateControl.tsx @@ -32,7 +32,8 @@ export function SkyStateControl() { content={ setCords(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', @@ -40,7 +41,7 @@ export function SkyStateControl() { escapeDeactivates: stopPropagation, }} > - + {SKY_STATES.map((s) => ( { setSkyState(s); setCords(undefined); @@ -70,7 +72,7 @@ export function SkyStateControl() { aria-label={`Sky: ${LABEL[state]}`} title={`Sky: ${LABEL[state]}`} aria-haspopup="menu" - aria-pressed={!!cords} + aria-expanded={!!cords} onClick={open} > diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts new file mode 100644 index 00000000..cb56a75b --- /dev/null +++ b/src/app/features/scene/skyPlate.css.ts @@ -0,0 +1,32 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { config } from 'folds'; +import { MessageBase, BubbleContent } from '../../components/message/layout/layout.css'; +import { SKY_PLATE_FALLBACK } from './skyPlate'; + +// Over the scene (html[data-scene]) the signed-in shell is clear, 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). Such text gets a plate instead, never a halo. +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 plate = { + backgroundColor: fill, + color: text, + borderRadius: config.radii.R400, +} as const; + +/** A title-bar group whose controls and title sit on the plate. */ +export const SkyPlate = style({}); +globalStyle(`${scene} ${SkyPlate}`, { ...plate, padding: `0 ${config.space.S100}` }); +// Controls in the plate take its ink; their themed ink was chosen for the clear shell. +globalStyle(`${scene} ${SkyPlate} button`, { color: 'inherit' }); + +// A message that is not a bubble (Modern, Compact) is plain text on the scene: plate the row. +globalStyle(`${scene} ${MessageBase}:not(:has(.${BubbleContent}))`, plate); + +// Links on a plate read against the plate. The Midnight and Noonday themes set their own +// --tc-link for it; the rest take the fallback, as the sign-in card does. +globalStyle(`${scene} body:not(.gamecult-theme) ${SkyPlate}, ${scene} body:not(.gamecult-theme) ${MessageBase}`, { + vars: { '--tc-link': SKY_PLATE_FALLBACK.link }, +}); diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts new file mode 100644 index 00000000..872e2fd6 --- /dev/null +++ b/src/app/features/scene/skyPlate.ts @@ -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; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 74b3280b..3fad1f3f 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -1,18 +1,34 @@ import { describe, expect, it } from 'vitest'; import { baked } from '../../../sky/baked'; import { AA, contrast, luminance } from '../../../sky/skyTokens.js'; -import { SKY_STATES, skyBackdrop, type SkyState } from './skyState'; +import { SKY_PLATE_FALLBACK } from './skyPlate'; +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 ratio = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +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(); - SKY_STATES.filter((s) => s !== 'auto').forEach((s) => { - expect(skyThemeId(s)).toBeDefined(); - }); + 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', () => { @@ -22,38 +38,55 @@ describe('skyThemeId', () => { }); }); -describe('contrast of the theme a state picks', () => { - const picked = SKY_STATES.flatMap((s) => { - const id = skyThemeId(s); - return id ? [[s, SKY[id]] as const] : []; +describe('ink of the theme a state picks, on bare sky', () => { + // [state, text ratio, link ratio]; a theme's own ink is not chosen for bare sky, so only day + // and night text pass (Nibu's measurement, skyState.test.ts). Links never rest on bare sky. + const cells: [SkyState, string, string, string, string][] = [ + ['day', 'noonday-sky', '#342d25', '7.62', '#2a4f27'], + ['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27'], + ['night', 'midnight-sky', '#aeccff', '5.05', '#609385'], + ]; + + it.each(cells)('%s with %s: ink %s measures %s', (state, id, ink, want, link) => { + 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); }); - it.each(picked)('%s: every container carries its text at AA', (_state, sky) => { - Object.values(sky.tokens).forEach((group) => { - expect(contrast(group.OnContainer, group.Container)).toBeGreaterThanOrEqual(AA); - }); - }); - - it.each(picked)('%s: links read at AA on the background surface', (_state, sky) => { - expect(contrast(sky.link, sky.tokens.Background.Container)).toBeGreaterThanOrEqual(AA); - }); - - // Bare sky has no surface under it. Text may sit there only where the ink clears AA against - // both the darkest and the lightest sky the state can show; anywhere else it needs a surface. - const onBareSky = (state: SkyState): number => { - const id = skyThemeId(state); - if (!id) return 0; - const ink = luminance(SKY[id].tokens.Background.OnContainer); - const { darkest, lightest } = skyBackdrop(state); - return Math.min(ratio(ink, darkest), ratio(ink, lightest)); - }; - - it('day and night text on bare sky clears AA', () => { - expect(onBareSky('day')).toBeGreaterThanOrEqual(AA); - expect(onBareSky('night')).toBeGreaterThanOrEqual(AA); - }); - - it('dusk text on bare sky does not, so dusk text stays on surfaces', () => { - expect(onBareSky('dusk')).toBeLessThan(AA); + it('only the cells that clear AA on bare sky carry text there', () => { + const passing = fixed.filter( + ([s, id]) => ratio(SKY[id].tokens.Background.OnContainer, skyBackdrop(s)) >= AA + ); + 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'], + ] 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); + }); + }); }); }); -- 2.47.3 From ce16609c768ad41bd129df1f6228941299ffbcc8 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:25:59 +0000 Subject: [PATCH 3/5] Plate every region of bare-sky text under the scene with one rule skyPlate.css.ts joins the room and page headers, the room list, list pages, the typing and Following bars, non-bubble messages and the room intro to the sign-in card's opaque --sky-bubble plate, with the plate's ink and link tokens, at all widths. The estate title bar's own plate goes: the shared header rule covers it. skyTheme.test.ts pins the link ratios on bare sky as well (day 5.25, night 2.36). Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/room-intro/RoomIntro.tsx | 6 +- src/app/features/estate/EstateNavigator.tsx | 4 +- src/app/features/scene/skyPlate.css.ts | 69 +++++++++++++++------ src/app/features/scene/skyTheme.test.ts | 16 ++--- 4 files changed, 64 insertions(+), 31 deletions(-) diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index 2e4e3ebc..0c6b10df 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -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 ( - + - + {title} diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index cb56a75b..102eefd0 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -1,32 +1,63 @@ import { globalStyle, style } from '@vanilla-extract/css'; -import { config } from 'folds'; +import { color, config } from 'folds'; import { MessageBase, BubbleContent } from '../../components/message/layout/layout.css'; +import { PageContent, PageNavContent } from '../../components/page/style.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, 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). Such text gets a plate instead, never a halo. +// 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})`; -const plate = { +/** A region with no component class of its own (the room intro) 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]}`; + +const regions = [ + `.${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 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) => + regions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', '); + +globalStyle(within(scene), { backgroundColor: fill, color: text, - borderRadius: config.radii.R400, -} as const; + // 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 }); -/** A title-bar group whose controls and title sit on the plate. */ -export const SkyPlate = style({}); -globalStyle(`${scene} ${SkyPlate}`, { ...plate, padding: `0 ${config.space.S100}` }); -// Controls in the plate take its ink; their themed ink was chosen for the clear shell. -globalStyle(`${scene} ${SkyPlate} button`, { color: 'inherit' }); - -// A message that is not a bubble (Modern, Compact) is plain text on the scene: plate the row. -globalStyle(`${scene} ${MessageBase}:not(:has(.${BubbleContent}))`, plate); - -// Links on a plate read against the plate. The Midnight and Noonday themes set their own -// --tc-link for it; the rest take the fallback, as the sign-in card does. -globalStyle(`${scene} body:not(.gamecult-theme) ${SkyPlate}, ${scene} body:not(.gamecult-theme) ${MessageBase}`, { +// 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}`, { + padding: config.space.S400, + borderRadius: config.radii.R400, +}); diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 3fad1f3f..60a3ae64 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -39,19 +39,21 @@ describe('skyThemeId', () => { }); describe('ink of the theme a state picks, on bare sky', () => { - // [state, text ratio, link ratio]; a theme's own ink is not chosen for bare sky, so only day - // and night text pass (Nibu's measurement, skyState.test.ts). Links never rest on bare sky. - const cells: [SkyState, string, string, string, string][] = [ - ['day', 'noonday-sky', '#342d25', '7.62', '#2a4f27'], - ['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27'], - ['night', 'midnight-sky', '#aeccff', '5.05', '#609385'], + // Per state: the picked theme, its ink and link, and their ratios on bare sky. A theme's own + // ink is not chosen for bare sky, so only day and night text pass (skyState.test.ts), and no + // link does (night 2.36): links never rest on bare sky, they sit on the plate below. + const cells: [SkyState, string, string, string, string, string][] = [ + ['day', 'noonday-sky', '#342d25', '7.62', '#2a4f27', '5.25'], + ['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 measures %s', (state, id, ink, want, link) => { + 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('only the cells that clear AA on bare sky carry text there', () => { -- 2.47.3 From 5862bbdba210a3a51a60240c1d9486ef0b16c16c Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:31:43 +0000 Subject: [PATCH 4/5] Plate the composer, members drawer, call view and call status bar They join the shared plate rule under the scene. The composer's own half-strength blurred fill goes (its text fell below 4.5:1 in dusk and some auto themes); the plate has the checked ink. skyTheme.test.ts pins which regions are on the plate. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/editor/Editor.css.ts | 11 +--------- src/app/features/call/CallView.tsx | 4 +++- src/app/features/scene/skyPlate.css.ts | 14 +++++++++---- src/app/features/scene/skyTheme.test.ts | 27 +++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 82adb6f4..d128ed07 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -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, { diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 1fb97650..430219c6 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -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 ( diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index 102eefd0..a3c0c567 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -2,6 +2,9 @@ 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'; @@ -15,19 +18,23 @@ 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 component class of its own (the room intro) joins the plate with this. */ +/** 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]}`; -const regions = [ +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), @@ -37,7 +44,7 @@ const regions = [ // Only inside #root: menus and dialogs render in #portalContainer with their own themed fill. const within = (ancestor: string) => - regions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', '); + plateRegions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', '); globalStyle(within(scene), { backgroundColor: fill, @@ -58,6 +65,5 @@ globalStyle(within(`${scene} body:not(.gamecult-theme)`), { }); globalStyle(`${scene} #root .${SkyPlate}`, { - padding: config.space.S400, borderRadius: config.radii.R400, }); diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 60a3ae64..53a68c5c 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -1,7 +1,13 @@ 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'; @@ -92,3 +98,24 @@ 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); + }); +}); -- 2.47.3 From 76daf7cdca2cb26262ad56af858c4ef52a4b5ddf Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:32:56 +0000 Subject: [PATCH 5/5] Re-pin the bare-sky ratios on skyBackdrop's union ranges #47 makes skyBackdrop the union of the skies the scenes draw: day 0.238-0.919, dusk 0.017-0.965, night 0.058-0.077. Only night text still clears 4.5:1 on bare sky (5.05); day falls to 3.71 text / 2.56 link. Everything outside a bubble stays on the plate. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyTheme.test.ts | 13 +++++++------ src/app/features/scene/skyTheme.ts | 7 ++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 53a68c5c..3fd06875 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -45,11 +45,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. A theme's own - // ink is not chosen for bare sky, so only day and night text pass (skyState.test.ts), and no - // link does (night 2.36): links never rest on bare sky, they sit on the plate below. + // 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', '7.62', '#2a4f27', '5.25'], + ['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'], ]; @@ -62,11 +63,11 @@ describe('ink of the theme a state picks, on bare sky', () => { expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink); }); - it('only the cells that clear AA on bare sky carry text there', () => { + 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(['day', 'night']); + expect(passing.map(([s]) => s)).toEqual(['night']); }); }); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index ebd4504f..8854fcd4 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -1,9 +1,10 @@ // 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. Dusk's -// gradient runs from near-black to bright orange, so no single ink reads on all of it; Dusk -// text therefore only ever sits on a theme surface, never on bare sky (skyTheme.test.ts). +// 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'; -- 2.47.3