Sky-state control in the estate title bar; the state picks the theme #45
12 changed files with 333 additions and 16 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, DefaultReset, toRem } from 'folds';
|
||||
|
||||
export const Editor = style([
|
||||
|
|
@ -12,15 +12,6 @@ export const Editor = style([
|
|||
},
|
||||
]);
|
||||
|
||||
// Over the background scene (html[data-scene]) the composer is a bubble too: half strength, blurred.
|
||||
globalStyle(`:root[data-scene] ${Editor}`, {
|
||||
backgroundColor: [
|
||||
color.SurfaceVariant.Container,
|
||||
`color-mix(in srgb, ${color.SurfaceVariant.Container} 50%, transparent)`,
|
||||
],
|
||||
backdropFilter: 'blur(12px)',
|
||||
});
|
||||
|
||||
export const EditorOptions = style([
|
||||
DefaultReset,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Avatar, Box, Button, Spinner, Text, as } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { SkyPlate } from '../../features/scene/skyPlate.css';
|
||||
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
|
||||
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
|
@ -23,7 +25,7 @@ export type RoomIntroProps = {
|
|||
room: Room;
|
||||
};
|
||||
|
||||
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
|
||||
export const RoomIntro = as<'div', RoomIntroProps>(({ room, className, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
|
@ -50,7 +52,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
|
||||
return (
|
||||
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
|
||||
<Box direction="Column" grow="Yes" gap="500" className={classNames(SkyPlate, className)} {...props} ref={ref}>
|
||||
<Box>
|
||||
<Avatar size="500">
|
||||
<RoomAvatar
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import React, { RefObject, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
|
||||
import { SkyPlate } from '../scene/skyPlate.css';
|
||||
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { PrescreenControls } from './PrescreenControls';
|
||||
|
|
@ -162,7 +164,7 @@ export function CallView() {
|
|||
|
||||
return (
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'Surface' })}
|
||||
className={classNames(ContainerColor({ variant: 'Surface' }), SkyPlate)}
|
||||
style={{ minWidth: toRem(280) }}
|
||||
grow="Yes"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { EstateRoom, EstateTier } from './buildEstate';
|
|||
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
|
||||
import { useEstate, useEstateRootId } from './useEstate';
|
||||
import * as css from './EstateNavigator.css';
|
||||
import { SkyStateControl } from '../scene/SkyStateControl';
|
||||
|
||||
/** How long a tap waits for the client store to hold the room it just joined. */
|
||||
const JOIN_ARRIVAL_TIMEOUT_MS = 20000;
|
||||
|
|
@ -295,6 +296,7 @@ export function EstateNavigator() {
|
|||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<SkyStateControl />
|
||||
<IconButton
|
||||
variant="Background"
|
||||
aria-label="Find a room"
|
||||
|
|
|
|||
82
src/app/features/scene/SkyStateControl.tsx
Normal file
82
src/app/features/scene/SkyStateControl.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import React, { MouseEventHandler, useState } from 'react';
|
||||
import { Box, config, Icon, IconButton, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useSkyState } from '../../hooks/useSkyState';
|
||||
import { SKY_STATES, setSkyState, type SkyState } from './skyState';
|
||||
|
||||
const LABEL: Record<SkyState, string> = {
|
||||
auto: 'Auto',
|
||||
day: 'Day',
|
||||
dusk: 'Dusk',
|
||||
night: 'Night',
|
||||
};
|
||||
|
||||
/**
|
||||
* One title-bar button that opens the four sky states. A single button, not a row, so the bar
|
||||
* still fits a 320 px phone beside the search button; the menu items carry the state names.
|
||||
*/
|
||||
export function SkyStateControl() {
|
||||
const state = useSkyState();
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
|
||||
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
return (
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: '[aria-checked="true"]',
|
||||
returnFocusOnDeactivate: true,
|
||||
onDeactivate: () => setCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu role="menu" aria-label="Sky">
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{SKY_STATES.map((s) => (
|
||||
<MenuItem
|
||||
key={s}
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
role="menuitemradio"
|
||||
aria-checked={state === s}
|
||||
onClick={() => {
|
||||
setSkyState(s);
|
||||
setCords(undefined);
|
||||
}}
|
||||
after={state === s ? <Icon size="100" src={Icons.Check} /> : undefined}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T300">{LABEL[s]}</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
variant="Background"
|
||||
aria-label={`Sky: ${LABEL[state]}`}
|
||||
title={`Sky: ${LABEL[state]}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={!!cords}
|
||||
onClick={open}
|
||||
>
|
||||
<Icon src={Icons.Bulb} size="200" />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
);
|
||||
}
|
||||
69
src/app/features/scene/skyPlate.css.ts
Normal file
69
src/app/features/scene/skyPlate.css.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
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';
|
||||
|
||||
// Over the scene (html[data-scene]) the signed-in shell is clear (sceneShell.css.ts), so text
|
||||
// outside a chat bubble would sit on bare sky, which a sky state can make light, dark or both
|
||||
// (dusk): no ink clears 4.5:1 on all of that (skyTheme.test.ts). Every such region is a plate
|
||||
// instead, the sign-in card's opaque --sky-bubble fill, with its own ink and link, so what was
|
||||
// checked for the card holds everywhere. One rule, below; a region joins it by class or by tag.
|
||||
const scene = ':root[data-scene]';
|
||||
const fill = `var(--sky-bubble, ${SKY_PLATE_FALLBACK.fill})`;
|
||||
const text = `var(--sky-bubble-text, ${SKY_PLATE_FALLBACK.text})`;
|
||||
|
||||
/** A region with no class of its own (the room intro, the call view) joins the plate with this. */
|
||||
export const SkyPlate = style({});
|
||||
|
||||
// A recipe's first class is its base style.
|
||||
const base = (recipe: () => string) => `.${recipe().split(' ')[0]}`;
|
||||
|
||||
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,
|
||||
});
|
||||
9
src/app/features/scene/skyPlate.ts
Normal file
9
src/app/features/scene/skyPlate.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// The plate: the opaque surface text sits on wherever the shell is clear over the background
|
||||
// scene and a sky state can make the sky behind it anything. It is the sign-in card's colour
|
||||
// (--sky-bubble, set by the Midnight and Noonday themes in sky.css.ts), so what was checked for
|
||||
// the card holds here. The other themes take these fallbacks.
|
||||
export const SKY_PLATE_FALLBACK = {
|
||||
fill: '#0d1a26',
|
||||
text: '#b7c7d9',
|
||||
link: '#99c7ff',
|
||||
} as const;
|
||||
122
src/app/features/scene/skyTheme.test.ts
Normal file
122
src/app/features/scene/skyTheme.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { baked } from '../../../sky/baked';
|
||||
import { AA, contrast, luminance } from '../../../sky/skyTokens.js';
|
||||
import { Editor } from '../../components/editor/Editor.css';
|
||||
import { PageContent, PageNavContent } from '../../components/page/style.css';
|
||||
import { CallStatus } from '../call-status/styles.css';
|
||||
import { MembersDrawer } from '../room/MembersDrawer.css';
|
||||
import { RoomViewTyping } from '../room/RoomViewTyping.css';
|
||||
import { SKY_PLATE_FALLBACK } from './skyPlate';
|
||||
import { SkyPlate, plateRegions } from './skyPlate.css';
|
||||
import { SKY_STATES, skyBackdrop, type SkyBackdrop, type SkyState } from './skyState';
|
||||
import { skyThemeId } from './skyTheme';
|
||||
|
||||
const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const;
|
||||
const fixed = (['day', 'dusk', 'night'] as const).flatMap((s) => {
|
||||
const id = skyThemeId(s);
|
||||
return id ? [[s, id] as const] : [];
|
||||
});
|
||||
|
||||
// WCAG contrast of an ink against the sky it is worst on, measured as skyState.test.ts does: the
|
||||
// sky can show any luminance between its darkest and lightest, and contrast is lowest where the
|
||||
// backdrop is closest to the ink, which is the ink's own luminance clamped into that range.
|
||||
const against = (ink: string, bg: number) => {
|
||||
const li = luminance(ink);
|
||||
const [hi, lo] = li > bg ? [li, bg] : [bg, li];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const ratio = (ink: string, b: SkyBackdrop) =>
|
||||
against(ink, Math.min(b.lightest, Math.max(b.darkest, luminance(ink))));
|
||||
const r2 = (n: number) => n.toFixed(2);
|
||||
|
||||
describe('skyThemeId', () => {
|
||||
it('leaves the theme alone in auto and picks one for every fixed state', () => {
|
||||
expect(skyThemeId('auto')).toBeUndefined();
|
||||
expect(SKY_STATES.filter((s) => s !== 'auto').map((s) => skyThemeId(s))).not.toContain(
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('lights day and dusk with dark ink and night with light ink', () => {
|
||||
expect(skyThemeId('day')).toBe('noonday-sky');
|
||||
expect(skyThemeId('dusk')).toBe('noonday-sky');
|
||||
expect(skyThemeId('night')).toBe('midnight-sky');
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
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'],
|
||||
['night', 'midnight-sky', '#aeccff', '5.05', '#609385', '2.36'],
|
||||
];
|
||||
|
||||
it.each(cells)('%s with %s: ink %s is %s, link %s is %s', (state, id, ink, want, link, wantLink) => {
|
||||
const sky = SKY[id as keyof typeof SKY];
|
||||
expect(sky.tokens.Background.OnContainer).toBe(ink);
|
||||
expect(sky.link).toBe(link);
|
||||
expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want);
|
||||
expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink);
|
||||
});
|
||||
|
||||
it('night is the only state whose text clears AA on bare sky', () => {
|
||||
const passing = fixed.filter(
|
||||
([s, id]) => ratio(SKY[id].tokens.Background.OnContainer, skyBackdrop(s)) >= AA
|
||||
);
|
||||
expect(passing.map(([s]) => s)).toEqual(['night']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the plate, where text cannot rest on bare sky', () => {
|
||||
// Each theme's plate: fill, ink, link and the WCAG ratios of ink and link on the fill.
|
||||
const plates = [
|
||||
['midnight-sky', baked.midnight, '12.17', '5.68'],
|
||||
['noonday-sky', baked.noonday, '6.70', '4.62'],
|
||||
] as const;
|
||||
|
||||
it.each(plates)('%s: ink and link on the fill', (_id, sky, inkRatio, linkRatio) => {
|
||||
expect(r2(contrast(sky.bubbles.OtherText, sky.bubbles.Other))).toBe(inkRatio);
|
||||
expect(r2(contrast(sky.link, sky.bubbles.Other))).toBe(linkRatio);
|
||||
expect(contrast(sky.bubbles.OtherText, sky.bubbles.Other)).toBeGreaterThanOrEqual(AA);
|
||||
expect(contrast(sky.link, sky.bubbles.Other)).toBeGreaterThanOrEqual(AA);
|
||||
});
|
||||
|
||||
it('the other themes take fallback colours that clear AA', () => {
|
||||
const { fill, text, link } = SKY_PLATE_FALLBACK;
|
||||
expect(r2(contrast(text, fill))).toBe('10.21');
|
||||
expect(r2(contrast(link, fill))).toBe('10.03');
|
||||
});
|
||||
|
||||
it('every container of the picked themes carries its text at AA', () => {
|
||||
fixed.forEach(([, id]) => {
|
||||
Object.values(SKY[id].tokens).forEach((g) => {
|
||||
expect(contrast(g.OnContainer, g.Container)).toBeGreaterThanOrEqual(AA);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
19
src/app/features/scene/skyTheme.ts
Normal file
19
src/app/features/scene/skyTheme.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Which theme a sky state lights the page with. 'auto' picks none: the page keeps the theme it
|
||||
// would have had (the user's choice when signed in, Midnight Sky when signed out).
|
||||
//
|
||||
// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. On bare sky
|
||||
// only Night's text clears 4.5:1 (5.05); Day's sky reaches 0.919 luminance and Dusk's spans
|
||||
// 0.017 to 0.965, so no ink holds there. Text therefore never rests on bare sky: every region
|
||||
// that would is a plate (skyPlate.css.ts), and skyTheme.test.ts pins the numbers.
|
||||
import type { SkyState } from './skyState';
|
||||
|
||||
export type SkyThemeId = 'midnight-sky' | 'noonday-sky';
|
||||
|
||||
const BY_STATE: Record<Exclude<SkyState, 'auto'>, SkyThemeId> = {
|
||||
day: 'noonday-sky',
|
||||
dusk: 'noonday-sky',
|
||||
night: 'midnight-sky',
|
||||
};
|
||||
|
||||
export const skyThemeId = (state: SkyState): SkyThemeId | undefined =>
|
||||
state === 'auto' ? undefined : BY_STATE[state];
|
||||
4
src/app/hooks/useSkyState.ts
Normal file
4
src/app/hooks/useSkyState.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { useSyncExternalStore } from 'react';
|
||||
import { getSkyState, subscribe, type SkyState } from '../features/scene/skyState';
|
||||
|
||||
export const useSkyState = (): SkyState => useSyncExternalStore(subscribe, getSkyState);
|
||||
|
|
@ -12,6 +12,8 @@ import '../../gamecult.css';
|
|||
import '../../sky.css';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { skyThemeId } from '../features/scene/skyTheme';
|
||||
import { useSkyState } from './useSkyState';
|
||||
|
||||
export enum ThemeKind {
|
||||
Light = 'light',
|
||||
|
|
@ -100,7 +102,14 @@ export const useSystemThemeKind = (): ThemeKind => {
|
|||
return themeKind;
|
||||
};
|
||||
|
||||
/** The theme the sky state asks for, or undefined while it is 'auto'. */
|
||||
export const useSkyTheme = (): Theme | undefined => {
|
||||
const id = skyThemeId(useSkyState());
|
||||
return id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme;
|
||||
};
|
||||
|
||||
export const useActiveTheme = (): Theme => {
|
||||
const skyTheme = useSkyTheme();
|
||||
const systemThemeKind = useSystemThemeKind();
|
||||
const themes = useThemes();
|
||||
const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme');
|
||||
|
|
@ -108,6 +117,8 @@ export const useActiveTheme = (): Theme => {
|
|||
const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId');
|
||||
const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
||||
|
||||
if (skyTheme) return skyTheme;
|
||||
|
||||
if (!systemTheme) {
|
||||
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? MidnightSkyTheme;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,17 +4,21 @@ import {
|
|||
MidnightSkyTheme,
|
||||
ThemeContextProvider,
|
||||
useActiveTheme,
|
||||
useSkyTheme,
|
||||
} from '../hooks/useTheme';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
export function UnAuthRouteThemeManager() {
|
||||
// Signed-out pages are Midnight Sky unless the sky state picks otherwise; they ignore the
|
||||
// system scheme.
|
||||
const theme = useSkyTheme() ?? MidnightSkyTheme;
|
||||
|
||||
useEffect(() => {
|
||||
document.body.className = '';
|
||||
document.body.classList.add(configClass, varsClass);
|
||||
// Signed-out pages are always Midnight Sky and ignore the system scheme.
|
||||
document.body.classList.add(...MidnightSkyTheme.classNames);
|
||||
}, []);
|
||||
document.body.classList.add(...theme.classNames);
|
||||
}, [theme]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue