diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 58e1dd7..b704e40 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -16,7 +16,7 @@ import { } from "./dialogs.js"; import { Editor } from "./Editor.js"; import { renderPageBody } from "./markdown.js"; -import { History, linkTarget, type Location } from "./navigation.js"; +import { linkTarget, Shell, type Location, type Overlay, type Pane } from "./navigation.js"; import { clearAt, clearFailureAt, @@ -28,8 +28,12 @@ import { type Place, } from "./notices.js"; import { Findings, History as HistoryPanel, PageSources, SourceAt } from "./Panels.js"; -import { RecordingIndicator } from "./RecordingIndicator.js"; -import { useRecording, type RecordingState } from "./recording.js"; +import { Rail } from "./Rail.js"; +import { useRecording } from "./recording.js"; +import { StatusBar } from "./StatusBar.js"; +import { Titlebar } from "./Titlebar.js"; +import { Drawer } from "./ui/Drawer.js"; +import { Sheet } from "./ui/Sheet.js"; import { Launcher } from "./Launcher.js"; import { Settings } from "./Settings.js"; import { Sources } from "./Sources.js"; @@ -62,14 +66,20 @@ export function App(): React.JSX.Element { // 1.5 — one slot per place rather than one for the window. What failed is // said where it failed, and a failure in one place no longer erases another. const [notices, setNotices] = useState([]); - const history = useRef(new History()); - const [location, setLocation] = useState({ view: "wiki" }); + // Where the window is, and the overlays that are not places. The `Shell` + // owns the rules (spec `desktop-shell`); these two mirror it so React + // re-renders, because a mutable object in a ref does not. + const shell = useRef(new Shell()); + const [location, setLocation] = useState(shell.current.location); + const [overlay, setOverlay] = useState(null); const recording = useRecording(); // 1.1 — every question this shell asks. `window.prompt` answers nothing in // Electron, so the four controls that used it did nothing at all. const { ask, confirm, element: dialog } = useDialogs(); - const [openSource, setOpenSource] = useState<{ id: string; fragment: string } | null>(null); + /** How many findings the checks last reported; null until they have run. */ + const [findings, setFindings] = useState(null); + const [lastWrite, setLastWrite] = useState(null); const [editing, setEditing] = useState(false); const [dragging, setDragging] = useState(false); const [dropped, setDropped] = useState(null); @@ -102,21 +112,28 @@ export function App(): React.JSX.Element { [say], ); - const go = useCallback((next: Location | null) => { - if (!next) return; + /** Arrive somewhere: whatever the `Shell` decided, put it on screen. */ + const arrive = useCallback((at: Location) => { setEditing(false); - setLocation(next); + setLocation(at); // The page left behind takes its notices with it. A rename's note is set // after the navigation that carried it, so it survives this on purpose. setNotices((current) => clearAt(current, "page")); }, []); - const visit = useCallback( - (next: Location) => { - go(history.current.visit(next)); - }, - [go], - ); + const visit = useCallback((next: Location) => arrive(shell.current.visit(next)), [arrive]); + + const goTo = useCallback((pane: Pane) => arrive(shell.current.goTo(pane)), [arrive]); + + const show = useCallback((next: Overlay) => { + shell.current.show(next); + setOverlay(next); + }, []); + + const dismiss = useCallback(() => { + shell.current.dismiss(); + setOverlay(null); + }, []); useEffect(() => { // The two failures that really are the window's, and the only two: without @@ -142,12 +159,12 @@ export function App(): React.JSX.Element { let timer: ReturnType | null = null; let reloadPage = false; const unsubscribe = bridge().onChanged((change) => { - reloadPage ||= isOpenPage(change, location.slug); + reloadPage ||= isOpenPage(change, location.selection); if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; void refreshIndex(); - if (reloadPage && location.slug) void reload(location.slug); + if (reloadPage && location.selection) void reload(location.selection); reloadPage = false; setReloadKey((n) => n + 1); }, COALESCE_MS); @@ -156,7 +173,7 @@ export function App(): React.JSX.Element { if (timer) clearTimeout(timer); unsubscribe(); }; - }, [location.slug, refreshIndex, reload]); + }, [location.selection, refreshIndex, reload]); // 3.7 — the doorway. A file an agent wrote into `raw/_inbox/` while this // window was open becomes a source with nobody clicking anything, so the @@ -173,12 +190,24 @@ export function App(): React.JSX.Element { }); }, []); + // Whether there is anything to undo (R5.4, R5.5). The newest operation, and + // nothing of it beyond the fact that it exists — the drawer is what shows the + // rest, and a copy of it in the status bar would be a second record of one + // fact, which is the one that goes stale. + useEffect(() => { + if (!hasBridge()) return; + void bridge() + .history() + .then((operations) => setLastWrite(operations[0]?.id ?? null)) + .catch(() => setLastWrite(null)); + }, [reloadKey]); + useEffect(() => { - if (location.view !== "wiki" || !location.slug) { + if (location.pane !== "wiki" || !location.selection) { setPage(null); return; } - void reload(location.slug); + void reload(location.selection); }, [location, reload]); const html = useMemo( @@ -199,10 +228,10 @@ export function App(): React.JSX.Element { // the system browser. if (target.kind === "page") { event.preventDefault(); - visit({ view: "wiki", slug: target.slug }); + visit({ pane: "wiki", selection: target.slug }); } else if (target.kind === "source") { event.preventDefault(); - setOpenSource({ id: target.id, fragment: target.fragment }); + show({ kind: "provenance", source: target.id, fragment: target.fragment }); } }, [visit], @@ -291,120 +320,127 @@ export function App(): React.JSX.Element { onDragLeave={() => setDragging(false)} onDrop={onDrop} > -
- {project?.name ?? "…"} - - - - void record(a)} /> -
- - {/* Beside the controls it is about, and above every pane because it is - about the window rather than about any one of them. */} - - - -
- {dropped ? setDropped(null)} /> : null} - { - setDropped((current) => [...(current ?? []), ...outcomes]); - setReloadKey((n) => n + 1); - }} - /> - {dragging ?

Drop files to add them as sources.

: null} - - {location.view === "wiki" && !location.slug ? ( - <> - {/* The wiki pane's own: reading the index failed, or creating a - page from this list did. */} - - visit({ view: "wiki", slug })} - onCreate={() => void createPage(index, ask, visit, say)} - /> - - ) : null} + void record(action)} + onSettings={() => show({ kind: "settings" })} + onBack={() => arrive(shell.current.back())} + onForward={() => arrive(shell.current.forward())} + canGoBack={shell.current.canGoBack} + canGoForward={shell.current.canGoForward} + /> + +
+ - {location.view === "wiki" && !page && location.slug ? ( - - ) : null} +
+ {/* Beside the controls they are about: the recording notice under + the titlebar, the shell's own above every pane. */} + + + {dropped ? setDropped(null)} /> : null} + { + setDropped((current) => [...(current ?? []), ...outcomes]); + setReloadKey((n) => n + 1); + }} + /> + {dragging ?

Drop files to add them as sources.

: null} - {location.view === "wiki" && page && !editing ? ( -
- + {/* The wiki pane's own: reading the index failed, or creating a + page from this list did. */} + + visit({ pane: "wiki", selection: slug })} + onCreate={() => void createPage(index, ask, visit, say)} + /> + + ) : null} + + {location.pane === "wiki" && !page && location.selection ? ( + + ) : null} + + {location.pane === "wiki" && page && !editing ? ( +
+ setEditing(true)} + onRename={() => void renameFlow(page.slug, ask, visit, say)} + onDelete={() => void deleteFlow(page.slug, confirm, visit, say)} + /> + {/* On the page, under the bar whose buttons caused it. */} + + + {/* 6.5 — where this page came from, and a way into each source. */} + show({ kind: "provenance", source: id, fragment })} + /> + {/* Rendered with `html: false` and two token rules, so what reaches + here is a closed set of tags this renderer produced. */} +
+
+ ) : null} + + {location.pane === "wiki" && page && editing ? ( + setEditing(true)} - onRename={() => void renameFlow(page.slug, ask, visit, say)} - onDelete={() => void deleteFlow(page.slug, confirm, visit, say)} + slugs={index.slugs} + onSaved={() => { + setEditing(false); + void reload(page.slug); + }} + onCancel={() => setEditing(false)} /> - {/* On the page, under the bar whose buttons caused it. */} - - - {/* 6.5 — where this page came from, and a way into each source. */} - setOpenSource({ id, fragment })} - /> - {/* Rendered with `html: false` and two token rules, so what reaches - here is a closed set of tags this renderer produced. */} -
visit({ pane: "wiki", selection: slug })} /> -
- ) : null} - - {location.view === "wiki" && page && editing ? ( - { - setEditing(false); - void reload(page.slug); - }} - onCancel={() => setEditing(false)} - /> - ) : null} - - {location.view === "sources" ? ( - visit({ view: "wiki", slug })} /> - ) : null} - {location.view === "checks" ? : null} - {location.view === "history" ? : null} - {location.view === "settings" ? : null} -
- - {openSource ? ( - setOpenSource(null)} - /> + ) : null} + {location.pane === "checks" ? ( + + ) : null} +
+ + + goTo("checks")} + onUndo={lastWrite ? () => show({ kind: "history" }) : null} + /> + + {/* The overlays. None of them is a place you went (R2.2), so none is in + the history — closing one puts you back exactly where it opened. */} + {overlay?.kind === "settings" ? ( + + + + ) : null} + + {overlay?.kind === "history" ? ( + + + + ) : null} + + {overlay?.kind === "provenance" ? ( + ) : null} {/* The open question, if there is one. A modal is in the top layer, so @@ -432,31 +468,6 @@ function Reported({ return

{notice.text}

; } -/** Record, pause, stop — the affordance 8.2 asks for. */ -function RecordControls({ - state, - onAction, -}: { - state: RecordingState; - onAction: (action: "start" | "pause" | "resume" | "stop") => void; -}): React.JSX.Element { - if (state === "idle") { - return ; - } - return ( - - {state === "paused" ? ( - - ) : ( - - )} - - - ); -} - /** What a drop did — 3.5 asks for what was recognised *and* what was not. */ function Dropped({ outcomes, @@ -537,7 +548,7 @@ async function createPage( ); return; } - visit({ view: "wiki", slug }); + visit({ pane: "wiki", selection: slug }); } catch (e) { say(failure("wiki", e)); } @@ -575,7 +586,7 @@ async function renameFlow( // **After the navigation, and a note rather than an error.** It reported a // success through the error channel before, in the same red box a failed // rename used — so the one outcome worth reading looked like the other. - visit({ view: "wiki", slug: to }); + visit({ pane: "wiki", selection: to }); if (result.repointed.length > 0) { say(note("page", `Renamed. Repointed the links on: ${result.repointed.join(", ")}`)); } @@ -597,7 +608,7 @@ async function deleteFlow( if (!(await confirm(deleteQuestion(slug)))) return; try { await bridge().remove(slug); - visit({ view: "wiki" }); + visit({ pane: "wiki" }); } catch (e) { // Still on the page, because the delete did not happen. say(failure("page", e)); diff --git a/apps/desktop/src/renderer/Panels.tsx b/apps/desktop/src/renderer/Panels.tsx index 99db238..1106015 100644 --- a/apps/desktop/src/renderer/Panels.tsx +++ b/apps/desktop/src/renderer/Panels.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { Finding, Operation } from "@open-wiki/access"; import type { PageSource, SourceLocation } from "../main/sources.js"; import { bridge } from "./bridge.js"; @@ -88,14 +88,48 @@ export function PageSources({ * where the person reading the problem is, and never to invent advice of its * own. */ -export function Findings({ reloadKey }: { reloadKey: number }): React.JSX.Element { +export function Findings({ + reloadKey, + onCount, +}: { + reloadKey: number; + /** + * How many there were, for the status bar (spec `desktop-shell`, R5.2). + * + * Handed up from this one load rather than counted again: `ow check` walks + * the whole project, and running it a second time to fill in a number in the + * frame is how a status bar becomes the slowest thing in the window. + */ + onCount?: (count: number) => void; +}): React.JSX.Element { const [findings, setFindings] = useState(null); + // Read through a ref so the effect below depends on `reloadKey` alone. A + // caller passing a fresh closure each render would otherwise re-run the + // whole check on every render. + const report = useRef(onCount); + report.current = onCount; useEffect(() => { + // Guarded, like `PageSources` above and for a sharper reason: two + // `reloadKey` bumps can overlap, and a slow answer for the older one + // arriving last would put a stale count in the status bar as well as stale + // findings on screen. + let live = true; void bridge() .findings() - .then(setFindings) - .catch(() => setFindings([])); + .then((found) => { + if (!live) return; + setFindings(found); + report.current?.(found.length); + }) + .catch(() => { + if (!live) return; + setFindings([]); + report.current?.(0); + }); + return () => { + live = false; + }; }, [reloadKey]); if (!findings) return

Checking…

; diff --git a/apps/desktop/src/renderer/Rail.tsx b/apps/desktop/src/renderer/Rail.tsx new file mode 100644 index 0000000..568a5bf --- /dev/null +++ b/apps/desktop/src/renderer/Rail.tsx @@ -0,0 +1,68 @@ +import { BookText, CircleCheck, Globe, Layers } from "lucide-react"; +import type { Pane } from "./navigation.js"; +import { ICON_MD, type Icon } from "./ui/icons.js"; + +/** + * The icon rail (spec `desktop-shell`, R4). + * + * **Every pane the window has, all of them visible at once.** A rail is not a + * menu: the point is that moving between the wiki and its sources costs one + * click and no memory of where the thing was, which is what makes it bearable + * to keep checking one against the other. + * + * MCP is absent, and that is the honest state rather than an omission — its + * pane is waiting on a server nobody has built (`specs/mcp-pane/`), and a rail + * entry leading to a pane that can say nothing is worse than no entry. + */ +export interface RailPane { + pane: Pane; + label: string; + icon: Icon; +} + +/** + * In the order the draft draws them: what you read, what it rests on, what is + * wrong with it. That is also the order a page is written in. + */ +export const PANES: readonly RailPane[] = [ + { pane: "wiki", label: "Wiki", icon: BookText }, + { pane: "sources", label: "Sources", icon: Layers }, + { pane: "checks", label: "Checks", icon: CircleCheck }, +]; + +export interface RailProps { + current: Pane; + onGoTo: (pane: Pane) => void; + /** The project's content language, as its code — `en`, `pt`, `es` (8.12). */ + language: string; +} + +export function Rail({ current, onGoTo, language }: RailProps): React.JSX.Element { + return ( + + ); +} diff --git a/apps/desktop/src/renderer/StatusBar.tsx b/apps/desktop/src/renderer/StatusBar.tsx new file mode 100644 index 0000000..7acbb72 --- /dev/null +++ b/apps/desktop/src/renderer/StatusBar.tsx @@ -0,0 +1,58 @@ +/** + * The status bar (spec `desktop-shell`, R5). + * + * Three facts, and each is a fact somebody needs without asking for it: which + * directory this window is actually looking at, whether the checks found + * anything, and a way back from the last write. + * + * **The findings count is handed in, never computed here.** `ow check` walks + * the whole project, and running it to fill a number in the frame is how a + * status bar becomes the slowest thing in the window. Until the checks pane has + * loaded once there is no count, and the bar says that rather than showing a + * confident zero (R5.2) — "no findings" and "not looked yet" are not the same + * sentence, and the second one dressed as the first is the more dangerous of + * the two. + */ +export interface StatusBarProps { + /** The project directory, to be read off the screen and typed into a shell. */ + root: string; + /** How many findings the checks last reported; null until they have run. */ + findings: number | null; + onGoToChecks: () => void; + /** Undoing the last recorded write, or null when there is none to undo. */ + onUndo: (() => void) | null; +} + +export function StatusBar({ + root, + findings, + onGoToChecks, + onUndo, +}: StatusBarProps): React.JSX.Element { + return ( +
+ {root} + + + {findings === null ? ( + not checked yet + ) : ( + + )} + + {onUndo ? ( + + ) : ( + // Said rather than offered (R5.5). A disabled button invites a click + // and then explains nothing; this explains and invites nothing. + nothing to undo + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/Titlebar.tsx b/apps/desktop/src/renderer/Titlebar.tsx new file mode 100644 index 0000000..7a72543 --- /dev/null +++ b/apps/desktop/src/renderer/Titlebar.tsx @@ -0,0 +1,77 @@ +import { ArrowLeft, ArrowRight, Mic, Pause, Play, Settings2, Square } from "lucide-react"; +import { RecordingIndicator } from "./RecordingIndicator.js"; +import type { Recording } from "./recording.js"; +import { Button } from "./ui/Button.js"; +import { IconButton } from "./ui/IconButton.js"; + +/** + * The titlebar (spec `desktop-shell`, R3). + * + * **What is here is what is true regardless of which pane is open**, and that + * is the whole rule for what belongs: which project you are in, and whether you + * are being recorded. Both are questions whose answer must never require + * looking somewhere — and the recording one especially, because this + * application captures other people's conversation and somebody who forgets it + * is running has a recording of a meeting the room thinks ended. + * + * The pause and stop controls sit beside the indicator rather than on the + * sources pane for the same reason: the moment you need them is the moment you + * are looking at something else. + */ +export interface TitlebarProps { + project: string; + recording: Recording; + onRecord: (action: "start" | "pause" | "resume" | "stop") => void; + onSettings: () => void; + onBack: () => void; + onForward: () => void; + canGoBack: boolean; + canGoForward: boolean; +} + +export function Titlebar({ + project, + recording, + onRecord, + onSettings, + onBack, + onForward, + canGoBack, + canGoForward, +}: TitlebarProps): React.JSX.Element { + const running = recording.state !== "idle"; + return ( +
+ {/* **Not in the draft, and here anyway.** The draft's titlebar has no + Back, because it draws a wiki you move around with the tree. But a + wiki is also read by following a link and returning, and the spec's + own purpose says so — a shell with no way to return has removed half + of how the content is used, quietly, while every screenshot still + looks right. */} + + + + {project} + + + + + {running ? ( + <> + {recording.state === "paused" ? ( + onRecord("resume")} /> + ) : ( + onRecord("pause")} /> + )} + onRecord("stop")} /> + + ) : ( + + )} + + +
+ ); +} diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css index f27b7f3..4e55a6f 100644 --- a/apps/desktop/src/renderer/globals.css +++ b/apps/desktop/src/renderer/globals.css @@ -152,10 +152,129 @@ body { } } +/* --- The frame (spec `desktop-shell`): a titlebar, the rail beside the pane, + a status bar. Three rows, and only the middle one scrolls. */ .app { display: grid; - grid-template-rows: auto 1fr; + grid-template-rows: 38px 1fr 24px; height: 100%; + background: var(--card); +} + +.titlebar { + display: flex; + align-items: center; + gap: 10px; + padding: 0 10px; + background: var(--background); + border-bottom: 1px solid var(--border); +} + +.app-body { + display: grid; + grid-template-columns: 52px 1fr; + min-height: 0; +} + +.rail { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + padding: var(--space-2) 0; + background: var(--background); + border-right: 1px solid var(--border); +} + +.rail-btn { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + width: 44px; + height: auto; + padding: 6px 0 5px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: var(--muted-foreground); + font: inherit; + font-size: var(--text-2xs); + cursor: pointer; +} + +.rail-btn:hover:not(:disabled) { + background: var(--muted); + color: var(--foreground); + border-color: transparent; +} + +/* Marked twice over: the accent, and a bar down the left edge. Colour alone is + not a state anybody should be asked to rely on. */ +.rail-btn[aria-selected="true"] { + color: var(--primary); + background: rgb(217 154 78 / 10%); +} + +.rail-btn[aria-selected="true"]::before { + content: ""; + position: absolute; + left: -8px; + top: 8px; + bottom: 8px; + width: 2px; + border-radius: 0 2px 2px 0; + background: var(--primary); +} + +/* The language is shown, not offered — the settings sheet is where it changes. */ +.rail-btn--static { + cursor: default; +} + +.rail-btn--static:hover { + background: transparent; + color: var(--muted-foreground); +} + +.rail-spacer { + flex: 1; +} + +.statusbar { + display: flex; + align-items: center; + gap: 14px; + padding: 0 var(--space-3); + background: var(--background); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--muted-foreground); +} + +.statusbar__path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Text that acts. A `.btn` here would be a control in a bar of labels. */ +.statusbar__button { + height: auto; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +.statusbar__button:hover:not(:disabled) { + background: transparent; + border-color: transparent; + color: var(--foreground); } .chrome { @@ -176,11 +295,6 @@ body { flex: 1; } -.nav { - display: flex; - gap: var(--space-1); -} - /* =========================================================================== The primitives (2.3), as the draft's Components plate draws them. diff --git a/apps/desktop/src/renderer/navigation.ts b/apps/desktop/src/renderer/navigation.ts index 7b9645b..2708e40 100644 --- a/apps/desktop/src/renderer/navigation.ts +++ b/apps/desktop/src/renderer/navigation.ts @@ -1,21 +1,51 @@ import { FRAGMENT_ATTR, PAGE_ATTR, SOURCE_ATTR } from "./markdown.js"; /** - * Where the reader is, and how they get back (plan 8.5). + * Where the reader is, and how they get back (plan 8.5, spec `desktop-shell`). * * A wiki is read by following links and returning, so "go back" is not a * convenience — it is half of how the content is used. Kept as a stack of - * visited slugs with a cursor rather than a list of "previous": following a + * visited locations with a cursor rather than a list of "previous": following a * link after going back has to discard the forward history, exactly as a * browser does, or Back stops meaning "where I came from". + * + * **A location is a pane and a selection within it; an overlay is neither.** + * + * This used to be one flat `view` that also carried `settings` and `history`, + * which made opening the settings a place you had gone — so Back after closing + * them landed on the pane *before* the one you opened them from, the settings + * having eaten the press that should have taken you there. `Shell` below is + * where that separation is enforced. */ +/** The panes the rail offers. MCP joins them with `specs/mcp-pane/`. */ +export type Pane = "wiki" | "sources" | "checks"; + export interface Location { - /** `wiki` browses a page; the other screens have no page. */ - view: "wiki" | "sources" | "checks" | "history" | "settings"; - slug?: string; + pane: Pane; + /** What is selected inside it — a page's slug, a source's id. */ + selection?: string; } +/** + * What sits *over* a location: consulted or adjusted, never travelled to. + * + * `Shell` holds one of these rather than a set, because two at once is a state + * nothing can arrive at and a set would make it representable. + * + * **Two are modal ``s; the provenance viewer is deliberately not.** You + * open a citation to check a claim you are in the middle of reading, and a + * panel that makes the paragraph behind it unreadable has answered a question + * by taking away the thing that raised it. So the rule that nothing navigates + * while an overlay is open is asserted below rather than inherited from the + * modal — it would hold for two of the three by accident, which is the kind of + * rule something later is built on and discovers to be false. + */ +export type Overlay = + | { kind: "settings" } + | { kind: "history" } + | { kind: "provenance"; source: string; fragment: string }; + export class History { private readonly entries: Location[] = []; private cursor = -1; @@ -66,7 +96,104 @@ export class History { } function same(a: Location, b: Location): boolean { - return a.view === b.view && a.slug === b.slug; + return a.pane === b.pane && a.selection === b.selection; +} + +/** + * The window's position, and the one thing that is not part of it. + * + * Three facts live here rather than in `App`: where you are, what each pane was + * left showing, and which overlay is open. They are together because every rule + * worth having is about the relationship between them — + * + * - **an overlay is never recorded** (R2.2), so closing one returns to the + * location it was opened over rather than to the one before that; + * - **nothing navigates while an overlay is open** (R2.4). The modal already + * makes the pane behind it unclickable, but the keyboard shortcuts of 8.1 are + * not behind that inert layer, so the rule is asserted rather than assumed; + * - **a pane remembers what it was showing** (R1.7). Kept beside the history + * rather than dug out of it: walking backwards for the most recent entry with + * a given pane is the same fact stored twice and read the long way round. + */ +export class Shell { + private readonly history = new History(); + private readonly lastSeen: Partial> = {}; + private open: Overlay | null = null; + + constructor(start: Location = { pane: "wiki" }) { + this.history.visit(start); + } + + get location(): Location { + return this.history.current ?? { pane: "wiki" }; + } + + get overlay(): Overlay | null { + return this.open; + } + + get canGoBack(): boolean { + return this.history.canGoBack; + } + + get canGoForward(): boolean { + return this.history.canGoForward; + } + + /** + * Enter a pane, at whatever it was last showing (R1.7). + * + * Coming back to the wiki after a detour through Sources lands on the page + * that was being read, which is the whole point of remembering: the rail is + * how you leave a page for a moment, not how you close it. + */ + goTo(pane: Pane): Location { + return this.navigate({ pane, selection: this.lastSeen[pane] }); + } + + /** Somewhere new outright — a wikilink to a page, say (R1.3). */ + visit(location: Location): Location { + return this.navigate(location); + } + + back(): Location { + return this.step(() => this.history.back()); + } + + forward(): Location { + return this.step(() => this.history.forward()); + } + + /** Open an overlay over where you are. At most one (R2.5). */ + show(overlay: Overlay): void { + this.open = overlay; + } + + /** Close it, onto the location it was opened over (R2.3). */ + dismiss(): void { + this.open = null; + } + + private navigate(to: Location): Location { + if (this.open) return this.location; + const at = this.history.visit(to); + this.remember(at); + return at; + } + + private step(move: () => Location | null): Location { + if (this.open) return this.location; + move(); + this.remember(this.location); + return this.location; + } + + private remember(at: Location): void { + // Only a real selection. A pane entered with nothing selected must not + // erase what it was showing, or Back and the rail would disagree about + // what "the wiki" means the moment you visited its index. + if (at.selection) this.lastSeen[at.pane] = at.selection; + } } /** diff --git a/apps/desktop/tests/renderer.spec.ts b/apps/desktop/tests/renderer.spec.ts index 215d86f..7fd7a87 100644 --- a/apps/desktop/tests/renderer.spec.ts +++ b/apps/desktop/tests/renderer.spec.ts @@ -6,7 +6,7 @@ import { renderPageBody, SOURCE_ATTR, } from "../src/renderer/markdown.js"; -import { History, isOpenableExternally, linkTarget } from "../src/renderer/navigation.js"; +import { History, isOpenableExternally, linkTarget, Shell } from "../src/renderer/navigation.js"; import { describeRecording, formatElapsed, readStatus, IDLE } from "../src/renderer/recording.js"; describe("extractWikilinks", () => { @@ -157,52 +157,146 @@ describe("renderPageBody (8.5)", () => { describe("History (8.5)", () => { it("remembers where the reader came from", () => { const history = new History(); - history.visit({ view: "wiki", slug: "a" }); - history.visit({ view: "wiki", slug: "b" }); - expect(history.back()).toEqual({ view: "wiki", slug: "a" }); + history.visit({ pane: "wiki", selection: "a" }); + history.visit({ pane: "wiki", selection: "b" }); + expect(history.back()).toEqual({ pane: "wiki", selection: "a" }); expect(history.canGoForward).toBe(true); }); it("goes forward again", () => { const history = new History(); - history.visit({ view: "wiki", slug: "a" }); - history.visit({ view: "wiki", slug: "b" }); + history.visit({ pane: "wiki", selection: "a" }); + history.visit({ pane: "wiki", selection: "b" }); history.back(); - expect(history.forward()).toEqual({ view: "wiki", slug: "b" }); + expect(history.forward()).toEqual({ pane: "wiki", selection: "b" }); }); it("discards the forward history when a new link is followed", () => { // Exactly as a browser does, or Back stops meaning "where I came from". const history = new History(); - history.visit({ view: "wiki", slug: "a" }); - history.visit({ view: "wiki", slug: "b" }); + history.visit({ pane: "wiki", selection: "a" }); + history.visit({ pane: "wiki", selection: "b" }); history.back(); - history.visit({ view: "wiki", slug: "c" }); + history.visit({ pane: "wiki", selection: "c" }); expect(history.canGoForward).toBe(false); - expect(history.back()).toEqual({ view: "wiki", slug: "a" }); + expect(history.back()).toEqual({ pane: "wiki", selection: "a" }); }); it("does not record visiting the place you are already at", () => { const history = new History(); - history.visit({ view: "wiki", slug: "a" }); - history.visit({ view: "wiki", slug: "a" }); + history.visit({ pane: "wiki", selection: "a" }); + history.visit({ pane: "wiki", selection: "a" }); expect(history.canGoBack).toBe(false); }); it("cannot go back past the beginning or forward past the end", () => { const history = new History(); expect(history.back()).toBeNull(); - history.visit({ view: "wiki" }); - expect(history.back()).toEqual({ view: "wiki" }); - expect(history.forward()).toEqual({ view: "wiki" }); + history.visit({ pane: "wiki" }); + expect(history.back()).toEqual({ pane: "wiki" }); + expect(history.forward()).toEqual({ pane: "wiki" }); }); it("keeps the trail behind the cursor", () => { const history = new History(); - history.visit({ view: "wiki", slug: "a" }); - history.visit({ view: "wiki", slug: "b" }); + history.visit({ pane: "wiki", selection: "a" }); + history.visit({ pane: "wiki", selection: "b" }); history.back(); - expect(history.trail).toEqual([{ view: "wiki", slug: "a" }]); + expect(history.trail).toEqual([{ pane: "wiki", selection: "a" }]); + }); +}); + +/** + * The window's position, and the thing that is not part of it + * (spec `desktop-shell`, R1 and R2). + */ +describe("Shell", () => { + it("starts in the wiki, with nothing selected", () => { + expect(new Shell().location).toEqual({ pane: "wiki" }); + }); + + it("records a pane and a selection as places you have been — R1.2, R1.3", () => { + const shell = new Shell(); + shell.visit({ pane: "wiki", selection: "fenix" }); + shell.goTo("sources"); + expect(shell.location).toEqual({ pane: "sources" }); + expect(shell.back()).toEqual({ pane: "wiki", selection: "fenix" }); + }); + + it("comes back to a pane at what it was last showing — R1.7", () => { + // The rail is how you leave a page for a moment, not how you close it. + const shell = new Shell(); + shell.visit({ pane: "wiki", selection: "fenix" }); + shell.goTo("sources"); + expect(shell.goTo("wiki")).toEqual({ pane: "wiki", selection: "fenix" }); + }); + + it("does not forget a pane's page when that pane's index is visited", () => { + // Otherwise Back and the rail disagree about what "the wiki" means the + // moment you go to its list of pages. + const shell = new Shell(); + shell.visit({ pane: "wiki", selection: "fenix" }); + shell.visit({ pane: "wiki" }); + shell.goTo("sources"); + expect(shell.goTo("wiki")).toEqual({ pane: "wiki", selection: "fenix" }); + }); + + it("does not record the location you are already at — R1.6", () => { + const shell = new Shell(); + shell.visit({ pane: "wiki", selection: "fenix" }); + shell.visit({ pane: "wiki", selection: "fenix" }); + shell.back(); + expect(shell.location).toEqual({ pane: "wiki" }); + expect(shell.canGoBack).toBe(false); + }); + + it("discards the future when you go somewhere new after going back — R1.5", () => { + const shell = new Shell(); + shell.visit({ pane: "wiki", selection: "a" }); + shell.visit({ pane: "wiki", selection: "b" }); + shell.back(); + shell.visit({ pane: "wiki", selection: "c" }); + expect(shell.canGoForward).toBe(false); + // `b` is gone: `c` was chosen from `a`, so `a` is what `c` came from. + expect(shell.back()).toEqual({ pane: "wiki", selection: "a" }); + }); + + it("does not record an overlay as a place you went — R2.2, R2.3", () => { + // The failure this exists for: with the settings sheet in the history, + // Back after closing it lands on the pane *before* the one you opened it + // from, the sheet having eaten the press that should have taken you there. + const shell = new Shell(); + shell.visit({ pane: "wiki", selection: "fenix" }); + shell.goTo("sources"); + shell.show({ kind: "settings" }); + shell.dismiss(); + expect(shell.location).toEqual({ pane: "sources" }); + expect(shell.back()).toEqual({ pane: "wiki", selection: "fenix" }); + }); + + it("shows one overlay at a time — R2.5", () => { + const shell = new Shell(); + shell.show({ kind: "settings" }); + shell.show({ kind: "history" }); + expect(shell.overlay).toEqual({ kind: "history" }); + }); + + it("stays where it is while an overlay is open — R2.4", () => { + // The modal makes the pane behind it unclickable, but the keyboard + // shortcuts of 8.1 are not behind that inert layer. + const shell = new Shell(); + shell.visit({ pane: "wiki", selection: "fenix" }); + shell.show({ kind: "provenance", source: "weekly", fragment: "14:32" }); + shell.goTo("sources"); + shell.back(); + expect(shell.location).toEqual({ pane: "wiki", selection: "fenix" }); + }); + + it("moves again once the overlay is dismissed", () => { + const shell = new Shell(); + shell.show({ kind: "history" }); + shell.dismiss(); + expect(shell.goTo("checks")).toEqual({ pane: "checks" }); }); }); diff --git a/plans/desktop-ui.md b/plans/desktop-ui.md index 7e34a3f..1e1840f 100644 --- a/plans/desktop-ui.md +++ b/plans/desktop-ui.md @@ -192,8 +192,8 @@ they are the difference between a port and an application. - [ ] 8.1 (Unit) Keyboard: pane switching, focus the search, Escape closes an overlay, and a visible focus path through the tree. A dense window read beside a harness is a window somebody keeps their hands off the mouse for - [ ] 8.2 (Unit) The page type is chosen when a page is created, instead of `template()` hardcoding `type: topic` for every page the UI makes - [ ] 8.3 (Unit) Loading and empty states per pane, distinguishable from failure. Every pane today renders nothing while it waits, which reads as "there is nothing here" -- [ ] 8.4 (Unit) The recording indicator earns the draft's persistence: elapsed time, and the pause/stop controls in the titlebar where 8.2 argued they belong — visible from every pane, which is the reason the plan gave for putting them there -- [ ] 8.5 (Unit) The status bar: the project path, the findings count as a way into Checks, and Undo last write +- 8.4 → **`specs/desktop-shell/`** (R3.2, R3.3). The recording indicator earning the draft's persistence — elapsed time, and the pause/stop controls in the titlebar — is the titlebar's own requirement, and a titlebar built without them would have had to be built twice. Moved rather than duplicated: two records of one fact disagree +- 8.5 → **`specs/desktop-shell/`** (R5). The status bar is the shell's third row, and the same argument applies: it cannot be assembled empty and filled in later --- diff --git a/specs/desktop-shell/design.md b/specs/desktop-shell/design.md index 5e5d9c6..05bd6cb 100644 --- a/specs/desktop-shell/design.md +++ b/specs/desktop-shell/design.md @@ -1,40 +1,87 @@ # Desktop shell — design - +`navigation.ts` keeps its `History` — the stack with a cursor, and the rule that +going somewhere new after going back discards the future. What changes is what a +`Location` is, and what is deliberately not one. -## What changes +```ts +export type Pane = "wiki" | "sources" | "checks"; + +export interface Location { + pane: Pane; + /** What is selected inside it: a page slug, a source id, a finding's code. */ + selection?: string; +} + +export type Overlay = + | { kind: "settings" } + | { kind: "history" } + | { kind: "provenance"; source: string; fragment: string }; +``` + +Two fields where there was a view and a slug, and one type that is not a +location at all. + +**An overlay is state beside the history, never inside it** (R2.2). The reason +is R2.3 read backwards: an overlay has exactly one thing to return to — the +location it was opened over — and that is already the current location, so +recording it would put a second entry on the stack whose only purpose is to be +skipped. It also makes the failure mode concrete: with the settings sheet in the +history, Back after closing it lands on the pane _before_ the one you opened it +from, because the sheet consumed the press that should have taken you there. -Serves R1.1. +The overlay is a single value rather than a set (R2.5). Two at once is not a +layout question but a state nothing can arrive at, and a set would make it +representable. - +That is why R2.4 is **asserted in `Shell` rather than inherited from the +modal**. For two of the three it would indeed fall out for free; for the third +it would not, and a rule that holds for two cases out of three by accident is +the kind that is discovered to be false by whatever is built on it next. The +keyboard shortcuts of 8.1 are not behind the inert layer either. -## Boundaries and contracts +### Selection is remembered per pane, not per visit - +R1.7 needs somewhere to put "the page I was reading" while the user is in +Sources. Keeping it in the history would mean reconstructing it by walking +backwards for the most recent entry with that pane, which is the same fact +stored twice and read the long way round. Instead the shell holds one +`Record` beside the history, written whenever a +location is visited and read when a pane is entered without a selection. -## Data +## Boundaries and contracts - +Serves R5.2, R5.3. -## Alternatives considered +The status bar's findings count is the number the checks pane already computes. +The shell does not run the checks a second time to fill in a number — `ow.check` +walks the whole project, and running it on every render of the frame is how a +status bar becomes the slowest thing in the window. The count is handed up from +the checks pane's own load, and is absent until something has loaded it once, +which the bar says rather than showing a confident `0`. - +## Alternatives -## Risks +**Keeping one flat `view` and adding `settings` and `history` to it**, as today. +Rejected: that is precisely the model that makes an overlay a location, and it +is why the settings screen currently replaces the pane you were reading instead +of sitting over it. The draft draws both as overlays, and the reason it draws +them that way is that neither is a place you go — one is a thing you adjust and +one is a thing you consult. - +**A router keyed on a URL string.** Rejected as ceremony: there is no address +bar, no deep link and no reload to survive, so a string would exist only to be +parsed back into the two fields above. The record that has to survive is the +project on disk, not the window's position in it. diff --git a/specs/desktop-shell/requirements.md b/specs/desktop-shell/requirements.md index b4b3daa..a77adce 100644 --- a/specs/desktop-shell/requirements.md +++ b/specs/desktop-shell/requirements.md @@ -5,31 +5,86 @@ ci: wait # Desktop shell — requirements - +Its real subject is **where the window is**. Today that is one `Location` with a +view and an optional slug, and the draft needs three things it cannot express: a +pane, a selection within that pane, and overlays that are not panes at all. -## Purpose +Getting that wrong is not cosmetic. A wiki is read by following links and coming +back, so Back is half of how the content is used — and if opening the settings +sheet enters the back history, Back stops meaning "where I came from" and starts +meaning "undo the last thing I clicked". 8.5 paid attention to this once and it +has to survive the repaint. + +## R1 · Where the window is + +- **R1.1** The shell shall hold the window's location as a pane and, where that + pane has one, a selection within it. +- **R1.2** When the user chooses a pane, the shell shall show that pane and + record the change as a location. +- **R1.3** When the user chooses an item within a pane, the shell shall record + the change as a location. +- **R1.4** When the user goes back, the shell shall show the location visited + before the current one. +- **R1.5** When the user goes somewhere new after going back, the shell shall discard + the locations that were ahead of the cursor. +- **R1.6** If the user chooses the location they are already at, then the shell shall not record it a second time. +- **R1.7** When a pane is shown again after being left, the shell shall restore + the selection it had. + +## R2 · Overlays are not locations + +- **R2.1** The shell shall open the settings sheet, the history drawer and the + provenance viewer as overlays over the current location. +- **R2.6** While the provenance viewer is open, the shell shall leave the page + behind it readable and its links followable. +- **R2.2** The shell shall not record an overlay as a location. +- **R2.3** When an overlay is dismissed, the shell shall show the location it + was opened over. +- **R2.4** While an overlay is open, the shell shall not act on a request to + change the location. +- **R2.5** The shell shall show at most one overlay at a time. + +## R3 · The titlebar + +- **R3.1** The titlebar shall show the open project's name. +- **R3.2** While a recording is running or paused, the titlebar shall show that + it is, and how long it has been running. +- **R3.3** While a recording is running or paused, the titlebar shall offer + pausing or resuming it, and stopping it. +- **R3.4** While no recording is running, the titlebar shall offer starting one. +- **R3.5** The titlebar shall offer opening the settings. +- **R3.6** The titlebar shall offer going back and going forward, and shall show when there is nowhere to go. + +## R4 · The rail - +- **R4.1** The rail shall offer every pane the window has, as an icon with the + pane's name. +- **R4.2** The rail shall mark which pane is open. +- **R4.3** The rail shall show the project's content language. -## R1 · +## R5 · The status bar -- **R1.1** The shall -- **R1.2** When , the shall -- **R1.3** If , then the shall +- **R5.1** The status bar shall show the open project's directory. +- **R5.2** The status bar shall show how many findings the checks last reported. +- **R5.3** When the user chooses the findings count, the shell shall show the + checks pane. +- **R5.4** The status bar shall offer undoing the most recent recorded write. +- **R5.5** If there is no recorded write to undo, then the status bar shall say + so rather than offer it. ## Out of scope - +- **The MCP pane.** The rail offers the panes that exist. MCP arrives with its + own pane in `specs/mcp-pane/`, which is waiting on a server nobody has built. +- **What is inside a pane.** The wiki pane is `specs/wiki-pane/`; sources, + checks and provenance are groups 5 and 6 of `plans/desktop-ui.md`. This spec + decides the frame and the routing and nothing that happens within a pane. +- **Switching projects.** The titlebar names the open project; opening a + different one is `ow` in that directory, and the launcher's job (8.4). + `adr:0013` makes a project the directory this window was opened on, so there + is nothing here to rebind. diff --git a/specs/desktop-shell/tasks.md b/specs/desktop-shell/tasks.md index 9b16b38..f11ab1c 100644 --- a/specs/desktop-shell/tasks.md +++ b/specs/desktop-shell/tasks.md @@ -1,25 +1,15 @@ # Desktop shell — tasks - - -## 1 · - -- [ ] 1.1 (Unit) — R1.1 -- [ ] 1.2 (TDD) — R1.2, R1.3 - -## 2 · - -- [ ] 2.1 (Unit) — R1.1 +- [x] 2.1 (Unit) The titlebar: the project's name, the recording indicator with its elapsed time and its pause, resume and stop controls, the record button, the way into settings, and Back and Forward — R3.1, R3.2, R3.3, R3.4, R3.5, R3.6 +- [x] 2.2 (Unit) The icon rail: one entry per pane with its Lucide icon and name, the open one marked, and the project's content language at the foot — R4.1, R4.2, R4.3 +- [x] 2.3 (Unit) The status bar: the project's directory, the findings count as the way into the checks pane, and Undo last write — including what it says when there is nothing to undo — R5.1, R5.2, R5.3, R5.4, R5.5 +- [x] 2.4 (Unit) Assemble the three into the shell `App` renders, replacing the flat row of text buttons over a single column — R1.2, R4.2