From 8dea8717ea4950f3f191a886ebf91f807c2389c1 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 18:10:31 +0000 Subject: [PATCH 1/2] desk: read-only Mind desk slice 1 (typed projections, buildDesk, deskMood, DeskView, navigator entry, demo forks) Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/desk/Desk.tsx | 45 ++++++ src/app/features/desk/DeskEntry.tsx | 17 +++ src/app/features/desk/DeskView.css.ts | 99 ++++++++++++ src/app/features/desk/DeskView.test.tsx | 110 +++++++++++++ src/app/features/desk/DeskView.tsx | 161 ++++++++++++++++++++ src/app/features/desk/buildDesk.test.ts | 127 +++++++++++++++ src/app/features/desk/buildDesk.ts | 153 +++++++++++++++++++ src/app/features/desk/deskMood.test.ts | 59 +++++++ src/app/features/desk/deskMood.ts | 75 +++++++++ src/app/features/desk/feedback.ts | 70 +++++++++ src/app/features/desk/fixtures.ts | 100 ++++++++++++ src/app/features/desk/index.ts | 3 + src/app/features/desk/source.ts | 13 ++ src/app/features/estate/EstateNavigator.tsx | 13 +- src/app/pages/Router.tsx | 11 ++ src/app/pages/pathUtils.ts | 4 + src/app/pages/paths.ts | 3 + src/preview-demo/deskForks.test.ts | 30 ++++ src/preview-demo/deskForks.ts | 72 +++++++++ src/preview-demo/install.ts | 3 + 20 files changed, 1166 insertions(+), 2 deletions(-) create mode 100644 src/app/features/desk/Desk.tsx create mode 100644 src/app/features/desk/DeskEntry.tsx create mode 100644 src/app/features/desk/DeskView.css.ts create mode 100644 src/app/features/desk/DeskView.test.tsx create mode 100644 src/app/features/desk/DeskView.tsx create mode 100644 src/app/features/desk/buildDesk.test.ts create mode 100644 src/app/features/desk/buildDesk.ts create mode 100644 src/app/features/desk/deskMood.test.ts create mode 100644 src/app/features/desk/deskMood.ts create mode 100644 src/app/features/desk/feedback.ts create mode 100644 src/app/features/desk/fixtures.ts create mode 100644 src/app/features/desk/index.ts create mode 100644 src/app/features/desk/source.ts create mode 100644 src/preview-demo/deskForks.test.ts create mode 100644 src/preview-demo/deskForks.ts diff --git a/src/app/features/desk/Desk.tsx b/src/app/features/desk/Desk.tsx new file mode 100644 index 00000000..9992be64 --- /dev/null +++ b/src/app/features/desk/Desk.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; +import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { getHomePath } from '../../pages/pathUtils'; +import { DeskView } from './DeskView'; +import { deskFor } from './source'; + +const labelOf = (mind: string) => mind.charAt(0).toUpperCase() + mind.slice(1); + +/** The route's page: one Mind's desk under the navigator. */ +export function Desk() { + const { mind = '' } = useParams(); + const navigate = useNavigate(); + const phone = useScreenSizeContext() === ScreenSize.Mobile; + const label = labelOf(mind); + + return ( + + + + + {phone && ( + navigate(getHomePath())}> + + + )} + + {`${label}'s desk`} + + + + + + + + + + + + + + ); +} diff --git a/src/app/features/desk/DeskEntry.tsx b/src/app/features/desk/DeskEntry.tsx new file mode 100644 index 00000000..1d3068ab --- /dev/null +++ b/src/app/features/desk/DeskEntry.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Text } from 'folds'; +import { getDeskPath } from '../../pages/pathUtils'; +import { countOpen } from './buildDesk'; +import { deskFor } from './source'; + +/** The way into a Mind's desk, set in its navigator group header; nothing at all when nothing is open. */ +export function DeskEntry({ mind }: { mind: string }) { + const open = countOpen(deskFor(mind)); + if (open === 0) return null; + return ( + + {`Desk · ${open} open`} + + ); +} diff --git a/src/app/features/desk/DeskView.css.ts b/src/app/features/desk/DeskView.css.ts new file mode 100644 index 00000000..1e93f51b --- /dev/null +++ b/src/app/features/desk/DeskView.css.ts @@ -0,0 +1,99 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; +import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; + +const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; + +/** Rows and booth side by side on wide screens. */ +export const Layout = style({ + display: 'flex', + gap: config.space.S500, + alignItems: 'flex-start', + '@media': { [PHONE]: { display: 'block', paddingBottom: toRem(120) } }, +}); + +export const Rows = style({ flex: 1, minWidth: 0, margin: 0, padding: 0, listStyle: 'none' }); + +/** A hairline splits rows; it is the only chrome. */ +export const Row = style({ + padding: `${config.space.S300} 0`, + borderTop: `1px solid ${color.Background.ContainerLine}`, +}); + +export const Title = style({ + margin: 0, + font: 'inherit', + fontWeight: 600, +}); + +/** The fold button reads as the title itself, not as a control. */ +export const Fold = style({ + all: 'unset', + cursor: 'pointer', + display: 'block', + width: '100%', + minHeight: 44, + boxSizing: 'border-box', +}); + +export const Quiet = style({ opacity: config.opacity.P300, margin: `${config.space.S100} 0` }); +export const Body = style({ whiteSpace: 'pre-wrap', margin: `${config.space.S200} 0` }); +export const Options = style({ margin: 0, padding: 0, listStyle: 'none' }); +export const Option = style({ padding: `${config.space.S100} 0` }); + +export const Note = style({ + display: 'block', + width: '100%', + boxSizing: 'border-box', + minHeight: 44, + margin: `${config.space.S200} 0`, + font: 'inherit', + color: 'inherit', + background: 'none', + border: `1px solid ${color.Background.ContainerLine}`, + opacity: config.opacity.Disabled, +}); + +/** Sticky beside the rows on wide screens; a small fixed corner image on phones. */ +export const Booth = style({ + position: 'sticky', + top: config.space.S400, + width: toRem(168), + flexShrink: 0, + '@media': { + [PHONE]: { + position: 'fixed', + top: 'auto', + right: config.space.S200, + bottom: config.space.S200, + width: toRem(88), + }, + }, +}); + +// PoseAvatar's button is laid out for message gutters; the booth takes it back into flow. +globalStyle(`${Booth} > button`, { + position: 'static', + float: 'none', + display: 'block', + width: '100%', + height: 'auto', + margin: 0, + maskImage: 'none', + WebkitMaskImage: 'none', + cursor: 'default', +}); + +export const BoothLine = style({ + '@media': { + // Still read out by the live region; only the picture shows in the corner. + [PHONE]: { + position: 'absolute', + width: 1, + height: 1, + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + whiteSpace: 'nowrap', + }, + }, +}); diff --git a/src/app/features/desk/DeskView.test.tsx b/src/app/features/desk/DeskView.test.tsx new file mode 100644 index 00000000..f1f09c86 --- /dev/null +++ b/src/app/features/desk/DeskView.test.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize'; +import { DeskEntry } from './DeskEntry'; +import { DeskView, SIGN_IN_NOTE } from './DeskView'; +import { NOW, STATES } from './fixtures'; +import { setDeskSource } from './source'; + +const view = (state: keyof typeof STATES, size: ScreenSize, mind = 'void') => + renderToStaticMarkup( + + + + ); + +describe('DeskView', () => { + it('folds a row behind its title on a phone, with the fold button saying so', () => { + const html = view('noAnswers', ScreenSize.Mobile); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain('Question plain'); + expect(html).not.toContain('Context for plain'); + expect(html).not.toContain('First way'); + }); + it('shows the row open, with no fold button, on a wide screen', () => { + const html = view('noAnswers', ScreenSize.Desktop); + expect(html).not.toContain('aria-expanded'); + expect(html).toContain('Context for plain'); + expect(html).toContain('First way'); + }); + it('states the recommendation in words, on that option only', () => { + const html = view('noAnswers', ScreenSize.Desktop); + expect(html.match(/\(recommended\)/g)).toHaveLength(1); + expect(html).toContain('B Second way (recommended)'); + }); + it('shows the depends line with the dependency status', () => { + expect(view('blocked', ScreenSize.Desktop)).toContain( + 'Waits on: "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=""/); + expect(html).toContain(SIGN_IN_NOTE); + expect(SIGN_IN_NOTE).toMatch(/sign-in/); + expect(html).toMatch(/aria-describedby="note-[^"]+-why"/); + }); + it('says No answers yet on a row without answers', () => { + expect(view('noAnswers', ScreenSize.Desktop)).toContain('No answers yet.'); + }); + it('shows how long a long-open question has waited', () => { + expect(view('longOpen', ScreenSize.Desktop)).toContain('Open for 21 days'); + }); + it('lists settled questions as recorded rulings with the ruling id', () => { + const html = view('settled', ScreenSize.Desktop); + expect(html).toContain('Recorded as rulings'); + expect(html).toContain('Question done (ruling:done-1)'); + expect(html).not.toContain('Question shut'); + }); + it('says it may be out of date when stale, and says nothing of the kind when fresh', () => { + expect(view('stale', ScreenSize.Desktop)).toContain('may be out of date'); + expect(view('noAnswers', ScreenSize.Desktop)).not.toContain('out of date'); + }); + it('says nothing is waiting when empty, and that nothing is published when it never arrived', () => { + expect(view('empty', ScreenSize.Desktop)).toContain('Nothing is waiting on you from Void.'); + expect(view('unpublished', ScreenSize.Desktop)).toContain( + 'Void's questions have not been published yet.' + ); + }); + it('shows hidden and erased rows as withheld stubs', () => { + expect(view('hidden', ScreenSize.Desktop)).toContain('hidden by Kiko'); + expect(view('erased', ScreenSize.Desktop)).toContain('erased account'); + }); + it('seats the Mind in the booth with the pose for the desk mood', () => { + const html = view('noAnswers', ScreenSize.Desktop); + expect(html).toContain('data-pose="thinking"'); + expect(html).toContain('Just one fork on the desk.'); + }); + it('shows no booth for a Mind without art rather than another face', () => { + expect(view('noAnswers', ScreenSize.Desktop, 'nobody')).not.toContain('data-pose'); + }); + it('shows no booth before anything is published', () => { + expect(view('unpublished', ScreenSize.Desktop)).not.toContain('data-pose'); + }); +}); + +describe('DeskEntry', () => { + afterEach(() => setDeskSource(() => undefined)); + const entry = (mind: string) => + renderToStaticMarkup( + + + + ); + it('reads "Desk · N open" and links to that Mind\'s desk', () => { + setDeskSource(() => STATES.blocked); + const html = entry('void'); + expect(html).toContain('Desk · 2 open'); + expect(html).toContain('href="/desk/void"'); + }); + it('is absent at zero open, when nothing is published, and when only settled forks exist', () => { + setDeskSource(() => STATES.empty); + expect(entry('void')).toBe(''); + setDeskSource(() => undefined); + expect(entry('void')).toBe(''); + setDeskSource(() => ({ ...STATES.settled, questions: STATES.settled.questions.slice(0, 2) })); + expect(entry('void')).toBe(''); + }); +}); diff --git a/src/app/features/desk/DeskView.tsx b/src/app/features/desk/DeskView.tsx new file mode 100644 index 00000000..80c22828 --- /dev/null +++ b/src/app/features/desk/DeskView.tsx @@ -0,0 +1,161 @@ +import React, { useState } from 'react'; +import { Text } from 'folds'; +import { PoseAvatar } from '../../components/message/pose/PoseAvatar'; +import { MINDS } from '../../components/message/pose/poses'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { AnswerRow, Desk, DeskRow, buildDesk } from './buildDesk'; +import { DeskProjection } from './feedback'; +import { deskMood } from './deskMood'; +import * as css from './DeskView.css'; + +/** Slice 1 has no session: every viewer is signed out, and the note says why it cannot be used. */ +export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can read the questions.'; + +const date = (iso: string) => iso.slice(0, 10); + +const age = (row: Extract) => { + if (row.longOpen) return `Open for ${row.ageDays} days`; + if (row.ageDays === 0) return 'Asked today'; + return row.ageDays === 1 ? 'Asked 1 day ago' : `Asked ${row.ageDays} days ago`; +}; + +function Answers({ answers }: { answers: AnswerRow[] }) { + if (answers.length === 0) return

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)})`} +
  • + ))} +
+ ); +} + +function Row({ row }: { row: DeskRow }) { + const phone = useScreenSizeContext() === ScreenSize.Mobile; + // Phones list titles; a tap opens one. Wide screens show every row open. + const [open, setOpen] = useState(!phone); + if (row.kind === 'hidden') { + return ( +
  • +

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

    +
  • + ); + } + const noteId = `note-${row.forkId}`; + return ( +
  • +

    + {phone ? ( + + ) : ( + row.title + )} +

    +

    {age(row)}

    + {open && ( + <> +

    {row.body}

    +
      + {row.options.map((o) => ( +
    • + {`${o.id} ${o.text}`} + {o.recommended && ' (recommended)'} +
    • + ))} +
    + {row.depends.map((d) => ( +

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

    + ))} + Your note +