Phone and desk visual findings: notice scrim, tap areas stop at neighbours, desk affordance #117
45 changed files with 851 additions and 191 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue