pi-weave 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +123 -37
  2. package/package.json +17 -5
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/cache/workspace.ts +466 -0
  5. package/src/core/concurrency.ts +36 -0
  6. package/src/core/frontmatter.ts +270 -23
  7. package/src/core/git.ts +19 -0
  8. package/src/core/graph/build.ts +97 -5
  9. package/src/core/graph/current.ts +41 -28
  10. package/src/core/graph/mentions.ts +170 -0
  11. package/src/core/graph/model.ts +39 -0
  12. package/src/core/graph/wikilinks.ts +5 -1
  13. package/src/core/index.ts +14 -0
  14. package/src/core/openInEditor.ts +69 -0
  15. package/src/core/paths.ts +7 -0
  16. package/src/core/sessions.ts +929 -0
  17. package/src/core/summaries.ts +1 -19
  18. package/src/core/types.ts +40 -0
  19. package/src/core/vault.ts +739 -57
  20. package/src/core/view/cluster.ts +262 -0
  21. package/src/core/view/detail.ts +118 -0
  22. package/src/core/view/focus.ts +109 -0
  23. package/src/core/view/health.ts +156 -0
  24. package/src/core/view/index.ts +15 -0
  25. package/src/core/view/links.ts +105 -0
  26. package/src/core/view/time.ts +47 -0
  27. package/src/core/view/tree.ts +269 -0
  28. package/src/core/view/types.ts +39 -0
  29. package/src/core/workspace.ts +3 -3
  30. package/src/pi/index.ts +248 -48
  31. package/src/pi/sessionScan.ts +104 -0
  32. package/src/pi/summarize.ts +24 -4
  33. package/src/pi/tools/noteTool.ts +13 -4
  34. package/src/pi/viewer/tui/branding.ts +8 -7
  35. package/src/pi/viewer/tui/explorer.ts +5 -3
  36. package/src/pi/viewer/tui/model.ts +46 -667
  37. package/src/pi/viewer/tui/openNote.ts +7 -56
  38. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  39. package/src/pi/viewer/web/run.ts +331 -0
  40. package/src/web/client/api.dom.ts +40 -0
  41. package/src/web/client/api.ts +472 -0
  42. package/src/web/client/bootstrap.ts +58 -0
  43. package/src/web/client/context/context.model.ts +313 -0
  44. package/src/web/client/dist/app.js +764 -0
  45. package/src/web/client/graph/Graph.tsx +209 -0
  46. package/src/web/client/graph/column.model.ts +372 -0
  47. package/src/web/client/graph/dynamics.ts +176 -0
  48. package/src/web/client/graph/graph.model.ts +546 -0
  49. package/src/web/client/graph/positions.ts +380 -0
  50. package/src/web/client/graph/project.ts +153 -0
  51. package/src/web/client/graph/renderer.dom.ts +52 -0
  52. package/src/web/client/graph/renderer.ts +339 -0
  53. package/src/web/client/graph/scheme.ts +44 -0
  54. package/src/web/client/live.model.ts +275 -0
  55. package/src/web/client/live.ts +151 -0
  56. package/src/web/client/main.tsx +27 -0
  57. package/src/web/client/note/Editor.tsx +102 -0
  58. package/src/web/client/note/Note.tsx +113 -0
  59. package/src/web/client/note/editor.controller.ts +151 -0
  60. package/src/web/client/note/editor.model.ts +636 -0
  61. package/src/web/client/note/note.model.ts +738 -0
  62. package/src/web/client/search/SearchPalette.tsx +105 -0
  63. package/src/web/client/search/search.model.ts +588 -0
  64. package/src/web/client/search/search.ts +107 -0
  65. package/src/web/client/selection.storage.ts +69 -0
  66. package/src/web/client/shell/Columns.tsx +161 -0
  67. package/src/web/client/shell/ContextRail.tsx +87 -0
  68. package/src/web/client/shell/Divider.tsx +44 -0
  69. package/src/web/client/shell/FocusTrap.tsx +56 -0
  70. package/src/web/client/shell/Header.tsx +64 -0
  71. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  72. package/src/web/client/shell/Shell.tsx +210 -0
  73. package/src/web/client/shell/StatusBar.tsx +28 -0
  74. package/src/web/client/shell/cssvars.ts +70 -0
  75. package/src/web/client/shell/drag.model.ts +170 -0
  76. package/src/web/client/shell/focus.model.ts +100 -0
  77. package/src/web/client/shell/keys.model.ts +453 -0
  78. package/src/web/client/shell/keys.ts +59 -0
  79. package/src/web/client/shell/layout.model.ts +526 -0
  80. package/src/web/client/shell/shell.model.ts +333 -0
  81. package/src/web/client/shell/theme.ts +490 -0
  82. package/src/web/client/shell/viewport.ts +29 -0
  83. package/src/web/client/state.ts +89 -0
  84. package/src/web/client/tree/Tree.tsx +141 -0
  85. package/src/web/client/tree/tree.model.ts +674 -0
  86. package/src/web/client/workspace.ts +278 -0
  87. package/src/web/server/page.ts +258 -0
  88. package/src/web/server/routes.ts +987 -0
  89. package/src/web/server/security.ts +361 -0
  90. package/src/web/server/server.ts +275 -0
  91. package/src/web/server/sse.ts +321 -0
  92. package/src/web/server/watcher.ts +507 -0
  93. package/src/web/shared/graph.ts +213 -0
  94. package/src/web/shared/layout.ts +314 -0
  95. package/src/web/shared/logo.ts +11 -0
  96. package/src/web/shared/metrics.ts +136 -0
  97. package/src/web/shared/view.ts +200 -0
  98. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,490 @@
1
+ /**
2
+ * The workspace stylesheet, and how it gets past the CSP
3
+ * (weave-workspace §1.2, §5.2).
4
+ *
5
+ * ## Why the client ships CSS at all
6
+ *
7
+ * `page.ts` emits a nonce'd `<style>` block, but it is deliberately tiny —
8
+ * the custom-property palette and enough body rules that the first paint has
9
+ * a background colour before `app.js` parses. It knows nothing about a grid,
10
+ * a divider or a status bar, and it is owned by the server tier, which this
11
+ * work may not edit. There is also no `/app.css` route. So the shell's own
12
+ * rules have to travel in the bundle and be installed at runtime.
13
+ *
14
+ * ## The CSP path, verified rather than assumed
15
+ *
16
+ * The policy is `style-src 'nonce-{N}'` with **no `'unsafe-inline'`**. Three
17
+ * facts decide the implementation, and each was checked rather than recalled:
18
+ *
19
+ * 1. **A literal `style="…"` attribute in markup is blocked.** That is what
20
+ * `'unsafe-inline'` governs for styles, and it is absent.
21
+ * 2. **CSSOM writes are not.** `el.style.setProperty(…)` has no CSP hook at
22
+ * all, which is why `cssvars.ts` applies column widths that way and why
23
+ * dynamic layout works here. Confirmed against Preact's own behaviour:
24
+ * `preact/src/diff/props.js` handles a `style` prop via
25
+ * `dom.style.cssText` or `style.setProperty`, never `setAttribute`.
26
+ * 3. **A script-created `<style>` element needs the nonce.** Inserting a
27
+ * stylesheet *is* subject to `style-src`, so the element must carry a
28
+ * matching `nonce` or the browser drops it.
29
+ *
30
+ * (3) is the one that shapes this module. The nonce is not a constant the
31
+ * bundle can hold — it is fresh per response (`page.ts`) — so it has to be
32
+ * read from the document at runtime. The `nonce` **content attribute** is
33
+ * hidden by browsers (`getAttribute("nonce")` returns `""`) specifically to
34
+ * stop an injection from exfiltrating it; the **IDL property** `el.nonce`
35
+ * remains readable by same-origin script, which is exactly this case. So
36
+ * {@link installTheme} copies `nonce` from an element the server already
37
+ * nonce'd onto the one it creates.
38
+ *
39
+ * ## Testability
40
+ *
41
+ * The CSS is a pure constant and the installer takes a four-method port, so
42
+ * both are covered without a DOM (§10). The port is narrow enough that the
43
+ * real `document` satisfies it structurally.
44
+ */
45
+
46
+ /**
47
+ * The theme.
48
+ *
49
+ * Dark-first, per the task's brief and against `page.ts`'s light-first
50
+ * fallback. That inversion is intentional and safe: this sheet is installed
51
+ * *after* the server's, so its `:root` wins on equal specificity, and the
52
+ * light branch is re-stated under `prefers-color-scheme: light`. The server
53
+ * block keeps doing its real job — painting a correct background before this
54
+ * bundle has parsed — while the palette a user actually looks at is decided
55
+ * here.
56
+ *
57
+ * ## "Dense but calm" (§1.2)
58
+ *
59
+ * The sketch is an information-dense IDE surface, not a marketing page, so
60
+ * the rules below deliberately avoid the defaults that produce the opposite:
61
+ * no card shadows, no 24 px gutters, no rounded panels floating on a
62
+ * contrasting background. Density comes from a 13 px base, 4–10 px padding
63
+ * and 1 px hairline rules; calm comes from a **single** accent colour used
64
+ * only for focus and selection, three greys doing all the structural work,
65
+ * and no borders where a background change already separates two regions.
66
+ */
67
+ export const THEME_CSS = `
68
+ :root{
69
+ --weave-bg:#141317;--weave-panel:#191820;--weave-fg:#e8e6ee;--weave-dim:#8f8a9c;
70
+ --weave-faint:#5d5869;--weave-line:#26242e;--weave-line-strong:#343141;
71
+ --weave-accent:#a48cff;--weave-ok:#4ade80;--weave-warn:#fbbf24;--weave-bad:#f87171;
72
+ --weave-new:rgba(164,140,255,.20);
73
+ --weave-row:26px;--weave-gutter:10px;
74
+ }
75
+ @media (prefers-color-scheme: light){
76
+ :root{
77
+ --weave-bg:#faf9f7;--weave-panel:#ffffff;--weave-fg:#1c1b19;--weave-dim:#6f6b66;
78
+ --weave-faint:#9a958e;--weave-line:#e6e3de;--weave-line-strong:#d2cec8;
79
+ --weave-accent:#6d4aff;--weave-ok:#15803d;--weave-warn:#b45309;--weave-bad:#b91c1c;
80
+ --weave-new:rgba(109,74,255,.14);
81
+ }
82
+ }
83
+ body{font-size:13px}
84
+ #app{height:100%;display:grid;grid-template-rows:auto 1fr auto;background:var(--weave-bg)}
85
+
86
+ /* header --------------------------------------------------------------- */
87
+ .weave-header{
88
+ display:flex;align-items:center;gap:14px;padding:0 var(--weave-gutter);
89
+ height:34px;border-bottom:1px solid var(--weave-line);background:var(--weave-panel);
90
+ }
91
+ .weave-brand{display:inline-flex;align-items:center;gap:6px;font-size:14px;font-weight:600;letter-spacing:.01em;white-space:nowrap}
92
+ .weave-brand-mark{height:20px;width:20px;border-radius:5px}
93
+ /* A button styled as a search field: the palette owns the only text input,
94
+ so this opens it rather than pretending to accept a query. */
95
+ .weave-search{
96
+ display:flex;align-items:center;gap:6px;flex:0 1 260px;min-width:0;
97
+ height:22px;padding:0 7px;font:inherit;font-size:12px;text-align:left;cursor:pointer;
98
+ color:var(--weave-dim);background:var(--weave-bg);
99
+ border:1px solid var(--weave-line-strong);border-radius:4px;
100
+ }
101
+ .weave-search:hover{color:var(--weave-fg);border-color:var(--weave-accent)}
102
+ .weave-search-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
103
+ .weave-search kbd{
104
+ font-family:var(--weave-mono);font-size:10.5px;color:var(--weave-faint);
105
+ padding:1px 5px;border:1px solid var(--weave-line-strong);border-radius:3px;white-space:nowrap;
106
+ }
107
+ .weave-summary{
108
+ margin-left:auto;display:flex;gap:9px;font-family:var(--weave-mono);
109
+ font-size:11.5px;color:var(--weave-dim);white-space:nowrap;
110
+ }
111
+ .weave-summary-part+.weave-summary-part::before{content:"·";margin-right:9px;color:var(--weave-faint)}
112
+ .weave-refresh{
113
+ font:inherit;font-size:14px;line-height:1;color:var(--weave-dim);background:none;
114
+ border:0;padding:3px 5px;border-radius:4px;cursor:pointer;
115
+ }
116
+ .weave-refresh:hover{color:var(--weave-fg);background:var(--weave-line)}
117
+
118
+ /* connection indicator -------------------------------------------------- */
119
+ .weave-conn{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--weave-dim);white-space:nowrap}
120
+ .weave-conn-dot{font-size:9px;line-height:1}
121
+ .weave-conn-ok .weave-conn-dot,.weave-conn-ok{color:var(--weave-ok)}
122
+ .weave-conn-warn .weave-conn-dot,.weave-conn-warn{color:var(--weave-warn)}
123
+ .weave-conn-bad .weave-conn-dot,.weave-conn-bad{color:var(--weave-bad)}
124
+
125
+ /* the grid -------------------------------------------------------------- */
126
+ /* Widths arrive as custom properties from cssvars.ts — the CSSOM path. The
127
+ fallbacks keep the layout sane for the first frame and if a write is ever
128
+ missed. */
129
+ .weave-grid{display:grid;min-height:0;overflow:hidden;background:var(--weave-line)}
130
+ .weave-grid[data-columns="3"]{
131
+ grid-template-columns:var(--weave-col-tree,22%) 1px var(--weave-col-note,46%) 1px var(--weave-col-graph,32%);
132
+ }
133
+ .weave-grid[data-columns="2"]{grid-template-columns:var(--weave-col-tree,32%) 1px var(--weave-col-note,68%)}
134
+ .weave-grid[data-columns="1"]{grid-template-columns:1fr}
135
+
136
+ .weave-col{
137
+ display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;
138
+ background:var(--weave-bg);
139
+ }
140
+ .weave-col-title{
141
+ margin:0;padding:5px var(--weave-gutter) 4px;font-size:10px;font-weight:600;
142
+ letter-spacing:.09em;text-transform:uppercase;color:var(--weave-faint);
143
+ border-bottom:1px solid var(--weave-line);
144
+ }
145
+ /* Title, then the graph (which takes the slack), then the context rail. The
146
+ \`1fr\` is what gives \`.weave-graph-canvas\` a definite height to measure. */
147
+ .weave-col-graph{display:grid;grid-template-rows:auto 1fr auto;background:var(--weave-bg)}
148
+
149
+ /* dividers -------------------------------------------------------------- */
150
+ .weave-divider{
151
+ background:var(--weave-line);cursor:col-resize;position:relative;
152
+ touch-action:none;
153
+ }
154
+ /* A 1 px target is unhittable, so the hit area is widened with a pseudo
155
+ element rather than by making the visible rule thicker. */
156
+ .weave-divider::after{content:"";position:absolute;inset:0 -3px;z-index:1}
157
+ .weave-divider:hover,.weave-divider:focus-visible{background:var(--weave-accent);outline:none}
158
+
159
+ /* tree column ----------------------------------------------------------- */
160
+ /* --weave-depth is written per row by \`depthVar\`, through the same CSSOM
161
+ path as the column widths, and multiplied by a step this sheet owns — so
162
+ the tree's density stays a CSS decision. */
163
+ .weave-tree{display:flex;flex-direction:column;min-height:0;flex:1}
164
+ .weave-tree-controls{
165
+ display:flex;align-items:center;gap:6px;padding:5px var(--weave-gutter);
166
+ border-bottom:1px solid var(--weave-line);
167
+ }
168
+ .weave-filter{
169
+ flex:1;min-width:0;height:21px;padding:0 6px;font:inherit;font-size:11.5px;
170
+ color:var(--weave-fg);background:var(--weave-panel);
171
+ border:1px solid var(--weave-line-strong);border-radius:4px;
172
+ }
173
+ .weave-chip{
174
+ font:inherit;font-size:10.5px;line-height:1;white-space:nowrap;cursor:pointer;
175
+ color:var(--weave-dim);background:var(--weave-panel);padding:4px 6px;
176
+ border:1px solid var(--weave-line-strong);border-radius:4px;
177
+ }
178
+ .weave-chip:hover{color:var(--weave-fg);border-color:var(--weave-accent)}
179
+ .weave-rows{
180
+ flex:1;min-height:0;overflow:auto;margin:0;padding:3px 0;list-style:none;
181
+ }
182
+ .weave-rows:focus-visible{outline-offset:-2px}
183
+ .weave-row{
184
+ display:flex;align-items:center;gap:5px;height:var(--weave-row);
185
+ padding-right:var(--weave-gutter);
186
+ padding-left:calc(var(--weave-gutter) + var(--weave-depth,0) * 13px);
187
+ cursor:default;white-space:nowrap;
188
+ }
189
+ .weave-row:hover{background:var(--weave-line)}
190
+ .weave-row-on{background:var(--weave-line-strong);color:var(--weave-fg)}
191
+ /* A newly-arrived node (a file or note added since the last update, §6):
192
+ one short highlight that fades while the label settles from bold back to
193
+ normal. The class is computed from the frame diff in workspace.ts and
194
+ expires with it, so collapsing and re-expanding later does not replay the
195
+ arrival, and the first load never flashes the whole tree. */
196
+ .weave-row-new{animation:weave-row-new 2.6s ease-out both}
197
+ @keyframes weave-row-new{
198
+ 0%{background:var(--weave-new);font-weight:700}
199
+ 40%{font-weight:700}
200
+ 100%{background:transparent;font-weight:400}
201
+ }
202
+ .weave-twisty{width:10px;flex:none;color:var(--weave-faint);cursor:pointer;text-align:center}
203
+ .weave-kind{flex:none;color:var(--weave-faint);font-size:11px}
204
+ .weave-prov{flex:none;font-size:9px}
205
+ .weave-prov-human{color:var(--weave-ok)}
206
+ .weave-prov-agent{color:var(--weave-accent)}
207
+ .weave-prov-generated{color:var(--weave-faint)}
208
+ .weave-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}
209
+ .weave-meta{flex:none;font-size:10.5px;color:var(--weave-faint)}
210
+ .weave-tree-empty{flex:1;margin:0;padding:14px var(--weave-gutter);color:var(--weave-dim)}
211
+ .weave-tree-count{
212
+ margin:0;padding:3px var(--weave-gutter);font-size:10.5px;color:var(--weave-faint);
213
+ border-top:1px solid var(--weave-line);
214
+ }
215
+
216
+ /* note column ----------------------------------------------------------- */
217
+ .weave-note{display:flex;flex-direction:column;min-height:0;flex:1;overflow:auto}
218
+ .weave-note-empty{flex:1;margin:0;padding:14px var(--weave-gutter);color:var(--weave-dim);max-width:44ch;line-height:1.5}
219
+ .weave-note-head{padding:10px var(--weave-gutter) 8px;border-bottom:1px solid var(--weave-line)}
220
+ .weave-note-title{margin:0 0 4px;font-size:15px;font-weight:600;line-height:1.3;color:var(--weave-fg)}
221
+ .weave-note-meta{margin:0;display:flex;gap:10px;font-size:11px;color:var(--weave-dim);flex-wrap:wrap}
222
+ .weave-note-time{color:var(--weave-faint)}
223
+ .weave-note-tags{margin:5px 0 0;display:flex;gap:5px;flex-wrap:wrap}
224
+ .weave-tag{
225
+ font-size:10.5px;color:var(--weave-accent);background:var(--weave-panel);
226
+ padding:1px 6px;border:1px solid var(--weave-line-strong);border-radius:9px;
227
+ }
228
+ .weave-note-body{padding:10px var(--weave-gutter) 24px;line-height:1.6;max-width:78ch;color:var(--weave-fg)}
229
+ .weave-note-body>*:first-child{margin-top:0}
230
+ .weave-note-body h1,.weave-note-body h2,.weave-note-body h3,
231
+ .weave-note-body h4,.weave-note-body h5,.weave-note-body h6{
232
+ margin:18px 0 6px;font-weight:600;line-height:1.3;
233
+ }
234
+ .weave-note-body h1{font-size:15px}
235
+ .weave-note-body h2{font-size:14px}
236
+ .weave-note-body h3,.weave-note-body h4,.weave-note-body h5,.weave-note-body h6{font-size:13px}
237
+ .weave-note-body p,.weave-note-body ul,.weave-note-body ol,.weave-note-body blockquote{margin:0 0 10px}
238
+ .weave-note-body ul,.weave-note-body ol{padding-left:20px}
239
+ .weave-note-body li{margin:2px 0}
240
+ .weave-note-body blockquote{
241
+ padding-left:10px;border-left:2px solid var(--weave-line-strong);color:var(--weave-dim);
242
+ }
243
+ .weave-note-body code{
244
+ font-family:var(--weave-mono);font-size:11.5px;padding:1px 4px;
245
+ background:var(--weave-panel);border:1px solid var(--weave-line);border-radius:3px;
246
+ }
247
+ .weave-note-body pre{
248
+ margin:0 0 10px;padding:8px 10px;overflow:auto;
249
+ background:var(--weave-panel);border:1px solid var(--weave-line);border-radius:5px;
250
+ }
251
+ .weave-note-body pre code{padding:0;background:none;border:0}
252
+ .weave-note-body hr{margin:14px 0;border:0;border-top:1px solid var(--weave-line)}
253
+ .weave-note-body table{border-collapse:collapse;margin:0 0 10px;font-size:12px}
254
+ .weave-note-body th,.weave-note-body td{padding:3px 8px;border:1px solid var(--weave-line);text-align:left}
255
+ .weave-note-body th{color:var(--weave-dim);font-weight:600}
256
+ .weave-note-body img{max-width:100%;height:auto}
257
+ .weave-note-body a{color:var(--weave-accent);text-decoration:none}
258
+ .weave-note-body a:hover{text-decoration:underline}
259
+ /* Wikilinks carry no href — they drive the §1.3 bus, not the browser — so the
260
+ pointer has to be restored by hand. A ghost is a name with no note behind
261
+ it: Obsidian's dashed, dimmed affordance to create one. */
262
+ .weave-wiki{cursor:pointer;color:var(--weave-accent);text-decoration:none}
263
+ .weave-wiki:hover{text-decoration:underline}
264
+ .weave-wiki-ghost{
265
+ cursor:help;color:var(--weave-faint);border-bottom:1px dashed var(--weave-faint);text-decoration:none;
266
+ }
267
+ .weave-wiki-ghost:hover{text-decoration:none}
268
+
269
+ /* note editor (§11 P5) ---------------------------------------------------- */
270
+ /* A \`<textarea>\`, not CodeMirror: §0 V10 measured CM6 at 118 KB gzip, more
271
+ than the entire rest of the client. It inherits the body's type so toggling
272
+ \`⌘E\` does not reflow the column into a different shape. */
273
+ .weave-note-bar{
274
+ display:flex;align-items:center;gap:6px;flex-wrap:wrap;
275
+ padding:5px var(--weave-gutter);border-bottom:1px solid var(--weave-line);
276
+ }
277
+ .weave-note-toggle,.weave-note-save,.weave-note-open,.weave-note-action{
278
+ font:inherit;font-size:11px;padding:2px 8px;cursor:pointer;
279
+ color:var(--weave-fg);background:var(--weave-raise);
280
+ border:1px solid var(--weave-line);border-radius:4px;
281
+ }
282
+ .weave-note-toggle:hover,.weave-note-save:hover,.weave-note-open:hover,.weave-note-action:hover{
283
+ border-color:var(--weave-accent);
284
+ }
285
+ .weave-note-toggle[aria-pressed="true"]{border-color:var(--weave-accent);color:var(--weave-accent)}
286
+ .weave-note-save:disabled{opacity:.45;cursor:default;border-color:var(--weave-line)}
287
+ .weave-note-dirty{color:var(--weave-accent);font-size:14px;line-height:1}
288
+ .weave-note-status{font-size:11px;color:var(--weave-dim)}
289
+ .weave-note-status-ok{color:var(--weave-ok)}
290
+ .weave-note-status-warn{color:var(--weave-warn)}
291
+ /* The prompt takes the full row rather than sitting inline: reload-or-overwrite
292
+ is a decision about the user's unsaved text, and a decision squeezed between
293
+ two buttons reads as a hint. */
294
+ .weave-note-prompt{
295
+ flex-basis:100%;display:flex;align-items:center;gap:8px;flex-wrap:wrap;
296
+ margin-top:4px;padding:5px 8px;border-radius:4px;
297
+ background:var(--weave-raise);border:1px solid var(--weave-warn);
298
+ }
299
+ .weave-note-prompt-conflict,.weave-note-prompt-collision{border-color:var(--weave-warn)}
300
+ .weave-note-prompt-external,.weave-note-prompt-discard{border-color:var(--weave-line)}
301
+ .weave-note-prompt-text{margin:0;font-size:11px;color:var(--weave-fg)}
302
+ .weave-note-prompt-actions{margin:0;display:flex;gap:6px;flex-wrap:wrap}
303
+ .weave-note-editor{
304
+ flex:1;min-height:240px;resize:none;
305
+ padding:10px var(--weave-gutter) 24px;
306
+ font:inherit;font-family:var(--weave-mono);font-size:12px;line-height:1.6;
307
+ color:var(--weave-fg);background:var(--weave-bg);border:0;outline-offset:-2px;
308
+ }
309
+
310
+ /* graph column ---------------------------------------------------------- */
311
+ /* The canvas is a plain block sigma appends its own <canvas> layers into. It
312
+ must have a definite size before sigma measures it, hence \`min-height\` and
313
+ the \`1fr\` row from \`.weave-col-graph\` above — a zero-height container makes
314
+ sigma refuse to render (we pass \`allowInvalidContainer\`, so it degrades to
315
+ blank rather than throwing). */
316
+ .weave-graph{display:grid;grid-template-rows:1fr auto auto;min-height:0;overflow:hidden}
317
+ .weave-graph-canvas{min-height:120px;min-width:0;position:relative;overflow:hidden}
318
+ .weave-graph-empty{
319
+ margin:0;padding:14px var(--weave-gutter);color:var(--weave-dim);
320
+ max-width:44ch;line-height:1.5;
321
+ }
322
+ .weave-graph-controls{
323
+ display:flex;align-items:center;gap:6px;flex-wrap:wrap;
324
+ padding:5px var(--weave-gutter);border-top:1px solid var(--weave-line);
325
+ }
326
+ .weave-graph-legend{
327
+ margin-left:auto;display:flex;gap:9px;font-size:10.5px;color:var(--weave-faint);
328
+ white-space:nowrap;
329
+ }
330
+ .weave-legend-on{color:var(--weave-accent)}
331
+ .weave-legend-near{color:var(--weave-fg)}
332
+ .weave-graph-count{
333
+ margin:0;padding:3px var(--weave-gutter);font-size:10.5px;color:var(--weave-faint);
334
+ border-top:1px solid var(--weave-line);
335
+ }
336
+
337
+ /* context rail ---------------------------------------------------------- */
338
+ /* The \`.weave-empty\` block that used to sit above went with \`EmptyState.tsx\`
339
+ in P3: every column now has its own empty state (\`treeEmptyMessage\`,
340
+ \`noteEmptyMessage\`, \`graphEmptyMessage\`, \`RAIL_EMPTY\`), each rendered as a
341
+ plain paragraph, so the shared placeholder had no callers left. */
342
+ .weave-rail{
343
+ display:flex;flex-direction:column;min-height:96px;max-height:46%;overflow:auto;
344
+ border-top:1px solid var(--weave-line);background:var(--weave-panel);
345
+ }
346
+ .weave-ctx-empty{margin:0;padding:10px var(--weave-gutter);color:var(--weave-dim);line-height:1.5}
347
+ .weave-ctx-group{padding:5px var(--weave-gutter) 2px}
348
+ .weave-ctx-heading{
349
+ margin:0 0 2px;font-size:9.5px;font-weight:600;letter-spacing:.09em;color:var(--weave-faint);
350
+ }
351
+ .weave-ctx-rows,.weave-ctx-tags{margin:0;padding:0;list-style:none}
352
+ .weave-ctx-row{display:flex}
353
+ .weave-ctx-link{
354
+ display:flex;align-items:center;gap:5px;width:100%;min-width:0;
355
+ font:inherit;font-size:12px;text-align:left;color:var(--weave-fg);
356
+ background:none;border:0;padding:2px var(--weave-gutter) 2px 0;cursor:pointer;
357
+ border-radius:3px;
358
+ }
359
+ .weave-ctx-link:hover{background:var(--weave-line)}
360
+ .weave-ctx-row.weave-row-on .weave-ctx-link{background:var(--weave-line-strong)}
361
+ .weave-ctx-tag{margin:0 0 3px}
362
+ .weave-ctx-tag .weave-ctx-rows{padding-left:12px}
363
+
364
+ /* status bar ------------------------------------------------------------ */
365
+ .weave-status{
366
+ display:flex;align-items:center;gap:12px;height:22px;padding:0 var(--weave-gutter);
367
+ font-family:var(--weave-mono);font-size:11px;color:var(--weave-dim);
368
+ border-top:1px solid var(--weave-line);background:var(--weave-panel);
369
+ }
370
+ .weave-status-cwd,.weave-status-sel{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
371
+ .weave-status-cwd{max-width:38%;color:var(--weave-faint)}
372
+ .weave-status-sel{color:var(--weave-fg)}
373
+ .weave-status-stamp{margin-left:auto;color:var(--weave-faint)}
374
+
375
+ /* overlays: the ⌘K palette and the ? help sheet (P4) --------------------- */
376
+ /* The scrim is a click target that closes, and the reason both overlays sit
377
+ at the top of the stacking context rather than inside a column: a dialog
378
+ that a column's \`overflow:hidden\` can clip is a dialog that disappears at
379
+ the wrong breakpoint. */
380
+ .weave-scrim{
381
+ position:fixed;inset:0;z-index:10;display:flex;justify-content:center;
382
+ align-items:flex-start;padding:9vh 16px 16px;background:rgba(0,0,0,.45);
383
+ }
384
+ .weave-palette,.weave-help{
385
+ display:flex;flex-direction:column;width:100%;max-width:560px;max-height:72vh;
386
+ overflow:hidden;background:var(--weave-panel);color:var(--weave-fg);
387
+ border:1px solid var(--weave-line-strong);border-radius:7px;
388
+ }
389
+ .weave-palette-input{
390
+ flex:none;height:34px;padding:0 11px;font:inherit;font-size:14px;
391
+ color:var(--weave-fg);background:transparent;border:0;
392
+ border-bottom:1px solid var(--weave-line);border-radius:0;
393
+ }
394
+ .weave-palette-input:focus-visible{outline-offset:-2px}
395
+ .weave-hits{flex:1;min-height:0;overflow:auto;margin:0;padding:3px 0;list-style:none}
396
+ .weave-hit{
397
+ display:flex;align-items:baseline;gap:7px;padding:3px 11px;cursor:pointer;
398
+ min-width:0;white-space:nowrap;
399
+ }
400
+ .weave-hit-on{background:var(--weave-line-strong)}
401
+ .weave-hit-badge{
402
+ flex:none;font-size:9.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--weave-faint);
403
+ min-width:56px;
404
+ }
405
+ .weave-hit-note .weave-hit-badge{color:var(--weave-accent)}
406
+ .weave-hit-label{flex:none;max-width:46%;overflow:hidden;text-overflow:ellipsis}
407
+ .weave-hit-detail{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:11px;color:var(--weave-dim)}
408
+ .weave-palette-status{margin:0;padding:14px 11px;color:var(--weave-dim);line-height:1.5}
409
+ .weave-palette-foot,.weave-help-foot{
410
+ display:flex;gap:12px;margin:0;padding:4px 11px;font-size:10.5px;color:var(--weave-faint);
411
+ border-top:1px solid var(--weave-line);
412
+ }
413
+ .weave-palette-hint,.weave-help-hint{margin-left:auto}
414
+ .weave-help-title{margin:0;padding:9px 11px;font-size:13px;font-weight:600;border-bottom:1px solid var(--weave-line)}
415
+ .weave-keys{flex:1;min-height:0;overflow:auto;margin:0;padding:5px 11px}
416
+ .weave-key-group{margin:0 0 3px;font-size:9.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--weave-faint)}
417
+ .weave-key-row{display:flex;align-items:baseline;gap:9px;padding:1px 0}
418
+ .weave-key-combo{
419
+ flex:none;min-width:74px;font-family:var(--weave-mono);font-size:11px;color:var(--weave-accent);
420
+ }
421
+ .weave-key-what{flex:1;min-width:0;font-size:12px}
422
+
423
+ /* focus ----------------------------------------------------------------- */
424
+ :focus-visible{outline:2px solid var(--weave-accent);outline-offset:1px}
425
+ @media (prefers-reduced-motion: reduce){*{transition:none!important;animation:none!important}}
426
+ `;
427
+
428
+ // --- installation --------------------------------------------------------------
429
+
430
+ /** A `<style>` element, as far as this module is concerned. */
431
+ export interface StyleElement {
432
+ /** The IDL property, not the content attribute. See the module header. */
433
+ nonce?: string | undefined;
434
+ textContent: string | null;
435
+ }
436
+
437
+ /**
438
+ * The slice of `document` the installer needs.
439
+ *
440
+ * Four members, so a fake is a short object literal and the real `document`
441
+ * satisfies it structurally.
442
+ */
443
+ export interface ThemeHost {
444
+ createElement(tag: "style"): StyleElement;
445
+ /** Used to find an element the server already nonce'd. */
446
+ querySelector(selector: string): StyleElement | null;
447
+ head: { appendChild(node: StyleElement): unknown };
448
+ }
449
+
450
+ /**
451
+ * Where to look for a nonce, in order.
452
+ *
453
+ * The server's `<style>` block first because it is guaranteed present and is
454
+ * the same kind of element we are about to create. The bundle's own `<script>`
455
+ * is the fallback: it carries the same per-response nonce, and it exists by
456
+ * definition, because it is the thing currently running.
457
+ */
458
+ export const NONCE_SOURCES = ["style[nonce]", "style", "script[nonce]", "script[src]"];
459
+
460
+ /** Read the per-response nonce from the document, or `null`. */
461
+ export function findNonce(host: ThemeHost): string | null {
462
+ for (const selector of NONCE_SOURCES) {
463
+ const nonce = host.querySelector(selector)?.nonce;
464
+ if (typeof nonce === "string" && nonce !== "") return nonce;
465
+ }
466
+ return null;
467
+ }
468
+
469
+ /**
470
+ * Install {@link THEME_CSS} into the document.
471
+ *
472
+ * Returns whether a nonce was found and applied. A `false` return means the
473
+ * browser will refuse the sheet — the workspace still renders, using only
474
+ * `page.ts`'s palette and the browser's defaults, which is ugly but legible.
475
+ * That is the right failure: silently weakening the CSP to guarantee styling
476
+ * would trade a cosmetic problem for a security one.
477
+ *
478
+ * The element is appended even without a nonce, deliberately. It costs
479
+ * nothing, it keeps the two paths identical, and the resulting CSP violation
480
+ * report in the console is a far better diagnostic than a stylesheet that was
481
+ * never created.
482
+ */
483
+ export function installTheme(host: ThemeHost, css: string = THEME_CSS): boolean {
484
+ const element = host.createElement("style");
485
+ const nonce = findNonce(host);
486
+ if (nonce !== null) element.nonce = nonce;
487
+ element.textContent = css;
488
+ host.head.appendChild(element);
489
+ return nonce !== null;
490
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Watching the viewport width (weave-workspace §1.2).
3
+ *
4
+ * The width is not a cosmetic detail here: it picks the breakpoint, which
5
+ * decides how many columns render, which decides how many dividers exist. So
6
+ * the subscription is worth a test, and a subscription written inline in a
7
+ * `useEffect` is one no test can reach (§10). Four lines, an injected port,
8
+ * and the shell keeps a one-line effect.
9
+ */
10
+
11
+ /** The slice of `window` this module reads. `window` satisfies it. */
12
+ export interface ViewportHost {
13
+ readonly innerWidth: number;
14
+ addEventListener(type: "resize", listener: () => void): void;
15
+ removeEventListener(type: "resize", listener: () => void): void;
16
+ }
17
+
18
+ /**
19
+ * Call `onChange` with the width whenever the window resizes.
20
+ *
21
+ * Returns an unsubscribe. The listener is *not* invoked eagerly: the shell
22
+ * already seeds its state from `initialWidth` at mount, and firing here would
23
+ * make the first render set state during an effect for no change in value.
24
+ */
25
+ export function watchViewport(host: ViewportHost, onChange: (width: number) => void): () => void {
26
+ const listener = (): void => onChange(host.innerWidth);
27
+ host.addEventListener("resize", listener);
28
+ return () => host.removeEventListener("resize", listener);
29
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The context bus (weave-workspace §1.3).
3
+ *
4
+ * No event bus, no pub/sub — five Preact signals. Selecting in the tree,
5
+ * clicking a graph node, following a wikilink and hitting a search result all
6
+ * write {@link selectedId}; the note column, the graph highlight and the
7
+ * context rail are computed from it.
8
+ *
9
+ * ## Tier rules (§2)
10
+ *
11
+ * This is `src/web/client/**`: it may import `src/web/shared` and browser
12
+ * deps, and must **never** import `src/core` or `node:*`. Core is
13
+ * Node-flavoured TypeScript that would drag `node:fs` into the bundle.
14
+ *
15
+ * ## What the P0 placeholders became
16
+ *
17
+ * This file used to declare three local interfaces behind `TODO(P1)` markers,
18
+ * standing in for contracts that had not been written yet. Two of them are
19
+ * now real and imported from `src/web/shared/wire.ts` — {@link graph} carries
20
+ * a `GraphPayload` and {@link noteBody} a `ViewNote`, the exact values
21
+ * `src/web/client/api.ts` returns. That removes a translation step that
22
+ * existed only to satisfy a stale type: the fetchers already produce these
23
+ * shapes, so anything narrower here was a downcast waiting to lose a field.
24
+ *
25
+ * The third resolved the other way. The TODO said to take `TreeState` from
26
+ * the wire too, and that was simply wrong: which rows a user has expanded is
27
+ * **not** a wire contract. It never crosses the network, the server has no
28
+ * opinion about it, and putting it in `wire.ts` would have made the
29
+ * client/server contract carry a purely local UI preference. So
30
+ * {@link TreeState} stays here, declared where it is owned, with the marker
31
+ * removed rather than carried forward.
32
+ */
33
+
34
+ import { signal } from "@preact/signals";
35
+ import type { GraphPayload, NotePayload } from "../shared/wire";
36
+
37
+ /** Connection state shown in the status bar. */
38
+ export type ConnectionState = "live" | "reconnecting" | "offline";
39
+
40
+ /**
41
+ * Which tree rows are open.
42
+ *
43
+ * Client-owned, deliberately: see the module header. An interface rather than
44
+ * a bare array so that P2's filter text and provenance-cycling state have an
45
+ * obvious home that does not change this signal's type.
46
+ */
47
+ export interface TreeState {
48
+ /** Ids of expanded tree rows. */
49
+ readonly expanded: readonly string[];
50
+ }
51
+
52
+ /** The empty tree: nothing expanded. */
53
+ export function initialTreeState(): TreeState {
54
+ return { expanded: [] };
55
+ }
56
+
57
+ /** The graph node id currently in context — everywhere. */
58
+ export const selectedId = signal<string | null>(null);
59
+
60
+ /** The whole graph, as delivered by the server. `null` until first load. */
61
+ export const graph = signal<GraphPayload | null>(null);
62
+
63
+ /**
64
+ * Derived: fetched when {@link selectedId} names a note.
65
+ *
66
+ * A {@link NotePayload} rather than a bare `ViewNote` as of P5. The revision
67
+ * travels **with** the body because the editor saves against it, and a
68
+ * revision fetched separately would describe a state the draft was not typed
69
+ * against — the exact window a conflict check exists to close. The note
70
+ * column reads `.note` and is otherwise unchanged.
71
+ */
72
+ export const noteBody = signal<NotePayload | null>(null);
73
+
74
+ /** Which tree rows are open. */
75
+ export const treeState = signal<TreeState>(initialTreeState());
76
+
77
+ /** SSE liveness (§6). */
78
+ export const connection = signal<ConnectionState>("live");
79
+
80
+ /**
81
+ * Node ids that appeared in the most recent graph update (weave-workspace
82
+ * §6 frame → tree feedback).
83
+ *
84
+ * Empty on the mount fetch — a first load must not animate the whole tree.
85
+ * The controller replaces the set on every published payload and clears it
86
+ * after {@link ../workspace!RECENT_TTL_MS}, so a row collapsed and re-expanded
87
+ * minutes later does not replay its arrival animation.
88
+ */
89
+ export const recentIds = signal<ReadonlySet<string>>(new Set());