Phone and desk visual findings: notice scrim, tap areas stop at neighbours, desk affordance #117

Merged
void merged 2 commits from void/phone-desk-visual-fixes into dev 2026-11-12 14:31:18 +00:00
45 changed files with 851 additions and 191 deletions

View file

@ -53,6 +53,10 @@ export const NavCategoryHeader = style({
'&&': { paddingLeft: 0 },
},
'@media': {
// A phone's section header is as tall as its 44 px button, so it does not overlap the next row.
'screen and (max-width: 750px)': {
selectors: { '&&': { minHeight: toRem(44), height: toRem(44) } },
},
// Category labels sit on the scene like the rows do, and need a wash to be read (wide screens;
// a phone's list is plain text).
'screen and (min-width: 751px)': {
@ -117,6 +121,8 @@ const NavItemBase = style({
[PHONE]: {
backgroundColor: 'transparent',
borderRadius: 0,
// The row is as tall as the 44 px tap target inside it (EstateNavigator.css.ts), so neighbours do not overlap.
minHeight: toRem(44),
},
[WIDE]: {
selectors: {

View file

@ -14,7 +14,7 @@ type MicrophoneButtonProps = {
function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
const [micState, toggleMic] = useAsyncCallback(onToggle);
const loading = micState.status === AsyncStatus.Loading;
return (
<TooltipProvider
position="Top"
@ -164,7 +164,10 @@ export function CallControl({
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const setCallEmbed = useSetAtom(callEmbedAtom);
const handleMicrophoneToggle = useCallback(() => callEmbed.control.toggleMicrophone(), [callEmbed]);
const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(),
[callEmbed]
);
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
const [hangupState, hangup] = useAsyncCallback(
@ -195,11 +198,7 @@ export function CallControl({
disabled={!callJoined}
/>
{!compact && <StatusDivider />}
<VideoButton
enabled={video}
onToggle={handleVideoToggle}
disabled={!callJoined}
/>
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
{!compact && (
<ScreenShareButton
enabled={screenshare}

View file

@ -71,7 +71,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
setCords(undefined);
};
const handleMicrophoneToggle = useCallback(() => callEmbed.control.toggleMicrophone(), [callEmbed]);
const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(),
[callEmbed]
);
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
const [hangupState, hangup] = useAsyncCallback(
@ -97,10 +100,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
>
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<MicrophoneButton
enabled={microphone}
onToggle={handleMicrophoneToggle}
/>
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
</Box>
{!compact && <ControlDivider />}

View file

@ -18,7 +18,7 @@ type MicrophoneButtonProps = {
export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
const [micState, toggleMic] = useAsyncCallback(onToggle);
const loading = micState.status === AsyncStatus.Loading;
return (
<TooltipProvider
position="Top"

View file

@ -26,7 +26,6 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } =
useCallPreferences();
const handleMicrophoneToggle = useCallback(async () => toggleMicrophone(), [toggleMicrophone]);
const handleVideoToggle = useCallback(async () => toggleVideo(), [toggleVideo]);

View file

@ -30,13 +30,29 @@ export const Title = style({
export const Fold = style({
all: 'unset',
cursor: 'pointer',
display: 'block',
width: '100%',
minHeight: 44,
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: config.space.S200,
// A folded row says it can be opened: a chevron at the end, turned down while the row is open.
selectors: {
'&::after': {
content: '"›"',
flexShrink: 0,
fontSize: '1.5em',
lineHeight: 1,
transition: 'transform 120ms',
},
'&[aria-expanded=true]::after': { transform: 'rotate(90deg)' },
},
});
export const Quiet = style({ opacity: config.opacity.P300, margin: `${config.space.S100} 0` });
// Quiet lines (age, who it waits on, helper text) were measured at 4.5 over the day sky and 3.9-4.0 at dusk and night
// with the theme's 0.75 (taps2/desk-REPORT.md); 0.9 clears 4.5 in all three.
export const Quiet = style({ opacity: 0.9, margin: `${config.space.S100} 0` });
export const Body = style({ whiteSpace: 'pre-wrap', margin: `${config.space.S200} 0` });
export const Options = style({ margin: 0, padding: 0, listStyle: 'none' });
export const Option = style({ padding: `${config.space.S100} 0` });
@ -50,8 +66,9 @@ export const Note = style({
font: 'inherit',
color: 'inherit',
background: 'none',
border: `1px solid ${color.Background.ContainerLine}`,
opacity: config.opacity.Disabled,
// The note is disabled (slice 1). Its border is a UI boundary (3:1 wanted; the faint line under the 0.38 disabled
// opacity measured 2.0), so it is drawn dashed in the full ink and not faded.
border: '1px dashed currentColor',
});
/** Sticky beside the rows on wide screens; a small fixed corner image on phones. */
@ -97,3 +114,9 @@ export const BoothLine = style({
},
},
});
/** The recorded rulings sit on the dark ridge in every sky; they carry their own ground (scrimStyle). */
export const Recorded = style({
marginTop: config.space.S400,
padding: `${config.space.S200} ${config.space.S300}`,
});

View file

@ -1,14 +1,19 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize';
import { SkyInkContextProvider } from '../../hooks/useTheme';
import { SCRIM_GROUND, SCRIM_INK } from '../scene/skyInk';
import { DeskEntry } from './DeskEntry';
import { DeskView, SIGN_IN_NOTE } from './DeskView';
import { ForkList } from './forkContract';
import { STATES } from './fixtures';
import { setDeskSource } from './source';
// useTheme reads the stored settings when it loads.
vi.hoisted(() => vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }));
const view = (
state: keyof typeof STATES | 'unpublished',
size: ScreenSize,
@ -66,6 +71,19 @@ describe('DeskView', () => {
expect(html).toContain('Question done (ruling:done-1)');
expect(html).not.toContain('Question shut');
});
it('gives the recorded rulings their own ground and ink while a scene runs, none without one', () => {
const withScene = renderToStaticMarkup(
<ScreenSizeProvider value={ScreenSize.Desktop}>
<SkyInkContextProvider value={{ state: 'dusk', phone: false }}>
<DeskView mind="void" label="Void" list={STATES.settled} unverified={new Set()} />
</SkyInkContextProvider>
</ScreenSizeProvider>
);
// renderToStaticMarkup writes the inline style as the browser would read it back
expect(withScene).toContain(SCRIM_GROUND.replace(/, /g, ', '));
expect(withScene).toContain(`color:${SCRIM_INK}`);
expect(view('settled', ScreenSize.Desktop)).not.toContain('background-color');
});
it('shows each outcome quietly, marks the counted answer, and renders a note only when sent', () => {
const html = view('answered', ScreenSize.Desktop);
expect(html).toContain(
@ -145,7 +163,15 @@ describe("a question's age", () => {
expect(html('2026-11-11T11:40:00Z')).toContain('raised just now');
});
it('shows no age when the card carries none, or an instant that does not parse or is ahead of the clock', () => {
['null', 'yesterday', '2027-01-01T00:00:00Z', '2026-11-06', 'Nov 6 2026', '0', '2026-02-30T00:00:00Z'].forEach((v) => {
[
'null',
'yesterday',
'2027-01-01T00:00:00Z',
'2026-11-06',
'Nov 6 2026',
'0',
'2026-02-30T00:00:00Z',
].forEach((v) => {
expect(html(v === 'null' ? null : v)).not.toContain('raised ');
});
});

View file

@ -4,6 +4,8 @@ import { PoseAvatar } from '../../components/message/pose/PoseAvatar';
import { MINDS } from '../../components/message/pose/poses';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk';
import { useSkyInk } from '../../hooks/useTheme';
import { scrimStyle } from '../scene/skyInk';
import { ForkList } from './forkContract';
import { deskMood } from './deskMood';
import { raisedAge } from './age';
@ -144,6 +146,7 @@ type DeskViewProps = {
/** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */
export function DeskView({ mind, label, list, unverified, now = Date.now() }: DeskViewProps) {
const desk = buildDesk(list, unverified);
const skyInk = useSkyInk();
// Slice 1 has no session, so nothing on the desk is the viewer's answer yet.
const mood = deskMood(desk.counts.open, 0);
return (
@ -166,7 +169,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De
)}
</div>
{desk.recorded.length > 0 && (
<>
<div className={css.Recorded} style={scrimStyle(skyInk)}>
<h3 className={css.Title}>Recorded as rulings</h3>
<ul className={css.Options}>
{desk.recorded.map((r) => (
@ -175,7 +178,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De
</li>
))}
</ul>
</>
</div>
)}
</section>
);

View file

@ -109,7 +109,10 @@ describe('v4 manifest against the parser', () => {
it("every voice file is Aqua's v4 render (sha256 prefix)", () => {
const hash = (file: string) =>
createHash('sha256').update(readFileSync(join(DINGS, file))).digest('hex').slice(0, 12);
createHash('sha256')
.update(readFileSync(join(DINGS, file)))
.digest('hex')
.slice(0, 12);
expect(Object.keys(V4_SHA).length).toBe(VOICES.length * MOTIFS.length);
Object.entries(V4_SHA).forEach(([k, want]) => expect(hash(`senders/${k}.ogg`)).toBe(want));
});

View file

@ -22,7 +22,9 @@ export function LobbyHero() {
const name = useRoomName(space);
const topic = useRoomTopic(space);
const avatarMxc = useRoomAvatar(space);
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined;
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
return (
<PageHero

View file

@ -4,7 +4,7 @@ import React from 'react';
import { useStateEvent } from '../../hooks/useStateEvent';
import { useClientConfig } from '../../hooks/useClientConfig';
import { useSkyInk } from '../../hooks/useTheme';
import { noticeInk } from '../scene/skyInk';
import { scrimStyle } from '../scene/skyInk';
import {
ROOM_NOTICE_EVENT_TYPE,
parseRoomNotice,
@ -24,7 +24,8 @@ export function RoomTrainingLine({ room }: { room: Room }) {
size="T200"
style={{
padding: `${config.space.S100} 0`,
color: (skyInk && noticeInk(skyInk)) ?? color.SurfaceVariant.OnContainer,
color: color.SurfaceVariant.OnContainer,
...scrimStyle(skyInk),
}}
>
{line}

View file

@ -2,15 +2,14 @@ import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
// Phone tap target: the topic is an 18 px line under the room name; its button grows 9 px up and 17 px down
// (44 px to tap; more below because the name is above) and the negative margin keeps the layout where it was.
// Phone tap target: the topic is an 18 px line directly under the room name (no gap), so its button grows only
// downward, 26 px (44 px to tap) and the negative margin keeps the layout where it was: growing up would take taps
// from the name line.
export const HeaderTopic = style({
'@media': {
[`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: {
paddingTop: 9,
paddingBottom: 17,
marginTop: -9,
marginBottom: -17,
paddingBottom: 26,
marginBottom: -26,
},
},
':hover': {

View file

@ -15,7 +15,12 @@ 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 { HORIZON_CREDIT, WEATHER_CREDIT, WEATHER_CREDIT_URL, WEATHER_DATASET } from './ridge/credits';
import {
HORIZON_CREDIT,
WEATHER_CREDIT,
WEATHER_CREDIT_URL,
WEATHER_DATASET,
} from './ridge/credits';
import { useClientConfig } from '../../hooks/useClientConfig';
import {
getSceneStyleOverride,
@ -118,7 +123,11 @@ export function SkyStateControl() {
</>
)}
{backgroundScene && sceneStyle === 'ridge' && (
<Box direction="Column" gap="100" style={{ padding: `${config.space.S200}`, maxWidth: '20rem' }}>
<Box
direction="Column"
gap="100"
style={{ padding: `${config.space.S200}`, maxWidth: '20rem' }}
>
<Text size="L400" priority="300">
Data
</Text>

View file

@ -12,7 +12,9 @@ describe('creature scale', () => {
it('draws nothing for a neutral creature, whatever sizeBucket it was sent', () => {
expect(creatureScale({ neutral: true })).toBeNull();
buckets.forEach((sizeBucket) => expect(creatureScale({ sizeBucket, neutral: true })).toBeNull());
buckets.forEach((sizeBucket) =>
expect(creatureScale({ sizeBucket, neutral: true })).toBeNull()
);
});
it('does not crash or default to medium when a non-neutral creature has no sizeBucket', () => {

View file

@ -33,5 +33,7 @@ export const BUCKET_SCALE: Readonly<Record<SizeBucket, number>> = {
/** The draw scale, or null when nothing is drawn: a neutral creature, or a bucket that is absent or unknown. */
export function creatureScale(c: Pick<Creature, 'sizeBucket' | 'neutral'>): number | null {
if (c.neutral || c.sizeBucket === undefined) return null;
return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null;
return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket)
? BUCKET_SCALE[c.sizeBucket]
: null;
}

View file

@ -13,6 +13,8 @@ describe('scene .css.ts files, compiled as the production build compiles them',
it.each(FILES)('%s compiles with exports vanilla-extract accepts', async (file) => {
const filePath = `${process.cwd()}/${file}`;
const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' });
await expect(processVanillaFile({ source, filePath, identOption: 'debug' })).resolves.toBeTypeOf('string');
await expect(
processVanillaFile({ source, filePath, identOption: 'debug' })
).resolves.toBeTypeOf('string');
});
});

View file

@ -818,8 +818,12 @@ describe('endless drift: chunks baked on demand and recycled', () => {
});
describe('rain in the scene', () => {
const rainy = (w: number): SkyColours => ({ ...DAY, physical: makePhysicalSky('day', new Date('2026-06-21T10:40:00Z'), w) });
const rainEls = (parent: any) => parent.children.filter((c: any) => c.tag === 'div' && c !== rigOf(parent));
const rainy = (w: number): SkyColours => ({
...DAY,
physical: makePhysicalSky('day', new Date('2026-06-21T10:40:00Z'), w),
});
const rainEls = (parent: any) =>
parent.children.filter((c: any) => c.tag === 'div' && c !== rigOf(parent));
it('lays one falling tile per rain layer over the ridges when it rains, each slid by its own height on the compositor', () => {
const { parent, canvas } = host();

View file

@ -6,7 +6,15 @@ import { skyColours, SkyColours, Srgb } from './skyColour';
import { paintSky as paintPhysicalSky } from './physicalSky';
import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars';
import { bakeSlice, BakeParams, layerPaint } from './ridgeBake';
import { LAYERS, LayerSpec, edgeBlur, HORIZON_TRACK_SHIFT, layerSize, scatterAlpha, transmittance } from './ridgeWorld';
import {
LAYERS,
LayerSpec,
edgeBlur,
HORIZON_TRACK_SHIFT,
layerSize,
scatterAlpha,
transmittance,
} from './ridgeWorld';
import { hfovDeg } from './horizon';
import { viewFor, VFOV_DEG } from './skyDome';
import { drawRainTile, RAIN_TILES } from './rain';
@ -471,7 +479,13 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
* The rain over everything: per tile, a div of the tile's texture (drawn once) taller than the viewport by one tile, slid down one
* tile height per loop on the compositor. 'still' draws none (a settled still has no animation to carry it).
*/
const startRain = (rain: number, colour: Srgb, w: number, h: number, scale: number): NonNullable<Built['rain']> => {
const startRain = (
rain: number,
colour: Srgb,
w: number,
h: number,
scale: number
): NonNullable<Built['rain']> => {
const els: HTMLElement[] = [];
const anims: (Animation | undefined)[] = [];
RAIN_TILES.forEach((t, k) => {
@ -498,11 +512,14 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
parent.appendChild(el);
els.push(el);
anims.push(
el.animate?.([{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(0,${px}px,0)` }], {
duration: t.seconds * 1000,
iterations: Infinity,
easing: 'linear',
})
el.animate?.(
[{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(0,${px}px,0)` }],
{
duration: t.seconds * 1000,
iterations: Infinity,
easing: 'linear',
}
)
);
});
return { els, anims };
@ -606,7 +623,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
parent.appendChild(rig);
layers.forEach((l) => startDrift(l, rotate));
built.sway = startSway(rig, w, h);
if (tier !== 'still' && sky.physical && sky.physical.weather.rain > 0) built.rain = startRain(sky.physical.weather.rain, sky.horizon, w, h, scale);
if (tier !== 'still' && sky.physical && sky.physical.weather.rain > 0)
built.rain = startRain(sky.physical.weather.rain, sky.horizon, w, h, scale);
pump();
syncPlayState();
return true;
@ -640,7 +658,9 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
};
function syncPlayState() {
const anims = built ? [built.sway, ...built.layers.map((l) => l.anim), ...(built.rain?.anims ?? [])] : [];
const anims = built
? [built.sway, ...built.layers.map((l) => l.anim), ...(built.rain?.anims ?? [])]
: [];
anims.forEach((a) => (moving() ? a?.play() : a?.pause()));
if (!built?.twinkle) return;
if (moving()) {

View file

@ -4,7 +4,7 @@ import { HORIZON_ATTRIBUTION } from './horizon';
import { WEATHER_ATTRIBUTION } from './weatherFeed';
describe('the credits the Sky menu shows', () => {
it('are the sources\' own', () => {
it("are the sources' own", () => {
expect(HORIZON_CREDIT).toBe(HORIZON_ATTRIBUTION);
expect(WEATHER_CREDIT).toBe(WEATHER_ATTRIBUTION);
});

View file

@ -362,7 +362,8 @@ function bakeMultiScatter(mieScale: number, trLut: Lut2): Lut2 {
const sx = Math.sqrt(Math.max(0, 1 - cosSun * cosSun));
const sz = cosSun;
// Hillaire's PLANET_RADIUS_OFFSET: a ray that starts exactly on the ground is degenerate.
const height = PLANET_KM + RADIUS_OFFSET_KM + clamp(v, 0, 1) * (TOP_KM - PLANET_KM - RADIUS_OFFSET_KM);
const height =
PLANET_KM + RADIUS_OFFSET_KM + clamp(v, 0, 1) * (TOP_KM - PLANET_KM - RADIUS_OFFSET_KM);
const sumMs = [0, 0, 0];
const sumL = [0, 0, 0];
for (let a = 0; a < dirs; a++) {
@ -410,7 +411,11 @@ function bakeMultiScatter(mieScale: number, trLut: Lut2): Lut2 {
* Sky-view LUT axes (Hillaire's non-linear mapping, upper hemisphere only: nothing below the horizon is drawn). y: view zenith angle
* from 0 to the horizon's, squared toward it; x: the azimuth from the sun, the cosine mapped `-(2 c^2 - 1)`.
*/
function skyViewParamsToUv(viewCos: number, cosFromSun: number, horizonZenith: number): [number, number] {
function skyViewParamsToUv(
viewCos: number,
cosFromSun: number,
horizonZenith: number
): [number, number] {
const zen = Math.acos(clamp(viewCos, -1, 1));
let coord = 1 - clamp(zen / horizonZenith, 0, 1);
coord = Math.sqrt(coord);
@ -494,7 +499,14 @@ export function bakeAtmosphere(opts: AtmosphereOptions): Atmosphere {
const horizonZenith = PI - beta;
const transmittance = bakeTransmittance(mieScale);
const multiScatter = bakeMultiScatter(mieScale, transmittance);
const skyView = bakeSkyView(sunCos, observerKm, mieScale, transmittance, multiScatter, horizonZenith);
const skyView = bakeSkyView(
sunCos,
observerKm,
mieScale,
transmittance,
multiScatter,
horizonZenith
);
const tmp = [0, 0, 0];
const trv = [0, 0, 0];
const atm: Atmosphere = {
@ -514,7 +526,11 @@ export function bakeAtmosphere(opts: AtmosphereOptions): Atmosphere {
out[2] = trv[2];
},
radiance(viewCos, cosFromSun, out) {
const [x, y] = skyViewParamsToUv(Math.max(viewCos, Math.cos(horizonZenith)), cosFromSun, horizonZenith);
const [x, y] = skyViewParamsToUv(
Math.max(viewCos, Math.cos(horizonZenith)),
cosFromSun,
horizonZenith
);
sampleTexel(skyView, x * (skyView.w - 1), y * (skyView.h - 1), tmp);
out[0] = tmp[0];
out[1] = tmp[1];

View file

@ -21,7 +21,10 @@ describe('the baked horizon', () => {
expect(horizonDistanceKm(305)).toBeGreaterThan(1);
});
it('interpolates between samples and returns unit normals', () => {
expect(horizonElevation(100.25)).toBeCloseTo((bake.elevationDeg[200] + bake.elevationDeg[201]) / 2, 6);
expect(horizonElevation(100.25)).toBeCloseTo(
(bake.elevationDeg[200] + bake.elevationDeg[201]) / 2,
6
);
expect(Math.hypot(...horizonNormal(250))).toBeCloseTo(1, 6);
});
});

View file

@ -8,7 +8,7 @@ const N = bake.elevationDeg.length;
/** Index pair and weight of an azimuth (degrees clockwise from north) on the baked ring. */
function at(azimuth: number): [number, number, number] {
const f = ((((azimuth / bake.stepDeg) % N) + N) % N);
const f = (((azimuth / bake.stepDeg) % N) + N) % N;
const i = Math.floor(f);
return [i, (i + 1) % N, f - i];
}

View file

@ -25,7 +25,9 @@ describe('the horizon layer', () => {
});
it('stays in 0..1 and covers a real range of shape, not a line', () => {
const v = Array.from({ length: 200 }, (_, i) => layerProfile(far, -0.06 + (i / 200) * 1.2, { azimuthDeg: 305, hfovDeg: view.hfovDeg }));
const v = Array.from({ length: 200 }, (_, i) =>
layerProfile(far, -0.06 + (i / 200) * 1.2, { azimuthDeg: 305, hfovDeg: view.hfovDeg })
);
expect(Math.min(...v)).toBeGreaterThanOrEqual(0);
expect(Math.max(...v)).toBeLessThanOrEqual(1);
expect(Math.max(...v) - Math.min(...v)).toBeGreaterThan(0.2);
@ -43,7 +45,7 @@ describe('the horizon layer', () => {
});
describe('weather in the layers', () => {
it('mist multiplies the layers\' own, and clear air leaves it alone', () => {
it("mist multiplies the layers' own, and clear air leaves it alone", () => {
const base = skyColours();
const day = new Date('2026-06-21T10:40:00Z');
const clear = layerPaint(LAYERS[1], { ...base, physical: makePhysicalSky('day', day, 0) });

View file

@ -10,7 +10,8 @@ const lin = (v: number): number => {
const c = v / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
const luma = (d: Uint8ClampedArray, i: number): number => 0.2126 * lin(d[i]) + 0.7152 * lin(d[i + 1]) + 0.0722 * lin(d[i + 2]);
const luma = (d: Uint8ClampedArray, i: number): number =>
0.2126 * lin(d[i]) + 0.7152 * lin(d[i + 1]) + 0.0722 * lin(d[i + 2]);
const DAYS = ['2026-03-20', '2026-06-21', '2026-09-23', '2026-12-21'];
const WEATHER = [0, 0.3, 0.55, 0.75, 1];
@ -21,28 +22,31 @@ const VIEWPORTS: [number, number][] = [
[390, 844],
];
describe.each(['day', 'dusk', 'night'] as FixedSkyState[])('the %s sky stays inside its backdrop bounds', (state) => {
it('over weather, season and viewport shape', () => {
const b = skyBackdrop(state);
let lo = 2;
let hi = -1;
for (const day of DAYS) {
for (const w of WEATHER) {
const sky = makePhysicalSky(state, new Date(`${day}T10:40:00Z`), w);
for (const [vw, vh] of VIEWPORTS) {
const px = paintSky(sky, vw, vh);
for (let i = 0; i < px.w * px.h * 4; i += 4) {
const l = luma(px.data, i);
lo = Math.min(lo, l);
hi = Math.max(hi, l);
describe.each(['day', 'dusk', 'night'] as FixedSkyState[])(
'the %s sky stays inside its backdrop bounds',
(state) => {
it('over weather, season and viewport shape', () => {
const b = skyBackdrop(state);
let lo = 2;
let hi = -1;
for (const day of DAYS) {
for (const w of WEATHER) {
const sky = makePhysicalSky(state, new Date(`${day}T10:40:00Z`), w);
for (const [vw, vh] of VIEWPORTS) {
const px = paintSky(sky, vw, vh);
for (let i = 0; i < px.w * px.h * 4; i += 4) {
const l = luma(px.data, i);
lo = Math.min(lo, l);
hi = Math.max(hi, l);
}
}
}
}
}
expect(lo).toBeGreaterThanOrEqual(b.darkest);
expect(hi).toBeLessThanOrEqual(b.lightest);
});
});
expect(lo).toBeGreaterThanOrEqual(b.darkest);
expect(hi).toBeLessThanOrEqual(b.lightest);
});
}
);
describe('weather in the sky', () => {
it('overcast is cloud: less blue and less contrast than clear air at the same sun', () => {
@ -70,14 +74,21 @@ describe('weather in the sky', () => {
describe('the sun disc', () => {
it('keeps the display floor the dark link was fitted to, at every hour it is drawn', () => {
const lin = (c: number) => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4);
const lin = (c: number) =>
c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4;
let drawn = 0;
for (const state of ['day', 'dusk'] as FixedSkyState[]) {
for (let m = 0; m < 24 * 60; m += 10) {
const d = paintSky(makePhysicalSky(state, new Date(Date.UTC(2026, 10, 11, 0, m)), 0), 1280, 720).sunDisc;
const d = paintSky(
makePhysicalSky(state, new Date(Date.UTC(2026, 10, 11, 0, m)), 0),
1280,
720
).sunDisc;
if (!d) continue;
drawn += 1;
expect(0.2126 * lin(d.rgb[0]) + 0.7152 * lin(d.rgb[1]) + 0.0722 * lin(d.rgb[2])).toBeGreaterThanOrEqual(0.4744);
expect(
0.2126 * lin(d.rgb[0]) + 0.7152 * lin(d.rgb[1]) + 0.0722 * lin(d.rgb[2])
).toBeGreaterThanOrEqual(0.4744);
}
}
expect(drawn).toBeGreaterThan(0);

View file

@ -4,7 +4,15 @@
import type { FixedSkyState } from '../skyBand';
import { bakeAtmosphere } from './earthAtmosphere';
import type { Atmosphere } from './earthAtmosphere';
import { domeSize, HORIZON_AT, renderDome, SUN_RADIUS, sunOnScreen, viewFor, VFOV_DEG } from './skyDome';
import {
domeSize,
HORIZON_AT,
renderDome,
SUN_RADIUS,
sunOnScreen,
viewFor,
VFOV_DEG,
} from './skyDome';
import type { Dome, DomeView } from './skyDome';
import type { Srgb } from './skyColour';
import { display8, fitDisplay, holdBudget, MARGIN } from './toneMap';
@ -45,7 +53,11 @@ export function rowMean(dome: Dome, display: Display, row: number): Srgb {
return [Math.round(acc[0] / dome.w), Math.round(acc[1] / dome.w), Math.round(acc[2] / dome.w)];
}
export function makePhysicalSky(state: FixedSkyState, now: Date, weatherValue: number): PhysicalSky {
export function makePhysicalSky(
state: FixedSkyState,
now: Date,
weatherValue: number
): PhysicalSky {
const t0 = clock();
const sun = sunForState(state, now);
const weather = weatherOf(weatherValue);
@ -117,8 +129,16 @@ function sunDisc(p: PhysicalSky, view: DomeView): SkyPixels['sunDisc'] {
const top = Math.max(...trans, 1e-9);
const rgb = trans.map((t) => Math.round(255 * (t / top) ** 0.6));
// The disc sits on the sky, so it keeps the display floor the dark link was fitted to: mix toward white until it does.
const lin = (c: number): number => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4);
const luma = (c: number[]): number => 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]);
for (let m = 0; luma(rgb) < DISC_FLOOR && m < 40; m += 1) for (let i = 0; i < 3; i += 1) rgb[i] = Math.round(rgb[i] + (255 - rgb[i]) * 0.1);
return { ...at, rgb: rgb as unknown as Srgb, radius: (SUN_RADIUS * 180) / Math.PI / VFOV_DEG, alpha };
const lin = (c: number): number =>
c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4;
const luma = (c: number[]): number =>
0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]);
for (let m = 0; luma(rgb) < DISC_FLOOR && m < 40; m += 1)
for (let i = 0; i < 3; i += 1) rgb[i] = Math.round(rgb[i] + (255 - rgb[i]) * 0.1);
return {
...at,
rgb: rgb as unknown as Srgb,
radius: (SUN_RADIUS * 180) / Math.PI / VFOV_DEG,
alpha,
};
}

View file

@ -4,7 +4,8 @@ import { drawRainTile, RAIN_TILES, streakCount } from './rain';
const recorder = () => {
const calls: string[] = [];
const ctx = new Proxy({} as Record<string, unknown>, {
get: (t, k: string) => (k in t ? t[k] : (...a: unknown[]) => calls.push(`${k}(${a.join(',')})`)),
get: (t, k: string) =>
k in t ? t[k] : (...a: unknown[]) => calls.push(`${k}(${a.join(',')})`),
set: (t, k: string, v) => {
t[k] = v;
return true;
@ -27,6 +28,8 @@ describe('rain', () => {
drawRainTile(a.ctx, RAIN_TILES[1], 1, [200, 210, 220], 1);
drawRainTile(b.ctx, RAIN_TILES[1], 1, [200, 210, 220], 1);
expect(a.calls).toEqual(b.calls);
expect(a.calls.filter((c) => c.startsWith('stroke'))).toHaveLength(streakCount(RAIN_TILES[1], 1) * 9);
expect(a.calls.filter((c) => c.startsWith('stroke'))).toHaveLength(
streakCount(RAIN_TILES[1], 1) * 9
);
});
});

View file

@ -27,10 +27,18 @@ export const RAIN_TILES: readonly RainTile[] = [
export const RAIN_SLANT = 0.18;
/** Streaks to draw at a rain amount 0..1 (none at 0). */
export const streakCount = (t: RainTile, rain: number): number => (rain <= 0 ? 0 : Math.max(4, Math.round(t.streaks * rain)));
export const streakCount = (t: RainTile, rain: number): number =>
rain <= 0 ? 0 : Math.max(4, Math.round(t.streaks * rain));
/** Draw one tile. The streaks wrap across the tile's edges so the tile repeats without a seam. */
export function drawRainTile(ctx: CanvasRenderingContext2D, t: RainTile, rain: number, colour: Srgb, scale: number, seed = 0): void {
export function drawRainTile(
ctx: CanvasRenderingContext2D,
t: RainTile,
rain: number,
colour: Srgb,
scale: number,
seed = 0
): void {
const n = streakCount(t, rain);
ctx.clearRect(0, 0, t.size, t.size);
ctx.lineCap = 'round';

View file

@ -3,7 +3,15 @@
// slope of the heightfield's crest line against the sun; the face under it takes a light-to-haze
// gradient, erosion streaks and a lit rim on the crest. The depth treatment (colour, haze, contrast
// by distance) is ridgeWorld's `layerColour`, `hazeColour` and `transmittance`, used here and not redone.
import { gullyAt, HorizonView, LayerSpec, layerProfile, hazeColour, layerColour, transmittance } from './ridgeWorld';
import {
gullyAt,
HorizonView,
LayerSpec,
layerProfile,
hazeColour,
layerColour,
transmittance,
} from './ridgeWorld';
import type { SkyBand } from '../skyBand';
import { LUMA, SKY_BAND } from '../skyBand';
import type { SkyColours, Srgb } from './skyColour';
@ -137,7 +145,10 @@ function crestRows(p: BakeParams, c0: number, c1: number): Float64Array {
const amp = p.spec.relief * p.viewH;
const lift = p.spec.base * p.viewH;
for (let c = c0 - 1; c <= c1; c += 1) {
const top = Math.min(p.bandH, lift + amp * layerProfile(p.spec, (p.px0 + c) / p.period, p.horizonView));
const top = Math.min(
p.bandH,
lift + amp * layerProfile(p.spec, (p.px0 + c) / p.period, p.horizonView)
);
out[c - c0 + 1] = p.bandH - top;
}
return out;

View file

@ -47,7 +47,6 @@ export interface HorizonView {
/** The static horizon layer's track sits this many viewport widths left of the screen, so the camera's sway never shows its edge. */
export const HORIZON_TRACK_SHIFT = 0.06;
/** Four layers: one function, four parameter rows. */
export const LAYERS: readonly LayerSpec[] = [
{
@ -225,7 +224,13 @@ export function layerSize(cssW: number, cssH: number, scale: number) {
}
/** Ridge-top y (canvas pixels, from the top of the layer's band) at world-x `t` (viewport widths). */
export function ridgeY(spec: LayerSpec, t: number, bandH: number, viewH: number, view?: HorizonView): number {
export function ridgeY(
spec: LayerSpec,
t: number,
bandH: number,
viewH: number,
view?: HorizonView
): number {
const topFromBottom = spec.base * viewH + spec.relief * viewH * layerProfile(spec, t, view);
return bandH - Math.min(bandH, topFromBottom);
}

View file

@ -20,7 +20,8 @@ const OUT: readonly Rgb[] = [
[-0.10208, 1.10813, -0.00605],
[-0.00327, -0.07276, 1.07602],
];
const fit = (v: number): number => (v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081);
const fit = (v: number): number =>
(v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081);
const encode = (l: number): number => {
const c = Math.min(1, Math.max(0, l));
return 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
@ -32,7 +33,8 @@ export function acesLinear(r: number, g: number, b: number, exposure: number, ou
const y = g * exposure;
const z = b * exposure;
const p = [0, 1, 2].map((i) => fit(Math.max(0, IN[i][0] * x + IN[i][1] * y + IN[i][2] * z)));
for (let i = 0; i < 3; i += 1) out[i] = Math.min(1, Math.max(0, OUT[i][0] * p[0] + OUT[i][1] * p[1] + OUT[i][2] * p[2]));
for (let i = 0; i < 3; i += 1)
out[i] = Math.min(1, Math.max(0, OUT[i][0] * p[0] + OUT[i][1] * p[1] + OUT[i][2] * p[2]));
}
/** The 8-bit sRGB of linear radiance, exposed and tone-mapped. */
@ -44,7 +46,8 @@ export function toneMap8(r: number, g: number, b: number, exposure: number, out:
}
/** WCAG relative luminance of linear display RGB. */
export const displayLuma = (c: ArrayLike<number>): number => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
export const displayLuma = (c: ArrayLike<number>): number =>
0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
/** What a sky state needs of its backdrop's display luminance, from the ink it wears (skyBand.ts, skyInk.ts). */
export interface Budget {
@ -90,7 +93,13 @@ const decode = (v: number): number => {
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
/** Display luminance (WCAG relative, from the encoded bytes) of a radiance through a display. */
export function displayLumaOf(r: number, g: number, b: number, d: Display, tmp: number[] = [0, 0, 0]): number {
export function displayLumaOf(
r: number,
g: number,
b: number,
d: Display,
tmp: number[] = [0, 0, 0]
): number {
display8(r, g, b, d, tmp);
return displayLuma([decode(tmp[0]), decode(tmp[1]), decode(tmp[2])]);
}
@ -113,7 +122,9 @@ export interface Fit {
export function fitDisplay(state: FixedSkyState, hdr: Float32Array, meanLuma: number): Fit {
const b = BUDGET[state];
const keyExposure =
b.key === undefined ? b.min : Math.min(b.max, Math.max(b.min, (b.key * 1.6) / Math.max(meanLuma, 1e-9)));
b.key === undefined
? b.min
: Math.min(b.max, Math.max(b.min, (b.key * 1.6) / Math.max(meanLuma, 1e-9)));
const n = hdr.length / 3;
const stride = Math.max(1, Math.floor(n / 4000));
const tmp = [0, 0, 0];
@ -187,7 +198,10 @@ export function holdBudget(state: FixedSkyState, hdr: Float32Array, d: Display):
r = range(cur);
}
for (let k = 0; k < 8 && r.lo < b.floor; k += 1) {
cur = { exposure: cur.exposure, lift: cur.lift + ((b.floor - r.lo) / (1 - r.lo)) * 1.05 + 1e-4 };
cur = {
exposure: cur.exposure,
lift: cur.lift + ((b.floor - r.lo) / (1 - r.lo)) * 1.05 + 1e-4,
};
r = range(cur);
}
return cur;

View file

@ -31,7 +31,8 @@ describe('the sun over Vienna', () => {
it('has a short day in December and a long one in June: published 8 h 4 min and 16 h 0 min with refraction, which adds about 6 minutes each end the ephemeris leaves out', () => {
const daylight = (day: string) => {
let n = 0;
for (let m = 0; m < 24 * 60; m += 1) if (viennaSun(new Date(Date.parse(day) + m * 60_000)).elevation > 0) n += 1;
for (let m = 0; m < 24 * 60; m += 1)
if (viennaSun(new Date(Date.parse(day) + m * 60_000)).elevation > 0) n += 1;
return n;
};
expect(Math.abs(daylight('2026-12-21T00:00:00Z') - (8 * 60 + 4))).toBeLessThan(15);

View file

@ -33,7 +33,8 @@ export const STATE_ELEVATION: Record<FixedSkyState, readonly [number, number]> =
night: [-90, -DAY_FROM],
};
const startOfUtcDay = (t: Date): number => Math.floor(t.getTime() / (24 * 60 * MIN)) * 24 * 60 * MIN;
const startOfUtcDay = (t: Date): number =>
Math.floor(t.getTime() / (24 * 60 * MIN)) * 24 * 60 * MIN;
/** The moment of the day (UTC day of `t`, every 5 minutes) the sun is highest over Vienna: solar noon to the step. */
export function solarNoon(t: Date): Date {

File diff suppressed because one or more lines are too long

View file

@ -43,7 +43,9 @@ export function weatherOf(value: number): Weather {
}
/** The page URL's `?weather=<0..1>`, when it is a number. */
export function urlWeather(search: string = typeof location === 'undefined' ? '' : location.search): number | undefined {
export function urlWeather(
search: string = typeof location === 'undefined' ? '' : location.search
): number | undefined {
const v = new URLSearchParams(search).get('weather');
if (v === null || v.trim() === '') return undefined;
const n = Number(v);

View file

@ -30,7 +30,9 @@ const NOW = new Date('2026-11-11T10:50:00Z');
describe('weather from the file', () => {
it('credits GeoSphere as its legal page asks, and claims no licence', () => {
expect(WEATHER_ATTRIBUTION).toBe('Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at');
expect(WEATHER_ATTRIBUTION).toBe(
'Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at'
);
expect(WEATHER_ATTRIBUTION).not.toMatch(/CC/);
});
it('rain is 0.8..1, by rate', () => {
@ -42,21 +44,41 @@ describe('weather from the file', () => {
expect(clearSkyGhi(-5)).toBe(0);
});
it('noon clear: radiation at the clear-sky value is no cloud', () => {
expect(weatherFromFile(file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 600, humidity_pct: 95 }), 21)).toBe(0);
expect(
weatherFromFile(
file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 600, humidity_pct: 95 }),
21
)
).toBe(0);
expect(cloudFromGlow(300, 21)).toBeCloseTo(0.56, 1); // k = 0.9 is still a few oktas
});
it('noon overcast: a fifth of the clear-sky radiation is nearly full cloud, and with saturated air, mist', () => {
expect(cloudFromGlow(0.2 * clearSkyGhi(21), 21)).toBeGreaterThan(0.9);
expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 80 }), 21)).toBeGreaterThan(0.6);
expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 100 }), 21)).toBe(0.75);
expect(
weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 80 }), 21)
).toBeGreaterThan(0.6);
expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 100 }), 21)).toBe(
0.75
);
});
it('the radiation, not the humidity, decides by day: humid air under a bright sky is clear', () => {
expect(weatherFromFile(file({ glow_wm2: clearSkyGhi(30), sunshine_s_10min: 600, humidity_pct: 98 }), 30)).toBe(0);
expect(
weatherFromFile(
file({ glow_wm2: clearSkyGhi(30), sunshine_s_10min: 600, humidity_pct: 98 }),
30
)
).toBe(0);
});
it('dusk: with the sun at 3 degrees the humidity ramp speaks, cloud 0 at 60% and 1 at 95%', () => {
expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 60 }), 3)).toBe(0);
expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 77.5 }), 3)).toBeCloseTo(0.35, 2);
expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 95 }), 3)).toBeCloseTo(0.7, 2);
expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 60 }), 3)).toBe(
0
);
expect(
weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 77.5 }), 3)
).toBeCloseTo(0.35, 2);
expect(
weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 95 }), 3)
).toBeCloseTo(0.7, 2);
});
it('night: the radiation and sunshine are 0.0 and the humidity speaks; saturated air is mist, 0.75', () => {
expect(weatherFromFile(file({ glow_wm2: 0, humidity_pct: 40 }), -20)).toBe(0);
@ -68,21 +90,30 @@ describe('weather from the file', () => {
for (const g of [56, 72, 89]) expect(cloudFromGlow(g, nov)).toBeGreaterThan(0.95);
for (const g of [546, 569]) expect(cloudFromGlow(g, oct)).toBe(0);
expect(weatherFromFile(file({ glow_wm2: 72, sunshine_s_10min: 0 }), nov)).toBeGreaterThan(0.65);
expect(weatherFromFile(file({ glow_wm2: 560, sunshine_s_10min: 600, humidity_pct: 70 }), oct)).toBe(0);
expect(
weatherFromFile(file({ glow_wm2: 560, sunshine_s_10min: 600, humidity_pct: 70 }), oct)
).toBe(0);
});
it('the sunshine cross-checks the radiation: agreement keeps the radiation, a half-sky disagreement splits it', () => {
expect(cloudEstimate(file({ glow_wm2: 0.2 * clearSkyGhi(21), sunshine_s_10min: 0 }), 21)).toBeGreaterThan(0.9);
expect(
cloudEstimate(file({ glow_wm2: 0.2 * clearSkyGhi(21), sunshine_s_10min: 0 }), 21)
).toBeGreaterThan(0.9);
// Radiation says clear, the sunshine says none: each is half trusted.
expect(cloudEstimate(file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 0 }), 21)).toBe(0.5);
// No radiation reading by day: the sunshine speaks alone.
expect(cloudEstimate(file({ glow_wm2: null, sunshine_s_10min: 300 }), 21)).toBe(0.5);
expect(cloudEstimate(file({ glow_wm2: 0, sunshine_s_10min: 600 }), 21)).toBe(0);
});
it('with the sun below the instruments\' reach there is no estimate: the humidity speaks', () => {
it("with the sun below the instruments' reach there is no estimate: the humidity speaks", () => {
expect(cloudEstimate(file({ glow_wm2: 3, sunshine_s_10min: 0 }), 3)).toBeNull();
});
it('null: unknown measurements are not zero, and nothing known is a clear sky', () => {
expect(weatherFromFile(file({ glow_wm2: null, humidity_pct: null, precip_mm_10min: null, sunshine_s_10min: null }), 21)).toBe(CLEAR_SKY);
expect(
weatherFromFile(
file({ glow_wm2: null, humidity_pct: null, precip_mm_10min: null, sunshine_s_10min: null }),
21
)
).toBe(CLEAR_SKY);
expect(weatherFromFile(file({ glow_wm2: null, humidity_pct: 95 }), 21)).toBeCloseTo(0.7, 2);
});
});
@ -97,17 +128,26 @@ describe('the weather now', () => {
expect(currentWeather(f, NOW)).toBe(CLEAR_SKY);
});
it('the 30 minute mark itself is still fresh', () => {
expect(currentWeather(file({ glow_wm2: 0, humidity_pct: 100, fetched_at: '2026-11-11T10:20:00Z' }), NOW)).toBe(0.75);
expect(
currentWeather(
file({ glow_wm2: 0, humidity_pct: 100, fetched_at: '2026-11-11T10:20:00Z' }),
NOW
)
).toBe(0.75);
});
it('falls back to a clear sky when the observation is more than 40 minutes old, though the file is fresh', () => {
const f = file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:09:00Z' });
expect(NOW.getTime() - Date.parse(f.observed_at)).toBeGreaterThan(OBSERVATION_STALE_MS);
expect(currentWeather(f, NOW)).toBe(CLEAR_SKY);
expect(currentWeather(file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:11:00Z' }), NOW)).toBeGreaterThan(0.8);
expect(
currentWeather(file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:11:00Z' }), NOW)
).toBeGreaterThan(0.8);
});
it('falls back to a clear sky with no file, and with a fetch time from the future', () => {
expect(currentWeather(null, NOW)).toBe(CLEAR_SKY);
expect(currentWeather(file({ precip_mm_10min: 2, fetched_at: '2026-11-12T10:40:00Z' }), NOW)).toBe(CLEAR_SKY);
expect(
currentWeather(file({ precip_mm_10min: 2, fetched_at: '2026-11-12T10:40:00Z' }), NOW)
).toBe(CLEAR_SKY);
});
});
@ -117,23 +157,45 @@ describe('bad instrument values', () => {
});
it('the radiation is set against the sun of the ten minutes it averaged, not the sun now', () => {
// Sun rising: 07:45Z observation (mean of 07:35-07:45), read 35 minutes later at the stale limit.
const f = file({ observed_at: '2026-11-11T07:45:00Z', fetched_at: '2026-11-11T07:46:00Z', glow_wm2: 100, sunshine_s_10min: null, humidity_pct: 50 });
const f = file({
observed_at: '2026-11-11T07:45:00Z',
fetched_at: '2026-11-11T07:46:00Z',
glow_wm2: 100,
sunshine_s_10min: null,
humidity_pct: 50,
});
const at = (m: number) => currentWeather(f, new Date(Date.parse(f.observed_at) + m * 60_000));
expect(at(0)).toBe(at(29));
});
});
describe('reading weather.json', () => {
it('reads Eureka\'s file as the host writes it: Z times, a whole-station gap as nulls, 0.0 at night', () => {
it("reads Eureka's file as the host writes it: Z times, a whole-station gap as nulls, 0.0 at night", () => {
const night = JSON.parse(
'{"fetched_at":"2026-11-11T22:48:22Z","observed_at":"2026-11-11T22:40:00Z","station":11035,"precip_mm_10min":0.1,"humidity_pct":100.0,"sunshine_s_10min":0.0,"glow_wm2":0.0,"attribution":"Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at"}'
);
expect(parseWeatherFile(night)).toMatchObject({ precip_mm_10min: 0.1, humidity_pct: 100, glow_wm2: 0, sunshine_s_10min: 0 });
const gap = parseWeatherFile({ ...night, precip_mm_10min: null, humidity_pct: null, sunshine_s_10min: null, glow_wm2: null });
expect(gap).toMatchObject({ precip_mm_10min: null, humidity_pct: null, sunshine_s_10min: null, glow_wm2: null });
expect(parseWeatherFile(night)).toMatchObject({
precip_mm_10min: 0.1,
humidity_pct: 100,
glow_wm2: 0,
sunshine_s_10min: 0,
});
const gap = parseWeatherFile({
...night,
precip_mm_10min: null,
humidity_pct: null,
sunshine_s_10min: null,
glow_wm2: null,
});
expect(gap).toMatchObject({
precip_mm_10min: null,
humidity_pct: null,
sunshine_s_10min: null,
glow_wm2: null,
});
expect(currentWeather(gap, new Date('2026-11-11T22:50:00Z'))).toBe(CLEAR_SKY);
});
it('is not another station\'s file', () => {
it("is not another station's file", () => {
expect(parseWeatherFile({ ...file(), station: 11036 })).toBeNull();
});
it('parses the agreed shape, keeping nulls and the sunshine it never reads', () => {
@ -146,15 +208,30 @@ describe('reading weather.json', () => {
expect(parseWeatherFile(null)).toBeNull();
expect(parseWeatherFile('x')).toBeNull();
});
it('rejects a time with no zone: it would be read in the client\'s local time', () => {
it("rejects a time with no zone: it would be read in the client's local time", () => {
expect(parseWeatherFile({ ...file(), observed_at: '2026-11-10T10:40:00' })).toBeNull();
expect(parseWeatherFile({ ...file(), observed_at: '2026-11-10T11:40:00+01:00' })).not.toBeNull();
expect(
parseWeatherFile({ ...file(), observed_at: '2026-11-10T11:40:00+01:00' })
).not.toBeNull();
});
it('is no observation when the file is missing, fails, or is not JSON', async () => {
expect(await fetchWeatherFile('/weather.json', (async () => ({ ok: false })) as unknown as typeof fetch)).toBeNull();
expect(await fetchWeatherFile('/weather.json', (async () => { throw new Error('offline'); }) as unknown as typeof fetch)).toBeNull();
expect(
await fetchWeatherFile('/weather.json', (async () => ({ ok: true, json: async () => { throw new Error('bad'); } })) as unknown as typeof fetch)
await fetchWeatherFile('/weather.json', (async () => ({
ok: false,
})) as unknown as typeof fetch)
).toBeNull();
expect(
await fetchWeatherFile('/weather.json', (async () => {
throw new Error('offline');
}) as unknown as typeof fetch)
).toBeNull();
expect(
await fetchWeatherFile('/weather.json', (async () => ({
ok: true,
json: async () => {
throw new Error('bad');
},
})) as unknown as typeof fetch)
).toBeNull();
});
it('reads a good file', async () => {

View file

@ -34,7 +34,8 @@ const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null);
/** An ISO time that names its zone (`Z` or an offset); `Date.parse` would read a bare one in the client's local time. */
const isZonedTime = (v: unknown): v is string => typeof v === 'string' && /(Z|[+-]\d\d:?\d\d)$/.test(v) && !Number.isNaN(Date.parse(v));
const isZonedTime = (v: unknown): v is string =>
typeof v === 'string' && /(Z|[+-]\d\d:?\d\d)$/.test(v) && !Number.isNaN(Date.parse(v));
/** A `WeatherFile` from parsed JSON, or null when it does not have the shape (times missing or unparseable). */
export function parseWeatherFile(json: unknown): WeatherFile | null {
@ -96,8 +97,10 @@ export function cloudEstimate(f: WeatherFile, sunEl: number): number | null {
export function weatherFromFile(f: WeatherFile, sunEl: number): number {
const rain = f.precip_mm_10min ?? 0;
if (rain > 0) return clamp01(0.8 + 0.2 * Math.min(1, rain / 1.5));
const cloud = cloudEstimate(f, sunEl) ?? (f.humidity_pct === null ? 0 : clamp01((f.humidity_pct - 60) / 35));
const mist = f.humidity_pct === null || cloud < 0.5 ? 0 : clamp01((f.humidity_pct - 92) / 8) * 0.75;
const cloud =
cloudEstimate(f, sunEl) ?? (f.humidity_pct === null ? 0 : clamp01((f.humidity_pct - 60) / 35));
const mist =
f.humidity_pct === null || cloud < 0.5 ? 0 : clamp01((f.humidity_pct - 92) / 8) * 0.75;
return Math.max(0.7 * cloud, mist);
}
@ -106,13 +109,24 @@ export function currentWeather(f: WeatherFile | null, now: Date = new Date()): n
if (!f) return CLEAR_SKY;
const fetched = now.getTime() - Date.parse(f.fetched_at);
const observed = now.getTime() - Date.parse(f.observed_at);
if (!(fetched <= STALE_MS && fetched >= -SKEW_MS && observed <= OBSERVATION_STALE_MS && observed >= -SKEW_MS)) return CLEAR_SKY;
if (
!(
fetched <= STALE_MS &&
fetched >= -SKEW_MS &&
observed <= OBSERVATION_STALE_MS &&
observed >= -SKEW_MS
)
)
return CLEAR_SKY;
// GLOW and SO are means of the ten minutes ending at observed_at: the sun of their midpoint is the one to set them against.
return weatherFromFile(f, viennaSun(new Date(Date.parse(f.observed_at) - 5 * 60_000)).elevation);
}
/** Fetch and parse `weather.json`; null on any failure. */
export async function fetchWeatherFile(url: string, fetcher: typeof fetch = fetch): Promise<WeatherFile | null> {
export async function fetchWeatherFile(
url: string,
fetcher: typeof fetch = fetch
): Promise<WeatherFile | null> {
try {
const res = await fetcher(url, { cache: 'no-store' });
return res.ok ? parseWeatherFile(await res.json()) : null;

View file

@ -37,5 +37,7 @@ export function fragmentSource(stage: Stage, defines = ''): string {
const erosion = solid && WORLD.phacelleGradBound > 0 ? phacelle : '';
const world = solid ? sdf : '';
const math = trimGlsl(cultmath, [erosion, common, world, stages[stage]]);
return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${stages[stage]}`;
return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${
stages[stage]
}`;
}

View file

@ -88,7 +88,13 @@ describe('ridgeSkyFor', () => {
});
it('draws the sky auto resolves to, never nothing', () => {
const key = (s: ReturnType<typeof ridgeSkyFor>) => [s.top, s.mid, s.horizon, s.band, s.physical?.sun];
const key = (s: ReturnType<typeof ridgeSkyFor>) => [
s.top,
s.mid,
s.horizon,
s.band,
s.physical?.sun,
];
setSkyClock(() => atStandardTime(12, 0));
expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('day', 0.3)));
setSkyClock(() => atStandardTime(0, 0));

View file

@ -7,10 +7,11 @@ import type { FixedSkyState } from './skyBand';
import {
LIGHTEST_PATCH,
NIGHT_INK_MAX_L,
NOTICE_GROUND,
SCRIM_INK,
SCRIM_GROUND,
inkForState,
labelInk,
noticeInk,
scrimStyle,
sceneInk,
userColorInk,
} from './skyInk';
@ -182,27 +183,24 @@ describe('labelInk', () => {
});
});
describe('noticeInk', () => {
const ratio = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
describe('scrimStyle', () => {
// The scrim over the lightest possible ground (white) is the worst case the ink can meet.
const [r, g, b, a] = (SCRIM_GROUND.match(/[\d.]+/g) ?? []).map(Number);
const worst = chroma.rgb(
...([r, g, b].map((c) => c * a + 255 * (1 - a)) as [number, number, number])
);
const ratio = (x: number, y: number) => (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
it('is light enough to clear AA on the dark ridge wherever the theme ink does not', () => {
(['day', 'dusk'] as FixedSkyState[]).forEach((state) => {
const ink = noticeInk({ state, phone: true }) as string;
expect(ratio(luminance(ink), NOTICE_GROUND)).toBeGreaterThanOrEqual(AA);
// the dark theme ink it replaces does not
expect(
ratio(luminance(baked.noonday.tokens.SurfaceVariant.OnContainer), NOTICE_GROUND)
).toBeLessThan(AA);
});
it('clears AA for its ink over its scrim even on a white ground', () => {
expect(ratio(luminance(SCRIM_INK), luminance(worst.hex()))).toBeGreaterThanOrEqual(AA);
});
it('leaves night (already light) and every desktop state to the theme', () => {
expect(noticeInk({ state: 'night', phone: true })).toBeUndefined();
(['day', 'dusk', 'night'] as FixedSkyState[]).forEach((state) =>
expect(noticeInk({ state, phone: false })).toBeUndefined()
it('is the same in every sky state and viewport while a scene runs, and absent without one', () => {
const styles = (['day', 'dusk', 'night'] as FixedSkyState[]).flatMap((state) =>
[true, false].map((phone) => scrimStyle({ state, phone }))
);
expect(
ratio(luminance(baked.midnight.tokens.SurfaceVariant.OnContainer), NOTICE_GROUND)
).toBeGreaterThanOrEqual(AA);
styles.forEach((x) => expect(x).toEqual(styles[0]));
expect(styles[0]).toMatchObject({ color: SCRIM_INK, backgroundColor: SCRIM_GROUND });
expect(scrimStyle(undefined)).toBeUndefined();
});
});

View file

@ -95,18 +95,22 @@ export const labelInk = ({ state, phone }: SkyInk): string | undefined =>
state === 'dusk' && phone ? '#29221a' : undefined;
/**
* WCAG relative luminance of the lightest pixel under the notice line above the composer on a phone: the dark
* foreground ridge, the same in every state (measured 2026-11-10 from the Starfire phone shots at 390x844,
* ratatoskr-dev-preview/phone-room-{day,dusk,night}.png, text region x 15-355 CSS px: median 0.0052, 90th percentile
* 0.0089, rounded up to 0.01). The labelInk patches above are the lightest terrain behind the timeline; here the
* ground is dark, so the ink must be light.
* The training-notice line above the composer sits on whatever the scene draws there: the dark ridge on a phone in
* the Starfire shots, but a mid-tone ridge at other widths and the near-black foreground ridge on a desktop, so no
* ink chosen for one ground holds on the others (measured 2026-11-10 on the pretend-server line, ratatoskr-dev-preview/
* taps2-REPORT.md: phone day 3.08, dusk 3.97 with light ink; desktop day 1.01, dusk 1.22 with the theme's dark ink).
* So the line carries its own ground: light ink on a dark scrim, in every sky state, on every viewport, whatever
* the scene is behind it. The scrim is composited over the lightest ground there is (white); the ink over that
* worst case clears AA (skyInk.test.ts).
*/
export const NOTICE_GROUND = 0.01;
export const SCRIM_INK = baked.midnight.tokens.SurfaceVariant.OnContainer;
export const SCRIM_GROUND = 'rgba(10, 14, 24, 0.82)';
/**
* The ink of the training-notice line above the composer where the theme's own does not clear AA there. On a phone the
* line sits on the dark ridge in every state, but Noonday (day, dusk) wears dark ink, 1.2:1 at dusk by the numbers
* above; Midnight's (night) is already light and stays (undefined). Desktop is unmeasured and stays.
* The style of a line of text that must be read whatever the scene draws behind it (the training notice, the desk's
* recorded rulings): its own ground and ink while a scene runs, or undefined without one.
*/
export const noticeInk = ({ state, phone }: SkyInk): string | undefined =>
phone && state !== 'night' ? baked.midnight.tokens.SurfaceVariant.OnContainer : undefined;
export const scrimStyle = (ink: SkyInk | undefined) =>
ink
? { color: SCRIM_INK, backgroundColor: SCRIM_GROUND, borderRadius: 4, paddingInline: 6 }
: undefined;

View file

@ -157,10 +157,8 @@ export function grainAt(
distance: number
): number {
const w = triWeights(n);
const over = (
k: ReadonlyArray<readonly [number, number]>,
f: typeof waves
) => w[0] * f(k, z, y) + w[1] * f(k, x, z) + w[2] * f(k, x, y);
const over = (k: ReadonlyArray<readonly [number, number]>, f: typeof waves) =>
w[0] * f(k, z, y) + w[1] * f(k, x, z) + w[2] * f(k, x, y);
const coarse = over(COARSE, waves);
const fine = over(FINE, wavesWarped) * (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance));
const layers = Math.sin(y * 0.9 + wobbleAt(x, y, z, n) * 3);

View file

@ -4,7 +4,7 @@ import * as css from './styles.css';
export function AuthFooter() {
return (
<Box className={css.AuthFooter} justifyContent="Center" gap="400" wrap="Wrap">
<Box className={`${css.AuthFooter} ${css.AuthFooterLinks}`} justifyContent="Center" wrap="Wrap">
<Text as="a" size="T300" href="https://gamecult.org" target="_blank" rel="noreferrer">
About
</Text>

View file

@ -36,6 +36,7 @@ import {
import { PasswordInput } from '../../../components/password-input';
import { FieldError } from '../FiledError';
import { loginErrorMessage } from '../authMessages';
import { ForgotLink } from '../styles.css';
import { getResetPasswordPath } from '../../pathUtils';
import { stopPropagation } from '../../../utils/keyboard';
@ -251,7 +252,9 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
)}
<Box grow="Yes" shrink="No" justifyContent="End">
<Text as="span" size="T200" priority="400" align="Right">
<Link to={getResetPasswordPath(server)}>Forget Password?</Link>
<Link className={ForgotLink} to={getResetPasswordPath(server)}>
Forget Password?
</Link>
</Text>
</Box>
</Box>

View file

@ -54,6 +54,17 @@ export const AuthFooter = style({
padding: config.space.S200,
});
// The footer's links: 16 px apart, and on a phone 22 px between rows, so each link's 11 px of hit area above and
// below meets its neighbour's and does not cross it (see the tap-target rules at the foot of this file).
export const AuthFooterLinks = style({
gap: config.space.S400,
'@media': { [`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: { rowGap: 22 } },
});
// Forget Password? sits 5 px under the password field and 16 px above Login: on a phone its hit area takes the 5 and
// grows 7 px of real layout so the 44 reach neither.
export const ForgotLink = style({});
// While the background scene runs (html[data-scene], set by features/scene/BackgroundScene), the
// signed-out ground is clear and the card is a pane shaped like a message bubble (BubbleContent:
// radius R500, no border), filled with the sky's bubble colour. The scene is one fixed dusk for
@ -134,6 +145,17 @@ globalStyle(`${scene} ${AuthFooter} a`, {
// back out of the layout, so nothing moves and nothing is drawn. An inline link ignores the margin and its padding
// never moves a line, which is the same result.
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
globalStyle(`${AuthCard} a.${ForgotLink}`, {
'@media': {
[SCREEN_PHONE]: {
display: 'inline-block',
paddingTop: 5,
paddingBottom: 23,
marginTop: -5,
marginBottom: -16,
},
},
});
globalStyle(`${AuthCard} a`, {
'@media': { [SCREEN_PHONE]: { paddingBlock: 14, marginBlock: -14 } },
});

View file

@ -9,11 +9,13 @@ const emitted = async (file: string) => {
const filePath = `${process.cwd()}/src/app/${file}`;
const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' });
const code = await processVanillaFile({ source, filePath, identOption: 'debug' });
const bytes = Buffer.from(
(code.match(/source=#?([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1],
'base64'
);
return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString();
// A file with recipes inlines one source per rule group; read them all.
return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)]
.map((m) => {
const bytes = Buffer.from(m[1], 'base64');
return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString();
})
.join('\n');
};
type Decls = Record<string, number>;
@ -42,37 +44,55 @@ const rulesFor = (css: string, selector: RegExp, media: string | undefined): Dec
const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
const TAP = 44;
// [target, css file, selector, measured height in CSS px on the Starfire phone run (390x844)].
const targets: [string, string, RegExp, number][] = [
// [target, css file, selector, measured height in CSS px on the Starfire phone run (390x844), free space measured
// above and below the target before its neighbour's box (taps2-REPORT.md): the hit area may reach out of the layout
// box (negative margin) by no more than that, or it takes taps from its neighbour. Space it needs beyond that is
// real layout growth, which the rules above take from the padding the margin does not give back.
const targets: [string, string, RegExp, number, number, number][] = [
[
'sender-name button',
'components/message/layout/layout.css.ts',
/^button\.layout_Username__/,
23,
11,
11,
],
[
'room-topic button',
'features/room/RoomViewHeader.css.ts',
/RoomViewHeader_HeaderTopic__\w+$/,
18,
0,
26,
],
['sign-in card links', 'pages/auth/styles.css.ts', /styles_AuthCard__\w+ a$/, 16],
['sign-in footer links', 'pages/auth/styles.css.ts', /styles_AuthFooter__\w+ a$/, 22],
['sign-in card links', 'pages/auth/styles.css.ts', /styles_AuthCard__\w+ a$/, 16, 14, 14],
[
'forget-password link',
'pages/auth/styles.css.ts',
/styles_AuthCard__\w+ a\.styles_ForgotLink__\w+$/,
16,
5,
16,
],
// The footer's rows are 22 px apart on a phone (AuthFooterLinks), 11 px for each side of the gap.
['sign-in footer links', 'pages/auth/styles.css.ts', /styles_AuthFooter__\w+ a$/, 22, 11, 11],
];
describe.each(targets)('phone tap target: %s', (_name, file, selector, height) => {
describe.each(targets)('phone tap target: %s', (_name, file, selector, height, above, below) => {
it(`grows its hit area to ${TAP} px without moving layout`, async () => {
const css = await emitted(file);
const [phone] = rulesFor(css, selector, PHONE);
expect(phone, 'a phone rule exists').toBeDefined();
const top = phone['padding-top'] ?? phone['padding-block'];
const bottom = phone['padding-bottom'] ?? phone['padding-block'];
const marginTop = phone['margin-top'] ?? phone['margin-block'];
const marginBottom = phone['margin-bottom'] ?? phone['margin-block'];
const top = phone['padding-top'] ?? phone['padding-block'] ?? 0;
const bottom = phone['padding-bottom'] ?? phone['padding-block'] ?? 0;
const marginTop = phone['margin-top'] ?? phone['margin-block'] ?? 0;
const marginBottom = phone['margin-bottom'] ?? phone['margin-block'] ?? 0;
expect(height + top + bottom).toBeGreaterThanOrEqual(TAP);
// the margin takes back exactly what the padding added, so the target does not move or push its neighbours
expect(marginTop).toBe(-top);
expect(marginBottom).toBe(-bottom);
// the hit area reaches out of the layout box by no more than the free space on that side
expect(-marginTop).toBeLessThanOrEqual(above);
expect(-marginBottom).toBeLessThanOrEqual(below);
expect(marginTop).toBeLessThanOrEqual(0);
expect(marginBottom).toBeLessThanOrEqual(0);
});
it('leaves every other screen as it was: no padding or margin outside the phone query', async () => {
@ -82,3 +102,17 @@ describe.each(targets)('phone tap target: %s', (_name, file, selector, height) =
);
});
});
describe('phone navigator rows', () => {
// The links and buttons inside a row are 44 px (EstateNavigator.css.ts); a row shorter than that lets them spill
// onto the next row (the 38 px pitch of taps2-REPORT.md), so the row and the section header are 44 too.
it('are as tall as their tap targets', async () => {
const css = await emitted('components/nav/styles.css.ts');
const phone = [
...css.matchAll(/@media screen and \(max-width: 750px\) \{\n([\s\S]*?\n)\}(?:\n|$)/g),
]
.map((m) => m[1])
.join('\n');
expect(phone.match(/min-height: 2\.75rem/g)?.length).toBeGreaterThanOrEqual(2);
});
});