24 KiB
common/ui — reference
Generated from
common/ui/srcbymake referencein common/ui (python3 -m common.slice reference). Do not edit this page: the next build replaces it. Prose goes insoleprint/common/ui/notes/<spot>.md, andmake reference-spotslists 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 | datasources/DataSource.ts |
— | vue | 1.3 KB | feed |
SSEDataSource |
datasource | datasources/SSEDataSource.ts |
DataSource.ts | vue | 3.8 KB | feed |
StaticDataSource |
datasource | datasources/StaticDataSource.ts |
DataSource.ts | vue | 2.4 KB | — |
useDataSource |
composable | composables/useDataSource.ts |
DataSource.ts | vue | 2.0 KB | — |
useRegistry |
composable | composables/useRegistry.ts |
— | vue | 1.9 KB | — |
useEditorExecution |
composable | composables/useEditorExecution.ts |
— | vue | 1.4 KB | — |
Panel |
component | components/Panel.vue |
— | vue | 4.3 KB | maximize, panel |
LayoutGrid |
component | components/LayoutGrid.vue |
— | vue | 0.5 KB | — |
ResizeHandle |
component | components/ResizeHandle.vue |
— | vue | 1.3 KB | — |
SplitPane |
component | components/SplitPane.vue |
— | vue | 3.7 KB | split |
ParameterEditor |
component | components/ParameterEditor.vue |
— | vue | 3.1 KB | params |
VideoPlayer |
component | components/VideoPlayer.vue |
— | vue | 4.1 KB | — |
LogRenderer |
renderer | renderers/LogRenderer.vue |
— | vue | 3.7 KB | — |
TimeSeriesRenderer |
renderer | renderers/TimeSeriesRenderer.vue |
— | uplot, vue | 4.3 KB | — |
GraphRenderer |
renderer | renderers/GraphRenderer.vue |
— | @vue-flow/core, vue | 7.9 KB | — |
FrameRenderer |
renderer | renderers/FrameRenderer.vue |
— | vue | 4.9 KB | — |
TableRenderer |
renderer | renderers/TableRenderer.vue |
— | vue | 2.4 KB | — |
BBoxDrawPlugin |
plugin | plugins/BBoxDrawPlugin.ts |
InteractionPlugin.ts | — | 3.0 KB | — |
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.cssandtheme.css. Their.sprstamp 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
connectingstatus the panel cannot show;--accent, which nvi and doocus read and nothing defines; theFrameRenderercolour 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.