ui exporting and docgen testing

This commit is contained in:
2026-09-23 18:48:45 -03:00
parent 88adcd68bc
commit b3cf24e750
25 changed files with 4228 additions and 3 deletions

View File

@@ -68,7 +68,7 @@ theme: ## ad-hoc pages [new|parts|bake|check|export|run
# ── ui ───────────────────────────────────────────────────────────────────── # ── ui ─────────────────────────────────────────────────────────────────────
ui: ## slices of the UI [list|scan DIR|slice FILE|check FILE|selftest] ui: ## slices of the UI [list|scan DIR|slice FILE|check FILE|reference|selftest]
bash ctrl/ui.sh $(or $(ARGS),list) bash ctrl/ui.sh $(or $(ARGS),list)
# ── docs ─────────────────────────────────────────────────────────────────── # ── docs ───────────────────────────────────────────────────────────────────

View File

@@ -6,6 +6,7 @@
# ./ctrl/ui.sh scan APP_DIR... # what an app uses; its framework copy against spr # ./ctrl/ui.sh scan APP_DIR... # what an app uses; its framework copy against spr
# ./ctrl/ui.sh slice FILE [--only N] [--list] [--force] # ./ctrl/ui.sh slice FILE [--only N] [--list] [--force]
# ./ctrl/ui.sh check FILE [--only N] # writes nothing; exit 1 if anything is stale # ./ctrl/ui.sh check FILE [--only N] # writes nothing; exit 1 if anything is stale
# ./ctrl/ui.sh reference [--check|--spots|--scaffold] # the ui-reference page of /docs, from common/ui/src
# ./ctrl/ui.sh selftest # ./ctrl/ui.sh selftest
# #
# FILE is a run file (see soleprint/common/slice/ui.example.toml). It lives beside # FILE is a run file (see soleprint/common/slice/ui.example.toml). It lives beside
@@ -27,7 +28,10 @@ ROOT_DIR="$(dirname "$SCRIPT_DIR")"
PYTHON="${PYTHON:-python3}" PYTHON="${PYTHON:-python3}"
args=() # The first word is the command, never a path — `docs` is also a directory at the
# repo root, and resolving it turned the command into /…/docs.
args=("${1:-}")
shift || true
for a in "$@"; do for a in "$@"; do
# A bare word that names an existing path is a path the caller typed. # A bare word that names an existing path is a path the caller typed.
if [[ "$a" != -* && -e "$CALLER_DIR/$a" ]]; then if [[ "$a" != -* && -e "$CALLER_DIR/$a" ]]; then

View File

@@ -0,0 +1,7 @@
{
"files": {
"data/en/ui-reference.md": "d010b68b9599f033a3920ef0590c95d10a895e7d4c5b98e2ba5d822c26a32dd4",
"data/ui-inventory.json": "7b8895661a3023f3198eb17e5c15101537b4915faf1a9b398abaa49cd575edc4"
},
"generator": "common.slice reference"
}

View File

@@ -28,6 +28,11 @@ make ui scan <app-dir> # what it uses, and how its copy compares
See [Export / Compile](#export) for run files, the two deliveries and drift. See [Export / Compile](#export) for run files, the two deliveries and drift.
**Reference:** [UI reference](#ui-reference) — every export's props, models, emits,
slots, closure, packages and tokens, generated from the source by `make reference` in
`soleprint/common/ui` (which also has `make check`: typecheck, tests, reference current,
parts in agreement). Hand-written notes live beside the library in `common/ui/notes/`.
## soleprint-modelgen ## soleprint-modelgen
Model generator. Reads schema definitions and produces model files. Model generator. Reads schema definitions and produces model files.

View File

@@ -0,0 +1,485 @@
# common/ui — reference
> Generated from `common/ui/src` by `make reference` in common/ui (`python3 -m common.slice reference`).
> Do not edit this page: the next build replaces it. Prose goes in `soleprint/common/ui/notes/<spot>.md`,
> and `make reference-spots` lists every spot.
## What this is
A panel framework, not a dashboard: boxes with a title and a status light, splits that
resize, a feed that pushes typed events, and renderers for what arrives. It was built for
high-frequency operational views — a minimal Grafana — and every piece below exists
because an app needed it.
**The contract is the framework; Vue is one binding of it.** The contract is four things:
the token names, the element layer (`base.css`), the class names (`.panel`,
`.panel-header`, `.split-pane`, …) and the status words. Those are not whole yet: a feed
reports `idle`, `connecting`, `live`, `error`, while the panel's dot shows `idle`,
`live`, `processing`, `error` — so `connecting` has no dot, and a consumer has to map it
by hand. That is a gap in the contract, listed below. `common/theme/parts` is the second
binding: the same contract as plain CSS and JS, for pages that must open from a file with
no build. docgen's site is not a binding yet: it bakes its colours from its own style
slots, a third token vocabulary.
## The layers, as use shaped them
| layer | what | the evidence |
| --- | --- | --- |
| contract | tokens, element layer, class names, status words | every consumer reads `tokens.css`; four apps pasted their own `base.css` before it shipped here |
| chrome | `Panel` (+ maximize), `SplitPane`, `ParameterEditor`, `VideoPlayer` | `Panel` and `SplitPane` are the only exports all five consumers use; nested splits, not `LayoutGrid`, turned out to be the layout |
| feed | `DataSource`, `SSEDataSource` | one stream, typed events, a terminal `done` |
| renderers | `LogRenderer`, `TableRenderer`, `FrameRenderer`, `TimeSeriesRenderer`, `GraphRenderer` | opt-in; the only two external packages arrive through these |
| app state | not here | mpr keeps its own Pinia stores; `pinia` is imported nowhere in this library |
| delivery | slices, not this whole folder | see below |
## How to take it
Not as this folder. A consumer takes a **slice**: the closure of the exports it uses,
as readable source, with a barrel exporting only those and a `tokens.css` cut down to
what is read — `make ui scan <app>` computes it, `ctrl/ui.sh slice <run file>` writes it,
and `check` reports drift both ways. The "Contents" table is the price list.
## How this document is made
Everything outside these notes is generated from `src/` by `make reference` (in
`common/ui`), so a prop that is removed disappears from here on the next build. The notes
are the only hand-written part, one file per spot in `soleprint/common/ui/notes/`; a note
whose spot disappears fails `make check`. `make docs` — anywhere in soleprint — launches
this site.
## What came in
`common/ui/src` — **24 file(s) seen, 24 read, 0 failed**; 19 exports and 15 types in the barrel.
## Contents
**Who uses what**, measured 2026-09-22 with `make ui scan <app>` over the five apps that
consume this library. It is the one table here that is not generated — the apps live
outside this repository — so re-run the scan before trusting a row.
| export | mpr detection | mts meetus | mts doocus | unt | nvi |
| --- | --- | --- | --- | --- | --- |
| `Panel`, `SplitPane` | ✓ | ✓ | ✓ | ✓ | ✓ |
| `LogRenderer` | ✓ | | | ✓ | |
| `VideoPlayer` | | ✓ | | | |
| `ParameterEditor`, `ResizeHandle`, all renderers but Log, `SSEDataSource`, `useRegistry`, `useEditorExecution` | ✓ | | | | |
| `LayoutGrid`, `StaticDataSource`, `useDataSource`, `BBoxDrawPlugin`, `CrosshairPlugin` | | | | | |
The last row has **no users at all**. Those exports stay in the library — they are
small and a slice never carries them unless asked — but nothing should be built on them
as if they were proven. `ResizeHandle` is imported by mpr and never rendered: it is what
`SplitPane` replaced.
What each export costs a consumer that takes it: the files it pulls in, the packages it
brings, and whether a plain-HTML part exists for it (`common/theme/parts`).
| export | kind | file | pulls in | packages | size | plain part |
| --- | --- | --- | --- | --- | --- | --- |
| [`DataSource`](#datasource) | datasource | `datasources/DataSource.ts` | — | vue | 1.3 KB | feed |
| [`SSEDataSource`](#ssedatasource) | datasource | `datasources/SSEDataSource.ts` | DataSource.ts | vue | 3.8 KB | feed |
| [`StaticDataSource`](#staticdatasource) | datasource | `datasources/StaticDataSource.ts` | DataSource.ts | vue | 2.4 KB | — |
| [`useDataSource`](#usedatasource) | composable | `composables/useDataSource.ts` | DataSource.ts | vue | 2.0 KB | — |
| [`useRegistry`](#useregistry) | composable | `composables/useRegistry.ts` | — | vue | 1.9 KB | — |
| [`useEditorExecution`](#useeditorexecution) | composable | `composables/useEditorExecution.ts` | — | vue | 1.4 KB | — |
| [`Panel`](#panel) | component | `components/Panel.vue` | — | vue | 4.3 KB | maximize, panel |
| [`LayoutGrid`](#layoutgrid) | component | `components/LayoutGrid.vue` | — | vue | 0.5 KB | — |
| [`ResizeHandle`](#resizehandle) | component | `components/ResizeHandle.vue` | — | vue | 1.3 KB | — |
| [`SplitPane`](#splitpane) | component | `components/SplitPane.vue` | — | vue | 3.7 KB | split |
| [`ParameterEditor`](#parametereditor) | component | `components/ParameterEditor.vue` | — | vue | 3.1 KB | params |
| [`VideoPlayer`](#videoplayer) | component | `components/VideoPlayer.vue` | — | vue | 4.1 KB | — |
| [`LogRenderer`](#logrenderer) | renderer | `renderers/LogRenderer.vue` | — | vue | 3.7 KB | — |
| [`TimeSeriesRenderer`](#timeseriesrenderer) | renderer | `renderers/TimeSeriesRenderer.vue` | — | uplot, vue | 4.3 KB | — |
| [`GraphRenderer`](#graphrenderer) | renderer | `renderers/GraphRenderer.vue` | — | @vue-flow/core, vue | 7.9 KB | — |
| [`FrameRenderer`](#framerenderer) | renderer | `renderers/FrameRenderer.vue` | — | vue | 4.9 KB | — |
| [`TableRenderer`](#tablerenderer) | renderer | `renderers/TableRenderer.vue` | — | vue | 2.4 KB | — |
| [`BBoxDrawPlugin`](#bboxdrawplugin) | plugin | `plugins/BBoxDrawPlugin.ts` | InteractionPlugin.ts | — | 3.0 KB | — |
| [`CrosshairPlugin`](#crosshairplugin) | plugin | `plugins/CrosshairPlugin.ts` | InteractionPlugin.ts | — | 2.5 KB | — |
## Components
### Panel
The one component everything uses — 13 instances in mpr, 7 across mts's two apps.
**Maximize came from mts.** mts's copy grew `v-model:maximized` and `maximizable` while
spr's stood still, and it was brought upstream on 2026-09-22 — the framework spec's own
rule ("a second application finds the holes") working as written. The reason it is the
panel's job and not the video's is in the source: a fullscreen `<video>` paints over the
panel's actions and overlays.
**`#actions` is where panel buttons go.** mts fills it in 6 of 7 panels; mpr fills it in
none and absolutely-positions buttons inside the body instead. The slot is the answer.
**`#overlay` is filled by no consumer**; it stays for the interaction plugins, which have
no consumer either.
`components/Panel.vue` · 4.3 KB with its closure · packages: `vue` ^3.5 · plain part: `maximize`, `panel`
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `title` | `string` | yes | — | — |
| `status` | `'idle' \| 'live' \| 'processing' \| 'error'` | no | — | Omit to show no indicator at all — see the note on `.panel-status`. |
| `maximizable` | `boolean` | no | `true` | Set false for panels that make no sense filling the window. |
| v-model | type | default |
| --- | --- | --- |
| `v-model:maximized` | `boolean` | `false` |
slots: `#actions`, `#default`, `#overlay`
reads: `--font-size-sm` `--font-ui` `--panel-border` `--panel-header-height` `--panel-radius` `--space-1` `--space-2` `--space-3` `--status-error` `--status-idle` `--status-live` `--status-processing` `--surface-1` `--surface-2` `--text-primary` `--text-secondary`
### LayoutGrid
`components/LayoutGrid.vue` · 0.5 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `columns` | `number` | no | `2` | — |
| `rows` | `number` | no | `2` | — |
| `gap` | `string` | no | `'var(--space-2)'` | — |
slots: `#default`
### ResizeHandle
`components/ResizeHandle.vue` · 1.3 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `direction` | `'horizontal' \| 'vertical'` | yes | — | — |
| emits | payload | |
| --- | --- | --- |
| `resize` | `[delta: number]` | — |
reads: `--text-dim`
### SplitPane
The real layout primitive. Every app lays itself out with nested splits — mpr three
deep — and none uses `LayoutGrid`. `sizeMode="ratio"` is the common case (all of mts);
`"px"` pins a sidebar (mpr). `resizable: false` has never been passed by anyone: a split
that cannot move is a flex row.
`components/SplitPane.vue` · 3.7 KB with its closure · packages: `vue` ^3.5 · plain part: `split`
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `direction` | `'horizontal' \| 'vertical'` | no | `'horizontal'` | Split direction |
| `initialSize` | `number` | no | `1` | Initial size of the sized pane (px or flex ratio) |
| `sizeMode` | `'px' \| 'ratio'` | no | `'ratio'` | Size mode: 'px' = sized pane fixed in pixels, 'ratio' = flex ratio |
| `anchor` | `'first' \| 'second'` | no | `'first'` | Which pane is sized: 'first' or 'second'. Default: 'first'. |
| `min` | `number` | no | `0.1` | Min size (px in px-mode, ratio in ratio-mode) |
| `max` | `number` | no | `10` | Max size (px in px-mode, ratio in ratio-mode) |
| `resizable` | `boolean` | no | `true` | Whether the divider is draggable |
slots: `#first`, `#second`
reads: `--text-dim`
### ParameterEditor
Schema-driven: the same field list a pipeline stage declares renders its sliders. Two
gaps, both fixed in the plain binding (`params` part) and not yet here: a field with
`options` (an enum) renders **nothing**, because only `int`/`float` and `bool` are
filtered for; and the `reset` event is declared but never emitted — mpr wires a handler
to it that cannot fire.
`components/ParameterEditor.vue` · 3.1 KB with its closure · packages: `vue` ^3.5 · plain part: `params`
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `fields` | `ConfigField[]` | yes | — | — |
| `values` | `Record<string, unknown>` | yes | — | — |
| emits | payload | |
| --- | --- | --- |
| `update` | `[name: string, value: unknown]` | — |
| `reset` | `[]` | — |
reads: `--space-2` `--surface-3` `--text-dim` `--text-primary` `--text-secondary`
### VideoPlayer
Agnostic seekable media player.
mts's, and only mts uses it. It came upstream with its `#overlay` slot on 2026-09-22.
It deliberately does no fullscreen of its own: put it in a `Panel` and maximize that.
`components/VideoPlayer.vue` · 4.1 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `src` | `string` | yes | — | Media source URL |
| `currentTime` | `number` | no | `0` | Playback position in seconds (v-model:currentTime) |
| `playing` | `boolean` | no | `false` | Whether the media is playing (v-model:playing) |
| emits | payload | |
| --- | --- | --- |
| `update:currentTime` | `[value: number]` | — |
| `update:playing` | `[value: boolean]` | — |
| `duration` | `[value: number]` | Media duration once metadata has loaded |
| `ended` | `[]` | Playback reached the end |
slots: `#overlay`
reads: `--surface-0`
## Renderers
### LogRenderer
Virtual scroll, and the only one in any of the apps: rows outside the viewport are not
rendered, and auto-scroll pauses while the reader scrolls up. mts's long lists and mpr's
React side render every row instead. `rowHeight` and `autoScroll` have never been passed
by a consumer; the defaults are what is proven.
`renderers/LogRenderer.vue` · 3.7 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `entries` | `LogEntry[]` | yes | — | — |
| `rowHeight` | `number` | no | `24` | — |
| `autoScroll` | `boolean` | no | `true` | — |
reads: `--font-mono` `--space-2` `--space-6` `--status-error` `--status-escalating` `--status-live` `--status-processing` `--text-dim`
### TimeSeriesRenderer
One of the only two exports that bring a package (`uplot`, with its stylesheet), and the
reason a chart cannot be a plain part: baking uplot into every standalone page is the
bundle this library refuses to be. A page that needs a real chart is a served page.
`renderers/TimeSeriesRenderer.vue` · 4.3 KB with its closure · packages: `uplot` ^1.6, `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `series` | `TimeSeriesSeries[]` | yes | — | Array of series configs (label + color) |
| `data` | `uPlot.AlignedData` | yes | — | Data: [timestamps[], series1[], series2[], ...] |
| `title` | `string` | no | — | Chart title (optional) |
| `stacked` | `boolean` | no | `false` | Stacked area mode |
reads: `--font-mono` `--surface-2` `--surface-3` `--text-primary` `--text-secondary`
### GraphRenderer
`renderers/GraphRenderer.vue` · 7.9 KB with its closure · packages: `@vue-flow/core` ^1.48.2, `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `nodes` | `GraphNode[]` | yes | — | — |
| `mode` | `GraphMode` | no | `'observe'` | Interaction mode — changes visual treatment and click behavior |
| `activeStage` | `string \| null` | no | `null` | Currently edited stage (highlighted in edit modes) |
| `regionStages` | `string[]` | no | — | Stages that have a region editor (bbox/polygon) |
| emits | payload | |
| --- | --- | --- |
| `open-region-editor` | `[stage: string]` | — |
| `open-stage-editor` | `[stage: string]` | — |
| `node-click` | `[stage: string]` | — |
reads: `--font-mono` `--font-size-sm` `--panel-radius` `--status-processing` `--text-secondary`
### FrameRenderer
**Known bug:** box colours by confidence are returned as `'var(--conf-high)'` strings and
handed to a canvas `strokeStyle`. Canvas does not resolve custom properties, so those
boxes draw in the context's previous colour. That is why the "Tokens" table shows the
`--conf-*` tokens as read by nobody: in effect, they are not.
`renderers/FrameRenderer.vue` · 4.9 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `imageSrc` | `string` | yes | — | Base64 JPEG image |
| `boxes` | `FrameBBox[]` | yes | — | Bounding boxes to overlay |
| `overlays` | `FrameOverlay[]` | no | — | Debug overlay layers (edge images, line visualizations, etc.) |
reads: `--text-dim`
### TableRenderer
`renderers/TableRenderer.vue` · 2.4 KB with its closure · packages: `vue` ^3.5
| prop | type | required | default | |
| --- | --- | --- | --- | --- |
| `columns` | `TableColumn[]` | yes | — | — |
| `rows` | `Record<string, unknown>[]` | yes | — | — |
| `sortKey` | `string` | no | — | — |
| `sortDir` | `'asc' \| 'desc'` | no | — | — |
| emits | payload | |
| --- | --- | --- |
| `sort` | `[key: string]` | — |
reads: `--font-mono` `--font-size-sm` `--panel-border` `--space-1` `--space-2` `--space-3` `--space-6` `--surface-2` `--surface-3` `--text-dim` `--text-primary` `--text-secondary`
## Datasources
### DataSource
`datasources/DataSource.ts` · 1.3 KB with its closure · packages: `vue` ^3.5 · plain part: `feed`
Base class for all data sources. A DataSource connects to some event stream, exposes reactive state, and lets consumers subscribe to typed events. Panels read from these reactively — they never touch the transport layer directly.
### SSEDataSource
One stream, many typed events: panels subscribe with `on(type, handler)` to the event
types they draw, and the stream ends with a terminal `done`. `status` uses the same four
words as a panel's dot, so wiring one to the other needs no mapping.
Two things the code does not do, both learned from mpr. **It does not throttle**: at a few
hundred events a second, a handler that writes to the DOM per event stalls — the plain
binding's `feed` part adds `onFrame()` (one call per animation frame, newest payload) for
exactly this, and a Vue consumer has to coalesce the same way. **Nobody unsubscribes**:
`on()` returns an unsubscribe function and every call site in mpr drops it, which only
works because its panels live as long as the page.
`datasources/SSEDataSource.ts` · 3.8 KB with its closure · packages: `vue` ^3.5 · plain part: `feed`
DataSource backed by native EventSource (Server-Sent Events). Connects to a single SSE endpoint and demultiplexes events by type. Multiple panels can subscribe to different event types from the same source.
### StaticDataSource
`datasources/StaticDataSource.ts` · 2.4 KB with its closure · packages: `vue` ^3.5
DataSource that replays a fixture array of events. Used for development and testing without a running backend. Events are emitted in sequence with optional delays.
## Composables
### useDataSource
Composable that connects a component to a DataSource.
`composables/useDataSource.ts` · 2.0 KB with its closure · packages: `vue` ^3.5
Composable that connects a component to a DataSource. Connects on mount, disconnects on unmount. Returns reactive refs for data, status, and error.
### useRegistry
Generic registry composable — fetches typed data from a URL, caches it, exposes it reactively.
`composables/useRegistry.ts` · 1.9 KB with its closure · packages: `vue` ^3.5
### useEditorExecution
`composables/useEditorExecution.ts` · 1.4 KB with its closure · packages: `vue` ^3.5
Generic editor execution pattern — debounced apply with auto-apply toggle, loading/error/timing state tracking. The caller provides the actual execution function. This composable handles the orchestration: debounce, auto-apply, loading state, timing.
## Plugins
### BBoxDrawPlugin
BBoxDrawPlugin — draw bounding boxes on the frame viewer.
`plugins/BBoxDrawPlugin.ts` · 3.0 KB with its closure · packages: none
### CrosshairPlugin
CrosshairPlugin — synchronized vertical crosshair across time-series panels.
`plugins/CrosshairPlugin.ts` · 2.5 KB with its closure · packages: none
## Types
Types ride with the file that declares them: taking the export takes its types.
| type | declared in | |
| --- | --- | --- |
| `DataSourceStatus` | `datasources/DataSource.ts` | type |
| `EditorExecutionOptions` | `composables/useEditorExecution.ts` | interface |
| `ConfigField` | `components/ParameterEditor.vue` | interface |
| `FrameBBox` | `renderers/FrameRenderer.vue` | interface |
| `FrameOverlay` | `renderers/FrameRenderer.vue` | interface |
| `LogEntry` | `renderers/LogRenderer.vue` | interface |
| `GraphNode` | `renderers/GraphRenderer.vue` | interface |
| `GraphMode` | `renderers/GraphRenderer.vue` | type |
| `TableColumn` | `renderers/TableRenderer.vue` | interface |
| `TimeSeriesSeries` | `renderers/TimeSeriesRenderer.vue` | interface |
| `InteractionPlugin` | `plugins/InteractionPlugin.ts` | interface |
| `PluginContext` | `plugins/InteractionPlugin.ts` | interface |
| `BBoxResult` | `plugins/BBoxDrawPlugin.ts` | interface |
| `BBoxCallback` | `plugins/BBoxDrawPlugin.ts` | type |
| `CrosshairCallback` | `plugins/CrosshairPlugin.ts` | type |
## Tokens
**There are two definitions, and the ⚠ rows are where they disagree.** `src/tokens.css`
is the library's own, dark-only; the theme contract in `common/theme` derives the same
names from its own palette. They agree on every colour but `--surface-3` and on neither
font stack. A Vue slice uses the library's values by default so swapping a consumer's
copy for a slice changes no pixel; `theme = "soleprint"` opts into the contract, and
that is a visible change. Retiring the library's file in favour of the contract is the
open question — it changes what every current consumer renders.
"read by nobody" means no stylesheet in the library reads it. Apps read tokens directly
too — `make ui scan` reports between 14 and 23 per app — and a slice carries those.
Every custom property the library defines or reads. `library` is `src/tokens.css`, what
Vue consumers render today; `theme` is the contract plain pages use
(`common/theme/tokens.css` + `themes/soleprint.css`). A row where the two differ is a
visible change for a consumer that switches.
| token | library | theme | read by |
| --- | --- | --- | --- |
| `--border` | `#2e2e38` | `#2e2e38` | tokens.css |
| `--conf-high` | `#3ecf8e` | `#3ecf8e` | *nobody* |
| `--conf-low` | `#f06565` | `#f06565` | *nobody* |
| `--conf-mid` | `#f5a623` | `#f5a623` | *nobody* |
| `--font-mono` ⚠ | `'JetBrains Mono', 'Fira Code', monospace` | `"JetBrains Mono", "Cascadia Mono", Consolas, monospace` | GraphRenderer.vue, LogRenderer.vue, TableRenderer.vue, TimeSeriesRenderer.vue |
| `--font-size-base` | `13px` | `13px` | base.css |
| `--font-size-lg` | `15px` | `15px` | *nobody* |
| `--font-size-sm` | `11px` | `11px` | GraphRenderer.vue, Panel.vue, TableRenderer.vue |
| `--font-ui` ⚠ | `'Inter', system-ui, sans-serif` | `Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif` | Panel.vue, base.css |
| `--panel-border` | `1px solid #2e2e38` | `1px solid #2e2e38` | Panel.vue, TableRenderer.vue, base.css |
| `--panel-header-height` | `36px` | `36px` | Panel.vue |
| `--panel-radius` | `6px` | `6px` | GraphRenderer.vue, Panel.vue, base.css |
| `--space-1` | `4px` | `4px` | Panel.vue, TableRenderer.vue, base.css |
| `--space-2` | `8px` | `8px` | LogRenderer.vue, Panel.vue, ParameterEditor.vue, TableRenderer.vue |
| `--space-3` | `12px` | `12px` | Panel.vue, TableRenderer.vue, base.css |
| `--space-4` | `16px` | `16px` | *nobody* |
| `--space-6` | `24px` | `24px` | LogRenderer.vue, TableRenderer.vue |
| `--space-8` | `32px` | `32px` | *nobody* |
| `--status-error` | `#f06565` | `#f06565` | LogRenderer.vue, Panel.vue |
| `--status-escalating` | `#f5a623` | `#f5a623` | LogRenderer.vue, tokens.css |
| `--status-idle` | `#555568` | `#555568` | Panel.vue |
| `--status-live` | `#3ecf8e` | `#3ecf8e` | LogRenderer.vue, Panel.vue, tokens.css |
| `--status-processing` | `#4f9cf9` | `#4f9cf9` | GraphRenderer.vue, LogRenderer.vue, Panel.vue, tokens.css |
| `--surface-0` | `#0d0d0f` | `#0d0d0f` | VideoPlayer.vue, base.css, theme.css |
| `--surface-1` | `#16161a` | `#16161a` | Panel.vue |
| `--surface-2` | `#1e1e24` | `#1e1e24` | Panel.vue, TableRenderer.vue, TimeSeriesRenderer.vue, base.css |
| `--surface-3` ⚠ | `#26262f` | `#2e2e38` | ParameterEditor.vue, TableRenderer.vue, TimeSeriesRenderer.vue, base.css |
| `--text-dim` | `#555568` | `#555568` | FrameRenderer.vue, LogRenderer.vue, ParameterEditor.vue, ResizeHandle.vue, SplitPane.vue, TableRenderer.vue |
| `--text-primary` | `#e8e8f0` | `#e8e8f0` | Panel.vue, ParameterEditor.vue, TableRenderer.vue, TimeSeriesRenderer.vue, base.css |
| `--text-secondary` | `#8888a0` | `#8888a0` | GraphRenderer.vue, Panel.vue, ParameterEditor.vue, TableRenderer.vue, TimeSeriesRenderer.vue |
## What came out
nodes: 1 barrel · 6 component · 3 composable · 3 datasource · 19 export · 3 package · 6 part · 3 plugin · 5 renderer · 3 stylesheet · 30 token · 15 type
edges: 7 binds · 28 imports · 70 reads · 17 requires
- ok 24 file(s) read produced 24 file node(s)
- ok 19 export(s) and 15 type(s) in the barrel, each with a node
- ok 11 of 11 component(s) have their surface read
- ok every note lands on a spot
## Where it stands
- **Done:** the reference is generated and checked; slices are cut per consumer with a
two-way drift check; mts's additions (maximize, the video overlay) are upstream.
- **Behind:** the copies in mpr, unt and nvi date from 2026-04 and lack maximize,
`VideoPlayer`, `base.css` and `theme.css`. Their `.spr` stamp names a commit that no
longer exists. Replacing each with a slice is the next step for each.
- **Open:** one token definition instead of two (three, counting docgen's style slots);
the `connecting` status the panel cannot show; `--accent`, which nvi and doocus read and
nothing defines; the `FrameRenderer` colour bug; `ParameterEditor`'s enum fields.
## Rebuilding this
`make reference` in `common/ui`. `make check` fails when this page is stale, when a
generated file was edited by hand, or when a note no longer has its spot. Extraction reads the
source with patterns, not a TypeScript parser; `common/slice/selftest.py` pins what the
patterns must find, so one that stops matching fails the suite rather than thinning this
page.

View File

@@ -167,6 +167,13 @@
"en": "Shared Components" "en": "Shared Components"
} }
}, },
{
"id": "ui-reference",
"title": {
"en": "↳ UI reference"
},
"sub": true
},
{ {
"id": "export", "id": "export",
"title": { "title": {

2701
docs/data/ui-inventory.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ The command line — every command slices have, and nothing else.
slice write every slice a run file lists (only what is stale) slice write every slice a run file lists (only what is stale)
check the same, writing nothing: exit 1 if anything is stale check the same, writing nothing: exit 1 if anything is stale
plexus build one self-contained page from a plexus (make dist) plexus build one self-contained page from a plexus (make dist)
reference the UI reference page of spr's /docs, generated from common/ui/src
selftest this package's own suite selftest this package's own suite
Library modules beside this package hold no command-line code; selftest asserts Library modules beside this package hold no command-line code; selftest asserts
@@ -25,6 +26,7 @@ COMMANDS = {
"slice": ("slice", "write every slice a run file lists"), "slice": ("slice", "write every slice a run file lists"),
"check": ("slice", "the same, writing nothing — exit 1 if stale"), "check": ("slice", "the same, writing nothing — exit 1 if stale"),
"plexus": ("plexus", "build a plexus: one self-contained page"), "plexus": ("plexus", "build a plexus: one self-contained page"),
"reference": ("reference", "the UI reference page of spr's docs, from common/ui/src"),
"selftest": ("selftest", "this package's own suite"), "selftest": ("selftest", "this package's own suite"),
} }

View File

@@ -0,0 +1,63 @@
"""
python3 -m common.slice reference the ui-reference page of spr's /docs, from common/ui/src
python3 -m common.slice reference --check write nothing; exit 1 if stale, edited or unreconciled
python3 -m common.slice reference --spots every spot a note can go, one per line
python3 -m common.slice reference --scaffold an empty stub for every spot without a note
python3 -m common.slice reference --force overwrite a generated file that was edited by hand
The page is generated; the notes in common/ui/notes are not. See reference.py for
why the two are kept apart, and for the measure each build ends with. To read it,
`make docs` — the one command that launches spr's docs site.
"""
import argparse
from .. import reference as docs
from .common import PROG, Abort
def main(argv, command=None) -> int:
p = argparse.ArgumentParser(prog=f"{PROG} reference")
p.add_argument("--check", action="store_true", help="Write nothing; exit 1 if anything is off.")
p.add_argument("--spots", action="store_true", help="List every spot a note can go.")
p.add_argument("--scaffold", action="store_true", help="Write a stub note for every empty spot.")
p.add_argument("--force", action="store_true", help="Overwrite a hand-edited generated file.")
args = p.parse_args(argv)
rel = docs.SITE.relative_to(docs.graph.COMMON.parent.parent)
notes_rel = docs.NOTES.relative_to(docs.graph.COMMON.parent.parent)
if args.spots or args.scaffold:
inv = docs.inventory()
if args.spots:
have = docs.load_notes()
for spot in docs.spots(inv):
mark = "written" if have.get(spot) else "stub" if spot in have else "-"
print(f"{spot:<22} {mark:<8} {notes_rel}/{spot}.md")
if args.scaffold:
made = docs.scaffold(inv)
print(f"{len(made)} stub(s) written" + (f": {', '.join(made)}" if made else ""))
return 0
res = docs.build(write=not args.check, force=args.force)
for name in res.edited:
print(f" EDITED {rel}/{name} — changed by hand since the last build"
+ ("; overwritten (--force)" if args.force and not args.check else "; left alone"))
for name in (res.written if not args.check else res.stale):
print(f" {'wrote ' if not args.check else 'STALE '} {rel}/{name}")
for name in res.unchanged:
print(f" ok {rel}/{name}")
for name in res.removed:
print(f" {'removed' if not args.check else 'GONE '} {rel}/{name} — no longer generated")
print()
for ok, msg in res.reconciled:
print(f" {'ok ' if ok else 'FAIL'} {msg}")
if res.edited and not args.force:
raise Abort(f"{len(res.edited)} generated file(s) edited by hand — move the text into "
f"{notes_rel}/, or rerun with --force to discard it")
if args.check and not res.ok:
raise Abort(f"the ui reference is not current — run: make reference (in common/ui)")
if not all(ok for ok, _ in res.reconciled):
raise Abort("the reference does not reconcile with what was read")
return 0

View File

@@ -0,0 +1,454 @@
"""
The UI framework's reference, generated from its source into spr's docs site.
python3 -m common.slice reference (from soleprint/; or make reference in common/ui)
make docs serves it, with the rest of spr's docs
It is a page of the `/docs` minisite (`docs/data/en/ui-reference.md`), because in
soleprint `make docs` always means one thing: launch that site. A component does
not grow a second documentation tree of its own.
The same bargain docgen makes, done here without docgen — docgen is a copy-out
tool with its own release cycle, and a library's reference should not wait on it:
**The source is read, never described.** Every prop, model, emit, slot, file,
package and token in the reference comes from graph.py and surface.py reading
common/ui/src. A prop that is removed leaves the reference on the next build.
**One format in the middle.** `inventory.json` is the only thing extraction
produces; the reference is rendered from it and nothing else. It has docgen's
three keys (`meta`, `nodes`, `edges`), stable ids, a closed `kind` vocabulary per
node, and no visual information at all — so the web view that comes later, or
docgen itself, reads the same file instead of re-reading the source.
**Both ends are measured.** The larder says what came in — files seen, read, and
failed **by name**; the book says what came out, and is reconciled against it.
"33 files in, 34 exports documented, nothing lost" is a fact. A clean reference
over an incomplete read is a lie by omission.
**Generated base, hand-written notes.** Prose only a person can write — why a
thing exists, what replaced what, who actually uses it — lives in
`common/ui/notes/<spot>.md`, beside the library, and is placed above its spot on every build. A note whose
spot is gone is reported and fails the check; it is never dropped.
**A rebuild replaces exactly what the last one wrote.** `docs/.generated.json`
records every generated file and its hash. A generated file edited by hand is
refused, not overwritten, until `--force` — the same rule a slice's dest keeps.
"""
import hashlib
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from ..theme import bake
from . import graph, surface, tokens
SITE = graph.COMMON.parent.parent / "docs" # spr's /docs minisite
PAGE = "data/en/ui-reference.md" # its id in topics.json: ui-reference
INVENTORY = "data/ui-inventory.json"
MANIFEST = "data/.ui-reference.generated.json"
NOTES = graph.COMMON / "ui" / "notes"
SCHEMA_VERSION = "1"
# The groups the library is laid out in, and what a file in each one is. A file
# at the top of src/ is the library's own plumbing: barrel and stylesheets.
KINDS = {
"components": "component",
"renderers": "renderer",
"datasources": "datasource",
"composables": "composable",
"plugins": "plugin",
}
ORDER = list(KINDS) + [""]
STUB = "<!-- note: {spot} — write below this line, or replace it; a stub renders nothing -->\n"
SPOTS_FIXED = ("_intro", "larder", "contents", "types", "tokens", "book", "_end")
# ── extraction → inventory ────────────────────────────────────────────────────
def _group(rel: str) -> str:
return rel.split("/", 1)[0] if "/" in rel else ""
def _kind(rel: str) -> str:
if "/" not in rel:
return "stylesheet" if rel.endswith(".css") else "barrel"
return KINDS.get(_group(rel), "module")
def _bindings(nodes: dict) -> dict[str, list[str]]:
"""Library file -> the plain parts derived from it, read from each part's header.
A part says where it came from in its own header ("Derived from
common/ui/src/components/Panel.vue"), so the link is kept by the part,
not by a table here that would go stale.
"""
by_base = {}
for rel in nodes:
by_base.setdefault(rel.rsplit("/", 1)[-1], []).append(rel)
out: dict[str, set] = {}
for name in bake.load_parts():
text = ""
for ext in ("css", "js"):
p = bake.PARTS / f"{name}.{ext}"
if p.exists():
t = p.read_text()
text += t[: t.find("*/")] if "*/" in t else ""
found = set(re.findall(r"common/ui/src/([\w./-]+\.(?:vue|ts|css))", text))
m = re.search(r"Derived from ([^\n]*(?:\n \*[^\n]*){0,2})", text)
if m:
for base in re.findall(r"\b([A-Za-z]\w*\.(?:vue|ts|css))\b", m.group(1)):
if len(by_base.get(base, [])) == 1:
found.add(by_base[base][0])
for rel in found:
if rel in nodes:
out.setdefault(rel, set()).add(name)
return {k: sorted(v) for k, v in out.items()}
def inventory(src: Path = graph.SRC) -> dict:
"""The one document everything else is rendered from."""
nodes = graph.load(src)
exports = graph.barrel(src, nodes)
surfaces = surface.load(src)
bindings = _bindings(nodes)
library = tokens.source(None)
theme = tokens.source("soleprint")
seen = sorted(p.relative_to(src).as_posix() for p in src.rglob("*")
if p.is_file() and not graph.SKIP & set(p.relative_to(src).parts))
failed = [{"name": rel, "error": f"no reader for {Path(rel).suffix or 'a file with no suffix'}"}
for rel in seen if rel not in nodes]
failed += [{"name": s.file, "error": s.error} for s in surfaces.values() if s.error]
out_nodes, edges = [], []
for rel, n in sorted(nodes.items()):
s = surfaces.get(rel)
attrs = {"path": rel, "bytes": n.size, "group": _group(rel)}
if s and s.summary:
attrs["summary"] = s.summary
if s and rel.endswith(".vue"):
attrs["props"] = [vars(p) for p in s.props]
attrs["models"] = [vars(m) for m in s.models]
attrs["emits"] = [vars(e) for e in s.emits]
attrs["slots"] = list(s.slots)
if s and s.declarations:
attrs["declarations"] = [{"kind": k, "name": nm, "doc": d} for k, nm, d in s.declarations]
if s and s.error:
attrs["error"] = s.error
out_nodes.append({"id": f"file:{rel}", "kind": _kind(rel), "label": rel.rsplit("/", 1)[-1],
"parent": None, "attrs": attrs})
for target in sorted(n.imports):
edges.append({"source": f"file:{rel}", "target": f"file:{target}", "kind": "imports", "attrs": {}})
for pkg in sorted({graph.package_of(p) for p in n.packages}):
edges.append({"source": f"file:{rel}", "target": f"package:{pkg}", "kind": "requires", "attrs": {}})
for tok in sorted(n.tokens):
edges.append({"source": f"file:{rel}", "target": f"token:{tok}", "kind": "reads", "attrs": {}})
for e in exports:
closure, _ = graph.select([e.name], [], exports, nodes)
out_nodes.append({"id": f"export:{e.name}", "kind": "type" if e.is_type else "export",
"label": e.name, "parent": f"file:{e.file}",
"attrs": {"file": e.file, "local": e.local,
"closure": sorted(closure),
"packages": graph.dependencies(closure, nodes),
"tokens": sorted(graph.tokens_read(closure, nodes)),
"bytes": sum(nodes[f].size for f in closure)}})
for pkg, version in sorted(graph.dependencies(set(nodes), nodes).items()):
out_nodes.append({"id": f"package:{pkg}", "kind": "package", "label": pkg,
"parent": None, "attrs": {"version": version}})
names = set(library.table) | set().union(*(n.tokens for n in nodes.values()))
for name in sorted(names):
lib, thm = tokens.resolved(library, name), tokens.resolved(theme, name)
out_nodes.append({"id": f"token:{name}", "kind": "token", "label": name, "parent": None,
"attrs": {"library": lib, "theme": thm}})
for rel, parts in sorted(bindings.items()):
for part in parts:
if not any(n["id"] == f"part:{part}" for n in out_nodes):
out_nodes.append({"id": f"part:{part}", "kind": "part", "label": part, "parent": None,
"attrs": {"path": f"common/theme/parts/{part}"}})
edges.append({"source": f"part:{part}", "target": f"file:{rel}", "kind": "binds", "attrs": {}})
larder = {"kind": "vue-library", "identity": "common/ui/src", "unit": "file",
"seen": len(seen), "failed": failed,
"extra": {"exports": sum(1 for e in exports if not e.is_type),
"types": sum(1 for e in exports if e.is_type)}}
return {"meta": {"source": "common/ui/src", "schema_version": SCHEMA_VERSION,
"generator": "common.slice docs", "larder": larder},
"nodes": out_nodes, "edges": edges}
# ── the book measure: what came out, reconciled against what came in ──────────
def reconcile(inv: dict, notes: dict[str, str]) -> list[tuple[bool, str]]:
larder = inv["meta"]["larder"]
read = larder["seen"] - len([f for f in larder["failed"] if f["error"].startswith("no reader")])
files = [n for n in inv["nodes"] if n["id"].startswith("file:")]
values = [n for n in inv["nodes"] if n["kind"] == "export"]
types = [n for n in inv["nodes"] if n["kind"] == "type"]
vue = [n for n in files if n["attrs"]["path"].endswith(".vue")]
unparsed = [n["attrs"]["path"] for n in vue if "error" in n["attrs"]]
orphans = sorted(set(notes) - set(spots(inv)))
return [
(read == len(files), f"{read} file(s) read produced {len(files)} file node(s)"),
(len(values) == larder["extra"]["exports"] and len(types) == larder["extra"]["types"],
f"{len(values)} export(s) and {len(types)} type(s) in the barrel, each with a node"),
(not unparsed, f"{len(vue) - len(unparsed)} of {len(vue)} component(s) have their surface read"
+ (f" — not: {', '.join(unparsed)}" if unparsed else "")),
(not orphans, "every note lands on a spot" if not orphans
else f"{len(orphans)} note(s) with no spot: {', '.join(orphans)}"),
]
# ── notes ─────────────────────────────────────────────────────────────────────
def spots(inv: dict) -> list[str]:
"""Every place a note can go. Exports by name; the fixed sections by role."""
named = [n["label"] for n in inv["nodes"] if n["kind"] == "export"]
return list(SPOTS_FIXED[:3]) + named + list(SPOTS_FIXED[3:])
def load_notes(notes_dir: Path = NOTES) -> dict[str, str]:
"""spot -> the note's text. A stub (only the scaffold comment) counts as empty."""
out = {}
if notes_dir.is_dir():
for p in sorted(notes_dir.glob("*.md")):
body = re.sub(r"<!--.*?-->", "", p.read_text(), flags=re.S).strip()
out[p.stem] = body
return out
def scaffold(inv: dict, notes_dir: Path = NOTES) -> list[str]:
"""Write an empty stub for every spot that has no note. Never overwrites."""
notes_dir.mkdir(parents=True, exist_ok=True)
made = []
for spot in spots(inv):
p = notes_dir / f"{spot}.md"
if not p.exists():
p.write_text(STUB.format(spot=spot))
made.append(spot)
return made
# ── the reference ─────────────────────────────────────────────────────────────
def _cell(text) -> str:
return str(text).replace("|", "\\|").replace("\n", " ") if text not in (None, "") else "—"
def _code(text) -> str:
return f"`{_cell(text)}`" if text not in (None, "") else "—"
def render(inv: dict, notes: dict[str, str]) -> str:
by_id = {n["id"]: n for n in inv["nodes"]}
out_edges: dict[str, list] = {}
for e in inv["edges"]:
out_edges.setdefault(e["source"], []).append(e)
binds: dict[str, list] = {}
for e in inv["edges"]:
if e["kind"] == "binds":
binds.setdefault(e["target"], []).append(by_id[e["source"]]["label"])
larder = inv["meta"]["larder"]
read = larder["seen"] - len([f for f in larder["failed"] if f["error"].startswith("no reader")])
L = []
def note(spot):
if notes.get(spot):
L.extend([notes[spot], ""])
L += ["# common/ui — reference", "",
"> Generated from `common/ui/src` by `make reference` in common/ui "
"(`python3 -m common.slice reference`).",
"> Do not edit this page: the next build replaces it. Prose goes in "
"`soleprint/common/ui/notes/<spot>.md`,",
"> and `make reference-spots` lists every spot.", ""]
note("_intro")
L += ["## What came in", ""]
note("larder")
L += [f"`{larder['identity']}` — **{larder['seen']} file(s) seen, {read} read, "
f"{len(larder['failed'])} failed**; {larder['extra']['exports']} exports and "
f"{larder['extra']['types']} types in the barrel.", ""]
if larder["failed"]:
L += ["| file | why it is not (fully) read |", "| --- | --- |"]
L += [f"| `{f['name']}` | {_cell(f['error'])} |" for f in larder["failed"]]
L.append("")
exports = [n for n in inv["nodes"] if n["kind"] == "export"]
L += ["## Contents", ""]
note("contents")
L += ["What each export costs a consumer that takes it: the files it pulls in, the packages it",
"brings, and whether a plain-HTML part exists for it (`common/theme/parts`).", "",
"| export | kind | file | pulls in | packages | size | plain part |",
"| --- | --- | --- | --- | --- | --- | --- |"]
for n in exports:
a = n["attrs"]
f = by_id[f"file:{a['file']}"]
pulls = ", ".join(p.rsplit("/", 1)[-1] for p in a["closure"] if p != a["file"]) or "—"
L.append(f"| [`{n['label']}`](#{n['label'].lower()}) | {f['kind']} | `{a['file']}` | {pulls} | "
f"{', '.join(a['packages']) or '—'} | {a['bytes'] / 1024:.1f} KB | "
f"{', '.join(binds.get(f['id'], [])) or '—'} |")
L.append("")
for group in ORDER:
members = [n for n in exports if _group(n["attrs"]["file"]) == group]
if not members:
continue
L += [f"## {group.capitalize() if group else 'Library'}", ""]
for n in members:
a = n["attrs"]
f = by_id[f"file:{a['file']}"]
fa = f["attrs"]
L += [f"### {n['label']}", ""]
if fa.get("summary"):
L += [fa["summary"], ""]
note(n["label"])
meta = [f"`{a['file']}` · {a['bytes'] / 1024:.1f} KB with its closure",
f"packages: {', '.join(f'`{p}` {v}' for p, v in a['packages'].items()) or 'none'}"]
if binds.get(f["id"]):
meta.append("plain part: " + ", ".join(f"`{p}`" for p in binds[f["id"]]))
L += [" · ".join(meta), ""]
if fa.get("error"):
L += [f"**Not fully read:** {fa['error']}", ""]
if fa.get("props"):
L += ["| prop | type | required | default | |", "| --- | --- | --- | --- | --- |"]
L += [f"| `{p['name']}` | {_code(p['type'])} | {'yes' if p['required'] else 'no'} | "
f"{_code(p['default'])} | {_cell(p['doc'])} |" for p in fa["props"]]
L.append("")
if fa.get("models"):
L += ["| v-model | type | default |", "| --- | --- | --- |"]
L += [f"| `v-model:{m['name']}` | {_code(m['type'])} | {_code(m['default'])} |"
for m in fa["models"]]
L.append("")
if fa.get("emits"):
L += ["| emits | payload | |", "| --- | --- | --- |"]
L += [f"| `{e['name']}` | {_code(e['type'])} | {_cell(e['doc'])} |" for e in fa["emits"]]
L.append("")
if fa.get("slots"):
L += ["slots: " + ", ".join(f"`#{s}`" for s in fa["slots"]), ""]
# A .ts export's own doc comment (a class or function's) is its
# description; an SFC's is the file summary, already printed above.
for d in (d for d in fa.get("declarations", []) if d["name"] == a["local"]):
if d["doc"] and d["doc"] != fa.get("summary"):
L += [d["doc"], ""]
if a["tokens"]:
L += ["reads: " + " ".join(f"`{t}`" for t in a["tokens"]), ""]
types = [n for n in inv["nodes"] if n["kind"] == "type"]
L += ["## Types", ""]
note("types")
L += ["Types ride with the file that declares them: taking the export takes its types.", "",
"| type | declared in | |", "| --- | --- | --- |"]
for n in types:
f = by_id[f"file:{n['attrs']['file']}"]
decl = next((d for d in f["attrs"].get("declarations", []) if d["name"] == n["attrs"]["local"]), None)
L.append(f"| `{n['label']}` | `{n['attrs']['file']}` | {_cell(decl['kind'] if decl else '')} |")
L.append("")
toks = [n for n in inv["nodes"] if n["kind"] == "token"]
readers: dict[str, list] = {}
for e in inv["edges"]:
if e["kind"] == "reads":
readers.setdefault(e["target"], []).append(by_id[e["source"]]["label"])
L += ["## Tokens", ""]
note("tokens")
L += ["Every custom property the library defines or reads. `library` is `src/tokens.css`, what",
"Vue consumers render today; `theme` is the contract plain pages use",
"(`common/theme/tokens.css` + `themes/soleprint.css`). A row where the two differ is a",
"visible change for a consumer that switches.", "",
"| token | library | theme | read by |", "| --- | --- | --- | --- |"]
for n in toks:
a = n["attrs"]
flag = " ⚠" if a["library"] and a["theme"] and a["library"] != a["theme"] else ""
who = ", ".join(sorted(set(readers.get(n["id"], [])))) or "*nobody*"
L.append(f"| `{n['label']}`{flag} | {_code(a['library'])} | {_code(a['theme'])} | {who} |")
L.append("")
L += ["## What came out", ""]
note("book")
kinds: dict[str, int] = {}
for n in inv["nodes"]:
kinds[n["kind"]] = kinds.get(n["kind"], 0) + 1
ekinds: dict[str, int] = {}
for e in inv["edges"]:
ekinds[e["kind"]] = ekinds.get(e["kind"], 0) + 1
L += ["nodes: " + " · ".join(f"{v} {k}" for k, v in sorted(kinds.items())),
"", "edges: " + " · ".join(f"{v} {k}" for k, v in sorted(ekinds.items())), ""]
L += [f"- {'ok ' if ok else '**FAIL**'} {msg}" for ok, msg in reconcile(inv, notes)]
L.append("")
note("_end")
return "\n".join(L).rstrip() + "\n"
# ── writing, and checking without writing ─────────────────────────────────────
@dataclass
class Result:
written: list = field(default_factory=list)
unchanged: list = field(default_factory=list)
stale: list = field(default_factory=list)
edited: list = field(default_factory=list)
removed: list = field(default_factory=list)
reconciled: list = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.stale and not self.edited and all(ok for ok, _ in self.reconciled)
def _sha(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()
def outputs(inv: dict, notes: dict[str, str]) -> dict[str, str]:
return {INVENTORY: json.dumps(inv, indent=2, sort_keys=True) + "\n",
PAGE: render(inv, notes)}
def build(docs: Path = SITE, *, write: bool, force: bool = False, src: Path = graph.SRC,
notes_dir: Path = NOTES) -> Result:
inv = inventory(src)
notes = load_notes(notes_dir)
files = outputs(inv, notes)
res = Result(reconciled=reconcile(inv, notes))
manifest = docs / MANIFEST
previous = json.loads(manifest.read_text()).get("files", {}) if manifest.exists() else {}
for name, text in files.items():
path = docs / name
on_disk = path.read_text() if path.exists() else None
if on_disk is not None and name in previous and _sha(on_disk) != previous[name]:
# Somebody edited a generated file. That text would be lost on the
# next build, so it is named and refused rather than overwritten.
res.edited.append(name)
if not force:
continue
if on_disk == text:
res.unchanged.append(name)
continue
res.stale.append(name)
if write:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
res.written.append(name)
gone = [n for n in previous if n not in files and (docs / n).exists()]
res.removed = gone
if write and (not res.edited or force):
for n in gone:
(docs / n).unlink()
manifest.parent.mkdir(parents=True, exist_ok=True)
manifest.write_text(json.dumps({"generator": "common.slice reference",
"files": {n: _sha(t) for n, t in files.items()}},
indent=2, sort_keys=True) + "\n")
if res.written:
res.stale = []
return res

View File

@@ -11,15 +11,19 @@ regression are marked (design), and they are the ones to keep if anything is cut
a vendored slice fails on any spr word no --force past the scrub a vendored slice fails on any spr word no --force past the scrub
a hand edit in dest is refused, not overwritten drift is reported both ways a hand edit in dest is refused, not overwritten drift is reported both ways
a vendored slice needs an explicit notice the tool never picks licensing a vendored slice needs an explicit notice the tool never picks licensing
a surface that does not parse is a failure by name the reference never thins silently
a generated doc edited by hand is refused the notes, not the output, hold prose
""" """
import ast import ast
import json
import re import re
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from . import css, graph, manifest, names, plexus, runfile, scan, slices, tokens from . import css, graph, manifest, names, plexus, runfile, scan, slices, surface, tokens
from . import reference as docs
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
PASS, FAIL = [], [] PASS, FAIL = [], []
@@ -257,6 +261,67 @@ def main() -> int:
else: else:
print(" skip meetus snapshot not on this machine") print(" skip meetus snapshot not on this machine")
section("the surface (what the ui reference is generated from)")
surf = surface.load()
panel = surf["components/Panel.vue"]
check("Panel's props, in order", [m.name for m in panel.props] == ["title", "status", "maximizable"],
[m.name for m in panel.props])
check("... with required-ness and withDefaults defaults",
panel.props[0].required and not panel.props[1].required and panel.props[2].default == "true")
check("... a doc comment lands on its prop", panel.props[1].doc.startswith("Omit to show"))
check("a defineModel is a v-model with its default",
[(m.name, m.type, m.default) for m in panel.models] == [("maximized", "boolean", "false")])
check("named and default slots are found", panel.slots == ["actions", "default", "overlay"], panel.slots)
check("quoted and colon-named emits parse (VideoPlayer)",
{"update:currentTime", "ended"} <= {e.name for e in surf["components/VideoPlayer.vue"].emits})
check("a file doc comment is its summary, a prop's is not",
surf["components/VideoPlayer.vue"].summary.startswith("Agnostic") and not panel.summary)
check("a .ts file's exports are declarations",
("abstract class", "DataSource") in [(k, n) for k, n, _ in surf["datasources/DataSource.ts"].declarations])
broken = [s.file for s in surf.values() if s.error]
check("(design) every component in the library parses", not broken, broken)
with tempfile.TemporaryDirectory() as t:
bad = Path(t) / "Broken.vue"
bad.write_text("<script setup lang=\"ts\">\ndefineProps<{ title: string\n</script>\n")
check("(design) a surface that does not parse is recorded by name, not dropped",
surface.read(bad, "Broken.vue").error is not None)
section("the ui reference")
inv = docs.inventory()
check("the inventory is deterministic: two builds, one document", inv == docs.inventory())
visual = {"color", "colour", "fill", "stroke", "shape", "x", "y", "width", "height", "position"}
keys = {k for n in inv["nodes"] for k in n["attrs"]}
check("no visual keys in the inventory — style belongs to whoever renders it",
not keys & visual, sorted(keys & visual))
ids = [n["id"] for n in inv["nodes"]]
check("node ids are unique", len(ids) == len(set(ids)))
check("every edge points at a node", all(e["source"] in ids and e["target"] in ids for e in inv["edges"]))
check("the reference reconciles with what was read, on spr's own tree",
all(ok for ok, _ in docs.reconcile(inv, {})), docs.reconcile(inv, {}))
check("a note for a spot that does not exist fails the reconcile",
not all(ok for ok, _ in docs.reconcile(inv, {"NoSuchExport": "text"})))
check("Panel's plain parts are found from the parts' own headers",
{e["source"] for e in inv["edges"] if e["kind"] == "binds" and e["target"] == "file:components/Panel.vue"}
== {"part:panel", "part:maximize"})
with tempfile.TemporaryDirectory() as t:
d = Path(t)
page = d / docs.PAGE
first = docs.build(d, write=True)
check("a first build writes the page and the inventory into the docs site",
set(first.written) == {docs.PAGE, docs.INVENTORY} and (d / docs.MANIFEST).exists())
check("a second build changes nothing", not docs.build(d, write=True).written)
check("--check on a current build passes", docs.build(d, write=False).ok)
page.write_text(page.read_text() + "\nhand edit\n")
edited = docs.build(d, write=True)
check("(design) a generated doc edited by hand is refused, not overwritten",
edited.edited == [docs.PAGE] and "hand edit" in page.read_text())
forced = docs.build(d, write=True, force=True)
check("... and --force discards it", "hand edit" not in page.read_text()
and docs.PAGE in forced.written)
check("the page is listed in the docs site's sidebar",
any(t.get("id") == "ui-reference" for t in json.loads(
(docs.SITE / "data" / "topics.json").read_text())))
section("the split") section("the split")
offenders = [] offenders = []
for path in HERE.rglob("*.py"): for path in HERE.rglob("*.py"):

View File

@@ -0,0 +1,219 @@
"""
What each file of common/ui/src offers a consumer: props, models, emits, slots,
and the declarations a .ts file exports.
graph.py answers "what does this file need". This answers "what does it give",
and between them they are everything the reference in common/ui/docs says —
read out of the source, so the reference cannot describe a prop that is gone.
Same method as graph.py and for the same reason: patterns, not a TypeScript
parser. The library writes its surface one way — `defineProps<{…}>()`, optionally
inside `withDefaults(…, {…})`, `defineEmits<{…}>()`, `defineModel<T>('name', …)`,
`<slot name="…">` — and the selftest pins what each of those must yield. A file
whose macros are there but do not parse is **recorded as a failure, by name**,
never dropped: a component with its props silently missing reads as a component
with no props, and that is the lie a reference must not tell.
"""
import re
from dataclasses import dataclass, field
from pathlib import Path
from . import graph
DOC = re.compile(r"/\*\*(.*?)\*/", re.S)
SLOT = re.compile(r"<slot\b([^>]*)/?>")
SLOT_NAME = re.compile(r"""\bname\s*=\s*["']([^"']+)["']""")
MODEL = re.compile(
r"defineModel\s*<\s*(?P<type>[^>]+?)\s*>\s*\(\s*(?:['\"](?P<name>[\w:-]+)['\"])?"
r"\s*(?:,\s*\{(?P<opts>[^}]*)\})?\s*\)")
EXPORT_DECL = re.compile(
r"(?:(?P<doc>/\*\*(?:(?!\*/).)*\*/)\s*)?"
r"^export\s+(?P<abstract>abstract\s+)?(?P<kind>class|function|interface|type|const)\s+"
r"(?P<name>[A-Za-z_]\w*)", re.S | re.M)
@dataclass
class Member:
"""One prop, emit or model."""
name: str
type: str
required: bool = True
default: str | None = None
doc: str = ""
@dataclass
class Surface:
"""What one file offers."""
file: str
props: list = field(default_factory=list)
models: list = field(default_factory=list)
emits: list = field(default_factory=list)
slots: list = field(default_factory=list)
declarations: list = field(default_factory=list) # (kind, name, doc)
summary: str = "" # first doc paragraph, if any
error: str | None = None
def _doc_text(raw: str) -> str:
lines = [ln.strip().lstrip("*").strip() for ln in raw.strip("/*").splitlines()]
return " ".join(ln for ln in lines if ln).strip()
def _balanced(text: str, start: int, open_ch="{", close_ch="}") -> int:
"""Index just past the bracket that closes the one at `start`, or -1."""
depth = 0
for i in range(start, len(text)):
c = text[i]
if c == open_ch:
depth += 1
elif c == close_ch:
depth -= 1
if depth == 0:
return i + 1
return -1
def _split_top(text: str, seps=",\n;") -> list[str]:
"""Split on separators that are not nested in brackets or quotes."""
out, depth, quote, cur = [], 0, None, []
for c in text:
if quote:
cur.append(c)
if c == quote:
quote = None
continue
if c in "'\"`":
quote = c
elif c in "([{<":
depth += 1
elif c in ")]}>":
depth -= 1
if c in seps and depth == 0:
out.append("".join(cur))
cur = []
else:
cur.append(c)
out.append("".join(cur))
return out
def _members(body: str) -> list[Member]:
"""The members of a type literal: `/** doc */ name?: type`, one per entry."""
out, pending_doc = [], ""
# Doc comments are pulled out first and attached to the member after them.
pieces = re.split(r"(/\*\*.*?\*/)", body, flags=re.S)
for piece in pieces:
if piece.startswith("/**"):
pending_doc = _doc_text(piece)
continue
for chunk in _split_top(piece):
chunk = re.sub(r"//[^\n]*", "", chunk).strip()
if not chunk:
continue
m = re.match(r"""^['"]?([\w:-]+)['"]?\s*(\?)?\s*:\s*(.+)$""", chunk, re.S)
if not m:
continue
out.append(Member(name=m.group(1), type=" ".join(m.group(3).split()),
required=not m.group(2), doc=pending_doc))
pending_doc = ""
return out
def _defaults(text: str) -> dict[str, str]:
"""`{ a: 1, b: () => [] }` → {"a": "1", "b": "() => []"}, values as written."""
out = {}
for chunk in _split_top(text.strip()[1:-1], seps=",\n"):
chunk = chunk.strip()
m = re.match(r"^['\"]?([\w-]+)['\"]?\s*:\s*(.+)$", chunk, re.S)
if m:
out[m.group(1)] = " ".join(m.group(2).split())
return out
def _macro_body(script: str, macro: str) -> tuple[str | None, int]:
"""The `{…}` type literal of `macro<{…}>()`, and where it ends."""
m = re.search(macro + r"\s*<\s*\{", script)
if not m:
return None, -1
start = m.end() - 1
end = _balanced(script, start)
if end < 0:
return None, -1
return script[start + 1:end - 1], end
def read(path: Path, rel: str) -> Surface:
text = path.read_text()
s = Surface(file=rel)
script, _ = graph.code_of(path, text)
for m in EXPORT_DECL.finditer(script):
kind = ("abstract class" if m.group("abstract") else m.group("kind"))
s.declarations.append((kind, m.group("name"), _doc_text(m.group("doc") or "")))
# A file's summary is a doc comment with nothing but imports before it. The
# first doc comment is not enough: in Panel.vue it belongs to a prop.
lead = DOC.search(script)
if lead:
before = re.sub(r"^\s*import\b[^\n]*$", "", script[:lead.start()], flags=re.M)
if not before.strip():
first = _doc_text(lead.group(0))
s.summary = first.split(". ")[0].rstrip(".") + "."
if path.suffix != ".vue":
return s
if "defineProps" in script:
body, end = _macro_body(script, "defineProps")
if body is None:
s.error = "defineProps is present but its type literal did not parse"
else:
s.props = _members(body)
# withDefaults(defineProps<{…}>(), { … }) — the defaults follow `)`.
tail = script[end:]
dm = re.match(r"\s*>\s*\(\s*\)\s*,\s*\{", tail)
if dm and "withDefaults" in script[:end]:
d_start = end + dm.end() - 1
d_end = _balanced(script, d_start)
if d_end > 0:
defaults = _defaults(script[d_start:d_end])
for p in s.props:
if p.name in defaults:
p.default = defaults[p.name]
if "defineEmits" in script:
body, _ = _macro_body(script, "defineEmits")
if body is None:
s.error = (s.error + "; " if s.error else "") + \
"defineEmits is present but its type literal did not parse"
else:
s.emits = _members(body)
for m in MODEL.finditer(script):
opts = m.group("opts") or ""
dflt = re.search(r"default\s*:\s*([^,}]+)", opts)
s.models.append(Member(name=m.group("name") or "modelValue", type=m.group("type").strip(),
required=bool(re.search(r"required\s*:\s*true", opts)),
default=dflt.group(1).strip() if dflt else None))
template = re.sub(r"<script\b.*?</script>|<style\b.*?</style>", "", text, flags=re.S)
for m in SLOT.finditer(template):
name = SLOT_NAME.search(m.group(1))
slot = name.group(1) if name else "default"
if slot not in s.slots:
s.slots.append(slot)
return s
def load(src: Path = graph.SRC) -> dict[str, Surface]:
"""Every .vue/.ts file's surface, keyed like graph.load()."""
out = {}
for p in sorted(src.rglob("*")):
rel = p.relative_to(src).as_posix()
if p.is_file() and p.suffix in (".vue", ".ts") and not graph.SKIP & set(p.relative_to(src).parts):
out[rel] = read(p, rel)
return out

View File

@@ -0,0 +1,83 @@
# common/ui — the Vue binding of spr's UI contract.
#
# Paths are derived from where this file sits. The logic lives in pnpm and in
# common/slice, never here: every target is one line.
#
# make install node_modules, offline when the store has it
# make check everything that must be green before a change here lands
# make docs launch spr's docs site, open at this library's reference —
# in soleprint, `make docs` always launches the /docs minisite
# make reference regenerate that reference page from src/ (never hand-edited)
# make reference-spots every place a hand-written note (notes/) can go
# make slices what a consumer can take, and what each piece pulls in
#
# This folder is also copied whole into consumers by ctrl/spr.py. There, the
# pnpm targets still work; the docs/reference/slices/parts targets need spr around them
# and say so instead of failing with a traceback.
HERE := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
SOLEPRINT := $(abspath $(HERE)/../..)
PY ?= python3
PNPM ?= pnpm
SLICE := cd $(SOLEPRINT) && $(PY) -m common.slice
IN_SPR := $(wildcard $(HERE)/../slice/__init__.py)
# The one guard every spr-side target shares: a copy of this folder elsewhere has
# no common/slice beside it.
need-spr = $(if $(IN_SPR),,$(error $@ needs spr around this folder — docs, reference, slices and parts live in spr, not in a copy))
.DEFAULT_GOAL := help
.PHONY: help install typecheck test build check docs reference reference-check reference-spots reference-scaffold slices parts-check clean
help: ## List every target
@echo "common/ui — the Vue binding of spr's UI contract"
@echo
@grep -E '^[a-z-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN{FS=":.*?## "}{printf " \033[1m%-19s\033[0m %s\n", $$1, $$2}'
install: ## node_modules (pnpm, offline when the store already has it)
cd $(HERE) && $(PNPM) install --prefer-offline
typecheck: ## vue-tsc over src/
cd $(HERE) && $(PNPM) typecheck
test: ## vitest
cd $(HERE) && $(PNPM) test
build: ## dist/soleprint-ui.js + dist/style.css (the whole-library bundle; consumers take slices)
cd $(HERE) && $(PNPM) build
check: typecheck test reference-check parts-check ## typecheck, test, reference current, parts agree with their sources
@echo "common/ui: all green"
docs: ## launch spr's docs site (the /docs minisite), open at #ui-reference
$(need-spr)
@echo "the UI reference: http://localhost:$${DOCS_PORT:-8080}/#ui-reference"
bash $(SOLEPRINT)/../ctrl/docs.sh serve
reference: ## regenerate the ui-reference page from src/ (refuses to overwrite a hand edit; FORCE=1 to discard it)
$(need-spr)
$(SLICE) reference $(if $(FORCE),--force)
reference-check: ## the page current, no hand edits, every note on a spot — writes nothing
$(need-spr)
$(SLICE) reference --check
reference-spots: ## every spot a note (notes/) can go, and which have one
$(need-spr)
$(SLICE) reference --spots
reference-scaffold: ## an empty stub in notes/ for every spot without a note (never overwrites)
$(need-spr)
$(SLICE) reference --scaffold
slices: ## what can be sliced, and what each export or part pulls in
$(need-spr)
$(SLICE) list
parts-check: ## the plain parts in common/theme still agree with the files here they came from
$(need-spr)
cd $(SOLEPRINT) && $(PY) common/theme/bake.py --check >/dev/null && echo "parts: agree with their sources; pages current"
clean: ## remove dist/
rm -rf $(HERE)/dist

View File

@@ -0,0 +1,4 @@
**Known bug:** box colours by confidence are returned as `'var(--conf-high)'` strings and
handed to a canvas `strokeStyle`. Canvas does not resolve custom properties, so those
boxes draw in the context's previous colour. That is why the "Tokens" table shows the
`--conf-*` tokens as read by nobody: in effect, they are not.

View File

@@ -0,0 +1,4 @@
Virtual scroll, and the only one in any of the apps: rows outside the viewport are not
rendered, and auto-scroll pauses while the reader scrolls up. mts's long lists and mpr's
React side render every row instead. `rowHeight` and `autoScroll` have never been passed
by a consumer; the defaults are what is proven.

View File

@@ -0,0 +1,12 @@
The one component everything uses — 13 instances in mpr, 7 across mts's two apps.
**Maximize came from mts.** mts's copy grew `v-model:maximized` and `maximizable` while
spr's stood still, and it was brought upstream on 2026-09-22 — the framework spec's own
rule ("a second application finds the holes") working as written. The reason it is the
panel's job and not the video's is in the source: a fullscreen `<video>` paints over the
panel's actions and overlays.
**`#actions` is where panel buttons go.** mts fills it in 6 of 7 panels; mpr fills it in
none and absolutely-positions buttons inside the body instead. The slot is the answer.
**`#overlay` is filled by no consumer**; it stays for the interaction plugins, which have
no consumer either.

View File

@@ -0,0 +1,5 @@
Schema-driven: the same field list a pipeline stage declares renders its sliders. Two
gaps, both fixed in the plain binding (`params` part) and not yet here: a field with
`options` (an enum) renders **nothing**, because only `int`/`float` and `bool` are
filtered for; and the `reset` event is declared but never emitted — mpr wires a handler
to it that cannot fire.

View File

@@ -0,0 +1,10 @@
One stream, many typed events: panels subscribe with `on(type, handler)` to the event
types they draw, and the stream ends with a terminal `done`. `status` uses the same four
words as a panel's dot, so wiring one to the other needs no mapping.
Two things the code does not do, both learned from mpr. **It does not throttle**: at a few
hundred events a second, a handler that writes to the DOM per event stalls — the plain
binding's `feed` part adds `onFrame()` (one call per animation frame, newest payload) for
exactly this, and a Vue consumer has to coalesce the same way. **Nobody unsubscribes**:
`on()` returns an unsubscribe function and every call site in mpr drops it, which only
works because its panels live as long as the page.

View File

@@ -0,0 +1,4 @@
The real layout primitive. Every app lays itself out with nested splits — mpr three
deep — and none uses `LayoutGrid`. `sizeMode="ratio"` is the common case (all of mts);
`"px"` pins a sidebar (mpr). `resizable: false` has never been passed by anyone: a split
that cannot move is a flex row.

View File

@@ -0,0 +1,3 @@
One of the only two exports that bring a package (`uplot`, with its stylesheet), and the
reason a chart cannot be a plain part: baking uplot into every standalone page is the
bundle this library refuses to be. A page that needs a real chart is a served page.

View File

@@ -0,0 +1,2 @@
mts's, and only mts uses it. It came upstream with its `#overlay` slot on 2026-09-22.
It deliberately does no fullscreen of its own: put it in a `Panel` and maximize that.

View File

@@ -0,0 +1,18 @@
## Where it stands
- **Done:** the reference is generated and checked; slices are cut per consumer with a
two-way drift check; mts's additions (maximize, the video overlay) are upstream.
- **Behind:** the copies in mpr, unt and nvi date from 2026-04 and lack maximize,
`VideoPlayer`, `base.css` and `theme.css`. Their `.spr` stamp names a commit that no
longer exists. Replacing each with a slice is the next step for each.
- **Open:** one token definition instead of two (three, counting docgen's style slots);
the `connecting` status the panel cannot show; `--accent`, which nvi and doocus read and
nothing defines; the `FrameRenderer` colour bug; `ParameterEditor`'s enum fields.
## Rebuilding this
`make reference` in `common/ui`. `make check` fails when this page is stale, when a
generated file was edited by hand, or when a note no longer has its spot. Extraction reads the
source with patterns, not a TypeScript parser; `common/slice/selftest.py` pins what the
patterns must find, so one that stops matching fails the suite rather than thinning this
page.

View File

@@ -0,0 +1,42 @@
## What this is
A panel framework, not a dashboard: boxes with a title and a status light, splits that
resize, a feed that pushes typed events, and renderers for what arrives. It was built for
high-frequency operational views — a minimal Grafana — and every piece below exists
because an app needed it.
**The contract is the framework; Vue is one binding of it.** The contract is four things:
the token names, the element layer (`base.css`), the class names (`.panel`,
`.panel-header`, `.split-pane`, …) and the status words. Those are not whole yet: a feed
reports `idle`, `connecting`, `live`, `error`, while the panel's dot shows `idle`,
`live`, `processing`, `error` — so `connecting` has no dot, and a consumer has to map it
by hand. That is a gap in the contract, listed below. `common/theme/parts` is the second
binding: the same contract as plain CSS and JS, for pages that must open from a file with
no build. docgen's site is not a binding yet: it bakes its colours from its own style
slots, a third token vocabulary.
## The layers, as use shaped them
| layer | what | the evidence |
| --- | --- | --- |
| contract | tokens, element layer, class names, status words | every consumer reads `tokens.css`; four apps pasted their own `base.css` before it shipped here |
| chrome | `Panel` (+ maximize), `SplitPane`, `ParameterEditor`, `VideoPlayer` | `Panel` and `SplitPane` are the only exports all five consumers use; nested splits, not `LayoutGrid`, turned out to be the layout |
| feed | `DataSource`, `SSEDataSource` | one stream, typed events, a terminal `done` |
| renderers | `LogRenderer`, `TableRenderer`, `FrameRenderer`, `TimeSeriesRenderer`, `GraphRenderer` | opt-in; the only two external packages arrive through these |
| app state | not here | mpr keeps its own Pinia stores; `pinia` is imported nowhere in this library |
| delivery | slices, not this whole folder | see below |
## How to take it
Not as this folder. A consumer takes a **slice**: the closure of the exports it uses,
as readable source, with a barrel exporting only those and a `tokens.css` cut down to
what is read — `make ui scan <app>` computes it, `ctrl/ui.sh slice <run file>` writes it,
and `check` reports drift both ways. The "Contents" table is the price list.
## How this document is made
Everything outside these notes is generated from `src/` by `make reference` (in
`common/ui`), so a prop that is removed disappears from here on the next build. The notes
are the only hand-written part, one file per spot in `soleprint/common/ui/notes/`; a note
whose spot disappears fails `make check`. `make docs` — anywhere in soleprint — launches
this site.

View File

@@ -0,0 +1,16 @@
**Who uses what**, measured 2026-09-22 with `make ui scan <app>` over the five apps that
consume this library. It is the one table here that is not generated — the apps live
outside this repository — so re-run the scan before trusting a row.
| export | mpr detection | mts meetus | mts doocus | unt | nvi |
| --- | --- | --- | --- | --- | --- |
| `Panel`, `SplitPane` | ✓ | ✓ | ✓ | ✓ | ✓ |
| `LogRenderer` | ✓ | | | ✓ | |
| `VideoPlayer` | | ✓ | | | |
| `ParameterEditor`, `ResizeHandle`, all renderers but Log, `SSEDataSource`, `useRegistry`, `useEditorExecution` | ✓ | | | | |
| `LayoutGrid`, `StaticDataSource`, `useDataSource`, `BBoxDrawPlugin`, `CrosshairPlugin` | | | | | |
The last row has **no users at all**. Those exports stay in the library — they are
small and a slice never carries them unless asked — but nothing should be built on them
as if they were proven. `ResizeHandle` is imported by mpr and never rendered: it is what
`SplitPane` replaced.

View File

@@ -0,0 +1,10 @@
**There are two definitions, and the ⚠ rows are where they disagree.** `src/tokens.css`
is the library's own, dark-only; the theme contract in `common/theme` derives the same
names from its own palette. They agree on every colour but `--surface-3` and on neither
font stack. A Vue slice uses the library's values by default so swapping a consumer's
copy for a slice changes no pixel; `theme = "soleprint"` opts into the contract, and
that is a visible change. Retiring the library's file in favour of the contract is the
open question — it changes what every current consumer renders.
"read by nobody" means no stylesheet in the library reads it. Apps read tokens directly
too — `make ui scan` reports between 14 and 23 per app — and a slice carries those.