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.
- package/README.md +40 -16
- package/package.json +2 -2
- package/src/core/frontmatter.ts +3 -3
- package/src/core/graph/model.ts +2 -2
- package/src/core/graph/wikilinks.ts +1 -1
- package/src/core/types.ts +1 -1
- package/src/pi/viewer/tui/surface/detail.ts +1 -1
- package/src/web/client/dist/app.js +239 -243
- package/src/web/client/graph/ForceTuner.tsx +14 -98
- package/src/web/client/graph/Graph.tsx +29 -74
- package/src/web/client/graph/column.model.ts +22 -15
- package/src/web/client/graph/graph.model.ts +31 -63
- package/src/web/client/graph/groups.ts +9 -7
- package/src/web/client/graph/positions.ts +3 -3
- package/src/web/client/graph/renderer.dom.ts +2 -2
- package/src/web/client/graph/renderer.ts +14 -5
- package/src/web/client/graph/tuner.model.ts +6 -180
- package/src/web/client/main.tsx +1 -4
- package/src/web/client/note/Note.tsx +77 -92
- package/src/web/client/note/drafts.ts +172 -0
- package/src/web/client/note/note.model.ts +2 -34
- package/src/web/client/search/SearchPalette.tsx +9 -8
- package/src/web/client/shell/ContextRail.tsx +8 -9
- package/src/web/client/shell/Settings.tsx +71 -0
- package/src/web/client/shell/Shell.tsx +409 -255
- package/src/web/client/shell/StatusBar.tsx +2 -6
- package/src/web/client/shell/focus.model.ts +2 -2
- package/src/web/client/shell/keys.model.ts +40 -39
- package/src/web/client/shell/keys.ts +1 -0
- package/src/web/client/shell/shell.model.ts +32 -128
- package/src/web/client/shell/theme.model.ts +27 -97
- package/src/web/client/shell/theme.ts +32 -238
- package/src/web/client/shell/workspace.css.ts +140 -0
- package/src/web/client/tree/Tree.tsx +43 -14
- package/src/web/client/tree/tree.model.ts +19 -0
- package/src/web/client/workspace.ts +19 -1
- package/src/web/server/backup.ts +59 -0
- package/src/web/server/routes.ts +45 -3
- package/src/web/server/server.ts +5 -0
- package/src/web/server/workspace-state.ts +121 -0
- package/src/web/shared/layout.ts +5 -6
- package/src/web/shared/preferences.ts +76 -0
- package/src/web/shared/themes.ts +92 -0
- package/src/web/shared/workspace.ts +310 -0
- package/src/web/client/selection.storage.ts +0 -69
- package/src/web/client/shell/Columns.tsx +0 -139
- package/src/web/client/shell/Divider.tsx +0 -15
- package/src/web/client/shell/Header.tsx +0 -119
- package/src/web/client/shell/drag.model.ts +0 -35
- 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
|
|
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="
|
|
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
|
|
7
|
-
* overlay, the user is now typing into a
|
|
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
|
-
|
|
94
|
-
|
|
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: "
|
|
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-
|
|
272
|
-
note: ".weave-
|
|
273
|
-
graph: ".weave-
|
|
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-
|
|
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
|
-
|
|
340
|
-
|
|
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
|
|
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 "
|
|
363
|
-
return fx.
|
|
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:
|
|
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: "
|
|
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
|
|
5
|
-
*
|
|
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
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
export const
|
|
27
|
+
export type ColumnId = "tree" | "note" | "graph";
|
|
28
|
+
export const COLUMNS: readonly ColumnId[] = ["tree", "note", "graph"];
|
|
42
29
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
/**
|
|
53
|
-
export function
|
|
54
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
/**
|
|
76
|
-
export function
|
|
77
|
-
return
|
|
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
|
|
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
|
-
|
|
3
|
-
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
+
readonly label: string;
|
|
133
64
|
readonly hint: string;
|
|
134
65
|
}
|
|
135
66
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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:
|
|
145
|
-
|
|
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
|
}
|