The style contract¶
What "coherent" means for these screens, written down so an agent can cite it
and fsmes ui-check can enforce the checkable parts. This document is the
flexibility mechanism as much as the coherence one: evolving the design
deliberately means editing this contract and re-accepting baselines
(fsmes ui-check --accept) in the branch that makes the change. Drift that
ships without touching this file or the baselines is a bug by definition.
The enforcement layer is tests/ui/baselines/*.json — computed styles of the
components named below, per theme, captured from the live screens. A rule
here that the baselines cannot see is still a rule; reviewers hold the line
where the crawler cannot.
Rules¶
- A component owns its own chrome.
.kpiowns the card — background, border, radius, padding, layout. A class added beside it (.kpi-action) may strip browser defaults and add affordance, but may not declare any property the base class already sets. (The rule that came from the 2026-09-01 tile regression, whereborder: 1px solid transparenton the affordance silently deleted every card's outline. Pinned bytest_a_clickable_tile_still_looks_like_the_card_it_was.) - Colour comes from the palette, nowhere else. Every colour on every
screen resolves to a variable defined in
themes.css. A hex literal in a page stylesheet is a bug — that is what let four colours escape theming before the four-theme pass caught them. - All four themes are first-class. control-room, daylight,
high-contrast, night-shift. A change is not done until it looks right in
all four;
ui-checksnapshots every theme for exactly this reason. High-contrast keeps its WCAG ratios (a test computes them). - Every list states its total. "Showing 50 of 18,347" — a truncated list must never look complete (the same honesty rule the API's envelope carries).
- Numbers describe the plant unless labelled otherwise. A tile, KPI or count scoped to the current page/filter says so. A plant-wide number and a page-scoped number may not sit side by side unlabelled.
- Affordance is structural, not decorative. A thing you can click is a
<button>or an<a>— never a<div>with a handler — so keyboards and screen readers work without extra code. Hover feedback moves colour only; nothing on the page shifts by a pixel. - Interactive elements carry
data-assistanchors where a guide points at them, and renaming an anchor is a deliberate act that re-authors the guides (they break loudly by design). - Unknown renders as "—" or "unknown", never as 0 (principle 4, on screen). A yield with nothing booked is unknown; a machine that reported nothing is unknown; zero is a measurement.
- No build step (principle 5). Plain HTML/JS/CSS, hand-drawn SVG for charts, no bundler, no framework, no font or script fetched from outside the box.
- What you DO sits apart from what you READ. A screen's action forms
are one strip, first on the page, with the accent edge and surface
.actionscarries instyles.css— styled once there, so all four themes follow without knowing the component exists. At a hundred machines the floor's action forms were a screen and a half below the fold, which is the same as not having them. - A filter bar holds one line. A long tail of controls goes behind a
.popovertoggle (FS.popover), and the toggle says what it is filtering to rather than looking idle. A wrapping row of controls puts a slab of chrome above the table it is meant to serve. - New screens join the system. Same header/nav, same
.panelgrid, same.kpistrip when there are KPIs, themed via the palette from day one — and they are crawled automatically (ui-check reads the routes fromapp.py), so a new screen is watched the day it exists. -
Every chart obeys the chart contract below, and is drawn by
kit.js—FS.kit.chart(kind, envelope, options). A second chart engine on a second screen is how two screens come to state coverage differently, which is the thingkit.jsexists to make impossible. The kinds areline,bars(aliaspareto),states,histogramandgraph.The
graphshape, the network of the deep-analysis design page (docs/design/deep-analysis.md§7, which lands with its own change), keeps four rules of its own on top of the six:- Every edge is a recorded fact. The shape draws the edges the envelope carries and never one between two nodes that ended up near each other. A picture is where "an edge nobody observed is not an edge" is hardest to keep and easiest to break.
- The holes are things with numbers on them.
unattributedandunlabelledare nodes, hatched, carrying their degree rather than a weight — what a hole is, is what it touches — so the reader sees the three turns with no person rather than a tidy graph that is three turns short. - Empty node kinds are drawn empty. A kind the model declares and
this plant records nothing of is on the picture with its zero and
its name. A graph that quietly omitted
screenwould read as a complete picture of a plant where the questions came from nowhere. - Coverage is
absent, always, and there is no centrality. A graph of records is not a rate over a watched window. And betweenness, PageRank and eigenvector centrality over an edge set that is whatever happens to be recorded would be the most convincing wrong number this product could show, so the shape does not offer one.
Node kinds and edge kinds map to palette variables in
styles.cssand nowhere inkit.js(rule 2). Under the night-shift palette three node kinds are close in hue, because that palette is deliberately low-blue; the legend and the node names carry the distinction there. -
What the reader narrowed a chart to re-states its total. Hovering a mark says that mark's own
data-valuewith the coverage sentence under it; the legend switches a kind out; the threshold moves; the time axis brushes. Every one of those rewritesdata-totaland the footer, because a filtered chart that kept the old total is a list that reads complete (rule 4). The frame does this for every shape, so a new shape cannot forget it. The controls are drawn inside the<svg>— there is no<button>in SVG, so they carryrole,tabindexand anaria-label, and they answer a keyboard. -
A chart is presentation-ready when its footer survives the paste.
FS.kit.export(chart, "svg" | "png")is the chart node's own markup with the theme's resolved colours inlined and the panel colour behind it. An export whose coverage sentence was stripped is not an export this product makes.
The chart contract¶
A rendered chart is the most convincing wrong thing this product can draw (house rule 6), so the six rules below are not style — they are the honesty rules this MES already keeps, applied to a picture. They come from the agentic-harness design page, §9 M1, which is where the reasoning lives; this is the short form a reviewer can hold in their head.
- A chart draws what the API measured. It computes nothing. Every number
on the picture is a number the envelope carried, and it appears verbatim in
a
data-valueattribute so a test can say so. Laying numbers out is allowed; working one out is not — which is why the histogram refuses to bin a series and asks the API for the bins. - Every figure carries its coverage, because the envelope it came from
does.
data-coverageis a number,nullwhen it could not be computed, orabsentwhen the payload has no such field: three different facts. A row below this plant's coverage floor is drawn withheld at full width with its ledger, never omitted, never averaged away, and never as a shorter bar — a shorter bar reads as a measurement of a machine rather than of how little of it anybody saw (0033). - Unknown is drawn as unknown — a rendering of its own: hatched, marked
data-unknown="true", and the line breaks rather than being drawn through the hole. Not a gap, not a zero, not a smooth line (rule 8 above, 0030). - Honest axes. A y-axis that does not start at zero says so on itself; a window the MES could not fill says what it truncated; a rate carries its denominator; and a shape that depends on a choice — a bin width, a bar scale — states the choice.
- Every chart states its total, in text on the chart and in
data-total, including the rows nobody drew (rule 4 above, applied to a picture). - The palette, and all four themes (rules 2 and 3 above). Nothing in
kit.jsnames a colour: every mark carries a class fromstyles.css, so all four themes follow without the page knowing a chart exists.
And every chart carries a <title> and a <desc> assembled from the same
sentences as its visible footer, so what a screen reader is told and what a
sighted reader sees cannot drift apart.
No build step, and nothing vendored (rule 9 above,
0005). Hand-drawn SVG,
extended in kit.js rather than duplicated. A charting library would have to
be vendored under web/vendor/ with its version, licence and SHA-256, and is a
decision record when it happens — the three.js precedent was accepted because
WebGL cannot be hand-drawn. None of the five shapes needed one, the network
graph included: its layout is ninety hand-written lines, and the reason it is
a shape rather than a vendored engine is that the six rules are structural
here — frame() writes data-total, data-coverage, the <title>, the
<desc> and the footer for every shape before the shape draws anything, so a
new shape inherits all of it and a new engine would inherit none
(docs/design/deep-analysis.md §6, where that is measured against vendoring
plotly).
Pinned by tests/test_a_chart_draws_only_what_the_api_measured.py: every shape,
every rule, every interaction and the export, in all four themes, from fixed
envelopes.
What ui-check watches¶
Components: header, .kpi, .panel, table, .pill, button,
.filters, svg.fs-chart — first instance per page, plus a count. The chart
is watched on its frame and not on a mark, for the same reason .pill is not
watched at all: a mark is coloured by the state it reports, which is runtime
data, and a check that fires on the plant working is a check people learn to
ignore.
Properties: color, background-color, border, border-radius, font-size,
font-weight, padding, display.
Add a component here and to WATCHED in src/fsmes/sim/ui_check.py in the
same change.
The header is watched twice over, because rule 10 is the one a person meets
first: a computed-style snapshot like every other component, and a
per-route fact — is it on the page, is it visible, has it a link home, does
it list any screens at all. A route that fails any of those is a no-header
finding, on that route in that theme, needing no baseline. A screen nobody
can leave is a bug however good it looks, and a theme that paints the header
out of existence is the same bug wearing a palette.