pi-weave 0.1.13 → 0.1.14

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.
@@ -28,7 +28,7 @@ if (host !== null) {
28
28
  // writes nothing — the media query already answered.
29
29
  applyPrePaintTheme();
30
30
  const boot = readBootstrap(document.getElementById(BOOTSTRAP_ELEMENT_ID)?.textContent ?? null);
31
- render(<Shell cwd={boot.cwd} platform={navigator.platform} />, host);
31
+ render(<Shell cwd={boot.cwd} initialWidth={window.innerWidth} platform={navigator.platform} />, host);
32
32
  }
33
33
 
34
34
  /** Apply the stored theme choice to `<html>` synchronously, pre-render. */
@@ -35,6 +35,7 @@ import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
35
35
  import type { GraphPayload, NotePayload } from "../../shared/wire";
36
36
  import {
37
37
  CREATED_WORD,
38
+ artifactKeyOfNode,
38
39
  EDITED_WORD,
39
40
  EMPTY_PREVIEW,
40
41
  PREVIEW_ID,
@@ -47,6 +48,7 @@ import {
47
48
  previewPlacement,
48
49
  reducePreview,
49
50
  renderNote,
51
+ selectedArtifactPath,
50
52
  tagLabel,
51
53
  wikiIndex,
52
54
  wikilinkTargetOf,
@@ -117,6 +119,7 @@ function Header({
117
119
 
118
120
  export function Note(props: NoteProps) {
119
121
  const note = props.note?.note ?? null;
122
+ const artifactPath = selectedArtifactPath(props.graph, props.selectedId);
120
123
  const empty = noteEmptyMessage(props.selectedId, note);
121
124
 
122
125
  // Both hook calls sit before the empty-return so the hook order cannot
@@ -185,6 +188,27 @@ export function Note(props: NoteProps) {
185
188
  element.style.setProperty(`--${PREVIEW_Y}`, `${spot.y}px`);
186
189
  }, [preview, card]);
187
190
 
191
+ if (artifactPath !== null) {
192
+ const artifact = props.graph?.model.nodes.find((node) => node.id === props.selectedId);
193
+ const title = artifact?.label ?? artifactPath;
194
+ const artifactKey = artifact === undefined ? artifactPath : artifactKeyOfNode(artifact) ?? artifactPath;
195
+ return (
196
+ <article key={artifactKey} class="weave-note weave-note-artifact">
197
+ <header class="weave-note-head">
198
+ <h3 class="weave-note-title">{title}</h3>
199
+ <p class="weave-note-meta"><span class="weave-note-time">{artifactPath}</span></p>
200
+ </header>
201
+ <iframe
202
+ key={artifactKey}
203
+ class="weave-artifact-frame"
204
+ title={title}
205
+ sandbox="allow-scripts"
206
+ src={`/api/artifact/${encodeURIComponent(artifactPath)}`}
207
+ />
208
+ </article>
209
+ );
210
+ }
211
+
188
212
  if (note === null || index === null) return <p class="weave-note-empty">{empty}</p>;
189
213
 
190
214
  // Keyed on the slug, not the body digest: the article is the scroll
@@ -256,6 +256,26 @@ export function slugOfNode(node: WireGraphNode): string | null {
256
256
  return slug === "" ? null : slug;
257
257
  }
258
258
 
259
+ /** The vault-relative HTML path inside an `artifact:<path>` file node. */
260
+ export function artifactPathOfNode(node: WireGraphNode): string | null {
261
+ if (node.kind !== "file" || !node.id.startsWith("artifact:")) return null;
262
+ const path = node.detail.path;
263
+ return path !== undefined && /\.html?$/i.test(path) ? path : null;
264
+ }
265
+
266
+ /** Stable iframe key that changes when the artifact on disk changes. */
267
+ export function artifactKeyOfNode(node: WireGraphNode): string | null {
268
+ const path = artifactPathOfNode(node);
269
+ return path === null ? null : `${path}:${node.detail.updated ?? ""}`;
270
+ }
271
+
272
+ /** Resolve the selected HTML artifact, if the graph has one. */
273
+ export function selectedArtifactPath(payload: GraphPayload | null, selectedId: string | null): string | null {
274
+ if (payload === null || selectedId === null) return null;
275
+ const node = payload.model.nodes.find((candidate) => candidate.id === selectedId);
276
+ return node === undefined ? null : artifactPathOfNode(node);
277
+ }
278
+
259
279
  /**
260
280
  * Build the index for rendering the note with slug `slug`.
261
281
  *
@@ -1,9 +1,10 @@
1
1
  /**
2
- * The fixed three-column grid and context rail. CSS owns responsive hiding;
3
- * keeping all three surfaces in the DOM preserves keyboard focus targets when
4
- * the viewport changes without a resize listener or persisted layout state.
2
+ * The resizable column grid and context rail. The shell supplies resolved
3
+ * widths and only the columns allowed by the current responsive breakpoint.
5
4
  */
6
5
 
6
+ import { Fragment } from "preact";
7
+ import { useLayoutEffect, useRef } from "preact/hooks";
7
8
  import { Graph } from "../graph/Graph";
8
9
  import type { ColorScheme } from "../graph/graph.model";
9
10
  import type { PositionStorage } from "../graph/positions";
@@ -12,11 +13,19 @@ import type { SchemeHost } from "../graph/scheme";
12
13
  import { Note } from "../note/Note";
13
14
  import { Tree } from "../tree/Tree";
14
15
  import type { GraphPayload, NotePayload } from "../../shared/wire";
15
- import type { ColumnId } from "./shell.model";
16
+ import { applyVars } from "./cssvars";
17
+ import type { ColumnId, DividerId, ResolvedColumn } from "./layout.model";
18
+ import { columnVars } from "./layout.model";
16
19
  import { ContextRail } from "./ContextRail";
17
- import { emptyStateFor } from "./shell.model";
20
+ import { columnSlots, emptyStateFor, type ColumnSlot } from "./shell.model";
21
+ import { Divider } from "./Divider";
18
22
 
19
23
  export interface ColumnsProps {
24
+ resolved: readonly ResolvedColumn[];
25
+ onDown: (divider: DividerId, clientX: number, pointerId: number) => void;
26
+ onMove: (clientX: number) => void;
27
+ onUp: () => void;
28
+ onKey: (divider: DividerId, key: string) => void;
20
29
  /** The §1.3 context bus, as the columns see it. */
21
30
  graph: GraphPayload | null;
22
31
  note: NotePayload | null;
@@ -95,12 +104,20 @@ function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
95
104
  );
96
105
  }
97
106
 
107
+ function Slot({ slot, props }: { slot: ColumnSlot; props: ColumnsProps }) {
108
+ const divider = slot.divider;
109
+ return <Fragment>
110
+ <Column id={slot.column.id} props={props} />
111
+ {divider === null ? null : <Divider id={divider} label={`Resize ${slot.column.id} column`}
112
+ onDown={(x, pointerId) => props.onDown(divider, x, pointerId)} onMove={props.onMove} onUp={props.onUp}
113
+ onKey={(key) => props.onKey(divider, key)} />}
114
+ </Fragment>;
115
+ }
116
+
98
117
  export function Columns(props: ColumnsProps) {
99
- return (
100
- <div class="weave-grid">
101
- <Column id="tree" props={props} />
102
- <Column id="note" props={props} />
103
- <Column id="graph" props={props} />
104
- </div>
105
- );
118
+ const grid = useRef<HTMLDivElement | null>(null);
119
+ useLayoutEffect(() => { applyVars(grid.current, columnVars(props.resolved)); }, [props.resolved]);
120
+ return <div class="weave-grid" ref={grid} data-columns={props.resolved.length}>
121
+ {columnSlots(props.resolved).map((slot) => <Slot key={slot.column.id} slot={slot} props={props} />)}
122
+ </div>;
106
123
  }
@@ -0,0 +1,18 @@
1
+ import type { DividerId } from "./layout.model";
2
+
3
+ export interface DividerProps {
4
+ id: DividerId;
5
+ label: string;
6
+ onDown: (clientX: number, pointerId: number) => void;
7
+ onMove: (clientX: number) => void;
8
+ onUp: () => void;
9
+ onKey: (key: string) => void;
10
+ }
11
+
12
+ export function Divider(props: DividerProps) {
13
+ return <div class="weave-divider" role="separator" aria-orientation="vertical" aria-label={props.label} tabIndex={0}
14
+ onPointerDown={(event) => { event.currentTarget.setPointerCapture(event.pointerId); props.onDown(event.clientX, event.pointerId); }}
15
+ onPointerMove={(event) => props.onMove(event.clientX)}
16
+ onPointerUp={(event) => { event.currentTarget.releasePointerCapture(event.pointerId); props.onUp(); }}
17
+ onKeyDown={(event) => props.onKey(event.key)} />;
18
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The workspace shell.
3
3
  *
4
- * Header, three fixed columns, context rail, status bar. This component holds
4
+ * Header, resizable columns, context rail, status bar. This component holds
5
5
  * the wiring and nothing else — every value it renders comes from pure
6
6
  * functions in the shell models,
7
7
  * and the fetch/poll loop is `workspace.ts`. What is left here is hooks:
@@ -17,7 +17,7 @@
17
17
  * would let `⌘K` stack a palette on top of itself.
18
18
  */
19
19
 
20
- import { useEffect, useRef, useState } from "preact/hooks";
20
+ import { useEffect, useMemo, useRef, useState } from "preact/hooks";
21
21
  import { fetchJson } from "../api.dom";
22
22
  import { openNote } from "../api";
23
23
  import type { ColorScheme } from "../graph/graph.model";
@@ -31,15 +31,19 @@ import type { WorkspaceHandle } from "../workspace";
31
31
  import { startWorkspace } from "../workspace";
32
32
  import { Columns } from "./Columns";
33
33
  import { deeplinkSelection, formatHash } from "./deeplink.model";
34
+ import { dividerHandlers } from "./drag.model";
34
35
  import { Header } from "./Header";
35
36
  import { HelpOverlay } from "./HelpOverlay";
36
37
  import { watchKeys } from "./keys";
37
38
  import { focusSelector, runShellAction } from "./keys.model";
38
39
  import { StatusBar } from "./StatusBar";
40
+ import type { LayoutState } from "./layout.model";
41
+ import { breakpointFor, loadLayout, resolveColumns, saveLayout } from "./layout.model";
39
42
  import type { OverlayId } from "./shell.model";
40
43
  import { TICK_MS, looksApple, searchShortcut, statusBarModel, summarize } from "./shell.model";
41
44
  import { cycleTheme, effectiveScheme, loadTheme, saveTheme, themeAttr, themeButton } from "./theme.model";
42
45
  import type { ThemeChoice } from "./theme.model";
46
+ import { watchViewport } from "./viewport";
43
47
 
44
48
  /**
45
49
  * Write the choice onto `<html>`, so the sheet's attribute branch and the
@@ -53,6 +57,8 @@ function applyThemeAttr(choice: ReturnType<typeof themeAttr>): void {
53
57
  export interface ShellProps {
54
58
  /** From the page bootstrap. Shown in the status bar. */
55
59
  cwd: string;
60
+ /** `window.innerWidth` at mount, used for the first breakpoint/layout. */
61
+ initialWidth: number;
56
62
  /** `navigator.platform`, for the `⌘K` vs `Ctrl K` hint. */
57
63
  platform: string;
58
64
  }
@@ -60,6 +66,8 @@ export interface ShellProps {
60
66
  export function Shell(props: ShellProps) {
61
67
  const [workspaceState, setWorkspaceState] = useState<WorkspaceState>(initialWorkspaceState);
62
68
  const [overlay, setOverlay] = useState<OverlayId>(null);
69
+ const [width, setWidth] = useState(props.initialWidth);
70
+ const [layout, setLayout] = useState<LayoutState>(() => loadLayout(localStorage, props.initialWidth));
63
71
  // The theme: what the user picked, and what the OS is currently saying. Two
64
72
  // states because they answer different questions — `theme` changes on a
65
73
  // button press or the `t` key, `systemScheme` on an OS flip while the user
@@ -72,8 +80,8 @@ export function Shell(props: ShellProps) {
72
80
  const fit = useRef<(() => void) | null>(null);
73
81
  // The global key listener reads through this so a handler registered at
74
82
  // mount still sees the current overlay.
75
- const live = useRef({ overlay, selectedId: workspaceState.selectedId });
76
- live.current = { overlay, selectedId: workspaceState.selectedId };
83
+ const live = useRef({ overlay, selectedId: workspaceState.selectedId, layout, width });
84
+ live.current = { overlay, selectedId: workspaceState.selectedId, layout, width };
77
85
 
78
86
  useEffect(() => {
79
87
  const handle = startWorkspace({ fetch: fetchJson, state: workspaceState, setState: setWorkspaceState });
@@ -81,6 +89,8 @@ export function Shell(props: ShellProps) {
81
89
  return () => handle.stop();
82
90
  }, []);
83
91
 
92
+ useEffect(() => watchViewport(window, setWidth), []);
93
+
84
94
  // §1.3 continuity: a reload keeps the note you were reading. Saving is
85
95
  // gated on the restore decision so the mount-time `null` cannot wipe the
86
96
  // saved id before the first graph arrives to validate it against.
@@ -152,6 +162,14 @@ export function Shell(props: ShellProps) {
152
162
  [],
153
163
  );
154
164
 
165
+ const resolved = useMemo(() => resolveColumns(layout, width, breakpointFor(width)), [layout, width]);
166
+ const drag = useMemo(() => dividerHandlers({
167
+ layout: () => live.current.layout,
168
+ width: () => live.current.width,
169
+ setLayout,
170
+ persist: (next) => void saveLayout(localStorage, next),
171
+ }), []);
172
+
155
173
  return (
156
174
  <>
157
175
  <Header
@@ -163,6 +181,11 @@ export function Shell(props: ShellProps) {
163
181
  onTheme={() => setTheme((current) => cycleTheme(current))}
164
182
  />
165
183
  <Columns
184
+ resolved={resolved}
185
+ onDown={drag.onDown}
186
+ onMove={drag.onMove}
187
+ onUp={drag.onUp}
188
+ onKey={drag.onKey}
166
189
  scheme={effectiveScheme(theme, systemScheme)}
167
190
  bootFailed={workspaceState.graphFailed}
168
191
  graph={workspaceState.graph}
@@ -0,0 +1,8 @@
1
+ export interface StyleTarget { setProperty(property: string, value: string): void }
2
+ export interface StyledElement { readonly style: StyleTarget }
3
+
4
+ export function applyVars(element: StyledElement | null, vars: readonly (readonly [string, string])[]): number {
5
+ if (element === null) return 0;
6
+ for (const [name, value] of vars) element.style.setProperty(name, value);
7
+ return vars.length;
8
+ }
@@ -0,0 +1,43 @@
1
+ import { COLUMNS, resizeAt } from "./layout.model";
2
+ import type { DividerId, LayoutState } from "./layout.model";
3
+
4
+ export interface DragState { readonly divider: DividerId; readonly origin: number; readonly base: LayoutState; readonly pointerId: number }
5
+ export function beginDrag(divider: DividerId, origin: number, base: LayoutState, pointerId: number): DragState { return { divider, origin, base, pointerId }; }
6
+ export function dragTo(drag: DragState, clientX: number, available: number): LayoutState { return resizeAt(drag.base, drag.divider, clientX - drag.origin, available); }
7
+ export function dragChanged(drag: DragState, final: LayoutState): boolean {
8
+ return COLUMNS.some((id) => drag.base.fractions[id] !== final.fractions[id]);
9
+ }
10
+ export const NUDGE_PX = 24;
11
+ export function nudgeFor(key: string): number { return key === "ArrowLeft" ? -NUDGE_PX : key === "ArrowRight" ? NUDGE_PX : 0; }
12
+
13
+ export interface DragHost {
14
+ layout(): LayoutState;
15
+ width(): number;
16
+ setLayout(next: LayoutState): void;
17
+ persist(layout: LayoutState): void;
18
+ }
19
+ export interface DividerHandlers {
20
+ onDown(divider: DividerId, clientX: number, pointerId: number): void;
21
+ onMove(clientX: number): void;
22
+ onUp(): void;
23
+ onKey(divider: DividerId, key: string): void;
24
+ }
25
+
26
+ export function dividerHandlers(host: DragHost): DividerHandlers {
27
+ let active: DragState | null = null;
28
+ return {
29
+ onDown(divider, clientX, pointerId) { active = beginDrag(divider, clientX, host.layout(), pointerId); },
30
+ onMove(clientX) { if (active !== null) host.setLayout(dragTo(active, clientX, host.width())); },
31
+ onUp() {
32
+ const drag = active;
33
+ active = null;
34
+ if (drag !== null && dragChanged(drag, host.layout())) host.persist(host.layout());
35
+ },
36
+ onKey(divider, key) {
37
+ const next = resizeAt(host.layout(), divider, nudgeFor(key), host.width());
38
+ if (next === host.layout()) return;
39
+ host.setLayout(next);
40
+ host.persist(next);
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,138 @@
1
+ /** Pure model for the resizable workspace columns. */
2
+
3
+ export type ColumnId = "tree" | "note" | "graph";
4
+ export const COLUMNS: readonly ColumnId[] = ["tree", "note", "graph"];
5
+ export type Columns<T> = { readonly [K in ColumnId]: T };
6
+ export const MIN_WIDTHS: Columns<number> = { tree: 180, note: 320, graph: 260 };
7
+ export const DEFAULT_FRACTIONS: Columns<number> = { tree: 0.22, note: 0.46, graph: 0.32 };
8
+
9
+ export type Breakpoint = "wide" | "medium" | "narrow";
10
+ export const BREAKPOINT_MEDIUM = 1100;
11
+ export const BREAKPOINT_NARROW = 800;
12
+
13
+ export function breakpointFor(width: number): Breakpoint {
14
+ if (!Number.isFinite(width) || width < BREAKPOINT_NARROW) return "narrow";
15
+ return width < BREAKPOINT_MEDIUM ? "medium" : "wide";
16
+ }
17
+
18
+ export function columnsAt(breakpoint: Breakpoint): readonly ColumnId[] {
19
+ return breakpoint === "wide" ? COLUMNS : breakpoint === "medium" ? ["tree", "note"] : ["note"];
20
+ }
21
+
22
+ export function isCollapsed(breakpoint: Breakpoint, column: ColumnId): boolean {
23
+ return !columnsAt(breakpoint).includes(column);
24
+ }
25
+
26
+ export interface LayoutState { readonly fractions: Columns<number> }
27
+
28
+ function normalizeOver(ids: readonly ColumnId[], fractions: Columns<number>, floors: Columns<number>): Columns<number> {
29
+ const clean: Record<ColumnId, number> = { tree: 0, note: 0, graph: 0 };
30
+ let total = 0;
31
+ for (const id of ids) {
32
+ const value = fractions[id];
33
+ clean[id] = Number.isFinite(value) && value > 0 ? value : DEFAULT_FRACTIONS[id];
34
+ total += clean[id];
35
+ }
36
+ for (const id of ids) clean[id] /= total;
37
+ let deficit = 0;
38
+ let slack = 0;
39
+ const atFloor: Record<ColumnId, boolean> = { tree: false, note: false, graph: false };
40
+ for (const id of ids) {
41
+ const floor = floors[id];
42
+ if (clean[id] < floor) {
43
+ deficit += floor - clean[id];
44
+ clean[id] = floor;
45
+ atFloor[id] = true;
46
+ } else slack += clean[id] - floor;
47
+ }
48
+ if (deficit > 0 && slack > 0) {
49
+ const rate = Math.min(deficit, slack) / slack;
50
+ for (const id of ids) if (!atFloor[id]) clean[id] -= (clean[id] - floors[id]) * rate;
51
+ }
52
+ return { tree: clean.tree, note: clean.note, graph: clean.graph };
53
+ }
54
+
55
+ export function normalizeFractions(fractions: Columns<number>, minShare: Columns<number>): Columns<number> {
56
+ return normalizeOver(COLUMNS, fractions, minShare);
57
+ }
58
+
59
+ export function minShares(available: number): Columns<number> {
60
+ const width = Number.isFinite(available) && available > 0 ? available : 1;
61
+ const raw = { tree: MIN_WIDTHS.tree / width, note: MIN_WIDTHS.note / width, graph: MIN_WIDTHS.graph / width };
62
+ const total = raw.tree + raw.note + raw.graph;
63
+ if (total <= 0.9) return raw;
64
+ const scale = 0.9 / total;
65
+ return { tree: raw.tree * scale, note: raw.note * scale, graph: raw.graph * scale };
66
+ }
67
+
68
+ export function makeLayout(fractions: Columns<number>, available: number): LayoutState {
69
+ return { fractions: normalizeFractions(fractions, minShares(available)) };
70
+ }
71
+
72
+ export function defaultLayout(available: number): LayoutState {
73
+ return makeLayout(DEFAULT_FRACTIONS, available);
74
+ }
75
+
76
+ export interface ResolvedColumn { readonly id: ColumnId; readonly width: number }
77
+
78
+ export function resolveColumns(state: LayoutState, available: number, breakpoint: Breakpoint): readonly ResolvedColumn[] {
79
+ const width = Number.isFinite(available) && available > 0 ? available : 0;
80
+ const shares = normalizeOver(columnsAt(breakpoint), state.fractions, minShares(width));
81
+ return columnsAt(breakpoint).map((id) => ({ id, width: shares[id] * width }));
82
+ }
83
+
84
+ export type DividerId = "tree" | "note";
85
+ export const DIVIDERS: readonly DividerId[] = ["tree", "note"];
86
+ export function dividerPair(divider: DividerId): readonly [ColumnId, ColumnId] {
87
+ return divider === "tree" ? ["tree", "note"] : ["note", "graph"];
88
+ }
89
+
90
+ export function resizeAt(state: LayoutState, divider: DividerId, deltaPx: number, available: number): LayoutState {
91
+ const width = Number.isFinite(available) && available > 0 ? available : 0;
92
+ if (width === 0 || !Number.isFinite(deltaPx) || deltaPx === 0) return state;
93
+ const [left, right] = dividerPair(divider);
94
+ const floors = minShares(width);
95
+ const delta = Math.max(-(Math.max(0, state.fractions[left] - floors[left])), Math.min(Math.max(0, state.fractions[right] - floors[right]), deltaPx / width));
96
+ if (delta === 0) return state;
97
+ return { fractions: normalizeFractions({ ...state.fractions, [left]: state.fractions[left] + delta, [right]: state.fractions[right] - delta }, floors) };
98
+ }
99
+
100
+ export interface LayoutStorage { getItem(key: string): string | null; setItem(key: string, value: string): void }
101
+ export const LAYOUT_STORAGE_KEY = "pi-weave.layout.v1";
102
+ const round4 = (value: number): number => Math.round(value * 10000) / 10000;
103
+
104
+ export function serializeLayout(state: LayoutState): string {
105
+ return JSON.stringify({ v: 1, tree: round4(state.fractions.tree), note: round4(state.fractions.note), graph: round4(state.fractions.graph) });
106
+ }
107
+
108
+ export function deserializeLayout(raw: string | null, available: number): LayoutState | null {
109
+ if (raw === null) return null;
110
+ let parsed: unknown;
111
+ try { parsed = JSON.parse(raw); } catch { return null; }
112
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
113
+ const record = parsed as Record<string, unknown>;
114
+ if (record["v"] !== 1) return null;
115
+ const fractions = {} as Record<ColumnId, number>;
116
+ for (const id of COLUMNS) {
117
+ const value = record[id];
118
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
119
+ fractions[id] = value;
120
+ }
121
+ return makeLayout(fractions, available);
122
+ }
123
+
124
+ export function loadLayout(storage: LayoutStorage, available: number): LayoutState {
125
+ try { return deserializeLayout(storage.getItem(LAYOUT_STORAGE_KEY), available) ?? defaultLayout(available); }
126
+ catch { return defaultLayout(available); }
127
+ }
128
+
129
+ export function saveLayout(storage: LayoutStorage, state: LayoutState): boolean {
130
+ try { storage.setItem(LAYOUT_STORAGE_KEY, serializeLayout(state)); return true; }
131
+ catch { return false; }
132
+ }
133
+
134
+ export function columnVar(column: ColumnId): string { return `--weave-col-${column}`; }
135
+ export function columnValue(width: number): string { return `${Math.round(width)}px`; }
136
+ export function columnVars(resolved: readonly ResolvedColumn[]): readonly (readonly [string, string])[] {
137
+ return resolved.map((column) => [columnVar(column.id), columnValue(column.width)] as const);
138
+ }
@@ -15,11 +15,9 @@
15
15
  */
16
16
 
17
17
  import type { GraphPayload, WireNodeKind, WireStalenessState } from "../../shared/wire";
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"];
18
+ import { COLUMNS, type ColumnId, type DividerId, type ResolvedColumn } from "./layout.model";
19
+ export { COLUMNS };
20
+ export type { ColumnId };
23
21
 
24
22
  // --- the header summary --------------------------------------------------------
25
23
 
@@ -108,6 +106,22 @@ export const CONTEXT_EMPTY: EmptyStateCopy = {
108
106
  title: "Context",
109
107
  };
110
108
 
109
+ export interface ColumnSlot {
110
+ readonly column: ResolvedColumn;
111
+ readonly divider: DividerId | null;
112
+ }
113
+
114
+ export function columnSlots(resolved: readonly ResolvedColumn[]): readonly ColumnSlot[] {
115
+ return resolved.map((column, index) => ({
116
+ column,
117
+ divider: index < resolved.length - 1 && isDivider(column.id) ? column.id : null,
118
+ }));
119
+ }
120
+
121
+ function isDivider(column: ColumnId): column is DividerId {
122
+ return column !== "graph";
123
+ }
124
+
111
125
  // --- the status bar -------------------------------------------------------------------
112
126
 
113
127
  /**
@@ -245,20 +245,20 @@ body{font-size:var(--weave-px-base)}
245
245
  .weave-theme:hover{color:var(--weave-fg);background:var(--weave-line)}
246
246
 
247
247
  /* the grid -------------------------------------------------------------- */
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}
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;
261
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
262
 
263
263
  .weave-col{
264
264
  display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;
@@ -374,6 +374,7 @@ body{font-size:var(--weave-px-base)}
374
374
  .weave-note-generated{--weave-spine:var(--weave-faint)}
375
375
  .weave-note{border-left:2px solid var(--weave-spine,transparent)}
376
376
  .weave-note-empty{flex:1;margin:0;padding:14px var(--weave-note-gutter);color:var(--weave-dim);max-width:44ch;line-height:1.5;background:var(--weave-page)}
377
+ .weave-artifact-frame{display:block;flex:1;width:100%;min-height:320px;border:0;background:#fff}
377
378
  /* The head pins itself (P6.3): a long note scrolls its prose under the title,
378
379
  and the title is what says you are still in the right document — the
379
380
  alternative, a title that scrolls away, is how a reader ends up annotating
@@ -0,0 +1,11 @@
1
+ export interface ViewportHost {
2
+ readonly innerWidth: number;
3
+ addEventListener(type: "resize", listener: () => void): void;
4
+ removeEventListener(type: "resize", listener: () => void): void;
5
+ }
6
+
7
+ export function watchViewport(host: ViewportHost, onChange: (width: number) => void): () => void {
8
+ const listener = (): void => onChange(host.innerWidth);
9
+ host.addEventListener("resize", listener);
10
+ return () => host.removeEventListener("resize", listener);
11
+ }
@@ -87,6 +87,7 @@ export function contentSecurityPolicy(nonce: string): string {
87
87
  `style-src 'nonce-${cspNonce(nonce)}'`,
88
88
  "img-src 'self' data:",
89
89
  "connect-src 'self'",
90
+ "frame-src 'self'",
90
91
  "font-src 'self'",
91
92
  "base-uri 'none'",
92
93
  "form-action 'none'",
@@ -8,6 +8,7 @@
8
8
  * | GET | `/api/graph` | {@link GraphPayload}, ETag'd on `stamp` |
9
9
  * | GET | `/api/note/:slug` | {@link NotePayload} |
10
10
  * | GET | `/api/okf/:rel` | {@link OkfFilePayload} |
11
+ * | GET | `/api/artifact/:rel` | sandboxed HTML artifact |
11
12
  * | GET | `/api/search?q=` | {@link SearchPayload} |
12
13
  * | POST | `/api/open` | {@link OpenResult} — hand the note to `$EDITOR` |
13
14
  *
@@ -28,10 +29,11 @@
28
29
  * would tell a browser to hand a rebinding attacker's JavaScript the
29
30
  * response body it otherwise could not read. A test asserts the absence.
30
31
  *
31
- * **No path resolution of its own.** `/api/okf/:rel` and `/api/note/:slug`
32
- * carry untrusted path fragments straight from the URL. Both are handed to
33
- * the existing core guards — `readOkfFileForView` anchors under `<cwd>/.okf`
34
- * and `resolveNotePath` (via `getNote`) rejects unsafe slugs.
32
+ * **No path resolution of its own.** `/api/okf/:rel`, `/api/artifact/:rel` and `/api/note/:slug`
33
+ * carry untrusted path fragments straight from the URL. All three are handed to
34
+ * the existing core guards — `readOkfFileForView` anchors under `<cwd>/.okf`,
35
+ * `resolveHtmlPath` anchors under the vault notes directory, and
36
+ * `resolveNotePath` (via `getNote`) rejects unsafe slugs.
35
37
  */
36
38
 
37
39
  import { createHash } from "node:crypto";
@@ -43,7 +45,7 @@ import { readOkfFileForView } from "../../core/graph/current";
43
45
  import type { GraphModel as CoreGraphModel } from "../../core/graph/model";
44
46
  import { openNoteInEditor } from "../../core/openInEditor";
45
47
  import type { Note } from "../../core/types";
46
- import { getNote, searchNotes } from "../../core/vault";
48
+ import { getNote, resolveHtmlPath, searchNotes } from "../../core/vault";
47
49
  import { deriveTagIndex, type TaggedNote } from "../../core/view/links";
48
50
  import type {
49
51
  GraphPayload,
@@ -372,6 +374,9 @@ async function route(
372
374
  if (method === "GET" && path.startsWith("/api/okf/")) {
373
375
  return sendOkf(deps, path.slice("/api/okf/".length), res);
374
376
  }
377
+ if (method === "GET" && path.startsWith("/api/artifact/")) {
378
+ return sendArtifact(deps, path.slice("/api/artifact/".length), res);
379
+ }
375
380
  if (method === "GET" && path === "/api/search") return sendSearch(deps, query, res);
376
381
  if (method === "POST" && path === "/api/open") return openNote(deps, req, res);
377
382
  sendText(res, 404, "not found\n");
@@ -576,6 +581,32 @@ async function sendOkf(deps: RouteDeps, rel: string, res: ServerResponse): Promi
576
581
  sendJson(res, 200, payload, { "cache-control": "no-store" });
577
582
  }
578
583
 
584
+ /** Serve a vault HTML artifact as a sandboxed iframe document. */
585
+ async function sendArtifact(deps: RouteDeps, rel: string, res: ServerResponse): Promise<void> {
586
+ const path = resolveHtmlPath(deps.vaultRoot, rel);
587
+ if (path === null) {
588
+ sendJson(res, 404, { error: "no such artifact" });
589
+ return;
590
+ }
591
+ let body: Buffer;
592
+ try {
593
+ body = await readFile(path);
594
+ } catch {
595
+ sendJson(res, 404, { error: "no such artifact" });
596
+ return;
597
+ }
598
+ res.writeHead(200, {
599
+ ...baseHeaders(),
600
+ "content-type": "text/html; charset=utf-8",
601
+ // The iframe is a preview, not an extension of the workspace origin.
602
+ // `sandbox allow-scripts` keeps demos interactive while retaining an
603
+ // opaque origin: scripts cannot reach the workspace or its cookies.
604
+ "content-security-policy": "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:",
605
+ "cache-control": "no-store",
606
+ });
607
+ res.end(body);
608
+ }
609
+
579
610
  async function sendSearch(deps: RouteDeps, query: URLSearchParams, res: ServerResponse): Promise<void> {
580
611
  const q = query.get("q") ?? "";
581
612
  // `searchNotes` already returns `[]` for an empty query, so a missing `q`