ui exporting and docgen testing
This commit is contained in:
2
Makefile
2
Makefile
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
7
docs/data/.ui-reference.generated.json
Normal file
7
docs/data/.ui-reference.generated.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": {
|
||||||
|
"data/en/ui-reference.md": "d010b68b9599f033a3920ef0590c95d10a895e7d4c5b98e2ba5d822c26a32dd4",
|
||||||
|
"data/ui-inventory.json": "7b8895661a3023f3198eb17e5c15101537b4915faf1a9b398abaa49cd575edc4"
|
||||||
|
},
|
||||||
|
"generator": "common.slice reference"
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
485
docs/data/en/ui-reference.md
Normal file
485
docs/data/en/ui-reference.md
Normal 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.
|
||||||
@@ -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
2701
docs/data/ui-inventory.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
63
soleprint/common/slice/cli/reference.py
Normal file
63
soleprint/common/slice/cli/reference.py
Normal 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
|
||||||
454
soleprint/common/slice/reference.py
Normal file
454
soleprint/common/slice/reference.py
Normal 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
|
||||||
@@ -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"):
|
||||||
|
|||||||
219
soleprint/common/slice/surface.py
Normal file
219
soleprint/common/slice/surface.py
Normal 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
|
||||||
83
soleprint/common/ui/Makefile
Normal file
83
soleprint/common/ui/Makefile
Normal 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
|
||||||
4
soleprint/common/ui/notes/FrameRenderer.md
Normal file
4
soleprint/common/ui/notes/FrameRenderer.md
Normal 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.
|
||||||
4
soleprint/common/ui/notes/LogRenderer.md
Normal file
4
soleprint/common/ui/notes/LogRenderer.md
Normal 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.
|
||||||
12
soleprint/common/ui/notes/Panel.md
Normal file
12
soleprint/common/ui/notes/Panel.md
Normal 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.
|
||||||
5
soleprint/common/ui/notes/ParameterEditor.md
Normal file
5
soleprint/common/ui/notes/ParameterEditor.md
Normal 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.
|
||||||
10
soleprint/common/ui/notes/SSEDataSource.md
Normal file
10
soleprint/common/ui/notes/SSEDataSource.md
Normal 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.
|
||||||
4
soleprint/common/ui/notes/SplitPane.md
Normal file
4
soleprint/common/ui/notes/SplitPane.md
Normal 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.
|
||||||
3
soleprint/common/ui/notes/TimeSeriesRenderer.md
Normal file
3
soleprint/common/ui/notes/TimeSeriesRenderer.md
Normal 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.
|
||||||
2
soleprint/common/ui/notes/VideoPlayer.md
Normal file
2
soleprint/common/ui/notes/VideoPlayer.md
Normal 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.
|
||||||
18
soleprint/common/ui/notes/_end.md
Normal file
18
soleprint/common/ui/notes/_end.md
Normal 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.
|
||||||
42
soleprint/common/ui/notes/_intro.md
Normal file
42
soleprint/common/ui/notes/_intro.md
Normal 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.
|
||||||
16
soleprint/common/ui/notes/contents.md
Normal file
16
soleprint/common/ui/notes/contents.md
Normal 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.
|
||||||
10
soleprint/common/ui/notes/tokens.md
Normal file
10
soleprint/common/ui/notes/tokens.md
Normal 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.
|
||||||
Reference in New Issue
Block a user