diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index e5db6c47..6d1a6691 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -1,7 +1,7 @@ import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; -import { surfaceInk } from '../../features/scene/sceneShell.css'; +import { skyLabelInk, surfaceInk } from '../../features/scene/sceneShell.css'; // Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows // are three distinct steps above them. @@ -222,6 +222,7 @@ phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, { textDecorationLine: 'underline', }); phoneLabel(`:root[data-scene] ${label}`, { + color: skyLabelInk('inherit'), textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, }); diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index b9655923..e5ab4f8e 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -1,6 +1,6 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; -import { surfaceInk } from '../scene/sceneShell.css'; +import { skyLabelInk, surfaceInk } from '../scene/sceneShell.css'; export const CategoryButton = style({ flexGrow: 1, @@ -15,6 +15,7 @@ export const CategoryButton = style({ borderRadius: 0, }, ':root[data-scene] &&': { + color: skyLabelInk('inherit'), textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, filter: `drop-shadow(0 0 3px ${surfaceInk})`, }, diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index c533528d..552fb04c 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -123,7 +123,8 @@ import { useSpaceOptionally } from '../../hooks/useSpace'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; -import { useTheme } from '../../hooks/useTheme'; +import { useSkyInk, useTheme } from '../../hooks/useTheme'; +import { skyLabelInk } from '../scene/sceneShell.css'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; @@ -462,10 +463,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); const theme = useTheme(); + const skyInk = useSkyInk(); const accessiblePowerTagColors = useAccessiblePowerTagColors( theme.kind, creatorsTag, - powerLevelTags + powerLevelTags, + skyInk ); const permissions = useRoomPermissions(creators, powerLevels); @@ -1674,9 +1677,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli newDivider && eventJSX && eventSender !== mx.getUserId() ? ( - - New Messages - + + New Messages + ) : null; diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 5c4421b6..62bb4dae 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -82,6 +82,8 @@ import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents'; import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room'; import { PowerIcon } from '../../../components/power'; import colorMXID from '../../../../util/colorMXID'; +import { useSkyInk } from '../../../hooks/useTheme'; +import { userColorInk } from '../../scene/skyInk'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -723,6 +725,7 @@ export const Message = as<'div', MessageProps>( ref ) => { const mx = useMatrixClient(); + const skyInk = useSkyInk(); const useAuthentication = useMediaAuthentication(); const senderId = mEvent.getSender() ?? ''; @@ -743,7 +746,8 @@ export const Message = as<'div', MessageProps>( ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) : undefined; - const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; + const hashColor = skyInk ? userColorInk(senderId, skyInk) : colorMXID(senderId); + const usernameColor = legacyUsernameColor ? hashColor : tagColor; const headerJSX = !collapse && ( by the theme manager (ThemeManager.tsx) only where it + * differs from the theme's; unset, `skyLabelInk(fallback)` is the fallback, so a person's own theme keeps its ink. + */ +export const SKY_LABEL_INK = '--sky-label-ink'; +export const skyLabelInk = (fallback: string) => `var(${SKY_LABEL_INK}, ${fallback})`; diff --git a/src/app/features/scene/skyInk.test.ts b/src/app/features/scene/skyInk.test.ts new file mode 100644 index 00000000..4cf2a6c4 --- /dev/null +++ b/src/app/features/scene/skyInk.test.ts @@ -0,0 +1,177 @@ +import chroma from 'chroma-js'; +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../sky/baked'; +import { AA, NIGHT_SKY_LIGHTEST, luminance } from '../../../sky/skyTokens.js'; +import { indexColorMXID } from '../../../util/colorMXID'; +import type { FixedSkyState } from './skyBand'; +import { + LIGHTEST_PATCH, + NIGHT_INK_MAX_L, + inkForState, + labelInk, + sceneInk, + userColorInk, +} from './skyInk'; +import { sceneThemeId } from './skyTheme'; + +// Every name colour the client can show over the scene, per state and viewport: Aqua's key colour #22d3e6 as +// accessibleColor leaves it (Lab L capped to 50 on a light theme: #00889a; unchanged on a dark one: #22d3e6), and +// the eight hash hues of the theme the state wears (noonday for day and dusk, midnight for night). Columns: name, +// state, phone, colour in, ink out. Aqua's day and dusk rows are the values measured in +// ratatoskr-sidebar-contrast-20261110/results.md (T4): #05707f, #016a78, #055763, #03454f, and #43e5f8 at night. +const TABLE: Array<[string, FixedSkyState, boolean, string, string]> = [ + ['aqua', 'day', false, '#00889a', '#05707f'], + ['aqua', 'day', true, '#00889a', '#016a78'], + ['aqua', 'dusk', false, '#00889a', '#055763'], + ['aqua', 'dusk', true, '#00889a', '#03454f'], + ['aqua', 'night', false, '#22d3e6', '#43e5f8'], + ['aqua', 'night', true, '#22d3e6', '#87f1ff'], + ['hash1', 'day', false, '#2b4675', '#2b4675'], + ['hash1', 'day', true, '#2b4675', '#2b4675'], + ['hash1', 'dusk', false, '#2b4675', '#2b4675'], + ['hash1', 'dusk', true, '#2b4675', '#233e6c'], + ['hash1', 'night', false, '#4683ff', '#bcd3ff'], + ['hash1', 'night', true, '#4683ff', '#cbddfe'], + ['hash2', 'day', false, '#5d366d', '#5d366d'], + ['hash2', 'day', true, '#5d366d', '#5d366d'], + ['hash2', 'dusk', false, '#5d366d', '#5d366d'], + ['hash2', 'dusk', true, '#5d366d', '#562f66'], + ['hash2', 'night', false, '#986fff', '#d4cbfe'], + ['hash2', 'night', true, '#986fff', '#ded7fe'], + ['hash3', 'day', false, '#214e3e', '#214e3e'], + ['hash3', 'day', true, '#214e3e', '#214e3e'], + ['hash3', 'dusk', false, '#214e3e', '#214e3e'], + ['hash3', 'dusk', true, '#214e3e', '#184636'], + ['hash3', 'night', false, '#3893b1', '#86defe'], + ['hash3', 'night', true, '#3893b1', '#ace8ff'], + ['hash4', 'day', false, '#6d334a', '#6d334a'], + ['hash4', 'day', true, '#6d334a', '#6d334a'], + ['hash4', 'dusk', false, '#6d334a', '#6d334a'], + ['hash4', 'dusk', true, '#6d334a', '#652c43'], + ['hash4', 'night', false, '#b26adc', '#e8c7fe'], + ['hash4', 'night', true, '#b26adc', '#edd2ff'], + ['hash5', 'day', false, '#5f401d', '#5f401d'], + ['hash5', 'day', true, '#5f401d', '#5f401d'], + ['hash5', 'dusk', false, '#5f401d', '#5f401d'], + ['hash5', 'dusk', true, '#5f401d', '#563814'], + ['hash5', 'night', false, '#9f8365', '#ecceae'], + ['hash5', 'night', true, '#9f8365', '#f6d8b7'], + ['hash6', 'day', false, '#204c52', '#204c52'], + ['hash6', 'day', true, '#204c52', '#204c52'], + ['hash6', 'dusk', false, '#204c52', '#204c52'], + ['hash6', 'dusk', true, '#204c52', '#19454b'], + ['hash6', 'night', false, '#358dde', '#b1d7ff'], + ['hash6', 'night', true, '#358dde', '#c6e1fe'], + ['hash7', 'day', false, '#41426c', '#41426c'], + ['hash7', 'day', true, '#41426c', '#41426c'], + ['hash7', 'dusk', false, '#41426c', '#41426c'], + ['hash7', 'dusk', true, '#41426c', '#3b3b65'], + ['hash7', 'night', false, '#677dff', '#c4d1fe'], + ['hash7', 'night', true, '#677dff', '#d3ddfe'], + ['hash8', 'day', false, '#3f4a20', '#3f4a20'], + ['hash8', 'day', true, '#3f4a20', '#3f4a20'], + ['hash8', 'dusk', false, '#3f4a20', '#3f4a20'], + ['hash8', 'dusk', true, '#3f4a20', '#394319'], + ['hash8', 'night', false, '#6a926a', '#b2ddb1'], + ['hash8', 'night', true, '#6a926a', '#c0ebc0'], +]; + +const patchRatio = (hex: string, state: FixedSkyState, phone: boolean) => { + const p = LIGHTEST_PATCH[state][phone ? 'phone' : 'desktop']; + const l = luminance(hex); + return (Math.max(l, p) + 0.05) / (Math.min(l, p) + 0.05); +}; +const hueGap = (a: string, b: string) => { + const d = Math.abs(chroma(a).oklch()[2] - chroma(b).oklch()[2]) % 360; + return Math.min(d, 360 - d); +}; + +describe('inkForState', () => { + it.each(TABLE)('%s %s phone=%s: %s -> %s', (_name, state, phone, base, ink) => { + expect(inkForState(base, { state, phone })).toBe(ink); + }); + + it('clears 4.5:1 on the lightest patch, keeps the hue, and moves lightness the way the state needs', () => { + TABLE.forEach(([, state, phone, base, ink]) => { + const [l0] = chroma(base).oklch(); + const [l1] = chroma(ink).oklch(); + if (ink === base) { + expect(patchRatio(base, state, phone)).toBeGreaterThanOrEqual(AA); + return; + } + expect(state === 'night' ? l1 > l0 : l1 < l0).toBe(true); + expect(hueGap(base, ink)).toBeLessThan(2); + if (l1 < NIGHT_INK_MAX_L - 0.006) + expect(patchRatio(ink, state, phone)).toBeGreaterThanOrEqual(AA); + }); + }); + + it('stops lightening at NIGHT_INK_MAX_L: a phone-night name keeps its hue instead of reaching 4.5:1 on the one pale snow patch', () => { + const aqua = inkForState('#22d3e6', { state: 'night', phone: true }); + expect(aqua).toBe('#87f1ff'); + expect(chroma(aqua).oklch()[0]).toBeLessThanOrEqual(NIGHT_INK_MAX_L); + expect(chroma(aqua).oklch()[1]).toBeGreaterThan(0.08); + expect(patchRatio(aqua, 'night', true)).toBeGreaterThan(3.9); + // The one-patch figure is what the cap gives up; against the sky's own lightest (skyBackdrop) it clears AA. + expect((luminance(aqua) + 0.05) / (NIGHT_SKY_LIGHTEST + 0.05)).toBeGreaterThanOrEqual(AA); + }); + + it('leaves a colour that already clears its patch untouched, and a non-colour alone', () => { + expect(inkForState('#2b4675', { state: 'day', phone: false })).toBe('#2b4675'); + expect(inkForState('var(--mx-uc-1)', { state: 'day', phone: false })).toBe('var(--mx-uc-1)'); + }); +}); + +describe('userColorInk', () => { + it('takes the hash colour of the theme the state wears, then steps it', () => { + const id = '@aqua:gamecult.org'; + const n = indexColorMXID(id) - 1; + expect(userColorInk(id, { state: 'dusk', phone: true })).toBe( + inkForState(baked.noonday.userColors[n], { state: 'dusk', phone: true }) + ); + expect(userColorInk(id, { state: 'night', phone: false })).toBe( + inkForState(baked.midnight.userColors[n], { state: 'night', phone: false }) + ); + }); +}); + +describe('sceneInk: an explicit theme wins over the auto ink', () => { + it('is the sky state and viewport only while the sky theme is worn and a scene runs', () => { + // auto, scene on, no theme of the person's own: the sky theme is worn. + expect(sceneInk(sceneThemeId('auto', 'dusk', true, false), true, 'dusk', true)).toEqual({ + state: 'dusk', + phone: true, + }); + // auto, but the person chose a theme: it wins, the theme's own ink stands. + expect(sceneInk(sceneThemeId('auto', 'dusk', true, true), true, 'dusk', true)).toBeUndefined(); + // a fixed sky state is itself a choice and wins over the theme (skyTheme.ts), so its ink applies. + expect(sceneInk(sceneThemeId('night', 'night', true, true), true, 'night', false)).toEqual({ + state: 'night', + phone: false, + }); + // no scene behind the page: nothing for the ink to be measured against. + expect( + sceneInk(sceneThemeId('auto', 'day', false, false), false, 'day', false) + ).toBeUndefined(); + expect(sceneInk(sceneThemeId('day', 'day', true, false), false, 'day', false)).toBeUndefined(); + }); +}); + +describe('labelInk', () => { + it('differs from the theme only on a phone at dusk', () => { + const states: FixedSkyState[] = ['day', 'dusk', 'night']; + states.forEach((state) => + [false, true].forEach((phone) => { + expect(labelInk({ state, phone })).toBe(state === 'dusk' && phone ? '#29221a' : undefined); + }) + ); + }); + + it('is the shipped dark ink, same hue and chroma, a little darker', () => { + const dark = baked.noonday.tokens.Surface.OnContainer; + const ink = labelInk({ state: 'dusk', phone: true }) as string; + expect(dark).toBe('#342d25'); + expect(luminance(ink)).toBeLessThan(luminance(dark)); + expect(hueGap(dark, ink)).toBeLessThan(2); + }); +}); diff --git a/src/app/features/scene/skyInk.ts b/src/app/features/scene/skyInk.ts new file mode 100644 index 00000000..7219159c --- /dev/null +++ b/src/app/features/scene/skyInk.ts @@ -0,0 +1,95 @@ +// The one owner of ink for a sky state: which colour a sender name or a sidebar label wears over the scene, +// per fixed sky state (day, dusk, night) and viewport (phone or not). Pure: the hooks that read the resolved +// sky and the viewport (useSkyInk, hooks/useTheme.ts) only hand their values here. +// +// Names are bare ink over the terrain, so the theme's fitted colours (fitted against panel grounds, skyTokens.js) +// can land on the same luminance as the ridge behind them. The rule is measured, not tuned by eye: keep the +// colour's OKLCH hue and chroma and move its lightness (darker by day and dusk, lighter at night, in 0.005 steps) +// until it clears 4.5:1 (WCAG) against the LIGHTEST 24x12 patch of terrain under the timeline in that state +// and viewport. Measured 2026-11-10 on the demo's terrain scene at 1920x1080 and 390x844 (Chrome, SwiftShader, +// not a GPU; ratatoskr-sidebar-contrast-20261110/results.md, T3). These are the scene's own patches, not +// `skyBackdrop` (skyState.ts): that is the bare sky's extreme (day 0.90, dusk 0.97), which the terrain under the +// timeline never shows, and at night the terrain's snow (0.103, phone 0.149) is lighter than the sky (0.0816). +// Only a person whose theme is the sky's (no explicit theme of their own, sceneThemeId) wears this ink. +import chroma from 'chroma-js'; +import { AA, luminance } from '../../../sky/skyTokens.js'; +import { baked } from '../../../sky/baked'; +import { indexColorMXID } from '../../../util/colorMXID'; +import type { FixedSkyState } from './skyBand'; +import type { SkyThemeId } from './skyTheme'; + +export interface SkyInk { + readonly state: FixedSkyState; + /** At or under the repo's mobile breakpoint (ScreenSize.Mobile, hooks/useScreenSize.ts); tablets read as desktop. */ + readonly phone: boolean; +} + +/** + * The ink in force: only while the page wears the sky's theme (`themeId`, from sceneThemeId, so a person's explicit + * theme, which that function lets win over 'auto', keeps its own ink) and a scene runs behind it. Otherwise undefined. + */ +export const sceneInk = ( + themeId: SkyThemeId | undefined, + sceneOn: boolean, + state: FixedSkyState, + phone: boolean +): SkyInk | undefined => (themeId && sceneOn ? { state, phone } : undefined); + +/** WCAG relative luminance of the lightest 24x12 patch of terrain behind the timeline. */ +export const LIGHTEST_PATCH: Record = { + day: { desktop: 0.777, phone: 0.703 }, + dusk: { desktop: 0.531, phone: 0.4 }, + night: { desktop: 0.103, phone: 0.149 }, +}; + +/** Night lightening stops here (OKLCH L): above it every hue reads white, and the hue is what names the sender. */ +export const NIGHT_INK_MAX_L = 0.9; + +const STEP = 0.005; +const SKY_COLORS = { noonday: baked.noonday.userColors, midnight: baked.midnight.userColors }; + +const inGamut = (l: number, c: number, h: number): string | undefined => { + for (let k = c; k > 1e-4; k *= 0.97) { + const out = chroma.oklch(l, k, h); + if (!out.clipped()) return out.hex(); + } + return chroma.oklch(l, 0, 0).hex(); +}; + +/** The ink for a colour (#rrggbb) in this sky state and viewport: itself when it already clears the lightest patch. */ +export function inkForState(hex: string, { state, phone }: SkyInk): string { + if (!chroma.valid(hex)) return hex; + const patch = LIGHTEST_PATCH[state][phone ? 'phone' : 'desktop']; + const ratio = (h: string) => { + const l = luminance(h); + return (Math.max(l, patch) + 0.05) / (Math.min(l, patch) + 0.05); + }; + if (ratio(hex) >= AA) return hex; + const [l0, c0, h0] = chroma(hex).oklch(); + const hue = Number.isNaN(h0) ? 0 : h0; + const dir = state === 'night' ? 1 : -1; + let best = hex; + for (let k = 1; k < 200; k += 1) { + const l = l0 + dir * k * STEP; + if (l < 0 || l > (state === 'night' ? NIGHT_INK_MAX_L : 1)) break; + best = inGamut(l, c0, hue) ?? best; + if (ratio(best) >= AA) break; + } + return best; +} + +/** The ink of a user's hash colour (the --mx-uc-1..8 the theme would give) in this sky state and viewport. */ +export function userColorInk(userId: string, ink: SkyInk): string { + const palette = ink.state === 'night' ? SKY_COLORS.midnight : SKY_COLORS.noonday; + return inkForState(palette[indexColorMXID(userId) - 1], ink); +} + +/** + * The sidebar label ink where it differs from the theme's. Phone Dusk: the dark ink #342d25 reached a median of 3.98:1 + * on the glyph pixels (worst 1.66); the same hue and chroma at OKLCH L 0.257 (#29221a) reaches 4.53 (worst 1.92), + * measured over the shipped dusk phone shot, halo included. Nothing darker is searched for: the ridge's dusk ochre + * sits at the dark ink's own luminance, so the worst pixels stay below 4.5. Every other state keeps the theme's + * ink (undefined): desktop and night were measured as shipped. + */ +export const labelInk = ({ state, phone }: SkyInk): string | undefined => + state === 'dusk' && phone ? '#29221a' : undefined; diff --git a/src/app/hooks/useMemberPowerTag.ts b/src/app/hooks/useMemberPowerTag.ts index 31e52aa0..68051749 100644 --- a/src/app/hooks/useMemberPowerTag.ts +++ b/src/app/hooks/useMemberPowerTag.ts @@ -6,6 +6,7 @@ import { MemberPowerTag, MemberPowerTagIcon } from '../../types/matrix/room'; import { useRoomCreatorsTag } from './useRoomCreatorsTag'; import { ThemeKind } from './useTheme'; import { accessibleColor } from '../plugins/color'; +import { inkForState, type SkyInk } from '../features/scene/skyInk'; export type GetMemberPowerTag = (userId: string) => MemberPowerTag; @@ -44,23 +45,29 @@ export const getPowerTagIconSrc = ( export const useAccessiblePowerTagColors = ( themeKind: ThemeKind, creatorsTag: MemberPowerTag, - powerLevelTags: PowerLevelTags + powerLevelTags: PowerLevelTags, + skyInk?: SkyInk ): Map => { const accessibleColors: Map = useMemo(() => { const colors: Map = new Map(); + // Over the scene a name wears the sky state's ink (skyInk.ts) in place of the theme's accessible colour's own step. + const ink = (color: string) => { + const accessible = accessibleColor(themeKind, color); + return skyInk ? inkForState(accessible, skyInk) : accessible; + }; if (creatorsTag.color) { - colors.set(creatorsTag.color, accessibleColor(themeKind, creatorsTag.color)); + colors.set(creatorsTag.color, ink(creatorsTag.color)); } Object.values(powerLevelTags).forEach((tag) => { const { color } = tag; if (!color) return; - colors.set(color, accessibleColor(themeKind, color)); + colors.set(color, ink(color)); }); return colors; - }, [powerLevelTags, creatorsTag, themeKind]); + }, [powerLevelTags, creatorsTag, themeKind, skyInk]); return accessibleColors; }; diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 60f89757..1ef0278d 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -13,7 +13,9 @@ import '../../sky.css'; import { defaultThemeId, settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; import { sceneThemeId } from '../features/scene/skyTheme'; +import { sceneInk, type SkyInk } from '../features/scene/skyInk'; import { useResolvedSkyState, useSceneOn, useSkyState } from './useSkyState'; +import { ScreenSize, useScreenSizeContext } from './useScreenSize'; export enum ThemeKind { Light = 'light', @@ -113,18 +115,39 @@ const chosenTheme = (useSystemTheme: boolean, themeId?: string) => export const useSkyTheme = (): Theme | undefined => toTheme(sceneThemeId(useSkyState(), useResolvedSkyState(), useSceneOn(), false)); -export const useActiveTheme = (): Theme => { +/** The sky theme the signed-in page wears, or undefined when the person's own theme wins. */ +const useSceneSkyThemeId = (): ReturnType => { const sky = useSkyState(); const resolved = useResolvedSkyState(); const sceneOn = useSceneOn(); + const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); + const [themeId] = useSetting(settingsAtom, 'themeId'); + // A person's own theme wins over 'auto'; a fixed sky state is itself a choice and wins over the theme. + return sceneThemeId(sky, resolved, sceneOn, chosenTheme(systemTheme, themeId)); +}; + +/** The ink the page wears over the scene (`sceneInk`, skyInk.ts), or undefined when the theme's own ink stands. */ +export const useSceneInk = (): SkyInk | undefined => { + const themeId = useSceneSkyThemeId(); + const state = useResolvedSkyState(); + const sceneOn = useSceneOn(); + const phone = useScreenSizeContext() === ScreenSize.Mobile; + return useMemo(() => sceneInk(themeId, sceneOn, state, phone), [themeId, sceneOn, state, phone]); +}; + +const SkyInkContext = createContext(undefined); +export const SkyInkContextProvider = SkyInkContext.Provider; +/** The ink for this sky state and viewport (provided once by the theme manager), or undefined for the theme's own. */ +export const useSkyInk = (): SkyInk | undefined => useContext(SkyInkContext); + +export const useActiveTheme = (): Theme => { const systemThemeKind = useSystemThemeKind(); const themes = useThemes(); const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); const [themeId] = useSetting(settingsAtom, 'themeId'); const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId'); const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId'); - // A person's own theme wins over 'auto'; a fixed sky state is itself a choice and wins over the theme. - const skyTheme = toTheme(sceneThemeId(sky, resolved, sceneOn, chosenTheme(systemTheme, themeId))); + const skyTheme = toTheme(useSceneSkyThemeId()); if (skyTheme) return skyTheme; diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index b7c50d93..980d35b7 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -2,12 +2,16 @@ import React, { ReactNode, useEffect } from 'react'; import { configClass, varsClass } from 'folds'; import { MidnightSkyTheme, + SkyInkContextProvider, ThemeContextProvider, useActiveTheme, + useSceneInk, useSkyTheme, } from '../hooks/useTheme'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; +import { labelInk } from '../features/scene/skyInk'; +import { SKY_LABEL_INK } from '../features/scene/sceneShell.css'; export function UnAuthRouteThemeManager() { // Signed-out pages are Midnight Sky unless the sky state picks otherwise; they ignore the @@ -26,6 +30,18 @@ export function UnAuthRouteThemeManager() { export function AuthRouteThemeManager({ children }: { children: ReactNode }) { const activeTheme = useActiveTheme(); const [monochromeMode] = useSetting(settingsAtom, 'monochromeMode'); + const skyInk = useSceneInk(); + const labels = skyInk && labelInk(skyInk); + + // The sky's label ink where it differs from the theme's (skyInk.ts); unset, the theme's ink stands. + useEffect(() => { + const root = document.documentElement; + if (labels) root.style.setProperty(SKY_LABEL_INK, labels); + else root.style.removeProperty(SKY_LABEL_INK); + return () => { + root.style.removeProperty(SKY_LABEL_INK); + }; + }, [labels]); useEffect(() => { document.body.className = ''; @@ -40,5 +56,9 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { } }, [activeTheme, monochromeMode]); - return {children}; + return ( + + {children} + + ); } diff --git a/src/util/colorMXID.js b/src/util/colorMXID.js index 95600d29..efebf90c 100644 --- a/src/util/colorMXID.js +++ b/src/util/colorMXID.js @@ -10,16 +10,20 @@ function hashCode(str) { for (i = 0; i < str.length; i += 1) { chr = str.charCodeAt(i); // eslint-disable-next-line no-bitwise - hash = ((hash << 5) - hash) + chr; + hash = (hash << 5) - hash + chr; // eslint-disable-next-line no-bitwise hash |= 0; } return Math.abs(hash); } +/** Which of the eight user colours (1..8) a user id takes. */ +export function indexColorMXID(userId) { + return (hashCode(userId) % 8) + 1; +} + export function cssColorMXID(userId) { - const colorNumber = hashCode(userId) % 8; - return `--mx-uc-${colorNumber + 1}`; + return `--mx-uc-${indexColorMXID(userId)}`; } export default function colorMXID(userId) {