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,107 @@
1
+ /**
2
+ * The palette's effects: a timer, a fetch, and nothing else
3
+ * (weave-workspace §10, P4).
4
+ *
5
+ * `search.model.ts` decides *when* to issue a request and *whether* a
6
+ * response may be applied; it cannot arm a timer or call `fetch` without
7
+ * ceasing to be pure. This module is the other half — a controller that owns
8
+ * exactly two capabilities, both injected:
9
+ *
10
+ * - a **clock** (`now`) and a **scheduler** (`delay`), so the 140 ms debounce
11
+ * is exercised by a fake in microseconds rather than waited out;
12
+ * - a **`FetchLike`**, the same port `api.ts` takes, so no DOM is involved.
13
+ *
14
+ * The result is that the palette's entire asynchronous behaviour — debounce,
15
+ * re-arm, stale-response rejection, failure — is covered by ordinary unit
16
+ * tests, and `SearchPalette.tsx` is a `useState`, an effect and three
17
+ * handlers. That is §10's split applied to the one part of the workspace that
18
+ * is genuinely concurrent.
19
+ *
20
+ * ## Why the controller and not the component owns the sequence number
21
+ *
22
+ * A component could hold `seq` in a ref and compare on arrival. It would also
23
+ * be re-created by every hot reload, remounted by every breakpoint change,
24
+ * and untestable. Keeping the counter inside {@link SearchState}, where the
25
+ * reducer can reason about it, means "an older response must not overwrite a
26
+ * newer one" is a property with a test rather than a comment.
27
+ */
28
+
29
+ import type { FetchLike } from "../api";
30
+ import { fetchSearch } from "../api";
31
+ import type { SearchEvent, SearchState, SearchTransition } from "./search.model";
32
+ import { initialSearchState, reduceSearch } from "./search.model";
33
+
34
+ /**
35
+ * Arms a one-shot timer and returns nothing.
36
+ *
37
+ * Deliberately *not* returning a cancel handle: the debounce in
38
+ * `reduceSearch` is a re-arming chain precisely so that nothing ever needs
39
+ * cancelling, and a cancel handle here would invite a second, competing
40
+ * mechanism. `(fn, ms) => void setTimeout(fn, ms)` at the call site; a queue
41
+ * a test drains by hand in a test.
42
+ */
43
+ export type Scheduler = (run: () => void, ms: number) => void;
44
+
45
+ /** What {@link createSearch} needs. Every capability injected. */
46
+ export interface SearchOptions {
47
+ fetch: FetchLike;
48
+ /** Epoch ms. `Date.now` in the browser. */
49
+ now: () => number;
50
+ delay: Scheduler;
51
+ /** Called after every state change, so the component can re-render. */
52
+ onChange: (state: SearchState) => void;
53
+ }
54
+
55
+ /** A running palette controller. */
56
+ export interface SearchHandle {
57
+ /** Current state. Read at render. */
58
+ state(): SearchState;
59
+ /** The text changed. */
60
+ setQuery(query: string): void;
61
+ /** Move the cursor — pointer hover, or a key the model resolved to an index. */
62
+ setCursor(cursor: number): void;
63
+ /** The palette closed: disarm the debounce. */
64
+ dismiss(): void;
65
+ }
66
+
67
+ /**
68
+ * Build the controller.
69
+ *
70
+ * One function, one loop: every event goes through {@link dispatch}, which
71
+ * reduces, publishes, and then performs whichever of the two effects the
72
+ * transition asked for. There is no other path that mutates state, which is
73
+ * what makes "a stale response cannot win" enforceable — the check lives in
74
+ * the reducer and every response arrives through here.
75
+ */
76
+ export function createSearch(opts: SearchOptions): SearchHandle {
77
+ let state = initialSearchState();
78
+
79
+ const dispatch = (event: SearchEvent): void => {
80
+ const next: SearchTransition = reduceSearch(state, event);
81
+ state = next.state;
82
+ opts.onChange(state);
83
+
84
+ if (next.schedule !== null) opts.delay(() => dispatch({ type: "tick", now: opts.now() }), next.schedule);
85
+
86
+ const request = next.request;
87
+ if (request === null) return;
88
+ // Fire and forget: `api.ts` returns failures as values, so there is
89
+ // nothing to reject and nothing to catch. `void` documents the floating
90
+ // promise rather than hiding it — the same shape `workspace.ts` uses.
91
+ void (async () => {
92
+ const result = await fetchSearch(opts.fetch, request.query);
93
+ dispatch(
94
+ result.ok
95
+ ? { type: "response", seq: request.seq, query: request.query, hits: result.data.hits }
96
+ : { type: "failed", seq: request.seq },
97
+ );
98
+ })();
99
+ };
100
+
101
+ return {
102
+ state: () => state,
103
+ setQuery: (query) => dispatch({ type: "query", query, now: opts.now() }),
104
+ setCursor: (cursor) => dispatch({ type: "cursor", cursor }),
105
+ dismiss: () => dispatch({ type: "dismiss" }),
106
+ };
107
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The three-column grid, its dividers and the context rail
3
+ * (weave-workspace §1.2).
4
+ *
5
+ * The one interesting line is the `useLayoutEffect`: column widths reach the
6
+ * DOM as **custom properties**, written through `applyVars` →
7
+ * `style.setProperty`. That is the CSSOM path, which `style-src 'nonce-…'`
8
+ * does not govern — see `cssvars.ts` for the verified reasoning — and it
9
+ * leaves the actual `grid-template-columns` rule in the nonce'd stylesheet
10
+ * where it can be read.
11
+ *
12
+ * `useLayoutEffect` rather than `useEffect` so the widths land before paint;
13
+ * with `useEffect` the first frame renders at whatever the CSS fallback says
14
+ * and then jumps.
15
+ *
16
+ * The column/divider pairing is {@link columnSlots}, not an index check here:
17
+ * it is breakpoint-sensitive and therefore worth a test.
18
+ *
19
+ * All three columns are built as of P3, so {@link Column} dispatches on the
20
+ * column id. That ternary chain is the one piece of branching in the file and
21
+ * it is a *routing* decision, not a product one: each arm is a bare element,
22
+ * and everything those components then decide lives in their own `.model.ts`.
23
+ */
24
+
25
+ import { Fragment } from "preact";
26
+ import { useLayoutEffect, useRef } from "preact/hooks";
27
+ import { Graph } from "../graph/Graph";
28
+ import type { PositionStorage } from "../graph/positions";
29
+ import type { RendererFactory } from "../graph/renderer";
30
+ import type { SchemeHost } from "../graph/scheme";
31
+ import type { EditorEvent, EditorPrompt, EditorToolbar } from "../note/editor.model";
32
+ import { Note } from "../note/Note";
33
+ import { Tree } from "../tree/Tree";
34
+ import type { GraphPayload, NotePayload } from "../../shared/wire";
35
+ import { applyVars } from "./cssvars";
36
+ import type { ColumnId, DividerId, ResolvedColumn } from "./layout.model";
37
+ import { columnVars } from "./layout.model";
38
+ import { ContextRail } from "./ContextRail";
39
+ import { Divider } from "./Divider";
40
+ import type { ColumnSlot } from "./shell.model";
41
+ import { columnSlots, emptyStateFor } from "./shell.model";
42
+
43
+ export interface ColumnsProps {
44
+ resolved: readonly ResolvedColumn[];
45
+ onDown: (divider: DividerId, clientX: number, pointerId: number) => void;
46
+ onMove: (clientX: number) => void;
47
+ onUp: () => void;
48
+ onKey: (divider: DividerId, key: string) => void;
49
+ /** The §1.3 context bus, as the columns see it. */
50
+ graph: GraphPayload | null;
51
+ note: NotePayload | null;
52
+ selectedId: string | null;
53
+ /**
54
+ * The §1.3 context bus.
55
+ *
56
+ * Takes `string | null` because the graph can *clear* the selection — a
57
+ * click on empty stage — and no other column can. `workspace.ts`'s `select`
58
+ * has always accepted `null`; this prop was merely narrower than the thing
59
+ * behind it. The tree, the note column and the rail pass a `string`, which
60
+ * a handler accepting the wider type takes without a cast.
61
+ */
62
+ onSelect: (id: string | null) => void;
63
+ /** Epoch ms for relative times, read once per render by the shell. */
64
+ now: number;
65
+ /**
66
+ * The graph column's three injected ports (§7.5, §10).
67
+ *
68
+ * Threaded through rather than imported by `Graph.tsx` for the reason
69
+ * `api.dom.ts` exists: `createSigmaRenderer` imports sigma, which is a
70
+ * `ReferenceError` outside a browser, so the module that names it must stay
71
+ * reachable only from a `.tsx` entry point.
72
+ */
73
+ renderer: RendererFactory;
74
+ storage: PositionStorage;
75
+ host: SchemeHost;
76
+ /** Slot the graph column fills with its `fit`, for the global `g` key. */
77
+ fit: { current: (() => void) | null };
78
+ /**
79
+ * The note editor's view model (§11 P5).
80
+ *
81
+ * Derived by the shell from the one `EditorHandle`, because the editor's
82
+ * state outlives the note column's mount: below 800 px the column can be
83
+ * unmounted by a resize, and an editor owned by the component would lose
84
+ * an unsaved draft to a window drag.
85
+ */
86
+ toolbar: EditorToolbar | null;
87
+ prompt: EditorPrompt | null;
88
+ draft: string;
89
+ send: (event: EditorEvent) => void;
90
+ }
91
+
92
+ /** One column: a titled region and whichever surface fills it. */
93
+ function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
94
+ const copy = emptyStateFor(id);
95
+ return (
96
+ <section class={`weave-col weave-col-${id}`} aria-label={copy.title}>
97
+ <h2 class="weave-col-title">{copy.title}</h2>
98
+ {id === "tree" ? <Tree graph={props.graph} selectedId={props.selectedId} onSelect={props.onSelect} now={props.now} /> : null}
99
+ {id === "note" ? (
100
+ <Note
101
+ note={props.note}
102
+ graph={props.graph}
103
+ selectedId={props.selectedId}
104
+ onSelect={props.onSelect}
105
+ now={props.now}
106
+ toolbar={props.toolbar}
107
+ prompt={props.prompt}
108
+ draft={props.draft}
109
+ send={props.send}
110
+ />
111
+ ) : null}
112
+ {id === "graph" ? (
113
+ <Graph
114
+ graph={props.graph}
115
+ selectedId={props.selectedId}
116
+ onSelect={props.onSelect}
117
+ renderer={props.renderer}
118
+ storage={props.storage}
119
+ host={props.host}
120
+ fit={props.fit}
121
+ />
122
+ ) : null}
123
+ {id === "graph" ? <ContextRail graph={props.graph} selectedId={props.selectedId} onSelect={props.onSelect} /> : null}
124
+ </section>
125
+ );
126
+ }
127
+
128
+ /** A column and, where one follows it, its divider. */
129
+ function Slot({ slot, props }: { slot: ColumnSlot; props: ColumnsProps }) {
130
+ const divider = slot.divider;
131
+ return (
132
+ <Fragment>
133
+ <Column id={slot.column.id} props={props} />
134
+ {divider === null ? null : (
135
+ <Divider
136
+ id={divider}
137
+ label={`Resize ${slot.column.id} column`}
138
+ onDown={(x, pointerId) => props.onDown(divider, x, pointerId)}
139
+ onMove={props.onMove}
140
+ onUp={props.onUp}
141
+ onKey={(key) => props.onKey(divider, key)}
142
+ />
143
+ )}
144
+ </Fragment>
145
+ );
146
+ }
147
+
148
+ export function Columns(props: ColumnsProps) {
149
+ const grid = useRef<HTMLDivElement | null>(null);
150
+ useLayoutEffect(() => {
151
+ applyVars(grid.current, columnVars(props.resolved));
152
+ }, [props.resolved]);
153
+
154
+ return (
155
+ <div class="weave-grid" ref={grid} data-columns={props.resolved.length}>
156
+ {columnSlots(props.resolved).map((slot) => (
157
+ <Slot key={slot.column.id} slot={slot} props={props} />
158
+ ))}
159
+ </div>
160
+ );
161
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The context rail beneath the graph column (weave-workspace §1.1, §1.2, P2.5).
3
+ *
4
+ * LINKS, BACKLINKS, TAGS and MENTIONS for whatever is selected — §1.1's
5
+ * "bring related information into the current view", with every entry
6
+ * clickable and nothing requiring a navigation to see.
7
+ *
8
+ * Props in, JSX out. `context.model.ts` decides which groups exist, which
9
+ * rows they hold, what each row is labelled and what it selects; this renders
10
+ * the result.
11
+ */
12
+
13
+ import type { ContextGroup, ContextRow, TagGroupRow } from "../context/context.model";
14
+ import { contextModel } from "../context/context.model";
15
+ import type { GraphPayload } from "../../shared/wire";
16
+ import { CONTEXT_EMPTY } from "./shell.model";
17
+
18
+ export interface ContextRailProps {
19
+ graph: GraphPayload | null;
20
+ selectedId: string | null;
21
+ onSelect: (id: string) => void;
22
+ }
23
+
24
+ function Row({ row, onSelect }: { row: ContextRow; onSelect: () => void }) {
25
+ return (
26
+ <li class={`weave-ctx-row${row.selected ? " weave-row-on" : ""}`}>
27
+ <button type="button" class="weave-ctx-link" onClick={onSelect}>
28
+ <span class="weave-kind" aria-hidden="true">
29
+ {row.kindGlyph}
30
+ </span>
31
+ <span class={`weave-prov weave-prov-${row.provenance ?? "none"}`} title={row.provenanceTitle}>
32
+ {row.provenanceGlyph}
33
+ </span>
34
+ <span class="weave-label">{row.label}</span>
35
+ </button>
36
+ </li>
37
+ );
38
+ }
39
+
40
+ function Group({ group, onSelect }: { group: ContextGroup; onSelect: (id: string) => void }) {
41
+ return (
42
+ <section class="weave-ctx-group">
43
+ <h4 class="weave-ctx-heading">{group.heading}</h4>
44
+ <ul class="weave-ctx-rows">
45
+ {group.rows.map((row) => (
46
+ <Row key={row.id} row={row} onSelect={() => onSelect(row.target)} />
47
+ ))}
48
+ </ul>
49
+ </section>
50
+ );
51
+ }
52
+
53
+ function Tag({ tag, onSelect }: { tag: TagGroupRow; onSelect: (id: string) => void }) {
54
+ return (
55
+ <li class="weave-ctx-tag">
56
+ <span class="weave-tag">#{tag.tag}</span>
57
+ <ul class="weave-ctx-rows">
58
+ {tag.siblings.map((row) => (
59
+ <Row key={row.id} row={row} onSelect={() => onSelect(row.target)} />
60
+ ))}
61
+ </ul>
62
+ </li>
63
+ );
64
+ }
65
+
66
+ export function ContextRail(props: ContextRailProps) {
67
+ const model = contextModel(props.graph, props.selectedId);
68
+ return (
69
+ <div class="weave-rail" aria-label={CONTEXT_EMPTY.title}>
70
+ <h2 class="weave-col-title">{CONTEXT_EMPTY.title}</h2>
71
+ {model.empty === null ? null : <p class="weave-ctx-empty">{model.empty}</p>}
72
+ {model.groups.map((group) => (
73
+ <Group key={group.heading} group={group} onSelect={props.onSelect} />
74
+ ))}
75
+ {model.tags.length === 0 ? null : (
76
+ <section class="weave-ctx-group">
77
+ <h4 class="weave-ctx-heading">TAGS</h4>
78
+ <ul class="weave-ctx-tags">
79
+ {model.tags.map((tag) => (
80
+ <Tag key={tag.tag} tag={tag} onSelect={props.onSelect} />
81
+ ))}
82
+ </ul>
83
+ </section>
84
+ )}
85
+ </div>
86
+ );
87
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A drag handle between two columns (weave-workspace §1.2).
3
+ *
4
+ * All four handlers forward straight to callbacks the shell supplies; the
5
+ * arithmetic is in `drag.model.ts` and `layout.model.ts`. The ARIA role is
6
+ * `separator` with `tabindex={0}` so the divider is reachable and operable
7
+ * from the keyboard, which §11's P4 requires of the whole workspace and which
8
+ * is far cheaper to do now than to retrofit.
9
+ */
10
+
11
+ import type { DividerId } from "./layout.model";
12
+
13
+ export interface DividerProps {
14
+ id: DividerId;
15
+ label: string;
16
+ onDown: (clientX: number, pointerId: number) => void;
17
+ onMove: (clientX: number) => void;
18
+ onUp: () => void;
19
+ onKey: (key: string) => void;
20
+ }
21
+
22
+ export function Divider(props: DividerProps) {
23
+ return (
24
+ <div
25
+ class="weave-divider"
26
+ role="separator"
27
+ aria-orientation="vertical"
28
+ aria-label={props.label}
29
+ tabIndex={0}
30
+ onPointerDown={(event) => {
31
+ // Capture, so a fast drag that outruns the 1 px handle keeps
32
+ // delivering moves here instead of to whatever is under the pointer.
33
+ event.currentTarget.setPointerCapture(event.pointerId);
34
+ props.onDown(event.clientX, event.pointerId);
35
+ }}
36
+ onPointerMove={(event) => props.onMove(event.clientX)}
37
+ onPointerUp={(event) => {
38
+ event.currentTarget.releasePointerCapture(event.pointerId);
39
+ props.onUp();
40
+ }}
41
+ onKeyDown={(event) => props.onKey(event.key)}
42
+ />
43
+ );
44
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The DOM half of the focus trap (weave-workspace P4, §10).
3
+ *
4
+ * `focus.model.ts` decides *which* index Tab should land on; this hook does
5
+ * the three things that require a real document and cannot be decided:
6
+ * remember what was focused, focus the dialog, and put focus back on unmount.
7
+ * There is no branch here that `trapTarget` and `restoreFocus` do not already
8
+ * own — the `?? null`s are DOM nullability, not decisions.
9
+ *
10
+ * Isolated in a `.tsx` for `renderer.dom.ts`'s reason: it names
11
+ * `document.activeElement` and `HTMLElement`, which do not exist in the root
12
+ * `tsconfig.json` project, so anything importing it can only be compiled by
13
+ * `tsconfig.web.json`.
14
+ */
15
+
16
+ import { useEffect, useRef } from "preact/hooks";
17
+ import { FOCUSABLE_SELECTOR, isTrapTab, restoreFocus, trapTarget } from "./focus.model";
18
+
19
+ /** What {@link useFocusTrap} hands back to the dialog. */
20
+ export interface FocusTrapHandle {
21
+ /** Put on the dialog element. */
22
+ readonly ref: { current: HTMLElement | null };
23
+ /** Call from the dialog's `onKeyDown`. Returns whether it consumed the key. */
24
+ onKeyDown(event: KeyboardEvent): boolean;
25
+ }
26
+
27
+ /**
28
+ * Trap focus inside an element while it is mounted, and restore it after.
29
+ *
30
+ * The dialog itself is focused on mount rather than its first control,
31
+ * deliberately: a screen reader announces the dialog's `aria-label` and role
32
+ * on arrival, which a jump straight to the input would skip. `trapTarget`
33
+ * handles the resulting `at === -1` explicitly for exactly this case.
34
+ */
35
+ export function useFocusTrap(): FocusTrapHandle {
36
+ const ref = useRef<HTMLElement | null>(null);
37
+
38
+ useEffect(() => {
39
+ const previous = document.activeElement;
40
+ ref.current?.focus();
41
+ return () => void restoreFocus(previous instanceof HTMLElement ? previous : null);
42
+ }, []);
43
+
44
+ return {
45
+ ref,
46
+ onKeyDown(event) {
47
+ if (!isTrapTab(event.key, event.ctrlKey, event.metaKey, event.altKey)) return false;
48
+ const items = [...(ref.current?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])];
49
+ const target = trapTarget(items.length, items.indexOf(document.activeElement as HTMLElement), event.shiftKey);
50
+ if (target === null) return false;
51
+ event.preventDefault();
52
+ items[target]?.focus();
53
+ return true;
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The header bar (weave-workspace §1.2).
3
+ *
4
+ * Title, the `⌘K` search button, the `vault:N · repo:… · N nodes` summary,
5
+ * the refresh button and the connection dot. Every string and every tone
6
+ * comes from `shell.model.ts`.
7
+ *
8
+ * The search control was `disabled` through P1–P3 because search was P4 and
9
+ * an affordance that looks live but is not would be the dishonest option. P4
10
+ * landed, so it is live — and it is a `<button>`, not an `<input>`: the
11
+ * palette owns the only text field in the workspace, and a header box you
12
+ * could type into whose contents were then thrown away when the overlay
13
+ * opened would be a worse lie than the disabled version it replaced.
14
+ */
15
+
16
+ import type { ConnectionView, HeaderSummary } from "./shell.model";
17
+ import { SEARCH_PLACEHOLDER, searchHint, summaryParts } from "./shell.model";
18
+
19
+ export interface HeaderProps {
20
+ summary: HeaderSummary;
21
+ connection: ConnectionView;
22
+ shortcut: string;
23
+ onRefresh: () => void;
24
+ /** Opens the ⌘K palette. The same action the global key performs. */
25
+ onSearch: () => void;
26
+ }
27
+
28
+ export function Header(props: HeaderProps) {
29
+ return (
30
+ <header class="weave-header">
31
+ <span class="weave-brand">🧵 pi-weave</span>
32
+ <button type="button" class="weave-search" title={searchHint(props.shortcut)} onClick={props.onSearch}>
33
+ <span class="weave-search-text">{SEARCH_PLACEHOLDER}</span>
34
+ <kbd>{props.shortcut}</kbd>
35
+ </button>
36
+ <span class="weave-summary">
37
+ {summaryParts(props.summary).map((part) => (
38
+ <span key={part} class="weave-summary-part">
39
+ {part}
40
+ </span>
41
+ ))}
42
+ </span>
43
+ <button type="button" class="weave-refresh" onClick={props.onRefresh} title="Refetch everything">
44
+ ⟳
45
+ </button>
46
+ <span class={`weave-conn weave-conn-${props.connection.tone}`} title={props.connection.hint}>
47
+ <span class="weave-conn-dot" aria-hidden="true">
48
+ ●
49
+ </span>
50
+ {props.connection.label}
51
+ </span>
52
+ </header>
53
+ );
54
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The `?` keyboard-shortcut sheet (weave-workspace §11 P4).
3
+ *
4
+ * Props in, JSX out. The rows come from `keyHelp` in `keys.model.ts` — the
5
+ * same module the map itself is built from, so the sheet cannot document a
6
+ * key the code does not implement — and the trap is `useFocusTrap`. There is
7
+ * no branch in this file.
8
+ *
9
+ * `Escape` is not handled here: the global listener already answers it with
10
+ * `closeOverlay` whenever an overlay is open, and a second handler claiming
11
+ * the same key is how a dialog ends up closing twice.
12
+ */
13
+
14
+ import { useFocusTrap } from "./FocusTrap";
15
+ import type { KeyHelpGroup } from "./keys.model";
16
+ import { HELP_HINT, HELP_TITLE, keyHelp } from "./keys.model";
17
+
18
+ export interface HelpOverlayProps {
19
+ /** `⌘K` or `Ctrl K`, from `searchShortcut`. */
20
+ shortcut: string;
21
+ onClose: () => void;
22
+ }
23
+
24
+ function Group({ group }: { group: KeyHelpGroup }) {
25
+ return (
26
+ <div>
27
+ <p class="weave-key-group">{group.title}</p>
28
+ {group.entries.map((entry) => (
29
+ <div key={entry.combo} class="weave-key-row">
30
+ <kbd class="weave-key-combo">{entry.combo}</kbd>
31
+ <span class="weave-key-what">{entry.what}</span>
32
+ </div>
33
+ ))}
34
+ </div>
35
+ );
36
+ }
37
+
38
+ export function HelpOverlay(props: HelpOverlayProps) {
39
+ const trap = useFocusTrap();
40
+ // `⌘K` reduced to `⌘` / `Ctrl `: the sheet composes its own combinations.
41
+ const cmd = props.shortcut.slice(0, -1);
42
+
43
+ return (
44
+ <div class="weave-scrim" onClick={props.onClose}>
45
+ <div
46
+ class="weave-help"
47
+ role="dialog"
48
+ aria-modal="true"
49
+ aria-label={HELP_TITLE}
50
+ tabIndex={-1}
51
+ ref={trap.ref as { current: HTMLDivElement | null }}
52
+ onClick={(event) => event.stopPropagation()}
53
+ onKeyDown={(event) => void trap.onKeyDown(event as unknown as KeyboardEvent)}
54
+ >
55
+ <h2 class="weave-help-title">{HELP_TITLE}</h2>
56
+ <div class="weave-keys">
57
+ {keyHelp(cmd).map((group) => (
58
+ <Group key={group.title} group={group} />
59
+ ))}
60
+ </div>
61
+ <p class="weave-help-foot">
62
+ <button type="button" class="weave-chip" onClick={props.onClose}>
63
+ close
64
+ </button>
65
+ <span class="weave-help-hint">{HELP_HINT}</span>
66
+ </p>
67
+ </div>
68
+ </div>
69
+ );
70
+ }