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,119 +0,0 @@
1
- /**
2
- * The header bar (weave-workspace §1.2).
3
- *
4
- * Title, the `⌘K` search button, the `vault:N · repo:… · N nodes` summary,
5
- * the refresh button. Every string comes from `shell.model.ts`.
6
- *
7
- * The search control was `disabled` through P1–P3 because search was P4 and
8
- * an affordance that looks live but is not would be the dishonest option. P4
9
- * landed, so it is live — and it is a `<button>`, not an `<input>`: the
10
- * palette owns the only text field in the workspace, and a header box you
11
- * could type into whose contents were then thrown away when the overlay
12
- * opened would be a worse lie than the disabled version it replaced.
13
- */
14
-
15
- import { useState } from "preact/hooks";
16
- import { LOGO_MARK_B64, LOGO_MARK_MIME } from "../../shared/logo";
17
- import type { ThemeButtonView } from "./theme.model";
18
- import type { HeaderSummary } from "./shell.model";
19
- import { REFRESH_ICON_PATHS, SEARCH_PLACEHOLDER, searchHint, summaryParts } from "./shell.model";
20
-
21
- export interface HeaderProps {
22
- summary: HeaderSummary;
23
- shortcut: string;
24
- onRefresh: () => void;
25
- /** Opens the ⌘K palette. The same action the global key performs. */
26
- onSearch: () => void;
27
- /** The theme control's face (glyph + hint), from `shell.model.ts`'s friend in `theme.model.ts`. */
28
- theme: ThemeButtonView;
29
- /** Advance the theme choice one step in its cycle. The same action the `t` key performs. */
30
- onTheme: () => void;
31
- }
32
-
33
- /**
34
- * The refresh control, drawn rather than typed.
35
- *
36
- * The `⟳` it replaces was a text character, so its weight and shape were
37
- * whatever the platform's font felt like — the one glyph in the header that
38
- * the theme did not own. These two strokes (`REFRESH_ICON_PATHS`, in
39
- * `shell.model.ts`) are stroked in `currentColor`, so hover recolours them
40
- * like any other text glyph.
41
- *
42
- * The spin is one 600 ms turn on click, restarted only by the next click —
43
- * it says "the request left", which is the truth, and stops rather than
44
- * pretending to know when the refetch lands (the refetch is fire-and-forget
45
- * by §7's register, and a spinner that waits for a signal a 304 may never
46
- * fire would hang forever). Reduced-motion users get the static glyph back,
47
- * as everywhere else in the sheet.
48
- */
49
- function RefreshButton({ onRefresh }: { onRefresh: () => void }) {
50
- const [spinning, setSpinning] = useState(false);
51
- return (
52
- <button
53
- type="button"
54
- class="weave-refresh"
55
- onClick={() => {
56
- onRefresh();
57
- setSpinning(true);
58
- }}
59
- onAnimationEnd={() => setSpinning(false)}
60
- title="Refetch everything"
61
- aria-busy={spinning ? "true" : undefined}
62
- >
63
- <svg
64
- class={spinning ? "weave-refresh-glyph weave-refresh-spinning" : "weave-refresh-glyph"}
65
- viewBox="0 0 24 24"
66
- width={14}
67
- height={14}
68
- fill="none"
69
- stroke="currentColor"
70
- stroke-width={2}
71
- stroke-linecap="round"
72
- stroke-linejoin="round"
73
- aria-hidden="true"
74
- >
75
- {REFRESH_ICON_PATHS.map((d) => (
76
- <path key={d} d={d} />
77
- ))}
78
- </svg>
79
- </button>
80
- );
81
- }
82
-
83
- export function Header(props: HeaderProps) {
84
- return (
85
- <header class="weave-header">
86
- <span class="weave-brand">
87
- <img
88
- class="weave-brand-mark"
89
- src={`data:${LOGO_MARK_MIME};base64,${LOGO_MARK_B64}`}
90
- alt=""
91
- width={18}
92
- height={18}
93
- />
94
- pi-weave
95
- </span>
96
- <button type="button" class="weave-search" title={searchHint(props.shortcut)} onClick={props.onSearch}>
97
- <span class="weave-search-text">{SEARCH_PLACEHOLDER}</span>
98
- <kbd>{props.shortcut}</kbd>
99
- </button>
100
- <span class="weave-summary">
101
- {summaryParts(props.summary).map((part) => (
102
- <span key={part} class="weave-summary-part">
103
- {part}
104
- </span>
105
- ))}
106
- </span>
107
- <button
108
- type="button"
109
- class="weave-theme"
110
- onClick={props.onTheme}
111
- title={props.theme.hint}
112
- aria-label={props.theme.hint}
113
- >
114
- {props.theme.glyph}
115
- </button>
116
- <RefreshButton onRefresh={props.onRefresh} />
117
- </header>
118
- );
119
- }
@@ -1,35 +0,0 @@
1
- import { COLUMNS, resizeAt } from "./layout.model";
2
- import type { DividerId, LayoutState } from "./layout.model";
3
-
4
- export interface DragState { readonly divider: DividerId; readonly origin: number; readonly base: LayoutState }
5
- export function beginDrag(divider: DividerId, origin: number, base: LayoutState): DragState { return { divider, origin, base }; }
6
- export function dragTo(drag: DragState, clientX: number, available: number): LayoutState { return resizeAt(drag.base, drag.divider, clientX - drag.origin, available); }
7
- export function dragChanged(drag: DragState, final: LayoutState): boolean {
8
- return COLUMNS.some((id) => drag.base.fractions[id] !== final.fractions[id]);
9
- }
10
- export const NUDGE_PX = 24;
11
- export function nudgeFor(key: string): number { return key === "ArrowLeft" ? -NUDGE_PX : key === "ArrowRight" ? NUDGE_PX : 0; }
12
-
13
- export function dividerHandlers(host: {
14
- layout(): LayoutState;
15
- width(): number;
16
- setLayout(next: LayoutState): void;
17
- persist(layout: LayoutState): void;
18
- }) {
19
- let active: DragState | null = null;
20
- return {
21
- onDown(divider: DividerId, clientX: number) { active = beginDrag(divider, clientX, host.layout()); },
22
- onMove(clientX: number) { if (active !== null) host.setLayout(dragTo(active, clientX, host.width())); },
23
- onUp() {
24
- const drag = active;
25
- active = null;
26
- if (drag !== null && dragChanged(drag, host.layout())) host.persist(host.layout());
27
- },
28
- onKey(divider: DividerId, key: string) {
29
- const next = resizeAt(host.layout(), divider, nudgeFor(key), host.width());
30
- if (next === host.layout()) return;
31
- host.setLayout(next);
32
- host.persist(next);
33
- },
34
- };
35
- }
@@ -1,132 +0,0 @@
1
- /** Pure model for the resizable workspace columns. */
2
-
3
- export type ColumnId = "tree" | "note" | "graph";
4
- export const COLUMNS: readonly ColumnId[] = ["tree", "note", "graph"];
5
- export type Columns<T> = { readonly [K in ColumnId]: T };
6
- export const MIN_WIDTHS: Columns<number> = { tree: 180, note: 320, graph: 260 };
7
- export const DEFAULT_FRACTIONS: Columns<number> = { tree: 0.22, note: 0.46, graph: 0.32 };
8
-
9
- export type Breakpoint = "wide" | "medium" | "narrow";
10
- export const BREAKPOINT_MEDIUM = 1100;
11
- export const BREAKPOINT_NARROW = 800;
12
-
13
- export function breakpointFor(width: number): Breakpoint {
14
- if (!Number.isFinite(width) || width < BREAKPOINT_NARROW) return "narrow";
15
- return width < BREAKPOINT_MEDIUM ? "medium" : "wide";
16
- }
17
-
18
- export function columnsAt(breakpoint: Breakpoint): readonly ColumnId[] {
19
- return breakpoint === "wide" ? COLUMNS : breakpoint === "medium" ? ["tree", "note"] : ["note"];
20
- }
21
-
22
- export function isCollapsed(breakpoint: Breakpoint, column: ColumnId): boolean {
23
- return !columnsAt(breakpoint).includes(column);
24
- }
25
-
26
- export interface LayoutState { readonly fractions: Columns<number> }
27
-
28
- function normalizeOver(ids: readonly ColumnId[], fractions: Columns<number>, floors: Columns<number>): Columns<number> {
29
- const clean: Record<ColumnId, number> = { tree: 0, note: 0, graph: 0 };
30
- let total = 0;
31
- for (const id of ids) {
32
- const value = fractions[id];
33
- clean[id] = Number.isFinite(value) && value > 0 ? value : DEFAULT_FRACTIONS[id];
34
- total += clean[id];
35
- }
36
- for (const id of ids) clean[id] /= total;
37
- let deficit = 0;
38
- let slack = 0;
39
- const atFloor: Record<ColumnId, boolean> = { tree: false, note: false, graph: false };
40
- for (const id of ids) {
41
- const floor = floors[id];
42
- if (clean[id] < floor) {
43
- deficit += floor - clean[id];
44
- clean[id] = floor;
45
- atFloor[id] = true;
46
- } else slack += clean[id] - floor;
47
- }
48
- if (deficit > 0 && slack > 0) {
49
- const rate = Math.min(deficit, slack) / slack;
50
- for (const id of ids) if (!atFloor[id]) clean[id] -= (clean[id] - floors[id]) * rate;
51
- }
52
- return { tree: clean.tree, note: clean.note, graph: clean.graph };
53
- }
54
-
55
- export function normalizeFractions(fractions: Columns<number>, minShare: Columns<number>): Columns<number> {
56
- return normalizeOver(COLUMNS, fractions, minShare);
57
- }
58
-
59
- export function minShares(available: number): Columns<number> {
60
- const width = Number.isFinite(available) && available > 0 ? available : 1;
61
- const raw = { tree: MIN_WIDTHS.tree / width, note: MIN_WIDTHS.note / width, graph: MIN_WIDTHS.graph / width };
62
- const total = raw.tree + raw.note + raw.graph;
63
- if (total <= 0.9) return raw;
64
- const scale = 0.9 / total;
65
- return { tree: raw.tree * scale, note: raw.note * scale, graph: raw.graph * scale };
66
- }
67
-
68
- export function makeLayout(fractions: Columns<number>, available: number): LayoutState {
69
- return { fractions: normalizeFractions(fractions, minShares(available)) };
70
- }
71
-
72
- export function defaultLayout(available: number): LayoutState {
73
- return makeLayout(DEFAULT_FRACTIONS, available);
74
- }
75
-
76
- export interface ResolvedColumn { readonly id: ColumnId; readonly width: number }
77
-
78
- export function resolveColumns(state: LayoutState, available: number, breakpoint: Breakpoint): readonly ResolvedColumn[] {
79
- const width = Number.isFinite(available) && available > 0 ? available : 0;
80
- const shares = normalizeOver(columnsAt(breakpoint), state.fractions, minShares(width));
81
- return columnsAt(breakpoint).map((id) => ({ id, width: shares[id] * width }));
82
- }
83
-
84
- export type DividerId = "tree" | "note";
85
- export const DIVIDERS: readonly DividerId[] = ["tree", "note"];
86
- export function dividerPair(divider: DividerId): readonly [ColumnId, ColumnId] {
87
- return divider === "tree" ? ["tree", "note"] : ["note", "graph"];
88
- }
89
-
90
- export function resizeAt(state: LayoutState, divider: DividerId, deltaPx: number, available: number): LayoutState {
91
- const width = Number.isFinite(available) && available > 0 ? available : 0;
92
- if (width === 0 || !Number.isFinite(deltaPx) || deltaPx === 0) return state;
93
- const [left, right] = dividerPair(divider);
94
- const floors = minShares(width);
95
- const delta = Math.max(-(Math.max(0, state.fractions[left] - floors[left])), Math.min(Math.max(0, state.fractions[right] - floors[right]), deltaPx / width));
96
- if (delta === 0) return state;
97
- return { fractions: normalizeFractions({ ...state.fractions, [left]: state.fractions[left] + delta, [right]: state.fractions[right] - delta }, floors) };
98
- }
99
-
100
- export interface LayoutStorage { getItem(key: string): string | null; setItem(key: string, value: string): void }
101
- export const LAYOUT_STORAGE_KEY = "pi-weave.layout.v1";
102
- const round4 = (value: number): number => Math.round(value * 10000) / 10000;
103
-
104
- export function serializeLayout(state: LayoutState): string {
105
- return JSON.stringify({ v: 1, tree: round4(state.fractions.tree), note: round4(state.fractions.note), graph: round4(state.fractions.graph) });
106
- }
107
-
108
- export function deserializeLayout(raw: string | null, available: number): LayoutState | null {
109
- if (raw === null) return null;
110
- let parsed: unknown;
111
- try { parsed = JSON.parse(raw); } catch { return null; }
112
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
113
- const record = parsed as Record<string, unknown>;
114
- if (record["v"] !== 1) return null;
115
- const fractions = {} as Record<ColumnId, number>;
116
- for (const id of COLUMNS) {
117
- const value = record[id];
118
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
119
- fractions[id] = value;
120
- }
121
- return makeLayout(fractions, available);
122
- }
123
-
124
- export function loadLayout(storage: LayoutStorage, available: number): LayoutState {
125
- try { return deserializeLayout(storage.getItem(LAYOUT_STORAGE_KEY), available) ?? defaultLayout(available); }
126
- catch { return defaultLayout(available); }
127
- }
128
-
129
- export function saveLayout(storage: LayoutStorage, state: LayoutState): boolean {
130
- try { storage.setItem(LAYOUT_STORAGE_KEY, serializeLayout(state)); return true; }
131
- catch { return false; }
132
- }