nemesis/demo: Meta's no-chrome look, theme auto, denser terrain, Background menu, with tests and the Ridge zero-size fix #65
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 817 B After Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4.6 KiB |
1
public/res/svg/ratatoskr-favicon-highlight.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
1
public/res/svg/ratatoskr-favicon-unread.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
1
public/res/svg/ratatoskr-favicon.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
|
@ -1,17 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<ellipse cx="19" cy="33" rx="13" ry="22" transform="rotate(-15 19 33)" fill="#d9742b"/>
|
||||
<ellipse cx="21" cy="33" rx="7" ry="15" transform="rotate(-15 21 33)" fill="#f0a45d"/>
|
||||
<ellipse cx="36" cy="43" rx="11" ry="14" transform="rotate(0 36 43)" fill="#e0833a"/>
|
||||
<ellipse cx="40" cy="46" rx="6" ry="9" transform="rotate(0 40 46)" fill="#f6dcb8"/>
|
||||
<polygon points="35,19 36,7 43,15" fill="#c9672a"/>
|
||||
<polygon points="44,15 50,7 52,19" fill="#c9672a"/>
|
||||
<circle cx="43" cy="25" r="10" fill="#e0833a"/>
|
||||
<ellipse cx="47" cy="29" rx="5" ry="3.5" transform="rotate(0 47 29)" fill="#f6dcb8"/>
|
||||
<circle cx="46.5" cy="23.5" r="1.8" fill="#1a1a1a"/>
|
||||
<circle cx="47" cy="23" r="0.6" fill="#ffffff"/>
|
||||
<circle cx="52" cy="27.5" r="1.5" fill="#1a1a1a"/>
|
||||
<ellipse cx="33" cy="56" rx="5" ry="2.5" transform="rotate(0 33 56)" fill="#c9672a"/>
|
||||
<ellipse cx="44" cy="56" rx="4.5" ry="2.5" transform="rotate(0 44 56)" fill="#c9672a"/>
|
||||
<circle cx="52" cy="52" r="11" fill="#ffffff"/>
|
||||
<circle cx="52" cy="52" r="8" fill="#45B83B"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,17 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<ellipse cx="19" cy="33" rx="13" ry="22" transform="rotate(-15 19 33)" fill="#d9742b"/>
|
||||
<ellipse cx="21" cy="33" rx="7" ry="15" transform="rotate(-15 21 33)" fill="#f0a45d"/>
|
||||
<ellipse cx="36" cy="43" rx="11" ry="14" transform="rotate(0 36 43)" fill="#e0833a"/>
|
||||
<ellipse cx="40" cy="46" rx="6" ry="9" transform="rotate(0 40 46)" fill="#f6dcb8"/>
|
||||
<polygon points="35,19 36,7 43,15" fill="#c9672a"/>
|
||||
<polygon points="44,15 50,7 52,19" fill="#c9672a"/>
|
||||
<circle cx="43" cy="25" r="10" fill="#e0833a"/>
|
||||
<ellipse cx="47" cy="29" rx="5" ry="3.5" transform="rotate(0 47 29)" fill="#f6dcb8"/>
|
||||
<circle cx="46.5" cy="23.5" r="1.8" fill="#1a1a1a"/>
|
||||
<circle cx="47" cy="23" r="0.6" fill="#ffffff"/>
|
||||
<circle cx="52" cy="27.5" r="1.5" fill="#1a1a1a"/>
|
||||
<ellipse cx="33" cy="56" rx="5" ry="2.5" transform="rotate(0 33 56)" fill="#c9672a"/>
|
||||
<ellipse cx="44" cy="56" rx="4.5" ry="2.5" transform="rotate(0 44 56)" fill="#c9672a"/>
|
||||
<circle cx="52" cy="52" r="11" fill="#ffffff"/>
|
||||
<circle cx="52" cy="52" r="8" fill="#989898"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1,001 B After Width: | Height: | Size: 59 KiB |
9
src/app/components/logo/logoMark.css.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
/**
|
||||
* The Ratatoskr mark is white on transparent (Meta's matte). On any light theme (they all carry `prism-light`) a class
|
||||
* of this name darkens it to the dark ink; put it on every <img> that shows the mark on a themed surface. The favicon and
|
||||
* notification icons are files the browser draws and cannot take it.
|
||||
*/
|
||||
export const LogoMark = style({});
|
||||
globalStyle(`body.prism-light .${LogoMark}`, { filter: 'brightness(0.18)' });
|
||||
|
|
@ -5,10 +5,11 @@ import { 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.
|
||||
const SCRIM_LABEL = '12%';
|
||||
const SCRIM_ROW = '16%';
|
||||
const SCRIM_HOVER = '26%';
|
||||
const SCRIM_SELECTED = '36%';
|
||||
// Meta, 2026-11-09: "this UI needs no chrome". Rows and labels are bare ink on the scene; hover and selection keep a faint wash.
|
||||
const SCRIM_LABEL = '0%';
|
||||
const SCRIM_ROW = '0%';
|
||||
const SCRIM_HOVER = '14%';
|
||||
const SCRIM_SELECTED = '22%';
|
||||
const SCRIM_PRESSED = '48%';
|
||||
|
||||
export const NavCategory = style([
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { getFallbackSession } from '../../state/sessions';
|
||||
import { mountBackgroundScene } from './host';
|
||||
import { readCapabilities, resolveTier } from './tier';
|
||||
import { getSceneStyleOverride } from './sceneStyle';
|
||||
import './sceneShell.css';
|
||||
import './sceneButtons.css';
|
||||
|
||||
/** Set on <html> while a scene runs, so signed-out screens can let it show through. */
|
||||
export const SCENE_ACTIVE_ATTRIBUTE = 'data-scene';
|
||||
|
|
@ -16,7 +18,11 @@ export const SCENE_ACTIVE_ATTRIBUTE = 'data-scene';
|
|||
* Tier 'off' renders nothing and loads nothing.
|
||||
*/
|
||||
export function BackgroundScene() {
|
||||
const config = useClientConfig().backgroundScene;
|
||||
const configured = useClientConfig().backgroundScene;
|
||||
const config = useMemo(() => {
|
||||
const style = getSceneStyleOverride();
|
||||
return configured && style ? { ...configured, style } : configured;
|
||||
}, [configured]);
|
||||
const enabled = resolveTier(config, readCapabilities(navigator)) !== 'off';
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,29 @@
|
|||
import React, { MouseEventHandler, useState } from 'react';
|
||||
import { Box, config, Icon, IconButton, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||
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';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import {
|
||||
getSceneStyleOverride,
|
||||
SERVED_SCENE_STYLES,
|
||||
setSceneStyle,
|
||||
type ServedSceneStyle,
|
||||
} from './sceneStyle';
|
||||
|
||||
const STYLE_LABEL: Record<ServedSceneStyle, string> = { terrain: 'Terrain', ridge: 'Ridges' };
|
||||
|
||||
const LABEL: Record<SkyState, string> = {
|
||||
auto: 'Auto',
|
||||
|
|
@ -19,6 +39,10 @@ const LABEL: Record<SkyState, string> = {
|
|||
export function SkyStateControl() {
|
||||
const state = useSkyState();
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
const backgroundScene = useClientConfig().backgroundScene;
|
||||
const configuredStyle = backgroundScene?.style;
|
||||
const sceneStyle: ServedSceneStyle =
|
||||
getSceneStyleOverride() ?? (configuredStyle === 'terrain' ? 'terrain' : 'ridge');
|
||||
|
||||
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
|
|
@ -62,6 +86,36 @@ export function SkyStateControl() {
|
|||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
{backgroundScene && (
|
||||
<>
|
||||
<Text
|
||||
size="L400"
|
||||
priority="300"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
|
||||
>
|
||||
Background
|
||||
</Text>
|
||||
{SERVED_SCENE_STYLES.map((st) => (
|
||||
<MenuItem
|
||||
key={st}
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
role="menuitemradio"
|
||||
aria-checked={sceneStyle === st}
|
||||
onClick={() => {
|
||||
setCords(undefined);
|
||||
if (st !== sceneStyle) setSceneStyle(st);
|
||||
}}
|
||||
after={sceneStyle === st ? <Icon size="100" src={Icons.Check} /> : undefined}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T300">{STYLE_LABEL[st]}</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
|
|
|
|||
49
src/app/features/scene/debugOverlays.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { debugOverlaysAllowed, overlayRequested } from './debugOverlays';
|
||||
|
||||
let search = '';
|
||||
beforeEach(() => {
|
||||
search = '';
|
||||
vi.stubGlobal('window', {
|
||||
get location() {
|
||||
return { search };
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('debugOverlaysAllowed', () => {
|
||||
it('a production build allows nothing by default', () => {
|
||||
vi.stubEnv('DEV', false);
|
||||
vi.stubEnv('VITE_PREVIEW_DEMO', '');
|
||||
expect(debugOverlaysAllowed(undefined)).toBe(false);
|
||||
expect(debugOverlaysAllowed({ tier: 'full' })).toBe(false);
|
||||
expect(debugOverlaysAllowed({ tier: 'full', debugOverlays: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('a dev build, a demo preview build or an explicit config flag allows them', () => {
|
||||
vi.stubEnv('DEV', true);
|
||||
expect(debugOverlaysAllowed(undefined)).toBe(true);
|
||||
vi.stubEnv('DEV', false);
|
||||
vi.stubEnv('VITE_PREVIEW_DEMO', '1');
|
||||
expect(debugOverlaysAllowed(undefined)).toBe(true);
|
||||
vi.stubEnv('VITE_PREVIEW_DEMO', '');
|
||||
expect(debugOverlaysAllowed({ debugOverlays: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('overlayRequested', () => {
|
||||
it('needs both the query and the permission', () => {
|
||||
search = '?probe=1';
|
||||
expect(overlayRequested('probe', true)).toBe(true);
|
||||
expect(overlayRequested('probe', false)).toBe(false);
|
||||
expect(overlayRequested('tierlabel', true)).toBe(false);
|
||||
search = '?tierlabel=1';
|
||||
expect(overlayRequested('tierlabel', true)).toBe(true);
|
||||
search = '';
|
||||
expect(overlayRequested('probe', true)).toBe(false);
|
||||
});
|
||||
});
|
||||
15
src/app/features/scene/debugOverlays.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { BackgroundSceneConfig } from './tier';
|
||||
|
||||
/**
|
||||
* Whether the page URL may switch on the scene's diagnostic overlays (`?probe=1` frame readout, `?tierlabel=1` path
|
||||
* label). Only a dev build, a demo preview build (VITE_PREVIEW_DEMO=1), or a config that says `debugOverlays: true`
|
||||
* allows it; a production visitor adding a query string gets nothing.
|
||||
*/
|
||||
export const debugOverlaysAllowed = (config: BackgroundSceneConfig | undefined): boolean =>
|
||||
import.meta.env?.DEV === true ||
|
||||
import.meta.env?.VITE_PREVIEW_DEMO === '1' ||
|
||||
config?.debugOverlays === true;
|
||||
|
||||
/** The page URL asks for this overlay (`?name=1`), and the overlays are allowed. */
|
||||
export const overlayRequested = (name: 'probe' | 'tierlabel', allowed: boolean): boolean =>
|
||||
allowed && new URLSearchParams(window.location.search).get(name) === '1';
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Scene, SceneOptions } from './Scene';
|
||||
import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier';
|
||||
import { debugOverlaysAllowed } from './debugOverlays';
|
||||
import { getSkyState, subscribe } from './skyState';
|
||||
import type { SkyState } from './skyState';
|
||||
|
||||
|
|
@ -17,7 +18,7 @@ export type StartOptions = Omit<SceneOptions, 'tier' | 'patchSource' | 'onCompil
|
|||
|
||||
async function create(
|
||||
config: BackgroundSceneConfig | undefined,
|
||||
options: StartOptions & { tier: SceneTier },
|
||||
options: StartOptions & { tier: SceneTier; debug?: boolean },
|
||||
state: SkyState
|
||||
): Promise<Scene | null> {
|
||||
let createScene: (o: SceneOptions) => Scene | null;
|
||||
|
|
@ -82,7 +83,8 @@ export async function startBackgroundScene(
|
|||
): Promise<Scene | null> {
|
||||
const tier = resolveTier(config, capabilities);
|
||||
if (tier === 'off') return null;
|
||||
const build = (state: SkyState) => create(config, { ...options, tier }, state);
|
||||
const debug = debugOverlaysAllowed(config);
|
||||
const build = (state: SkyState) => create(config, { ...options, tier, debug }, state);
|
||||
const state = getSkyState();
|
||||
const key0 = config?.style === 'raymarch' ? null : await skyKey(config, state);
|
||||
const first = await build(state);
|
||||
|
|
|
|||
|
|
@ -428,7 +428,7 @@ describe('createRidgeScene', () => {
|
|||
it('?probe=1 keeps the rAF loop alive and shows median and p95', () => {
|
||||
search = '?probe=1';
|
||||
const { parent, canvas } = host();
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY, debug: true })!;
|
||||
const readout = parent.children[parent.children.length - 1];
|
||||
runFrames(16.7, 300);
|
||||
expect(rafQueue.length).toBe(1); // still sampling
|
||||
|
|
@ -437,6 +437,41 @@ describe('createRidgeScene', () => {
|
|||
expect(parent.children.some((c: any) => c === readout)).toBe(false);
|
||||
});
|
||||
|
||||
it('?probe=1 does nothing where the overlays are not allowed: no readout, the loop ends', () => {
|
||||
search = '?probe=1';
|
||||
const { parent, canvas } = host();
|
||||
const before = parent.children.length;
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
expect(parent.children.length).toBe(before + LAYERS.length);
|
||||
runFrames(16.7, 300);
|
||||
expect(rafQueue.length).toBe(0);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
it('a scene started at zero size paints nothing, then builds on the first non-zero resize', async () => {
|
||||
const { parent, canvas } = host();
|
||||
canvas.clientWidth = 0;
|
||||
canvas.clientHeight = 0;
|
||||
(window as any).innerWidth = 0;
|
||||
(window as any).innerHeight = 0;
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY });
|
||||
expect(s).not.toBeNull();
|
||||
expect(parent.children).toEqual([canvas]);
|
||||
expect(draws).toHaveLength(0);
|
||||
// Still zero: a resize does not paint either.
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(parent.children).toEqual([canvas]);
|
||||
(window as any).innerWidth = 400;
|
||||
(window as any).innerHeight = 800;
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(parent.children.slice(1)).toHaveLength(LAYERS.length);
|
||||
expect(draws.filter((d) => d === 'sky')).toHaveLength(1);
|
||||
s!.dispose();
|
||||
expect(parent.children).toEqual([canvas]);
|
||||
});
|
||||
|
||||
it('dispose cancels the loop, the animations and the layers', () => {
|
||||
const { parent, canvas } = host();
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Scene, SceneOptions } from '../Scene';
|
||||
import { overlayRequested } from '../debugOverlays';
|
||||
import { FrameSampler, FrameStats } from './probe';
|
||||
import { skyColours, SkyColours, Srgb } from './skyColour';
|
||||
import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars';
|
||||
|
|
@ -14,8 +15,10 @@ import {
|
|||
} from './ridgeWorld';
|
||||
|
||||
export type RidgeOptions = Pick<SceneOptions, 'canvas' | 'tier'> & {
|
||||
/** Show the rAF median/p95 readout. Default: the page URL has `?probe=1`. */
|
||||
/** Show the rAF median/p95 readout. Default: the page URL has `?probe=1`, where `debug` allows it. */
|
||||
probe?: boolean;
|
||||
/** The diagnostic overlays may be asked for by URL (debugOverlays.ts). Default: no. */
|
||||
debug?: boolean;
|
||||
/** Each time the readout would update, and once with `still` when the fallback fires. */
|
||||
onProbe?: (s: FrameStats & { still: boolean }) => void;
|
||||
/** The sky's colours; default `skyColours()`, the one function to replace with Void's sun.js. */
|
||||
|
|
@ -151,7 +154,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
const parent = canvas.parentElement;
|
||||
if (!parent) return null;
|
||||
const sky = opts.sky ?? skyColours();
|
||||
const probe = opts.probe ?? new URLSearchParams(window.location.search).get('probe') === '1';
|
||||
const probe = opts.probe ?? overlayRequested('probe', opts.debug === true);
|
||||
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
||||
|
||||
let built: Built | undefined;
|
||||
|
|
@ -180,9 +183,18 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
built = undefined;
|
||||
};
|
||||
|
||||
/** Bake the sky, then the layers. Returns false when a 2D context is missing. */
|
||||
/** True while there is nothing to paint on: the canvas has no client size and the window itself is 0 x 0 (`viewSize` falls back to the window). */
|
||||
const hasNoArea = () => {
|
||||
const { w, h, scale } = viewSize();
|
||||
const size = layerSize(w, h, scale);
|
||||
return size.period < 1 || size.height < 1 || Math.round(h * scale) < 1;
|
||||
};
|
||||
|
||||
/** Bake the sky, then the layers. Returns false when a 2D context is missing or there is no area yet. */
|
||||
const build = (): boolean => {
|
||||
teardownLayers();
|
||||
// A zero-size canvas throws on drawImage; wait for the first resize that gives the host an area.
|
||||
if (hasNoArea()) return false;
|
||||
const { w, h, scale } = viewSize();
|
||||
const size = layerSize(w, h, scale);
|
||||
canvas.width = size.period;
|
||||
|
|
@ -343,7 +355,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
if (!build()) return null;
|
||||
// No area yet is not a failure: the resize listener below builds once the host has one.
|
||||
if (!build() && !hasNoArea()) return null;
|
||||
if (probe) {
|
||||
readout = document.createElement('div');
|
||||
Object.assign(readout.style, {
|
||||
|
|
|
|||
32
src/app/features/scene/sceneButtons.css.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { globalStyle } from '@vanilla-extract/css';
|
||||
|
||||
// Meta, 2026-11-10: "buttons are big and ugly, too, they don't need chrome either". Over the scene a folds Button or
|
||||
// Chip is its label in the page's ink, with a faint wash on hover and focus. Menus and dialogs (#portalContainer) keep
|
||||
// folds' look. The selectors are folds' hashed recipe base classes (Button epr39zd, Chip _1bv8u1w6).
|
||||
const scene = ':root[data-scene] #root';
|
||||
const controls = [`${scene} .epr39zd`, `${scene} ._1bv8u1w6`].join(', ');
|
||||
globalStyle(controls, {
|
||||
backgroundColor: 'transparent',
|
||||
boxShadow: 'none',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
});
|
||||
globalStyle(
|
||||
[
|
||||
`${scene} .epr39zd:hover`,
|
||||
`${scene} .epr39zd:focus-visible`,
|
||||
`${scene} ._1bv8u1w6:hover`,
|
||||
`${scene} ._1bv8u1w6:focus-visible`,
|
||||
].join(', '),
|
||||
{ backgroundColor: 'color-mix(in srgb, currentColor 12%, transparent)' }
|
||||
);
|
||||
// A visible keyboard focus ring in the control's own ink, on top of the hover wash.
|
||||
globalStyle([`${scene} .epr39zd:focus-visible`, `${scene} ._1bv8u1w6:focus-visible`].join(', '), {
|
||||
outline: '2px solid currentColor',
|
||||
outlineOffset: '2px',
|
||||
});
|
||||
globalStyle(`${scene} .epr39zd`, {
|
||||
fontWeight: 700,
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: '0.25em',
|
||||
});
|
||||
28
src/app/features/scene/sceneStyle.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Temporary: which background the viewer picked (terrain or ridges), kept in one localStorage key so the two scenes we
|
||||
// serve can be compared on any device. Absent, the client config's backgroundScene.style decides.
|
||||
import type { SceneStyle } from './tier';
|
||||
|
||||
export type ServedSceneStyle = Extract<SceneStyle, 'terrain' | 'ridge'>;
|
||||
export const SERVED_SCENE_STYLES: readonly ServedSceneStyle[] = ['terrain', 'ridge'];
|
||||
export const SCENE_STYLE_KEY = 'ratatoskr.sceneStyle';
|
||||
|
||||
export function getSceneStyleOverride(): ServedSceneStyle | undefined {
|
||||
try {
|
||||
const v = localStorage.getItem(SCENE_STYLE_KEY);
|
||||
return SERVED_SCENE_STYLES.includes(v as ServedSceneStyle)
|
||||
? (v as ServedSceneStyle)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Store the pick and reload, so the scene host mounts the other scene from a clean page. */
|
||||
export function setSceneStyle(s: ServedSceneStyle): void {
|
||||
try {
|
||||
localStorage.setItem(SCENE_STYLE_KEY, s);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
|
@ -1,13 +1,9 @@
|
|||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { color, config } from 'folds';
|
||||
import { MessageBase, BubbleContent } from '../../components/message/layout/layout.css';
|
||||
import { PageContent, PageNavContent } from '../../components/page/style.css';
|
||||
import { Editor } from '../../components/editor/Editor.css';
|
||||
import { MembersDrawer } from '../room/MembersDrawer.css';
|
||||
import { CallStatus } from '../call-status/styles.css';
|
||||
import { RoomViewFollowing } from '../room/RoomViewFollowing.css';
|
||||
import { RoomViewTyping } from '../room/RoomViewTyping.css';
|
||||
import { SKY_PLATE_FALLBACK } from './skyPlate';
|
||||
import { PLATE_TINT, 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
|
||||
|
|
@ -24,20 +20,13 @@ export const SkyPlate = style({});
|
|||
// A recipe's first class is its base style.
|
||||
const base = (recipe: () => string) => `.${recipe().split(' ')[0]}`;
|
||||
|
||||
// Meta, 2026-11-09: no chrome. Title bars, the room list, list pages, the drawer and the bars over the composer are bare
|
||||
// ink on the sky (dusk is allowed to be low contrast: it is the crossover between the light and dark inks).
|
||||
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.
|
||||
// The composer 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}))`,
|
||||
];
|
||||
|
|
@ -47,7 +36,10 @@ const within = (ancestor: string) =>
|
|||
plateRegions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', ');
|
||||
|
||||
globalStyle(within(scene), {
|
||||
backgroundColor: fill,
|
||||
// Blur with a light tint toward the sky, like the chat bubbles; never an opaque slab (PLATE_TINT).
|
||||
backgroundColor: [fill, `color-mix(in srgb, ${fill} ${PLATE_TINT * 100}%, transparent)`],
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
color: text,
|
||||
// Themed ink was chosen for the clear shell; inside the plate every token reads the plate's.
|
||||
vars: {
|
||||
|
|
|
|||
|
|
@ -18,3 +18,11 @@ export const SKY_PLATE_FALLBACK = {
|
|||
* on, the worst link contrast is 5.0 at Dusk, 5.7 at Day and 11.2 at Night.
|
||||
*/
|
||||
export const FOOTER_TINT = 0.82;
|
||||
|
||||
/**
|
||||
* How much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind a plate (title bars, lists, the
|
||||
* composer, the sign-in card). Meta, 2026-11-09: "The app shouldn't need chrome, and where it does help for readability it
|
||||
* should be like the chat bubbles, blur with a bit of tint towards the background color. If that's not working, the fix
|
||||
* is in the background, not covering it up."
|
||||
*/
|
||||
export const PLATE_TINT = 0.45;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { atStandardTime } from './standardTime.testkit';
|
||||
import { baked } from '../../../sky/baked';
|
||||
import { AA, NIGHT_SKY_LIGHTEST, contrast, luminance } from '../../../sky/skyTokens.js';
|
||||
|
|
@ -9,10 +9,15 @@ import { MembersDrawer } from '../room/MembersDrawer.css';
|
|||
import { RoomViewTyping } from '../room/RoomViewTyping.css';
|
||||
import { FOOTER_TINT, SKY_PLATE_FALLBACK } from './skyPlate';
|
||||
import { SkyPlate, plateRegions } from './skyPlate.css';
|
||||
import { getResolvedSkyState, setSkyClock, skyBackdrop } from './skyState';
|
||||
import {
|
||||
getResolvedSkyState,
|
||||
setSkyClock,
|
||||
skyBackdrop,
|
||||
subscribe as subscribeSky,
|
||||
} from './skyState';
|
||||
import type { SkyBackdrop } from './skyState';
|
||||
import type { FixedSkyState } from './skyBand';
|
||||
import { skyThemeId } from './skyTheme';
|
||||
import { sceneThemeId, skyThemeId } from './skyTheme';
|
||||
|
||||
const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const;
|
||||
const fixed = (['day', 'dusk', 'night'] as const).flatMap((s) => {
|
||||
|
|
@ -47,6 +52,42 @@ describe('skyThemeId', () => {
|
|||
setSkyClock();
|
||||
});
|
||||
|
||||
describe('sceneThemeId, the theme the page wears', () => {
|
||||
it('a fixed state always picks its theme, whatever the scene or the person chose', () => {
|
||||
[true, false].forEach((scene) =>
|
||||
[true, false].forEach((chose) => {
|
||||
expect(sceneThemeId('day', 'night', scene, chose)).toBe('noonday-sky');
|
||||
expect(sceneThemeId('night', 'day', scene, chose)).toBe('midnight-sky');
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('auto follows the resolved sky only while a scene is on and the person chose no theme', () => {
|
||||
expect(sceneThemeId('auto', 'day', true, false)).toBe('noonday-sky');
|
||||
expect(sceneThemeId('auto', 'dusk', true, false)).toBe('noonday-sky');
|
||||
expect(sceneThemeId('auto', 'night', true, false)).toBe('midnight-sky');
|
||||
expect(sceneThemeId('auto', 'day', false, false)).toBeUndefined();
|
||||
expect(sceneThemeId('auto', 'day', true, true)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('the resolved sky a hook subscribes to changes as the clock crosses, so theme classes follow', () => {
|
||||
vi.useFakeTimers();
|
||||
let hour = 12;
|
||||
setSkyClock(() => atStandardTime(hour, 10));
|
||||
const seen: string[] = [];
|
||||
const off = subscribeSky((s) => seen.push(s));
|
||||
expect(getResolvedSkyState()).toBe('day');
|
||||
hour = 0;
|
||||
setSkyClock(() => atStandardTime(hour, 10));
|
||||
vi.advanceTimersByTime(61_000);
|
||||
expect(seen).toEqual(['night']);
|
||||
expect(getResolvedSkyState()).toBe('night');
|
||||
off();
|
||||
vi.useRealTimers();
|
||||
setSkyClock();
|
||||
});
|
||||
|
||||
it('is what a wiring that follows the sky reads: the resolved state names a theme', () => {
|
||||
setSkyClock(() => atStandardTime(12));
|
||||
expect(skyThemeId(getResolvedSkyState())).toBe('noonday-sky');
|
||||
|
|
@ -145,19 +186,26 @@ describe('regions that sit on the plate', () => {
|
|||
// 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', () => {
|
||||
it('plates the composer, 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');
|
||||
it('plates a message that is not a bubble, and nothing else of the signed-in shell', () => {
|
||||
expect(plateRegions.some((r) => r.includes(':not(:has('))).toBe(true);
|
||||
expect(plateRegions).toHaveLength(4);
|
||||
});
|
||||
|
||||
// Meta, 2026-11-09: no chrome. These are bare ink on the sky, not plates.
|
||||
it('leaves title bars, the room list, list pages, the drawer and the bars over the composer bare', () => {
|
||||
[
|
||||
`.${MembersDrawer}`,
|
||||
`.${PageNavContent}`,
|
||||
`.${PageContent}`,
|
||||
`.${RoomViewTyping}`,
|
||||
'#root header',
|
||||
].forEach((cls) => expect(plateRegions).not.toContain(cls));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -22,3 +22,18 @@ const BY_STATE: Record<FixedSkyState, SkyThemeId> = {
|
|||
|
||||
export const skyThemeId = (state: SkyState): SkyThemeId | undefined =>
|
||||
state === 'auto' ? undefined : BY_STATE[state];
|
||||
|
||||
/**
|
||||
* The theme the page wears: a fixed sky state always picks its theme; 'auto' follows the resolved sky (`resolved`, the
|
||||
* clock's state) only while a scene is on and the person has not chosen a theme of their own, and otherwise picks none.
|
||||
* Signed-out pages choose no theme, so they pass `userChoseTheme: false`.
|
||||
*/
|
||||
export const sceneThemeId = (
|
||||
state: SkyState,
|
||||
resolved: FixedSkyState,
|
||||
sceneOn: boolean,
|
||||
userChoseTheme: boolean
|
||||
): SkyThemeId | undefined => {
|
||||
if (state !== 'auto') return BY_STATE[state];
|
||||
return sceneOn && !userChoseTheme ? BY_STATE[resolved] : undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
SKY_FRAG,
|
||||
} from './terrainShaders';
|
||||
import { createTerrainStars } from './terrainStars';
|
||||
import { overlayRequested } from '../debugOverlays';
|
||||
import { showTierLabel } from './tierLabel';
|
||||
|
||||
export type TerrainOptions = Pick<
|
||||
|
|
@ -36,8 +37,10 @@ export type TerrainOptions = Pick<
|
|||
band?: SkyBand;
|
||||
/** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */
|
||||
seed?: number;
|
||||
/** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`. */
|
||||
/** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`, where `debug` allows it. */
|
||||
probe?: boolean;
|
||||
/** The diagnostic overlays (readout, tier label) may be asked for by URL (debugOverlays.ts). Default: no. */
|
||||
debug?: boolean;
|
||||
onProbe?: (s: FrameStats & { still: boolean; triangles: number }) => void;
|
||||
};
|
||||
|
||||
|
|
@ -89,7 +92,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null {
|
|||
const tier = opts.tier ?? 'full';
|
||||
if (tier === 'off') return null;
|
||||
const { canvas } = opts;
|
||||
const probe = opts.probe ?? new URLSearchParams(window.location.search).get('probe') === '1';
|
||||
const probe = opts.probe ?? overlayRequested('probe', opts.debug === true);
|
||||
const gl = canvas.getContext('webgl2', {
|
||||
antialias: false,
|
||||
alpha: false,
|
||||
|
|
@ -395,7 +398,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null {
|
|||
});
|
||||
canvas.parentElement.appendChild(readout);
|
||||
}
|
||||
const removeLabel = showTierLabel(canvas, tier);
|
||||
const removeLabel = showTierLabel(canvas, tier, opts.debug === true);
|
||||
if (still) settleToStill();
|
||||
else wake();
|
||||
if (!still && !moving()) draw(0);
|
||||
|
|
|
|||
|
|
@ -168,6 +168,42 @@ describe('createTerrainSkyScene', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('the sky-only tier label', () => {
|
||||
const labels = (parentAppend: ReturnType<typeof vi.fn>) =>
|
||||
parentAppend.mock.calls.filter(([e]) => e?.textContent === 'terrain · sky-only');
|
||||
|
||||
it('shows for ?tierlabel=1 only where the overlays are allowed', () => {
|
||||
search = '?tierlabel=1';
|
||||
const off = canvasWith();
|
||||
createTerrainSkyScene({ canvas: off.canvas, tier: 'low' })!.dispose();
|
||||
expect(labels(off.parentAppend)).toHaveLength(0);
|
||||
const on = canvasWith();
|
||||
createTerrainSkyScene({ canvas: on.canvas, tier: 'low', debug: true })!.dispose();
|
||||
expect(labels(on.parentAppend)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('the door allows it from the config flag, and not from a plain production config', async () => {
|
||||
vi.stubEnv('DEV', false);
|
||||
vi.stubEnv('VITE_PREVIEW_DEMO', '');
|
||||
search = '?tierlabel=1';
|
||||
const plain = canvasWith();
|
||||
(
|
||||
await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas: plain.canvas })
|
||||
)?.dispose();
|
||||
expect(labels(plain.parentAppend)).toHaveLength(0);
|
||||
const flagged = canvasWith();
|
||||
(
|
||||
await startBackgroundScene(
|
||||
{ tier: 'low', style: 'terrain', debugOverlays: true },
|
||||
{},
|
||||
{ canvas: flagged.canvas }
|
||||
)
|
||||
)?.dispose();
|
||||
expect(labels(flagged.parentAppend)).toHaveLength(1);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startBackgroundScene, terrain style', () => {
|
||||
it('without WebGL2 mounts the sky on the same canvas instead of returning null', async () => {
|
||||
const { canvas, painted } = canvasWith({ gl: null });
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import { CAMERA, view } from './terrainWorld';
|
|||
export const SKY_WIDTH = 96;
|
||||
|
||||
/** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */
|
||||
export type TerrainSkyOptions = Pick<TerrainOptions, 'canvas' | 'tier' | 'sky' | 'sun' | 'band'>;
|
||||
export type TerrainSkyOptions = Pick<
|
||||
TerrainOptions,
|
||||
'canvas' | 'tier' | 'sky' | 'sun' | 'band' | 'debug'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the
|
||||
|
|
@ -69,7 +72,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null {
|
|||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
paint();
|
||||
const removeLabel = showTierLabel(canvas, 'sky-only');
|
||||
const removeLabel = showTierLabel(canvas, 'sky-only', opts.debug === true);
|
||||
|
||||
return {
|
||||
login() {
|
||||
|
|
|
|||
|
|
@ -41,8 +41,13 @@ describe('grid', () => {
|
|||
});
|
||||
|
||||
it('each tier fits a 32-bit index and the cheap tiers stay cheap', () => {
|
||||
// 205k at 320 quads: a guess at what a phone carries (SwiftShader only, no phone frame time), kept as a ceiling so a bigger grid is a deliberate change.
|
||||
expect(triangleCount(GRID.low.grid)).toBeLessThan(210000);
|
||||
// 524k at 512 quads (low), 1.18M at 768 (full): Meta asked for denser terrain on 2026-11-10, up from 205k at 320. No
|
||||
// phone frame time is measured (SwiftShader only); kept as a ceiling so a bigger grid is a deliberate change.
|
||||
expect(triangleCount(GRID.low.grid)).toBeLessThan(540000);
|
||||
expect(triangleCount(GRID.full.grid)).toBeLessThan(1200000);
|
||||
(['still', 'low', 'full'] as const).forEach((t) =>
|
||||
expect((GRID[t].grid + 1) ** 2).toBeLessThan(2 ** 32)
|
||||
);
|
||||
expect(GRID.full.grid).toBeGreaterThan(GRID.low.grid);
|
||||
expect(GRID.still.tex).toBeLessThanOrEqual(GRID.low.tex);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ export const TERRAIN = { span: 3000, hmax: 256 };
|
|||
* under shading read from the height map shows as hard-edged facets and stepped crests.
|
||||
*/
|
||||
export const GRID = {
|
||||
still: { grid: 192, tex: 512 },
|
||||
low: { grid: 320, tex: 1024 },
|
||||
full: { grid: 384, tex: 1024 },
|
||||
still: { grid: 320, tex: 1024 },
|
||||
low: { grid: 512, tex: 1024 },
|
||||
full: { grid: 768, tex: 2048 },
|
||||
} as const;
|
||||
|
||||
/** The camera: at the patch centre, above the highest peak, turning slowly and looking a little down. */
|
||||
|
|
|
|||
|
|
@ -29,21 +29,27 @@ afterEach(() => vi.unstubAllGlobals());
|
|||
|
||||
describe('showTierLabel', () => {
|
||||
it('adds nothing in a normal build', () => {
|
||||
expect(showTierLabel(canvas(true), 'full')).toBeNull();
|
||||
expect(showTierLabel(canvas(true), 'full', true)).toBeNull();
|
||||
expect(appended).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('names the path on screen when asked with ?tierlabel=1, and removes itself', () => {
|
||||
search = '?tierlabel=1';
|
||||
const remove = showTierLabel(canvas(true), 'sky-only');
|
||||
const remove = showTierLabel(canvas(true), 'sky-only', true);
|
||||
expect(appended).toHaveLength(1);
|
||||
expect(appended[0].textContent).toBe('terrain · sky-only');
|
||||
remove!();
|
||||
expect(appended[0].remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores ?tierlabel=1 where the overlays are not allowed (a production page)', () => {
|
||||
search = '?tierlabel=1';
|
||||
expect(showTierLabel(canvas(true), 'full', false)).toBeNull();
|
||||
expect(appended).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('has nowhere to go without a parent', () => {
|
||||
search = '?tierlabel=1';
|
||||
expect(showTierLabel(canvas(false), 'full')).toBeNull();
|
||||
expect(showTierLabel(canvas(false), 'full', true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import { overlayRequested } from '../debugOverlays';
|
||||
|
||||
// A tiny label naming which terrain path is on screen ('full', 'low', 'still', or 'sky-only' where WebGL2 is missing),
|
||||
// so a screenshot of a preview says what it shows. Built into preview builds only (VITE_PREVIEW_DEMO=1), or by `?tierlabel=1`.
|
||||
const wanted = (): boolean =>
|
||||
import.meta.env?.VITE_PREVIEW_DEMO === '1' ||
|
||||
new URLSearchParams(window.location.search).get('tierlabel') === '1';
|
||||
// so a screenshot of a preview says what it shows. Built into preview builds (VITE_PREVIEW_DEMO=1), or by `?tierlabel=1`
|
||||
// where the diagnostic overlays are allowed (debugOverlays.ts).
|
||||
const wanted = (allowed: boolean): boolean =>
|
||||
import.meta.env?.VITE_PREVIEW_DEMO === '1' || overlayRequested('tierlabel', allowed);
|
||||
|
||||
/** Adds the label to the canvas's parent; returns its remover, or null when no label is wanted. */
|
||||
export function showTierLabel(canvas: HTMLCanvasElement, text: string): (() => void) | null {
|
||||
export function showTierLabel(
|
||||
canvas: HTMLCanvasElement,
|
||||
text: string,
|
||||
allowed: boolean
|
||||
): (() => void) | null {
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent || !wanted()) return null;
|
||||
if (!parent || !wanted(allowed)) return null;
|
||||
const el = document.createElement('div');
|
||||
el.textContent = `terrain · ${text}`;
|
||||
Object.assign(el.style, {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export type SceneStyle = 'ridge' | 'raymarch' | 'terrain';
|
|||
export interface BackgroundSceneConfig {
|
||||
tier?: SceneTier;
|
||||
style?: SceneStyle;
|
||||
/** Let the page URL switch on the diagnostic overlays (`?probe=1`, `?tierlabel=1`) in a production build. Default: no. */
|
||||
debugOverlays?: boolean;
|
||||
}
|
||||
|
||||
export function readCapabilities(nav: Navigator): Capabilities {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
|||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { LogoMark } from '../../../components/logo/logoMark.css';
|
||||
import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg';
|
||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
|
@ -37,6 +38,7 @@ export function About({ requestClose }: AboutProps) {
|
|||
<Box gap="400">
|
||||
<Box shrink="No">
|
||||
<img
|
||||
className={LogoMark}
|
||||
style={{ width: toRem(60), height: toRem(60) }}
|
||||
src={RatatoskrSVG}
|
||||
alt="Ratatoskr logo"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
import { useSyncExternalStore } from 'react';
|
||||
import { getSkyState, subscribe, type SkyState } from '../features/scene/skyState';
|
||||
import {
|
||||
getResolvedSkyState,
|
||||
getSkyState,
|
||||
subscribe,
|
||||
type SkyState,
|
||||
} from '../features/scene/skyState';
|
||||
import type { FixedSkyState } from '../features/scene/skyBand';
|
||||
import { readCapabilities, resolveTier } from '../features/scene/tier';
|
||||
import { useClientConfig } from './useClientConfig';
|
||||
|
||||
/** The choice, as the control shows it ('auto' stays 'auto'). The sky being drawn is `getResolvedSkyState`. */
|
||||
/** The choice, as the control shows it ('auto' stays 'auto'). The sky being drawn is `useResolvedSkyState`. */
|
||||
export const useSkyState = (): SkyState => useSyncExternalStore(subscribe, getSkyState);
|
||||
|
||||
/** The sky being drawn: day, dusk or night. Unlike the choice it changes as the clock crosses, so it re-renders. */
|
||||
export const useResolvedSkyState = (): FixedSkyState =>
|
||||
useSyncExternalStore(subscribe, getResolvedSkyState);
|
||||
|
||||
/** Whether a background scene runs on this device and config (the tier the scene host will mount). */
|
||||
export const useSceneOn = (): boolean =>
|
||||
resolveTier(useClientConfig().backgroundScene, readCapabilities(navigator)) !== 'off';
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import {
|
|||
} from '../../colors.css';
|
||||
import '../../gamecult.css';
|
||||
import '../../sky.css';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { defaultThemeId, settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { skyThemeId } from '../features/scene/skyTheme';
|
||||
import { useSkyState } from './useSkyState';
|
||||
import { sceneThemeId } from '../features/scene/skyTheme';
|
||||
import { useResolvedSkyState, useSceneOn, useSkyState } from './useSkyState';
|
||||
|
||||
export enum ThemeKind {
|
||||
Light = 'light',
|
||||
|
|
@ -102,20 +102,29 @@ export const useSystemThemeKind = (): ThemeKind => {
|
|||
return themeKind;
|
||||
};
|
||||
|
||||
/** The theme the sky state asks for, or undefined while it is 'auto': auto never overrides a person's theme. */
|
||||
export const useSkyTheme = (): Theme | undefined => {
|
||||
const id = skyThemeId(useSkyState());
|
||||
return id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme;
|
||||
};
|
||||
const toTheme = (id: ReturnType<typeof sceneThemeId>): Theme | undefined =>
|
||||
id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme;
|
||||
|
||||
/** A theme the person chose: the system scheme, or one other than the default. A saved Midnight Sky is the default. */
|
||||
const chosenTheme = (useSystemTheme: boolean, themeId?: string) =>
|
||||
useSystemTheme || (themeId !== undefined && themeId !== defaultThemeId);
|
||||
|
||||
/** The theme the sky asks for on a page with no theme of its own (signed out), or undefined for none. */
|
||||
export const useSkyTheme = (): Theme | undefined =>
|
||||
toTheme(sceneThemeId(useSkyState(), useResolvedSkyState(), useSceneOn(), false));
|
||||
|
||||
export const useActiveTheme = (): Theme => {
|
||||
const skyTheme = useSkyTheme();
|
||||
const sky = useSkyState();
|
||||
const resolved = useResolvedSkyState();
|
||||
const sceneOn = useSceneOn();
|
||||
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)));
|
||||
|
||||
if (skyTheme) return skyTheme;
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import {
|
|||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
|
||||
import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg';
|
||||
import { LogoMark } from '../../components/logo/logoMark.css';
|
||||
import { SkyStateControl } from '../../features/scene/SkyStateControl';
|
||||
import { ServerPicker } from './ServerPicker';
|
||||
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
|
||||
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
|
||||
|
|
@ -134,9 +136,14 @@ export function AuthLayout() {
|
|||
<Box direction="Column" className={css.AuthCard}>
|
||||
<Header className={css.AuthHeader} size="600" variant="Surface">
|
||||
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
|
||||
<img className={css.AuthLogo} src={RatatoskrSVG} alt="Ratatoskr logo" />
|
||||
<img
|
||||
className={`${css.AuthLogo} ${LogoMark}`}
|
||||
src={RatatoskrSVG}
|
||||
alt="Ratatoskr logo"
|
||||
/>
|
||||
<Text size="H3">Ratatoskr</Text>
|
||||
</Box>
|
||||
<SkyStateControl />
|
||||
</Header>
|
||||
<Box className={css.AuthCardContent} direction="Column">
|
||||
<Box direction="Column" gap="100">
|
||||
|
|
|
|||
|
|
@ -43,9 +43,17 @@ describe('the sign-in footer band, as emitted', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('declares exactly these properties, so nothing else (a shadow, a prefixed override) rides along', async () => {
|
||||
it('declares exactly these properties, so nothing else (a shadow, a stray override) rides along', async () => {
|
||||
const [rule] = declarations(await emitted(), over);
|
||||
// -webkit-backdrop-filter is the one prefixed property, for Safari 14; it must blur exactly as the standard one does.
|
||||
const blur = (prop: string) =>
|
||||
rule
|
||||
.find((d) => d.startsWith(`${prop}:`))
|
||||
?.split(':')[1]
|
||||
.trim();
|
||||
expect(blur('-webkit-backdrop-filter')).toBe(blur('backdrop-filter'));
|
||||
expect(rule.map((d) => d.slice(0, d.indexOf(':'))).sort()).toEqual([
|
||||
'-webkit-backdrop-filter',
|
||||
'backdrop-filter',
|
||||
'background-color',
|
||||
'background-color',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
import { FOOTER_TINT } from '../../features/scene/skyPlate';
|
||||
import { FOOTER_TINT, PLATE_TINT } from '../../features/scene/skyPlate';
|
||||
|
||||
export const AuthLayout = style({
|
||||
minHeight: '100%',
|
||||
|
|
@ -27,8 +27,8 @@ export const AuthCard = style({
|
|||
export const AuthLogo = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: toRem(26),
|
||||
height: toRem(26),
|
||||
width: toRem(32),
|
||||
height: toRem(32),
|
||||
|
||||
borderRadius: '50%',
|
||||
},
|
||||
|
|
@ -67,22 +67,37 @@ export const AuthFooter = style({
|
|||
// made for their own grounds, not this card; Dark and Butter already use the same hsl(213 100% 80%).
|
||||
// The card is also made shorter so it leaves more of the scene visible on a phone.
|
||||
const scene = ':root[data-scene]';
|
||||
const tint = `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${FOOTER_TINT * 100}%, transparent)`;
|
||||
globalStyle(`${scene} ${AuthLayout}`, {
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
});
|
||||
globalStyle(`${scene} ${AuthCard}`, {
|
||||
backgroundColor: 'var(--sky-bubble, #0d1a26)',
|
||||
// Meta, 2026-11-09: no chrome; where readability needs help, the chat-bubble treatment (blur with a tint toward the
|
||||
// background), and if that is not enough the fix goes in the background, not in a plate. Same tint as the footer band.
|
||||
backgroundColor: [
|
||||
'var(--sky-bubble, #0d1a26)',
|
||||
`color-mix(in srgb, var(--sky-bubble, #0d1a26) ${PLATE_TINT * 100}%, transparent)`,
|
||||
],
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
color: 'var(--sky-bubble-text, #b7c7d9)',
|
||||
borderRadius: config.radii.R500,
|
||||
// A faint inner edge, so a pane this clear still reads as a surface.
|
||||
borderColor: 'rgba(183, 199, 217, 0.14)',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
});
|
||||
globalStyle(`${scene} body:not(.gamecult-theme) ${AuthCard}`, {
|
||||
vars: { '--tc-link': '#99c7ff' },
|
||||
});
|
||||
globalStyle(`${scene} ${AuthHeader}`, {
|
||||
backgroundColor: 'transparent',
|
||||
borderBottomWidth: 0,
|
||||
boxShadow: 'none',
|
||||
});
|
||||
globalStyle(`${scene} #root header.${AuthHeader}`, {
|
||||
backgroundColor: 'transparent',
|
||||
backdropFilter: 'none',
|
||||
WebkitBackdropFilter: 'none',
|
||||
});
|
||||
globalStyle(`${scene} ${AuthCardContent}`, {
|
||||
paddingTop: config.space.S500,
|
||||
|
|
@ -99,12 +114,12 @@ const FADE_Y =
|
|||
'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)';
|
||||
const FADE_X =
|
||||
'linear-gradient(to right, transparent, #000 24px, #000 calc(100% - 24px), transparent)';
|
||||
const tint = `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${FOOTER_TINT * 100}%, transparent)`;
|
||||
globalStyle(`${scene} ${AuthFooter}`, {
|
||||
paddingTop: config.space.S500,
|
||||
paddingBottom: config.space.S500,
|
||||
backgroundColor: ['var(--sky-bubble, #0d1a26)', tint],
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
maskImage: `${FADE_Y}, ${FADE_X}`,
|
||||
maskComposite: 'intersect',
|
||||
color: 'var(--sky-bubble-text, #b7c7d9)',
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||
import LogoSVG from '../../../../public/res/svg/ratatoskr.svg';
|
||||
import LogoUnreadSVG from '../../../../public/res/svg/ratatoskr-unread.svg';
|
||||
import LogoHighlightSVG from '../../../../public/res/svg/ratatoskr-highlight.svg';
|
||||
import FaviconSVG from '../../../../public/res/svg/ratatoskr-favicon.svg';
|
||||
import FaviconUnreadSVG from '../../../../public/res/svg/ratatoskr-favicon-unread.svg';
|
||||
import FaviconHighlightSVG from '../../../../public/res/svg/ratatoskr-favicon-highlight.svg';
|
||||
import NotificationSound from '../../../../public/sound/notification.ogg';
|
||||
import InviteSound from '../../../../public/sound/invite.ogg';
|
||||
import { notificationPermission, setFavicon } from '../../utils/dom';
|
||||
|
|
@ -67,9 +67,9 @@ function FaviconUpdater() {
|
|||
});
|
||||
|
||||
if (notification) {
|
||||
setFavicon(highlight ? LogoHighlightSVG : LogoUnreadSVG);
|
||||
setFavicon(highlight ? FaviconHighlightSVG : FaviconUnreadSVG);
|
||||
} else {
|
||||
setFavicon(LogoSVG);
|
||||
setFavicon(FaviconSVG);
|
||||
}
|
||||
}, [roomToUnread]);
|
||||
|
||||
|
|
@ -89,8 +89,8 @@ function InviteNotifications() {
|
|||
const notify = useCallback(
|
||||
(count: number) => {
|
||||
const noti = new window.Notification('Invitation', {
|
||||
icon: LogoSVG,
|
||||
badge: LogoSVG,
|
||||
icon: FaviconSVG,
|
||||
badge: FaviconSVG,
|
||||
body: `You have ${count} new invitation request.`,
|
||||
silent: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
|
||||
import { Page, PageHero, PageHeroSection } from '../../components/page';
|
||||
import { LogoMark } from '../../components/logo/logoMark.css';
|
||||
import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg';
|
||||
|
||||
export function WelcomePage() {
|
||||
|
|
@ -14,7 +15,15 @@ export function WelcomePage() {
|
|||
>
|
||||
<PageHeroSection>
|
||||
<PageHero
|
||||
icon={<img width="70" height="70" src={RatatoskrSVG} alt="Ratatoskr logo" />}
|
||||
icon={
|
||||
<img
|
||||
className={LogoMark}
|
||||
width="70"
|
||||
height="70"
|
||||
src={RatatoskrSVG}
|
||||
alt="Ratatoskr logo"
|
||||
/>
|
||||
}
|
||||
title="Welcome to Ratatoskr"
|
||||
subTitle={
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -58,8 +58,10 @@ export interface Settings {
|
|||
soundscapeVoiceCap: number;
|
||||
}
|
||||
|
||||
export const defaultThemeId = 'midnight-sky';
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
themeId: 'midnight-sky',
|
||||
themeId: defaultThemeId,
|
||||
useSystemTheme: false,
|
||||
lightThemeId: undefined,
|
||||
darkThemeId: undefined,
|
||||
|
|
@ -85,7 +87,7 @@ const defaultSettings: Settings = {
|
|||
encUrlPreview: false,
|
||||
showHiddenEvents: false,
|
||||
legacyUsernameColor: false,
|
||||
poseBubbles: false,
|
||||
poseBubbles: true,
|
||||
|
||||
showNotifications: true,
|
||||
isNotificationSounds: true,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
/* eslint-disable import/first */
|
||||
import './polyfills';
|
||||
import './preview-demo/seed';
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
|
|
|||
72
src/polyfills.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/* eslint-disable no-extend-native, func-names */
|
||||
// Safari 14 (iPadOS 14) lacks these; the bundle (ours and matrix-js-sdk) calls them at runtime. Safari 15.4 has all.
|
||||
// Loaded first by src/index.tsx. Each is installed only when missing.
|
||||
|
||||
function at<T>(this: ArrayLike<T>, n: number): T | undefined {
|
||||
const len = this.length;
|
||||
const i = Math.trunc(n) || 0;
|
||||
const k = i < 0 ? len + i : i;
|
||||
return k < 0 || k >= len ? undefined : this[k];
|
||||
}
|
||||
if (!Array.prototype.at) Object.defineProperty(Array.prototype, 'at', { value: at, writable: true, configurable: true });
|
||||
if (!String.prototype.at)
|
||||
Object.defineProperty(String.prototype, 'at', { value: at, writable: true, configurable: true });
|
||||
|
||||
if (!(Array.prototype as unknown as Record<string, unknown>).findLast)
|
||||
Object.defineProperty(Array.prototype, 'findLast', {
|
||||
value: function <T>(this: T[], fn: (v: T, i: number, a: T[]) => unknown, thisArg?: unknown) {
|
||||
for (let i = this.length - 1; i >= 0; i -= 1) if (fn.call(thisArg, this[i], i, this)) return this[i];
|
||||
return undefined;
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
if (!(Array.prototype as unknown as Record<string, unknown>).findLastIndex)
|
||||
Object.defineProperty(Array.prototype, 'findLastIndex', {
|
||||
value: function <T>(this: T[], fn: (v: T, i: number, a: T[]) => unknown, thisArg?: unknown) {
|
||||
for (let i = this.length - 1; i >= 0; i -= 1) if (fn.call(thisArg, this[i], i, this)) return i;
|
||||
return -1;
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
if (!(Object as unknown as Record<string, unknown>).hasOwn)
|
||||
Object.defineProperty(Object, 'hasOwn', {
|
||||
value: (o: object, k: PropertyKey) => Object.prototype.hasOwnProperty.call(o, k),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Structured clone for the data the client clones: plain objects, arrays, Map, Set, Date, RegExp, typed arrays, with cycles.
|
||||
if (typeof (globalThis as { structuredClone?: unknown }).structuredClone !== 'function') {
|
||||
const clone = (v: unknown, seen: Map<unknown, unknown>): unknown => {
|
||||
if (v === null || typeof v !== 'object') return v;
|
||||
if (seen.has(v)) return seen.get(v);
|
||||
if (v instanceof Date) return new Date(v.getTime());
|
||||
if (v instanceof RegExp) return new RegExp(v.source, v.flags);
|
||||
if (ArrayBuffer.isView(v)) return (v as unknown as { slice: () => unknown }).slice();
|
||||
if (v instanceof ArrayBuffer) return v.slice(0);
|
||||
if (v instanceof Map) {
|
||||
const m = new Map();
|
||||
seen.set(v, m);
|
||||
v.forEach((val, key) => m.set(clone(key, seen), clone(val, seen)));
|
||||
return m;
|
||||
}
|
||||
if (v instanceof Set) {
|
||||
const s = new Set();
|
||||
seen.set(v, s);
|
||||
v.forEach((val) => s.add(clone(val, seen)));
|
||||
return s;
|
||||
}
|
||||
const out: Record<string, unknown> | unknown[] = Array.isArray(v) ? [] : {};
|
||||
seen.set(v, out);
|
||||
Object.keys(v).forEach((k) => {
|
||||
(out as Record<string, unknown>)[k] = clone((v as Record<string, unknown>)[k], seen);
|
||||
});
|
||||
return out;
|
||||
};
|
||||
(globalThis as { structuredClone?: unknown }).structuredClone = (v: unknown) => clone(v, new Map());
|
||||
}
|
||||
|
||||
export {};
|
||||