From 6870d9f41dce2dc2f4d356651e347d5e26a87a45 Mon Sep 17 00:00:00 2001 From: Shreyan C Date: Sun, 6 Sep 2026 00:09:16 +0530 Subject: [PATCH] feat(blocks): a block is a thing you can run, ask about, and copy Drilling into a block changed what you could see and nothing about what you could do. Pressing play inside CPU/ALU/add ran the CPU, and if control never reached the adder on that word you learned nothing about the adder. StateMate was handed all three thousand states while eight were on screen. The clipboard was the one door left unlocked. - js/run-scope.js: a run boundary. "Run this block on its own" and "run the whole machine but stop when it enters this block" are one test on one step, enforced by the player's cursor rather than by twenty-five simulators. runStartId() is the one declaration the machine layer reads; App.startId stays the machine's start. - The trace log is its own card, and the rest of the run is reachable from it: the elided line is a button, and an expansion lasts only while the cursor is still. - scopedSource(): inside a block, the subject is that block plus its boundary. compileSpec takes a scope, so a scoped edit leaves the machine around the block standing rather than deleting it silently. - Ports describe the wiring, not the record: every crossing gets a tab, an undeclared one in orange. A note lives on one level (note.scope). - Copy/cut/paste across machines asks whether the transition shape is one this machine reads, and whether it can have blocks at all. - The profile weighs the level you are standing in, at every depth: a large machine hidden inside eight boxes is still a large machine. Tests: block-run, block-crossings, block-clipboard, blocks-statemate, statemate-scope, trace-log, note-scope. 1946 pass. --- CLAUDE.md | 124 ++++++++- css/canvas.css | 72 ++++- css/modals.css | 7 +- css/panels.css | 77 +++++- css/views.css | 207 +++++++++++++- electron/main.cjs | 1 + index.html | 75 ++++- js/alphabet.js | 28 +- js/blocks-ui.js | 15 +- js/blocks.js | 77 +++++- js/bridge.js | 13 +- js/canvas.js | 197 ++++++++++++-- js/electron-bridge.js | 3 +- js/machines/finite.js | 12 +- js/machines/index.js | 74 ++++- js/machines/omega.js | 8 +- js/machines/pushdown.js | 6 +- js/machines/transducer.js | 14 +- js/machines/turing.js | 26 +- js/machines/twoway.js | 10 +- js/machines/weighted.js | 8 +- js/main.js | 1 + js/panel-sections.js | 10 +- js/parallel/snapshot.js | 5 + js/persistence.js | 15 +- js/render.js | 227 +++++++++++++--- js/run-scope.js | 350 ++++++++++++++++++++++++ js/simulation.js | 421 ++++++++++++++++++++++++---- js/state.js | 89 +++++- js/statemate-agent.js | 40 ++- js/statemate-compile.js | Bin 22499 -> 34710 bytes js/statemate-lint.js | 19 ++ js/statemate-prompt.js | 130 ++++++++- js/statemate-spec.js | 285 ++++++++++++++++++- js/statemate-ui.js | 58 +++- js/statemate.js | 42 ++- js/states-transitions.js | 65 ++++- js/ui.js | 176 ++++++++---- js/utils.js | 7 + js/view-graph.js | 350 +++++++++++++++++++----- tests/block-clipboard.test.js | 393 +++++++++++++++++++++++++++ tests/block-crossings.test.js | 471 ++++++++++++++++++++++++++++++++ tests/block-run.test.js | 482 +++++++++++++++++++++++++++++++++ tests/blocks-integrity.test.js | 23 ++ tests/blocks-statemate.test.js | 244 +++++++++++++++++ tests/harness.js | 10 +- tests/machines.test.js | 54 ++++ tests/note-scope.test.js | 6 +- tests/panel-float.test.js | 36 ++- tests/panel-sections.test.js | 14 +- tests/sim-blocks.test.js | 8 +- tests/statemate-scope.test.js | 283 +++++++++++++++++++ tests/trace-log.test.js | 132 +++++++++ tests/view-graph.test.js | 89 +++++- 54 files changed, 5191 insertions(+), 398 deletions(-) create mode 100644 js/run-scope.js create mode 100644 tests/block-clipboard.test.js create mode 100644 tests/block-crossings.test.js create mode 100644 tests/block-run.test.js create mode 100644 tests/blocks-statemate.test.js create mode 100644 tests/statemate-scope.test.js create mode 100644 tests/trace-log.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 864e76b..3217670 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -436,7 +436,15 @@ Three node kinds share one list, because every one of them is a thing with an `x - **A cache hit must not recompute a derived block size, and that is what makes `viewGraph()` cheap enough to be on the hot path at all.** `refresh()` called `blockSize()`, which falls through to `blockMembers()` + `blockChildren()` whenever a record carries no size of its own — and `inlineBlock` leaves those null, so that is the ordinary case rather than the exception. Both are unindexed filters that allocate, and this function is reached per edge label by `edgeLabelsHidden()` and per item by every surface that resolves a machine id to a drawn one. A select-all over 2000 transitions measured **617ms with eight blocks against 7ms without**, and the *root* cost was in a function whose whole job is to be free. Skipping it is sound because what a derived size derives from cannot change without `stillValid()` failing and the projection being rebuilt outright; only a hand-set size can move under a hit, and that is two reads off the record. [tests/view-graph.test.js](tests/view-graph.test.js) pins it on **object identity** rather than on a timer, because an equal box rebuilt every call is exactly the walk being guarded against and looks identical from the outside. - **`ownerMap` is one pass over the machine, not one per block.** A CPU is twenty blocks over three thousand states, and asking each block for its subtree separately is that product; the walk memoises per block on the way up instead. -**`largeMachineProfile()` judges the view, not the model.** A CPU is three thousand states in `App.states` while the reader is looking at eight boxes, and the profile as written would strip the edge labels and the eased layout off an eight-node diagram. `machineIsLarge()` reads `drawnSize()`, which `view-graph.js` installs into `state.js` as a **function** — pushed as a value it goes stale the moment a loader replaces `App.states` without the projection having been read since. What scales with the *model* rather than the view — the undo stack's byte budget, the autosave stringify — is still bounded by its own numbers. +**`largeMachineProfile()` judges the *level the reader is standing in* — that level and everything under every box on it, at every depth.** It used to judge the projection's node count, on the reasoning that a CPU is three thousand states in `App.states` while the reader is looking at eight boxes and an eight-node diagram should be drawn in full. Half of that was right and the other half was a belief that a box is small. **A box is not one node**: it carries a live preview of its interior, so it costs a `blockPreviewGraph` walk of the machine to build, a `blockPreviewKey` scan of the machine to key and up to `previewNodeBudget()` elements to draw — and the states behind it cost a full `JSON.stringify` on every autosave tick and a full workspace copy per undo entry, none of which cares how many boxes they are drawn as. So an arbitrarily large machine could hide inside a handful of boxes and be called small, and the note over `syncBlockNode` in [js/render.js](js/render.js) had already measured the frame that resulted — twelve boxes over 4800 states, **6.4ms of an 8.3ms repaint, with the profile off throughout**, because `drawnSize()` correctly reported twelve. + +`drawnSize()` therefore answers the level's **weight**, and it costs nothing to compute: `build()` already walks every state under the scope into `ownerMap` to work out which drawn node stands for it, so `owner.size` *is* the subtree count at every depth, and the interior transitions are the branch the edge loop was already discarding. It is cached beside the projection and never recomputed on a hit, by the same argument `refresh()` makes for a derived block size — what it derives from cannot change without `stillValid()` failing. + +The half that was right survives, and it is what makes this safe to leave on: the weight is the **subtree**, not the machine, so drilling into an eight-state adder inside that CPU weighs eight and gets its labels and its easing back. A genuinely small level is still drawn in full; it is only the belief that a box is a small thing that is gone. + +**The preview budget is part of the profile, and it has to be.** On a level made of boxes the labels, the easing and the minimap are a handful of elements between them and the previews are hundreds — so announcing the profile there while still drawing previews at full size would be announcing a simplification that had not happened. `previewNodeBudget()` answers `PREVIEW_MAX_NODES_LARGE` under the profile, and the budget is folded into `__previewKey`, or a preview drawn at the old budget keeps its hundred and twenty dots until its own interior happens to change. It stays a silhouette rather than going blank: blanking is what the zoom LOD already does below `LOD_LABEL_ZOOM`, where four pixels a state leaves nothing to recognise, but at reading zoom the shape is the whole reason a box is a drawing instead of a label. + +`machineIsLarge()` reads `drawnSize()`, which `view-graph.js` installs into `state.js` as a **function** — pushed as a value it goes stale the moment a loader replaces `App.states` without the projection having been read since. **`largeMachineOverridePrompt()` quotes the same numbers**, so a prompt shown inside a block asks about that block rather than about three thousand states the reader is not looking at. The layout stages are the one thing that still counts *nodes*: `buildLayoutContext` reads `states.length` off the context it is laying out, and twenty boxes really are twenty boxes to route and label. ### A block is its preview @@ -459,9 +467,26 @@ Three node kinds share one list, because every one of them is a thing with an `x - **A block is entered by its own ancestry**, not by appending to where the reader happens to be — the Blocks panel offers every block in the machine, and "open the multiplier" has to work from the top level. - **Escape pops a level, last in the ladder.** It already dismisses a menu, closes an aux view, cancels a half-drawn transition and clears the selection; going out one level is what is left when none of those applied, so drilling in never costs the key its other jobs. +- **A port describes the *wiring*, not the record — and that distinction is the whole of it.** `entry` and `exits` are what a block *declares*; the crossings are what the machine actually does, and the two diverge the moment anyone draws an edge. `buildPorts` used to read only the declaration, and the projection dropped every other edge that crossed the scope, because `ownerMap` walks only *down* from the current level: a state above it gets no owner at all, so `if (!from || !to) continue` needed **both** ends under the scope for an edge to be drawn. Four things were invisible from inside a block, every one of them silently — the model stays perfectly consistent, so nothing anywhere raises: + + - **an edge to any level above the immediately enclosing one, at any depth.** Inside `B`, an edge from a state in `B/C` to a top-level state simply was not there. Its N=1 sibling — an edge from `B/C` to a member of `B` — always drew, because that is an ordinary edge between two nodes at the current level, which is what made the gap look like a rendering quirk rather than a rule. + - **an edge into a member that is not the entry.** Invisible from inside, and from *outside* indistinguishable from an edge into the entry, since both collapse onto the same arrow into the box. + - **an edge out of a state that is not a declared exit.** + - **the second and later targets of a declared exit**, because the tab rendered `to[0]` and dropped the rest with nothing on screen to say it had. + + Every crossing now gets a tab, and an undeclared one is drawn in `--orange` rather than like the others. That is not decoration: a block whose boundary is not the one it declares is not a clean subroutine, so a copy of it placed elsewhere would have a wire hanging off a state its definition never mentioned. It is a finding, not a fault, which is why it is orange rather than red and why nothing refuses it. + +- **A tab anchors on a drawn *node*, not on a state**, which is what lets a nested block's box carry the crossings of everything inside it: the state an edge really leaves from is inside the box, and the box is the only thing on screen standing for it. `refresh()` resolves an anchor through `byId` rather than through `getState` — and so does the **drag**: `onPortDown` and `dragPortTo` kept `getState(node.anchor)`, which answers null for a box, so a tab on a nested block silently would not move. No error, no cursor change, nothing to see but a control that ignores you, which is the worst way for this to fail. `portAnchor()` is the one resolution. +- **A tab is sized to what it says, and it says it in two rows.** It was a transparent 96px pill with one line of 9px mono in it, and it failed twice: `ADDR_L_leaf_14 → ADDR_L_count_14` is not 96px wide in any font, so the label ran clean out of both ends; and with no fill it was drawn *through* by every edge and edge label behind it. `portBox()` sizes it between `PORT_MIN_W` and `PORT_MAX_W`, the label is clipped **from the front** (machine-generated names differ at the end), and the body is filled so it occludes. The two rows are the app's own labelling idiom — `LANGUAGE / MTM`, `ALPHABET Σ / 2` — and they carry the two facts one line could only ever carry one of: the **role** (`ENTRY`, `FROM`, or the block's own word for an exit) and the **target**. Direction is therefore said in language rather than in a glyph, which is why there is no arrowhead on it. +- **`placePortParts()` is one function with two callers**, the shape `startArrowD()` and `slideBlockPreview()` already carry their own notes about — and it is here because they drifted again. `movePortNode` moved the body and the label and left the role behind, still writing the label at the old single-line offset, so the moment anything called `updateFastDOM` the tab came apart on screen: box and one line at the new position, the other stranded where the tab had last been fully drawn. +- **A tab drags by the same rules as everything else on the canvas.** It kept a private `moved` flag where the rest of the app uses `App.dragPendingSnapshot`, and honoured neither grid snap nor auto-pan — so Shift did nothing and dragging a tab toward the edge of the viewport stopped there instead of bringing the diagram with it. Three ways for one gesture to behave unlike its neighbours, each invisible until you try it. +- **How far up the other end is, is part of the label.** `from u` is enough one level down and useless four levels down, where *which level is u on* is the whole question. `relativeTo` counts hops outward: nothing for the level immediately outside (much the commonest case, and the one the fixed label already read correctly), `↑2` and up for a further ancestor, `↗` for a sibling subtree, which is not "up" from here at all. The tab is 96px, so it carries the hop count, the nearest name and a `+N`; the **tooltip** carries the full path of every crossing and the transition each is on, columnar, the way `transTipRows` builds an edge's. +- **Clicking a tab follows the crossing, which generalises "go back out" rather than replacing it.** When the other end is on the level immediately outside — again, the only case that used to draw a tab at all — going to its scope *is* going out one level, so the gesture is unchanged for every port that existed before. What it adds is the case a fixed up-one cannot serve: a tab on a nested block whose edge runs to the grandparent, where up-one lands somewhere the edge does not go. +- **Ids are chosen so a hand-placed offset still resolves.** Offsets live on the block record keyed by port id, so the entry keeps `__in__` and a declared exit keeps `__out__::`; only the tabs that never existed before are new ids (`__in__:`, `__out__:x:`). A declared way in or out with nothing wired to it still draws and reads `entry` rather than a truncated `from` — "nothing arrives here" and "this is not the entry" must not look the same. +- **From outside, the arrow onto a box says what the box hides.** Several transitions collapse onto one drawn edge there, and the group already holds all of them, so `edgeTipFor` adds a row: *2 transitions, 1 not through B's entry: B/m2*. It also resolves its **heading** through `viewEdgeKeyFor` now — `getState` answers null for a block id, so every crossing edge on the canvas used to lose the one line an unlabelled arrow among a thousand others needs first. - **Ports are derived, never stored.** A drilled-in view without them is a disconnected fragment — you see the sub-machine and nothing about how it is reached, which is most of what you drilled in to find out. They are a third node kind in the view graph, so routing, culling, fit-to-screen and export handle them with no special casing; they reach no serializer; and their edges answer `null` from `viewEdgeGroup`, which is what makes every edge listener inert on one. A port edge draws **no label** — asking `transLabel()` for the label of something that is not a transition produces `undefined → undefined, undefined`. - **A port is *placed*, not pinned.** It used to sit at one fixed offset from its anchor, so it drew on top of whatever happened to be standing there and read as nailed to the diagram rather than laid out on it. `placePorts` scores candidate directions ideal-first — the same shape as the self-loop stage — so an uncrowded diagram costs one test per port and lands exactly where the fixed offset put it, while a crowded one steps the tab around its anchor. The offsets stay derived: they are recomputed whenever the projection is rebuilt and reach no serializer, which is why this is a placement pass and not a draggable handle. An exit's node id carries its **index** as well as its state, because a block may hand control back from one state under two labels — keyed on the state alone both tabs took one id, the second overwrote the first, and a block with two answers drew one unnamed exit. -- **The panel lists and the transition editor follow the scope, and the editor offers *states only*.** A From/To menu offering three thousand states is one you cannot find anything in — and one offering the block boxes beside them is worse: picking a box wrote `to: "b1"` onto a real transition, an endpoint naming no state the machine has. It was saved to the file and counted in the Transitions δ list, and drawn nowhere, because the projection has no endpoint to resolve. A state is the node kind with no `kind` at all, which is the test both the editor and `updateLPanel` use. `lpanelKey` carries the scope, or drilling in would leave the previous level's rows on screen — it changes what those lists show without changing one state or one transition. The formal definition beside them still reports the machine's own |Q|, which is the honest number there: every state inside a block is a state the machine really has. +- **The panel lists and the transition editor follow the scope, and the editor offers *states only*.** A From/To menu offering three thousand states is one you cannot find anything in — and one offering the block boxes beside them is worse: picking a box wrote `to: "b1"` onto a real transition, an endpoint naming no state the machine has. It was saved to the file and counted in the Transitions δ list, and drawn nowhere, because the projection has no endpoint to resolve. A state is the node kind with no `kind` at all, which is the test both the editor and `updateLPanel` use. **The canvas click path was the other half of that and stayed open**: `onStateDown` passed a block id straight to `openTransModal`, so drawing an edge onto a box wrote the same dangling endpoint the menus had been narrowed to prevent. It is *resolved* rather than refused — refusing is the smaller fix and the worse one, because while drilled in there is otherwise no way to draw a crossing at all, and drawing one to the box is the obvious gesture for it. `wireEndpoint` answers a block's entry when an edge arrives and its exit when one leaves, which is what those two fields are for; a block with several exits is not guessed at, because the wrong one is silent. `lpanelKey` carries the scope, or drilling in would leave the previous level's rows on screen — it changes what those lists show without changing one state or one transition. The formal definition beside them still reports the machine's own |Q|, which is the honest number there: every state inside a block is a state the machine really has. ### Making and reusing blocks @@ -496,7 +521,26 @@ Four things about it are worth keeping. **A paste lands in the scope the reader is standing in.** `{...s}` carries the source state's own `blockId`, so states copied while drilled into a block arrived still claiming to belong to it — pasted at the top level they vanished straight back inside the block they came from, with nothing on screen to say where they had gone. And a selected *block* is a node the reader clicked, so Ctrl+C has to mean something for it: it is copied as its **definition** (`outlineBlock`) and pasted through `inlineBlock`, the same path the library uses, rather than a second copier that could disagree with it. -[tests/view-graph.test.js](tests/view-graph.test.js) pins the projection, the array identity across a drag, the profile judging the view, that drilling in moves nothing in the model, and that a port is placed rather than pinned. [tests/block-render.test.js](tests/block-render.test.js) pins the preview against the minimap's own geometry and that a drag rebuilds none of it. [tests/block-actions.test.js](tests/block-actions.test.js) pins that grouping and ungrouping leave the language alone, in one undo step each. [tests/sim-blocks.test.js](tests/sim-blocks.test.js) pins the playback highlight against the projection — the crossing edge's drawn key, the box carrying the playhead for a state nobody can see, several interior states as one mark, the ring's shape, the trail rebuilding across a drill-in, and the preview marks — including that a step touching no block writes into no preview and that marking a run rebuilds none of them — plus the four non-playback surfaces above, since each is invisible on any machine without a block on it. [tests/block-render.test.js](tests/block-render.test.js) pins the slide against a record moved by anything but a drag. [tests/note-scope.test.js](tests/note-scope.test.js) pins the level a note lives on — that the field is absent at the top, that each level draws only its own, the one-level-down anchor and its ride along a dragged box, both ways a block can stop existing, and that the prune still holds a note still when it is run from another level. +**A block taken out of another block is where that copier's naming broke, and it broke silently.** `outlineBlock` stripped the instance prefix by *matching* `block.name + '/'` — which is right at the top level and wrong everywhere else, because a nested block's states are named with the whole path: `add/` does not match `ALU/add/scan`, so nothing was stripped and placing the definition prefixed a second time, giving `add/ALU/add/scan`. That is the accumulation the function exists to prevent, arrived at from the one direction a string match cannot see. It strips *positionally* now, by `blockAncestry(id).length`, which is the rule `localStateName` already states and for the same reason. How visible it was depended on depth, since `localStateName` also counts rather than matches: pasted at the top level the box read `ALU/add/scan` for what should have been `scan`, and pasted two levels down the stray segment happened to be eaten and the path read correctly by luck. + +**Copying is the one gesture in the app that crosses machines, so it is the one that has to ask.** A clipboard outlives the machine it was filled from — nothing clears it on a switch, deliberately, so copy on a TM / look at something else / switch back / paste keeps working. That is also what made it the only door left unlocked: copy on an MTM, switch the canvas to a DFA, press Ctrl+V, and the states, their tape-shaped transitions and any block records among them all landed. Nothing threw, because a transition is a plain object and every field on it is optional — the DFA simply went on to decide against rules holding `tapeSyms` it has no reader for, from states with no `symbol` at all. **A machine reading `undefined` rejects everything, which looks exactly like a machine that is merely wrong.** + +`transitionShapeRefusal(src, m, subject)` in [js/machines/index.js](js/machines/index.js) is the question, and it lives beside the schemas because `transitionFields` is what answers it — the app's one declaration of what a transition carries, so the test is that rather than the family or the name. DFA, NFA and ε-NFA share theirs and states move freely among them, as do TM, NDTM, LBA and ITM; that is the case worth keeping, since those differ in their *tape* and their *δ* rather than in their rules, neither of which is a property of a transition. MTM does not share the TM's, because its rules are a read *tuple*. + +Two things it deliberately does not do. It **does not consult `stateFields`**: a state's extra fields are defaultable — a Moore state pasted onto a DFA carries an `out` nothing reads, and every importer already produces exactly that — while a transition's are what the machine runs on, so refusing on them would turn a cosmetic mismatch into a refusal. And a fragment naming **no** machine is allowed through, the rule the `render.*` flags follow read from the other side; everything this app writes records one, so absence means something hand-made or older with nothing to compare against. `copySelection` stamps `App.clipboard.machine` for exactly that reason — a block definition records its own, but a loose state and its transitions record nothing. The clipboard is session state and reaches no serializer, so there is no older-file question to answer. + +**A block asks that question and one more.** Every other route onto a canvas already asked the machine — the context-menu row is gated on `machineSupportsBlocks()`, the Blocks panel is hidden without it, the JFLAP importer drops the grouping and StateMate strips the field — and the clipboard was the one that did not, so a block reached a machine with no concept of one and the DFA carried a `blocks` array over states named `ALU/add/scan`, drawn as boxes it has no drill-in for, all the way into the `.json`. + +`blockPlacementRefusal(def, m)` in [js/blocks.js](js/blocks.js) is the pair of them, asked once and enforced **inside `inlineBlock`** — the one path onto a canvas, which the library, the clipboard and the wizard all arrive through — so a caller that forgets to ask cannot be the way this comes back. It is separate from `validateBlockDefinition` because the two ask different things: that one is about the definition alone, which is why *saving* to the library asks it, and a perfectly well-formed definition is still not placeable everywhere. Two questions in it: + +- **Can this machine have blocks at all?** `supportsBlocks`, declared on the family, never a name check. +- **Are its rules the shape this machine reads?** The same `transitionShapeRefusal` above, asked of `def.machine` — a block is inlined, so its transitions *become* the host's, which is the identical question a pasted state raises. **MTM is where it earns its keep**, and is the half a `supportsBlocks` check alone would have missed: an MTM has a stay move, so the first question says yes, and `setTapeArity` only reshapes arrays that already exist, so a single-tape block placed on one arrives with no `tapeSyms` at all. + +Three things follow. **The refusal is asked before the `commit`, never left to the throw** — a throw from inside `commit()` leaves the snapshot it took standing with no edit under it and no `emit` after it, which is an undo step that undoes nothing; `placeBlockDefinition` asks for that reason, and the library is the ordinary case rather than the odd one, since a definition kept while working on an MTM is still listed while working on a TM. **A mixed clipboard is refused whole rather than half**: pasting the states and dropping the block would leave the interior of a subroutine loose on the canvas under names that no longer mean anything, and the clipboard is *kept*, so switching back and pasting there still works. And **`loadData` filters `blocks` the way it already filters `scope`** — a document naming a machine with no stay move and carrying blocks anyway can no longer come from this app but can come from a hand-edited file, and the flat machine underneath is perfectly good, so the grouping goes and not one state, transition or verdict with it. That is the same call [js/import-jflap.js](js/import-jflap.js) makes for a JFLAP file whose family has no stay move. + +**Cut is copy plus delete, and the only thing that makes it more than two keystrokes the reader could press themselves is that it has to be one edit.** So the removal is `deleteSelection()` in [js/ui.js](js/ui.js) — no snapshot, no `emit` — and both callers wrap it: the Delete key takes one undo point, and `cutSelection()` copies first (against the live machine, since `outlineBlock` reads a selected block's subtree straight off `App.states`) and then takes the same one. The context-menu rows go through `ctxTargetSelection()`, because a right-click sets `App.ctxId` and deliberately does not disturb the selection: a node already selected means the whole selection, one that is not means that node alone. + +[tests/view-graph.test.js](tests/view-graph.test.js) pins the projection, the array identity across a drag, that drilling in moves nothing in the model, and that a port is placed rather than pinned. It also pins what the profile weighs: that a large machine hidden inside eight boxes is still a large machine, that the weight reaches through nesting however deep — a box holding nothing but another box still weighs what is under it — that a small level inside a large machine is drawn in full, and that the preview budget comes down with the profile and back up with the override. [tests/block-render.test.js](tests/block-render.test.js) pins the preview against the minimap's own geometry and that a drag rebuilds none of it. [tests/block-actions.test.js](tests/block-actions.test.js) pins that grouping and ungrouping leave the language alone, in one undo step each. [tests/sim-blocks.test.js](tests/sim-blocks.test.js) pins the playback highlight against the projection — the crossing edge's drawn key, the box carrying the playhead for a state nobody can see, several interior states as one mark, the ring's shape, the trail rebuilding across a drill-in, and the preview marks — including that a step touching no block writes into no preview and that marking a run rebuilds none of them — plus the four non-playback surfaces above, since each is invisible on any machine without a block on it. [tests/block-render.test.js](tests/block-render.test.js) pins the slide against a record moved by anything but a drag. [tests/block-crossings.test.js](tests/block-crossings.test.js) pins the boundary a drilled-in view describes — the grandparent crossing, the hop count, the tab on a nested block's box, the non-entry arrival, the non-exit departure, the second target a declared exit used to drop, the ids a hand-placed offset was stored under, and that an edge drawn onto a box lands on a real state — plus that the mark on a half-drawn edge comes off the node it went on, since `wireEndpoint` resolves a click on a box to a state the scope does not draw. [tests/block-clipboard.test.js](tests/block-clipboard.test.js) pins the definition a *nested* block comes out as, that a copy of one is independent, that a paste lands in the scope the reader is standing in, that a cut is one history entry rather than two, and the machine a paste is allowed to land on — the refused DFA, the refused MTM, the four tape machines that share a transition shape, that the whole of a mixed clipboard is refused, that the clipboard survives the refusal, and the same four answers for a clipboard of loose states rather than a block. [tests/machines.test.js](tests/machines.test.js) pins the rule under both, walking `MachineTypes` the way the rest of that file does: that a paste onto the machine it was copied from never refuses, that a fragment claiming no machine is let through, and that the answer for every ordered pair of machines is exactly whether their `transitionFields` agree — so a machine added to `state.js` cannot silently become paste-compatible with everything. [tests/blocks-integrity.test.js](tests/blocks-integrity.test.js) pins the load-side half: a document claiming a machine that cannot have blocks keeps every state and transition and loses the grouping. [tests/blocks-statemate.test.js](tests/blocks-statemate.test.js) pins that a no-op round trip through the candidate pipeline leaves the hierarchy and the scope exactly as they were. [tests/statemate-scope.test.js](tests/statemate-scope.test.js) pins both halves of the scoped turn — the cut, its boundary, the prompt's wording, that an agent session is shown the same block the prompt was and cannot delete the machine by rewriting it, that an empty `blocks` list dissolves while an absent one does not, and above all that a scoped edit leaves the machine around the block standing. [tests/trace-log.test.js](tests/trace-log.test.js) pins the lazy log, including that moving the playhead drops the window back to the tail. [tests/block-run.test.js](tests/block-run.test.js) pins the run boundary, including that a block run is step-for-step the whole-machine run from that point, that `App.startId` is read nowhere under `js/machines/`, that deciding answers about the machine however the run box is pointed, and — driven through `stepFwd` rather than `stepToEnd`, which is what let this ship — that playing a block run to its end shows the exit it left by and shows nothing before there is anything to show. [tests/note-scope.test.js](tests/note-scope.test.js) pins the level a note lives on — that the field is absent at the top, that each level draws only its own, the one-level-down anchor and its ride along a dragged box, both ways a block can stop existing, and that the prune still holds a note still when it is run from another level. ### Simulation @@ -515,6 +559,43 @@ All simulators produce the same artifact: a flat `App.simSteps` array the UI scr **The run button plays, pauses, resumes and replays — one control, four jobs.** Its not-playing branch used to call `runSim()` whatever the state, and `runSim()` begins with `resetSim()`: pausing to look at a configuration and pressing play again threw the run away and re-ran it from step 0, which is the one thing a pause button must not do, and it made pausing useless on exactly the runs worth pausing. Resuming is not a new capability either — the step-forward button already advances the same paused run without re-simulating; `canResumeSim()` is only what decides which of the two a press means. Two cases deliberately do *not* resume: a run sitting on its last step has nothing to resume and the button is already showing the replay icon by then, and a run whose word no longer matches the run box re-runs, because editing the box and pressing play means "run this". **`App.simInput` is what makes the second test possible** — the word the steps in `App.simSteps` were produced from, written after the guards so a refused run leaves nothing to resume into, and cleared by `resetSim` along with the steps. +### Running one block + +[js/run-scope.js](js/run-scope.js). **Drilling into a block used to change what you could see and nothing about what you could do.** `runSim` reads `App.startId` and the whole flat δ; the word `scope` appeared in [js/simulation.js](js/simulation.js) exactly once, in the trail cache's key. So pressing play inside `CPU/ALU/add` ran the CPU, and if control never reached the adder on that word you learned nothing about the adder. There was no toggle. + +**"Run this block on its own" and "run the whole machine but stop when it reaches this block" look like two features and are one** — a *run boundary*, a block whose subtree the player watches the run against. `subject` starts at that block's entry and stops when control leaves; `break` runs everything and pauses where control first enters. Both are answered by the same test on the same step, which is why they are one module rather than two passes over the same trace. + +Points worth keeping in mind: + +- **A boundary is enforced by the player, never by the machine layer.** Teaching twenty-five simulators to halt at a subtree would be twenty-five ways to disagree; the player already holds a cursor over the steps and already decides which one is on screen, so "do not go past this one" is a bound on the cursor. `stepAt` is the single gate — it was already the bounds check, so `stepFwd`, `stepToEnd` and the autoplay tick need nothing taught about it — and on a streaming run the steps past the boundary are never computed, because nothing pulls them. [tests/block-run.test.js](tests/block-run.test.js) pins the invariant that makes it safe: **a block run is step-for-step the whole-machine run from that point.** +- **The scan reads the steps array and never pulls, which is the one thing here that is easy to get catastrophically wrong.** "How far may the reader go" is asked on every render, and answered by walking the *cursor* it would materialize every step of the run on the first frame — draining a streaming machine to `maxTmSteps`, which is exactly the frozen tab [lazy execution](#which-kind-of-execution) exists to prevent. `scanBoundary` therefore inspects `steps` directly, with a cursor keyed on the array, so a run is walked once across every ask rather than once per ask. +- **`maxReachable()` is what an eager run needs, and it costs nothing when there is no boundary.** An eager simulator writes the whole trace before anything pulls a cursor over it, so there would otherwise be nothing to notice that control had left; the walk runs only when a subject or a break scope is set, and answers the array's end immediately otherwise. The scrubber's `max` and the step counter read the reachable prefix, or a block run's slider would reach into the host machine. +- **The one thing the machine layer reads is where to *start*, and that is now one declaration.** `runStartId()` in [js/state.js](js/state.js) answers `App.simStart || App.startId`, and `App.startId` stays exactly the machine's start: it draws the start arrow, it is q₀ in the formal definition, and it is what every exporter writes. A parameter threaded through the twenty-five reads would be twenty-four passing it on and one quietly deciding from the wrong state, so the test asserts against the source that `App.startId` appears nowhere under `js/machines/`. +- **The subject scopes the *player*, and deciding is a different question.** `decideMachine()` and `decideWord()` in [js/machines/index.js](js/machines/index.js) lift the override for the length of the call, so the Test Words table, the Language panel's fingerprint and StateMate's verification all answer about the machine. Left through, picking a block in the run box silently re-decided every one of them from that block's entry — a word flipping from reject to accept with nothing anywhere saying why. It is wrong rather than merely surprising, for two reasons that are the same reason twice: **a block has no F**, which is the whole point of the bullet below, so accept/reject measured from its entry against the host's F answers no question at all; and the Language panel prints Q, q₀ and F of the *machine* directly above the grid it would have scoped, so one panel came to contradict its own heading. `simStart` therefore does **not** ride in `snapshotMachine()` — a worker only ever decides, and its absence is what keeps the worker's verdict and the main thread's the same one. +- **A block has no verdict, so it does not borrow one.** Its accepting marks are dropped when it is inlined — a block finishing is not the machine accepting — so the answer is **which exit control left by**, which is what `block.exits` declares and what everything downstream of it branches on. An *undeclared* way out is reported as one rather than rounded to the nearest label: it is a real result, and it is also exactly what stops the block being reusable. A run that never leaves says that instead, since a caller downstream could wait on it forever. +- **The subject survives a reset; it does not survive the machine.** `resetSim` clears where the last run stopped and keeps what it was *about*, because re-picking the block for every word tried is the one thing you do repeatedly here. `clearTransientPointers` drops it, which is where the pointers that would outlive what they point at already go. +- **Changing the subject takes the run with it, and that is not tidiness.** A finished whole-machine run — one that went through the block and out the far side to an accepting state — was relabelled the moment the reader picked that block: ACCEPT became "No exit, control never left B", which is false about the very trace still drawn under it. It could not correct itself either, because `scanBoundary` is a cursor over an array it had already walked to the end. So `setRunSubjectFromUI` and `setBreakScopeFromUI` call `resetSim()` when the answer actually changed — steps produced from another start state, judged against another boundary, are not steps this question has an answer for. +- **The frame after the last one is the one that knows how the run ended.** The step where control leaves is materialized inside `stepAt` and then *refused*, so `App.simExit` is written after the last frame was painted; a streaming run learns it is `done` the same way, on the pull that fails. `stepFwd` redraws when either becomes true, and until either does a block run's banner shows nothing — on a streaming run the newest step always satisfies `isLast`, so where an ordinary machine falls through to a hidden banner this branch drew one, and "No exit — control never left B" flashed past on every frame of a run still inside the block and about to leave it. ⏭ escaped both, because it re-reads `maxReachable()` after every drain slice; play and step-forward, the two controls anyone actually uses, did not. +- **All of it is session state and reaches no serializer.** `runSubject`, `simStart`, `simBreakAt`, `simStopAt` and `simExit` are a property of this reader's investigation, not of the machine — the same reasoning [js/scope.js](js/scope.js) gives for the per-scope cameras. A file recording a start state that is not the machine's start would be a file that lies. +- **One `advanceOne()`, because there were three.** The autoplay tick was written out in `toggleAuto`, in `restartAutoTimerIfPlaying` and in the drain; two lines duplicated three ways is fine until something has to be added to all of them. +- **"Break in" is offered only while the subject is the machine.** Asking to stop on entering the block you are already starting inside is a question with no answer, and selecting a block is the other way of asking the same thing. The picker offers the machine and every block the reader is standing inside, outermost first — deliberately not every block in the machine, which is the Blocks panel's job. + +### The trace log + +**Its own card, and the rest of the run reachable from it.** + +A transport you operate and a history you read are two things, and they shared one box: reading back through a run pushed the play button off the top of the panel, and collapsing the transport took the log with it. They are also the two sections most worth pulling into windows *separately*, which [js/panel-sections.js](js/panel-sections.js) can only offer per card. `rp-trace` is that card; `rp-simulate` keeps the tracker as its own elastic region. + +**Only the tail is written, and that is not an optimisation.** The log used to be rebuilt from step 0 on every tick — quadratic in the length of the run, and the reason playing back a Turing machine got slower the longer it ran: at `maxTmSteps` a single tick meant building and parsing ten thousand divs, ten thousand times over. + +But *"the rest is gone"* is a different claim from *"the rest is not drawn"*, and the elided line made the first one — a count, and no way to reach what it counted. The steps are all still in `App.simSteps`; the only reason they are not on screen is that drawing them costs. So the floor is lowered on demand: the line is a button, and reaching the top of the log pulls the next page in behind it. + +- **An expansion lasts only while the cursor is still.** That is the whole of what keeps the quadratic from coming back. Every path that moves the playhead — a tick, a step, a scrub, a reset — calls `resetTraceWindow()`, because a reader watching playback is not reading history, and re-rendering a thousand revealed rows per tick is exactly the cost the tail exists to avoid. Reading history happens while paused, and there the render is one click. +- **A reveal holds the reader where they were.** The rows arrive *above* what is in view, so the content moves down by exactly the height added; without that, revealing a page throws them to the top of the page they have already read. +- **The button and the scroll both exist because either alone is wrong** — a button nobody sees at the top of a scroller is a dead end, and an auto-load with nothing naming it reads as the page jumping. The button is `position: sticky` for the same reason: it is the way *up* out of a scroller. +- **The header counts the prefix the reader can reach, not the array.** On a block run the steps past the boundary have been computed and belong to the host machine, so counting them had the Trace header say 6 over a scrubber reading `4 / 4`. `reachableCount()` is the one declaration the header, the scrubber and the step counter all read. +- `logFloor` is module state, so [tests/harness.js](tests/harness.js) clears it in `resetModuleState()` — a test that revealed a page would otherwise hand the next one a log already whole. Every path that moves the playhead goes through `handMovedPlayhead()`, which drops the window back to the tail **and** spends a pending break mark — a reader who has stepped past where control entered the watched block has already seen it, so pressing play afterwards must carry straight on rather than pause again on a mark it can no longer reach. + ### Which kind of execution **The trigger is run length, not machine size, and that is why this is not another thing `largeMachineProfile()` turns off.** The two are close to uncorrelated: a 1000-state DFA on a twelve-symbol word is thirteen steps and precomputing it is free, while a three-state Turing machine is `maxTmSteps` steps each carrying a tape snapshot, and it is that one which freezes the tab. Keying this on states-and-transitions would switch strategy on precisely the machines that never needed it. @@ -884,8 +965,31 @@ Two guards ride along. **`scopeGuard` drops `auto` to a proposal when an *edit* **Switching machine type has always worked** — `validateSpec` accepts any `MachineTypes` key, `compileSpec` starts clean on a type change, `assignCandidate` calls `applyMachineSwitch`. What was missing was telling the model, which refused buildable requests instead ("I build only DFAs"). `switchBlock` in the prompt lists every other machine with the extra transition fields it needs; only the current machine's full rules are spelled out, and the linter's repair round covers a switch that gets the shape wrong, since `lintCandidate` judges the machine the answer actually names. - **[js/statemate-spec.js](js/statemate-spec.js)** — the dialect, plus `parseTurn` above it. Deliberately *not* the workspace save format: no ids, no coordinates, `start`/`accept` as booleans on the state. Field names differ (`on` not `symbol`, `move` not `dir`, `out` not `output`) so a model that regurgitates a save file fails loudly at the gate instead of half-working. `transitionFieldsFor`/`stateFieldsFor` read the legal fields off the machine's own definition, which is the same list the editor draws its rows from and the wizard asks its questions from — so a machine cannot be describable to the model and un-editable on the canvas at the same time. `caveat` is the model's one line about a gap between the request and the machine — capped, unsevered (severity is the app's), and dropped when it narrates the repair rather than describing the machine. Imports `state.js` and the machine registry only — still nothing about the UI, the provider or the pipeline it feeds. -- **[js/statemate-compile.js](js/statemate-compile.js)** — spec → candidate, diffed against the live machine **by state name**. Survivors keep their id, their x/y, their anchored notes and their hand-tuned `curve`/`loopAngle`; only new states are placed, at the centroid of their placed neighbours plus `resolveNodeOverlaps`. This is the whole difference between an edit and a replacement — "add a trap state" must add one circle, not rearrange the diagram. A machine-type change starts clean rather than half-inheriting. +- **[js/statemate-compile.js](js/statemate-compile.js)** — spec → candidate, diffed against the live machine **by state name**. Survivors keep their id, their x/y, their anchored notes, their hand-tuned `curve`/`loopAngle` and **the block they are in**; only new states are placed, at the centroid of their placed neighbours plus `resolveNodeOverlaps`. This is the whole difference between an edit and a replacement — "add a trap state" must add one circle, not rearrange the diagram. A machine-type change starts clean rather than half-inheriting. - **[js/statemate-lint.js](js/statemate-lint.js)** — the machine-shape rules a schema cannot express, pure over the candidate. Three severities: `fix` is applied locally and *reported* (a fix the user cannot see is a fix they cannot distrust), `repair` costs a model round trip, `warn` never blocks. Determinism is the rule that earns its keep. +**Blocks are a dimension the pipeline had to be taught to *keep*, and until it was, it destroyed them.** `compileSpec` built fresh `{id, name}` states carrying no `blockId` and the candidate carried no `blocks`, so `assignCandidate` replaced `App.states` wholesale, `blockIsIntact()` then failed on every record, and `pruneBlocks()` dropped the lot — leaving the flat machine with its path names (`CPU/ALU/add/scan`) now meaningless literals and `liveScope()` quietly putting the reader back at the top level. A **no-op** round trip did it: `compileSpec(machineToSpec())` applied to a machine with blocks came back with `App.blocks === []`. The wizard shares the route, so `tests/wizard-apply.test.js`'s "press Create untouched and you get the same machine back" was true of ids, coordinates, curves and notes and false of the hierarchy, because no fixture there had one. + +The rule is **absent means unchanged** — the one the four `App.config.render` flags follow, and what lets every prompt, few-shot and test written before the field existed go on meaning what it meant: + +- **A spec that says nothing about the hierarchy has not asked for it to go.** The records are carried forward and reused states keep their `blockId`; a block the edit gutted fails `blockIsIntact()` and is pruned by machinery that already runs, so nothing has to work out which ones survived. +- **An *empty* list is a declaration, not an absence.** `blocks: []` is the only way for a model to say "this machine has no hierarchy any more", and read as "unchanged" it was a request the dialect could not express — the records were handed straight back and the reader watched a dissolve they had asked for silently not happen. So the branch turns on `Array.isArray`, never on length. +- **A spec that *does* send `blocks` is authoritative**, and its entries are matched to the existing records **by path** (`CPU/ALU 2`), not by name — a block's name is unique among its *siblings* only, so "add" under the ALU and "add" under the FPU are two blocks with one name. That is the same shape a state name already has, since inlining writes the path into it. A matched record keeps its id, and with the id its box, its position and its hand-placed port offsets; the record itself keeps the **local** name, because a record holding the path would write the path down twice and the two disagree the moment a parent is renamed. `blockPath()` in [js/blocks.js](js/blocks.js) is the one derivation. +- **Neither survives a machine-type change**, which is the rule `compileSpec` already states for ids, positions and curves: a block is a grouping over a machine, and the machine is a different object now. Nor does either reach a machine with no stay move — `machineSupportsBlocks` gates the field in `validateSpec`, which *strips* `block` from every state rather than leaving one standing for later stages to remember to ignore. +- **A dissolved block is reported by the diff, not by the linter.** The diff has both sides in hand; the linter is handed the candidate alone, deliberately, so it can lint a machine that is not and may never be on the canvas. It keeps one rule of its own for the shape it *can* see — a candidate carrying a record whose entry it also deleted, which is what the carried-forward path can produce. +- **`buildUserMessage` says where the reader is standing.** The machine sent is flat, as it always is, but "this" means something different depending on which level is on screen — and until that line the model had no way to know a reader asking why the adder rejects `11+01` was looking at forty of the three thousand states it was handed. + +**And when the reader is inside a block, the subject *is* that block.** Saying where they are standing was half an answer: `machineToSpec()` still sent `App.states` — every state at every depth — so the context chip offered "614 states, 19191 transitions" while eight were on screen, and the model had to find the adder in a processor. `scopedSource()` in [js/statemate-spec.js](js/statemate-spec.js) cuts the machine to one block and everything under it; it answers `null` at the top level, so a machine with no blocks in it sends exactly what it always sent. + +- **A cut without its boundary is a disconnected fragment**, the same thing a drilled-in view without ports would be, and for the same reason. `spec.scope` carries the entry, the declared exits and the crossing edges by name, and `boundaryBlock()` states them as **prose beside** the machine rather than as fields inside it — they describe the machine *around* the one being shown, which is not on the table, and a field the model could write to would say otherwise. `validateSpec` never reads `scope` back. +- **The start is the block's entry, not `App.startId`**, which is very likely not under the block at all. Control really does arrive there. +- **The write half is the dangerous one, and it is silent and total.** A spec naming only the block's states, diffed against the whole machine, is an edit that removed every state outside it — so asking a question about the adder would have deleted the processor around it, in one undoable step nobody would think to press. `compileSpec(spec, current, { scope })` bounds the diff to the subtree; `keepOutsideScope()` carries the rest through with its ids, coordinates and curves, which is the promise `compileSpec` already makes about a state the model left alone, applied one level up. +- **Agentic mode takes the same cut, and it was the one route past the guard.** The model was prompted with the block while `createAgentSession` built its private draft from the *whole* machine and compiled it against the whole machine with no scope — two halves disagreeing about what "this machine" means, with `replace_candidate_from_spec` one call away from handing back the block it had been shown as if it were everything. The session now takes `scope`, its draft is built from `scopedSource()` so `get_candidate` answers with what the console said was attached, and `refreshCandidate` passes the scope to `compileSpec`. `base` stays whole, because that is what `keepOutsideScope` carries the remainder through from. +- **A cut has to survive a JSON round trip**, and it did not. `scopedSource()` builds `blockPaths` as a `Map`, which `JSON.stringify` turns into `{}` — still truthy, so `blockContext` handed back an object with no `.get` and the next line threw. Everything downstream of a source is entitled to clone it (the agent session does, checkpoints do, a worker structured-clones), so the shape is normalised on read in `asPathMap` rather than defended at each call site. +- **Three rules there, and the third is the one that is easy to miss.** A state outside the subtree is carried verbatim; a transition with both ends outside is carried verbatim; and a transition that *crosses* the boundary is carried only if the end **inside** still exists — the outside end was never shown and cannot have moved, but the inside end may well be gone, and an endpoint naming nothing is saved to the file, counted in the δ list and drawn nowhere. +- **q0 and F belong to the machine, not to the block.** A scoped spec marks its own entry as the start, which is true of the block and false of the machine; written through, it would move q0 into a subroutine. F is the union of what the spec said about the states it governs and what the machine already said about the ones it does not. +- **The cut carries absolute block paths, including its own ancestors.** Walked over the filtered records, `ALU/ADD` comes back as `ADD` — which reads fine and matches nothing, since `compileSpec` pairs a spec block to an existing record *by path*, so a scoped edit would mint a second `ALU/ADD` beside the first. And a parent above the cut has to resolve, or the block being edited comes back declaring no parent at all, which reads as "move me to the top level" — the one thing a scoped edit must not be able to say by omission. +- **The chip says which of the two it is doing.** `Inside ALU/ADD: 8 states, 16 transitions` rather than a count that was right about the model and wrong about the question. + - **`verifyCandidate`** in [js/statemate.js](js/statemate.js) — the reason to trust the output. The model must predict what its machine does on ≥3 words; those predictions are executed through `computeBatchResults()` before anything is drawn. Stash the workspace, import the candidate without emitting, decide, restore in a `finally`. `computeBatchResults` is DOM-free by construction — that split is what makes this possible. - **[js/statemate-agent.js](js/statemate-agent.js)** — the tool runtime, and the second way a turn can reach `apply`. Instead of guessing a whole machine in one answer, the model may open a **private candidate** and work on it over several rounds: ~30 tools tagged `read` / `write` / `control`, covering reading the draft or the starting canvas, running words through the *real* simulator, the textbook constructions (`minimize_dfa`, `convert_nfa_to_dfa`, `complete_dfa`), private checkpoints, and the three that end a turn — `ask_user`, `request_approval`, `finish`. Nothing here touches `App`: tools mutate `session.draft`, and `finish` hands its candidate through the same compile → lint → verify → apply gate a one-shot answer goes through, so **the canvas is still written exactly once, at the end, or not at all**. `authority === 'ask'` refuses every `write` tool at the registry boundary rather than in the prompt. Budgets are `MAX_AGENT_STEPS` / `MAX_TOOL_CALLS_PER_STEP` / `MAX_AGENT_TOOL_CALLS`; tool rounds are continuations of one answer, deliberately not charged against the repair budget. Two rules are load-bearing and silent to break: @@ -1065,6 +1169,18 @@ Below 900px the canvas is the whole app and everything else is **one bar along t [tests/mobile-shell.test.js](tests/mobile-shell.test.js) pins the tool cells against `setTool`, the panel cell's naming, that the preference reaches none of the four serializers, the detents, that the head is injected once and lists every tab, the popover's Escape and tap-away, and that the jump-to-workspace button is offered with a single workspace. [tests/canvas-overlays.test.js](tests/canvas-overlays.test.js) pins the bar clearance and that a hidden bar reserves nothing. +### A dashed outline means "not part of the machine" + +Stated once, at the top of [css/views.css](css/views.css), because it was doing five jobs and none of them legibly. A dashed border was carrying *absence* (a blank tape cell), *a draft* (an uncommitted test word), *an invitation* (add one here), *a constraint* (a locked chip) and *a decided fault* (a proven loop) — so it told the reader only that something was unusual, and which unusual had to be worked out from context every time. + + dashed derived, drafted, absent, or offered — not (yet) part of the + machine, and carried by no serializer + solid something the machine actually has + +Three broke the rule and are solid now. **`.tv-cell.is-head.loop`** was the worst of them: a proven loop is a *decision* — the machine never halts, so the input is not accepted, which is strictly more than the step budget can tell you — and drawing the app's firmest verdict as an absence put it in the same visual bracket as an unwritten tape cell. It is `border-style: double` instead, which distinguishes it from an ordinary reject without borrowing the dash. **`.lang-sym.dead`** is a real fact about a real machine: uninteresting, not missing, and quiet is what colour and opacity are for. **`.wiz-chips .chip.is-locked`** is a constraint the machine has, so it takes the disabled idiom the app now owns. + +Everything still dashed is genuinely one of the four: `.tv-cell.is-blank`, `.tv-cell.is-ghost`, `.example-chip.is-pending`, `.example-card-add`, `.gram-stub`, `.canvas-info-btn.is-invite`, `.wiz-example`, and `.pn-body` — a port, derived from the wiring on every rebuild. + ### Themes Adding a theme touches two places, documented at the top of [js/themes.js](js/themes.js): a `:root[data-theme="id"]` block in `css/variables.css`, and an entry in the `Themes` registry. The entry needs an `export` palette because the SVG canvas and minimap paint from JS colour values, not CSS variables — `applyTheme()` ([js/ui.js](js/ui.js)) copies it into `App.config.export.*` and repaints. diff --git a/css/canvas.css b/css/canvas.css index 85e9cd5..d422b19 100644 --- a/css/canvas.css +++ b/css/canvas.css @@ -1543,12 +1543,29 @@ cursor: grabbing; } +/* ── the boundary tab ── + A DASHED OUTLINE MEANS "NOT PART OF THE MACHINE" — see the note at the top + of css/views.css. A port is derived from the wiring on every rebuild and + reaches no serializer, so it keeps the dash; what changed is everything the + dash was being asked to carry on its own. + + It was a transparent 96px pill with one line of 9px mono in it, and it + failed two ways at once. The label ran clean out of both ends, because + `ADDR_L_leaf_14 -> ADDR_L_count_14` is not 96px wide in any font. And with no + fill it was drawn *through* by every edge and edge label behind it. + + So: sized to its text (js/view-graph.js), filled so it occludes, and two rows + so the role and the target each get one. Direction is the ROLE — `ENTRY`, + `FROM`, or the block's own word for an exit — which says it in language + rather than in a glyph, and does not need to be read at any particular size + or angle. Colour is left to mean the one thing left: whether the block + declared this crossing at all. */ .pn-body { - fill: transparent; + fill: var(--bg2); stroke: var(--accent); - stroke-width: 1.2; - stroke-dasharray: 5 3; - opacity: 0.75; + stroke-width: 1; + stroke-dasharray: 4 3; + opacity: 0.9; } .pn.is-out .pn-body { @@ -1559,6 +1576,10 @@ opacity: 1; } +.pn:hover .pn-body { + fill: var(--bg3, var(--bg2)); +} + /* A hand-placed tab is drawn solid: the dash says "derived, placed for you", so a port the reader has put somewhere deliberately should stop claiming to be. It is the same thing `t.curve` does to an auto-routed edge. */ @@ -1567,6 +1588,49 @@ opacity: 1; } +/* ── a crossing the block did not declare ── + `entry` and `exits` are what a block promises about itself; every other edge + across the same boundary is a wire into or out of the middle of a + sub-machine. The block still works — this is a finding, not a fault, which is + why it is `--orange` and not `--red` — but it is not *reusable*: a copy of it + placed elsewhere would have that wire hanging off a state its definition + never mentioned. Worth seeing on the canvas rather than in a panel nobody + opens, and worth seeing at a glance among forty tabs. + + Written after .is-out so it wins on order rather than on !important. */ +.pn.is-stray .pn-body, +.pn.is-out.is-stray .pn-body { + stroke: var(--orange); +} + +.pn.is-stray .pn-role { + fill: var(--orange); +} + +/* A declared way in or out that nothing is wired to yet. Quieter than either, + because it is the one tab that describes an intention rather than an edge. */ +.pn.is-empty .pn-body { + opacity: 0.5; +} + +.pn.is-empty .pn-label { + font-style: italic; +} + +/* The two rows follow the app's own rule: the role is a small-caps label, the + target is an identifier. LANGUAGE / MTM, ALPHABET Σ / 2, FINGERPRINT / + 0 OF 257 — the tab was the one piece of canvas chrome that did not say what + kind of thing it was naming. */ +.pn-role { + font-family: var(--sans); + font-size: 7px; + font-weight: 700; + letter-spacing: .09em; + fill: var(--text3); + text-anchor: middle; + pointer-events: none; +} + .pn-label { font-family: var(--mono); font-size: 9px; diff --git a/css/modals.css b/css/modals.css index 8fd25ae..2942204 100644 --- a/css/modals.css +++ b/css/modals.css @@ -5070,10 +5070,15 @@ button:focus-visible { padding: 3px 5px 3px 10px; } +/* Locked is a constraint this machine has, not something absent — so it is the + app's disabled treatment (dimmed, no pointer) rather than a dashed edge, + which is reserved for what is not part of the machine. See the rule at the + top of css/views.css. */ .wiz-chips .chip.is-locked { color: var(--text2); padding-right: 10px; - border-style: dashed; + opacity: .62; + cursor: not-allowed; } .wiz-chip-sym { diff --git a/css/panels.css b/css/panels.css index 5ca595d..4c38937 100644 --- a/css/panels.css +++ b/css/panels.css @@ -88,8 +88,14 @@ } /* Fill alone carries the chip — the extra ring on top of it was what made - these read as heavy buttons rather than counts. */ -.lp-section-count { + these read as heavy buttons rather than counts. + + Both panels, from one rule. The right panel's Trace count was written into + the markup with a class nothing styled, so it drew as a bare "0" beside a + pill on every other section — the same drift .panel-header and .panel-tab + were pulled together to stop. */ +.lp-section-count, +.rp-section-count { min-width: 16px; height: 16px; padding: 0 5px; @@ -107,7 +113,8 @@ } /* A zero count is not news — mute it so populated sections stand out. */ -.lp-section-count[data-empty="1"] { +.lp-section-count[data-empty="1"], +.rp-section-count[data-empty="1"] { background: var(--surface3, var(--bg3)); color: var(--text3); } @@ -281,21 +288,48 @@ gap: 4px; } +/* A - +
@@ -393,7 +393,7 @@
- +
@@ -409,7 +409,7 @@
- +
@@ -725,7 +725,7 @@ - @@ -830,13 +853,26 @@ -
Run a string to +
+ + +
+
+ Trace + 0 + +
+
+
Run a string to simulate…