diff --git a/src/app/components/message/pose/PoseAvatar.tsx b/src/app/components/message/pose/PoseAvatar.tsx index 75a4b333..fac585a4 100644 --- a/src/app/components/message/pose/PoseAvatar.tsx +++ b/src/app/components/message/pose/PoseAvatar.tsx @@ -12,7 +12,8 @@ type PoseAvatarProps = { alt?: boolean; /** Play the first-appearance pop. */ enter?: boolean; - onClick: React.MouseEventHandler; + /** Without it the portrait is a plain image: no button, no tab stop. */ + onClick?: React.MouseEventHandler; }; export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) { @@ -43,9 +44,10 @@ export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvat }; }, [src, alt]); + const Tag = onClick ? 'button' : 'span'; return ( - + ); } diff --git a/src/app/features/desk/Desk.tsx b/src/app/features/desk/Desk.tsx new file mode 100644 index 00000000..548be685 --- /dev/null +++ b/src/app/features/desk/Desk.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; +import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { getHomePath } from '../../pages/pathUtils'; +import { DeskView } from './DeskView'; +import { unverifiedForks } from './optionsHash'; +import { deskFor } from './source'; + +const labelOf = (mind: string) => mind.charAt(0).toUpperCase() + mind.slice(1); + +/** The route's page: one Mind's desk under the navigator. */ +export function Desk() { + const { mind = '' } = useParams(); + const navigate = useNavigate(); + const phone = useScreenSizeContext() === ScreenSize.Mobile; + const label = labelOf(mind); + const projection = useMemo(() => deskFor(mind), [mind]); + // Until the recompute finishes nothing is flagged; a card that fails is withdrawn when it lands. + const [unverified, setUnverified] = useState>(new Set()); + useEffect(() => { + let live = true; + unverifiedForks(projection?.questions ?? []).then((bad) => live && setUnverified(bad)); + return () => { + live = false; + }; + }, [projection]); + + 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..4c7d08a7 --- /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} > [data-pose]`, { + position: 'static', + float: 'none', + display: 'block', + width: '100%', + height: 'auto', + margin: 0, + maskImage: 'none', + WebkitMaskImage: 'none', + cursor: 'default', +}); + +export const BoothLine = style({ + '@media': { + // Still read out by the live region; only the picture shows in the corner. + [PHONE]: { + position: 'absolute', + width: 1, + height: 1, + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + whiteSpace: 'nowrap', + }, + }, +}); diff --git a/src/app/features/desk/DeskView.test.tsx b/src/app/features/desk/DeskView.test.tsx new file mode 100644 index 00000000..09d3f45a --- /dev/null +++ b/src/app/features/desk/DeskView.test.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize'; +import { DeskEntry } from './DeskEntry'; +import { DeskView, SIGN_IN_NOTE } from './DeskView'; +import { NOW, STATES } from './fixtures'; +import { setDeskSource } from './source'; + +const view = ( + state: keyof typeof STATES, + size: ScreenSize, + mind = 'void', + unverified = new Set() +) => + 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('makes the booth portrait a plain image: no button, no tab stop', () => { + const html = view('noAnswers', ScreenSize.Desktop); + const booth = html.slice(html.indexOf('data-pose')); + expect(booth).toContain(']*data-pose/); + expect(html).not.toMatch(/<[a-z]+[^>]*data-pose[^>]*tabindex/); + expect(html).toMatch(/]*data-pose/); + }); + it('withdraws a card whose hash does not match and does not count it open', () => { + const html = view('noAnswers', ScreenSize.Desktop, 'void', new Set(['c:question:plain'])); + expect(html).toContain('does not match the hash it was published with'); + expect(html).not.toContain('First way'); + expect(html).not.toContain('Just one fork'); + expect(html).toContain('Nothing is waiting'); + }); + it('shows no booth for a Mind without art rather than another face', () => { + expect(view('noAnswers', ScreenSize.Desktop, 'nobody')).not.toContain('data-pose'); + }); + it('shows no booth before anything is published', () => { + expect(view('unpublished', ScreenSize.Desktop)).not.toContain('data-pose'); + }); +}); + +describe('DeskEntry', () => { + afterEach(() => setDeskSource(() => undefined)); + const entry = (mind: string) => + renderToStaticMarkup( + + + + ); + 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..fed7d18e --- /dev/null +++ b/src/app/features/desk/DeskView.tsx @@ -0,0 +1,172 @@ +import React, { useState } from 'react'; +import { Text } from 'folds'; +import { PoseAvatar } from '../../components/message/pose/PoseAvatar'; +import { MINDS } from '../../components/message/pose/poses'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { AnswerRow, Desk, DeskRow, buildDesk } from './buildDesk'; +import { DeskProjection } from './feedback'; +import { deskMood } from './deskMood'; +import * as css from './DeskView.css'; + +/** Slice 1 has no session: every viewer is signed out, and the note says why it cannot be used. */ +export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can read the questions.'; + +const date = (iso: string) => iso.slice(0, 10); + +const age = (row: Extract) => { + 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 === 'unverified') { + return ( +
  • +

    + A question here does not match the hash it was published with, so it is not shown. +

    +
  • + ); + } + 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 +