Merge pull request 'scene: per-sky-state ink for names and phone-dusk labels; New Messages as plain label' (#74) from void/scene-ink into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled

This commit is contained in:
Void 2026-11-10 17:58:23 +00:00
commit 1499e9a810
11 changed files with 361 additions and 19 deletions

View file

@ -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}`,
});

View file

@ -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})`,
},

View file

@ -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() ? (
<MessageBase space={messageSpacing}>
<TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
<Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
<Text size="L400">New Messages</Text>
</Badge>
<Text as="span" size="L400" style={{ color: skyLabelInk(color.Surface.OnContainer) }}>
New Messages
</Text>
</TimelineDivider>
</MessageBase>
) : null;

View file

@ -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 && (
<Box

View file

@ -28,3 +28,10 @@ globalStyle(':root[data-scene] #root', {
const surfaceInkVar = createVar();
globalStyle(':root[data-scene] body', { vars: { [surfaceInkVar]: color.Surface.Container } });
export const surfaceInk = fallbackVar(surfaceInkVar, color.Surface.Container);
/**
* The sky's label ink (skyInk.ts `labelInk`), set on <html> 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})`;

View file

@ -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);
});
});

View file

@ -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<FixedSkyState, { desktop: number; phone: number }> = {
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;

View file

@ -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<string, string> => {
const accessibleColors: Map<string, string> = useMemo(() => {
const colors: Map<string, string> = 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;
};

View file

@ -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<typeof sceneThemeId> => {
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<SkyInk | undefined>(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;

View file

@ -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 <ThemeContextProvider value={activeTheme}>{children}</ThemeContextProvider>;
return (
<ThemeContextProvider value={activeTheme}>
<SkyInkContextProvider value={skyInk}>{children}</SkyInkContextProvider>
</ThemeContextProvider>
);
}

View file

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