pi-weave 0.1.8 → 0.1.10

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 (52) hide show
  1. package/README.md +9 -3
  2. package/package.json +2 -2
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/concurrency.ts +36 -0
  5. package/src/core/frontmatter.ts +53 -0
  6. package/src/core/graph/build.ts +60 -1
  7. package/src/core/graph/model.ts +15 -0
  8. package/src/core/graph/wikilinks.ts +5 -1
  9. package/src/core/index.ts +2 -0
  10. package/src/core/paths.ts +7 -0
  11. package/src/core/sessions.ts +929 -0
  12. package/src/core/summaries.ts +1 -19
  13. package/src/core/vault.ts +264 -16
  14. package/src/core/workspace.ts +3 -3
  15. package/src/pi/index.ts +144 -37
  16. package/src/pi/sessionScan.ts +104 -0
  17. package/src/pi/summarize.ts +24 -4
  18. package/src/pi/tools/noteTool.ts +13 -4
  19. package/src/pi/viewer/tui/branding.ts +8 -7
  20. package/src/pi/viewer/tui/explorer.ts +1 -1
  21. package/src/web/client/dist/app.js +272 -113
  22. package/src/web/client/graph/Graph.tsx +112 -22
  23. package/src/web/client/graph/column.model.ts +52 -94
  24. package/src/web/client/graph/dynamics.ts +181 -0
  25. package/src/web/client/graph/graph.model.ts +113 -34
  26. package/src/web/client/graph/positions.ts +58 -13
  27. package/src/web/client/graph/renderer.ts +89 -5
  28. package/src/web/client/graph/scheme.ts +51 -6
  29. package/src/web/client/main.tsx +12 -0
  30. package/src/web/client/note/Editor.tsx +16 -9
  31. package/src/web/client/note/Note.tsx +40 -7
  32. package/src/web/client/note/note.model.ts +1 -1
  33. package/src/web/client/search/SearchPalette.tsx +11 -2
  34. package/src/web/client/selection.storage.ts +69 -0
  35. package/src/web/client/shell/Columns.tsx +13 -0
  36. package/src/web/client/shell/Header.tsx +25 -1
  37. package/src/web/client/shell/Shell.tsx +76 -2
  38. package/src/web/client/shell/deeplink.model.ts +61 -0
  39. package/src/web/client/shell/focus.model.ts +14 -6
  40. package/src/web/client/shell/keys.model.ts +27 -3
  41. package/src/web/client/shell/layout.model.ts +12 -38
  42. package/src/web/client/shell/theme.model.ts +147 -0
  43. package/src/web/client/shell/theme.ts +289 -79
  44. package/src/web/client/state.ts +22 -0
  45. package/src/web/client/tree/Tree.tsx +11 -2
  46. package/src/web/client/tree/tree.model.ts +12 -1
  47. package/src/web/client/workspace.ts +78 -6
  48. package/src/web/server/page.ts +6 -4
  49. package/src/web/server/routes.ts +19 -7
  50. package/src/web/shared/graph.ts +7 -0
  51. package/src/web/shared/layout.ts +393 -307
  52. package/src/web/shared/logo.ts +11 -0
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import DOMPurify from "dompurify";
27
+ import { useMemo } from "preact/hooks";
27
28
  import type { GraphPayload, NotePayload } from "../../shared/wire";
28
29
  import type { EditorEvent, EditorPrompt, EditorToolbar } from "./editor.model";
29
30
  import { Editor, EditorBar } from "./Editor";
@@ -74,28 +75,60 @@ function Header({ view }: { view: NoteHeaderView }) {
74
75
  export function Note(props: NoteProps) {
75
76
  const note = props.note?.note ?? null;
76
77
  const empty = noteEmptyMessage(props.selectedId, note);
77
- if (note === null) return <p class="weave-note-empty">{empty}</p>;
78
78
 
79
- const index = wikiIndex(props.graph, note.slug);
79
+ // Both hook calls sit before the empty-return so the hook order cannot
80
+ // depend on whether a note is loaded. The memoization is cheap insurance:
81
+ // one marked parse + DOMPurify pass + O(nodes) index per *body or vault*
82
+ // instead of per shell render (every editor keystroke and divider pixel
83
+ // re-renders the shell). The instance the render builds —
84
+ // `markdownRenderer(index)` inside `renderNote` — stays per-call by design;
85
+ // only the *result* is memoized.
86
+ const index = useMemo(() => (note === null ? null : wikiIndex(props.graph, note.slug)), [note, props.graph]);
87
+ const html = useMemo(
88
+ () => (note === null || index === null ? "" : renderNote(DOMPurify, note.body, index)),
89
+ [note, index],
90
+ );
91
+
92
+ if (note === null || index === null) return <p class="weave-note-empty">{empty}</p>;
93
+
80
94
  // Bound once so TypeScript narrows it for both uses below: `toolbar.editing`
81
95
  // is what decides whether the textarea renders, and reading it twice off
82
96
  // `props` would need a non-null assertion at the second read.
83
97
  const toolbar = props.toolbar;
84
98
  const shell = { draft: props.draft, prompt: props.prompt, send: props.send };
99
+ // Keyed on the slug, not the body digest: the article is the scroll
100
+ // container, so an in-place swap would open every note at the previous
101
+ // note's scroll offset. A key change remounts it, and a fresh container
102
+ // starts at the top — no scroll-API effect to untest. The provenance
103
+ // class is the page's spine: the left rule takes the source's colour,
104
+ // which is how the desk says who wrote what a glance away from the text.
105
+ const header = noteHeader(note, props.now);
85
106
  return (
86
- <article class="weave-note">
87
- <Header view={noteHeader(note, props.now)} />
107
+ <article key={note.slug} class={`weave-note weave-note-${header.provenance}`}>
108
+ <Header view={header} />
88
109
  {toolbar === null ? null : <EditorBar toolbar={toolbar} {...shell} />}
89
110
  {toolbar !== null && toolbar.editing ? (
90
111
  <Editor toolbar={toolbar} {...shell} />
91
112
  ) : (
92
113
  <div
93
114
  class="weave-note-body"
115
+ // Programmatic focus target for `⌘2`: without a `tabindex`,
116
+ // `focusSelector`'s `.focus()` is a silent no-op. `-1` keeps it out
117
+ // of the Tab order (the workspace moves by `j/k` and `⌘1/2/3`, and
118
+ // Tab must stay the user's).
119
+ tabIndex={-1}
94
120
  onClick={(event) => {
95
121
  // A wikilink carries no href, so nothing is navigating; this only
96
- // has to route the click onto the §1.3 context bus.
122
+ // has to route the click onto the §1.3 context bus. Anywhere else
123
+ // on the page *is* the edit affordance: a click on the prose opens
124
+ // the editor. The two never fight, because the link check reads
125
+ // the exact element the click landed on.
97
126
  const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
98
- if (target !== null) props.onSelect(target);
127
+ if (target !== null) {
128
+ props.onSelect(target);
129
+ return;
130
+ }
131
+ if (props.toolbar !== null) props.send({ type: "toggle" });
99
132
  }}
100
133
  onKeyDown={(event) => {
101
134
  if (event.key !== "Enter" && event.key !== " ") return;
@@ -105,7 +138,7 @@ export function Note(props: NoteProps) {
105
138
  props.onSelect(target);
106
139
  }}
107
140
  // Sanitised by `renderNote`'s three layers — see note.model.ts.
108
- dangerouslySetInnerHTML={{ __html: renderNote(DOMPurify, note.body, index) }}
141
+ dangerouslySetInnerHTML={{ __html: html }}
109
142
  />
110
143
  )}
111
144
  </article>
@@ -729,7 +729,7 @@ export function tagLabel(tag: string): string {
729
729
  */
730
730
  export function noteEmptyMessage(selectedId: string | null, note: ViewNote | null): string | null {
731
731
  if (note !== null) return null;
732
- if (selectedId === null) return "Select a note to read it here.";
732
+ if (selectedId === null) return "Nothing open — search with ⌘K, or walk the tree with j and k.";
733
733
  if (selectedId.startsWith(NOTE_PREFIX)) return "Loading…";
734
734
  // A module, a file, a package, the git state: real nodes with no prose
735
735
  // behind them. The context rail beside this column is where their
@@ -7,7 +7,7 @@
7
7
  * `useState`, one memo and three handlers. §10's rule.
8
8
  */
9
9
 
10
- import { useMemo, useState } from "preact/hooks";
10
+ import { useEffect, useMemo, useRef, useState } from "preact/hooks";
11
11
  import type { GraphPayload } from "../../shared/wire";
12
12
  import { useFocusTrap } from "../shell/FocusTrap";
13
13
  import type { SearchRowView } from "./search.model";
@@ -45,6 +45,15 @@ export function SearchPalette(props: SearchPaletteProps) {
45
45
  const [state, setState] = useState(initialSearchState);
46
46
  const search = useMemo(() => createSearch({ ...props.ports, onChange: setState }), []);
47
47
  const trap = useFocusTrap();
48
+ const input = useRef<HTMLInputElement | null>(null);
49
+ // Focus the input, not the container the trap starts on: the palette's
50
+ // whole surface is its query, and opening it to a container that only
51
+ // swallows (and does not forward) keystrokes makes the user click before
52
+ // they can type. Runs after the trap's own effect — hooks run in order,
53
+ // and the trap is created first — so this is the final word. The input
54
+ // carries the dialog's `aria-label`, so the announcement the
55
+ // container-focus exists for still happens.
56
+ useEffect(() => void input.current?.focus(), []);
48
57
  const model = paletteModel(state, props.graph);
49
58
 
50
59
  const pick = (index: number | null): void => {
@@ -76,7 +85,7 @@ export function SearchPalette(props: SearchPaletteProps) {
76
85
  <input
77
86
  type="search"
78
87
  class="weave-palette-input"
79
- autofocus
88
+ ref={input}
80
89
  value={state.query}
81
90
  placeholder={PALETTE_PLACEHOLDER}
82
91
  aria-label={PALETTE_TITLE}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * selection.storage.ts — the §1.3 context bus's memory across reloads.
3
+ *
4
+ * A manual refresh used to drop the open note: `selectedId` is a Preact
5
+ * signal, and signals do not survive a page load. The layout already
6
+ * persists (`shell/layout.model.ts`), and this is the same trick for the
7
+ * one piece of selection state the user actually misses: which note they
8
+ * were reading.
9
+ *
10
+ * ## Restore needs the graph
11
+ *
12
+ * A saved id is only offered back if it still names a node of the freshly
13
+ * loaded graph — a note deleted in another window must not resurrect as a
14
+ * selection pointing at nothing. That check needs the graph, which arrives
15
+ * after mount, so the restore is deliberately a pure function of
16
+ * `(graph, storage)` the shell calls once the first graph lands, not
17
+ * something this module schedules itself.
18
+ *
19
+ * ## Tier rules (§2)
20
+ *
21
+ * `src/web/client/**`: storage arrives as a two-method port (same shape as
22
+ * `LayoutStorage`) rather than the global, because the real `localStorage`
23
+ * throws in partitioned-storage contexts and a module this small should be
24
+ * testable without a DOM.
25
+ */
26
+
27
+ import type { GraphPayload } from "../shared/wire";
28
+
29
+ /** The slice of `Storage` this module needs. */
30
+ export interface SelectionStorage {
31
+ getItem(key: string): string | null;
32
+ setItem(key: string, value: string): void;
33
+ }
34
+
35
+ /** Versioned like `pi-weave.layout.v1`: an id-shape change gets a new key. */
36
+ export const SELECTION_STORAGE_KEY = "pi-weave.selection.v1";
37
+
38
+ /**
39
+ * Remember the selection; an empty string records "nothing open".
40
+ * Reports failure as `false` instead of throwing — a quota or
41
+ * partitioned-storage error must never break a selection.
42
+ */
43
+ export function saveSelection(storage: SelectionStorage, id: string | null): boolean {
44
+ try {
45
+ storage.setItem(SELECTION_STORAGE_KEY, id ?? "");
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The saved selection to restore, or `null`.
54
+ *
55
+ * `graph` gates the offer: the id must still name a node of the current
56
+ * payload, so a note deleted elsewhere is quietly not restored. Reads are
57
+ * failure-absorbing for the same reason writes are.
58
+ */
59
+ export function restoreSelection(graph: GraphPayload | null, storage: SelectionStorage): string | null {
60
+ if (graph === null) return null;
61
+ let raw: string | null;
62
+ try {
63
+ raw = storage.getItem(SELECTION_STORAGE_KEY);
64
+ } catch {
65
+ return null;
66
+ }
67
+ if (raw === null || raw === "") return null;
68
+ return graph.model.nodes.some((node) => node.id === raw) ? raw : null;
69
+ }
@@ -25,6 +25,7 @@
25
25
  import { Fragment } from "preact";
26
26
  import { useLayoutEffect, useRef } from "preact/hooks";
27
27
  import { Graph } from "../graph/Graph";
28
+ import type { ColorScheme } from "../graph/graph.model";
28
29
  import type { PositionStorage } from "../graph/positions";
29
30
  import type { RendererFactory } from "../graph/renderer";
30
31
  import type { SchemeHost } from "../graph/scheme";
@@ -73,6 +74,16 @@ export interface ColumnsProps {
73
74
  renderer: RendererFactory;
74
75
  storage: PositionStorage;
75
76
  host: SchemeHost;
77
+ /**
78
+ * The scheme the shell resolved from the user's theme choice
79
+ * (`shell/theme.model.ts`'s `effectiveScheme`), or `null` to let the column
80
+ * read the OS (`graph/scheme.ts`'s `schemeOf`). The stylesheet carries the
81
+ * choice to everything CSS paints; WebGL cannot read custom properties, so
82
+ * the graph needs the decision handed to it as a value.
83
+ */
84
+ scheme: ColorScheme | null;
85
+ /** The boot graph fetch failed — see `state.ts`'s `graphFailed`. */
86
+ bootFailed: boolean;
76
87
  /** Slot the graph column fills with its `fit`, for the global `g` key. */
77
88
  fit: { current: (() => void) | null };
78
89
  /**
@@ -117,6 +128,8 @@ function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
117
128
  renderer={props.renderer}
118
129
  storage={props.storage}
119
130
  host={props.host}
131
+ scheme={props.scheme}
132
+ bootFailed={props.bootFailed}
120
133
  fit={props.fit}
121
134
  />
122
135
  ) : null}
@@ -13,6 +13,8 @@
13
13
  * opened would be a worse lie than the disabled version it replaced.
14
14
  */
15
15
 
16
+ import { LOGO_MARK_B64, LOGO_MARK_MIME } from "../../shared/logo";
17
+ import type { ThemeButtonView } from "./theme.model";
16
18
  import type { ConnectionView, HeaderSummary } from "./shell.model";
17
19
  import { SEARCH_PLACEHOLDER, searchHint, summaryParts } from "./shell.model";
18
20
 
@@ -23,12 +25,25 @@ export interface HeaderProps {
23
25
  onRefresh: () => void;
24
26
  /** Opens the ⌘K palette. The same action the global key performs. */
25
27
  onSearch: () => void;
28
+ /** The theme control's face (glyph + hint), from `shell.model.ts`'s friend in `theme.model.ts`. */
29
+ theme: ThemeButtonView;
30
+ /** Advance the theme choice one step in its cycle. The same action the `t` key performs. */
31
+ onTheme: () => void;
26
32
  }
27
33
 
28
34
  export function Header(props: HeaderProps) {
29
35
  return (
30
36
  <header class="weave-header">
31
- <span class="weave-brand">🧵 pi-weave</span>
37
+ <span class="weave-brand">
38
+ <img
39
+ class="weave-brand-mark"
40
+ src={`data:${LOGO_MARK_MIME};base64,${LOGO_MARK_B64}`}
41
+ alt=""
42
+ width={18}
43
+ height={18}
44
+ />
45
+ pi-weave
46
+ </span>
32
47
  <button type="button" class="weave-search" title={searchHint(props.shortcut)} onClick={props.onSearch}>
33
48
  <span class="weave-search-text">{SEARCH_PLACEHOLDER}</span>
34
49
  <kbd>{props.shortcut}</kbd>
@@ -40,6 +55,15 @@ export function Header(props: HeaderProps) {
40
55
  </span>
41
56
  ))}
42
57
  </span>
58
+ <button
59
+ type="button"
60
+ class="weave-theme"
61
+ onClick={props.onTheme}
62
+ title={props.theme.hint}
63
+ aria-label={props.theme.hint}
64
+ >
65
+ {props.theme.glyph}
66
+ </button>
43
67
  <button type="button" class="weave-refresh" onClick={props.onRefresh} title="Refetch everything">
44
68
  ⟳
45
69
  </button>
@@ -25,15 +25,19 @@
25
25
 
26
26
  import { useEffect, useMemo, useRef, useState } from "preact/hooks";
27
27
  import { fetchJson } from "../api.dom";
28
+ import type { ColorScheme } from "../graph/graph.model";
29
+ import { schemeOf, watchScheme } from "../graph/scheme";
28
30
  import { createSigmaRenderer } from "../graph/renderer.dom";
29
31
  import { domEventSource } from "../live";
30
32
  import { createEditor, watchUnload, type EditorHandle } from "../note/editor.controller";
31
33
  import { editorPrompt, editorToolbar, initialEditorState, shouldBlockUnload } from "../note/editor.model";
32
34
  import { SearchPalette } from "../search/SearchPalette";
33
- import { connection, graph, noteBody, selectedId } from "../state";
35
+ import { restoreSelection, saveSelection } from "../selection.storage";
36
+ import { connection, graph, graphFailed, noteBody, selectedId } from "../state";
34
37
  import type { WorkspaceHandle } from "../workspace";
35
38
  import { observeNotes, select, startWorkspace } from "../workspace";
36
39
  import { Columns } from "./Columns";
40
+ import { deeplinkSelection, formatHash } from "./deeplink.model";
37
41
  import { dividerHandlers } from "./drag.model";
38
42
  import { Header } from "./Header";
39
43
  import { HelpOverlay } from "./HelpOverlay";
@@ -44,8 +48,21 @@ import { breakpointFor, loadLayout, resolveColumns, saveLayout } from "./layout.
44
48
  import { StatusBar } from "./StatusBar";
45
49
  import type { OverlayId } from "./shell.model";
46
50
  import { connectionView, looksApple, searchShortcut, statusBarModel, summarize } from "./shell.model";
51
+ import { cycleTheme, effectiveScheme, loadTheme, saveTheme, themeAttr, themeButton } from "./theme.model";
52
+ import type { ThemeChoice } from "./theme.model";
47
53
  import { watchViewport } from "./viewport";
48
54
 
55
+ /**
56
+ * Write the choice onto `<html>`, so the sheet's attribute branch and the
57
+ * media query agree on who is in charge (see `theme.model.ts`'s header). The
58
+ * one DOM write the theme needs, and it is here rather than in the model for
59
+ * the same reason `applyVars` is: it is an effect, not a decision.
60
+ */
61
+ function applyThemeAttr(choice: ReturnType<typeof themeAttr>): void {
62
+ if (choice === null) delete document.documentElement.dataset.weaveTheme;
63
+ else document.documentElement.dataset.weaveTheme = choice;
64
+ }
65
+
49
66
  export interface ShellProps {
50
67
  /** From the page bootstrap. Shown in the status bar. */
51
68
  cwd: string;
@@ -60,6 +77,12 @@ export function Shell(props: ShellProps) {
60
77
  const [overlay, setOverlay] = useState<OverlayId>(null);
61
78
  const [layout, setLayout] = useState<LayoutState>(() => loadLayout(localStorage, props.initialWidth));
62
79
  const [editorState, setEditorState] = useState(initialEditorState);
80
+ // The theme: what the user picked, and what the OS is currently saying. Two
81
+ // states because they answer different questions — `theme` changes on a
82
+ // button press or the `t` key, `systemScheme` on an OS flip while the user
83
+ // is in system mode — and `effectiveScheme` is where the two resolve.
84
+ const [theme, setTheme] = useState<ThemeChoice>(() => loadTheme(localStorage) ?? "system");
85
+ const [systemScheme, setSystemScheme] = useState<ColorScheme>(() => schemeOf(window));
63
86
  const workspace = useRef<WorkspaceHandle | null>(null);
64
87
  // Filled by the graph column at mount, cleared on unmount. The global `g`
65
88
  // key's only route to the renderer — see `Graph.tsx`'s `fit` prop.
@@ -83,6 +106,37 @@ export function Shell(props: ShellProps) {
83
106
  return () => handle.stop();
84
107
  }, []);
85
108
 
109
+ // §1.3 continuity: a reload keeps the note you were reading. Saving is
110
+ // gated on the restore decision so the mount-time `null` cannot wipe the
111
+ // saved id before the first graph arrives to validate it against.
112
+ const selectionRestored = useRef(false);
113
+ // The deep link is read once, at mount, into a ref — the URL is an input
114
+ // to boot only, and the hash-write effect below must never clear a link
115
+ // this effect has not read yet.
116
+ const bootHash = useRef(location.hash);
117
+ useEffect(() => {
118
+ if (!selectionRestored.current) return;
119
+ saveSelection(localStorage, selectedId.value);
120
+ // replaceState, not pushState: the address bar mirrors the note on
121
+ // screen, and reading three notes is not three history entries. The
122
+ // *string* is the model's (`formatHash`); this is the write itself.
123
+ history.replaceState(null, "", formatHash(selectedId.value));
124
+ }, [selectedId.value]);
125
+ useEffect(() => {
126
+ if (selectionRestored.current || graph.value === null) return;
127
+ selectionRestored.current = true;
128
+ if (selectedId.value !== null) return;
129
+ // A link wins over storage: the address bar is an explicit instruction,
130
+ // the saved note is a habit. A link to a note the graph does not hold is
131
+ // refused (see `deeplink.model.ts`) and continuity falls through.
132
+ const linked = deeplinkSelection(bootHash.current, graph.value);
133
+ if (linked !== null) void select(fetchJson, linked);
134
+ else {
135
+ const saved = restoreSelection(graph.value, localStorage);
136
+ if (saved !== null) void select(fetchJson, saved);
137
+ }
138
+ }, [graph.value]);
139
+
86
140
  // Every note that arrives, from any of the three directions it can arrive
87
141
  // from (mount, selection, SSE refetch), so the editor can decide whether it
88
142
  // is news or an interruption — see `editor.model.ts`'s header.
@@ -95,6 +149,18 @@ export function Shell(props: ShellProps) {
95
149
 
96
150
  useEffect(() => watchViewport(window, setWidth), []);
97
151
 
152
+ // The theme's two effects: the attribute decides what the sheet paints, the
153
+ // save decides what a reload restores. Both keyed on the choice alone — a
154
+ // system-scheme flip touches neither, because the media query repaints the
155
+ // stylesheet by itself and the shell only re-resolves the graph's scheme.
156
+ useEffect(() => applyThemeAttr(themeAttr(theme)), [theme]);
157
+ useEffect(() => void saveTheme(localStorage, theme), [theme]);
158
+ // Live OS flips, needed only in system mode but harmless always: the
159
+ // stylesheet follows the OS on its own; this is what lets the *graph* (and
160
+ // the header button, which shows the resolved scheme nowhere — it shows the
161
+ // choice) keep up while the choice is "system".
162
+ useEffect(() => watchScheme(window, setSystemScheme), []);
163
+
98
164
  useEffect(
99
165
  () =>
100
166
  watchKeys(document, {
@@ -111,6 +177,7 @@ export function Shell(props: ShellProps) {
111
177
  clearSelection: () => editor.send({ type: "navigate", id: null }),
112
178
  toggleEdit: () => editor.send({ type: "toggle" }),
113
179
  saveNote: () => editor.send({ type: "save" }),
180
+ cycleTheme: () => setTheme((current) => cycleTheme(current)),
114
181
  }),
115
182
  }),
116
183
  [],
@@ -139,6 +206,8 @@ export function Shell(props: ShellProps) {
139
206
  shortcut={searchShortcut(looksApple(props.platform))}
140
207
  onRefresh={() => workspace.current?.refresh()}
141
208
  onSearch={() => setOverlay("search")}
209
+ theme={themeButton(theme)}
210
+ onTheme={() => setTheme((current) => cycleTheme(current))}
142
211
  />
143
212
  <Columns
144
213
  resolved={resolved}
@@ -146,6 +215,8 @@ export function Shell(props: ShellProps) {
146
215
  onMove={drag.onMove}
147
216
  onUp={drag.onUp}
148
217
  onKey={drag.onKey}
218
+ scheme={effectiveScheme(theme, systemScheme)}
219
+ bootFailed={graphFailed.value}
149
220
  graph={graph.value}
150
221
  note={noteBody.value}
151
222
  selectedId={selectedId.value}
@@ -180,7 +251,10 @@ export function Shell(props: ShellProps) {
180
251
  {overlay === "search" ? (
181
252
  <SearchPalette
182
253
  graph={graph.value}
183
- onSelect={(id) => void select(fetchJson, id)}
254
+ // Through the editor, like every column's `onSelect`: choosing a hit
255
+ // while a draft is dirty must park behind the UNSAVED prompt, not
256
+ // wipe it. This was the one navigation path that skipped the guard.
257
+ onSelect={(id) => editor.send({ type: "navigate", id })}
184
258
  onClose={() => setOverlay(null)}
185
259
  ports={{ fetch: fetchJson, now: Date.now, delay: (run, ms) => void setTimeout(run, ms) }}
186
260
  />
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Deep links: the selection, addressable (weave-workspace UX backlog T5.4).
3
+ *
4
+ * `#note/<slug>` in the location bar is the current selection, both ways:
5
+ * a `location.hash` naming a real node at boot takes priority over the saved
6
+ * *last note* (the link is an explicit instruction, storage is a habit), and
7
+ * every selection change is written back with `history.replaceState` — a
8
+ * shared URL must mean the note on screen, but reading three notes must not
9
+ * put three entries in the back button's history.
10
+ *
11
+ * Two facts decide the shape. First, only *note* nodes deep-link: a
12
+ * repository or file node has no URL-worthy permanence (its id is derived
13
+ * from paths that move), and the note fetch behind a selection 404s for
14
+ * anything else. Second, a hash naming a node that is *not in the graph*
15
+ * must refuse rather than select: a stale link pointing at a deleted note
16
+ * would otherwise land the reader on "Nothing open" with the saved note
17
+ * passed over — parsing without validation would make every dead link hide
18
+ * the workspace's own continuity.
19
+
20
+ * Everything here is a pure function over strings and payloads (§10); the
21
+ * `history.replaceState` call itself is the shell's, because it is an effect.
22
+ */
23
+
24
+ import type { GraphPayload } from "../../shared/wire";
25
+
26
+ /**
27
+ * The note node a hash names, or `null`.
28
+ *
29
+ * Accepts the two spellings a user might paste — `#note/<slug>` and the
30
+ * node id it encodes (`#note:<slug>` is what the tree's own rows use) — and
31
+ * rejects everything else, including the bare `#` a reader gets from
32
+ * clearing the hash by hand. An empty slug would produce a request for
33
+ * `/api/note/`, which means nothing to anyone.
34
+ */
35
+ export function hashSelection(hash: string): string | null {
36
+ if (hash === "" || hash === "#") return null;
37
+ const body = hash.slice(1);
38
+ const match = /^note[/:](.+)$/.exec(body);
39
+ return match === null ? null : `note:${match[1]}`;
40
+ }
41
+
42
+ /**
43
+ * The boot selection: a hash that names a node the payload actually holds,
44
+ * or `null`. Validation is *the* point — see the module header.
45
+ */
46
+ export function deeplinkSelection(hash: string, payload: GraphPayload | null): string | null {
47
+ const wanted = hashSelection(hash);
48
+ if (wanted === null || payload === null) return null;
49
+ return payload.model.nodes.some((node) => node.id === wanted) ? wanted : null;
50
+ }
51
+
52
+ /**
53
+ * The URL fragment for a selection: `#note/<slug>`, or `""` for a cleared
54
+ * selection (an empty string hands `replaceState` the bare URL, clearing the
55
+ * hash). A non-note id formats to `""` too — the address bar describes the
56
+ * *note* being read, nothing else.
57
+ */
58
+ export function formatHash(selectedId: string | null): string {
59
+ const slug = selectedId === null ? null : selectedId.startsWith("note:") ? selectedId.slice("note:".length) : null;
60
+ return slug === null || slug === "" ? "" : `#note/${slug}`;
61
+ }
@@ -52,11 +52,18 @@ export const FOCUSABLE_SELECTOR = [
52
52
  * Where Tab should land next, or `null` to leave the event alone.
53
53
  *
54
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.
55
+ * with an array of numbers if you like. Two refusal cases, by different
56
+ * reasoning:
57
+ *
58
+ * - `count === 0` — nothing to cycle, and holding the user on a dialog that
59
+ * eats Tab and does nothing with it is strictly worse than an untrapped one
60
+ * they can at least escape.
61
+ * - `count === 1` — there *is* something to cycle, and the modal must not
62
+ * let go: Tab returns the user to the single control, matching the WAI-ARIA
63
+ * expectation that focus stays in an open dialog. (This was once `null`
64
+ * with the zero case's reasoning — "one thing, nowhere to go" — but a
65
+ * palette whose only control is its input would then Tab straight out into
66
+ * the covered workspace, typing into a search box the user cannot see.)
60
67
  *
61
68
  * @param count how many focusables the dialog contains
62
69
  * @param at the index of the currently focused one, or `-1` when focus is on
@@ -64,7 +71,8 @@ export const FOCUSABLE_SELECTOR = [
64
71
  * @param backwards Shift+Tab
65
72
  */
66
73
  export function trapTarget(count: number, at: number, backwards: boolean): number | null {
67
- if (count <= 1) return null;
74
+ if (count === 0) return null;
75
+ if (count === 1) return 0;
68
76
  // Focus on the container itself: Tab enters at the top, Shift+Tab at the
69
77
  // bottom. Without this the first Tab out of a freshly-opened dialog would
70
78
  // compute from -1 and land on the second control, skipping the input.
@@ -27,7 +27,7 @@
27
27
  * | `⌘1` `⌘2` `⌘3` | always |
28
28
  * | `⌘E` `⌘S` | always — the editor's two keys (§11 P5.4) |
29
29
  * | `Esc` | an overlay is open, or something is selected. Never otherwise |
30
- * | `/` `g` `?` | **only** when focus is not in a text field and no overlay is open |
30
+ * | `/` `g` `?` `t` | **only** when focus is not in a text field and no overlay is open |
31
31
  *
32
32
  * `⌘S` is claimed **even while typing**, which is the one place this map
33
33
  * deliberately overrides the browser. It has to be: the browser's `⌘S` is
@@ -51,6 +51,7 @@
51
51
  * `⌘K`, which would otherwise re-open the palette on top of itself.
52
52
  */
53
53
 
54
+ import { COLUMNS, columnsAt } from "./layout.model";
54
55
  import type { ColumnId } from "./layout.model";
55
56
  import type { OverlayId } from "./shell.model";
56
57
 
@@ -104,7 +105,9 @@ export type ShellAction =
104
105
  /** `⌘E` — toggle the note column between read and edit (§11 P5.4). */
105
106
  | { readonly type: "toggleEdit" }
106
107
  /** `⌘S` — save the open draft. */
107
- | { readonly type: "saveNote" };
108
+ | { readonly type: "saveNote" }
109
+ /** `t` — cycle the colour theme: system → light → dark → system. */
110
+ | { readonly type: "cycleTheme" };
108
111
 
109
112
  /**
110
113
  * The command-key letters, as data.
@@ -129,6 +132,18 @@ export const COMMAND_KEYS: Readonly<Record<string, ShellAction>> = {
129
132
  */
130
133
  export const COLUMN_DIGITS: Readonly<Record<string, ColumnId>> = { "1": "tree", "2": "note", "3": "graph" };
131
134
 
135
+ /**
136
+ * The columns a breakpoint can put off screen — the graph at `"medium"`, the
137
+ * tree too at `"narrow"`, never the note. The help sheet's derived rows use it
138
+ * to stay honest about `⌘1`/`⌘3`: at 900 px the `⌘3` command is a documented
139
+ * no-op, and a help line that hides that is the same lie the `disabled`
140
+ * search button once was. Derived from `columnsAt`, not listed, so the caveat
141
+ * cannot drift from the breakpoints the sheet is attached to.
142
+ */
143
+ const COLLAPSIBLE_COLUMNS: ReadonlySet<ColumnId> = new Set(
144
+ COLUMNS.filter((c) => columnsAt("medium").includes(c) === false || columnsAt("narrow").includes(c) === false),
145
+ );
146
+
132
147
  /**
133
148
  * Whether a modifier combination counts as "the platform's command key".
134
149
  *
@@ -162,6 +177,7 @@ export const BARE_KEYS: Readonly<Record<string, ShellAction>> = {
162
177
  "/": { type: "filterTree" },
163
178
  g: { type: "fitGraph" },
164
179
  "?": { type: "openHelp" },
180
+ t: { type: "cycleTheme" },
165
181
  };
166
182
 
167
183
  /**
@@ -346,6 +362,8 @@ export interface ShellEffects {
346
362
  toggleEdit(): void;
347
363
  /** `⌘S` — dispatch `save` into the note editor. */
348
364
  saveNote(): void;
365
+ /** `t` — advance the user's theme choice by one step in its cycle. */
366
+ cycleTheme(): void;
349
367
  }
350
368
 
351
369
  /** Perform an action. Total over {@link ShellAction}. */
@@ -371,6 +389,8 @@ export function runShellAction(action: ShellAction, fx: ShellEffects): void {
371
389
  return fx.toggleEdit();
372
390
  case "saveNote":
373
391
  return fx.saveNote();
392
+ case "cycleTheme":
393
+ return fx.cycleTheme();
374
394
  }
375
395
  }
376
396
 
@@ -408,8 +428,12 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
408
428
  title: "Global",
409
429
  entries: [
410
430
  { combo: `${cmd}K`, what: "Search notes and the repository" },
411
- ...Object.entries(COLUMN_DIGITS).map(([digit, column]) => ({ combo: `${cmd}${digit}`, what: `Focus the ${column} column` })),
431
+ ...Object.entries(COLUMN_DIGITS).map(([digit, column]) => ({
432
+ combo: `${cmd}${digit}`,
433
+ what: `Focus the ${column} column${COLLAPSIBLE_COLUMNS.has(column) ? " (when on screen)" : ""}`,
434
+ })),
412
435
  { combo: "?", what: "This help" },
436
+ { combo: "t", what: "Cycle the colour theme (system / light / dark)" },
413
437
  { combo: "Esc", what: "Clear the selection, or close an overlay" },
414
438
  ],
415
439
  },