pi-weave 0.3.0 → 0.3.1

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 (40) hide show
  1. package/README.md +27 -16
  2. package/package.json +1 -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 +210 -178
  9. package/src/web/client/graph/Graph.tsx +18 -0
  10. package/src/web/client/graph/column.model.ts +19 -2
  11. package/src/web/client/graph/graph.model.ts +1 -1
  12. package/src/web/client/graph/positions.ts +1 -1
  13. package/src/web/client/graph/renderer.ts +9 -1
  14. package/src/web/client/note/Note.tsx +66 -91
  15. package/src/web/client/note/drafts.ts +172 -0
  16. package/src/web/client/note/note.model.ts +2 -34
  17. package/src/web/client/search/SearchPalette.tsx +9 -8
  18. package/src/web/client/shell/ContextRail.tsx +8 -9
  19. package/src/web/client/shell/Shell.tsx +371 -254
  20. package/src/web/client/shell/StatusBar.tsx +1 -1
  21. package/src/web/client/shell/focus.model.ts +2 -2
  22. package/src/web/client/shell/keys.model.ts +26 -31
  23. package/src/web/client/shell/keys.ts +1 -0
  24. package/src/web/client/shell/shell.model.ts +31 -107
  25. package/src/web/client/shell/theme.model.ts +4 -4
  26. package/src/web/client/shell/theme.ts +12 -93
  27. package/src/web/client/shell/workspace.css.ts +117 -0
  28. package/src/web/client/tree/Tree.tsx +43 -14
  29. package/src/web/client/tree/tree.model.ts +19 -0
  30. package/src/web/client/workspace.ts +19 -1
  31. package/src/web/server/routes.ts +34 -3
  32. package/src/web/server/server.ts +5 -0
  33. package/src/web/server/workspace-state.ts +121 -0
  34. package/src/web/shared/workspace.ts +270 -0
  35. package/src/web/client/selection.storage.ts +0 -69
  36. package/src/web/client/shell/Columns.tsx +0 -139
  37. package/src/web/client/shell/Divider.tsx +0 -15
  38. package/src/web/client/shell/Header.tsx +0 -119
  39. package/src/web/client/shell/drag.model.ts +0 -35
  40. package/src/web/client/shell/layout.model.ts +0 -132
@@ -14,7 +14,7 @@ export function StatusBar({ model }: { model: StatusBarModel }) {
14
14
  <span class="weave-status-cwd" title={model.cwd}>
15
15
  {model.cwd}
16
16
  </span>
17
- <span class="weave-status-sel" title="the §1.3 context bus — one signal, every column">
17
+ <span class="weave-status-sel" title="Current item">
18
18
  {model.selection}
19
19
  </span>
20
20
  <span class="weave-status-stamp" title="data as of">
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * P4's exit criterion is "the whole workspace is drivable without a mouse",
5
5
  * and a dialog that does not trap focus fails it in the most literal way
6
- * available: Tab from the last control lands on the header behind the
7
- * overlay, the user is now typing into a search box they cannot see, and
6
+ * available: Tab from the last control lands on the workspace behind the
7
+ * overlay, the user is now typing into a field they cannot see, and
8
8
  * `Esc` goes to whatever they landed on. So the palette and the help overlay
9
9
  * both trap, and both restore.
10
10
  *
@@ -90,6 +90,7 @@ export type ShellAction =
90
90
  | { readonly type: "filterTree" }
91
91
  | { readonly type: "fitGraph" }
92
92
  | { readonly type: "clearSelection" }
93
+ | { readonly type: "closeTab" }
93
94
  /** `t` — cycle the colour theme: system → light → dark → system. */
94
95
  | { readonly type: "cycleTheme" };
95
96
 
@@ -112,16 +113,6 @@ export const COMMAND_KEYS: Readonly<Record<string, ShellAction>> = {
112
113
  */
113
114
  export const COLUMN_DIGITS: Readonly<Record<string, ColumnId>> = { "1": "tree", "2": "note", "3": "graph" };
114
115
 
115
- /**
116
- * The columns a breakpoint can put off screen — the graph at `"medium"`, the
117
- * tree too at `"narrow"`, never the note. The help sheet's derived rows use it
118
- * to stay honest about `⌘1`/`⌘3`: at 900 px the `⌘3` command is a documented
119
- * no-op, and a help line that hides that is the same lie the `disabled`
120
- * search button once was. Derived from `columnsAt`, not listed, so the caveat
121
- * cannot drift from the breakpoints the sheet is attached to.
122
- */
123
- const COLLAPSIBLE_COLUMNS: ReadonlySet<ColumnId> = new Set(["tree", "graph"]);
124
-
125
116
  /**
126
117
  * Whether a modifier combination counts as "the platform's command key".
127
118
  *
@@ -168,6 +159,8 @@ export const BARE_KEYS: Readonly<Record<string, ShellAction>> = {
168
159
  export function shellKey(event: KeyDescriptor, ctx: KeyContext): ShellAction | null {
169
160
  if (ctx.overlay !== null) return event.key === "Escape" ? { type: "closeOverlay" } : null;
170
161
 
162
+ if (event.alt && !event.meta && !event.ctrl && !event.shift && event.key.toLowerCase() === "w") return { type: "closeTab" };
163
+
171
164
  if (isCommand(event)) {
172
165
  // Lower-cased because ⌘K with caps lock on reports `"K"`, and a shortcut
173
166
  // that stops working under caps lock is a bug report nobody can reproduce.
@@ -231,6 +224,8 @@ export interface KeyTarget {
231
224
  * use, for the same reason: there is no DOM test environment (§10).
232
225
  */
233
226
  export interface KeyboardEventLike {
227
+ readonly code?: string;
228
+ readonly defaultPrevented: boolean;
234
229
  readonly key: string;
235
230
  readonly ctrlKey: boolean;
236
231
  readonly metaKey: boolean;
@@ -243,7 +238,7 @@ export interface KeyboardEventLike {
243
238
  /** Reduce a platform event to a {@link KeyDescriptor}. */
244
239
  export function describeKey(event: KeyboardEventLike): KeyDescriptor {
245
240
  return {
246
- key: event.key,
241
+ key: event.altKey && event.code === "KeyW" ? "w" : event.key,
247
242
  ctrl: event.ctrlKey,
248
243
  meta: event.metaKey,
249
244
  shift: event.shiftKey,
@@ -254,27 +249,15 @@ export function describeKey(event: KeyboardEventLike): KeyDescriptor {
254
249
 
255
250
  // --- where an action points ---------------------------------------------------------
256
251
 
257
- /**
258
- * `⌘1/2/3` → the element to focus in that column.
259
- *
260
- * Selectors rather than refs, and the reason is the breakpoint: below 1100 px
261
- * the graph column does not exist and below 800 px the tree does not either,
262
- * so a ref would be `null` for a column that is legitimately absent and the
263
- * shell would need a branch per column to cope. A `querySelector` that finds
264
- * nothing is already the same answer, expressed once.
265
- *
266
- * Each target is the column's *content*, not its `<section>`: focusing the
267
- * section would put the ring around the whole pane and leave the arrow keys
268
- * pointing at nothing.
269
- */
252
+ /** Content focus targets; the shell opens hidden sidebars/tabs before focusing. */
270
253
  export const COLUMN_FOCUS_SELECTORS: Readonly<Record<ColumnId, string>> = {
271
- tree: ".weave-col-tree .weave-rows",
272
- note: ".weave-col-note .weave-note-body",
273
- graph: ".weave-col-graph .weave-graph-canvas",
254
+ tree: ".weave-sidebar-notes .weave-rows",
255
+ note: ".weave-pane-active .weave-note-body",
256
+ graph: ".weave-graph-host .weave-graph-canvas",
274
257
  };
275
258
 
276
259
  /** `/` → the tree's filter box. */
277
- export const TREE_FILTER_SELECTOR = ".weave-col-tree .weave-filter";
260
+ export const TREE_FILTER_SELECTOR = ".weave-sidebar-notes .weave-filter";
278
261
 
279
262
  /** The slice of an element {@link focusable} produces. */
280
263
  export interface Focusable {
@@ -336,6 +319,7 @@ export interface ShellEffects {
336
319
  fitGraph(): void;
337
320
  /** Write `null` to §1.3's `selectedId`. */
338
321
  clearSelection(): void;
322
+ closeTab(): void;
339
323
  /** `t` — advance the user's theme choice by one step in its cycle. */
340
324
  cycleTheme(): void;
341
325
  }
@@ -350,8 +334,7 @@ export function runShellAction(action: ShellAction, fx: ShellEffects): void {
350
334
  case "closeOverlay":
351
335
  return fx.setOverlay(null);
352
336
  case "focusColumn":
353
- // The boolean is deliberately dropped: a column that is not on screen
354
- // at this breakpoint is a miss with nothing to report to.
337
+ // The shell reveals the target before focusing it.
355
338
  return void fx.focusSelector(COLUMN_FOCUS_SELECTORS[action.column]);
356
339
  case "filterTree":
357
340
  return void fx.focusSelector(TREE_FILTER_SELECTOR);
@@ -359,6 +342,8 @@ export function runShellAction(action: ShellAction, fx: ShellEffects): void {
359
342
  return fx.fitGraph();
360
343
  case "clearSelection":
361
344
  return fx.clearSelection();
345
+ case "closeTab":
346
+ return fx.closeTab();
362
347
  case "cycleTheme":
363
348
  return fx.cycleTheme();
364
349
  }
@@ -397,10 +382,11 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
397
382
  {
398
383
  title: "Global",
399
384
  entries: [
385
+ { combo: "⌥W / Alt W", what: "Close the active tab" },
400
386
  { combo: `${cmd}K`, what: "Search notes and the repository" },
401
387
  ...Object.entries(COLUMN_DIGITS).map(([digit, column]) => ({
402
388
  combo: `${cmd}${digit}`,
403
- what: `Focus the ${column} column${COLLAPSIBLE_COLUMNS.has(column) ? " (when on screen)" : ""}`,
389
+ what: column === "tree" ? "Open and focus the notes sidebar" : column === "graph" ? "Open and focus Graph view" : "Focus the note",
404
390
  })),
405
391
  { combo: "?", what: "This help" },
406
392
  { combo: "t", what: "Cycle the colour theme (system / light / dark)" },
@@ -418,6 +404,14 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
418
404
  { combo: "Home / End", what: "First / last row" },
419
405
  ],
420
406
  },
407
+ {
408
+ title: "Tabs and panes",
409
+ entries: [
410
+ { combo: "← / → / Home / End", what: "Select a tab when the tab strip is focused" },
411
+ { combo: `${cmd}click`, what: "Open a note or search result in a new tab" },
412
+ { combo: "Pane options (···)", what: "Split right or down, move a tab, or close a pane" },
413
+ ],
414
+ },
421
415
  {
422
416
  title: "Graph",
423
417
  entries: [{ combo: "g", what: "Fit the whole graph" }],
@@ -427,6 +421,7 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
427
421
  entries: [
428
422
  { combo: "↑ / ↓", what: "Move through results" },
429
423
  { combo: "↵", what: "Open the highlighted result" },
424
+ { combo: `${cmd}↵`, what: "Open the highlighted result in a new tab" },
430
425
  { combo: "Esc", what: "Close the palette" },
431
426
  ],
432
427
  },
@@ -49,6 +49,7 @@ export interface KeyOptions {
49
49
  */
50
50
  export function watchKeys(host: KeyHost, opts: KeyOptions): () => void {
51
51
  const listener = (event: KeyboardEventLike): void => {
52
+ if (event.defaultPrevented) return;
52
53
  const action = shellKey(describeKey(event), opts.context());
53
54
  if (action === null) return;
54
55
  event.preventDefault();
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Everything the shell *decides*, as pure functions (weave-workspace §1.2).
3
3
  *
4
- * The header's status summary, the per-column empty-state copy and the status
5
- * bar's text all live here rather than inside the components that render them.
4
+ * The Recent list, per-column empty-state copy and footer status text live
5
+ * here rather than inside the components that render them.
6
6
  * That is not a stylistic
7
7
  * preference: §10 forbids adding a DOM test environment, so a conditional
8
8
  * inside a `.tsx` is a conditional that can never be covered, and §14 lists
@@ -14,67 +14,43 @@
14
14
  * types, no `node:*`, no `src/core`.
15
15
  */
16
16
 
17
- import type { GraphPayload, WireNodeKind, WireStalenessState } from "../../shared/wire";
18
- import { COLUMNS, type ColumnId } from "./layout.model";
19
- export { COLUMNS };
20
- export type { ColumnId };
17
+ import type { GraphPayload, WireNodeKind } from "../../shared/wire";
18
+ import { kindIcon } from "../tree/tree.model";
19
+ import type { IconName } from "./icons.model";
20
+ export const NOTE_DRAG_TYPE = "application/x-weave-note";
21
21
 
22
- // --- the header summary --------------------------------------------------------
23
-
24
- /**
25
- * The `vault:34 · repo:fresh · 127 nodes` readout from the §1.2 sketch.
26
- *
27
- * Kept as three fields rather than one pre-joined string so the component can
28
- * put a separator between them without this module owning a `·`, and so a
29
- * test asserts the numbers rather than a formatting decision.
30
- */
31
- export interface HeaderSummary {
32
- /** Note nodes in the vault. */
33
- readonly notes: number;
34
- /** Repository index freshness, or `null` when the repo is unindexed. */
35
- readonly repo: WireStalenessState | null;
36
- /** Every node, of every kind. */
37
- readonly nodes: number;
22
+ /** Pane drops open only note IDs that still exist in this workspace. */
23
+ export function noteDropId(graph: GraphPayload | null, id: string): string | null {
24
+ return id.startsWith("note:") && graph?.model.nodes.some((node) => node.id === id && node.kind === "note") ? id : null;
38
25
  }
39
26
 
40
- /** The summary before the first graph arrives. */
41
- export const EMPTY_SUMMARY: HeaderSummary = { notes: 0, repo: null, nodes: 0 };
27
+ export type ColumnId = "tree" | "note" | "graph";
28
+ export const COLUMNS: readonly ColumnId[] = ["tree", "note", "graph"];
42
29
 
43
- /**
44
- * Which node kinds count as "vault" for the header.
45
- *
46
- * Only `note`. The `vault` node itself is the container, and counting it
47
- * would make an empty vault read `vault:1` — a number that is technically
48
- * defensible and would still be read as "there is one note in there".
49
- */
50
- const VAULT_KINDS: readonly WireNodeKind[] = ["note"];
30
+ /** Most recent selections in this window, independent of whether their tabs stay open. */
31
+ export function recordVisit(previous: readonly string[], id: string | null): readonly string[] {
32
+ return id === null ? previous : [id, ...previous.filter((item) => item !== id)].slice(0, 100);
33
+ }
51
34
 
52
- /** Derive the header counts from a payload. */
53
- export function summarize(payload: GraphPayload | null): HeaderSummary {
54
- if (payload === null) return EMPTY_SUMMARY;
55
- const nodes = payload.model.nodes;
56
- return {
57
- notes: nodes.filter((node) => VAULT_KINDS.includes(node.kind)).length,
58
- repo: payload.model.staleness?.state ?? null,
59
- nodes: nodes.length,
60
- };
35
+ /** A selected graph node opens only after its preview has already been shown. */
36
+ export function graphClickOpensTab(previewId: string | null, id: string | null): id is string {
37
+ return id !== null && id === previewId;
61
38
  }
62
39
 
63
- /**
64
- * The repo segment's text.
65
- *
66
- * `"unindexed"` rather than `"missing"` for a null or `missing` staleness:
67
- * "missing" reads as an error, and a repository that has simply never been
68
- * scanned is the ordinary first-run state, not a fault. The word the user
69
- * needs is the one that implies an action they can take.
70
- */
71
- export function repoLabel(repo: WireStalenessState | null): string {
72
- return repo === null || repo === "missing" ? "unindexed" : repo;
40
+ export interface RecentEntry {
41
+ readonly id: string;
42
+ readonly label: string;
43
+ readonly icon: IconName;
44
+ readonly selected: boolean;
73
45
  }
74
46
 
75
- /** `vault:34 · repo:fresh · 127 nodes`, as the pieces to join. */
76
- export function summaryParts(summary: HeaderSummary): readonly string[] {
77
- return [`vault:${summary.notes}`, `repo:${repoLabel(summary.repo)}`, `${summary.nodes} nodes`];
47
+ /** Present a frozen visit order without changing it when selection moves. */
48
+ export function recentEntries(ids: readonly string[], graph: GraphPayload | null, selectedId: string | null): RecentEntry[] {
49
+ return ids.map((id) => {
50
+ const node = graph?.model.nodes.find((entry) => entry.id === id);
51
+ const kind: WireNodeKind = node?.kind ?? (id.startsWith("vfolder:") ? "module" : id.startsWith("artifact:") ? "file" : "note");
52
+ return { id, label: node?.label ?? id.replace(/^[^:]+:/, ""), icon: kindIcon(kind), selected: id === selectedId };
53
+ });
78
54
  }
79
55
 
80
56
  // --- empty states ------------------------------------------------------------------
@@ -84,23 +60,6 @@ export interface EmptyStateCopy {
84
60
  readonly title: string;
85
61
  }
86
62
 
87
- const EMPTY_STATES: Readonly<Record<ColumnId, EmptyStateCopy>> = {
88
- tree: {
89
- title: "Tree",
90
- },
91
- note: {
92
- title: "Note",
93
- },
94
- graph: {
95
- title: "Graph",
96
- },
97
- };
98
-
99
- /** The empty-state copy for a column. */
100
- export function emptyStateFor(column: ColumnId): EmptyStateCopy {
101
- return EMPTY_STATES[column];
102
- }
103
-
104
63
  /** The context rail's heading and `aria-label`. */
105
64
  export const CONTEXT_EMPTY: EmptyStateCopy = {
106
65
  title: "Context",
@@ -156,24 +115,6 @@ export function shortStamp(stamp: string | null): string {
156
115
  return match?.[1] ?? stamp;
157
116
  }
158
117
 
159
- // --- the refresh button ------------------------------------------------------------
160
-
161
- /**
162
- * The refresh control's glyph, as SVG path data.
163
- *
164
- * A drawn icon replaces the `⟳` text character for the same reason §P6.4
165
- * retires the tree's glyph soup: a text arrow is whatever the platform's
166
- * fallback font draws it as, while these two strokes are the brand's own
167
- * weight everywhere. Stored as pure data here — §10 — so `Header.tsx` stays
168
- * props-in/JSX-out and the shape is testable without a DOM. 24×24 viewBox,
169
- * stroked, `currentColor`: the header recolours it on hover like any other
170
- * glyph.
171
- */
172
- export const REFRESH_ICON_PATHS: readonly string[] = [
173
- "M21 12a9 9 0 1 1-2.64-6.36",
174
- "M21 3v6h-6",
175
- ];
176
-
177
118
  /**
178
119
  * How often the shell re-renders on its own, in ms.
179
120
  *
@@ -189,29 +130,12 @@ export const TICK_MS = 60_000;
189
130
 
190
131
  // --- the search affordance ---------------------------------------------------------
191
132
 
192
- /**
193
- * The `⌘K` control.
194
- *
195
- * Rendered **disabled** through P1–P3, with a title saying search arrived in
196
- * P4 — a deliberate choice over omitting it: the keyboard hint taught the
197
- * shortcut that would exist, and a `disabled` control explaining itself is
198
- * honest in a way that a working-looking box that does nothing is not.
199
- *
200
- * P4 made it live, and it is now a *button* rather than an `<input>`. The
201
- * palette owns the only text field, so a second one in the header would be
202
- * two places to type a query into and one of them would be a lie — clicking
203
- * it opens the overlay and whatever was typed into the header box would be
204
- * discarded. A button that looks like a search field and opens the real one
205
- * is the affordance the §1.2 sketch actually describes.
206
- */
207
- export const SEARCH_PLACEHOLDER = "Search…";
208
-
209
133
  /** The shortcut hint. `⌘K` on Apple platforms, `Ctrl K` elsewhere. */
210
134
  export function searchShortcut(isApple: boolean): string {
211
135
  return isApple ? "⌘K" : "Ctrl K";
212
136
  }
213
137
 
214
- /** The `title=` on the live search button. Names the key that also opens it. */
138
+ /** The search action's tooltip, including the key that also opens it. */
215
139
  export function searchHint(shortcut: string): string {
216
140
  return `Search notes and repository (${shortcut})`;
217
141
  }
@@ -13,7 +13,7 @@
13
13
  * "Dark" and "light" alone are not enough: stripping a user of "follow my OS"
14
14
  * is a regression the moment anyone prefers the *default* behaviour rather
15
15
  * than either specific scheme. The cycle is `system → light → dark → system`
16
- * so the control is one button, never a menu — a popover in a 34 px header is
16
+ * so the control is one button, never a menu — a popover in the ribbon is
17
17
  * a second focusable thing to reach, and the setting is not worth one.
18
18
  *
19
19
  * ## The attribute, not a class, not inline styles
@@ -32,7 +32,7 @@
32
32
  * ## Tier rules (§2)
33
33
  *
34
34
  * `src/web/client/**`. Storage arrives as the two-method port
35
- * `selection.storage.ts` uses, so a fake is an object literal and quota
35
+ * the graph position cache uses, so a fake is an object literal and quota
36
36
  * failures stay the caller's problem — a theme the user could not persist is
37
37
  * the same cosmetic cost as a selection.
38
38
  */
@@ -76,7 +76,7 @@ export function loadTheme(storage: ThemeStorage): ThemeChoice | null {
76
76
  }
77
77
  }
78
78
 
79
- /** Persist the choice. Reports failure as `false`, like `saveSelection`. */
79
+ /** Persist the choice. Reports failure as `false`. */
80
80
  export function saveTheme(storage: ThemeStorage, choice: ThemeChoice): boolean {
81
81
  try {
82
82
  storage.setItem(THEME_STORAGE_KEY, choice);
@@ -120,7 +120,7 @@ export function themeAttr(choice: ThemeChoice): "light" | "dark" | null {
120
120
  // --- the control's view ------------------------------------------------------------
121
121
 
122
122
  /**
123
- * The header button's face.
123
+ * The ribbon button's face.
124
124
  *
125
125
  * The glyphs are the provenance language the tree already teaches
126
126
  * (`tree.model.ts`): filled, half and hollow of one shape. Filled `●` is
@@ -1,3 +1,5 @@
1
+ import { WORKSPACE_CSS } from "./workspace.css";
2
+
1
3
  /**
2
4
  * The workspace stylesheet, and how it gets past the CSP
3
5
  * (weave-workspace §1.2, §5.2).
@@ -162,7 +164,7 @@ export const THEME_CSS = `
162
164
  row 12 list rows, text inputs, inline code
163
165
  base 13 body copy, h3-h6, preview-card text
164
166
  body 13.5 the note column's prose
165
- subhead 14 palette input, note-body h2, header icon buttons
167
+ subhead 14 palette input, note-body h2, ribbon icon buttons
166
168
  title 15 note-body h1
167
169
  display 20 the note's page title
168
170
  Two earlier sizes were merged into neighbours: 9px glyphs to prov, and
@@ -196,86 +198,7 @@ export const THEME_CSS = `
196
198
  ${LIGHT_TOKENS}
197
199
  }
198
200
  body{font-size:var(--weave-px-base)}
199
- #app{height:100%;display:grid;grid-template-rows:auto 1fr auto;background:var(--weave-bg)}
200
-
201
- /* header --------------------------------------------------------------- */
202
- .weave-header{
203
- display:flex;align-items:center;gap:14px;padding:0 var(--weave-gutter);
204
- height:34px;border-bottom:1px solid var(--weave-line);background:var(--weave-panel);
205
- }
206
- .weave-brand{display:inline-flex;align-items:center;gap:6px;font-size:var(--weave-px-subhead);font-weight:600;letter-spacing:.01em;white-space:nowrap}
207
- .weave-brand-mark{height:20px;width:20px;border-radius:var(--weave-radius)}
208
- /* A button styled as a search field: the palette owns the only text input,
209
- so this opens it rather than pretending to accept a query. */
210
- .weave-search{
211
- display:flex;align-items:center;gap:6px;flex:0 1 260px;min-width:0;
212
- height:22px;padding:0 7px;font:inherit;font-size:var(--weave-px-row);text-align:left;cursor:pointer;
213
- color:var(--weave-dim);background:var(--weave-bg);
214
- border:1px solid var(--weave-line-strong);border-radius:var(--weave-radius);
215
- }
216
- .weave-search:hover{color:var(--weave-fg);border-color:var(--weave-accent)}
217
- .weave-search-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
218
- .weave-search kbd{
219
- font-family:var(--weave-mono);font-size:var(--weave-px-caption);color:var(--weave-faint);
220
- padding:1px 5px;border:1px solid var(--weave-line-strong);border-radius:var(--weave-radius);white-space:nowrap;
221
- }
222
- .weave-summary{
223
- margin-left:auto;display:flex;gap:9px;font-family:var(--weave-mono);
224
- font-size:var(--weave-px-ui);color:var(--weave-dim);white-space:nowrap;
225
- }
226
- .weave-summary-part+.weave-summary-part::before{content:"·";margin-right:9px;color:var(--weave-faint)}
227
- .weave-refresh{
228
- font:inherit;font-size:var(--weave-px-subhead);line-height:1;color:var(--weave-dim);background:none;
229
- border:0;padding:3px 5px;border-radius:var(--weave-radius);cursor:pointer;
230
- display:inline-flex;align-items:center;
231
- }
232
- .weave-refresh:hover{color:var(--weave-fg);background:var(--weave-line)}
233
- /* One full 600 ms turn per click ("the request left"), not a loop: the
234
- refetch is fire-and-forget and a 304 may never fire a completion signal
235
- for a spinner to wait on. Reduced-motion restores the static stroke. */
236
- .weave-refresh-spinning{animation:weave-refresh-turn 600ms ease-out}
237
- @keyframes weave-refresh-turn{from{transform:rotate(0)}to{transform:rotate(360deg)}}
238
- /* The theme cycle (shell/theme.model.ts). Same shape as the refresh button:
239
- an icon-size control in a 34 px bar, glyph-only because the filled/half/
240
- hollow family already means something in this workspace. */
241
- .weave-theme{
242
- font:inherit;font-size:var(--weave-px-base);line-height:1;color:var(--weave-dim);background:none;
243
- border:0;padding:3px 6px;border-radius:var(--weave-radius);cursor:pointer;
244
- }
245
- .weave-theme:hover{color:var(--weave-fg);background:var(--weave-line)}
246
-
247
- /* the grid -------------------------------------------------------------- */
248
- /* Widths arrive as custom properties from layout.model.ts. Shell/layout.model
249
- selects the breakpoint; CSS consumes the resulting data-columns shape. */
250
- .weave-grid{display:grid;min-height:0;overflow:hidden;background:var(--weave-line)}
251
- .weave-grid[data-columns="3"]{grid-template-columns:var(--weave-col-tree,22%) 1px var(--weave-col-note,46%) 1px var(--weave-col-graph,32%)}
252
- .weave-grid[data-columns="2"]{grid-template-columns:var(--weave-col-tree,32%) 1px var(--weave-col-note,68%)}
253
- .weave-grid[data-columns="1"]{grid-template-columns:1fr}
254
-
255
- /* dividers -------------------------------------------------------------- */
256
- .weave-divider{
257
- width:1px;cursor:col-resize;touch-action:none;background:var(--weave-line);position:relative;z-index:2;
258
- }
259
- .weave-divider::after{content:"";position:absolute;inset:0 -4px}
260
- .weave-divider:hover{background:var(--weave-faint)}
261
- .weave-divider:focus-visible{outline:2px solid var(--weave-accent);outline-offset:-1px}
262
-
263
- .weave-col{
264
- display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;
265
- background:var(--weave-bg);
266
- }
267
- .weave-col-title{
268
- margin:0;padding:5px var(--weave-gutter) 4px;font-size:var(--weave-px-caption);font-weight:600;
269
- letter-spacing:.09em;text-transform:uppercase;color:var(--weave-faint);
270
- border-bottom:1px solid var(--weave-line);
271
- }
272
- /* Title, then the graph (which takes the slack), then the context rail. The
273
- 1fr is what gives .weave-graph-canvas a definite height to measure, and the
274
- rail's row is a fixed fraction of the column — not content-sized — so
275
- moving the selection between a sparsely-connected note and a richly
276
- connected one never repartitions the right-hand column: the rail scrolls
277
- inside the same region instead of squeezing the graph. */
278
- .weave-col-graph{display:grid;grid-template-rows:auto minmax(0,1fr) minmax(96px,40%);background:var(--weave-bg)}
201
+ #app{height:100%;display:grid;grid-template-rows:minmax(0,1fr) auto;background:var(--weave-bg)}
279
202
 
280
203
  /* tree column ----------------------------------------------------------- */
281
204
  /* --weave-depth is written per row by \`depthVar\`, through CSSOM
@@ -487,8 +410,8 @@ body{font-size:var(--weave-px-base)}
487
410
  text-decoration-thickness:1px;text-underline-offset:2px;
488
411
  }
489
412
  .weave-note-body a:hover{text-decoration-color:var(--weave-accent)}
490
- /* A ghost is a name with no note behind it: Obsidian's dashed, dimmed
491
- affordance to create one. */
413
+ /* A ghost is a name with no note behind it: a dashed, dimmed affordance to
414
+ create one. */
492
415
  .weave-wiki{cursor:pointer;color:var(--weave-accent);text-decoration:none}
493
416
  .weave-wiki:hover{text-decoration:underline}
494
417
  .weave-wiki-ghost{
@@ -633,9 +556,8 @@ body{font-size:var(--weave-px-base)}
633
556
  text-align:left;background:none;border:0;cursor:pointer;
634
557
  }
635
558
  .weave-ctx-heading:hover{color:var(--weave-fg)}
636
- /* The count is a mono voice, like every other number in the chrome: the
637
- header summary and the status bar count in mono, so a rail that says
638
- "BACKLINKS 4" in the text face would be the one thing off-key. */
559
+ /* The count is a mono voice like the other status numbers, so a rail that
560
+ says "BACKLINKS 4" in the text face would be the one thing off-key. */
639
561
  .weave-ctx-count{
640
562
  margin-left:auto;font-family:var(--weave-mono);font-size:var(--weave-px-caption);
641
563
  letter-spacing:0;color:var(--weave-faint);font-weight:400;
@@ -662,11 +584,8 @@ body{font-size:var(--weave-px-base)}
662
584
  .weave-ctx-tag{margin:0 0 3px}
663
585
  .weave-ctx-tag .weave-ctx-rows{padding-left:12px}
664
586
 
665
- /* status bar ------------------------------------------------------------ */
666
- /* One separator vocabulary, shared with the header summary: every run of
667
- status items after the first opens with the same mid-dot, in the same
668
- --weave-faint. cwd · selection · stamp reads as one grammar,
669
- the same way the header's three-part summary does. */
587
+ /* workspace footer ------------------------------------------------------ */
588
+ /* The status items share one mid-dot separator in --weave-faint. */
670
589
  .weave-status{
671
590
  display:flex;align-items:center;gap:8px;height:22px;padding:0 var(--weave-gutter);
672
591
  font-family:var(--weave-mono);font-size:var(--weave-px-ui);color:var(--weave-dim);
@@ -760,7 +679,7 @@ body{font-size:var(--weave-px-base)}
760
679
  would need delay-unmount plumbing far past polish. Compositor-safe
761
680
  properties only — colour, border, opacity, transform; height and padding
762
681
  snap. */
763
- .weave-row,.weave-ctx-link,.weave-chip,.weave-search,.weave-refresh,.weave-theme,
682
+ .weave-row,.weave-ctx-link,.weave-chip,.weave-ribbon button,
764
683
  .weave-hit,.weave-note-open{
765
684
  transition:background-color 120ms ease,border-color 120ms ease,color 120ms ease;
766
685
  }
@@ -826,7 +745,7 @@ export function findNonce(host: ThemeHost): string | null {
826
745
  * report in the console is a far better diagnostic than a stylesheet that was
827
746
  * never created.
828
747
  */
829
- export function installTheme(host: ThemeHost, css: string = THEME_CSS): boolean {
748
+ export function installTheme(host: ThemeHost, css: string = THEME_CSS + WORKSPACE_CSS): boolean {
830
749
  const element = host.createElement("style");
831
750
  const nonce = findNonce(host);
832
751
  if (nonce !== null) element.nonce = nonce;