pi-weave 0.3.0 → 0.3.2

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 (50) hide show
  1. package/README.md +40 -16
  2. package/package.json +2 -2
  3. package/src/core/frontmatter.ts +3 -3
  4. package/src/core/graph/model.ts +2 -2
  5. package/src/core/graph/wikilinks.ts +1 -1
  6. package/src/core/types.ts +1 -1
  7. package/src/pi/viewer/tui/surface/detail.ts +1 -1
  8. package/src/web/client/dist/app.js +239 -243
  9. package/src/web/client/graph/ForceTuner.tsx +14 -98
  10. package/src/web/client/graph/Graph.tsx +29 -74
  11. package/src/web/client/graph/column.model.ts +22 -15
  12. package/src/web/client/graph/graph.model.ts +31 -63
  13. package/src/web/client/graph/groups.ts +9 -7
  14. package/src/web/client/graph/positions.ts +3 -3
  15. package/src/web/client/graph/renderer.dom.ts +2 -2
  16. package/src/web/client/graph/renderer.ts +14 -5
  17. package/src/web/client/graph/tuner.model.ts +6 -180
  18. package/src/web/client/main.tsx +1 -4
  19. package/src/web/client/note/Note.tsx +77 -92
  20. package/src/web/client/note/drafts.ts +172 -0
  21. package/src/web/client/note/note.model.ts +2 -34
  22. package/src/web/client/search/SearchPalette.tsx +9 -8
  23. package/src/web/client/shell/ContextRail.tsx +8 -9
  24. package/src/web/client/shell/Settings.tsx +71 -0
  25. package/src/web/client/shell/Shell.tsx +409 -255
  26. package/src/web/client/shell/StatusBar.tsx +2 -6
  27. package/src/web/client/shell/focus.model.ts +2 -2
  28. package/src/web/client/shell/keys.model.ts +40 -39
  29. package/src/web/client/shell/keys.ts +1 -0
  30. package/src/web/client/shell/shell.model.ts +32 -128
  31. package/src/web/client/shell/theme.model.ts +27 -97
  32. package/src/web/client/shell/theme.ts +32 -238
  33. package/src/web/client/shell/workspace.css.ts +140 -0
  34. package/src/web/client/tree/Tree.tsx +43 -14
  35. package/src/web/client/tree/tree.model.ts +19 -0
  36. package/src/web/client/workspace.ts +19 -1
  37. package/src/web/server/backup.ts +59 -0
  38. package/src/web/server/routes.ts +45 -3
  39. package/src/web/server/server.ts +5 -0
  40. package/src/web/server/workspace-state.ts +121 -0
  41. package/src/web/shared/layout.ts +5 -6
  42. package/src/web/shared/preferences.ts +76 -0
  43. package/src/web/shared/themes.ts +92 -0
  44. package/src/web/shared/workspace.ts +310 -0
  45. package/src/web/client/selection.storage.ts +0 -69
  46. package/src/web/client/shell/Columns.tsx +0 -139
  47. package/src/web/client/shell/Divider.tsx +0 -15
  48. package/src/web/client/shell/Header.tsx +0 -119
  49. package/src/web/client/shell/drag.model.ts +0 -35
  50. package/src/web/client/shell/layout.model.ts +0 -132
@@ -1,12 +1,11 @@
1
1
  /**
2
2
  * The status bar (weave-workspace §1.2).
3
3
  *
4
- * Working directory, current selection, and data-as-of stamp. The
4
+ * Working directory and current selection. The
5
5
  * model is built by `statusBarModel`; this renders it.
6
6
  */
7
7
 
8
8
  import type { StatusBarModel } from "./shell.model";
9
- import { shortStamp } from "./shell.model";
10
9
 
11
10
  export function StatusBar({ model }: { model: StatusBarModel }) {
12
11
  return (
@@ -14,12 +13,9 @@ export function StatusBar({ model }: { model: StatusBarModel }) {
14
13
  <span class="weave-status-cwd" title={model.cwd}>
15
14
  {model.cwd}
16
15
  </span>
17
- <span class="weave-status-sel" title="the §1.3 context bus — one signal, every column">
16
+ <span class="weave-status-sel" title="Current item">
18
17
  {model.selection}
19
18
  </span>
20
- <span class="weave-status-stamp" title="data as of">
21
- {shortStamp(model.stamp)}
22
- </span>
23
19
  </footer>
24
20
  );
25
21
  }
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * P4's exit criterion is "the whole workspace is drivable without a mouse",
5
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
6
+ * available: Tab from the last control lands on the workspace behind the
7
+ * overlay, the user is now typing into a field they cannot see, and
8
8
  * `Esc` goes to whatever they landed on. So the palette and the help overlay
9
9
  * both trap, and both restore.
10
10
  *
@@ -84,14 +84,16 @@ export interface KeyContext {
84
84
  export type ShellAction =
85
85
  | { readonly type: "openSearch" }
86
86
  | { readonly type: "openHelp" }
87
+ | { readonly type: "openSettings" }
87
88
  | { readonly type: "closeOverlay" }
88
89
  | { readonly type: "focusColumn"; readonly column: ColumnId }
89
90
  /** Focus the tree's filter box — `/`. */
90
91
  | { readonly type: "filterTree" }
91
92
  | { readonly type: "fitGraph" }
92
93
  | { readonly type: "clearSelection" }
93
- /** `t` — cycle the colour theme: system → light → dark → system. */
94
- | { readonly type: "cycleTheme" };
94
+ | { readonly type: "closeTab" }
95
+ /** `t` — switch between the selected light and dark themes. */
96
+ | { readonly type: "toggleTheme" };
95
97
 
96
98
  /**
97
99
  * The command-key letters, as data.
@@ -101,6 +103,7 @@ export type ShellAction =
101
103
  */
102
104
  export const COMMAND_KEYS: Readonly<Record<string, ShellAction>> = {
103
105
  k: { type: "openSearch" },
106
+ ",": { type: "openSettings" },
104
107
  };
105
108
 
106
109
  /**
@@ -112,16 +115,6 @@ export const COMMAND_KEYS: Readonly<Record<string, ShellAction>> = {
112
115
  */
113
116
  export const COLUMN_DIGITS: Readonly<Record<string, ColumnId>> = { "1": "tree", "2": "note", "3": "graph" };
114
117
 
115
- /**
116
- * The columns a breakpoint can put off screen — the graph at `"medium"`, the
117
- * tree too at `"narrow"`, never the note. The help sheet's derived rows use it
118
- * to stay honest about `⌘1`/`⌘3`: at 900 px the `⌘3` command is a documented
119
- * no-op, and a help line that hides that is the same lie the `disabled`
120
- * search button once was. Derived from `columnsAt`, not listed, so the caveat
121
- * cannot drift from the breakpoints the sheet is attached to.
122
- */
123
- const COLLAPSIBLE_COLUMNS: ReadonlySet<ColumnId> = new Set(["tree", "graph"]);
124
-
125
118
  /**
126
119
  * Whether a modifier combination counts as "the platform's command key".
127
120
  *
@@ -155,7 +148,7 @@ export const BARE_KEYS: Readonly<Record<string, ShellAction>> = {
155
148
  "/": { type: "filterTree" },
156
149
  g: { type: "fitGraph" },
157
150
  "?": { type: "openHelp" },
158
- t: { type: "cycleTheme" },
151
+ t: { type: "toggleTheme" },
159
152
  };
160
153
 
161
154
  /**
@@ -168,6 +161,8 @@ export const BARE_KEYS: Readonly<Record<string, ShellAction>> = {
168
161
  export function shellKey(event: KeyDescriptor, ctx: KeyContext): ShellAction | null {
169
162
  if (ctx.overlay !== null) return event.key === "Escape" ? { type: "closeOverlay" } : null;
170
163
 
164
+ if (event.alt && !event.meta && !event.ctrl && !event.shift && event.key.toLowerCase() === "w") return { type: "closeTab" };
165
+
171
166
  if (isCommand(event)) {
172
167
  // Lower-cased because ⌘K with caps lock on reports `"K"`, and a shortcut
173
168
  // that stops working under caps lock is a bug report nobody can reproduce.
@@ -231,6 +226,8 @@ export interface KeyTarget {
231
226
  * use, for the same reason: there is no DOM test environment (§10).
232
227
  */
233
228
  export interface KeyboardEventLike {
229
+ readonly code?: string;
230
+ readonly defaultPrevented: boolean;
234
231
  readonly key: string;
235
232
  readonly ctrlKey: boolean;
236
233
  readonly metaKey: boolean;
@@ -243,7 +240,7 @@ export interface KeyboardEventLike {
243
240
  /** Reduce a platform event to a {@link KeyDescriptor}. */
244
241
  export function describeKey(event: KeyboardEventLike): KeyDescriptor {
245
242
  return {
246
- key: event.key,
243
+ key: event.altKey && event.code === "KeyW" ? "w" : event.key,
247
244
  ctrl: event.ctrlKey,
248
245
  meta: event.metaKey,
249
246
  shift: event.shiftKey,
@@ -254,27 +251,15 @@ export function describeKey(event: KeyboardEventLike): KeyDescriptor {
254
251
 
255
252
  // --- where an action points ---------------------------------------------------------
256
253
 
257
- /**
258
- * `⌘1/2/3` → the element to focus in that column.
259
- *
260
- * Selectors rather than refs, and the reason is the breakpoint: below 1100 px
261
- * the graph column does not exist and below 800 px the tree does not either,
262
- * so a ref would be `null` for a column that is legitimately absent and the
263
- * shell would need a branch per column to cope. A `querySelector` that finds
264
- * nothing is already the same answer, expressed once.
265
- *
266
- * Each target is the column's *content*, not its `<section>`: focusing the
267
- * section would put the ring around the whole pane and leave the arrow keys
268
- * pointing at nothing.
269
- */
254
+ /** Content focus targets; the shell opens hidden sidebars/tabs before focusing. */
270
255
  export const COLUMN_FOCUS_SELECTORS: Readonly<Record<ColumnId, string>> = {
271
- tree: ".weave-col-tree .weave-rows",
272
- note: ".weave-col-note .weave-note-body",
273
- graph: ".weave-col-graph .weave-graph-canvas",
256
+ tree: ".weave-sidebar-notes .weave-rows",
257
+ note: ".weave-pane-active .weave-note-body",
258
+ graph: ".weave-graph-host .weave-graph-canvas",
274
259
  };
275
260
 
276
261
  /** `/` → the tree's filter box. */
277
- export const TREE_FILTER_SELECTOR = ".weave-col-tree .weave-filter";
262
+ export const TREE_FILTER_SELECTOR = ".weave-sidebar-notes .weave-filter";
278
263
 
279
264
  /** The slice of an element {@link focusable} produces. */
280
265
  export interface Focusable {
@@ -336,8 +321,9 @@ export interface ShellEffects {
336
321
  fitGraph(): void;
337
322
  /** Write `null` to §1.3's `selectedId`. */
338
323
  clearSelection(): void;
339
- /** `t` — advance the user's theme choice by one step in its cycle. */
340
- cycleTheme(): void;
324
+ closeTab(): void;
325
+ /** `t` — switch between the user's selected light and dark themes. */
326
+ toggleTheme(): void;
341
327
  }
342
328
 
343
329
  /** Perform an action. Total over {@link ShellAction}. */
@@ -345,13 +331,14 @@ export function runShellAction(action: ShellAction, fx: ShellEffects): void {
345
331
  switch (action.type) {
346
332
  case "openSearch":
347
333
  return fx.setOverlay("search");
334
+ case "openSettings":
335
+ return fx.setOverlay("settings");
348
336
  case "openHelp":
349
337
  return fx.setOverlay("help");
350
338
  case "closeOverlay":
351
339
  return fx.setOverlay(null);
352
340
  case "focusColumn":
353
- // The boolean is deliberately dropped: a column that is not on screen
354
- // at this breakpoint is a miss with nothing to report to.
341
+ // The shell reveals the target before focusing it.
355
342
  return void fx.focusSelector(COLUMN_FOCUS_SELECTORS[action.column]);
356
343
  case "filterTree":
357
344
  return void fx.focusSelector(TREE_FILTER_SELECTOR);
@@ -359,8 +346,10 @@ export function runShellAction(action: ShellAction, fx: ShellEffects): void {
359
346
  return fx.fitGraph();
360
347
  case "clearSelection":
361
348
  return fx.clearSelection();
362
- case "cycleTheme":
363
- return fx.cycleTheme();
349
+ case "closeTab":
350
+ return fx.closeTab();
351
+ case "toggleTheme":
352
+ return fx.toggleTheme();
364
353
  }
365
354
  }
366
355
 
@@ -397,13 +386,15 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
397
386
  {
398
387
  title: "Global",
399
388
  entries: [
389
+ { combo: "⌥W / Alt W", what: "Close the active tab" },
390
+ { combo: `${cmd},`, what: "Open settings" },
400
391
  { combo: `${cmd}K`, what: "Search notes and the repository" },
401
392
  ...Object.entries(COLUMN_DIGITS).map(([digit, column]) => ({
402
393
  combo: `${cmd}${digit}`,
403
- what: `Focus the ${column} column${COLLAPSIBLE_COLUMNS.has(column) ? " (when on screen)" : ""}`,
394
+ what: column === "tree" ? "Open and focus the notes sidebar" : column === "graph" ? "Open and focus Graph view" : "Focus the note",
404
395
  })),
405
396
  { combo: "?", what: "This help" },
406
- { combo: "t", what: "Cycle the colour theme (system / light / dark)" },
397
+ { combo: "t", what: "Switch between selected light and dark themes" },
407
398
  { combo: "Esc", what: "Clear the selection, or close an overlay" },
408
399
  ],
409
400
  },
@@ -418,6 +409,15 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
418
409
  { combo: "Home / End", what: "First / last row" },
419
410
  ],
420
411
  },
412
+ {
413
+ title: "Tabs and panes",
414
+ entries: [
415
+ { combo: "Alt ← / →", what: "Reorder the focused tab" },
416
+ { combo: "← / → / Home / End", what: "Select a tab when the tab strip is focused" },
417
+ { combo: `${cmd}click`, what: "Open a note or search result in a new tab" },
418
+ { combo: "Pane options (···)", what: "Split right or down, move a tab, or close a pane" },
419
+ ],
420
+ },
421
421
  {
422
422
  title: "Graph",
423
423
  entries: [{ combo: "g", what: "Fit the whole graph" }],
@@ -427,6 +427,7 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
427
427
  entries: [
428
428
  { combo: "↑ / ↓", what: "Move through results" },
429
429
  { combo: "↵", what: "Open the highlighted result" },
430
+ { combo: `${cmd}↵`, what: "Open the highlighted result in a new tab" },
430
431
  { combo: "Esc", what: "Close the palette" },
431
432
  ],
432
433
  },
@@ -49,6 +49,7 @@ export interface KeyOptions {
49
49
  */
50
50
  export function watchKeys(host: KeyHost, opts: KeyOptions): () => void {
51
51
  const listener = (event: KeyboardEventLike): void => {
52
+ if (event.defaultPrevented) return;
52
53
  const action = shellKey(describeKey(event), opts.context());
53
54
  if (action === null) return;
54
55
  event.preventDefault();
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Everything the shell *decides*, as pure functions (weave-workspace §1.2).
3
3
  *
4
- * The header's status summary, the per-column empty-state copy and the status
5
- * bar's text all live here rather than inside the components that render them.
4
+ * The Recent list, per-column empty-state copy and footer status text live
5
+ * here rather than inside the components that render them.
6
6
  * That is not a stylistic
7
7
  * preference: §10 forbids adding a DOM test environment, so a conditional
8
8
  * inside a `.tsx` is a conditional that can never be covered, and §14 lists
@@ -14,67 +14,43 @@
14
14
  * types, no `node:*`, no `src/core`.
15
15
  */
16
16
 
17
- import type { GraphPayload, WireNodeKind, WireStalenessState } from "../../shared/wire";
18
- import { COLUMNS, type ColumnId } from "./layout.model";
19
- export { COLUMNS };
20
- export type { ColumnId };
17
+ import type { GraphPayload, WireNodeKind } from "../../shared/wire";
18
+ import { kindIcon } from "../tree/tree.model";
19
+ import type { IconName } from "./icons.model";
20
+ export const NOTE_DRAG_TYPE = "application/x-weave-note";
21
21
 
22
- // --- the header summary --------------------------------------------------------
23
-
24
- /**
25
- * The `vault:34 · repo:fresh · 127 nodes` readout from the §1.2 sketch.
26
- *
27
- * Kept as three fields rather than one pre-joined string so the component can
28
- * put a separator between them without this module owning a `·`, and so a
29
- * test asserts the numbers rather than a formatting decision.
30
- */
31
- export interface HeaderSummary {
32
- /** Note nodes in the vault. */
33
- readonly notes: number;
34
- /** Repository index freshness, or `null` when the repo is unindexed. */
35
- readonly repo: WireStalenessState | null;
36
- /** Every node, of every kind. */
37
- readonly nodes: number;
22
+ /** Pane drops open only note IDs that still exist in this workspace. */
23
+ export function noteDropId(graph: GraphPayload | null, id: string): string | null {
24
+ return id.startsWith("note:") && graph?.model.nodes.some((node) => node.id === id && node.kind === "note") ? id : null;
38
25
  }
39
26
 
40
- /** The summary before the first graph arrives. */
41
- export const EMPTY_SUMMARY: HeaderSummary = { notes: 0, repo: null, nodes: 0 };
27
+ export type ColumnId = "tree" | "note" | "graph";
28
+ export const COLUMNS: readonly ColumnId[] = ["tree", "note", "graph"];
42
29
 
43
- /**
44
- * Which node kinds count as "vault" for the header.
45
- *
46
- * Only `note`. The `vault` node itself is the container, and counting it
47
- * would make an empty vault read `vault:1` — a number that is technically
48
- * defensible and would still be read as "there is one note in there".
49
- */
50
- const VAULT_KINDS: readonly WireNodeKind[] = ["note"];
30
+ /** Most recent selections in this window, independent of whether their tabs stay open. */
31
+ export function recordVisit(previous: readonly string[], id: string | null): readonly string[] {
32
+ return id === null ? previous : [id, ...previous.filter((item) => item !== id)].slice(0, 100);
33
+ }
51
34
 
52
- /** Derive the header counts from a payload. */
53
- export function summarize(payload: GraphPayload | null): HeaderSummary {
54
- if (payload === null) return EMPTY_SUMMARY;
55
- const nodes = payload.model.nodes;
56
- return {
57
- notes: nodes.filter((node) => VAULT_KINDS.includes(node.kind)).length,
58
- repo: payload.model.staleness?.state ?? null,
59
- nodes: nodes.length,
60
- };
35
+ /** A selected graph node opens only after its preview has already been shown. */
36
+ export function graphClickOpensTab(previewId: string | null, id: string | null): id is string {
37
+ return id !== null && id === previewId;
61
38
  }
62
39
 
63
- /**
64
- * The repo segment's text.
65
- *
66
- * `"unindexed"` rather than `"missing"` for a null or `missing` staleness:
67
- * "missing" reads as an error, and a repository that has simply never been
68
- * scanned is the ordinary first-run state, not a fault. The word the user
69
- * needs is the one that implies an action they can take.
70
- */
71
- export function repoLabel(repo: WireStalenessState | null): string {
72
- return repo === null || repo === "missing" ? "unindexed" : repo;
40
+ export interface RecentEntry {
41
+ readonly id: string;
42
+ readonly label: string;
43
+ readonly icon: IconName;
44
+ readonly selected: boolean;
73
45
  }
74
46
 
75
- /** `vault:34 · repo:fresh · 127 nodes`, as the pieces to join. */
76
- export function summaryParts(summary: HeaderSummary): readonly string[] {
77
- return [`vault:${summary.notes}`, `repo:${repoLabel(summary.repo)}`, `${summary.nodes} nodes`];
47
+ /** Present a frozen visit order without changing it when selection moves. */
48
+ export function recentEntries(ids: readonly string[], graph: GraphPayload | null, selectedId: string | null): RecentEntry[] {
49
+ return ids.map((id) => {
50
+ const node = graph?.model.nodes.find((entry) => entry.id === id);
51
+ const kind: WireNodeKind = node?.kind ?? (id.startsWith("vfolder:") ? "module" : id.startsWith("artifact:") ? "file" : "note");
52
+ return { id, label: node?.label ?? id.replace(/^[^:]+:/, ""), icon: kindIcon(kind), selected: id === selectedId };
53
+ });
78
54
  }
79
55
 
80
56
  // --- empty states ------------------------------------------------------------------
@@ -84,23 +60,6 @@ export interface EmptyStateCopy {
84
60
  readonly title: string;
85
61
  }
86
62
 
87
- const EMPTY_STATES: Readonly<Record<ColumnId, EmptyStateCopy>> = {
88
- tree: {
89
- title: "Tree",
90
- },
91
- note: {
92
- title: "Note",
93
- },
94
- graph: {
95
- title: "Graph",
96
- },
97
- };
98
-
99
- /** The empty-state copy for a column. */
100
- export function emptyStateFor(column: ColumnId): EmptyStateCopy {
101
- return EMPTY_STATES[column];
102
- }
103
-
104
63
  /** The context rail's heading and `aria-label`. */
105
64
  export const CONTEXT_EMPTY: EmptyStateCopy = {
106
65
  title: "Context",
@@ -120,8 +79,6 @@ export const CONTEXT_EMPTY: EmptyStateCopy = {
120
79
  export interface StatusBarModel {
121
80
  readonly cwd: string;
122
81
  readonly selection: string;
123
- /** `null` before the first successful graph fetch. */
124
- readonly stamp: string | null;
125
82
  }
126
83
 
127
84
  /** The `—` shown where a value is genuinely absent, not zero. */
@@ -131,49 +88,13 @@ export const NO_VALUE = "—";
131
88
  export function statusBarModel(
132
89
  cwd: string,
133
90
  selectedId: string | null,
134
- stamp: string | null,
135
91
  ): StatusBarModel {
136
92
  return {
137
93
  cwd: cwd === "" ? NO_VALUE : cwd,
138
94
  selection: selectedId ?? "nothing selected",
139
- stamp,
140
95
  };
141
96
  }
142
97
 
143
- /**
144
- * A `generatedAt` stamp, shortened for the status bar.
145
- *
146
- * Just the `HH:MM:SS`, because the date is almost always today and the whole
147
- * ISO string is 24 characters of mostly-constant noise in a bar that has
148
- * three other things to say. A stamp that does not look like an ISO timestamp
149
- * is passed through untouched rather than sliced blindly — the server derives
150
- * it from input timestamps and is free to change that derivation, and a
151
- * mangled substring would be a worse lie than the full value.
152
- */
153
- export function shortStamp(stamp: string | null): string {
154
- if (stamp === null) return NO_VALUE;
155
- const match = /T(\d{2}:\d{2}:\d{2})/.exec(stamp);
156
- return match?.[1] ?? stamp;
157
- }
158
-
159
- // --- the refresh button ------------------------------------------------------------
160
-
161
- /**
162
- * The refresh control's glyph, as SVG path data.
163
- *
164
- * A drawn icon replaces the `⟳` text character for the same reason §P6.4
165
- * retires the tree's glyph soup: a text arrow is whatever the platform's
166
- * fallback font draws it as, while these two strokes are the brand's own
167
- * weight everywhere. Stored as pure data here — §10 — so `Header.tsx` stays
168
- * props-in/JSX-out and the shape is testable without a DOM. 24×24 viewBox,
169
- * stroked, `currentColor`: the header recolours it on hover like any other
170
- * glyph.
171
- */
172
- export const REFRESH_ICON_PATHS: readonly string[] = [
173
- "M21 12a9 9 0 1 1-2.64-6.36",
174
- "M21 3v6h-6",
175
- ];
176
-
177
98
  /**
178
99
  * How often the shell re-renders on its own, in ms.
179
100
  *
@@ -189,29 +110,12 @@ export const TICK_MS = 60_000;
189
110
 
190
111
  // --- the search affordance ---------------------------------------------------------
191
112
 
192
- /**
193
- * The `⌘K` control.
194
- *
195
- * Rendered **disabled** through P1–P3, with a title saying search arrived in
196
- * P4 — a deliberate choice over omitting it: the keyboard hint taught the
197
- * shortcut that would exist, and a `disabled` control explaining itself is
198
- * honest in a way that a working-looking box that does nothing is not.
199
- *
200
- * P4 made it live, and it is now a *button* rather than an `<input>`. The
201
- * palette owns the only text field, so a second one in the header would be
202
- * two places to type a query into and one of them would be a lie — clicking
203
- * it opens the overlay and whatever was typed into the header box would be
204
- * discarded. A button that looks like a search field and opens the real one
205
- * is the affordance the §1.2 sketch actually describes.
206
- */
207
- export const SEARCH_PLACEHOLDER = "Search…";
208
-
209
113
  /** The shortcut hint. `⌘K` on Apple platforms, `Ctrl K` elsewhere. */
210
114
  export function searchShortcut(isApple: boolean): string {
211
115
  return isApple ? "⌘K" : "Ctrl K";
212
116
  }
213
117
 
214
- /** The `title=` on the live search button. Names the key that also opens it. */
118
+ /** The search action's tooltip, including the key that also opens it. */
215
119
  export function searchHint(shortcut: string): string {
216
120
  return `Search notes and repository (${shortcut})`;
217
121
  }
@@ -227,7 +131,7 @@ export function searchHint(shortcut: string): string {
227
131
  * other. Making that unrepresentable costs nothing here and removes a whole
228
132
  * class of bug from the keyboard layer, which is the thing that opens them.
229
133
  */
230
- export type OverlayId = "search" | "help" | null;
134
+ export type OverlayId = "search" | "help" | "settings" | null;
231
135
 
232
136
  /**
233
137
  * Whether a platform string looks like an Apple one.
@@ -1,64 +1,18 @@
1
- /**
2
- * The theme *choice* — what the user picked, and what that makes the screen
3
- * do (weave-workspace §1.2).
4
- *
5
- * The stylesheet decides what the palettes are (`theme.ts`); this module
6
- * decides *which one is showing*. The two are separate for §10's reason: the
7
- * choice is state a user controls, so every decision about it has to be a
8
- * testable pure function, while the `.tsx` that applies the attribute is
9
- * three lines no test can reach.
10
- *
11
- * ## Why tri-state
12
- *
13
- * "Dark" and "light" alone are not enough: stripping a user of "follow my OS"
14
- * is a regression the moment anyone prefers the *default* behaviour rather
15
- * than either specific scheme. The cycle is `system → light → dark → system`
16
- * so the control is one button, never a menu — a popover in a 34 px header is
17
- * a second focusable thing to reach, and the setting is not worth one.
18
- *
19
- * ## The attribute, not a class, not inline styles
20
- *
21
- * The sheet's light branch moves from the bare `prefers-color-scheme` media
22
- * query to a *pair* of selectors: `:root[data-weave-theme="light"]` (the
23
- * override) plus the media query narrowed by
24
- * `:root:not([data-weave-theme="dark"])` (system default). The attribute is
25
- * written on `<html>` because CSP `style-src 'nonce-…'` blocks inline
26
- * `style=""` attributes outright — the same verified reasoning that pushes
27
- * `cssvars.ts` through the CSSOM — while `data-*` attribute writes are not
28
- * style at all and need no exception. In `system` mode the attribute is
29
- * **cleared**, not set to a third value, so the media query keeps answering
30
- * a live OS flip without the client listening.
31
- *
32
- * ## Tier rules (§2)
33
- *
34
- * `src/web/client/**`. Storage arrives as the two-method port
35
- * `selection.storage.ts` uses, so a fake is an object literal and quota
36
- * failures stay the caller's problem — a theme the user could not persist is
37
- * the same cosmetic cost as a selection.
38
- */
39
-
1
+ /** Persistent appearance mode and switching between the selected light/dark palettes. */
2
+ import { DEFAULT_PREFERENCES, type Preferences } from "../../shared/preferences";
3
+ import { THEMES } from "../../shared/themes";
4
+ import type { PaletteChoice, ThemeChoice } from "../../shared/themes";
40
5
  import type { ColorScheme } from "../graph/graph.model";
6
+ import { THEME_CHOICES, isThemeChoice } from "../../shared/themes";
7
+ export { THEME_CHOICES, isThemeChoice };
8
+ export type { ThemeChoice };
41
9
 
42
- /** What the user picked, as the button cycles it. */
43
- export type ThemeChoice = "system" | "light" | "dark";
44
-
45
- /** The choices in cycle order. */
46
- export const THEME_CHOICES: readonly ThemeChoice[] = ["system", "light", "dark"];
47
-
48
- /** The `localStorage` key. Namespaced and versioned. */
49
10
  export const THEME_STORAGE_KEY = "pi-weave.theme.v1";
50
-
51
- /** The slice of `Storage` this module needs. */
52
11
  export interface ThemeStorage {
53
12
  getItem(key: string): string | null;
54
13
  setItem(key: string, value: string): void;
55
14
  }
56
15
 
57
- /** Structural guard, mirroring `readBootstrap`'s. */
58
- export function isThemeChoice(value: unknown): value is ThemeChoice {
59
- return value === "system" || value === "light" || value === "dark";
60
- }
61
-
62
16
  /**
63
17
  * The stored choice, or `null`.
64
18
  *
@@ -76,7 +30,7 @@ export function loadTheme(storage: ThemeStorage): ThemeChoice | null {
76
30
  }
77
31
  }
78
32
 
79
- /** Persist the choice. Reports failure as `false`, like `saveSelection`. */
33
+ /** Persist the choice. Reports failure as `false`. */
80
34
  export function saveTheme(storage: ThemeStorage, choice: ThemeChoice): boolean {
81
35
  try {
82
36
  storage.setItem(THEME_STORAGE_KEY, choice);
@@ -86,62 +40,38 @@ export function saveTheme(storage: ThemeStorage, choice: ThemeChoice): boolean {
86
40
  }
87
41
  }
88
42
 
89
- /** The next choice in the cycle {@link THEME_CHOICES}. */
90
- export function cycleTheme(choice: ThemeChoice): ThemeChoice {
91
- const index = THEME_CHOICES.indexOf(choice);
92
- // An out-of-table choice cannot exist (the type says so), but a miss
93
- // here must not be an `undefined` into the UI: wrap to the head.
94
- return THEME_CHOICES[(index + 1) % THEME_CHOICES.length] ?? "system";
43
+ type ThemePair = Pick<Preferences, "lightTheme" | "darkTheme">;
44
+
45
+ /** Toggle to the selected palette for the opposite effective appearance. */
46
+ export function toggleTheme(choice: ThemeChoice, systemScheme: ColorScheme, pair: ThemePair = DEFAULT_PREFERENCES): PaletteChoice {
47
+ const current = THEMES[effectiveScheme(choice, systemScheme, pair)].scheme;
48
+ return current === "light" ? pair.darkTheme : pair.lightTheme;
95
49
  }
96
50
 
97
- /**
98
- * The scheme actually drawing right now.
99
- *
100
- * This is the *whole* resolution: the sheet owns what colours a scheme is,
101
- * `scheme.ts` owns reading the OS once, and this owns the decision — so the
102
- * graph column, whose WebGL palette cannot read CSS custom properties, takes
103
- * the same answer the stylesheet's attribute takes.
104
- */
105
- export function effectiveScheme(choice: ThemeChoice, systemScheme: ColorScheme): ColorScheme {
106
- return choice === "system" ? systemScheme : choice;
51
+ /** System mode uses the user's palette for the current device appearance. */
52
+ export function effectiveScheme(choice: ThemeChoice, systemScheme: ColorScheme, pair: ThemePair = DEFAULT_PREFERENCES): PaletteChoice {
53
+ return choice === "system" ? (systemScheme === "light" ? pair.lightTheme : pair.darkTheme) : choice;
107
54
  }
108
55
 
109
- /**
110
- * The `data-weave-theme` attribute value, or `null` to clear it.
111
- *
112
- * `null` is not a third value on purpose: in `system` mode the media query
113
- * alone must govern, and any attribute would override the OS. See the module
114
- * header's "attribute, not class" note.
115
- */
116
- export function themeAttr(choice: ThemeChoice): "light" | "dark" | null {
56
+ /** System mode clears the attribute so the media query governs. */
57
+ export function themeAttr(choice: ThemeChoice): PaletteChoice | null {
117
58
  return choice === "system" ? null : choice;
118
59
  }
119
60
 
120
- // --- the control's view ------------------------------------------------------------
121
-
122
- /**
123
- * The header button's face.
124
- *
125
- * The glyphs are the provenance language the tree already teaches
126
- * (`tree.model.ts`): filled, half and hollow of one shape. Filled `●` is
127
- * dark, hollower `◐` is system — half machine, half choice — and `○` is
128
- * light. Three glyphs, one family, no icon font, no `url()`.
129
- */
130
61
  export interface ThemeButtonView {
131
62
  readonly glyph: "●" | "◐" | "○";
132
- /** The `title=`/`aria-label`. Names where it is *and* what clicking does. */
63
+ readonly label: string;
133
64
  readonly hint: string;
134
65
  }
135
66
 
136
- const THEME_GLYPHS: Readonly<Record<ThemeChoice, ThemeButtonView["glyph"]>> = {
137
- dark: "●",
138
- system: "◐",
139
- light: "○",
140
- };
67
+ function themeName(choice: ThemeChoice): string {
68
+ return choice === "system" ? "System" : THEMES[choice].name;
69
+ }
141
70
 
142
- export function themeButton(choice: ThemeChoice): ThemeButtonView {
71
+ export function themeButton(choice: ThemeChoice, systemScheme: ColorScheme = "dark", pair: ThemePair = DEFAULT_PREFERENCES): ThemeButtonView {
143
72
  return {
144
- glyph: THEME_GLYPHS[choice],
145
- hint: `Colour theme: ${choice} — click for ${cycleTheme(choice)}`,
73
+ glyph: choice === "system" ? "◐" : THEMES[choice].scheme === "light" ? "○" : "●",
74
+ label: themeName(choice),
75
+ hint: `Colour theme: ${themeName(choice)} — click for ${themeName(toggleTheme(choice, systemScheme, pair))}`,
146
76
  };
147
77
  }