Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

106 changes: 106 additions & 0 deletions css/canvas.css
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,37 @@
vector-effect: non-scaling-stroke;
}

/* A run does not stop at a block's edge — it steps inside, where there is
nothing on screen to mark. The box standing in for those states is what the
reader can see, so it takes the playhead's marks: the machine is flat, and
which node is drawn for a state is the projection's answer (js/view-graph.js),
not the run's. Written against `.bn-body` because a block is a rounded rect
where a state is a circle, and the ink is the only difference. */
.bn.act-st .bn-body,
.pn.act-st .pn-body {
fill: var(--state-active-fill);
stroke: var(--accent);
stroke-width: 2;
filter: var(--state-active-shadow);
vector-effect: non-scaling-stroke;
}

.bn.rej-st .bn-body,
.pn.rej-st .pn-body {
fill: var(--state-reject-fill);
stroke: var(--red);
stroke-width: 2;
vector-effect: non-scaling-stroke;
}

.bn.sim-visited-st .bn-body,
.pn.sim-visited-st .pn-body {
stroke: var(--accent);
stroke-opacity: .65;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}

.edge-g.sim-trail-t .tarr {
stroke: var(--accent);
stroke-width: 1.5;
Expand Down Expand Up @@ -317,9 +348,22 @@
pointer-events: none;
transform-box: fill-box;
transform-origin: center;
/* Screen-space, like every other simulation stroke above: a 2px ring divided
by the camera is nothing at the zoom a large machine is read at, which is
where an arrival is hardest to spot in the first place. */
vector-effect: non-scaling-stroke;
animation: sim-pulse .5s ease-out forwards;
}

/* The same arrival, on a box. The ratio is what carries the meaning, not the
distance: 1.7x of a 22px circle is a ring that reads at a glance and 1.7x of a
200px block is a sweep across half the diagram. A box is already the largest
thing on the canvas, so it needs the smaller ramp and a little longer to be
read. */
.sim-pulse.is-box {
animation: sim-pulse-box .55s ease-out forwards;
}

.sim-pulse.rej {
stroke: var(--red);
}
Expand All @@ -340,6 +384,18 @@
}
}

@keyframes sim-pulse-box {
from {
transform: scale(1);
opacity: .9;
}

to {
transform: scale(1.14);
opacity: 0;
}
}

@media (prefers-reduced-motion: reduce) {
.edge-g.sim-active-t .tarr {
animation: none;
Expand Down Expand Up @@ -1407,6 +1463,56 @@
stroke: var(--gold);
}

/* ─── The run, inside a preview ───
A box on the canvas is a small drawing of the machine inside it, so the
playback marks have to reach one level in: without these the box lights up
and the dot that is actually running stays the same grey as the twenty around
it, which says "something in here" and nothing more.

The dots are 1.6–7px, so the mark cannot be a stroke weight — at the bottom
of that range a 1px ring is the whole node. It is a fill change plus a glow,
which reads at any of those sizes, and the glow is what carries it when the
dot itself is two pixels across. */
.bn-pv-node.is-visited {
fill: color-mix(in srgb, var(--accent) 45%, var(--bg2));
}

.bn-pv-node.is-active,
.bn-pv-block.is-active {
fill: var(--accent);
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--accent) 85%, transparent));
}

.bn-pv-node.is-rej,
.bn-pv-block.is-rej {
fill: var(--red);
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--red) 85%, transparent));
}

.bn-pv-block.is-visited {
fill: color-mix(in srgb, var(--accent) 30%, var(--bg2));
}

/* The transition being taken, over the quiet edges and under the dots — the
paint order the canvas itself uses. Empty `d` the rest of the time, which is
why it costs nothing on a box the run is nowhere near. It carries no dash
animation: at this scale a 7-5 dash pattern on a twelve-pixel edge is one
dash, flickering. */
.bn-pv-active {
fill: none;
stroke: var(--accent);
stroke-width: 1.6;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
pointer-events: none;
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--accent) 70%, transparent));
}

.bn-pv-active.is-rej {
stroke: var(--red);
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--red) 70%, transparent));
}

.bn-pv-node.is-start {
stroke: var(--green);
}
Expand Down
23 changes: 21 additions & 2 deletions js/blocks-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@

import {
blockAncestry, blockChildren, blockMembers, getBlock, inlineBlock, liveBlocks,
machineSupportsBlocks, outlineBlock, removeBlock, uniqueBlockName,
blockRemovalIds, machineSupportsBlocks, outlineBlock, removeBlock, uniqueBlockName,
validateBlockDefinition, blockDefinitionCycle, BLOCK_NAME_SEP
} from './blocks.js';
import { clearSelection } from './canvas.js';
import { commit } from './history.js';
import { askConfirm } from './modal.js';
import { pruneNoteAnchorsExcluding } from './notes.js';
import { openWorkspaceDb } from './persistence.js';
import { enterBlockScope, syncScopeBar } from './scope.js';
import { $, App, getState, stateNameKey } from './state.js';
Expand Down Expand Up @@ -173,6 +174,15 @@ export function ungroupBlock(id) {
if (parent) s.blockId = parent; else delete s.blockId;
}
for (const child of blockChildren(id)) child.parent = parent;
// The notes written inside this block come up with its states. Left behind,
// `noteScopeOf` would answer null for a block that no longer exists and they
// would all surface at the *top* level rather than at the one their states
// just landed on — the right rescue for a deleted block and the wrong answer
// for a dissolved one, which knows its own parent.
for (const n of App.notes || []) {
if (n.scope !== id) continue;
if (parent) n.scope = parent; else delete n.scope;
}
App.blocks = (App.blocks || []).filter(x => x.id !== id);
invalidateViewGraph();
}, Change.GRAPH);
Expand Down Expand Up @@ -326,7 +336,16 @@ export function ctxDeleteBlock() {
confirmLabel: 'Delete',
danger: true,
onConfirm: () => {
commit(() => { removeBlock(id); invalidateViewGraph(); }, Change.GRAPH);
commit(() => {
// While the ids are still resolvable, so a note *outside* the block that
// anchors into it keeps the position it was drawn at rather than jumping
// to its stored offset. The notes written inside go with the block —
// removeBlock takes those.
const gone = blockRemovalIds(id);
pruneNoteAnchorsExcluding([...gone.states], gone.transitions);
removeBlock(id);
invalidateViewGraph();
}, Change.GRAPH);
showStatus(`Deleted ${b.name}`);
}
});
Expand Down
51 changes: 48 additions & 3 deletions js/blocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -674,17 +674,62 @@ export function machineAsBlockDefinition(opts = {}) {
* Like inlineBlock, this neither snapshots nor emits: deleting a block is one
* edit, and the caller owns the undo point.
*/
export function removeBlock(id) {
/**
* What removing a block would take with it: every state at every depth behind
* the box, and every transition touching one.
*
* Exported because the *callers* need it before the call. `pruneNoteAnchors-
* Excluding` has to run while the ids are still resolvable, so a note anchored
* into the subtree can be held where it was drawn — and the Delete key's own
* prune names `App.selectedStates`, which for a block holds the box's id and no
* state at all, so it named nothing and the notes settled at their stored
* offsets instead. One declaration, so what is pruned cannot drift from what is
* removed.
*/
export function blockRemovalIds(id) {
const subtree = new Set(blockSubtree(id));
if (!subtree.size) return false;
const doomed = new Set((App.states || [])
const states = new Set((App.states || [])
.filter(s => s.blockId && subtree.has(s.blockId))
.map(s => s.id));
const transitions = (App.transitions || [])
.filter(t => states.has(t.from) || states.has(t.to))
.map(t => t.id);
return { subtree, states, transitions };
}

export function removeBlock(id) {
const { subtree, states: doomed } = blockRemovalIds(id);
if (!subtree.size) return false;
if (!doomed.size && !subtree.size) return false;

App.states = (App.states || []).filter(s => !doomed.has(s.id));
App.transitions = (App.transitions || []).filter(t => !doomed.has(t.from) && !doomed.has(t.to));
App.blocks = (App.blocks || []).filter(b => !subtree.has(b.id));
// The notes written *inside* the subtree go with it. Surfacing them at the
// top level instead — which is what `noteScopeOf` does for a scope that has
// simply stopped existing — is the right rescue for a record that vanished by
// accident and the wrong answer for one the reader deliberately deleted: a
// block with thirty notes in it would empty thirty notes onto the machine
// above, at coordinates from another level, every one of them pointing at
// states this call has just removed. Deleting a block means deleting what was
// in it, and one Ctrl+Z brings the notes back with everything else, because
// serializeState carries them.
//
// A note that merely *anchors* into the subtree is left alone: it lives
// outside, so it is not part of what was deleted, and pruneNoteAnchors drops
// its dangling anchors on the next render and freezes it where it was.
//
// Filtered here rather than through removeNotes() because notes.js reaches the
// DOM and this module deliberately imports nothing that does. The two model
// fields that can point at a gone note are cleared with it.
if ((App.notes || []).length) {
const orphaned = new Set(App.notes.filter(n => n.scope && subtree.has(n.scope)).map(n => n.id));
if (orphaned.size) {
App.notes = App.notes.filter(n => !orphaned.has(n.id));
orphaned.forEach(id => App.selectedNotes.delete(id));
if (orphaned.has(App.activeNoteId)) App.activeNoteId = null;
}
}
for (const sid of doomed) App.accepts.delete(sid);
if (doomed.has(App.startId)) App.startId = App.states[0]?.id || null;
invalidateBlockIndex();
Expand Down
Loading
Loading