The fixed-width time readout wrapped to two lines for fractional times (dt 0.25
→ 't = 70.25'), growing the panel a row each frame and reflowing the whole
panel — the real flicker. Pin its width and forbid wrapping.
The default (no data-theme) resolves to DaisyUI's dark theme, whose
:root:not([data-theme]) selector outranks a custom light theme — so the
previous light-only override never reached buttons in dark mode. Force it with
an !important :root override that wins regardless of theme.
The playhead was a canvas line redrawn by redrawing the whole uPlot chart on
every frame, which flickers. Draw it as a DOM overlay that slides over the
canvas instead, so the canvas is only redrawn when the data actually changes.
A Stock can carry a display unit (°C, people, $, …): a new optional field, an
inspector text input, validation + round-trip in io, and a readout beside the
live value on the canvas. Equip the demonstrative samples (Water L, Balance $,
Coffee °C, Population/Epidemic people, Yeast cells). Also bumps the fill gauge
opacity for a more vivid level.
A simulation store owns one run and one playhead, so the chart and the canvas
read off the same instant. While the panel is open, Stocks show a live value and
a fill gauge, Flows and Converters their current value; a play/pause + scrubber
drives the playhead (a wall-clock-paced clock in usePlayback), and a marker
tracks it on the chart.
Draw the simulation's Stock trajectories with uPlot (ADR-0005): a real time
axis, hover-to-read values, and a playhead-marker hook the canvas animation
drives. SimChart owns the imperative uPlot lifecycle behind a small prop API.
Switch the body font to Sono and point the @import at
api.fonts.coollabs.io. The bare fonts.coollabs.io host serves the
marketing homepage (HTML), not CSS, so the @font-face rules never
loaded and the font silently fell back to system-ui.
Recast the S-curve as a Reinforcing inflow plus a crowding-driven die-off that
grows with Yeast², using only the existing proportional rule. Yeast now climbs
20 → ~1000 as a true sigmoid, and the detector still classifies it R + B — the
balancing loop stays visible, which a single "logistic" rule would have hidden.
Drops the carrying-capacity converter (a faithful one needs a divide rule).
- ResultsPanel: a Simulate panel that charts every Stock over time (hand-built
SVG), with start/stop/dt run controls and a divergence warning
- Inspector: edit a selected Stock's initial value or a Flow/Converter's rule
- store: undoable setInitialValue / setRule / setSimSpec actions
Give 10 of 11 samples initial values, rules, and a run window so they simulate
on load: Bathtub (linear), Savings/Escalation/Fixes-that-fail (Reinforcing),
Coffee (Balancing), Population, Epidemic (SIR), Tragedy (overshoot/collapse),
Drift (eroding goal), Predator-prey (damped). Limits to growth is left
diagram-only — its S-curve needs a saturating rule the vocabulary lacks.
Phase 2 brings Models to life. ADR-0004: behaviour comes from a small fixed
vocabulary of rules (Constant / Proportional / Gap) read over the inbound
Information Links, not free-form formulas — valid by construction.
- types: Rule union, SimSpec, initialValue; replaces the unused equation field
- simulation: forward-Euler engine, dependency-ordered evaluation, algebraic-loop
detection, non-negative stocks, and a divergence guard
- io: validate and round-trip the new fields (F8)
Vue Flow's open arrow renders at 0.625x the edge stroke (its 20-unit
viewBox is squeezed into a 12.5 marker box), so the chevron looked thin
against the 2.5px pipe. A marker strokeWidth of 20/12.5 cancels the
downscale, drawing the head at the line's weight at any width.
Rename the coffee outflow "heat loss" to "cooling" so the balancing loop
reads more naturally, and stack carrying capacity above the source in
limits to growth so its link to crowding is a clean horizontal hop.
Swap the filled-triangle arrowhead for an open arrow on flow pipes and
information links, and route flow pipes through a custom cubic edge whose
horizontal flatten arm scales with the vertical drop, not just the
horizontal gap. Vue Flow's default arm collapses toward zero when the
valve sits nearly above the stock, so a steep pipe stayed flat only in
its last pixels and the arrowhead detached from the visible line. The
pipe now keeps a real horizontal run into the stock's left edge at any
approach angle.
Snap all sample coordinates to multiples of 20 so they sit on the
editor grid and read cleanly. Computed midpoint() valves had to land
on grid points too, so a few endpoints were nudged (e.g. escalation
arsenals 300->280, limits-to-growth source -300->-280). Population is
re-laid as a grid cascade so its links no longer overlap the pipes.
Two more Meadows system traps: Fixes that fail (a balancing
road-building fix whose induced-driving side effect reinforces the
congestion it targets) and Drift to low performance (an eroding goal
that spirals actual performance and the standard downward).
Also re-lays-out the coupled-stock samples for readability: predator
and prey and escalation now use aligned two-row layouts, and fixes
that fail stacks its flow valves in one column so the backfire link
reads as a clean vertical.
Add a second tier past the four-model primer: Limits to growth, Predator
and prey, and Epidemic each introduce one structure the primer never
shows, plus two of Donella Meadows' system traps — Tragedy of the
commons and Escalation — to contrast healthy dynamics.
Dragging a Flow back onto a Stock it already touches is refused (an
Information Link never targets a Stock), but that gesture is almost
always someone trying to "close the loop" by hand. The Flow's own pipe
already is the link back, so surface a self-dismissing hint saying so
instead of letting the drop die silently (F4: guide, don't nag).
Restoring on reload fit the view synchronously in onRestore, framing
against unmeasured 0×0 nodes, so the model landed jammed top-left. Arm
the same post-measure onNodesInitialized fit the sample/import loads use.
pnpm 11 defaults strictDepBuilds to true, so a dependency build script
that is not pre-decided in allowBuilds becomes a hard error. The install
layer only copied package.json and the lockfile, so the vue-demi build
decision in pnpm-workspace.yaml was absent and the build failed.
Badge centroids now read Vue Flow's live computedPosition instead of the
store, which commits positions only on drop — so badges no longer lag a
node drag. Centres use real node dimensions, dropping the hard-coded nudge.
Loops are derived from the wiring, never stored (ADR-0001): a signed
graph (link polarities plus flows' inherent inflow/outflow) is searched
with Tarjan SCC and a capped enumeration, classifying each cycle R or B
by the parity of its negative links. Both the result count and the
search are bounded so detection can never blow the frame budget.
Move the visual above §1 so the assembled picture serves as an
at-a-glance overview; flip its cross-reference from "tables above"
to "tables below" to match the new position.