From e0710aa75654777c96a5cb28ea23fc912b6397a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 23:49:22 +0000 Subject: [PATCH] refactor: split the viewer out of the Claude artifact host The artifact host's UI (space and view switcher, parameter controls, map view, side panel) moves to src/viewer as a host-neutral Viewer, so other hosts, such as a self-contained HTML page, can reuse it. The snapshot type moves to core, and the artifact's loader returns it instead of restating it. Card labels for the artifact's comment mode become a cardLabel option the artifact host passes. Module boundary tests keep the viewer free of host code and keep hosts unimported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019XphpeFwEYm8K11SoT9ewz --- AGENTS.md | 1 + docs/architecture.md | 5 +- .../map-views/scripts/build-artifact-page.ts | 2 +- packages/map-views/src/artifact/load.ts | 26 +-- packages/map-views/src/artifact/main.tsx | 219 +++--------------- packages/map-views/src/artifact/page.css | 65 ------ packages/map-views/src/core/snapshot.ts | 16 ++ packages/map-views/src/node/snapshot.ts | 17 +- packages/map-views/src/viewer/Viewer.tsx | 200 ++++++++++++++++ packages/map-views/src/viewer/viewer.css | 65 ++++++ .../map-views/tests/module-boundaries.test.ts | 11 + 11 files changed, 337 insertions(+), 290 deletions(-) delete mode 100644 packages/map-views/src/artifact/page.css create mode 100644 packages/map-views/src/core/snapshot.ts create mode 100644 packages/map-views/src/viewer/Viewer.tsx create mode 100644 packages/map-views/src/viewer/viewer.css diff --git a/AGENTS.md b/AGENTS.md index 938e128..86cf7b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ Declarative map views of structured-context spaces: the definition format, the r - `packages/map-views`: the published library, `structured-context-map-views`. Its `src/index.ts` is the public surface; add an export only when a host needs it. - `packages/map-views/src/core`: framework-agnostic. Definitions, parameters, graph helpers and the projections. No React, DOM, Node or runtime structured-context imports (type-only is fine, and `structured-context/types` is browser-safe); `tests/module-boundaries.test.ts` enforces this. - `packages/map-views/src/layouts`: React components and the SVG edge overlay. Don't import host code from here. +- `packages/map-views/src/viewer`: the page-filling viewer that hosts share. It knows nothing about any one host: anything host-specific (such as card labels) is an option the host passes. Hosts (`src/artifact`) are imported by nothing else. - `packages/map-views/src/node`: Node-only definition discovery (`loadMapViewDefinitions`), shipped as its own entry point (`structured-context-map-views/node`). Core and layouts must not import it. - `apps/navigator`: the standalone host (private package: a Vite app plus dev-server middleware calling the structured-context API). It consumes the library like any other host. - `packages/map-views/map-views/*.json5`: definitions bundled with the library, only for structured-context's bundled schemas (`$id` `sctx-map-view://core/`). `bun run generate:definitions` embeds them as `src/node/bundled-definitions.ts`; a test fails if that is stale. Plugins ship their own; personal ones go in a `map-views/` folder next to the structured-context config. diff --git a/docs/architecture.md b/docs/architecture.md index dcf69d8..a17a796 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,10 +17,11 @@ flowchart LR |---|---|---| | `packages/map-views/src/core` | Definition parsing and validation, parameter resolution, graph helpers, and the projections (`buildTree`, `buildLandscape`, `buildXYPlot`) | Types from structured-context only. No React, DOM or Node | | `packages/map-views/src/layouts` | `MapView`, the layout components, `NodeCard`, `EdgeOverlay`, the parameter controls (`useParameterControls`, the typed inputs and the default `ParameterControls` layout), styles | core, React | +| `packages/map-views/src/viewer` | `Viewer`: a page-filling viewer for space snapshots (space and map view switcher, parameter controls, the map view, a side panel with a node's fields and validation results) and its page styles. Hosts pass the snapshots and options such as card labels | core, layouts, React, `structured-context/browser` | | `packages/map-views/src/node` | `loadMapViewDefinitions`: finding definitions in the library (embedded at build time from `packages/map-views/map-views/`), plugins and config folders; `exportSnapshot` and the `export` bin, which snapshot a space for browser-only hosts | core, Node, structured-context | | `apps/navigator` | The standalone host: a Vite app plus dev-server middleware calling the structured-context API | Anything | -`packages/map-views/src/index.ts` is the published entry point and re-exports the few things hosts need (modules import each other directly, with no barrel files); `src/node` is the second entry point, and `src/node/cli.ts` is the bin. The library build puts the projections and annotations in a chunk of their own, so bundling just those from the package root leaves out React and ajv. `packages/map-views/tests/module-boundaries.test.ts` enforces the core and layouts rules, including that neither imports the Node module. Core runs anywhere (browser, Node, worker) and is tested without a DOM, against fixtures captured from spaces (`packages/map-views/tests/fixtures/`, written by `packages/map-views/scripts/capture-fixtures.ts`). The public sample space in `samples/launch-playbook/` is the source of `launch-playbook.json`. +`packages/map-views/src/index.ts` is the published entry point and re-exports the few things hosts need (modules import each other directly, with no barrel files); `src/node` is the second entry point, and `src/node/cli.ts` is the bin. The library build puts the projections and annotations in a chunk of their own, so bundling just those from the package root leaves out React and ajv. `packages/map-views/tests/module-boundaries.test.ts` enforces the core, layouts and viewer rules, including that none imports the Node module or a host. Core runs anywhere (browser, Node, worker) and is tested without a DOM, against fixtures captured from spaces (`packages/map-views/tests/fixtures/`, written by `packages/map-views/scripts/capture-fixtures.ts`). The public sample space in `samples/launch-playbook/` is the source of `launch-playbook.json`. ## Data in @@ -55,5 +56,5 @@ Hosts theme map views through nine variables: `--mv-color-surface`, `--mv-color- ## Hosts - **Navigator** (`apps/navigator`): dev-server middleware serves spaces (from the structured-context config), the definitions `loadMapViewDefinitions` finds, and, when a map view asks for them, validation results (`validateSpace`, expression rules only), as JSON. The chosen space, map view and parameter values live in the query string, so a reload restores them. Selecting a node opens a side panel with its fields, content and validation results. -- **Claude artifact** (`packages/map-views/src/artifact`, shipped as `dist/artifact.js` with its page `dist/artifact.html`): a host with no server. The publishing agent runs the `export` command and writes each space to the artifact's database (`spaces/`: `schemaId`, and `graph`, `validation` and `definitions` as JSON strings). The page lists that collection, offers the spaces and their map views, renders `MapView` with the parameter controls, and lists a card's validation results in a side panel. It declares only the `db` capability and shows a message when the database is unavailable, empty or slow (10 s). The viewer allows scripts only from a few CDNs and blocks external stylesheets, so `scripts/build-artifact-page.ts` writes a page that carries the CSS inline and loads `dist/artifact.js` (pinned to this package's version) and its dependencies from jsDelivr through an import map; `react` and `react-dom` are pinned to one version so the page runs one React. Cards are given an accessible name `[] · <view>` (from `data-node-id`), where the index is the node's position in the stored graph's `nodes` array, because the viewer's comment mode reads a comment's location from it (cut at about 45 characters, too short for a node id), so a comment tells an agent which node it concerns. Refreshing the data is writing the documents again; the page doesn't change. +- **Claude artifact** (`packages/map-views/src/artifact`, shipped as `dist/artifact.js` with its page `dist/artifact.html`): a host with no server. The publishing agent runs the `export` command and writes each space to the artifact's database (`spaces/<space>`: `schemaId`, and `graph`, `validation` and `definitions` as JSON strings). The page lists that collection and shows it in the `Viewer`. It declares only the `db` capability and shows a message when the database is unavailable, empty or slow (10 s). Claude's artifact viewer allows scripts only from a few CDNs and blocks external stylesheets, so `scripts/build-artifact-page.ts` writes a page that carries the CSS inline and loads `dist/artifact.js` (pinned to this package's version) and its dependencies from jsDelivr through an import map; `react` and `react-dom` are pinned to one version so the page runs one React. The host names cards `[<index>] <title> · <view>` through the `Viewer`'s `cardLabel` option, where the index is the node's position in the stored graph's `nodes` array, because the artifact viewer's comment mode reads a comment's location from it (cut at about 45 characters, too short for a node id), so a comment tells an agent which node it concerns. Refreshing the data is writing the documents again; the page doesn't change. - **Obsidian plugin** ([obsidian-structured-context](https://github.com/mindsocket/obsidian-structured-context)): renders map views in a pane, builds the data in-process, opens the source note on selection and keeps parameter values in the view state. diff --git a/packages/map-views/scripts/build-artifact-page.ts b/packages/map-views/scripts/build-artifact-page.ts index 3991cc1..cdfa228 100644 --- a/packages/map-views/scripts/build-artifact-page.ts +++ b/packages/map-views/scripts/build-artifact-page.ts @@ -51,7 +51,7 @@ for (const url of Object.values(pinnedUrls)) { } } -const css = [join(root, 'dist/styles.css'), join(root, 'src/artifact/page.css')] +const css = [join(root, 'dist/styles.css'), join(root, 'src/viewer/viewer.css')] .map((f) => readFileSync(f, 'utf8')) .join('\n'); const entry = `${CDN}/${pkg.name}@${pkg.version}/dist/artifact.js`; diff --git a/packages/map-views/src/artifact/load.ts b/packages/map-views/src/artifact/load.ts index d84cfa3..5755812 100644 --- a/packages/map-views/src/artifact/load.ts +++ b/packages/map-views/src/artifact/load.ts @@ -1,15 +1,4 @@ -import type { spaceGraphFrom } from 'structured-context/browser'; -import type { ValidationInput } from '../core/annotations'; -import type { MapViewDefinition } from '../core/definition'; - -/** A space as the artifact's db holds it: the `export` command's document with its parts as JSON strings. */ -export type LoadedSpace = { - name: string; - schemaId: string | undefined; - graph: Parameters<typeof spaceGraphFrom>[0]; - validation: ValidationInput; - definitions: MapViewDefinition[]; -}; +import type { SpaceSnapshot } from '../core/snapshot'; const SPACES = 'spaces'; const TIMEOUT_MS = 10_000; @@ -18,8 +7,11 @@ type Db = { collection(path: string): { get(): Promise<{ docs: { id: string; data(): Record<string, unknown> | undefined }[] }> }; }; -/** Every space written to the artifact's `spaces` collection. Rejects with a message fit to show the viewer. */ -export async function loadSpaces(): Promise<LoadedSpace[]> { +/** + * Every space written to the artifact's `spaces` collection: the `export` command's document, keyed by space + * name, with its parts as JSON strings. Rejects with a message fit to show the viewer. + */ +export async function loadSpaces(): Promise<SpaceSnapshot[]> { const claude = (window as unknown as { claude?: { use(name: string): Promise<unknown> } }).claude; const db = (claude ? await claude.use('db') : null) as Db | null; if (!db) throw new Error('This page needs the artifact database, which is not available to this viewer.'); @@ -38,14 +30,14 @@ export async function loadSpaces(): Promise<LoadedSpace[]> { return spaces; } -function toSpace(name: string, data: Record<string, unknown>): LoadedSpace { +function toSpace(space: string, data: Record<string, unknown>): SpaceSnapshot { const json = (field: string) => { const value = data[field]; - if (typeof value !== 'string') throw new Error(`Space "${name}": missing "${field}" in the artifact database.`); + if (typeof value !== 'string') throw new Error(`Space "${space}": missing "${field}" in the artifact database.`); return JSON.parse(value); }; return { - name, + space, schemaId: typeof data.schemaId === 'string' ? data.schemaId : undefined, graph: json('graph'), validation: json('validation'), diff --git a/packages/map-views/src/artifact/main.tsx b/packages/map-views/src/artifact/main.tsx index 5d25a1e..30773d3 100644 --- a/packages/map-views/src/artifact/main.tsx +++ b/packages/map-views/src/artifact/main.tsx @@ -1,20 +1,21 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { spaceGraphFrom } from 'structured-context/browser'; -import { annotationsFrom } from '../core/annotations'; -import type { MapViewDefinition, ParameterValues } from '../core/definition'; -import { MapView } from '../layouts/MapView'; -import { ParameterControls } from '../layouts/ParameterControls'; -import { type LoadedSpace, loadSpaces } from './load'; +import type { SpaceGraph, SpaceNode } from 'structured-context/browser'; +import type { MapViewDefinition } from '../core/definition'; +import type { SpaceSnapshot } from '../core/snapshot'; +import { Viewer } from '../viewer/Viewer'; +import { loadSpaces } from './load'; /** - * The host for a Claude artifact: renders the spaces the publishing agent wrote to the artifact's - * db (the `export` command's document, one per space) with the parameter controls and a panel of - * validation messages. It reads nothing else and ships as `dist/artifact.js`; `dist/artifact.html` - * is the page that loads it. See docs/architecture.md. + * The host for a Claude artifact: shows the spaces the publishing agent wrote to the artifact's db in the + * viewer, and names cards for the artifact's comment mode. It ships as `dist/artifact.js`; + * `dist/artifact.html` is the page that loads it. See docs/architecture.md. */ -type State = { status: 'loading' } | { status: 'error'; message: string } | { status: 'ready'; spaces: LoadedSpace[] }; +type State = + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'ready'; spaces: SpaceSnapshot[] }; function App() { const [state, setState] = useState<State>({ status: 'loading' }); @@ -24,189 +25,27 @@ function App() { (err) => setState({ status: 'error', message: err instanceof Error ? err.message : String(err) }), ); }, []); - if (state.status === 'loading') return <p className="art-message">Loading from the artifact database…</p>; - if (state.status === 'error') return <p className="art-message">{state.message}</p>; - return <Viewer spaces={state.spaces} />; + if (state.status === 'loading') return <p className="viewer-message">Loading from the artifact database…</p>; + if (state.status === 'error') return <p className="viewer-message">{state.message}</p>; + return <Viewer spaces={state.spaces} cardLabel={commentLabel} />; } -function Viewer({ spaces }: { spaces: LoadedSpace[] }) { - const [spaceName, setSpaceName] = useState(spaces[0]!.name); - const space = spaces.find((s) => s.name === spaceName)!; - const [viewId, setViewId] = useState(space.definitions[0]?.id); - const definition = space.definitions.find((d) => d.id === viewId) ?? space.definitions[0]; - const [values, setValues] = useState<ParameterValues>({}); - const [selectedId, setSelectedId] = useState<string>(); - - const graph = useMemo(() => spaceGraphFrom(space.graph), [space]); - const annotated = useMemo(() => annotationsFrom(space.validation, graph), [space, graph]); - const mapRef = useNodeLabels(graph, definition); - - if (!definition) return <p className="art-message">No map views apply to the schema of "{space.name}".</p>; - const selected = selectedId ? graph.nodes.get(selectedId as never) : undefined; - const results = selected ? (annotated.annotations.get(selected.id as never) ?? []) : []; - - return ( - <div className="art-root"> - <header className="art-bar"> - {spaces.length > 1 && ( - <select - aria-label="Space" - value={spaceName} - onChange={(e) => { - const next = spaces.find((s) => s.name === e.target.value)!; - setSpaceName(next.name); - setViewId(next.definitions[0]?.id); - setValues({}); - setSelectedId(undefined); - }} - > - {spaces.map((s) => ( - <option key={s.name} value={s.name}> - {s.name} - </option> - ))} - </select> - )} - {space.definitions.length > 1 && ( - <select - aria-label="Map view" - value={definition.id} - onChange={(e) => { - setViewId(e.target.value); - setValues({}); - }} - > - {space.definitions.map((d) => ( - <option key={d.id} value={d.id}> - {d.title} - </option> - ))} - </select> - )} - <ParameterControls - definition={definition} - values={values} - onChange={(name, value) => setValues((prev) => ({ ...prev, [name]: value }))} - /> - </header> - <div className="art-body"> - <div className="art-map" ref={mapRef}> - <MapView - definition={definition} - graph={graph} - values={values} - annotations={annotated.annotations} - onNodeSelect={(node) => setSelectedId(node.id)} - /> - </div> - {(selected || annotated.unattached.length > 0) && ( - <aside className="art-panel" aria-label="Validation results"> - {selected && ( - <section> - <button type="button" className="art-close" aria-label="Close" onClick={() => setSelectedId(undefined)}> - × - </button> - <p className="art-type">{selected.resolvedType}</p> - <h2>{selected.title}</h2> - {results.length > 0 && ( - <> - <h3>Validation</h3> - <Results items={results} /> - </> - )} - <Fields data={selected.schemaData} /> - {selected.content && ( - <> - <h3>Content</h3> - <p className="art-content">{selected.content}</p> - </> - )} - {selected.source && <p className="art-muted">{selected.source.file}</p>} - </section> - )} - {annotated.unattached.length > 0 && ( - <section> - <h3>Not on a node ({annotated.unattached.length})</h3> - <Results items={annotated.unattached} /> - </section> - )} - </aside> - )} - </div> - </div> - ); -} - -const SKIPPED_FIELDS = new Set(['title', 'type', 'content']); -const isEmpty = (v: unknown) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0); -const asText = (v: unknown): string => - Array.isArray(v) ? v.map(asText).join(', ') : v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v); - -/** The node's fields as a list; the title, type and body are shown elsewhere. */ -function Fields({ data }: { data: Record<string, unknown> }) { - const fields = Object.entries(data).filter(([name, value]) => !SKIPPED_FIELDS.has(name) && !isEmpty(value)); - if (fields.length === 0) return null; - return ( - <> - <h3>Fields</h3> - <dl className="art-fields"> - {fields.map(([name, value]) => ( - <div key={name}> - <dt>{name}</dt> - <dd>{asText(value)}</dd> - </div> - ))} - </dl> - </> - ); -} - -function Results({ - items, -}: { - items: readonly { severity: string; source: string; ruleId?: string; message: string; nodeId?: string }[]; -}) { - return ( - <ul className="art-results"> - {items.map((a, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: a static list with no stable identity beyond position. - <li key={i}> - <span className={`art-severity art-${a.severity}`}>{a.severity}</span>{' '} - <span className="art-muted">{a.ruleId ? `${a.source} · ${a.ruleId}` : a.source}</span> - {a.nodeId && <span className="art-muted"> · {a.nodeId}</span>} - <div>{a.message}</div> - </li> - ))} - </ul> - ); -} +const positionsByGraph = new WeakMap<SpaceGraph, Map<string, number>>(); /** - * Names each card for the viewer's comment mode, which reads the location of a comment from the - * element's accessible name, which the viewer cuts at about 45 characters. Node ids are long (a file - * and heading), so the name leads with the node's position in the stored graph, then its title and the - * view: `[<index>] <title> · <view>`. An agent reading the comment takes `graph.nodes[<index>]` from the - * db and checks the title. + * The comment mode reads a comment's location from the element's accessible name, cut at about 45 + * characters. Node ids are long (a file and heading), so the name leads with the node's position in the + * stored graph, then its title and the view: `[<index>] <title> · <view>`. An agent reading the comment + * takes `graph.nodes[<index>]` from the db and checks the title. */ -function useNodeLabels(graph: ReturnType<typeof spaceGraphFrom>, definition: MapViewDefinition | undefined) { - const ref = useRef<HTMLDivElement>(null); - useEffect(() => { - const root = ref.current; - if (!root || !definition) return; - const positions = new Map([...graph.nodes.keys()].map((id, index) => [id as string, index])); - const view = definition.id.slice(definition.id.lastIndexOf('/') + 1); - const stamp = () => { - for (const card of root.querySelectorAll<HTMLElement>('[data-node-id]')) { - const node = graph.nodes.get(card.dataset.nodeId as never); - if (node) card.setAttribute('aria-label', `[${positions.get(node.id as string)}] ${node.title} · ${view}`); - } - }; - stamp(); - const observer = new MutationObserver(stamp); - observer.observe(root, { childList: true, subtree: true }); - return () => observer.disconnect(); - }, [graph, definition]); - return ref; +function commentLabel(node: SpaceNode, graph: SpaceGraph, definition: MapViewDefinition): string { + let positions = positionsByGraph.get(graph); + if (!positions) { + positions = new Map([...graph.nodes.keys()].map((id, index) => [id as string, index])); + positionsByGraph.set(graph, positions); + } + const view = definition.id.slice(definition.id.lastIndexOf('/') + 1); + return `[${positions.get(node.id as string)}] ${node.title} · ${view}`; } createRoot(document.getElementById('root')!).render(<App />); diff --git a/packages/map-views/src/artifact/page.css b/packages/map-views/src/artifact/page.css deleted file mode 100644 index cd87f76..0000000 --- a/packages/map-views/src/artifact/page.css +++ /dev/null @@ -1,65 +0,0 @@ -:root { - --mv-color-surface: #f8f8f7; - --mv-color-card: #ffffff; - --mv-color-ink: #1f2328; - --mv-color-muted: #59636e; - --mv-color-edge: #d1d9e0; - --mv-color-accent: #0969da; - --mv-color-error: #cf222e; - --mv-color-warning: #9a6700; - --mv-color-info: #0969da; - --art-ground: #ffffff; - color-scheme: light; -} -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { - --mv-color-surface: #161b22; - --mv-color-card: #21262d; - --mv-color-ink: #e6edf3; - --mv-color-muted: #9198a1; - --mv-color-edge: #3d444d; - --mv-color-accent: #4493f8; - --mv-color-error: #f85149; - --mv-color-warning: #d29922; - --mv-color-info: #4493f8; - --art-ground: #0d1117; - color-scheme: dark; - } -} -:root[data-theme="dark"] { - --mv-color-surface: #161b22; - --mv-color-card: #21262d; - --mv-color-ink: #e6edf3; - --mv-color-muted: #9198a1; - --mv-color-edge: #3d444d; - --mv-color-accent: #4493f8; - --mv-color-error: #f85149; - --mv-color-warning: #d29922; - --mv-color-info: #4493f8; - --art-ground: #0d1117; - color-scheme: dark; -} -body { background: var(--art-ground); color: var(--mv-color-ink); } -.art-root { display: flex; flex-direction: column; height: 100vh; } -.art-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem 1rem; padding: 0.5rem 1rem; border-bottom: 1px solid var(--mv-color-edge); } -.art-body { flex: 1; min-height: 0; display: flex; flex-wrap: wrap; } -.art-map { flex: 1 1 24rem; min-width: 0; min-height: 50vh; } -.art-panel { flex: 0 1 20rem; max-height: 100%; overflow: auto; padding: 0.75rem 1rem; border-left: 1px solid var(--mv-color-edge); } -.art-panel h2 { margin: 0 0 0.75rem; font-size: 1.15rem; } -.art-panel h3 { margin: 1rem 0 0.4rem; padding-top: 0.6rem; border-top: 1px solid var(--mv-color-edge); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--mv-color-muted); } -.art-panel section + section { margin-top: 1rem; } -.art-close { float: right; border: 0; background: none; color: inherit; font-size: 1.25rem; cursor: pointer; } -.art-type { margin: 0; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--mv-color-muted); } -.art-muted { color: var(--mv-color-muted); } -.art-message { padding: 1rem; } -.art-results { margin: 0; padding: 0; list-style: none; } -.art-results li { margin-bottom: 0.6rem; } -.art-severity { font-weight: 600; } -.art-error { color: var(--mv-color-error); } -.art-warning { color: var(--mv-color-warning); } -.art-info { color: var(--mv-color-info); } -.art-fields { margin: 0 0 0.75rem; } -.art-fields div { margin-bottom: 0.4rem; } -.art-fields dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--mv-color-muted); } -.art-fields dd { margin: 0; overflow-wrap: anywhere; } -.art-content { white-space: pre-wrap; overflow-wrap: anywhere; } diff --git a/packages/map-views/src/core/snapshot.ts b/packages/map-views/src/core/snapshot.ts new file mode 100644 index 0000000..10790cb --- /dev/null +++ b/packages/map-views/src/core/snapshot.ts @@ -0,0 +1,16 @@ +import type { spaceGraphFrom } from 'structured-context/browser'; +import type { ValidationInput } from './annotations'; +import type { MapViewDefinition } from './definition'; + +/** + * Everything a host without filesystem access needs to render a space: its graph, the validation + * results that attach to nodes, and the map view definitions that apply to its schema. Plain JSON. + */ +export type SpaceSnapshot = { + space: string; + schemaId: string | undefined; + graph: Parameters<typeof spaceGraphFrom>[0]; + /** What `annotationsFrom` attaches */ + validation: ValidationInput; + definitions: MapViewDefinition[]; +}; diff --git a/packages/map-views/src/node/snapshot.ts b/packages/map-views/src/node/snapshot.ts index 0a685e6..9df64ad 100644 --- a/packages/map-views/src/node/snapshot.ts +++ b/packages/map-views/src/node/snapshot.ts @@ -5,23 +5,10 @@ import { exportSpaceGraph, validateSpace, } from 'structured-context/api'; -import type { ValidationInput } from '../core/annotations'; -import { type MapViewDefinition, mapViewsForSchema } from '../core/definition'; +import { mapViewsForSchema } from '../core/definition'; +import type { SpaceSnapshot } from '../core/snapshot'; import { loadMapViewDefinitions } from './index'; -/** - * Everything a host without filesystem access needs to render a space: its graph, the validation - * results that attach to nodes, and the map view definitions that apply to its schema. Plain JSON. - */ -export type SpaceSnapshot = { - space: string; - schemaId: string | undefined; - graph: ReturnType<typeof exportSpaceGraph>; - /** What `annotationsFrom` attaches */ - validation: ValidationInput; - definitions: MapViewDefinition[]; -}; - /** The `validateSpace` options `export` passes through unchanged. */ export type ExportValidateOptions = Pick< NonNullable<Parameters<typeof validateSpace>[1]>, diff --git a/packages/map-views/src/viewer/Viewer.tsx b/packages/map-views/src/viewer/Viewer.tsx new file mode 100644 index 0000000..f4c1dce --- /dev/null +++ b/packages/map-views/src/viewer/Viewer.tsx @@ -0,0 +1,200 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { type SpaceGraph, type SpaceNode, spaceGraphFrom } from 'structured-context/browser'; +import { annotationsFrom } from '../core/annotations'; +import type { MapViewDefinition, ParameterValues } from '../core/definition'; +import type { SpaceSnapshot } from '../core/snapshot'; +import { MapView } from '../layouts/MapView'; +import { ParameterControls } from '../layouts/ParameterControls'; + +/** + * A page-filling viewer for one or more space snapshots: a space and map view switcher, the parameter + * controls, the map view, and a side panel with the selected node's fields and validation results. Hosts + * supply the snapshots and anything specific to where the page is shown. See docs/architecture.md. + */ + +export type ViewerProps = { + spaces: SpaceSnapshot[]; + /** Accessible name for each card, for hosts whose surroundings read it. Cards keep their default name without it. */ + cardLabel?: (node: SpaceNode, graph: SpaceGraph, definition: MapViewDefinition) => string; +}; + +export function Viewer({ spaces, cardLabel }: ViewerProps) { + const [spaceName, setSpaceName] = useState(spaces[0]!.space); + const space = spaces.find((s) => s.space === spaceName)!; + const [viewId, setViewId] = useState(space.definitions[0]?.id); + const definition = space.definitions.find((d) => d.id === viewId) ?? space.definitions[0]; + const [values, setValues] = useState<ParameterValues>({}); + const [selectedId, setSelectedId] = useState<string>(); + + const graph = useMemo(() => spaceGraphFrom(space.graph), [space]); + const annotated = useMemo(() => annotationsFrom(space.validation, graph), [space, graph]); + const mapRef = useCardLabels(graph, definition, cardLabel); + + if (!definition) return <p className="viewer-message">No map views apply to the schema of "{space.space}".</p>; + const selected = selectedId ? graph.nodes.get(selectedId as never) : undefined; + const results = selected ? (annotated.annotations.get(selected.id as never) ?? []) : []; + + return ( + <div className="viewer-root"> + <header className="viewer-bar"> + {spaces.length > 1 && ( + <select + aria-label="Space" + value={spaceName} + onChange={(e) => { + const next = spaces.find((s) => s.space === e.target.value)!; + setSpaceName(next.space); + setViewId(next.definitions[0]?.id); + setValues({}); + setSelectedId(undefined); + }} + > + {spaces.map((s) => ( + <option key={s.space} value={s.space}> + {s.space} + </option> + ))} + </select> + )} + {space.definitions.length > 1 && ( + <select + aria-label="Map view" + value={definition.id} + onChange={(e) => { + setViewId(e.target.value); + setValues({}); + }} + > + {space.definitions.map((d) => ( + <option key={d.id} value={d.id}> + {d.title} + </option> + ))} + </select> + )} + <ParameterControls + definition={definition} + values={values} + onChange={(name, value) => setValues((prev) => ({ ...prev, [name]: value }))} + /> + </header> + <div className="viewer-body"> + <div className="viewer-map" ref={mapRef}> + <MapView + definition={definition} + graph={graph} + values={values} + annotations={annotated.annotations} + onNodeSelect={(node) => setSelectedId(node.id)} + /> + </div> + {(selected || annotated.unattached.length > 0) && ( + <aside className="viewer-panel" aria-label="Validation results"> + {selected && ( + <section> + <button + type="button" + className="viewer-close" + aria-label="Close" + onClick={() => setSelectedId(undefined)} + > + × + </button> + <p className="viewer-type">{selected.resolvedType}</p> + <h2>{selected.title}</h2> + {results.length > 0 && ( + <> + <h3>Validation</h3> + <Results items={results} /> + </> + )} + <Fields data={selected.schemaData} /> + {selected.content && ( + <> + <h3>Content</h3> + <p className="viewer-content">{selected.content}</p> + </> + )} + {selected.source && <p className="viewer-muted">{selected.source.file}</p>} + </section> + )} + {annotated.unattached.length > 0 && ( + <section> + <h3>Not on a node ({annotated.unattached.length})</h3> + <Results items={annotated.unattached} /> + </section> + )} + </aside> + )} + </div> + </div> + ); +} + +const SKIPPED_FIELDS = new Set(['title', 'type', 'content']); +const isEmpty = (v: unknown) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0); +const asText = (v: unknown): string => + Array.isArray(v) ? v.map(asText).join(', ') : v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v); + +/** The node's fields as a list; the title, type and body are shown elsewhere. */ +function Fields({ data }: { data: Record<string, unknown> }) { + const fields = Object.entries(data).filter(([name, value]) => !SKIPPED_FIELDS.has(name) && !isEmpty(value)); + if (fields.length === 0) return null; + return ( + <> + <h3>Fields</h3> + <dl className="viewer-fields"> + {fields.map(([name, value]) => ( + <div key={name}> + <dt>{name}</dt> + <dd>{asText(value)}</dd> + </div> + ))} + </dl> + </> + ); +} + +function Results({ + items, +}: { + items: readonly { severity: string; source: string; ruleId?: string; message: string; nodeId?: string }[]; +}) { + return ( + <ul className="viewer-results"> + {items.map((a, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: a static list with no stable identity beyond position. + <li key={i}> + <span className={`viewer-severity viewer-${a.severity}`}>{a.severity}</span>{' '} + <span className="viewer-muted">{a.ruleId ? `${a.source} · ${a.ruleId}` : a.source}</span> + {a.nodeId && <span className="viewer-muted"> · {a.nodeId}</span>} + <div>{a.message}</div> + </li> + ))} + </ul> + ); +} + +/** Names each card with `cardLabel`, again whenever the map view redraws its cards. */ +function useCardLabels( + graph: SpaceGraph, + definition: MapViewDefinition | undefined, + cardLabel: ViewerProps['cardLabel'], +) { + const ref = useRef<HTMLDivElement>(null); + useEffect(() => { + const root = ref.current; + if (!root || !definition || !cardLabel) return; + const stamp = () => { + for (const card of root.querySelectorAll<HTMLElement>('[data-node-id]')) { + const node = graph.nodes.get(card.dataset.nodeId as never); + if (node) card.setAttribute('aria-label', cardLabel(node, graph, definition)); + } + }; + stamp(); + const observer = new MutationObserver(stamp); + observer.observe(root, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, [graph, definition, cardLabel]); + return ref; +} diff --git a/packages/map-views/src/viewer/viewer.css b/packages/map-views/src/viewer/viewer.css new file mode 100644 index 0000000..15ee7d4 --- /dev/null +++ b/packages/map-views/src/viewer/viewer.css @@ -0,0 +1,65 @@ +:root { + --mv-color-surface: #f8f8f7; + --mv-color-card: #ffffff; + --mv-color-ink: #1f2328; + --mv-color-muted: #59636e; + --mv-color-edge: #d1d9e0; + --mv-color-accent: #0969da; + --mv-color-error: #cf222e; + --mv-color-warning: #9a6700; + --mv-color-info: #0969da; + --viewer-ground: #ffffff; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --mv-color-surface: #161b22; + --mv-color-card: #21262d; + --mv-color-ink: #e6edf3; + --mv-color-muted: #9198a1; + --mv-color-edge: #3d444d; + --mv-color-accent: #4493f8; + --mv-color-error: #f85149; + --mv-color-warning: #d29922; + --mv-color-info: #4493f8; + --viewer-ground: #0d1117; + color-scheme: dark; + } +} +:root[data-theme="dark"] { + --mv-color-surface: #161b22; + --mv-color-card: #21262d; + --mv-color-ink: #e6edf3; + --mv-color-muted: #9198a1; + --mv-color-edge: #3d444d; + --mv-color-accent: #4493f8; + --mv-color-error: #f85149; + --mv-color-warning: #d29922; + --mv-color-info: #4493f8; + --viewer-ground: #0d1117; + color-scheme: dark; +} +body { background: var(--viewer-ground); color: var(--mv-color-ink); } +.viewer-root { display: flex; flex-direction: column; height: 100vh; } +.viewer-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem 1rem; padding: 0.5rem 1rem; border-bottom: 1px solid var(--mv-color-edge); } +.viewer-body { flex: 1; min-height: 0; display: flex; flex-wrap: wrap; } +.viewer-map { flex: 1 1 24rem; min-width: 0; min-height: 50vh; } +.viewer-panel { flex: 0 1 20rem; max-height: 100%; overflow: auto; padding: 0.75rem 1rem; border-left: 1px solid var(--mv-color-edge); } +.viewer-panel h2 { margin: 0 0 0.75rem; font-size: 1.15rem; } +.viewer-panel h3 { margin: 1rem 0 0.4rem; padding-top: 0.6rem; border-top: 1px solid var(--mv-color-edge); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--mv-color-muted); } +.viewer-panel section + section { margin-top: 1rem; } +.viewer-close { float: right; border: 0; background: none; color: inherit; font-size: 1.25rem; cursor: pointer; } +.viewer-type { margin: 0; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--mv-color-muted); } +.viewer-muted { color: var(--mv-color-muted); } +.viewer-message { padding: 1rem; } +.viewer-results { margin: 0; padding: 0; list-style: none; } +.viewer-results li { margin-bottom: 0.6rem; } +.viewer-severity { font-weight: 600; } +.viewer-error { color: var(--mv-color-error); } +.viewer-warning { color: var(--mv-color-warning); } +.viewer-info { color: var(--mv-color-info); } +.viewer-fields { margin: 0 0 0.75rem; } +.viewer-fields div { margin-bottom: 0.4rem; } +.viewer-fields dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--mv-color-muted); } +.viewer-fields dd { margin: 0; overflow-wrap: anywhere; } +.viewer-content { white-space: pre-wrap; overflow-wrap: anywhere; } diff --git a/packages/map-views/tests/module-boundaries.test.ts b/packages/map-views/tests/module-boundaries.test.ts index 5e229ee..e9d26bc 100644 --- a/packages/map-views/tests/module-boundaries.test.ts +++ b/packages/map-views/tests/module-boundaries.test.ts @@ -32,6 +32,17 @@ describe('module boundaries', () => { test('layouts does not import from host code or Node-only modules', () => { expect(importsIn('layouts').filter(({ specifier }) => /\/node(\/|$)|^node:/.test(specifier))).toEqual([]); }); + + // The viewer is shared by hosts, so it knows nothing about any one of them + test('viewer imports only core, layouts, React and structured-context/browser', () => { + const allowed = /^\.\.\/(core|layouts)\/|^\.\/|^(react|react-dom)(\/|$)|^structured-context\/browser$/; + expect(importsIn('viewer').filter(({ specifier }) => !allowed.test(specifier))).toEqual([]); + }); + + test('no module imports the artifact host', () => { + const imports = ['core', 'layouts', 'node', 'viewer'].flatMap(importsIn); + expect(imports.filter(({ specifier }) => /^(\.\.\/)+artifact(\/|$)/.test(specifier))).toEqual([]); + }); }); /** `import type` is erased at build time, so it is browser-safe. */