pi-weave 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +114 -34
  2. package/package.json +16 -4
  3. package/src/core/cache/workspace.ts +466 -0
  4. package/src/core/frontmatter.ts +217 -23
  5. package/src/core/git.ts +19 -0
  6. package/src/core/graph/build.ts +37 -4
  7. package/src/core/graph/current.ts +41 -28
  8. package/src/core/graph/mentions.ts +170 -0
  9. package/src/core/graph/model.ts +24 -0
  10. package/src/core/index.ts +12 -0
  11. package/src/core/openInEditor.ts +69 -0
  12. package/src/core/types.ts +40 -0
  13. package/src/core/vault.ts +477 -43
  14. package/src/core/view/cluster.ts +262 -0
  15. package/src/core/view/detail.ts +118 -0
  16. package/src/core/view/focus.ts +109 -0
  17. package/src/core/view/health.ts +156 -0
  18. package/src/core/view/index.ts +15 -0
  19. package/src/core/view/links.ts +105 -0
  20. package/src/core/view/time.ts +47 -0
  21. package/src/core/view/tree.ts +269 -0
  22. package/src/core/view/types.ts +39 -0
  23. package/src/pi/index.ts +104 -11
  24. package/src/pi/viewer/tui/explorer.ts +4 -2
  25. package/src/pi/viewer/tui/model.ts +46 -667
  26. package/src/pi/viewer/tui/openNote.ts +7 -56
  27. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  28. package/src/pi/viewer/web/run.ts +331 -0
  29. package/src/web/client/api.dom.ts +40 -0
  30. package/src/web/client/api.ts +472 -0
  31. package/src/web/client/bootstrap.ts +58 -0
  32. package/src/web/client/context/context.model.ts +313 -0
  33. package/src/web/client/dist/app.js +751 -0
  34. package/src/web/client/graph/Graph.tsx +158 -0
  35. package/src/web/client/graph/column.model.ts +431 -0
  36. package/src/web/client/graph/graph.model.ts +538 -0
  37. package/src/web/client/graph/positions.ts +339 -0
  38. package/src/web/client/graph/project.ts +153 -0
  39. package/src/web/client/graph/renderer.dom.ts +52 -0
  40. package/src/web/client/graph/renderer.ts +279 -0
  41. package/src/web/client/graph/scheme.ts +44 -0
  42. package/src/web/client/live.model.ts +275 -0
  43. package/src/web/client/live.ts +151 -0
  44. package/src/web/client/main.tsx +27 -0
  45. package/src/web/client/note/Editor.tsx +102 -0
  46. package/src/web/client/note/Note.tsx +113 -0
  47. package/src/web/client/note/editor.controller.ts +151 -0
  48. package/src/web/client/note/editor.model.ts +636 -0
  49. package/src/web/client/note/note.model.ts +738 -0
  50. package/src/web/client/search/SearchPalette.tsx +105 -0
  51. package/src/web/client/search/search.model.ts +588 -0
  52. package/src/web/client/search/search.ts +107 -0
  53. package/src/web/client/shell/Columns.tsx +161 -0
  54. package/src/web/client/shell/ContextRail.tsx +87 -0
  55. package/src/web/client/shell/Divider.tsx +44 -0
  56. package/src/web/client/shell/FocusTrap.tsx +56 -0
  57. package/src/web/client/shell/Header.tsx +54 -0
  58. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  59. package/src/web/client/shell/Shell.tsx +193 -0
  60. package/src/web/client/shell/StatusBar.tsx +28 -0
  61. package/src/web/client/shell/cssvars.ts +70 -0
  62. package/src/web/client/shell/drag.model.ts +170 -0
  63. package/src/web/client/shell/focus.model.ts +100 -0
  64. package/src/web/client/shell/keys.model.ts +453 -0
  65. package/src/web/client/shell/keys.ts +59 -0
  66. package/src/web/client/shell/layout.model.ts +526 -0
  67. package/src/web/client/shell/shell.model.ts +333 -0
  68. package/src/web/client/shell/theme.ts +477 -0
  69. package/src/web/client/shell/viewport.ts +29 -0
  70. package/src/web/client/state.ts +78 -0
  71. package/src/web/client/tree/Tree.tsx +138 -0
  72. package/src/web/client/tree/tree.model.ts +674 -0
  73. package/src/web/client/workspace.ts +214 -0
  74. package/src/web/server/page.ts +256 -0
  75. package/src/web/server/routes.ts +975 -0
  76. package/src/web/server/security.ts +361 -0
  77. package/src/web/server/server.ts +275 -0
  78. package/src/web/server/sse.ts +321 -0
  79. package/src/web/server/watcher.ts +507 -0
  80. package/src/web/shared/graph.ts +206 -0
  81. package/src/web/shared/layout.ts +497 -0
  82. package/src/web/shared/metrics.ts +136 -0
  83. package/src/web/shared/view.ts +200 -0
  84. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The workspace shell (weave-workspace §1.2).
3
+ *
4
+ * Header, three resizable columns, context rail, status bar. This component
5
+ * holds the wiring and nothing else — every value it renders comes from a
6
+ * pure function in `shell.model.ts`, `layout.model.ts` or `drag.model.ts`,
7
+ * and the fetch/SSE loop is `workspace.ts`. What is left here is hooks:
8
+ * signals in, callbacks out.
9
+ *
10
+ * The three effects, all one-liners over injected units:
11
+ *
12
+ * 1. **mount** — `startWorkspace` fetches the graph and opens the stream;
13
+ * the returned `stop` is the cleanup, so a hot reload cannot leak a
14
+ * socket.
15
+ * 2. **resize** — `watchViewport` keeps `width` current, because the width
16
+ * picks the breakpoint, which decides how many columns exist.
17
+ * 3. **keys** — `watchKeys` attaches the one global `keydown` listener
18
+ * (§11 P4). Its context is read through `live`, because a listener
19
+ * registered at mount outlives every render and a captured overlay flag
20
+ * would let `⌘K` stack a palette on top of itself.
21
+ *
22
+ * Layout persistence is not an effect: `dividerHandlers` writes to
23
+ * `localStorage` on release and on a keyboard nudge, never per frame.
24
+ */
25
+
26
+ import { useEffect, useMemo, useRef, useState } from "preact/hooks";
27
+ import { fetchJson } from "../api.dom";
28
+ import { createSigmaRenderer } from "../graph/renderer.dom";
29
+ import { domEventSource } from "../live";
30
+ import { createEditor, watchUnload, type EditorHandle } from "../note/editor.controller";
31
+ import { editorPrompt, editorToolbar, initialEditorState, shouldBlockUnload } from "../note/editor.model";
32
+ import { SearchPalette } from "../search/SearchPalette";
33
+ import { connection, graph, noteBody, selectedId } from "../state";
34
+ import type { WorkspaceHandle } from "../workspace";
35
+ import { observeNotes, select, startWorkspace } from "../workspace";
36
+ import { Columns } from "./Columns";
37
+ import { dividerHandlers } from "./drag.model";
38
+ import { Header } from "./Header";
39
+ import { HelpOverlay } from "./HelpOverlay";
40
+ import { watchKeys } from "./keys";
41
+ import { focusSelector, runShellAction } from "./keys.model";
42
+ import type { LayoutState } from "./layout.model";
43
+ import { breakpointFor, loadLayout, resolveColumns, saveLayout } from "./layout.model";
44
+ import { StatusBar } from "./StatusBar";
45
+ import type { OverlayId } from "./shell.model";
46
+ import { connectionView, looksApple, searchShortcut, statusBarModel, summarize } from "./shell.model";
47
+ import { watchViewport } from "./viewport";
48
+
49
+ export interface ShellProps {
50
+ /** From the page bootstrap. Shown in the status bar. */
51
+ cwd: string;
52
+ /** `window.innerWidth` at mount. Injected so the first render is testable. */
53
+ initialWidth: number;
54
+ /** `navigator.platform`, for the `⌘K` vs `Ctrl K` hint. */
55
+ platform: string;
56
+ }
57
+
58
+ export function Shell(props: ShellProps) {
59
+ const [width, setWidth] = useState(props.initialWidth);
60
+ const [overlay, setOverlay] = useState<OverlayId>(null);
61
+ const [layout, setLayout] = useState<LayoutState>(() => loadLayout(localStorage, props.initialWidth));
62
+ const [editorState, setEditorState] = useState(initialEditorState);
63
+ const workspace = useRef<WorkspaceHandle | null>(null);
64
+ // Filled by the graph column at mount, cleared on unmount. The global `g`
65
+ // key's only route to the renderer — see `Graph.tsx`'s `fit` prop.
66
+ const fit = useRef<(() => void) | null>(null);
67
+ // The gesture and the global key listener read through these so a handler
68
+ // built on an early render still sees current state — see `DragHost`.
69
+ const live = useRef({ layout, width, overlay });
70
+ live.current = { layout, width, overlay };
71
+
72
+ // Built once and owned by the shell, not by the note column. The column is
73
+ // unmounted by a resize below 800 px, and an editor whose lifetime was the
74
+ // column's would lose an unsaved draft to a window drag.
75
+ const editor: EditorHandle = useMemo(
76
+ () => createEditor({ fetch: fetchJson, select: (id) => void select(fetchJson, id), onChange: setEditorState }),
77
+ [],
78
+ );
79
+
80
+ useEffect(() => {
81
+ const handle = startWorkspace({ fetch: fetchJson, open: domEventSource });
82
+ workspace.current = handle;
83
+ return () => handle.stop();
84
+ }, []);
85
+
86
+ // Every note that arrives, from any of the three directions it can arrive
87
+ // from (mount, selection, SSE refetch), so the editor can decide whether it
88
+ // is news or an interruption — see `editor.model.ts`'s header.
89
+ useEffect(() => observeNotes((payload) => editor.send({ type: "loaded", payload })), []);
90
+
91
+ // Read through a thunk, not captured: a listener registered at mount
92
+ // outlives every render, so a captured state would always look clean and
93
+ // the guard would be installed but inert.
94
+ useEffect(() => watchUnload(window, () => shouldBlockUnload(editor.state())), []);
95
+
96
+ useEffect(() => watchViewport(window, setWidth), []);
97
+
98
+ useEffect(
99
+ () =>
100
+ watchKeys(document, {
101
+ context: () => ({ overlay: live.current.overlay, hasSelection: selectedId.value !== null }),
102
+ run: (action) =>
103
+ runShellAction(action, {
104
+ setOverlay,
105
+ focusSelector: (selector) => focusSelector(document, selector),
106
+ fitGraph: () => fit.current?.(),
107
+ // Through the editor, not straight to `select`: clearing the
108
+ // selection while a draft is dirty must be refused like any
109
+ // other navigation, and `Esc` is the easiest way to do it by
110
+ // accident.
111
+ clearSelection: () => editor.send({ type: "navigate", id: null }),
112
+ toggleEdit: () => editor.send({ type: "toggle" }),
113
+ saveNote: () => editor.send({ type: "save" }),
114
+ }),
115
+ }),
116
+ [],
117
+ );
118
+
119
+ const resolved = useMemo(() => resolveColumns(layout, width, breakpointFor(width)), [layout, width]);
120
+
121
+ // Built once: the handlers read state through `live`, so they never go
122
+ // stale and never need to be rebuilt.
123
+ const drag = useMemo(
124
+ () =>
125
+ dividerHandlers({
126
+ layout: () => live.current.layout,
127
+ width: () => live.current.width,
128
+ setLayout,
129
+ persist: (next) => void saveLayout(localStorage, next),
130
+ }),
131
+ [],
132
+ );
133
+
134
+ return (
135
+ <>
136
+ <Header
137
+ summary={summarize(graph.value)}
138
+ connection={connectionView(connection.value)}
139
+ shortcut={searchShortcut(looksApple(props.platform))}
140
+ onRefresh={() => workspace.current?.refresh()}
141
+ onSearch={() => setOverlay("search")}
142
+ />
143
+ <Columns
144
+ resolved={resolved}
145
+ onDown={drag.onDown}
146
+ onMove={drag.onMove}
147
+ onUp={drag.onUp}
148
+ onKey={drag.onKey}
149
+ graph={graph.value}
150
+ note={noteBody.value}
151
+ selectedId={selectedId.value}
152
+ // Through the editor: a selection made while a draft is dirty is
153
+ // parked rather than performed, and the column then asks. Every
154
+ // column's `onSelect` routes here, so there is one guarded door
155
+ // rather than three that each had to remember.
156
+ onSelect={(id) => editor.send({ type: "navigate", id })}
157
+ now={Date.now()}
158
+ toolbar={editorToolbar(editorState)}
159
+ prompt={editorPrompt(editorState)}
160
+ draft={editorState.draft}
161
+ send={editor.send}
162
+ // The graph column's three ports (§7.5, §10). Supplied here, at the
163
+ // one place that is already allowed to name browser globals, so
164
+ // `Graph.tsx` and everything under it takes its world as parameters.
165
+ renderer={createSigmaRenderer}
166
+ storage={localStorage}
167
+ host={window}
168
+ fit={fit}
169
+ />
170
+ {/*
171
+ `model.generatedAt`, not `stamp`. The two used to be the same string;
172
+ since §15.6 `stamp` is a content digest (a hex validator for the ETag
173
+ and the SSE dedupe) and would render as `a3f9c2…` under a label that
174
+ says "data as of". `generatedAt` kept the data-as-of job, which is
175
+ exactly what this bar wants.
176
+ */}
177
+ <StatusBar
178
+ model={statusBarModel(props.cwd, selectedId.value, connection.value, graph.value?.model.generatedAt ?? null)}
179
+ />
180
+ {overlay === "search" ? (
181
+ <SearchPalette
182
+ graph={graph.value}
183
+ onSelect={(id) => void select(fetchJson, id)}
184
+ onClose={() => setOverlay(null)}
185
+ ports={{ fetch: fetchJson, now: Date.now, delay: (run, ms) => void setTimeout(run, ms) }}
186
+ />
187
+ ) : null}
188
+ {overlay === "help" ? (
189
+ <HelpOverlay shortcut={searchShortcut(looksApple(props.platform))} onClose={() => setOverlay(null)} />
190
+ ) : null}
191
+ </>
192
+ );
193
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The status bar (weave-workspace §1.2).
3
+ *
4
+ * Working directory, current selection, data-as-of stamp, connection. The
5
+ * model is built by `statusBarModel`; this renders it.
6
+ */
7
+
8
+ import type { StatusBarModel } from "./shell.model";
9
+ import { shortStamp } from "./shell.model";
10
+
11
+ export function StatusBar({ model }: { model: StatusBarModel }) {
12
+ return (
13
+ <footer class="weave-status">
14
+ <span class="weave-status-cwd" title={model.cwd}>
15
+ {model.cwd}
16
+ </span>
17
+ <span class="weave-status-sel" title="the §1.3 context bus — one signal, every column">
18
+ {model.selection}
19
+ </span>
20
+ <span class="weave-status-stamp" title="data as of">
21
+ {shortStamp(model.stamp)}
22
+ </span>
23
+ <span class={`weave-conn weave-conn-${model.connection.tone}`} title={model.connection.hint}>
24
+ {model.connection.label}
25
+ </span>
26
+ </footer>
27
+ );
28
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Applying computed layout widths to an element, under a strict CSP
3
+ * (weave-workspace §5.2).
4
+ *
5
+ * ## The constraint, stated precisely
6
+ *
7
+ * `page.ts` serves `style-src 'nonce-{N}'` with **no `'unsafe-inline'`**. In
8
+ * CSP terms that blocks a literal `style="…"` *attribute in the markup the
9
+ * parser sees*. It does **not** block mutation of an element's
10
+ * `CSSStyleDeclaration` from script: `el.style.setProperty(...)`,
11
+ * `el.style.width = …` and `el.style.cssText = …` are CSSOM writes, and CSP
12
+ * has no hook on them. This is why a strict-CSP app can still have dynamic
13
+ * layout at all.
14
+ *
15
+ * Preact reaches the same place: `setProperty` in `preact/src/diff/props.js`
16
+ * handles a `style` prop by assigning `dom.style.cssText` for a string, or by
17
+ * `style.setProperty` / `style[key] = …` for an object. It never calls
18
+ * `setAttribute("style", …)`. So a `style={{…}}` prop in a component would in
19
+ * fact survive the CSP — verified in the installed source, not assumed.
20
+ *
21
+ * We still do not use one. The widths are written as **custom properties** by
22
+ * this module, and the nonce'd stylesheet owns the `grid-template-columns`
23
+ * rule that consumes them. The reason is not CSP but ownership: a `style`
24
+ * prop would put a second layout implementation in the bundle, competing with
25
+ * the one in CSS, and the browser's devtools would show a computed width with
26
+ * no rule to trace it back to. One number per column in, one rule in CSS —
27
+ * that is the whole contribution.
28
+ *
29
+ * ## Why the element is a parameter and the API is one function
30
+ *
31
+ * There is no DOM test environment (§10). Everything *decidable* about the
32
+ * widths already lives in `layout.model.ts` as pure functions returning
33
+ * `[name, value]` pairs; what remains here is a loop that hands those pairs
34
+ * to a `setProperty` this module does not own. The port below is that method
35
+ * and nothing else, so the untestable surface is one line long and a fake is
36
+ * an object literal.
37
+ */
38
+
39
+ /**
40
+ * The slice of `CSSStyleDeclaration` used to apply a layout.
41
+ *
42
+ * A real `HTMLElement.style` satisfies it structurally. Declared here rather
43
+ * than imported from the DOM lib because this module is compiled by the root
44
+ * `tsconfig.json` when a test imports it, and that project has no `DOM` lib.
45
+ */
46
+ export interface StyleTarget {
47
+ setProperty(property: string, value: string): void;
48
+ }
49
+
50
+ /** An element with a `style`. `HTMLElement` satisfies it. */
51
+ export interface StyledElement {
52
+ readonly style: StyleTarget;
53
+ }
54
+
55
+ /**
56
+ * Write custom properties onto an element.
57
+ *
58
+ * Tolerates a `null` element so the caller — a `useLayoutEffect` holding a
59
+ * ref — does not need a guard of its own. A ref is `null` on the render
60
+ * before the element exists and on the one after it is removed, and both are
61
+ * ordinary rather than exceptional.
62
+ *
63
+ * Returns the number of properties written, which is what makes the function
64
+ * observable in a test without a DOM: `0` for a null element, `n` otherwise.
65
+ */
66
+ export function applyVars(element: StyledElement | null, vars: readonly (readonly [string, string])[]): number {
67
+ if (element === null) return 0;
68
+ for (const [name, value] of vars) element.style.setProperty(name, value);
69
+ return vars.length;
70
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Divider dragging, as pure state (weave-workspace §1.2).
3
+ *
4
+ * `layout.model.ts` already owns the arithmetic — {@link resizeAt} converts a
5
+ * pixel delta into clamped, normalised fractions. What it does not own is the
6
+ * *gesture*: where the pointer went down, how far it has travelled since, and
7
+ * when the result should be persisted. That is this file, and it is separate
8
+ * for the usual reason — a gesture living inside `Divider.tsx` would be
9
+ * untestable without a DOM (§10), and a drag that mis-clamps is exactly the
10
+ * bug a unit test catches and a glance at the screen does not.
11
+ *
12
+ * ## Deltas are measured from the gesture's origin, never frame to frame
13
+ *
14
+ * A `pointermove` handler that applied `event.movementX` to the current
15
+ * layout would accumulate error: `resizeAt` clamps, so any movement the clamp
16
+ * swallowed is lost, and the divider then trails the pointer by however much
17
+ * was discarded. Storing the layout as it was at `pointerdown` and applying
18
+ * the *total* offset each time makes the divider track the pointer exactly,
19
+ * and makes dragging into a wall and back out again return to where it
20
+ * started rather than to wherever the drift left it.
21
+ */
22
+
23
+ import type { DividerId, LayoutState } from "./layout.model";
24
+ import { resizeAt } from "./layout.model";
25
+
26
+ /**
27
+ * A gesture in progress.
28
+ *
29
+ * `origin` is the pointer x at `pointerdown`; `base` is the layout as it was
30
+ * at that instant. Both are needed to satisfy the total-offset rule above.
31
+ */
32
+ export interface DragState {
33
+ readonly divider: DividerId;
34
+ readonly origin: number;
35
+ readonly base: LayoutState;
36
+ /** `setPointerCapture` id, so the shell can release exactly this pointer. */
37
+ readonly pointerId: number;
38
+ }
39
+
40
+ /** Begin a drag. */
41
+ export function beginDrag(divider: DividerId, clientX: number, layout: LayoutState, pointerId: number): DragState {
42
+ return { divider, origin: clientX, base: layout, pointerId };
43
+ }
44
+
45
+ /**
46
+ * The layout for a pointer position during a drag.
47
+ *
48
+ * Always derived from `base`, never from the previous frame — see the module
49
+ * header. `resizeAt` returns its input identically when nothing moved, so a
50
+ * pointer jittering by a subpixel at a clamped edge produces the same object
51
+ * and wakes no signal subscribers.
52
+ */
53
+ export function dragTo(drag: DragState, clientX: number, available: number): LayoutState {
54
+ return resizeAt(drag.base, drag.divider, clientX - drag.origin, available);
55
+ }
56
+
57
+ /**
58
+ * Whether a completed drag actually changed anything.
59
+ *
60
+ * The persistence trigger. A click on a divider with no movement is a
61
+ * `pointerdown`/`pointerup` pair that should not write to `localStorage`, and
62
+ * a drag that was entirely absorbed by the clamp should not either. Compares
63
+ * the three fractions rather than object identity, because `resizeAt`
64
+ * normalises and can return an equal-but-distinct object.
65
+ */
66
+ export function dragChanged(drag: DragState, final: LayoutState): boolean {
67
+ const a = drag.base.fractions;
68
+ const b = final.fractions;
69
+ return a.tree !== b.tree || a.note !== b.note || a.graph !== b.graph;
70
+ }
71
+
72
+ /**
73
+ * The keyboard nudge, in pixels.
74
+ *
75
+ * A divider is a `separator` with `tabindex`, so it must be operable from the
76
+ * keyboard — §11's P4 makes the whole workspace keyboard-drivable, and a
77
+ * control that can only be dragged is one that has to be retrofitted then. 24
78
+ * px is a visible step without being a jump.
79
+ */
80
+ export const NUDGE_PX = 24;
81
+
82
+ /**
83
+ * Map an arrow key to a signed nudge, or `0` for any other key.
84
+ *
85
+ * Returning `0` rather than `null` lets the caller feed the result straight
86
+ * into `resizeAt`, which already treats a zero delta as "return the state
87
+ * unchanged" — one branch instead of two, and the one that exists is already
88
+ * covered.
89
+ */
90
+ export function nudgeFor(key: string): number {
91
+ if (key === "ArrowLeft") return -NUDGE_PX;
92
+ if (key === "ArrowRight") return NUDGE_PX;
93
+ return 0;
94
+ }
95
+
96
+ // --- the gesture, as a unit -----------------------------------------------------
97
+
98
+ /**
99
+ * What {@link dividerHandlers} needs from the component around it.
100
+ *
101
+ * Accessors rather than values, because a handler installed on one render
102
+ * must see the layout as it is when the pointer moves, not as it was when the
103
+ * closure was built. A stale `layout` here is the classic React/Preact
104
+ * gesture bug: the drag applies to a snapshot and the divider jumps back on
105
+ * the next render.
106
+ */
107
+ export interface DragHost {
108
+ layout(): LayoutState;
109
+ /** Container width in CSS pixels. */
110
+ width(): number;
111
+ /** Publish a new layout (a `setState`). */
112
+ setLayout(next: LayoutState): void;
113
+ /** Persist a layout. Called on release and on a keyboard nudge, never per frame. */
114
+ persist(layout: LayoutState): void;
115
+ }
116
+
117
+ /** The four callbacks a {@link Divider} needs. */
118
+ export interface DividerHandlers {
119
+ onDown(divider: DividerId, clientX: number, pointerId: number): void;
120
+ onMove(clientX: number): void;
121
+ onUp(): void;
122
+ onKey(divider: DividerId, key: string): void;
123
+ }
124
+
125
+ /**
126
+ * Build the divider gesture handlers over a host.
127
+ *
128
+ * This lives here rather than as four `useCallback`s in `Shell.tsx` for the
129
+ * reason §10 gives: the ordering rules they encode are real logic — persist
130
+ * on release but not per frame, ignore a move with no gesture in progress,
131
+ * persist a keyboard nudge immediately because there is no release to wait
132
+ * for — and logic inside a `.tsx` is logic no test can reach. The component
133
+ * is left holding a ref and a `setState`.
134
+ *
135
+ * The mutable gesture is kept in the closure rather than in component state
136
+ * on purpose: a `pointermove` at 120 Hz writing to `useState` would rerender
137
+ * the whole shell on every frame to store a value nothing renders.
138
+ */
139
+ export function dividerHandlers(host: DragHost): DividerHandlers {
140
+ let active: DragState | null = null;
141
+
142
+ return {
143
+ onDown(divider, clientX, pointerId) {
144
+ active = beginDrag(divider, clientX, host.layout(), pointerId);
145
+ },
146
+
147
+ onMove(clientX) {
148
+ // No gesture in progress: a plain hover over the divider, which fires
149
+ // `pointermove` just as a drag does.
150
+ if (active !== null) host.setLayout(dragTo(active, clientX, host.width()));
151
+ },
152
+
153
+ onUp() {
154
+ const finished = active;
155
+ active = null;
156
+ // Persisted only if something actually moved — a click on a divider,
157
+ // and a drag entirely absorbed by the clamp, both write nothing.
158
+ if (finished !== null && dragChanged(finished, host.layout())) host.persist(host.layout());
159
+ },
160
+
161
+ onKey(divider, key) {
162
+ const next = resizeAt(host.layout(), divider, nudgeFor(key), host.width());
163
+ // `resizeAt` returns its input identically for an unhandled key, so a
164
+ // `Tab` or an `Enter` on a focused divider costs nothing.
165
+ if (next === host.layout()) return;
166
+ host.setLayout(next);
167
+ host.persist(next);
168
+ },
169
+ };
170
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Focus management for the modal surfaces (weave-workspace P4).
3
+ *
4
+ * P4's exit criterion is "the whole workspace is drivable without a mouse",
5
+ * and a dialog that does not trap focus fails it in the most literal way
6
+ * available: Tab from the last control lands on the header behind the
7
+ * overlay, the user is now typing into a search box they cannot see, and
8
+ * `Esc` goes to whatever they landed on. So the palette and the help overlay
9
+ * both trap, and both restore.
10
+ *
11
+ * ## Why this is a model and not three lines in a `useEffect`
12
+ *
13
+ * Trapping is arithmetic — "which element is next, given the current one, a
14
+ * direction, and a wrap" — and arithmetic in a `.tsx` is arithmetic no test
15
+ * can reach (§10). {@link trapTarget} takes an array of focusables and an
16
+ * index and returns an index; the component supplies the array from a
17
+ * `querySelectorAll` and calls `.focus()` on the answer. Everything that can
18
+ * be wrong is on this side of that line.
19
+ *
20
+ * The DOM types are deliberately absent: {@link Focusable} is a one-method
21
+ * structural port that a real `HTMLElement` satisfies, so this file compiles
22
+ * under the root `tsconfig.json` (no `DOM` lib) and the tests that import it
23
+ * need no browser.
24
+ */
25
+
26
+ /** The slice of an element this module uses. `HTMLElement` satisfies it. */
27
+ export interface Focusable {
28
+ focus(): void;
29
+ }
30
+
31
+ /**
32
+ * The selector for "things a user can Tab to", inside a dialog.
33
+ *
34
+ * `:not([disabled])` matters — a disabled control is not in the tab order,
35
+ * and including one produces a trap with a dead stop in it. `tabindex="-1"`
36
+ * is excluded for the same reason: it means *programmatically* focusable,
37
+ * which is the opposite of what a Tab cycle wants.
38
+ *
39
+ * Kept here rather than in the component so the two overlays cannot drift
40
+ * into two different definitions of what is focusable.
41
+ */
42
+ export const FOCUSABLE_SELECTOR = [
43
+ "a[href]",
44
+ "button:not([disabled])",
45
+ "input:not([disabled])",
46
+ "select:not([disabled])",
47
+ "textarea:not([disabled])",
48
+ '[tabindex]:not([tabindex="-1"])',
49
+ ].join(",");
50
+
51
+ /**
52
+ * Where Tab should land next, or `null` to leave the event alone.
53
+ *
54
+ * Returns an **index** rather than an element so the whole thing is testable
55
+ * with an array of numbers if you like. `null` means "not our business":
56
+ * a trap over zero or one focusable elements has nothing to cycle, and
57
+ * calling `preventDefault` in that case would strand the user on a dialog
58
+ * that eats Tab and does nothing with it — strictly worse than an untrapped
59
+ * one they can at least escape.
60
+ *
61
+ * @param count how many focusables the dialog contains
62
+ * @param at the index of the currently focused one, or `-1` when focus is on
63
+ * the dialog container itself (which is where it starts)
64
+ * @param backwards Shift+Tab
65
+ */
66
+ export function trapTarget(count: number, at: number, backwards: boolean): number | null {
67
+ if (count <= 1) return null;
68
+ // Focus on the container itself: Tab enters at the top, Shift+Tab at the
69
+ // bottom. Without this the first Tab out of a freshly-opened dialog would
70
+ // compute from -1 and land on the second control, skipping the input.
71
+ if (at < 0) return backwards ? count - 1 : 0;
72
+ if (!backwards) return at === count - 1 ? 0 : at + 1;
73
+ return at === 0 ? count - 1 : at - 1;
74
+ }
75
+
76
+ /**
77
+ * Whether an event is the Tab a trap should act on.
78
+ *
79
+ * Modified Tab — ⌘Tab, Ctrl+Tab, Alt+Tab — belongs to the operating system or
80
+ * the browser's tab strip, and intercepting it is both futile and rude. Shift
81
+ * is the one modifier that is ours, because Shift+Tab *is* backwards Tab.
82
+ */
83
+ export function isTrapTab(key: string, ctrl: boolean, meta: boolean, alt: boolean): boolean {
84
+ return key === "Tab" && !ctrl && !meta && !alt;
85
+ }
86
+
87
+ /**
88
+ * Restore focus to where it was before a dialog opened.
89
+ *
90
+ * A separate function for one reason: the null check. The previously-focused
91
+ * element is whatever `document.activeElement` was at open time, and that is
92
+ * legitimately `null` — a fresh page load where nothing has been clicked, or
93
+ * a body that was itself focused. Returning `false` rather than throwing lets
94
+ * the caller stay a one-liner, and the boolean is what the test asserts.
95
+ */
96
+ export function restoreFocus(previous: Focusable | null): boolean {
97
+ if (previous === null) return false;
98
+ previous.focus();
99
+ return true;
100
+ }