pi-weave 0.1.11 → 0.1.13

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 (75) hide show
  1. package/README.md +148 -132
  2. package/package.json +1 -2
  3. package/src/core/cache/workspace.ts +26 -9
  4. package/src/core/concurrency.ts +3 -6
  5. package/src/core/frontmatter.ts +0 -53
  6. package/src/core/graph/build.ts +12 -8
  7. package/src/core/graph/current.ts +4 -6
  8. package/src/core/graph/model.ts +1 -1
  9. package/src/core/graph/wikilinks.ts +3 -3
  10. package/src/core/index.ts +26 -27
  11. package/src/core/paths.ts +0 -7
  12. package/src/core/slug.ts +13 -0
  13. package/src/core/types.ts +1 -0
  14. package/src/core/vault.ts +45 -467
  15. package/src/core/view/detail.ts +1 -1
  16. package/src/core/view/health.ts +1 -1
  17. package/src/core/view/tree.ts +15 -3
  18. package/src/pi/index.ts +6 -85
  19. package/src/pi/summarize.ts +2 -2
  20. package/src/pi/viewer/tui/bodyStore.ts +4 -7
  21. package/src/pi/viewer/tui/branding.ts +7 -148
  22. package/src/pi/viewer/tui/run.ts +3 -17
  23. package/src/pi/viewer/tui/surface/base.ts +24 -3
  24. package/src/pi/viewer/tui/surface/explore.ts +41 -6
  25. package/src/pi/viewer/tui/workspace.ts +23 -351
  26. package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
  27. package/src/pi/viewer/web/run.ts +7 -117
  28. package/src/web/client/api.dom.ts +2 -2
  29. package/src/web/client/api.ts +14 -176
  30. package/src/web/client/bootstrap.ts +5 -14
  31. package/src/web/client/context/context.model.ts +9 -11
  32. package/src/web/client/dist/app.js +77 -167
  33. package/src/web/client/graph/dynamics.ts +5 -65
  34. package/src/web/client/graph/renderer.dom.ts +7 -8
  35. package/src/web/client/graph/renderer.ts +9 -35
  36. package/src/web/client/main.tsx +1 -1
  37. package/src/web/client/note/Note.tsx +37 -60
  38. package/src/web/client/note/note.model.ts +23 -0
  39. package/src/web/client/search/SearchPalette.tsx +45 -36
  40. package/src/web/client/search/search.model.ts +33 -454
  41. package/src/web/client/shell/Columns.tsx +13 -81
  42. package/src/web/client/shell/Header.tsx +2 -10
  43. package/src/web/client/shell/Shell.tsx +50 -123
  44. package/src/web/client/shell/StatusBar.tsx +1 -4
  45. package/src/web/client/shell/icons.model.ts +4 -7
  46. package/src/web/client/shell/keys.model.ts +5 -42
  47. package/src/web/client/shell/keys.ts +2 -2
  48. package/src/web/client/shell/shell.model.ts +10 -133
  49. package/src/web/client/shell/theme.model.ts +2 -2
  50. package/src/web/client/shell/theme.ts +33 -121
  51. package/src/web/client/state.ts +9 -89
  52. package/src/web/client/tree/Tree.tsx +24 -97
  53. package/src/web/client/tree/tree.model.ts +8 -56
  54. package/src/web/client/workspace.ts +72 -242
  55. package/src/web/server/page.ts +8 -10
  56. package/src/web/server/routes.ts +30 -415
  57. package/src/web/server/server.ts +6 -145
  58. package/src/web/shared/layout.ts +72 -624
  59. package/src/web/shared/wire.ts +10 -178
  60. package/src/core/sessions.ts +0 -929
  61. package/src/pi/sessionScan.ts +0 -104
  62. package/src/pi/viewer/tui/explorer.ts +0 -586
  63. package/src/web/client/live.model.ts +0 -275
  64. package/src/web/client/live.ts +0 -151
  65. package/src/web/client/note/Editor.tsx +0 -109
  66. package/src/web/client/note/editor.controller.ts +0 -151
  67. package/src/web/client/note/editor.model.ts +0 -686
  68. package/src/web/client/search/search.ts +0 -107
  69. package/src/web/client/shell/Divider.tsx +0 -44
  70. package/src/web/client/shell/cssvars.ts +0 -70
  71. package/src/web/client/shell/drag.model.ts +0 -170
  72. package/src/web/client/shell/layout.model.ts +0 -500
  73. package/src/web/client/shell/viewport.ts +0 -29
  74. package/src/web/server/sse.ts +0 -321
  75. package/src/web/server/watcher.ts +0 -507
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * `keys.model.ts` decides what a keystroke means; this attaches one listener
5
5
  * and performs the answer. Four lines of it are the listener and the rest is
6
- * dispatch, which is deliberate — the same split `viewport.ts` uses, and for
7
- * the same reason: a subscription written inline in a `useEffect` is one no
6
+ * dispatch, which is deliberate — a subscription written inline in a
7
+ * `useEffect` is one no
8
8
  * test can reach.
9
9
  *
10
10
  * ## One listener, on the document, at the bubble phase
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Everything the shell *decides*, as pure functions (weave-workspace §1.2).
3
3
  *
4
- * The header's status summary, the connection indicator's wording, the
5
- * per-column empty-state copy and the status bar's text all live here rather
6
- * than inside the components that render them. That is not a stylistic
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.
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
9
9
  * "coverage gate blocks the UI work" as a live risk whose stated mitigation
@@ -15,9 +15,11 @@
15
15
  */
16
16
 
17
17
  import type { GraphPayload, WireNodeKind, WireStalenessState } from "../../shared/wire";
18
- import type { ConnectionState } from "../state";
19
- import type { ColumnId, DividerId, ResolvedColumn } from "./layout.model";
20
- import { DIVIDERS } from "./layout.model";
18
+ /** The three fixed workspace surfaces. */
19
+ export type ColumnId = "tree" | "note" | "graph";
20
+
21
+ /** Columns in keyboard and visual order. */
22
+ export const COLUMNS: readonly ColumnId[] = ["tree", "note", "graph"];
21
23
 
22
24
  // --- the header summary --------------------------------------------------------
23
25
 
@@ -77,93 +79,22 @@ export function summaryParts(summary: HeaderSummary): readonly string[] {
77
79
  return [`vault:${summary.notes}`, `repo:${repoLabel(summary.repo)}`, `${summary.nodes} nodes`];
78
80
  }
79
81
 
80
- // --- the connection indicator ----------------------------------------------------
81
-
82
- /**
83
- * How a connection state is presented.
84
- *
85
- * `tone` is a class suffix rather than a colour, so the palette stays in the
86
- * stylesheet where §5.2's nonce'd CSS can own it and this module stays free
87
- * of presentation constants it cannot test the appearance of.
88
- */
89
- export interface ConnectionView {
90
- readonly label: string;
91
- readonly tone: "ok" | "warn" | "bad";
92
- /** The `title=` tooltip. Says what the user should expect to happen next. */
93
- readonly hint: string;
94
- }
95
-
96
- const CONNECTION_VIEWS: Readonly<Record<ConnectionState, ConnectionView>> = {
97
- live: { label: "live", tone: "ok", hint: "watching the vault and the repository for changes" },
98
- reconnecting: {
99
- label: "reconnecting",
100
- tone: "warn",
101
- // Naming the recovery matters: §6 has the client refetch everything on
102
- // reopen, so the user's screen will catch up on its own and they should
103
- // not go looking for a reload button.
104
- hint: "the event stream dropped — retrying, and everything refetches when it returns",
105
- },
106
- offline: {
107
- label: "offline",
108
- tone: "bad",
109
- hint: "the workspace server is gone — the header's refresh control retries",
110
- },
111
- };
112
-
113
- /** Present a connection state. Total over the three states of §1.3. */
114
- export function connectionView(state: ConnectionState): ConnectionView {
115
- return CONNECTION_VIEWS[state];
116
- }
117
-
118
82
  // --- empty states ------------------------------------------------------------------
119
83
 
120
- /**
121
- * A column's title, and the placeholder shown while its content is someone
122
- * else's phase.
123
- *
124
- * P1's deliverable for the three columns was an *honest* empty state naming
125
- * the phase that would fill it — more useful than a spinner implying a load,
126
- * and far more useful than a blank pane that reads as a bug.
127
- *
128
- * **As of P3 no column uses one.** All three are built, so `Columns.tsx`
129
- * renders their real surfaces and each has its *own* empty states —
130
- * `treeEmptyMessage`, `noteEmptyMessage` and `graphEmptyMessage`, which
131
- * distinguish "loading" from "filtered to nothing" from "genuinely empty" in a
132
- * way one static sentence cannot.
133
- *
134
- * The table stays because {@link EmptyStateCopy.title} is still every column's
135
- * heading and its `aria-label`. {@link EmptyStateCopy.body} and `phase` are
136
- * now dead for all three, and are kept accurate rather than deleted for one
137
- * reason: `tests/web/client-shell.test.ts` asserts the `phase` values against
138
- * §11, so a column whose phase silently disagreed with the doc would be the
139
- * first sign that this table had stopped tracking reality. `EmptyState.tsx`
140
- * itself now has no caller, and is deleted rather than kept warm — an unused
141
- * component is a thing the next reader has to work out is unused.
142
- */
84
+ /** The title used by a column heading and its `aria-label`. */
143
85
  export interface EmptyStateCopy {
144
86
  readonly title: string;
145
- readonly body: string;
146
- /** The phase that fills this column in, e.g. `"P2"`. */
147
- readonly phase: string;
148
87
  }
149
88
 
150
89
  const EMPTY_STATES: Readonly<Record<ColumnId, EmptyStateCopy>> = {
151
90
  tree: {
152
91
  title: "Tree",
153
- // Built in P2; body and phase are no longer rendered. See the header.
154
- body: "The vault and repository outline, expandable, filterable, with provenance markers.",
155
- phase: "P2",
156
92
  },
157
93
  note: {
158
94
  title: "Note",
159
- body: "Select anything to read it here — rendered Markdown, front matter, tags and wikilinks.",
160
- phase: "P2",
161
95
  },
162
96
  graph: {
163
97
  title: "Graph",
164
- // Built in P3; body and phase are no longer rendered. See the header.
165
- body: "The knowledge graph, laid out and navigable, with the selection's neighbourhood highlighted.",
166
- phase: "P3",
167
98
  },
168
99
  };
169
100
 
@@ -172,18 +103,9 @@ export function emptyStateFor(column: ColumnId): EmptyStateCopy {
172
103
  return EMPTY_STATES[column];
173
104
  }
174
105
 
175
- /**
176
- * The context rail's title.
177
- *
178
- * Built in P2.5, so `body`/`phase` are no longer rendered either — the rail's
179
- * real empty states are `RAIL_EMPTY` in `context/context.model.ts`, which
180
- * separates "loading" from "nothing selected" from "this node is isolated".
181
- * `title` is still the rail's heading and `aria-label`.
182
- */
106
+ /** The context rail's heading and `aria-label`. */
183
107
  export const CONTEXT_EMPTY: EmptyStateCopy = {
184
108
  title: "Context",
185
- body: "Links, backlinks and mentions for whatever is selected.",
186
- phase: "P2",
187
109
  };
188
110
 
189
111
  // --- the status bar -------------------------------------------------------------------
@@ -200,7 +122,6 @@ export const CONTEXT_EMPTY: EmptyStateCopy = {
200
122
  export interface StatusBarModel {
201
123
  readonly cwd: string;
202
124
  readonly selection: string;
203
- readonly connection: ConnectionView;
204
125
  /** `null` before the first successful graph fetch. */
205
126
  readonly stamp: string | null;
206
127
  }
@@ -212,13 +133,11 @@ export const NO_VALUE = "—";
212
133
  export function statusBarModel(
213
134
  cwd: string,
214
135
  selectedId: string | null,
215
- connection: ConnectionState,
216
136
  stamp: string | null,
217
137
  ): StatusBarModel {
218
138
  return {
219
139
  cwd: cwd === "" ? NO_VALUE : cwd,
220
140
  selection: selectedId ?? "nothing selected",
221
- connection: connectionView(connection),
222
141
  stamp,
223
142
  };
224
143
  }
@@ -239,48 +158,6 @@ export function shortStamp(stamp: string | null): string {
239
158
  return match?.[1] ?? stamp;
240
159
  }
241
160
 
242
- // --- pairing columns with dividers ---------------------------------------------------
243
-
244
- /**
245
- * One rendered column, plus the divider that follows it (if any).
246
- *
247
- * `divider` is `null` for the last column: there are three columns and two
248
- * dividers, and the trailing edge of the grid is the window, not a handle.
249
- */
250
- export interface ColumnSlot {
251
- readonly column: ResolvedColumn;
252
- readonly divider: DividerId | null;
253
- }
254
-
255
- /**
256
- * Interleave resolved columns with the dividers between them.
257
- *
258
- * This exists as a model function rather than an `index + 1 < length` check
259
- * inside the JSX for two reasons. The first is coverage: that check is a
260
- * branch, and a branch in a `.tsx` is a branch no test can reach (§10). The
261
- * second is that the naive version is *wrong at a breakpoint* — at `"medium"`
262
- * only tree and note render, so the divider after `note` must not appear even
263
- * though `DIVIDERS` contains one. Deriving the pairing from the columns
264
- * actually being rendered, rather than from the static divider list, makes
265
- * that impossible to get wrong.
266
- */
267
- export function columnSlots(resolved: readonly ResolvedColumn[]): readonly ColumnSlot[] {
268
- return resolved.map((column, index) => ({
269
- column,
270
- // A divider is *named by the column to its left* (`layout.model.ts`'s
271
- // `dividerPair`), so the id is the column's own — no index arithmetic into
272
- // `DIVIDERS`, which would need an unreachable `?? null` to satisfy
273
- // `noUncheckedIndexedAccess` and would leave a branch no test can cover.
274
- // It exists only where a next column follows to resize against.
275
- divider: index < resolved.length - 1 && isDivider(column.id) ? column.id : null,
276
- }));
277
- }
278
-
279
- /** Whether a column has a divider named after it — i.e. is not the last one. */
280
- function isDivider(column: ColumnId): column is DividerId {
281
- return (DIVIDERS as readonly ColumnId[]).includes(column);
282
- }
283
-
284
161
  // --- the refresh button ------------------------------------------------------------
285
162
 
286
163
  /**
@@ -45,7 +45,7 @@ export type ThemeChoice = "system" | "light" | "dark";
45
45
  /** The choices in cycle order. */
46
46
  export const THEME_CHOICES: readonly ThemeChoice[] = ["system", "light", "dark"];
47
47
 
48
- /** The `localStorage` key. Namespaced and versioned, like the layout's. */
48
+ /** The `localStorage` key. Namespaced and versioned. */
49
49
  export const THEME_STORAGE_KEY = "pi-weave.theme.v1";
50
50
 
51
51
  /** The slice of `Storage` this module needs. */
@@ -144,4 +144,4 @@ export function themeButton(choice: ThemeChoice): ThemeButtonView {
144
144
  glyph: THEME_GLYPHS[choice],
145
145
  hint: `Colour theme: ${choice} — click for ${cycleTheme(choice)}`,
146
146
  };
147
- }
147
+ }
@@ -6,8 +6,8 @@
6
6
  *
7
7
  * `page.ts` emits a nonce'd `<style>` block, but it is deliberately tiny —
8
8
  * the custom-property palette and enough body rules that the first paint has
9
- * a background colour before `app.js` parses. It knows nothing about a grid,
10
- * a divider or a status bar, and it is owned by the server tier, which this
9
+ * a background colour before `app.js` parses. It knows nothing about the
10
+ * status bar, and it is owned by the server tier, which this
11
11
  * work may not edit. There is also no `/app.css` route. So the shell's own
12
12
  * rules have to travel in the bundle and be installed at runtime.
13
13
  *
@@ -19,8 +19,8 @@
19
19
  * 1. **A literal `style="…"` attribute in markup is blocked.** That is what
20
20
  * `'unsafe-inline'` governs for styles, and it is absent.
21
21
  * 2. **CSSOM writes are not.** `el.style.setProperty(…)` has no CSP hook at
22
- * all, which is why `cssvars.ts` applies column widths that way and why
23
- * dynamic layout works here. Confirmed against Preact's own behaviour:
22
+ * all, which is why dynamic note preview positions use CSSOM. Confirmed
23
+ * against Preact's own behaviour:
24
24
  * `preact/src/diff/props.js` handles a `style` prop via
25
25
  * `dom.style.cssText` or `style.setProperty`, never `setAttribute`.
26
26
  * 3. **A script-created `<style>` element needs the nonce.** Inserting a
@@ -161,7 +161,7 @@ export const THEME_CSS = `
161
161
  ui 11.5 status bar, the note's meta row, small controls
162
162
  row 12 list rows, text inputs, inline code
163
163
  base 13 body copy, h3-h6, preview-card text
164
- body 13.5 the note column's prose and its mono editor
164
+ body 13.5 the note column's prose
165
165
  subhead 14 palette input, note-body h2, header icon buttons
166
166
  title 15 note-body h1
167
167
  display 20 the note's page title
@@ -244,30 +244,21 @@ body{font-size:var(--weave-px-base)}
244
244
  }
245
245
  .weave-theme:hover{color:var(--weave-fg);background:var(--weave-line)}
246
246
 
247
- /* connection indicator -------------------------------------------------- */
248
- .weave-conn{display:inline-flex;align-items:center;gap:5px;font-size:var(--weave-px-ui);white-space:nowrap}
249
- .weave-conn-dot{font-size:var(--weave-px-prov);line-height:1}
250
- /* Reconnecting breathes instead of sitting there: the one state whose whole
251
- message is "hold on, I am still working" is the one that should keep
252
- saying it. The offline dot stays still — its message needs a decision,
253
- not patience. The global reduced-motion kill switch restores the static
254
- dot, the same way it does every animation in this sheet. */
255
- .weave-conn-ok .weave-conn-dot,.weave-conn-ok{color:var(--weave-ok)}
256
- .weave-conn-warn .weave-conn-dot,.weave-conn-warn{color:var(--weave-warn)}
257
- .weave-conn-warn .weave-conn-dot{animation:weave-conn-pulse 1.6s ease-in-out infinite}
258
- .weave-conn-bad .weave-conn-dot,.weave-conn-bad{color:var(--weave-bad)}
259
- @keyframes weave-conn-pulse{0%,100%{opacity:1}50%{opacity:.35}}
260
-
261
247
  /* the grid -------------------------------------------------------------- */
262
- /* Widths arrive as custom properties from cssvars.ts — the CSSOM path. The
263
- fallbacks keep the layout sane for the first frame and if a write is ever
264
- missed. */
265
- .weave-grid{display:grid;min-height:0;overflow:hidden;background:var(--weave-line)}
266
- .weave-grid[data-columns="3"]{
267
- grid-template-columns:var(--weave-col-tree,22%) 1px var(--weave-col-note,46%) 1px var(--weave-col-graph,32%);
268
- }
269
- .weave-grid[data-columns="2"]{grid-template-columns:var(--weave-col-tree,32%) 1px var(--weave-col-note,68%)}
270
- .weave-grid[data-columns="1"]{grid-template-columns:1fr}
248
+ /* One boring grid. CSS owns the breakpoints; no saved widths, divider state
249
+ or viewport listener can make the shell disagree with the browser. */
250
+ .weave-grid{
251
+ display:grid;min-height:0;overflow:hidden;background:var(--weave-line);
252
+ grid-template-columns:minmax(180px,22fr) minmax(320px,46fr) minmax(260px,32fr);
253
+ }
254
+ @media (max-width:1099px){
255
+ .weave-grid{grid-template-columns:minmax(180px,32fr) minmax(320px,68fr)}
256
+ .weave-col-graph{display:none}
257
+ }
258
+ @media (max-width:799px){
259
+ .weave-grid{grid-template-columns:1fr}
260
+ .weave-col-tree{display:none}
261
+ }
271
262
 
272
263
  .weave-col{
273
264
  display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;
@@ -286,18 +277,8 @@ body{font-size:var(--weave-px-base)}
286
277
  inside the same region instead of squeezing the graph. */
287
278
  .weave-col-graph{display:grid;grid-template-rows:auto minmax(0,1fr) minmax(96px,40%);background:var(--weave-bg)}
288
279
 
289
- /* dividers -------------------------------------------------------------- */
290
- .weave-divider{
291
- background:var(--weave-line);cursor:col-resize;position:relative;
292
- touch-action:none;
293
- }
294
- /* A 1 px target is unhittable, so the hit area is widened with a pseudo
295
- element rather than by making the visible rule thicker. */
296
- .weave-divider::after{content:"";position:absolute;inset:0 -3px;z-index:1}
297
- .weave-divider:hover,.weave-divider:focus-visible{background:var(--weave-accent);outline:none}
298
-
299
280
  /* tree column ----------------------------------------------------------- */
300
- /* --weave-depth is written per row by \`depthVar\`, through the same CSSOM
281
+ /* --weave-depth is written per row by \`depthVar\`, through CSSOM
301
282
  path as the column widths, and multiplied by a step this sheet owns — so
302
283
  the tree's density stays a CSS decision. */
303
284
  .weave-tree{display:flex;flex-direction:column;min-height:0;flex:1}
@@ -332,26 +313,17 @@ body{font-size:var(--weave-px-base)}
332
313
  hover from reading as a stray grey rectangle — the row answers the pointer
333
314
  in both channels at once. */
334
315
  .weave-row:hover .weave-label{color:var(--weave-fg)}
335
- /* The session fold (tree.model.ts's isMuted): machine-written memory that
336
- accrues by the dozens with near-duplicate titles, dropped one notch so the
337
- rows a human wrote keep the foreground. --weave-dim holds ≥ 4.5 on both
338
- grounds, so this is quiet, not illegible — and the two states below always
339
- restore full weight, which is why the model never emits both classes on
340
- one row. */
341
- .weave-row-muted .weave-label{color:var(--weave-dim)}
342
- .weave-row-muted:hover .weave-label{color:var(--weave-fg)}
343
316
  /* The selection has one voice. On the tinted ground every quieter token still
344
317
  fails contrast — even --weave-dim lands under 4.5 — so a selected row's
345
318
  kind, provenance and meta children join its label in --weave-fg. The
346
319
  provenance glyph shape carries the distinction the colour swap drops;
347
320
  elsewhere the hues are unaffected. The palette's hit rows need the same
348
321
  remap: same selected ground, same failure. The label is restated last
349
- because .weave-row-muted outranks the inheritance the row-on rule relies
350
- on. */
322
+ so the selected row is always readable. */
351
323
  .weave-row-on .weave-twisty,.weave-row-on .weave-kind,.weave-row-on .weave-prov,
352
324
  .weave-row-on .weave-meta,.weave-row-on .weave-label{color:var(--weave-fg)}
353
325
  .weave-hit-on .weave-hit-badge,.weave-hit-on .weave-hit-detail{color:var(--weave-fg)}
354
- /* A newly-arrived node (a file or note added since the last update, §6):
326
+ /* A newly-arrived node (a file or note added since the last update, ):
355
327
  one short highlight that fades while the label settles from bold back to
356
328
  normal. The class is computed from the frame diff in workspace.ts and
357
329
  expires with it, so collapsing and re-expanding later does not replay the
@@ -378,7 +350,7 @@ body{font-size:var(--weave-px-base)}
378
350
 
379
351
  /* icons ------------------------------------------------------------------
380
352
  The sprite's only CSS: one colour (the glyph inherits the row's, so a
381
- muted row's icon recedes with it and a selected row's brightens with it)
353
+ quiet row's icon recedes with it and a selected row's brightens with it)
382
354
  and the twisty's rotation, which is the *same* 16px chevron the rail's
383
355
  group headings use — right-pointing closed, rotated 90° open. The
384
356
  rotation is not in the motion block's transition list, on purpose: that
@@ -394,8 +366,8 @@ body{font-size:var(--weave-px-base)}
394
366
  so it lies on --weave-page — one step off the desk and the canvas grounds.
395
367
  The 2px rule flush to its left edge is the spine: it takes the note's
396
368
  provenance colour (see the --weave-spine map), so the document's origin is
397
- readable peripherally, before any glyph is. Head, body and editor share the
398
- --weave-note-gutter so toggling the editor does not reflow the measure. */
369
+ readable peripherally, before any glyph is. Head and body share the
370
+ --weave-note-gutter so the page has one consistent measure. */
399
371
  .weave-note{display:flex;flex-direction:column;min-height:0;flex:1;overflow:auto;background:var(--weave-page)}
400
372
  .weave-note-human{--weave-spine:var(--weave-ok)}
401
373
  .weave-note-agent{--weave-spine:var(--weave-accent)}
@@ -407,9 +379,7 @@ body{font-size:var(--weave-px-base)}
407
379
  alternative, a title that scrolls away, is how a reader ends up annotating
408
380
  the wrong file. It carries the page ground rather than a fill or a shadow,
409
381
  so the reveal reads as the page continuing under it and not as a bar
410
- arriving; the hairline is the only seam. The editor bar is the *next*
411
- sibling in the column flow, so it can never slide underneath — no stacking
412
- contest exists between them. The head's z-index is only what keeps it above
382
+ arriving; the hairline is the only seam. The head's z-index is only what keeps it above
413
383
  the prose (and the wikilinks inside it) as that prose scrolls beneath. */
414
384
  .weave-note-head{position:sticky;top:0;z-index:2;padding:14px var(--weave-note-gutter) 9px;background:var(--weave-page);border-bottom:1px solid var(--weave-line)}
415
385
  /* The page's largest voice, and the point of the review's headline finding:
@@ -423,13 +393,9 @@ body{font-size:var(--weave-px-base)}
423
393
  so the eye can read the whole of it without ever reading it. */
424
394
  .weave-note-meta{margin:0;display:flex;align-items:center;gap:8px;font-size:var(--weave-px-ui);color:var(--weave-dim);flex-wrap:wrap}
425
395
  .weave-note-time{color:var(--weave-faint)}
426
- /* \`Open in $EDITOR\`, demoted (P6.3). It used to be the bar's full-width
427
- bordered button — the note's loudest control, sitting between its title and
428
- its prose. Now it is an icon at the end of the meta line: the hint rides
429
- the \`title\`/\`aria-label\` (\`editor.model.ts\`'s OPEN_HINT/OPEN_LABEL), and
430
- the control is quiet until the pointer asks for it. */
396
+ /* \`Open in $EDITOR\` is a quiet icon at the end of the meta line. */
431
397
  .weave-note-open{
432
- margin-left:auto;display:inline-flex;align-items:center;justify-content:center;
398
+ display:inline-flex;align-items:center;justify-content:center;
433
399
  width:22px;height:22px;padding:0;font:inherit;color:var(--weave-faint);
434
400
  background:none;border:0;border-radius:var(--weave-radius);cursor:pointer;
435
401
  }
@@ -444,9 +410,7 @@ body{font-size:var(--weave-px-base)}
444
410
  withdrawn at the user's call: this is a workspace, and the note shares the
445
411
  width the desk gives it. It reads at --weave-px-body on 1.7 rather than the
446
412
  chrome's 1.6: the extra leading is what a page of running text needs that a
447
- status bar does not. Code and the raw editor stay mono deliberately —
448
- reading prose and reading raw are different postures, and the face switch
449
- is the toggle. */
413
+ status bar does not. Code stays mono deliberately. */
450
414
  .weave-note-body{padding:12px var(--weave-note-gutter) 28px;font-size:var(--weave-px-body);line-height:1.7;color:var(--weave-fg)}
451
415
  .weave-note-body>*:first-child{margin-top:0}
452
416
  .weave-note-body h1,.weave-note-body h2,.weave-note-body h3,
@@ -534,53 +498,6 @@ body{font-size:var(--weave-px-base)}
534
498
  underlines a hover should not land with a thud. */
535
499
  @keyframes weave-preview-in{from{opacity:0;transform:translateY(2px)}}
536
500
 
537
- /* note editor (§11 P5) ---------------------------------------------------- */
538
- /* A \`<textarea>\`, not CodeMirror: §0 V10 measured CM6 at 118 KB gzip, more
539
- than the entire rest of the client. It inherits the body's type so toggling
540
- \`⌘E\` does not reflow the column into a different shape. */
541
- .weave-note-bar{
542
- display:flex;align-items:center;gap:6px;flex-wrap:wrap;
543
- padding:5px var(--weave-gutter);border-bottom:1px solid var(--weave-line);
544
- }
545
- /* \`weave-note-open\` is styled in the note column above (P6.3 moved it into
546
- the head's meta row as an icon), and no longer shares the bar's bordered
547
- button voice. */
548
- .weave-note-toggle,.weave-note-save,.weave-note-action{
549
- font:inherit;font-size:var(--weave-px-ui);padding:2px 8px;cursor:pointer;
550
- color:var(--weave-fg);background:var(--weave-raise);
551
- border:1px solid var(--weave-line);border-radius:var(--weave-radius);
552
- }
553
- .weave-note-toggle:hover,.weave-note-save:hover,.weave-note-action:hover{
554
- border-color:var(--weave-accent);
555
- }
556
- .weave-note-toggle[aria-pressed="true"]{border-color:var(--weave-accent);color:var(--weave-accent)}
557
- .weave-note-save:disabled{opacity:.45;cursor:default;border-color:var(--weave-line)}
558
- .weave-note-dirty{color:var(--weave-accent);font-size:var(--weave-px-subhead);line-height:1}
559
- .weave-note-status{font-size:var(--weave-px-ui);color:var(--weave-dim)}
560
- .weave-note-status-ok{color:var(--weave-ok)}
561
- .weave-note-status-warn{color:var(--weave-warn)}
562
- /* The prompt takes the full row rather than sitting inline: reload-or-overwrite
563
- is a decision about the user's unsaved text, and a decision squeezed between
564
- two buttons reads as a hint. */
565
- .weave-note-prompt{
566
- flex-basis:100%;display:flex;align-items:center;gap:8px;flex-wrap:wrap;
567
- margin-top:4px;padding:5px 8px;border-radius:var(--weave-radius);
568
- background:var(--weave-raise);border:1px solid var(--weave-warn);
569
- }
570
- .weave-note-prompt-conflict,.weave-note-prompt-collision{border-color:var(--weave-warn)}
571
- .weave-note-prompt-external,.weave-note-prompt-discard{border-color:var(--weave-line)}
572
- .weave-note-prompt-text{margin:0;font-size:var(--weave-px-ui);color:var(--weave-fg)}
573
- .weave-note-prompt-actions{margin:0;display:flex;gap:6px;flex-wrap:wrap}
574
- .weave-note-editor{
575
- flex:1;min-height:240px;resize:none;
576
- padding:10px var(--weave-note-gutter) 24px;
577
- /* Exactly the prose body's size and leading. The invariant is documented
578
- and deliberate: ⌘E must not reflow, and reading raw at the same measure
579
- the prose uses is what makes the toggle a lens rather than a jump. */
580
- font:inherit;font-family:var(--weave-mono);font-size:var(--weave-px-body);line-height:1.7;
581
- color:var(--weave-fg);background:var(--weave-page);border:0;outline-offset:-2px;
582
- }
583
-
584
501
  /* graph column ---------------------------------------------------------- */
585
502
  /* The canvas is a plain block sigma appends its own <canvas> layers into. It
586
503
  must have a definite size before sigma measures it, hence \`min-height\` and
@@ -691,14 +608,14 @@ body{font-size:var(--weave-px-base)}
691
608
  /* status bar ------------------------------------------------------------ */
692
609
  /* One separator vocabulary, shared with the header summary: every run of
693
610
  status items after the first opens with the same mid-dot, in the same
694
- --weave-faint. cwd · selection | stamp · connection reads as one grammar,
611
+ --weave-faint. cwd · selection · stamp reads as one grammar,
695
612
  the same way the header's three-part summary does. */
696
613
  .weave-status{
697
614
  display:flex;align-items:center;gap:8px;height:22px;padding:0 var(--weave-gutter);
698
615
  font-family:var(--weave-mono);font-size:var(--weave-px-ui);color:var(--weave-dim);
699
616
  border-top:1px solid var(--weave-line);background:var(--weave-panel);
700
617
  }
701
- .weave-status-sel::before,.weave-conn::before{content:"·";margin-right:8px;color:var(--weave-faint)}
618
+ .weave-status-sel::before{content:"·";margin-right:8px;color:var(--weave-faint)}
702
619
  .weave-status-cwd,.weave-status-sel{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
703
620
  .weave-status-cwd{max-width:38%;color:var(--weave-faint)}
704
621
  .weave-status-sel{color:var(--weave-fg)}
@@ -767,7 +684,7 @@ body{font-size:var(--weave-px-base)}
767
684
  --weave-new tint is exactly an accent at a strength text stays legible in. */
768
685
  ::selection{background:var(--weave-new)}
769
686
  /* Every scroller in the workspace (rows, note body, rail, palette results,
770
- help sheet, editor) gets the same thin chrome: the default scrollbar is a
687
+ help sheet) gets the same thin chrome: the default scrollbar is a
771
688
  15 px system object the hairline aesthetic cannot afford, and Firefox and
772
689
  the Blink/WebKit pair cover the whole surface with these five rules. */
773
690
  *{scrollbar-width:thin;scrollbar-color:var(--weave-line-strong) transparent}
@@ -787,18 +704,13 @@ body{font-size:var(--weave-px-base)}
787
704
  properties only — colour, border, opacity, transform; height and padding
788
705
  snap. */
789
706
  .weave-row,.weave-ctx-link,.weave-chip,.weave-search,.weave-refresh,.weave-theme,
790
- .weave-divider,.weave-hit,.weave-note-toggle,.weave-note-save,.weave-note-open,
791
- .weave-note-action{
707
+ .weave-hit,.weave-note-open{
792
708
  transition:background-color 120ms ease,border-color 120ms ease,color 120ms ease;
793
709
  }
794
710
  .weave-scrim{animation:weave-fade-in 140ms ease-out both}
795
711
  .weave-palette,.weave-help{animation:weave-overlay-in 160ms ease-out both}
796
- /* The conflict prompt mounts once per conflict, so its entrance cannot strobe
797
- the way a context-rail flash would — that rail deliberately stays still. */
798
- .weave-note-prompt{animation:weave-prompt-in 180ms ease-out both}
799
712
  @keyframes weave-fade-in{from{opacity:0}}
800
713
  @keyframes weave-overlay-in{from{opacity:0;transform:translateY(4px)}}
801
- @keyframes weave-prompt-in{from{opacity:0;transform:translateY(-2px)}}
802
714
  @media (prefers-reduced-motion: reduce){*{transition:none!important;animation:none!important}}
803
715
  `;
804
716
 
@@ -1,100 +1,20 @@
1
- /**
2
- * The context bus (weave-workspace §1.3).
3
- *
4
- * No event bus, no pub/sub — five Preact signals. Selecting in the tree,
5
- * clicking a graph node, following a wikilink and hitting a search result all
6
- * write {@link selectedId}; the note column, the graph highlight and the
7
- * context rail are computed from it.
8
- *
9
- * ## Tier rules (§2)
10
- *
11
- * This is `src/web/client/**`: it may import `src/web/shared` and browser
12
- * deps, and must **never** import `src/core` or `node:*`. Core is
13
- * Node-flavoured TypeScript that would drag `node:fs` into the bundle.
14
- *
15
- * ## What the P0 placeholders became
16
- *
17
- * This file used to declare three local interfaces behind `TODO(P1)` markers,
18
- * standing in for contracts that had not been written yet. Two of them are
19
- * now real and imported from `src/web/shared/wire.ts` — {@link graph} carries
20
- * a `GraphPayload` and {@link noteBody} a `ViewNote`, the exact values
21
- * `src/web/client/api.ts` returns. That removes a translation step that
22
- * existed only to satisfy a stale type: the fetchers already produce these
23
- * shapes, so anything narrower here was a downcast waiting to lose a field.
24
- *
25
- * The third resolved the other way. The TODO said to take `TreeState` from
26
- * the wire too, and that was simply wrong: which rows a user has expanded is
27
- * **not** a wire contract. It never crosses the network, the server has no
28
- * opinion about it, and putting it in `wire.ts` would have made the
29
- * client/server contract carry a purely local UI preference. So
30
- * {@link TreeState} stays here, declared where it is owned, with the marker
31
- * removed rather than carried forward.
32
- */
33
-
34
- import { signal } from "@preact/signals";
35
1
  import type { GraphPayload, NotePayload } from "../shared/wire";
36
2
 
37
- /** Connection state shown in the status bar. */
38
- export type ConnectionState = "live" | "reconnecting" | "offline";
39
-
40
3
  /**
41
4
  * Which tree rows are open.
42
5
  *
43
6
  * Client-owned, deliberately: see the module header. An interface rather than
44
7
  * a bare array so that P2's filter text and provenance-cycling state have an
45
- * obvious home that does not change this signal's type.
8
+ * obvious home that does not change this state shape.
46
9
  */
47
- export interface TreeState {
48
- /** Ids of expanded tree rows. */
49
- readonly expanded: readonly string[];
10
+ export interface WorkspaceState {
11
+ graph: GraphPayload | null;
12
+ graphFailed: boolean;
13
+ note: NotePayload | null;
14
+ recentIds: ReadonlySet<string>;
15
+ selectedId: string | null;
50
16
  }
51
17
 
52
- /** The empty tree: nothing expanded. */
53
- export function initialTreeState(): TreeState {
54
- return { expanded: [] };
18
+ export function initialWorkspaceState(): WorkspaceState {
19
+ return { graph: null, graphFailed: false, note: null, recentIds: new Set(), selectedId: null };
55
20
  }
56
-
57
- /** The graph node id currently in context — everywhere. */
58
- export const selectedId = signal<string | null>(null);
59
-
60
- /** The whole graph, as delivered by the server. `null` until first load. */
61
- export const graph = signal<GraphPayload | null>(null);
62
-
63
- /**
64
- * Whether the *first* graph fetch failed.
65
- *
66
- * Distinct from {@link connection} on purpose: the socket can be live while a
67
- * fetch 500s, and "offline" is then the wrong sentence. With a graph already
68
- * on screen a failed refetch leaves the stale value standing (see
69
- * `workspace.ts`'s header — a stale graph beats a blank one), so this only
70
- * ever describes the boot, which is the one moment there is nothing to show.
71
- */
72
- export const graphFailed = signal(false);
73
-
74
- /**
75
- * Derived: fetched when {@link selectedId} names a note.
76
- *
77
- * A {@link NotePayload} rather than a bare `ViewNote` as of P5. The revision
78
- * travels **with** the body because the editor saves against it, and a
79
- * revision fetched separately would describe a state the draft was not typed
80
- * against — the exact window a conflict check exists to close. The note
81
- * column reads `.note` and is otherwise unchanged.
82
- */
83
- export const noteBody = signal<NotePayload | null>(null);
84
-
85
- /** Which tree rows are open. */
86
- export const treeState = signal<TreeState>(initialTreeState());
87
-
88
- /** SSE liveness (§6). */
89
- export const connection = signal<ConnectionState>("live");
90
-
91
- /**
92
- * Node ids that appeared in the most recent graph update (weave-workspace
93
- * §6 frame → tree feedback).
94
- *
95
- * Empty on the mount fetch — a first load must not animate the whole tree.
96
- * The controller replaces the set on every published payload and clears it
97
- * after {@link ../workspace!RECENT_TTL_MS}, so a row collapsed and re-expanded
98
- * minutes later does not replay its arrival animation.
99
- */
100
- export const recentIds = signal<ReadonlySet<string>>(new Set());