@miadi/stateloom-web 0.1.2 → 0.1.4

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 (68) hide show
  1. package/package.json +1 -1
  2. package/server/.next/BUILD_ID +1 -1
  3. package/server/.next/app-path-routes-manifest.json +1 -0
  4. package/server/.next/build-manifest.json +2 -2
  5. package/server/.next/routes-manifest.json +6 -0
  6. package/server/.next/server/app/_global-error.html +2 -2
  7. package/server/.next/server/app/_global-error.rsc +1 -1
  8. package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  9. package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/server/.next/server/app/_not-found.html +1 -1
  15. package/server/.next/server/app/_not-found.rsc +2 -2
  16. package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  17. package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  18. package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  19. package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  20. package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  21. package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  22. package/server/.next/server/app/api/docs/route/app-paths-manifest.json +3 -0
  23. package/server/.next/server/app/api/docs/route/build-manifest.json +11 -0
  24. package/server/.next/server/app/api/docs/route/server-reference-manifest.json +4 -0
  25. package/server/.next/server/app/api/docs/route.js +8 -0
  26. package/server/.next/server/app/api/docs/route.js.map +5 -0
  27. package/server/.next/server/app/api/docs/route.js.nft.json +1 -0
  28. package/server/.next/server/app/api/docs/route_client-reference-manifest.js +2 -0
  29. package/server/.next/server/app/api/file/route.js.nft.json +1 -1
  30. package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
  31. package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
  32. package/server/.next/server/app/index.html +1 -1
  33. package/server/.next/server/app/index.rsc +3 -3
  34. package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  35. package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
  36. package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
  37. package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
  38. package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  39. package/server/.next/server/app/page.js.nft.json +1 -1
  40. package/server/.next/server/app/page_client-reference-manifest.js +1 -1
  41. package/server/.next/server/app-paths-manifest.json +1 -0
  42. package/server/.next/server/chunks/[root-of-the-server]__13c3d2a8._.js +3 -0
  43. package/server/.next/server/chunks/[root-of-the-server]__2046b812._.js +2 -2
  44. package/server/.next/server/chunks/[root-of-the-server]__87bf4b91._.js +1 -1
  45. package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +3 -3
  46. package/server/.next/server/chunks/ssr/{[root-of-the-server]__bfd16096._.js → [root-of-the-server]__2b35b711._.js} +1 -1
  47. package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +3 -3
  48. package/server/.next/server/chunks/web__next-internal_server_app_api_docs_route_actions_1f543c30.js +3 -0
  49. package/server/.next/server/pages/404.html +1 -1
  50. package/server/.next/server/pages/500.html +2 -2
  51. package/server/.next/static/chunks/1cbe108679f9af04.css +2 -0
  52. package/server/.next/static/chunks/c8da5c76cf5bb070.js +10 -0
  53. package/server/package-lock.json +4 -4
  54. package/server/package.json +16 -2
  55. package/server/src/app/api/docs/route.ts +31 -0
  56. package/server/src/app/globals.css +4 -9
  57. package/server/src/components/DocSwitcher.tsx +119 -31
  58. package/server/src/components/PresenceChips.tsx +77 -0
  59. package/server/src/components/SocketBridgeProvider.tsx +6 -22
  60. package/server/src/components/Toolbar.tsx +5 -0
  61. package/server/src/lib/projectFile.ts +106 -5
  62. package/server/stateloom-web.json +2 -2
  63. package/server/tsconfig.tsbuildinfo +1 -1
  64. package/server/.next/static/chunks/0a0f393b77d3422b.css +0 -2
  65. package/server/.next/static/chunks/e29b67abadc7c68a.js +0 -10
  66. /package/server/.next/static/{Q7HQy4udl4A_DaAl9bdUt → yHDbm89IPqExJUSbiWtnD}/_buildManifest.js +0 -0
  67. /package/server/.next/static/{Q7HQy4udl4A_DaAl9bdUt → yHDbm89IPqExJUSbiWtnD}/_clientMiddlewareManifest.json +0 -0
  68. /package/server/.next/static/{Q7HQy4udl4A_DaAl9bdUt → yHDbm89IPqExJUSbiWtnD}/_ssgManifest.js +0 -0
@@ -1,12 +1,12 @@
1
1
  {
2
- "name": "stateloom-web",
3
- "version": "0.1.1",
2
+ "name": "@miadi/stateloom-web-source",
3
+ "version": "0.1.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
- "name": "stateloom-web",
9
- "version": "0.1.1",
8
+ "name": "@miadi/stateloom-web-source",
9
+ "version": "0.1.4",
10
10
  "dependencies": {
11
11
  "@miadi/stateloom-canvas": "file:../bridge-canvas",
12
12
  "@miadi/stateloom-client": "file:../bridge-client",
@@ -1,7 +1,21 @@
1
1
  {
2
- "name": "stateloom-web",
3
- "version": "0.1.2",
2
+ "name": "@miadi/stateloom-web-source",
3
+ "version": "0.1.4",
4
4
  "private": true,
5
+ "//": [
6
+ "This is the designer's SOURCE and is never published — hence private: true.",
7
+ "What ships is its BUILD: `next build` (output: standalone) produces",
8
+ ".next/standalone, which web-dist/scripts/sync-web.mjs assembles into",
9
+ "web-dist/server/ and publishes as @miadi/stateloom-web. So `npx -y",
10
+ "@miadi/stateloom-web` runs this app without a Next.js toolchain anywhere.",
11
+ "",
12
+ "Until 2026-08-16 this package was also called `stateloom-web`, and two",
13
+ "different things answering to one name is why it kept looking as though the",
14
+ "designer was the one package nobody had published.",
15
+ "",
16
+ "The version here is the DESIGNER's version, reported by `stateloom-web",
17
+ "--version` as `designer`. Bump it with web-dist's when the canvas changes."
18
+ ],
5
19
  "scripts": {
6
20
  "dev": "next dev -p ${PORT:-4598}",
7
21
  "build": "next build",
@@ -0,0 +1,31 @@
1
+ /**
2
+ * GET /api/docs — the documents this server would let you open.
3
+ *
4
+ * The `?doc=` parameter and its allowlist shipped together; the way to SEE what
5
+ * the allowlist admits did not, so the switcher was a path prompt with no
6
+ * autocomplete and the paths it wanted were the server's, not the human's. In a
7
+ * container those differ — your `~/diagrams` is the server's `/data` — which
8
+ * made "switch diagrams" a thing you had to already know how to do.
9
+ *
10
+ * Enumeration is bounded in lib/projectFile.ts (depth, count, no dotfiles) and
11
+ * confined to the same roots `resolveDocPath` enforces, so this endpoint can
12
+ * never reveal a path the file API would refuse to serve.
13
+ */
14
+ import { NextResponse } from "next/server";
15
+ import { allowedDocRoots, getProjectFilePath, listDocuments } from "@/lib/projectFile";
16
+
17
+ export const dynamic = "force-dynamic";
18
+ export const revalidate = 0;
19
+
20
+ export async function GET(): Promise<Response> {
21
+ const { docs, truncated } = listDocuments();
22
+ return NextResponse.json(
23
+ {
24
+ roots: allowedDocRoots(),
25
+ current: getProjectFilePath(),
26
+ docs,
27
+ truncated,
28
+ },
29
+ { headers: { "cache-control": "no-store" } },
30
+ );
31
+ }
@@ -108,15 +108,10 @@ body {
108
108
  }
109
109
  }
110
110
 
111
- /* Presence chips ride the top-right corner. Under `viewport-fit=cover` that
112
- corner can be beneath a notch or the status bar, so the offset is measured
113
- from the safe area rather than the raw viewport edge. */
114
- .presence-dock {
115
- position: fixed;
116
- top: calc(env(safe-area-inset-top) + 0.5rem);
117
- right: calc(env(safe-area-inset-right) + 1rem);
118
- z-index: 50;
119
- }
111
+ /* `.presence-dock` lived here: a fixed top-right rail of one pill per peer.
112
+ It grew with the room and painted itself across the toolbar's own controls,
113
+ so presence moved into the toolbar flow as a single fixed-width control —
114
+ see components/PresenceChips.tsx. Nothing floats over the navigation now. */
120
115
 
121
116
  /* The way back. A pinch on the chrome moves the root font size, and every length
122
117
  in this stylesheet is a multiple of it — so at 175% the app is legible but the
@@ -1,20 +1,60 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useEffect, useState } from "react";
4
4
  import { navigateToDoc, useRequestedDoc } from "@/lib/docParam";
5
5
  import { rememberedDocs } from "@/lib/layoutMemory";
6
6
 
7
+ interface DocEntry {
8
+ path: string;
9
+ name: string;
10
+ dir: string;
11
+ mtime: number;
12
+ }
13
+
7
14
  /**
8
15
  * A human switches diagrams without an agent and without a terminal
9
- * (chart_1785683062725). Presentation over state that already exists: recents
10
- * are the documents this browser holds layout memory for, plus a free path
11
- * field. Choosing navigates via `?doc=` — the providers re-key (step 3), the
12
- * server allowlist guards (step 1), no reload, no new transport.
16
+ * (chart_1785683062725). Choosing navigates via `?doc=` — the providers re-key
17
+ * (step 3), the server allowlist guards (step 1), no reload, no new transport.
18
+ *
19
+ * The list comes from `GET /api/docs`, which enumerates exactly what that
20
+ * allowlist admits. Before it existed this was a free-text field over this
21
+ * browser's layout memory — empty on first use — so switching required knowing
22
+ * the server's path for a directory you were looking at under a different name,
23
+ * and typing it by hand every time. In a container that is `/data`, which is
24
+ * not a thing anyone can guess. The field stays, for a path the walk did not
25
+ * reach; it is no longer the only way in.
13
26
  */
14
27
  export default function DocSwitcher() {
15
28
  const requested = useRequestedDoc();
16
29
  const [open, setOpen] = useState(false);
17
30
  const [typed, setTyped] = useState("");
31
+ const [docs, setDocs] = useState<DocEntry[] | null>(null);
32
+ const [roots, setRoots] = useState<string[]>([]);
33
+ const [error, setError] = useState<string | null>(null);
34
+
35
+ // Fetched on each open, not once: an agent creating a document while the
36
+ // panel is shut is the normal case, and a stale list is the whole complaint.
37
+ useEffect(() => {
38
+ if (!open) return;
39
+ let live = true;
40
+ setError(null);
41
+ fetch("/api/docs", { cache: "no-store" })
42
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
43
+ .then((body: { docs?: DocEntry[]; roots?: string[] }) => {
44
+ if (!live) return;
45
+ setDocs(body.docs ?? []);
46
+ setRoots(body.roots ?? []);
47
+ })
48
+ .catch((e: Error) => {
49
+ if (!live) return;
50
+ // An older server has no /api/docs. Recents and the field still work.
51
+ setDocs([]);
52
+ setError(e.message);
53
+ });
54
+ return () => {
55
+ live = false;
56
+ };
57
+ }, [open]);
18
58
 
19
59
  const go = (doc: string | null) => {
20
60
  setOpen(false);
@@ -22,10 +62,22 @@ export default function DocSwitcher() {
22
62
  navigateToDoc(doc);
23
63
  };
24
64
 
65
+ // Layout memory still contributes: a document reached once and since deleted,
66
+ // or one outside the walk's depth, is worth keeping in reach.
67
+ const listed = new Set((docs ?? []).map((d) => d.path));
25
68
  const recents = open
26
- ? rememberedDocs().filter((d) => d.startsWith("/") && d !== requested)
69
+ ? rememberedDocs().filter((d) => d.startsWith("/") && d !== requested && !listed.has(d))
27
70
  : [];
28
71
 
72
+ const available = (docs ?? []).filter((d) => d.path !== requested);
73
+ const filter = typed.trim().toLowerCase();
74
+ const shown = filter
75
+ ? available.filter((d) => d.path.toLowerCase().includes(filter))
76
+ : available;
77
+
78
+ const rowClass =
79
+ "block w-full truncate rounded px-1.5 py-1 text-left text-xs text-gray-300 hover:bg-gray-800";
80
+
29
81
  return (
30
82
  <span className="relative inline-flex">
31
83
  <button
@@ -36,47 +88,83 @@ export default function DocSwitcher() {
36
88
  ⇄
37
89
  </button>
38
90
  {open && (
39
- <div className="absolute left-0 top-full z-50 mt-1 w-72 rounded border border-gray-700 bg-gray-900 p-2 shadow-lg">
91
+ <div className="absolute left-0 top-full z-50 mt-1 w-80 rounded border border-gray-700 bg-gray-900 p-2 shadow-lg">
40
92
  <div className="mb-1 text-[10px] uppercase tracking-wide text-gray-500">
41
93
  switch document
42
94
  </div>
43
- {requested && (
44
- <button
45
- onClick={() => go(null)}
46
- className="block w-full truncate rounded px-1.5 py-1 text-left text-xs text-gray-300 hover:bg-gray-800"
47
- title="Return to the default project file"
48
- >
49
- ⌂ default document
50
- </button>
51
- )}
52
- {recents.map((d) => (
53
- <button
54
- key={d}
55
- onClick={() => go(d)}
56
- className="block w-full truncate rounded px-1.5 py-1 text-left text-xs text-gray-300 hover:bg-gray-800"
57
- title={d}
58
- >
59
- {d.split("/").pop()}
60
- <span className="ml-1 text-[10px] text-gray-600">{d}</span>
61
- </button>
62
- ))}
95
+
96
+ {/* Doubles as filter and as free-text path — one field, because with a
97
+ list present the typing is almost always narrowing, not addressing. */}
63
98
  <form
64
99
  onSubmit={(e) => {
65
100
  e.preventDefault();
66
- if (typed.trim()) go(typed.trim());
101
+ const value = typed.trim();
102
+ if (!value) return;
103
+ // A single match means the human already chose it by narrowing.
104
+ if (shown.length === 1) return go(shown[0]!.path);
105
+ if (value.startsWith("/")) return go(value);
67
106
  }}
68
- className="mt-1 flex gap-1"
107
+ className="mb-1 flex gap-1"
69
108
  >
70
109
  <input
71
110
  value={typed}
72
111
  onChange={(e) => setTyped(e.target.value)}
73
- placeholder="/absolute/path/to/doc.smdf.json"
112
+ placeholder={docs === null ? "loading…" : "filter, or /absolute/path.json"}
74
113
  className="min-w-0 flex-1 rounded border border-gray-700 bg-gray-950 px-1.5 py-1 text-xs text-gray-200 placeholder:text-gray-600"
75
114
  />
76
- <button type="submit" className="toolbar-btn" title="Open this document">
115
+ <button type="submit" className="toolbar-btn" title="Open">
77
116
  →
78
117
  </button>
79
118
  </form>
119
+
120
+ <div className="max-h-64 overflow-y-auto">
121
+ {requested && (
122
+ <button onClick={() => go(null)} className={rowClass} title="Return to the default project file">
123
+ ⌂ default document
124
+ </button>
125
+ )}
126
+
127
+ {shown.map((d) => (
128
+ <button key={d.path} onClick={() => go(d.path)} className={rowClass} title={d.path}>
129
+ {d.name}
130
+ <span className="ml-1 text-[10px] text-gray-600">{d.dir}</span>
131
+ </button>
132
+ ))}
133
+
134
+ {recents.length > 0 && (
135
+ <div className="mt-1 border-t border-gray-800 pt-1">
136
+ <div className="px-1.5 pb-0.5 text-[10px] uppercase tracking-wide text-gray-600">
137
+ recent
138
+ </div>
139
+ {recents.map((d) => (
140
+ <button key={d} onClick={() => go(d)} className={rowClass} title={d}>
141
+ {d.split("/").pop()}
142
+ <span className="ml-1 text-[10px] text-gray-600">{d}</span>
143
+ </button>
144
+ ))}
145
+ </div>
146
+ )}
147
+
148
+ {docs !== null && shown.length === 0 && recents.length === 0 && (
149
+ <div className="px-1.5 py-1 text-[11px] text-gray-500">
150
+ {filter ? "nothing matches" : "no other documents here"}
151
+ </div>
152
+ )}
153
+ </div>
154
+
155
+ {/* The server's view of where documents live. In a container this is
156
+ /data, and saying so is the difference between a refusal that
157
+ reads as a bug and one that reads as an instruction. */}
158
+ {roots.length > 0 && (
159
+ <div className="mt-1 truncate border-t border-gray-800 pt-1 text-[10px] text-gray-600">
160
+ paths are as the server sees them: {roots.join(", ")}
161
+ </div>
162
+ )}
163
+ {error && (
164
+ <div className="mt-1 text-[10px] text-gray-600">
165
+ this server does not list documents ({error}) — type a path
166
+ </div>
167
+ )}
80
168
  </div>
81
169
  )}
82
170
  </span>
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { colorFor } from "@miadi/stateloom-protocol";
5
+ import { useDesignerStore } from "@/store/useDesignerStore";
6
+
7
+ /** Enough dots to read "several peers" at a glance; the count carries the rest. */
8
+ const MAX_DOTS = 4;
9
+
10
+ /**
11
+ * Who else is on this board. It used to float over the top-right corner as one
12
+ * pill per peer — which on a busy room meant a dozen chips painted across the
13
+ * toolbar's own controls, hiding Validate/Generate/RISE behind a colour bar
14
+ * nobody could dismiss. `pointer-events-none` kept the buttons clickable but
15
+ * you could no longer see what you were clicking.
16
+ *
17
+ * So presence stops floating: it is a toolbar control like any other, laid out
18
+ * in the flow beside the document identity it describes. Fixed width regardless
19
+ * of how many peers join — a stack of colour dots and a count — with the full
20
+ * roster one click away. Nothing can be covered by a room that grows.
21
+ */
22
+ export default function PresenceChips() {
23
+ const presence = useDesignerStore((s) => s.presence);
24
+ const [open, setOpen] = useState(false);
25
+
26
+ if (presence.length === 0) return null;
27
+
28
+ const byRole = new Map<string, number>();
29
+ for (const p of presence) byRole.set(p.role, (byRole.get(p.role) ?? 0) + 1);
30
+ const summary = [...byRole]
31
+ .map(([role, n]) => (n > 1 ? `${role} ×${n}` : role))
32
+ .join(" · ");
33
+
34
+ return (
35
+ <span className="relative inline-flex">
36
+ <button
37
+ onClick={() => setOpen((v) => !v)}
38
+ className="toolbar-btn inline-flex items-center gap-1.5"
39
+ title={`Connected: ${summary}`}
40
+ aria-expanded={open}
41
+ aria-label={`${presence.length} connected: ${summary}`}
42
+ >
43
+ <span className="flex -space-x-1">
44
+ {presence.slice(0, MAX_DOTS).map((p) => (
45
+ <span
46
+ key={p.clientId}
47
+ className="inline-block h-2 w-2 rounded-full ring-1 ring-gray-900"
48
+ style={{ backgroundColor: p.color ?? colorFor(p.clientId) }}
49
+ />
50
+ ))}
51
+ </span>
52
+ <span className="tabular-nums">{presence.length}</span>
53
+ </button>
54
+ {open && (
55
+ <div className="absolute left-0 top-full z-50 mt-1 max-h-64 w-56 overflow-y-auto rounded border border-gray-700 bg-gray-900 p-2 shadow-lg">
56
+ <div className="mb-1 text-[10px] uppercase tracking-wide text-gray-500">
57
+ connected · {summary}
58
+ </div>
59
+ {presence.map((p) => (
60
+ <div
61
+ key={p.clientId}
62
+ className="flex items-center gap-1.5 rounded px-1 py-1 text-xs text-gray-300"
63
+ title={p.clientId}
64
+ >
65
+ <span
66
+ className="inline-block h-2 w-2 shrink-0 rounded-full"
67
+ style={{ backgroundColor: p.color ?? colorFor(p.clientId) }}
68
+ />
69
+ <span className="shrink-0">{p.role}</span>
70
+ {p.name && <span className="truncate text-gray-500">{p.name}</span>}
71
+ </div>
72
+ ))}
73
+ </div>
74
+ )}
75
+ </span>
76
+ );
77
+ }
@@ -7,7 +7,6 @@ import {
7
7
  } from "@miadi/stateloom-react";
8
8
  import {
9
9
  diffDefinition,
10
- colorFor,
11
10
  type StateMachineDefinition,
12
11
  } from "@miadi/stateloom-protocol";
13
12
  import { useDesignerStore } from "@/store/useDesignerStore";
@@ -24,9 +23,13 @@ const OUTBOUND_DEBOUNCE_MS = 60;
24
23
  * Rendered only once a bridge URL is known — see DesignBridge, which resolves
25
24
  * it from the serving process at runtime so a published build carries nobody's
26
25
  * URL baked in.
26
+ *
27
+ * Transport only — it paints nothing. Presence lands in the store and the
28
+ * toolbar draws it (PresenceChips); the floating corner dock this used to
29
+ * render grew one pill per peer and ended up covering the toolbar it hovered
30
+ * over.
27
31
  */
28
32
  export default function SocketBridgeProvider({ url }: { url: string }) {
29
- const presence = useDesignerStore((s) => s.presence);
30
33
  // The requested `?doc=` (step 2): passed through to the file API, which
31
34
  // resolves and guards it; the join below keys the room by the RESOLVED path.
32
35
  const requested = useRequestedDoc();
@@ -126,24 +129,5 @@ export default function SocketBridgeProvider({ url }: { url: string }) {
126
129
  // an arbitrary definition, so the arriving full-def repaints the canvas.
127
130
  }, [url, requested]);
128
131
 
129
- if (presence.length === 0) return null;
130
-
131
- return (
132
- // `presence-dock` replaces the raw `top-2 right-4`: with viewport-fit=cover
133
- // that corner can sit under a notch or the status bar, so the offset is
134
- // measured from the safe-area inset instead. Still pointer-events-none, so
135
- // it never competes with the toolbar it floats over.
136
- <div className="presence-dock flex gap-1 pointer-events-none">
137
- {presence.map((p) => (
138
- <span
139
- key={p.clientId}
140
- title={`${p.role}${p.name ? ` · ${p.name}` : ""}`}
141
- className="text-[10px] leading-none px-2 py-1 rounded-full text-white/95 shadow font-medium"
142
- style={{ backgroundColor: p.color ?? colorFor(p.clientId) }}
143
- >
144
- {p.role}
145
- </span>
146
- ))}
147
- </div>
148
- );
132
+ return null;
149
133
  }
@@ -10,6 +10,7 @@ import {
10
10
  } from "@/lib/exportImage";
11
11
  import type { StateDef } from "@/types/definition";
12
12
  import DocSwitcher from "./DocSwitcher";
13
+ import PresenceChips from "./PresenceChips";
13
14
  import { docQuery, useRequestedDoc } from "@/lib/docParam";
14
15
 
15
16
  export default function Toolbar() {
@@ -224,6 +225,10 @@ export default function Toolbar() {
224
225
  <span className="text-[10px] text-gray-600" title={remoteMessage ?? "No file bound"}>○ no disk</span>
225
226
  )}
226
227
 
228
+ {/* Who else is on the board. In the flow, next to the document it
229
+ describes — it used to float over this toolbar and cover it. */}
230
+ <PresenceChips />
231
+
227
232
  <div className="flex-1" />
228
233
  </div>
229
234
 
@@ -1,5 +1,5 @@
1
- import { existsSync, realpathSync } from "fs";
2
- import { dirname, isAbsolute, resolve, sep } from "path";
1
+ import { existsSync, readdirSync, realpathSync, statSync } from "fs";
2
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
3
3
  import { envAlias } from "@miadi/stateloom-protocol";
4
4
 
5
5
  export function getProjectFilePath(): string {
@@ -61,13 +61,114 @@ export function resolveDocPath(doc: string | null | undefined): DocResolution {
61
61
  ? realpathSync(lexical)
62
62
  : resolve(realpathSync(dirname(lexical)), lexical.slice(lexical.lastIndexOf(sep) + 1));
63
63
  } catch {
64
- return { ok: false, error: "doc directory does not exist" };
64
+ // Almost always a host path handed to a containerised server: the directory
65
+ // exists where the human is looking and not where the server is.
66
+ return { ok: false, error: outsideRootsMessage(doc, allowedDocRoots()) };
65
67
  }
66
68
 
67
- for (const root of allowedDocRoots()) {
69
+ const roots = allowedDocRoots();
70
+ for (const root of roots) {
68
71
  if (anchored === root || anchored.startsWith(root + sep)) {
69
72
  return { ok: true, path: anchored };
70
73
  }
71
74
  }
72
- return { ok: false, error: "doc is outside the allowed roots" };
75
+ return { ok: false, error: outsideRootsMessage(doc, roots) };
76
+ }
77
+
78
+ /**
79
+ * A refusal that is true from the READER's side.
80
+ *
81
+ * "doc is outside the allowed roots" and "doc directory does not exist" are both
82
+ * accurate from the server's point of view and both false from the human's: in a
83
+ * container they are looking straight at the directory, on the host, where it
84
+ * plainly does exist. What they are missing is that the server sees it somewhere
85
+ * else. So the message names the roots and, when they differ from the path being
86
+ * asked for, says what the translation is.
87
+ */
88
+ function outsideRootsMessage(doc: string, roots: string[]): string {
89
+ const where = roots.length ? roots.join(", ") : "(no readable root is configured)";
90
+ const hint =
91
+ roots.length === 1 && roots[0] === "/data"
92
+ ? " — this server is in a container, and it sees your mounted directory as /data," +
93
+ ` so ask for /data/${basename(doc)}`
94
+ : "";
95
+ return `${doc} is not under the permitted document root${roots.length === 1 ? "" : "s"} ${where}${hint}`;
96
+ }
97
+
98
+ export interface DocEntry {
99
+ /** Absolute path as the SERVER sees it — the value `?doc=` wants. */
100
+ path: string;
101
+ /** Basename, for display. */
102
+ name: string;
103
+ /** Directory, for display when two documents share a name. */
104
+ dir: string;
105
+ size: number;
106
+ mtime: number;
107
+ }
108
+
109
+ /**
110
+ * Every document the allowlist would admit, so the switcher can be a switcher.
111
+ *
112
+ * Both the compose file and `.env.docker.example` promised that other documents
113
+ * in the mounted directory are "reachable from the canvas's document switcher".
114
+ * That was true of the ALLOWLIST and false of the UI: recents came from this
115
+ * browser's layout memory, empty on first use, and nothing enumerated the
116
+ * directory — so switching meant knowing the container sees your folder as
117
+ * /data and typing the path by hand, every time. (Found in review.)
118
+ *
119
+ * Bounded deliberately: depth and count caps, no dotfiles, no node_modules. A
120
+ * mount pointed at a home directory must not turn a dropdown into a filesystem
121
+ * crawl.
122
+ */
123
+ export function listDocuments(limit = 300, maxDepth = 4): { docs: DocEntry[]; truncated: boolean } {
124
+ const docs: DocEntry[] = [];
125
+ const seen = new Set<string>();
126
+ let truncated = false;
127
+
128
+ const walk = (dir: string, depth: number): void => {
129
+ if (docs.length >= limit) {
130
+ truncated = true;
131
+ return;
132
+ }
133
+ let entries: string[];
134
+ try {
135
+ entries = readdirSync(dir);
136
+ } catch {
137
+ return; // unreadable directory is not an error, it is just not listed
138
+ }
139
+ for (const entry of entries) {
140
+ if (entry.startsWith(".") || entry === "node_modules") continue;
141
+ const full = join(dir, entry);
142
+ let stat;
143
+ try {
144
+ stat = statSync(full);
145
+ } catch {
146
+ continue; // a broken symlink lists as nothing, not as a crash
147
+ }
148
+ if (stat.isDirectory()) {
149
+ if (depth < maxDepth) walk(full, depth + 1);
150
+ continue;
151
+ }
152
+ if (!entry.endsWith(".json")) continue;
153
+ if (docs.length >= limit) {
154
+ truncated = true;
155
+ return;
156
+ }
157
+ if (seen.has(full)) continue;
158
+ seen.add(full);
159
+ docs.push({ path: full, name: entry, dir, size: stat.size, mtime: stat.mtimeMs });
160
+ }
161
+ };
162
+
163
+ for (const root of allowedDocRoots()) walk(root, 0);
164
+
165
+ // `.smdf.json` first — the rest are `.json` files the allowlist tolerates but
166
+ // nobody came here looking for — then most recently touched.
167
+ docs.sort((a, b) => {
168
+ const aSmdf = a.name.endsWith(".smdf.json") ? 0 : 1;
169
+ const bSmdf = b.name.endsWith(".smdf.json") ? 0 : 1;
170
+ return aSmdf - bSmdf || b.mtime - a.mtime || a.path.localeCompare(b.path);
171
+ });
172
+
173
+ return { docs, truncated };
73
174
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.1.2",
3
- "designer": "0.1.2",
2
+ "package": "0.1.4",
3
+ "designer": "0.1.4",
4
4
  "next": "16.1.6"
5
5
  }