desk: read the gamecult.fork.* contract as served #83
16 changed files with 925 additions and 858 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -16,16 +16,16 @@ export function Desk() {
|
|||
const navigate = useNavigate();
|
||||
const phone = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
const label = labelOf(mind);
|
||||
const projection = useMemo(() => deskFor(mind), [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(projection?.questions ?? []).then((bad) => live && setUnverified(bad));
|
||||
unverifiedForks(list?.forks ?? []).then((bad) => live && setUnverified(bad));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [projection]);
|
||||
}, [list]);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
|
@ -46,13 +46,7 @@ export function Desk() {
|
|||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<PageContentCenter>
|
||||
<DeskView
|
||||
mind={mind}
|
||||
label={label}
|
||||
projection={projection}
|
||||
now={Date.now()}
|
||||
unverified={unverified}
|
||||
/>
|
||||
<DeskView mind={mind} label={label} list={list} unverified={unverified} />
|
||||
</PageContentCenter>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,11 @@ 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));
|
||||
const open = deskFor(mind)?.open_count ?? 0;
|
||||
if (open === 0) return null;
|
||||
return (
|
||||
<Text as={Link} to={getDeskPath(mind)} size="T200" priority="300">
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ 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 { STATES } from './fixtures';
|
||||
import { setDeskSource } from './source';
|
||||
|
||||
const view = (
|
||||
state: keyof typeof STATES,
|
||||
state: keyof typeof STATES | 'unpublished',
|
||||
size: ScreenSize,
|
||||
mind = 'void',
|
||||
unverified = new Set<string>()
|
||||
|
|
@ -19,8 +19,7 @@ const view = (
|
|||
<DeskView
|
||||
mind={mind}
|
||||
label="Void"
|
||||
projection={STATES[state]}
|
||||
now={NOW}
|
||||
list={state === 'unpublished' ? undefined : STATES[state]}
|
||||
unverified={unverified}
|
||||
/>
|
||||
</ScreenSizeProvider>
|
||||
|
|
@ -45,11 +44,6 @@ describe('DeskView', () => {
|
|||
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: "Needs the first one settled" (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=""/);
|
||||
|
|
@ -57,21 +51,31 @@ describe('DeskView', () => {
|
|||
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: "Needs the first one settled"'
|
||||
);
|
||||
});
|
||||
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('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 "Because it is first." (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 "Let me in."');
|
||||
expect(html).toContain('Answer "Either, if measured first."');
|
||||
expect(html.match(/\(counted\)/g)).toHaveLength(1);
|
||||
expect(html).not.toContain('"null"');
|
||||
});
|
||||
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.');
|
||||
|
|
@ -79,10 +83,6 @@ describe('DeskView', () => {
|
|||
'Void'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"');
|
||||
|
|
@ -125,12 +125,16 @@ describe('DeskEntry', () => {
|
|||
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, questions: STATES.settled.questions.slice(0, 2) }));
|
||||
setDeskSource(() => ({ ...STATES.settled, open_count: 0 }));
|
||||
expect(entry('void')).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ 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 { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk';
|
||||
import { ForkList } from './forkContract';
|
||||
import { deskMood } from './deskMood';
|
||||
import * as css from './DeskView.css';
|
||||
|
||||
|
|
@ -13,21 +13,32 @@ export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can re
|
|||
|
||||
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`;
|
||||
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.recordId} className={css.Option}>
|
||||
{`${a.who}, ${a.kind.toLowerCase()}: `}
|
||||
{a.withheld ?? [a.choice, a.text && `"${a.text}"`].filter(Boolean).join(' ')}
|
||||
{` (${date(a.at)})`}
|
||||
<li key={a.answerId} className={css.Option}>
|
||||
{`${answerLine(a)} (${date(a.at)})`}
|
||||
{a.counted && ' (counted)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -47,17 +58,6 @@ function Row({ row }: { row: DeskRow }) {
|
|||
</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}>
|
||||
|
|
@ -75,10 +75,9 @@ function Row({ row }: { row: DeskRow }) {
|
|||
row.title
|
||||
)}
|
||||
</h3>
|
||||
<p className={css.Quiet}>{age(row)}</p>
|
||||
{open && (
|
||||
<>
|
||||
<p className={css.Body}>{row.body}</p>
|
||||
<p className={css.Body}>{row.context}</p>
|
||||
<ul className={css.Options}>
|
||||
{row.options.map((o) => (
|
||||
<li key={o.id} className={css.Option}>
|
||||
|
|
@ -88,8 +87,8 @@ function Row({ row }: { row: DeskRow }) {
|
|||
))}
|
||||
</ul>
|
||||
{row.depends.map((d) => (
|
||||
<p key={d.text} className={css.Quiet}>
|
||||
{`Waits on: "${d.text}"${d.status ? ` (${d.status})` : ''}`}
|
||||
<p key={d} className={css.Quiet}>
|
||||
{`Waits on: "${d}"`}
|
||||
</p>
|
||||
))}
|
||||
<span id={`${noteId}-label`}>Your note</span>
|
||||
|
|
@ -115,9 +114,7 @@ function Status({ desk, label }: { desk: Desk; label: string }) {
|
|||
<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.'}
|
||||
: `${desk.counts.open} open.`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -126,16 +123,16 @@ type DeskViewProps = {
|
|||
/** The Mind's key, as the navigator groups it. */
|
||||
mind: string;
|
||||
label: string;
|
||||
projection?: DeskProjection;
|
||||
now: number;
|
||||
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, projection, now, unverified }: DeskViewProps) {
|
||||
const desk = buildDesk(projection, now, undefined, unverified);
|
||||
const mood = deskMood(desk.counts.open, desk.counts.answered);
|
||||
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} />
|
||||
|
|
|
|||
|
|
@ -1,127 +1,97 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { LONG_OPEN_DAYS, buildDesk, countOpen } from './buildDesk';
|
||||
import { NOW, STATES } from './fixtures';
|
||||
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 projection that never arrived, never "no questions"', () => {
|
||||
expect(buildDesk(undefined, NOW).published).toBe(false);
|
||||
expect(buildDesk(STATES.unpublished, NOW)).toMatchObject({
|
||||
published: false,
|
||||
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, answered: 0 },
|
||||
counts: { open: 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]);
|
||||
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, NOW));
|
||||
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 p = structuredClone(STATES.noAnswers);
|
||||
(p.questions[0] as { recommendation?: string }).recommendation = 'Z';
|
||||
const [row] = questions(buildDesk(p, NOW));
|
||||
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 status of a cited dependency, and none for one that cites no question', () => {
|
||||
const rows = questions(buildDesk(STATES.blocked, NOW));
|
||||
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([
|
||||
{ text: 'Needs the first one settled', status: 'open' },
|
||||
'Needs the first one settled',
|
||||
]);
|
||||
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);
|
||||
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('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('orders open rows by the card order, oldest admission first, whatever order the list came in', () => {
|
||||
const d = buildDesk(list([entry('late', { order: 9 }), entry('early', { order: 2 })]));
|
||||
expect(d.open.map((r) => r.forkId)).toEqual(['c:question:early', 'c:question:late']);
|
||||
});
|
||||
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('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('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('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('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('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);
|
||||
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' }]);
|
||||
expect(d.counts.open).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,164 +1,97 @@
|
|||
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;
|
||||
import { DeskAnswer, ForkEntry, ForkList } from './forkContract';
|
||||
|
||||
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'];
|
||||
answerId: string;
|
||||
outcome: DeskAnswer['outcome'];
|
||||
/** The option's text for a choice. */
|
||||
choice?: string;
|
||||
text?: 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;
|
||||
/** Why the content is not shown: hidden by the Mind, or the author's account was erased. */
|
||||
withheld?: string;
|
||||
/** The record's counted answer: marked as served, never derived here. */
|
||||
counted: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
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: { text: string; status?: QuestionStatus['state'] }[];
|
||||
ageDays: number;
|
||||
longOpen: boolean;
|
||||
depends: string[];
|
||||
/** In admission order, as served. */
|
||||
answers: AnswerRow[];
|
||||
};
|
||||
|
||||
export type Desk = {
|
||||
/** False until the projection first arrives: "not published yet", never "no questions". */
|
||||
/** False until the list first arrives: "not published yet", never "no questions". */
|
||||
published: boolean;
|
||||
syncedAt?: string;
|
||||
stale: boolean;
|
||||
/** Oldest first. */
|
||||
/** Oldest first, by the card's admission ordinal. */
|
||||
open: DeskRow[];
|
||||
/** Settled questions with the Mind's ruling id. Closed (withdrawn) questions are not listed. */
|
||||
/** Closed with a ruling id. A card closed with no ruling (withdrawn) is 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 };
|
||||
/** Verified open questions on the page, for the booth mood. */
|
||||
counts: { open: 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;
|
||||
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 = (
|
||||
p: DeskProjection | undefined,
|
||||
now: number,
|
||||
viewerId?: string,
|
||||
list: ForkList | undefined,
|
||||
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));
|
||||
|
||||
if (!list) return { published: false, open: [], recorded: [], counts: { open: 0 } };
|
||||
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 });
|
||||
[...list.forks]
|
||||
.sort((a, b) => a.card.order - b.card.order)
|
||||
.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 (q.status.state === 'answered') {
|
||||
recorded.push({ forkId: q.fork_id, title: q.title, rulingId: q.status.rulingId });
|
||||
if (unverified.has(card.fork_id)) {
|
||||
open.push({ kind: 'unverified', forkId: card.fork_id });
|
||||
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)),
|
||||
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,
|
||||
syncedAt: p.syncedAt,
|
||||
stale: now - Date.parse(p.syncedAt) > STALE_MS,
|
||||
open,
|
||||
recorded,
|
||||
counts: { open: open.filter((r) => r.kind === 'question').length, answered },
|
||||
counts: { open: open.filter((r) => r.kind === 'question').length },
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,100 +1,116 @@
|
|||
import { Answer, DeskProjection, Question } from './feedback';
|
||||
import { DeskAnswer, ForkCard, ForkEntry, ForkList } from './forkContract';
|
||||
|
||||
/** 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 => ({
|
||||
/** 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}`,
|
||||
owner: 'mind',
|
||||
title: `Question ${label}`,
|
||||
body: `Context for ${label}.`,
|
||||
context: `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' },
|
||||
depends: [],
|
||||
leans: null,
|
||||
flag: null,
|
||||
raised_in: null,
|
||||
deciders: ['d1'],
|
||||
status: 'open',
|
||||
ruling: null,
|
||||
ruled_from: [],
|
||||
order: 1,
|
||||
options_hash: `hash-${label}`,
|
||||
...over,
|
||||
});
|
||||
|
||||
const a = (label: string, n: number, over: Partial<Answer> = {}): Answer => ({
|
||||
recordId: `${label}.a${n}`,
|
||||
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}`,
|
||||
answerer: `acct-${n}`,
|
||||
displayName: `Person ${n}`,
|
||||
kind: 'Member',
|
||||
weightPermille: 1000,
|
||||
answer: { choice: 'A' },
|
||||
answered_at: ago(DAY / 2),
|
||||
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,
|
||||
});
|
||||
|
||||
const desk = (
|
||||
questions: DeskProjection['questions'],
|
||||
answers: Answer[] = [],
|
||||
syncedAt = ago(2 * MIN)
|
||||
) => ({
|
||||
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',
|
||||
syncedAt,
|
||||
questions,
|
||||
answers,
|
||||
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 projection per state of doc section 8, plus the hidden and erased rows. */
|
||||
/** One list per state the desk shows. */
|
||||
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' }],
|
||||
}),
|
||||
empty: list([]),
|
||||
noAnswers: list([entry('plain')]),
|
||||
blocked: list([
|
||||
entry('first', { order: 1 }),
|
||||
entry('second', { order: 2, depends: ['Needs the first one settled'] }),
|
||||
]),
|
||||
settled: desk([
|
||||
q('done', { status: { state: 'answered', rulingId: 'ruling:done-1' } }),
|
||||
q('shut', { status: { state: 'closed' } }),
|
||||
q('live'),
|
||||
settled: list([
|
||||
entry('done', { status: 'closed', ruling: 'ruling:done-1', ruled_from: ['n1'] }),
|
||||
entry('shut', { status: 'closed' }),
|
||||
entry('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>;
|
||||
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>;
|
||||
|
|
|
|||
92
src/app/features/desk/forkContract.ts
Normal file
92
src/app/features/desk/forkContract.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import vectors from './optionsHashVectors.json';
|
||||
import { STATES } from './fixtures';
|
||||
import { Question } from './feedback';
|
||||
import { entry } from './fixtures';
|
||||
import { ForkCard } from './forkContract';
|
||||
import { canonicalShown, optionsHash, shownOf, unverifiedForks } from './optionsHash';
|
||||
|
||||
describe('options_hash against the CultLib vectors', () => {
|
||||
|
|
@ -17,34 +17,34 @@ describe('options_hash against the CultLib vectors', () => {
|
|||
});
|
||||
|
||||
describe('unverifiedForks', () => {
|
||||
const card = async (): Promise<Question> => {
|
||||
const q = structuredClone(STATES.noAnswers.questions[0]) as Question;
|
||||
return { ...q, options_hash: await optionsHash(shownOf(q)) };
|
||||
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 card()])).toEqual(new Set());
|
||||
expect(await unverifiedForks([await sealed()])).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>[] = [
|
||||
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' },
|
||||
{ body: 'Other context.' },
|
||||
{ options: [...q.options].reverse() },
|
||||
{ context: 'Other context.' },
|
||||
{ options: [...e.card.options].reverse() },
|
||||
{ recommendation: 'A' },
|
||||
{ dependsOn: [{ text: 'new line' }] },
|
||||
{ depends: ['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])
|
||||
)
|
||||
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 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());
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { DeskProjection, Question, isHiddenQuestion } from './feedback';
|
||||
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.
|
||||
|
|
@ -17,10 +17,7 @@ const jstr = (s: string): string => {
|
|||
return `${out}"`;
|
||||
};
|
||||
|
||||
export type Shown = Pick<Question, 'title' | 'options' | 'recommendation'> & {
|
||||
context: string;
|
||||
depends: string[];
|
||||
};
|
||||
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 =>
|
||||
|
|
@ -28,12 +25,12 @@ export const canonicalShown = (c: Shown): string =>
|
|||
.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 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> => {
|
||||
|
|
@ -43,14 +40,11 @@ export const optionsHash = async (c: Shown): Promise<string> => {
|
|||
};
|
||||
|
||||
/** 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>> => {
|
||||
export const unverifiedForks = async (forks: ForkEntry[]): 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);
|
||||
forks.map(async ({ card }) => {
|
||||
if ((await optionsHash(shownOf(card))) !== card.options_hash) bad.add(card.fork_id);
|
||||
})
|
||||
);
|
||||
return bad;
|
||||
|
|
|
|||
|
|
@ -1,346 +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), 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": [
|
||||
"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": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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!"
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { DeskProjection } from './feedback';
|
||||
import { ForkList } from './forkContract';
|
||||
|
||||
type DeskSource = (mind: string) => DeskProjection | undefined;
|
||||
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 projection reader, which is why the page asks a function and not a fixture.
|
||||
// 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): DeskProjection | undefined => source(mind);
|
||||
export const deskFor = (mind: string): ForkList | undefined => source(mind);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,86 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDesk, countOpen } from '../app/features/desk/buildDesk';
|
||||
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', () => {
|
||||
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);
|
||||
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();
|
||||
|
|
@ -19,17 +90,8 @@ describe('the demo desks', () => {
|
|||
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());
|
||||
expect(await unverifiedForks(nibu.forks)).toEqual(new Set());
|
||||
expect(await unverifiedForks((demoDesk('eureka') as ForkList).forks)).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,87 +1,187 @@
|
|||
// 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';
|
||||
// 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 question's options_hash over what it shows (optionsHash.ts); deskForks.test.ts fails if a seed is edited without it.
|
||||
// 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 fork = (mind: string, label: string, daysAgo: number, over: Partial<Question>): Question => ({
|
||||
const card = (label: string, order: number, over: Partial<ForkCard>): ForkCard => ({
|
||||
schemaVersion: 'gamecult.fork.card.v0',
|
||||
fork_id: `demo:question:${label}`,
|
||||
owner: mind,
|
||||
title: label,
|
||||
body: '',
|
||||
context: '',
|
||||
options: [],
|
||||
recommendation: '',
|
||||
dependsOn: [],
|
||||
askedAt: new Date(Date.now() - daysAgo * DAY).toISOString(),
|
||||
status: { state: 'open' },
|
||||
depends: [],
|
||||
leans: null,
|
||||
flag: null,
|
||||
raised_in: null,
|
||||
deciders: ['demo-decider'],
|
||||
status: 'open',
|
||||
ruling: null,
|
||||
ruled_from: [],
|
||||
order,
|
||||
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: [],
|
||||
}),
|
||||
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,
|
||||
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): DeskProjection | undefined => SEEDS[mind]?.(Date.now());
|
||||
export const demoDesk = (mind: string): ForkList | undefined => SEEDS[mind]?.();
|
||||
|
|
|
|||
Loading…
Reference in a new issue