pi-weave 0.1.12 → 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 (71) hide show
  1. package/README.md +8 -37
  2. package/package.json +1 -2
  3. package/src/core/concurrency.ts +3 -6
  4. package/src/core/frontmatter.ts +0 -53
  5. package/src/core/graph/build.ts +6 -7
  6. package/src/core/graph/current.ts +2 -4
  7. package/src/core/graph/model.ts +1 -1
  8. package/src/core/graph/wikilinks.ts +3 -3
  9. package/src/core/index.ts +26 -27
  10. package/src/core/paths.ts +0 -7
  11. package/src/core/vault.ts +16 -681
  12. package/src/core/view/detail.ts +1 -1
  13. package/src/core/view/health.ts +1 -1
  14. package/src/core/view/tree.ts +1 -1
  15. package/src/pi/index.ts +6 -85
  16. package/src/pi/summarize.ts +2 -2
  17. package/src/pi/viewer/tui/bodyStore.ts +4 -7
  18. package/src/pi/viewer/tui/branding.ts +7 -148
  19. package/src/pi/viewer/tui/run.ts +3 -17
  20. package/src/pi/viewer/tui/surface/base.ts +24 -3
  21. package/src/pi/viewer/tui/surface/explore.ts +41 -6
  22. package/src/pi/viewer/tui/workspace.ts +23 -351
  23. package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
  24. package/src/pi/viewer/web/run.ts +7 -117
  25. package/src/web/client/api.dom.ts +2 -2
  26. package/src/web/client/api.ts +14 -223
  27. package/src/web/client/bootstrap.ts +5 -14
  28. package/src/web/client/context/context.model.ts +9 -11
  29. package/src/web/client/dist/app.js +93 -219
  30. package/src/web/client/graph/dynamics.ts +5 -65
  31. package/src/web/client/graph/renderer.dom.ts +7 -8
  32. package/src/web/client/graph/renderer.ts +9 -35
  33. package/src/web/client/main.tsx +1 -1
  34. package/src/web/client/note/Note.tsx +21 -63
  35. package/src/web/client/search/SearchPalette.tsx +45 -36
  36. package/src/web/client/search/search.model.ts +33 -454
  37. package/src/web/client/shell/Columns.tsx +13 -83
  38. package/src/web/client/shell/Header.tsx +2 -10
  39. package/src/web/client/shell/Shell.tsx +50 -125
  40. package/src/web/client/shell/StatusBar.tsx +1 -4
  41. package/src/web/client/shell/icons.model.ts +4 -7
  42. package/src/web/client/shell/keys.model.ts +5 -42
  43. package/src/web/client/shell/keys.ts +2 -2
  44. package/src/web/client/shell/shell.model.ts +10 -133
  45. package/src/web/client/shell/theme.model.ts +2 -2
  46. package/src/web/client/shell/theme.ts +33 -157
  47. package/src/web/client/state.ts +9 -89
  48. package/src/web/client/tree/Tree.tsx +25 -575
  49. package/src/web/client/tree/tree.model.ts +8 -162
  50. package/src/web/client/workspace.ts +72 -242
  51. package/src/web/server/page.ts +8 -10
  52. package/src/web/server/routes.ts +30 -563
  53. package/src/web/server/server.ts +6 -145
  54. package/src/web/shared/layout.ts +72 -624
  55. package/src/web/shared/wire.ts +10 -196
  56. package/src/core/sessions.ts +0 -929
  57. package/src/pi/sessionScan.ts +0 -104
  58. package/src/pi/viewer/tui/explorer.ts +0 -586
  59. package/src/web/client/live.model.ts +0 -275
  60. package/src/web/client/live.ts +0 -151
  61. package/src/web/client/note/Editor.tsx +0 -109
  62. package/src/web/client/note/editor.controller.ts +0 -151
  63. package/src/web/client/note/editor.model.ts +0 -686
  64. package/src/web/client/search/search.ts +0 -107
  65. package/src/web/client/shell/Divider.tsx +0 -44
  66. package/src/web/client/shell/cssvars.ts +0 -70
  67. package/src/web/client/shell/drag.model.ts +0 -170
  68. package/src/web/client/shell/layout.model.ts +0 -500
  69. package/src/web/client/shell/viewport.ts +0 -29
  70. package/src/web/server/sse.ts +0 -321
  71. package/src/web/server/watcher.ts +0 -507
@@ -1,500 +0,0 @@
1
- /**
2
- * The three-column layout, as pure data (weave-workspace §1.2).
3
- *
4
- * §1.2 is emphatic that there is exactly one layout: three resizable columns,
5
- * a context rail under the graph, widths in `localStorage`, and two
6
- * breakpoints. No panel engine, no presets. This module is that entire
7
- * "layout system" — and it is a `.model.ts` rather than logic inside a
8
- * component because there is no DOM test environment in this repository and
9
- * we may not add one (§10). Everything below is a pure function over plain
10
- * objects, so the drag arithmetic, the breakpoint table and the persistence
11
- * validation are all covered by ordinary unit tests; `Shell.tsx` is left with
12
- * nothing to do but render what these functions return.
13
- *
14
- * ## Widths are fractions, not pixels
15
- *
16
- * A stored pixel width is wrong the moment the window is resized, and worse,
17
- * it is wrong *silently* — restore a 1600 px session on a 1200 px laptop and
18
- * a column is simply off-screen. So the persisted unit is a fraction of the
19
- * available width, and pixels only ever exist inside {@link resolveColumns},
20
- * at the moment of rendering, where the viewport width is known.
21
- *
22
- * The invariant that makes this safe is {@link normalizeFractions}: the three
23
- * fractions always sum to exactly 1 and none is below its minimum share. It
24
- * is applied on *every* path into a {@link LayoutState} — construction,
25
- * drag, and deserialization alike — so no caller can produce a state that
26
- * violates it. That is deliberately stronger than validating at the edges: a
27
- * layout that has drifted to summing 0.97 renders a 3 % dead stripe that
28
- * nobody will trace back to a rounding bug three releases ago.
29
- *
30
- * ## Tier rules (§2)
31
- *
32
- * `src/web/client/**`: no `node:*`, no `src/core`. This file goes further and
33
- * touches no DOM type either — {@link LayoutStorage} is a two-method
34
- * interface, not `Storage`, and the viewport arrives as a number. That is
35
- * what lets the root `tsconfig.json` project (which has no `DOM` lib) compile
36
- * the tests that import it.
37
- */
38
-
39
- // --- columns -----------------------------------------------------------------
40
-
41
- /** The three columns of §1.2, left to right. */
42
- export type ColumnId = "tree" | "note" | "graph";
43
-
44
- /** Every {@link ColumnId}, in layout order. */
45
- export const COLUMNS: readonly ColumnId[] = ["tree", "note", "graph"];
46
-
47
- /** A width per column. The unit depends on the container: see below. */
48
- export type Columns<T> = { readonly [K in ColumnId]: T };
49
-
50
- /**
51
- * Minimum width per column, in CSS pixels.
52
- *
53
- * Not arbitrary: the tree must fit `▾ repository` plus a nested path without
54
- * ellipsis, the note column is the reading surface and gets the largest
55
- * floor, and the graph needs enough room for the legend row under the canvas
56
- * to stay on one line. Below these a column is not "small", it is useless,
57
- * which is why the drag clamps instead of allowing a 12 px sliver.
58
- */
59
- export const MIN_WIDTHS: Columns<number> = { tree: 180, note: 320, graph: 260 };
60
-
61
- /**
62
- * The default split.
63
- *
64
- * Note-heavy on purpose. §11's P2 exit criterion is that the workspace is
65
- * "genuinely useful with no graph at all", and the default arrangement should
66
- * say the same thing.
67
- */
68
- export const DEFAULT_FRACTIONS: Columns<number> = { tree: 0.22, note: 0.46, graph: 0.32 };
69
-
70
- // --- breakpoints --------------------------------------------------------------
71
-
72
- /**
73
- * Which columns the viewport can afford (§1.2).
74
- *
75
- * `"wide"` shows all three; `"medium"` collapses the graph to a toggle;
76
- * `"narrow"` collapses the tree as well, leaving the note column — the
77
- * product — alone on screen.
78
- */
79
- export type Breakpoint = "wide" | "medium" | "narrow";
80
-
81
- /** §1.2: "Below 1100 px the graph column collapses to a toggle." */
82
- export const BREAKPOINT_MEDIUM = 1100;
83
-
84
- /** §1.2: "below 800 px the tree does too." */
85
- export const BREAKPOINT_NARROW = 800;
86
-
87
- /**
88
- * Classify a viewport width.
89
- *
90
- * Boundaries are inclusive at the top: exactly 1100 px is `"wide"`, because
91
- * the doc says "below 1100", and an off-by-one here is a column that
92
- * disappears one pixel early on a very common window size.
93
- *
94
- * A non-finite or negative width — which `window.innerWidth` will not
95
- * produce, but a test double or a detached iframe can — classifies as
96
- * `"narrow"` rather than throwing. Degrading to the single-column layout is
97
- * the safe direction: it renders something readable, where a thrown error at
98
- * render time renders nothing at all.
99
- */
100
- export function breakpointFor(viewport: number): Breakpoint {
101
- if (!Number.isFinite(viewport) || viewport < BREAKPOINT_NARROW) return "narrow";
102
- if (viewport < BREAKPOINT_MEDIUM) return "medium";
103
- return "wide";
104
- }
105
-
106
- /** The columns a breakpoint renders inline, rather than behind a toggle. */
107
- export function columnsAt(breakpoint: Breakpoint): readonly ColumnId[] {
108
- if (breakpoint === "wide") return COLUMNS;
109
- if (breakpoint === "medium") return ["tree", "note"];
110
- return ["note"];
111
- }
112
-
113
- /**
114
- * Whether a column is collapsed *by the viewport* at this breakpoint.
115
- *
116
- * Distinct from a user-toggled panel: this one is not a preference and is not
117
- * persisted, so a laptop user who widens their window gets their three
118
- * columns back without having to re-open anything.
119
- */
120
- export function isCollapsed(breakpoint: Breakpoint, column: ColumnId): boolean {
121
- return !columnsAt(breakpoint).includes(column);
122
- }
123
-
124
- // --- state --------------------------------------------------------------------
125
-
126
- /**
127
- * The persisted layout.
128
- *
129
- * `fractions` always satisfies the {@link normalizeFractions} invariant.
130
- *
131
- * There was once a `revealed` set here — a toggle that could re-open a
132
- * viewport-collapsed column — but no surface ever rendered it, so it shipped
133
- * as dead state that survived persistence and loads. Deleted rather than
134
- * wired: a documented-but-absent affordance is worse than none, and the
135
- * breakpoint collapse is the §1.2 behaviour as specified.
136
- */
137
- export interface LayoutState {
138
- readonly fractions: Columns<number>;
139
- }
140
-
141
- /**
142
- * Force `fractions` to sum to 1 with every column above its minimum share.
143
- *
144
- * Three passes, in this order, because each depends on the previous:
145
- *
146
- * 1. **Sanitise.** Any non-finite or non-positive entry is replaced by its
147
- * default. `NaN` is the interesting case — it propagates through every
148
- * subsequent sum and would turn one bad stored value into three broken
149
- * columns, so it must die here rather than being clamped later.
150
- * 2. **Scale.** Divide by the total. This is what makes the function
151
- * idempotent and what lets {@link resizeAt} do naive arithmetic and hand
152
- * the result back for repair.
153
- * 3. **Clamp and redistribute.** Lift anything under `minShare` up to it,
154
- * then take the deficit back from the columns that are still above their
155
- * floor, in proportion to their slack. If there is no slack anywhere —
156
- * which happens when the minimums cannot all be honoured at this
157
- * viewport — the minimums win and the sum is allowed to exceed 1. The
158
- * alternative is a column below its declared floor, and a container that
159
- * scrolls is a better failure than a column that cannot be read.
160
- *
161
- * @param fractions raw, possibly invalid shares
162
- * @param minShare per-column floor as a fraction, from {@link minShares}
163
- */
164
- export function normalizeFractions(fractions: Columns<number>, minShare: Columns<number>): Columns<number> {
165
- return normalizeOver(COLUMNS, fractions, minShare);
166
- }
167
-
168
- /**
169
- * {@link normalizeFractions} over an arbitrary subset of columns.
170
- *
171
- * The subset parameter is not a generalisation for its own sake — it is what
172
- * {@link resolveColumns} needs, and getting it wrong once already produced a
173
- * real bug. Passing a collapsed column a share of `0` and hoping the
174
- * three-column normaliser would drop it does not work: pass 1 treats `0` as
175
- * corrupt and substitutes the default, so the hidden column comes back as
176
- * invisible padding. Columns outside `ids` are excluded from every pass and
177
- * emitted as `0`, which is the only formulation that cannot resurrect them.
178
- */
179
- function normalizeOver(
180
- ids: readonly ColumnId[],
181
- fractions: Columns<number>,
182
- minShare: Columns<number>,
183
- ): Columns<number> {
184
- const clean: Record<ColumnId, number> = { tree: 0, note: 0, graph: 0 };
185
- for (const id of ids) {
186
- const raw = fractions[id];
187
- clean[id] = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_FRACTIONS[id];
188
- }
189
-
190
- let total = 0;
191
- for (const id of ids) total += clean[id];
192
- for (const id of ids) clean[id] = clean[id] / total;
193
-
194
- // Pass 3. `deficit` is how much we must borrow to satisfy the floors;
195
- // `slack` is how much the unclamped columns can spare.
196
- let deficit = 0;
197
- let slack = 0;
198
- const atFloor: Record<ColumnId, boolean> = { tree: false, note: false, graph: false };
199
- for (const id of ids) {
200
- const floor = minShare[id];
201
- if (clean[id] < floor) {
202
- deficit += floor - clean[id];
203
- clean[id] = floor;
204
- atFloor[id] = true;
205
- } else {
206
- slack += clean[id] - floor;
207
- }
208
- }
209
- if (deficit > 0 && slack > 0) {
210
- // Proportional to slack, so a column with room to spare gives up more
211
- // than one that is nearly at its own floor. Capped by `slack` so that
212
- // repaying an impossible deficit cannot push a donor below its minimum.
213
- const rate = Math.min(deficit, slack) / slack;
214
- for (const id of ids) {
215
- if (atFloor[id]) continue;
216
- clean[id] -= (clean[id] - minShare[id]) * rate;
217
- }
218
- }
219
-
220
- return { tree: clean.tree, note: clean.note, graph: clean.graph };
221
- }
222
-
223
- /**
224
- * {@link MIN_WIDTHS} expressed as fractions of an available width.
225
- *
226
- * When the viewport is too small to honour every minimum the shares are
227
- * scaled down to leave 10 % of the width unspoken for, rather than being
228
- * returned as-is summing above 1. Without that, `normalizeFractions` would
229
- * see three floors it can never satisfy, find zero slack, and return a state
230
- * that overflows by an unbounded amount. Scaling keeps the columns
231
- * proportional to their declared importance, which is the closest thing to
232
- * "right" available at 400 px.
233
- */
234
- export function minShares(available: number): Columns<number> {
235
- const width = Number.isFinite(available) && available > 0 ? available : 1;
236
- const raw = {
237
- tree: MIN_WIDTHS.tree / width,
238
- note: MIN_WIDTHS.note / width,
239
- graph: MIN_WIDTHS.graph / width,
240
- };
241
- const total = raw.tree + raw.note + raw.graph;
242
- if (total <= 0.9) return raw;
243
- const scale = 0.9 / total;
244
- return { tree: raw.tree * scale, note: raw.note * scale, graph: raw.graph * scale };
245
- }
246
-
247
- /** A valid {@link LayoutState} from arbitrary shares. The only constructor. */
248
- export function makeLayout(fractions: Columns<number>, available: number): LayoutState {
249
- return {
250
- fractions: normalizeFractions(fractions, minShares(available)),
251
- };
252
- }
253
-
254
- /** The §1.2 default, sized for a viewport. */
255
- export function defaultLayout(available: number): LayoutState {
256
- return makeLayout(DEFAULT_FRACTIONS, available);
257
- }
258
-
259
- // --- resolution ---------------------------------------------------------------
260
-
261
- /** A column's rendered width, in CSS pixels. */
262
- export interface ResolvedColumn {
263
- readonly id: ColumnId;
264
- readonly width: number;
265
- }
266
-
267
- /**
268
- * Fractions → pixels, for the columns this breakpoint actually renders.
269
- *
270
- * Collapsed columns are dropped and their share is redistributed among the
271
- * survivors rather than left as a gap — at 900 px the tree and note should
272
- * fill the window, not sit in the left two-thirds of it. Redistribution
273
- * re-runs {@link normalizeFractions} over the visible subset so the minimums
274
- * are enforced against the *remaining* width, which is the only width that
275
- * matters once the graph is behind a toggle.
276
- *
277
- * @param available container width in CSS pixels, gutters already subtracted
278
- */
279
- export function resolveColumns(state: LayoutState, available: number, breakpoint: Breakpoint): readonly ResolvedColumn[] {
280
- const visible = columnsAt(breakpoint);
281
- const width = Number.isFinite(available) && available > 0 ? available : 0;
282
- const shares = normalizeOver(visible, state.fractions, minShares(width));
283
- return visible.map((id) => ({ id, width: shares[id] * width }));
284
- }
285
-
286
- // --- dragging -----------------------------------------------------------------
287
-
288
- /**
289
- * A divider, named by the column to its left.
290
- *
291
- * Two of them: `tree|note` and `note|graph`. A drag moves width between
292
- * exactly those two neighbours and leaves the third alone, which is what
293
- * makes the gesture feel local — the §1.2 sketch has no four-way splitter and
294
- * a divider that reflowed all three columns would be a different, worse
295
- * interaction.
296
- */
297
- export type DividerId = "tree" | "note";
298
-
299
- /** Both dividers, left to right. */
300
- export const DIVIDERS: readonly DividerId[] = ["tree", "note"];
301
-
302
- /** The pair of columns a divider sits between. */
303
- export function dividerPair(divider: DividerId): readonly [ColumnId, ColumnId] {
304
- return divider === "tree" ? ["tree", "note"] : ["note", "graph"];
305
- }
306
-
307
- /**
308
- * Apply a drag: move `deltaPx` of width across `divider`.
309
- *
310
- * Positive `delta` widens the left column. The delta is **clamped before it
311
- * is applied**, against how much room each neighbour has above its floor —
312
- * not applied first and repaired afterwards. That ordering matters: a raw
313
- * overshoot drives the shrinking column to a negative share, which
314
- * {@link normalizeFractions} reads as corrupt and replaces with the default,
315
- * so the divider would snap to a position nowhere near the pointer. Clamping
316
- * first gives the behaviour a user pulling a divider into a wall expects:
317
- * the divider stops, the pointer keeps going, and releasing does not
318
- * teleport anything.
319
- *
320
- * Returns the input state unchanged when nothing moved, so a `mousemove`
321
- * storm at a clamped edge does not churn signal subscribers on every frame.
322
- */
323
- export function resizeAt(state: LayoutState, divider: DividerId, deltaPx: number, available: number): LayoutState {
324
- const width = Number.isFinite(available) && available > 0 ? available : 0;
325
- if (width === 0 || !Number.isFinite(deltaPx) || deltaPx === 0) return state;
326
-
327
- const [left, right] = dividerPair(divider);
328
- const floors = minShares(width);
329
- // How far each neighbour can shrink. `max(0, …)` because a column can
330
- // already sit below its floor when the floors do not all fit; in that case
331
- // it simply cannot donate, rather than donating a negative amount.
332
- const leftRoom = Math.max(0, state.fractions[left] - floors[left]);
333
- const rightRoom = Math.max(0, state.fractions[right] - floors[right]);
334
- // Zero means the divider is against a wall — both when the pointer has not
335
- // moved and when the clamp consumed the whole delta. Returning the input
336
- // *identically* in both cases is what keeps a `mousemove` storm at a
337
- // stopped divider from waking every signal subscriber sixty times a second.
338
- const delta = Math.max(-leftRoom, Math.min(rightRoom, deltaPx / width));
339
- if (delta === 0) return state;
340
-
341
- const moved = { ...state.fractions, [left]: state.fractions[left] + delta, [right]: state.fractions[right] - delta };
342
- return { fractions: normalizeFractions(moved, floors) };
343
- }
344
-
345
- // --- persistence ---------------------------------------------------------------
346
-
347
- /**
348
- * The slice of `Storage` this module uses.
349
- *
350
- * An interface rather than the global for two reasons. The obvious one is
351
- * testability without a DOM. The other is that `localStorage` *throws* on
352
- * access in a handful of real configurations — Safari private browsing
353
- * historically, and any embedding where storage is partitioned off — so the
354
- * call has to be wrapped somewhere, and a narrow injected port is a better
355
- * place for that wrapper than a component.
356
- */
357
- export interface LayoutStorage {
358
- getItem(key: string): string | null;
359
- setItem(key: string, value: string): void;
360
- }
361
-
362
- /** The `localStorage` key. Namespaced; §1.2 puts widths in storage. */
363
- export const LAYOUT_STORAGE_KEY = "pi-weave.layout.v1";
364
-
365
- /**
366
- * Serialize for storage.
367
- *
368
- * Fractions are rounded to four decimals — about a quarter-pixel on a 4K
369
- * display, and far below what a drag can express — so that a stored value is
370
- * short and, more usefully, so that a hand-inspected entry is readable.
371
- */
372
- export function serializeLayout(state: LayoutState): string {
373
- return JSON.stringify({
374
- v: 1,
375
- tree: round4(state.fractions.tree),
376
- note: round4(state.fractions.note),
377
- graph: round4(state.fractions.graph),
378
- });
379
- }
380
-
381
- function round4(value: number): number {
382
- return Math.round(value * 10000) / 10000;
383
- }
384
-
385
- /**
386
- * Parse a stored layout, or `null`.
387
- *
388
- * Everything here is untrusted. The string is user-editable by construction —
389
- * it lives in a devtools pane that anyone can type into — and it also
390
- * outlives the schema, so a v1 reader will one day meet a v2 entry written by
391
- * a newer build the user ran yesterday. Both cases have the same correct
392
- * answer: return `null` and let the caller fall back to the default. There is
393
- * no repair path, because a partially-repaired layout is a bug report that
394
- * says "my columns are weird sometimes".
395
- *
396
- * Note what is *not* rejected: out-of-range or non-summing fractions. Those
397
- * go to {@link normalizeFractions}, which is total. Only structural nonsense —
398
- * not JSON, not an object, wrong version, a missing or non-numeric column —
399
- * is fatal.
400
- */
401
- export function deserializeLayout(raw: string | null, available: number): LayoutState | null {
402
- if (raw === null) return null;
403
- let parsed: unknown;
404
- try {
405
- parsed = JSON.parse(raw);
406
- } catch {
407
- return null;
408
- }
409
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
410
-
411
- const record = parsed as Record<string, unknown>;
412
- if (record["v"] !== 1) return null;
413
-
414
- // The `revealed` key of the toggle era is deliberately not parsed: the
415
- // toggle never rendered, so a stored set is dead weight and unknown keys
416
- // are ignored the same way devtools experiments are.
417
- const fractions: Record<ColumnId, number> = { tree: 0, note: 0, graph: 0 };
418
- for (const id of COLUMNS) {
419
- const value = record[id];
420
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
421
- fractions[id] = value;
422
- }
423
-
424
- return makeLayout(fractions, available);
425
- }
426
-
427
- /**
428
- * Read the stored layout, falling back to the §1.2 default.
429
- *
430
- * Storage access is wrapped: see {@link LayoutStorage}. A throwing
431
- * `getItem` is indistinguishable from an absent entry as far as the layout is
432
- * concerned, and the workspace opening with default widths is not an error
433
- * worth surfacing.
434
- */
435
- export function loadLayout(storage: LayoutStorage, available: number): LayoutState {
436
- let raw: string | null;
437
- try {
438
- raw = storage.getItem(LAYOUT_STORAGE_KEY);
439
- } catch {
440
- return defaultLayout(available);
441
- }
442
- return deserializeLayout(raw, available) ?? defaultLayout(available);
443
- }
444
-
445
- /**
446
- * Persist the layout, best-effort.
447
- *
448
- * Returns whether it stuck, rather than throwing. `setItem` fails on quota
449
- * exhaustion and in partitioned-storage contexts, and neither is a reason to
450
- * break a drag that has already been applied to the live layout — the user's
451
- * columns move, they just do not survive a reload.
452
- */
453
- export function saveLayout(storage: LayoutStorage, state: LayoutState): boolean {
454
- try {
455
- storage.setItem(LAYOUT_STORAGE_KEY, serializeLayout(state));
456
- return true;
457
- } catch {
458
- return false;
459
- }
460
- }
461
-
462
- // --- rendering support ----------------------------------------------------------
463
-
464
- /**
465
- * The CSS custom property carrying a column's width.
466
- *
467
- * **This is a CSP constraint, not a style preference.** §5.2 serves the page
468
- * with `style-src 'nonce-…'` and no `'unsafe-inline'`, so an inline
469
- * `style="width:340px"` attribute is *blocked by the browser* — Preact sets
470
- * that attribute via `dom.style.cssText` for string styles and via
471
- * `dom.style[key] = …` for object styles, and neither is subject to
472
- * `style-src`, because CSSOM mutation is not "inline style" in CSP terms.
473
- * Only a literal `style` attribute in markup is.
474
- *
475
- * We use the custom-property form anyway, via `el.style.setProperty`, for a
476
- * reason beyond CSP: the nonce'd stylesheet in `page.ts` can then own the
477
- * actual `grid-template-columns` declaration, and the client contributes one
478
- * number per column instead of a layout rule. That keeps the presentation in
479
- * CSS where it can be read, and keeps the bundle from carrying a second,
480
- * competing layout implementation.
481
- */
482
- export function columnVar(column: ColumnId): string {
483
- return `--weave-col-${column}`;
484
- }
485
-
486
- /** A resolved width as a CSS length. Integral: subpixel columns blur text. */
487
- export function columnValue(width: number): string {
488
- return `${Math.round(width)}px`;
489
- }
490
-
491
- /**
492
- * The full set of custom properties for a resolved layout.
493
- *
494
- * Returned as pairs rather than applied, so the caller — a `useLayoutEffect`
495
- * in the shell — is the only thing that touches an element, and this stays
496
- * testable without one.
497
- */
498
- export function columnVars(resolved: readonly ResolvedColumn[]): readonly (readonly [string, string])[] {
499
- return resolved.map((column) => [columnVar(column.id), columnValue(column.width)] as const);
500
- }
@@ -1,29 +0,0 @@
1
- /**
2
- * Watching the viewport width (weave-workspace §1.2).
3
- *
4
- * The width is not a cosmetic detail here: it picks the breakpoint, which
5
- * decides how many columns render, which decides how many dividers exist. So
6
- * the subscription is worth a test, and a subscription written inline in a
7
- * `useEffect` is one no test can reach (§10). Four lines, an injected port,
8
- * and the shell keeps a one-line effect.
9
- */
10
-
11
- /** The slice of `window` this module reads. `window` satisfies it. */
12
- export interface ViewportHost {
13
- readonly innerWidth: number;
14
- addEventListener(type: "resize", listener: () => void): void;
15
- removeEventListener(type: "resize", listener: () => void): void;
16
- }
17
-
18
- /**
19
- * Call `onChange` with the width whenever the window resizes.
20
- *
21
- * Returns an unsubscribe. The listener is *not* invoked eagerly: the shell
22
- * already seeds its state from `initialWidth` at mount, and firing here would
23
- * make the first render set state during an effect for no change in value.
24
- */
25
- export function watchViewport(host: ViewportHost, onChange: (width: number) => void): () => void {
26
- const listener = (): void => onChange(host.innerWidth);
27
- host.addEventListener("resize", listener);
28
- return () => host.removeEventListener("resize", listener);
29
- }