pi-weave 0.1.19 → 0.1.21

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.
@@ -65,6 +65,8 @@ export interface ColumnsProps {
65
65
  bootFailed: boolean;
66
66
  /** Slot the graph column fills with its `fit`, for the global `g` key. */
67
67
  fit: { current: (() => void) | null };
68
+ /** `?sliders=1` — the hidden force tuner (docs/weave-workspace.md §15.7). */
69
+ tuner: boolean;
68
70
  }
69
71
 
70
72
  /** One column: a titled region and whichever surface fills it. */
@@ -95,6 +97,7 @@ function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
95
97
  scheme={props.scheme}
96
98
  bootFailed={props.bootFailed}
97
99
  fit={props.fit}
100
+ tuner={props.tuner}
98
101
  />
99
102
  ) : null}
100
103
  {id === "graph" ? <ContextRail graph={props.graph} selectedId={props.selectedId} onSelect={props.onSelect} /> : null}
@@ -60,6 +60,12 @@ export interface ShellProps {
60
60
  initialWidth: number;
61
61
  /** `navigator.platform`, for the `⌘K` vs `Ctrl K` hint. */
62
62
  platform: string;
63
+ /**
64
+ * `location.search` carried `?sliders=1` — open the hidden force tuner over
65
+ * the graph column (docs/weave-workspace.md §15.7). Resolved by `slidersFlag`
66
+ * in `main.tsx`; optional so every existing caller and test keeps its shape.
67
+ */
68
+ tuner?: boolean;
63
69
  }
64
70
 
65
71
  export function Shell(props: ShellProps) {
@@ -206,6 +212,7 @@ export function Shell(props: ShellProps) {
206
212
  storage={localStorage}
207
213
  host={window}
208
214
  fit={fit}
215
+ tuner={props.tuner === true}
209
216
  />
210
217
  {/*
211
218
  `model.generatedAt`, not `stamp`. The two used to be the same string;
@@ -297,6 +297,9 @@ body{font-size:var(--weave-px-base)}
297
297
  border:1px solid var(--weave-line-strong);border-radius:var(--weave-radius);
298
298
  }
299
299
  .weave-chip:hover{color:var(--weave-fg);border-color:var(--weave-accent)}
300
+ /* A chip that is a toggle, while it is on: the accent states it without a
301
+ second control or an icon. */
302
+ .weave-chip-on{color:var(--weave-accent);border-color:var(--weave-accent)}
300
303
  .weave-rows{
301
304
  flex:1;min-height:0;overflow:auto;margin:0;padding:3px 0;list-style:none;
302
305
  }
@@ -511,7 +514,7 @@ body{font-size:var(--weave-px-base)}
511
514
  the \`1fr\` row from \`.weave-col-graph\` above — a zero-height container makes
512
515
  sigma refuse to render (we pass \`allowInvalidContainer\`, so it degrades to
513
516
  blank rather than throwing). */
514
- .weave-graph{display:grid;grid-template-rows:1fr auto auto;min-height:0;overflow:hidden}
517
+ .weave-graph{display:grid;grid-template-rows:1fr auto auto;min-height:0;overflow:hidden;position:relative}
515
518
  .weave-graph-canvas{min-height:120px;min-width:0;position:relative;overflow:hidden}
516
519
  /* A barely-there vignette, so the stage reads as a lit surface rather than a
517
520
  flat void. Sigma clears its WebGL layers transparent (no background colour
@@ -560,6 +563,32 @@ body{font-size:var(--weave-px-base)}
560
563
  border-top:1px solid var(--weave-line);
561
564
  }
562
565
 
566
+ /* the hidden force tuner (?sliders=1, docs/weave-workspace.md 15.7) --------- */
567
+ /* Absolutely positioned over the canvas rather than given a grid row: the
568
+ panel is a debug overlay and must not change the layout the graph is being
569
+ judged in — moving the canvas while tuning it would falsify the very thing
570
+ being looked at. */
571
+ .weave-tuner{
572
+ position:absolute;top:8px;right:8px;z-index:3;width:232px;max-height:calc(100% - 16px);
573
+ overflow:auto;display:flex;flex-direction:column;gap:6px;padding:9px 10px;
574
+ font-size:var(--weave-px-caption);color:var(--weave-fg);
575
+ background:var(--weave-panel);border:1px solid var(--weave-line-strong);
576
+ border-radius:var(--weave-radius);
577
+ }
578
+ .weave-tuner-title{margin:0;color:var(--weave-faint);letter-spacing:.04em;text-transform:uppercase}
579
+ .weave-tuner-row{display:flex;flex-direction:column;gap:2px;cursor:pointer}
580
+ .weave-tuner-label{display:flex;justify-content:space-between;gap:8px;color:var(--weave-dim)}
581
+ .weave-tuner-label b{color:var(--weave-accent);font-weight:600;font-variant-numeric:tabular-nums}
582
+ .weave-tuner-row input{width:100%;margin:0;accent-color:var(--weave-accent)}
583
+ .weave-tuner-toggle{display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--weave-dim)}
584
+ .weave-tuner-toggle input{margin:0;accent-color:var(--weave-accent)}
585
+ .weave-tuner-actions{display:flex;gap:6px;margin-top:2px;flex-wrap:wrap}
586
+ .weave-tuner-snippet{
587
+ margin:0;padding:6px;overflow:auto;white-space:pre;color:var(--weave-faint);
588
+ background:var(--weave-bg);border:1px solid var(--weave-line);border-radius:var(--weave-radius);
589
+ font-size:var(--weave-px-prov);line-height:1.45;
590
+ }
591
+
563
592
  /* context rail ---------------------------------------------------------- */
564
593
  /* The \`.weave-empty\` block that used to sit above went with \`EmptyState.tsx\`
565
594
  in P3: every column now has its own empty state (\`treeEmptyMessage\`,
@@ -1,6 +1,6 @@
1
1
  /** The vault and repository tree column. */
2
2
 
3
- import { useState } from "preact/hooks";
3
+ import { useLayoutEffect, useRef, useState } from "preact/hooks";
4
4
  import { createFolder, deleteFolder, deleteNote, moveNote, renameFolder, renameNote } from "../api";
5
5
  import { fetchJson } from "../api.dom";
6
6
  import { isTextEntry, type KeyTarget } from "../shell/keys.model";
@@ -8,6 +8,8 @@ import { ICON_BOX, ICON_STROKE, ICONS } from "../shell/icons.model";
8
8
  import type { IconName } from "../shell/icons.model";
9
9
  import type { GraphPayload } from "../../shared/wire";
10
10
  import {
11
+ DRAFT_FOLDER_ID,
12
+ deleteItemLabel,
11
13
  FILTER_HINT,
12
14
  FILTER_LABEL,
13
15
  FILTER_PLACEHOLDER,
@@ -20,6 +22,9 @@ import {
20
22
  dropFolder,
21
23
  expand,
22
24
  initialTreeView,
25
+ newFolderParent,
26
+ newFolderPath,
27
+ withDraftRow,
23
28
  internalsHint,
24
29
  internalsLabel,
25
30
  mutableTreeRow,
@@ -80,6 +85,7 @@ function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMe
80
85
  style={depthVar(view.depth)}
81
86
  onClick={onSelect}
82
87
  draggable={view.id.startsWith("note:")}
88
+ aria-label={view.id === DRAFT_FOLDER_ID ? "New folder" : undefined}
83
89
  onDragStart={(event) => event.dataTransfer?.setData("text/plain", view.id)}
84
90
  onDragOver={folder === undefined ? undefined : (event) => event.preventDefault()}
85
91
  onDrop={folder === undefined ? undefined : (event) => { event.preventDefault(); onDrop(event.dataTransfer?.getData("text/plain") ?? ""); }}
@@ -90,17 +96,75 @@ function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMe
90
96
  </span>
91
97
  <span class="weave-kind" aria-hidden="true"><Icon name={view.kindIcon} class="weave-icon" /></span>
92
98
  <span class={`weave-prov weave-prov-${view.provenance ?? "none"}`} title={view.provenanceTitle}>{view.provenanceGlyph}</span>
93
- {edit === null ? <span class="weave-label">{view.label}</span> : <input class="weave-tree-rename" value={edit} autoFocus onClick={(event) => event.stopPropagation()} onInput={(event) => onEdit(event.currentTarget.value)} onBlur={(event) => onRename(event.currentTarget.value)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); else if (event.key === "Escape") onRename(null); }} />}
99
+ {edit === null ? <span class="weave-label">{view.label}</span> : <RenameInput value={edit} draft={view.id === DRAFT_FOLDER_ID} onEdit={onEdit} onCommit={onRename} />}
94
100
  <span class="weave-meta">{view.meta}</span>
95
101
  </li>
96
102
  );
97
103
  }
98
104
 
105
+ /**
106
+ * The inline editor, as its own component so it **mounts** when an edit
107
+ * starts.
108
+ *
109
+ * That is the whole reason it is not an `<input>` inline in {@link Row}: the
110
+ * existing name has to be selected once, when the editor opens, and neither
111
+ * obvious spelling does that. An inline `ref={(el) => el?.select()}` re-runs
112
+ * on every render — preact's children diff fires a ref whenever its
113
+ * *identity* changes (`oldVNode.ref != childVNode.ref` in
114
+ * `diff/children.js`), and an arrow literal is a new identity each time — so
115
+ * every keystroke would re-select the text being typed. `onFocus` re-selects
116
+ * whenever the tab regains focus mid-edit. A mount effect fires exactly once,
117
+ * which is the actual requirement.
118
+ *
119
+ * `useLayoutEffect`, not `useEffect`, so the selection is in place before
120
+ * paint — otherwise the fix itself flashes unselected text for a frame.
121
+ */
122
+ function RenameInput({ value, draft, onEdit, onCommit }: { value: string; draft: boolean; onEdit: (value: string) => void; onCommit: (value: string | null) => void }) {
123
+ const input = useRef<HTMLInputElement | null>(null);
124
+ useLayoutEffect(() => {
125
+ const element = input.current;
126
+ if (element === null) return;
127
+ element.focus();
128
+ // A draft folder has no name yet, and `select()` on an empty input is a
129
+ // no-op — so this needs no branch.
130
+ element.select();
131
+ }, []);
132
+ return (
133
+ <input
134
+ ref={input}
135
+ class="weave-tree-rename"
136
+ value={value}
137
+ placeholder={draft ? "New folder name…" : undefined}
138
+ onClick={(event) => event.stopPropagation()}
139
+ onInput={(event) => onEdit(event.currentTarget.value)}
140
+ onBlur={(event) => onCommit(event.currentTarget.value)}
141
+ onKeyDown={(event) => {
142
+ event.stopPropagation();
143
+ if (event.key === "Enter") event.currentTarget.blur();
144
+ else if (event.key === "Escape") onCommit(null);
145
+ }}
146
+ />
147
+ );
148
+ }
149
+
99
150
  export function Tree(props: TreeProps) {
100
151
  const [state, setState] = useState(initialTreeView);
101
- const [menu, setMenu] = useState<{ id: string; label: string; x: number; y: number } | null>(null);
152
+ /**
153
+ * The open context menu. `armed` is the folder-delete confirmation: the
154
+ * first click on `Delete…` sets it, and only then does the item delete —
155
+ * see `deleteItemLabel`. Held on the menu so closing it disarms, which is
156
+ * what makes a mis-click cheap.
157
+ */
158
+ const [menu, setMenu] = useState<{ id: string; label: string; x: number; y: number; armed?: boolean } | null>(null);
102
159
  const [editing, setEditing] = useState<{ id: string; label: string; value: string } | null>(null);
103
- const rows = rowsFor(props.graph, state);
160
+ /**
161
+ * The parent path a pending "New folder" will be created under, or `null`.
162
+ *
163
+ * The folder is not created until the name is committed — see
164
+ * {@link withDraftRow}. Escaping leaves nothing behind.
165
+ */
166
+ const [draft, setDraft] = useState<string | null>(null);
167
+ const rows = withDraftRow(rowsFor(props.graph, state), draft);
104
168
  const empty = treeEmptyMessage(props.graph, rows, state);
105
169
  const run = async (pending: ReturnType<typeof deleteNote>, select?: string): Promise<void> => {
106
170
  const result = await pending;
@@ -117,18 +181,67 @@ export function Tree(props: TreeProps) {
117
181
  setMenu(null);
118
182
  if (target === null) return;
119
183
  if (action === "newFolder") {
120
- const name = window.prompt("Folder name:")?.trim();
121
- if (!name) return;
122
- const parent = target.type === "vault" ? "" : target.type === "folder" ? target.path : target.path.split("/").slice(0, -1).join("/");
123
- const targetPath = parent ? `${parent}/${name}` : name;
124
- if (parent) setState((current) => expand(current, `vfolder:${parent}`));
125
- await run(createFolder(fetchJson, targetPath));
184
+ // A draft row with the cursor in it, not a modal: the same inline
185
+ // gesture `rename` already uses, and it shows *where* the folder will
186
+ // land, which a prompt cannot.
187
+ const parent = newFolderParent(target);
188
+ if (parent !== "") setState((current) => expand(current, `vfolder:${parent}`));
189
+ setDraft(parent);
190
+ setEditing({ id: DRAFT_FOLDER_ID, label: "", value: "" });
126
191
  } else if (action === "rename") {
127
192
  setEditing({ id: menu.id, label: menu.label, value: menu.label });
128
- } else if (target.type !== "vault" && (!deleteNeedsConfirmation(target) || window.confirm(`Permanently delete folder “${menu.label}” and everything inside it?`))) {
193
+ } else if (target.type !== "vault") {
129
194
  await run(target.type === "note" ? deleteNote(fetchJson, target.path) : deleteFolder(fetchJson, target.path), deletesSelection(props.selectedId, target) ? "vault" : undefined);
130
195
  }
131
196
  };
197
+
198
+ /**
199
+ * The delete item was clicked. Arms first for a folder, deletes second.
200
+ *
201
+ * A note deletes on the first click (§#37): it is one file whose name the
202
+ * user just read off the row. A folder is `fs.rm(recursive)` over a subtree
203
+ * the row shows no count for, so it takes two — and the second click's label
204
+ * names the folder, which is the chance to notice the wrong row was hit.
205
+ */
206
+ const clickDelete = (target: { type: "note" | "folder" | "vault" }): void => {
207
+ if (menu === null) return;
208
+ if (deleteNeedsConfirmation(target) && menu.armed !== true) {
209
+ setMenu({ ...menu, armed: true });
210
+ return;
211
+ }
212
+ void act("delete");
213
+ };
214
+ /**
215
+ * The inline editor was committed (Enter or blur) or abandoned (Escape).
216
+ *
217
+ * Two gestures share one input, so this is where they part: the draft row
218
+ * creates a folder, every other row renames. Both clear the editor first,
219
+ * so an abandoned edit and a failed request leave the same clean state.
220
+ */
221
+ const commitEdit = (value: string | null): void => {
222
+ const current = editing;
223
+ const parent = draft;
224
+ setEditing(null);
225
+ setDraft(null);
226
+ if (current === null) return;
227
+ const name = value?.trim();
228
+ if (current.id === DRAFT_FOLDER_ID) {
229
+ // Escape, or an empty name: the draft simply disappears. Nothing was
230
+ // created, so there is nothing to undo.
231
+ const path = parent === null || !name ? null : newFolderPath(parent, name);
232
+ if (path !== null) void run(createFolder(fetchJson, path));
233
+ return;
234
+ }
235
+ const target = mutableTreeRow(current.id);
236
+ // No confirmation. A rename can break wiki-links, but the warning was
237
+ // unactionable — it named no link and offered no way to see them — and it
238
+ // fired on the *commit* of an edit the user had already typed out, which
239
+ // is the least useful moment to ask. Renaming back is one more rename.
240
+ if (target !== null && name && name !== current.label) {
241
+ void run(target.type === "note" ? renameNote(fetchJson, target.path, name) : renameFolder(fetchJson, target.path, name));
242
+ }
243
+ };
244
+
132
245
  return (
133
246
  <div class="weave-tree" onKeyDown={(event) => {
134
247
  const target = event.target as KeyTarget;
@@ -155,7 +268,7 @@ export function Tree(props: TreeProps) {
155
268
  setMenu({ id: "vault", label: "Vault", x: event.clientX, y: event.clientY });
156
269
  }}
157
270
  >
158
- {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={(value) => { const current = editing; setEditing(null); const target = current === null ? null : mutableTreeRow(current.id); const name = value?.trim(); const label = current?.label; if (target !== null && name && label !== undefined && name !== label && window.confirm(`Rename “${label}”? Existing links to it may break.`)) void run(target.type === "note" ? renameNote(fetchJson, target.path, name) : renameFolder(fetchJson, target.path, name)); }} onSelect={() => { props.onSelect(view.id); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); }} />)}
271
+ {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={() => { props.onSelect(view.id); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); }} />)}
159
272
  </ul>
160
273
  ) : <p class="weave-tree-empty">{empty}</p>}
161
274
  <p class="weave-tree-count">{rowCountLabel(rows)}</p>
@@ -169,7 +282,7 @@ export function Tree(props: TreeProps) {
169
282
  {target.type !== "vault" ? (
170
283
  <>
171
284
  <button type="button" role="menuitem" onClick={() => void act("rename")}>Rename…</button>
172
- <button type="button" role="menuitem" class="weave-menu-danger" onClick={() => void act("delete")}>Delete</button>
285
+ <button type="button" role="menuitem" class="weave-menu-danger" onClick={() => clickDelete(target)}>{deleteItemLabel(target, menu.label, menu.armed === true)}</button>
173
286
  </>
174
287
  ) : null}
175
288
  </div>
@@ -104,6 +104,80 @@ export function rowsFor(payload: GraphPayload | null, state: TreeViewState): Tre
104
104
  return treeRows(viewModel(payload), state);
105
105
  }
106
106
 
107
+ // --- the draft folder row ------------------------------------------------------------
108
+
109
+ /**
110
+ * The id of the placeholder row a pending "New folder" occupies.
111
+ *
112
+ * A real id from the same namespace as the rest of the tree, so the row is an
113
+ * ordinary `TreeRow` that renders, indents and takes the rename input with no
114
+ * special-casing anywhere below {@link withDraftRow}. It cannot collide with a
115
+ * folder the vault holds, because `slugify` strips the leading `\u0000`.
116
+ */
117
+ export const DRAFT_FOLDER_ID = "vfolder:\u0000draft";
118
+
119
+ /**
120
+ * Where a new folder created from `target` would go.
121
+ *
122
+ * A right-click on the vault makes a top-level folder; on a folder, a child of
123
+ * it; on a *note*, a sibling of that note — which is what "new folder here"
124
+ * means when "here" is a file. Returns the parent path, `""` for the root.
125
+ */
126
+ export function newFolderParent(target: { type: "note" | "folder" | "vault"; path: string }): string {
127
+ if (target.type === "vault") return "";
128
+ if (target.type === "folder") return target.path;
129
+ return target.path.split("/").slice(0, -1).join("/");
130
+ }
131
+
132
+ /** The path `createFolder` should be asked for, given a parent and a typed name. */
133
+ export function newFolderPath(parent: string, name: string): string | null {
134
+ const trimmed = name.trim();
135
+ if (trimmed === "") return null;
136
+ return parent === "" ? trimmed : `${parent}/${trimmed}`;
137
+ }
138
+
139
+ /**
140
+ * Splice a draft folder row into the list, under its parent.
141
+ *
142
+ * This is what replaces the `window.prompt`. Renaming is already inline (click
143
+ * a row, type, Enter) and a modal for the adjacent gesture was the odd one
144
+ * out — it also blocks the thread, cannot be styled, and on a folder deep in
145
+ * the tree gives no clue *where* the folder will land. A draft row shows the
146
+ * answer: it sits at the position the real folder will occupy, indented under
147
+ * the right parent, with the cursor in it.
148
+ *
149
+ * Created only after the name is committed, so an abandoned draft leaves
150
+ * nothing behind — the alternative (create "untitled", then rename) litters
151
+ * the vault on Escape and fires two requests for one gesture.
152
+ *
153
+ * The draft goes **last among its parent's subtree** rather than in sort
154
+ * position, because it has no name yet to sort by and a row that jumps as you
155
+ * type is worse than one that appears at a predictable place.
156
+ */
157
+ export function withDraftRow(rows: readonly TreeRow[], parent: string | null): TreeRow[] {
158
+ if (parent === null) return [...rows];
159
+ const draft: TreeRow = {
160
+ id: DRAFT_FOLDER_ID,
161
+ depth: parent === "" ? 1 : parent.split("/").length + 1,
162
+ hasKids: false,
163
+ expanded: false,
164
+ label: "",
165
+ kind: "module",
166
+ provenance: null,
167
+ meta: null,
168
+ };
169
+ // The root's draft goes after the whole tree; a folder's goes after the last
170
+ // row that is still inside that folder. `treeRows` emits a subtree
171
+ // contiguously, so "the last row at greater depth after the parent" is the
172
+ // end of it.
173
+ if (parent === "") return [...rows, draft];
174
+ const at = rows.findIndex((row) => row.id === `vfolder:${parent}`);
175
+ if (at === -1) return [...rows, draft];
176
+ let end = at + 1;
177
+ while (end < rows.length && (rows[end] as TreeRow).depth > (rows[at] as TreeRow).depth) end++;
178
+ return [...rows.slice(0, end), draft, ...rows.slice(end)];
179
+ }
180
+
107
181
  // --- reducers -----------------------------------------------------------------------
108
182
 
109
183
  /**
@@ -157,11 +231,38 @@ export function deletesSelection(selectedId: string | null, target: { type: "not
157
231
  return false;
158
232
  }
159
233
 
160
- /** Notes delete directly; folders retain confirmation because they may contain many notes. */
234
+ /**
235
+ * Notes delete directly; folders ask once, **in the menu**.
236
+ *
237
+ * Not a `window.confirm` — no gesture in this workspace opens a browser
238
+ * dialog any more. But not nothing either: `deleteFolder` is
239
+ * `fs.rm(recursive: true)` on a directory that may hold hundreds of notes,
240
+ * there is no undo, and the tree row shows no count, so a single stray click
241
+ * on a 300-note folder is unrecoverable. A note is one file the user can see
242
+ * the name of, which is why §#37 dropped its confirmation and this keeps one.
243
+ *
244
+ * The confirmation is {@link deleteItemLabel}: the same menu item, clicked
245
+ * twice, with the second click's label saying exactly what it will destroy.
246
+ */
161
247
  export function deleteNeedsConfirmation(target: { type: "note" | "folder" | "vault" }): boolean {
162
248
  return target.type === "folder";
163
249
  }
164
250
 
251
+ /**
252
+ * The delete menu item's label — what pressing it *now* will do.
253
+ *
254
+ * One function so the label and the action cannot disagree: `Tree.tsx` reads
255
+ * the same `armed` flag to decide whether the next click deletes, and a menu
256
+ * item that says "Delete" but arms instead (or the reverse) is the classic
257
+ * version of this bug. Naming the folder in the armed state is the whole
258
+ * safety mechanism — it is the user's chance to notice they right-clicked the
259
+ * wrong row.
260
+ */
261
+ export function deleteItemLabel(target: { type: "note" | "folder" | "vault" }, label: string, armed: boolean): string {
262
+ if (!deleteNeedsConfirmation(target)) return "Delete";
263
+ return armed ? `Delete “${label}” and everything in it` : "Delete…";
264
+ }
265
+
165
266
  /**
166
267
  * Set the substring filter.
167
268
  *
@@ -34,12 +34,75 @@ export function collideRadius(drawnSize: number): number {
34
34
  const DEFAULT_TICKS = 300;
35
35
  const DEFAULT_SEED = 1;
36
36
  const ALPHA_MIN = 0.001;
37
- const CONTAINS_REST = 90;
38
- const CONTAINS_STRENGTH = 0.02;
39
- const RELATION_DISTANCE = 170;
40
- const RELATION_STRENGTH = 0.05;
41
- const CHARGE_STRENGTH = -50;
42
- const CENTER_STRENGTH = 0.09;
37
+
38
+ /**
39
+ * The force constants, as one **mutable** record.
40
+ *
41
+ * Mutable because picking these numbers is an act of taste, not of
42
+ * derivation: the difference between "one hairball" and "legible groups" is a
43
+ * ratio between containment cohesion, charge and centre gravity that is far
44
+ * easier to *see* than to reason about. The hidden tuner (`?sliders=1`, see
45
+ * `client/graph/tuner.model.ts` and docs/weave-workspace.md §15.7) writes here
46
+ * and re-lays out live, so a human can find the values by eye and they then
47
+ * get frozen back into {@link FORCE_DEFAULTS}.
48
+ *
49
+ * Determinism is untouched: every production and test path only ever *reads*
50
+ * this, so `computeLayout(model, { seed })` stays byte-identical. The tuner is
51
+ * the one writer, and it is unreachable without the query flag.
52
+ *
53
+ * ponytail: module-level mutable state, fine at one graph per page — thread it
54
+ * as a `LayoutOptions` field if a second concurrent consumer ever appears.
55
+ */
56
+ export interface ForceConstants {
57
+ /** Rest length of a `contains` / `anchored-at` spring: a rosette's radius. */
58
+ containsRest: number;
59
+ /** Stiffness of that spring. This is what makes a group *be* a group. */
60
+ containsStrength: number;
61
+ /** Rest length of a `links-to` / `mentions` spring: the inter-group reach. */
62
+ relationDistance: number;
63
+ /** Stiffness of that spring. Weak, so associations bend without merging. */
64
+ relationStrength: number;
65
+ /** `forceManyBody` strength. Negative is repulsion. */
66
+ charge: number;
67
+ /**
68
+ * Distance past which charge is ignored (`forceManyBody.distanceMax`).
69
+ *
70
+ * A cap rather than `Infinity` is what keeps a strong charge from simply
71
+ * inflating the whole graph uniformly: beyond it, neighbours stop pushing
72
+ * and only the springs and centre gravity speak, so groups separate at
73
+ * local scale without the picture growing without bound.
74
+ */
75
+ chargeMax: number;
76
+ /** `forceX`/`forceY` pull toward the origin. The no-escape guarantee. */
77
+ center: number;
78
+ }
79
+
80
+ /**
81
+ * The live constants. See {@link ForceConstants} for why this is mutable.
82
+ *
83
+ * These values were **found by eye** through the `?sliders=1` tuner and then
84
+ * frozen here, which is the loop §15.7 describes working as intended. They are
85
+ * not derived and should not be "corrected" toward rounder numbers: the
86
+ * previous set (`containsStrength: 0.02`, `charge: -50`, `center: 0.09`) was
87
+ * defensible on paper and produced a single hairball on screen.
88
+ */
89
+ export const FORCES: ForceConstants = {
90
+ containsRest: 55,
91
+ containsStrength: 0.12,
92
+ relationDistance: 50,
93
+ relationStrength: 0.07,
94
+ charge: -200,
95
+ chargeMax: 800,
96
+ center: 0.05,
97
+ };
98
+
99
+ /** The shipped values, for the tuner's reset and for a test to restore from. */
100
+ export const FORCE_DEFAULTS: Readonly<ForceConstants> = { ...FORCES };
101
+
102
+ /** Overwrite the live constants in place. The tuner's one write. */
103
+ export function setForces(next: Partial<ForceConstants>): void {
104
+ Object.assign(FORCES, next);
105
+ }
43
106
 
44
107
  export function isContainment(kind: EdgeKind): boolean {
45
108
  return kind === "contains" || kind === "anchored-at";
@@ -94,17 +157,19 @@ export function createForceSimulation<N extends SimulationNodeDatum & { id: stri
94
157
  ): Simulation<N, undefined> {
95
158
  const link = forceLink<N, { source: string | N; target: string | N; kind: EdgeKind }>(opts.links)
96
159
  .id((node) => node.id)
97
- .distance((edge) => (isContainment(edge.kind) ? CONTAINS_REST : RELATION_DISTANCE))
98
- .strength((edge) => (isContainment(edge.kind) ? CONTAINS_STRENGTH : RELATION_STRENGTH))
160
+ .distance((edge) => (isContainment(edge.kind) ? FORCES.containsRest : FORCES.relationDistance))
161
+ .strength((edge) => (isContainment(edge.kind) ? FORCES.containsStrength : FORCES.relationStrength))
99
162
  .iterations(2);
100
163
 
164
+ // Read out of `FORCES` here, at construction: a simulation is built fresh
165
+ // per layout run and per live mount, so a tuner write lands on the next one.
101
166
  return forceSimulation<N>(opts.nodes)
102
167
  .randomSource(lcg(opts.seed ?? DEFAULT_SEED))
103
- .force("charge", forceManyBody<N>().strength(CHARGE_STRENGTH))
168
+ .force("charge", forceManyBody<N>().strength(FORCES.charge).distanceMax(FORCES.chargeMax))
104
169
  .force("link", link)
105
170
  .force("collide", forceCollide<N>((node) => node.r ?? COLLIDE_RADIUS).strength(1))
106
- .force("x", forceX<N>(0).strength(CENTER_STRENGTH))
107
- .force("y", forceY<N>(0).strength(CENTER_STRENGTH))
171
+ .force("x", forceX<N>(0).strength(FORCES.center))
172
+ .force("y", forceY<N>(0).strength(FORCES.center))
108
173
  .stop();
109
174
  }
110
175