pi-weave 0.3.1 → 0.3.3
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.
- package/README.md +14 -1
- package/package.json +2 -1
- package/src/web/client/dist/app.js +88 -124
- package/src/web/client/graph/ForceTuner.tsx +14 -98
- package/src/web/client/graph/Graph.tsx +11 -74
- package/src/web/client/graph/column.model.ts +3 -13
- package/src/web/client/graph/graph.model.ts +30 -62
- package/src/web/client/graph/groups.ts +9 -7
- package/src/web/client/graph/positions.ts +2 -2
- package/src/web/client/graph/renderer.dom.ts +2 -2
- package/src/web/client/graph/renderer.ts +5 -4
- package/src/web/client/graph/tuner.model.ts +6 -180
- package/src/web/client/main.tsx +1 -4
- package/src/web/client/note/Note.tsx +11 -1
- package/src/web/client/shell/Settings.tsx +71 -0
- package/src/web/client/shell/Shell.tsx +61 -24
- package/src/web/client/shell/StatusBar.tsx +1 -5
- package/src/web/client/shell/keys.model.ts +14 -8
- package/src/web/client/shell/shell.model.ts +1 -21
- package/src/web/client/shell/theme.model.ts +26 -96
- package/src/web/client/shell/theme.ts +20 -145
- package/src/web/client/shell/workspace.css.ts +27 -4
- package/src/web/server/backup.ts +59 -0
- package/src/web/server/routes.ts +12 -1
- package/src/web/shared/forces.ts +70 -0
- package/src/web/shared/layout.ts +4 -69
- package/src/web/shared/preferences.ts +76 -0
- package/src/web/shared/themes.ts +92 -0
- package/src/web/shared/workspace.ts +44 -4
|
@@ -84,6 +84,7 @@ export interface KeyContext {
|
|
|
84
84
|
export type ShellAction =
|
|
85
85
|
| { readonly type: "openSearch" }
|
|
86
86
|
| { readonly type: "openHelp" }
|
|
87
|
+
| { readonly type: "openSettings" }
|
|
87
88
|
| { readonly type: "closeOverlay" }
|
|
88
89
|
| { readonly type: "focusColumn"; readonly column: ColumnId }
|
|
89
90
|
/** Focus the tree's filter box — `/`. */
|
|
@@ -91,8 +92,8 @@ export type ShellAction =
|
|
|
91
92
|
| { readonly type: "fitGraph" }
|
|
92
93
|
| { readonly type: "clearSelection" }
|
|
93
94
|
| { readonly type: "closeTab" }
|
|
94
|
-
/** `t` —
|
|
95
|
-
| { readonly type: "
|
|
95
|
+
/** `t` — switch between the selected light and dark themes. */
|
|
96
|
+
| { readonly type: "toggleTheme" };
|
|
96
97
|
|
|
97
98
|
/**
|
|
98
99
|
* The command-key letters, as data.
|
|
@@ -102,6 +103,7 @@ export type ShellAction =
|
|
|
102
103
|
*/
|
|
103
104
|
export const COMMAND_KEYS: Readonly<Record<string, ShellAction>> = {
|
|
104
105
|
k: { type: "openSearch" },
|
|
106
|
+
",": { type: "openSettings" },
|
|
105
107
|
};
|
|
106
108
|
|
|
107
109
|
/**
|
|
@@ -146,7 +148,7 @@ export const BARE_KEYS: Readonly<Record<string, ShellAction>> = {
|
|
|
146
148
|
"/": { type: "filterTree" },
|
|
147
149
|
g: { type: "fitGraph" },
|
|
148
150
|
"?": { type: "openHelp" },
|
|
149
|
-
t: { type: "
|
|
151
|
+
t: { type: "toggleTheme" },
|
|
150
152
|
};
|
|
151
153
|
|
|
152
154
|
/**
|
|
@@ -320,8 +322,8 @@ export interface ShellEffects {
|
|
|
320
322
|
/** Write `null` to §1.3's `selectedId`. */
|
|
321
323
|
clearSelection(): void;
|
|
322
324
|
closeTab(): void;
|
|
323
|
-
/** `t` —
|
|
324
|
-
|
|
325
|
+
/** `t` — switch between the user's selected light and dark themes. */
|
|
326
|
+
toggleTheme(): void;
|
|
325
327
|
}
|
|
326
328
|
|
|
327
329
|
/** Perform an action. Total over {@link ShellAction}. */
|
|
@@ -329,6 +331,8 @@ export function runShellAction(action: ShellAction, fx: ShellEffects): void {
|
|
|
329
331
|
switch (action.type) {
|
|
330
332
|
case "openSearch":
|
|
331
333
|
return fx.setOverlay("search");
|
|
334
|
+
case "openSettings":
|
|
335
|
+
return fx.setOverlay("settings");
|
|
332
336
|
case "openHelp":
|
|
333
337
|
return fx.setOverlay("help");
|
|
334
338
|
case "closeOverlay":
|
|
@@ -344,8 +348,8 @@ export function runShellAction(action: ShellAction, fx: ShellEffects): void {
|
|
|
344
348
|
return fx.clearSelection();
|
|
345
349
|
case "closeTab":
|
|
346
350
|
return fx.closeTab();
|
|
347
|
-
case "
|
|
348
|
-
return fx.
|
|
351
|
+
case "toggleTheme":
|
|
352
|
+
return fx.toggleTheme();
|
|
349
353
|
}
|
|
350
354
|
}
|
|
351
355
|
|
|
@@ -383,13 +387,14 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
|
|
|
383
387
|
title: "Global",
|
|
384
388
|
entries: [
|
|
385
389
|
{ combo: "⌥W / Alt W", what: "Close the active tab" },
|
|
390
|
+
{ combo: `${cmd},`, what: "Open settings" },
|
|
386
391
|
{ combo: `${cmd}K`, what: "Search notes and the repository" },
|
|
387
392
|
...Object.entries(COLUMN_DIGITS).map(([digit, column]) => ({
|
|
388
393
|
combo: `${cmd}${digit}`,
|
|
389
394
|
what: column === "tree" ? "Open and focus the notes sidebar" : column === "graph" ? "Open and focus Graph view" : "Focus the note",
|
|
390
395
|
})),
|
|
391
396
|
{ combo: "?", what: "This help" },
|
|
392
|
-
{ combo: "t", what: "
|
|
397
|
+
{ combo: "t", what: "Switch between selected light and dark themes" },
|
|
393
398
|
{ combo: "Esc", what: "Clear the selection, or close an overlay" },
|
|
394
399
|
],
|
|
395
400
|
},
|
|
@@ -407,6 +412,7 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
|
|
|
407
412
|
{
|
|
408
413
|
title: "Tabs and panes",
|
|
409
414
|
entries: [
|
|
415
|
+
{ combo: "Alt ← / →", what: "Reorder the focused tab" },
|
|
410
416
|
{ combo: "← / → / Home / End", what: "Select a tab when the tab strip is focused" },
|
|
411
417
|
{ combo: `${cmd}click`, what: "Open a note or search result in a new tab" },
|
|
412
418
|
{ combo: "Pane options (···)", what: "Split right or down, move a tab, or close a pane" },
|
|
@@ -79,8 +79,6 @@ export const CONTEXT_EMPTY: EmptyStateCopy = {
|
|
|
79
79
|
export interface StatusBarModel {
|
|
80
80
|
readonly cwd: string;
|
|
81
81
|
readonly selection: string;
|
|
82
|
-
/** `null` before the first successful graph fetch. */
|
|
83
|
-
readonly stamp: string | null;
|
|
84
82
|
}
|
|
85
83
|
|
|
86
84
|
/** The `—` shown where a value is genuinely absent, not zero. */
|
|
@@ -90,31 +88,13 @@ export const NO_VALUE = "—";
|
|
|
90
88
|
export function statusBarModel(
|
|
91
89
|
cwd: string,
|
|
92
90
|
selectedId: string | null,
|
|
93
|
-
stamp: string | null,
|
|
94
91
|
): StatusBarModel {
|
|
95
92
|
return {
|
|
96
93
|
cwd: cwd === "" ? NO_VALUE : cwd,
|
|
97
94
|
selection: selectedId ?? "nothing selected",
|
|
98
|
-
stamp,
|
|
99
95
|
};
|
|
100
96
|
}
|
|
101
97
|
|
|
102
|
-
/**
|
|
103
|
-
* A `generatedAt` stamp, shortened for the status bar.
|
|
104
|
-
*
|
|
105
|
-
* Just the `HH:MM:SS`, because the date is almost always today and the whole
|
|
106
|
-
* ISO string is 24 characters of mostly-constant noise in a bar that has
|
|
107
|
-
* three other things to say. A stamp that does not look like an ISO timestamp
|
|
108
|
-
* is passed through untouched rather than sliced blindly — the server derives
|
|
109
|
-
* it from input timestamps and is free to change that derivation, and a
|
|
110
|
-
* mangled substring would be a worse lie than the full value.
|
|
111
|
-
*/
|
|
112
|
-
export function shortStamp(stamp: string | null): string {
|
|
113
|
-
if (stamp === null) return NO_VALUE;
|
|
114
|
-
const match = /T(\d{2}:\d{2}:\d{2})/.exec(stamp);
|
|
115
|
-
return match?.[1] ?? stamp;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
98
|
/**
|
|
119
99
|
* How often the shell re-renders on its own, in ms.
|
|
120
100
|
*
|
|
@@ -151,7 +131,7 @@ export function searchHint(shortcut: string): string {
|
|
|
151
131
|
* other. Making that unrepresentable costs nothing here and removes a whole
|
|
152
132
|
* class of bug from the keyboard layer, which is the thing that opens them.
|
|
153
133
|
*/
|
|
154
|
-
export type OverlayId = "search" | "help" | null;
|
|
134
|
+
export type OverlayId = "search" | "help" | "settings" | null;
|
|
155
135
|
|
|
156
136
|
/**
|
|
157
137
|
* Whether a platform string looks like an Apple one.
|
|
@@ -1,64 +1,18 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* The stylesheet decides what the palettes are (`theme.ts`); this module
|
|
6
|
-
* decides *which one is showing*. The two are separate for §10's reason: the
|
|
7
|
-
* choice is state a user controls, so every decision about it has to be a
|
|
8
|
-
* testable pure function, while the `.tsx` that applies the attribute is
|
|
9
|
-
* three lines no test can reach.
|
|
10
|
-
*
|
|
11
|
-
* ## Why tri-state
|
|
12
|
-
*
|
|
13
|
-
* "Dark" and "light" alone are not enough: stripping a user of "follow my OS"
|
|
14
|
-
* is a regression the moment anyone prefers the *default* behaviour rather
|
|
15
|
-
* than either specific scheme. The cycle is `system → light → dark → system`
|
|
16
|
-
* so the control is one button, never a menu — a popover in the ribbon is
|
|
17
|
-
* a second focusable thing to reach, and the setting is not worth one.
|
|
18
|
-
*
|
|
19
|
-
* ## The attribute, not a class, not inline styles
|
|
20
|
-
*
|
|
21
|
-
* The sheet's light branch moves from the bare `prefers-color-scheme` media
|
|
22
|
-
* query to a *pair* of selectors: `:root[data-weave-theme="light"]` (the
|
|
23
|
-
* override) plus the media query narrowed by
|
|
24
|
-
* `:root:not([data-weave-theme="dark"])` (system default). The attribute is
|
|
25
|
-
* written on `<html>` because CSP `style-src 'nonce-…'` blocks inline
|
|
26
|
-
* `style=""` attributes outright — the same verified reasoning that pushes
|
|
27
|
-
* `cssvars.ts` through the CSSOM — while `data-*` attribute writes are not
|
|
28
|
-
* style at all and need no exception. In `system` mode the attribute is
|
|
29
|
-
* **cleared**, not set to a third value, so the media query keeps answering
|
|
30
|
-
* a live OS flip without the client listening.
|
|
31
|
-
*
|
|
32
|
-
* ## Tier rules (§2)
|
|
33
|
-
*
|
|
34
|
-
* `src/web/client/**`. Storage arrives as the two-method port
|
|
35
|
-
* the graph position cache uses, so a fake is an object literal and quota
|
|
36
|
-
* failures stay the caller's problem — a theme the user could not persist is
|
|
37
|
-
* the same cosmetic cost as a selection.
|
|
38
|
-
*/
|
|
39
|
-
|
|
1
|
+
/** Persistent appearance mode and switching between the selected light/dark palettes. */
|
|
2
|
+
import { DEFAULT_PREFERENCES, type Preferences } from "../../shared/preferences";
|
|
3
|
+
import { THEMES } from "../../shared/themes";
|
|
4
|
+
import type { PaletteChoice, ThemeChoice } from "../../shared/themes";
|
|
40
5
|
import type { ColorScheme } from "../graph/graph.model";
|
|
6
|
+
import { THEME_CHOICES, isThemeChoice } from "../../shared/themes";
|
|
7
|
+
export { THEME_CHOICES, isThemeChoice };
|
|
8
|
+
export type { ThemeChoice };
|
|
41
9
|
|
|
42
|
-
/** What the user picked, as the button cycles it. */
|
|
43
|
-
export type ThemeChoice = "system" | "light" | "dark";
|
|
44
|
-
|
|
45
|
-
/** The choices in cycle order. */
|
|
46
|
-
export const THEME_CHOICES: readonly ThemeChoice[] = ["system", "light", "dark"];
|
|
47
|
-
|
|
48
|
-
/** The `localStorage` key. Namespaced and versioned. */
|
|
49
10
|
export const THEME_STORAGE_KEY = "pi-weave.theme.v1";
|
|
50
|
-
|
|
51
|
-
/** The slice of `Storage` this module needs. */
|
|
52
11
|
export interface ThemeStorage {
|
|
53
12
|
getItem(key: string): string | null;
|
|
54
13
|
setItem(key: string, value: string): void;
|
|
55
14
|
}
|
|
56
15
|
|
|
57
|
-
/** Structural guard, mirroring `readBootstrap`'s. */
|
|
58
|
-
export function isThemeChoice(value: unknown): value is ThemeChoice {
|
|
59
|
-
return value === "system" || value === "light" || value === "dark";
|
|
60
|
-
}
|
|
61
|
-
|
|
62
16
|
/**
|
|
63
17
|
* The stored choice, or `null`.
|
|
64
18
|
*
|
|
@@ -86,62 +40,38 @@ export function saveTheme(storage: ThemeStorage, choice: ThemeChoice): boolean {
|
|
|
86
40
|
}
|
|
87
41
|
}
|
|
88
42
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return
|
|
43
|
+
type ThemePair = Pick<Preferences, "lightTheme" | "darkTheme">;
|
|
44
|
+
|
|
45
|
+
/** Toggle to the selected palette for the opposite effective appearance. */
|
|
46
|
+
export function toggleTheme(choice: ThemeChoice, systemScheme: ColorScheme, pair: ThemePair = DEFAULT_PREFERENCES): PaletteChoice {
|
|
47
|
+
const current = THEMES[effectiveScheme(choice, systemScheme, pair)].scheme;
|
|
48
|
+
return current === "light" ? pair.darkTheme : pair.lightTheme;
|
|
95
49
|
}
|
|
96
50
|
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
* This is the *whole* resolution: the sheet owns what colours a scheme is,
|
|
101
|
-
* `scheme.ts` owns reading the OS once, and this owns the decision — so the
|
|
102
|
-
* graph column, whose WebGL palette cannot read CSS custom properties, takes
|
|
103
|
-
* the same answer the stylesheet's attribute takes.
|
|
104
|
-
*/
|
|
105
|
-
export function effectiveScheme(choice: ThemeChoice, systemScheme: ColorScheme): ColorScheme {
|
|
106
|
-
return choice === "system" ? systemScheme : choice;
|
|
51
|
+
/** System mode uses the user's palette for the current device appearance. */
|
|
52
|
+
export function effectiveScheme(choice: ThemeChoice, systemScheme: ColorScheme, pair: ThemePair = DEFAULT_PREFERENCES): PaletteChoice {
|
|
53
|
+
return choice === "system" ? (systemScheme === "light" ? pair.lightTheme : pair.darkTheme) : choice;
|
|
107
54
|
}
|
|
108
55
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
111
|
-
*
|
|
112
|
-
* `null` is not a third value on purpose: in `system` mode the media query
|
|
113
|
-
* alone must govern, and any attribute would override the OS. See the module
|
|
114
|
-
* header's "attribute, not class" note.
|
|
115
|
-
*/
|
|
116
|
-
export function themeAttr(choice: ThemeChoice): "light" | "dark" | null {
|
|
56
|
+
/** System mode clears the attribute so the media query governs. */
|
|
57
|
+
export function themeAttr(choice: ThemeChoice): PaletteChoice | null {
|
|
117
58
|
return choice === "system" ? null : choice;
|
|
118
59
|
}
|
|
119
60
|
|
|
120
|
-
// --- the control's view ------------------------------------------------------------
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* The ribbon button's face.
|
|
124
|
-
*
|
|
125
|
-
* The glyphs are the provenance language the tree already teaches
|
|
126
|
-
* (`tree.model.ts`): filled, half and hollow of one shape. Filled `●` is
|
|
127
|
-
* dark, hollower `◐` is system — half machine, half choice — and `○` is
|
|
128
|
-
* light. Three glyphs, one family, no icon font, no `url()`.
|
|
129
|
-
*/
|
|
130
61
|
export interface ThemeButtonView {
|
|
131
62
|
readonly glyph: "●" | "◐" | "○";
|
|
132
|
-
|
|
63
|
+
readonly label: string;
|
|
133
64
|
readonly hint: string;
|
|
134
65
|
}
|
|
135
66
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
light: "○",
|
|
140
|
-
};
|
|
67
|
+
function themeName(choice: ThemeChoice): string {
|
|
68
|
+
return choice === "system" ? "System" : THEMES[choice].name;
|
|
69
|
+
}
|
|
141
70
|
|
|
142
|
-
export function themeButton(choice: ThemeChoice): ThemeButtonView {
|
|
71
|
+
export function themeButton(choice: ThemeChoice, systemScheme: ColorScheme = "dark", pair: ThemePair = DEFAULT_PREFERENCES): ThemeButtonView {
|
|
143
72
|
return {
|
|
144
|
-
glyph:
|
|
145
|
-
|
|
73
|
+
glyph: choice === "system" ? "◐" : THEMES[choice].scheme === "light" ? "○" : "●",
|
|
74
|
+
label: themeName(choice),
|
|
75
|
+
hint: `Colour theme: ${themeName(choice)} — click for ${themeName(toggleTheme(choice, systemScheme, pair))}`,
|
|
146
76
|
};
|
|
147
77
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { THEMES, ACCENTS } from "../../shared/themes";
|
|
2
|
+
import type { PaletteChoice } from "../../shared/themes";
|
|
1
3
|
import { WORKSPACE_CSS } from "./workspace.css";
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -45,96 +47,19 @@ import { WORKSPACE_CSS } from "./workspace.css";
|
|
|
45
47
|
* real `document` satisfies it structurally.
|
|
46
48
|
*/
|
|
47
49
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
* here.
|
|
58
|
-
*
|
|
59
|
-
* And since `shell/theme.model.ts`, the palette a user looks at is a
|
|
60
|
-
* *choice*: `:root` carries dark, the light tokens sit in
|
|
61
|
-
* {@link LIGHT_TOKENS} and are applied by a `data-weave-theme` attribute
|
|
62
|
-
* (a manual selection) and by the media query narrowed with
|
|
63
|
-
* `:root:not([data-weave-theme="dark"])` (the system default). Clearing the
|
|
64
|
-
* attribute returns the workspace to following the OS with no client-side
|
|
65
|
-
* listener involved. The graph follows the same resolution through
|
|
66
|
-
* `theme.model.ts`'s `effectiveScheme`, because its WebGL palette cannot
|
|
67
|
-
* read any of this.
|
|
68
|
-
*
|
|
69
|
-
* ## "Dense but calm" (§1.2)
|
|
70
|
-
*
|
|
71
|
-
* The sketch is an information-dense IDE surface, not a marketing page, so
|
|
72
|
-
* the rules below deliberately avoid the defaults that produce the opposite:
|
|
73
|
-
* no card shadows, no 24 px gutters, no rounded panels floating on a
|
|
74
|
-
* contrasting background. Density comes from a 13 px base, 4–10 px padding
|
|
75
|
-
* and 1 px hairline rules; calm comes from a **single** accent colour used
|
|
76
|
-
* only for focus and selection, three greys doing all the structural work,
|
|
77
|
-
* and no borders where a background change already separates two regions.
|
|
78
|
-
*
|
|
79
|
-
* ## Where the hexes come from
|
|
80
|
-
*
|
|
81
|
-
* Both schemes are Catppuccin (the user's call): dark is **Macchiato**, light
|
|
82
|
-
* is **Latte**, mapped onto the desk-and-page split with one structural rule
|
|
83
|
-
* shared by both — the note page is the `crust` ground, rails and bars are
|
|
84
|
-
* `mantle`, the desk and graph canvas are `base`, raised controls and
|
|
85
|
-
* hairlines are `surface0` with `surface1` for the strong hairline. Light's
|
|
86
|
-
* topology is therefore an exact mirror of dark's (a darker sheet on a lighter
|
|
87
|
-
* desk), so a token means the same thing in either scheme:
|
|
88
|
-
*
|
|
89
|
-
* - **Dark, Macchiato** — every value a stock Catppuccin label: `base #24273A`
|
|
90
|
-
* desk, `mantle #1E2030` panel, `crust #181926` page, `surface0 #363A4F`
|
|
91
|
-
* raise/hairline, `surface1 #494D64` strong hairline; text `#CAD3F5`,
|
|
92
|
-
* dims `#A5ADCB`/`#939AB7`, and **Mauve `#C6A0F6` as the single accent
|
|
93
|
-
* voice** (7.5:1 on panel) with Green/Yellow/Red as status.
|
|
94
|
-
*
|
|
95
|
-
* - **Light, Latte** — grounds are stock labels again (`base #EFF1F5` desk,
|
|
96
|
-
* `mantle #E6E9EF` panel, `crust #DCE0E8` page, `surface0 #CCD0DA`,
|
|
97
|
-
* `surface1 #BCC0CC`), and text/status hue-preservingly *deepened* to hold
|
|
98
|
-
* 4.5:1 on the darkest grounds Latte owns: `dim #56586A` (from Subtext0,
|
|
99
|
-
* same hue 233°), `faint #606274` (Overlay2), `accent #7113EC` (Mauve),
|
|
100
|
-
* `ok #28641B`, `warn #7C4F10`, `bad #B20D30`. Text `#4C4F69` is stock
|
|
101
|
-
* Text. The hue-preserving deepening keeps the palette recognisably
|
|
102
|
-
* Catppuccin where blending toward neutral would grey it away.
|
|
103
|
-
*
|
|
104
|
-
* Measured ratios (WCAG, on the darkest grounds — `raise`/`surface0` for
|
|
105
|
-
* light text tokens, `panel`/`mantle` for dark): light fg 4.5, dim 4.5,
|
|
106
|
-
* accent 4.5, ok 4.6, warn 4.6, bad 4.5, each also ≥ 4.5 on the brighter
|
|
107
|
-
* grounds; dark faint 4.6 on `surface0` and 5.8 on panel, dim 5.0 on
|
|
108
|
-
* `surface0`, fg 7.6. The one below-4.5 site is `faint` *on* `raise`
|
|
109
|
-
* (light 3.9) — every use is non-text there (scrollbar thumb, ghost
|
|
110
|
-
* borders, icon glyphs), where WCAG asks 3:1.
|
|
111
|
-
*
|
|
112
|
-
* Status colours keep each scheme's temperature at ≥ 4.5:1 text contrast —
|
|
113
|
-
* measured, not eyeballed (dark `#a6da95`/`#1e2030` 10.0, `#eed49f` 11.2,
|
|
114
|
-
* `#ed8796` 6.5; light values are the deepened derivatives above).
|
|
115
|
-
*
|
|
116
|
-
* The graph cannot read these variables (WebGL — `graph.model.ts`), so
|
|
117
|
-
* `GRAPH_PALETTE` mirrors the eight palette slots as literals; the test that makes
|
|
118
|
-
* that copy safe asserts every mirrored hex appears in this string.
|
|
119
|
-
*/
|
|
120
|
-
/**
|
|
121
|
-
* The light-block tokens, interpolated into both selectors that need them —
|
|
122
|
-
* see the {@link THEME_CSS} branch rules.
|
|
123
|
-
*/
|
|
124
|
-
const LIGHT_TOKENS = `
|
|
125
|
-
--weave-bg:#eff1f5;--weave-panel:#e6e9ef;--weave-raise:#ccd0da;--weave-fg:#4c4f69;--weave-dim:#56586a;
|
|
126
|
-
--weave-faint:#606274;--weave-line:#ccd0da;--weave-line-strong:#bcc0cc;
|
|
127
|
-
--weave-accent:#7113ec;--weave-ok:#28641b;--weave-warn:#7c4f10;--weave-bad:#b20d30;
|
|
128
|
-
--weave-new:rgba(113,19,236,.10);
|
|
129
|
-
--weave-page:#dce0e8;
|
|
130
|
-
`;
|
|
50
|
+
/** Palette declarations are shared with the WebGL graph. */
|
|
51
|
+
function paletteTokens(id: PaletteChoice): string {
|
|
52
|
+
const { scheme, colors } = THEMES[id];
|
|
53
|
+
return Object.entries(colors).map(([key, value]) => `--weave-${key}:${value};`).join("")
|
|
54
|
+
+ `--weave-new:color-mix(in srgb,var(--weave-accent) ${scheme === "light" ? 10 : 16}%,transparent);`
|
|
55
|
+
+ `color-scheme:${scheme};`
|
|
56
|
+
+ `--weave-vignette:radial-gradient(ellipse at center,transparent 55%,${scheme === "light" ? "rgba(0,0,0,.10)" : "rgba(0,0,0,.22)"} 100%);`
|
|
57
|
+
+ `--weave-scrim:rgba(0,0,0,${scheme === "light" ? ".30" : ".45"});`;
|
|
58
|
+
}
|
|
131
59
|
|
|
132
60
|
export const THEME_CSS = `
|
|
133
61
|
:root{
|
|
134
|
-
|
|
135
|
-
--weave-faint:#939ab7;--weave-line:#363a4f;--weave-line-strong:#494d64;
|
|
136
|
-
--weave-accent:#c6a0f6;--weave-ok:#a6da95;--weave-warn:#eed49f;--weave-bad:#ed8796;
|
|
137
|
-
--weave-new:rgba(198,160,246,.16);
|
|
62
|
+
${paletteTokens("dark")}
|
|
138
63
|
--weave-row:26px;--weave-gutter:10px;
|
|
139
64
|
/* The reading gutter is note-only: prose wants a wider margin than chrome.
|
|
140
65
|
Rails, rows and bars keep --weave-gutter, so density is a property of the
|
|
@@ -148,7 +73,7 @@ export const THEME_CSS = `
|
|
|
148
73
|
third face was tried (serif prose) and withdrawn at the user's call; two
|
|
149
74
|
voices read calmer than three. The CSP allows nothing fetched, and both
|
|
150
75
|
are system stacks. */
|
|
151
|
-
|
|
76
|
+
|
|
152
77
|
/* Two radii, per the plan: hairline-sharp for controls (a 4 px corner is
|
|
153
78
|
the difference between a control and a card), one softer corner for the
|
|
154
79
|
two overlays that float above the grid. Tag pills are 999px — a capsule
|
|
@@ -183,20 +108,13 @@ export const THEME_CSS = `
|
|
|
183
108
|
--weave-px-row:12px;--weave-px-base:13px;--weave-px-body:13.5px;
|
|
184
109
|
--weave-px-subhead:14px;--weave-px-title:15px;--weave-px-display:20px;
|
|
185
110
|
}
|
|
186
|
-
/* The light tokens, shared verbatim by the attribute branch and the media
|
|
187
|
-
query below — one const interpolated twice is the only way CSS gets
|
|
188
|
-
"these are the same colours" without a preprocessor. Which branch wins is
|
|
189
|
-
theme.model.ts's business: the attribute carries a *manual* choice, the
|
|
190
|
-
media query the system default, and the :not() is what lets the two coexist
|
|
191
|
-
without a manual "dark" being dragged back into light by the OS. */
|
|
192
111
|
@media (prefers-color-scheme: light){
|
|
193
|
-
:root:not([data-weave-theme
|
|
194
|
-
${LIGHT_TOKENS}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
:root[data-weave-theme="light"]{
|
|
198
|
-
${LIGHT_TOKENS}
|
|
112
|
+
:root:not([data-weave-theme]){${paletteTokens("light")}}
|
|
199
113
|
}
|
|
114
|
+
${(Object.keys(THEMES) as PaletteChoice[]).map((id) => `:root[data-weave-theme="${id}"]{${paletteTokens(id)}}`).join("\n")}
|
|
115
|
+
${Object.entries(ACCENTS).map(([id, colors]) => ["light", "dark"].map((scheme) =>
|
|
116
|
+
`:root[data-weave-scheme="${scheme}"][data-weave-accent="${id}"]{--weave-accent:${colors[scheme as "light" | "dark"]};}`
|
|
117
|
+
).join("\n")).join("\n")}
|
|
200
118
|
body{font-size:var(--weave-px-base)}
|
|
201
119
|
#app{height:100%;display:grid;grid-template-rows:minmax(0,1fr) auto;background:var(--weave-bg)}
|
|
202
120
|
|
|
@@ -473,15 +391,7 @@ body{font-size:var(--weave-px-base)}
|
|
|
473
391
|
faster than a dark one under a black veil. */
|
|
474
392
|
.weave-graph-canvas::after{
|
|
475
393
|
content:"";position:absolute;inset:0;pointer-events:none;
|
|
476
|
-
background:
|
|
477
|
-
}
|
|
478
|
-
:root[data-weave-theme="light"] .weave-graph-canvas::after{
|
|
479
|
-
background:radial-gradient(ellipse at center,transparent 55%,rgba(76,79,105,.10) 100%);
|
|
480
|
-
}
|
|
481
|
-
@media (prefers-color-scheme: light){
|
|
482
|
-
:root:not([data-weave-theme="dark"]) .weave-graph-canvas::after{
|
|
483
|
-
background:radial-gradient(ellipse at center,transparent 55%,rgba(76,79,105,.10) 100%);
|
|
484
|
-
}
|
|
394
|
+
background:var(--weave-vignette);
|
|
485
395
|
}
|
|
486
396
|
.weave-graph-empty{
|
|
487
397
|
margin:0;padding:14px var(--weave-gutter);color:var(--weave-dim);
|
|
@@ -507,32 +417,6 @@ body{font-size:var(--weave-px-base)}
|
|
|
507
417
|
border-top:1px solid var(--weave-line);
|
|
508
418
|
}
|
|
509
419
|
|
|
510
|
-
/* the hidden force tuner (?sliders=1, docs/weave-workspace.md 15.7) --------- */
|
|
511
|
-
/* Absolutely positioned over the canvas rather than given a grid row: the
|
|
512
|
-
panel is a debug overlay and must not change the layout the graph is being
|
|
513
|
-
judged in — moving the canvas while tuning it would falsify the very thing
|
|
514
|
-
being looked at. */
|
|
515
|
-
.weave-tuner{
|
|
516
|
-
position:absolute;top:8px;right:8px;z-index:3;width:232px;max-height:calc(100% - 16px);
|
|
517
|
-
overflow:auto;display:flex;flex-direction:column;gap:6px;padding:9px 10px;
|
|
518
|
-
font-size:var(--weave-px-caption);color:var(--weave-fg);
|
|
519
|
-
background:var(--weave-panel);border:1px solid var(--weave-line-strong);
|
|
520
|
-
border-radius:var(--weave-radius);
|
|
521
|
-
}
|
|
522
|
-
.weave-tuner-title{margin:0;color:var(--weave-faint);letter-spacing:.04em;text-transform:uppercase}
|
|
523
|
-
.weave-tuner-row{display:flex;flex-direction:column;gap:2px;cursor:pointer}
|
|
524
|
-
.weave-tuner-label{display:flex;justify-content:space-between;gap:8px;color:var(--weave-dim)}
|
|
525
|
-
.weave-tuner-label b{color:var(--weave-accent);font-weight:600;font-variant-numeric:tabular-nums}
|
|
526
|
-
.weave-tuner-row input{width:100%;margin:0;accent-color:var(--weave-accent)}
|
|
527
|
-
.weave-tuner-toggle{display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--weave-dim)}
|
|
528
|
-
.weave-tuner-toggle input{margin:0;accent-color:var(--weave-accent)}
|
|
529
|
-
.weave-tuner-actions{display:flex;gap:6px;margin-top:2px;flex-wrap:wrap}
|
|
530
|
-
.weave-tuner-snippet{
|
|
531
|
-
margin:0;padding:6px;overflow:auto;white-space:pre;color:var(--weave-faint);
|
|
532
|
-
background:var(--weave-bg);border:1px solid var(--weave-line);border-radius:var(--weave-radius);
|
|
533
|
-
font-size:var(--weave-px-prov);line-height:1.45;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
420
|
/* context rail ---------------------------------------------------------- */
|
|
537
421
|
/* The \`.weave-empty\` block that used to sit above went with \`EmptyState.tsx\`
|
|
538
422
|
in P3: every column now has its own empty state (\`treeEmptyMessage\`,
|
|
@@ -595,7 +479,6 @@ body{font-size:var(--weave-px-base)}
|
|
|
595
479
|
.weave-status-cwd,.weave-status-sel{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
596
480
|
.weave-status-cwd{max-width:38%;color:var(--weave-faint)}
|
|
597
481
|
.weave-status-sel{color:var(--weave-fg)}
|
|
598
|
-
.weave-status-stamp{margin-left:auto;color:var(--weave-faint)}
|
|
599
482
|
|
|
600
483
|
/* overlays: the ⌘K palette and the ? help sheet (P4) --------------------- */
|
|
601
484
|
/* The scrim is a click target that closes, and the reason both overlays sit
|
|
@@ -604,15 +487,7 @@ body{font-size:var(--weave-px-base)}
|
|
|
604
487
|
the wrong breakpoint. */
|
|
605
488
|
.weave-scrim{
|
|
606
489
|
position:fixed;inset:0;z-index:10;display:flex;justify-content:center;
|
|
607
|
-
align-items:flex-start;padding:9vh 16px 16px;background:
|
|
608
|
-
}
|
|
609
|
-
/* The scrim above is tuned for the dark ground, where a neutral black veil
|
|
610
|
-
reads as depth. On the linen ground the same veil reads as a power cut, so
|
|
611
|
-
the light scheme gets its own — a plum-tinted veil at reduced strength,
|
|
612
|
-
drawn from the foreground ramp rather than from grey. */
|
|
613
|
-
:root[data-weave-theme="light"] .weave-scrim{background:rgba(76,79,105,.30)}
|
|
614
|
-
@media (prefers-color-scheme: light){
|
|
615
|
-
:root:not([data-weave-theme="dark"]) .weave-scrim{background:rgba(76,79,105,.30)}
|
|
490
|
+
align-items:flex-start;padding:9vh 16px 16px;background:var(--weave-scrim);
|
|
616
491
|
}
|
|
617
492
|
.weave-palette,.weave-help{
|
|
618
493
|
display:flex;flex-direction:column;width:100%;max-width:560px;max-height:72vh;
|
|
@@ -41,7 +41,9 @@ export const WORKSPACE_CSS = `
|
|
|
41
41
|
.weave-pane-drop{position:relative}
|
|
42
42
|
.weave-pane-drop::after{content:attr(data-drop-label);position:absolute;inset:4px;z-index:20;display:grid;place-items:center;pointer-events:none;border:2px dashed var(--weave-accent);border-radius:var(--weave-radius);color:var(--weave-accent);background:var(--weave-new)}
|
|
43
43
|
.weave-tabs{display:flex;align-items:stretch;min-width:0;overflow-x:auto;background:var(--weave-panel);gap:3px;padding:5px 6px 0;border-bottom:1px solid var(--weave-line);scrollbar-width:thin}
|
|
44
|
-
.weave-tab{display:flex;align-items:center;min-width:90px;max-width:210px;border-radius:var(--weave-radius) var(--weave-radius) 0 0;color:var(--weave-dim);flex-shrink:0;border-bottom:2px solid transparent}
|
|
44
|
+
.weave-tab{position:relative;display:flex;align-items:center;min-width:90px;max-width:210px;border-radius:var(--weave-radius) var(--weave-radius) 0 0;color:var(--weave-dim);flex-shrink:0;border-bottom:2px solid transparent}
|
|
45
|
+
.weave-tab-drop-before::before,.weave-tab-drop-after::after{content:"";position:absolute;top:2px;bottom:2px;width:3px;background:var(--weave-accent);pointer-events:none}
|
|
46
|
+
.weave-tab-drop-before::before{left:-3px}.weave-tab-drop-after::after{right:-3px}
|
|
45
47
|
.weave-tab-active{background:var(--weave-page);color:var(--weave-fg)}
|
|
46
48
|
.weave-pane-active .weave-tab-active{border-bottom-color:var(--weave-accent)}
|
|
47
49
|
.weave-tab button{border:0;background:none}
|
|
@@ -65,12 +67,12 @@ export const WORKSPACE_CSS = `
|
|
|
65
67
|
.weave-pane-content{min-width:0;min-height:0;display:flex;overflow:hidden;position:relative}
|
|
66
68
|
.weave-document{display:flex;min-width:0;min-height:0;flex:1}
|
|
67
69
|
.weave-document .weave-note{width:100%;border-left:0}
|
|
68
|
-
.weave-document .weave-note-head{position:static;padding:28px max(24px,calc((100% - 760px)/2)) 18px;border:0}
|
|
70
|
+
.weave-document .weave-note-head{position:static;padding:28px max(24px,calc((100% - var(--weave-reading-width,760px))/2)) 18px;border:0}
|
|
69
71
|
.weave-document .weave-note-title{font-size:var(--weave-px-display);letter-spacing:-.03em;margin-bottom:12px}
|
|
70
|
-
.weave-document .weave-note-body{padding:10px max(24px,calc((100% - 760px)/2)) 64px;font-size:var(--weave-px-subhead);line-height:1.8}
|
|
72
|
+
.weave-document .weave-note-body{padding:10px max(24px,calc((100% - var(--weave-reading-width,760px))/2)) 64px;font-size:var(--weave-note-size,var(--weave-px-subhead));line-height:1.8}
|
|
71
73
|
.weave-document .weave-note-body h1{font-size:var(--weave-px-display);line-height:1.4;margin-bottom:18px}
|
|
72
74
|
.weave-document .weave-note-body h2{font-size:var(--weave-px-title);margin-top:28px}
|
|
73
|
-
.weave-document .weave-note-editor{padding:14px
|
|
75
|
+
.weave-document .weave-note-editor{padding:14px max(24px,calc((100% - var(--weave-reading-width,760px))/2));min-height:220px;flex:1;font-size:var(--weave-note-size,var(--weave-px-subhead))}
|
|
74
76
|
.weave-document .weave-note-tags{margin-top:12px}
|
|
75
77
|
.weave-document .weave-note-edit,.weave-document .weave-note-save{padding:5px 10px;border-radius:var(--weave-radius)}
|
|
76
78
|
.weave-welcome{align-self:center;margin:auto;padding:35px;max-width:560px;color:var(--weave-dim);line-height:1.7}
|
|
@@ -114,4 +116,25 @@ export const WORKSPACE_CSS = `
|
|
|
114
116
|
@media(prefers-reduced-motion:reduce){
|
|
115
117
|
.weave-sidebar-dock,.weave-sidebar-dock[data-open=false],.weave-sidebar-dock>.weave-sidebar{transition:none}
|
|
116
118
|
}
|
|
119
|
+
|
|
120
|
+
.weave-settings-scrim{align-items:center;padding:24px 16px}
|
|
121
|
+
.weave-settings button{font:inherit;color:inherit;cursor:pointer;border:0;background:transparent}
|
|
122
|
+
.weave-settings a{color:var(--weave-accent)}
|
|
123
|
+
.weave-settings{display:grid;grid-template-columns:180px minmax(0,1fr);grid-template-rows:auto minmax(0,1fr);width:min(900px,calc(100vw - 32px));height:min(680px,calc(100dvh - 48px));background:var(--weave-panel);border:1px solid var(--weave-line-strong);border-radius:12px;box-shadow:0 24px 80px var(--weave-scrim);overflow:hidden}
|
|
124
|
+
.weave-settings header{grid-column:1/-1;display:flex;justify-content:space-between;align-items:center;padding:16px 24px;border-bottom:1px solid var(--weave-line)}
|
|
125
|
+
.weave-settings h2,.weave-settings h3{margin:0}.weave-settings h3{font-size:20px;margin-bottom:24px}
|
|
126
|
+
.weave-settings header button{font-size:24px}.weave-settings nav{padding:16px 12px;background:var(--weave-bg);overflow:auto;border-right:1px solid var(--weave-line)}
|
|
127
|
+
.weave-settings nav button{display:block;width:100%;text-align:left;padding:10px 12px;border-radius:6px;color:var(--weave-dim)}
|
|
128
|
+
.weave-settings nav button[aria-current]{color:var(--weave-accent);background:var(--weave-new)}
|
|
129
|
+
.weave-settings section{padding:28px;overflow:auto;min-width:0}.weave-settings p{line-height:1.6;color:var(--weave-dim)}
|
|
130
|
+
.weave-setting-row{display:flex;justify-content:space-between;align-items:center;gap:24px;padding:16px 0;border-bottom:1px solid var(--weave-line)}
|
|
131
|
+
.weave-setting-row>span:first-child{min-width:0;flex:1}.weave-setting-row strong{display:block;font-weight:500}.weave-setting-row small{display:block;color:var(--weave-dim);margin-top:6px;line-height:1.5}
|
|
132
|
+
.weave-settings select{background:var(--weave-raise);border:1px solid var(--weave-line-strong);border-radius:6px;padding:7px;color:var(--weave-fg);max-width:230px;font:inherit}
|
|
133
|
+
.weave-settings input{accent-color:var(--weave-accent)}.weave-settings input[type=checkbox]{width:18px;height:18px;flex-shrink:0}
|
|
134
|
+
.weave-setting-range{display:flex;align-items:center;gap:12px;width:190px}.weave-setting-range input{width:130px;min-width:0}.weave-setting-range output{min-width:42px;text-align:right;color:var(--weave-accent);font-variant-numeric:tabular-nums}
|
|
135
|
+
.weave-settings button:focus-visible,.weave-settings a:focus-visible,.weave-settings summary:focus-visible{outline:2px solid var(--weave-accent);outline-offset:2px}
|
|
136
|
+
.weave-settings-actions{display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin:24px 0}.weave-settings-actions button,.weave-settings-download,.weave-settings-forces>button{display:inline-block;padding:9px 14px;border:1px solid var(--weave-line-strong);border-radius:6px;color:var(--weave-accent);background:var(--weave-raise);text-decoration:none}
|
|
137
|
+
.weave-settings-forces>button{margin-top:20px}.weave-settings-forces>button:disabled{opacity:.5}.weave-settings-locations dd{margin:6px 0 18px;overflow-wrap:anywhere;color:var(--weave-dim);font-family:var(--weave-font-mono)}
|
|
138
|
+
.weave-settings details summary{cursor:pointer;padding:12px 0}.weave-settings .weave-key-row{gap:16px;align-items:baseline}.weave-settings .weave-key-row kbd{min-width:150px;color:var(--weave-accent)}
|
|
139
|
+
@media(max-width:650px){.weave-settings{grid-template-columns:1fr;grid-template-rows:auto auto minmax(0,1fr)}.weave-settings nav{border-right:0;border-bottom:1px solid var(--weave-line);padding:8px 16px}.weave-settings nav select{width:100%;max-width:none}.weave-settings section{padding:20px}.weave-setting-row{align-items:flex-start;gap:12px}.weave-settings select{max-width:170px}.weave-setting-range{width:140px}.weave-setting-range input{width:90px}}
|
|
117
140
|
`;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Create a complete archive before offering a download; originals are read only. */
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { mkdtemp, open, readdir, realpath, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Zip, ZipDeflate } from "fflate";
|
|
7
|
+
|
|
8
|
+
export async function createVaultBackup(vaultRoot: string): Promise<{ path: string; dispose: () => Promise<void> }> {
|
|
9
|
+
const directory = await mkdtemp(join(tmpdir(), "pi-weave-backup-"));
|
|
10
|
+
const path = join(directory, "vault.zip");
|
|
11
|
+
const dispose = (): Promise<void> => rm(directory, { recursive: true, force: true });
|
|
12
|
+
try {
|
|
13
|
+
const output = await open(path, "w");
|
|
14
|
+
try {
|
|
15
|
+
let pending = Promise.resolve();
|
|
16
|
+
let failure: Error | null = null;
|
|
17
|
+
let entries = 0;
|
|
18
|
+
const zip = new Zip((error, chunk) => {
|
|
19
|
+
if (error) { failure = error; return; }
|
|
20
|
+
pending = pending.then(() => output.writeFile(chunk));
|
|
21
|
+
});
|
|
22
|
+
const flush = async (): Promise<void> => { await pending; if (failure) throw failure; };
|
|
23
|
+
const walk = async (directory: string, prefix: string, ancestors: ReadonlySet<string>): Promise<void> => {
|
|
24
|
+
const resolved = await realpath(directory);
|
|
25
|
+
if (ancestors.has(resolved)) throw new Error(`Symbolic-link cycle in vault: ${prefix}`);
|
|
26
|
+
const next = new Set([...ancestors, resolved]);
|
|
27
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
28
|
+
// ZIP readers on Windows treat backslashes as path separators.
|
|
29
|
+
if (entry.name.includes("\\")) throw new Error(`Unsupported backup filename: ${entry.name}`);
|
|
30
|
+
const source = join(directory, entry.name);
|
|
31
|
+
const info = await stat(source);
|
|
32
|
+
const name = prefix + entry.name + (info.isDirectory() ? "/" : "");
|
|
33
|
+
if (!info.isDirectory() && !info.isFile()) throw new Error(`Unsupported vault entry: ${name}`);
|
|
34
|
+
if (++entries > 65535 || info.isFile() && info.size > 0xffffffff) throw new Error("Vault exceeds ZIP format limits");
|
|
35
|
+
const file = new ZipDeflate(name, { level: 1 });
|
|
36
|
+
file.mtime = new Date(Math.max(new Date(1980, 0, 1).getTime(), Math.min(new Date(2099, 11, 31).getTime(), info.mtime.getTime())));
|
|
37
|
+
zip.add(file);
|
|
38
|
+
await flush();
|
|
39
|
+
if (info.isDirectory()) {
|
|
40
|
+
file.push(new Uint8Array(), true);
|
|
41
|
+
await flush();
|
|
42
|
+
await walk(source, name, next);
|
|
43
|
+
} else {
|
|
44
|
+
for await (const chunk of createReadStream(source)) {
|
|
45
|
+
file.push(chunk as Uint8Array);
|
|
46
|
+
await flush();
|
|
47
|
+
}
|
|
48
|
+
file.push(new Uint8Array(), true);
|
|
49
|
+
await flush();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
await walk(vaultRoot, "", new Set());
|
|
54
|
+
zip.end();
|
|
55
|
+
await flush();
|
|
56
|
+
} finally { await output.close(); }
|
|
57
|
+
return { path, dispose };
|
|
58
|
+
} catch (error) { await dispose(); throw error; }
|
|
59
|
+
}
|