Compare commits

...

11 Commits

Author SHA1 Message Date
Julien Calixte
37017c1175 docs: align design docs and README with the editor 2026-06-20 09:37:50 +02:00
Julien Calixte
d3119e8a20 feat(editor): wire the canvas and mount the editor 2026-06-20 09:37:50 +02:00
Julien Calixte
03848ae893 feat(editor): add node, edge, palette, and loop-overlay components 2026-06-20 09:37:42 +02:00
Julien Calixte
0416581799 feat(editor): add shared loop-highlight state 2026-06-20 09:37:32 +02:00
Julien Calixte
0aaa4114d9 feat(store): add model store with snapshot undo/redo and loops 2026-06-20 09:37:32 +02:00
Julien Calixte
6d03d706a0 feat(model): add a gallery of sample models 2026-06-20 09:37:32 +02:00
Julien Calixte
5c3f467a24 feat(model): detect and classify feedback loops
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.
2026-06-20 09:37:26 +02:00
Julien Calixte
fd94a8b7ed feat(model): guard connections to preserve valid structure 2026-06-20 09:37:19 +02:00
Julien Calixte
8fac6f4694 feat(model): project the model into a Vue Flow graph 2026-06-20 09:37:19 +02:00
Julien Calixte
95990b0d1f feat(model): add domain types and model factory 2026-06-20 09:37:18 +02:00
Julien Calixte
2c0d4b842f chore(deps): add Vue Flow and Pinia with pnpm workspace config
pnpm-workspace.yaml opts vue-demi out of build scripts; Vue Flow pulls
it in transitively and pnpm 11 otherwise fails the install on it.
2026-06-20 09:37:13 +02:00
29 changed files with 2061 additions and 107 deletions

View File

@@ -2,7 +2,7 @@
The ubiquitous language for **meadows** — a web app for building systems-thinking
models (stock-and-flow diagrams with feedback loops) in the spirit of Donella
Meadows' *Thinking in Systems*. The diagram is the product now; numeric simulation
Meadows' _Thinking in Systems_. The diagram is the product now; numeric simulation
is a planned later phase, so the language is chosen to be simulation-ready.
Part of [meadows](./README.md); see [DESIGN.md](./DESIGN.md) for how these terms
@@ -12,7 +12,7 @@ map to goals, functions, and components.
**System**:
The real-world thing being studied — an interconnected set of elements producing
a behaviour over time. It is the *referent*, never the artifact in the app.
a behaviour over time. It is the _referent_, never the artifact in the app.
_Avoid_: model, diagram, map (those are the artifact, not the real thing).
**Model**:

191
DESIGN.md
View File

@@ -256,10 +256,10 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
}
\begin{document}
% --- meadows: 3 goals (WHATs) x 9 functions (HOWs); no competitor zone;
% --- meadows: 4 goals (WHATs) x 10 functions (HOWs); no competitor zone;
% basement = Target / Importance Sigma / Relative %. ---
\def\qfdNW{3}
\def\qfdNH{9}
\def\qfdNW{4}
\def\qfdNH{10}
\def\qfdWhatW{4.2}
\def\qfdBasementN{3}
\def\qfdWhatsTitle{Goals (WHATs)}
@@ -270,23 +270,26 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
\pgfmathsetmacro{\qfdWhatTextW}{\qfdWhatW - 0.2}
\foreach \r/\t in {1/{G1 Build with near-zero friction},
2/{G2 Feedback surfaces as live insight},
3/{G3 Models safe \& portable}}
3/{G3 Models safe \& portable},
4/{G4 Learn the language by example}}
\node[anchor=west, font=\scriptsize, text width=\qfdWhatTextW cm, align=left]
at ({\qfdLeftEdge + 0.1}, {-\r + 0.5}) {\t};
\foreach \r/\imp in {1/9, 2/8, 3/7}
\foreach \r/\imp in {1/9, 2/8, 3/7, 4/4}
\node[font=\scriptsize] at ({-\qfdImpW/2}, {-\r + 0.5}) {\imp};
% --- HOWs (functions, rotated) ---
\foreach \c/\t in {1/{F1 place node}, 2/{F2 connect},
3/{F3 smooth canvas}, 4/{F4 valid inline},
5/{F5 loops live}, 6/{F6 polarity},
7/{F7 autosave}, 8/{F8 round-trip}, 9/{F9 undo/redo}}
7/{F7 autosave}, 8/{F8 round-trip}, 9/{F9 undo/redo},
10/{F10 samples}}
\node[rotate=90, anchor=west, font=\scriptsize] at ({\c - 0.5}, 0.15) {\t};
% --- Relation matrix (col c = function, row r = goal); strength S/M/W ---
\foreach \c/\r/\s in {1/1/S, 2/1/S, 3/1/S, 4/1/M, 5/1/W, 6/1/M, 7/1/W, 9/1/M,
\foreach \c/\r/\s in {1/1/S, 2/1/S, 3/1/S, 4/1/M, 5/1/W, 6/1/M, 7/1/W, 9/1/M, 10/1/W,
2/2/M, 3/2/W, 4/2/M, 5/2/S, 6/2/S, 9/2/W,
4/3/W, 7/3/S, 8/3/S, 9/3/S}
4/3/W, 7/3/S, 8/3/S, 9/3/S,
5/4/M, 6/4/W, 10/4/S}
\node[qfdrel/\s] at ({\c - 0.5}, {-\r + 0.5}) {};
% --- Roof correlations (C-i-j, i<j); from DESIGN.md §5 ---
@@ -296,15 +299,16 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
\node[font=\scriptsize] at (C-3-9) {$-$}; % F3 x F9 mild conflict
\node[font=\scriptsize] at (C-4-5) {$+$}; % F4 o F5 mild reinforce
\node[font=\scriptsize] at (C-5-6) {$+\!+$}; % F5 o F6 strong reinforce
\node[font=\scriptsize] at (C-7-10) {$-$}; % F7 x F10 conflict (load clears history)
% --- Basement: Target / Importance Sigma / Relative %, + row labels ---
\foreach \rr/\lbl in {0/{Target}, 1/{Imp.\ $\Sigma$}, 2/{Rel.\ \%}}
\node[anchor=east, font=\scriptsize\itshape]
at (-0.1, {-\qfdNW - 0.5 - \rr}) {\lbl};
\foreach \c/\tgt/\sig/\rel in
{1/{1 gest.}/81/11, 2/{1 drag}/105/14, 3/{60 fps}/89/12,
4/{0 inv.}/58/8, 5/{$\le$16ms}/81/11, 6/{dflt $\pm$}/99/13,
7/{$\le$500ms}/72/10, 8/{id.}/63/8, 9/{$\ge$50}/98/13} {
{1/{1 gest.}/81/10, 2/{1 drag}/105/13, 3/{60 fps}/89/11,
4/{0 inv.}/58/7, 5/{$\le$16ms}/93/11, 6/{dflt $\pm$}/103/13,
7/{$\le$500ms}/72/9, 8/{id.}/63/8, 9/{$\ge$50}/98/12, 10/{1 load}/45/6} {
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 0.5}) {\tgt};
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 1.5}) {\sig};
\node[font=\scriptsize\bfseries] at ({\c - 0.5}, {-\qfdNW - 2.5}) {\rel};
@@ -317,98 +321,113 @@ symbols: ● strong (9) · ◐ medium (3) · △ weak (1). Roof: `++`/`+` reinfo
## 1. Goals — the WHATs
| ID | Goal | Weight | Source |
|-----|-----------------------------------------------|:------:|---------------------------------|
| G1 | Build a model with near-zero friction | 9 | original request; CONTEXT.md |
| G2 | Feedback structure surfaces as insight (live) | 8 | original request; ADR-0001 |
| G3 | Models are safe and portable | 7 | original request |
| ID | Goal | Weight | Source |
| --- | --------------------------------------------- | :----: | ---------------------------- |
| G1 | Build a model with near-zero friction | 9 | original request; CONTEXT.md |
| G2 | Feedback structure surfaces as insight (live) | 8 | original request; ADR-0001 |
| G3 | Models are safe and portable | 7 | original request |
| G4 | Learn the language by example | 4 | README (Meadows spirit) |
## 2. Functions — the HOWs
| ID | Function | Dir | Target (now) | Target (future) |
|-----|----------------------------------------------------------------------|:---:|--------------------------------------|------------------------|
| F1 | Place a node in one gesture (palette → canvas, auto-named) | ↓ | 1 click/drag, 0 dialogs | — |
| F2 | Connect by direct manipulation (auto source/sink cloud on open end) | ↓ | 1 drag, 0 menus | — |
| F3 | Render & manipulate the canvas smoothly | ↑ | 60 fps pan/zoom/drag @ ≤150 elements | ≥300 elements |
| F4 | Enforce valid structure inline | → | 0 invalid models persistable | — |
| F5 | Detect & classify loops live (R/B) on every structural change | ↓ | ≤16 ms recompute @ ≤150 elements | ≤16 ms @ ≥300 |
| F6 | Capture link polarity frictionlessly | ↓ | default on create, 1-click toggle | — |
| F7 | Autosave locally, continuously | → | debounced ≤500 ms; survive reload | — |
| F8 | Round-trip Models as versioned JSON | → | export→import identity; `version` | schema migrations |
| F9 | Undo/redo every model change | ↑ | ≥50 steps, all structural ops | — |
| ID | Function | Dir | Target (now) | Target (future) |
| --- | ------------------------------------------------------------------- | :-: | ------------------------------------ | ----------------------- |
| F1 | Place a node in one gesture (palette → canvas, auto-named) | ↓ | 1 click/drag, 0 dialogs | — |
| F2 | Connect by direct manipulation (auto source/sink cloud on open end) | ↓ | 1 drag, 0 menus | — |
| F3 | Render & manipulate the canvas smoothly | ↑ | 60 fps pan/zoom/drag @ ≤150 elements | ≥300 elements |
| F4 | Enforce valid structure inline | → | 0 invalid models persistable | — |
| F5 | Detect & classify loops live (R/B) on every structural change | ↓ | ≤16 ms recompute @ ≤150 elements | ≤16 ms @ ≥300 |
| F6 | Capture link polarity frictionlessly | ↓ | default on create, 1-click toggle | — |
| F7 | Autosave locally, continuously | → | debounced ≤500 ms; survive reload | — |
| F8 | Round-trip Models as versioned JSON | → | export→import identity; `version` | schema migrations |
| F9 | Undo/redo every model change | ↑ | ≥50 steps, all structural ops | — |
| F10 | Load a curated sample on demand (covers the whole language) | → | 1 click; set spans whole language | more samples; shareable |
## 3. Cascade — Goals → Functions → How → Components
- **G1** Build a model with near-zero friction _W:9_
- **F1** Place a node in one gesture _↓ 1 click/drag_
- **G1** Build a model with near-zero friction _W:9_
- **F1** Place a node in one gesture _↓ 1 click/drag_
- **How**: Vue Flow drag-from-palette → store action creates the node
- **Component**: C1 Vue Flow canvas · C2 custom node components · C6 projection
- **F2** Connect by direct manipulation (auto cloud on open end) _↓ 1 drag_
- **F2** Connect by direct manipulation (auto cloud on open end) _↓ 1 drag_
- **How**: drag from a node handle; open end auto-spawns a Cloud
- **Component**: C2 node handles · C3 edge components · C5 store actions · C7 structure guard
- **F3** Render & manipulate smoothly _↑ 60 fps @ ≤150_
- **F3** Render & manipulate smoothly _↑ 60 fps @ ≤150_
- **How**: Vue Flow viewport (GPU transforms); Model→VueFlow projection memoised
- **Component**: C1 Vue Flow canvas · C6 projection
- **F9** Undo/redo _↑ ≥50 steps_
- **F9** Undo/redo _↑ ≥50 steps_
- **How**: ring buffer of deep-cloned Model snapshots in the store
- **Component**: C5 model store (history)
- **G2** Feedback structure surfaces as insight (live) _W:8_
- **F5** Detect & classify loops live _↓ ≤16 ms @ ≤150_
- **G2** Feedback structure surfaces as insight (live) _W:8_
- **F5** Detect & classify loops live _↓ ≤16 ms @ ≤150_
- **How**: derive directed graph → Tarjan SCC → capped Johnson → polarity product (R/B)
- **How**: SCC-only — _rejected (loses per-loop insight), see T3_
- **Component**: C8 loop engine · C11 loop overlay
- **F6** Capture link polarity frictionlessly _↓ default + toggle_
- **F6** Capture link polarity frictionlessly _↓ default + toggle_
- **How**: InfoLink defaults to `+` on create; 1-click `+/` toggle on the edge badge
- **Component**: C3 edge components · C5 store actions
- **F4** Enforce valid structure inline _→ 0 invalid persistable_
- **How**: guard *guides* (disallow invalid handle targets) rather than erroring after
- **F4** Enforce valid structure inline _→ 0 invalid persistable_
- **How**: guard _guides_ (disallow invalid handle targets) rather than erroring after
- **Component**: C7 structure guard · C4 domain types
- **G3** Models are safe and portable _W:7_
- **F7** Autosave locally, continuously _→ ≤500 ms debounce_
- **G3** Models are safe and portable _W:7_
- **F7** Autosave locally, continuously _→ ≤500 ms debounce_
- **How**: store subscribes to Model changes → debounced `repository.save()`
- **Component**: C5 store · C9 ModelRepository (IndexedDB/idb)
- **F8** Round-trip Models as versioned JSON _→ lossless_
- **F8** Round-trip Models as versioned JSON _→ lossless_
- **How**: serialise Model (`version` field) → download; import → validate → load
- **How**: PouchDB/cloud sync — _deferred (needs a backend), see Tensions_
- **Component**: C10 import/export · C4 domain types
- **F9** (also serves G3 as a safety net) → C5 model store
- **G4** Learn the language by example _W:4_
- **F10** Load a curated sample on demand _→ 1 click, set spans the language_
- **How**: ship example Models as pure data (the same factory constructors the
store uses); a header menu loads one via `store.setModel` + `fitView`
- **How**: interactive tutorial / guided tour — _rejected (a tutorial
framework to maintain; a readable, editable gallery teaches by example and
doubles as instant content), see T6_
- **Component**: C13 sample gallery · C5 store (`setModel`) · C1 canvas (`fitView`)
- **F5** (seeing loops classified R/B _is_ the lesson) → C8 loop engine · C11 overlay
- **F6** (visible polarity reinforces the lesson) → C3 edge components
## 4. House — Goals × Functions
Cells: strength 9/3/1. Importance Σ = Σ(weight × strength).
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 | F9 |
|----------|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
| G1 (9) | 9 | 9 | 9 | 3 | 1 | 3 | 1 | | 3 |
| G2 (8) | | 3 | 1 | 3 | 9 | 9 | | | 1 |
| G3 (7) | | | | 1 | | | 9 | 9 | 9 |
| **Σ** | 81 | 105| 89 | 58 | 81 | 99 | 72 | 63 | 98 |
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 | F9 | F10 |
| ------ | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
| G1 (9) | 9 | 9 | 9 | 3 | 1 | 3 | 1 | | 3 | 1 |
| G2 (8) | | 3 | 1 | 3 | 9 | 9 | | | 1 | |
| G3 (7) | | | | 1 | | | 9 | 9 | 9 | |
| G4 (4) | | | | | 3 | 1 | | | | 9 |
| **Σ** | 81 | 105 | 89 | 58 | 93 | 103 | 72 | 63 | 98 | 45 |
**Top engineering priorities (from importance):** the matrix puts **F2 (connect, 105)**, **F6 (polarity, 99)** and **F9 (undo, 98)** above the headline detector **F5 (81)**. The lesson: loop insight is only as good as its *inputs* and *ergonomics*getting wiring (F2) and polarity capture (F6) effortless matters as much as the detection itself, and a reliable safety net (F9) underwrites fearless building. F4 (58) is real but lowest-leverage — keep it lightweight (guide, don't nag).
**Top engineering priorities (from importance):** the matrix still puts **F2 (connect, 105)**, **F6 (polarity, 103)** and **F9 (undo, 98)** at the top, with the headline detector **F5** climbing to **93** — G4 reinforces it, because _seeing_ a loop classified R/B is itself the lesson. The lesson holds: loop insight is only as good as its _inputs_ and _ergonomics_effortless wiring (F2) and polarity capture (F6) matter as much as detection, and a reliable safety net (F9) underwrites fearless building. **F10 (samples, 45)** is the lowest-leverage function, exactly right for a supporting aid: it earns its place by being nearly free — static Models reusing the whole pipeline, no new model code — not by priority. F4 (58) stays low: keep it lightweight (guide, don't nag).
## 5. Roof — Function × Function tradeoffs
`◎` strong reinforce · `○` mild reinforce · `×` mild conflict · `⊗` strong conflict.
| | F2 | F3 | F4 | F5 | F6 | F9 |
|--------|:--:|:--:|:--:|:--:|:--:|:--:|
| **F2** | — | | × | | ◎ | |
| **F3** | | — | | ⊗ | | × |
| **F4** | | | — | ○ | | |
| **F5** | | | | — | ◎ | |
| **F6** | | | | | — | |
| **F9** | | | | | | — |
| | F2 | F3 | F4 | F5 | F6 | F9 |
| ------ | :-: | :-: | :-: | :-: | :-: | :-: |
| **F2** | — | | × | | ◎ | |
| **F3** | | — | | ⊗ | | × |
| **F4** | | | — | ○ | | |
| **F5** | | | | — | ◎ | |
| **F6** | | | | | — | |
| **F9** | | | | | | — |
**Conflicts that actually shape the design:**
- **F5 ⊗ F3** — live loop enumeration on every change vs the 60 fps frame budget. The design-shaping conflict. Mitigated by the **cap** (≤200 cycles) + running detection **off the render frame** (debounce / idle callback / worker). Owned by ADR-0001 + the §7 budget.
- **F2 × F4** — guardrails can add friction to connecting. Mitigated by *guiding* the gesture (invalid handles refuse the drop) instead of erroring afterwards.
- **F2 × F4** — guardrails can add friction to connecting. Mitigated by _guiding_ the gesture (invalid handles refuse the drop) instead of erroring afterwards.
- **F3 × F9** — snapshot cloning on every change costs memory/time. Bounded by small models + a fixed-size ring buffer; revisit with structural sharing only if profiling demands.
- **F2 ◎ F6, F5 ◎ F6** — polarity is captured *in* the connect gesture and *is* the detector's input; these reinforce, so build them together.
- **F2 ◎ F6, F5 ◎ F6** — polarity is captured _in_ the connect gesture and _is_ the detector's input; these reinforce, so build them together.
- **F10 × F7** — loading a sample calls `setModel`, which _replaces the whole document and clears undo history_. Against autosave (F7), one click could wipe real work. Mitigated by confirming the replace only when the canvas is non-empty (frictionless on the empty start). Owned by T6. (F7 and F10 sit outside the core roof table above; this is the new tension this release.)
## 6. Components & Function → Component map
| ID | Component | ADR |
|-----|-----------------------------------------------------|----------|
| ID | Component | ADR |
| --- | --------------------------------------------------- | -------- |
| C1 | Vue Flow canvas (`@vue-flow/core` + addons) | ADR-0002 |
| C2 | Custom node components (Stock/Flow/Converter/Cloud) | ADR-0002 |
| C3 | Custom edge components (InfoLink badge, Flow pipe) | ADR-0002 |
@@ -421,38 +440,41 @@ Cells: strength 9/3/1. Importance Σ = Σ(weight × strength).
| C10 | Import/Export (versioned JSON `io.ts`) | — |
| C11 | Loop overlay UI (R/B badges, hover highlight) | ADR-0001 |
| C12 | Palette + chrome (DaisyUI toolbar, model list) | — |
| C13 | Sample gallery (`model/samples.ts` + header menu) | — |
| | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | C11 | C12 |
|-----|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:---:|:---:|:---:|
| F1 | 9 | 9 | | | 3 | 9 | | | | | | 9 |
| F2 | 9 | 9 | 9 | | 9 | 3 | 9 | | | | | |
| F3 | 9 | 3 | 3 | | | 9 | | | | | | |
| F4 | | 1 | 1 | 3 | 3 | | 9 | | | | | |
| F5 | | | | 3 | 1 | | | 9 | | | 9 | |
| F6 | | | 9 | 3 | 3 | | | 1 | | | | |
| F7 | | | | | 9 | | | | 9 | | | |
| F8 | | | | 3 | | | | | 1 | 9 | | |
| F9 | | | | | 9 | | | | | | | |
| | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | C11 | C12 | C13 |
| --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
| F1 | 9 | 9 | | | 3 | 9 | | | | | | 9 | |
| F2 | 9 | 9 | 9 | | 9 | 3 | 9 | | | | | | |
| F3 | 9 | 3 | 3 | | | 9 | | | | | | | |
| F4 | | 1 | 1 | 3 | 3 | | 9 | | | | | | |
| F5 | | | | 3 | 1 | | | 9 | | | 9 | | |
| F6 | | | 9 | 3 | 3 | | | 1 | | | | | |
| F7 | | | | | 9 | | | | 9 | | | | |
| F8 | | | | 3 | | | | | 1 | 9 | | | |
| F9 | | | | | 9 | | | | | | | | |
| F10 | 1 | | | | 3 | | | | | | | | 9 |
## 7. Critical performance budget
| Rank | Function | Target | Watched on | If we miss it |
|------|----------|----------------------------|-------------------------------------|-----------------------------------------------------------|
| 1 | F5 | ≤16 ms recompute @ ≤150 | bench on synthetic dense graphs | enforce cycle cap; move detection off-frame (idle/worker); debounce |
| 2 | F3 | 60 fps pan/zoom/drag @ ≤150| frame profiling in DevTools | memoise projection; simplify node DOM; virtualise off-screen nodes |
| 3 | F2 | 1 drag, 0 menus | manual UX walkthrough | simplify handle model; reduce connect steps |
| 4 | F7 | ≤500 ms debounce, 0 loss | reload-after-edit integration test | flush on `visibilitychange`/`beforeunload` |
| 5 | F8 | export→import identity | property test (round-trip) | pin schema; add migration on `version` bump |
| Rank | Function | Target | Watched on | If we miss it |
| ---- | -------- | --------------------------- | ---------------------------------- | ------------------------------------------------------------------- |
| 1 | F5 | ≤16 ms recompute @ ≤150 | bench on synthetic dense graphs | enforce cycle cap; move detection off-frame (idle/worker); debounce |
| 2 | F3 | 60 fps pan/zoom/drag @ ≤150 | frame profiling in DevTools | memoise projection; simplify node DOM; virtualise off-screen nodes |
| 3 | F2 | 1 drag, 0 menus | manual UX walkthrough | simplify handle model; reduce connect steps |
| 4 | F7 | ≤500 ms debounce, 0 loss | reload-after-edit integration test | flush on `visibilitychange`/`beforeunload` |
| 5 | F8 | export→import identity | property test (round-trip) | pin schema; add migration on `version` bump |
## 8. Tradeoffs — Got / Paid / ADR
| ID | Tradeoff | Got | Paid | ADR |
|-----|--------------------------------------------|--------------------------------------------------|-----------------------------------------------------|----------|
| T1 | Vue Flow over hand-rolled SVG | fast F1F3, batteries-included editor mechanics | a dependency owning the view layer + a Model↔VueFlow projection to maintain | ADR-0002 |
| T2 | Flow-as-node + materialised Clouds | uniform graph for loops/render; info-links can target flows; sim-clean | Cloud lifecycle management; a node that renders as a pipe | ADR-0003 |
| T3 | Enumerate loops with a cap | per-loop R/B insight (G2) within the frame budget | capped completeness on pathological graphs ("+N more") | ADR-0001 |
| T4 | IndexedDB(`idb`) behind `ModelRepository` | size headroom + swappable engine (sync later) | async wrapper + a small dependency; no sync today | — |
| T5 | Snapshot undo over command pattern | simplicity and correctness | memory per snapshot (bounded by ring buffer + small models) | — |
| ID | Tradeoff | Got | Paid | ADR |
| --- | ----------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------- |
| T1 | Vue Flow over hand-rolled SVG | fast F1F3, batteries-included editor mechanics | a dependency owning the view layer + a Model↔VueFlow projection to maintain | ADR-0002 |
| T2 | Flow-as-node + materialised Clouds | uniform graph for loops/render; info-links can target flows; sim-clean | Cloud lifecycle management; a node that renders as a pipe | ADR-0003 |
| T3 | Enumerate loops with a cap | per-loop R/B insight (G2) within the frame budget | capped completeness on pathological graphs ("+N more") | ADR-0001 |
| T4 | IndexedDB(`idb`) behind `ModelRepository` | size headroom + swappable engine (sync later) | async wrapper + a small dependency; no sync today | — |
| T5 | Snapshot undo over command pattern | simplicity and correctness | memory per snapshot (bounded by ring buffer + small models) | — |
| T6 | Samples load by replacing the whole Model | a sample is _just a Model_ — no special load path; reuses the whole pipeline; one click | the load is destructive (`setModel` clears undo history); guarded by a confirm on a non-empty canvas | — |
### Tensions being watched (unresolved by design)
@@ -464,6 +486,7 @@ Cells: strength 9/3/1. Importance Σ = Σ(weight × strength).
- **Brief vocabulary vs glossary.** The original request said "functions," "arrows," "faucets"; DESIGN uses the resolved CONTEXT.md terms verbatim — **Converter**, **Flow**/**Information Link**, faucet dropped.
- **G3 ambiguity.** "Safe and portable" initially read as possible cloud sync; clarified — G3 is satisfied by **local persistence + JSON export**, not sync (PouchDB deferred, see Tensions).
- **Storage default drift.** Initial recommendation was localStorage; resolved to **IndexedDB via `idb`**, behind a `ModelRepository` interface so the engine choice is no longer hard-to-reverse.
- **Teaching intent unrecorded.** The project is framed as Meadows-inspired (README) and the whole vocabulary is pedagogical (CONTEXT.md), yet no Goal captured "teach the language" until now — the intent lived only in the vision. Surfaced and recorded as **G4** (weight 4), answered by the sample gallery (**F10** / **C13**).
---

View File

@@ -1,7 +1,7 @@
# meadows
A canvas for thinking in systems — stocks, flows, converters, and feedback loops.
Inspired by Donella Meadows' *Thinking in Systems*.
Inspired by Donella Meadows' _Thinking in Systems_.
Deployed at https://meadows.apoena.dev

View File

@@ -6,7 +6,7 @@ A **Feedback Loop** in a **Model** is a cycle in the wiring (Stock → Informati
Link(s)/Flow → same Stock). We chose to **derive** loops by detecting cycles and
classifying each as **Reinforcing** (even number of `` polarities) or
**Balancing** (odd), rather than storing them as first-class objects the user
hand-labels. This makes loops *discovered insight* that can never disagree with
hand-labels. This makes loops _discovered insight_ that can never disagree with
the actual structure — the point of a systems-thinking tool.
## Considered Options

View File

@@ -2,8 +2,8 @@
_Part of [meadows](../../README.md) · see [DESIGN.md](../../DESIGN.md)._
In the domain `Model`, a **Flow** is a *node* carrying `source` and `target`
references (each to a Stock or Cloud), not an edge. The pipe you see is *rendered*
In the domain `Model`, a **Flow** is a _node_ carrying `source` and `target`
references (each to a Stock or Cloud), not an edge. The pipe you see is _rendered_
from those references. The **Source/Sink** boundary is a **materialised Cloud
node**, so every Flow connects node→node uniformly — no `null` ends.

View File

@@ -13,7 +13,11 @@
"fmt:check": "oxfmt --check"
},
"dependencies": {
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"daisyui": "^5.5.23",
"pinia": "^3.0.4",
"vue": "^3.5.34"
},
"devDependencies": {

268
pnpm-lock.yaml generated
View File

@@ -8,9 +8,21 @@ importers:
.:
dependencies:
'@vue-flow/background':
specifier: ^1.3.2
version: 1.3.2(@vue-flow/core@1.48.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))
'@vue-flow/controls':
specifier: ^1.1.3
version: 1.1.3(@vue-flow/core@1.48.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))
'@vue-flow/core':
specifier: ^1.48.2
version: 1.48.2(vue@3.5.38(typescript@6.0.3))
daisyui:
specifier: ^5.5.23
version: 5.5.23
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
vue:
specifier: ^3.5.34
version: 3.5.38(typescript@6.0.3)
@@ -541,6 +553,9 @@ packages:
'@types/node@24.13.2':
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@vitejs/plugin-vue@6.0.7':
resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -557,6 +572,23 @@ packages:
'@volar/typescript@2.4.28':
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
'@vue-flow/background@1.3.2':
resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
'@vue-flow/controls@1.1.3':
resolution: {integrity: sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
'@vue-flow/core@1.48.2':
resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==}
peerDependencies:
vue: ^3.3.0
'@vue/compiler-core@3.5.38':
resolution: {integrity: sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==}
@@ -569,6 +601,15 @@ packages:
'@vue/compiler-ssr@3.5.38':
resolution: {integrity: sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==}
'@vue/devtools-api@7.7.9':
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
'@vue/devtools-kit@7.7.9':
resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==}
'@vue/devtools-shared@7.7.9':
resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==}
'@vue/language-core@3.3.5':
resolution: {integrity: sha512-UkKu5nhX89fg4VhlG/FOeI10G3cj/7radKT/cy9BT4Q9qJmJlSTAc/dP63Xqs29aypN4f39xUV6PsLNk/dcD6g==}
@@ -600,12 +641,66 @@ packages:
vue:
optional: true
'@vueuse/core@10.11.1':
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
'@vueuse/metadata@10.11.1':
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
'@vueuse/shared@10.11.1':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
alien-signals@3.2.1:
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
daisyui@5.5.23:
resolution: {integrity: sha512-xuheNUSL4T6ZVtWXoioqcNkjoyGX85QTDz4HTw2aBPfqk4fuMjax5HDo8qCmpV6M1YN8bGvfx5BpYCoDeRlt+A==}
@@ -641,6 +736,13 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
is-what@5.5.0:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
@@ -722,6 +824,9 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
@@ -759,6 +864,9 @@ packages:
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -766,10 +874,22 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pinia@3.0.4:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies:
typescript: '>=4.5.0'
vue: ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rolldown@1.0.3:
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -779,6 +899,14 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
superjson@2.2.6:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
tailwindcss@4.3.1:
resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==}
@@ -851,6 +979,17 @@ packages:
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-tsc@3.3.5:
resolution: {integrity: sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==}
hasBin: true
@@ -1166,6 +1305,8 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/web-bluetooth@0.0.20': {}
'@vitejs/plugin-vue@6.0.7(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))(vue@3.5.38(typescript@6.0.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
@@ -1184,6 +1325,27 @@ snapshots:
path-browserify: 1.0.1
vscode-uri: 3.1.0
'@vue-flow/background@1.3.2(@vue-flow/core@1.48.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))':
dependencies:
'@vue-flow/core': 1.48.2(vue@3.5.38(typescript@6.0.3))
vue: 3.5.38(typescript@6.0.3)
'@vue-flow/controls@1.1.3(@vue-flow/core@1.48.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))':
dependencies:
'@vue-flow/core': 1.48.2(vue@3.5.38(typescript@6.0.3))
vue: 3.5.38(typescript@6.0.3)
'@vue-flow/core@1.48.2(vue@3.5.38(typescript@6.0.3))':
dependencies:
'@vueuse/core': 10.11.1(vue@3.5.38(typescript@6.0.3))
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-zoom: 3.0.0
vue: 3.5.38(typescript@6.0.3)
transitivePeerDependencies:
- '@vue/composition-api'
'@vue/compiler-core@3.5.38':
dependencies:
'@babel/parser': 7.29.7
@@ -1214,6 +1376,24 @@ snapshots:
'@vue/compiler-dom': 3.5.38
'@vue/shared': 3.5.38
'@vue/devtools-api@7.7.9':
dependencies:
'@vue/devtools-kit': 7.7.9
'@vue/devtools-kit@7.7.9':
dependencies:
'@vue/devtools-shared': 7.7.9
birpc: 2.9.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
speakingurl: 14.0.1
superjson: 2.2.6
'@vue/devtools-shared@7.7.9':
dependencies:
rfdc: 1.4.1
'@vue/language-core@3.3.5':
dependencies:
'@volar/language-core': 2.4.28
@@ -1253,10 +1433,71 @@ snapshots:
typescript: 6.0.3
vue: 3.5.38(typescript@6.0.3)
'@vueuse/core@10.11.1(vue@3.5.38(typescript@6.0.3))':
dependencies:
'@types/web-bluetooth': 0.0.20
'@vueuse/metadata': 10.11.1
'@vueuse/shared': 10.11.1(vue@3.5.38(typescript@6.0.3))
vue-demi: 0.14.10(vue@3.5.38(typescript@6.0.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/metadata@10.11.1': {}
'@vueuse/shared@10.11.1(vue@3.5.38(typescript@6.0.3))':
dependencies:
vue-demi: 0.14.10(vue@3.5.38(typescript@6.0.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
alien-signals@3.2.1: {}
birpc@2.9.0: {}
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
csstype@3.2.3: {}
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-selection@3.0.0: {}
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
daisyui@5.5.23: {}
detect-libc@2.1.2: {}
@@ -1279,6 +1520,10 @@ snapshots:
graceful-fs@4.2.11: {}
hookable@5.5.3: {}
is-what@5.5.0: {}
jiti@2.7.0: {}
lightningcss-android-arm64@1.32.0:
@@ -1334,6 +1579,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
mitt@3.0.1: {}
muggle-string@0.4.1: {}
nanoid@3.3.13: {}
@@ -1386,16 +1633,27 @@ snapshots:
path-browserify@1.0.1: {}
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {}
picomatch@4.0.4: {}
pinia@3.0.4(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)):
dependencies:
'@vue/devtools-api': 7.7.9
vue: 3.5.38(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
postcss@8.5.15:
dependencies:
nanoid: 3.3.13
picocolors: 1.1.1
source-map-js: 1.2.1
rfdc@1.4.1: {}
rolldown@1.0.3:
dependencies:
'@oxc-project/types': 0.133.0
@@ -1419,6 +1677,12 @@ snapshots:
source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
superjson@2.2.6:
dependencies:
copy-anything: 4.0.5
tailwindcss@4.3.1: {}
tapable@2.3.3: {}
@@ -1451,6 +1715,10 @@ snapshots:
vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.38(typescript@6.0.3)):
dependencies:
vue: 3.5.38(typescript@6.0.3)
vue-tsc@3.3.5(typescript@6.0.3):
dependencies:
'@volar/typescript': 2.4.28

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
allowBuilds:
vue-demi: false

View File

@@ -1,21 +1,7 @@
<script setup lang="ts">
const appName = "meadows"
import Editor from "@/components/Editor.vue"
</script>
<template>
<main class="min-h-screen bg-base-200 flex items-center justify-center p-6">
<div class="card w-full max-w-md bg-base-100 shadow-xl">
<div class="card-body items-center text-center">
<img src="/favicon.svg" alt="" class="size-12" />
<h1 class="card-title text-3xl font-semibold">{{ appName }}</h1>
<p class="text-base-content/70">
A canvas for thinking in systems stocks, flows, faucets, and feedback loops.
</p>
<div class="card-actions mt-4">
<button class="btn btn-primary" disabled>Start a system</button>
</div>
<p class="text-xs text-base-content/40 mt-2">Scaffold ready. Editor coming next.</p>
</div>
</div>
</main>
<Editor />
</template>

294
src/components/Editor.vue Normal file
View File

@@ -0,0 +1,294 @@
<script setup lang="ts">
/**
* Editor (C1) — the Vue Flow canvas and chrome.
*
* The Model store is the source of truth; `nodes`/`edges` here are a derived
* projection bound one-way into Vue Flow (ADR-0002). Vue Flow owns interaction
* (pan/zoom/drag/select) on its internal copy; we translate the gestures we care
* about back into store actions:
* - drag start → take an undo restore point
* - drag stop → commit final positions
* - Delete/Backspace → remove selected nodes (Vue Flow's own delete is off, so
* it can't delete from its copy behind the Model's back)
* After each store mutation the projection recomputes and Vue Flow reconciles by
* id, so positions never fight.
*/
import { Background } from "@vue-flow/background"
import { Controls } from "@vue-flow/controls"
import {
type Connection,
ErrorCode,
isErrorOfType,
type OnConnectStartParams,
Panel,
useVueFlow,
VueFlow,
type XYPosition,
} from "@vue-flow/core"
import { computed, nextTick, onBeforeUnmount, onMounted } from "vue"
import { project } from "@/model/projection"
import { type Sample, SAMPLES } from "@/model/samples"
import { canConnect } from "@/model/validation"
import { useModelStore } from "@/store/model"
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
import LoopOverlay from "./LoopOverlay.vue"
import Palette from "./Palette.vue"
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
import CloudNode from "./nodes/CloudNode.vue"
import ConverterNode from "./nodes/ConverterNode.vue"
import FlowNode from "./nodes/FlowNode.vue"
import StockNode from "./nodes/StockNode.vue"
const store = useModelStore()
const graph = computed(() => project(store.model))
const nodes = computed(() => graph.value.nodes)
const edges = computed(() => graph.value.edges)
// Explicit shared id: useVueFlow() runs here in the parent setup, before
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
// store instance, so the event hooks below actually fire.
const {
onNodeDragStart,
onNodeDragStop,
onConnect,
onConnectStart,
onConnectEnd,
onError,
getSelectedNodes,
getSelectedEdges,
viewport,
vueFlowRef,
fitView,
} = useVueFlow("meadows")
onNodeDragStart(() => store.beginInteraction())
onNodeDragStop(({ nodes: dragged }) => {
for (const node of dragged) store.moveNode(node.id, node.position)
})
// Vue Flow measures the pane synchronously in its own onMounted, before the
// browser has flushed first layout, so the initial read is 0×0 and it emits a
// benign MISSING_VIEWPORT_DIMENSIONS error (it falls back to 500×500, then its
// ResizeObserver corrects to the real size next frame). Swallow only that one;
// registering any handler suppresses Vue Flow's default console.warn, so log
// every other error to keep genuine problems visible.
onError((err) => {
if (isErrorOfType(err, ErrorCode.MISSING_VIEWPORT_DIMENSIONS)) return
console.warn(err.message)
})
// --- Connecting (F2) ---
// Refuse invalid drops mid-gesture so the structure stays valid (F4, "guide,
// don't nag"). The same guard backs store.connect as defence in depth.
//
// Vue Flow runs this guard for BOTH live gestures and the edges we project. The
// projected pipe/link edges arrive as full edges (with an `id`) and are valid by
// construction — the store guards every mutation — whereas a live gesture is a
// handle-less Connection. We must only guard the gesture: the derived pipe-out
// edge is valve→stock, which is invalid *as a gesture* but a real rendered edge,
// so validating projected edges would drop every pipe-out (Vue Flow EDGE_INVALID).
function isValidConnection(connection: Connection): boolean {
if ("id" in connection) return true
return canConnect(store.model, connection.source, connection.target)
}
// Remember the drag's source so releasing on empty canvas can open onto a Cloud.
let connectSource: string | null = null
let connectionMade = false
onConnectStart((params: OnConnectStartParams) => {
connectSource = params.handleType === "source" ? (params.nodeId ?? null) : null
connectionMade = false
})
onConnect((connection) => {
connectionMade = true
store.connect(connection.source, connection.target)
})
onConnectEnd((event) => {
if (!connectionMade && connectSource && event) {
const point = pointerPosition(event)
if (point) store.connectToNewCloud(connectSource, screenToFlow(point.x, point.y))
}
connectSource = null
})
/** clientX/clientY from a mouse or touch end event. */
function pointerPosition(event: MouseEvent | TouchEvent): { x: number; y: number } | null {
if ("clientX" in event) return { x: event.clientX, y: event.clientY }
const touch = event.changedTouches[0]
return touch ? { x: touch.clientX, y: touch.clientY } : null
}
/** Convert a screen point (clientX/clientY) to graph coordinates. */
function screenToFlow(clientX: number, clientY: number): XYPosition {
const rect = vueFlowRef.value?.getBoundingClientRect()
const { x, y, zoom } = viewport.value
return {
x: (clientX - (rect?.left ?? 0) - x) / zoom,
y: (clientY - (rect?.top ?? 0) - y) / zoom,
}
}
/** Drop a node so its top-left lands roughly centred on the given graph point. */
function placeNode(kind: PlaceableKind, at: XYPosition): void {
const position = { x: at.x - 48, y: at.y - 24 }
if (kind === "stock") store.addStock(position)
else store.addConverter(position)
}
/** Click-to-add: drop at the visible pane centre, cascaded so clicks don't stack. */
function addNode(kind: PlaceableKind): void {
const rect = vueFlowRef.value?.getBoundingClientRect()
const centre = screenToFlow(
(rect?.left ?? 0) + (rect?.width ?? 800) / 2,
(rect?.top ?? 0) + (rect?.height ?? 600) / 2,
)
const step = (store.model.nodes.length % 8) * 22
placeNode(kind, { x: centre.x + step, y: centre.y + step })
}
/** Drag-to-add: drop where the chip is released. */
function onDrop(event: DragEvent): void {
event.preventDefault()
const kind = event.dataTransfer?.getData(NODE_DND_MIME)
if (kind !== "stock" && kind !== "converter") return
placeNode(kind, screenToFlow(event.clientX, event.clientY))
}
function onDragOver(event: DragEvent): void {
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"
}
/**
* Load a sample Model (G4, the instructive goal). `setModel` replaces the whole
* document and clears undo history, so guard real work behind a confirm — but
* stay frictionless on the empty starting canvas. Fit the view once the
* projection has re-derived so the loaded model lands framed.
*/
async function loadSample(sample: Sample): Promise<void> {
if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) {
return
}
store.setModel(sample.build())
// Close the DaisyUI dropdown (it stays open while the trigger keeps focus).
;(document.activeElement as HTMLElement | null)?.blur()
await nextTick()
fitView({ padding: 0.2 })
}
function isTextEntry(target: EventTarget | null): boolean {
const el = target as HTMLElement | null
return el?.tagName === "INPUT" || el?.tagName === "TEXTAREA" || el?.isContentEditable === true
}
function onKeydown(event: KeyboardEvent): void {
if (isTextEntry(event.target)) return
const mod = event.metaKey || event.ctrlKey
if (mod && event.key.toLowerCase() === "z") {
event.preventDefault()
if (event.shiftKey) store.redo()
else store.undo()
return
}
if (event.key === "Delete" || event.key === "Backspace") {
const nodeIds = getSelectedNodes.value.map((node) => node.id)
// Edges are projected, not stored: a "pipe" edge stands in for its Flow node
// (id is `${flowId}::in|out`), an "info" edge is the Information Link itself.
const flowIds = new Set<string>()
const linkIds = new Set<string>()
for (const edge of getSelectedEdges.value) {
if (edge.data?.kind === "pipe") flowIds.add(edge.id.split("::")[0])
else if (edge.data?.kind === "info") linkIds.add(edge.id)
}
if (nodeIds.length === 0 && flowIds.size === 0 && linkIds.size === 0) return
event.preventDefault()
for (const id of nodeIds) store.removeNode(id)
for (const id of flowIds) store.removeNode(id)
for (const id of linkIds) store.removeInfoLink(id)
}
}
onMounted(() => window.addEventListener("keydown", onKeydown))
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown))
</script>
<template>
<div class="flex h-screen flex-col bg-base-200">
<header class="flex items-center gap-3 border-b border-base-300 bg-base-100 px-4 py-2">
<img src="/favicon.svg" alt="" class="size-6" />
<h1 class="text-lg font-semibold">meadows</h1>
<span class="text-sm text-base-content/50">
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
</span>
<div class="ml-auto flex items-center gap-1">
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-sm">Samples</button>
<ul
tabindex="0"
class="dropdown-content menu z-10 mt-1 w-72 gap-1 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li v-for="sample in SAMPLES" :key="sample.title">
<button class="flex flex-col items-start gap-0.5" @click="loadSample(sample)">
<span class="font-medium">{{ sample.title }}</span>
<span class="text-xs text-base-content/60">{{ sample.blurb }}</span>
</button>
</li>
</ul>
</div>
<button class="btn btn-ghost btn-sm" :disabled="!store.canUndo" @click="store.undo()">
Undo
</button>
<button class="btn btn-ghost btn-sm" :disabled="!store.canRedo" @click="store.redo()">
Redo
</button>
</div>
</header>
<div class="relative min-h-0 flex-1" @drop="onDrop" @dragover="onDragOver">
<VueFlow
id="meadows"
:nodes="nodes"
:edges="edges"
:delete-key-code="null"
:is-valid-connection="isValidConnection"
:connection-radius="30"
:min-zoom="0.2"
:max-zoom="4"
class="size-full"
>
<Background :gap="20" pattern-color="#d1d5db" />
<Controls position="bottom-left" :show-interactive="false" />
<Panel position="top-left">
<Palette @add="addNode" />
</Panel>
<template #node-stock="nodeProps">
<StockNode v-bind="nodeProps" />
</template>
<template #node-converter="nodeProps">
<ConverterNode v-bind="nodeProps" />
</template>
<template #node-flow="nodeProps">
<FlowNode v-bind="nodeProps" />
</template>
<template #node-cloud="nodeProps">
<CloudNode v-bind="nodeProps" />
</template>
<template #edge-info="edgeProps">
<InfoLinkEdge v-bind="edgeProps" />
</template>
</VueFlow>
<LoopOverlay />
</div>
</div>
</template>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
/**
* Loop overlay (C11, ADR-0001) — an R/B badge floating at each detected loop's
* centre, Reinforcing in green and Balancing in amber. Loops are derived live
* from the wiring (store getter), so badges appear, vanish and reclassify as you
* wire, toggle polarity, or delete. Hovering a badge lights up that loop's nodes
* and links via the shared highlight.
*
* Badges live in screen space: each loop's centroid (the average of its node
* positions, in flow coords) is mapped through the live viewport transform so it
* tracks pan/zoom, and colliding badges are stacked so overlapping loops stay
* legible (the accepted cost of on-canvas badges).
*/
import { useVueFlow } from "@vue-flow/core"
import { computed } from "vue"
import { useLoopHighlight } from "@/composables/useLoopHighlight"
import type { Loop } from "@/model/loops"
import { useModelStore } from "@/store/model"
const store = useModelStore()
const { viewport } = useVueFlow("meadows")
const { highlight, clear } = useLoopHighlight()
const nameOf = computed(() => {
const map = new Map<string, string>()
for (const node of store.model.nodes) if (node.kind !== "cloud") map.set(node.id, node.name)
return map
})
interface Badge {
loop: Loop
x: number
y: number
}
const badges = computed<Badge[]>(() => {
const positions = new Map(store.model.nodes.map((n) => [n.id, n.position]))
const { x: vx, y: vy, zoom } = viewport.value
const placed: Badge[] = []
for (const loop of store.loops) {
let sx = 0
let sy = 0
for (const id of loop.nodeIds) {
const p = positions.get(id)
if (p) {
sx += p.x
sy += p.y
}
}
const n = loop.nodeIds.length || 1
// node positions are top-left; nudge toward node centre, then to screen.
const x = (sx / n + 24) * zoom + vx
let y = (sy / n + 18) * zoom + vy
while (placed.some((b) => Math.abs(b.x - x) < 26 && Math.abs(b.y - y) < 26)) y += 26
placed.push({ loop, x, y })
}
return placed
})
function title(loop: Loop): string {
const kind = loop.type === "R" ? "Reinforcing" : "Balancing"
const names = loop.nodeIds.map((id) => nameOf.value.get(id) ?? "·").join(" → ")
return `${kind} loop · ${names}`
}
</script>
<template>
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<button
v-for="badge in badges"
:key="badge.loop.id"
class="pointer-events-auto absolute flex size-6 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border text-xs font-bold shadow transition-transform hover:scale-125"
:class="
badge.loop.type === 'R'
? 'border-success/60 bg-success text-success-content'
: 'border-warning/60 bg-warning text-warning-content'
"
:style="{ left: `${badge.x}px`, top: `${badge.y}px` }"
:title="title(badge.loop)"
@mouseenter="highlight(badge.loop)"
@mouseleave="clear()"
@focus="highlight(badge.loop)"
@blur="clear()"
>
{{ badge.loop.type }}
</button>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
/**
* Palette (C12) — place a node either way (F1, "1 click/drag"): click to drop at
* the viewport centre, or drag a chip onto the canvas to drop where you release.
* The Editor owns placement; this just signals the kind (via event on click, via
* dataTransfer on drag). Flows aren't placed here — they're drawn by connecting
* two nodes (F2, next).
*/
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
const emit = defineEmits<{ add: [kind: PlaceableKind] }>()
function onDragStart(event: DragEvent, kind: PlaceableKind): void {
if (!event.dataTransfer) return
event.dataTransfer.setData(NODE_DND_MIME, kind)
event.dataTransfer.effectAllowed = "copy"
}
</script>
<template>
<div
class="flex flex-col gap-1 rounded-box border border-base-300 bg-base-100/90 p-2 shadow-md backdrop-blur"
>
<span
class="px-2 pt-1 pb-0.5 text-xs font-semibold tracking-wide text-base-content/50 uppercase"
>
Add
</span>
<button
class="btn btn-ghost btn-sm cursor-grab justify-start gap-2 active:cursor-grabbing"
draggable="true"
@click="emit('add', 'stock')"
@dragstart="onDragStart($event, 'stock')"
>
<span class="inline-block size-3.5 rounded-sm border-2 border-current" />
Stock
</button>
<button
class="btn btn-ghost btn-sm cursor-grab justify-start gap-2 active:cursor-grabbing"
draggable="true"
@click="emit('add', 'converter')"
@dragstart="onDragStart($event, 'converter')"
>
<span class="inline-block size-3.5 rounded-full border-2 border-current" />
Converter
</button>
</div>
</template>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
/**
* Information Link edge (C3) — the dashed influence arrow plus a clickable
* polarity badge at its midpoint. Polarity is captured on create (default `+`,
* F6) and flipped in one click here; it is the loop detector's only structural
* input (ADR-0001), so it lives right on the link. The bezier pipe is drawn by
* BaseEdge; the badge is portalled over the viewport by EdgeLabelRenderer so it
* stays pinned to the path under pan/zoom.
*/
import { BaseEdge, type EdgeProps, EdgeLabelRenderer, getBezierPath } from "@vue-flow/core"
import { computed } from "vue"
import { useLoopHighlight } from "@/composables/useLoopHighlight"
import type { EdgeData } from "@/model/projection"
import { useModelStore } from "@/store/model"
const props = defineProps<EdgeProps<EdgeData>>()
const store = useModelStore()
const { highlighted } = useLoopHighlight()
// [path, labelX, labelY, ...] — labelX/Y is the path centre, in flow coords.
const path = computed(() =>
getBezierPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
sourcePosition: props.sourcePosition,
targetX: props.targetX,
targetY: props.targetY,
targetPosition: props.targetPosition,
}),
)
const polarity = computed(() => props.data.polarity ?? "+")
// Display the typographic minus (the model stores ASCII "-").
const glyph = computed(() => (polarity.value === "+" ? "+" : ""))
// Light up when this link is an edge of the hovered loop (C11).
const inLoop = computed(
() => highlighted.value?.edges.has(`${props.source}>${props.target}`) ?? false,
)
const edgeStyle = computed(() =>
inLoop.value
? {
...props.style,
stroke: highlighted.value?.type === "R" ? "var(--color-success)" : "var(--color-warning)",
strokeWidth: "2.5px",
}
: props.style,
)
</script>
<template>
<BaseEdge :id="props.id" :path="path[0]" :marker-end="props.markerEnd" :style="edgeStyle" />
<EdgeLabelRenderer>
<button
class="nodrag nopan absolute flex size-5 items-center justify-center rounded-full border text-xs font-bold leading-none shadow-sm transition-colors"
:class="
polarity === '+'
? 'border-primary/40 bg-base-100 text-primary hover:bg-primary/10'
: 'border-error/50 bg-base-100 text-error hover:bg-error/10'
"
:style="{
transform: `translate(-50%, -50%) translate(${path[1]}px, ${path[2]}px)`,
pointerEvents: 'all',
}"
:title="`Polarity ${glyph} — click to toggle`"
@click="store.toggleLinkPolarity(props.id)"
>
{{ glyph }}
</button>
</EdgeLabelRenderer>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
/**
* Cloud — the model boundary (Source/Sink) on an open Flow end. Auto-materialised
* (ADR-0003), never placed by hand, carries no name. Drawn as the conventional
* cloud symbol, with handles so a Flow can terminate on it either way.
*/
import { Handle, type NodeProps, Position } from "@vue-flow/core"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
defineProps<NodeProps<NodeData>>()
</script>
<template>
<div class="relative">
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
<svg
viewBox="0 0 24 24"
class="size-9 text-base-content/40"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6.5 19a4.5 4.5 0 0 1 0-9 5.5 5.5 0 0 1 10.5-1.5 4 4 0 0 1 1 7.9" />
</svg>
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
/**
* Converter — a stateless helper value. Drawn as a circle, the classic
* auxiliary/converter symbol, with its name beneath. Handles let Information
* Links arrive (target) and leave (source).
*/
import { Handle, type NodeProps, Position } from "@vue-flow/core"
import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { ConverterNode } from "@/model/types"
import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a converter-typed node here.
const converter = computed(() => props.data.node as ConverterNode)
const loopRing = useNodeLoopRing(props.id)
</script>
<template>
<div class="flex flex-col items-center gap-1">
<div
class="relative size-12 rounded-full border-2 bg-base-100 shadow-sm transition-colors"
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
>
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div>
<NodeLabel :node-id="props.id" :name="converter.name" />
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
/**
* Flow — a rate, drawn as the valve (an outlined bowtie/butterfly) sitting on
* the pipe between source and target. The pipe segments are projected edges
* (ADR-0003); this node is the valve and its name. Outlined (not filled) so it
* reads as a tap rather than two arrows. Handles sit on the valve so the pipe
* connects through it and Information Links can target/leave the rate.
*/
import { Handle, type NodeProps, Position } from "@vue-flow/core"
import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { FlowNode } from "@/model/types"
import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a flow-typed node here.
const flow = computed(() => props.data.node as FlowNode)
const loopRing = useNodeLoopRing(props.id)
</script>
<template>
<div class="flex flex-col items-center gap-1">
<div class="relative rounded-full" :class="loopRing">
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
<svg
viewBox="0 0 24 24"
class="size-7 transition-colors"
:class="props.selected ? 'text-primary' : 'text-base-content/60'"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linejoin="round"
>
<!-- Outlined bowtie valve: two hollow triangles meeting at the centre. -->
<path d="M3 5 L12 12 L3 19 Z" />
<path d="M21 5 L12 12 L21 19 Z" />
</svg>
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div>
<NodeLabel :node-id="props.id" :name="flow.name" />
</div>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
/**
* A node's name with frictionless inline rename: double-click to edit, Enter or
* blur to commit through the store, Escape to cancel. The `nodrag` class and
* stopped pointer events keep Vue Flow from starting a drag while typing.
*
* While editing, an invisible sizer span (mirroring the draft text) holds the
* box width and the input overlays it absolutely — so switching between display
* and edit never reflows the node (no width jump on a Stock, no shifted circle
* on a Converter).
*/
import { nextTick, ref } from "vue"
import { useModelStore } from "@/store/model"
const props = defineProps<{ nodeId: string; name: string }>()
const store = useModelStore()
const editing = ref(false)
const draft = ref("")
const input = ref<HTMLInputElement | null>(null)
async function begin(): Promise<void> {
draft.value = props.name
editing.value = true
await nextTick()
input.value?.focus()
input.value?.select()
}
function commit(): void {
if (!editing.value) return
editing.value = false
const trimmed = draft.value.trim()
if (trimmed && trimmed !== props.name) store.renameNode(props.nodeId, trimmed)
}
function cancel(): void {
editing.value = false
}
</script>
<template>
<span class="relative inline-block min-w-[1.5ch] text-center text-sm font-medium">
<template v-if="editing">
<!-- Invisible sizer: defines the box width from the draft, so the input
(absolutely positioned over it) matches the text it replaced. -->
<span class="invisible whitespace-pre" aria-hidden="true">{{ draft || " " }}</span>
<input
ref="input"
v-model="draft"
class="nodrag absolute inset-0 w-full rounded bg-base-100 px-0 text-center text-sm font-medium outline-none ring-1 ring-primary"
@blur="commit"
@keydown.enter.prevent="commit"
@keydown.esc.prevent="cancel"
@pointerdown.stop
/>
</template>
<span v-else class="cursor-text whitespace-pre select-none" @dblclick.stop="begin">
{{ name }}
</span>
</span>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
/**
* Stock — the accumulation. Drawn as a rectangle, the classic stock-and-flow
* symbol for the one stateful element. A target handle (left) and source handle
* (right) let Flows attach and Information Links leave.
*/
import { Handle, type NodeProps, Position } from "@vue-flow/core"
import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { StockNode } from "@/model/types"
import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a stock-typed node here.
const stock = computed(() => props.data.node as StockNode)
const loopRing = useNodeLoopRing(props.id)
</script>
<template>
<div
class="min-w-24 rounded-md border-2 bg-base-100 px-4 py-3 text-center shadow-sm transition-colors"
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
>
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
<NodeLabel :node-id="props.id" :name="stock.name" />
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div>
</template>

View File

@@ -0,0 +1,7 @@
/** Shared contract between the Palette (drag source) and Editor (drop target). */
/** Node kinds the palette can place. Flows are drawn by connecting (F2). */
export type PlaceableKind = "stock" | "converter"
/** Custom MIME type carrying the kind across an HTML5 drag-and-drop. */
export const NODE_DND_MIME = "application/x-meadows-node"

View File

@@ -0,0 +1,48 @@
/**
* Shared loop-hover highlight (C11). Deliberately *not* in the Model store: this
* is transient view state that must never be snapshotted for undo or picked up
* by autosave (F7). The badge layer sets it on hover; nodes and Information Link
* edges read it to light up the loop they belong to.
*
* A module-level ref makes it a singleton shared by every component that calls
* the composable, without prop-drilling through Vue Flow's render tree.
*/
import { computed, type Ref, ref } from "vue"
import type { Loop, LoopType } from "@/model/loops"
interface LoopHighlight {
nodes: Set<string>
/** Directed `source>target` pairs of the loop's edges, for link highlighting. */
edges: Set<string>
type: LoopType
}
const highlighted = ref<LoopHighlight | null>(null)
export function useLoopHighlight(): {
highlighted: Ref<LoopHighlight | null>
highlight: (loop: Loop) => void
clear: () => void
} {
function highlight(loop: Loop): void {
const ids = loop.nodeIds
const edges = new Set<string>()
for (let i = 0; i < ids.length; i++) edges.add(`${ids[i]}>${ids[(i + 1) % ids.length]}`)
highlighted.value = { nodes: new Set(ids), edges, type: loop.type }
}
function clear(): void {
highlighted.value = null
}
return { highlighted, highlight, clear }
}
/** Ring class for a node when it belongs to the hovered loop (empty otherwise). */
export function useNodeLoopRing(nodeId: string): Ref<string> {
return computed(() => {
const hl = highlighted.value
if (!hl || !hl.nodes.has(nodeId)) return ""
return hl.type === "R"
? "ring-2 ring-success ring-offset-2"
: "ring-2 ring-warning ring-offset-2"
})
}

View File

@@ -1,5 +1,10 @@
import { createPinia } from "pinia"
import { createApp } from "vue"
// Vue Flow styles first so our Tailwind utilities can override them.
import "@vue-flow/core/dist/style.css"
import "@vue-flow/core/dist/theme-default.css"
import "@vue-flow/controls/dist/style.css"
import "./style.css"
import App from "./App.vue"
createApp(App).mount("#app")
createApp(App).use(createPinia()).mount("#app")

74
src/model/factory.ts Normal file
View File

@@ -0,0 +1,74 @@
/**
* Pure constructors for Model pieces: id generation, a blank Model, and the
* auto-naming that lets F1 place a node with zero dialogs. No store, no Vue —
* just data in, data out, so it stays unit-testable.
*/
import {
type CloudNode,
type ConverterNode,
type FlowNode,
type Model,
MODEL_VERSION,
type ModelNode,
type NodeKind,
type Position,
type StockNode,
} from "./types"
/** Short, readable, collision-resistant id. `crypto.randomUUID` is built in. */
export function newId(prefix: string): string {
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`
}
/** Human label per kind, used for auto-naming. Clouds are unnamed boundaries. */
const LABEL: Record<Exclude<NodeKind, "cloud">, string> = {
stock: "Stock",
flow: "Flow",
converter: "Converter",
}
export function emptyModel(name = "Untitled model"): Model {
return { version: MODEL_VERSION, id: newId("model"), name, nodes: [], infoLinks: [] }
}
/**
* Next free auto-name for `kind`, e.g. "Stock 3". Picks one past the highest
* trailing number already in use so it stays stable across deletes/renames.
*/
export function nextName(nodes: ModelNode[], kind: Exclude<NodeKind, "cloud">): string {
const label = LABEL[kind]
const pattern = new RegExp(`^${label} (\\d+)$`)
let max = 0
for (const node of nodes) {
if (node.kind !== kind || !("name" in node)) continue
const match = pattern.exec(node.name)
if (match) max = Math.max(max, Number(match[1]))
}
return `${label} ${max + 1}`
}
export function makeStock(position: Position, name: string): StockNode {
return { id: newId("stock"), kind: "stock", name, position }
}
export function makeConverter(position: Position, name: string): ConverterNode {
return { id: newId("conv"), kind: "converter", name, position }
}
export function makeFlow(
position: Position,
name: string,
source: string,
target: string,
): FlowNode {
return { id: newId("flow"), kind: "flow", name, source, target, position }
}
export function makeCloud(position: Position): CloudNode {
return { id: newId("cloud"), kind: "cloud", position }
}
/** Midpoint between two positions — where a Flow's valve sits on its pipe. */
export function midpoint(a: Position, b: Position): Position {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
}

173
src/model/loops.ts Normal file
View File

@@ -0,0 +1,173 @@
/**
* Loop engine (C8, ADR-0001) — feedback loops are *derived* from the wiring, not
* stored, so they can never disagree with the structure.
*
* We build a signed directed graph and enumerate its simple cycles, classifying
* each by the parity of its negative links: an even number of `` is a
* Reinforcing loop (R), an odd number is Balancing (B) — the sign of the product
* of the loop's link polarities.
*
* Signed edges come from two places (ADR-0001 "every link carries polarity;
* inflows/outflows carry inherent polarity"):
* - Information Links contribute their stored `+`/`` (source → target).
* - A Flow contributes its inherent material polarity: `+` into the stock it
* fills (flow → target) and `` out of the stock it drains (flow → source).
* Clouds have no outgoing edges, so no cycle passes through the model boundary.
*
* Enumeration is Tarjan SCC (prune acyclic structure) → DFS of simple cycles
* within each component, started from each member in id order with descendants
* restricted to that order so every cycle is found exactly once (from its
* minimum node). Both the result count and the search are capped (T3): pathology
* yields "the first N loops", never a blown frame budget (F5 ⊗ F3).
*/
import type { Model, Polarity } from "./types"
export type LoopType = "R" | "B"
export interface Loop {
/** Canonical id: the cycle's node ids in traversal order, min-id first. */
id: string
/** Nodes on the cycle, in order (no repeated closing node). */
nodeIds: string[]
/** Count of `` links around the loop; its parity decides R vs B. */
negatives: number
type: LoopType
}
export interface LoopResult {
loops: Loop[]
/** True if the result or search cap was hit — there may be more loops. */
capped: boolean
}
/** Max simple cycles reported before we stop (ADR-0001/T3: "+N more"). */
export const LOOP_CAP = 200
/** Hard ceiling on edge expansions, so a dense graph can't blow the frame. */
const STEP_BUDGET = 50_000
interface SignedEdge {
to: string
polarity: Polarity
}
function buildGraph(model: Model): Map<string, SignedEdge[]> {
const adj = new Map<string, SignedEdge[]>()
for (const node of model.nodes) adj.set(node.id, [])
const add = (from: string, edge: SignedEdge): void => {
const list = adj.get(from)
if (list) list.push(edge)
}
for (const link of model.infoLinks) add(link.source, { to: link.target, polarity: link.polarity })
for (const node of model.nodes) {
if (node.kind !== "flow") continue
add(node.id, { to: node.target, polarity: "+" }) // fills its target
add(node.id, { to: node.source, polarity: "-" }) // drains its source
}
return adj
}
/** Tarjan's SCC — components are returned in reverse topological order. */
function stronglyConnectedComponents(adj: Map<string, SignedEdge[]>): string[][] {
const index = new Map<string, number>()
const low = new Map<string, number>()
const onStack = new Set<string>()
const stack: string[] = []
const sccs: string[][] = []
let counter = 0
const connect = (v: string): void => {
index.set(v, counter)
low.set(v, counter)
counter++
stack.push(v)
onStack.add(v)
for (const { to } of adj.get(v) ?? []) {
if (!index.has(to)) {
connect(to)
low.set(v, Math.min(low.get(v)!, low.get(to)!))
} else if (onStack.has(to)) {
low.set(v, Math.min(low.get(v)!, index.get(to)!))
}
}
if (low.get(v) === index.get(v)) {
const component: string[] = []
let w: string
do {
w = stack.pop()!
onStack.delete(w)
component.push(w)
} while (w !== v)
sccs.push(component)
}
}
for (const v of adj.keys()) if (!index.has(v)) connect(v)
return sccs
}
export function detectLoops(model: Model): LoopResult {
const adj = buildGraph(model)
const components = stronglyConnectedComponents(adj).filter((c) => c.length > 1)
const loops: Loop[] = []
const seen = new Set<string>()
let capped = false
let steps = 0
const record = (nodeIds: string[], negatives: number): void => {
if (loops.length >= LOOP_CAP) {
capped = true
return
}
const id = nodeIds.join(">")
if (seen.has(id)) return
seen.add(id)
loops.push({ id, nodeIds, negatives, type: negatives % 2 === 0 ? "R" : "B" })
}
for (const component of components) {
if (capped) break
const members = new Set(component)
const order = [...component].sort()
const rank = new Map(order.map((id, i) => [id, i]))
for (let start = 0; start < order.length && !capped; start++) {
const startId = order[start]
const path: string[] = []
const onPath = new Set<string>()
const polarities: Polarity[] = []
const walk = (v: string): void => {
path.push(v)
onPath.add(v)
for (const edge of adj.get(v) ?? []) {
if (capped) break
if (++steps > STEP_BUDGET) {
capped = true
break
}
if (!members.has(edge.to) || rank.get(edge.to)! < start) continue
if (edge.to === startId) {
const negatives =
polarities.filter((p) => p === "-").length + (edge.polarity === "-" ? 1 : 0)
record([...path], negatives)
} else if (!onPath.has(edge.to)) {
polarities.push(edge.polarity)
walk(edge.to)
polarities.pop()
}
}
path.pop()
onPath.delete(v)
}
walk(startId)
}
}
return { loops, capped }
}

98
src/model/projection.ts Normal file
View File

@@ -0,0 +1,98 @@
/**
* Model → Vue Flow projection (C6, ADR-0002).
*
* One-directional and pure: the domain Model in, the `{ nodes, edges }` Vue Flow
* renders out. Writes never flow this way — Vue Flow events call store actions
* that mutate the Model, and this re-derives. Keeping it pure means it is cheap
* to memoise (the F3 escape hatch) and trivial to reason about.
*
* Edges are derived, never stored. A Flow node contributes two "pipe" edges
* (source→valve, valve→target) so the classic pipe-with-valve renders from the
* Flow's references (ADR-0003); each Information Link contributes one "info"
* edge carrying its polarity.
*/
import type { Edge, Node } from "@vue-flow/core"
import type { Model, ModelNode, Polarity } from "./types"
/** Payload carried on every projected Vue Flow node: the domain node itself. */
export interface NodeData {
node: ModelNode
}
/** Payload on a projected edge — which kind, and (for info links) its polarity. */
export interface EdgeData {
kind: "pipe" | "info"
polarity?: Polarity
}
export type FlowGraphNode = Node<NodeData>
export type FlowGraphEdge = Edge<EdgeData>
/**
* Stable handle ids every node renders (target on the left, source on the
* right). Edges name them explicitly so Vue Flow always resolves the right
* anchor — without ids, an edge whose source is the small two-handle valve fails
* to render (the "pipe-out" bug).
*/
export const HANDLE_IN = "in"
export const HANDLE_OUT = "out"
export function projectNodes(model: Model): FlowGraphNode[] {
return model.nodes.map((node) => ({
id: node.id,
type: node.kind,
position: node.position,
data: { node },
}))
}
export function projectEdges(model: Model): FlowGraphEdge[] {
const edges: FlowGraphEdge[] = []
for (const node of model.nodes) {
if (node.kind !== "flow") continue
// Pipe in: source → valve. No arrowhead — the valve is the visual midpoint.
edges.push({
id: `${node.id}::in`,
source: node.source,
sourceHandle: HANDLE_OUT,
target: node.id,
targetHandle: HANDLE_IN,
data: { kind: "pipe" },
style: { strokeWidth: "2.5px" },
})
// Pipe out: valve → target. Arrowhead carries the flow direction.
edges.push({
id: `${node.id}::out`,
source: node.id,
sourceHandle: HANDLE_OUT,
target: node.target,
targetHandle: HANDLE_IN,
data: { kind: "pipe" },
style: { strokeWidth: "2.5px" },
markerEnd: "arrowclosed",
})
}
// Information Links: thin, dashed, arrow at the influenced end. The custom
// "info" edge type renders the dashed pipe plus the clickable polarity badge.
for (const link of model.infoLinks) {
edges.push({
id: link.id,
type: "info",
source: link.source,
sourceHandle: HANDLE_OUT,
target: link.target,
targetHandle: HANDLE_IN,
data: { kind: "info", polarity: link.polarity },
style: { strokeDasharray: "5 4" },
markerEnd: "arrowclosed",
})
}
return edges
}
export function project(model: Model): { nodes: FlowGraphNode[]; edges: FlowGraphEdge[] } {
return { nodes: projectNodes(model), edges: projectEdges(model) }
}

155
src/model/samples.ts Normal file
View File

@@ -0,0 +1,155 @@
/**
* Sample Models — a small, curated gallery the user can load to learn the
* language by example (CONTEXT.md) and to have something on the canvas in one
* click.
*
* The set is deliberately "simple yet exhaustive": each Model is the smallest
* thing that makes its point, but read top to bottom they introduce the whole
* vocabulary and both loop kinds, one new idea at a time:
*
* 1. Bathtub — Stock, Flow (in/out), Source, Sink. No feedback.
* 2. Savings account — + Information Link, `+` polarity → a Reinforcing loop.
* 3. Coffee cooling — + Converter, `` polarity → a Balancing loop.
* 4. Population — all of the above at once: Reinforcing *and* Balancing.
*
* These are plain data built from the same tested constructors the store uses
* (factory.ts), so every sample is a valid Model by construction. `build()`
* mints fresh ids on each call, so loading a sample twice never collides.
*/
import { makeCloud, makeConverter, makeFlow, makeStock, midpoint, newId } from "./factory"
import {
type InformationLink,
type Model,
MODEL_VERSION,
type ModelNode,
type Polarity,
} from "./types"
/** A loadable example: a title and one-line blurb for the menu, plus a builder. */
export interface Sample {
title: string
blurb: string
build: () => Model
}
/** Information Link between two already-built nodes, with an explicit polarity. */
function link(source: ModelNode, target: ModelNode, polarity: Polarity): InformationLink {
return { id: newId("link"), source: source.id, target: target.id, polarity }
}
function model(name: string, nodes: ModelNode[], infoLinks: InformationLink[]): Model {
return { version: MODEL_VERSION, id: newId("model"), name, nodes, infoLinks }
}
/**
* Bathtub — the canonical first model. A Stock filled by one Flow and drained by
* another, each open end resting on a Cloud. No Information Links, so no
* feedback: it just shows the substrate (Stock, Flow, Source, Sink).
*/
function bathtub(): Model {
const source = makeCloud({ x: -278, y: -18 })
const water = makeStock({ x: -48, y: -20 }, "Water")
const sink = makeCloud({ x: 242, y: -18 })
const filling = makeFlow(
midpoint(source.position, water.position),
"filling",
source.id,
water.id,
)
const emptying = makeFlow(midpoint(water.position, sink.position), "emptying", water.id, sink.id)
return model("Bathtub", [source, water, sink, filling, emptying], [])
}
/**
* Savings account — the simplest Reinforcing loop. Interest flows in from outside
* (a Source), and the bigger the Balance the larger the interest: Balance → [+]
* → interest → (inflow) → Balance. Even number of `` (zero) → Reinforcing.
*/
function savings(): Model {
// Source up-left, Balance down-right: the interest valve lands at their
// midpoint (above Balance), so the `Balance → interest` link arcs back up as a
// visible Reinforcing loop instead of overlapping the inflow pipe.
const source = makeCloud({ x: -250, y: -70 })
const balance = makeStock({ x: 110, y: 30 }, "Balance")
const interest = makeFlow(
midpoint(source.position, balance.position),
"interest",
source.id,
balance.id,
)
return model("Savings account", [source, balance, interest], [link(balance, interest, "+")])
}
/**
* Coffee cooling — the simplest Balancing loop, plus a Converter. Heat leaves the
* cup faster the hotter it is (Coffee → [+] → heat loss) but slower the warmer
* the room (room temperature → [] → heat loss). The loop Coffee → heat loss →
* (outflow) → Coffee has one `` → Balancing: it settles toward room temperature.
*/
function coffee(): Model {
const coffee = makeStock({ x: -200, y: -10 }, "Coffee")
const sink = makeCloud({ x: 190, y: -8 })
const heatLoss = makeFlow(
midpoint(coffee.position, sink.position),
"heat loss",
coffee.id,
sink.id,
)
const room = makeConverter({ x: -29, y: -150 }, "room temperature")
return model(
"Coffee cooling",
[coffee, sink, heatLoss, room],
[link(coffee, heatLoss, "+"), link(room, heatLoss, "-")],
)
}
/**
* Population — the whole language in one model. Births add to Population and more
* people means more births (Reinforcing); deaths remove from it and more people
* means more deaths (Balancing). Converters feed each rate: fertility raises
* births (`+`), life expectancy lowers deaths (``).
*/
function population(): Model {
const source = makeCloud({ x: -360, y: -8 })
const people = makeStock({ x: -48, y: -10 }, "Population")
const sink = makeCloud({ x: 250, y: -8 })
const births = makeFlow(
midpoint(source.position, people.position),
"births",
source.id,
people.id,
)
const deaths = makeFlow(midpoint(people.position, sink.position), "deaths", people.id, sink.id)
const fertility = makeConverter({ x: -204, y: -150 }, "fertility")
const lifeExpectancy = makeConverter({ x: 101, y: -150 }, "life expectancy")
return model(
"Population",
[source, people, sink, births, deaths, fertility, lifeExpectancy],
[
link(people, births, "+"),
link(fertility, births, "+"),
link(people, deaths, "+"),
link(lifeExpectancy, deaths, "-"),
],
)
}
/** The gallery, ordered simplest first. */
export const SAMPLES: Sample[] = [
{ title: "Bathtub", blurb: "A stock filled and drained — no feedback yet.", build: bathtub },
{
title: "Savings account",
blurb: "Interest on a balance: a Reinforcing loop.",
build: savings,
},
{
title: "Coffee cooling",
blurb: "Settling toward room temperature: a Balancing loop.",
build: coffee,
},
{
title: "Population",
blurb: "Births and deaths: Reinforcing and Balancing together.",
build: population,
},
]

110
src/model/types.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* The domain Model — the single source of truth for a meadows document.
*
* The Vue Flow graph is a derived projection of this (ADR-0002); the future
* simulator reads this and knows nothing of the view layer. Everything here is
* plain, serialisable data (no class instances, no functions) so it round-trips
* as JSON (F8) and deep-clones cheaply for undo snapshots (F9).
*
* Vocabulary follows CONTEXT.md. Structural rules follow the ADRs:
* - A Flow is a *node* with `source`/`target` references, not an edge (ADR-0003).
* - Source/Sink boundaries are materialised Cloud nodes, so no end is ever null.
* - The only stored edge type is the Information Link; loops are detected, never
* stored (ADR-0001).
*/
/** Schema version stamped on every Model for round-trip migrations (F8). */
export const MODEL_VERSION = 1
/** The four node kinds. A `kind` field discriminates the `ModelNode` union. */
export type NodeKind = "stock" | "flow" | "converter" | "cloud"
/**
* The sign on an Information Link (and inherent to a Flow): `+` when more cause
* means more effect, `` when more cause means less effect. The input that lets
* loops be classified Reinforcing/Balancing.
*/
export type Polarity = "+" | "-"
export interface Position {
x: number
y: number
}
interface BaseNode {
id: string
position: Position
}
/**
* An accumulation that holds a quantity over time — the system's memory and the
* only stateful element. Changes only through Flows.
*/
export interface StockNode extends BaseNode {
kind: "stock"
name: string
/** Initial accumulated quantity. Optional in the diagram phase; the simulator reads it. */
initialValue?: number
}
/**
* A rate that moves quantity between two ends. A Flow is a node (ADR-0003): the
* pipe-with-valve you see is rendered from `source`/`target`. Inflow/outflow is
* relative to a given Stock, not a separate type — a stock→stock Flow is an
* outflow for `source` and an inflow for `target`.
*/
export interface FlowNode extends BaseNode {
kind: "flow"
name: string
/** Node id of the Stock or Cloud the Flow draws from. */
source: string
/** Node id of the Stock or Cloud the Flow feeds into. */
target: string
/** Rate expression, recomputed each instant. Optional in the diagram phase. */
equation?: string
}
/**
* A stateless helper value, recomputed each instant from its inputs (or a fixed
* constant). Feeds Flows or other Converters via Information Links.
*/
export interface ConverterNode extends BaseNode {
kind: "converter"
name: string
/** Expression or constant. Optional in the diagram phase. */
equation?: string
}
/**
* A model boundary (Source/Sink) on an open Flow end — drawn as a cloud.
* Auto-managed: spawned when a Flow is left open-ended, removed when the Flow
* attaches to a Stock (ADR-0003). Carries no name and no value.
*/
export interface CloudNode extends BaseNode {
kind: "cloud"
}
export type ModelNode = StockNode | FlowNode | ConverterNode | CloudNode
/**
* Shows that one element's value influences a Flow's or Converter's value.
* Carries a Polarity; carries no quantity and never targets a Stock (a Stock
* changes only via Flows).
*/
export interface InformationLink {
id: string
/** Source node id — a Stock, Flow, or Converter (the value being read). */
source: string
/** Target node id — a Flow or Converter, never a Stock (ADR-0001). */
target: string
polarity: Polarity
}
/** One saved document: the unit of save / export / reopen. */
export interface Model {
version: number
id: string
name: string
nodes: ModelNode[]
infoLinks: InformationLink[]
}

51
src/model/validation.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Structure guard (C7, F4) — decides what dragging source→target *means* and
* whether it's allowed, from the domain rules in CONTEXT.md / ADR-0001:
*
* - A Flow terminates on a Stock or Cloud; its other end is a Stock or Cloud
* too (never cloud→cloud, which would bound nothing).
* - An Information Link terminates on a Flow or Converter, never a Stock
* (Stocks change only via Flows). Its source is a Stock, Flow, or Converter,
* never a Cloud (a boundary carries no value).
*
* The *target* kind disambiguates the gesture: dropping on a Stock/Cloud can only
* be a Flow; dropping on a Flow/Converter can only be an Information Link. This
* is "guide, don't nag" (F4): the editor refuses an invalid drop mid-gesture
* rather than erroring after the fact.
*/
import type { Model, ModelNode } from "./types"
/** What a source→target connection would create, or null if the rules forbid it. */
export type ConnectionIntent = "flow" | "info" | null
export function intentFor(source: ModelNode, target: ModelNode): ConnectionIntent {
if (source.id === target.id) return null
if (target.kind === "stock" || target.kind === "cloud") {
// A Flow. Both ends must be Stocks or Clouds, and not cloud→cloud.
if (source.kind !== "stock" && source.kind !== "cloud") return null
if (source.kind === "cloud" && target.kind === "cloud") return null
return "flow"
}
// target is a Flow or Converter → an Information Link.
if (source.kind === "cloud") return null
return "info"
}
/** Whether source→target is allowed *and* not a duplicate of an existing edge. */
export function canConnect(model: Model, sourceId: string, targetId: string): boolean {
const source = model.nodes.find((node) => node.id === sourceId)
const target = model.nodes.find((node) => node.id === targetId)
if (!source || !target) return false
const intent = intentFor(source, target)
if (intent === null) return false
if (intent === "info") {
return !model.infoLinks.some((link) => link.source === sourceId && link.target === targetId)
}
return !model.nodes.some(
(node) => node.kind === "flow" && node.source === sourceId && node.target === targetId,
)
}

239
src/store/model.ts Normal file
View File

@@ -0,0 +1,239 @@
/**
* Model store (C5) — the live home of the source-of-truth Model plus undo/redo.
*
* Every structural write goes through an action here; the Vue Flow view is a
* projection downstream (ADR-0002). Undo is snapshot-based (T5): we deep-clone
* the whole Model onto a bounded history stack before each change. Models are
* small and the data is plain, so cloning is cheap and always correct — no
* command objects to keep in sync.
*
* Drags are a special case: positions change many times per gesture, so the
* restore point is taken once on drag start (`beginInteraction`) and the
* position writes during/after the drag don't record history.
*/
import { defineStore } from "pinia"
import { computed, ref } from "vue"
import {
emptyModel,
makeCloud,
makeConverter,
makeFlow,
makeStock,
midpoint,
newId,
nextName,
} from "@/model/factory"
import { detectLoops } from "@/model/loops"
import type { ConverterNode, Model, ModelNode, Position, StockNode } from "@/model/types"
import { canConnect, intentFor } from "@/model/validation"
/** Ring-buffer depth for undo (F9 target: ≥50 steps). */
const HISTORY_LIMIT = 50
export const useModelStore = defineStore("model", () => {
const model = ref<Model>(emptyModel())
const past = ref<Model[]>([])
const future = ref<Model[]>([])
const canUndo = computed(() => past.value.length > 0)
const canRedo = computed(() => future.value.length > 0)
const nodeCount = computed(() => model.value.nodes.length)
// Feedback loops are derived from the wiring, never stored (ADR-0001). This
// re-runs only on *structural* change: detectLoops reads ids/kinds/links, not
// positions, so Vue's per-property reactivity skips it during node drags (F5).
const detected = computed(() => detectLoops(model.value))
const loops = computed(() => detected.value.loops)
const loopsCapped = computed(() => detected.value.capped)
function snapshot(): Model {
// Plain, deep, proxy-immune clone. JSON is safe here precisely because the
// Model is contractually JSON-serialisable (F8); structuredClone would choke
// on Vue reactive proxies that array reassignments leave nested in the state.
return JSON.parse(JSON.stringify(model.value)) as Model
}
/** Push a restore point before a mutation and drop the redo branch. */
function record(): void {
past.value.push(snapshot())
if (past.value.length > HISTORY_LIMIT) past.value.shift()
future.value = []
}
function findNode(id: string): ModelNode | undefined {
return model.value.nodes.find((node) => node.id === id)
}
function addStock(position: Position): StockNode {
record()
const node = makeStock(position, nextName(model.value.nodes, "stock"))
model.value.nodes.push(node)
return node
}
function addConverter(position: Position): ConverterNode {
record()
const node = makeConverter(position, nextName(model.value.nodes, "converter"))
model.value.nodes.push(node)
return node
}
/** Take the undo restore point at the start of a drag gesture. */
function beginInteraction(): void {
record()
}
/** Commit a node's new position. No history — the drag already recorded one. */
function moveNode(id: string, position: Position): void {
const node = findNode(id)
if (node) node.position = { ...position }
}
function renameNode(id: string, name: string): void {
const node = findNode(id)
if (!node || node.kind === "cloud") return
record()
node.name = name
}
/**
* Create whatever source→target means under the structure guard: an
* Information Link (default `+` polarity, F6) onto a Flow/Converter, or a Flow
* node (valve at the midpoint) between two Stocks/Clouds. Invalid or duplicate
* connections are silently ignored — the gesture is also guarded upstream.
*/
function connect(sourceId: string, targetId: string): void {
const source = findNode(sourceId)
const target = findNode(targetId)
if (!source || !target || !canConnect(model.value, sourceId, targetId)) return
const intent = intentFor(source, target)
record()
if (intent === "info") {
model.value.infoLinks.push({
id: newId("link"),
source: sourceId,
target: targetId,
polarity: "+",
})
} else if (intent === "flow") {
const name = nextName(model.value.nodes, "flow")
const position = midpoint(source.position, target.position)
model.value.nodes.push(makeFlow(position, name, sourceId, targetId))
}
}
/**
* Open-ended Flow: drop a Stock's connection on empty canvas to spawn a Sink
* Cloud there and a Flow into it (ADR-0003). Only Stocks originate boundary
* flows for now; other sources are ignored.
*/
function connectToNewCloud(sourceId: string, dropPosition: Position): void {
const source = findNode(sourceId)
if (!source || source.kind !== "stock") return
record()
const cloud = makeCloud({ x: dropPosition.x - 18, y: dropPosition.y - 18 })
const name = nextName(model.value.nodes, "flow")
model.value.nodes.push(cloud)
model.value.nodes.push(
makeFlow(midpoint(source.position, cloud.position), name, sourceId, cloud.id),
)
}
/**
* Remove a node and everything that would otherwise dangle: Flow nodes that
* reference it as an end, Information Links touching any removed node, and then
* any Cloud left unreferenced (a Cloud exists only to bound a Flow, ADR-0003).
*/
function removeNode(id: string): void {
record()
const removed = new Set<string>([id])
for (const node of model.value.nodes) {
if (node.kind === "flow" && (node.source === id || node.target === id)) removed.add(node.id)
}
model.value.nodes = model.value.nodes.filter((node) => !removed.has(node.id))
model.value.infoLinks = model.value.infoLinks.filter(
(link) => !removed.has(link.source) && !removed.has(link.target),
)
pruneClouds()
}
/**
* Flip an Information Link's polarity (F6). Polarity is captured on create
* (default `+`) and toggled in one click on the edge badge; it is the loop
* detector's only structural input (ADR-0001), so each flip is a real,
* undoable model change.
*/
function toggleLinkPolarity(id: string): void {
const link = model.value.infoLinks.find((l) => l.id === id)
if (!link) return
record()
link.polarity = link.polarity === "+" ? "-" : "+"
}
/**
* Remove a single Information Link by id. Links are edges in the projection,
* not nodes, so they need their own delete path (selecting a link and pressing
* Delete). No cascade: a link touches nothing that depends on it.
*/
function removeInfoLink(id: string): void {
if (!model.value.infoLinks.some((link) => link.id === id)) return
record()
model.value.infoLinks = model.value.infoLinks.filter((link) => link.id !== id)
}
/** Drop Clouds no longer referenced by any Flow. */
function pruneClouds(): void {
const referenced = new Set<string>()
for (const node of model.value.nodes) {
if (node.kind === "flow") {
referenced.add(node.source)
referenced.add(node.target)
}
}
model.value.nodes = model.value.nodes.filter(
(node) => node.kind !== "cloud" || referenced.has(node.id),
)
}
/** Replace the whole document (load / import / new). Clears history. */
function setModel(next: Model): void {
model.value = next
past.value = []
future.value = []
}
function undo(): void {
if (!canUndo.value) return
future.value.push(snapshot())
model.value = past.value.pop() as Model
}
function redo(): void {
if (!canRedo.value) return
past.value.push(snapshot())
model.value = future.value.pop() as Model
}
return {
model,
canUndo,
canRedo,
nodeCount,
loops,
loopsCapped,
addStock,
addConverter,
connect,
connectToNewCloud,
beginInteraction,
moveNode,
renameNode,
removeNode,
removeInfoLink,
toggleLinkPolarity,
setModel,
undo,
redo,
}
})

View File

@@ -15,3 +15,15 @@
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
/* Larger, easier-to-grab connection handles (Vue Flow defaults to 6px). The
Flow valve is small, so its handles get extra size. Loaded after Vue Flow's
own CSS (see main.ts) so these win. */
.vue-flow__handle {
width: 11px;
height: 11px;
}
.vue-flow__node-flow .vue-flow__handle {
width: 14px;
height: 14px;
}