pi-weave 0.3.1 → 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.
@@ -1,126 +1,10 @@
1
- /**
2
- * The hidden force tuner's logic (docs/weave-workspace.md §15.7).
3
- *
4
- * The graph's arrangement is decided by six numbers in `shared/layout.ts`, and
5
- * the difference between "one hairball" and "legible groups" is a *ratio*
6
- * between them that is far easier to see than to derive. So there is a panel
7
- * of sliders behind a URL flag, and everything about it that is not a DOM node
8
- * lives here — §10's rule, the same split `Graph.tsx` / `column.model.ts`
9
- * already uses.
10
- *
11
- * ## Why a query flag and not a setting
12
- *
13
- * The tuner is a *developer* affordance with a human in the loop; it is not a
14
- * feature and must not read as one. A URL string is the cheapest gate that is
15
- * genuinely hidden (no menu entry, no chord to collide with §11 P4's
16
- * keymap, no persisted state to leak into a normal session) while staying
17
- * reachable on a running workspace with no rebuild. A build-time `define`
18
- * would have been more hidden still and was rejected: it breaks the
19
- * byte-reproducible `build:web:check` contract for a panel that is already
20
- * unreachable without the flag.
21
- *
22
- * ## Tier rules (§2)
23
- *
24
- * `src/web/client/**`, but nothing here touches the DOM or npm — the flag
25
- * arrives as a `location.search` *string*, so this module compiles under the
26
- * root `tsconfig.json` and its tests are ordinary ones.
27
- */
28
-
29
- import type { ForceConstants } from "../../shared/layout";
30
- import { FORCE_DEFAULTS } from "../../shared/layout";
31
-
32
- // --- the gate ----------------------------------------------------------------------
33
-
34
- /** The query parameter that opens the panel. Documented in §15.7. */
35
- export const SLIDERS_FLAG = "sliders";
36
-
37
- /**
38
- * Whether `location.search` asks for the tuner.
39
- *
40
- * `?sliders`, `?sliders=1` and `?sliders=true` all open it; `?sliders=0` and
41
- * `?sliders=false` do not, so a bookmarked URL can carry the parameter in the
42
- * off position. Anything unparseable is off — a typo must not silently put a
43
- * debug panel over a user's graph.
44
- *
45
- * Hand-parsed rather than via `URLSearchParams`, which is a DOM/Node global
46
- * this tier's `tsconfig` does not provide.
47
- */
48
- export function slidersFlag(search: string): boolean {
49
- for (const pair of search.replace(/^\?/, "").split("&")) {
50
- const eq = pair.indexOf("=");
51
- const name = eq === -1 ? pair : pair.slice(0, eq);
52
- if (name !== SLIDERS_FLAG) continue;
53
- const value = eq === -1 ? "" : pair.slice(eq + 1).toLowerCase();
54
- return value !== "0" && value !== "false";
55
- }
56
- return false;
57
- }
58
-
59
- // --- the sliders --------------------------------------------------------------------
1
+ /** Parsing and formatting for Settings → Interface graph sliders. */
2
+ import { FORCE_DEFAULTS, type ForceConstants } from "../../shared/layout";
3
+ import { FORCE_SLIDERS } from "../../shared/preferences";
4
+ import type { SliderSpec } from "../../shared/preferences";
5
+ export { FORCE_SLIDERS };
6
+ export type { SliderSpec };
60
7
 
61
- /** One slider: which constant it drives, and the range it may drive it over. */
62
- export interface SliderSpec {
63
- readonly key: keyof ForceConstants;
64
- readonly label: string;
65
- /** One line under the label saying what moving it does. */
66
- readonly hint: string;
67
- readonly min: number;
68
- readonly max: number;
69
- readonly step: number;
70
- }
71
-
72
- /**
73
- * The panel, in the order the forces actually fight each other: cohesion
74
- * first (what holds a group together), then repulsion (what pushes groups
75
- * apart), then gravity (what pulls everything back in). Reading top to bottom
76
- * is reading the tug-of-war.
77
- *
78
- * Ranges are generous on purpose — the point of the exercise is that the
79
- * shipped values are in the wrong region, so a range hugging them would hide
80
- * the answer. `chargeMax` tops out at 4000, which is past any useful value on
81
- * a graph that spans a few thousand units; that top of the range is
82
- * effectively "uncapped".
83
- */
84
- export const FORCE_SLIDERS: readonly SliderSpec[] = [
85
- { key: "containsStrength", label: "contains strength", hint: "how hard a parent holds its children — group cohesion", min: 0, max: 1, step: 0.01 },
86
- { key: "containsRest", label: "contains rest", hint: "how far children sit from their parent — rosette radius", min: 10, max: 300, step: 5 },
87
- { key: "relationStrength", label: "relation strength", hint: "pull of links-to / mentions across groups", min: 0, max: 1, step: 0.01 },
88
- { key: "relationDistance", label: "relation distance", hint: "how long those cross-group springs are", min: 20, max: 600, step: 10 },
89
- { key: "charge", label: "charge", hint: "node-to-node repulsion — more negative pushes harder", min: -1200, max: 0, step: 10 },
90
- { key: "chargeMax", label: "charge range", hint: "distance past which repulsion stops — caps overall spread", min: 100, max: 4000, step: 50 },
91
- { key: "center", label: "centre gravity", hint: "pull toward the origin — too much makes one blob", min: 0, max: 0.3, step: 0.005 },
92
- ];
93
-
94
- /**
95
- * The arrangement this whole exercise replaced, kept as a **comparison**.
96
- *
97
- * Not a suggestion any more — `FORCE_DEFAULTS` is now the chosen answer, found
98
- * through these sliders and frozen in `shared/layout.ts`. This is the *before*
99
- * picture, one button press away, so the next person to open the panel can see
100
- * in one click what the constants are for rather than reading a changelog.
101
- *
102
- * Measured at 300 ticks, worst-case gap between the big branches' bounding
103
- * boxes (negative = the branches interleave, which is the hairball):
104
- * `siblingBlobsGraph` **−312** here against **+39** shipped, `repoLikeGraph`
105
- * **−274** against **+133**.
106
- */
107
- export const HAIRBALL: Readonly<ForceConstants> = {
108
- containsRest: 90,
109
- containsStrength: 0.02,
110
- relationDistance: 170,
111
- relationStrength: 0.05,
112
- charge: -50,
113
- chargeMax: Infinity,
114
- center: 0.09,
115
- };
116
-
117
- /**
118
- * A slider's value for `<input type="range">`.
119
- *
120
- * `chargeMax` defaults to `Infinity`, which is not a number a range input can
121
- * hold, so it reads as the top of its range — the position that means "no
122
- * meaningful cap", which is what `Infinity` is.
123
- */
124
8
  export function sliderValue(forces: ForceConstants, spec: SliderSpec): number {
125
9
  const raw = forces[spec.key];
126
10
  if (!Number.isFinite(raw)) return spec.max;
@@ -147,64 +31,6 @@ export function formatValue(value: number): string {
147
31
  return Number.isInteger(value) ? String(value) : value.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
148
32
  }
149
33
 
150
- // --- handing the numbers back ----------------------------------------------------------
151
-
152
- /**
153
- * The current constants as the literal that belongs in `shared/layout.ts`.
154
- *
155
- * The whole point of the tuner is that a human finds the values and they then
156
- * get *frozen into the source*, so the last step of the loop is transcription
157
- * — and transcribing seven floats by eye off a panel is exactly where a digit
158
- * gets dropped. The Copy button emits the block verbatim.
159
- */
160
- export function forcesSnippet(forces: ForceConstants): string {
161
- const line = (key: keyof ForceConstants): string => ` ${key}: ${Number.isFinite(forces[key]) ? forces[key] : "Infinity"},`;
162
- return ["export const FORCES: ForceConstants = {", ...FORCE_SLIDERS.map((spec) => line(spec.key)), "};"].join("\n");
163
- }
164
-
165
- /** True when nothing has been moved off the shipped values. Drives "Reset". */
166
34
  export function isDefault(forces: ForceConstants): boolean {
167
35
  return FORCE_SLIDERS.every((spec) => Object.is(forces[spec.key], FORCE_DEFAULTS[spec.key]));
168
36
  }
169
-
170
- // --- the group-colour setting (§15.8) ------------------------------------------------
171
-
172
- /**
173
- * Where the group-colour choice is remembered.
174
- *
175
- * Versioned like the position cache, and stored separately from it: a palette
176
- * preference must survive the layout invalidation that a force change causes,
177
- * or tuning the physics would silently reset the user's colours.
178
- */
179
- export const GROUP_COLORS_STORAGE_KEY = "pi-weave.graph.groupColors.v1";
180
-
181
- /** The two-method storage port, same shape as `PositionStorage`. */
182
- export interface SettingStorage {
183
- getItem(key: string): string | null;
184
- setItem(key: string, value: string): void;
185
- }
186
-
187
- /**
188
- * Whether to colour by group. Defaults to **on**.
189
- *
190
- * On by default because the forces go to real trouble to separate the groups
191
- * and leaving them one colour wastes that. A throwing `getItem` (Safari
192
- * private browsing, partitioned storage) is a default, not an error — the
193
- * same answer `loadPositions` gives.
194
- */
195
- export function loadGroupColors(storage: SettingStorage): boolean {
196
- try {
197
- return storage.getItem(GROUP_COLORS_STORAGE_KEY) !== "off";
198
- } catch {
199
- return true;
200
- }
201
- }
202
-
203
- /** Persist the choice, best-effort. A full or refusing quota is not an error. */
204
- export function saveGroupColors(storage: SettingStorage, on: boolean): void {
205
- try {
206
- storage.setItem(GROUP_COLORS_STORAGE_KEY, on ? "on" : "off");
207
- } catch {
208
- // A preference that does not survive a reload still works this session.
209
- }
210
- }
@@ -13,7 +13,6 @@
13
13
  import { render } from "preact";
14
14
  import { BOOTSTRAP_ELEMENT_ID } from "../shared/wire";
15
15
  import { readBootstrap } from "./bootstrap";
16
- import { slidersFlag } from "./graph/tuner.model";
17
16
  import { Shell } from "./shell/Shell";
18
17
  import { installTheme } from "./shell/theme";
19
18
  import { loadTheme, themeAttr } from "./shell/theme.model";
@@ -34,9 +33,7 @@ if (host !== null) {
34
33
  cwd={boot.cwd}
35
34
  initialWidth={window.innerWidth}
36
35
  platform={navigator.platform}
37
- // The fourth DOM read that cannot be injected further up. `slidersFlag`
38
- // owns the parsing, so this stays a read and the decision stays tested.
39
- tuner={slidersFlag(window.location.search)}
36
+
40
37
  />,
41
38
  host,
42
39
  );
@@ -84,6 +84,8 @@ const PREVIEW_X = "weave-preview-x";
84
84
  const PREVIEW_Y = "weave-preview-y";
85
85
 
86
86
  export interface NoteProps {
87
+ spellcheck?: boolean;
88
+ defaultEdit?: boolean;
87
89
  note: NotePayload | null;
88
90
  loadFailed: boolean;
89
91
  graph: GraphPayload | null;
@@ -233,6 +235,14 @@ export function Note(props: NoteProps) {
233
235
  }
234
236
  };
235
237
 
238
+ const defaultOpened = useRef<string | null>(null);
239
+ useEffect(() => {
240
+ if (note !== null && defaultOpened.current !== note.slug) {
241
+ defaultOpened.current = note.slug;
242
+ if (props.defaultEdit && drafts.get(note.slug) === null) drafts.open(note.slug, body);
243
+ }
244
+ }, [note?.slug, props.defaultEdit]);
245
+
236
246
  const toggleEdit = (): void => {
237
247
  if (note === null || drafts.isTaskSaving(note.slug)) return;
238
248
  if (drafts.get(note.slug) === null) drafts.open(note.slug, body);
@@ -343,7 +353,7 @@ export function Note(props: NoteProps) {
343
353
  <textarea
344
354
  class="weave-note-editor"
345
355
  aria-label={EDITOR_ARIA_LABEL}
346
- spellcheck
356
+ spellcheck={props.spellcheck ?? true}
347
357
  value={draft.body}
348
358
  onInput={(event) => drafts.edit(note.slug, (event.target as HTMLTextAreaElement).value)}
349
359
  onKeyDown={(event) => {
@@ -0,0 +1,71 @@
1
+ import { useState } from "preact/hooks";
2
+ import type { ComponentChildren } from "preact";
3
+ import type { WorkspaceLayout } from "../../shared/workspace";
4
+ import type { Preferences } from "../../shared/preferences";
5
+ import { ACCENTS, THEMES } from "../../shared/themes";
6
+ import type { AccentChoice, PaletteChoice } from "../../shared/themes";
7
+ import { ForceTuner } from "../graph/ForceTuner";
8
+ import { useFocusTrap } from "./FocusTrap";
9
+ import { keyHelp } from "./keys.model";
10
+
11
+ const CATEGORIES = ["General", "Appearance", "Interface", "Editor", "Files", "Backup"] as const;
12
+ function Row(props: { name: string; hint: string; children: ComponentChildren }) {
13
+ return <label class="weave-setting-row"><span><strong>{props.name}</strong><small>{props.hint}</small></span>{props.children}</label>;
14
+ }
15
+
16
+ export function Settings(props: {
17
+ compact: boolean; layout: WorkspaceLayout; info: { version: string; vaultRoot: string }; cwd: string; shortcut: string;
18
+ onChange: (layout: WorkspaceLayout) => void; onRefresh: () => void; onClose: () => void;
19
+ }) {
20
+ const [category, setCategory] = useState<typeof CATEGORIES[number]>("General");
21
+ const trap = useFocusTrap();
22
+ const p = props.layout.preferences;
23
+ const update = (values: Partial<Preferences>): void => props.onChange({ ...props.layout, preferences: { ...p, ...values } });
24
+ return <div class="weave-scrim weave-settings-scrim" onClick={props.onClose}>
25
+ <div class="weave-settings" role="dialog" aria-modal="true" aria-label="Settings" tabIndex={-1}
26
+ ref={trap.ref as { current: HTMLDivElement | null }} onClick={(event) => event.stopPropagation()}
27
+ onKeyDown={(event) => void trap.onKeyDown(event as unknown as KeyboardEvent)}>
28
+ <header><h2>Settings</h2><button type="button" aria-label="Close settings" onClick={props.onClose}>×</button></header>
29
+ <nav aria-label="Settings categories">{props.compact ? <select class="weave-settings-category" aria-label="Settings category" value={category} onChange={(event) => setCategory(event.currentTarget.value as typeof category)}>{CATEGORIES.map((name) => <option key={name} value={name}>{name}</option>)}</select> : CATEGORIES.map((name) => <button type="button" key={name} aria-current={category === name ? "page" : undefined} onClick={() => setCategory(name)}>{name}</button>)}</nav>
30
+ <section aria-label={`${category} settings`}><h3>{category}</h3>
31
+ {category === "General" ? <>
32
+ <p>Pi Weave {props.info.version}</p>
33
+ <dl class="weave-settings-locations"><dt>Vault</dt><dd>{props.info.vaultRoot || "Loading…"}</dd><dt>Repository</dt><dd>{props.cwd}</dd></dl>
34
+ <div class="weave-settings-actions"><button type="button" onClick={props.onRefresh}>Refresh workspace</button><a href="https://github.com/EranYonai/pi-weave#readme" target="_blank" rel="noreferrer">Documentation ↗</a></div>
35
+ <details><summary tabIndex={0}>Keyboard shortcuts</summary>{keyHelp(props.shortcut.slice(0, -1)).map((group) => <div key={group.title}><h4>{group.title}</h4>{group.entries.map((entry) => <div class="weave-key-row" key={entry.combo}><kbd>{entry.combo}</kbd><span>{entry.what}</span></div>)}</div>)}</details>
36
+ </> : null}
37
+ {category === "Appearance" ? <>
38
+ <Row name="Color scheme" hint="System follows your device and uses your chosen light and dark themes."><select aria-label="Color scheme" value={props.layout.theme === "system" ? "system" : THEMES[props.layout.theme].scheme} onChange={(event) => props.onChange({ ...props.layout, theme: event.currentTarget.value === "system" ? "system" : event.currentTarget.value === "light" ? p.lightTheme : p.darkTheme })}><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></Row>
39
+ {(["light", "dark"] as const).map((scheme) => <Row key={scheme} name={`${scheme === "light" ? "Light" : "Dark"} theme`} hint={`Used in ${scheme} mode and by the ribbon theme button.`}><select aria-label={`${scheme === "light" ? "Light" : "Dark"} theme`} value={p[`${scheme}Theme`]} onChange={(event) => {
40
+ const theme = event.currentTarget.value as PaletteChoice;
41
+ props.onChange({ ...props.layout, preferences: { ...p, [`${scheme}Theme`]: theme }, theme: props.layout.theme !== "system" && THEMES[props.layout.theme].scheme === scheme ? theme : props.layout.theme });
42
+ }}>{Object.entries(THEMES).filter(([, theme]) => theme.scheme === scheme).map(([id, theme]) => <option key={id} value={id}>{theme.name}</option>)}</select></Row>)}
43
+ <Row name="Accent color" hint="Choose independently of the theme. Shades adapt to light and dark backgrounds."><select aria-label="Accent color" value={p.accent} onChange={(event) => update({ accent: event.currentTarget.value as AccentChoice })}><option value="theme">Theme default</option>{Object.entries(ACCENTS).map(([id, accent]) => <option value={id} key={id}>{accent.name}</option>)}</select></Row>
44
+ <Row name="Note font size" hint="Applies to reading and editing notes."><span class="weave-setting-range"><input aria-label="Note font size" type="range" min={12} max={22} step={1} value={p.fontSize} onInput={(event) => update({ fontSize: Number(event.currentTarget.value) })} /><output>{p.fontSize}px</output></span></Row>
45
+ </> : null}
46
+ {category === "Interface" ? <>
47
+ <Row name="Notes sidebar" hint="Show files and recent notes."><input type="checkbox" checked={props.layout.treeVisible} onChange={(event) => props.onChange({ ...props.layout, treeVisible: event.currentTarget.checked })} /></Row>
48
+ <Row name="Context sidebar" hint="Show links, backlinks, and related items."><input type="checkbox" checked={props.layout.contextVisible} onChange={(event) => props.onChange({ ...props.layout, contextVisible: event.currentTarget.checked })} /></Row>
49
+ <Row name="Color graph by group" hint="Give each containment group its own color."><input type="checkbox" checked={p.groupColors} onChange={(event) => update({ groupColors: event.currentTarget.checked })} /></Row>
50
+ <h4>Graph layout</h4><p class="weave-settings-hint">Adjust the graph live. Your choices are saved for this workspace.</p>
51
+ <ForceTuner forces={p.forces} onChange={(forces) => update({ forces })} />
52
+ </> : null}
53
+ {category === "Editor" ? <>
54
+ <Row name="Spellcheck" hint="Use your browser or device’s spellchecker while editing."><input type="checkbox" checked={p.spellcheck} onChange={(event) => update({ spellcheck: event.currentTarget.checked })} /></Row>
55
+ <Row name="Readable line length" hint="Keep note text in a comfortable reading column."><input type="checkbox" checked={p.readable} onChange={(event) => update({ readable: event.currentTarget.checked })} /></Row>
56
+ <Row name="Open notes in editing mode" hint="Newly opened notes start in the editor. Existing drafts are preserved."><input type="checkbox" checked={p.defaultEdit} onChange={(event) => update({ defaultEdit: event.currentTarget.checked })} /></Row>
57
+ </> : null}
58
+ {category === "Files" ? <>
59
+ <Row name="On startup" hint="Choose which tabs appear when you reopen this workspace."><select aria-label="On startup" value={p.startup} onChange={(event) => update({ startup: event.currentTarget.value as Preferences["startup"] })}><option value="restore">Restore open tabs</option><option value="empty">Start with an empty tab</option></select></Row>
60
+ <Row name="Focus new tabs" hint="Switch to notes opened in a new tab, including from the graph."><input type="checkbox" checked={p.focusNewTabs} onChange={(event) => update({ focusNewTabs: event.currentTarget.checked })} /></Row>
61
+ <h4>Deleting files</h4><p>Deleting a note or folder removes it from the vault after confirmation. Download a backup before deleting anything you may need again.</p>
62
+ </> : null}
63
+ {category === "Backup" ? <>
64
+ <h4>Download your vault</h4><p>Save a ZIP containing the vault’s files and folders, including notes, metadata, and attachments. Linked files and folders are included as files.</p>
65
+ <p>Save any unsaved edits first. The download contains files saved on disk. Backups are manual; store the ZIP somewhere safe.</p>
66
+ <a class="weave-settings-download" href="/api/backup" download>Download vault ZIP</a>
67
+ </> : null}
68
+ </section>
69
+ </div>
70
+ </div>;
71
+ }
@@ -1,7 +1,7 @@
1
1
  /** Shared browser/desktop workspace: one data source, two optional tab groups. */
2
- import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
2
+ import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "preact/hooks";
3
3
  import type { GraphPayload, NotePayload } from "../../shared/wire";
4
- import { activePane, activeTab, activateTab, closePane, closeTab, focusDocument, initialLayout, moveTab, navigateTab, openDocument, openGraph, parseWorkspaceLayout, splitPane, tabDropSource, tabSelection } from "../../shared/workspace";
4
+ import { activePane, activeTab, activateTab, closePane, closeTab, focusDocument, initialLayout, reorderTab, moveTab, navigateTab, openDocument, openWithPreferences, restoreWorkspace, openGraph, parseWorkspaceLayout, splitPane, tabDropSource, tabSelection } from "../../shared/workspace";
5
5
  import type { Pane, WorkspaceLayout, WorkspaceTab } from "../../shared/workspace";
6
6
  import { fetchJson } from "../api.dom";
7
7
  import { openNote, saveNote } from "../api";
@@ -19,15 +19,18 @@ import { startWorkspace, watchNote } from "../workspace";
19
19
  import type { WorkspaceHandle } from "../workspace";
20
20
  import { ContextRail } from "./ContextRail";
21
21
  import { deeplinkSelection, formatHash } from "./deeplink.model";
22
+ import { Settings } from "./Settings";
23
+ import type { Preferences } from "../../shared/preferences";
24
+ import { THEMES } from "../../shared/themes";
22
25
  import { HelpOverlay } from "./HelpOverlay";
23
26
  import { watchKeys } from "./keys";
24
27
  import { COLUMN_FOCUS_SELECTORS, TREE_FILTER_SELECTOR, focusSelector, runShellAction } from "./keys.model";
25
28
  import { StatusBar } from "./StatusBar";
26
29
  import type { OverlayId } from "./shell.model";
27
30
  import { NOTE_DRAG_TYPE, noteDropId, TICK_MS, graphClickOpensTab, looksApple, recentEntries, recordVisit, searchHint, searchShortcut, statusBarModel } from "./shell.model";
28
- import { cycleTheme, effectiveScheme, loadTheme, saveTheme, themeAttr, themeButton } from "./theme.model";
31
+ import { toggleTheme, effectiveScheme, loadTheme, saveTheme, themeButton } from "./theme.model";
29
32
 
30
- export interface ShellProps { cwd: string; initialWidth: number; platform: string; tuner?: boolean }
33
+ export interface ShellProps { cwd: string; initialWidth: number; platform: string }
31
34
 
32
35
  function titleOf(tab: WorkspaceTab, graph: GraphPayload | null): string {
33
36
  if (tab.kind === "graph") return "Graph view";
@@ -46,7 +49,7 @@ function RecentList(props: { visits: readonly string[]; graph: GraphPayload | nu
46
49
 
47
50
  /** A tab view can unmount; the shared draft store outlives it. */
48
51
  function DocumentView(props: {
49
- tab: WorkspaceTab; graph: GraphPayload | null; drafts: DraftStore; revision: number;
52
+ preferences: Preferences; tab: WorkspaceTab; graph: GraphPayload | null; drafts: DraftStore; revision: number;
50
53
  onSelect: (id: string, newTab?: boolean) => void; onSave: (slug: string, body: string) => Promise<boolean>;
51
54
  now: number; onScroll: (value: number) => void; onSearch: () => void; onGraph: () => void;
52
55
  }) {
@@ -87,7 +90,7 @@ function DocumentView(props: {
87
90
  return <div class="weave-document" ref={element} onScrollCapture={(event) => {
88
91
  const target = event.target as HTMLElement;
89
92
  if (target.classList.contains("weave-note")) { savedScroll.current = target.scrollTop; props.onScroll(target.scrollTop); }
90
- }}><Note note={payload?.note ?? null} loadFailed={payload?.failed ?? false} loadVersion={payload?.version ?? 0} graph={props.graph} selectedId={id}
93
+ }}><Note spellcheck={props.preferences.spellcheck} defaultEdit={props.preferences.defaultEdit} note={payload?.note ?? null} loadFailed={payload?.failed ?? false} loadVersion={payload?.version ?? 0} graph={props.graph} selectedId={id}
91
94
  onSelect={props.onSelect} onOpen={(noteSlug) => void openNote(fetchJson, noteSlug).then((result) => {
92
95
  if (!result.ok) window.alert(result.message); else if (!result.data.opened) window.alert("Could not open the note in an editor.");
93
96
  })} onSave={props.onSave} drafts={props.drafts} idPrefix={props.tab.id} now={props.now} /></div>;
@@ -119,12 +122,14 @@ export function Shell(props: ShellProps) {
119
122
  const [width, setWidth] = useState(props.initialWidth);
120
123
  const [scheme, setScheme] = useState(() => schemeOf(window));
121
124
  const [ready, setReady] = useState(false);
125
+ const [info, setInfo] = useState({ version: "", vaultRoot: "" });
122
126
  const [persistError, setPersistError] = useState(false);
123
127
  const [revision, setRevision] = useState(0);
124
128
  const [, setDraftRevision] = useState(0);
125
129
  const [graphSelection, setGraphSelection] = useState<string | null>(null);
126
130
  const [graphPreviewId, setGraphPreviewId] = useState<string | null>(null);
127
131
  const draggedTab = useRef<string | null>(null);
132
+ const [dropTab, setDropTab] = useState<{ paneId: string; before: string | null } | null>(null);
128
133
  const [dropPane, setDropPane] = useState<string | null>(null);
129
134
  const [recentMode, setRecentMode] = useState(false);
130
135
  const [visits, setVisits] = useState<readonly string[]>([]);
@@ -140,7 +145,9 @@ export function Shell(props: ShellProps) {
140
145
  const [graphBox, setGraphBox] = useState({ left: 0, top: 0, width: 600, height: 500 });
141
146
  const pane = activePane(layout);
142
147
  const tab = activeTab(layout);
143
- const theme = themeButton(layout.theme);
148
+ const theme = themeButton(layout.theme, scheme, layout.preferences);
149
+ const palette = effectiveScheme(layout.theme, scheme, layout.preferences);
150
+ const graphTheme = useMemo(() => ({ theme: palette, accent: layout.preferences.accent }), [palette, layout.preferences.accent]);
144
151
  const selectedId = tab.kind === "graph" ? graphSelection : tabSelection(tab);
145
152
  const graphPane = layout.panes.find((group) => group.tabs.some((entry) => entry.id === group.activeTab && entry.kind === "graph"));
146
153
  const graphVisible = graphPane !== undefined && (width >= 850 || graphPane.id === layout.activePane);
@@ -157,7 +164,7 @@ export function Shell(props: ShellProps) {
157
164
  if (mayRemoveDrafts(drafts, live.current.layout, next, () => window.confirm(DISCARD_PROMPT))) setLayout(next);
158
165
  };
159
166
  const select = (id: string | null, newTab = false, paneId = layout.activePane, keepSidebar = false): void => {
160
- const next = openDocument(layout, id, { newTab, paneId });
167
+ const next = (id === null ? openDocument : openWithPreferences)(layout, id, { newTab, paneId });
161
168
  change(width < 850 && !keepSidebar ? { ...next, treeVisible: false } : next);
162
169
  setCompactContext(false);
163
170
  };
@@ -189,13 +196,14 @@ export function Shell(props: ShellProps) {
189
196
  void (async () => {
190
197
  try {
191
198
  const response = await fetchJson("/api/workspace-state");
192
- const value = await response.json() as { viewId?: unknown; layout?: unknown };
199
+ const value = await response.json() as { viewId?: unknown; layout?: unknown; info?: { version: string; vaultRoot: string } };
193
200
  if (!current) return;
194
201
  if (response.ok && typeof value.viewId === "string") {
195
202
  viewId.current = value.viewId;
203
+ if (value.info) setInfo(value.info);
196
204
  const restored = parseWorkspaceLayout(value.layout);
197
205
  if (restored) {
198
- setLayout(restored);
206
+ setLayout(restoreWorkspace(restored));
199
207
  setVisits(restored.panes.flatMap((pane) => pane.tabs.flatMap((tab) => tab.history)).reduce<readonly string[]>(recordVisit, []));
200
208
  }
201
209
  } else setPersistError(true);
@@ -214,11 +222,11 @@ export function Shell(props: ShellProps) {
214
222
  useEffect(() => {
215
223
  if (!ready) return;
216
224
  history.replaceState(null, "", formatHash(selectedId));
217
- const attr = themeAttr(layout.theme);
218
- if (attr === null) delete document.documentElement.dataset.weaveTheme;
219
- else document.documentElement.dataset.weaveTheme = attr;
225
+ document.documentElement.dataset.weaveTheme = palette;
226
+ document.documentElement.dataset.weaveScheme = THEMES[palette].scheme;
227
+ document.documentElement.dataset.weaveAccent = layout.preferences.accent;
220
228
  saveTheme(localStorage, layout.theme);
221
- }, [ready, selectedId, layout.theme]);
229
+ }, [ready, selectedId, layout.theme, palette, layout.preferences.accent]);
222
230
  const pendingSave = useRef(Promise.resolve());
223
231
  useEffect(() => {
224
232
  if (!ready || !viewId.current) return;
@@ -265,7 +273,7 @@ export function Shell(props: ShellProps) {
265
273
  else live.current.select(null);
266
274
  },
267
275
  closeTab: () => { const current = live.current.layout; change(closeTab(current, current.activePane, activeTab(current).id)); },
268
- cycleTheme: () => setLayout((current) => ({ ...current, theme: cycleTheme(current.theme) })),
276
+ toggleTheme: () => setLayout((current) => ({ ...current, theme: toggleTheme(current.theme, schemeOf(window), current.preferences) })),
269
277
  focusSelector: (selector) => {
270
278
  if (selector === COLUMN_FOCUS_SELECTORS.graph) setLayout((current) => ({ ...openGraph(current), ...(window.innerWidth < 850 ? { treeVisible: false } : {}) }));
271
279
  else if (selector === COLUMN_FOCUS_SELECTORS.tree || selector === TREE_FILTER_SELECTOR) setLayout((current) => ({ ...current, treeVisible: true }));
@@ -304,6 +312,7 @@ export function Shell(props: ShellProps) {
304
312
  if (tabDropSource(layout, paneId, draggedTab.current) === null && !event.dataTransfer?.types.includes(NOTE_DRAG_TYPE)) return;
305
313
  event.preventDefault();
306
314
  if (event.dataTransfer) event.dataTransfer.dropEffect = draggedTab.current === null ? "copy" : "move";
315
+ setDropTab(null);
307
316
  setDropPane(paneId);
308
317
  };
309
318
  const tabDrop = (paneId: string, event: DragEvent): void => {
@@ -318,6 +327,20 @@ export function Shell(props: ShellProps) {
318
327
  });
319
328
  draggedTab.current = null;
320
329
  setDropPane(null);
330
+ setDropTab(null);
331
+ };
332
+ const reorderDragOver = (pane: Pane, before: string | null, event: DragEvent): void => {
333
+ if (!pane.tabs.some((tab) => tab.id === draggedTab.current)) return;
334
+ event.preventDefault(); event.stopPropagation();
335
+ event.dataTransfer!.dropEffect = "move";
336
+ setDropPane(null); setDropTab({ paneId: pane.id, before });
337
+ };
338
+ const reorderDrop = (pane: Pane, event: DragEvent): void => {
339
+ const id = draggedTab.current;
340
+ if (id === null || !pane.tabs.some((tab) => tab.id === id)) return;
341
+ event.preventDefault(); event.stopPropagation();
342
+ setLayout((current) => reorderTab(current, pane.id, id, dropTab?.paneId === pane.id ? dropTab.before : null));
343
+ draggedTab.current = null; setDropTab(null); setDropPane(null);
321
344
  };
322
345
  const renderPane = (group: Pane) => {
323
346
  const current = group.tabs.find((entry) => entry.id === group.activeTab)!;
@@ -327,14 +350,27 @@ export function Shell(props: ShellProps) {
327
350
  onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropPane(null); }}
328
351
  hidden={width < 850 && !isActive} onFocusCapture={() => { if (!isActive) setLayout((value) => ({ ...value, activePane: group.id })); }}
329
352
  onPointerDown={() => { if (!isActive) setLayout((value) => ({ ...value, activePane: group.id })); }}>
330
- <div class="weave-tabs" role="tablist" aria-label="Open documents">
331
- {group.tabs.map((entry) => <div key={entry.id} class={`weave-tab${entry.id === current.id ? " weave-tab-active" : ""}`}>
353
+ <div class="weave-tabs" role="tablist" aria-label="Open documents" onDragOver={(event) => reorderDragOver(group, null, event)} onDrop={(event) => reorderDrop(group, event)}>
354
+ {group.tabs.map((entry) => <div key={entry.id} class={`weave-tab${entry.id === current.id ? " weave-tab-active" : ""}${dropTab?.paneId === group.id && dropTab.before === entry.id ? " weave-tab-drop-before" : ""}${dropTab?.paneId === group.id && dropTab.before === null && entry === group.tabs.at(-1) ? " weave-tab-drop-after" : ""}`}
355
+ onDragOver={(event) => {
356
+ const bounds = event.currentTarget.getBoundingClientRect();
357
+ const before = event.clientX < bounds.left + bounds.width / 2 ? entry.id : group.tabs[group.tabs.indexOf(entry) + 1]?.id ?? null;
358
+ reorderDragOver(group, before, event);
359
+ }} onDrop={(event) => reorderDrop(group, event)}>
332
360
  <button type="button" role="tab" title={titleOf(entry, data.graph)} id={`tab-${entry.id}`} aria-selected={entry.id === current.id} aria-controls={`panel-${group.id}`} tabIndex={entry.id === current.id ? 0 : -1}
333
- draggable={layout.panes.length === 2}
361
+ draggable
334
362
  onDragStart={(event) => { draggedTab.current = entry.id; event.dataTransfer!.effectAllowed = "move"; event.dataTransfer!.setData("application/x-weave-tab", entry.id); }}
335
363
  onClick={() => setLayout(activateTab(layout, group.id, entry.id))}
336
364
  onKeyDown={(event) => {
337
365
  const offset = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
366
+ if (offset && event.altKey && !event.ctrlKey && !event.metaKey) {
367
+ event.preventDefault(); event.stopPropagation();
368
+ const index = group.tabs.indexOf(entry);
369
+ const before = offset < 0 ? group.tabs[Math.max(0, index - 1)]!.id : group.tabs[index + 2]?.id ?? null;
370
+ setLayout(reorderTab(layout, group.id, entry.id, before));
371
+ requestAnimationFrame(() => document.getElementById(`tab-${entry.id}`)?.focus());
372
+ return;
373
+ }
338
374
  if (!offset && event.key !== "Home" && event.key !== "End") return;
339
375
  event.preventDefault();
340
376
  const index = event.key === "Home" ? 0 : event.key === "End" ? group.tabs.length - 1 : (group.tabs.indexOf(entry) + offset + group.tabs.length) % group.tabs.length;
@@ -361,7 +397,7 @@ export function Shell(props: ShellProps) {
361
397
  </div></details>
362
398
  </div>
363
399
  <div class="weave-pane-content" role="tabpanel" tabIndex={-1} id={`panel-${group.id}`} aria-labelledby={`tab-${current.id}`} {...(current.kind === "graph" ? { ref: graphSlot } : {})}>
364
- {current.kind === "document" ? <DocumentView key={`${current.id}:${current.cursor}:${tabSelection(current)}`} tab={current} graph={data.graph} drafts={drafts} revision={revision} now={now}
400
+ {current.kind === "document" ? <DocumentView key={`${current.id}:${current.cursor}:${tabSelection(current)}`} preferences={layout.preferences} tab={current} graph={data.graph} drafts={drafts} revision={revision} now={now}
365
401
  onSelect={(id, newTab) => select(id, newTab, group.id)} onSave={save} onScroll={(value) => rememberScroll(current.id, value)}
366
402
  onSearch={() => setOverlay("search")} onGraph={showGraph} /> : null}
367
403
  </div>
@@ -369,7 +405,7 @@ export function Shell(props: ShellProps) {
369
405
  };
370
406
 
371
407
  return <>
372
- <div class="weave-workbench" ref={root} onDragEnd={() => { draggedTab.current = null; setDropPane(null); }}>
408
+ <div class="weave-workbench" style={{ "--weave-note-size": `${layout.preferences.fontSize}px`, "--weave-reading-width": layout.preferences.readable ? "760px" : "100%" }} ref={root} onDragEnd={() => { draggedTab.current = null; setDropPane(null); setDropTab(null); }}>
373
409
  <nav class="weave-ribbon" aria-label="Workspace tools">
374
410
  <button type="button" aria-label="Toggle notes sidebar" aria-pressed={layout.treeVisible} title="Notes" onClick={() => setLayout({ ...layout, treeVisible: !layout.treeVisible })}>▤</button>
375
411
  <button type="button" aria-label="Open graph view" title="Graph view" onClick={showGraph}>◌</button>
@@ -379,8 +415,8 @@ export function Shell(props: ShellProps) {
379
415
  <span class="weave-ribbon-space" />
380
416
  <button type="button" aria-label="Toggle context sidebar" aria-pressed={contextVisible} title="Context" onClick={() => width < 1050 ? setCompactContext(!compactContext) : setLayout({ ...layout, contextVisible: !layout.contextVisible })}>☷</button>
381
417
  <button type="button" aria-label="Refresh workspace" title="Refresh workspace" onClick={() => { workspace.current?.refresh(); setRevision((value) => value + 1); }}>↻</button>
382
- <button type="button" aria-label={theme.hint} title={theme.hint} onClick={() => setLayout({ ...layout, theme: cycleTheme(layout.theme) })}>{theme.glyph}</button>
383
- <button type="button" aria-label="Keyboard shortcuts" onClick={() => setOverlay("help")}>?</button>
418
+ <button type="button" aria-label={theme.hint} title={theme.hint} onClick={() => setLayout({ ...layout, theme: toggleTheme(layout.theme, scheme, layout.preferences) })}>{theme.glyph}</button>
419
+ <button type="button" aria-label="Settings" title="Settings" onClick={() => setOverlay("settings")}>⚙</button>
384
420
  </nav>
385
421
  <div class="weave-sidebar-dock weave-notes-dock" data-open={layout.treeVisible} inert={!layout.treeVisible} aria-hidden={!layout.treeVisible} style={{ "--weave-sidebar-width": `${Math.min(layout.treeWidth, width - 90)}px` }}><aside class="weave-sidebar weave-sidebar-notes" aria-label="Notes sidebar">
386
422
  <div class="weave-sidebar-heading"><button type="button" aria-pressed={!recentMode} onClick={() => setRecentMode(false)}>Files</button><button type="button" aria-pressed={recentMode} onClick={() => setRecentMode(true)}>Recent</button><button type="button" aria-label="Hide notes sidebar" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle notes sidebar"]')?.focus(); setLayout({ ...layout, treeVisible: false }); }}>«</button></div>
@@ -395,15 +431,16 @@ export function Shell(props: ShellProps) {
395
431
  </main>
396
432
  <div class="weave-sidebar-dock weave-context-dock" data-open={contextVisible} inert={!contextVisible} aria-hidden={!contextVisible} style={{ "--weave-sidebar-width": `${layout.contextWidth}px` }}><ResizeHandle label="Resize context sidebar" min={180} max={400} value={layout.contextWidth} onChange={(delta) => setLayout((value) => ({ ...value, contextWidth: Math.max(180, Math.min(400, value.contextWidth - delta)) }))} /><aside class="weave-sidebar weave-sidebar-context" aria-label="Context sidebar"><div class="weave-sidebar-heading"><strong>Context</strong><button type="button" aria-label="Hide context sidebar" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle context sidebar"]')?.focus(); setCompactContext(false); setLayout({ ...layout, contextVisible: false }); }}>»</button></div><ContextRail graph={data.graph} selectedId={selectedId} onSelect={select} /></aside></div>
397
433
  {hasGraph ? <div class="weave-graph-host" onDragOver={(event) => { if (graphPane) tabDragOver(graphPane.id, event); }} onDrop={(event) => { if (graphPane) tabDrop(graphPane.id, event); }} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropPane(null); }} onPointerDown={() => { if (graphPane) setLayout((current) => ({ ...current, activePane: graphPane.id })); }} onFocusCapture={() => { if (graphPane) setLayout((current) => ({ ...current, activePane: graphPane.id })); }} aria-hidden={!graphVisible} style={{ ...graphBox, visibility: graphVisible ? "visible" : "hidden", pointerEvents: graphVisible ? "auto" : "none" }}>
398
- <Graph graph={data.graph} selectedId={selectedId} previewId={graphPreviewId} onSelect={selectGraph} onOpen={openGraphNode} renderer={createSigmaRenderer} storage={localStorage} host={window} scheme={effectiveScheme(layout.theme, scheme)} bootFailed={data.graphFailed} fit={fit} tuner={props.tuner === true} />
434
+ <Graph graph={data.graph} selectedId={selectedId} previewId={graphPreviewId} onSelect={selectGraph} onOpen={openGraphNode} renderer={createSigmaRenderer} storage={localStorage} host={window} scheme={graphTheme} forces={layout.preferences.forces} groupColors={layout.preferences.groupColors} bootFailed={data.graphFailed} fit={fit} />
399
435
  </div> : null}
400
436
  </div>
401
- <div class="weave-footer"><StatusBar model={statusBarModel(props.cwd, selectedId, data.graph?.model.generatedAt ?? null)} />
437
+ <div class="weave-footer"><StatusBar model={statusBarModel(props.cwd, selectedId)} />
402
438
  {persistError ? <span role="status">Workspace restoration unavailable</span> : null}
403
439
  {drafts.dirtySlugs().length ? <span>{drafts.dirtySlugs().length} unsaved</span> : null}
404
440
  {width < 850 && layout.panes.length > 1 ? <button type="button" onClick={() => setLayout({ ...layout, activePane: layout.panes.find((group) => group.id !== pane.id)!.id })}>Switch pane</button> : null}
405
441
  </div>
406
442
  {overlay === "search" ? <SearchPalette graph={data.graph} onSelect={select} onClose={() => setOverlay(null)} ports={{ fetch: fetchJson }} /> : null}
443
+ {overlay === "settings" ? <Settings compact={width <= 650} layout={layout} info={info} cwd={props.cwd} shortcut={searchShortcut(looksApple(props.platform))} onChange={(next) => { change(next); setCompactContext(next.contextVisible); }} onRefresh={() => { workspace.current?.refresh(); setRevision((value) => value + 1); }} onClose={() => setOverlay(null)} /> : null}
407
444
  {overlay === "help" ? <HelpOverlay shortcut={searchShortcut(looksApple(props.platform))} onClose={() => setOverlay(null)} /> : null}
408
445
  </>;
409
446
  }
@@ -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 (
@@ -17,9 +16,6 @@ export function StatusBar({ model }: { model: StatusBarModel }) {
17
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
  }