The visual and interaction record for Maiman Studio's interface. It exists because
src/maiman/studio/index.html is a build, and a build without a
written rationale is a set of numbers nobody can argue with later. Everything
here was decided against the audience and use scene in PRODUCT.md;
where a decision was made by measurement rather than judgement, the measurement
is given.
An Operate surface: a schematic editor, used to complete a task rather than to be admired or read through. Scanability, consistency and the real usage scene outrank expression. Brand lives in precise details, not in gestures.
The engineer is iterating — change a parameter, run, look at the constellation, change it again — dozens of times an hour. Three consequences follow, and they decide most of what is below:
- Density beats generosity. Screen space spent on decoration is space not spent on the schematic or the plot.
- Recognition beats novelty. The layout is the one this audience already knows from Blender, VS Code and EDA tools. Nothing is moved to be different.
- Nothing on screen may be invented. Every number, every parameter, every port comes from a real engine run. See §7.
The interface ships paper and graphite, and defaults to paper.
A schematic is a document before it is a screen. Its plots leave the tool for reports, theses and papers, where a screenshot off a black canvas is the wrong artefact. Graphite is one click away for the night session beside a terminal.
Neither is a skin over the other:
| paper | graphite | |
|---|---|---|
| ground | #e9edf3 bed under white panels |
#0b0e13 under #11151c panels |
| a block | white card on a grey bed | raised panel on a dark one |
| hover | adds ink (darker) | adds light |
| plot bed | #ffffff — a plot is on paper |
#080b10 |
The port hues are the identity and survive both, but their values are re-derived per ground rather than inverted — on paper the spectrum darkens to hold contrast against a light bed.
Structure. The bare :root block carries the complete paper palette. The
graphite tokens appear twice: once under
@media (prefers-color-scheme: dark) guarded as :root:not([data-theme="light"]),
and once under :root[data-theme="dark"] so an explicit choice wins in both
directions. No colour is ever defined only inside a media or [data-theme]
block — that is the classic unreadable-page bug, and it is checked rather than
assumed (§8).
Every port colour is a wavelength, not a preference. A wire's colour tells you what travels down it.
| port type | paper | graphite |
|---|---|---|
| optical (C-band) | #0a8279 |
#22d3c5 |
| electrical | #a35f00 |
#f0a030 |
| binary | #5a6474 |
#7c86a0 |
| symbol | #6539cc |
#9b7cf6 |
| metric | #bc2668 |
#e85d9b |
This is the one place the interface is allowed to be memorable, and it earns its place by being information: a typed-port system that refuses invalid wiring at edit time is meaningless if the types are invisible.
The shown project is chosen so that all five types appear on the canvas at once. That is why it is a coherent link rather than an on-off-keyed one: only a coherent chain carries binary into symbols, symbols into two electrical drives, an optical field, two photocurrents back, and symbols out again. It runs over 80 km of real fibre, because a simulator whose demonstration link has no fibre in it is showing the wrong thing.
Accent is --optical, because the tool is an optical simulator. Semantic colour
(--good / --warn / --bad) is kept separate from it and does not count as
the accent.
Four layers, spaced so hierarchy comes from the distance between them rather than from pushing the weakest below readability.
| token | paper | vs white | graphite | role |
|---|---|---|---|---|
--ink |
#0f141c |
18.5 | #e6ecf5 |
values, headings |
--ink-2 |
#333d4b |
11.0 | #a8b4c6 |
labels, body |
--ink-3 |
#4c5666 |
7.4 | #8e9bb0 |
section titles, axes |
--ink-4 |
#5f6a79 |
5.5 | #7a869b |
units, ranges, disabled |
The bottom rung is set by the tinted grounds it actually lands on — a unit inside an input well, a disabled toolbar label — not by white, where it would look too pale. Against the darkest surface it still clears 4.5:1.
Four regions, top to bottom:
menu bar 34px File / Edit / Simulate / View / Help, ground switch, engine state
action toolbar 40px Run, Stop, Sweep · select/pan/align · zoom · run context
body 1fr palette 216px | node canvas 1fr | inspector 260px
results dock 292px tabs + metric strip, plot + side readouts
status bar 24px block and link counts, selection, sample count, precision
Run sits leftmost in the toolbar, where the eye lands first, because it is the action taken dozens of times an hour.
The dock is 292px because the constellation's plot is square and therefore sized by the dock's height, not its width. A shorter dock wastes the width it has.
The canvas viewBox is 1000 x 530, seven columns of 138px at a node width of 116, three rows at y = 30 / 220 / 410. The seventh column arrived with the span and its compensator; the alternative was to keep six and squeeze the spacing to 120, which leaves four pixels between adjacent blocks and reads as cramped.
The height is set by the region the SVG actually lands in, and that number is
worth measuring rather than estimating. At 1440px the canvas region is
964 x 510 — an aspect of 1.89. With preserveAspectRatio="xMidYMid meet"
any box wider than that is letterboxed top and bottom: the original 880 x 420
(2.10) wasted 50px, and widening to 1000 x 420 (2.38) wasted 105px — it made
the fit worse, not better, which is the opposite of what a first pass here
claimed. Matching the box to 1.89 wastes none, and the rows spread into the
height they gained.
Below 940px the palette and inspector collapse and a note says so. This is a desktop tool; the real build would dock them as overlays rather than dropping them, and the note says that too rather than pretending the narrow layout is the intended one.
Two reduced result types, and they share a visual language because they mean the same thing: distance from the page is how many landed there.
- Eye diagram — density ramp bed → cyan → far stop.
- Constellation — the same ramp; axes through the origin, because a constellation is read relative to zero and not to the corner of a box; ideal points marked with crosses rather than filled dots, so a cluster's own centre stays visible underneath and a bias offset reads instead of being covered by the marker meant to locate it.
- Sensitivity — one curve per format, each labelled where it crosses the FEC threshold rather than in a legend box. The threshold is 1e-3, not Q = 6: nobody operates an error-free channel any more, they operate one a soft-decision code can close.
The ramp's mid stop is placed by luminance, at roughly the same fraction of each ground's span, which is what keeps sparse outliers subordinate to the dense regions on both.
A canvas has no cascade, so the plots read the same tokens as everything else at draw time and are redrawn when the ground changes. Nothing below the token block names a colour.
The palette is generated from manifests() — the same call the real GUI will
make. The inspector shows true parameters, units, ranges and docstrings. The
constellation, eye and sweep are a real run, exported by
examples/export_ui_data.py.
This is not a purity exercise. Building the mockup against real data has twice found engine defects that the test suite missed:
eye_histogramaccepted anytime_binsand rendered a 32-sample trace across 96 columns as vertical banding. Time resolution is now capped at one column per sample.- Placing an analyser after the differential decoder reported EVM of exactly zero however bad the link was, because that block emits decisions. The mockup now carries two analysers — soft measurement before the decoder, error count after — which is also how a bench does it.
- The sensitivity sweep's 256-QAM curve never crossed the FEC threshold in the swept range, so it was the one curve with no label — quietly contradicting the rule in section 6 that each is labelled where it crosses. The range now extends far enough that all four cross.
Not by looking at downscaled screenshots. Each publish is checked by measurement, in both grounds:
- Contrast — every text node's computed colour against its resolved background; ~110 elements per dock tab. Target 4.5:1 (3:1 for large text). Current status: zero failures, both grounds, all four tabs.
- Token completeness — every
var(--…)the stylesheet references must resolve from bare:rootalone. Current: 34/34. - Theme-block drift — the two graphite blocks are compared key by key.
- Canvas repaint — plot beds are sampled after switching, in both directions.
- Geometry — no SVG element may stray outside the viewBox; the viewBox aspect must match the canvas region's, or the difference is letterboxed away; the Ports legend must not overlap a block; no horizontal page overflow.
- Screenshots —
python docs/capture_screenshots.pyregeneratesdocs/images/in both grounds at the same 1440px the audits use, so what the README shows is what the current build renders rather than an older one. It refuses to run if the mockup's theme bootstrap has changed, because the failure mode otherwise is two paper captures, one of them labelled graphite.
Two findings worth keeping:
- A contrast audit run while the browser pane was not compositing reported a
clean result that meant nothing — the page had laid out at its narrow
breakpoint, where the palette and inspector are
display: noneand were never checked. It happened a second time on a freshly opened tab, which does not inherit the previous tab's emulated size. The audit now assertsinnerWidth === 1440and throws otherwise, and reports how many nodes it inspected so a zero is readable as a pass rather than as silence. - The design detector once ran degraded and returned
[]. An empty result from a degraded tool is an undercount, not a pass.
- Paper is the default, and it is stamped before first paint so a dark host
never flashes through. The stylesheet still answers
prefers-color-schemeon its own for the no-script case. - A backward wire is the schematic wrapping, not a mistake. It needs a tighter bezier control offset than a forward one; scaling the offset with the span throws the curve outside the canvas on exactly the wire that already travels furthest.
- Node labels truncate with the full name in a tooltip. A fixed-width box that lets long names spill across neighbours is worse than one that clips.
- Hover on paper adds ink, not light. Lightening a hover on a white ground moves it towards invisible.
- The mockup shows a single-carrier link. The dual-polarization link is in
examples/dualpol_link.pyand is not on the canvas: at ~20 blocks the node text stops being readable at this canvas size, and a schematic nobody can read is not a better demonstration. At 18 blocks the current graph is already close to that ceiling — the eighteenth is an Eye Diagram, added because the eye in the results dock had been computed beside the graph rather than by it, and so never changed when the graph did. - No motion beyond the run pulse and the control transitions.
- The running, failed and stale states are now real, because the page calls the engine. A run in flight dims the canvas and swaps Run for Stop; a failure turns the engine dot red, names the kind of failure from the status the server chose (400 unreadable, 422 readable but unrunnable, 413 too large) and opens the log with the engine's own message in it; a graph edited since the last run marks the results dock stale rather than leaving numbers on screen that answer a question no longer being asked.
- The empty state exists now that the canvas can be emptied. It names the next action rather than describing the situation: someone looking at a blank canvas has already worked out that it is blank, and what they need is where to start and that ctrl+Z brings back what they just deleted.
- A refused connection is answered on the canvas, in one line at the bottom, rather than in the log. The log is for what a run did; this answers something the hand is still doing, and by the time you have looked at a panel to find it the gesture is over.