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.
@@ -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` — cycle the colour theme: system → light → dark → system. */
95
- | { readonly type: "cycleTheme" };
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: "cycleTheme" },
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` — advance the user's theme choice by one step in its cycle. */
324
- cycleTheme(): void;
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 "cycleTheme":
348
- return fx.cycleTheme();
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: "Cycle the colour theme (system / light / dark)" },
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
- * The theme *choice* — what the user picked, and what that makes the screen
3
- * do (weave-workspace §1.2).
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
- /** The next choice in the cycle {@link THEME_CHOICES}. */
90
- export function cycleTheme(choice: ThemeChoice): ThemeChoice {
91
- const index = THEME_CHOICES.indexOf(choice);
92
- // An out-of-table choice cannot exist (the type says so), but a miss
93
- // here must not be an `undefined` into the UI: wrap to the head.
94
- return THEME_CHOICES[(index + 1) % THEME_CHOICES.length] ?? "system";
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
- * The scheme actually drawing right now.
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
- * The `data-weave-theme` attribute value, or `null` to clear it.
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
- /** The `title=`/`aria-label`. Names where it is *and* what clicking does. */
63
+ readonly label: string;
133
64
  readonly hint: string;
134
65
  }
135
66
 
136
- const THEME_GLYPHS: Readonly<Record<ThemeChoice, ThemeButtonView["glyph"]>> = {
137
- dark: "●",
138
- system: "◐",
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: THEME_GLYPHS[choice],
145
- hint: `Colour theme: ${choice} — click for ${cycleTheme(choice)}`,
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
- * The theme.
50
- *
51
- * Dark-first, per the task's brief and against `page.ts`'s light-first
52
- * fallback. That inversion is intentional and safe: this sheet is installed
53
- * *after* the server's, so its `:root` wins on equal specificity, and the
54
- * light branch is re-stated under `prefers-color-scheme: light`. The server
55
- * block keeps doing its real job — painting a correct background before this
56
- * bundle has parsed — while the palette a user actually looks at is decided
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
- --weave-bg:#24273a;--weave-panel:#1e2030;--weave-raise:#363a4f;--weave-fg:#cad3f5;--weave-dim:#a5adcb;
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
- --weave-page:#181926;
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="dark"]){
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:radial-gradient(ellipse at center,transparent 55%,rgba(0,0,0,.22) 100%);
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:rgba(0,0,0,.45);
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 28px;min-height:220px;flex:1}
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
+ }