pi-weave 0.1.9 → 0.1.11

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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/web/client/context/context.model.ts +155 -8
  3. package/src/web/client/dist/app.js +447 -127
  4. package/src/web/client/graph/Graph.tsx +43 -4
  5. package/src/web/client/graph/column.model.ts +42 -25
  6. package/src/web/client/graph/dynamics.ts +16 -5
  7. package/src/web/client/graph/graph.model.ts +326 -111
  8. package/src/web/client/graph/positions.ts +17 -4
  9. package/src/web/client/graph/renderer.ts +30 -6
  10. package/src/web/client/graph/scheme.ts +51 -6
  11. package/src/web/client/main.tsx +12 -0
  12. package/src/web/client/note/Editor.tsx +20 -13
  13. package/src/web/client/note/Note.tsx +209 -17
  14. package/src/web/client/note/editor.model.ts +50 -0
  15. package/src/web/client/note/note.model.ts +312 -1
  16. package/src/web/client/search/SearchPalette.tsx +11 -2
  17. package/src/web/client/shell/Columns.tsx +13 -0
  18. package/src/web/client/shell/ContextRail.tsx +52 -16
  19. package/src/web/client/shell/Header.tsx +66 -3
  20. package/src/web/client/shell/Shell.tsx +75 -6
  21. package/src/web/client/shell/deeplink.model.ts +61 -0
  22. package/src/web/client/shell/focus.model.ts +14 -6
  23. package/src/web/client/shell/icons.model.ts +101 -0
  24. package/src/web/client/shell/keys.model.ts +27 -3
  25. package/src/web/client/shell/layout.model.ts +12 -38
  26. package/src/web/client/shell/shell.model.ts +36 -1
  27. package/src/web/client/shell/theme.model.ts +147 -0
  28. package/src/web/client/shell/theme.ts +473 -96
  29. package/src/web/client/state.ts +11 -0
  30. package/src/web/client/tree/Tree.tsx +48 -5
  31. package/src/web/client/tree/tree.model.ts +88 -28
  32. package/src/web/client/workspace.ts +10 -2
  33. package/src/web/server/page.ts +4 -4
  34. package/src/web/shared/layout.ts +409 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-weave",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "An agent-native knowledge workspace for your life and your code. Smart notepad + repository exploration, readable by humans and agents alike.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -39,7 +39,8 @@
39
39
  import type { DetailLinkRow, ViewGraphModel } from "../../shared/view";
40
40
  import { deriveBacklinks, detailModel, listLabel } from "../../shared/view";
41
41
  import type { GraphPayload, WireGraphNode, WireNodeKind, WireNoteSource } from "../../shared/wire";
42
- import { kindGlyph, provenanceGlyph, provenanceTitle, viewModel } from "../tree/tree.model";
42
+ import { isSessionNote, kindIcon, provenanceGlyph, provenanceTitle, viewModel } from "../tree/tree.model";
43
+ import type { IconName } from "../shell/icons.model";
43
44
 
44
45
  // --- rows and groups ---------------------------------------------------------------
45
46
 
@@ -51,7 +52,8 @@ export interface ContextRow {
51
52
  readonly target: string;
52
53
  readonly label: string;
53
54
  readonly kind: WireNodeKind;
54
- readonly kindGlyph: string;
55
+ /** Which sprite glyph the row draws; the `.tsx` builds the `<svg>`. */
56
+ readonly kindIcon: IconName;
55
57
  readonly provenance: WireNoteSource | null;
56
58
  readonly provenanceGlyph: string;
57
59
  readonly provenanceTitle: string;
@@ -63,6 +65,8 @@ export interface ContextRow {
63
65
  export interface ContextGroup {
64
66
  /** `LINKS`, `BACKLINKS`, `TAGS`, `MENTIONS`. */
65
67
  readonly heading: string;
68
+ /** How many rows the section holds — the heading's badge and the collapse default's input. */
69
+ readonly count: number;
66
70
  /** Shown when the group is present but empty. Never rendered when hidden. */
67
71
  readonly rows: readonly ContextRow[];
68
72
  }
@@ -83,6 +87,15 @@ export interface ContextModel {
83
87
  * shape.
84
88
  */
85
89
  readonly tags: readonly TagGroupRow[];
90
+ /**
91
+ * How many tags TAGS holds — the heading's count.
92
+ *
93
+ * Carried next to `tags` rather than derived in the component for the same
94
+ * reason {@link ContextGroup.count} exists: the number is a *decision* about
95
+ * what the heading says, and an empty rail reports `0` where a group-less
96
+ * `tags.length` would report nothing to talk about.
97
+ */
98
+ readonly tagsCount: number;
86
99
  /** Present instead of groups when there is nothing to show. */
87
100
  readonly empty: string | null;
88
101
  }
@@ -92,14 +105,21 @@ export const HEADINGS = { links: "LINKS", backlinks: "BACKLINKS", tags: "TAGS",
92
105
 
93
106
  // --- building rows ------------------------------------------------------------------
94
107
 
95
- /** A row from a node. The rail's one place that reads a node's presentation. */
108
+ /**
109
+ * A row from a node. The rail's one place that reads a node's presentation.
110
+ *
111
+ * The icon follows the tree's rule, not the bare kind: a session-memory note
112
+ * appears in LINKS just as often as in the tree, and the rail repeating the
113
+ * tree's icon is how "this is the same thing" is communicated across the two
114
+ * columns.
115
+ */
96
116
  export function rowFor(id: string, node: WireGraphNode, selectedId: string | null): ContextRow {
97
117
  return {
98
118
  id,
99
119
  target: node.id,
100
120
  label: listLabel(node),
101
121
  kind: node.kind,
102
- kindGlyph: kindGlyph(node.kind),
122
+ kindIcon: isSessionNote(node.id) ? "session" : kindIcon(node.kind),
103
123
  provenance: node.provenance,
104
124
  provenanceGlyph: provenanceGlyph(node.provenance),
105
125
  provenanceTitle: provenanceTitle(node.provenance),
@@ -250,7 +270,7 @@ export const RAIL_EMPTY = {
250
270
 
251
271
  /** The rail with nothing to show, and a reason. */
252
272
  function emptyRail(reason: string): ContextModel {
253
- return { subject: null, groups: [], tags: [], empty: reason };
273
+ return { subject: null, groups: [], tags: [], tagsCount: 0, empty: reason };
254
274
  }
255
275
 
256
276
  /**
@@ -278,7 +298,7 @@ export function contextModel(payload: GraphPayload | null, selectedId: string |
278
298
  const byId = nodesById(model);
279
299
  const groups: ContextGroup[] = [];
280
300
  const push = (heading: string, rows: readonly ContextRow[]): void => {
281
- if (rows.length > 0) groups.push({ heading, rows });
301
+ if (rows.length > 0) groups.push({ heading, count: rows.length, rows });
282
302
  };
283
303
 
284
304
  const resolve = (rows: readonly DetailLinkRow[]): ContextRow[] =>
@@ -307,7 +327,134 @@ export function contextModel(payload: GraphPayload | null, selectedId: string |
307
327
  const tags = slug === null ? [] : tagsFor(payload, slug, byId, selectedId);
308
328
 
309
329
  if (groups.length === 0 && tags.length === 0) {
310
- return { subject: detail.label, groups: [], tags: [], empty: RAIL_EMPTY.isolated };
330
+ return { subject: detail.label, groups: [], tags: [], tagsCount: 0, empty: RAIL_EMPTY.isolated };
311
331
  }
312
- return { subject: detail.label, groups, tags, empty: null };
332
+ return { subject: detail.label, groups, tags, tagsCount: tags.length, empty: null };
333
+ }
334
+
335
+ // --- counts and collapse (Tier 6, §8 P6.4) ---------------------------------------------
336
+
337
+ /**
338
+ * A rail section as the component renders it: the heading's count, whether it
339
+ * is open, and the rows to show under it.
340
+ *
341
+ * Collapsed is a rendering state, not a data state, so `rows` is emptied here
342
+ * rather than the component filtering — otherwise the collapsed decision
343
+ * would be an untestable branch in a `.tsx`.
344
+ */
345
+ export interface RailSectionView {
346
+ readonly heading: string;
347
+ readonly count: number;
348
+ readonly collapsed: boolean;
349
+ /** The rows, `[]` when collapsed. */
350
+ readonly rows: readonly ContextRow[];
351
+ }
352
+
353
+ /**
354
+ * Which sections the user has explicitly opened or closed.
355
+ *
356
+ * Two sets rather than one because "collapsed" has three origins — the user,
357
+ * the >8 default, and the selection's force-expand — and a single Set cannot
358
+ * say which of them a heading is in. `open` and `closed` are the user's word;
359
+ * the other two are computed by {@link railCollapsed}.
360
+ */
361
+ export interface RailToggles {
362
+ /** Sections the user explicitly opened. */
363
+ readonly open: ReadonlySet<string>;
364
+ /** Sections the user explicitly closed. */
365
+ readonly closed: ReadonlySet<string>;
366
+ }
367
+
368
+ /** No user opinion yet — the rail's state on mount. */
369
+ export function emptyRailToggles(): RailToggles {
370
+ return { open: new Set(), closed: new Set() };
371
+ }
372
+
373
+ /**
374
+ * A section longer than this opens collapsed.
375
+ *
376
+ * Eight is where a list stops being scannable without scrolling: the rail has
377
+ * a fixed fraction of a fixed column (§1.2), so a MENTIONS section with forty
378
+ * entries would push the sections below it out of the viewport entirely —
379
+ * which is how a rail meant to show *everything at once* ends up showing one
380
+ * group. Under eight the rhythm would cost more than it saved.
381
+ */
382
+ export const RAIL_COLLAPSE_THRESHOLD = 8;
383
+
384
+ /**
385
+ * Whether a section is collapsed.
386
+ *
387
+ * Order of precedence, most specific wins:
388
+ *
389
+ * 1. **The selection is never hidden.** If the selected row lives in this
390
+ * section, force-expand — `selectedId` is the §1.3 bus and can change a
391
+ * hundred times a minute while the user is reading something else; a
392
+ * selection that vanished into a fold would be a bug the user experiences
393
+ * as "clicking did nothing" and never connects to a toggle 40px away.
394
+ * 2. The user's explicit *close* beats the default but not the selection.
395
+ * 3. The user's explicit *open* beats the default.
396
+ * 4. Otherwise, the {@link RAIL_COLLAPSE_THRESHOLD} default.
397
+ */
398
+ export function railCollapsed(toggles: RailToggles, heading: string, count: number, holdsSelection: boolean): boolean {
399
+ if (holdsSelection) return false;
400
+ if (toggles.closed.has(heading)) return true;
401
+ if (toggles.open.has(heading)) return false;
402
+ return count > RAIL_COLLAPSE_THRESHOLD;
403
+ }
404
+
405
+ /** Whether the selection itself is one of this section's rows. */
406
+ function holdsSelection(rows: readonly ContextRow[]): boolean {
407
+ return rows.some((row) => row.selected);
408
+ }
409
+
410
+ /**
411
+ * Resolve one section for rendering.
412
+ *
413
+ * The heading identifies the toggle, and headings are a fixed vocabulary
414
+ * ({@link HEADINGS}), so a per-render id is stable across re-renders and
415
+ * remounts — which is what `aria-controls` needs to stay a true promise about
416
+ * the DOM.
417
+ */
418
+ export function railSectionView(group: ContextGroup, toggles: RailToggles): RailSectionView {
419
+ const collapsed = railCollapsed(toggles, group.heading, group.count, holdsSelection(group.rows));
420
+ return { heading: group.heading, count: group.count, collapsed, rows: collapsed ? [] : group.rows };
421
+ }
422
+
423
+ /**
424
+ * The TAGS section through the same vocabulary as the link sections.
425
+ *
426
+ * `TAGS` is not a {@link ContextGroup} — its rows are tag groups — but it
427
+ * toggles and counts exactly the same way, so it goes through the same
428
+ * predicate rather than growing a second collapse rule. The count is the
429
+ * number of *tags*, not sibling rows: the heading names the category, and
430
+ * "3 tags" is what a scanner wants before deciding whether to open it.
431
+ */
432
+ export function railTagsView(tags: readonly TagGroupRow[], toggles: RailToggles): RailSectionView {
433
+ const collapsed = railCollapsed(toggles, HEADINGS.tags, tags.length, tags.some((tag) => tag.siblings.some((row) => row.selected)));
434
+ return { heading: HEADINGS.tags, count: tags.length, collapsed, rows: [] };
435
+ }
436
+
437
+ /**
438
+ * Move one section to the other state.
439
+ *
440
+ * Both sets are updated per toggle so a heading can never sit in both: the
441
+ * user asking twice (click, selection moves it, click) always lands on a
442
+ * consistent single-word answer.
443
+ */
444
+ export function railToggled(toggles: RailToggles, heading: string, collapsed: boolean): RailToggles {
445
+ const open = new Set(toggles.open);
446
+ const closed = new Set(toggles.closed);
447
+ if (collapsed) {
448
+ open.add(heading);
449
+ closed.delete(heading);
450
+ } else {
451
+ closed.add(heading);
452
+ open.delete(heading);
453
+ }
454
+ return { open, closed };
455
+ }
456
+
457
+ /** The `id` of a section's row list, for `aria-controls` to point at. */
458
+ export function railPanelId(heading: string): string {
459
+ return `weave-ctx-panel-${heading.toLowerCase()}`;
313
460
  }