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
@@ -0,0 +1,172 @@
1
+ import { tabSelection, type WorkspaceLayout } from "../../shared/workspace";
2
+
3
+ export interface NoteDraft {
4
+ body: string;
5
+ baseline: string;
6
+ saving: boolean;
7
+ }
8
+
9
+ interface TaskSource {
10
+ body: string;
11
+ version: number;
12
+ pending?: { body: string; version: number };
13
+ saving: boolean;
14
+ }
15
+
16
+ export interface DraftStore {
17
+ get(slug: string): Readonly<NoteDraft> | null;
18
+ subscribe(listener: () => void): () => void;
19
+ open(slug: string, body: string): void;
20
+ edit(slug: string, body: string): void;
21
+ discard(slug: string): void;
22
+ isDirty(slug: string): boolean;
23
+ dirtySlugs(): string[];
24
+ save(slug: string, onSave: (slug: string, body: string) => Promise<boolean>): Promise<void>;
25
+ nextLoadVersion(): number;
26
+ getTaskBody(slug: string, fallback: string): string;
27
+ taskBody(slug: string, serverBody: string, loadVersion: number): string;
28
+ isTaskSaving(slug: string): boolean;
29
+ saveTask(slug: string, body: string, onSave: (slug: string, body: string) => Promise<boolean>): Promise<boolean>;
30
+ clearTaskSources(slugs: readonly string[]): void;
31
+ }
32
+
33
+ export function createDraftStore(): DraftStore {
34
+ const drafts = new Map<string, NoteDraft>();
35
+ const taskSources = new Map<string, TaskSource>();
36
+ const listeners = new Set<() => void>();
37
+ let loadVersion = 0;
38
+ const notify = (): void => listeners.forEach((listener) => listener());
39
+
40
+ return {
41
+ get: (slug) => drafts.get(slug) ?? null,
42
+ subscribe(listener) {
43
+ listeners.add(listener);
44
+ return () => listeners.delete(listener);
45
+ },
46
+ open(slug, body) {
47
+ if (drafts.has(slug)) return;
48
+ drafts.set(slug, { body, baseline: body, saving: false });
49
+ notify();
50
+ },
51
+ edit(slug, body) {
52
+ const draft = drafts.get(slug);
53
+ if (draft === undefined || draft.body === body) return;
54
+ draft.body = body;
55
+ notify();
56
+ },
57
+ discard(slug) {
58
+ if (drafts.delete(slug)) notify();
59
+ },
60
+ isDirty(slug) {
61
+ const draft = drafts.get(slug);
62
+ return draft !== undefined && draft.body !== draft.baseline;
63
+ },
64
+ dirtySlugs() {
65
+ return [...drafts].filter(([, draft]) => draft.body !== draft.baseline).map(([slug]) => slug);
66
+ },
67
+ async save(slug, onSave) {
68
+ const draft = drafts.get(slug);
69
+ if (draft === undefined || draft.saving || taskSources.get(slug)?.saving) return;
70
+ const sent = draft.body;
71
+ draft.saving = true;
72
+ notify();
73
+ let ok = false;
74
+ try {
75
+ ok = await onSave(slug, sent);
76
+ } finally {
77
+ // Discard/reopen during an in-flight request gives the new draft a new identity.
78
+ if (drafts.get(slug) === draft) {
79
+ const source = taskSources.get(slug);
80
+ if (ok && source !== undefined) {
81
+ source.body = sent;
82
+ source.version = ++loadVersion;
83
+ delete source.pending;
84
+ }
85
+ if (ok && draft.body === sent) drafts.delete(slug);
86
+ else {
87
+ if (ok) draft.baseline = sent;
88
+ draft.saving = false;
89
+ }
90
+ notify();
91
+ }
92
+ }
93
+ },
94
+ nextLoadVersion() {
95
+ return ++loadVersion;
96
+ },
97
+ getTaskBody(slug, fallback) {
98
+ return taskSources.get(slug)?.body ?? fallback;
99
+ },
100
+ taskBody(slug, serverBody, version) {
101
+ let source = taskSources.get(slug);
102
+ let changed = false;
103
+ if (source === undefined) {
104
+ source = { body: serverBody, version, saving: false };
105
+ taskSources.set(slug, source);
106
+ } else if (version > source.version) {
107
+ if (source.saving) {
108
+ if (source.pending === undefined || version > source.pending.version) source.pending = { body: serverBody, version };
109
+ } else {
110
+ changed = source.body !== serverBody;
111
+ source.body = serverBody;
112
+ source.version = version;
113
+ }
114
+ }
115
+ if (changed) notify();
116
+ return source.body;
117
+ },
118
+ isTaskSaving: (slug) => taskSources.get(slug)?.saving ?? false,
119
+ async saveTask(slug, body, onSave) {
120
+ const source = taskSources.get(slug);
121
+ if (source === undefined || source.saving || drafts.get(slug)?.saving) return false;
122
+ source.saving = true;
123
+ notify();
124
+ let ok = false;
125
+ try {
126
+ ok = await onSave(slug, body);
127
+ return ok;
128
+ } finally {
129
+ if (taskSources.get(slug) === source) {
130
+ if (ok) {
131
+ source.body = body;
132
+ // Invalidate note loads that began before this save completed.
133
+ source.version = ++loadVersion;
134
+ } else if (source.pending !== undefined && source.pending.version > source.version) {
135
+ source.body = source.pending.body;
136
+ source.version = source.pending.version;
137
+ }
138
+ delete source.pending;
139
+ source.saving = false;
140
+ notify();
141
+ }
142
+ }
143
+ },
144
+ clearTaskSources(slugs) {
145
+ let changed = false;
146
+ for (const slug of slugs) changed = taskSources.delete(slug) || changed;
147
+ if (changed) notify();
148
+ },
149
+ };
150
+ }
151
+
152
+ /** Removing the last selected view of a dirty note requires deliberate discard. */
153
+ export function mayRemoveDrafts(drafts: DraftStore, current: WorkspaceLayout, next: WorkspaceLayout, confirm: () => boolean): boolean {
154
+ const before = new Set(current.panes.flatMap((pane) => pane.tabs.map(tabSelection)));
155
+ const after = new Set(next.panes.flatMap((pane) => pane.tabs.map(tabSelection)));
156
+ const removed = [...before].filter((id): id is string => id !== null && id.startsWith("note:") && !after.has(id))
157
+ .map((id) => id.slice(5));
158
+ if (removed.some((slug) => drafts.isDirty(slug)) && !confirm()) return false;
159
+ removed.forEach((slug) => drafts.discard(slug));
160
+ drafts.clearTaskSources(removed);
161
+ return true;
162
+ }
163
+
164
+ /** Confirm only dirty drafts affected by a tree mutation, then clear affected state. */
165
+ export function mayMutateDrafts(drafts: DraftStore, affectedSlugs: readonly string[], confirm: () => boolean): boolean {
166
+ const affected = new Set(affectedSlugs);
167
+ const dirty = drafts.dirtySlugs().filter((slug) => affected.has(slug));
168
+ if (dirty.length > 0 && !confirm()) return false;
169
+ affected.forEach((slug) => drafts.discard(slug));
170
+ drafts.clearTaskSources([...affected]);
171
+ return true;
172
+ }
@@ -334,7 +334,7 @@ export function resolveMarkdownLink(index: WikiIndex, href: string): string | nu
334
334
  * Whether an unresolved wikilink should be drawn as a ghost.
335
335
  *
336
336
  * See {@link WikiIndex}: only when the payload independently confirms this
337
- * note has unresolved links. Obsidian's ghost styling is an *offer to create
337
+ * note has unresolved links. Ghost styling is an *offer to create
338
338
  * the note*, so showing it for a link that in fact resolves would be an offer
339
339
  * to create a note that already exists.
340
340
  */
@@ -847,32 +847,6 @@ export const SAVING_LABEL = "Saving…";
847
847
  export const EDITOR_ARIA_LABEL = "Note body";
848
848
  export const DISCARD_PROMPT = "Discard unsaved changes to this note?";
849
849
 
850
- /**
851
- * Whether the draft differs from the note as last loaded. Trailing whitespace
852
- * counts: the save trims, so calling such a draft clean would skip the prompt
853
- * on the one edit that gets silently reverted.
854
- */
855
- export function draftDirty(draft: string, body: string): boolean {
856
- return draft !== body;
857
- }
858
-
859
- /**
860
- * Whether a save's reply still belongs to the editor that issued it. A
861
- * counter, not the slug: reopening the same note is a new session too.
862
- */
863
- export function saveLanded(issued: number, current: number): boolean {
864
- return issued === current;
865
- }
866
-
867
- /**
868
- * Whether the draft moved since the bytes a save sent — keystrokes made
869
- * during the request were never in it, so a success must not close over them.
870
- * Unlike {@link saveLanded}, this is about the *text*, not the session.
871
- */
872
- export function draftMoved(sent: string, current: string | null): boolean {
873
- return current !== null && current !== sent;
874
- }
875
-
876
850
  /** The note header: title, provenance badge, relative time, tags. */
877
851
  export interface NoteHeaderView {
878
852
  readonly title: string;
@@ -959,13 +933,7 @@ export const PREVIEW_GAP = 8;
959
933
  /** The card's kind line for a link nothing in the vault answers to. */
960
934
  export const GHOST_KIND = "no note";
961
935
 
962
- /**
963
- * The card's DOM id, shared by the element and the `aria-describedby` it is
964
- * announced through. A constant rather than an inline literal on both sides
965
- * because the link is a *string* of rendered HTML — see
966
- * `Note.tsx`'s layout effect — and the pair cannot be typed into agreement.
967
- */
968
- export const PREVIEW_ID = "weave-preview";
936
+
969
937
 
970
938
  /** A `PreviewElement` the preview's delegated handlers may land on. */
971
939
  export interface PreviewAnchor {
@@ -9,12 +9,12 @@ import type { SearchState } from "./search.model";
9
9
 
10
10
  export interface SearchPaletteProps {
11
11
  graph: GraphPayload | null;
12
- onSelect: (id: string) => void;
12
+ onSelect: (id: string, newTab?: boolean) => void;
13
13
  onClose: () => void;
14
14
  ports: { fetch: FetchLike };
15
15
  }
16
16
 
17
- function Row({ row, onPick, onHover }: { row: SearchRowView; onPick: () => void; onHover: () => void }) {
17
+ function Row({ row, onPick, onHover }: { row: SearchRowView; onPick: (newTab: boolean) => void; onHover: () => void }) {
18
18
  return (
19
19
  <li
20
20
  id={row.domId}
@@ -22,7 +22,7 @@ function Row({ row, onPick, onHover }: { row: SearchRowView; onPick: () => void;
22
22
  aria-selected={row.active}
23
23
  class={`weave-hit weave-hit-${row.kind}${row.active ? " weave-hit-on" : ""}`}
24
24
  onMouseMove={onHover}
25
- onClick={onPick}
25
+ onClick={(event) => onPick(event.metaKey || event.ctrlKey)}
26
26
  >
27
27
  <span class="weave-hit-badge">{row.badge}</span>
28
28
  <span class="weave-hit-label">{row.label}</span>
@@ -70,9 +70,9 @@ export function SearchPalette(props: SearchPaletteProps) {
70
70
  }, [state.query, props.ports.fetch]);
71
71
 
72
72
  const model = paletteModel(state, props.graph);
73
- const pick = (index: number | null): void => {
73
+ const pick = (index: number | null, newTab = false): void => {
74
74
  const id = resultIdAt(model.rows, index);
75
- if (id !== null) props.onSelect(id);
75
+ if (id !== null) props.onSelect(id, newTab);
76
76
  props.onClose();
77
77
  };
78
78
 
@@ -92,7 +92,7 @@ export function SearchPalette(props: SearchPaletteProps) {
92
92
  if (!next.handled) return;
93
93
  event.preventDefault();
94
94
  setState((current) => ({ ...current, cursor: next.cursor }));
95
- if (next.dismiss) pick(next.activate);
95
+ if (next.dismiss) pick(next.activate, event.metaKey || event.ctrlKey);
96
96
  }}
97
97
  >
98
98
  <input
@@ -108,14 +108,15 @@ export function SearchPalette(props: SearchPaletteProps) {
108
108
  />
109
109
  {model.status === null ? (
110
110
  <ul id="weave-search-results" class="weave-hits" role="listbox" aria-label={PALETTE_TITLE}>
111
- {model.rows.map((row, index) => <Row key={row.id} row={row} onPick={() => pick(index)} onHover={() => setState((current) => ({ ...current, cursor: index }))} />)}
111
+ {model.rows.map((row, index) => <Row key={row.id} row={row} onPick={(newTab) => pick(index, newTab)} onHover={() => setState((current) => ({ ...current, cursor: index }))} />)}
112
112
  </ul>
113
113
  ) : (
114
114
  <p class="weave-palette-status" role="status">{model.status}</p>
115
115
  )}
116
116
  <p class="weave-palette-foot">
117
117
  <span>{model.countLabel}</span>
118
- <span class="weave-palette-hint">{PALETTE_HINT}</span>
118
+ <button type="button" class="weave-chip" disabled={model.count === 0} onClick={() => pick(state.cursor, true)}>Open in new tab</button>
119
+ <span class="weave-palette-hint">{PALETTE_HINT} · ⌘/Ctrl ↵ new tab</span>
119
120
  </p>
120
121
  </div>
121
122
  </div>
@@ -22,13 +22,13 @@ import { Icon } from "../tree/Tree";
22
22
  export interface ContextRailProps {
23
23
  graph: GraphPayload | null;
24
24
  selectedId: string | null;
25
- onSelect: (id: string) => void;
25
+ onSelect: (id: string, newTab?: boolean) => void;
26
26
  }
27
27
 
28
- function Row({ row, onSelect }: { row: ContextRow; onSelect: () => void }) {
28
+ function Row({ row, onSelect }: { row: ContextRow; onSelect: (newTab: boolean) => void }) {
29
29
  return (
30
30
  <li class={`weave-ctx-row${row.selected ? " weave-row-on" : ""}`}>
31
- <button type="button" class="weave-ctx-link" onClick={onSelect}>
31
+ <button type="button" class="weave-ctx-link" onClick={(event) => onSelect(event.metaKey || event.ctrlKey)}>
32
32
  <span class="weave-kind" aria-hidden="true">
33
33
  <Icon name={row.kindIcon} class="weave-icon" />
34
34
  </span>
@@ -64,26 +64,26 @@ function Heading({ view, onToggle }: { view: RailSectionView; onToggle: () => vo
64
64
  );
65
65
  }
66
66
 
67
- function Group({ view, onSelect, onToggle }: { view: RailSectionView; onSelect: (id: string) => void; onToggle: () => void }) {
67
+ function Group({ view, onSelect, onToggle }: { view: RailSectionView; onSelect: (id: string, newTab?: boolean) => void; onToggle: () => void }) {
68
68
  return (
69
69
  <section class="weave-ctx-group">
70
70
  <Heading view={view} onToggle={onToggle} />
71
71
  <ul class="weave-ctx-rows" id={railPanelId(view.heading)} hidden={view.collapsed}>
72
72
  {view.rows.map((row) => (
73
- <Row key={row.id} row={row} onSelect={() => onSelect(row.target)} />
73
+ <Row key={row.id} row={row} onSelect={(newTab) => onSelect(row.target, newTab)} />
74
74
  ))}
75
75
  </ul>
76
76
  </section>
77
77
  );
78
78
  }
79
79
 
80
- function Tag({ tag, onSelect }: { tag: TagGroupRow; onSelect: (id: string) => void }) {
80
+ function Tag({ tag, onSelect }: { tag: TagGroupRow; onSelect: (id: string, newTab?: boolean) => void }) {
81
81
  return (
82
82
  <li class="weave-ctx-tag">
83
83
  <span class="weave-tag">#{tag.tag}</span>
84
84
  <ul class="weave-ctx-rows">
85
85
  {tag.siblings.map((row) => (
86
- <Row key={row.id} row={row} onSelect={() => onSelect(row.target)} />
86
+ <Row key={row.id} row={row} onSelect={(newTab) => onSelect(row.target, newTab)} />
87
87
  ))}
88
88
  </ul>
89
89
  </li>
@@ -102,7 +102,6 @@ export function ContextRail(props: ContextRailProps) {
102
102
  const tags = railTagsView(model.tags, toggles);
103
103
  return (
104
104
  <div class="weave-rail" aria-label={CONTEXT_EMPTY.title}>
105
- <h2 class="weave-col-title">{CONTEXT_EMPTY.title}</h2>
106
105
  {model.empty === null ? null : <p class="weave-ctx-empty">{model.empty}</p>}
107
106
  {model.groups.map((group) => {
108
107
  const view = railSectionView(group, toggles);
@@ -120,4 +119,4 @@ export function ContextRail(props: ContextRailProps) {
120
119
  )}
121
120
  </div>
122
121
  );
123
- }
122
+ }
@@ -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
+ }