desk: read-only Mind desk, slice 1 #77

Merged
void merged 2 commits from void/desk-slice-1 into dev 2026-11-10 18:29:38 +00:00
24 changed files with 1710 additions and 6 deletions

View file

@ -12,7 +12,8 @@ type PoseAvatarProps = {
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>;
};
export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) {
@ -43,9 +44,10 @@ export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvat
};
}, [src, alt]);
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 }}
data-user-id={userId}
@ -59,6 +61,6 @@ export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvat
alt={`${mind}, ${pose}`}
draggable={false}
/>
</button>
</Tag>
);
}

View file

@ -0,0 +1,62 @@
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 projection = 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(projection?.questions ?? []).then((bad) => live && setUnverified(bad));
return () => {
live = false;
};
}, [projection]);
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}
projection={projection}
now={Date.now()}
unverified={unverified}
/>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1,17 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Text } from 'folds';
import { getDeskPath } from '../../pages/pathUtils';
import { countOpen } from './buildDesk';
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 = countOpen(deskFor(mind));
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,136 @@
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 { NOW, STATES } from './fixtures';
import { setDeskSource } from './source';
const view = (
state: keyof typeof STATES,
size: ScreenSize,
mind = 'void',
unverified = new Set<string>()
) =>
renderToStaticMarkup(
<ScreenSizeProvider value={size}>
<DeskView
mind={mind}
label="Void"
projection={STATES[state]}
now={NOW}
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('shows the depends line with the dependency status', () => {
expect(view('blocked', ScreenSize.Desktop)).toContain(
'Waits on: &quot;Needs the first one settled&quot; (open)'
);
});
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('says No answers yet on a row without answers', () => {
expect(view('noAnswers', ScreenSize.Desktop)).toContain('No answers yet.');
});
it('shows how long a long-open question has waited', () => {
expect(view('longOpen', ScreenSize.Desktop)).toContain('Open for 21 days');
});
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('says it may be out of date when stale, and says nothing of the kind when fresh', () => {
expect(view('stale', ScreenSize.Desktop)).toContain('may be out of date');
expect(view('noAnswers', ScreenSize.Desktop)).not.toContain('out of date');
});
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('shows hidden and erased rows as withheld stubs', () => {
expect(view('hidden', ScreenSize.Desktop)).toContain('hidden by Kiko');
expect(view('erased', ScreenSize.Desktop)).toContain('erased account');
});
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).not.toContain('Just one fork');
expect(html).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('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, questions: STATES.settled.questions.slice(0, 2) }));
expect(entry('void')).toBe('');
});
});

View file

@ -0,0 +1,172 @@
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, DeskRow, buildDesk } from './buildDesk';
import { DeskProjection } from './feedback';
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 age = (row: Extract<DeskRow, { kind: 'question' }>) => {
if (row.longOpen) return `Open for ${row.ageDays} days`;
if (row.ageDays === 0) return 'Asked today';
return row.ageDays === 1 ? 'Asked 1 day ago' : `Asked ${row.ageDays} days ago`;
};
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.recordId} className={css.Option}>
{`${a.who}, ${a.kind.toLowerCase()}: `}
{a.withheld ?? [a.choice, a.text && `"${a.text}"`].filter(Boolean).join(' ')}
{` (${date(a.at)})`}
</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>
);
}
if (row.kind === 'hidden') {
return (
<li className={css.Row}>
<p className={css.Quiet}>
{`A question here is hidden by ${row.hidden.by}, ${date(row.hidden.at)}, ${
row.hidden.reason
}.`}
</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>
<p className={css.Quiet}>{age(row)}</p>
{open && (
<>
<p className={css.Body}>{row.body}</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.text} className={css.Quiet}>
{`Waits on: "${d.text}"${d.status ? ` (${d.status})` : ''}`}
</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. `}
{`Synced ${desk.syncedAt && date(desk.syncedAt)}.`}
{desk.stale && ' This may be out of date.'}
</p>
);
}
type DeskViewProps = {
/** The Mind's key, as the navigator groups it. */
mind: string;
label: string;
projection?: DeskProjection;
now: number;
/** 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, projection, now, unverified }: DeskViewProps) {
const desk = buildDesk(projection, now, undefined, unverified);
const mood = deskMood(desk.counts.open, desk.counts.answered);
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,127 @@
import { describe, expect, it } from 'vitest';
import { LONG_OPEN_DAYS, buildDesk, countOpen } from './buildDesk';
import { NOW, STATES } from './fixtures';
const questions = (d: ReturnType<typeof buildDesk>) =>
d.open.flatMap((r) => (r.kind === 'question' ? [r] : []));
describe('buildDesk', () => {
it('says "not published" for a projection that never arrived, never "no questions"', () => {
expect(buildDesk(undefined, NOW).published).toBe(false);
expect(buildDesk(STATES.unpublished, NOW)).toMatchObject({
published: false,
open: [],
counts: { open: 0, answered: 0 },
});
});
it('is published and empty, with its sync time, when nothing is open', () => {
const d = buildDesk(STATES.empty, NOW);
expect(d).toMatchObject({ published: true, open: [], counts: { open: 0 } });
expect(d.syncedAt).toBeDefined();
});
it('is stale only past thirty minutes since sync', () => {
expect(buildDesk(STATES.noAnswers, NOW).stale).toBe(false);
expect(buildDesk(STATES.stale, NOW).stale).toBe(true);
});
it('shows no answers as an empty list on an open row', () => {
expect(questions(buildDesk(STATES.noAnswers, NOW))[0].answers).toEqual([]);
});
it('flags a question open past fourteen days and keeps it listed', () => {
const [row] = questions(buildDesk(STATES.longOpen, NOW));
expect(row).toMatchObject({ ageDays: 21, longOpen: true });
expect(LONG_OPEN_DAYS).toBe(14);
expect(questions(buildDesk(STATES.noAnswers, NOW))[0].longOpen).toBe(false);
});
it('puts the long-open line past fourteen days exactly: day 14 is not long, day 15 is', () => {
const at = (days: number) => {
const p = structuredClone(STATES.noAnswers);
(p.questions[0] as { askedAt: string }).askedAt = new Date(
NOW - days * 86400000
).toISOString();
return questions(buildDesk(p, NOW))[0].longOpen;
};
expect([at(14), at(15)]).toEqual([false, true]);
});
it('resolves the recommendation to its option, marks only that option, and never preselects', () => {
const [row] = questions(buildDesk(STATES.noAnswers, NOW));
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 p = structuredClone(STATES.noAnswers);
(p.questions[0] as { recommendation?: string }).recommendation = 'Z';
const [row] = questions(buildDesk(p, NOW));
expect(row.recommended).toBeUndefined();
expect(row.options.some((o) => o.recommended)).toBe(false);
});
it('carries the status of a cited dependency, and none for one that cites no question', () => {
const rows = questions(buildDesk(STATES.blocked, NOW));
expect(rows.find((r) => r.forkId.endsWith('second'))?.depends).toEqual([
{ text: 'Needs the first one settled', status: 'open' },
]);
expect(rows.find((r) => r.forkId.endsWith('first'))?.depends).toEqual([]);
});
it('lists a settled question under recorded rulings with its ruling id, not as open; drops a closed one', () => {
const d = buildDesk(STATES.settled, NOW);
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('orders open rows oldest first', () => {
const p = structuredClone(STATES.blocked);
(p.questions[0] as { askedAt: string }).askedAt = new Date(NOW).toISOString();
expect(buildDesk(p, NOW).open.map((r) => r.forkId)).toEqual([
'c:question:second',
'c:question:first',
]);
});
it('folds earlier answers by one account under its latest and shows choice as option text', () => {
const [row] = questions(buildDesk(STATES.answered, NOW));
expect(row.answers).toHaveLength(2);
const first = row.answers.find((a) => a.recordId === 'plain.a2');
expect(first).toMatchObject({ choice: 'Third way', text: 'On reflection, third.' });
expect(row.answers.some((a) => a.recordId === 'plain.a1')).toBe(false);
});
it('lists answers oldest first and tallies nothing', () => {
const [row] = questions(buildDesk(STATES.answered, NOW));
const times = row.answers.map((a) => a.at);
expect([...times].sort()).toEqual(times);
expect(Object.keys(buildDesk(STATES.answered, NOW).counts).sort()).toEqual([
'answered',
'open',
]);
});
it('withholds a hidden answer and a hidden question, naming who hid them', () => {
const d = buildDesk(STATES.hidden, NOW);
expect(d.open.find((r) => r.kind === 'hidden')).toMatchObject({
hidden: { by: 'Kiko', reason: 'PrivateMatter' },
});
const [row] = questions(d);
expect(row.answers[0]).toMatchObject({ choice: undefined, text: undefined });
expect(row.answers[0].withheld).toMatch(/^Hidden by Kiko, 2026-11-\d\d, Spam$/);
});
it('does not count a hidden question as open', () => {
expect(buildDesk(STATES.hidden, NOW).counts.open).toBe(1);
expect(countOpen(STATES.hidden)).toBe(1);
});
it('shows an erased author as "erased account" with no name or content, keeping kind and time', () => {
const [row] = questions(buildDesk(STATES.erased, NOW));
expect(row.answers[0]).toMatchObject({
who: 'erased account',
kind: 'Member',
choice: undefined,
text: undefined,
});
expect(row.answers[0].withheld).toBeDefined();
expect(row.answers[0].at).toBeDefined();
});
it('counts answered only for the viewer, so a signed-out desk has none', () => {
expect(buildDesk(STATES.answered, NOW).counts.answered).toBe(0);
expect(buildDesk(STATES.answered, NOW, 'acct-1').counts.answered).toBe(1);
expect(buildDesk(STATES.answered, NOW, 'acct-9').counts.answered).toBe(0);
});
it('does not count the viewer as answered when their only answer was hidden', () => {
expect(buildDesk(STATES.hidden, NOW, 'acct-1').counts.answered).toBe(0);
});
});

View file

@ -0,0 +1,164 @@
import {
Answer,
DeskProjection,
Hidden,
Question,
QuestionStatus,
isHiddenQuestion,
} from './feedback';
/** Past two heartbeats the projection may be out of date (provisional, doc section 8). */
export const STALE_MS = 30 * 60 * 1000;
/** Past this a question shows how long it has waited; it is never demoted or hidden (provisional). */
export const LONG_OPEN_DAYS = 14;
const DAY_MS = 24 * 60 * 60 * 1000;
export type AnswerRow = {
recordId: string;
/** Never a handle: the name at answer time, or the words for an account that has none. */
who: string;
kind: Answer['kind'];
/** The option's text for a choice. */
choice?: string;
text?: string;
at: string;
/** Why the content is not shown: hidden by the Mind, or the author's account was erased. */
withheld?: string;
};
export type DeskRow =
| { kind: 'hidden'; forkId: string; hidden: Hidden }
/** 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;
body: 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: { text: string; status?: QuestionStatus['state'] }[];
ageDays: number;
longOpen: boolean;
answers: AnswerRow[];
};
export type Desk = {
/** False until the projection first arrives: "not published yet", never "no questions". */
published: boolean;
syncedAt?: string;
stale: boolean;
/** Oldest first. */
open: DeskRow[];
/** Settled questions with the Mind's ruling id. Closed (withdrawn) questions are not listed. */
recorded: { forkId: string; title: string; rulingId: string }[];
/** Derived for the booth mood. The page tallies nothing about answers. */
counts: { open: number; answered: number };
};
const ageDays = (iso: string, now: number) =>
Math.max(0, Math.floor((now - Date.parse(iso)) / DAY_MS));
const answerRow = (a: Answer, q: Question): AnswerRow => {
let withheld: string | undefined;
if (a.hidden)
withheld = `Hidden by ${a.hidden.by}, ${a.hidden.at.slice(0, 10)}, ${a.hidden.reason}`;
else if (a.erased) withheld = 'Author erased their account';
const choice = withheld ? undefined : a.answer?.choice;
return {
recordId: a.recordId,
who: a.erased ? 'erased account' : a.displayName ?? 'an account',
kind: a.kind,
choice:
choice === undefined ? undefined : q.options.find((o) => o.id === choice)?.text ?? choice,
text: withheld ? undefined : a.answer?.text,
at: a.answered_at,
withheld,
};
};
/** Earlier answers by the same account fold under their latest; the page never counts them. */
const standing = (answers: Answer[]): Answer[] => {
const latest = new Map<string, Answer>();
answers.forEach((a) => {
const key = a.answerer ?? a.recordId;
const held = latest.get(key);
if (!held || held.answered_at <= a.answered_at) latest.set(key, a);
});
return [...latest.values()].sort((a, b) => a.answered_at.localeCompare(b.answered_at));
};
/** Open questions the page would list: the one rule the navigator's count and the desk share. */
export const countOpen = (p?: DeskProjection): number =>
p?.questions.filter((q) => !isHiddenQuestion(q) && q.status.state === 'open').length ?? 0;
export const buildDesk = (
p: DeskProjection | undefined,
now: number,
viewerId?: string,
unverified: ReadonlySet<string> = new Set()
): Desk => {
if (!p?.syncedAt) {
return {
published: false,
stale: false,
open: [],
recorded: [],
counts: { open: 0, answered: 0 },
};
}
const known = new Map<string, Question>();
p.questions.forEach((q) => !isHiddenQuestion(q) && known.set(q.fork_id, q));
const open: DeskRow[] = [];
const recorded: Desk['recorded'] = [];
let answered = 0;
[...p.questions]
.sort((a, b) => {
const at = (q: typeof a) => (isHiddenQuestion(q) ? '' : q.askedAt);
return at(a).localeCompare(at(b));
})
.forEach((q) => {
if (isHiddenQuestion(q)) {
open.push({ kind: 'hidden', forkId: q.fork_id, hidden: q.hidden });
return;
}
if (q.status.state === 'answered') {
recorded.push({ forkId: q.fork_id, title: q.title, rulingId: q.status.rulingId });
return;
}
if (q.status.state !== 'open') return;
if (unverified.has(q.fork_id)) {
open.push({ kind: 'unverified', forkId: q.fork_id });
return;
}
const mine = standing(p.answers.filter((a) => a.fork_id === q.fork_id));
if (viewerId && mine.some((a) => a.answerer === viewerId && !a.hidden)) answered += 1;
const days = ageDays(q.askedAt, now);
open.push({
kind: 'question',
forkId: q.fork_id,
title: q.title,
body: q.body,
options: q.options.map((o) => ({ ...o, recommended: o.id === q.recommendation })),
recommended: q.options.find((o) => o.id === q.recommendation)?.text,
depends: q.dependsOn.map((d) => ({
text: d.text,
status: d.questionId ? known.get(d.questionId)?.status.state : undefined,
})),
ageDays: days,
longOpen: days > LONG_OPEN_DAYS,
answers: mine.map((a) => answerRow(a, q)),
});
});
return {
published: true,
syncedAt: p.syncedAt,
stale: now - Date.parse(p.syncedAt) > STALE_MS,
open,
recorded,
counts: { open: open.filter((r) => r.kind === 'question').length, answered },
};
};

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,70 @@
/**
* The desk's typed view of the feedback projections in docs/mind-feedback-pages.md (sections 5 and 12):
* org.gamecult.feedback.question.v0 and org.gamecult.feedback.answer.v0. Bifrost serves them; the page
* reads them and stores nothing. `answerer`, `kind` and `weightPermille` are the receiver's stamp from the
* verified session; the client never supplies them, so nothing here has a write shape.
*/
export type Hidden = { by: string; at: string; reason: string };
/** The status is derived in the owning Mind; the page shows it and never writes it. */
export type QuestionStatus =
| { state: 'open' }
| { state: 'answered'; rulingId: string }
| { state: 'closed' };
export type Question = {
/** `<campaign>:question:<label>`. */
fork_id: string;
owner: string;
title: string;
body: string;
/** `id` is the option's label. */
options: { id: string; text: string }[];
/** An option label; never nil. Never preselected. */
recommendation: string;
/** `questionId` only where the line cites one question verbatim. */
dependsOn: { text: string; questionId?: string }[];
/** An ISO instant: the question's receipt `committed_at`. */
askedAt: string;
status: QuestionStatus;
/**
* Publisher's hash of what is shown. The desk recomputes it (optionsHash.ts, pinned to the CultLib vectors)
* and refuses to show or answer a card whose hash differs. The receiver rejects an answer carrying a hash
* it did not publish; sending is slice 2.
*/
options_hash: string;
};
/** A hidden question keeps a stub; title, body, options and answers are withheld. */
export type HiddenQuestion = { fork_id: string; hidden: Hidden };
export type Answer = {
/** `<question-label>.a<N>`. */
recordId: string;
fork_id: string;
options_hash: string;
/** Bifrost account id; absent once the account is erased. Receiver-set. */
answerer?: string;
/** Name and kind as they were at the moment of the answer; the name is gone once erased. Receiver-set. */
displayName?: string;
kind: 'Guest' | 'User' | 'Patron' | 'Member' | 'Mind';
weightPermille: number;
/** An option label, free text, or both. Absent while hidden or erased. */
answer?: { choice?: string; text?: string };
/** ISO instant the receiver accepted it. */
answered_at: string;
hidden?: Hidden;
erased?: boolean;
};
/** What the page holds for one Mind's desk. `syncedAt` is absent until the projection first arrives. */
export type DeskProjection = {
mind: string;
syncedAt?: string;
questions: (Question | HiddenQuestion)[];
answers: Answer[];
};
export const isHiddenQuestion = (q: Question | HiddenQuestion): q is HiddenQuestion =>
'hidden' in q;

View file

@ -0,0 +1,100 @@
import { Answer, DeskProjection, Question } from './feedback';
/** The clock every fixture is written against. */
export const NOW = Date.parse('2026-11-10T12:00:00Z');
const ago = (ms: number) => new Date(NOW - ms).toISOString();
const MIN = 60 * 1000;
const DAY = 24 * 60 * MIN;
const q = (label: string, over: Partial<Question> = {}): Question => ({
fork_id: `c:question:${label}`,
owner: 'mind',
title: `Question ${label}`,
body: `Context for ${label}.`,
options: [
{ id: 'A', text: 'First way' },
{ id: 'B', text: 'Second way' },
{ id: 'C', text: 'Third way' },
],
recommendation: 'B',
dependsOn: [],
askedAt: ago(DAY),
status: { state: 'open' },
options_hash: `hash-${label}`,
...over,
});
const a = (label: string, n: number, over: Partial<Answer> = {}): Answer => ({
recordId: `${label}.a${n}`,
fork_id: `c:question:${label}`,
options_hash: `hash-${label}`,
answerer: `acct-${n}`,
displayName: `Person ${n}`,
kind: 'Member',
weightPermille: 1000,
answer: { choice: 'A' },
answered_at: ago(DAY / 2),
...over,
});
const desk = (
questions: DeskProjection['questions'],
answers: Answer[] = [],
syncedAt = ago(2 * MIN)
) => ({
mind: 'mind',
syncedAt,
questions,
answers,
});
/** One projection per state of doc section 8, plus the hidden and erased rows. */
export const STATES = {
/** The projection never arrived. */
unpublished: { mind: 'mind', questions: [], answers: [] } as DeskProjection,
empty: desk([]),
noAnswers: desk([q('plain')]),
stale: desk([q('plain')], [], ago(45 * MIN)),
longOpen: desk([q('old', { askedAt: ago(21 * DAY) })]),
blocked: desk([
q('first'),
q('second', {
dependsOn: [{ text: 'Needs the first one settled', questionId: 'c:question:first' }],
}),
]),
settled: desk([
q('done', { status: { state: 'answered', rulingId: 'ruling:done-1' } }),
q('shut', { status: { state: 'closed' } }),
q('live'),
]),
answered: desk(
[q('plain')],
[
a('plain', 1, { answered_at: ago(DAY / 2) }),
a('plain', 2, {
answerer: 'acct-1',
answer: { choice: 'C', text: 'On reflection, third.' },
answered_at: ago(DAY / 4),
}),
a('plain', 3, { kind: 'Mind', answer: { text: 'Either, if measured first.' } }),
]
),
hidden: desk(
[
q('plain'),
{ fork_id: 'c:question:gone', hidden: { by: 'Kiko', at: ago(DAY), reason: 'PrivateMatter' } },
],
[a('plain', 1, { hidden: { by: 'Kiko', at: ago(DAY / 3), reason: 'Spam' }, answer: undefined })]
),
erased: desk(
[q('plain')],
[
a('plain', 1, {
erased: true,
answerer: undefined,
displayName: undefined,
answer: undefined,
}),
]
),
} satisfies Record<string, DeskProjection>;

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 { STATES } from './fixtures';
import { Question } from './feedback';
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 card = async (): Promise<Question> => {
const q = structuredClone(STATES.noAnswers.questions[0]) as Question;
return { ...q, options_hash: await optionsHash(shownOf(q)) };
};
it('passes a card whose hash is the hash of what it shows', async () => {
expect(await unverifiedForks([await card()])).toEqual(new Set());
});
it('flags a card whose shown text, option, recommendation or depends changed after hashing', async () => {
const q = await card();
const edits: Partial<Question>[] = [
{ title: 'Other' },
{ body: 'Other context.' },
{ options: [...q.options].reverse() },
{ recommendation: 'A' },
{ dependsOn: [{ text: 'new line' }] },
];
await Promise.all(
edits.map(async (e) =>
expect(await unverifiedForks([{ ...q, ...e }]), JSON.stringify(Object.keys(e))).toEqual(
new Set([q.fork_id])
)
)
);
});
it('does not read fork_id, status or asked time, which are not shown members', async () => {
const q = await card();
expect(
await unverifiedForks([{ ...q, fork_id: 'x:question:y', askedAt: '2020-01-01T00:00:00Z' }])
).toEqual(new Set());
});
});

View file

@ -0,0 +1,57 @@
import { DeskProjection, Question, isHiddenQuestion } from './feedback';
// 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<Question, 'title' | 'options' | 'recommendation'> & {
context: string;
depends: string[];
};
/** 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 = (q: Question): Shown => ({
title: q.title,
context: q.body,
options: q.options,
recommendation: q.recommendation,
depends: q.dependsOn.map((d) => d.text),
});
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 (
questions: DeskProjection['questions']
): Promise<Set<string>> => {
const bad = new Set<string>();
await Promise.all(
questions.map(async (q) => {
if (isHiddenQuestion(q)) return;
if ((await optionsHash(shownOf(q))) !== q.options_hash) bad.add(q.fork_id);
})
);
return bad;
};

View file

@ -0,0 +1,346 @@
{
"source": "GameCult/CultLib contracts/cultnet/governance/fork-card-vectors.json, `optionsHash` member only, values unchanged (re-escaped to ASCII so TypeScript parses it), from CultLib a499c581 (file last changed in 8ca1d184). 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 { DeskProjection } from './feedback';
type DeskSource = (mind: string) => DeskProjection | undefined;
// Slice 1 reads no network. The preview demo installs seeded forks here; slice 2 replaces this with
// Bifrost's projection 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): DeskProjection | 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

@ -12,6 +12,7 @@ import { ClientConfig } from '../hooks/useClientConfig';
import { AuthLayout, Login, Register, ResetPassword } from './auth';
import {
DIRECT_PATH,
DESK_PATH,
EXPLORE_PATH,
HOME_PATH,
LOGIN_PATH,
@ -48,6 +49,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';
@ -246,6 +248,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

@ -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

@ -74,6 +74,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,35 @@
import { describe, expect, it } from 'vitest';
import { buildDesk, countOpen } from '../app/features/desk/buildDesk';
import { deskFor, setDeskSource } from '../app/features/desk/source';
import { unverifiedForks } from '../app/features/desk/optionsHash';
import { demoDesk } from './deskForks';
describe('the demo desks', () => {
it('seed the Minds whose groups the demo has, with open forks and a settled one', () => {
const nibu = demoDesk('nibu');
expect(countOpen(nibu)).toBe(2);
expect(buildDesk(nibu, Date.now()).recorded).toHaveLength(1);
expect(countOpen(demoDesk('eureka'))).toBe(1);
});
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('resolve every dependency they cite, so no seeded row waits on a ghost', () => {
const nibu = demoDesk('nibu');
const ids = new Set(nibu?.questions.map((q) => q.fork_id));
nibu?.questions.forEach(
(q) =>
'dependsOn' in q &&
q.dependsOn.forEach((d) => d.questionId && expect(ids.has(d.questionId)).toBe(true))
);
});
it('carry the hash of what they show, so the desk shows every seeded card', async () => {
expect(await unverifiedForks(demoDesk('nibu')?.questions ?? [])).toEqual(new Set());
expect(await unverifiedForks(demoDesk('eureka')?.questions ?? [])).toEqual(new Set());
});
});

View file

@ -0,0 +1,87 @@
// Seeded forks for the preview's Mind desks. Invented here, public-safe: no real question, person or
// decision. Written against the clock at the time of the call so ages and sync time stay plausible.
import { DeskProjection, Question } from '../app/features/desk/feedback';
const DAY = 24 * 60 * 60 * 1000;
// Each question'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',
'ridge-low-tier': '6edd4a08b6c86703d91b3f48bd697aca16450111573a8e391e3dfe3adc6cbf5f',
'settled-window': 'b0dd0db3a37a256618d447ff8fb4067892b4f1f82d05a1e5bebf5563c447731c',
};
const fork = (mind: string, label: string, daysAgo: number, over: Partial<Question>): Question => ({
fork_id: `demo:question:${label}`,
owner: mind,
title: label,
body: '',
options: [],
recommendation: '',
dependsOn: [],
askedAt: new Date(Date.now() - daysAgo * DAY).toISOString(),
status: { state: 'open' },
options_hash: HASHES[label],
...over,
});
const SEEDS: Record<string, (now: number) => DeskProjection> = {
nibu: (now) => ({
mind: 'nibu',
syncedAt: new Date(now - 4 * 60 * 1000).toISOString(),
questions: [
fork('nibu', 'sky-label', 3, {
title: 'Where should the sky label sit on a phone?',
body: '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',
}),
fork('nibu', 'dusk-ink', 18, {
title: 'Should the dusk ink follow the sky or the theme?',
body: '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',
dependsOn: [{ text: 'Where the sky label sits', questionId: 'demo:question:sky-label' }],
}),
fork('nibu', 'ridge-low-tier', 40, {
title: 'Keep the ridge scene at the low tier?',
body: 'The ridge costs a little on older phones.',
options: [
{ id: 'keep', text: 'Keep it' },
{ id: 'drop', text: 'Drop it' },
],
recommendation: 'keep',
status: { state: 'answered', rulingId: 'demo:ruling:ridge-low-tier' },
}),
],
answers: [],
}),
eureka: (now) => ({
mind: 'eureka',
syncedAt: new Date(now - 2 * 60 * 1000).toISOString(),
questions: [
fork('eureka', 'settled-window', 1, {
title: 'How long should a Mind keep a settled question on its desk?',
body: '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',
}),
],
answers: [],
}),
};
/** The demo's desk for a Mind, or nothing: a Mind without seeds has not published yet. */
export const demoDesk = (mind: string): DeskProjection | undefined => SEEDS[mind]?.(Date.now());

View file

@ -7,6 +7,8 @@
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';
@ -88,6 +90,7 @@ export const dropForeignSession = (storage: 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);