Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
08bf515997 Merge pull request 'Release candidate: dev into main' (#88) from dev into main 2026-11-11 02:13:08 +00:00
f8115609b2 Register entry: https only, http only for a local host (#87)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m45s
Build pull request / Build pull request (pull_request) Successful in 3m29s
2026-11-11 01:31:35 +00:00
450a7e91ff auth: Register entry beside Sign in, behind a config switch (off) (#68)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 01:30:40 +00:00
94c976632e Default homeserver is gamecult.org (#86)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 01:30:16 +00:00
b6da92a081 Albums slice 1: read-only album view for the file zone (#70)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 00:25:37 +00:00
7045532187 Poses nest into the chat bubble corner (desktop and phone) (#72)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 23:26:53 +00:00
ac7a958f5c Enforce the one join path in lint; a drive that asserts nothing fails (#64)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 23:16:59 +00:00
3def499b0f desk: served row order, one open count (#84)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 21:13:24 +00:00
8e0e3b8986 desk: read the gamecult.fork.* contract as served (#83)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 21:08:12 +00:00
c286c4adc4 Bound the reply preview on phone width (#82)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 20:33:49 +00:00
1fe3e2c92f Merge pull request 'scene: ridge heightfields, slope shading, endless world-x drift' (#81) from nibu/ridge-heightfield into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 19:05:27 +00:00
Nibu
d172c4eda3 scene: ridge chunk jobs own their scratch canvas; tests for the endless drift and the ridged core
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m6s
Soul pass fixes. The shared scratch canvas was resized when a job was created
but read in a deferred task, so chunks of layers with a different blur pad got
a faded right edge. Each job now makes its own scratch when it first runs and
drops it when done. A chunk is baked, put and scatter-filled in column bands,
the blur is one draw; allocation is a task of its own. rotate no longer needs a
forward reference (startDrift takes the finish callback).

Tests: the fake animation can finish, so the rotation is tested at scene level
(world columns per chunk, slot repositioning, restart, recycled chunk id, one
chunk of drift distance, per-job scratch width at the moment of each blur).
The ridged core has direct tests for the fold, the warp, the previous-octave
weighting, the lacunarity and contrast by distance. README sun statement
matches sunDirection.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:56:52 +00:00
Nibu
9391e4753f scene: ridge heightfields with slope shading and endless world-x drift
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Each ridge layer is a function of world-x (ridged multifractal value noise,
domain-warped, one function and a parameter row per layer) instead of a
periodic strip. World-x is baked in 1.2-viewport chunks on demand: the first
view bakes one chunk per layer, the rest are baked in ~6 ms tasks, and the
compositor slides a track that is recycled each chunk. A slow yaw/pitch sway
rides on top. ridgeBake.ts shades each column from the crest slope against
the sun with a lit rim, a light-to-haze face gradient and erosion streaks,
reusing the existing aerial perspective as the depth treatment.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:31:56 +00:00
313d070dd4 Merge pull request 'desk: read-only Mind desk, slice 1' (#77) from void/desk-slice-1 into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 18:29:38 +00:00
51d5fb0909 Merge pull request 'Phone: notice contrast, 44px tap hit areas, skip empty OIDC validation' (#78) from void/phone-contrast-taps into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 18:29:16 +00:00
Void
0f82576d9f desk: booth portrait is a plain image; options_hash recomputed against the CultLib vectors
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m9s
PoseAvatar renders a span, not a button, when it has no onClick. The desk recomputes each card's options_hash (RFC 8785 after NFC, SHA-256; 15 CultLib vectors) and withdraws a card that differs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:26:03 +00:00
Void
8dea8717ea desk: read-only Mind desk slice 1 (typed projections, buildDesk, deskMood, DeskView, navigator entry, demo forks)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:26:03 +00:00
Void
0351f1f6e8 Skip auth-metadata validation when the homeserver returned none
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:26:01 +00:00
Void
de6f1849ea Phone: 44 px hit areas for sender names, room topic and sign-in links, no layout shift
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:26:01 +00:00
Void
06d530d061 Phone: light ink for the training notice over the dark ridge (day, dusk)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:26:01 +00:00
84770a2d2c Merge pull request 'preview demo: drop a stored login for another server before the app reads it' (#80) from void/demo-homeserver-unreachable into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 18:24:00 +00:00
Void
5bb78b21cf preview demo: drop a stored login for a real homeserver before the app reads it
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m53s
Every preview shares the origin minds.gamecult.org and so one localStorage. A login another
preview left (cinny_hs_base_url of a real homeserver) made the demo connect to that server, which
the guard refuses, so SpecVersions showed 'Unable to connect to the homeserver'. installDemo now
drops any stored session whose base is not the demo origin, and the service worker is registered
after that so it is never handed the dropped session. Replaces the foreign-session banner text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:22:43 +00:00
7bac860016 Merge pull request 'preview: build-dev-preview.sh, the dev demo build recipe (publishing is the shared script's)' (#79) from void/publish-dev-preview into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 18:19:09 +00:00
683d3b92f8 Merge pull request 'Release candidate: dev 64edacac to main' (#71) from release/dev-64edacac into main 2026-11-10 17:42:15 +00:00
92 changed files with 5262 additions and 603 deletions

View file

@ -1,3 +1,8 @@
// Airbnb's own bans, kept: a `no-restricted-syntax` entry below replaces the whole list.
const airbnbRestrictedSyntax = require('eslint-config-airbnb-base/rules/style').rules[
'no-restricted-syntax'
];
module.exports = {
env: {
browser: true,
@ -62,6 +67,21 @@ module.exports = {
"@typescript-eslint/no-shadow": "error"
},
overrides: [
{
// The one join path: a join is `joinEstateRoom`, which files the DM before it sends the join.
files: ['src/**/*.ts', 'src/**/*.tsx'],
excludedFiles: ['src/app/features/estate/joinEstateRoom.ts', 'src/**/*.test.*'],
rules: {
'no-restricted-syntax': [
...airbnbRestrictedSyntax,
{
selector: "CallExpression[callee.property.name='joinRoom']",
message:
'Join a room through joinEstateRoom (features/estate/joinEstateRoom.ts), the one join path.',
},
],
},
},
{
files: ['*.ts'],
rules: {

View file

@ -1,6 +1,13 @@
{
"defaultHomeserver": 1,
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
"defaultHomeserver": 0,
"homeserverList": [
"gamecult.org",
"converser.eu",
"matrix.org",
"mozilla.org",
"unredacted.org",
"xmr.se"
],
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
@ -24,5 +31,10 @@
"dings": {
"enabled": false
},
"registration": {
"enabled": false,
"url": ""
}
}

View file

@ -1,5 +1,7 @@
# Mind feedback pages
> The desk reads `gamecult.fork.*` now, not the `org.gamecult.feedback.*` projections named below; see CultLib `contracts/cultnet/governance/fork-card.md` (src/app/features/desk/forkContract.ts).
Design only. No code exists for this; nothing here is built. Drafted by Kiko Self (2026-11-07) with Heimdall's draft rulings
on guests and hiding; placement ruled for this repo by Void. Follow-up `eureka-body:follow_up:ratatoskr-mind-feedback-pages`.
Every number below is provisional.

View file

@ -2,12 +2,17 @@
// non-zero when the page or the fake homeserver's log says otherwise.
const fs = require('fs');
const fails = [];
let asserted = 0;
exports.expect = (label, ok, detail = '') => {
asserted += 1;
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
if (!ok) fails.push(label);
};
/** Exit code for the drive: 4 when any assertion failed. */
exports.done = () => (fails.length ? 4 : 0);
/** Exit code for the drive: 4 when any assertion failed, 5 when none ran (a drive that asserts nothing proves nothing). */
exports.done = () => {
if (fails.length) return 4;
return asserted === 0 ? 5 : 0;
};
/** The fake homeserver's request log (HSLOG is shared with the server process). */
exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n');
/** Bodies of the account-data PUTs for m.direct, in order. */

View file

@ -0,0 +1,35 @@
// @vitest-environment node
import { createRequire } from 'module';
import { describe, expect, it, vi } from 'vitest';
// A drive that asserts nothing proves nothing: its exit code must say so.
const fresh = () => {
const require = createRequire(import.meta.url);
const path = require.resolve('./check.cjs');
delete require.cache[path];
return require('./check.cjs') as {
expect: (label: string, ok: boolean, detail?: string) => void;
done: () => number;
};
};
describe('estate drive exit code', () => {
it('is 5 when no assertion ran', () => {
expect(fresh().done()).toBe(5);
});
it('is 0 when assertions ran and all held', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const c = fresh();
c.expect('holds', true);
expect(c.done()).toBe(0);
});
it('is 4 when any assertion failed', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const c = fresh();
c.expect('holds', true);
c.expect('breaks', false, 'detail');
expect(c.done()).toBe(4);
});
});

View file

@ -1,12 +1,29 @@
// Standalone page for the ridge scene: full window, no host app. `?tier=still|low|full` picks the
// tier, `?probe=1` shows the rAF median/p95 readout. Not shipped.
// tier, `?probe=1` shows the rAF median/p95 readout. The bake record (each chunk's cost, from
// the scene's `onBake`) and the long tasks the page saw are on `window.bakes` and `window.longTasks`
// for a driver to read. Not shipped.
import { startBackgroundScene } from '../../src/app/features/scene';
import type { SceneTier } from '../../src/app/features/scene';
const q = new URLSearchParams(location.search);
const tier = (q.get('tier') ?? 'full') as SceneTier;
const canvas = document.getElementById('c') as HTMLCanvasElement;
startBackgroundScene({ tier }, {}, { canvas }).then((scene) => {
(window as unknown as { scene: unknown }).scene = scene;
const w = window as unknown as { scene: unknown; bakes: unknown[]; longTasks: number[]; firstViewMs: number };
w.bakes = [];
w.longTasks = [];
try {
new PerformanceObserver((l) => l.getEntries().forEach((e) => w.longTasks.push(e.duration))).observe({
entryTypes: ['longtask'],
});
} catch {
// No long-task entries in this browser.
}
const t0 = performance.now();
startBackgroundScene({ tier }, {}, {
canvas,
onBake: (b: unknown) => w.bakes.push(b),
} as never).then((scene) => {
w.firstViewMs = performance.now() - t0;
w.scene = scene;
document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)';
});

View file

@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { loadServerConfigs } from './ServerConfigsLoader';
const server = (authMetadata: () => Promise<unknown>) =>
({
getCapabilities: async () => ({}),
getMediaConfig: async () => ({ 'm.upload.size': 1 }),
getAuthMetadata: authMetadata,
} as unknown as Parameters<typeof loadServerConfigs>[0]);
describe('loadServerConfigs', () => {
afterEach(() => vi.restoreAllMocks());
it('logs nothing when the homeserver has no OIDC (no auth metadata came back)', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const configs = await loadServerConfigs(
server(async () => {
throw new Error('M_UNRECOGNIZED');
})
);
expect(error).not.toHaveBeenCalled();
expect(configs.authMetadata).toBeUndefined();
expect(configs.mediaConfig).toEqual({ 'm.upload.size': 1 });
});
it('still logs and drops auth metadata that came back malformed', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const configs = await loadServerConfigs(server(async () => ({ issuer: 'https://x' })));
expect(error).toHaveBeenCalled();
expect(configs.authMetadata).toBeUndefined();
});
});

View file

@ -1,5 +1,10 @@
import { ReactNode, useCallback, useMemo } from 'react';
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
import {
Capabilities,
MatrixClient,
validateAuthMetadata,
ValidatedAuthMetadata,
} from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig';
@ -14,35 +19,42 @@ export type ServerConfigs = {
type ServerConfigsLoaderProps = {
children: (configs: ServerConfigs) => ReactNode;
};
/**
* The homeserver's capabilities, media config and OIDC metadata. A homeserver with no OIDC (and the demo's pretend
* server) returns no auth metadata: there is nothing to validate, so nothing is logged. Metadata that did come back
* and fails validation is logged and dropped.
*/
export async function loadServerConfigs(
mx: Pick<MatrixClient, 'getCapabilities' | 'getMediaConfig' | 'getAuthMetadata'>
): Promise<ServerConfigs> {
const result = await Promise.allSettled([
mx.getCapabilities(),
mx.getMediaConfig(),
mx.getAuthMetadata(),
]);
const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]);
const authMetadata = promiseFulfilledResult(result[2]);
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
if (authMetadata) {
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
console.error(e);
}
}
return { capabilities, mediaConfig, authMetadata: validatedAuthMetadata };
}
export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
const mx = useMatrixClient();
const fallbackConfigs = useMemo(() => ({}), []);
const [configsState] = useAsyncCallbackValue<ServerConfigs, unknown>(
useCallback(async () => {
const result = await Promise.allSettled([
mx.getCapabilities(),
mx.getMediaConfig(),
mx.getAuthMetadata(),
]);
const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]);
const authMetadata = promiseFulfilledResult(result[2]);
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
console.error(e);
}
return {
capabilities,
mediaConfig,
authMetadata: validatedAuthMetadata,
};
}, [mx])
useCallback(() => loadServerConfigs(mx), [mx])
);
const configs: ServerConfigs =

View file

@ -31,6 +31,7 @@ export const Reply = style({
});
export const ReplyContent = style({
minWidth: 0,
opacity: config.opacity.P300,
selectors: {
@ -39,3 +40,19 @@ export const ReplyContent = style({
},
},
});
export const ReplyText = style({
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
minWidth: 0,
maxWidth: '100%',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
});
export const ReplyWrap = style({
minWidth: 0,
maxWidth: '100%',
});

View file

@ -101,7 +101,14 @@ export const Reply = as<'div', ReplyProps>(
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
return (
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
<Box
className={css.ReplyWrap}
direction="Row"
gap="200"
alignItems="Center"
{...props}
ref={ref}
>
{threadRootId && (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
)}
@ -119,7 +126,7 @@ export const Reply = as<'div', ReplyProps>(
onClick={onClick}
>
{replyEvent !== undefined ? (
<Text size="T300" truncate>
<Text size="T300" className={css.ReplyText}>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
) : (

View file

@ -30,10 +30,8 @@ type BubbleLayoutProps = {
hideBubble?: boolean;
/** The sender is the signed-in user: right-aligned, in the accent colour, no avatar. */
own?: boolean;
/** A Mind's pose portrait; it replaces the avatar and sits beside (wide) or inside (phone) the bubble. */
/** A Mind's pose portrait; it replaces the avatar and nests in a corner of the bubble. */
pose?: ReactNode;
/** On phones the portrait floats on the other side. */
alt?: boolean;
/** Delay in ms before the first-appearance animation; undefined plays none. */
enterDelay?: number;
before?: ReactNode;
@ -41,7 +39,7 @@ type BubbleLayoutProps = {
};
export const BubbleLayout = as<'div', BubbleLayoutProps>(
({ hideBubble, own, pose, alt, enterDelay, before, header, children, style, ...props }, ref) => {
({ hideBubble, own, pose, enterDelay, before, header, children, style, ...props }, ref) => {
// `false` is a Mind's message continuing a run: aligned with the portraits, showing none.
const poseRow = pose !== undefined;
const arrow = !!before && !poseRow && !own;
@ -50,12 +48,7 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
<Box
gap="300"
{...props}
className={classNames(
poseRow && css.BubbleRowPose,
pose && css.BubbleRowPortrait,
own && css.BubbleRowOwn,
props.className
)}
className={classNames(own && css.BubbleRowOwn, props.className)}
style={entering ? { ...style, ['--enter-delay' as string]: `${enterDelay}ms` } : style}
ref={ref}
>
@ -67,19 +60,9 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
<Box
grow={own ? 'No' : 'Yes'}
direction="Column"
className={classNames(poseRow && css.BubbleColumnPose, own && css.BubbleColumnOwn)}
className={classNames(own && css.BubbleColumnOwn)}
>
{header && (
<div
className={classNames(
pose && css.BubbleHeaderPose,
pose && alt && css.BubbleHeaderPoseAlt,
own && css.BubbleHeaderOwn
)}
>
{header}
</div>
)}
{header && <div className={classNames(own && css.BubbleHeaderOwn)}>{header}</div>}
{hideBubble ? (
children
) : (
@ -89,6 +72,7 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
css.BubbleContent,
arrow && css.BubbleContentArrowLeft,
poseRow && css.BubbleContentPose,
pose && css.BubbleContentPortrait,
own && css.BubbleContentOwn,
entering && css.EnterBubble
)}

View file

@ -1,6 +1,7 @@
import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
import { MOBILE_BREAKPOINT } from '../../../hooks/useScreenSize';
export const StickySection = style({
position: 'sticky',
@ -152,39 +153,21 @@ export const BubbleContentArrowLeft = style({
borderTopLeftRadius: 0,
});
// Look of the three-doors mock-up (chat.gamecult.org/mockups/three-doors): a Mind's pose sits
// beside its bubble on wide screens; on phones it floats inside the bubble, overhanging the top
// edge, and the text wraps around its silhouette (shape-outside on the alpha channel).
// A Mind's pose nests in the corner of its bubble, on phones and wide screens alike: the sprite's
// body sits in the bubble's padding and the bubble's edge cuts it, so nothing hangs outside; the
// text wraps around its silhouette (shape-outside, traced from the alpha channel) and the bubble
// grows to hold it. How much of the art the edge may cut comes from pose/nest.ts.
// Displayed width of the whole sprite image; the crop fractions are multiples of it.
export const portraitWidth = createVar();
const PHONE = 'screen and (max-width: 700px)';
const PORTRAIT = toRem(190);
const PHONE_PORTRAIT = 'min(38vw, 160px)';
const PHONE_TEXT_FLOOR = toRem(170);
const PORTRAIT_OVERHANG = 58;
const PORTRAIT = toRem(124);
const POSE_GUTTER = toRem(14);
const BUBBLE_PAD = toRem(12);
const BUBBLE_CORNER = toRem(6);
const POP = 'cubic-bezier(0.34, 1.7, 0.64, 1)';
const RISE = 'cubic-bezier(0.34, 1.45, 0.64, 1)';
export const BubbleRowPose = style({
position: 'relative',
paddingLeft: `calc(${PORTRAIT} + ${POSE_GUTTER})`,
'@media': {
[PHONE]: {
paddingLeft: 0,
},
},
});
// A row that shows a portrait: room for it to hang above the bubble on phones.
export const BubbleRowPortrait = style({
minHeight: PORTRAIT,
'@media': {
[PHONE]: {
minHeight: 0,
},
},
});
export const BubbleRowOwn = style({
justifyContent: 'flex-end',
});
@ -198,66 +181,31 @@ export const BubbleHeaderOwn = style({
alignSelf: 'flex-end',
});
// Phone: name and time are one plain line in flow above the bubble, on the side opposite the
// portrait. It is as wide as the full name needs, up to the room beside the portrait; only a name
// longer than that wraps (and then the time drops below it). It is capped by the viewport, not by
// the column: a short bubble's column is narrower than its own name, and a percentage cap there
// truncated names to one letter.
export const BubbleHeaderPose = style({
'@media': {
[PHONE]: {
alignSelf: 'flex-end',
// The band above the bubble where the portrait overhangs: the header sits at its foot, beside
// the portrait, so the overhang needs no margin of its own.
minHeight: toRem(PORTRAIT_OVERHANG),
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
margin: `0 ${toRem(14)} ${toRem(2)}`,
width: 'max-content',
maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`,
textAlign: 'right',
},
},
});
export const BubbleHeaderPoseAlt = style({
'@media': {
[PHONE]: {
alignSelf: 'flex-start',
textAlign: 'left',
},
},
});
// A portrait row with no header (nothing to fill the overhang band) keeps the band as a margin.
globalStyle(`${BubbleRowPortrait}:not(:has(.${BubbleHeaderPose}))`, {
'@media': {
[PHONE]: {
marginTop: toRem(PORTRAIT_OVERHANG),
},
},
});
export const BubbleColumnPose = style({
position: 'relative',
});
// The message body is a flex column, which forms its own block formatting context and would sit
// whole beside the floated portrait; as a plain block its lines wrap around the silhouette and
// then run the bubble's full width below it.
export const BubbleContentPose = style({
borderRadius: config.radii.R500,
borderTopLeftRadius: BUBBLE_CORNER,
display: 'flow-root',
overflow: 'clip',
padding: BUBBLE_PAD,
});
// A bubble that shows a portrait is at least portrait + a readable column wide, or the text beside
// it collapses to a strip.
export const BubbleContentPortrait = style({
vars: { [portraitWidth]: PORTRAIT },
minWidth: `min(100%, calc(${portraitWidth} + ${PHONE_TEXT_FLOOR}))`,
'@media': {
[PHONE]: {
display: 'flow-root',
// A bubble with a portrait is at least portrait + a readable column wide, or the text
// beside the portrait collapses to a strip.
minWidth: `min(100%, calc(${PHONE_PORTRAIT} + ${PHONE_TEXT_FLOOR}))`,
borderTopLeftRadius: config.radii.R500,
padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`,
vars: { [portraitWidth]: PHONE_PORTRAIT },
},
},
});
globalStyle(`${BubbleContentPose} > div`, {
display: 'block',
});
export const BubbleContentOwn = style({
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
@ -266,51 +214,37 @@ export const BubbleContentOwn = style({
borderTopRightRadius: BUBBLE_CORNER,
});
// The button is the visible part of the sprite (the part inside the bubble's corner); the image
// inside it is offset by the crop. The inline --pose-* fractions (of the image width) come from
// nestView; until the art's size is known the button is simply the whole image.
export const PoseButton = style({
position: 'absolute',
top: 0,
left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`,
width: PORTRAIT,
height: PORTRAIT,
float: 'left',
width: `var(--pose-bw, ${portraitWidth})`,
height: 'var(--pose-bh, auto)',
// Out into the padding on the top, bottom and outer side, so the bubble's edge is the crop.
margin: `calc(-1 * ${BUBBLE_PAD}) ${toRem(6)} calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD})`,
padding: 0,
border: 'none',
background: 'none',
cursor: 'pointer',
lineHeight: 0,
overflow: 'hidden',
shapeMargin: toRem(7),
transformOrigin: '50% 90%',
'@media': {
[PHONE]: {
position: 'static',
float: 'left',
width: PHONE_PORTRAIT,
height: 'auto',
margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`,
shapeMargin: toRem(7),
WebkitMaskImage: 'linear-gradient(#000 84%, transparent)',
maskImage: 'linear-gradient(#000 84%, transparent)',
},
},
});
export const PoseButtonAlt = style({
'@media': {
[PHONE]: {
float: 'right',
margin: `${toRem(-82)} ${toRem(-12)} 0 ${toRem(8)}`,
},
},
float: 'right',
margin: `calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) ${toRem(6)}`,
});
export const PosePortrait = style({
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'top',
'@media': {
[PHONE]: {
height: 'auto',
},
},
display: 'block',
width: portraitWidth,
maxWidth: 'none',
height: 'auto',
marginLeft: 'var(--pose-ix, 0)',
marginTop: 'var(--pose-iy, 0)',
});
const pop = keyframes({
@ -350,6 +284,9 @@ export const BubbleLeftArrow = style({
zIndex: 1,
});
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves.
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
export const Username = style({
overflow: 'hidden',
whiteSpace: 'nowrap',
@ -357,6 +294,9 @@ export const Username = style({
selectors: {
'button&': {
cursor: 'pointer',
'@media': {
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 },
},
},
'button&:hover, button&:focus-visible': {
textDecoration: 'underline',
@ -395,61 +335,12 @@ export const MessageTextBody = recipe({
export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;
// The message content is a flex column, which forms its own block formatting context and so sits
// whole beside the floated portrait: the text stays as narrow as the gap beside it for the
// bubble's full height. As a plain block its lines wrap around the silhouette and then run the
// bubble's full width below it.
globalStyle(`${BubbleContentPose} > div`, {
'@media': {
[PHONE]: {
display: 'block',
},
},
});
// The name wraps instead of ellipsising: the header already caps its own width, and Username's own
// nowrap + ellipsis, and the truncate on the Text inside it, clipped long names to "Heimdall o…".
globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *`, {
'@media': {
[PHONE]: {
whiteSpace: 'normal',
overflow: 'visible',
textOverflow: 'clip',
overflowWrap: 'break-word',
},
},
});
// A name too long for one line takes its own lines and the time drops below it, instead of the
// name shrinking to a sliver beside the time (the header's one child is the flex row of both). The
// row hugs the side the header sits on, so a dropped time stays under the name's edge.
globalStyle(`${BubbleHeaderPose} > div`, {
'@media': {
[PHONE]: {
flexWrap: 'wrap',
justifyContent: 'flex-end',
},
},
});
globalStyle(`${BubbleHeaderPoseAlt} > div`, {
'@media': {
[PHONE]: {
justifyContent: 'flex-start',
},
},
});
// An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so
// beside a left portrait the number lands under the art. Inside, it is a word on the first line
// and wraps around the silhouette like the rest.
globalStyle(`${BubbleContentPose} ol, ${BubbleContentPose} ul`, {
'@media': {
[PHONE]: {
listStylePosition: 'inside',
paddingLeft: 0,
},
},
listStylePosition: 'inside',
paddingLeft: 0,
});
// While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in

View file

@ -1,53 +1,126 @@
import React, { useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import * as css from '../layout/layout.css';
import { poseSrc } from './poses';
import { poseSrc, safeBox } from './poses';
import { alphaBounds, nestView, type Box } from './nest';
import { silhouette } from './silhouette';
type PoseAvatarProps = {
mind: string;
pose: string;
userId: string;
/** Float on the right on phones. */
/** Nest in the bubble's top-right corner instead of the top-left. */
alt?: boolean;
/** Play the first-appearance pop. */
enter?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
/** Without it the portrait is a plain image: no button, no tab stop. */
onClick?: React.MouseEventHandler<HTMLButtonElement>;
};
// Safe boxes derived from art's alpha, by source; the art is traced once.
const traced = new Map<string, { box: Box; size: [number, number] }>();
const trace = (img: HTMLImageElement, src: string) => {
const hit = traced.get(src);
if (hit) return hit;
const w = img.naturalWidth;
const h = img.naturalHeight;
const ctx = document.createElement('canvas').getContext('2d');
if (!ctx) return undefined;
ctx.canvas.width = w;
ctx.canvas.height = h;
ctx.drawImage(img, 0, 0);
const box = alphaBounds(ctx.getImageData(0, 0, w, h).data, w, h);
if (!box) return undefined;
const found = { box, size: [w, h] as [number, number] };
traced.set(src, found);
return found;
};
export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) {
const src = poseSrc(mind, pose, import.meta.env.BASE_URL);
const imgRef = useRef<HTMLImageElement>(null);
const [shape, setShape] = useState<string>();
const measured = safeBox(mind, pose);
const [derived, setDerived] = useState<{ box: Box; size: [number, number] }>();
const art = measured ?? (derived?.size ? derived : undefined);
// Measured boxes are cropped by the share Aqua's numbers allow; a box traced from the alpha is
// already tight to the art, so the bubble may take all of the transparent margin.
const view = art && nestView(art.box, art.size, !!alt, measured ? undefined : 1);
// On phones the bubble text wraps around the portrait's silhouette. Retraced when the art
// loads and when the portrait's width changes with the viewport.
// Art with no measured box: find its extent when it loads.
useEffect(() => {
setDerived(undefined);
const img = imgRef.current;
if (!img || measured) return undefined;
const find = () => {
if (!img.complete || img.naturalWidth === 0) return;
try {
setDerived(trace(img, src));
} catch {
setDerived(undefined);
}
};
find();
img.addEventListener('load', find);
return () => img.removeEventListener('load', find);
}, [src, measured]);
// The bubble text wraps around the portrait's silhouette. Retraced when the art loads, when its
// crop is known, and when its width changes with the viewport.
useEffect(() => {
const img = imgRef.current;
if (!img) return undefined;
const trace = () => {
const retrace = () => {
if (!img.complete || img.naturalWidth === 0) return;
try {
setShape(silhouette(img, !alt));
const box = img.parentElement;
const style = getComputedStyle(img);
setShape(
silhouette(
img,
!alt,
box
? {
x: 0 - parseFloat(style.marginLeft || '0'),
y: 0 - parseFloat(style.marginTop || '0'),
width: box.clientWidth,
height: box.clientHeight,
}
: undefined
)
);
} catch {
setShape(undefined);
}
};
trace();
img.addEventListener('load', trace);
const resize = new ResizeObserver(trace);
retrace();
img.addEventListener('load', retrace);
const resize = new ResizeObserver(retrace);
resize.observe(img);
return () => {
img.removeEventListener('load', trace);
img.removeEventListener('load', retrace);
resize.disconnect();
};
}, [src, alt]);
}, [src, alt, view?.x, view?.y, view?.width, view?.height]);
// Crop fractions are of the image's width, so one set serves every displayed size.
const frac = (px: number) => `calc(${css.portraitWidth} * ${px / (art?.size[0] ?? 1)})`;
const cropStyle =
art && view
? ({
'--pose-bw': frac(view.width),
'--pose-bh': frac(view.height),
'--pose-ix': frac(-view.x),
'--pose-iy': frac(-view.y),
} as React.CSSProperties)
: undefined;
const Tag = onClick ? 'button' : 'span';
return (
<button
type="button"
<Tag
type={onClick ? 'button' : undefined}
className={classNames(css.PoseButton, alt && css.PoseButtonAlt, enter && css.EnterPortrait)}
style={{ shapeOutside: shape }}
style={{ ...cropStyle, shapeOutside: shape }}
data-user-id={userId}
data-pose={pose}
onClick={onClick}
@ -59,6 +132,6 @@ export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvat
alt={`${mind}, ${pose}`}
draggable={false}
/>
</button>
</Tag>
);
}

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { alphaBounds, nestView } from './nest';
import boxes from './safeBoxes.json';
describe('nestView', () => {
// Aqua neutral: 560x374, safe box [144,22,492,317] (right margin 68, top 22).
it('crops half the right and top margins for a top-right nest', () => {
expect(nestView([144, 22, 492, 317], [560, 374], true)).toEqual({
x: 0,
y: 11,
width: 526,
height: 363,
});
});
it('crops the left margin instead for a top-left nest', () => {
expect(nestView([144, 22, 492, 317], [560, 374], false)).toEqual({
x: 72,
y: 11,
width: 488,
height: 363,
});
});
it('never crops the top of art already touching it (smug, headband at y=0)', () => {
const v = nestView([140, 0, 487, 241], [560, 374], true);
expect(v.y).toBe(0);
expect(v.height).toBe(374);
});
it('can take the whole margin', () => {
expect(nestView([10, 4, 90, 50], [100, 60], true, 1)).toEqual({
x: 0,
y: 4,
width: 90,
height: 56,
});
});
it('keeps every measured safe box intact at the default share', () => {
const aqua = boxes.aqua as Record<string, number[]>;
expect(Object.keys(aqua)).toHaveLength(15);
Object.values(aqua).forEach(([x0, y0, x1, y1, w, h]) => {
const right = nestView([x0, y0, x1, y1], [w, h], true);
expect(right.x).toBeLessThanOrEqual(x0);
expect(right.x + right.width).toBeGreaterThanOrEqual(x1);
expect(right.y).toBeLessThanOrEqual(y0);
const left = nestView([x0, y0, x1, y1], [w, h], false);
expect(left.x).toBeLessThanOrEqual(x0);
expect(left.x + left.width).toBe(w);
});
});
});
describe('alphaBounds', () => {
it('finds the opaque rectangle', () => {
const w = 6;
const h = 5;
const data = new Uint8ClampedArray(w * h * 4);
[
[2, 1],
[4, 3],
].forEach(([x, y]) => {
data[(y * w + x) * 4 + 3] = 255;
});
expect(alphaBounds(data, w, h)).toEqual([2, 1, 5, 4]);
});
it('reports nothing for an empty image', () => {
expect(alphaBounds(new Uint8ClampedArray(16), 2, 2)).toBeUndefined();
});
});

View file

@ -0,0 +1,58 @@
// A pose nests into the corner of its bubble: the sprite's body sits in the bubble's padding and is
// cut off by the bubble's edge, so nothing hangs outside it.
/** [x0, y0, x1, y1] in image pixels, origin top-left, x1/y1 exclusive. */
export type Box = [number, number, number, number];
// Share of the room between the safe box and the image edge that the bubble edge may cut away.
// Aqua's measurement says all of it can go; half leaves the outline a little air.
export const CROP_SHARE = 0.5;
export type NestView = {
/** The visible region of the image, in pixels. */
x: number;
y: number;
width: number;
height: number;
};
// The part of the image that shows, for a sprite in the bubble's top-right (`right`) or top-left
// corner. Only the corner's two sides are cropped: the side towards the bubble's edge and the top.
// The far side and the bottom stay whole; the bubble's bottom edge cuts the bust.
export const nestView = (
safe: Box,
size: [number, number],
right: boolean,
share = CROP_SHARE
): NestView => {
const [w, h] = size;
const [x0, y0, x1] = safe;
const side = Math.round(share * (right ? w - x1 : x0));
const top = Math.round(share * y0);
const x = right ? 0 : side;
return { x, y: top, width: w - side, height: h - top };
};
// Bounds of the pixels whose alpha exceeds `min`, as a safe box for art with no measured one.
export const alphaBounds = (
data: ArrayLike<number>,
w: number,
h: number,
min = 8
): Box | undefined => {
let x0 = w;
let y0 = h;
let x1 = 0;
let y1 = 0;
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
if (data[(y * w + x) * 4 + 3] > min) {
x0 = Math.min(x0, x);
x1 = Math.max(x1, x + 1);
y0 = Math.min(y0, y);
y1 = y + 1;
}
}
}
return x1 > x0 ? [x0, y0, x1, y1] : undefined;
};

View file

@ -1,4 +1,6 @@
import catalogue from './catalogue.json';
import safeBoxes from './safeBoxes.json';
import type { Box } from './nest';
// Pose labels travel in the event content under POSE_KEY. The catalogue
// (cast-art poses/catalogue.json, synced by `npm run sync:poses`) is the only
@ -40,3 +42,14 @@ export const resolvePose = (mind: string, label: unknown): string => {
export const poseSrc = (mind: string, pose: string, base = '/'): string =>
`${base}public/poses/${artPath(mind, pose) ?? artPath(mind, NEUTRAL)}`;
// Safe-crop boxes measured from the art's pixels (Aqua, `aqua-pose-crop-boxes-20261110`): the
// head and headphones plus outline, which the bubble's edge must not cut. [x0, y0, x1, y1, width,
// height] in image pixels. Art with no entry gets its box from the sprite's alpha at load time.
const boxes = safeBoxes as Record<string, Record<string, number[]>>;
export const safeBox = (
mind: string,
pose: string
): { box: Box; size: [number, number] } | undefined => {
const b = Object.prototype.hasOwnProperty.call(boxes, mind) ? boxes[mind][pose] : undefined;
return b ? { box: [b[0], b[1], b[2], b[3]], size: [b[4], b[5]] } : undefined;
};

View file

@ -0,0 +1,19 @@
{
"aqua": {
"neutral": [144, 22, 492, 317, 560, 374],
"happy": [157, 22, 506, 317, 574, 374],
"amused": [154, 22, 500, 317, 562, 374],
"thinking": [154, 16, 500, 307, 562, 374],
"annoyed": [154, 4, 499, 282, 562, 374],
"surprised": [141, 5, 485, 282, 560, 374],
"concerned": [156, 4, 501, 283, 574, 374],
"fretful": [156, 16, 503, 305, 574, 374],
"weary": [161, 16, 509, 307, 624, 374],
"glare": [160, 5, 508, 282, 624, 374],
"smug": [140, 0, 487, 241, 560, 374],
"sigh": [154, 0, 500, 241, 574, 374],
"charging": [152, 0, 497, 241, 562, 374],
"delighted": [159, 0, 507, 241, 624, 374],
"content": [144, 16, 492, 306, 560, 374]
}
}

View file

@ -53,4 +53,22 @@ describe('silhouette', () => {
silhouette({ clientWidth: 0, clientHeight: 0 } as HTMLImageElement, true)
).toBeUndefined();
});
it('traces only the visible part of a cropped portrait, drawn at its offset', () => {
const drawImage = vi.fn();
const canvas = {
width: 0,
height: 0,
getContext: () => ({
canvas,
drawImage,
getImageData: () => ({ data: new Uint8ClampedArray(40 * 8 * 4) }),
}),
};
vi.stubGlobal('document', { createElement: () => canvas });
const img = { clientWidth: 100, clientHeight: 20 } as HTMLImageElement;
silhouette(img, true, { x: 0, y: 12, width: 40, height: 8 });
expect([canvas.width, canvas.height]).toEqual([40, 8]);
expect(drawImage).toHaveBeenCalledWith(img, 0, -12, 100, 20);
});
});

View file

@ -5,14 +5,23 @@ const ROW = 4;
// `shape-outside: url()` cannot do this: the browser sizes that image at its natural size and
// anchors it at the margin box, which the phone layout's negative top margin shifts down by the
// overhang, so the exclusion neither matches the art's size nor its place.
export const silhouette = (img: HTMLImageElement, textOnRight: boolean): string | undefined => {
const w = Math.round(img.clientWidth);
const h = Math.round(img.clientHeight);
//
// `view` is the part of the displayed image that shows (the button's box, in displayed pixels):
// the portrait is cropped by the bubble, so the polygon is traced over what is left of the art.
export type View = { x: number; y: number; width: number; height: number };
export const silhouette = (
img: HTMLImageElement,
textOnRight: boolean,
view?: View
): string | undefined => {
const w = Math.round(view?.width ?? img.clientWidth);
const h = Math.round(view?.height ?? img.clientHeight);
const ctx = w > 0 && h > 0 ? document.createElement('canvas').getContext('2d') : null;
if (!ctx) return undefined;
ctx.canvas.width = w;
ctx.canvas.height = h;
ctx.drawImage(img, 0, 0, w, h);
ctx.drawImage(img, 0 - (view?.x ?? 0), 0 - (view?.y ?? 0), img.clientWidth, img.clientHeight);
const { data } = ctx.getImageData(0, 0, w, h);
const outer = textOnRight ? 0 : w;
const edge: string[] = [];

View file

@ -0,0 +1,51 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
const WIDE = `screen and (min-width: ${MOBILE_BREAKPOINT + 1}px)`;
/** One column, bare ink on the scene. */
export const Column = style({
maxWidth: toRem(720),
width: '100%',
margin: '0 auto',
padding: config.space.S400,
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
});
/** A tappable text row: no border, no fill, at least 44 px tall. */
export const Row = style({
all: 'unset',
boxSizing: 'border-box',
cursor: 'pointer',
minHeight: 44,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: toRem(2),
color: color.Background.OnContainer,
':focus-visible': { textDecoration: 'underline' },
});
export const Back = style([Row, { minHeight: 44 }]);
export const Files = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S400,
});
/** Images may tile into a grid on wide screens only. */
export const FilesWide = style({
'@media': {
[WIDE]: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
alignItems: 'start',
},
},
});
export const Dim = style({ opacity: config.opacity.P300 });

View file

@ -0,0 +1,141 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Scroll, Text } from 'folds';
import { Room, RoomEvent } from 'matrix-js-sdk';
import { MediaControl } from '../../components/media';
import { AudioContent } from '../../components/message';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { IAudioContent } from '../../../types/matrix/common';
import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { markAsRead } from '../../utils/notifications';
import { getBlobSafeMimeType } from '../../utils/mimeTypes';
import { timeDayMonYear } from '../../utils/time';
import { getMemberDisplayName } from '../../utils/room';
import { Album, AlbumFile } from './buildAlbums';
import { useAlbums } from './useAlbums';
import * as css from './AlbumView.css';
/**
* Audio goes straight to AudioContent, skipping the chat Attachment frame and file-type badge: the
* title above it is the only label and the transport controls sit on the scene. Anything that is
* not playable audio falls through to the stock renderer (a file download).
*/
const playableAudio = (content: Record<string, unknown>) => {
const c = content as IAudioContent;
const url = c.file?.url ?? c.url;
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? '');
if (!c.info || typeof url !== 'string' || !mimeType.startsWith('audio')) return undefined;
return { info: c.info, url, mimeType, encInfo: c.file };
};
type FileRowProps = { room: Room; file: AlbumFile; author: string };
function FileRow({ room, file, author }: FileRowProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const htmlReactParserOptions = useMemo(
() =>
getReactCustomHtmlParser(mx, room.roomId, { linkifyOpts: LINKIFY_OPTS, useAuthentication }),
[mx, room.roomId, useAuthentication]
);
const audio = file.kind === 'audio' ? playableAudio(file.content) : undefined;
return (
<Box direction="Column" gap="100">
<Text size="T300" truncate>
{file.name}
</Text>
{audio ? (
<AudioContent {...audio} renderMediaControl={(p) => <MediaControl {...p} />} />
) : (
<RenderMessageContent
displayName={author}
msgType={file.content.msgtype as string}
ts={file.ts}
getContent={<T,>() => file.content as T}
mediaAutoLoad={mediaAutoLoad}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={LINKIFY_OPTS}
/>
)}
</Box>
);
}
const countLabel = (n: number) => `${n} ${n === 1 ? 'file' : 'files'}`;
/** The zone never mounts RoomTimeline, so it sends the read receipt the timeline would. */
function useMarkZoneRead(room: Room) {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
useEffect(() => {
const mark = () => {
markAsRead(mx, room.roomId, hideActivity).catch(() => undefined);
};
mark();
room.on(RoomEvent.Timeline, mark);
return () => {
room.off(RoomEvent.Timeline, mark);
};
}, [mx, room, hideActivity]);
}
type AlbumViewProps = { room: Room; onTimeline: () => void };
export function AlbumView({ room, onTimeline }: AlbumViewProps) {
useMarkZoneRead(room);
const { loading, error, albums } = useAlbums(room);
const [dateFormat] = useSetting(settingsAtom, 'dateFormatString');
const [openId, setOpenId] = useState<string>();
const open: Album | undefined = albums.find((a) => a.id === openId);
const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author;
const encrypted = room.hasEncryptionStateEvent();
let body: React.ReactNode;
if (loading) body = <Text>Loading albums…</Text>;
else if (error) body = <Text>Could not load albums: {error.message}</Text>;
else if (open) {
const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image');
body = (
<>
<button type="button" className={css.Back} onClick={() => setOpenId(undefined)}>
<Text size="B400">‹ Albums</Text>
</button>
<Text size="H4">{open.title}</Text>
<Text size="T200" className={css.Dim}>
{authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)}
</Text>
{open.count === 0 && <Text>This album has no files.</Text>}
<div className={images ? `${css.Files} ${css.FilesWide}` : css.Files}>
{open.files.map((f) => (
<FileRow key={f.id} room={room} file={f} author={authorOf(open)} />
))}
</div>
</>
);
} else if (encrypted)
body = <Text>This zone is encrypted, so its albums cannot be listed here.</Text>;
else if (albums.length === 0) body = <Text>No albums yet.</Text>;
else {
body = albums.map((a) => (
<button key={a.id} type="button" className={css.Row} onClick={() => setOpenId(a.id)}>
<Text size="T400">{a.title}</Text>
<Text size="T200" className={css.Dim}>
{authorOf(a)} · {countLabel(a.count)} · {timeDayMonYear(a.ts, dateFormat)}
</Text>
</button>
));
}
return (
<Scroll hideTrack visibility="Hover">
<div className={css.Column}>
{body}
<button type="button" className={css.Row} onClick={onTimeline}>
<Text size="B400">Timeline ›</Text>
</button>
</div>
</Scroll>
);
}

View file

@ -0,0 +1,216 @@
import { describe, expect, it } from 'vitest';
import { AlbumInput, ZoneEvent, buildAlbums } from './buildAlbums';
const root = (id: string, ts: number, over: Partial<ZoneEvent> = {}): ZoneEvent => ({
event_id: id,
type: 'm.room.message',
sender: '@void:x',
origin_server_ts: ts,
content: { msgtype: 'm.text', body: `album ${id}` },
...over,
});
const reply = (id: string, ts: number, msgtype: string, body = id, extra = {}): ZoneEvent => ({
event_id: id,
type: 'm.room.message',
sender: '@void:x',
origin_server_ts: ts,
content: { msgtype, body, ...extra },
});
const one = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }])[0];
describe('buildAlbums', () => {
it('lists the newest album first', () => {
const inputs: AlbumInput[] = [
{ root: root('$old', 100), replies: [] },
{ root: root('$new', 300), replies: [] },
{ root: root('$mid', 200), replies: [] },
];
expect(buildAlbums(inputs).map((a) => a.id)).toEqual(['$new', '$mid', '$old']);
});
it('takes title, author and date from the root', () => {
const a = one(root('$r', 555, { sender: '@eureka:x' }), []);
expect([a.title, a.author, a.ts]).toEqual(['album $r', '@eureka:x', 555]);
});
it('lists files oldest first whatever order the server returned them in', () => {
const a = one(root('$r', 1), [
reply('$c', 30, 'm.audio'),
reply('$a', 10, 'm.audio'),
reply('$b', 20, 'm.audio'),
]);
expect(a.files.map((f) => f.id)).toEqual(['$a', '$b', '$c']);
});
it('counts media replies only; a text reply is not a file', () => {
const a = one(root('$r', 1), [
reply('$t', 5, 'm.text', 'nice'),
reply('$n', 6, 'm.notice', 'bot'),
reply('$a', 10, 'm.audio'),
reply('$f', 11, 'm.file'),
]);
expect(a.count).toBe(2);
expect(a.files.map((f) => f.id)).toEqual(['$a', '$f']);
});
it('keeps a root with no media as an album of zero files', () => {
const a = one(root('$r', 1), [reply('$t', 5, 'm.text')]);
expect(a).toMatchObject({ id: '$r', count: 0, files: [], cover: undefined });
});
it('uses the first image as the cover', () => {
const a = one(root('$r', 1), [
reply('$a', 5, 'm.audio'),
reply('$i2', 20, 'm.image'),
reply('$i1', 10, 'm.image'),
]);
expect(a.cover?.id).toBe('$i1');
expect(one(root('$q', 1), [reply('$a', 5, 'm.audio')]).cover).toBeUndefined();
});
it('derives the kind from the msgtype', () => {
const a = one(root('$r', 1), [
reply('$1', 1, 'm.audio'),
reply('$2', 2, 'm.image'),
reply('$3', 3, 'm.video'),
reply('$4', 4, 'm.file'),
]);
expect(a.files.map((f) => f.kind)).toEqual(['audio', 'image', 'video', 'file']);
});
it('names a file by filename, else body', () => {
const a = one(root('$r', 1), [
reply('$1', 1, 'm.audio', 'caption', { filename: 'clip.wav' }),
reply('$2', 2, 'm.audio', 'plain.wav'),
]);
expect(a.files.map((f) => f.name)).toEqual(['clip.wav', 'plain.wav']);
});
it('does not duplicate a file the relations endpoint returned twice', () => {
const a = one(root('$r', 1), [reply('$a', 5, 'm.audio'), reply('$a', 5, 'm.audio')]);
expect(a.count).toBe(1);
});
it('does not duplicate an album listed twice', () => {
expect(
buildAlbums([
{ root: root('$r', 1), replies: [] },
{ root: root('$r', 1), replies: [] },
])
).toHaveLength(1);
});
it('skips redacted and empty events', () => {
const redacted = {
...reply('$x', 5, 'm.audio'),
content: {},
unsigned: { redacted_because: {} },
};
const stillTyped = { ...reply('$y', 6, 'm.audio'), unsigned: { redacted_because: {} } };
const empty: ZoneEvent = { event_id: '$z' };
const a = one(root('$r', 1), [redacted, stillTyped, empty, reply('$ok', 7, 'm.audio')]);
expect(a.files.map((f) => f.id)).toEqual(['$ok']);
expect(buildAlbums([{ root: root('$g', 1, { content: {} }), replies: [] }])).toEqual([]);
expect(
buildAlbums([{ root: root('$h', 1, { unsigned: { redacted_because: {} } }), replies: [] }])
).toEqual([]);
});
it('tolerates events with missing fields', () => {
const a = one({ event_id: '$r', content: { msgtype: 'm.text' } }, [
{ event_id: '$a', content: { msgtype: 'm.audio' } },
]);
expect(a).toMatchObject({ title: 'Untitled album', author: '', ts: 0, count: 1 });
});
it('does not read inherited object keys as file kinds', () => {
const a = one(root('$r', 1), [
reply('$c', 1, 'constructor'),
reply('$p', 2, '__proto__'),
reply('$t', 3, 'toString'),
]);
expect(a.count).toBe(0);
});
it('skips events that are not room messages even if their content looks like media', () => {
const reaction = { ...reply('$x', 5, 'm.audio'), type: 'm.reaction' };
const a = one(root('$r', 1), [reaction, reply('$ok', 6, 'm.audio')]);
expect(a.files.map((f) => f.id)).toEqual(['$ok']);
expect(buildAlbums([{ root: root('$g', 1, { type: 'm.reaction' }), replies: [] }])).toEqual([]);
});
it('skips events without an event id', () => {
const anon: ZoneEvent = { type: 'm.room.message', content: { msgtype: 'm.audio', body: 'a' } };
expect(one(root('$r', 1), [anon]).count).toBe(0);
expect(
buildAlbums([{ root: { content: { msgtype: 'm.text', body: 'x' } }, replies: [] }])
).toEqual([]);
});
it('titles an edited album by its latest edit', () => {
const edited = root('$r', 1, {
unsigned: {
'm.relations': {
'm.replace': { content: { 'm.new_content': { msgtype: 'm.text', body: 'renamed' } } },
},
},
});
expect(one(edited, []).title).toBe('renamed');
const emptyEdit = root('$q', 1, { unsigned: { 'm.relations': { 'm.replace': {} } } });
expect(one(emptyEdit, []).title).toBe('album $q');
});
it('survives null or non-object entries in inputs and replies', () => {
const bad = [null, undefined, 'x', 7] as unknown as ZoneEvent[];
const a = buildAlbums([
null as unknown as AlbumInput,
{ root: null as unknown as ZoneEvent, replies: [] },
{ root: root('$r', 1), replies: [...bad, reply('$ok', 2, 'm.audio')] },
{ root: root('$s', 2), replies: null as unknown as ZoneEvent[] },
]);
expect(a.map((x) => x.id)).toEqual(['$s', '$r']);
expect(a[1].files.map((f) => f.id)).toEqual(['$ok']);
});
it('treats a non-numeric timestamp as 0 so it sorts first, not by string order', () => {
const a = one(root('$r', 1), [
reply('$b', 20, 'm.audio'),
{ ...reply('$bad', 0, 'm.audio'), origin_server_ts: '5' as unknown as number },
{ ...reply('$nan', 0, 'm.audio'), origin_server_ts: NaN },
reply('$a', 10, 'm.audio'),
]);
expect(a.files.map((f) => f.id)).toEqual(['$bad', '$nan', '$a', '$b']);
const albums = buildAlbums([
{ root: root('$x', 100), replies: [] },
{ root: root('$y', 0, { origin_server_ts: '999' as unknown as number }), replies: [] },
]);
expect(albums.map((x) => [x.id, x.ts])).toEqual([
['$x', 100],
['$y', 0],
]);
});
it('gives an album with an empty body a fallback title', () => {
expect(one(root('$r', 1, { content: { msgtype: 'm.text', body: '' } }), []).title).toBe(
'Untitled album'
);
expect(one(root('$q', 1, { content: { msgtype: 'm.text' } }), []).title).toBe('Untitled album');
});
it('never counts an edit event as a file or an album', () => {
const edit = reply('$e', 9, 'm.audio', '* x', {
'm.relates_to': { rel_type: 'm.replace', event_id: '$a' },
'm.new_content': { msgtype: 'm.audio', body: 'x' },
});
const a = one(root('$r', 1), [reply('$a', 5, 'm.audio'), edit]);
expect(a.files.map((f) => f.id)).toEqual(['$a']);
const rootEdit = root('$re', 1, {
content: { msgtype: 'm.text', body: '* t', 'm.relates_to': { rel_type: 'm.replace' } },
});
expect(buildAlbums([{ root: rootEdit, replies: [] }])).toEqual([]);
const threaded = reply('$t', 6, 'm.audio', 't', {
'm.relates_to': { rel_type: 'm.thread', event_id: '$r' },
});
expect(one(root('$q', 1), [threaded]).count).toBe(1);
});
});

View file

@ -0,0 +1,121 @@
// Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON
// Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread,
// into albums. See docs/file-zone.md for the rulings this encodes.
/** The slice of a Matrix event this module reads. Every field may be missing. */
export type ZoneEvent = {
event_id?: string;
type?: string;
sender?: string;
origin_server_ts?: number;
content?: Record<string, unknown>;
unsigned?: Record<string, unknown>;
};
export type AlbumInput = { root: ZoneEvent; replies: ZoneEvent[] };
export type FileKind = 'audio' | 'image' | 'video' | 'file';
export type AlbumFile = {
id: string;
kind: FileKind;
name: string;
ts: number;
/** The event content, as RenderMessageContent reads it. */
content: Record<string, unknown>;
};
export type Album = {
id: string;
title: string;
author: string;
ts: number;
cover?: AlbumFile;
files: AlbumFile[];
count: number;
};
// A Map, not an object: msgtype is server-supplied and "constructor" must not name a kind.
const KIND_OF_MSGTYPE = new Map<string, FileKind>([
['m.audio', 'audio'],
['m.image', 'image'],
['m.video', 'video'],
['m.file', 'file'],
]);
// A redacted event keeps its id but loses its content and gains unsigned.redacted_because.
const live = (
e: ZoneEvent
): e is ZoneEvent & { event_id: string; content: Record<string, unknown> } =>
!!e &&
typeof e === 'object' &&
!!e.event_id &&
// An edit is an m.room.message too, but it revises an event; it is never a file or an album.
(e.content?.['m.relates_to'] as { rel_type?: unknown } | undefined)?.rel_type !== 'm.replace' &&
!e.unsigned?.redacted_because &&
(e.type === undefined || e.type === 'm.room.message') &&
typeof e.content?.msgtype === 'string';
const tsOf = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
const toFile = (e: ZoneEvent): AlbumFile | undefined => {
if (!live(e)) return undefined;
const kind = KIND_OF_MSGTYPE.get(e.content.msgtype as string);
if (!kind) return undefined;
return {
id: e.event_id,
kind,
name: asString(e.content.filename) || asString(e.content.body),
ts: tsOf(e.origin_server_ts),
content: e.content,
};
};
export const UNTITLED = 'Untitled album';
// An edited root carries its latest edit under unsigned["m.relations"]["m.replace"]; the title is
// that edit's m.new_content.body, else the original body.
const titleOf = (root: ZoneEvent & { content: Record<string, unknown> }): string => {
const relations = root.unsigned?.['m.relations'] as Record<string, unknown> | undefined;
const edit = relations?.['m.replace'] as ZoneEvent | undefined;
const edited = (edit?.content?.['m.new_content'] as Record<string, unknown> | undefined)?.body;
return asString(edited) || asString(root.content.body) || UNTITLED;
};
const toAlbum = (input: AlbumInput): Album | undefined => {
const { root, replies } = input ?? {};
if (!live(root)) return undefined;
const seen = new Set<string>();
const files = (Array.isArray(replies) ? replies : [])
.map(toFile)
.filter((f): f is AlbumFile => {
if (!f || seen.has(f.id)) return false;
seen.add(f.id);
return true;
})
.sort((a, b) => a.ts - b.ts);
return {
id: root.event_id,
title: titleOf(root),
author: root.sender ?? '',
ts: tsOf(root.origin_server_ts),
cover: files.find((f) => f.kind === 'image'),
files,
count: files.length,
};
};
/** Newest album first; files oldest first. Loose room messages never reach this function. */
export const buildAlbums = (inputs: AlbumInput[]): Album[] => {
const seen = new Set<string>();
return inputs
.map(toAlbum)
.filter((a): a is Album => {
if (!a || seen.has(a.id)) return false;
seen.add(a.id);
return true;
})
.sort((a, b) => b.ts - a.ts);
};

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { isFileZone } from './isFileZone';
const room = { roomId: '!f:x', aliases: ['#files:x'] };
describe('isFileZone', () => {
it('is true for the file-zone create type', () => {
expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone' }, {})).toBe(true);
});
it('is false for other create types and for plain rooms', () => {
expect(isFileZone({ ...room, createType: 'm.space' }, {})).toBe(false);
expect(isFileZone({ ...room, createType: 'org.gamecult.other' }, {})).toBe(false);
expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone.x' }, {})).toBe(false);
expect(isFileZone(room, {})).toBe(false);
expect(isFileZone(room, { fileZoneRooms: [] })).toBe(false);
});
it('is true when the room id or an alias is configured', () => {
expect(isFileZone(room, { fileZoneRooms: ['!f:x'] })).toBe(true);
expect(isFileZone(room, { fileZoneRooms: ['#other:x', '#files:x'] })).toBe(true);
});
it('is false when only another room is configured', () => {
expect(isFileZone(room, { fileZoneRooms: ['!g:x', '#g:x'] })).toBe(false);
});
});

View file

@ -0,0 +1,14 @@
export const FILE_ZONE_TYPE = 'org.gamecult.file_zone';
export type FileZoneRoom = {
roomId: string;
/** Canonical and alternative aliases. */
aliases?: string[];
/** The `type` of the room's m.room.create content. */
createType?: string;
};
/** A room is the file zone when its create type says so or the client config lists it. */
export const isFileZone = (room: FileZoneRoom, config: { fileZoneRooms?: string[] }): boolean =>
room.createType === FILE_ZONE_TYPE ||
!!config.fileZoneRooms?.some((r) => r === room.roomId || room.aliases?.includes(r));

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { mapLimit } from './mapLimit';
describe('mapLimit', () => {
it('keeps result order and never exceeds the limit', async () => {
let inFlight = 0;
let peak = 0;
const out = await mapLimit([5, 1, 4, 2, 3, 0], 2, async (n) => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((r) => {
setTimeout(r, n);
});
inFlight -= 1;
return n * 10;
});
expect(out).toEqual([50, 10, 40, 20, 30, 0]);
expect(peak).toBe(2);
});
it('handles an empty list and rejects when a call rejects', async () => {
expect(await mapLimit([], 3, async (n) => n)).toEqual([]);
await expect(
mapLimit([1, 2], 2, async (n) => {
if (n === 2) throw new Error('boom');
return n;
})
).rejects.toThrow('boom');
});
});

View file

@ -0,0 +1,19 @@
/** Like Promise.all(items.map(fn)) but with at most `limit` calls in flight; result order kept. */
export const mapLimit = async <T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> => {
const out = new Array<R>(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next;
next += 1;
// eslint-disable-next-line no-await-in-loop
out[i] = await fn(items[i]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return out;
};

View file

@ -0,0 +1,73 @@
import { useQuery } from '@tanstack/react-query';
import { ClientPrefix, Method, Room } from 'matrix-js-sdk';
import { StateEvent } from '../../../types/matrix/room';
import { useStateEvent } from '../../hooks/useStateEvent';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { Album, ZoneEvent, buildAlbums } from './buildAlbums';
import { isFileZone } from './isFileZone';
import { mapLimit } from './mapLimit';
const REPLY_FETCHES_IN_FLIGHT = 6;
const REFRESH_MS = 30_000;
type Chunk = { chunk?: ZoneEvent[] };
/** True when the room is the file zone: by its create type, or listed in the client config. */
export const useIsFileZone = (room: Room): boolean => {
const config = useClientConfig();
// Subscribes to alias changes (alt aliases live in the same event).
useStateEvent(room, StateEvent.RoomCanonicalAlias);
const createType = room.currentState.getStateEvents('m.room.create', '')?.getContent().type;
return isFileZone(
{
roomId: room.roomId,
aliases: [room.getCanonicalAlias(), ...room.getAltAliases()].filter((a): a is string => !!a),
createType,
},
config
);
};
export type AlbumsState = { loading: boolean; error: Error | null; albums: Album[] };
/**
* Slice 1 reads ONE page of each endpoint (threads: 100 roots; replies per root: 100). A zone with
* more albums or files than that is truncated; pagination is a later slice. Reads are made with
* the logged-in client, so the room must be unencrypted: encrypted events carry no msgtype and
* are skipped.
*/
export const useAlbums = (room: Room): AlbumsState => {
const mx = useMatrixClient();
const { roomId } = room;
const query = useQuery({
queryKey: ['albums', roomId],
queryFn: async (): Promise<Album[]> => {
const get = (path: string, params: Record<string, string>) =>
mx.http.authedRequest<Chunk>(Method.Get, path, params, undefined, {
prefix: ClientPrefix.V1,
});
const room_ = encodeURIComponent(roomId);
const roots = (await get(`/rooms/${room_}/threads`, { limit: '100' })).chunk ?? [];
const inputs = await mapLimit(roots, REPLY_FETCHES_IN_FLIGHT, async (root: ZoneEvent) => ({
root,
replies:
(
await get(
`/rooms/${room_}/relations/${encodeURIComponent(root.event_id ?? '')}/m.thread`,
{ limit: '100', dir: 'f' }
)
).chunk ?? [],
}));
return buildAlbums(inputs);
},
retry: 1,
refetchInterval: REFRESH_MS,
});
return {
loading: query.isLoading,
// A failed refetch keeps the albums already loaded; the error shows only when there are none.
error: query.data ? null : (query.error as Error | null),
albums: query.data ?? [],
};
};

View file

@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { zoneView } from './zoneView';
describe('zoneView', () => {
it('opens albums by default and the timeline for an event deep link', () => {
expect(zoneView(undefined, undefined)).toBe('albums');
expect(zoneView(undefined, '$e')).toBe('timeline');
});
it('lets an event link win over an earlier choice of albums', () => {
expect(zoneView('albums', '$e')).toBe('timeline');
});
it('honours the choice when there is no event link', () => {
expect(zoneView('albums', undefined)).toBe('albums');
expect(zoneView('timeline', undefined)).toBe('timeline');
});
});

View file

@ -0,0 +1,5 @@
export type ZoneView = 'albums' | 'timeline';
/** The view a file-zone room shows. A link to an event always wins; else the user's choice; else albums. */
export const zoneView = (choice: ZoneView | undefined, eventId: string | undefined): ZoneView =>
eventId ? 'timeline' : choice ?? 'albums';

View file

@ -0,0 +1,56 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { getHomePath } from '../../pages/pathUtils';
import { DeskView } from './DeskView';
import { unverifiedForks } from './optionsHash';
import { deskFor } from './source';
const labelOf = (mind: string) => mind.charAt(0).toUpperCase() + mind.slice(1);
/** The route's page: one Mind's desk under the navigator. */
export function Desk() {
const { mind = '' } = useParams();
const navigate = useNavigate();
const phone = useScreenSizeContext() === ScreenSize.Mobile;
const label = labelOf(mind);
const list = useMemo(() => deskFor(mind), [mind]);
// Until the recompute finishes nothing is flagged; a card that fails is withdrawn when it lands.
const [unverified, setUnverified] = useState<ReadonlySet<string>>(new Set());
useEffect(() => {
let live = true;
unverifiedForks(list?.forks ?? []).then((bad) => live && setUnverified(bad));
return () => {
live = false;
};
}, [list]);
return (
<Page>
<PageHeader balance>
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" basis="No">
{phone && (
<IconButton aria-label="Back to rooms" onClick={() => navigate(getHomePath())}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</Box>
<Text size="H3" truncate>{`${label}'s desk`}</Text>
<Box grow="Yes" basis="No" />
</Box>
</PageHeader>
<Box style={{ position: 'relative' }} grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<DeskView mind={mind} label={label} list={list} unverified={unverified} />
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1,16 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Text } from 'folds';
import { getDeskPath } from '../../pages/pathUtils';
import { deskFor } from './source';
/** The way into a Mind's desk, set in its navigator group header; nothing at all when nothing is open. */
export function DeskEntry({ mind }: { mind: string }) {
const open = deskFor(mind)?.open_count ?? 0;
if (open === 0) return null;
return (
<Text as={Link} to={getDeskPath(mind)} size="T200" priority="300">
{`Desk · ${open} open`}
</Text>
);
}

View file

@ -0,0 +1,99 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
/** Rows and booth side by side on wide screens. */
export const Layout = style({
display: 'flex',
gap: config.space.S500,
alignItems: 'flex-start',
'@media': { [PHONE]: { display: 'block', paddingBottom: toRem(120) } },
});
export const Rows = style({ flex: 1, minWidth: 0, margin: 0, padding: 0, listStyle: 'none' });
/** A hairline splits rows; it is the only chrome. */
export const Row = style({
padding: `${config.space.S300} 0`,
borderTop: `1px solid ${color.Background.ContainerLine}`,
});
export const Title = style({
margin: 0,
font: 'inherit',
fontWeight: 600,
});
/** The fold button reads as the title itself, not as a control. */
export const Fold = style({
all: 'unset',
cursor: 'pointer',
display: 'block',
width: '100%',
minHeight: 44,
boxSizing: 'border-box',
});
export const Quiet = style({ opacity: config.opacity.P300, 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` });
export const Note = style({
display: 'block',
width: '100%',
boxSizing: 'border-box',
minHeight: 44,
margin: `${config.space.S200} 0`,
font: 'inherit',
color: 'inherit',
background: 'none',
border: `1px solid ${color.Background.ContainerLine}`,
opacity: config.opacity.Disabled,
});
/** Sticky beside the rows on wide screens; a small fixed corner image on phones. */
export const Booth = style({
position: 'sticky',
top: config.space.S400,
width: toRem(168),
flexShrink: 0,
'@media': {
[PHONE]: {
position: 'fixed',
top: 'auto',
right: config.space.S200,
bottom: config.space.S200,
width: toRem(88),
},
},
});
// PoseAvatar's button is laid out for message gutters; the booth takes it back into flow.
globalStyle(`${Booth} > [data-pose]`, {
position: 'static',
float: 'none',
display: 'block',
width: '100%',
height: 'auto',
margin: 0,
maskImage: 'none',
WebkitMaskImage: 'none',
cursor: 'default',
});
export const BoothLine = style({
'@media': {
// Still read out by the live region; only the picture shows in the corner.
[PHONE]: {
position: 'absolute',
width: 1,
height: 1,
overflow: 'hidden',
clip: 'rect(0 0 0 0)',
whiteSpace: 'nowrap',
},
},
});

View file

@ -0,0 +1,150 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, describe, expect, it } from 'vitest';
import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize';
import { DeskEntry } from './DeskEntry';
import { DeskView, SIGN_IN_NOTE } from './DeskView';
import { STATES } from './fixtures';
import { setDeskSource } from './source';
const view = (
state: keyof typeof STATES | 'unpublished',
size: ScreenSize,
mind = 'void',
unverified = new Set<string>()
) =>
renderToStaticMarkup(
<ScreenSizeProvider value={size}>
<DeskView
mind={mind}
label="Void"
list={state === 'unpublished' ? undefined : STATES[state]}
unverified={unverified}
/>
</ScreenSizeProvider>
);
describe('DeskView', () => {
it('folds a row behind its title on a phone, with the fold button saying so', () => {
const html = view('noAnswers', ScreenSize.Mobile);
expect(html).toContain('aria-expanded="false"');
expect(html).toContain('Question plain');
expect(html).not.toContain('Context for plain');
expect(html).not.toContain('First way');
});
it('shows the row open, with no fold button, on a wide screen', () => {
const html = view('noAnswers', ScreenSize.Desktop);
expect(html).not.toContain('aria-expanded');
expect(html).toContain('Context for plain');
expect(html).toContain('First way');
});
it('states the recommendation in words, on that option only', () => {
const html = view('noAnswers', ScreenSize.Desktop);
expect(html.match(/\(recommended\)/g)).toHaveLength(1);
expect(html).toContain('B Second way (recommended)');
});
it('keeps the note disabled and says why in a sentence the field points at', () => {
const html = view('noAnswers', ScreenSize.Desktop);
expect(html).toMatch(/<textarea[^>]* disabled=""/);
expect(html).toContain(SIGN_IN_NOTE);
expect(SIGN_IN_NOTE).toMatch(/sign-in/);
expect(html).toMatch(/aria-describedby="note-[^"]+-why"/);
});
it('shows the depends line as the text it is', () => {
expect(view('blocked', ScreenSize.Desktop)).toContain(
'Waits on: &quot;Needs the first one settled&quot;'
);
});
it('says No answers yet on a row without answers', () => {
expect(view('noAnswers', ScreenSize.Desktop)).toContain('No answers yet.');
});
it('lists settled questions as recorded rulings with the ruling id', () => {
const html = view('settled', ScreenSize.Desktop);
expect(html).toContain('Recorded as rulings');
expect(html).toContain('Question done (ruling:done-1)');
expect(html).not.toContain('Question shut');
});
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(
'Answer First way &quot;Because it is first.&quot; (2026-11-09) (counted)'
);
expect(html).toContain('Revision of n1 Third way (2026-11-09)');
expect(html).toContain('Retraction of n2 (2026-11-09)');
expect(html).toContain('Refused (not_a_decider) Second way &quot;Let me in.&quot;');
expect(html).toContain('Answer &quot;Either, if measured first.&quot;');
expect(html.match(/\(counted\)/g)).toHaveLength(1);
expect(html).not.toContain('&quot;null&quot;');
});
it('says nothing is waiting when empty, and that nothing is published when it never arrived', () => {
expect(view('empty', ScreenSize.Desktop)).toContain('Nothing is waiting on you from Void.');
expect(view('unpublished', ScreenSize.Desktop)).toContain(
'Void&#x27;s questions have not been published yet.'
);
});
it('seats the Mind in the booth with the pose for the desk mood', () => {
const html = view('noAnswers', ScreenSize.Desktop);
expect(html).toContain('data-pose="thinking"');
expect(html).toContain('Just one fork on the desk.');
});
it('makes the booth portrait a plain image: no button, no tab stop', () => {
const html = view('noAnswers', ScreenSize.Desktop);
const booth = html.slice(html.indexOf('data-pose'));
expect(booth).toContain('<img');
expect(html).not.toMatch(/<button[^>]*data-pose/);
expect(html).not.toMatch(/<[a-z]+[^>]*data-pose[^>]*tabindex/);
expect(html).toMatch(/<span[^>]*data-pose/);
});
it('withdraws a card whose hash does not match and does not count it open', () => {
const html = view('noAnswers', ScreenSize.Desktop, 'void', new Set(['c:question:plain']));
expect(html).toContain('does not match the hash it was published with');
expect(html).not.toContain('First way');
expect(html).toContain('1 open.');
expect(html).not.toContain('Nothing is waiting');
});
it('never claims an empty desk while the list says forks are open, whatever rows it could show', () => {
setDeskSource(() => undefined);
const html = renderToStaticMarkup(
<ScreenSizeProvider value={ScreenSize.Desktop}>
<DeskView mind="void" label="Void" list={{ ...STATES.empty, open_count: 3 }} />
</ScreenSizeProvider>
);
expect(html).toContain('3 open.');
expect(html).not.toContain('Nothing is waiting');
});
it('shows no booth for a Mind without art rather than another face', () => {
expect(view('noAnswers', ScreenSize.Desktop, 'nobody')).not.toContain('data-pose');
});
it('shows no booth before anything is published', () => {
expect(view('unpublished', ScreenSize.Desktop)).not.toContain('data-pose');
});
});
describe('DeskEntry', () => {
afterEach(() => setDeskSource(() => undefined));
const entry = (mind: string) =>
renderToStaticMarkup(
<MemoryRouter>
<DeskEntry mind={mind} />
</MemoryRouter>
);
it('reads "Desk · N open" and links to that Mind\'s desk', () => {
setDeskSource(() => STATES.blocked);
const html = entry('void');
expect(html).toContain('Desk · 2 open');
expect(html).toContain('href="/desk/void"');
});
it('reads the count the list states, not a count of the forks it carries', () => {
setDeskSource(() => ({ ...STATES.empty, open_count: 5 }));
expect(entry('void')).toContain('Desk · 5 open');
});
it('is absent at zero open, when nothing is published, and when only settled forks exist', () => {
setDeskSource(() => STATES.empty);
expect(entry('void')).toBe('');
setDeskSource(() => undefined);
expect(entry('void')).toBe('');
setDeskSource(() => ({ ...STATES.settled, open_count: 0 }));
expect(entry('void')).toBe('');
});
});

View file

@ -0,0 +1,169 @@
import React, { useState } from 'react';
import { Text } from 'folds';
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 { ForkList } from './forkContract';
import { deskMood } from './deskMood';
import * as css from './DeskView.css';
/** Slice 1 has no session: every viewer is signed out, and the note says why it cannot be used. */
export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can read the questions.';
const date = (iso: string) => iso.slice(0, 10);
const OUTCOME: Record<AnswerRow['outcome'], string> = {
Counted: 'Answer',
Revision: 'Revision',
Retraction: 'Retraction',
Refused: 'Refused',
};
/** One answer as served: its outcome, what it chose, its note when the server sent one, and whether it counts. */
const answerLine = (a: AnswerRow) =>
[
OUTCOME[a.outcome] + (a.code ? ` (${a.code})` : ''),
a.target && `of ${a.target}`,
a.choice,
a.note !== undefined && `"${a.note}"`,
]
.filter(Boolean)
.join(' ');
function Answers({ answers }: { answers: AnswerRow[] }) {
if (answers.length === 0) return <p className={css.Quiet}>No answers yet.</p>;
return (
<ul className={css.Options}>
{answers.map((a) => (
<li key={a.answerId} className={css.Option}>
{`${answerLine(a)} (${date(a.at)})`}
{a.counted && ' (counted)'}
</li>
))}
</ul>
);
}
function Row({ row }: { row: DeskRow }) {
const phone = useScreenSizeContext() === ScreenSize.Mobile;
// Phones list titles; a tap opens one. Wide screens show every row open.
const [open, setOpen] = useState(!phone);
if (row.kind === 'unverified') {
return (
<li className={css.Row}>
<p className={css.Quiet}>
A question here does not match the hash it was published with, so it is not shown.
</p>
</li>
);
}
const noteId = `note-${row.forkId}`;
return (
<li className={css.Row}>
<h3 className={css.Title}>
{phone ? (
<button
type="button"
className={css.Fold}
aria-expanded={open}
onClick={() => setOpen(!open)}
>
{row.title}
</button>
) : (
row.title
)}
</h3>
{open && (
<>
<p className={css.Body}>{row.context}</p>
<ul className={css.Options}>
{row.options.map((o) => (
<li key={o.id} className={css.Option}>
{`${o.id} ${o.text}`}
{o.recommended && ' (recommended)'}
</li>
))}
</ul>
{row.depends.map((d) => (
<p key={d} className={css.Quiet}>
{`Waits on: "${d}"`}
</p>
))}
<span id={`${noteId}-label`}>Your note</span>
<textarea
className={css.Note}
disabled
aria-labelledby={`${noteId}-label`}
aria-describedby={`${noteId}-why`}
/>
<p id={`${noteId}-why`} className={css.Quiet}>
{SIGN_IN_NOTE}
</p>
<Answers answers={row.answers} />
</>
)}
</li>
);
}
function Status({ desk, label }: { desk: Desk; label: string }) {
if (!desk.published) return <p>{`${label}'s questions have not been published yet.`}</p>;
return (
<p role="status">
{desk.counts.open === 0
? `Nothing is waiting on you from ${label}. `
: `${desk.counts.open} open.`}
</p>
);
}
type DeskViewProps = {
/** The Mind's key, as the navigator groups it. */
mind: string;
label: string;
list?: ForkList;
/** Fork ids whose options_hash failed the recompute (optionsHash.ts). */
unverified?: ReadonlySet<string>;
};
/** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */
export function DeskView({ mind, label, list, unverified }: DeskViewProps) {
const desk = buildDesk(list, unverified);
// Slice 1 has no session, so nothing on the desk is the viewer's answer yet.
const mood = deskMood(desk.counts.open, 0);
return (
<section aria-label={`${label}'s desk`}>
<Status desk={desk} label={label} />
<div className={css.Layout}>
<ol className={css.Rows}>
{desk.open.map((row) => (
<Row key={row.forkId} row={row} />
))}
</ol>
{/* Only Minds with art in the catalogue get a booth; none gets another Mind's face. */}
{desk.published && MINDS.includes(mind) && (
<div className={css.Booth}>
<PoseAvatar mind={mind} pose={mood.pose} userId={mind} />
<Text className={css.BoothLine} size="T200" aria-live="polite">
{mood.line}
</Text>
</div>
)}
</div>
{desk.recorded.length > 0 && (
<>
<h3 className={css.Title}>Recorded as rulings</h3>
<ul className={css.Options}>
{desk.recorded.map((r) => (
<li key={r.forkId} className={css.Option}>
{`${r.title} (${r.rulingId})`}
</li>
))}
</ul>
</>
)}
</section>
);
}

View file

@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import { buildDesk } from './buildDesk';
import { STATES, entry, list } from './fixtures';
const questions = (d: ReturnType<typeof buildDesk>) =>
d.open.flatMap((r) => (r.kind === 'question' ? [r] : []));
describe('buildDesk', () => {
it('says "not published" for a list that never arrived, never "no questions"', () => {
expect(buildDesk(undefined)).toMatchObject({ published: false, open: [], counts: { open: 0 } });
});
it('is published and empty when the list holds nothing', () => {
expect(buildDesk(STATES.empty)).toMatchObject({
published: true,
open: [],
counts: { open: 0 },
});
});
it('shows no answers as an empty list on an open row', () => {
expect(questions(buildDesk(STATES.noAnswers))[0].answers).toEqual([]);
});
it('resolves the recommendation to its option, marks only that option, and never preselects', () => {
const [row] = questions(buildDesk(STATES.noAnswers));
expect(row.recommended).toBe('Second way');
expect(row.options.filter((o) => o.recommended).map((o) => o.id)).toEqual(['B']);
});
it('leaves the recommendation unresolved when it names no option', () => {
const [row] = questions(buildDesk(list([entry('x', { recommendation: 'Z' })])));
expect(row.recommended).toBeUndefined();
expect(row.options.some((o) => o.recommended)).toBe(false);
});
it('carries the depends lines as the text they are', () => {
const rows = questions(buildDesk(STATES.blocked));
expect(rows.find((r) => r.forkId.endsWith('second'))?.depends).toEqual([
'Needs the first one settled',
]);
expect(rows.find((r) => r.forkId.endsWith('first'))?.depends).toEqual([]);
});
it('lists a closed card with a ruling as recorded, not open; omits one closed with no ruling', () => {
const d = buildDesk(STATES.settled);
expect(d.recorded).toEqual([
{ forkId: 'c:question:done', title: 'Question done', rulingId: 'ruling:done-1' },
]);
expect(d.open.map((r) => r.forkId)).toEqual(['c:question:live']);
});
it('keeps the served order (newest first by card.order) and sorts nothing itself', () => {
const served = list([entry('late', { order: 9 }), entry('early', { order: 2 })]);
expect(buildDesk(served).open.map((r) => r.forkId)).toEqual([
'c:question:late',
'c:question:early',
]);
const odd = list([entry('a', { order: 2 }), entry('b', { order: 9 })]);
expect(buildDesk(odd).open.map((r) => r.forkId)).toEqual(['c:question:a', 'c:question:b']);
});
it("takes the open count from the list's open_count, not from the rows it could verify or carries", () => {
const d = buildDesk({ ...list([entry('only')]), open_count: 4 }, new Set(['c:question:only']));
expect(d.counts.open).toBe(4);
});
it('marks the answer the record names as counted, and no other', () => {
const [row] = questions(buildDesk(STATES.answered));
expect(row.answers.filter((a) => a.counted).map((a) => a.answerId)).toEqual(['n1']);
});
it('follows the record when a revision is the counted answer: nothing is re-derived', () => {
const e = STATES.answered.forks[0];
const moved = list([{ ...e, record: { ...e.record, counted_answer_id: 'n2' } }]);
expect(
questions(buildDesk(moved))[0]
.answers.filter((a) => a.counted)
.map((a) => a.answerId)
).toEqual(['n2']);
});
it('keeps answers in the served order and carries each outcome, target and refusal code', () => {
const [row] = questions(buildDesk(STATES.answered));
expect(row.answers.map((a) => [a.answerId, a.outcome, a.target, a.code])).toEqual([
['n1', 'Counted', undefined, undefined],
['n2', 'Revision', 'n1', undefined],
['n3', 'Retraction', 'n2', undefined],
['n4', 'Refused', undefined, 'not_a_decider'],
['n5', 'Counted', undefined, undefined],
]);
});
it('shows a choice as option text, passes an unknown id through, and leaves a null choice absent', () => {
const [row] = questions(buildDesk(STATES.answered));
expect(row.answers.map((a) => a.choice)).toEqual([
'First way',
'Third way',
undefined,
'Second way',
undefined,
]);
const odd = list([
entry('odd', {}, [{ ...STATES.answered.forks[0].record.answers[0], choice: 'Q' }]),
]);
expect(questions(buildDesk(odd))[0].answers[0].choice).toBe('Q');
});
it('carries a note verbatim when present and leaves it absent when null', () => {
const [row] = questions(buildDesk(STATES.answered));
expect(row.answers[0].note).toBe('Because it is first.');
expect(row.answers[1].note).toBeUndefined();
});
it('withdraws an unverified open card and does not count it', () => {
const d = buildDesk(STATES.noAnswers, new Set(['c:question:plain']));
expect(d.open).toEqual([{ kind: 'unverified', forkId: 'c:question:plain' }]);
});
});

View file

@ -0,0 +1,95 @@
import { DeskAnswer, ForkEntry, ForkList } from './forkContract';
export type AnswerRow = {
answerId: string;
outcome: DeskAnswer['outcome'];
/** The option's text for a choice. */
choice?: string;
/** Verbatim, and only when the server sent one. */
note?: string;
/** The answer a Revision or Retraction points at. */
target?: string;
/** A refusal's code. */
code?: string;
at: string;
/** The record's counted answer: marked as served, never derived here. */
counted: boolean;
};
export type DeskRow =
/** Its published options_hash is not the hash of what it shows: neither shown nor counted. */
| { kind: 'unverified'; forkId: string }
| {
kind: 'question';
forkId: string;
title: string;
context: string;
options: { id: string; text: string; recommended: boolean }[];
/** The recommendation resolved to its option's text; absent if it names no option. */
recommended?: string;
depends: string[];
/** In admission order, as served. */
answers: AnswerRow[];
};
export type Desk = {
/** False until the list first arrives: "not published yet", never "no questions". */
published: boolean;
/** Oldest first, by the card's admission ordinal. */
open: DeskRow[];
/** Closed with a ruling id. A card closed with no ruling (withdrawn) is not listed. */
recorded: { forkId: string; title: string; rulingId: string }[];
/** The list's own open_count: the one count the navigator, the status line and the booth share. */
counts: { open: number };
};
const answerRow = (a: DeskAnswer, entry: ForkEntry): AnswerRow => ({
answerId: a.answer_id,
outcome: a.outcome,
choice:
a.choice === null
? undefined
: entry.card.options.find((o) => o.id === a.choice)?.text ?? a.choice,
note: a.note ?? undefined,
target: a.of ?? a.retracts ?? undefined,
code: a.code ?? undefined,
at: a.answered_at,
counted: entry.record.counted_answer_id === a.answer_id,
});
export const buildDesk = (
list: ForkList | undefined,
unverified: ReadonlySet<string> = new Set()
): Desk => {
if (!list) return { published: false, open: [], recorded: [], counts: { open: 0 } };
const open: DeskRow[] = [];
const recorded: Desk['recorded'] = [];
list.forks.forEach((entry) => {
const { card, record } = entry;
if (card.status === 'closed') {
if (card.ruling !== null)
recorded.push({ forkId: card.fork_id, title: card.title, rulingId: card.ruling });
return;
}
if (unverified.has(card.fork_id)) {
open.push({ kind: 'unverified', forkId: card.fork_id });
return;
}
open.push({
kind: 'question',
forkId: card.fork_id,
title: card.title,
context: card.context,
options: card.options.map((o) => ({ ...o, recommended: o.id === card.recommendation })),
recommended: card.options.find((o) => o.id === card.recommendation)?.text,
depends: card.depends,
answers: record.answers.map((a) => answerRow(a, entry)),
});
});
return {
published: true,
open,
recorded,
counts: { open: list.open_count },
};
};

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { MINDS, resolvePose } from '../../components/message/pose/poses';
import { MOOD_POSE, deskMood } from './deskMood';
describe('deskMood', () => {
it('is neutral with the desk clear when nothing is open', () => {
expect(deskMood(0, 0)).toEqual({
mood: 'neutral',
pose: 'neutral',
line: "Desk's clear. I'll wave when something lands.",
});
});
it('is triumph when every open fork is answered', () => {
expect(deskMood(3, 3).mood).toBe('triumph');
expect(deskMood(1, 1).mood).toBe('triumph');
});
it('is thinking when none is answered, and says whether it is one fork or several', () => {
expect(deskMood(1, 0)).toMatchObject({ mood: 'thinking', line: 'Just one fork on the desk.' });
expect(deskMood(4, 0)).toMatchObject({
mood: 'thinking',
line: '4 forks on the desk. No rush.',
});
});
it('is determined partway, counting what is left', () => {
expect(deskMood(5, 2)).toMatchObject({ mood: 'determined', line: '3 to go.' });
});
it('is happy when the pick is the recommendation', () => {
expect(deskMood(2, 0, { type: 'picked', choice: 'B', recommended: 'B' }).mood).toBe('happy');
});
it('is surprised, and asks for a line on why, when the pick differs', () => {
expect(deskMood(2, 0, { type: 'picked', choice: 'A', recommended: 'B' })).toMatchObject({
mood: 'surprised',
line: expect.stringContaining('A line in the note on why helps'),
});
});
it('is surprised, not happy, when there is no recommendation to match', () => {
expect(deskMood(2, 0, { type: 'picked', choice: 'A' }).mood).toBe('surprised');
});
it('is note when a pick is cleared, saying the note can carry the answer', () => {
expect(deskMood(2, 1, { type: 'cleared' })).toMatchObject({
mood: 'note',
line: expect.stringContaining('note can carry the answer on its own'),
});
});
it('is concerned when a send failed, even with everything answered', () => {
expect(deskMood(2, 2, { type: 'sendFailed' }).mood).toBe('concerned');
});
it('is surprised when a new fork arrives, even on a clear desk', () => {
expect(deskMood(0, 0, { type: 'newFork' })).toMatchObject({
mood: 'surprised',
line: 'New fork on the desk!',
});
});
it('maps every mood to a label every Mind in the catalogue can show', () => {
Object.values(MOOD_POSE).forEach((pose) =>
MINDS.forEach((mind) => expect(resolvePose(mind, pose)).toBe(pose))
);
});
});

View file

@ -0,0 +1,75 @@
/**
* The booth Persona's reactions, from the original Fork Desk's mood() and choose() rules. A pure function of
* what is on the desk and the last thing that happened; it is the desk's character, so each rule is tested.
*/
/** The original's nine moods. */
export type Mood =
| 'idle'
| 'neutral'
| 'thinking'
| 'happy'
| 'surprised'
| 'note'
| 'determined'
| 'triumph'
| 'concerned';
/** What last happened; `none` means nothing since the page opened or the last event wore off. */
export type DeskEvent =
| { type: 'none' }
| { type: 'picked'; choice: string; recommended?: string }
| { type: 'cleared' }
| { type: 'sendFailed' }
| { type: 'newFork' };
/**
* Each mood on the shared cast-art labels (neutral, happy, amused, thinking, annoyed, surprised) that every
* Mind has, so the booth never asks a Mind for art it lacks.
*/
export const MOOD_POSE: Record<Mood, string> = {
idle: 'neutral',
neutral: 'neutral',
thinking: 'thinking',
happy: 'happy',
surprised: 'surprised',
note: 'thinking',
determined: 'thinking',
triumph: 'happy',
concerned: 'surprised',
};
export type DeskMood = { mood: Mood; pose: string; line: string };
const say = (mood: Mood, line: string): DeskMood => ({ mood, pose: MOOD_POSE[mood], line });
export const deskMood = (
open: number,
answered: number,
event: DeskEvent = { type: 'none' }
): DeskMood => {
switch (event.type) {
case 'sendFailed':
return say('concerned', "That one didn't save. Give it another try in a moment?");
case 'newFork':
return say('surprised', 'New fork on the desk!');
case 'cleared':
return say('note', 'Pick cleared. Your note can carry the answer on its own.');
case 'picked':
return event.recommended !== undefined && event.choice === event.recommended
? say('happy', "That's the one I'd have picked too.")
: say('surprised', 'Off-script. A line in the note on why helps the next brief.');
default:
break;
}
if (open === 0) return say('neutral', "Desk's clear. I'll wave when something lands.");
if (answered >= open)
return say('triumph', 'All answered! I will carry these back and admit them.');
if (answered === 0) {
return say(
'thinking',
open === 1 ? 'Just one fork on the desk.' : `${open} forks on the desk. No rush.`
);
}
return say('determined', `${open - answered} to go.`);
};

View file

@ -0,0 +1,116 @@
import { DeskAnswer, ForkCard, ForkEntry, ForkList } from './forkContract';
/** A card as the server writes it: every key present. The hash is a stand-in; tests pass `unverified` directly. */
export const card = (label: string, over: Partial<ForkCard> = {}): ForkCard => ({
schemaVersion: 'gamecult.fork.card.v0',
fork_id: `c:question:${label}`,
title: `Question ${label}`,
context: `Context for ${label}.`,
options: [
{ id: 'A', text: 'First way' },
{ id: 'B', text: 'Second way' },
{ id: 'C', text: 'Third way' },
],
recommendation: 'B',
depends: [],
leans: null,
flag: null,
raised_in: null,
deciders: ['d1'],
status: 'open',
ruling: null,
ruled_from: [],
order: 1,
options_hash: `hash-${label}`,
...over,
});
export const answer = (label: string, n: number, over: Partial<DeskAnswer> = {}): DeskAnswer => ({
schemaVersion: 'gamecult.desk_answer.v0',
answer_id: `n${n}`,
fork_id: `c:question:${label}`,
outcome: 'Counted',
of: null,
retracts: null,
code: null,
options_hash: `hash-${label}`,
choice: 'A',
note: null,
answered_at: '2026-11-09T10:00:00Z',
received_at: '2026-11-09T10:00:01Z',
claim_in_future: false,
answerer: 'acct-1',
authority: 'Member',
request_id: `r${n}`,
...over,
});
export const entry = (
label: string,
over: Partial<ForkCard> = {},
answers: DeskAnswer[] = [],
counted: string | null = null
): ForkEntry => {
const c = card(label, over);
return {
schemaVersion: 'gamecult.fork.entry.v0',
card: c,
record: {
schemaVersion: 'gamecult.fork.record.v0',
fork_id: c.fork_id,
options_hash: c.options_hash,
counted_answer_id: counted,
answers,
},
};
};
export const list = (forks: ForkEntry[], openCount?: number): ForkList => ({
schemaVersion: 'gamecult.fork.list.v0',
mind: 'mind',
stamp: 's12',
open_count: openCount ?? forks.filter((f) => f.card.status === 'open').length,
recorded_count: forks.filter((f) => f.card.status === 'closed').length,
forks,
next_cursor: null,
});
/** One list per state the desk shows. */
export const STATES = {
empty: list([]),
noAnswers: list([entry('plain')]),
blocked: list([
entry('first', { order: 1 }),
entry('second', { order: 2, depends: ['Needs the first one settled'] }),
]),
settled: list([
entry('done', { status: 'closed', ruling: 'ruling:done-1', ruled_from: ['n1'] }),
entry('shut', { status: 'closed' }),
entry('live'),
]),
answered: list([
entry(
'plain',
{},
[
answer('plain', 1, { note: 'Because it is first.' }),
answer('plain', 2, {
outcome: 'Revision',
of: 'n1',
choice: 'C',
note: null,
answerer: 'acct-2',
}),
answer('plain', 3, { outcome: 'Retraction', retracts: 'n2', choice: null }),
answer('plain', 4, {
outcome: 'Refused',
code: 'not_a_decider',
choice: 'B',
note: 'Let me in.',
}),
answer('plain', 5, { choice: null, note: 'Either, if measured first.' }),
],
'n1'
),
]),
} satisfies Record<string, ForkList>;

View file

@ -0,0 +1,92 @@
/**
* The desk's typed view of the documents Bifrost serves for a Mind's desk: CultLib's
* contracts/cultnet/governance/fork-card.md (gamecult.fork.list.v0 / entry / card / record, and the stored
* gamecult.desk_answer.v0). Read-only: the page decodes them as served and derives nothing from them (the counted
* answer is the record's `counted_answer_id`, not the page's opinion), so nothing here has a write shape.
* Every key is written by the server, nil where absent.
*/
export type ForkOption = { id: string; text: string };
export type ForkCard = {
schemaVersion: 'gamecult.fork.card.v0';
/** `<campaign>:question:<label>`. */
fork_id: string;
title: string;
context: string;
options: ForkOption[];
/** An option id; never nil. Never preselected. */
recommendation: string;
/** Prose lines, each verbatim; a line may read like a reference but is only text. */
depends: string[];
/** An option id, or nil. Not shown by the desk. */
leans: string | null;
/** Not shown by the desk. */
flag: string | null;
raised_in: { kind: string; id: string } | null;
deciders: string[];
status: 'open' | 'closed';
/** The ruling id when a ruling closed the fork; nil when open, and nil on a closed card withdrawn with no ruling. */
ruling: string | null;
ruled_from: string[];
/** The admission ordinal. */
order: number;
/**
* Publisher's hash of the five shown members. The desk recomputes it (optionsHash.ts, pinned to the CultLib
* vectors) and withdraws a card whose hash differs.
*/
options_hash: string;
};
/** One stored answer, told apart by `outcome` (and `code` for a refusal). */
export type DeskAnswer = {
schemaVersion: 'gamecult.desk_answer.v0';
/** `n<seq>`. */
answer_id: string;
fork_id: string;
outcome: 'Counted' | 'Revision' | 'Retraction' | 'Refused';
/** A Revision's target. */
of: string | null;
/** A Retraction's target (or a refused retraction's). */
retracts: string | null;
/** A refusal's code. */
code: string | null;
options_hash: string;
choice: string | null;
/** The decider's own words, verbatim; nil for everyone Bifrost does not show notes to. */
note: string | null;
answered_at: string;
received_at: string;
claim_in_future: boolean;
answerer: string;
authority: string;
request_id: string;
};
export type ForkRecord = {
schemaVersion: 'gamecult.fork.record.v0';
fork_id: string;
options_hash: string;
/** Derived by the receiver; the page renders it and never recomputes it. */
counted_answer_id: string | null;
/** In admission order. */
answers: DeskAnswer[];
};
export type ForkEntry = {
schemaVersion: 'gamecult.fork.entry.v0';
card: ForkCard;
record: ForkRecord;
};
/** What the page holds for one Mind's desk. */
export type ForkList = {
schemaVersion: 'gamecult.fork.list.v0';
mind: string;
/** `s<N>`: the same stamp is the same state. */
stamp: string;
open_count: number;
recorded_count: number;
forks: ForkEntry[];
next_cursor: string | null;
};

View file

@ -0,0 +1,3 @@
export { Desk } from './Desk';
export { DeskEntry } from './DeskEntry';
export { setDeskSource } from './source';

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import vectors from './optionsHashVectors.json';
import { entry } from './fixtures';
import { ForkCard } from './forkContract';
import { canonicalShown, optionsHash, shownOf, unverifiedForks } from './optionsHash';
describe('options_hash against the CultLib vectors', () => {
it('has all fifteen vectors', () => {
expect(vectors.optionsHash).toHaveLength(15);
});
vectors.optionsHash.forEach((v) => {
it(`${v.name}: canonical bytes and sha256 match`, async () => {
expect(canonicalShown(v)).toBe(v.canonical);
expect(await optionsHash(v)).toBe(v.sha256);
});
});
});
describe('unverifiedForks', () => {
const sealed = async (over: Partial<ForkCard> = {}) => {
const e = entry('plain', over);
return { ...e, card: { ...e.card, options_hash: await optionsHash(shownOf(e.card)) } };
};
it('passes a card whose hash is the hash of what it shows', async () => {
expect(await unverifiedForks([await sealed()])).toEqual(new Set());
});
it('flags a card whose shown title, context, option, recommendation or depends changed after hashing', async () => {
const e = await sealed();
const edits: Partial<ForkCard>[] = [
{ title: 'Other' },
{ context: 'Other context.' },
{ options: [...e.card.options].reverse() },
{ recommendation: 'A' },
{ depends: ['new line'] },
];
await Promise.all(
edits.map(async (x) =>
expect(
await unverifiedForks([{ ...e, card: { ...e.card, ...x } }]),
JSON.stringify(Object.keys(x))
).toEqual(new Set([e.card.fork_id]))
)
);
});
it('does not read fork_id, status, order, leans or flag, which are not shown members', async () => {
const e = await sealed();
const card = { ...e.card, fork_id: 'x:question:y', order: 7, leans: 'A', flag: 'f' };
expect(await unverifiedForks([{ ...e, card }])).toEqual(new Set());
});
});

View file

@ -0,0 +1,51 @@
import { ForkCard, ForkEntry } from './forkContract';
// The hash and its canonical bytes are specified in CultLib contracts/cultnet/governance/fork-card.md
// ("Options hash", rule owner: Heimdall's binding file 1.2); the vectors in optionsHashVectors.json pin this code to it.
const jstr = (s: string): string => {
let out = '"';
// eslint-disable-next-line no-restricted-syntax
for (const ch of s.normalize('NFC')) {
const o = ch.codePointAt(0) as number;
const named: Record<number, string> = { 8: '\\b', 9: '\\t', 10: '\\n', 12: '\\f', 13: '\\r' };
if (ch === '"') out += '\\"';
else if (ch === '\\') out += '\\\\';
else if (named[o]) out += named[o];
else out += o < 0x20 ? `\\u${o.toString(16).padStart(4, '0')}` : ch;
}
return `${out}"`;
};
export type Shown = Pick<ForkCard, 'title' | 'context' | 'options' | 'recommendation' | 'depends'>;
/** The five shown members as RFC 8785 JSON, keys already in sorted order. */
export const canonicalShown = (c: Shown): string =>
`{"context":${jstr(c.context)},"depends":[${c.depends.map(jstr).join(',')}],"options":[${c.options
.map((o) => `{"id":${jstr(o.id)},"text":${jstr(o.text)}}`)
.join(',')}],"recommendation":${jstr(c.recommendation)},"title":${jstr(c.title)}}`;
export const shownOf = (c: ForkCard): Shown => ({
title: c.title,
context: c.context,
options: c.options,
recommendation: c.recommendation,
depends: c.depends,
});
export const optionsHash = async (c: Shown): Promise<string> => {
const bytes = new TextEncoder().encode(canonicalShown(c));
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
};
/** Fork ids whose published `options_hash` is not the hash of what the card shows. */
export const unverifiedForks = async (forks: ForkEntry[]): Promise<Set<string>> => {
const bad = new Set<string>();
await Promise.all(
forks.map(async ({ card }) => {
if ((await optionsHash(shownOf(card))) !== card.options_hash) bad.add(card.fork_id);
})
);
return bad;
};

View file

@ -0,0 +1,320 @@
{
"source": "GameCult/CultLib contracts/cultnet/governance/fork-card-vectors.json, `optionsHash` member only, values unchanged (re-escaped to ASCII so TypeScript parses it), checked equal at CultLib 0c19eddb. Rule: contracts/cultnet/governance/fork-card.md, 'Options hash'.",
"optionsHash": [
{
"name": "heimdall_A_five_members",
"heimdall": "A",
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": ["gamecult-chat:question:file-zone-audience"],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48"
},
{
"name": "heimdall_B_recommendation_changed",
"heimdall": "B",
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "yes",
"depends": ["gamecult-chat:question:file-zone-audience"],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"yes\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A22796573222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc"
},
{
"name": "heimdall_C_option_order_changed",
"heimdall": "C",
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "no",
"text": "Keep it local."
},
{
"id": "yes",
"text": "Share it."
}
],
"recommendation": "no",
"depends": ["gamecult-chat:question:file-zone-audience"],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"no\",\"text\":\"Keep it local.\"},{\"id\":\"yes\",\"text\":\"Share it.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D2C7B226964223A22796573222C2274657874223A2253686172652069742E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "ac67b41539f869f6203ac0908819df046e4328b27795743a919bd4ab390596b5"
},
{
"name": "heimdall_D_nfc_escapes_astral",
"heimdall": "D",
"title": "Caf\u00e9 \u201cdesk\u201d",
"context": "Line one\nLine two \u2014 \ud83d\ude00",
"options": [
{
"id": "a",
"text": "x"
},
{
"id": "b",
"text": "y"
}
],
"recommendation": "a",
"depends": [],
"canonical": "{\"context\":\"Line one\\nLine two \u2014 \ud83d\ude00\",\"depends\":[],\"options\":[{\"id\":\"a\",\"text\":\"x\"},{\"id\":\"b\",\"text\":\"y\"}],\"recommendation\":\"a\",\"title\":\"Caf\u00e9 \u201cdesk\u201d\"}",
"canonicalHex": "7B22636F6E74657874223A224C696E65206F6E655C6E4C696E652074776F20E2809420F09F9880222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A2261222C2274657874223A2278227D2C7B226964223A2262222C2274657874223A2279227D5D2C227265636F6D6D656E646174696F6E223A2261222C227469746C65223A22436166C3A920E2809C6465736BE2809D227D",
"sha256": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272"
},
{
"name": "heimdall_E_decomposed_equals_D",
"heimdall": "E",
"title": "Cafe\u0301 \u201cdesk\u201d",
"context": "Line one\nLine two \u2014 \ud83d\ude00",
"options": [
{
"id": "a",
"text": "x"
},
{
"id": "b",
"text": "y"
}
],
"recommendation": "a",
"depends": [],
"canonical": "{\"context\":\"Line one\\nLine two \u2014 \ud83d\ude00\",\"depends\":[],\"options\":[{\"id\":\"a\",\"text\":\"x\"},{\"id\":\"b\",\"text\":\"y\"}],\"recommendation\":\"a\",\"title\":\"Caf\u00e9 \u201cdesk\u201d\"}",
"canonicalHex": "7B22636F6E74657874223A224C696E65206F6E655C6E4C696E652074776F20E2809420F09F9880222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A2261222C2274657874223A2278227D2C7B226964223A2262222C2274657874223A2279227D5D2C227265636F6D6D656E646174696F6E223A2261222C227469746C65223A22436166C3A920E2809C6465736BE2809D227D",
"sha256": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272"
},
{
"name": "heimdall_F_option_id_changed",
"heimdall": "F",
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "share",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": ["gamecult-chat:question:file-zone-audience"],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"share\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A227368617265222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "fb53aa3a9f7e74029f755d774064eedd240ab90d8fc64723f97c31b3d2846d40"
},
{
"name": "heimdall_G_depends_emptied",
"heimdall": "G",
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": [],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "f856edce121db4121b5f1af400b1c471ebbfa6579a042aca9a34ae31c15f5c86"
},
{
"name": "option_text_changed",
"heimdall": null,
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it!"
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": ["gamecult-chat:question:file-zone-audience"],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it!\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A22536861726520697421227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "9ce0d2770497fdd2d5338e66f9f1ae1741d87301db4c793407c25762929392b6"
},
{
"name": "title_changed",
"heimdall": null,
"title": "Slot mark consent?",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": ["gamecult-chat:question:file-zone-audience"],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent?\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E743F227D",
"sha256": "30fe0c5bc88bd21d64592a495316c30e80fcee6cacf5c63fd9a818ca5cf87d0f"
},
{
"name": "context_changed",
"heimdall": null,
"title": "Slot mark consent",
"context": "Should slot marks be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": ["gamecult-chat:question:file-zone-audience"],
"canonical": "{\"context\":\"Should slot marks be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C6420736C6F74206D61726B73206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "716de65096e34fd89cc92848b667d90f5c27eeb66929da3d340cabb80155e173"
},
{
"name": "depends_order_changed",
"heimdall": null,
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": ["gamecult-chat:question:file-zone-audience", "x:question:y"],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\",\"x:question:y\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365222C22783A7175657374696F6E3A79225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "fc3f651c6f498385256fc1232d5db31ece9dcc542e391f97245d27bc9e385b3d"
},
{
"name": "depends_order_changed_reversed",
"heimdall": null,
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": ["x:question:y", "gamecult-chat:question:file-zone-audience"],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"x:question:y\",\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B22783A7175657374696F6E3A79222C2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "b22642e30f8d68839e3a0df5bc23b83ff50a839fcecf0366a65fa6e3cf873796"
},
{
"name": "hidden_members_do_not_move_it",
"heimdall": null,
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": ["gamecult-chat:question:file-zone-audience"],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48"
},
{
"name": "control_characters_and_markup_are_escaped_minimally",
"heimdall": null,
"title": "q \" b \\ t \t n \n bell \u0007 del \u007f",
"context": "</script> \u2028 &",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": ["gamecult-chat:question:file-zone-audience"],
"canonical": "{\"context\":\"</script> \u2028 &\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"q \\\" b \\\\ t \\t n \\n bell \\u0007 del \u007f\"}",
"canonicalHex": "7B22636F6E74657874223A223C2F7363726970743E20E280A82026222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A2271205C222062205C5C2074205C74206E205C6E2062656C6C205C75303030372064656C207F227D",
"sha256": "f2d5a09513ce728129f6ca13f7123aa75b2bb77541b1de8946f39239634b40fe"
},
{
"name": "astral_option_id_and_text",
"heimdall": null,
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "\ud83d\ude00",
"text": "\ud834\udd1e"
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": ["gamecult-chat:question:file-zone-audience"],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"\ud83d\ude00\",\"text\":\"\ud834\udd1e\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22F09F9880222C2274657874223A22F09D849E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "836fbfce16127132bae100aa773d328a85ad08d241ae9bf47ea7f84766762cac"
}
]
}

View file

@ -0,0 +1,13 @@
import { ForkList } from './forkContract';
type DeskSource = (mind: string) => ForkList | undefined;
// Slice 1 reads no network. The preview demo installs seeded forks here; slice 2 replaces this with
// Bifrost's list reader, which is why the page asks a function and not a fixture.
let source: DeskSource = () => undefined;
export const setDeskSource = (next: DeskSource) => {
source = next;
};
export const deskFor = (mind: string): ForkList | undefined => source(mind);

View file

@ -42,6 +42,7 @@ import { EstateGroup, EstateRoom, EstateTier, splitGroups } from './buildEstate'
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
import { useEstate, useEstateRootId } from './useEstate';
import * as css from './EstateNavigator.css';
import { DeskEntry } from '../desk';
import { SkyStateControl } from '../scene/SkyStateControl';
/** How long a tap waits for the client store to hold the room it just joined. */
@ -135,9 +136,11 @@ type CollapsibleProps = {
categoryId: string;
label: string;
rooms: EstateRoom[];
/** Sits in the header beside the label, so a collapsed section still shows it. */
trailing?: ReactNode;
children: ReactNode;
};
function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
function Collapsible({ categoryId, label, rooms, trailing, children }: CollapsibleProps) {
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
const selectedRoomId = useSelectedRoom();
const roomToUnread = useAtomValue(roomToUnreadAtom);
@ -161,6 +164,7 @@ function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
<RoomNavCategoryButton closed={closed} onClick={handleClick}>
{label}
</RoomNavCategoryButton>
{trailing}
</NavCategoryHeader>
{!closed && children}
</NavCategory>
@ -169,7 +173,12 @@ function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) {
return (
<Collapsible categoryId={`${tierId}|${group.mind}`} label={group.label} rooms={group.rooms}>
<Collapsible
categoryId={`${tierId}|${group.mind}`}
label={group.label}
rooms={group.rooms}
trailing={<DeskEntry mind={group.mind} />}
>
{group.rooms.map((r) => (
<EstateRow key={r.roomId} room={r} />
))}

View file

@ -0,0 +1,32 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { ESLint } from 'eslint';
// The product rule is one join path: a join is joinEstateRoom, which files the DM before it sends the join.
// This pins that the lint config enforces it, not just that the code happens to comply today.
const lint = async (filePath: string, code: string) => {
const eslint = new ESLint({ cwd: process.cwd(), useEslintrc: true });
const [result] = await eslint.lintText(code, { filePath });
return result.messages.filter((m) => m.ruleId === 'no-restricted-syntax');
};
const JOIN = 'export const f = (mx: { joinRoom: (id: string) => void }) => mx.joinRoom("!a:b");\n';
describe('the one join path is enforced by lint', () => {
it('rejects a joinRoom call anywhere but joinEstateRoom.ts', async () => {
const found = await lint('src/app/features/estate/EstateNavigator.tsx', JOIN);
expect(found).toHaveLength(1);
expect(found[0].message).toContain('joinEstateRoom');
});
it('allows the call inside joinEstateRoom.ts itself', async () => {
expect(await lint('src/app/features/estate/joinEstateRoom.ts', JOIN)).toHaveLength(0);
});
it('still applies the Airbnb syntax bans in the same files', async () => {
const found = await lint(
'src/app/foo.ts',
'export const g = (o: object) => { for (const k in o) { return k; } return 0; };\n'
);
expect(found.some((m) => m.message.includes('for..in'))).toBe(true);
});
});

View file

@ -3,6 +3,8 @@ import { Room } from 'matrix-js-sdk';
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 {
ROOM_NOTICE_EVENT_TYPE,
parseRoomNotice,
@ -14,12 +16,16 @@ import {
export function RoomTrainingLine({ room }: { room: Room }) {
const event = useStateEvent(room, ROOM_NOTICE_EVENT_TYPE);
const { previewDemo } = useClientConfig();
const skyInk = useSkyInk();
const line = roomTrainingLine(parseRoomNotice(event?.getContent()), previewDemo);
if (!line) return null;
return (
<Text
size="T200"
style={{ padding: `${config.space.S100} 0`, color: color.SurfaceVariant.OnContainer }}
style={{
padding: `${config.space.S100} 0`,
color: (skyInk && noticeInk(skyInk)) ?? color.SurfaceVariant.OnContainer,
}}
>
{line}
</Text>

View file

@ -1,4 +1,4 @@
import React, { useCallback, useRef } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { Box, Text, config } from 'folds';
import { EventType } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
@ -23,6 +23,10 @@ import { useSetting } from '../../state/hooks/settings';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoom } from '../../hooks/useRoom';
import { AlbumView } from '../albums/AlbumView';
import * as albumCss from '../albums/AlbumView.css';
import { useIsFileZone } from '../albums/useAlbums';
import { ZoneView, zoneView } from '../albums/zoneView';
const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
@ -64,6 +68,12 @@ export function RoomView({ eventId }: { eventId?: string }) {
const room = useRoom();
const { roomId } = room;
const editor = useEditor();
const isZone = useIsFileZone(room);
// A zone shows its albums; the timeline stays one tap away, and an event deep link opens it.
const [choice, setChoice] = useState<{ roomId: string; view: ZoneView }>();
const fileZone =
isZone &&
zoneView(choice && choice.roomId === roomId ? choice.view : undefined, eventId) === 'albums';
const mx = useMatrixClient();
@ -78,7 +88,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
window,
useCallback(
(evt) => {
if (editableActiveElement()) return;
if (fileZone || editableActiveElement()) return;
const portalContainer = document.getElementById('portalContainer');
if (portalContainer && portalContainer.children.length > 0) {
return;
@ -87,13 +97,35 @@ export function RoomView({ eventId }: { eventId?: string }) {
ReactEditor.focus(editor);
}
},
[editor]
[editor, fileZone]
)
);
// The file zone is its own view: no timeline, no composer.
if (fileZone) {
return (
<Page ref={roomViewRef}>
<AlbumView
key={roomId}
room={room}
onTimeline={() => setChoice({ roomId, view: 'timeline' })}
/>
</Page>
);
}
return (
<Page ref={roomViewRef}>
<Box grow="Yes" direction="Column">
{isZone && !eventId && (
<button
type="button"
className={albumCss.Back}
onClick={() => setChoice({ roomId, view: 'albums' })}
>
<Text size="B400">‹ Albums</Text>
</button>
)}
<RoomTimeline
key={roomId}
room={room}

View file

@ -1,7 +1,18 @@
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.
export const HeaderTopic = style({
'@media': {
[`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: {
paddingTop: 9,
paddingBottom: 17,
marginTop: -9,
marginBottom: -17,
},
},
':hover': {
cursor: 'pointer',
opacity: config.opacity.P500,

View file

@ -1168,7 +1168,6 @@ export const Message = as<'div', MessageProps>(
<BubbleLayout
own={own}
pose={mind ? poseJSX : undefined}
alt={poseAlt}
enterDelay={enterDelay}
before={avatarJSX}
header={headerJSX}

View file

@ -4,13 +4,22 @@
config says `backgroundScene.style: 'raymarch'`; the tier flag still defaults to `off`, and null still
means "keep the CSS ground". Everything below this section describes the opt-in ray-march scene.
- `ridge/Ridge.ts` `createRidgeScene({ canvas, tier, probe?, onProbe?, sky? })` returns `{ login, dispose }` or
null (tier `off`, no 2D context). Four ridge layers are generated once on the CPU from CultMath's
value-noise fbm (`ridge/noise.ts`, a clean-room port, periodic so the drift loops), each painted once to a plain
2D canvas of integer size (two periods wide; DPR 2 or more paints at 2x, otherwise 1x; no resampling between
layers) over a baked sky gradient on the host canvas. Per frame the only motion is the browser sliding each layer with a
CSS transform (a Web Animation of `translate3d`, 150-420 s per period, farther is slower); no script runs per frame.
The host canvas and the layers' parent must be positioned and the canvas should fill the parent.
- `ridge/Ridge.ts` `createRidgeScene({ canvas, tier, probe?, onProbe?, onBake?, sky? })` returns `{ login, dispose }` or
null (tier `off`, no 2D context). Each of four ridge layers is a function of world-x (`ridge/ridgeWorld.ts` `profile`:
ridged multifractal value noise, each octave folded at its crest and weighted by the previous one, domain-warped; one
function, a parameter row per layer; nothing repeats). World-x is baked in chunks (1.2 viewport widths, integer pixels)
onto plain 2D canvases over a baked sky gradient on the host canvas. Per layer a track holds three chunk canvases: two
the window can reach and one ahead. The first view bakes only the first chunk of each layer; the others are baked in
tasks of about 6 ms (`setTimeout`; a chunk is baked, put on a canvas with its in-scatter, and blurred in column bands, each sized from the last band's time). Per frame the only motion is the
compositor sliding the track with a CSS transform (a Web Animation of `translate3d`, one chunk long, `fill: forwards`,
150-420 s per viewport width, farther is slower); when it finishes the chunks move one slot left and the track restarts
at 0 in the same task (no jump), and the canvas that left is baked with the next stretch of world. A second animation
sways the whole rig (up to 5% of the width sideways, 3% of the height down, 150 s loop): the slow yaw and pitch.
No script runs per frame. The host canvas and the layers' parent must be positioned and the canvas should fill the parent.
- `ridge/ridgeBake.ts` is the pixel function (pure, no DOM): per column the crest's slope gives a normal, lit against the
sun (`sunDirection`: x = 0.9 sin(2 pi \* `sunAzimuth`), so the sun is toward +x for azimuth 0 to 0.5, strongest at 0.25, toward -x for 0.5 to 1; with no `sunAzimuth` the face lighting falls back to 0.3 and the in-scatter is off); the face under it has a light-to-haze gradient, erosion
streaks (a folded noise at gully width, darker with steepness) and a lit rim on the crest. Contrast scales with
`transmittance`; the base colour, haze and blur are the aerial perspective below, not redone.
- Paused when the tab is hidden; under `prefers-reduced-motion` the layers are painted but never played.
`tier: 'still'` flattens sky and layers into the host canvas at once and leaves no extra elements.
- Cadence guard: for the first 2 s after start (after 5 warm-up frames) rAF intervals are sampled; if their p95 is over
@ -23,8 +32,9 @@ means "keep the CSS ground". Everything below this section describes the opt-in
in-scatter). Layer distance d = 1 - depth; transmittance T = exp(-1.6 d) (0.24 / 0.37 / 0.58 / 1.00 far to near); colour is
`T * ground + (1 - T) * haze`, then chroma drops by `0.5 (1 - T)`; the edge is blurred up to 2 px (canvas pixels at DPR 1,
via the 2D `filter`, skipped where unsupported, e.g. Safari); a faint warm in-scatter (at most 0.16 alpha) peaks at the
sun's azimuth. Nothing runs per frame. The in-scatter lobe is painted into each layer, so it drifts with the layer
rather than staying fixed under the sun; that is a known approximation until the sun is a real, fixed light.
sun's azimuth in world-x (two incommensurate cosines, so not periodic). Nothing runs per frame. The in-scatter lobe is
painted into each chunk, so it drifts with the layer rather than staying fixed under the sun; that is a known
approximation until the sun is a real, fixed light.
- `harness/scene/bundle.cjs <out> ridge` builds a standalone page (`?tier=`, `?probe=1`).
- Not yet measured on a phone: see the PR description.

View file

@ -1,10 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRidgeScene } from './Ridge';
import { fbmPeriodic, valueNoisePeriodic, hash2 } from './noise';
import { bakeSlice, BakeParams, layerPaint } from './ridgeBake';
import { valueNoise, hash2 } from './noise';
import { FrameSampler, frameStats, SLOW_P95_MS, WARMUP_FRAMES, WINDOW_MS } from './probe';
import {
AERIAL,
FBM,
LAYERS,
edgeBlur,
hazeColour,
@ -23,58 +23,37 @@ describe('noise', () => {
it('is deterministic and in [0, 1)', () => {
expect(hash2(3, 5)).toBe(hash2(3, 5));
for (let i = 0; i < 200; i += 1) {
const v = valueNoisePeriodic(i * 0.37, 2, 7);
const v = valueNoise(i * 0.37 - 20, 2);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(1);
}
});
it('wraps: the lattice repeats every `period` cells', () => {
for (const x of [0, 0.4, 3.9, 6.99]) {
expect(valueNoisePeriodic(x + 7, 4, 7)).toBeCloseTo(valueNoisePeriodic(x, 4, 7), 12);
expect(valueNoise(i * 0.37 - 20, 2)).toBe(v);
}
});
it('different rows are different profiles', () => {
expect(valueNoisePeriodic(1.5, 0, 9)).not.toBeCloseTo(valueNoisePeriodic(1.5, 1, 9), 3);
expect(valueNoise(1.5, 0)).not.toBeCloseTo(valueNoise(1.5, 1), 3);
});
});
describe('ridge profile', () => {
it('every layer is periodic in t, so a drift of one period loops seamlessly', () => {
it('stays in [0, 1] for any world-x, negative and far included, and layers differ', () => {
for (const spec of LAYERS) {
for (const t of [0, 0.13, 0.5, 0.97]) {
expect(Number.isFinite(profile(spec, t))).toBe(true);
}
// the end of one period meets the start of the next
expect(profile(spec, 1 - 1e-9)).toBeCloseTo(profile(spec, 0), 3);
}
});
it('every octave count is an integer lattice, which periodicity needs', () => {
for (const spec of LAYERS) {
for (let k = 0; k < FBM.octaves; k += 1) {
expect(Number.isInteger(spec.baseCells * FBM.lacunarity ** k)).toBe(true);
}
}
});
it('stays in [0, 1] and layers differ', () => {
for (const spec of LAYERS) {
for (let i = 0; i < 100; i += 1) {
const p = profile(spec, i / 100);
expect(p).toBeGreaterThanOrEqual(0);
expect(p).toBeLessThanOrEqual(1);
for (let i = -300; i < 300; i += 7) {
for (const off of [0, 1234.5, 98765.25]) {
const p = profile(spec, i / 50 + off);
expect(p).toBeGreaterThanOrEqual(0);
expect(p).toBeLessThanOrEqual(1);
}
}
}
expect(profile(LAYERS[0], 0.3)).not.toBeCloseTo(profile(LAYERS[1], 0.3), 3);
});
it('ridge tops lie inside the layer band and rise with farther base', () => {
it('ridge tops lie inside the layer band', () => {
const size = layerSize(400, 800, 1);
for (const spec of LAYERS) {
for (let x = 0; x < size.period; x += 17) {
const y = ridgeY(spec, x, size.period, size.height, 800);
for (let x = -500; x < 5 * size.period; x += 17) {
const y = ridgeY(spec, x / size.period, size.height, 800);
expect(y).toBeGreaterThanOrEqual(0);
expect(y).toBeLessThanOrEqual(size.height);
}
@ -95,9 +74,9 @@ describe('ridge profile', () => {
] as const) {
const z = layerSize(w, h, s);
expect(
Number.isInteger(z.period) && Number.isInteger(z.width) && Number.isInteger(z.height)
Number.isInteger(z.period) && Number.isInteger(z.chunk) && Number.isInteger(z.height)
).toBe(true);
expect(z.width).toBe(2 * z.period);
expect(z.chunk).toBeGreaterThan(z.period);
}
});
});
@ -147,14 +126,16 @@ describe('aerial perspective', () => {
expect(edgeBlur(far)).toBeLessThanOrEqual(AERIAL.blurPx);
});
it('in-scatter peaks at the sun azimuth, is periodic, faint, grows with distance, and is off without a sun', () => {
it('in-scatter is faint, grows with distance, is off without a sun, and never repeats', () => {
const az = 0.3;
const s = { ...sky, sunAzimuth: az };
expect(scatterAlpha(far, s, az)).toBeGreaterThan(scatterAlpha(far, s, az + 0.5));
expect(scatterAlpha(far, s, 0)).toBeCloseTo(scatterAlpha(far, s, 1), 12);
expect(scatterAlpha(far, s, az)).toBeLessThanOrEqual(AERIAL.scatter);
const lobe = (t: number) => scatterAlpha(far, s, t);
expect(lobe(az * 1.9)).toBeGreaterThan(lobe(az * 1.9 + 0.95));
for (const period of [1, 1.9, 3.8, 5.7, 19])
expect(Math.abs(lobe(0.7 + period) - lobe(0.7))).toBeGreaterThan(1e-4);
expect(lobe(az * 1.9)).toBeLessThanOrEqual(AERIAL.scatter);
expect(scatterAlpha(near, s, az)).toBe(0);
expect(scatterAlpha(LAYERS[1], s, az)).toBeLessThan(scatterAlpha(far, s, az));
expect(scatterAlpha(LAYERS[1], s, az * 1.9)).toBeLessThan(scatterAlpha(far, s, az * 1.9));
expect(scatterAlpha(far, { ...sky, sunAzimuth: undefined }, az)).toBe(0);
});
});
@ -237,8 +218,21 @@ function fakeEl(tag = 'div') {
this.children.push(c);
return c;
},
animate: vi.fn(() => {
const a = { currentTime: 0, play: vi.fn(), pause: vi.fn(), cancel: vi.fn() };
animate: vi.fn((keyframes: any[], options: any) => {
const a: any = {
currentTime: 0,
keyframes,
options,
onfinish: null,
play: vi.fn(),
pause: vi.fn(),
cancel: vi.fn(),
/** The animation reaches its end: what the compositor's finish event does. */
finish() {
a.currentTime = options.duration;
a.onfinish?.();
},
};
el.anims.push(a);
return a;
}),
@ -256,6 +250,10 @@ const DAY: SkyColours = {
sunAzimuth: 0.3,
};
const draws: string[] = [];
/** Every ImageData put on any canvas, in order. */
const puts: any[] = [];
/** For every drawImage: the source canvas's width as it was at the moment of the draw. */
const drawnWidths: number[] = [];
function ctx(withFilter = false) {
const c: any = {
globalCompositeOperation: 'source-over',
@ -263,6 +261,16 @@ function ctx(withFilter = false) {
fillRect() {
draws.push(c.globalCompositeOperation === 'source-atop' ? 'scatter' : 'sky');
},
save() {},
restore() {},
rect() {},
clip() {},
createImageData: (w: number, h: number) => ({
data: new Uint8ClampedArray(w * h * 4),
width: w,
height: h,
}),
putImageData: (img: any) => puts.push(img),
beginPath() {},
moveTo() {},
lineTo() {},
@ -280,7 +288,10 @@ function ctx(withFilter = false) {
if (c.arcing) c.arcing = false;
else draws.push('fill');
},
drawImage: () => draws.push('flatten'),
drawImage: (src: any) => {
drawnWidths.push(src.width);
draws.push('flatten');
},
set fillStyle(_v: unknown) {},
};
if (withFilter) c.filter = 'none';
@ -295,6 +306,8 @@ let search: string;
beforeEach(() => {
rafQueue = [];
draws.length = 0;
puts.length = 0;
drawnWidths.length = 0;
search = '';
motion = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() };
doc = Object.assign(new EventTarget(), {
@ -330,6 +343,11 @@ function host() {
parent.appendChild(canvas);
return { parent, canvas };
}
/** The rig holds one track per layer, farthest first; each track holds three chunk canvases. */
const rigOf = (parent: any) => parent.children.find((c: any) => c.tag === 'div');
const tracksOf = (parent: any): any[] => rigOf(parent).children;
const animsOf = (parent: any): any[] =>
[rigOf(parent), ...tracksOf(parent)].flatMap((e: any) => e.anims);
const runFrames = (dt: number, n: number, from = 0) => {
let t = from;
for (let i = 0; i < n && rafQueue.length; i += 1) {
@ -353,19 +371,25 @@ describe('createRidgeScene', () => {
expect(createRidgeScene({ canvas, tier: 'full', sky: DAY })).toBeNull();
});
it('bakes the sky once and paints four layers at integer sizes, farthest first', () => {
it('bakes the sky once, and per layer only the first chunk before the first frame, at integer sizes, farthest first', () => {
const { parent, canvas } = host();
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
const baked: any[] = [];
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY, onBake: (b) => baked.push(b) })!;
expect(s).not.toBeNull();
const layers = parent.children.slice(1);
expect(layers).toHaveLength(LAYERS.length);
layers.forEach((l: any) => {
expect(Number.isInteger(l.width) && Number.isInteger(l.height)).toBe(true);
expect(l.width).toBe(canvas.width * 2); // DPR 2: layer is two periods wide
expect(l.animate).toHaveBeenCalledTimes(1);
const tracks = tracksOf(parent);
expect(tracks).toHaveLength(LAYERS.length);
tracks.forEach((t: any) => {
expect(t.children).toHaveLength(3);
expect(t.animate).toHaveBeenCalledTimes(1);
t.children.forEach((c: any) => {
expect(Number.isInteger(c.width) && Number.isInteger(c.height)).toBe(true);
expect(c.width).toBe(layerSize(400, 800, 2).chunk);
});
});
expect(baked.map((b) => [b.layer, b.chunk, b.first])).toEqual(
LAYERS.map((l) => [l.index, 0, true])
);
expect(draws.filter((d) => d === 'sky')).toHaveLength(1);
expect(draws.filter((d) => d === 'fill')).toHaveLength(LAYERS.length);
s.dispose();
});
@ -403,8 +427,9 @@ describe('createRidgeScene', () => {
motion.matches = true;
const { canvas } = host();
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
const anims = canvas.anims.concat(...canvas.parentElement.children.map((c: any) => c.anims));
expect(anims.length).toBe(LAYERS.length);
const anims = animsOf(canvas.parentElement);
expect(anims.length).toBe(LAYERS.length + 1); // one drift per layer and the sway
expect(anims.every((a: any) => a.pause.mock.calls.length > 0)).toBe(true);
anims.forEach((a: any) => expect(a.play).not.toHaveBeenCalled());
expect(rafQueue).toHaveLength(0);
s.dispose();
@ -413,7 +438,7 @@ describe('createRidgeScene', () => {
it('pauses the drift when the tab is hidden and resumes when it is visible', () => {
const { parent, canvas } = host();
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
const anims = parent.children.slice(1).flatMap((l: any) => l.anims);
const anims = animsOf(parent);
anims.forEach((a: any) => a.pause.mockClear());
doc.visibilityState = 'hidden';
doc.dispatchEvent(new Event('visibilitychange'));
@ -442,7 +467,7 @@ describe('createRidgeScene', () => {
const { parent, canvas } = host();
const before = parent.children.length;
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
expect(parent.children.length).toBe(before + LAYERS.length);
expect(parent.children.length).toBe(before + 1); // the rig
runFrames(16.7, 300);
expect(rafQueue.length).toBe(0);
s.dispose();
@ -466,7 +491,7 @@ describe('createRidgeScene', () => {
(window as any).innerHeight = 800;
window.dispatchEvent(new Event('resize'));
await new Promise((r) => setTimeout(r, 300));
expect(parent.children.slice(1)).toHaveLength(LAYERS.length);
expect(tracksOf(parent)).toHaveLength(LAYERS.length);
expect(draws.filter((d) => d === 'sky')).toHaveLength(1);
s!.dispose();
expect(parent.children).toEqual([canvas]);
@ -475,7 +500,7 @@ describe('createRidgeScene', () => {
it('dispose cancels the loop, the animations and the layers', () => {
const { parent, canvas } = host();
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
const anims = parent.children.slice(1).flatMap((l: any) => l.anims);
const anims = animsOf(parent);
s.dispose();
anims.forEach((a: any) => expect(a.cancel).toHaveBeenCalled());
expect(parent.children).toEqual([canvas]);
@ -501,16 +526,6 @@ describe('the door', () => {
});
});
describe('fbm', () => {
it('is normalised into [0, 1]', () => {
for (let i = 0; i < 100; i += 1) {
const v = fbmPeriodic(i / 100, 0, 3, FBM);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
}
});
});
describe('aerial perspective in the scene', () => {
it('is baked once: one scatter fill per hazy layer, none per frame, the nearest layer has none', () => {
const { canvas } = host();
@ -533,7 +548,7 @@ describe('aerial perspective in the scene', () => {
s.dispose();
});
it('softens with the canvas filter where it exists, blur scaled by DPR, tiled so the wrap does not fade', () => {
it('softens with the canvas filter where it exists, blur scaled by DPR, from pixels baked past the chunk edges', () => {
const { parent, canvas } = host();
const filters: string[] = [];
const orig = (document as any).createElement;
@ -557,7 +572,7 @@ describe('aerial perspective in the scene', () => {
const blurs = filters.filter((f) => f !== 'none');
expect(blurs).toHaveLength(LAYERS.length - 1); // nearest is sharp
expect(blurs[0]).toBe(`blur(${edgeBlur(LAYERS[0]) * 2}px)`); // DPR 2
expect(parent.children).toHaveLength(1 + LAYERS.length);
expect(parent.children).toHaveLength(2); // sky canvas and the rig
s.dispose();
});
@ -591,9 +606,8 @@ describe('the starfield in the scene', () => {
const s = createRidgeScene({ canvas, tier: 'full', sky: night })!;
expect(count('star')).toBe(N);
expect(count('sky')).toBe(1);
expect(parent.children.indexOf(parent.children[1])).toBe(1);
expect(parent.children[1].anims).toHaveLength(0); // the overlay: above the sky, under the ridges
expect(parent.children).toHaveLength(2 + LAYERS.length);
expect(parent.children).toHaveLength(3);
const before = baked();
runFrames(16.7, 300); // 5 s at 60 fps; the overlay repaints at 20 fps
expect(baked()).toBe(before);
@ -609,7 +623,7 @@ describe('the starfield in the scene', () => {
const { parent, canvas } = host();
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
expect(count('star')).toBe(0);
expect(parent.children).toHaveLength(1 + LAYERS.length);
expect(parent.children).toHaveLength(2);
runFrames(16.7, 60);
expect(count('clear')).toBe(0);
s.dispose();
@ -688,3 +702,113 @@ describe('the starfield in the scene', () => {
expect(rafQueue).toHaveLength(0);
});
});
describe('endless drift: chunks baked on demand and recycled', () => {
const size = layerSize(400, 800, 2); // the fake host: 400 x 800 CSS px at DPR 2
const until = async (cond: () => boolean) => {
const t0 = Date.now();
while (!cond()) {
if (Date.now() - t0 > 10000) throw new Error('timed out waiting for the background bakes');
await new Promise((r) => setTimeout(r, 5));
}
};
/** The pixels chunk `m` of layer `li` must hold: the pure bake of its world columns, no blur. */
const expected = (li: number, m: number) => {
const p: BakeParams = {
spec: LAYERS[li],
sky: DAY,
period: size.period,
scale: 2,
viewH: 1600,
rows: size.height,
bandH: size.height,
px0: m * size.chunk,
cols: size.chunk,
};
const out = new Uint8ClampedArray(p.cols * p.rows * 4);
bakeSlice(out, p, layerPaint(p.spec, DAY), 0, p.cols);
return Buffer.from(out.buffer);
};
/** Each distinct ImageData put, in order of first appearance: one per baked chunk. */
const baked = () => [...new Set(puts)].map((img) => Buffer.from(img.data.buffer));
const start = () => {
const { parent, canvas } = host();
const records: any[] = [];
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY, onBake: (b) => records.push(b) })!;
return { parent, s, records };
};
it('bakes every chunk from its own world columns: no offset, the next chunk continues the last', async () => {
const { s, records } = start();
await until(() => records.length === 3 * LAYERS.length);
// First view: chunk 0 of each layer; then, per layer in queue order, chunks 1 and 2.
const order = [
...LAYERS.map((l) => [l.index, 0]),
...LAYERS.flatMap((l) => [
[l.index, 1],
[l.index, 2],
]),
];
expect(records.map((r) => [r.layer, r.chunk])).toEqual(order);
const imgs = baked();
expect(imgs).toHaveLength(order.length);
order.forEach(([li, m], i) => expect(imgs[i].equals(expected(li, m))).toBe(true));
s.dispose();
});
it('when the drift animation ends, the chunks move one slot left and the track restarts', async () => {
const { parent, s, records } = start();
await until(() => records.length === 3 * LAYERS.length);
const track = tracksOf(parent)[0];
const [e0, e1, e2] = track.children;
const first = track.anims[0];
// One chunk of drift, not one viewport: the track slides by the width of a chunk.
expect(first.keyframes[1].transform).toContain(`${-size.chunk / 2}px`);
expect(track.animate).toHaveBeenCalledTimes(1);
first.finish();
expect(first.cancel).toHaveBeenCalled();
expect(track.animate).toHaveBeenCalledTimes(2);
const w = size.chunk / 2;
expect([e1.style.left, e2.style.left, e0.style.left]).toEqual(['0px', `${w}px`, `${2 * w}px`]);
expect(track.anims[1].keyframes[1].transform).toContain(`${-w}px`);
s.dispose();
});
it('the canvas that left is baked with the next stretch of world, and each rotation after it, without end', async () => {
const { parent, s, records } = start();
await until(() => records.length === 3 * LAYERS.length);
const track = tracksOf(parent)[0];
const done = () => records.filter((r) => r.layer === 0).map((r) => r.chunk);
for (let m = 3; m <= 5; m += 1) {
track.anims[track.anims.length - 1].finish();
await until(() => done().includes(m));
}
expect(done()).toEqual([0, 1, 2, 3, 4, 5]);
// The pixels of chunks 3 to 5 are the world's, not a repeat of an earlier one.
const imgs = baked();
const layer0 = imgs.filter((_, i) => records[i].layer === 0);
[3, 4, 5].forEach((m, k) => expect(layer0[3 + k].equals(expected(0, m))).toBe(true));
expect(layer0[3].equals(layer0[0])).toBe(false);
s.dispose();
});
it('with the blur, each chunk is blurred from a scratch canvas of its own width: seams stay opaque and exact', async () => {
const orig = (document as any).createElement;
(document as any).createElement = (tag: string) => {
const el = orig(tag);
el.getContext = vi.fn(() => ctx(true));
return el;
};
const { s, records } = start();
await until(() => records.length === 3 * LAYERS.length);
const want = new Set(
LAYERS.filter((l) => edgeBlur(l) > 0).map(
(l) => size.chunk + 2 * Math.ceil(3 * edgeBlur(l) * 2)
)
);
expect(want.size).toBeGreaterThan(1); // the layers' pads differ, which is what a shared canvas gets wrong
// Every blur reads a source exactly as wide as that chunk's padded bake, at the moment of the draw.
expect(new Set(drawnWidths)).toEqual(want);
s.dispose();
});
});

View file

@ -1,18 +1,20 @@
/* eslint-disable no-param-reassign -- the bake writes into the canvases and layer records it is handed */
import type { Scene, SceneOptions } from '../Scene';
import { overlayRequested } from '../debugOverlays';
import { FrameSampler, FrameStats } from './probe';
import { skyColours, SkyColours, Srgb } from './skyColour';
import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars';
import {
LAYERS,
LayerSpec,
edgeBlur,
hazeColour,
layerColour,
layerSize,
ridgeY,
scatterAlpha,
} from './ridgeWorld';
import { bakeSlice, BakeParams, layerPaint } from './ridgeBake';
import { LAYERS, LayerSpec, edgeBlur, layerSize, scatterAlpha, transmittance } from './ridgeWorld';
/** One chunk's bake: `ms` is the summed task time, `maxTaskMs` the longest single task; `first` marks the bake of the first view. */
export interface BakeRecord {
layer: number;
chunk: number;
ms: number;
maxTaskMs: number;
first: boolean;
}
export type RidgeOptions = Pick<SceneOptions, 'canvas' | 'tier'> & {
/** Show the rAF median/p95 readout. Default: the page URL has `?probe=1`, where `debug` allows it. */
@ -23,15 +25,11 @@ export type RidgeOptions = Pick<SceneOptions, 'canvas' | 'tier'> & {
onProbe?: (s: FrameStats & { still: boolean }) => void;
/** The sky's colours; default `skyColours()`, the one function to replace with Void's sun.js. */
sky?: SkyColours;
/** Called after each chunk is baked (a chunk is one layer's stretch of world-x): the cost record. */
onBake?: (b: BakeRecord) => void;
};
const css = (c: Srgb): string => `rgb(${c[0]},${c[1]},${c[2]})`;
const mix = (a: Srgb, b: Srgb, t: number): Srgb => [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
function context2d(c: HTMLCanvasElement): CanvasRenderingContext2D | null {
return c.getContext('2d');
}
@ -49,95 +47,202 @@ function paintSky(canvas: HTMLCanvasElement, sky: SkyColours): boolean {
return true;
}
const clock = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
type Size = ReturnType<typeof layerSize>;
const SCATTER_STOPS = 16;
/** Warm in-scatter over the silhouette only (source-atop), as a horizontal gradient that repeats each period. */
/** Warm in-scatter over the silhouette only (source-atop): a horizontal gradient over the chunk's world-x. */
function scatter(
ctx: CanvasRenderingContext2D,
spec: LayerSpec,
sky: SkyColours,
size: { period: number; width: number; height: number }
widthPx: number,
heightPx: number,
t0: number,
tPerPx: number,
bandX = 0,
bandW = widthPx
) {
if (sky.sunAzimuth === undefined || scatterAlpha(spec, sky, sky.sunAzimuth) <= 0) return;
const g = ctx.createLinearGradient(0, 0, size.width, 0);
if (sky.sunAzimuth === undefined || transmittance(spec) >= 1) return; // the nearest layer takes none
const g = ctx.createLinearGradient(0, 0, widthPx, 0);
const [r, gr, b] = sky.horizon;
for (let k = 0; k <= 2 * SCATTER_STOPS; k += 1) {
const t = (k % SCATTER_STOPS) / SCATTER_STOPS;
g.addColorStop(k / (2 * SCATTER_STOPS), `rgba(${r},${gr},${b},${scatterAlpha(spec, sky, t)})`);
for (let k = 0; k <= SCATTER_STOPS; k += 1) {
const a = scatterAlpha(spec, sky, t0 + (tPerPx * widthPx * k) / SCATTER_STOPS);
g.addColorStop(k / SCATTER_STOPS, `rgba(${r},${gr},${b},${a})`);
}
ctx.globalCompositeOperation = 'source-atop';
ctx.fillStyle = g;
ctx.fillRect(0, 0, size.width, size.height);
ctx.fillRect(bandX, 0, bandW, heightPx);
ctx.globalCompositeOperation = 'source-over';
}
/**
* Soften the silhouette's edge by `px` once, where the browser's 2D canvas supports `filter` (not
* Safari, which keeps the sharp edge). One period is tiled past both ends and the bottom row extended below,
* then blurred in a single draw, so the blur sees the wrap and the ground instead of fading at borders.
*/
function soften(src: HTMLCanvasElement, px: number, period: number): HTMLCanvasElement {
const probe = context2d(src);
if (!probe || px <= 0 || !('filter' in probe)) return src;
const pad = Math.ceil(3 * px);
// The unfiltered tiling: blurring tile by tile would fade each tile's own border.
const tiled = document.createElement('canvas');
tiled.width = src.width + 2 * period;
tiled.height = src.height + pad;
const tctx = context2d(tiled);
const out = document.createElement('canvas');
out.width = src.width;
out.height = src.height;
const ctx = context2d(out);
if (!tctx || !ctx) return src;
for (let k = 0; k < 4; k += 1) {
tctx.drawImage(src, 0, 0, period, src.height, k * period, 0, period, src.height);
tctx.drawImage(src, 0, src.height - 1, period, 1, k * period, src.height, period, pad);
}
ctx.filter = `blur(${px}px)`;
ctx.drawImage(tiled, -period, 0);
ctx.filter = 'none';
return out;
/** A chunk's bake in slices: `step(budgetMs)` returns true once the chunk is on its canvas. */
interface Job {
step(budgetMs: number): boolean;
}
/** One ridge layer, painted once: a filled silhouette two periods wide on a plain 2D canvas. */
function paintLayer(
/**
* One layer's stretch of world-x `[m * chunk, (m + 1) * chunk)` painted onto `el`, in three phases of
* column bands, each band sized to the task budget by measuring the last one: bake the pixels
* (`bakeSlice`), put them on a canvas with the in-scatter, and (where the 2D `filter` exists) blur them
* into `el`. The blur needs neighbours, so the pixels are baked `pad` columns past both ends and
* the bottom into a scratch canvas this job owns, and each blurred band reads `pad` columns either side of
* itself: seams show no fade. The scratch is made when the job first runs and dropped when it ends.
*/
function chunkJob(
el: HTMLCanvasElement,
makeCanvas: () => HTMLCanvasElement,
spec: LayerSpec,
sky: SkyColours,
size: { period: number; width: number; height: number },
size: Size,
viewH: number,
scale: number
): HTMLCanvasElement | null {
const el = document.createElement('canvas');
el.width = size.width;
el.height = size.height;
scale: number,
m: number,
record: (ms: number, maxTaskMs: number) => void
): Job | null {
const ctx = context2d(el);
if (!ctx) return null;
const top: number[] = [];
for (let x = 0; x < size.period; x += 1)
top.push(ridgeY(spec, x, size.period, size.height, viewH));
const c = layerColour(spec, sky);
const g = ctx.createLinearGradient(0, 0, 0, size.height);
g.addColorStop(0, css(c));
g.addColorStop(1, css(mix(c, hazeColour(sky), 0.5 * (1 - spec.depth))));
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(0, size.height);
for (let x = 0; x <= size.width; x += 1) ctx.lineTo(x, top[x % size.period]);
ctx.lineTo(size.width, size.height);
ctx.closePath();
ctx.fill();
scatter(ctx, spec, sky, size);
return soften(el, edgeBlur(spec) * scale, size.period);
const blur = edgeBlur(spec) * scale;
const pad = blur > 0 && 'filter' in ctx ? Math.ceil(3 * blur) : 0;
const cols = size.chunk + 2 * pad;
const rows = size.height + pad;
const params: BakeParams = {
spec,
sky,
period: size.period,
scale,
viewH,
rows,
bandH: size.height,
px0: m * size.chunk - pad,
cols,
};
const paint = layerPaint(spec, sky);
const t0World = params.px0 / size.period;
let scratch: HTMLCanvasElement | undefined;
let sctx: CanvasRenderingContext2D | null = ctx;
let img: ImageData | undefined;
/** 0 bake, 1 put and in-scatter, 2 blur into el; a chunk with no blur ends after 1. */
let phase = 0;
let pos = 0;
let sliceCols = 32;
let total = 0;
let longest = 0;
const last = pad > 0 ? 2 : 1;
const start = (): boolean => {
if (pad > 0) {
scratch = makeCanvas();
scratch.width = cols;
scratch.height = rows;
sctx = context2d(scratch);
} else {
el.width = size.chunk;
el.height = size.height;
}
if (!sctx) return false;
img = sctx.createImageData(cols, rows);
return true;
};
const work = (x: number, n: number) => {
if (!img || !sctx) return;
if (phase === 0) bakeSlice(img.data, params, paint, x, x + n);
else if (phase === 1) {
sctx.putImageData(img, 0, 0, x, 0, n, rows);
scatter(sctx, spec, sky, cols, rows, t0World, 1 / size.period, x, n);
} else {
// One draw: a blurred draw cut into bands costs more in the margins than it saves in task length.
ctx.drawImage(scratch as HTMLCanvasElement, -pad, 0);
}
};
return {
step(budgetMs) {
const t0 = clock();
// Allocating the canvases and the pixel buffer is a task of its own.
if (!img) {
if (!start()) return true;
if (Number.isFinite(budgetMs)) {
total += clock() - t0;
longest = Math.max(longest, clock() - t0);
return false;
}
}
while (phase <= last && clock() - t0 < budgetMs) {
const end = phase === 2 ? size.chunk : cols;
if (pos === 0 && phase === 2) {
el.width = size.chunk;
el.height = size.height;
ctx.filter = `blur(${blur}px)`;
}
const n = phase === 2 ? end : Math.min(end - pos, sliceCols);
const s0 = clock();
work(pos, n);
pos += n;
const dt = Math.max(clock() - s0, 0.02);
sliceCols = Number.isFinite(budgetMs)
? Math.max(8, Math.min(512, Math.round((n * budgetMs * 0.4) / dt)))
: cols;
if (pos >= end) {
if (phase === 2) ctx.filter = 'none';
phase += 1;
pos = 0;
if (phase === 1 || phase === 2) sliceCols = Number.isFinite(budgetMs) ? 64 : cols;
}
}
const dt = clock() - t0;
total += dt;
longest = Math.max(longest, dt);
const done = phase > last;
if (done) {
img = undefined;
if (scratch) {
scratch.width = 0;
scratch.height = 0;
}
scratch = undefined;
record(total, longest);
}
return done;
},
};
}
interface Chunk {
el: HTMLCanvasElement;
/** World chunk index it holds or is being baked to hold. */
id: number;
job?: Job;
ready: boolean;
}
/** One layer: a track that drifts by one chunk per animation, holding three chunk canvases. */
interface LayerState {
spec: LayerSpec;
track: HTMLElement;
/** Slot 0 and 1 are what the window can see; slot 2 is the one ahead, baked in the background. */
slots: Chunk[];
anim?: Animation;
}
interface Built {
layers: HTMLCanvasElement[];
anims: Animation[];
rig: HTMLElement;
layers: LayerState[];
sway?: Animation;
size: Size;
/** CSS px to canvas px, and the viewport's height in canvas px, as baked. */
scale: number;
viewH: number;
/** Makes a canvas, bound to the document the scene was built in. */
makeCanvas: () => HTMLCanvasElement;
/** The twinkling stars' own canvas, between the sky and the ridges; absent in a day sky and in 'still'. */
twinkle?: HTMLCanvasElement;
}
/** Yaw and pitch: the camera's slow sway, a loop of this many seconds over the whole rig. */
const SWAY_SECONDS = 150;
const SLICE_BUDGET_MS = 6;
/** The twinkle overlay redraws at most this often: the swings are 2 to 7 s, no need for 60 fps. */
const TWINKLE_FRAME_MS = 50;
@ -162,6 +267,9 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
let twRaf = 0;
let twLast = 0;
let still = tier === 'still';
let jobTimer: ReturnType<typeof setTimeout> | undefined;
let queue: Chunk[] = [];
let generation = 0;
let disposed = false;
let raf = 0;
let readout: HTMLElement | undefined;
@ -175,8 +283,13 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
};
const teardownLayers = () => {
built?.anims.forEach((a) => a.cancel());
built?.layers.forEach((l) => l.remove());
generation += 1;
clearTimeout(jobTimer);
jobTimer = undefined;
queue = [];
built?.sway?.cancel();
built?.layers.forEach((l) => l.anim?.cancel());
built?.rig.remove();
built?.twinkle?.remove();
cancelAnimationFrame(twRaf);
twRaf = 0;
@ -190,6 +303,109 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
return size.period < 1 || size.height < 1 || Math.round(h * scale) < 1;
};
/**
* Start baking chunk `c` of layer `l`. `first` runs it to the end in this task (the first view);
* otherwise it joins the queue, which `pump` works through in short tasks. False when the canvas has no 2D context.
*/
const bake = (l: LayerState, c: Chunk, first: boolean): boolean => {
if (!built) return false;
const { scale, viewH, makeCanvas } = built;
const job = chunkJob(
c.el,
makeCanvas,
l.spec,
sky,
built.size,
viewH,
scale,
c.id,
(ms, maxTaskMs) => {
c.ready = true;
c.job = undefined;
opts.onBake?.({ layer: l.spec.index, chunk: c.id, ms, maxTaskMs, first });
}
);
if (!job) return false;
c.ready = false;
c.job = job;
if (first) job.step(Infinity);
else queue.push(c);
return true;
};
const pump = () => {
if (jobTimer !== undefined || !queue.length) return;
const gen = generation;
jobTimer = setTimeout(() => {
jobTimer = undefined;
if (gen !== generation) return;
const c = queue[0];
if (!c.job || c.job.step(SLICE_BUDGET_MS)) queue.shift();
pump();
}, 0);
};
/** Bake a still-queued chunk to the end now: only when the drift has caught up with the background work. */
const flush = (c: Chunk) => {
while (c.job && !c.job.step(Infinity));
queue = queue.filter((q) => q !== c);
};
const chunkSeconds = (spec: LayerSpec, size: Size) => (spec.seconds * size.chunk) / size.period;
/** One chunk of drift as one animation, holding its end until `rotate` hands the track the next chunk. */
const startDrift = (l: LayerState, onEnd: (l: LayerState) => void) => {
if (!built) return;
const { size, scale } = built;
l.anim = l.track.animate?.(
[
{ transform: 'translate3d(0,0,0)' },
{ transform: `translate3d(${-size.chunk / scale}px,0,0)` },
],
{ duration: chunkSeconds(l.spec, size) * 1000, easing: 'linear', fill: 'forwards' }
);
if (l.anim) l.anim.onfinish = () => onEnd(l);
};
/**
* The window has drifted one chunk. Slot 1 is now slot 0 and slot 2 slot 1, at the same screen place
* (the track restarts at 0 and the chunks move one slot left in the same task, so nothing jumps); the
* canvas that left becomes slot 2 and is baked with the world one chunk further on, off the critical path.
*/
const rotate = (l: LayerState) => {
if (!built || !built.layers.includes(l)) return;
const [s0, s1, s2] = l.slots;
flush(s1);
flush(s2);
l.slots = [s1, s2, s0];
s0.id = s2.id + 1;
const { size, scale } = built;
l.slots.forEach((c, i) => {
c.el.style.left = `${(i * size.chunk) / scale}px`;
});
l.anim?.cancel();
startDrift(l, rotate);
bake(l, s0, false);
pump();
syncPlayState();
};
/** The camera's slow yaw and pitch: the rig eases sideways (up to 5% of the width) and down (up to 3% of the height) and back, in a loop. */
const startSway = (rig: HTMLElement, w: number, h: number) => {
const x = 0.05 * w;
const y = 0.03 * h;
const at = (fx: number, fy: number) => `translate3d(${-x * fx}px,${y * fy}px,0)`;
return rig.animate?.(
[
{ transform: at(0, 0), offset: 0 },
{ transform: at(0.55, 1), offset: 0.35 },
{ transform: at(1, 0.3), offset: 0.7 },
{ transform: at(0, 0), offset: 1 },
],
{ duration: SWAY_SECONDS * 1000, iterations: Infinity, easing: 'ease-in-out' }
);
};
/** Bake the sky, then the layers. Returns false when a 2D context is missing or there is no area yet. */
const build = (): boolean => {
teardownLayers();
@ -200,7 +416,6 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
canvas.width = size.period;
canvas.height = Math.round(h * scale);
if (!paintSky(canvas, sky)) return false;
const layers: HTMLCanvasElement[] = [];
// Every star is baked into the sky; the twinkling ones get their own canvas, which a per-frame
// pass clears and paints over. 'still' has no overlay.
const stars = makeStars(sky, w, h);
@ -223,39 +438,73 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
parent.appendChild(twinkle);
}
field = stars;
for (const spec of LAYERS) {
const el = paintLayer(spec, sky, size, canvas.height, scale);
if (!el) {
layers.forEach((l) => l.remove());
twinkle?.remove();
return false;
}
el.setAttribute('aria-hidden', 'true');
Object.assign(el.style, {
// The rig carries the camera's sway; each layer's track carries its drift; chunks sit on the track.
const rig = document.createElement('div');
rig.setAttribute('aria-hidden', 'true');
Object.assign(rig.style, {
position: 'absolute',
left: '0',
bottom: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
willChange: 'transform',
});
const layers: LayerState[] = LAYERS.map((spec) => {
const track = document.createElement('div');
Object.assign(track.style, {
position: 'absolute',
left: '0',
bottom: '0',
width: `${size.width / scale}px`,
width: `${size.period / scale}px`,
height: `${size.height / scale}px`,
pointerEvents: 'none',
willChange: 'transform',
});
layers.push(el);
const slots: Chunk[] = [0, 1, 2].map((i) => {
const el = document.createElement('canvas');
el.width = size.chunk;
el.height = size.height;
Object.assign(el.style, {
position: 'absolute',
bottom: '0',
width: `${size.chunk / scale}px`,
height: `${size.height / scale}px`,
});
return { el, id: i, ready: false };
});
return { spec, track, slots };
});
const doc = document;
built = {
rig,
layers,
size,
scale,
viewH: canvas.height,
makeCanvas: () => doc.createElement('canvas'),
twinkle,
};
// First view: only slot 0 of each layer, in this task. The window sees nothing else for the first
// fifth of a chunk's drift; slots 1 and 2 are baked in short tasks behind it.
if (!layers.every((l) => bake(l, l.slots[0], true))) {
teardownLayers();
return false;
}
layers.forEach((l) => {
l.slots.forEach((c, i) => {
c.el.setAttribute('aria-hidden', 'true');
c.el.style.left = `${(i * size.chunk) / scale}px`;
l.track.appendChild(c.el);
});
rig.appendChild(l.track);
bake(l, l.slots[1], false);
bake(l, l.slots[2], false);
});
// DOM order is paint order: farthest first, so the nearest ridge ends up on top.
layers.forEach((l) => parent.appendChild(l));
const anims = layers
.map((el, i) =>
el.animate?.(
[
{ transform: 'translate3d(0,0,0)' },
{ transform: `translate3d(${-size.period / scale}px,0,0)` },
],
{ duration: LAYERS[i].seconds * 1000, iterations: Infinity, easing: 'linear' }
)
)
.filter((a): a is Animation => !!a);
built = { layers, anims, twinkle };
parent.appendChild(rig);
layers.forEach((l) => startDrift(l, rotate));
built.sway = startSway(rig, w, h);
pump();
syncPlayState();
return true;
};
@ -288,7 +537,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
};
function syncPlayState() {
built?.anims.forEach((a) => (moving() ? a.play() : a.pause()));
const anims = built ? [built.sway, ...built.layers.map((l) => l.anim)] : [];
anims.forEach((a) => (moving() ? a?.play() : a?.pause()));
if (!built?.twinkle) return;
if (moving()) {
if (!twRaf) twRaf = requestAnimationFrame(twTick);
@ -305,13 +555,15 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
still = true;
const ctx = context2d(canvas);
if (ctx && built) {
const { anims } = built;
built.layers.forEach((l, i) => {
// Freeze where the drift is, so the swap to a still does not jump.
const a = anims[i];
const t = Number(a?.currentTime ?? 0);
const turns = (t % (LAYERS[i].seconds * 1000)) / (LAYERS[i].seconds * 1000);
ctx.drawImage(l, -Math.round(turns * canvas.width), canvas.height - l.height);
const { size } = built;
built.layers.forEach((l) => {
// Freeze where the drift is, so the swap to a still does not jump; whatever is baked and in view.
const dur = chunkSeconds(l.spec, size) * 1000;
const p = Math.min(1, Math.max(0, Number(l.anim?.currentTime ?? 0) / dur));
const x = -Math.round(p * size.chunk);
l.slots.slice(0, 2).forEach((c, i) => {
if (c.ready) ctx.drawImage(c.el, x + i * size.chunk, canvas.height - size.height);
});
});
}
teardownLayers();

View file

@ -1,7 +1,7 @@
// CultMath's value noise and fbm on the CPU, in one dimension and periodic. Clean-room: the
// algorithm is read from shaders/CultMath.glsl (`cultmath_hash`, `cultmath_value_noise`), not copied
// from any other terrain. Doubles instead of floats, so values differ from the GLSL in the last
// digits; nothing here has to match a shader.
// CultMath's value noise on the CPU, in one dimension, over the whole line (no period). Clean-room:
// the algorithm is read from shaders/CultMath.glsl (`cultmath_hash`, `cultmath_value_noise`), not
// copied from any other terrain. Doubles instead of floats, so values differ from the GLSL in the
// last digits; nothing here has to match a shader.
const frac = (v: number): number => v - Math.floor(v);
@ -12,40 +12,14 @@ export const hash2 = (x: number, y: number): number =>
const smooth = (t: number): number => t * t * (3 - 2 * t);
/**
* `cultmath_value_noise` along row `row` of the lattice, with the lattice wrapping every `period`
* cells: noise(x + period) === noise(x). `row` is an integer, so the vertical blend of the 2D
* original is the identity.
* `cultmath_value_noise` along row `row` of the lattice, in [0, 1). `row` is an integer, so the
* vertical blend of the 2D original is the identity. The lattice is hashed per cell, never wrapped:
* a ridge is a function of world-x, with no period for the eye to find.
*/
export function valueNoisePeriodic(x: number, row: number, period: number): number {
export function valueNoise(x: number, row: number): number {
const cell = Math.floor(x);
const u = smooth(x - cell);
const wrap = (c: number) => ((c % period) + period) % period;
const a = hash2(wrap(cell), row);
const b = hash2(wrap(cell + 1), row);
const a = hash2(cell, row);
const b = hash2(cell + 1, row);
return a + (b - a) * u;
}
export interface FbmShape {
octaves: number;
lacunarity: number;
gain: number;
}
/**
* Fractal sum in [0, 1]. `t` runs 0..1 across one period; the first octave has `baseCells` cells
* per period and each next one `lacunarity` times more, so `baseCells * lacunarity^k` must be an
* integer for the sum to be periodic (checked by the caller's config, not here).
*/
export function fbmPeriodic(t: number, row: number, baseCells: number, s: FbmShape): number {
let sum = 0;
let norm = 0;
let amp = 1;
let cells = baseCells;
for (let k = 0; k < s.octaves; k += 1) {
sum += amp * valueNoisePeriodic(t * cells, row + k * 13, cells);
norm += amp;
amp *= s.gain;
cells *= s.lacunarity;
}
return sum / norm;
}

View file

@ -0,0 +1,303 @@
import { describe, expect, it } from 'vitest';
import { BakeParams, bakeSlice, faceLight, layerPaint, sunDirection } from './ridgeBake';
import { LACUNARITY, LAYERS, profile } from './ridgeWorld';
import { skyColours, SkyColours } from './skyColour';
const SKY: SkyColours = { ...skyColours(), sunAzimuth: 0.3 }; // sun toward +x
const PERIOD = 400;
const VIEW_H = 800;
const BAND = 480;
const params = (
li: number,
px0: number,
cols: number,
over: Partial<BakeParams> = {}
): BakeParams => ({
spec: LAYERS[li],
sky: SKY,
period: PERIOD,
scale: 1,
viewH: VIEW_H,
rows: BAND,
bandH: BAND,
px0,
cols,
...over,
});
function bake(p: BakeParams, slices: number[] = [p.cols]): Uint8ClampedArray {
const out = new Uint8ClampedArray(p.cols * p.rows * 4);
const paint = layerPaint(p.spec, p.sky);
let c = 0;
for (const n of slices) {
bakeSlice(out, p, paint, c, Math.min(p.cols, c + n));
c += n;
}
return out;
}
const lum = (d: Uint8ClampedArray, cols: number, x: number, y: number) => {
const o = (y * cols + x) * 4;
return 0.2126 * d[o] + 0.7152 * d[o + 1] + 0.0722 * d[o + 2];
};
/** First row of column x whose alpha is full. */
const solidRow = (d: Uint8ClampedArray, cols: number, x: number) => {
let y = 0;
while (y < BAND && d[(y * cols + x) * 4 + 3] < 255) y += 1;
return y;
};
describe('the field is a function of world-x', () => {
it('is deterministic, and defined and bounded at any world-x', () => {
for (const spec of LAYERS) {
for (const t of [-40.3, 0, 0.5, 17.77, 5000.123]) {
expect(profile(spec, t)).toBe(profile(spec, t));
expect(profile(spec, t)).toBeGreaterThanOrEqual(0);
expect(profile(spec, t)).toBeLessThanOrEqual(1);
}
}
});
it('has no repeat at the old period (1 viewport) or its multiples: the profile differs from itself shifted', () => {
for (const spec of LAYERS) {
for (const lag of [1, 2, 3, 0.5]) {
let diff = 0;
for (let i = 0; i < 400; i += 1)
diff += Math.abs(profile(spec, i / 100) - profile(spec, i / 100 + lag));
expect(diff / 400).toBeGreaterThan(0.04);
}
}
});
it('the same world column bakes to the same pixels whichever chunk or slice paints it', () => {
for (const li of [0, 3]) {
const whole = bake(params(li, 1000, 200));
const sliced = bake(params(li, 1000, 200), [37, 64, 1000]);
expect(Array.from(sliced)).toEqual(Array.from(whole));
// A chunk starting 120 columns later sees the same pixels for the columns it shares.
const shifted = bake(params(li, 1120, 80));
for (const x of [0, 17, 79]) {
for (const y of [0, 100, 300, BAND - 1]) {
const a = (y * 200 + 120 + x) * 4;
const b = (y * 80 + x) * 4;
expect(Array.from(shifted.slice(b, b + 4))).toEqual(Array.from(whole.slice(a, a + 4)));
}
}
}
});
it('is continuous across a chunk seam: the crest steps no more at the seam than elsewhere', () => {
const chunk = Math.round(PERIOD * 1.2);
for (const li of [0, 1, 2, 3]) {
const p = params(li, chunk - 40, 80);
const d = bake(p);
let worst = 0;
for (let x = 1; x < 80; x += 1)
worst = Math.max(worst, Math.abs(solidRow(d, 80, x) - solidRow(d, 80, x - 1)));
const seam = Math.abs(solidRow(d, 80, 40) - solidRow(d, 80, 39));
expect(seam).toBeLessThanOrEqual(Math.max(worst, 1));
// and the seam columns equal a chunk baked from the other side
const other = bake(params(li, chunk - 40, 80, { cols: 80 }));
expect(Array.from(other)).toEqual(Array.from(d));
}
});
});
describe('shading from the slope normal', () => {
const sun = sunDirection(SKY);
it('the sun is toward +x for azimuth 0.3', () => {
expect(sun[0]).toBeGreaterThan(0.5);
});
it('a face looking at the sun is lit more than one looking away', () => {
// slope < 0: the crest falls toward +x, so the face looks at +x, at the sun.
for (const s of [0.2, 0.6, 1.5]) expect(faceLight(-s, sun)).toBeGreaterThan(faceLight(s, sun));
expect(faceLight(1.5, sun)).toBeLessThan(0.2);
expect(faceLight(-1.5, sun)).toBeGreaterThan(0.6);
});
it('in the baked pixels the lit side is brighter than the shadow side', () => {
for (const li of [0, 1, 2]) {
const cols = 900;
const p = params(li, 0, cols);
const d = bake(p);
const slope = (x: number) => (solidRow(d, cols, x - 1) - solidRow(d, cols, x + 1)) / 2; // rows up per column
const litX: number[] = [];
const shadeX: number[] = [];
for (let x = 2; x < cols - 2; x += 1) {
if (slope(x) < -0.8) litX.push(x);
if (slope(x) > 0.8) shadeX.push(x);
}
expect(litX.length).toBeGreaterThan(5);
expect(shadeX.length).toBeGreaterThan(5);
const mean = (xs: number[]) =>
xs.reduce((a, x) => a + lum(d, cols, x, solidRow(d, cols, x) + 12), 0) / xs.length;
expect(mean(litX)).toBeGreaterThan(mean(shadeX));
}
});
it('a lit crest rim: the crest pixels are brighter than the face a little below, on both sides', () => {
for (const li of [0, 1, 2, 3]) {
const cols = 600;
const d = bake(params(li, 300, cols));
let rimmed = 0;
let n = 0;
for (let x = 3; x < cols - 3; x += 1) {
const y = solidRow(d, cols, x);
if (y + 40 >= BAND) continue;
n += 1;
if (lum(d, cols, x, y + 1) > lum(d, cols, x, y + 30)) rimmed += 1;
}
expect(n).toBeGreaterThan(100);
expect(rimmed / n).toBeGreaterThan(0.8);
}
});
it('the foot of the face is hazier than its top: it moves toward the haze colour', () => {
const cols = 300;
const p = params(0, 0, cols);
const d = bake(p);
const haze = layerPaint(p.spec, p.sky).haze;
const dist = (x: number, y: number) => {
const o = (y * cols + x) * 4;
return Math.hypot(d[o] - haze[0], d[o + 1] - haze[1], d[o + 2] - haze[2]);
};
let nearer = 0;
let n = 0;
for (let x = 0; x < cols; x += 1) {
const y = solidRow(d, cols, x);
if (y + 200 >= BAND) continue;
n += 1;
if (dist(x, y + 200) < dist(x, y + 20)) nearer += 1;
}
expect(n).toBeGreaterThan(50);
expect(nearer / n).toBeGreaterThan(0.9);
});
it('erosion streaks run down the face: brightness varies across columns at one depth, and not without them', () => {
const cols = 500;
const spread = (gully: number) => {
const d = bake(params(2, 0, cols, { spec: { ...LAYERS[2], gully } }));
// The same depth below the crest in every column, in the lit-independent part (gullies darken, lit is column-wise).
const v: number[] = [];
for (let x = 0; x < cols; x += 1) {
const y = solidRow(d, cols, x);
if (y + 90 < BAND) v.push(lum(d, cols, x, y + 80) / Math.max(1, lum(d, cols, x, y + 8)));
}
const m = v.reduce((a, b) => a + b, 0) / v.length;
return v.reduce((a, b) => a + (b - m) ** 2, 0) / v.length;
};
expect(spread(LAYERS[2].gully)).toBeGreaterThan(spread(0) * 1.2);
});
});
describe('bake cost', () => {
it('is recorded, not asserted: one chunk of every layer at 1280 x 720', () => {
const period = 1280;
const rows = Math.round(720 * 0.6);
const ms = LAYERS.map((spec) => {
const p: BakeParams = {
spec,
sky: SKY,
period,
scale: 1,
viewH: 720,
rows,
bandH: rows,
px0: 0,
cols: Math.round(period * 1.2),
};
const t0 = performance.now();
bake(p);
return performance.now() - t0;
});
expect(ms.every((v) => Number.isFinite(v) && v >= 0)).toBe(true);
console.info(
`ridge bake ms per layer (node, 1 chunk, 1280x720): ${ms.map((v) => v.toFixed(1)).join(' ')}`
);
});
});
describe('the ridged multifractal core, each rule on its own', () => {
const one = { ...LAYERS[0], octaves: 1, warp: 0, crag: 1 };
it('folds each octave at its crest: a creased, sharp ridge line, not a smooth swell', () => {
// Largest second difference over a fine scan. A smooth curve of this size stays in the hundreds;
// a fold leaves a crease where the noise crosses its midpoint.
const h = 1e-3;
let worst = 0;
for (let i = 1; i < 20000; i += 1) {
const t = i * h;
worst = Math.max(
worst,
Math.abs(profile(one, t + h) - 2 * profile(one, t) + profile(one, t - h)) / (h * h)
);
}
expect(worst).toBeGreaterThan(2000);
});
it('domain-warps: the same layer without its warp is a measurably different line', () => {
const spec = LAYERS[1];
let diff = 0;
for (let i = 0; i < 2000; i += 1)
diff += Math.abs(profile(spec, i / 100) - profile({ ...spec, warp: 0 }, i / 100));
expect(diff / 2000).toBeGreaterThan(0.02);
});
it('weights each octave by the previous one: where the first octave is low, the later ones add almost nothing', () => {
const lo = { ...LAYERS[1], octaves: 1, warp: 0 };
const hi = { ...LAYERS[1], octaves: 5, warp: 0 };
let valley = 0;
let nv = 0;
let peak = 0;
let np = 0;
for (let i = 0; i < 10000; i += 1) {
const a = profile(lo, i / 500);
const d = Math.abs(profile(hi, i / 500) - a);
if (a < 0.12) {
valley += d;
nv += 1;
} else if (a > 0.35) {
peak += d;
np += 1;
}
}
expect(nv).toBeGreaterThan(200);
expect(np).toBeGreaterThan(200);
expect(valley / nv).toBeLessThan(0.25 * (peak / np));
});
it('octave lattices do not line up: the lacunarity is not a power of two', () => {
expect(Number.isInteger(Math.log2(LACUNARITY))).toBe(false);
expect(Number.isInteger(LACUNARITY)).toBe(false);
});
it('distance flattens relief: contrast falls from the nearest layer to the farthest', () => {
const c = LAYERS.map((spec) => layerPaint(spec, SKY).contrast);
expect(c[LAYERS.length - 1]).toBe(1);
for (let i = 1; i < c.length; i += 1) expect(c[i]).toBeGreaterThan(c[i - 1]);
// and it shows in the pixels: the lit/shadow difference is smaller on the farthest layer than the nearest
const gap = (li: number) => {
const cols = 700;
const d = bake(params(li, 0, cols));
const slope = (x: number) => (solidRow(d, cols, x - 1) - solidRow(d, cols, x + 1)) / 2;
const avg = (pick: (s: number) => boolean) => {
const xs = Array.from({ length: cols - 4 }, (_, k) => k + 2).filter((x) => pick(slope(x)));
return (
xs.reduce((a, x) => a + lum(d, cols, x, solidRow(d, cols, x) + 12), 0) /
Math.max(1, xs.length)
);
};
return (
Math.abs(avg((s) => s < -0.8) - avg((s) => s > 0.8)) /
Math.max(
1,
avg(() => true)
)
);
};
expect(gap(0)).toBeLessThan(gap(3));
});
});

View file

@ -0,0 +1,139 @@
/* eslint-disable no-param-reassign -- `bakeSlice` fills the caller's pixel buffer */
// The pixels of a ridge chunk, as a pure function: no DOM, no canvas. Each column is lit from the
// 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, LayerSpec, profile, hazeColour, layerColour, transmittance } from './ridgeWorld';
import type { SkyColours, Srgb } from './skyColour';
export type Vec3 = readonly [number, number, number];
/** Unit vector toward the sun: x across the screen (from the azimuth), y up, z toward the viewer. */
export function sunDirection(sky: SkyColours): Vec3 {
const az = sky.sunAzimuth ?? 0.3;
const x = 0.9 * Math.sin(2 * Math.PI * az);
const l = Math.hypot(x, 0.35, 0.45);
return [x / l, 0.35 / l, 0.45 / l];
}
/** How steeply the crest's slope tilts the face's normal sideways. */
const TILT = 1.2;
/**
* Sun light on the face under a crest whose silhouette has `slope` (rows per column, positive when the
* crest rises toward +x, so the face looks toward -x). 0 in full shadow .. 1 head-on to the sun.
*/
export function faceLight(slope: number, sun: Vec3): number {
const nx = -slope * TILT;
const inv = 1 / Math.hypot(nx, 0.55, 0.85);
const d = (nx * sun[0] + 0.55 * sun[1] + 0.85 * sun[2]) * inv;
return Math.min(1, Math.max(0, (d - 0.2) / 0.7));
}
export interface BakeParams {
spec: LayerSpec;
sky: SkyColours;
/** Pixels in one viewport width: the unit of world-x. */
period: number;
/** CSS px to canvas px. */
scale: number;
/** Viewport height in canvas px. */
viewH: number;
/** Rows of the pixel buffer (the band plus any padding below it). */
rows: number;
/** Row count of the visible band; the crest is measured from the top of it. */
bandH: number;
/** World pixel of the buffer's column 0 (may be fractional-free negative: a chunk's left minus its padding). */
px0: number;
/** Columns of the buffer. */
cols: number;
}
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
const smoothstep = (a: number, b: number, v: number): number => {
const t = clamp01((v - a) / (b - a));
return t * t * (3 - 2 * t);
};
const SUN_WHITE: Srgb = [255, 238, 205];
/** Everything `bakeSlice` needs per layer, computed once. */
export interface LayerPaint {
base: Srgb;
haze: Srgb;
warm: Srgb;
contrast: number;
sun: Vec3;
}
export function layerPaint(spec: LayerSpec, sky: SkyColours): LayerPaint {
const warm: Srgb = [
(sky.horizon[0] + SUN_WHITE[0]) / 2,
(sky.horizon[1] + SUN_WHITE[1]) / 2,
(sky.horizon[2] + SUN_WHITE[2]) / 2,
];
return {
base: layerColour(spec, sky),
haze: hazeColour(sky),
warm,
// Distance flattens relief: the same transmittance that thins the colour.
contrast: 0.35 + 0.65 * transmittance(spec),
sun: sunDirection(sky),
};
}
/** Crest row (float, from the top of the band) of every buffer column from c0 - 1 to c1 (inclusive). */
function crestRows(p: BakeParams, c0: number, c1: number): Float64Array {
const out = new Float64Array(c1 - c0 + 2);
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 * profile(p.spec, (p.px0 + c) / p.period));
out[c - c0 + 1] = p.bandH - top;
}
return out;
}
/**
* Paint columns [c0, c1) of the chunk into `out` (RGBA, `p.cols` wide, `p.rows` tall, zeroed). One
* call is a slice of a chunk, so a caller can keep each task short. Seams are exact: a column's pixels
* depend only on its world pixel, never on where the slice or chunk begins.
*/
export function bakeSlice(
out: Uint8ClampedArray,
p: BakeParams,
paint: LayerPaint,
c0: number,
c1: number
): void {
const { spec } = p;
const crest = crestRows(p, c0, c1);
const faceH = 0.45 * p.viewH;
const rim = 2.2 * p.scale;
const { base, haze, warm, contrast, sun } = paint;
for (let c = c0; c < c1; c += 1) {
const cy = crest[c - c0 + 1];
const slope = (crest[c - c0 + 2] - crest[c - c0]) * -0.5; // rows up per column
const lit = faceLight(slope, sun);
const steep = 0.45 + 0.55 * clamp01(Math.abs(slope) * 0.8);
const gl = gullyAt(spec, (p.px0 + c) / p.scale) * steep;
const y0 = Math.max(0, Math.floor(cy));
for (let y = y0; y < p.rows; y += 1) {
const d = y + 0.5 - cy;
const dn = clamp01(d / faceH);
const cover = clamp01(y + 1 - cy);
const gully = spec.gully * gl * smoothstep(0, 0.2, dn);
// Shade the ground colour by the sun; the lit warm light fades down the face and in the gullies.
const shade = (0.7 + 0.3 * lit * contrast + 0.3 * (1 - contrast)) * (1 - 0.5 * gully);
const warmK = 0.22 * contrast * lit * lit * (1 - 0.7 * dn) * (1 - 0.6 * gully);
const rimK = clamp01(1 - d / rim) * contrast * (0.3 + 0.7 * lit) * 0.6;
const mist = spec.mist * smoothstep(0, 1, dn);
const o = (y * p.cols + c) * 4;
for (let i = 0; i < 3; i += 1) {
let v = base[i] * shade + warm[i] * (warmK + rimK);
v += (haze[i] - v) * mist;
out[o + i] = v;
}
out[o + 3] = cover * 255;
}
}
}

View file

@ -1,39 +1,137 @@
// Every number of the ridge scene, and the pure functions that turn them into silhouettes. No DOM.
import { fbmPeriodic, FbmShape } from './noise';
// A ridge is a function of world-x (t, in viewport widths): ridged multifractal noise, domain-warped,
// one function for every layer with the parameters below. Nothing in it repeats.
import { valueNoise } from './noise';
import type { Srgb, SkyColours } from './skyColour';
export interface LayerSpec {
/** 0 farthest. Also the noise row, so layers never share a profile. */
index: number;
/** Lattice cells across one period for the first octave; the fbm octaves scale it by `lacunarity`. */
/** Lattice cells per viewport width for the first octave; each octave multiplies it by LACUNARITY. */
baseCells: number;
/** Peak-to-valley height of the silhouette, as a fraction of the layer band. */
/** Peak-to-valley height of the silhouette, as a fraction of the viewport height. */
relief: number;
/** 0 hills (plain fbm) .. 1 crags (ridged: 1 - |2n - 1|). */
/** 0 rounded hills .. 1 sharp crags: how tightly each octave is folded at its crest. */
crag: number;
/** Ridge top at its lowest point, fraction of the viewport height up from the bottom. */
base: number;
/** 0 = the layer is the horizon haze, 1 = the nearest ground colour. */
depth: number;
/** Seconds for the layer to drift one period. Farther layers are slower. */
/** Seconds for the layer to drift one viewport width. Farther layers are slower. */
seconds: number;
/** Octaves of the ridged multifractal; the bake cost is linear in it. */
octaves: number;
/** Domain warp, in cells of the first octave. */
warp: number;
/** 0..1 depth of the erosion streaks down the face. */
gully: number;
/** Width of one gully, CSS px. */
gullyPx: number;
/** 0..1 how far the foot of the face dissolves into the valley mist. */
mist: number;
}
/** Four layers. `lacunarity` 2 with these base cell counts keeps every octave an integer. */
/** Four layers: one function, four parameter rows. */
export const LAYERS: readonly LayerSpec[] = [
{ index: 0, baseCells: 3, relief: 0.24, crag: 0.8, base: 0.34, depth: 0.12, seconds: 420 },
{ index: 1, baseCells: 4, relief: 0.2, crag: 0.55, base: 0.26, depth: 0.38, seconds: 300 },
{ index: 2, baseCells: 5, relief: 0.16, crag: 0.3, base: 0.17, depth: 0.66, seconds: 210 },
{ index: 3, baseCells: 6, relief: 0.12, crag: 0.1, base: 0.08, depth: 1, seconds: 150 },
{
index: 0,
baseCells: 2.2,
relief: 0.24,
crag: 0.85,
base: 0.34,
depth: 0.12,
seconds: 420,
octaves: 5,
warp: 0.6,
gully: 0.3,
gullyPx: 16,
mist: 0.35,
},
{
index: 1,
baseCells: 3.0,
relief: 0.2,
crag: 0.7,
base: 0.26,
depth: 0.38,
seconds: 300,
octaves: 5,
warp: 0.55,
gully: 0.5,
gullyPx: 20,
mist: 0.3,
},
{
index: 2,
baseCells: 3.8,
relief: 0.16,
crag: 0.5,
base: 0.17,
depth: 0.66,
seconds: 210,
octaves: 5,
warp: 0.5,
gully: 0.6,
gullyPx: 24,
mist: 0.22,
},
{
index: 3,
baseCells: 4.6,
relief: 0.12,
crag: 0.3,
base: 0.08,
depth: 1,
seconds: 150,
octaves: 4,
warp: 0.45,
gully: 0.7,
gullyPx: 30,
mist: 0.16,
},
];
export const FBM: FbmShape = { octaves: 5, lacunarity: 2, gain: 0.5 };
/** Not an integer power of two, so no octave's lattice lines up with another's. */
export const LACUNARITY = 2.07;
/** Each octave is weighted by the previous octave's folded signal, times this, clamped to 1. */
export const WEIGHT_GAIN = 2;
const AMP_GAIN = 0.5;
/** Calibration: the weighted sum's typical top is about 1 / this. */
const PEAK_NORM = 1.25;
/** Silhouette height in [0, 1] at `t` (0..1 across one period); periodic in t. */
/** Silhouette height in [0, 1] at world-x `t` (viewport widths); defined for every real t. */
export function profile(spec: LayerSpec, t: number): number {
const n = fbmPeriodic(t, spec.index * 101, spec.baseCells, FBM);
const crag = 1 - Math.abs(2 * n - 1);
return n + (crag - n) * spec.crag;
const row = spec.index * 101;
const wt =
t + (spec.warp * (2 * valueNoise(t * spec.baseCells * 0.7, row + 97) - 1)) / spec.baseCells;
const e = 0.03 + (1 - spec.crag) * 0.4;
const top = Math.sqrt(1 + e * e);
let sum = 0;
let norm = 0;
let amp = 1;
let f = spec.baseCells;
let w = 1;
for (let k = 0; k < spec.octaves; k += 1) {
const v = 2 * valueNoise(wt * f, row + k * 13) - 1;
// Fold: 1 at the zero crossing of the noise, 0 at its extremes; `e` rounds the crease.
let sig = 1 - Math.sqrt(v * v + e * e) / top;
sig *= sig;
sig *= w;
w = Math.min(1, sig * WEIGHT_GAIN);
sum += sig * amp;
norm += amp;
amp *= AMP_GAIN;
f *= LACUNARITY;
}
// A soft swell under the folds, so the valleys between sharp crests are not flat floors.
const swell = valueNoise(wt * spec.baseCells * 0.6, row + 211);
return Math.min(1, 0.85 * (sum / norm) * PEAK_NORM + 0.15 * swell);
}
/** Erosion streak strength in [0, 1] at world-x `wxCss` (CSS px): a folded noise at gully width. */
export function gullyAt(spec: LayerSpec, wxCss: number): number {
const g = 1 - Math.abs(2 * valueNoise(wxCss / spec.gullyPx, spec.index * 131 + 5) - 1);
return g * g;
}
/** The distance haze the far layers dissolve into: between the mid sky and the horizon, not the horizon glow itself. */
@ -75,32 +173,32 @@ export function layerColour(spec: LayerSpec, sky: SkyColours): Srgb {
export const edgeBlur = (spec: LayerSpec): number => AERIAL.blurPx * (1 - transmittance(spec));
/**
* Alpha of the warm sun-side in-scatter at `t` (0..1 across one period; periodic). Zero when the sky has
* no sun; grows with distance, peaks at the sun's azimuth.
* Alpha of the warm sun-side in-scatter at world-x `t` (viewport widths). Zero when the sky has no
* sun; grows with distance. The lobe is the product of two cosines with incommensurate wavelengths
* (1.9 and 1.9 * golden ratio), so it is not periodic; its peaks are where the sun's azimuth puts them.
*/
export function scatterAlpha(spec: LayerSpec, sky: SkyColours, t: number): number {
if (sky.sunAzimuth === undefined) return 0;
const lobe = 0.5 + 0.5 * Math.cos(2 * Math.PI * (t - sky.sunAzimuth));
return AERIAL.scatter * (1 - transmittance(spec)) * lobe * lobe;
const a = 0.5 + 0.5 * Math.cos(2 * Math.PI * (t / 1.9 - sky.sunAzimuth));
const b = 0.75 + 0.25 * Math.cos((2 * Math.PI * t) / (1.9 * 1.618034));
return AERIAL.scatter * (1 - transmittance(spec)) * a * a * b;
}
/** Integer pixel sizes of a layer for a viewport: one period wide, twice that painted, so a drift of one period loops. */
/** Chunk width as a multiple of the viewport width: room for the yaw sway on top of the viewport. */
export const CHUNK_OVER = 1.2;
/**
* Integer pixel sizes for a viewport. `period` is one viewport width, the unit of world-x; a chunk
* is `chunk` pixels of world, `height` the band a layer is painted in.
*/
export function layerSize(cssW: number, cssH: number, scale: number) {
const period = Math.round(cssW * scale);
const band = Math.round(cssH * scale * 0.6);
return { period, width: period * 2, height: band };
return { period, chunk: Math.round(period * CHUNK_OVER), height: band };
}
/** Ridge-top y (canvas pixels, from the top of the layer's band) for pixel column x. */
export function ridgeY(
spec: LayerSpec,
x: number,
period: number,
bandH: number,
viewH: number
): number {
const amp = spec.relief * viewH;
const bottomOffset = spec.base * viewH; // lowest the top sits above the viewport bottom
const topFromBottom = bottomOffset + amp * profile(spec, (x % period) / period);
/** 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): number {
const topFromBottom = spec.base * viewH + spec.relief * viewH * profile(spec, t);
return bandH - Math.min(bandH, topFromBottom);
}

View file

@ -7,8 +7,10 @@ import type { FixedSkyState } from './skyBand';
import {
LIGHTEST_PATCH,
NIGHT_INK_MAX_L,
NOTICE_GROUND,
inkForState,
labelInk,
noticeInk,
sceneInk,
userColorInk,
} from './skyInk';
@ -175,3 +177,28 @@ describe('labelInk', () => {
expect(hueGap(dark, ink)).toBeLessThan(2);
});
});
describe('noticeInk', () => {
const ratio = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 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('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()
);
expect(
ratio(luminance(baked.midnight.tokens.SurfaceVariant.OnContainer), NOTICE_GROUND)
).toBeGreaterThanOrEqual(AA);
});
});

View file

@ -93,3 +93,20 @@ export function userColorInk(userId: string, ink: SkyInk): string {
*/
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.
*/
export const NOTICE_GROUND = 0.01;
/**
* 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.
*/
export const noticeInk = ({ state, phone }: SkyInk): string | undefined =>
phone && state !== 'night' ? baked.midnight.tokens.SurfaceVariant.OnContainer : undefined;

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { ClientConfig, clientDefaultServer } from './useClientConfig';
import config from '../../../config.json';
describe('default homeserver', () => {
it('is gamecult.org when the config names none', () => {
expect(clientDefaultServer({})).toBe('gamecult.org');
expect(clientDefaultServer({ homeserverList: [] })).toBe('gamecult.org');
});
it('is the listed server the config points at', () => {
expect(
clientDefaultServer({ homeserverList: ['chat.gamecult.org'], defaultHomeserver: 0 })
).toBe('chat.gamecult.org');
});
it('is gamecult.org in the repo config.json too', () => {
expect(clientDefaultServer(config as ClientConfig)).toBe('gamecult.org');
});
});

View file

@ -7,6 +7,11 @@ export type HashRouterConfig = {
basename?: string;
};
export type RegistrationConfig = {
enabled?: boolean;
url?: string;
};
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
@ -26,6 +31,13 @@ export type ClientConfig = {
// Per-Mind notification earcons (src/app/features/dings). Off unless `enabled: true`.
dings?: DingsConfig;
// Rooms (ids or aliases) shown as the album view; a room whose create type is org.gamecult.file_zone needs no entry.
fileZoneRooms?: string[];
// The sign-up entry on the sign-in page. Off unless `enabled` is true; `url` is the account
// service's own registration page (a value, never a route this client invents).
registration?: RegistrationConfig;
// Dev preview only: the build answers a pretend homeserver in-page (src/preview-demo).
previewDemo?: boolean;
};
@ -41,7 +53,7 @@ export function useClientConfig(): ClientConfig {
}
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'gamecult.org';
export const clientAllowedServer = (clientConfig: ClientConfig, server: string): boolean => {
const { homeserverList, allowCustomHomeservers } = clientConfig;

View file

@ -9,15 +9,13 @@ import {
} from 'react-router-dom';
import { ClientConfig } from '../hooks/useClientConfig';
import { AuthLayout, Login, Register, ResetPassword } from './auth';
import { authRoutes } from './auth/authRoutes';
import {
DIRECT_PATH,
DESK_PATH,
EXPLORE_PATH,
HOME_PATH,
LOGIN_PATH,
INBOX_PATH,
REGISTER_PATH,
RESET_PASSWORD_PATH,
SPACE_PATH,
_CREATE_PATH,
_FEATURED_PATH,
@ -48,6 +46,7 @@ import { Notifications, Inbox, Invites } from './client/inbox';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
import { Room } from '../features/room';
import { Lobby } from '../features/lobby';
import { Desk } from '../features/desk';
import { WelcomePage } from './client/WelcomePage';
import { EstatePageNav } from './client/EstatePageNav';
import { PageRoot } from '../components/page';
@ -55,7 +54,7 @@ import { ScreenSize } from '../hooks/useScreenSize';
import { MobileFriendlyPageNav } from './MobileFriendly';
import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
import { ClientNonUIFeatures } from './client/ClientNonUIFeatures';
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
import { AuthRouteThemeManager } from './ThemeManager';
import { ReceiveSelfDeviceVerification } from '../components/DeviceVerification';
import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
import { RoomSettingsRenderer } from '../features/room-settings';
@ -86,25 +85,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
return redirect(getLoginPath());
}}
/>
<Route
loader={() => {
if (getFallbackSession()) {
return redirect(getHomePath());
}
return null;
}}
element={
<>
<AuthLayout />
<UnAuthRouteThemeManager />
</>
}
>
<Route path={LOGIN_PATH} element={<Login />} />
<Route path={REGISTER_PATH} element={<Register />} />
<Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
</Route>
{authRoutes()}
<Route
loader={() => {
@ -246,6 +227,15 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_FEATURED_PATH} element={<FeaturedRooms />} />
<Route path={_SERVER_PATH} element={<PublicRooms />} />
</Route>
<Route
path={DESK_PATH}
element={
// The navigator shows beside the desk on wide screens; a phone shows the desk alone.
<PageRoot nav={<EstatePageNav path={HOME_PATH} />}>
<Desk />
</PageRoot>
}
/>
<Route path={CREATE_PATH} element={<Create />} />
<Route
path={INBOX_PATH}

View file

@ -0,0 +1,22 @@
import { Box, Text, color } from 'folds';
import React from 'react';
import { useClientConfig } from '../../hooks/useClientConfig';
import { registrationEntry } from './registrationEntry';
// Beside Sign in. Nothing renders unless the config switch is on; the consent sentence and its
// version come from trainingNotice.ts, the same ones a room shows.
export function RegisterEntry() {
const config = useClientConfig();
const entry = registrationEntry(config);
if (!entry) return null;
return (
<Box direction="Column" gap="100" alignItems="Center">
<Text align="Center">
Do not have an account? <a href={entry.url}>Register</a>
</Text>
<Text size="T200" align="Center" style={{ color: color.SurfaceVariant.OnContainer }}>
{entry.notice}
</Text>
</Box>
);
}

View file

@ -0,0 +1,54 @@
import { Box, Header, Scroll, Text } from 'folds';
import classNames from 'classnames';
import React from 'react';
import { Link, Navigate } from 'react-router-dom';
import { useClientConfig } from '../../hooks/useClientConfig';
import { getLoginPath } from '../pathUtils';
import { registrationEntry } from './registrationEntry';
import { AuthFooter } from './AuthFooter';
import * as css from './styles.css';
import * as PatternsCss from '../../styles/Patterns.css';
import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg';
import { LogoMark } from '../../components/logo/logoMark.css';
// Where the account service sends a newcomer back. It promises nothing about mail or timing:
// the sign-up page said what happens next, and this page points back to it. It has its own card
// because AuthLayout looks up a homeserver first and sends paths it does not know to sign in.
export function SignedUp() {
const config = useClientConfig();
if (!registrationEntry(config)) return <Navigate to={getLoginPath()} replace />;
return (
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
<Box
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
direction="Column"
alignItems="Center"
justifyContent="SpaceBetween"
gap="400"
>
<Box direction="Column" className={css.AuthCard}>
<Header className={css.AuthHeader} size="600" variant="Surface">
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
<img
className={`${css.AuthLogo} ${LogoMark}`}
src={RatatoskrSVG}
alt="Ratatoskr logo"
/>
<Text size="H3">Ratatoskr</Text>
</Box>
</Header>
<Box className={css.AuthCardContent} direction="Column" gap="500">
<Text size="H2" priority="400">
You signed up
</Text>
<Text>Follow the steps from the sign-up page to finish. Then sign in here.</Text>
<Text>
<Link to={getLoginPath()}>Sign in</Link>
</Text>
</Box>
</Box>
<AuthFooter />
</Box>
</Scroll>
);
}

View file

@ -57,7 +57,14 @@ export const serverTroubleMessage = (
export const noSignInMethodMessage = (server: string): string =>
`${server} does not offer a way to sign in that this app can use. Pick another server from the Homeserver list, or tell whoever runs ${server}.`;
export type RegisterTrouble = 'closed' | 'rate-limited' | 'invalid' | 'unsupported';
export type RegisterTrouble =
| 'closed'
| 'rate-limited'
| 'invalid'
| 'unsupported'
| 'password-weak'
| 'password-short'
| 'unknown';
export const registerTroubleMessage = (trouble: RegisterTrouble, server: string): string => {
switch (trouble) {
@ -67,6 +74,12 @@ export const registerTroubleMessage = (trouble: RegisterTrouble, server: string)
return 'Too many tries in a short time. Wait a minute, then try again.';
case 'invalid':
return `${server} did not accept the request to make an account. Reload the page and try again; if it keeps failing, tell whoever invited you.`;
case 'password-weak':
return 'That password is too easy to guess. Pick a longer one, with more than one kind of character.';
case 'password-short':
return 'That password is too short. Pick a longer one.';
case 'unknown':
return `Something went wrong making the account on ${server}. Try again in a minute; if it keeps failing, tell whoever invited you.`;
case 'unsupported':
default:
return `${server} asks for something this app cannot do when making an account. Tell whoever invited you.`;

View file

@ -0,0 +1,40 @@
import React from 'react';
import { Route, redirect } from 'react-router-dom';
import { getFallbackSession } from '../../state/sessions';
import { UnAuthRouteThemeManager } from '../ThemeManager';
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, SIGNED_UP_PATH } from '../paths';
import { getHomePath } from '../pathUtils';
import { AuthLayout } from './AuthLayout';
import { Login } from './login';
import { RegisterRoute } from './register/RegisterRoute';
import { ResetPassword } from './reset-password';
import { SignedUp } from './SignedUp';
// The routes a signed-out visitor sees. The sign-up return page sits beside the layout, not in
// it: the layout looks up a homeserver and sends any path it does not know to sign in.
export const authRoutes = () => (
<>
<Route
loader={() => (getFallbackSession() ? redirect(getHomePath()) : null)}
element={
<>
<AuthLayout />
<UnAuthRouteThemeManager />
</>
}
>
<Route path={LOGIN_PATH} element={<Login />} />
<Route path={REGISTER_PATH} element={<RegisterRoute />} />
<Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
</Route>
<Route
path={SIGNED_UP_PATH}
element={
<>
<SignedUp />
<UnAuthRouteThemeManager />
</>
}
/>
</>
);

View file

@ -1,4 +1,5 @@
export * from './AuthLayout';
export * from './login';
export * from './register';
export * from './SignedUp';
export * from './reset-password';

View file

@ -9,6 +9,8 @@ import { PasswordLoginForm } from './PasswordLoginForm';
import { SSOLogin } from '../SSOLogin';
import { TokenLogin } from './TokenLogin';
import { OrDivider } from '../OrDivider';
import { RegisterEntry } from '../RegisterEntry';
import { registrationEntry } from '../registrationEntry';
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
import { LoginPathSearchParams } from '../../paths';
@ -37,7 +39,8 @@ const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchPar
export function Login() {
const server = useAuthServer();
const { hashRouter, previewDemo } = useClientConfig();
const config = useClientConfig();
const { hashRouter, previewDemo } = config;
const { loginFlows } = useAuthFlows();
const [searchParams] = useSearchParams();
const loginSearchParams = useLoginSearchParams(searchParams);
@ -90,9 +93,13 @@ export function Login() {
<span data-spacing-node />
</>
)}
<Text align="Center">
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
</Text>
{registrationEntry(config) ? (
<RegisterEntry />
) : (
<Text align="Center">
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
</Text>
)}
</Box>
);
}

View file

@ -30,7 +30,15 @@ import {
import { useUIACompleted, useUIAFlow, useUIAParams } from '../../../hooks/useUIAFlows';
import { AsyncState, AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import { RegisterError, RegisterResult, register, useRegisterComplete } from './registerUtil';
import {
RegisterError,
RegisterResult,
register,
registerErrorTrouble,
useRegisterComplete,
} from './registerUtil';
import { registerTroubleMessage } from '../authMessages';
import { useAuthServer } from '../../../hooks/useAuthServer';
import { FieldError } from '../FiledError';
import { TrainingNotice } from '../TrainingNotice';
import {
@ -185,6 +193,7 @@ export function PasswordRegisterForm({
defaultEmail,
defaultRegisterToken,
}: PasswordRegisterFormProps) {
const server = useAuthServer();
const serverDiscovery = useAutoDiscoveryInfo();
const baseUrl = serverDiscovery['m.homeserver'].base_url;
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
@ -207,6 +216,9 @@ export function PasswordRegisterForm({
const registerError =
registerState.status === AsyncStatus.Error ? registerState.error : undefined;
// The password troubles show under the password field; the rest is the form's.
const registerTrouble = registerError && registerErrorTrouble(registerError.errcode);
const formTrouble = registerTrouble?.startsWith('password-') ? undefined : registerTrouble;
useRegisterComplete(customRegisterResp);
const handleSubmit: ChangeEventHandler<HTMLFormElement> = (evt) => {
@ -298,20 +310,10 @@ export function PasswordRegisterForm({
required
/>
{registerError?.errcode === RegisterError.PasswordWeak && (
<FieldError
message={
registerError.data.error ??
'Weak Password. Password rejected by server please choosing more strong Password.'
}
/>
<FieldError message={registerTroubleMessage('password-weak', server)} />
)}
{registerError?.errcode === RegisterError.PasswordShort && (
<FieldError
message={
registerError.data.error ??
'Short Password. Password rejected by server please choosing more long Password.'
}
/>
<FieldError message={registerTroubleMessage('password-short', server)} />
)}
</Box>
<Box direction="Column" gap="100">
@ -378,18 +380,7 @@ export function PasswordRegisterForm({
</Text>
</Box>
)}
{registerError?.errcode === RegisterError.RateLimited && (
<FieldError message="Failed to register. Your register request has been rate-limited by server, Please try after some time." />
)}
{registerError?.errcode === RegisterError.Forbidden && (
<FieldError message="Failed to register. The homeserver does not permit registration." />
)}
{registerError?.errcode === RegisterError.InvalidRequest && (
<FieldError message="Failed to register. Invalid request." />
)}
{registerError?.errcode === RegisterError.Unknown && (
<FieldError message={registerError.data.error ?? 'Failed to register. Unknown Reason.'} />
)}
{formTrouble && <FieldError message={registerTroubleMessage(formTrouble, server)} />}
<span data-spacing-node />
<TrainingNotice checked={trainingAck} onToggle={() => setTrainingAck((ack) => !ack)} />
<Button variant="Primary" size="500" type="submit" disabled={!trainingAck}>

View file

@ -0,0 +1,37 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { renderToString } from 'react-dom/server';
import { ClientConfigProvider } from '../../../hooks/useClientConfig';
import type { ClientConfig } from '../../../hooks/useClientConfig';
import { RegisterRoute } from './RegisterRoute';
// The in-client form needs a homeserver; the route test only needs to know whether it was asked for.
vi.mock('./Register', () => ({ Register: () => <div>in-client register form</div> }));
const render = (config: ClientConfig) =>
renderToString(
<ClientConfigProvider value={config}>
<RegisterRoute />
</ClientConfigProvider>
);
describe('the /register route', () => {
it('with the switch on offers only the account service link, not the in-client form', () => {
const html = render({ registration: { enabled: true, url: 'https://accounts.example/join' } });
expect(html).toContain('href="https://accounts.example/join"');
expect(html).not.toContain('in-client register form');
});
it('with the switch off shows the in-client form', () => {
expect(render({ registration: { enabled: false, url: 'https://accounts.example/join' } })).toContain(
'in-client register form'
);
expect(render({})).toContain('in-client register form');
});
it('with the switch on but an unusable url falls back to the form', () => {
expect(render({ registration: { enabled: true, url: 'ftp://accounts.example/join' } })).toContain(
'in-client register form'
);
});
});

View file

@ -0,0 +1,26 @@
import { Box, Text } from 'folds';
import React, { useEffect } from 'react';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { registrationEntry } from '../registrationEntry';
import { Register } from './Register';
// With the Register switch on, a stranger gets one way to register: the account service's own
// page. This route sends them there (the link stays for when the redirect does not run). With
// the switch off, the in-client form is the way.
export function RegisterRoute() {
const entry = registrationEntry(useClientConfig());
useEffect(() => {
if (entry) window.location.replace(entry.url);
}, [entry]);
if (!entry) return <Register />;
return (
<Box direction="Column" gap="300">
<Text size="H2" priority="400">
Register
</Text>
<Text>
Sign-up happens on the account page. <a href={entry.url}>Continue to sign up</a>
</Text>
</Box>
);
}

View file

@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { registerTroubleMessage } from '../authMessages';
import { RegisterError, registerErrorTrouble } from './registerUtil';
// What the form shows for a failed registration, from the error code the server answered with.
const shown = (errcode: string) => {
const trouble = registerErrorTrouble(errcode);
return trouble && registerTroubleMessage(trouble, 'chat.example');
};
describe('registration failures as the form shows them', () => {
it('say what to do next for every failure that is not a username field error', () => {
[
RegisterError.PasswordWeak,
RegisterError.PasswordShort,
RegisterError.RateLimited,
RegisterError.Forbidden,
RegisterError.InvalidRequest,
RegisterError.Unknown,
].forEach((e) => expect(shown(e)?.length).toBeGreaterThan(20));
});
it('never carry the server text or a Matrix error code', () => {
Object.values(RegisterError).forEach((e) => {
expect(shown(e) ?? '').not.toMatch(/M_[A-Z_]+|errcode|homeserver does not/i);
});
});
it('leave the username errors to their own field messages', () => {
[RegisterError.UserTaken, RegisterError.UserInvalid, RegisterError.UserExclusive].forEach((e) =>
expect(shown(e)).toBeUndefined()
);
});
it('tell a closed server from a rate limit', () => {
expect(shown(RegisterError.Forbidden)).toContain('chat.example');
expect(shown(RegisterError.RateLimited)).not.toBe(shown(RegisterError.Forbidden));
});
});

View file

@ -17,6 +17,7 @@ import {
import { getHomePath, getLoginPath, withSearchParam } from '../../pathUtils';
import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
import { setFallbackSession } from '../../../state/sessions';
import type { RegisterTrouble } from '../authMessages';
export enum RegisterError {
UserTaken = 'UserTaken',
@ -30,6 +31,27 @@ export enum RegisterError {
Unknown = 'Unknown',
}
// The form-level words for a failed registration, never the server's own text. The username
// errors are the form's own field messages and have no trouble here.
export const registerErrorTrouble = (errcode: string): RegisterTrouble | undefined => {
switch (errcode) {
case RegisterError.PasswordWeak:
return 'password-weak';
case RegisterError.PasswordShort:
return 'password-short';
case RegisterError.RateLimited:
return 'rate-limited';
case RegisterError.Forbidden:
return 'closed';
case RegisterError.InvalidRequest:
return 'invalid';
case RegisterError.Unknown:
return 'unknown';
default:
return undefined;
}
};
export type CustomRegisterResponse = {
baseUrl: string;
response: RegisterResponse;

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { registrationEntry } from './registrationEntry';
import type { ClientConfig } from '../../hooks/useClientConfig';
import {
PREVIEW_NOTICE_TEXT,
TRAINING_NOTICE_TEXT,
TRAINING_NOTICE_VERSION,
} from '../../utils/trainingNotice';
import config from '../../../../config.json';
const url = 'https://accounts.example/register';
describe('registration entry', () => {
it('is hidden when the switch is absent or off', () => {
expect(registrationEntry({})).toBeUndefined();
expect(registrationEntry({ registration: { url } })).toBeUndefined();
expect(registrationEntry({ registration: { enabled: false, url } })).toBeUndefined();
});
it('ships off in config.json', () => {
expect(registrationEntry(config as ClientConfig)).toBeUndefined();
expect(config.registration.enabled).toBe(false);
});
it('is hidden when the switch is on but the url is no web address', () => {
expect(registrationEntry({ registration: { enabled: true } })).toBeUndefined();
expect(registrationEntry({ registration: { enabled: true, url: '' } })).toBeUndefined();
expect(
registrationEntry({ registration: { enabled: true, url: '/register' } })
).toBeUndefined();
expect(
registrationEntry({ registration: { enabled: true, url: 'data:text/html,x' } })
).toBeUndefined();
});
it('reads the consent text and version from trainingNotice.ts', () => {
expect(registrationEntry({ registration: { enabled: true, url } })).toEqual({
url,
notice: TRAINING_NOTICE_TEXT,
noticeVersion: TRAINING_NOTICE_VERSION,
});
});
it('says the pretend sentence on the pretend server', () => {
const entry = registrationEntry({ previewDemo: true, registration: { enabled: true, url } });
expect(entry?.notice).toBe(PREVIEW_NOTICE_TEXT);
});
it('accepts http only for a local development host', () => {
const on = (u: string) => registrationEntry({ registration: { enabled: true, url: u } });
expect(on('http://localhost:8080/register')?.url).toBe('http://localhost:8080/register');
expect(on('http://127.0.0.1:8080/register')).toBeDefined();
expect(on('http://accounts.example/register')).toBeUndefined();
expect(on('http://localhost.evil.example/register')).toBeUndefined();
expect(on('https://accounts.example/register')).toBeDefined();
});
});

View file

@ -0,0 +1,32 @@
import type { ClientConfig } from '../../hooks/useClientConfig';
import { TRAINING_NOTICE_VERSION, trainingNoticeText } from '../../utils/trainingNotice';
export type RegistrationEntry = { url: string; notice: string; noticeVersion: number };
// Plain http is for a developer's own machine only; a stranger's sign-up page is https.
const isLocalHost = (hostname: string): boolean =>
hostname === 'localhost' ||
hostname.endsWith('.localhost') ||
hostname === '127.0.0.1' ||
hostname === '[::1]';
/**
* The sign-up entry the sign-in page may show: the account service's registration page, when the
* switch is on and the configured url is an https address (http only for localhost). Anything else is no entry. The
* consent sentence and its version are trainingNotice.ts's, the ones a room shows.
*/
export const registrationEntry = (config: ClientConfig): RegistrationEntry | undefined => {
const { enabled, url } = config.registration ?? {};
if (enabled !== true || typeof url !== 'string') return undefined;
try {
const { protocol, hostname } = new URL(url);
if (protocol !== 'https:' && !(protocol === 'http:' && isLocalHost(hostname))) return undefined;
} catch {
return undefined;
}
return {
url,
notice: trainingNoticeText(config.previewDemo),
noticeVersion: TRAINING_NOTICE_VERSION,
};
};

View file

@ -1,5 +1,6 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
import { FOOTER_TINT, PLATE_TINT } from '../../features/scene/skyPlate';
export const AuthLayout = style({
@ -127,3 +128,15 @@ globalStyle(`${scene} ${AuthFooter}`, {
globalStyle(`${scene} ${AuthFooter} a`, {
color: 'var(--sky-bubble-text, #b7c7d9)',
});
// Phone tap targets for the sign-in links, hit area only (every link is under 44 px tall: Forget Password? 16,
// Register 20, the footer's 22): vertical padding grows each link's box and the negative margin takes the growth
// 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`, {
'@media': { [SCREEN_PHONE]: { paddingBlock: 14, marginBlock: -14 } },
});
globalStyle(`${AuthFooter} a`, {
'@media': { [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 } },
});

View file

@ -23,6 +23,7 @@ import {
SPACE_ROOM_PATH,
SPACE_SEARCH_PATH,
CREATE_PATH,
DESK_PATH,
} from './paths';
import { trimLeadingSlash, trimTrailingSlash } from '../utils/common';
import { HashRouterConfig } from '../hooks/useClientConfig';
@ -153,6 +154,9 @@ export const getExploreServerPath = (server: string): string => {
return generatePath(EXPLORE_SERVER_PATH, params);
};
export const getDeskPath = (mind: string): string =>
generatePath(DESK_PATH, { mind: encodeURIComponent(mind) });
export const getCreatePath = (): string => CREATE_PATH;
export const getInboxPath = (): string => INBOX_PATH;

View file

@ -17,6 +17,8 @@ export const REGISTER_PATH = '/register/:server?/';
export type ResetPasswordPathSearchParams = {
email?: string;
};
export const SIGNED_UP_PATH = '/signed-up/';
export const RESET_PASSWORD_PATH = '/reset-password/:server?/';
export const _CREATE_PATH = 'create/';
@ -74,6 +76,9 @@ export type ExploreServerPathSearchParams = {
};
export const EXPLORE_SERVER_PATH = `/explore/${_SERVER_PATH}`;
/** One Mind's desk: its open questions, read-only until sign-in reaches chat. */
export const DESK_PATH = '/desk/:mind/';
export const CREATE_PATH = '/create';
export const _NOTIFICATIONS_PATH = 'notifications/';

View file

@ -0,0 +1,51 @@
import React from 'react';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { renderToString } from 'react-dom/server';
import { Route, createRoutesFromElements } from 'react-router-dom';
import { StaticRouter, createStaticHandler } from 'react-router-dom/server';
import { ClientConfigProvider } from '../hooks/useClientConfig';
import type { ClientConfig } from '../hooks/useClientConfig';
import { SIGNED_UP_PATH } from './paths';
const config: ClientConfig = { registration: { enabled: true, url: 'https://accounts.example/' } };
// The routes read localStorage when imported (settings atoms); the node environment has none.
let authRoutes: typeof import('./auth/authRoutes').authRoutes;
let SignedUp: typeof import('./auth').SignedUp;
beforeAll(async () => {
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
});
({ authRoutes } = await import('./auth/authRoutes'));
({ SignedUp } = await import('./auth'));
}, 120_000);
const matchPath = async (path: string) => {
const { query } = createStaticHandler(createRoutesFromElements(<Route>{authRoutes()}</Route>));
const context = await query(new Request(`http://localhost${path}`));
if (context instanceof Response) {
throw new Error(`redirected to ${context.headers.get('Location')}`);
}
return context.matches;
};
describe('the sign-up return page in the real route table', () => {
it('is its own route, outside AuthLayout (which would send it to sign in)', async () => {
const matches = await matchPath(SIGNED_UP_PATH);
// the pathless root, then the page itself: no layout route in between
expect(matches?.map((m) => m.route.path)).toEqual([undefined, SIGNED_UP_PATH]);
});
it('renders its words, not the sign-in form, when the switch is on', () => {
const html = renderToString(
<ClientConfigProvider value={config}>
<StaticRouter location={SIGNED_UP_PATH}>
<SignedUp />
</StaticRouter>
</ClientConfigProvider>
);
expect(html).toContain('Follow the steps from the sign-up page');
});
});

View file

@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import zlib from 'zlib';
import { compile, processVanillaFile } from '@vanilla-extract/integration';
import { MOBILE_BREAKPOINT } from './hooks/useScreenSize';
// The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does;
// vanilla-extract gzips the inlined source only when it is large, so both forms are read).
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();
};
type Decls = Record<string, number>;
const parse = (body: string): Decls =>
Object.fromEntries(
body
.split('\n')
.map((l) => l.trim().replace(/;$/, ''))
.filter((l) => /^[a-z-]+: -?\d+px$/.test(l))
.map((l) => [l.split(':')[0], Number(l.split(': ')[1].replace('px', ''))])
);
/** Pixel declarations of the rules whose selector matches, inside or outside a media query. */
const rulesFor = (css: string, selector: RegExp, media: string | undefined): Decls[] => {
const scope = media
? [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)]
.filter((m) => m[1] === media)
.map((m) => m[2])
.join('\n')
: css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, '');
return [...scope.matchAll(/^\s*([^{}\n]+) \{\n([^}]*)\}/gm)]
.filter((m) => selector.test(m[1]))
.map((m) => parse(m[2]));
};
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][] = [
[
'sender-name button',
'components/message/layout/layout.css.ts',
/^button\.layout_Username__/,
23,
],
[
'room-topic button',
'features/room/RoomViewHeader.css.ts',
/RoomViewHeader_HeaderTopic__\w+$/,
18,
],
['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],
];
describe.each(targets)('phone tap target: %s', (_name, file, selector, height) => {
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'];
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);
});
it('leaves every other screen as it was: no padding or margin outside the phone query', async () => {
const css = await emitted(file);
rulesFor(css, selector, undefined).forEach((decls) =>
expect(Object.keys(decls).filter((k) => /^(padding|margin)/.test(k))).toEqual([])
);
});
});

View file

@ -40,17 +40,17 @@ export function setFallbackSession(
localStorage.setItem('cinny_user_id', userId);
localStorage.setItem('cinny_hs_base_url', baseUrl);
}
export const removeFallbackSession = () => {
localStorage.removeItem('cinny_hs_base_url');
localStorage.removeItem('cinny_user_id');
localStorage.removeItem('cinny_device_id');
localStorage.removeItem('cinny_access_token');
export const removeFallbackSession = (storage: Storage = localStorage) => {
storage.removeItem('cinny_hs_base_url');
storage.removeItem('cinny_user_id');
storage.removeItem('cinny_device_id');
storage.removeItem('cinny_access_token');
};
export const getFallbackSession = (): Session | undefined => {
const baseUrl = localStorage.getItem('cinny_hs_base_url');
const userId = localStorage.getItem('cinny_user_id');
const deviceId = localStorage.getItem('cinny_device_id');
const accessToken = localStorage.getItem('cinny_access_token');
export const getFallbackSession = (storage: Storage = localStorage): Session | undefined => {
const baseUrl = storage.getItem('cinny_hs_base_url');
const userId = storage.getItem('cinny_user_id');
const deviceId = storage.getItem('cinny_device_id');
const accessToken = storage.getItem('cinny_access_token');
if (baseUrl && userId && deviceId && accessToken) {
const session: Session = {

View file

@ -28,6 +28,13 @@ export const trainingNoticeText = (previewDemo?: boolean): string =>
* else is malformed. Absent or malformed in any way means no train line.
*/
export const MAX_NOTICE_VERSION = 4294967295;
/**
* The version of the notice wording above. Registration shows it with the text, and a room's
* notice event carries the same number, so one text has one version on both surfaces. Bump it
* with the wording and nowhere else.
*/
export const TRAINING_NOTICE_VERSION = 1;
export const ROOM_NOTICE_EVENT_TYPE = StateEvent.GameCultNotice;
export type RoomNotice = { version: number; scope: 'show' | 'study' | 'train' };

View file

@ -30,29 +30,32 @@ import { getFallbackSession } from './app/state/sessions';
document.body.classList.add(configClass, varsClass);
// Register Service Worker
if ('serviceWorker' in navigator) {
const swUrl =
import.meta.env.MODE === 'production'
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
: `/dev-sw.js?dev-sw`;
// Register Service Worker. After the preview demo has settled the stored session: the worker is
// handed that session, and the demo may drop one it cannot serve.
const registerServiceWorker = () => {
if ('serviceWorker' in navigator) {
const swUrl =
import.meta.env.MODE === 'production'
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
: `/dev-sw.js?dev-sw`;
const sendSessionToSW = () => {
const session = getFallbackSession();
pushSessionToSW(session?.baseUrl, session?.accessToken);
};
const sendSessionToSW = () => {
const session = getFallbackSession();
pushSessionToSW(session?.baseUrl, session?.accessToken);
};
navigator.serviceWorker.register(swUrl).then(sendSessionToSW);
navigator.serviceWorker.ready.then(sendSessionToSW);
navigator.serviceWorker.register(swUrl).then(sendSessionToSW);
navigator.serviceWorker.ready.then(sendSessionToSW);
navigator.serviceWorker.addEventListener('message', (ev) => {
const { type } = ev.data ?? {};
navigator.serviceWorker.addEventListener('message', (ev) => {
const { type } = ev.data ?? {};
if (type === 'requestSession') {
sendSessionToSW();
}
});
}
if (type === 'requestSession') {
sendSessionToSW();
}
});
}
};
// The pretend homeserver of the dev preview: built in only with VITE_PREVIEW_DEMO=1 (the dev
// workflow) and switched on by `previewDemo: true` in config.json. Any other build drops it.
@ -76,4 +79,7 @@ const mountApp = () => {
root.render(<App />);
};
installPreviewDemo().finally(mountApp);
installPreviewDemo().finally(() => {
registerServiceWorker();
mountApp();
});

View file

@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { buildDesk } from '../app/features/desk/buildDesk';
import { ForkList } from '../app/features/desk/forkContract';
import { deskFor, setDeskSource } from '../app/features/desk/source';
import { unverifiedForks } from '../app/features/desk/optionsHash';
import { demoDesk } from './deskForks';
const KEYS = {
list: ['schemaVersion', 'mind', 'stamp', 'open_count', 'recorded_count', 'forks', 'next_cursor'],
card: [
'schemaVersion',
'fork_id',
'title',
'context',
'options',
'recommendation',
'depends',
'leans',
'flag',
'raised_in',
'deciders',
'status',
'ruling',
'ruled_from',
'order',
'options_hash',
],
record: ['schemaVersion', 'fork_id', 'options_hash', 'counted_answer_id', 'answers'],
answer: [
'schemaVersion',
'answer_id',
'fork_id',
'outcome',
'of',
'retracts',
'code',
'options_hash',
'choice',
'note',
'answered_at',
'received_at',
'claim_in_future',
'answerer',
'authority',
'request_id',
],
};
const sorted = (o: object) => Object.keys(o).sort();
describe('the demo desks', () => {
const nibu = demoDesk('nibu') as ForkList;
it('are contract-shaped: every key written, no others, counts matching the forks', () => {
['nibu', 'eureka'].forEach((m) => {
const l = demoDesk(m) as ForkList;
expect(sorted(l)).toEqual([...KEYS.list].sort());
expect(l.open_count).toBe(l.forks.filter((f) => f.card.status === 'open').length);
expect(l.recorded_count).toBe(l.forks.filter((f) => f.card.status === 'closed').length);
l.forks.forEach(({ card, record }) => {
expect(sorted(card)).toEqual([...KEYS.card].sort());
expect(sorted(record)).toEqual([...KEYS.record].sort());
expect(record.fork_id).toBe(card.fork_id);
expect(record.options_hash).toBe(card.options_hash);
record.answers.forEach((a) => expect(sorted(a)).toEqual([...KEYS.answer].sort()));
const ids = record.answers.map((a) => a.answer_id);
if (record.counted_answer_id !== null) expect(ids).toContain(record.counted_answer_id);
expect(card.options.map((o) => o.id)).toContain(card.recommendation);
if (card.status === 'closed') expect(card.ruling).not.toBeNull();
});
});
});
it('seed open forks, a counted answer with a note and one without, a revision, and a ruling', () => {
const answers = nibu.forks.flatMap((f) => f.record.answers);
const counted = nibu.forks.flatMap((f) =>
f.record.answers.filter((a) => a.answer_id === f.record.counted_answer_id)
);
expect(nibu.open_count).toBe(3);
expect(counted.some((a) => a.note !== null)).toBe(true);
expect(counted.some((a) => a.note === null)).toBe(true);
expect(answers.some((a) => a.outcome === 'Revision')).toBe(true);
expect(buildDesk(nibu).recorded).toEqual([
expect.objectContaining({ rulingId: 'demo:ruling:ridge-low-tier' }),
]);
expect(nibu.forks.some((f) => f.card.leans !== null && f.card.flag !== null)).toBe(true);
});
it('leave other Minds unpublished, so their group shows no desk entry', () => {
expect(demoDesk('kiko')).toBeUndefined();
});
it('are what the page reads once installed as the source', () => {
setDeskSource(demoDesk);
expect(deskFor('nibu')?.mind).toBe('nibu');
setDeskSource(() => undefined);
});
it('carry the hash of what they show, so the desk shows every seeded card', async () => {
expect(await unverifiedForks(nibu.forks)).toEqual(new Set());
expect(await unverifiedForks((demoDesk('eureka') as ForkList).forks)).toEqual(new Set());
});
});

View file

@ -0,0 +1,187 @@
// Seeded forks for the preview's Mind desks, shaped as Bifrost serves them (gamecult.fork.list.v0, CultLib
// fork-card.md). Invented here, public-safe, demo-only: no real question, person or decision. Answer times are
// written against the clock at the time of the call so they stay plausible.
import { DeskAnswer, ForkCard, ForkEntry, ForkList } from '../app/features/desk/forkContract';
const DAY = 24 * 60 * 60 * 1000;
// Each card's options_hash over what it shows (optionsHash.ts); deskForks.test.ts fails if a seed is edited without it.
const HASHES: Record<string, string> = {
'sky-label': '727c7944919baf8953a0473bcbb38837c2e83c87d97a31a09ed6506a0e3b4028',
'dusk-ink': 'e15b64509db522af68ad8904767edf0903a701809e50f1074c1c8b4b3660acae',
'phone-gutter': '893d90ae7bc8e3204afb62501af76375e98597ec565856622401583d6db33324',
'ridge-low-tier': '6edd4a08b6c86703d91b3f48bd697aca16450111573a8e391e3dfe3adc6cbf5f',
'settled-window': 'b0dd0db3a37a256618d447ff8fb4067892b4f1f82d05a1e5bebf5563c447731c',
};
const card = (label: string, order: number, over: Partial<ForkCard>): ForkCard => ({
schemaVersion: 'gamecult.fork.card.v0',
fork_id: `demo:question:${label}`,
title: label,
context: '',
options: [],
recommendation: '',
depends: [],
leans: null,
flag: null,
raised_in: null,
deciders: ['demo-decider'],
status: 'open',
ruling: null,
ruled_from: [],
order,
options_hash: HASHES[label],
...over,
});
const answer = (
c: ForkCard,
n: number,
daysAgo: number,
over: Partial<DeskAnswer> = {}
): DeskAnswer => {
const at = new Date(Date.now() - daysAgo * DAY).toISOString().replace(/\.\d+Z$/, 'Z');
return {
schemaVersion: 'gamecult.desk_answer.v0',
answer_id: `n${n}`,
fork_id: c.fork_id,
outcome: 'Counted',
of: null,
retracts: null,
code: null,
options_hash: c.options_hash,
choice: null,
note: null,
answered_at: at,
received_at: at,
claim_in_future: false,
answerer: `demo-account-${n}`,
authority: 'Member',
request_id: `demo-request-${n}`,
...over,
};
};
const entry = (
c: ForkCard,
answers: DeskAnswer[] = [],
counted: string | null = null
): ForkEntry => ({
schemaVersion: 'gamecult.fork.entry.v0',
card: c,
record: {
schemaVersion: 'gamecult.fork.record.v0',
fork_id: c.fork_id,
options_hash: c.options_hash,
counted_answer_id: counted,
answers,
},
});
const list = (mind: string, forks: ForkEntry[], stamp: string): ForkList => ({
schemaVersion: 'gamecult.fork.list.v0',
mind,
stamp,
open_count: forks.filter((f) => f.card.status === 'open').length,
recorded_count: forks.filter((f) => f.card.status === 'closed').length,
forks: [...forks].sort((a, b) => b.card.order - a.card.order),
next_cursor: null,
});
const nibu = (): ForkList => {
const sky = card('sky-label', 4, {
title: 'Where should the sky label sit on a phone?',
context:
'The label can ride the horizon or sit in the header. The horizon is prettier; the header never moves.',
options: [
{ id: 'horizon', text: 'On the horizon, over the ridge' },
{ id: 'header', text: 'In the header, fixed' },
],
recommendation: 'header',
leans: 'header',
flag: 'Blocks the dusk ink question',
});
const dusk = card('dusk-ink', 3, {
title: 'Should the dusk ink follow the sky or the theme?',
context:
'Names in dusk are readable either way; following the sky is livelier, following the theme is steadier.',
options: [
{ id: 'sky', text: 'Follow the sky' },
{ id: 'theme', text: 'Follow the theme' },
{ id: 'both', text: 'Sky by day, theme by night' },
],
recommendation: 'sky',
depends: ['Where the sky label sits'],
});
const gutter = card('phone-gutter', 2, {
title: 'How wide is the phone gutter?',
context: 'A narrow gutter fits more on screen; a wide one is easier to thumb past.',
options: [
{ id: 'narrow', text: 'Narrow' },
{ id: 'wide', text: 'Wide' },
],
recommendation: 'narrow',
});
const ridge = card('ridge-low-tier', 1, {
title: 'Keep the ridge scene at the low tier?',
context: 'The ridge costs a little on older phones.',
options: [
{ id: 'keep', text: 'Keep it' },
{ id: 'drop', text: 'Drop it' },
],
recommendation: 'keep',
status: 'closed',
ruling: 'demo:ruling:ridge-low-tier',
ruled_from: ['n1'],
});
return list(
'nibu',
[
entry(sky),
entry(
dusk,
[
answer(dusk, 2, 2, { choice: 'sky' }),
answer(dusk, 3, 1, {
outcome: 'Revision',
of: 'n2',
choice: 'both',
answerer: 'demo-account-3',
}),
],
'n2'
),
entry(
gutter,
[answer(gutter, 4, 1, { choice: 'narrow', note: 'Thumbs are smaller than we fear.' })],
'n4'
),
entry(
ridge,
[answer(ridge, 1, 12, { choice: 'keep', note: 'It is the best part of the page.' })],
'n1'
),
],
's41'
);
};
const eureka = (): ForkList => {
const window = card('settled-window', 1, {
title: 'How long should a Mind keep a settled question on its desk?',
context:
'Settled questions are listed as rulings. A shorter window keeps the desk light; a longer one keeps context near.',
options: [
{ id: '30d', text: 'Thirty days' },
{ id: '90d', text: 'Ninety days' },
{ id: 'always', text: 'Keep them all' },
],
recommendation: '30d',
});
return list('eureka', [entry(window)], 's7');
};
const SEEDS: Record<string, () => ForkList> = { nibu, eureka };
/** The demo's desk for a Mind, or nothing: a Mind without seeds has not published yet. */
export const demoDesk = (mind: string): ForkList | undefined => SEEDS[mind]?.();

View file

@ -44,6 +44,8 @@ export type DemoRoom = {
topic?: string;
members: string[];
notice?: 'show' | 'study' | 'train';
/** The m.room.create `type`. */
type?: string;
parent?: string;
steward?: { mind: string; repo: string };
joined: boolean;
@ -54,6 +56,7 @@ export const ROOT = `!root:${DEMO_SERVER}`;
export const PROJECTS = `!projects:${DEMO_SERVER}`;
export const GENERAL = `!general:${DEMO_SERVER}`;
export const COMMONS = `!commons:${DEMO_SERVER}`;
export const FILES = `!files:${DEMO_SERVER}`;
const everyone = USERS.map((u) => u.id);
export const MIND_IDS = [user('eureka'), user('void'), user('nibu')];
@ -115,6 +118,17 @@ export const ROOMS: DemoRoom[] = [
{ from: user('heimdall'), body: 'Ok.', pose: 'thinking', minutesAgo: 20 },
],
},
{
id: FILES,
alias: `#files:${DEMO_SERVER}`,
name: 'Files',
topic: 'The file zone: one thread per Mind, shown as albums.',
type: 'org.gamecult.file_zone',
members: [ME, ...minds],
parent: ROOT,
joined: true,
lines: [],
},
{
id: PROJECTS,
alias: `#projects:${DEMO_SERVER}`,
@ -302,6 +316,36 @@ export const ROOMS: DemoRoom[] = [
},
];
// The file zone's albums: a thread root per album, one thread reply per file. Media is generated
// in media.ts: audio by tone frequency, images by gradient color.
export type DemoAlbum = {
mind: string;
title: string;
minutesAgo: number;
files: { kind: 'audio' | 'image'; name: string; seed: number | [number, number, number] }[];
};
export const ALBUMS: DemoAlbum[] = [
{
mind: user('void'),
title: 'void',
minutesAgo: 90,
files: [
{ kind: 'audio', name: 'low-tone.wav', seed: 220 },
{ kind: 'audio', name: 'mid-tone.wav', seed: 330 },
{ kind: 'audio', name: 'high-tone.wav', seed: 440 },
],
},
{
mind: user('nibu'),
title: 'nibu',
minutesAgo: 30,
files: [
{ kind: 'image', name: 'dusk.png', seed: [200, 90, 60] },
{ kind: 'image', name: 'lagoon.png', seed: [40, 150, 190] },
],
},
];
// A Mind's canned answer to whatever the visitor types: label and line.
export const REPLIES: { pose: string; body: string }[] = [
{ pose: 'thinking', body: 'Hm. Let me think about that.' },

View file

@ -4,8 +4,11 @@
// only by a build with VITE_PREVIEW_DEMO=1 whose config.json says previewDemo: true (src/index.tsx).
// Own-origin requests (the app's static files) pass; the demo origin is answered in-page; any
// other origin is refused, so no real homeserver can be contacted while this is on.
import { removeFallbackSession } from '../app/state/sessions';
import { DEMO_ORIGIN } from './fixtures';
import { createDemoServer, DemoServer } from './server';
import { demoDesk } from './deskForks';
import { setDeskSource } from '../app/features/desk/source';
export type Route = 'own' | 'demo' | 'refuse';
@ -27,8 +30,6 @@ const refusal = (what: string, url: string) =>
new Error(`preview: pretend server only; ${what} to ${url} refused`);
const BANNER_TEXT = 'preview: pretend server. Any password works.';
const FOREIGN_SESSION_TEXT =
'preview: pretend server. A login for another server is stored here and cannot be used.';
// In flow, above the app: body becomes a column and the app root takes the rest, so the banner
// never covers the room header or the connection strip.
@ -72,14 +73,30 @@ export const guardedFetch =
body: parseBody(raw),
signal: init?.signal ?? req?.signal ?? undefined,
});
if (out.raw) {
return new Response(out.raw.data, {
status: out.status,
headers: { 'Content-Type': out.raw.type },
});
}
return new Response(JSON.stringify(out.body), {
status: out.status,
headers: { 'Content-Type': 'application/json' },
});
};
// Every preview of this site shares one origin, so one localStorage: a login another preview left
// (a real homeserver) would make the app connect to that server, which the guard refuses. The
// demo has one server, so a stored login for any other is dropped before the app reads it.
export const dropForeignSession = (storage: Storage) => {
const base = storage.getItem('cinny_hs_base_url');
if (base !== null && base !== DEMO_ORIGIN) removeFallbackSession(storage);
};
export const installDemo = (win: Window & typeof globalThis = window) => {
dropForeignSession(win.localStorage);
const server = createDemoServer();
setDeskSource(demoDesk);
const { origin } = win.location;
const realFetch = win.fetch.bind(win);
@ -111,7 +128,6 @@ export const installDemo = (win: Window & typeof globalThis = window) => {
routeOf(String(url), origin) === 'own' ? realBeacon(url, data) : false;
}
const stored = win.localStorage.getItem('cinny_hs_base_url');
showBanner(stored !== null && stored !== DEMO_ORIGIN ? FOREIGN_SESSION_TEXT : BANNER_TEXT);
showBanner(BANNER_TEXT);
return server;
};

95
src/preview-demo/media.ts Normal file
View file

@ -0,0 +1,95 @@
/* eslint-disable no-bitwise */
// Tiny media for the pretend server's file zone, generated here so the repo holds no binaries.
export type DemoMedia = { type: string; data: Uint8Array };
const u32 = (n: number): number[] => [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255];
const le = (n: number, bytes: number): number[] =>
Array.from({ length: bytes }, (_, i) => (n >>> (8 * i)) & 255);
/** A 0.6 s mono 8 kHz 8-bit WAV: a sine at `hz` with a soft fade. */
export const wavTone = (hz: number): DemoMedia => {
const rate = 8000;
const n = Math.round(rate * 0.6);
const pcm = Array.from({ length: n }, (_, i) => {
const fade = Math.min(1, i / 400, (n - i) / 400);
return Math.round(128 + 90 * fade * Math.sin((2 * Math.PI * hz * i) / rate));
});
const tag = (s: string) => Array.from(s, (c) => c.charCodeAt(0));
return {
type: 'audio/wav',
data: Uint8Array.from([
...tag('RIFF'),
...le(36 + n, 4),
...tag('WAVEfmt '),
...le(16, 4),
...le(1, 2),
...le(1, 2),
...le(rate, 4),
...le(rate, 4),
...le(1, 2),
...le(8, 2),
...tag('data'),
...le(n, 4),
...pcm,
]),
};
};
const crc32 = (bytes: number[]): number => {
let c = ~0;
bytes.forEach((b) => {
c ^= b;
for (let k = 0; k < 8; k += 1) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
});
return ~c >>> 0;
};
const chunk = (name: string, data: number[]): number[] => {
const body = [...Array.from(name, (c) => c.charCodeAt(0)), ...data];
return [...u32(data.length), ...body, ...u32(crc32(body))];
};
/** A 32 x 32 RGB PNG: a diagonal gradient in `rgb`, zlib "stored" blocks, no compression. */
export const pngGradient = (rgb: [number, number, number]): DemoMedia => {
const size = 32;
const raw: number[] = [];
for (let y = 0; y < size; y += 1) {
raw.push(0);
for (let x = 0; x < size; x += 1) {
const f = 0.35 + (0.65 * (x + y)) / (2 * size);
raw.push(...rgb.map((v) => Math.round(v * f)));
}
}
let a = 1;
let b = 0;
raw.forEach((v) => {
a = (a + v) % 65521;
b = (b + a) % 65521;
});
// One stored block holds up to 65535 bytes; 32 * 97 = 3104 fits.
const zlib = [
0x78,
0x01,
1,
...le(raw.length, 2),
...le(~raw.length & 0xffff, 2),
...raw,
...u32(((b << 16) | a) >>> 0),
];
return {
type: 'image/png',
data: Uint8Array.from([
137,
80,
78,
71,
13,
10,
26,
10,
...chunk('IHDR', [...u32(size), ...u32(size), 8, 2, 0, 0, 0]),
...chunk('IDAT', zlib),
...chunk('IEND', []),
]),
};
};

View file

@ -1,10 +1,18 @@
/* eslint-disable max-classes-per-file, no-promise-executor-return, no-useless-constructor, no-empty-function, no-void, @typescript-eslint/no-non-null-assertion */
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ClientEvent, MatrixClient, SyncState, createClient } from 'matrix-js-sdk';
import { DEMO_ORIGIN, ME, ROOMS, GENERAL, COMMONS } from './fixtures';
import { guardedFetch, installDemo, routeOf } from './install';
import {
ClientEvent,
ClientPrefix,
MatrixClient,
Method,
SyncState,
createClient,
} from 'matrix-js-sdk';
import { ALBUMS, DEMO_ORIGIN, ME, ROOMS, GENERAL, COMMONS, FILES } from './fixtures';
import { dropForeignSession, guardedFetch, installDemo, routeOf } from './install';
import { createDemoServer, DemoServer } from './server';
import { seedDemoSettings } from './seed';
import { getFallbackSession } from '../app/state/sessions';
import { mindOfSender, POSE_KEY, resolvePose } from '../app/components/message/pose/poses';
import { parseRoomNotice, roomTrainingLine } from '../app/utils/trainingNotice';
@ -29,6 +37,9 @@ const waitFor = <T>(read: () => T | undefined, ms = 5000): Promise<T> =>
tick();
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Ev = { event_id: string; content: Record<string, any> };
const live: { server: DemoServer; mx: MatrixClient }[] = [];
afterEach(() => {
live.splice(0).forEach(({ server, mx }) => {
@ -115,7 +126,12 @@ describe('the pretend homeserver through matrix-js-sdk', () => {
const ev = mx.getRoom(r.id)!.currentState.getStateEvents('org.gamecult.notice', '');
return [r.name, roomTrainingLine(parseRoomNotice(ev?.getContent())) !== undefined];
});
expect(Object.fromEntries(lines)).toEqual({ General: true, Ratatoskr: false, Huginn: false });
expect(Object.fromEntries(lines)).toEqual({
General: true,
Files: false,
Ratatoskr: false,
Huginn: false,
});
});
it('sends a message, echoes it once with its transaction id, and a Mind answers with a pose', async () => {
@ -171,6 +187,56 @@ describe('the pretend homeserver through matrix-js-sdk', () => {
expect(stewards.filter(Boolean).length).toBe(2);
});
it('syncs the file zone as a joined room whose create type marks it', async () => {
const { mx } = await syncedClient();
const create = mx.getRoom(FILES)!.currentState.getStateEvents('m.room.create', '')!;
expect(create.getContent().type).toBe('org.gamecult.file_zone');
expect(
mx.getRoom(GENERAL)!.currentState.getStateEvents('m.room.create', '')!.getContent().type
).toBeUndefined();
});
it('serves the zone albums through the thread endpoints and their media as bytes', async () => {
const { mx } = await syncedClient();
const get = (path: string, params?: Record<string, string>) =>
mx.http.authedRequest<{ chunk: Ev[] }>(Method.Get, path, params, undefined, {
prefix: ClientPrefix.V1,
});
const roots = (await get(`/rooms/${encodeURIComponent(FILES)}/threads`)).chunk;
expect(roots.map((r) => r.content.body).sort()).toEqual(ALBUMS.map((a) => a.title).sort());
const counts: Record<string, string[]> = {};
await Promise.all(
roots.map(async (root: Ev) => {
const rel = `/rooms/${encodeURIComponent(FILES)}/relations/${encodeURIComponent(
root.event_id
)}/m.thread`;
const oldest = (await get(rel, { dir: 'f' })).chunk;
const newest = (await get(rel)).chunk;
expect(newest.map((e: Ev) => e.event_id)).toEqual(
oldest.map((e: Ev) => e.event_id).reverse()
);
counts[root.content.body] = oldest.map((e: Ev) => e.content.msgtype);
expect(oldest.every((e: Ev) => e.content['m.relates_to'].event_id === root.event_id)).toBe(
true
);
})
);
expect(counts).toEqual({
void: ['m.audio', 'm.audio', 'm.audio'],
nibu: ['m.image', 'm.image'],
});
const f = guardedFetch(createDemoServer(), PAGE, never as never);
const wav = await f(`${DEMO_ORIGIN}/_matrix/client/v1/media/download/demo.invalid/album0file0`);
expect(wav.headers.get('content-type')).toBe('audio/wav');
expect(new TextDecoder().decode((await wav.arrayBuffer()).slice(0, 4))).toBe('RIFF');
const png = await f(`${DEMO_ORIGIN}/_matrix/media/v3/download/demo.invalid/album1file1`);
expect(png.headers.get('content-type')).toBe('image/png');
expect((await png.arrayBuffer()).byteLength).toBeGreaterThan(100);
expect(
(await f(`${DEMO_ORIGIN}/_matrix/client/v1/media/download/demo.invalid/nope`)).status
).toBe(404);
});
it('answers a long poll with nothing new after its timeout', async () => {
const server = createDemoServer();
live.push({ server, mx: clientFor(server) });
@ -233,6 +299,7 @@ describe('the guard', () => {
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
} as unknown as Window & typeof globalThis;
vi.stubGlobal('document', {
@ -254,6 +321,59 @@ describe('the guard', () => {
vi.unstubAllGlobals();
});
it('drops a stored login for a real homeserver before the app reads it, keeps the demo one', () => {
const SESSION = {
cinny_access_token: 'tok',
cinny_device_id: 'DEV',
cinny_user_id: '@a:b',
cinny_hs_base_url: 'https://chat.gamecult.org',
};
const store = new Map<string, string>(Object.entries({ ...SESSION, settings: '{}' }));
const storage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
} as Storage;
dropForeignSession(storage);
expect(getFallbackSession(storage)).toBeUndefined();
expect([...store.keys()]).toEqual(['settings']);
Object.entries({ ...SESSION, cinny_hs_base_url: DEMO_ORIGIN }).forEach(([k, v]) =>
store.set(k, v)
);
dropForeignSession(storage);
expect(getFallbackSession(storage)?.baseUrl).toBe(DEMO_ORIGIN);
});
it('installDemo clears a foreign login so the app starts at the login screen', () => {
const store = new Map<string, string>([
['cinny_hs_base_url', 'https://chat.gamecult.org'],
['cinny_access_token', 'tok'],
['cinny_device_id', 'DEV'],
['cinny_user_id', '@a:b'],
]);
const win = {
location: { origin: PAGE },
fetch: never,
WebSocket: class {},
XMLHttpRequest: { prototype: { open: vi.fn() } },
navigator: {},
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
removeItem: (k: string) => void store.delete(k),
},
} as unknown as Window & typeof globalThis;
vi.stubGlobal('document', {
createElement: () => ({ style: {}, setAttribute: vi.fn() }),
getElementById: () => null,
body: { style: {}, insertBefore: vi.fn() },
});
const server = installDemo(win);
live.push({ server, mx: clientFor(server) });
expect(store.size).toBe(0);
vi.unstubAllGlobals();
});
it('seeds pose portraits on for a first visit and keeps what a visitor already chose', () => {
const fresh = new Map<string, string>();
seedDemoSettings({

View file

@ -3,6 +3,7 @@
// send messages and get a Mind's canned answer. It touches no network and no storage; the
// fetch guard that routes requests here lives in install.ts.
import {
ALBUMS,
COMMONS,
DEMO_DEVICE,
DEMO_ORIGIN,
@ -10,14 +11,17 @@ import {
DEMO_TOKEN,
DemoEvent,
DemoRoom,
FILES,
ME,
MIND_IDS,
REPLIES,
ROOMS,
USERS,
} from './fixtures';
import { DemoMedia, pngGradient, wavTone } from './media';
export type DemoResponse = { status: number; body: unknown };
/** `raw` answers a media download with bytes instead of JSON. */
export type DemoResponse = { status: number; body: unknown; raw?: DemoMedia };
export type DemoRequest = { method: string; url: string; body?: unknown; signal?: AbortSignal };
const POSE_KEY = 'org.gamecult.pose';
@ -79,7 +83,12 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
mk(
'm.room.create',
creator,
room.space ? { type: 'm.space', room_version: '10' } : { room_version: '10' },
{
room_version: '10',
...((room.space ? 'm.space' : room.type)
? { type: room.space ? 'm.space' : room.type }
: {}),
},
t0,
''
),
@ -159,6 +168,45 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
[`@eureka:${DEMO_SERVER}`]: ROOMS.filter((r) => r.direct).map((r) => r.id),
});
// The file zone's albums live outside the sync timeline: thread roots per room, replies per
// root, media by mxc id. The thread endpoints and media downloads answer from these.
const threadRoots = new Map<string, DemoEvent[]>();
const threadReplies = new Map<string, DemoEvent[]>();
const media = new Map<string, DemoMedia>();
ALBUMS.forEach((album, a) => {
const t0 = now() - album.minutesAgo * 60 * 1000;
const root = mk('m.room.message', album.mind, { msgtype: 'm.text', body: album.title }, t0);
threadRoots.set(FILES, [...(threadRoots.get(FILES) ?? []), root]);
threadReplies.set(
root.event_id,
album.files.map((f, i) => {
const id = `album${a}file${i}`;
const m =
f.kind === 'audio'
? wavTone(f.seed as number)
: pngGradient(f.seed as [number, number, number]);
media.set(id, m);
return mk(
'm.room.message',
album.mind,
{
msgtype: f.kind === 'audio' ? 'm.audio' : 'm.image',
body: f.name,
url: `mxc://${DEMO_SERVER}/${id}`,
info: { mimetype: m.type, size: m.data.length },
'm.relates_to': {
rel_type: 'm.thread',
event_id: root.event_id,
is_falling_back: true,
'm.in_reply_to': { event_id: root.event_id },
},
},
t0 + (i + 1) * 60 * 1000
);
})
);
});
const notify = () => {
waiters.forEach((w) => w());
waiters.clear();
@ -290,6 +338,11 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
if (p === '/.well-known/matrix/client')
return ok({ 'm.homeserver': { base_url: DEMO_ORIGIN } });
if (!p.startsWith('/_matrix/')) return err(404, 'M_NOT_FOUND', 'Not found');
g = p.match(/^\/_matrix\/(?:client\/v1\/media|media\/v3)\/download\/[^/]+\/([^/]+)$/);
if (g) {
const found = media.get(g[1]);
return found ? { status: 200, body: null, raw: found } : err(404, 'M_NOT_FOUND', 'No media');
}
const c = p.replace(/^\/_matrix\/client\/(v\d+|r0|unstable)/, '');
if (p.endsWith('/versions'))
return ok({ versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
@ -394,6 +447,13 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
if (g) {
return ok({ chunk: (stateOf.get(g[1]) ?? []).filter((e) => e.type === 'm.room.member') });
}
g = c.match(/^\/rooms\/([^/]+)\/threads$/);
if (g) return ok({ chunk: [...(threadRoots.get(g[1]) ?? [])].reverse() });
g = c.match(/^\/rooms\/([^/]+)\/relations\/([^/]+)\/m\.thread$/);
if (g) {
const oldestFirst = [...(threadReplies.get(g[2]) ?? [])];
return ok({ chunk: u.searchParams.get('dir') === 'f' ? oldestFirst : oldestFirst.reverse() });
}
g = c.match(/^\/rooms\/([^/]+)\/messages$/);
if (g) return ok({ chunk: [], start: 'p0', end: 'p0' });
g = c.match(/^\/rooms\/([^/]+)\/(?:summary|context\/.+)$/);