From 3898239ec4c8d31d83e4af496c51e19702e57f59 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 20:33:44 +0000 Subject: [PATCH 01/10] Bound the reply preview: min-width 0 on its flex parents, 2-line clamp, break long words and urls On a 360px phone the reply quote above a message was unbounded and overflowed the screen. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/Reply.css.ts | 17 +++++++++++++++++ src/app/components/message/Reply.tsx | 11 +++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/app/components/message/Reply.css.ts b/src/app/components/message/Reply.css.ts index 7e5540fd..d5254ecb 100644 --- a/src/app/components/message/Reply.css.ts +++ b/src/app/components/message/Reply.css.ts @@ -31,6 +31,7 @@ export const Reply = style({ }); export const ReplyContent = style({ + minWidth: 0, opacity: config.opacity.P300, selectors: { @@ -39,3 +40,19 @@ export const ReplyContent = style({ }, }, }); + +export const ReplyText = style({ + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 2, + overflow: 'hidden', + minWidth: 0, + maxWidth: '100%', + overflowWrap: 'anywhere', + wordBreak: 'break-word', +}); + +export const ReplyWrap = style({ + minWidth: 0, + maxWidth: '100%', +}); diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 57bf2af9..894b3b03 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -101,7 +101,14 @@ export const Reply = as<'div', ReplyProps>( const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody; return ( - + {threadRootId && ( )} @@ -119,7 +126,7 @@ export const Reply = as<'div', ReplyProps>( onClick={onClick} > {replyEvent !== undefined ? ( - + {badEncryption ? : bodyJSX} ) : ( From c286c4adc47651222036171a9c797e9ed333f1d2 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 20:33:49 +0000 Subject: [PATCH 02/10] Bound the reply preview on phone width (#82) --- src/app/components/message/Reply.css.ts | 17 +++++++++++++++++ src/app/components/message/Reply.tsx | 11 +++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/app/components/message/Reply.css.ts b/src/app/components/message/Reply.css.ts index 7e5540fd..d5254ecb 100644 --- a/src/app/components/message/Reply.css.ts +++ b/src/app/components/message/Reply.css.ts @@ -31,6 +31,7 @@ export const Reply = style({ }); export const ReplyContent = style({ + minWidth: 0, opacity: config.opacity.P300, selectors: { @@ -39,3 +40,19 @@ export const ReplyContent = style({ }, }, }); + +export const ReplyText = style({ + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 2, + overflow: 'hidden', + minWidth: 0, + maxWidth: '100%', + overflowWrap: 'anywhere', + wordBreak: 'break-word', +}); + +export const ReplyWrap = style({ + minWidth: 0, + maxWidth: '100%', +}); diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 57bf2af9..894b3b03 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -101,7 +101,14 @@ export const Reply = as<'div', ReplyProps>( const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody; return ( - + {threadRootId && ( )} @@ -119,7 +126,7 @@ export const Reply = as<'div', ReplyProps>( onClick={onClick} > {replyEvent !== undefined ? ( - + {badEncryption ? : bodyJSX} ) : ( From 8e0e3b8986da6433baae72e9f2fd5344034cd74a Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 21:08:12 +0000 Subject: [PATCH 03/10] desk: read the gamecult.fork.* contract as served (#83) --- docs/mind-feedback-pages.md | 2 + src/app/features/desk/Desk.tsx | 14 +- src/app/features/desk/DeskEntry.tsx | 3 +- src/app/features/desk/DeskView.test.tsx | 44 +- src/app/features/desk/DeskView.tsx | 63 +- src/app/features/desk/buildDesk.test.ts | 154 ++--- src/app/features/desk/buildDesk.ts | 169 ++--- src/app/features/desk/feedback.ts | 70 -- src/app/features/desk/fixtures.ts | 168 ++--- src/app/features/desk/forkContract.ts | 92 +++ src/app/features/desk/optionsHash.test.ts | 42 +- src/app/features/desk/optionsHash.ts | 28 +- src/app/features/desk/optionsHashVectors.json | 604 +++++++++--------- src/app/features/desk/source.ts | 8 +- src/preview-demo/deskForks.test.ts | 96 ++- src/preview-demo/deskForks.ts | 232 +++++-- 16 files changed, 928 insertions(+), 861 deletions(-) delete mode 100644 src/app/features/desk/feedback.ts create mode 100644 src/app/features/desk/forkContract.ts diff --git a/docs/mind-feedback-pages.md b/docs/mind-feedback-pages.md index cad15c42..9ff45d08 100644 --- a/docs/mind-feedback-pages.md +++ b/docs/mind-feedback-pages.md @@ -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. diff --git a/src/app/features/desk/Desk.tsx b/src/app/features/desk/Desk.tsx index 548be685..f1695716 100644 --- a/src/app/features/desk/Desk.tsx +++ b/src/app/features/desk/Desk.tsx @@ -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>(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 ( @@ -46,13 +46,7 @@ export function Desk() { - + diff --git a/src/app/features/desk/DeskEntry.tsx b/src/app/features/desk/DeskEntry.tsx index 1d3068ab..a36ec8e4 100644 --- a/src/app/features/desk/DeskEntry.tsx +++ b/src/app/features/desk/DeskEntry.tsx @@ -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 ( diff --git a/src/app/features/desk/DeskView.test.tsx b/src/app/features/desk/DeskView.test.tsx index 09d3f45a..8dbb1dd6 100644 --- a/src/app/features/desk/DeskView.test.tsx +++ b/src/app/features/desk/DeskView.test.tsx @@ -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() @@ -19,8 +19,7 @@ const view = ( @@ -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(/]* 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(''); }); }); diff --git a/src/app/features/desk/DeskView.tsx b/src/app/features/desk/DeskView.tsx index fed7d18e..e57bfa70 100644 --- a/src/app/features/desk/DeskView.tsx +++ b/src/app/features/desk/DeskView.tsx @@ -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) => { - 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 = { + 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

No answers yet.

; return (
    {answers.map((a) => ( -
  • - {`${a.who}, ${a.kind.toLowerCase()}: `} - {a.withheld ?? [a.choice, a.text && `"${a.text}"`].filter(Boolean).join(' ')} - {` (${date(a.at)})`} +
  • + {`${answerLine(a)} (${date(a.at)})`} + {a.counted && ' (counted)'}
  • ))}
@@ -47,17 +58,6 @@ function Row({ row }: { row: DeskRow }) { ); } - if (row.kind === 'hidden') { - return ( -
  • -

    - {`A question here is hidden by ${row.hidden.by}, ${date(row.hidden.at)}, ${ - row.hidden.reason - }.`} -

    -
  • - ); - } const noteId = `note-${row.forkId}`; return (
  • @@ -75,10 +75,9 @@ function Row({ row }: { row: DeskRow }) { row.title )} -

    {age(row)}

    {open && ( <> -

    {row.body}

    +

    {row.context}

      {row.options.map((o) => (
    • @@ -88,8 +87,8 @@ function Row({ row }: { row: DeskRow }) { ))}
    {row.depends.map((d) => ( -

    - {`Waits on: "${d.text}"${d.status ? ` (${d.status})` : ''}`} +

    + {`Waits on: "${d}"`}

    ))} Your note @@ -115,9 +114,7 @@ function Status({ desk, label }: { desk: Desk; label: string }) {

    {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.`}

    ); } @@ -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; }; /** 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 (
    diff --git a/src/app/features/desk/buildDesk.test.ts b/src/app/features/desk/buildDesk.test.ts index 29d2df40..657677fa 100644 --- a/src/app/features/desk/buildDesk.test.ts +++ b/src/app/features/desk/buildDesk.test.ts @@ -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) => 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); }); }); diff --git a/src/app/features/desk/buildDesk.ts b/src/app/features/desk/buildDesk.ts index 4c8fee6f..5694df9a 100644 --- a/src/app/features/desk/buildDesk.ts +++ b/src/app/features/desk/buildDesk.ts @@ -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(); - 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 = new Set() ): Desk => { - if (!p?.syncedAt) { - return { - published: false, - stale: false, - open: [], - recorded: [], - counts: { open: 0, answered: 0 }, - }; - } - const known = new Map(); - 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 }, }; }; diff --git a/src/app/features/desk/feedback.ts b/src/app/features/desk/feedback.ts deleted file mode 100644 index 2b188b17..00000000 --- a/src/app/features/desk/feedback.ts +++ /dev/null @@ -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 = { - /** `:question: