From 4475a55be5ff2458f6cfbecdee041a4a13b22f98 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 23:00:40 +0000 Subject: [PATCH] Sky text: bubble treatment instead of plates; Noonday ink toward black Replaces #49's approach. No slab or pane plates: each run of shell text gets a half-strength tint of the theme's surface with a backdrop blur, as a chat bubble does, and links wear the ink underlined. Noonday Sky's ink moves toward black (text key 0.4, #342d25 to #1f1a15) so Day text clears 4.5:1 on its whole sky (4.73); Dusk still falls short (bare 1.10, bubble treatment 4.08 at its dark end; its darkest sky luminance must reach 0.03). The room intro and call view lose their plates. skyTheme.test.ts pins the numbers. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/room-intro/RoomIntro.tsx | 6 +- src/app/features/call/CallView.tsx | 4 +- src/app/features/scene/skyPlate.css.ts | 95 ++++++----------- src/app/features/scene/skyPlate.ts | 9 -- src/app/features/scene/skyState.test.ts | 1 + src/app/features/scene/skyTheme.test.ts | 111 +++++++++++--------- src/app/features/scene/skyTheme.ts | 6 +- src/app/hooks/useTheme.ts | 1 + src/sky/baked.ts | 20 ++-- src/sky/skyTokens.js | 2 +- 10 files changed, 112 insertions(+), 143 deletions(-) delete mode 100644 src/app/features/scene/skyPlate.ts diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index 0c6b10df..2e4e3ebc 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -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 ( - + diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index a3c0c567..3606ab10 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -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', }); diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts deleted file mode 100644 index 872e2fd6..00000000 --- a/src/app/features/scene/skyPlate.ts +++ /dev/null @@ -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; diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index 7b33a1ba..49b2121e 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -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}`); diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 3fd06875..c6c2c250 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -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); - }); -}); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 8854fcd4..f4647cbb 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -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'; diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index bf556dd0..abbb9d3a 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -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'; diff --git a/src/sky/baked.ts b/src/sky/baked.ts index 0393fdbf..8b286f92 100644 --- a/src/sky/baked.ts +++ b/src/sky/baked.ts @@ -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', diff --git a/src/sky/skyTokens.js b/src/sky/skyTokens.js index c234de12..967b0757 100644 --- a/src/sky/skyTokens.js +++ b/src/sky/skyTokens.js @@ -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 }, }; -- 2.47.3