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) {