All notable changes to Cryptographer are documented in this file.
The format is based on Keep a Changelog, and the project adheres to Semantic Versioning. See docs/versioning.md for the versioning policy (release process, step-type @N suffix bumps, and document schemaVersion migrations).
- Blowfish gained ECB and CBC — the first non-AES cipher with multi-block modes, and the first 8-byte block to run them (
docs/plans/foamy-prancing-wren.mdPhase C). Modes of operation have been AES's private property since they shipped; Blowfish now offers ECB and CBC in the mode-of-operation dropdown, encrypt and decrypt, and gains the padding selector. This is the phase that made the previous two honest. Phases A and B built a cipher-agnostic mode machine and made it block-size-generic — but every cipher it could drive was AES, whose block is 16 bytes, so a stray hardcoded16anywhere in that arithmetic would have passed the entire test suite (AES-192/256 didn't help; also 16-byte). Blowfish is the first core whose block width actually exercises it: the iterate splits at 8, CBC'sfetch-ivreads 8, the padding overlay fills to 8, and a 20-byte message pads to 24 rather than 32. The cost was exactly what the seam promised — one new file (src/ciphers/blowfish-core.ts, a sibling ofaes-core.ts), three table rows, and no change whatsoever to the mode builders, which still know nothing about which cipher they're driving. Verified two ways: a composed oracle (node:cryptocannot do Blowfish — OpenSSL 3 removed it to the legacy provider — so the mode's chaining rule is composed over the already-pycryptodome-verified single-block core, which is what a mode of operation is), and, better, ECB's blocks land on the published Eric-Young vectors — ECB is by definition each block encrypted independently, so a published single-block vector is a published ECB vector (tests/blowfish-modes-kat.test.ts). The seed-threading that a core requires turned out to be one line: Blowfish's round builder already took its input as a parameter, so only round 1's$inputwas hardcoded. The canonical single-block Blowfish specs are provably untouched — snapshotted before the extraction and deep-equal after, so the demo could not have moved a frame. Blowfish also gains PKCS#7 in single-block mode, because "has a core" is the single gate for both "can run a mode" and "the padding overlay knows your block size"; keeping it out would have required a second gate encoding nothing but "AES is special". It is the first cipher able to show a pad filling to 8 bytes rather than 16 — a lesson the three AES variants structurally cannot teach. The remaining four ciphers (Speck/Serpent/DES/Twofish) are now cheap follow-ups templated on this file. NoschemaVersionchange. - AES-192 and AES-256 gained ECB and CBC (
docs/plans/foamy-prancing-wren.mdPhase B). Multi-block modes were AES-128-only since they shipped; both other variants now offer them in the mode-of-operation dropdown, encrypt and decrypt. The specs are generated from each variant'sBlockCipherCorerather than hand-written per (variant × mode × direction) — the payoff of the Phase-A seam, and the reason this cost two lines instead of eight files. Verified against the published NIST SP 800-38A §F vectors (§F.1.3/§F.1.5 ECB, §F.2.3/§F.2.5 CBC) and, independently,node:cryptoon a message NIST never published (tests/aes-192-256-modes-kat.test.ts). The plan called this "a table-only change, correct by construction" — butaesCoreat Nk=6/Nk=8 driving the mode builders was a path no test had ever run, and its failure mode is silent (plausible-but-wrong ciphertext), so it got a KAT anyway.
- Block-cipher modes, padding, and the IV stopped assuming a 16-byte block (
docs/plans/foamy-prancing-wren.mdPhase B). Five places hardcoded AES's block width, which is why every non-AES cipher was single-block-only with the padding selector greyed out. They now read the width from the active cipher'sBlockCipherCorevia a new registry (src/ui/stores/block-cipher-cores.ts): the padding overlay takes the block size as a parameter (and its AES-by-spec-id-prefix gate is deleted — the caller passing no width is what scopes it now, so a hash or RSA can't be padded),paddingLimitsderives every bound from the core and honours the cipher mode, the Run handler's alignment check and its error prose name the actual cipher, and the IV store accepts one block of bytes rather than throwing on anything but 16 — an 8-byte-block cipher in CBC needs an 8-byte IV, which was previously unrepresentable. This is plumbing, not a new capability: a cipher still needs aBlockCipherCoreto run a mode at all, and AES is the only family with one today. The remaining eight ciphers are single-block for that reason — not the block-size limitation the old code comments claimed (they described a blocker retired withMatrixStateback in Phase 5, and are purged here). Because no shipped cipher has a non-16 block in a mode yet, the newly generic paths are pinned by a fake 8-byte-block core intests/block-size-generic-modes.test.tsrather than by the app. NoschemaVersionchange.
- The IV no longer keeps AES's width after you switch to a cipher with a different block size (
docs/plans/foamy-prancing-wren.mdPhase C addendum). Selecting Blowfish + CBC left the 16-byte AES default IV (000102…0f) sitting in the IV field of a cipher whose block is 8 — and since the field validates against the active cipher's width, it was displaying a value it would have rejected if you typed it back. The IV is persisted across reloads while the cipher and mode selectors are session-only, so the drift ran both ways: an 8-byte IV left over from a Blowfish session would be handed to AES-128 on the next CBC run. The IV is now re-defaulted whenever the active block width changes, on both triggers — switching cipher, and switching single-block→CBC (where no cipher change fires but a previously-inert IV suddenly becomes load-bearing). AES-128 CBC still boots on the published SP 800-38A §F vector, so its first-impression run reproduces the §F.2.1 ciphertext as before. Found by looking at a screenshot, not by a test — and no test could have: the 16-byte default truncated to 8 by the runtime's port-length coercion is byte-identical to the correct 8-byte default, so the broken and fixed versions produce the same ciphertext. The defect was the stored value, so that is whattests/iv-width-reconcile.test.tspins.
0.8.0 - 2026-07-17
- A Twofish round diagram for the linear view — and the 4-way swap the graph view couldn't draw. Twofish's round isn't the 2-way Feistel form, so the linear view's Feistel trio (which all key off
analyzeFeistelRound, and which returns null for Twofish's 4-input recombine) simply never rendered for it: a Twofish learner scrubbing a round got the per-step chain and nothing else.<TwofishRoundDiagram />is the 4-rail equivalent — the four input words,g(R0)andg(ROL(R1,8))feeding the PHT,F0/F1landing on the two mix rails, and then the swap. The swap is the payload. A Twofish round ends withconcat(R2′, R3′, R0, R1): the two mixed words move to the front, the two carried words to the back, so this round's untouched pair is the next round's mixed pair. That 4-way rotation is Twofish's answer to the Feistel crossing, and stepping through 28 leaves one at a time hides it completely. The graph view deliberately omits it (Twofish rounds lay out horizontally ~2000px apart, where the same wires read as a tangle rather than an X); at single-round scale the four wires are ~50px, which makes the linear view the swap's honest home. Every label is derived from the round's real wiring rather than its leaf ids — which word each rail mixes, whichFeach⊕consumes, and each rotation named the way the Twofish paper names it (ROL 8, not the builder's equivalentROR 24) — so the encrypt and decrypt rounds (which differ in their two 1-bit rotations and the order those sit on the rails) both draw correctly, and a rewire moves the picture. Elements light up to show where the scrubber is and click to scrub back; thegboxes and the PHT stand for their several leaves rather than decomposing them, since the graph's canonical cell already draws g's interior. - KMAC128 / KMAC256 + KMACXOF128 / KMACXOF256 — the Keccak keyed MAC (NIST SP 800-185), and the app's first keyed hash (
docs/plans/effervescent-plotting-puppy.md). All four variants join the Hash selector. KMAC is the SHA-3 family's native alternative to HMAC: it is cSHAKE with the function name fixed to"KMAC", the message wrapped with a length-prefixed key block (bytepad(encode_string(K), rate)) in front and aright_encode(L)length commitment behind — binding the output length into the hashed input makes the tag length-committing (a 32-byte tag is not a prefix of a 64-byte tag for the same key/message, so a tag can't be truncated or extended). KMACXOF appendsright_encode(0)instead, turning it into an arbitrary-length keyed XOF — the single value is the only KMAC↔KMACXOF difference. Being the first keyed hash, KMAC surfaces the key field for a hash (previously hidden for the whole hash category): the key field now shows whenever the live spec actually consumes a key (inputs.key.byteLength > 0), so it lights up for KMAC (default = the NIST 32-byte sample key) and stays hidden for keyless hashes. The key flows through the sameaux["key"]channel the block ciphers use — no new plumbing — and is read back withaux-load-bytes@1, so it is a runtime input, never baked into the spec. The customization stringSis live-editable (the function nameNis shown read-only, fixed"KMAC"); the output length is the committed tag length (shared control). Reuses cSHAKE's SP 800-185 encoding primitives + the shared sponge tail unchanged. Verified against the independent triple oracle (from-scratch Keccak sponge + pycryptodome + the NIST SP 800-185 published KMAC/KMACXOF samples):tests/kmac-kat.test.tsdrives every variant through the real key-in-aux path the app uses — the NIST samples, prefix-instability (each output length independent), KMAC≠KMACXOF, and key sensitivity — plus a KMAC Save→Load round-trip and a real-Chromium smoke (key field appears, tag matches Sample #1, changing the key changes the tag). The key length is variable (SP 800-185 places no bound; capped at 512 bytes for trace legibility): the key field is the source of truth — the number of bytes you type drives the whole key block (inputs.key.byteLength, theaux-load-bytes@1read width, andencode_string(K)'sleft_encode(8·len)bit-length prefix), so committing a shorter/longer key structurally rebuilds the trace at that length (default = the NIST 32-byte sample).tests/kmac-variable-key.test.tspins all four variants at key lengths 1/8/16/31/40 — spanning bothleft_encodewidth regimes — against the same triple oracle (pycryptodome for lengths ≥ its per-strength minimum, the from-scratch sponge below it), and the store lockstep + document-load key-length sync are covered intests/spec-store-hash-branch.test.ts. NoschemaVersionchange. - cSHAKE128 / cSHAKE256 — the first NIST SP 800-185 functions: the customizable SHAKE, with live-editable customization strings (
docs/plans/effervescent-plotting-puppy.md). Both cSHAKE variants join the Hash selector. A cSHAKE is a SHAKE whose output is bound to two byte strings — a function-nameN(reserved for NIST-defined functions; empty for direct use) and a user customization stringS— so any change toSdomain-separates the XOF into a wholly unrelated instance. The whole of cSHAKE lives upstream of the sponge: three new port-native primitives build a customization prefix —encode-string@1(SP 800-185encode_string(S) = left_encode(8·len(S)) ‖ S— length-prefixed by its bit length, the classic footgun),bytepad@1(aligns it to a whole rate block), andright-encode@1(KMAC's length commitment, unused by cSHAKE but shipped now) — which is prepended to the message before the identical SHAKE absorb + squeeze (extracted into a sharedsrc/ciphers/sponge.ts, a provably-inert refactor —buildShakeSpecJSON byte-identical across 14 variant×length checks). Only the pad's domain byte differs (0x04, merge0x84), so no new pad step. The empty-customization branch is exact: withNandSboth empty, SP 800-185 defines cSHAKE to equal SHAKE, so the builder emits no prefix and uses domain0x1F— byte-for-byte a SHAKE pipeline. BothNandSare live-editable (text controls shown only for cSHAKE) and, like the shared output-length control, editing them structurally rebuilds the trace; they travel via Save/Share asconstant-load@1params and sync the controls on load. Verified against an independent triple oracle (a from-scratch Keccak sponge + pycryptodome + the NIST SP 800-185 published cSHAKE samples, all in agreement —node:cryptohas no cSHAKE):tests/cshake-kat.test.ts(NIST samples, an output-length sweep straddling the rate boundary, the empty-N/S ⇒ SHAKE reduction cross-checked againstnode:crypto, and customization sensitivity),tests/sp800-185-encodings.test.ts(the §2.3 encodings incl. the bit-vs-byte length), plus a real-Chromium smoke. NoschemaVersionchange. - SHAKE128 / SHAKE256 — the first extendable-output functions (XOFs), with a live-editable output length (
docs/plans/fuzzy-sprouting-grove.md, FIPS 202). Both SHAKE variants join the Hash selector, byte-equal tonode:crypto'sshake128/shake256across message and output lengths. They reuse SHA3-256's Keccak-f[1600] permutation + sponge absorb unchanged (extracted into a sharedsrc/ciphers/keccak-f.tsfirst, a provably-inert refactor —JSON.stringify(buildSha3256Spec())byte-identical before/after, so no SHA3 layout-pin or URL-share hash drift); the one new element is the variable-length squeeze loop. The user's binding pedagogical choice: the output length is live-editable — an "output bytes" numeric control (shown only for SHAKE) resizes the digest and structurally rebuilds the trace, so cranking it up makes new squeeze-permutation blocks appear on the canvas and cranking it down collapses them (default 200 bytes = 2 squeeze blocks for both variants; capped at 512 for trace legibility; a live caption shows the block count · rate · cap). The squeeze is honestly unrolled, not aniterate(the port-mode iterate derives its count from the input length, but a squeeze's count comes from the desired output length) and matches FIPS 202 Algorithm 8 — for n output blocks there are exactly nbyte-sliceextractions and n−1 Keccak-f permutations, no trailing wasted permute. Zero new step types: padding reuseskeccak.pad@1withdomainByte: 0x1F(the executor already merges0x1F ^ 0x80 = 0x9Fgenerically), the rounds reuse the shared Keccak-f machinery, and the squeeze isbyte-slice+ Keccak-f +concat. The absorb/pad narration is rate-parameterized so SHAKE128's 168/32 rate/capacity split never shows SHA-3's 136/64 numbers (an advisor catch — the KAT is blind to prose). Output length travels via Save/Share (captured structurally in thesqueeze.truncatestep'slength;applyDocumentsyncs the control from a loaded spec). Verified:tests/shake-kat.test.ts(98 KATs — published vectors +node:cryptoacross both the message-length and output-length axes, the0x9Fpad-merge case, and squeeze-block-count pins);tests/shake-graph-resolution.test.tsguards that every squeeze edge resolves in the value inspector. Store integration widens theHashunion +isHash+ label/description/history/default maps (cipher.ts),HASH_IDS(document-schema.ts, whose compile-time assert enforces the widening), andresolveHashDefaultbuilds SHAKE on demand at the editable length (spec.ts); a real-Chromium smoke confirmed the stepper rebuilds live and the graph renders without crash. NoschemaVersionchange. Deferred: SHA3-224/384/512 (rate/output re-parameterization on this same base), cSHAKE/KMAC (need the customization-string encoding), and a canonical sponge graph layout. - SHA3-256 — the second hash and the first sponge / SHA-3 function (
docs/plans/joyful-sauteeing-turtle.md, FIPS 202). Keccak joins the Hash selector (node:crypto-equal across the length range), the app's first non-Merkle–Damgård construction and the honest foundation for future post-quantum work — ML-KEM, ML-DSA and SLH-DSA all build on Keccak/SHAKE. It is fully port-native: the sponge absorb reuses the same port-modeiteratefold SHA-256's multi-block hashing uses (the carried chain is the full 200-byte Keccak state, bootstrapped to all-zeros; each block is XORed into the first 136 rate bytes, then the permutation runs), and the 32-byte digest is squeezed with a singlebyte-slice(256 bits fit inside the 1088-bit rate — no XOF loop; that is SHAKE's later slice). Keccak-f[1600]'s round θ→ρ→π→χ→ι maps onto four new primitives plus the existing vocabulary: θ = a newkeccak.theta@1(column parities over the non-contiguous lanes — one leaf, since decomposing it is ~30 leaves of lane-juggling with no pedagogical payoff); ρ = a new genericrotate-lanes@1(per-lane left rotation with 25 distinct offsets, little-endian — Keccak's lanes are LE, so the endianness lives inside the step and the 200-byte state never needs byte-reversal, and the big-endianrotate-bits-right@1is deliberately NOT used); π = a singlepermute@1lane transposition (a 200-byte gather); χ = decomposed intopermute/not/and/xorbecause it is Keccak's only nonlinear step and worth keeping visible (A'[x] = A[x] ⊕ (¬A[x+1] ∧ A[x+2])); ι = a new hybrid-portedkeccak.iota@1that XORsRC[round](sliced from a shared 192-byteaux["RC"]table viameta.auxReadPorts, preserving theRC → ιfan-out in the graph) into lane 0. Padding is a newkeccak.pad@1(pad10*1 + the0x06domain byte — no length suffix, unlike SHA-256; the one-byte-short-of-a-block case merges the domain and trailing pad bits into0x86). The entire decomposition was validated againstnode:cryptobefore any app code was written (feedback_crypto_verification):tests/sha3-256-kat.test.tspins the published empty/"abc"/quick-brown-fox vectors AND anode:cryptocross-check straddling every 136-byte rate boundary (135↔136↔137, 271↔272↔273) plus fold-engagement counts, all through the real runtime;tests/keccak-steps.test.tsKAT-checks each of the four primitives from the FIPS 202 formulas. Store integration addssha3-256to theHashunion + label/description/history/default-plaintext maps (cipher.ts), thehashDefaultstable (spec.ts), andHASH_IDS(document-schema.ts, whose compile-time coverage assertion caught the omission); four read-onlyParamEditorblocks; arotate-lanes@1provenance-allowlist entry (per-lane rotation → byte-approximate); and aConstantsPanelcollectConsumerscase so the RC table attributes its 24 ι readers. The RC round constants + the all-zero initial state live as module constants in the spec, never editable params. Browser-smoked: correct digest, clean default-collapsed graph, a round expands to render θ→ρ→π→χ→ι. NoschemaVersionchange. Deferred: SHAKE128/256 (the variable-length squeeze-fold XOF loop — reuses this exact Keccak-f + absorb), SHA3-224/384/512 (rate/output re-parameterization), and a canonical sponge graph layout. - Twofish — the sixth cipher family and third Feistel cipher (
docs/plans/twofish.md). Schneier, Kelsey, Whiting, Wagner, Hall & Ferguson's 1998 AES finalist — Blowfish's successor — joins the selector (encrypt + decrypt, single-block, 128-bit key in v1). It is the richest cipher in the box, exercising machinery no existing cipher has: a second and third GF(2⁸) field (neither AES's), a pseudo-Hadamard transform, input/output whitening, and 1-bit round rotations on the Feistel skeleton. The round body is port-native — a port-modegroupper round wiringsplit → g(R0) / g(ROL(R1,8)) → pseudo-Hadamard combine (two subkeys) → 1-bit rotations → concat, the Feistel swap expressed as theconcatargument order — where the g function is four aux-fed byte→bytetwofish.sbox-lookup@1leaves (a new primitive: Twofish's S-boxes are byte→byte, unlike Blowfish's byte→word) feeding an MDS matrix over GF(2⁸)/0x169 via a newgf-matrix-multiply@2(generalizes@1with afieldModulusparam, backed bygfMulPoly;@1untouched,0x11Bdefault = AES parity). Words travel the ports big-endian so the genericadd-mod-32@1/rotate-bits-right@1primitives apply unchanged; Twofish's little-endian serialization is honored by visiblepermute@1word-reversals localized to plaintext-in / ciphertext-out and inside each g. The key schedule follows Twofish's partial-visibility split (user decision): the pseudo-Hadamard subkey mixing is 20 visible PHT blocks ofadd-mod-32/rotate-bits-rightframes combining the h-outputs into the 40 subkeys (gathered by atwofish.publish-subkeys@1aux-publish tail, allowlisted like the other*-publish-round-keys@1tails), while the h-function machinery — the Reed–Solomon S-vector over GF(2⁸)/0x14D, the key-dependent S-box construction, and the 40 h-evaluations — is one opaquetwofish.h-expand@1step (publishing the A/B intermediates + the four byte→byte S-boxes to aux). Unlike Blowfish's silent monolith,h-expandcarries a rich value-prose narrator (twofishHExpandNarration) that discloses its four hidden stages (key decode → RS S-vector → S-box construction → h/A-B material) as<details>rows annotated with the real per-key values this run produced — read from the frame's published aux + display-only output ports (the S-vector words + master-key words ride inportOutputswithout a spurious unused-write edge). Decryption runs the same network with the 1-bit rotations inverted, the round + subkey order reversed, and the whitening subkeys swapped (K0..3 ↔ K4..7). Verified against TWO independent references (feedback_crypto_verification): a Python reference built from the published spec constants (paper MDS/RS matrices + q0/q1 t-table construction) cross-checked byte-for-byte against Niels Ferguson's reference C library at all three levels — the key-dependent S-boxes, all 40 subkeys, and endpoint ciphertext — agreeing on the canonical all-zero 128-bit vector9f589f5cf6122c32b6bfec2f2ae8c35a.tests/twofish-vectors.test.tspins the S-vector + 40 subkeys + S-box heads + a g-output BEFORE the endpoint KAT (the plan/advisor gate for a 16-round cipher), at both the oracle and full-spec-through-the-runtime layers;tests/twofish-roundtrip.test.tsadds the decrypt-KAT + round-trip;tests/twofish-narration.test.tsxdrives both narrators off real trace frames (doubling as the check thath-expandactually publishes A/B/S and exposes the S-vector on its display ports). Store integration adds aTwofishCipherfamily tocipher.ts(union, labels, structural + historical one-liners, default key/PT/CT tables — the default lands on the Ferguson-verifieddf8451d2…3203), adefaultsentry, theSUPPORTED_CIPHER_MODES_BY_CIPHERrow (single-block only;cipher-mode-fallbackcanary updated), thepaddingLimits16-byte case,CIPHER_IDS, and fourParamEditorblocks (the three Twofish steps +gf-matrix-multiply@2with an editable MDS matrix + read-only field polynomial). The q0/q1 + MDS + RS constants live as module constants (src/ciphers/twofish-constants.ts), never spec params. NoschemaVersionchange. Deferred: 192/256-bit keys (the k=3/4 h-function branches), multi-block ECB/CBC, the RS S-vector as a visible frame (needs a 4×8 GF step), full h-function decomposition, and the canonical two-column Feistel graph layout (the round's whitening-into-g + PHT shape doesn't matchanalyzeFeistelRound, so it falls back to the generic vertical stack). - Blowfish — the fifth cipher family and second Feistel cipher (
docs/plans/blowfish.md). Schneier's 1993 64-bit-block Feistel cipher joins the selector (encrypt + decrypt, single-block, 8-byte key in v1). Its round body is fully port-native — a port-modegroupper round wiringsplit-bytes → xor-with-aux(P[i]) → F → xor → concat, the Feistel swap expressed as theconcatargument order (DES precedent), with the F function((S0[a]+S1[b]) ⊕ S2[c]) + S3[d]decomposed into four aux-fedblowfish.sbox-lookup@1leaves (a new primitive — the S-boxes are key-derived, so the 32-bit-word table comes from aux, not params) plusadd-mod-32/xor; decryption is the same network with the P-array consumed in reverse. The one deliberate exception to the project's decompose-everything grain: Blowfish's key schedule runs the cipher on itself 521 times to regenerate its key-dependent P-array + four S-boxes, a hard data-dependency chain that has no legible frame-by-frame decomposition (a full unroll would be tens of thousands of frames). Per the "decompose what decomposes legibly" rule, the 521-loop is a single hybrid-portedblowfish.key-schedule@1monolith (samemeta.auxWritePortspublish-to-aux posture as the four*-publish-round-keys@1tails +rsa.publish-key-params@1; it doubles as the KAT oracle) — while thekey ⊕ Pmixing IS visible as 18 realxor@1frames (how a variable-length key enters the cipher). The π-digit P + S seed tables are module constants (src/ciphers/blowfish-constants.ts), never spec params — 4 KB of incompressible π digits would balloon every saved.cipher.jsonand#doc=share URL. Verified against the Eric-Young / pycryptodome Blowfish-ECB vector set (feedback_crypto_verification):tests/blowfish-vectors.test.tschecks both the pure math oracle AND the full port-native spec through the runtime on five published vectors (all-zero, all-one,0123456789abcdef/1111111111111111, …), andtests/blowfish-roundtrip.test.tspins the reversed-P decrypt + encrypt↔decrypt identity. Store integration adds aBlowfishCipherfamily tocipher.ts(union, labels, default key/PT/CT tables — the default lands on the published61f9c3802281b096vector), adefaultsentry, theSUPPORTED_CIPHER_MODES_BY_CIPHERmatrix row (single-block only; thecipher-mode-fallbackcanary updated), thepaddingLimits8-byte case,CIPHER_IDS, and two read-onlyParamEditorblocks. NoschemaVersionchange. Deferred: variable-length keys (4–56 bytes), multi-block ECB/CBC, and the canonical two-column Feistel graph layout (Blowfish's pre-FL ⊕ P[i]doesn't matchanalyzeFeistelRound'ssplit→F→xor→concatshape, so it falls back to the generic vertical stack). - Unified undo/redo across spec edits AND layout moves (graph-legibility plan Part C,
docs/plans/toasty-zooming-harp.md).undo/redobuttons in the App top toolbar (beside Run) plus Ctrl/Cmd+Z (undo), Ctrl/Cmd+Shift+Z and Ctrl/Cmd+Y (redo) drive ONE stack that reverts both kinds of change: spec edits (param, rewire, palette/composite drop, delete, duplicate-round, cross-mode mirrors, reset spec) and layout moves (drag, collapse, replication mode, layout reset). Snapshot-based and O(1) — each entry captures the whole dual-mode spec union + the whole layout map by reference (sound because both stores rebuild wholesale with structural sharing, never mutate in place); a single App-scopecreateEffect(on([specs, layout]))observer captures a pre-change snapshot for every mutation in one place, a pointer-drag coalesces into ONE entry (not per-pointermove),duplicateRoundInSpec's three writes arebatch()ed into one, and traces are never stored (the 200ms rerun regenerates them, frame preserved by stepId). Selector switches are stack boundaries (cipher / cipher-mode / algorithm / padding change, and document Load /#doc=boot) — they suppress the rebuild's transition and drop both stacks, since the rebuilt spec no longer matches any pre-switch snapshot's implicit selectors; an encrypt↔decrypt mode flip is NOT a boundary (the observer never watchesmode, so the stack survives). Two real-browser details the design gets right:Ctrl+Zinside a text field (plaintext / key / S-box / param inputs) does the browser's native text undo — the handler bails on editable targets first — and a Shift+lettere.keyarrives uppercase ("Z"), so the handler normalizes case or redo would be dead. The three graph "reset layout" confirmations dropped their now-false "Cannot be undone." copy. Depth: 50 steps. Coverage:tests/edit-history-c4.test.ts+tests/edit-history-shortcuts.test.tsx(shortcut wiring, editable bail, reactive button-disable accessors) on top of the C1–C3 suites, plus a real-Chromium block ine2e/exploratory-hash-and-edges.spec.ts(real Ctrl+Z / Ctrl+Shift+Z, editable bail, cipher-switch boundary). NoschemaVersionchange (undo history is session-only, never persisted). - Independent per-source colour and dash-style thresholds (graph-legibility plan Part A follow-up,
docs/plans/toasty-zooming-harp.md). "color by source" and "style by source" no longer share one fanout-threshold knob — each owns a separate≥counter in the graph toolbar, so the two disambiguation channels can be tuned independently. Both thresholds are now per-spec with a per-spec shipped default (drop-on-match persistence, mirroring the strokes enable map): SHA-256 opens with both channels fully on at threshold 1 — every source coloured and dashed — so the densest built-in reads clearly from first open, while every other built-in keeps the fanout-≥3 default. The (colour, dash) index pairing is no longer locked; that decoupling is the deliberate trade-off for independent cutoffs. This reverses the earlier "shared threshold, never split it" decision. NoschemaVersionchange (viewer preferences, never persisted to the document). - Authoring headroom for large iterate bodies (graph view). An expanded iterate with a large body (today only SHA-256's ~76-child per-block compression fold) opens ~2× taller, reserving empty vertical space below its children so a hand-authored default arrangement has room to breathe. AES ECB/CBC iterates (a single round-body group) are unaffected.
- Per-algorithm one-liners — a structural line in the header and a historical line under the selector (2026-07-11, split 2026-07-12). Each cipher, hash, and public-key algorithm now carries two short one-liners, deliberately complementary rather than duplicated. (1) A structural "what is this primitive" line (
CIPHER_DESCRIPTIONS/HASH_DESCRIPTIONS/ASYMMETRIC_DESCRIPTIONS+ adescribeAlgorithmrouter instores/cipher.ts) drives the header, right after the cipher name next to "Cryptographer" (always visible, shown regardless ofisCustom(), truncating with an ellipsis + full-texttitleon narrow viewports) and each dropdown<option>/<select>titletooltip so the learner can compare structures while the menu is open. (2) A historical line (CIPHER_HISTORY/HASH_HISTORY/ASYMMETRIC_HISTORY+ ahistoryOfAlgorithmrouter — designer, year, lineage) drives the always-visible muted caption on its own full-width row directly below the dropdowns (flex: 0 0 100%, so it wraps to its own line rather than reflowing the settings row). Both routers share the family-variant granularity ofCIPHER_LABELS(AES-128/192/256, the two Speck byte orders, Serpent-128/192/256 each get their own structural line; history is a family property, so those variants share their family's sentence). NoschemaVersionchange. - Blowfish linear-view pedagogy — the opaque key schedule expands into rows, and the F-function S-box lookup gets value-prose (2026-07-11). Two per-frame narrators, both keyed on Blowfish-only step types so no other cipher's linear view changes. (1) The 521-encryption key-schedule monolith stays one trace frame (there is no legible per-encryption decomposition) but now discloses into coarse pedagogy ROWS via the existing
StepNarration<details>mechanism — mix → P-fill → S-fill → publish — each annotated with the real values this run produced (the 72-byte key-mixed P it read, all 18 final P-array words, and the head of each of the four S-boxes), read from the frame'sportInputs+ the P/S it published toauxWritten. (2) Theblowfish.sbox-lookup@1F-function lookup — a 1-byte index → 4-byte word, exactly the transformation the port-I/O table doesn't make legible — gets value-prose naming the specific key-derived S-box (S0[entry 19] → 32-bit word) and the resolved word, with a note that the boxes are regenerated from the key. The round body's shared port-native arithmetic (xor@1/add-mod-32@1/xor-with-aux@1/concat@1/split-bytes@1) is deliberately left toPortFlowView+ each leaf'snarrationOverridedetail — the same posture as AES's port-native round body, which carries no value-prose either (narrating them would necessarily touch every port-native cipher, since narration is keyed by shared step type). Coverage:tests/blowfish-narration.test.tsxdrives both narrators off real trace frames (doubling as the check that the monolith frame actually publishesblowfish.P.*/blowfish.S*); the narration-registry contract test stays green (both types sit below the cell-shape gate, so the additions need no allowlist edit). NoschemaVersionchange.
- More scroll headroom below and right of the graph canvas (2026-07-12). The canvas now appends trailing padding (180px bottom, 120px right) beyond the leading margin, so the bottommost / rightmost nodes are no longer flush against the scroll boundary — the user can scroll the last row up into the middle of the viewport to read it (previously "hard to access and read even when scrolled maximally down"), and because the value inspector floats sticky at the top-left, the extra room lets any node be panned out from under it. The layout ORIGIN is untouched (still
CANVAS_MARGIN= 60), so no pinned position, drag test, or density snapshot shifts. CSS/layout only, noschemaVersionchange. - "color by source" and "style by source" now default ON at threshold 1 for EVERY spec (2026-07-11). Previously the colour channel shipped globally on but auto-coloured only sources fanning out to ≥3 consumers on most ciphers (≥1 only for SHA-256 + RSA), and the dash-style channel shipped OFF-except-SHA-256/RSA. Per the user's request, both channels now open fully on for Blowfish and all ciphers and hashes, present and future:
defaultColorThresholdFor/defaultStrokeThresholdForreturn 1 universally anddefaultStrokeStylingForreturns true universally (the oldsha-256/rsaprefix sets are gone;DEFAULT_COLOR_THRESHOLD/DEFAULT_STROKE_THRESHOLDare now 1). Every source's outgoing edges get a distinct colour and dash pattern on first open, so source identity reads at a glance on any cipher without touching the toolbar knobs. The per-spec + drop-on-match persistence is unchanged (raising a spec's threshold or turning a channel off still persists as a divergence-from-default), so nothing a user previously customised is lost. Viewer preferences, never persisted to the document — noschemaVersionchange.
- A group-to-group
seedInputcarry no longer draws a redundant "no frame found" container edge (graph view, 2026-07-13). SHAKE's squeeze loop introduced the first shipped spec where a top-levelgroupseeds from anothergroup's published"out"(squeeze.perm.{j}.seedInput = port("perm.{j-1}","out")). The loop-input-edge pass drew a container→container edge (perm.1 → perm.2) alongside the real resolved leaf→leaf carry (…round.23.iota → …round.0.theta), and that container edge reported "no frame found" in the value inspector — the same failure class as the DESinitial-permutation → roundsredundancy, reached through a group"out"rather than a leaf. The "does the seed already reach a descendant?" skip-check compared the rawseedInputnode (a group id) while the resolved leaf edge'sfromis the deep producing leaf, so the skip never fired. Resolving the seed throughresolveSeedChainbefore the check drops the redundant edge; SHA-256'sblocksloop-input edge is unaffected (its seed is a leaf"output", and the chase stops at the iterate boundary). Pinned bytests/shake-graph-resolution.test.ts;tests/graph-arrival-dot-coverage.test.tsstays green across every cipher. Core only, noschemaVersionchange. - DES's round-1 carry no longer draws two phantom arrows / "no frame found" (graph view, 2026-07-12). DES wraps its 16 rounds in an outer
roundsgroup, andround.1.seedInput = port("rounds","in")whilerounds.seedInput = port("initial-permutation","state")— a seed-of-a-seed that the port-edge inference's single-hop"in"resolution couldn't chase. It stopped at the framelessport("rounds","in")container pseudo-port and drew a phantomrounds → round.1.splitedge (the one the value inspector reported as "no frame found for either endpoint of state edge "rounds" → "round.1""), while the loop-input pass separately drewinitial-permutation → rounds— so the Initial Permutation appeared to feed the same value into the rounds twice over two long arrows. This is a NEW failure mode distinct from the prior collapsed-group / rejoin-chip "no frame found" fixes (those were node-click resolution; this is a port-flow edge whosefromwas a container).resolveSeedChainnow chases aport(group,"in")seed the rest of the way to the real producer (the Initial Permutation), so the round-1 carry renders as a single honest, click-resolvableinitial-permutation → round.1.splitedge — while stopping at aniterate's"in"port (the loop boundary) so SHA-256's per-blocklength-append → blocksloop-input edge and every ECB/CBC per-block read are byte-identically unaffected. The now-redundantinitial-permutation → roundsloop-input edge is suppressed (its bytes already reach a descendant leaf). Pinned bytests/des-graph.test.ts(the honest carry exists with realstate/inputports, resolves to a value in the inspector, and NO edge anchors on theroundsboundary). Core only, noschemaVersionchange. - Clicking a collapsed group's replicated aux output no longer errors "no frame found for step" (graph view, 2026-07-11). With replication on, a replica of a collapsed group's aux output (the reported case: Blowfish's default-collapsed "Key Setup", whose 521-loop monolith publishes P/S to aux) routes its node-click to the group id via
replicaOf— but no trace frame is keyed by a group id, so the value inspector showedno frame found for step "key-schedule". AES doesn't hit this because itskey-expansionaux source is a root-level leaf (its id resolves to a frame); a group-wrapped schedule broke the assumption.lookupNodeValuenow resolves a container id to its terminal (deepest last-descendant) leaf's frame — general to any collapsed-group aux source, matching how a leaf source already resolves. Pinned bytests/node-value-lookup.test.ts(the Blowfishkey-schedulegroup id resolves to a value; an unknown id still returnsmissing). Core only, noschemaVersionchange. - The value inspector no longer shoves the graph canvas down on the first click (graph view, 2026-07-11). The inspector panel starts collapsed and the first node/edge click auto-opens it; mounting its body in flow grew the sticky header and pushed the whole SVG down. The body slot was already fixed-height (so selection-to-selection was stable) — only the closed→open mount shifted. Per
feedback_layout_shift_fix_reserve_vs_absolute(absolute-position when the footprint isn't already-present slack), the open body now floats as an absolute overlay anchored to the panel's bottom edge (card chrome, above the SVG), so opening it reflows nothing. Verified in-browser: the sticky header holds at 132px with zero scroll shift. CSS only, noschemaVersionchange. - Port-handle dots now track their node during a drag (graph view). A leaf's input-port "arm" handles (the small dots on its left edge — visible on SHA-256's
s0/s1/s2/assemble) stayed frozen when the node was dragged while the rectangle and label moved with it. They read the node box through a reactive thunk now instead of a value captured in a non-reactive<For>scope. - Expanded iterate containers no longer clip children dragged below the body row (graph view). The iterate box sized itself off the tallest child's height, so a child pinned or dragged downward spilled out the bottom and a rearranged layout would not round-trip through Save/reload. It now tracks the lowest child extent (like group containers), growing to contain any dragged-down child.
0.7.0 - 2026-07-09
- Relicensed from MIT to the Boyko Non-Commercial License v1.0 (BNCL-1.0). The project is now free to use, modify, and redistribute for non-commercial purposes; commercial use requires separate written permission from the copyright holder.
LICENSEcarries the full terms, the newNOTICEfile the short-form summary (both must be retained on redistribution), andpackage.jsondeclares"license": "LicenseRef-BNCL-1.0". This is a deliberate move away from an OSI-approved open-source license —LicenseRef-BNCL-1.0is a namespaced non-standard SPDX identifier that dependency tooling will not recognize as open source.
- RSA — the first public-key cipher, with traced key generation (Phases 1 + 3 of
docs/plans/shimmying-booping-moth.md). A new Public-key category in thekindselector (alongside Cipher / Hash) ships textbook RSA: select it and the symmetric-key field, cipher-mode dropdown, and padding selector vanish (a key field for RSA would miseducate — the public/private material is the editablep, q, econstants on the spec), while the encrypt/decrypt mode toggle stays. Both halves of RSA are visible trace frames. Key generation derivesn = p·q,φ(n) = (p-1)(q-1), and the private exponentd = e⁻¹ mod φ(extended Euclid). Exponentiation is an unrolled square-and-multiply ladder (c = mᵉ mod nencrypt,m = cᵈ mod ndecrypt): one rung per modulus-width bit, each amod-mul@1square + acond-mod-mul@1conditional multiply that reads its exponent bit at run time — so editinge(orp, q→ the derivedd) re-runs the trace live, the project's edit-and-watch pedagogy applied to the exponent. Architecture fit: the big-integer math lives inside the executors (bigint) and only ever exchangesUint8Arrayat the port boundary (no new State shape — per the byte-flat port thesis). Five new pure port-native primitives —mul@1,sub@1,mod-mul@1(squaring = wire both factors to one source),cond-mod-mul@1,mod-inverse@1— plussrc/core/big-int-codec.ts(bytesToBigInt/bigIntToBytes, big-endian fixed-width, throws on overflow). The spec (src/ciphers/rsa.ts) is flat (no groups): key-gen leaves fann/dport-to-port to the ladder among same-scope siblings;p, q, eridecipherConstants(editable, default61 / 53 / 17⇒n = 3233). The UI rejects a messagem ≥ nwith a value-based error (else the ladder would silently computem mod n). Verified against a Pythonpow()oracle (feedback_crypto_verification):tests/rsa-vectors.test.tspinspow(65,17,3233) = 2790, the derivedn / φ / d, and an exhaustive0..3232round-trip compared bybigintvalue;tests/app-rsa.test.tsxdrives the whole flow through real DOM events (select Public-key → encrypt 65 → 2790 → decrypt → 65; key field hidden;m ≥ nrejected). Store integration adds a thirdSpecsByModekind (asymmetric) + anAsymmetricfamily /Categorytocipher.ts(theisCipherpredicate is now an explicit exclusion of every non-cipher family —!isHash && !isAsymmetric— so wideningAlgorithmcan't silently route RSA down the cipher path), and"rsa"joins the document-schemaALGORITHM_IDSso saved RSA docs round-trip. NoschemaVersionbump. Deferred to follow-ups: a collapsible "Key Generation" group + per-leafnarrationOverride(Phase 2 — now shipped, see the next entry), and decomposing themod-inverse@1oracle into a traced extended-Euclid loop (Phase 4). - RSA key generation is now a collapsible group with narrated steps (Phase 2 of
docs/plans/shimmying-booping-moth.md). Phase 1 shipped RSA flat to prove the math without fighting group scope; Phase 2 adds the pedagogical structure the flat spec lacked. The key-generation leaves (n = p·q,φ = (p−1)(q−1),d = e⁻¹ mod φ) now live in a default-expanded "Key Generation" group the learner can fold away to focus on the exponentiation ladder (expanded, not collapsed, because key generation is RSA's headline traced feature — collapsing it would hide what Phase 1 showed). Wrapping them re-introduces the group-scope wall — a ladder rung outside the group can't reference a port inside it — so a new sixth, hybridrsa.publish-key-params@1step mirrors the computed key material intoaux(the one channel that crosses a group boundary), and the ladder reads it back via top-levelaux-load-bytes@1loaders. This is the same B-minimal publish-to-aux export the four decomposed key schedules use. Each direction publishes exactly the key its ladder consumes — the public key{rsa.n, rsa.e}on encrypt, the private key{rsa.n, rsa.d}on decrypt — which makes RSA's public/private split concrete AND avoids writing an aux value nothing reads (publishing the off-direction exponent would leave it unread → anunused-writewarning glyph on the default spec; a manual browser pass caught exactly that, andtests/rsa-key-gen-group.test.tsnow pins zero unused-write warnings in both directions viavalidateGraph). The private exponentdis still derived + narrated in the group even when encrypting; its output simply goes unconsumed there (as in flat Phase 1). Every key-gen leaf and ladder rung also gains a cipher-specificnarrationOverride(e.g. "Modulus n = p·q", "Totient φ(n) = (p−1)(q−1)", "Private exponent d = e⁻¹ mod φ(n)", "Square (rung 1/16)", "Multiply if exponent bit 15 is set") so the inspector reads RSA prose instead of the genericmul@1/mod-inverse@1/cond-mod-mul@1registry doc. The exhaustive0..3232KAT round-trip stays byte-identical after the wrap — the regrouping changed the topology, not a single number. Coverage:tests/rsa-key-gen-group.test.ts(group structure, the cross-scope aux export's byte-identity, executor +metacontract, and the per-leaf override names) +tests/graph-view-rsa.test.tsxupdated for the grouped topology (45 leaves, 1 "Key Generation" container, ladder stays flat). NoschemaVersionbump. Deferred: decomposing themod-inverse@1oracle into a traced extended-Euclid loop (Phase 4). - RSA's modular inverse is now a traced extended-Euclid loop, not a single-frame oracle (Phase 4 of
docs/plans/shimmying-booping-moth.md). Phase 1–2 computed the private exponentd = e⁻¹ mod φinside one opaquemod-inverse@1executor — correct, but a black box in the very step RSA's security rests on. Phase 4 decomposes it into a visible unrolled extended-Euclid ladder: each rung is a tracedeea-step@1(one division-with-remainder + coefficient update, the Bézout recurrence advanced by one iteration), and a finaleea-extract@1reduces the surviving coefficient mod φ to the non-negatived. The rung countK = ⌈1.4404·8W⌉ + 2is the Lamé/Fibonacci worst-case bound for aW-byte modulus, pinned by a Fibonacci-adversary gate (tests/rsa-eea-decomposition.test.ts) so a user-supplied key can never silently under-unroll (a too-smallKwould truncate the algorithm to a wrongdwith no error). The exhaustive0..3232KAT round-trip stays byte-identical — the decomposition changed the trace topology, not a single number. Because Phase 4 roughly doubles the key-gen subtree (~38 of 73 leaves), the "Key Generation" group flipped to default-collapsed (see below). NoschemaVersionchange. - High-fanout REFERENCE replication keeps a pure-aux iterate's box while decluttering its fan-out (2026-06-08). SHA-256's
msg-schedule(afor-each-subgraph-with-history) publishesaux["W"]to all 64 compression rounds; rendered, those 64 edges crossed the entire round column as one fan-out bundle (yellow under source-coloring) — the reported complaint. Iterate-family containers were excluded fromreplicateHighFanoutSourcesby design: full replication DELETES the source, which for a spine iterate erases the loop from the main flow (the documented 2026-05-16 ECB lesson). The fix reframes that exclusion rather than dropping it — an iterate isn't excluded because it's an iterate, but because deleting it is wrong; so a collapsed pure-aux iterate (aux fan-out, no outgoing state/port-flow edge, ≥2 consumers) now gets a SECOND mode, reference replication: the loop box STAYS on the canvas and only its outgoing aux edges reroute to short per-consumer reference chips (labeled with the aux key —W— not the verbose container label, since all N chips slice the same buffer, so a per-chipW_0..W_63would falsely imply 64 distinct values). Full replication (leaves, collapsed groups → DELETE + scatter) is untouched. The guard is verified to catch onlymsg-scheduleamong shipped specs: ECB/CBC*-blocksiterates have zero outgoing edges (never enter the fan-out map) and SHA-256's outerblocksiterate has an outgoing port-flow edge (stays spine-ineligible) — confirmed by a probe over all four shipped iterate-family containers. The ≥2-consumer floor preserves the 2026-05-16 regression guard (a fanout-1ecb-blocks → concat-blocksiterate must NOT replicate — a lone rerouted edge has nothing to declutter and just adds a spine indirection);tests/replicate-fanout.test.ts's "always"-on-iterate case stays green because that fixture is fanout-1 (its name/comment retargeted to the precise invariant). The kept box reads as the source — it retains its incoming seed edges, label, expand chevron, and color-linked chips — not an orphan. The user's complaint was the visual bundle, not "W" by name: the other fanout-64-to-the-rounds source, K (round constants), is already full-replicated into short hops, so no source still draws a crossing bundle — pinned by the smoke's max-long-edge-per-source assertion. A latent renderer gap surfaced and is covered: replica chips placed inside an iterate body DO get layout boxes (the existing iterate second-pass), so they actually render. Coverage:tests/replicate-pure-aux-iterate.test.ts(box kept; 64 edges rerouted off the box onto chips; shortWlabels; incoming edges preserved; no spine-replica promotion — theisSpinegate is load-bearing becausesamePathis genuinely true post-collapse, msg-schedule and round.N both inblocksscope) + real-Chromiume2e/msg-schedule-reference-replica-smoke.spec.ts(box survives, 64 chips render, AND no source exceeds a handful of long edges — the bundle-gone criterion). Core + CSS-free; noschemaVersionchange.
- RSA's "Key Generation" group now default-COLLAPSED, reversing the Phase-2 default-expanded pick (2026-06-08). Phase 2 shipped the group default-expanded on the argument that key generation is RSA's headline traced feature. In practice it is the bulk of RSA's leaves — Phase 4 decomposed the modular inverse into a traced extended-Euclid loop, so the default key's key-gen subtree is ~38 of the spec's 73 leaves — and the exponentiation ladder (the actual encrypt/decrypt) is what should headline the first render. The group now carries
defaultCollapsed: true: on load it folds behind its labeled "Key Generation" container chip (one click to expand), dropping the graph from 73 to 35 visible leaf chips. The collapsed header still names the group, so the derivation is folded, not hidden — the ladder consumes key gen only through the publishedn/e/d, so nothing the ladder needs disappears.tests/rsa-key-gen-group.test.ts(now assertsdefaultCollapsed === true) +tests/graph-view-rsa.test.tsx(35 visible leaves; the "Key Generation" container still renders as a collapsed box) updated. NoschemaVersionchange.
0.6.0 - 2026-06-04
- Compose-and-save — save a group as a reusable palette element (universal-port Phase 4f, 2026-06-02). The last open milestone of Phase 4 (Q-C decision (b)) and the payoff of the just-shipped port-wiring editor: hover any group in the graph view and click the
★[save as element]chip to capture it — name it via the prompt and it lands in a new "my elements" palette section, draggable back onto any spec like a built-in step type. A composite is pure JSON — a storedStepGrouptemplate, not a registered step type with a synthesized executor — so a dropped copy's internals stay fully visible and scrubbable ("cipher = JSON, not code", now at the reusable-block grain). Group-scope isolation gives a captured group a clean boundary for free: one port input (itsseedInput), one published output (bodyOutput/outputPorts), plus any number of aux reads (e.g. a round'sxor-with-aux@1readingaux["roundKey.N"]). Drop copies/inlines the subtree:cloneGroupWithFreshIds(src/core/spec-mutations.ts) regenerates every id collision-free and rebases internal port references through the rename map; the clone'sseedInputis auto-bound to the insertion-point predecessor ("insert into the pipeline") since the 4d-bis editor only rewires leaf ports — a container seed left unbound would have no in-app fix. The library is a localStorage-only global store (src/ui/stores/composites.ts,cryptographer.composites), surfaced via a distinct drag MIME so a composite id can never be mistaken for a step type; because the drop inlines, any saved/shared.cipher.jsonstays self-contained and there is noschemaVersionbump. v1 saves an existing group (arbitrary-subgraph selection is a deferred follow-up) and bakes params in; rename/save use a native prompt (a styled dialog is a trivial follow-up). Known limitation (on-brand): a dropped composite that reads aux only computes correctly if that aux cell exists in the new context — otherwise the existing coerce / aux-missing machinery surfaces it in the trace. Coverage:tests/composite-capture-clone.test.ts(clone/capture/seed mechanics),tests/composites-store.test.ts(CRUD + validation),tests/composite-palette.test.tsx(palette section),tests/composite-graph-drop.test.tsx(save chip + drop inlining),tests/composite-parity.test.ts(the oracle — a captured+cloned round spliced back into AES-128 reproduces the FIPS-197 §C.1 ciphertext + per-leaf byte-identity), ande2e/composite-save-drop-smoke.spec.ts(real Chromium — save → palette → reload persistence → real HTML5 drag → inlined group). Help:docs/help/graph-view.md"Saving a group as a reusable element". Plan:docs/plans/compose-and-save.md. - Port-wiring editor — rewire a step's inputs in-app (universal-port Phase 4d-bis, 2026-06-02). Until now, which upstream output feeds each step input was declared only in TypeScript (
StepLeaf.portInputs); the graph editor could insert/drag/param-edit but not rewire. This ships the affordance — the prerequisite for the queued 4f "compose-and-save." Two surfaces over one store boundary (bindPortInSpec→ the existing debounced rerun, active-slot-only, never auto-synced across encrypt/decrypt): (1) canvas click-to-arm — each leaf grows small input-port handles on its left edge; click one to arm it (dashed-accent outline), every scope-legal source then rings and grows a right-edge bind handle, click one to wire (Esc or empty-canvas click cancels); (2) per-input-port dropdown below the graph (PortWiringEditor) — the keyboard/accessibility-complete equivalent, and the only path to a container's incoming-value source (the canvas draws no handle there). Design spine = the legal-source SET, not the gesture: a scope-bounded enumerator (src/core/port-sources.ts::legalSourcesForInput, factored to mirror the runtime /spec-shapes.tswalk and STRICT on top-scope-only$input) only ever offers sources the runtime can resolve, so a cross-scope binding that would throw on Run is unrepresentable by construction. The single soft case is a byteLength mismatch among legal sources — allowed, but flagged amber (canvas) /⚠ size mismatch (coerces)(dropdown), since the runtime coerces it as a visible trace step ("permissiveness is the pedagogy"). The dropdown also renders an explicit⚠ current (unresolvable)option when a stale binding falls out of the legal set, so a<select>never silently lies about what's wired.setPortBinding(src/core/spec-mutations.ts) is ref-equality-preserving and normalizes a cleared map back to absent (not{}), keeping spec-only saves byte-stable;portInputsis already in the document schema, so noschemaVersionbump and rewires save/share like any other edit. Coverage:tests/spec-mutations.test.ts(+setPortBinding),tests/port-sources.test.ts(enumerator scope inclusion/exclusion + an anti-drift superset over 7 shipped specs),tests/port-wiring-editor.test.tsx(dropdown, incl. a real multi-inputxor@1so per-port wiring is exercised),tests/graph-view-wiring.test.tsx(gesture logic),tests/port-wiring-roundtrip.test.ts(Save/Load + clear byte-stability), ande2e/port-wiring-smoke.spec.ts(real Chromium — handles clickable, scope-bounding holds in the browser, Esc cancels). Help:docs/help/graph-view.md"Rewiring ports". Plan:docs/plans/port-wiring-editor.md. - Speck round-body α/β/byteOrder are now editable in place (2026-06-02).
SpeckRoundBlock(src/ui/components/ParamEditor.tsx) used to render all five Speck params read-only; now the two rotation constantsalpha/beta(via the sharedIntInput, clamped to[1, wordBits-1]) and thebyteOrderconvention (abe-paper/le-nsa<select>) are editable — thespeck.round@1/speck.round-inverse@1executors read all three at run time (src/steps/speck-round.ts), so editing them just diverges the cipher ("what if Speck rotated by 3 instead of 7?" — the project's tinker-and-watch-it-break pedagogy). These bare round-body leaves aren't inside a group, so in-place editing is the only path to them (no composite/graph affordance reaches them).roundKeyAux(per-round distinct wiring) andwordBits(structural — the schedule builder throws on ≠16) stay read-only, matching the existing read-only-when-an-edit-would-throw rule. A scoped "Apply α/β/byte order to all N rounds" row (SpeckArxApplyAllRow) broadcasts only those three keys viaeditAllStepsByTypeby spreading each leaf's existing params — so every round keeps its ownroundKeyAux, which is why it can NOT reuse the genericApplyAllRow(that replaces the whole params object and would point every round at the same key). A.muted smallnote flags that α/β also drive the key schedule's rotations (decomposed inside the Key Expansion group), so editing them here changes only the round function and diverges from canonical Speck by design. Coverage:tests/speck-round-param-edit.test.tsx(single-round edit isolation;byteOrderselect commit; and the load-bearing assertion — apply-to-all broadcasts α/β/byteOrder to every round while preserving each round's distinctroundKeyAux). Shipped as Option 2 of a "compose-and-save parameterization" exploration whose knob-based Option 1 was dropped — Speck round bodies are bare top-level leaves, not a capturable group, so composite-knob value ≈ nil on shipped specs. No new step type, noschemaVersionbump.
-
The graph value inspector now resolves a multi-port leaf and its wires instead of showing "no resolvable state" (2026-06-03). Clicking a DES round's
splitnode, or either of its outgoing wires that lands on a fan-in consumer (split → fxor,split → recombine), surfacedstep "round.1.split" has no resolvable state at frame N/no frame found for either endpoint of state edge …. Root cause:split-bytes@1has two outputs (output0= L,output1= R) and its consumers (xor@1/concat@1) read two operands each, so the cipher-agnosticframePrimaryOut/InByteshelpers ("state"port → sole port → null) returnnullfor both endpoints — and aGraphEdgecarried only(from, to), discarding which port the edge represented, so the lookup had no way to pickoutput0vsoutput1. NowinferPortEdgesstamps each port-flow edge with the specificfromPort(producer output) /toPort(consumer input, theportInputsbinding key), andlookupRegularStateadds a port-specific fallback — read after the two existing primary reads so every already-resolving edge stays byte-identical — that reads the consumer's named input port (toPort, always a real port name), then the producer's named output. The node lookup falls back to a leaf's primary input when it has no single output, so clickingsplitshows the one 8-byte block it divides rather than an error; a genuinely multi-input leaf (a fan-in, or the 11-inputpublish-round-keystail) still resolves "missing" and is inspected per-port inPortFlowView. Pinned bytests/des-multiport-value-lookup.test.tsagainst the published FIPS 46-3 / Grabbe worked example (split→fxor=CC00CCFF,split→recombine=F0AAF0AA, node =CC00CCFFF0AAF0AA); the new edge fields are optional so everyGraphEdgetest literal compiles unchanged, andencodeEdgeKey(from/to/auxKey/kind) is unaffected. Core only, noschemaVersionchange. -
Drag-to-pan now works from inside an expanded container (e.g. a DES round), not just the bare canvas background (2026-06-03). Dragging the mouse inside an expanded round to move the view did nothing.
handleCanvasPanPointerDown(on the.graph-viewscroll wrapper) only started a pan when the pointerdown's target was the wrapper itself or the SVG root — but an expanded container's interior is fully covered by its owngraph-container-rect, which paints above the SVG root, so a pointerdown there hit the rect (not the SVG background) and the pan bailed, leaving a populated round with no pannable surface. The guard now also accepts agraph-container-rect. The body rect owns no click/drag gesture of its own (the header band is a separate rect with its own pointerdown→startNodeDrag; leaves / port handles / chevrons stopPropagation), so treating it as a pan surface can't steal another gesture, and immediate pointer capture on it is harmless (no handler to break) — the same established pattern already used for the wrapper / SVG-root background. Edges are deliberately excluded: agraph-edge-hitpath owns click-to-inspect (the just-fixed value-inspector surface), and immediate capture would swallow that click. Collapsed-container bodies also become pannable — harmless. Verified in real Chromium (drag from a container-body point scrolls the canvas; clicking an edge still selects it) and pinned by an additive case intests/graph-view-canvas-pan.test.tsx(the existing four wrapper / SVG-root / leaf-rect / no-overflow cases are unchanged — a leaf rect isgraph-leaf-rect, notgraph-container-rect, so it's still rejected). UI only, noschemaVersionchange. (Deferred, only if a "dead spots on the lines" follow-up lands: threshold-gated panning from edges/decorations too, mirroringstartNodeDrag's drag threshold so a no-move click still selects.) -
High-fanout replication now works for collapsed group containers, not just leaves (2026-06-02). A collapsed
group(e.g. AES's default-collapsed "Key Expansion") renders as a single chip but, once collapsed, becomes the source of all its fan-out edges (the innerkey-schedule.publishleaf remaps to the container id).replicateHighFanoutSourcesused to skip every container source unconditionally — so collapsing the key schedule left its 11 round-key lines fanning across the whole canvas, and the override panel's "always" toggle silently no-op'd on it. Now a collapsedgroup(kind === "group"&&childIds.length === 0— i.e. it already paints as a leaf-like chip) is eligible: it replicates into one small chip per consumer exactly like a leaf source, and the original container box is filtered out ofgraph.containers(the renderer paints every container entry, so the orphan had to be removed, not just unlinked from rootIds/childIds). Expanded containers stay ineligible (their body box can't collapse to one chip — and the inner leaf already replicates), and iterate-family containers (iterate/for-each-subgraph[-with-history]) stay ineligible by design: they're spine loop structures, and replicating fully removes the source, erasing the loop — this preserves the documented 2026-05-16 behavior where a collapsed ECB iterate must not replicate. In the same change, replication eligibility switched from raw edge count to distinct-consumer count — the honest metric, since the transform makes one replica per (source, consumer) pair. For every shipped (source, consumer) pair this equals the edge count (one aux key each); it differs only where collapse folds many edges onto one consumer (a collapsed key-schedule group feeding a collapsed ECB iterate = 1 fan-target), where auto-replication now correctly defers to the×Nbundle the container→container relationship already renders cleanly. The override-panel "N edges" count mirrors the same metric (replicationSourcesinGraphView.tsx). Replica chips inheriting a long container label (vs the fixedLEAF_W) now truncate the rendered text with an ellipsis (full label kept in the<title>). Verified bytests/graph-view-container-replica.test.tsx(jsdom render: 11 placed replica chips — present testid ⇒ resolved layout box — orphan container box gone, no bare-source fan-out edges), three new data-transform cases intests/replicate-fanout.test.ts, and a throwaway Playwright pass eyeballing the off/on canvas (Playwright stays dormant — not committed). Thegraph-bundle/graph-view-bundle-render/graph-view-bundle-inspectortests stay green: the bundle-render test passes unmodified (its both-collapsed scenario folds to 1 distinct consumer, so the bundle still forms) — the proof the new metric respects the prior "bundling owns the container→container relationship" decision rather than overriding it. CSS/JSX/core only, noschemaVersionchange. -
The cipher "reset" button no longer reflows the settings row when the spec first diverges from canonical (2026-06-02). The divergence-only
.reset-spec-buttonused to render inline inside.cipher-select-row, so its first appearance widened the cipher item ~52px and tipped a settings sibling (mode-of-operation / padding / byte-format) onto a new line — growing the.inputsflex-wrap row ~54px in both auto and manual modes. (Anchoring-masked at wide viewports where the settings row has >52px of slack; visible as a once-per-session downward jump at narrow viewports where it doesn't.) The button is now absolutely positioned in the cipher<label>'s top-right dead space — the ~100px of empty width beside the ~40px "cipher" caption (the label is a flex column, so the caption line is as wide as the select below it, which is itself pinned by the always-present"Speck 32/64 (BE, paper)"option). Taken out of the flex flow, the button's appearance reflows nothing, and it stays on the cipher control (honoring the deliberate "keep it by the dropdown, not mirrored next to the muted header indicator" placement). Rejected alternatives: reserving the inline slot makes the wrap permanent for every user (the originally-flagged objection, confirmed — "anchoring-masked" means the wrap is real at common viewports and only the jump is hidden); absolute-right/left collides with the adjacent padding/mode controls; relocating to the header reverses a documented decision. CSS + JSX only, noschemaVersionchange;tests/app-custom-spec-indicator.test.tsxstill guards button presence + the reset round-trip, and a throwaway Playwright pass confirmed zero settings-row reflow across divergence at a narrow viewport (Playwright stays dormant — not committed). Residual, out of scope: for Speck/Serpent-256 the<select>itself grows on divergence because"Custom (was Speck 32/64 (BE, paper))"exceeds the option that otherwise pins the select width — but the reported case is the AES-128 landing, where"Custom (was AES-128)"is narrower than that pin, so the select stays put and the button was the sole contributor. -
Manual-mode "edits pending" banner no longer shoves the page down on every edit (2026-06-02). In auto-rerun-OFF mode each spec edit lights the
.pending-bannerand each Run clears it — and because the banner was a full-width row mounted/unmounted directly on thedirtyflag inside the.inputsgrid area, every edit→Run cycle grew/shrank the section ~41px and pushed all content below it down. The browser's scroll anchoring masks this whenever there's scroll headroom above the viewport (the common deep-scroll case measured a 0px net shift), but a tall viewport / short trace / near-top edit has nothing for anchoring to absorb the insertion into and shows a visible jump (measured +176px in a no-headroom Playwright repro). The banner now lives inside an always-present.pending-banner-slot(reserved whenever auto-rerun is off — gated on!autoRerun(), not ondirty); togglingdirtyonly swaps the slot's content — the live<output>banner ⇄ a muted "auto-rerun off — edits apply on run" idle filler that shares the banner's box metrics (padding / font-size / 1px border) — so.inputsheight is identical in either state and nothing jumps across the edit→Run→edit batch workflow manual mode exists for. Pinned by a slot-presence invariant intests/app-auto-rerun-toggle.test.tsx(slot present in manual mode regardless ofdirty; live banner only whendirty). JSX + CSS only, noschemaVersionchange. (Separately diagnosed during this fix: the.reset-spec-buttonappearing on the first divergence from canonical reflowed.inputs~54px in both auto and manual modes — now fixed in its own entry above by absolutely positioning that button out of the flex flow, which sidesteps the "widen the default landing layout" objection that had deferred it.) -
Decrypt-mode cipher switch no longer carries a stale, wrong-sized ciphertext into the input field (2026-06-02). Switching the cipher selector while in decrypt mode left whatever ciphertext was in the input field untouched — so going AES-128 (decrypt) → DES kept the 16-byte AES block in the field and DES single-block tripped its "must be exactly 8 bytes" input-validation banner. Root cause:
changeCipher's smart-swap (src/ui/App.tsx) only compared the field againstDEFAULT_PT_BYTES_BY_CIPHER(a plaintext default), which never matches a ciphertext, so the swap silently skipped in decrypt mode. The handler now picks the per-mode default table — a newDEFAULT_CT_BYTES_BY_CIPHER(src/ui/stores/cipher.ts) holding each cipher's canonical ciphertext (the output ofencrypt(default PT, default key)) — so a decrypt-mode cipher switch swaps the field to the new cipher's canonical ciphertext and the first Run round-trips straight back to its KAT plaintext (mirroring how encrypt's first Run lands on the published vector). Sacred-input policy preserved: a user-typed ciphertext (matching no canonical default) is never clobbered — it survives the switch and shows the friendly length banner if it doesn't fit, exactly like a user-typed key. The new table is pinned to the implementation two ways bytests/default-ciphertext-table.test.ts: every entry must equalencrypt(default PT, default key)run through the real spec (round-trip-by-construction, dodging the Speck BE/LE + Serpent byte-convention traps), and the entries with published vectors (AES-128 FIPS-197 §C.1, Speck32/64 Beaulieu et al., DES FIPS 46-3 App B) are additionally anchored to their literal published bytes (non-circularity). End-to-end UI coverage + a "does NOT clobber a user-typed ciphertext" guard added totests/app-cipher-selector.test.tsx. NoschemaVersionchange. Note: the swap fires only on the canonical, unedited ciphertext (single-block); a custom-typed CT, or a CT under non-default key/padding/cipher-mode, is preserved and may still show the banner until manually replaced. -
RSA's modular inverse is now a traced extended-Euclid loop, not a one-frame oracle (Phase 4 of
docs/plans/shimmying-booping-moth.md). Phases 1–3 derived the private exponentd = e⁻¹ mod φ(n)in a singlemod-inverse@1black-box frame; Phase 4 decomposes it into a visible loop — one trace frame per Euclidean division step — so the learner watchesdderived the way a textbook derives it. Two new pure port-native primitives:eea-step@1carries the algorithm's running 4-tuple(r, newR, t, newT)port-to-port and shifts it byq = ⌊r/newR⌋per rung (r ← newR,newR ← r mod newR,t ← newT,newT ← (t − q·newT) mod φ);eea-extract@1reads the settled(r = gcd, t = inverse)slot and emitsd, throwing the same "not invertible" error the oracle did whengcd(e, φ) ≠ 1(a non-coprimee). The spec (src/ciphers/rsa.ts) replaces the singledleaf with two coefficient seeds (t = 0,newT = 1) + an unrolled chain ofeeaMaxIterations(W)rungs + theeea-extracttail (which keeps the id"d", so the publish tail'sport("d","output")and the KAT'sframeOut(trace,"d")resolve unchanged); the chain stays flat inside the existing default-expanded "Key Generation" group (a nested EEA group would re-hit the group-scope wall — two external inputsφ,evs oneseedInput). The load-bearing design decision: the Bézout coefficient is kept reduced mod φ, not signed. The raw coefficient dips negative mid-loop, but every primitive + thebigIntToBytescodec is unsigned big-endian and throws on a negative — a two's-complement value on alayout:"raw"port that a downstream step reads as a giant unsigned int is exactly the footgun the "every port is a non-negative BE integer" convention prevents. Reducing mod φ is mathematically exact (the gcd is independent oft;t ≡ true coefficient (mod φ)throughout, so the finaltisddirectly with no terminal+= m); the only visible cost is a textbook's−183showing asφ − 183 = 2937, which the per-leaf narration names. The unroll countK = eeaMaxIterations(W) = ⌈1.4404·8W⌉ + 2is the Lamé worst-case bound (Euclid's worst case is consecutive Fibonacci numbers) plus a+2margin that also absorbs a user-enterede ≥ φ; a rung whose remainder has reached 0 is an honest identity frame (carry-forward, like the ladder's 0-bit rungs), so the default key's 4 real iterations are followed by trailing identities. A too-smallKwould be a silent wrongdfor some user-entered key (every prior RSA test fixese=17→ always 4 iterations), so the gate (tests/rsa-eea-decomposition.test.ts) drives the realeea-stepexecutor with the largest consecutive Fibonacci pair at the working width (W=1/2/3) plus 200 random coprime pairs, asserting convergence strictly withinKand byte-equality with the independentmodInverseBigIntoracle — it is what pinsK. The existing exhaustive0..3232encrypt→decrypt round-trip now flows through the decomposition and stays byte-identical.mod-inverse@1stays registered as the cross-check oracle (no longer emitted by any spec — same posture as the four key-expansion oracles after their schedules decomposed).eea-step@1/eea-extract@1join theport-provenance"approximate" allowlist (big-integer carries mix all bytes); their empty params keep the raw-JSON ParamEditor fallback (plan-deferred, as for the other RSA primitives). In the graph view the chain renders as a clean vertical sequence with the 4-tuple carried as a bundled×4multi-output edge; clicking a rung node shows "no value" by design — a 5-input/4-output leaf has no single value, identical toconcat/fan-inxor/the 11-inputpublish-round-keystail, with the values inspectable per-edge — andvalidateGraphis warning-free in both directions.tests/graph-view-rsa.test.tsxupdated for the new topology (45 → 73 leaves). NoschemaVersionbump. Gate green: biome + tsc + 2427 vitest / 213 files + vite build clean; browser-smoked in real Chromium (correct live ciphertext0ae6= 2790, the loop renders + scrubs in graph view with zero console errors).
- Key-schedule decomposition — K4b: the
<KeyScheduleExplorer />+ the wholesrc/ui/key-schedule-sim/subsystem retired (key-schedule-decomposition plan, 2026-06-02). The explorer was a linear-mode pedagogy surface that faked the internal decomposition of the four monolithic key-schedule executors (it ran a viz-only per-cipher simulator and rendered a per-round table, because the standard view of a monolithic key-expansion frame shows the unchanged input on both sides — the executor only writes aux). K1–K4 made that decomposition real: every schedule is now a tree of port-native primitive frames the standard<FrameStateView />renders directly and scrubs into. The AES branch was retired in K1c and the Serpent branch in K3b; K4a left the DES branch dormant (its only registered simulator,des.key-schedule@1, is no longer emitted by any shipped spec). DES being the last cipher, K4b empties the whole subsystem. Deleted:src/ui/components/KeyScheduleExplorer.tsx,src/ui/key-schedule-sim/des.ts+registry.ts(the directory is gone), and the two explorer tests (tests/des-key-schedule-explorer.test.tsx,tests/des-key-schedule-sim-parity.test.ts). Changed: App.tsx'sisKeyExpansionStepType-gated<Show>intercept collapsed to a bare<FrameStateView frame={frame()} />(the gate's fallback was already FrameStateView, so no shipped frame changes surface). Kept: the shared<ByteRow>renderer (src/ui/components/byte-row.tsx) and itskey-schedule-byte-*CSS — still used by the port-native Feistel views + the narration components; thekey-schedule-class prefix is now just a naming holdover (only the explorer-specific CSS rules were removed). The four monolithic oracle executors (aes.key-expansion@1/@2,serpent.key-expansion@1,des.key-schedule@1) stay registered and on the narration allowlist as KAT oracle / pre-decomposition doc back-compat; only the stale prose comments naming the deleted explorer were rewritten acrossdefault-registry.ts,narration/registry.ts,PortFlowView.tsx,StepNarration.tsx,RoundKeyPanel.tsx,byte-row.tsx,app.css, and the steps guide. No KAT, schema, or behavior change. Gate green: biome + tsc + 2228 vitest / 191 files + vite build clean. - Speck32/64 key-schedule decomposition — K2c follow-up: full retire of
speck.key-schedule@1(key-schedule-decomposition plan, 2026-06-01). The K2c initial closure retired only the read-only ParamEditor block + kept the legacy executor registered as a KAT oracle; an advisor pass on K2b+K2c flagged the partial-retire as diverging from the user's literal Q2 option-text ("Delete the block + retire the speck.key-schedule@1 executor registration"). User re-confirmed full retire; the follow-up commit executes it. Deleted:src/steps/speck-key-schedule.ts(the entire step file — executor, doc, meta, port contract); the import + registration block insrc/ciphers/default-registry.ts; thespeck.key-schedule@1entry in the narration allowlist (src/ui/narration/registry.ts). Migrated:tests/speck-32-64-key-schedule-decomposition.test.tsfrom the monolith-executor oracle to an inline Beaulieu et al. 2013 §3 reference implementation (~80 lines: per-byteOrder master-key decoder, ROR/+/⊕/ROL recurrence in straight-line TS, per-byteOrder round-key encoder). The two recurrence implementations are now independent — the decomposed schedule expresses it as a tree of port-native primitive frames; the oracle expresses it as TS — so a mismatch surfaces a decomposition bug or an oracle bug before any cipher KAT fails. Rewired: the runtime-ported-dispatch (d) synthetic ("one native round in isolation") intests/runtime-ported-dispatch-speck.test.tsnow pre-seedsroundKey.0ininitialAux(the Beaulieu k_0 = 0x0100 in BE bytes) rather than instantiating the retired step type. Test name + commentary updated. Tooling fix from the advisor pass: the narration-allowlist test's brittle size-pin (expect(size).toBe(9), which would have decremented 9→8 for this retire and then incremented again for K3/K4) was replaced with atoEqual(new Set([...]))set-equality assertion that pins the entire allowlist shape — K3/K4 will touch the data array, not the assertion arithmetic. Doc comment refresh acrossdefault-registry.ts,narration/registry.ts,RoundKeyPanel.tsx, and the K2 plan to reflect the post-retire reality + the strengthened K2d back-compat framing (the K2a..K2d span is a single un-released sub-phase under one[Unreleased], so no tagged release will ever carry pre-K2 docs as loadable; the release manager must either hold the tag until K2d lands or accept the breakage). The K1 precedent (AES @1/@2 kept registered) is broken for Speck by this pick. K2d (the A-topology rewrite) gets two new open advisor questions: decrypt-variant reverse-order port wiring (22 cross-wires in reverse — exactly the topology risk the K1 plan flagged for A) andRoundKeyPanelretarget (the aux-keyedisRelevantFrameclassifier may quietly miss-classify under A). Gate green.
- Key-schedule decomposition — K2d resolved as B-minimal; the K2c topology-A pick reversed (key-schedule-decomposition plan, 2026-06-01). Doc-only, no code, no
schemaVersionchange. The K2c gate had picked topology A (each round leaf wiresportInputs.roundKeyto a publish-tail output port) for Speck. When K2d opened, A proved unbuildable without new runtime machinery: agroupwalks its children in an isolatednodeOutputsscope,portInputsresolve same-scope only and throw across a group boundary ("… no recorded outputs in this scope … same-scope wiring only"), and the decomposed schedule lives inside the collapsedkey-schedulegroup, so only the globalauxmap crosses — the plan's "SHA-256 cross-container precedent" was a misread (SHA-256's rounds are same-scope siblings, not grouped). A's payoff also narrowed to ~nothing: collapsed, A and B render identically; SHA-256 already ships B for itsK/H/Wacross container boundaries with no complaint; and round keys are derived (view-only), so A unlocks no inspect/edit. The user chose B-minimal, whichmainalready ships for both AES (K1c) and Speck (K2a–K2c) — so K2d closes with no code change. The K3/K4 decision rule changes from "A for ≥22 keys / B for ≤11" to "B-minimal for all grouped schedules; topology A is only reconsidered if a group-output-export runtime feature ships" — there is no per-cipher A-vs-B gate, and K3 (Serpent) / K4 (DES) proceed as B-minimal mechanically. The full A design is preserved as a deferred appendix indocs/plans/key-schedule-decomposition.md; the runtime fact behind the infeasibility is recorded indocs/gotchas.md(new "Port-flow scope" section) and memoryproject_group_scope_port_isolation. This closes the K2a..K2d span as a single un-released sub-phase under one[Unreleased]section — no tagged release ever carried the topology-A state. - Speck32/64 key-schedule decomposition — K2c closure (key-schedule-decomposition plan, 2026-06-01). The K2c gate slice: a throwaway-Playwright graph smoke (
e2e/k2c-speck-schedule-graph-smoke.spec.ts, deleted post-gate per [[feedback_playwright_dormant]]) rendered all 4 decomposed Speck specs (BE-paper / LE-NSA × encrypt / decrypt) at a 1600×2400 viewport with full-page captures of both the default-collapsed view and the chevron-expandedkey-schedulegroup, thenAskUserQuestionsurfaced the A-vs-B topology pick + theSpeckKeyScheduleBlockfate, both flagged by the K2 advisor pass + the K2c plan. Smoke results (all 4 spec/mode combinations): 0 console errors, 0 page errors, KAT byte-equal ciphertexts (a86842f2BE,f24268a8LE), collapsed view shows a singleKey Schedulechip with the 22-round aux fan-out tamed byreplicateHighFanoutSourcesinto a clean bundle along the white spine of 22 round chips, expanded view stacks the 21 ARX iterations top-down (load-key → input-codec → master-split → rot-l → sum → round-const → new-l → rol-k → new-kper iteration) with the (m-1)=3-iteration l-chain lag rendered as in-placemaster-splitreplicas at each consumer, NOT as tangled back-arcs — the architectural win the universal-port + replicate-high-fanout-sources combination delivers. Source-color count differs honestly across modes: BE = 22 colored sources (the 22 publish keyN outputs), LE = 23 (the 22 + the LE-onlyoutput-codecbulk-byte-swap leaf the K2 advisor pick made explicit). Gate outcomes (DIVERGES from K1's two picks): (1) Topology = A (explicit per-round-key ports) — the K1 plan explicitly sequenced A as an optional follow-on; for Speck it becomes K2d (separate slice + advisor pass + KAT gate per [[feedback_iterative_slice_review]]). (2)SpeckKeyScheduleBlock= retire now — the fallback-only block K2b kept around is gone (no shipped spec uses the legacy leaf; palette-dropping aspeck.key-schedule@1step now falls through to the raw-JSON editor). The legacy executor +speckKeyScheduleDocstay registered indefault-registry.tsas the KAT oracle fortests/speck-32-64-key-schedule-decomposition.test.ts. The block delete is a small targeted change insrc/ui/components/ParamEditor.tsx— removed the read-only<dl>block component + its<Match>arm, replaced with a K2c-tagged comment explaining the retire and where back-compat lives. No KAT orschemaVersionchange; gate stays green (biome + tsc + vitest + vite build clean — no test surface touched the deleted block). - Speck32/64 key-schedule decomposition — K2b blast-radius cleanup (key-schedule-decomposition plan, 2026-06-01). Thin hygiene slice closing the cleanups K2a's
featcommit deferred. The aux-graph derivation tests had already been retargeted preemptively in K2a (speck.key-scheduleleaf →key-scheduleGROUP container), so K2b is purely the framing/allowlist work the advisor pass left for closure. Narration allowlist (src/ui/narration/registry.ts) gains aspeck.publish-round-keys@1entry — parity with the AES analog (aes.publish-round-keys@1, K1a) — both are identity-passthrough aux-publish tails whose per-frame value-prose is the wrong surface (the recurrence leaves above them carry their ownnarrationOverride). Both publish tails are below the cell-shape contract gate (input: "any"), so the entries are for parity / documentation, not coverage enforcement. The stalespeck.key-schedule@1allowlist comment is refreshed to acknowledge K2a's post-decomposition reality: NO LONGER used by any shipped spec — the four Speck32/64 specs route through the decomposedkey-scheduleGROUP built bybuildSpeck32_64KeyScheduleNative; the monolithic executor survives registered only as the KAT oracle fortests/speck-32-64-key-schedule-decomposition.test.tsand for loading pre-K2 saved docs (parallel framing to the AES @1/@2 comment). The "Phase 3 irreducible 6 entries" docstring rot from K1's additions is corrected — the allowlist is now described as growing past the original baseline as the key-schedule decomposition (K1, K2) split each monolithic schedule into trees of port-native leaves and introduced a meta-bearing aux-publish tail per cipher.SpeckKeyScheduleBlockinsrc/ui/components/ParamEditor.tsxhas its header comment reframed as fallback-only: post-K2a no shipped Speck spec contains thespeck.key-schedule@1leaf; the block survives so pre-K2 saved docs can be inspected and the palette-droppable legacy executor (kept registered as KAT oracle) has a usable editor when dropped manually — per the K2 advisor pass keep-decision. Test contract pin intests/narration-registry-contract.test.tsbumped allowlist-size assertion 8 → 9; the failing-test name renamed from "6 irreducible + DES key-schedule + AES publish-round-keys" to "6 irreducible + DES key-schedule + AES/Speck publish-round-keys" and a parallelhas("speck.publish-round-keys@1")positive-control assertion added. Gate green: biome + tsc + 2227 vitest / 192 files + vite build clean (frame counts, bundle size, and behavior unchanged vs K2a). K2c (throwaway-Playwright graph smoke across all 4 decomposed specs + the A-vs-B topology pick) is the remaining open slice.
- DES key-schedule decomposition — K4a (key-schedule-decomposition plan, 2026-06-02). The last monolithic hybrid-ported key schedule,
des.key-schedule@1, is now a tree of visible port-native primitive frames in both shipped DES specs (des.tsencrypt +des-decrypt.ts) — the same B-minimal posture K1a (AES) / K2a (Speck) / K3a (Serpent) took. The punchline DES makes visible: unlike the other three (all ARX or S-box driven), the DES key schedule contains NO arithmetic and NO S-box — it is pure bit-wiring: two bit permutations (PC-1, PC-2) and a per-round rotation of the two 28-bit halves. Newsrc/ciphers/des-key-schedule-builder-native.ts(buildDesKeyScheduleNative(), no params — DES has no key-size variant): one default-collapsedkey-schedulegroup holdingload-key(aux-load-bytes@1, 8 bytes) →pc1→ 16 × (g{r}.rotate→g{r}.pc2) → publish tail. The 7-byte C‖D register threads round-to-round; each round's PC-2 reads the freshly-rotated register and publishes one 6-byte round key (roundKey.0 = K₁…roundKey.15 = K₁₆). No byte-order codec (unlike K2/K3) — DES uses FIPS MSB-first bit numbering consistently throughout. Three new step types (the K4 slice-open advisor pass settled the load-bearing design call — the rotation representation): (1)des.bit-permute@1— a generic port-native FIPS bit-permute (verbatimfipsPermutelift, params{table, outBits},input/outputports) serving both PC-1 (64→56, drop parity → C₀‖D₀) and PC-2 (56→48, select Kᵣ); a bit-level permute is needed because the existingpermute@1gathers whole bytes and the round-body permutes (IP/FP/E/P) have hardcoded role-specific widths. Namedbit-permute(notkey-permute) to leave the door open to later absorbing the round-body permutes — deferred. (2)des.rotate-halves@1— the dedicated left-rotation of the two 28-bit halves (params{shift, halfBits}), reusing the monolith's exact loop body (fipsBytesToBits/rotateBitsLeft/bitsToFipsBytes) so it is byte-identical by construction. The advisor picked this over expressing the rotation as a build-generateddes.bit-permute@1table: a rotation IS pure bit-routing (so the "zero arithmetic" punchline survives either way), but the cycling C/D halves are the signature feature of the DES schedule (the cumulative-28 cycle, C₁₆ = C₀), anddes.rotate-halves@1(shift: 2)is self-describing where a 56-entry permute table buries "rotate" in narration. (3)des.publish-round-keys@1— the B-minimal meta-bearing tail (identity executor;meta.auxWritePortsmapskey${r} → roundKey.${r}), a thin sibling of the AES/Speck/Serpent publish tails: 16 FIXED keys (countparam, not AES'srounds+1), 6 bytes each. Reusingserpent.publish-round-keys@1(byteLength-agnostic, would functionally work) was declined — it would weld aserpent.*step type into every saved DES document's JSON forever. No S-box mirror hazard — the DES schedule has no S-box, so it dodges the K1/K3 mirror-corruption trap entirely (like Speck did); no role-scoping needed. KAT byte-equality pinned: newtests/des-key-schedule-decomposition.test.tsruns the decomposed schedule and asserts every publishedroundKey.0..15is byte-equal to the monolithicdes.key-schedule@1oracle (the FIPS 46-3 Appendix B key + one more — DES has no key-size variant, so no 3-size matrix); the shippeddes-vectors/des-decryptKATs (now routing end-to-end through the decomposition) stay byte-identical to the published FIPS 46-3 vectors. Thedes.key-schedule@1executor STAYS registered (KAT oracle + back-compat for pre-K4 saved docs; do not global-renamekey-schedule). Blast radius: both DES specs rewired; three new step types registered + given ParamEditor blocks (des.bit-permute@1reuses the read-onlyDesPermutationBlock; new thin read-onlyDesRotateHalvesBlock+DesPublishRoundKeysBlock) + the publish tail added to the narration allowlist (parity with the AES/Speck/Serpent tails); the round-key-fan-out graph/UI tests retargetedkey-scheduleleaf →key-schedule.publish(the surviving meta-bearing writer) —des-graph.test.ts,round-key-panel.test.tsx's DES section, andruntime-ported-dispatch-des.test.ts (c). The KeyScheduleExplorer DES branch is now dormant (no shipped spec emits ades.key-schedule@1frame); itsdes-key-schedule-explorer.test.tsxkeeps exercising the branch by synthesizing the monolithic frame directly (the K3a pattern), pending formal retirement in K4b — and since DES is the last cipher, K4b empties the whole KeyScheduleExplorer subsystem. Gate green: biome + tsc + 2220 vitest / 192 files + vite build; noschemaVersionchange. Graph smoke (throwaway Playwright, deleted post-gate per [[feedback_playwright_dormant]]): both DES specs (encrypt + decrypt) render in graph view with 0 console/page errors; thekey-schedulegroup default-collapses to a single chip (no ~50-chip wall) and expands into the cleanload-key → pc1 → (rotate → pc2)×16 → publishstaircase; clicking arotate-halveschip shows its read-only ParamEditor block (not raw JSON). The decrypt collapsed view (reversed round-key consumption) renders structurally identical to encrypt — the round-key fan-out originates from the single collapsed container, so consumption order does not produce a fishnet (consistent with the K2d analysis). - Serpent key-schedule decomposition — K3a (key-schedule-decomposition plan, 2026-06-02). The monolithic
serpent.key-expansion@1leaf in all six shipped Serpent specs (128/192/256 × encrypt/decrypt) is now a tree of visible port-native primitive frames — the same B-minimal posture K1a (AES) and K2a (Speck) took. The schedule's prekey recurrence (Anderson/Biham/Knudsen 1998 §2:w_j = ROL(w_{j-8} ⊕ w_{j-5} ⊕ w_{j-3} ⊕ w_{j-1} ⊕ φ ⊕ j, 11), φ = 0x9e3779b9 — the same golden-ratio constant Speck/TEA use) now runs as scrubbable frames, the pedagogical payoff this slice exists for. Newsrc/ciphers/serpent-key-schedule-builder-native.ts(buildSerpentKeyScheduleNative(keyByteLength)): one default-collapsiblekey-schedulegroup holdingload-key(aux-load-bytes@1) → size-specific pad (concat@1+constant-load@10x01-tail; no-op for 256-bit keys) →input-codec(permute@1byte-swap LE→BE per 32-bit word) →master-split(split-bytes@1×8) → 132 unrolled recurrence iterations (eachxor@1over the 4 taps + φ-const + per-iteration index-const, thenrotate-bits-right@1{wordBits:32, bits:21}= ROL 11) → 33serpent.key-sbox@1groups → publish tail. The 4-tap lag structure (lags 8/5/3/1) generalizes K2's single(m-1)lag. Two new step types: (1)serpent.key-sbox@1— the dedicated, port-native lift of the schedule's bitsliced-S-box + IP stage (steps 4+5 of the oracle), reusingsboxBitslice4/wordsToBytes4/applyBitPermutation(SERPENT_IP)VERBATIM so it is byte-identical to the monolith by construction; groupiusesS_{(35-i) mod 8}(the index-walks-down-with-wraparound trap); it applies the FORWARD S-box in both directions (the schedule is identical for encrypt/decrypt) so it carries no cross-mode mirror — a deliberate design pick (the K3 advisor's option B) that sidesteps the K1 mirror-corruption hazardserpent.sub-bytes@1would have introduced. A throwaway probe confirmed the standard⇄bitslice identityIP(bitslice_Sbox(w)) == nibble_Sbox(IP(w))(400/400 cases), so reusing the round-body S-box (option A) is a documented viable future upgrade — declined here because the unreviewed run shouldn't take on A's silent, KAT-invisible mirror-scoping surface. (2)serpent.publish-round-keys@1— the B-minimal meta-bearing tail (identity executor;meta.auxWritePortsmapskey${i} → roundKey.${i}), a thin sibling of the AES/Speck publish tails: 33 FIXED keys (countparam, not AES'srounds+1), 16 bytes each. Byte order (the K3 load-bearing detail, parallel to K2's codec): the prekeys are little-endian butrotate-bits-right@1reads big-endian, so the recurrence runs in BE behind one input-codec byte-swap; XOR/constant-load are byte-wise (order-invariant) so only the rotate needed the BE view;serpent.key-sbox@1decodes BE then serializes LE+IP verbatim. KAT byte-equality pinned: newtests/serpent-32-key-schedule-decomposition.test.tsruns the decomposed schedule for all three key sizes and asserts every publishedroundKey.0..32is byte-equal to the monolithicserpent.key-expansion@1oracle; the shippedserpent-vectors/serpent-roundtrip/serpent-key-scheduleKATs (now routing end-to-end through the decomposition) stay byte-identical to the published Serpent vectors. Theserpent.key-expansion@1executor STAYS registered (KAT oracle + back-compat for pre-K3 saved docs; do not global-renamekey-expansion). Blast radius:serpent-spec-builder.tsrewired; both new step types registered + given ParamEditor blocks (a thinSerpentPublishRoundKeysBlock+ read-onlySerpentKeySboxBlock) + narration allowlist / shape coverage; the round-key-fan-out graph/UI tests retargetedserpent.key-expansionleaf →key-schedule.publish(the surviving meta-bearing writer), and the golden frame-stream checksums regenerated (99 → 567 for the padded 128/192 keys, 565 for 256 — the −2 being the absent pad leaves) only after parity + KATs were green. The KeyScheduleExplorer Serpent branch is now dormant (no shipped spec emits aserpent.key-expansion@1frame — the AES branch's K1c state); its formal retirement is the K3b remainder slice. Behavioral change consistent with K1/K2: a wrong-length master key now coerces-and-runs (warn-and-run viaaux-load-bytes@1) rather than hard-rejecting. Gate green: biome + tsc + 2231 vitest / 193 files + vite build; noschemaVersionchange. A throwaway graph smoke (Playwright, deleted post-gate per [[feedback_playwright_dormant]]) confirmed all six Serpent specs render in graph view with 0 console/page errors and thekey-schedulegroup default-collapses (no ~469-chip wall) and expands cleanly. K3b (KeyScheduleExplorer retirement + any further blast-radius tidy) and K4 (DES schedule, different shape, no S-box mirror) follow. - Serpent key-schedule explorer retired — K3b (key-schedule-decomposition plan, 2026-06-02). The K1c-for-Serpent analog: now that K3a decomposed the Serpent schedule into real scrubbable trace frames, the
KeyScheduleExplorer's Serpent swimlane (the viz-only re-simulation of pad → prekey-recurrence → bitsliced-S-box → IP) was dormant — no shipped Serpent spec emits aserpent.key-expansion@1frame, so the branch was unreachable. Premise verified directly (not just inferred from the K3a commit message):src/ciphers/serpent-spec-builder.tsroutes the key schedule throughbuildSerpentKeyScheduleNative, so the monolithic leaf no longer appears in any trace. Deleted:src/ui/key-schedule-sim/serpent.ts(thesimulateSerpentKeySchedulesimulator + itsSerpentScheduleTrace/SerpentStagetypes); theserpentvariant of theScheduleSimulatordiscriminated union + itsserpent.key-expansion@1registry entry insrc/ui/key-schedule-sim/registry.ts(the union is now a single DES member, andKeyScheduleExplorer's dispatch collapses from aSwitch/Matchto the loneDesExplorer); theSerpentExplorer/SerpentScheduleView/PadStageViewrender branch insrc/ui/components/KeyScheduleExplorer.tsx; and the two Serpent-only tests (tests/serpent-key-schedule-sim-parity.test.tsandtests/key-schedule-explorer.test.tsx— the latter tested only the Serpent dispatch branch; DES dispatch through<KeyScheduleExplorer>stays independently covered bytests/des-key-schedule-explorer.test.tsx). Left in place (matching the K1c choice, which left the parallel.key-schedule-aes*rules): the now-unused.key-schedule-serpent*CSS inapp.css. Kept: theserpent.key-expansion@1executor (KAT oracle + back-compat — never global-renamekey-expansion); the entire DES branch (last monolithic schedule until K4). No heavy graph smoke — K3b changes no spec/trace/graph topology (viz-only linear-mode dead-code removal); the dormancy confirmation IS the relevant check, per the advisor. Gate green: biome + tsc + vitest + vite build; noschemaVersionchange. K4 (DES schedule, different shape, no S-box mirror) is the remaining key-schedule-decomposition slice. - Speck32/64 key-schedule decomposition — K2a (key-schedule-decomposition plan, 2026-06-01). The previously monolithic
speck.key-schedule@1leaf in the four shipped Speck specs (BE-paper / LE-NSA × encrypt / decrypt) is now a tree of visible port-native primitive frames — the same B-minimal posture K1a took for AES. The cipher's own ARX kernel (ROR / + mod 2^16 / ⊕ / ROL / ⊕ per Beaulieu et al. 2013 §3) now runs as scrubbable frames in both the round body AND the schedule, which is the pedagogical payoff this slice exists for. Structure (newsrc/ciphers/speck-32-64-key-schedule-builder-native.ts): one default-collapsedkey-schedulegroup holdingload-key→input-codec(per-byteOrder permute reshaping the master key from MEMORY layout(l_{m-2},…,l_0,k_0)BE-paper or(k_0,l_0,…,l_{m-2})LE-NSA to logical[k_0,l_0,…,l_{m-2}]BE per word) →master-split→ 21 iterations × 6 leaves (rot-l/sum/round-const/new-l/rol-k/new-k), then asymmetric output: BE-paper wiresnew-kdirectly into the publish tail (body bytes match published encoding); LE-NSA inserts one bulk codec sub-pipeline (concat → output-codec byte-swap → 22 byte-slices → publish) mirroring K1a's word-stream pattern. The cross-iteration l-chain has lag(m-1)— iterationgwritesl_{g+m-1}, iterationg+(m-1)reads it; builder parameterized by m/wordBits/alpha/beta so future Speck variants (Speck64/128, Speck128/256) drop in without code changes. Two new step types the K2 advisor pass flagged as structurally necessary (not just naming): (1)speck.publish-round-keys@1— parallel-name sibling ofaes.publish-round-keys@1because AES emitsrounds+1keys (initial pre-round key + one per round) while Speck emits exactlyrounds, and AES hardcodesbyteLength: 16in the port shape while Speck32/64 needs 2-byte keys (and future variants 4 or 8); shared executor shape but divergent loop bound and port-shape contract make reuse structurally impossible. (2)add-mod-16@1— exact 16-bit dual ofadd-mod-32@1, fixed-width per the same precedent (carry semantics differs per width → folding into a parameterizedadd-mod@1would hide it). Byte-order handling lives at the I/O boundary as two clearly-labeled codec leaves (input always present with mode-specific indices; output only in LE-NSA as a single bulk byte-swap) — the body in between operates uniformly on BE 16-bit words, honest pedagogy: the byte-order convention IS a codec, and scattering permutes through the schedule body would conflate two distinct concerns. KAT byte-equality pinned three ways: existingspeck-32-64-vectors+speck-32-64-decrypttest files green end-to-end across all 4 specs; newtests/speck-32-64-key-schedule-decomposition.test.tsruns each shipped spec and compares every publishedroundKey.0..21byte-for-byte against the still-registered legacyspeck.key-schedule@1executor's outputs under BOTH byte orders + explicit k_0 codec check (logical word0x0100→ BE bytes[01,00]vs LE bytes[00,01]); the 22-round-frame golden stream stays byte-identical inruntime-ported-dispatch-speck.test.ts (b). Behavior change worth flagging: the legacy monolith threw/8 bytes/on a mis-sized master key; the decomposed schedule reads master-key bytes viaaux-load-bytes@1's declaredbyteLength: 8port, so a wrong-sized aux value triggers the runtime's port-length coercion (__coerce__synthetic frame + ⚠ badge in the UI) and produces garbled output rather than crashing — the universal-port architecture's "coerce is visible, not thrown" posture. Saved Speck docs with mis-sized keys now produce non-canonical ciphertext + a coerce warning instead of an error. ParamEditor extended:INPUT_COUNT_PARAM_TYPESallowlist includesadd-mod-16@1(label "+ mod 2¹⁶");speck.publish-round-keys@1reuses the AESPublishRoundKeysBlock(identical{ outputPrefix, rounds }params shape). The legacyspeck.key-schedule@1executor stays registered (back-compat oracle for pre-K2 saved docs + this decomposition test); new specs reference the decomposed builder. Frame-count delta: BE-paper went 23 → 152 frames (130 schedule + 22 rounds); LE-NSA adds the output-codec sub-pipeline. Suite at 2227 tests / 192 files (+18 vs Phase 2 close); biome + tsc + vite build all clean. K2b (blast-radius cleanup) and K2c (graph smoke + A-vs-B gate) follow. - Multi-block SHA-256 + KAT parity matrix — Phase 2 CLOSED (Slice 2.11, 2026-06-01). SHA-256 now hashes messages of any length (the explorer caps input at 512 bytes — a trace-legibility ceiling, not a SHA-256 limit). The shipped spec was single-block only (≤55 bytes), and ≥56-byte input silently produced a wrong digest. SHA-256 over N blocks is a fold —
H_0 =the initial hash; for each 64-byte blockH ← H + compress(H, block); the digest isHafter the last block (FIPS 180-4 §6.2.2) — and the port-modeiteratealready folds N blocks while carrying a cross-iteration value (the CBC chain). So the running hash IS that chain: Slice 2.11a addschainOutputto theiteratenode (the honest dual ofchainInput, harvesting the final carried value — the mechanism a fold-to-a-single-value needs; additive optional field, noschemaVersionbump), and Slice 2.11b wraps SHA-256's per-block body (message schedule + 64 rounds + per-block final-add) in a port-modeiterate"blocks"whose chain is H —chainInputbootstraps it from the initial-H constant,chainFeedbackadvances it each block,chainOutput("digest") harvests the final H. Padding (pad-with-byte+append-be64-length) already produced a multiple of 64 for any length, so it is unchanged; round 0's working vars and the per-block final-add's addend now read the running chain (port("blocks","chain")) rather than the constantaux["H"](retiring thefinal.fetch-Hleaf). For a single-block message the fold runs once and the §A.1 "abc" digest is byte-identical — only trace stepIds gain a:b0suffix. Graph fix (core/graph.ts): the per-block iterate'sseedInput(length-append) floated because its only consumer is the msg-schedule container'sseedInput = port("blocks","in"), not a leafportInput—inferPortEdgesnow draws the loop-input edgelength-append → blockswhen no body leaf consumes the iterate's"in"port (ECB/CBC/round groups, whose body heads ARE leaves, are unaffected); browser-smoked, the spine reads message → pad → length-append → blocks → … → digest continuously. The hash input cap rose 55 → 512 bytes (App.tsx). Slice 2.11c shipstests/sha-256-kat-matrix.test.ts: published exact-hex vectors (empty, "a", FIPS 180-4 §A.1 "abc" 1 block, §A.2 56-byte 448-bit message 2 blocks), anode:cryptoblock-boundary cross-check across lengths straddling every block transition up to 512 bytes, fold-engagement pins (55→1 block, 56→2, 120→3 via uniqueblockIndex— the 56/64-byte cases that mis-hashed before flip green here), and a bounded multi-block stress behind theSHA256_STRESSenv flag (4 KB / 64 blocks + a 0..256 length sweep). The stress was revised down from the sketch's "10 MB" — that literal predates the Slice 2.6d decomposition, which made the tracing runtime emit ~2299 frames per 64-byte block (10 MB ≈ 360M frames, infeasible). Verified byte-equal vs FIPS 180-4 +node:cryptoacross lengths 0/1/55/56/63/64/119/120/200/1000 + §A.2 + the flag-gated sweep. Multi-block restructure preserved the full stepId/structural test surface (:b0suffixes, descend into the iterate for round-group pins, history-seed edges re-anchored atblocks, fanout moved blocks=5/length-append=1).npm run checkGREEN; noschemaVersionchange. Phase 2 of the universal-port-dataflow plan is closed — SHA-256 ships port-native end-to-end, multi-block, KAT-equal. Next:metaretirement / key-schedule decomposition (its own phase + AES-first spike). - Port-native Feistel/swap visualization rebuilt for DES (Slice 5.3d, 2026-05-31). When B4 made DES port-native (each round a plain
groupofsplit-bytes → …F function… → xor → concat, the swap expressed as theconcatargument order), the oldfeistel-round-keyed linear views went dark — they read the deadbranchPath/ synthetic:rejoinframe. The rebuild restores them reading the real port I/O, with detection AND structure derived purely from the round group's wiring (no spec annotation, zero schema change — a user pick): a new pure modulesrc/core/feistel-shape.tsrecognizes the split→F→xor→concat shape (analyzeFeistelRound), finds the active round fromframe.path(findActiveFeistelRound), and reads L/R/F/L⊕F/new_L/new_R from the child frames'portOutputs/portInputs(resolveFeistelRoundBytes). The swap is read from the recombine's argument order, never hardcoded — the same analyzer derives the textbook swap for rounds 1–15 and the no-swap exception for round 16 purely from each round's different wiring (pinned across all 16 rounds, encrypt and decrypt), and because the components read the re-run trace the picture re-derives live whenever a spec edit changes the wiring. A leaf-membership guard infindActiveFeistelRoundkeeps the diagram off a frame that isn't actually a leaf of the detected round (every cipher names its roundsround.N, so a transient spec/trace mismatch during a cipher switch could otherwise draw DES structure on an AESround.Nframe). Three new self-detecting linear-mode components:FeistelSwapDiagram(the abstract SVG — two halves, F applied to R, ⊕ into L, then the swap crossing for rounds 1–15 vs straight-down for the round-16 no-swap exception; clicking an F-stack leaf scrubs, the active leaf is accented, the round-key leaf shows aK_icross-reference),FeistelRoundBytes(round-level L / R / F / L⊕F / L' / R' byte rows), andFeistelRecombineView(an additive panel on theconcatframe that reframes it as the swap — naming the two inputs R / L⊕F in wiring order and explaining the self-inverse no-swap exception). The components use their own CSS class names (copied from, not shared with, the oldfeistel-mini-diagram-*/feistel-context-*rules) and are keyed off the new structural detection, so they are independent of the pending 5.3e removal of the old toy-only Feistel components. Cipher-agnostic: any port-mode group built the same way (a future TEA/XTEA/Twofish) renders for free. +26 tests (tests/feistel-shape.test.tspins the analyzer's enc/dec swap pattern, its null cases for AES/SHA/the outerroundsgroup/a hand-broken round/the spec-trace mismatch guard, andresolveFeistelRoundBytesbyte-equal to the FIPS 46-3des-kat.jsonvector across all 16 rounds including the round-16preFpbyte-order pin; three jsdom component tests). Browser-smoked in Chromium (encrypt + decrypt + a composite full-column look). Gate green (2402 tests / 210 files); no KAT orschemaVersionchange. - Universal port-based dataflow — Phase 2, Slice 2.0b-i GREEN (2026-05-24). Widens the
for-each-subgraphspec node kind with item-array mode — Slice 2.0a's state-thread pattern (SHA-256 compression's 64-round body) now sits alongside a second mode that subsumes legacyIterateGroup's per-block iteration under one universal-port construct. Two user picks locked at slice start: (1) Open #N1 = (b) Concatenated single port — block count is run-time data (varies with plaintext length), so the Phase-1 "(a) is the precedent" hint from key-expansion (Decision B, dynamic-N via spec-timeparams.rounds) does NOT transfer — picking per-block ports would force aPortShapeMapwidening to take run-time data OR a magic-numberparams.maxBlockscap baked into every CBC spec OR a wildcard-edge convention; advisor flagged the asymmetry, user picked (b). Graph-view fidelity worry is moot — today's legacy iterate already shows one aux edge → arrow bundling collapses N parallel edges to a ×N pill anyway, so (b)'s single-edge-with-runtime-derived-×N annotation matches what users see today. (2) Slice 2.0b sub-decision = (X) Node-explicit fields —for-each-subgraphcarriesinputArrayPort+outputsPort+blockByteLength+blockLayoutdirectly;iterationCountauto-derives asstate.bytes.length / blockByteLength. Mirrors legacy iterate'sblocksFromAux/countFromAux/outBlocksAuxself-contained pattern; no graph-introspection coupling. Type widening insrc/core/types.ts(ForEachSubgraphNode): four new optional fields. Mode discriminator enforced at runtime — partial-field configs throw with a noisy "item-array mode requires ALL of …" error; both-modes-set throws "item-array mode forbids iterationCount." Both-fields-absent on the state-thread branch throws "state-thread mode requires iterationCount." Zod schema insrc/core/document-schema.ts:iterationCountbecomes optional + four new optional fields. NoschemaVersionbump (pre-Slice-2.0b documents validate unchanged). Static shape walk insrc/core/spec-shapes.ts::walk: item-array mode treats the body's input shape asblockLayoutand the node's after shape as"bytes"; state-thread mode stays shape-transparent. Runtime walker insrc/core/runtime.ts::runForEachSubgraph: item-array mode reads parent-scopestate.bytes(Phase 4+ port-edge wiring is the future story forinputArrayPortas an actual edge anchor), slices intoblockByteLengthchunks, decodes each viaportBytesToState(slice, blockLayout)to seed body's state, walks children with:r{i}suffix, encodes body's exit state viastateToPortBytes, accumulates, and concatenates back to parent-scopestateas a flat BytesState on node exit. Mirror semantics make legacyconcat-blocksredundant in port-native specs (liftingsplit-blocks/concat-blocksis the next sub-slice 2.0b-ii). Toy fixture intests/runtime-for-each-subgraph-outer.test.ts(8 cases): 3-block × 4-byte XOR-with-constant pins per-iterationstateBefore/stateAfter+ concatenated final state + iterationCount auto-derivation; zero-blocks case emits zero frames; single-block (1 × 4 bytes) emits one:r0frame; length-divisibility throws when bytes don't evenly divide blockByteLength; mode-exclusivity throws when both iterationCount and item-array fields are set; partial-field throws when only some item-array fields are set; non-bytes parent state throws;blockLayout: "matrix4x4-bytes"round-trips a 16-byte input through the matrix encode/decode pair (sanity check for AES-CBC-style block layouts). Pass gate: Phase 1 frame-parity matrix (Slice 1.11) stays green untouched. Suite at 1761 tests across 159 files (+8 since Slice 2.0a),npm run checkgreen in ~41 s. Bundle 548.39 KB gzipped (+2.13 KB for the widened type + new runtime branch + Zod schema branch — within the Phase-2 expected envelope from Open #N8). Slice 2.0b-ii (liftsplit-blocks@1+concat-blocks@1with the new layout vocabulary forState[]aux values) is the next stop; for-each-subgraph contract is still not yet final — Slice 2.0c closes it with feedback / lookback support per Open #N4. - Universal port-based dataflow — Phase 2, Slice 2.0a GREEN (2026-05-24). First load-bearing contract of Phase 2: the
for-each-subgraphspec node kind ships as a newStepNodevariant, validated on a synthetic toy fixture before any SHA-256 specifics land. The new kind threads state across iterations — iterationi+1's body input is iterationi's body output, no clone-from-aux per iteration likeiterate— which is what SHA-256's 64-round compression body needs. The runtime walker (runSpecinsrc/core/runtime.ts) handleskind === "for-each-subgraph"inline, appending:r{i}to each emitted body frame's stepId. Composition with the existing:t{name}(Feistel) and:b{i}(iterate) suffixes follows a fixed type order (:t<:b<:r) with outer-first walk order within a type — surfacing the misread that the prior "innermost-first" doc comment described: the rule is type-order + walk-order, not depth. A leaf inside Feistel-A wrapping Feistel-B inside an iterate inside a for-each-subgraph now emits<leafId>:tA:tB:b3:r7.core/step-id.tsregex extended to strip:r\d+(correctness gate forsetTraceframe preservation).core/document-schema.tsZod schema gainsForEachSubgraphSchemaso saved documents using the new kind round-trip through Save / Share; noschemaVersionbump (the v2 union widens; pre-Slice-2.0a documents validate unchanged).core/spec-shapes.ts::walktreats the new kind as shape-transparent (unlikeiteratewhich clobbers shape tomatrix4x4-bytes). Graph data model gains a fourth container kind ("for-each-subgraph") mirroringiterate's container shape and spine-termination treatment for Slice 2.0a; richer rendering (round-count badge, collapse semantics) lands in Slice 2.10.iterationCountaccepts either a literalnumber(the toy uses5; SHA-256 compression will use64) or{ fromParam: string }(param-form deferred — the resolution mechanism settles at the first param-form consumer; today's runtime throws a clear "Slice 2.0a defers param-form" error).findStepAndParentincore/spec-mutations.tsaccepts ForEachSubgraphNode as a parent type;duplicateRoundGroupgains a defensive bail when called on a for-each-subgraph parent (per-iteration content shifts aren't a designed operation). Toy fixture intests/runtime-for-each-subgraph-toy.test.ts(5 cases, pinning the four Slice 2.0a contract picks): (1) inner-only 5-iter XOR-with-constant — final state byte-equal to start XOR constant (odd-count self-cancellation); per-iteration stepIdsxor:r0..xor:r4; state threads correctly across iterations; canonical stepId strips to"xor". (2) 4-iter XOR returns start state byte-equal (even-count). (3)iterationCount: 0emits zero frames. (4)iterationCount: { fromParam }throws with/Slice 2\.0a/regex match. (5) nested toy — 2-block outeriterate× 3-iter innerfor-each-subgraph, 6 body frames emit with composedinc:b{i}:r{j}suffixes pinning Open #N6 + the type-order rule; state threads within a block but resets at the iterate boundary per iterate's existing contract. Pass gate: Phase 1 frame-parity matrix (Slice 1.11, 22 rows) stays green untouched — no shipped cipher uses the new kind. Suite at 1753 tests across 159 files (+5 since Phase 1 close),npm run checkgreen in ~77 s. Bundle 546.26 KB gzipped (+3 KB for the new spec node kind + Zod schema + walker code — within the Phase-2 expected envelope from Open #N8). Slice 2.0b (item-array input + iteration-outputs port + liftingsplit-blocks/concat-blocks) is the next stop; the for-each-subgraph contract is not yet final — Slice 2.0c closes it with feedback / lookback support. - Universal port-based dataflow — Phase 0, Q-gate-9 green (2026-05-23). The load-bearing assertion of
docs/plans/universal-port-dataflow.md— "every TraceFrame is one projection of unified per-port byte arrays; the legacy state/aux split is lossless against (inputs, outputs, layoutTags)" — passes empirically on real AES-128 frames produced by the existing runtime. Phase 0 is the ~3-day trace-shape spike that gates the entire migration: if the round-tripdeepEqual(reconstruct(project(legacyFrame), layoutTags), legacyFrame)could not be made lossless withoutlayoutTagsballooning, Phases 1–5 would have re-opened for redesign. Three Phase-0 scenarios all green: pure state-only (generic.byte-substitution@1in single-block AES-128), aux-reading (generic.add-round-key@1with the round-key name preserved through the port-name ↔ aux-key binding), and iterate body (both step types when emitted insideaes-128-ecb's ECB iterate, preservingblockIndex: 0and the:b0stepId suffix). Type additions insrc/core/types.ts(PortShape,PortContract,PortLayout,StepInputs,StepOutputs,PortedExecutor,LayoutTags,PortedFrame) are non-breaking — nothing in the runtime walker or registry imports them yet; the dual-dispatch flip lands in Phase 0's task 5. Naming note: the plan's contract sketch called the new shapeStepShapeContractbut that identifier was already taken attypes.ts:319for the single-thread state-shape contract used by the palette chip + drop-anchor greying +validateShapes; the new shape is renamedPortContractso both contracts coexist during the migration. Anti-trivial discipline:LayoutTagscarries the layout enum + optional bit-length / bigint-encoding metadata + port-name ↔ aux-key bindings only — it does NOT carry the legacyStateobject verbatim, so reconstruction genuinely rebuilds variant wrappers from raw bytes via the tag alone. Phase-0 scope was bound (per 2026-05-23 user pick) to the three matrix4x4-bytes targets in the plan, withbitvec+bigintState variants TODO-marked in the projection helpers andLayoutTagsfor Phase 1 (first cipher to exercise them — SHA-2 in Phase 2 or RSA later — forces the encoding decision). New files:src/core/port-projection.ts(theproject/reconstructpure helpers + per-step-typeProjectionMetadatainterface),tests/port-projection-q-gate-9.test.ts(4 round-trip assertions). Suite at 1612 tests (+4); typecheck + full suite green in 39.6 s. - Palette drops into Feistel-round tracks (Phase 6d of
docs/plans/des-feistel.md, 2026-05-20). The graph editor's drop machinery now reaches inside afeistel-roundcontainer. Empty tracks (DES's L track is the shipped example — passthrough-only after the spec) accept at-start drops via a full-column sentinel gutter encodedinto-track-start:roundId#trackIdx; populated tracks (the R track in DES, where F = E ⊕ K → S → P runs) get standard before/between/after horizontal strips using real chip ids. A newprependChildToTrack(spec, roundId, trackIdx, newStep)primitive sits alongsideprependChildToContainer; the structural mutator chain (insertStepAfter/Before/removeStep/reorderStep) gained Feistel-track descent intransformParentArray, so any stepId living inside a track is now a valid anchor.StepLocationwidens to includeFeistelRoundGroupparents + an optionaltrackIdx;findStepAndParentdescends accordingly. Drops on the inter-track gap (between L and R columns, or on the round's header band) fall through the round chip's outerdata-drop-anchorand route to{kind: "after", stepId: roundId}— inserting the new leaf immediately AFTER the round in its containing parent (theroundsgroup for DES). User-picked semantic (the inverse of the group case where header drops mean "into-start") because a feistel-round has no unambiguous default body. Reference-equality discipline on the spec store's debounced re-run pinned by test: a drop into R keeps L'strackobject reference identical, so a per-track edit doesn't trigger the trace to re-run on every keystroke. The Slice-11 round-trip test (tests/built-from-palette-roundtrip.test.tsx) extends with a DES flow: cipher selector → DES, palette into round.5's L track, edit params, pin layout, Save + reset + Load — track membership, params, layout pin all survive AND the DES ciphertext stays byte-equal to the FIPS 46-3 vector. Horizontal-flow Feistel (a Feistel-round nested inside an iterate, future case with no shipped cipher) deferred. Bundle +1.61 KB (515.54 → 517.15 KB). Suite +31 tests acrosstests/spec-mutations-feistel-track.test.ts(NEW),tests/spec-mutations-structure.test.ts(extended with track-descent cases),tests/insert-into-track-store.test.ts(NEW),tests/graph-view-feistel-drop-gutters.test.tsx(NEW including the inter-track-gap regression), andtests/built-from-palette-roundtrip.test.tsx(DES round-trip extension). - DES enters the cipher selector — first Feistel cipher in the project (Phase 4 of
docs/plans/des-feistel.md). DES single-block encrypt + decrypt are now reachable from the cipher dropdown alongside AES / Speck / Serpent. Picking it with the default values (FIPS 46-3 Appendix B test vector —PT=0123456789abcdef,K=133457799bbcdff1) and clicking Run produces the canonical ciphertext85e813540f0ab405. The cipher uses thefeistel-roundbranching primitive shipped in Phase 2: the round body forks into an L track (passthrough) and an R track (E-expand → XOR with K_i → 8 S-boxes → P-permute), withfeistel-standardcombine on rounds 1..15 andfeistel-no-swapon round 16 (the textbook exception that makes the cipher self-inverse under key-reversal). The padding and cipher-mode selectors disable for DES the same way they do for Speck / Serpent — the overlay'sload-blockstep is 16-byte-only today; DES uses its 8-byte block as the input length. Per-step ParamEditor blocks for all seven new step types (key-schedule with three structural tables, IP / FP / E / P bit-permutation views, xor-with-K aux name, and a brand-new 8-S-box collapsed view — unlike Serpent's one-S-box-per-leaf, DES carries all 8 on a singledes.s-boxes@1leaf). Per-frame value-prose narration for the six round-body step types using the bit-level structural-overview + per-output-byte drill pattern (matching Serpent IP/FP) plus per-S-box units fordes.s-boxes@1. Cell-level provenance for the same six (honest byte-level: each output byte → deduplicated input-byte set). The__rejoin__synthetic stepType the runtime emits at everyfeistel-roundrejoin frame gets a single narrator that dispatches onparams.combineKind, with prose explaining the 4-arg combine formula and a one-sentence cross-row callout ("the swap is why this highlight crosses rows").des.key-schedule@1stays on the narration allowlist — the multi-round PC-1 → 16 shifts → PC-2 walk is the wrong shape for per-frame narration; the futureDesKeyScheduleExplorer(Phase 5e of the plan) replaces FrameStateView for that step, matching how AES / Serpent key expansions are handled. CipherDocument.schemaVersionbumped 1 → 2 with backward-compat migration. Saved.cipher.jsonfiles and URL-share links from prior sessions still load:parseDocumentnow accepts both versions and applies a pure v1 → v2 migration (a version-field bump only — v1 documents predate DES being in the cipher selector, so they cannot containfeistel-roundnodes). The Zod schema validates the bumped version after migration, and post-migration documents carry the current version literal in the in-memoryCipherDocument. The forward-incompat error message now lists every accepted version, so a v3 file from a future build gets a friendly "this app reads versions 1, 2" message rather than the raw Zod literal mismatch.- End-to-end DES test via the cipher selector (
tests/app-cipher-selector.test.tsx). Verifies the FIPS Appendix B vector round-trips through the full UI path (cipher swap → store defaults swap → spec swap → input/key text swap → Run), and pins the AES + PKCS7 → DES swap path (advisor-flagged regression risk:applyPaddingScheme(desSpec, "encrypt", "pkcs7")could throw without thestateShape !== "matrix4x4-bytes"guard inapplyPaddingScheme).
- PortFlowView formalized as the universal inspector default (Slice 5.3a, 2026-05-31). No runtime behavior change — every user-selectable cipher/hash was already port-native, so every selectable trace frame already routed to
PortFlowView;BytesViewis now reachable ONLY by the test-only lifted-legacyfeistel.toy-add-k@1(injected via__setSpecForTests, never in the cipher selector).App.tsx::FrameStateView's dispatch comment is reframed to name this intentional contract, and a new invariant intests/requires-ported-dispatch.test.ts(reusing theshippedSpecsenumeration) asserts every leaf of every shipped spec is port-native (kind:"ported",legacy === undefined) → no selectable cipher reachesBytesView, with a positive control that the toy IS lifted-legacy (the invariant has teeth). The stale S2(e) docstring ingraph.ts(inferPortEdges) is truthed-up: AES/DES are now port-wired (spine frominferPortEdges) but Speck/Serpent are NOT (monolithic hybrid-ported, no specportInputs), soinferStateEdgesremains the sole source of their graph spine. This is the first slice of the re-scoped 5.3 sub-arc (5.3a–e) that reaches full legacy-state retirement (Path C) — research found the one-liner mis-ordered: the field/edge removals (stateBefore/stateAfter,inferStateEdges,dropAuxOnlyStateEdges,BytesState) re-sequence behind port-wiring Speck/Serpent (5.3b), migrating the value/narration readers (5.3c), and the obligatory port-native Feistel-viz rebuild (5.3d), landing in 5.3e. Seedocs/plans/phase-5-legacy-retirement.md. No KAT orschemaVersionchange. - Padding + Speck key-schedule frames now render in the port-flow inspector (Slice 5.2, 2026-05-31). Converting the lifted key-schedules (AES/Speck/Serpent/DES), the padding family (PKCS#7 / zero-pad / ISO 7816-4, pad + unpad), and the aux primitives (
aux-load/aux-xor/aux-copy) to true port-native executors flips their trace frames fromBytesView(before/after byte rows) toPortFlowView(labelled input/outputstateport rows) — the same inspector SHA-256's own message padding already uses. For padding the pad/unpad length change reads across the two port rows (input vs output cell counts). AES/Serpent/DES key-schedule frames are visually unchanged (still intercepted byKeyScheduleExplorerby step type); only Speck's key-schedule reroutes (it isn't in that registry). No cipher/hash output or KAT changed — this is an inspector-rendering change only, and the byte-level frame data (auxRead/auxWritten/state) stays identical because the executors retain their projectionmeta. - DES rebuilt port-native — no longer uses the
feistel-roundprimitive (B4 / universal-port Phase 4d, 2026-05-30). DES was the last cipher on the legacy Feistel contract; B4 rebuilt it, so every shipped cipher/hash is now port-native. Each round is a port-modegroupwiringsplit-bytes(widths=[4,4])→des.expand-R→des.xor-with-K→des.s-boxes→des.p-permutation→xor@1(L⊕F) →concat@1, with$input → IP → rounds → FP → outputFrom. The Feistel swap is theconcatargument order — rounds 1–15concat(R, L⊕F), round 16 (no-swap)concat(L⊕F, R)— proving the universal-port thesis that Feistel needs no special primitive. The 6 F-leaf executors became truePortedExecutors (IP/FP/E/S/P pure port-native with projection meta dropped;des.xor-with-K@1keepsmeta.auxReadPortsonly); the key-schedule stays lifted (aux-only). KAT byte-equal to FIPS 46-3 (85e813540f0ab405), pinned by a per-leaf parity net againstdes-kat.jsonacross all 16 rounds plus the swap-order trap (round.16.recombine == preFp). Linear-view narration fix: DES is the first cipher to drop the state-thread projection meta, so the runtime never updates the threadedstatethrough the rounds (every DES frame'sstateBefore/stateAfteris the stale plaintext) — the DES narrators were repointed to read the port I/O.FeistelRoundGroup/BranchTrack/CombineKind,runFeistelRound, the toy fixture, and the linear/graph Feistel components survive Phase-5-deprecation-pending; no shipped spec uses them. Thefeistel-round-keyed Feistel visualizations go dark for DES — a port-native-aware Feistel/swap diagram is an obligatory follow-up. NoschemaVersionbump (the schema still acceptsfeistel-roundfor old saved docs + the toy fixture). Seedocs/plans/scaffolding-suppression.md"## B4". - Offset-based graph layout is now ON by default (2026-05-28). The orientation-driven offset layout (alternating Y in horizontal-flow contexts, staircase X in vertical-flow contexts) merged behind
?offsets=1via PR #1; the default is now flipped —isOffsetsEnabled()returns ON unless explicitly disabled via?offsets=0(the A/B escape hatch inverted). No layout math changed, only the gate's default. Four baseline graph-geometry test files pin offsets OFF in theirbeforeEach(graph-view-replica-gutter,graph-view-replica-placement,graph-view.test.tsxAES-128 describe,graph-view-label-truncation) so they keep exercising the un-offset invariants they were written for; a default-ON unit test was added tograph-view-offsets-layout.test.ts. Replica/gutter placement WITH offsets ON is intentionally left to the pending visual smoke. Visual smoke on the three offset behaviors still pending. - The DES allowlist entries in
NARRATION_NO_OP_ALLOWLISTandPROVENANCE_NO_OP_ALLOWLIST(6 each, added in Phase 3 ofdocs/plans/des-feistel.md) drop in Phase 4 alongside the dedicated narrators/provenance fns. The contract tests now demand coverage for IP / FP / E / P / xor-with-K / s-boxes; onlydes.key-schedule@1stays on the narration allowlist (covered by the future Phase 5e explorer). - Result line moved from below the action buttons into the inputs row (Phase 6e smoke, 2026-05-20). Plaintext / key / ciphertext now sit in a single visual neighbourhood; the result wraps to its own flex-100% row inside
.inputsso the action buttons stay on a clean line below. Phase 6e DES smoke surfaced "ciphertext should sit closer to the plaintext and key, currently it is separated by a row" — the move closes that gap..inputs-resultCSS rule handles the wrap; the oldgrid-area: resultwas unused after the move and dropped. - Frame slider + StepList hidden on the graph tab (Phase 6e smoke, 2026-05-20). Both are now wrapped in
<Show when={viewMode() !== "graph"}>. Rationale: in graph mode the user drives the value inspector by clicking nodes/edges, so neither the trace timeline scrubber nor the linear-mode StepList sidebar was carrying its weight there. Linear + JSON views are unchanged. - Feistel-round containers get a subtle border + label distinct from the parent group (Phase 6e smoke, 2026-05-20). New
.graph-container-rect-feistelCSS rule (neutral fill, 1px stroke that's a 60/40 mix of--border+--changed) so eachround.Nreads as a distinct sub-container inside its parentRoundsgroup without shouting. Matches the quiet visual register of AES round groups; the warm hue mix keeps the kind hue-distinguishable from the iterate's dashed accent stroke. Root cause of "no border visible" was SVG paint order, NOT CSS:<For each={graph().containers}>rendered the parentRoundsgroup AFTER its feistel-round children, so the parent's<rect>painted ON TOP of every child rect, obliterating any visual treatment. Fix: newcontainersInPaintOrdermemo sorts bycontainerPath.lengthascending so parents paint first and children paint on top, matching standard SVG nesting convention. Generalizes for any future nested-container pattern. - Inspector resolves rejoin / passthrough clicks AND state-edge values correctly (Phase 6e smoke, 2026-05-20,
src/core/edge-value-lookup.ts).findConsumerFramenow canonicalizes BOTH the input stepId AND each frame's stepId — the asymmetric pre-fix made rejoin chip clicks miss (canonicalStepId("round.1:rejoin")="round.1", but the input stayed"round.1:rejoin"). NewlookupPassthroughByteshelper resolves passthrough chip ids to theirL_in/R_infrom the round's rejoin frame's params, shared by both node-side (chip click) and edge-side (arrow click) lookups. Rejoin-source state edges slice the combined output by the consumer's track: an arrow fromround.N:rejointo the next round's L track now surfacesnew_L(4 bytes), to the R track surfacesnew_R(4 bytes) — the full 8-byte concat is the right value only for the edge from the last round's rejoin to the final-permutation outside the Rounds group. Track membership is determined by a spec walk that descends into feistel-round tracks, with the passthrough chip's:passthrough-Nsuffix as a shortcut when the consumer is the passthrough itself. - Source-color coding excludes rejoin synthetics (Phase 6e smoke, 2026-05-20,
src/core/source-colors.ts). A rejoin chip emits two outgoing edges by construction (new_Landnew_Rhalves), tripping the fanout ≥ 2 colourability threshold and auto-painting the swap crossover X with two arbitrary palette colours. Pedagogically both arrows are halves of the same combined state, not distinct sources; treating them as such turned visual continuity into visual noise. Fix:sourceFanoutMapnow skips edges whose source is asynthetic === "rejoin"node, so rejoin-source edges fall back to the neutral default kind-based styling and the X arrows stay one colour. - Cross-scope replicas land in the consumer's container, not the source's (Phase 6e smoke, 2026-05-20,
src/core/graph.ts). The "spine replica lives in source's parent scope" rule shipped 2026-05-17 was implicitly designed for source.parent === consumer.parent (AES / Speck / Serpent today). DES breaks that:key-scheduleis at root, its 16 consumers are insideRounds > round.N. Under the rule, round.1'sxor-Kgot the (key-schedule, round.1.xor-K) spine-replica designation, which placed it at root level next to the plaintext pill with a long arrow back to round.1's xor-K — asymmetric with round.2..16's replicas inside their rounds. Fix: the spine-replica designation now also requiressourcePathandconsumerPathto be equal. Cross-scope cases fall back to consumer-scope placement, so round.1's xor-K replica lands in round.1's right gutter alongside round.2..16's. Byte-equivalent for AES / Speck / Serpent (same-parent cases keep the spine-replica designation and the source's old spec slot).
- Legacy executor contract + dual-dispatch retired (Phase C / universal-port Phase 5, 2026-05-31). After Slice 5.3e left every shipped step port-native (
legacy === undefined), the legacy single-thread executor contract and the per-callportedDispatchEnableddual-dispatch flag were dead — the app always ran the ported path. This phase collapses that scaffolding into one execution path; behavior-preserving by construction (the always-on path is the exact path every shipped spec already ran), every KAT byte-identical, noschemaVersionbump (registration kind + the dispatch flag are never persisted). Batch 1 converted the lastkind:"legacy"registrations — the test-onlyStepDefinitionhelper steps in theiterate/for-each-subgraphruntime tests — to hybrid-ported steps, and reframed the FES-with-history aux snapshot/restore tests ontotrace.finalAux(the ported path's syntheticctx.auxcan't observe the live aux map a probe step used). Batch 2 deleted the runtime's OFF-flag legacy branch + its "requires portedDispatchEnabled: true" throw (the ported branch is now unconditional), removedportedDispatchEnabledfromRuntimeInput, deletedcore/dispatch.ts(requiresPortedDispatchwas vestigial — true for every shipped spec) + theApp.tsxflag derivation, and collapsedStepRegistrationto its single port-native shape — dropping thekind:"legacy"arm + thelegacy?fallback field and deleting the orphanedStepExecutor/StepDefinition/StepResulttypes (StepContextsurvives; the ported contract reuses it).register()now takes aStepRegistrationdirectly (no more bare-fn /StepDefinitionnormalization);get()andnormalizeRegistrationare gone. Thekind:"ported"literal is kept as a single-member tag so every registration literal compiles unchanged (dropping the discriminator would have churned dozens ofdefault-registryliterals for a purely cosmetic gain).meta/ProjectionMetadatais untouched — hybrid-ported key-schedules / padding / aux still project state/aux onto ports; its retirement (re-expressing the key-schedule round-key aux fan-out as explicit port wiring) is a separate graph-topology decision, deferred. TheGraphViewreplication-auto-on memo simplified — itsrequiresPortedDispatch(spec, registry)call became a literaltrue. This is behavior-identical for every shipped spec:requiresPortedDispatchwas true for any spec with a port-native leaf, and every shipped spec has satisfied that since Slice 5.2, so the predicate already returned true for all of them — a pure simplification, not a default change. Test sweep (~110 files): removedportedDispatchEnabled: truefrom everyrunSpeccall (tsc-verified complete) + the@/core/dispatchimports; deleted the per-primitive "off-flag dispatch throws" guard tests (the on-flag "input port not wired" siblings stay), the now-vacuousrequires-ported-dispatch.test.tsandstate-shape-contracts.test.ts(its "legacy-shaped steps declare a shapeContract" category is gone — vacuous since Slice 5.2;spec-shapes.test.tskeeps the contract↔topology coherence), and thebyte-native-ports-contractlegacy-contract tripwire (the collapsed type makes a legacy registration unrepresentable — a stronger guarantee than the runtime filter). Gate green — biome clean, tsc 0 errors, 2165 tests / 189 files, build 620.52 KB raw / 182.93 KB gz. Browser-smoked (throwaway Playwright): DES (FIPS 46-385e813540f0ab405— verifies theApp.tsxwiring + themetakey-schedule projection) + AES-128 (FIPS-197 C.1, confirming the new universal replication-auto-on default renders clean), zero console/page errors. Seedocs/plans/phase-5-legacy-retirement.md"## Phase C". TraceFrame.stateBefore/stateAfterretired — the trace is port-flow-only (Slice 5.3e, Batch 4 of 4, 2026-05-31; irreversible). Completes Slice 5.3e. Deleted the per-framestateBefore/stateAfterState snapshots fromTraceFrame(core/types.ts) and their runtime construction (the per-leaf frame's captured-before + after-clone, and the__coerce__frame's two field lines — the pre/post byte locals SURVIVE, feeding the coerce frame'sportInputs/portOutputs). The honest per-step bytes ride the captured port I/O; the cipher's seed + result live onTrace.initialState/Trace.finalState.frameStateInBytes/frameStateOutBytes(core/frame-state.ts) now returnportInputs/portOutputs.get("state") ?? null— the field fallback is gone.State/StateShape/BytesStatewere ALREADY at the bytes floor (Slices 5.0/5.1), so the "collapse" was a no-op: removing the now-vestigialshape:"bytes"discriminant would churn ~566 sites across 132 files for zero gain, so it was deliberately NOT done —Statesurvives verbatim as the runtime-internal thread type (cloneState). The runtime still threads aStateinternally; it's just no longer published per frame. Accepted regression (user-confirmed, browser-verified): the cipher-agnostic"state"-keyed surfaces — step-strip thumbnails, the graph value inspector (lookupNodeValue),RunExplorerModaltiles — now show "(no state)" for any leaf with no"state"port (every native-AES leaf, all SHA-256 primitives — their payloads rideoutput/a/input/… ports). PortFlowView, the main linear inspector, is UNAFFECTED (it reads the real ports by name); DES F-leaves (named"state") keep showing honest round-local bytes. Pre-Batch-4 these surfaces showed only the stale leftover threaded state for native-port leaves, so "(no state)" is strictly more honest; the uniform fix is the deferred port-aware inspector (Slice 2.9c-e). Tests (85 tsc errors / 21 files, triaged): narration test frames migratedstateBefore/stateAfter→portInputs/portOutputskeyed"state"(the narrators read the helpers); Speck + Serpent golden frame-streams retargeted toframeStateOutBytes(Speck's aux-only key-schedule frame →(no-state); the 6 Serpent SHA-256 digests regenerated — only the aux-only key-expansion frame flipped, all 98 round/IP/FP frames byte-identical, KAT suite guards correctness); coercion test → ports byportName; thefor-each-subgraphtoy fork reframed onto the survivingfinalStateexcept the self-inverse XOR inner toy, whose body leaf became hybrid-ported so its threading invariant reads the captured"state"port;node-value-lookup+provenance-serpentupdated for the port-only reality;trace-initial-state's pre-deletionhelper==fieldcorpus deleted (moot post-deletion) and replaced with a slim port-only regime pin; ~30 doc-comments truthed-up. Gate green — biome clean, tsc 0 errors, 2237 tests / 191 files (−5 vs Batch 3), build 621.72 KB raw / 183.37 KB gz. NoschemaVersionbump (trace frames are not persisted). Browser-smoked (throwaway Playwright, 3/3): AES-128 FIPS-197 C.1, SHA-256abc, DES FIPS 46-3 — KATs correct, PortFlowView renders full port bytes, "(no state)" thumbnails don't crash, all view tabs error-free. Seedocs/plans/phase-5-legacy-retirement.md"## Slice 5.3e".- Graph legacy state-edge inference retired (Slice 5.3e, Batch 3 of 4, 2026-05-31). Deleted
inferStateEdges(the legacy consecutive-siblings(state, params) → statespine inference, plus its local helpers and the S2(f)skipStateEdgeTogate) anddropAuxOnlyStateEdges(the aux-only-root spine-edge suppressor) fromsrc/core/graph.ts— ~390 lines. The graph spine is now composed entirely byinferPortEdgesfrom each leaf's declaredportInputs. This is safe because every shipped spec wires its first consumer to the$inputsource (specReferencesInputSourceis true for all), so the legacyCIPHER_INPUT_IDpill never injects and the only survivingkind:"state"+auxKey:"state"edge is the output pill (last step → output, never an aux-only root) — i.e.dropAuxOnlyStateEdgeswas already a pure no-op for every shipped spec. Probe-confirmed: AES-128 spine = 40 edges, all port-flow, legacy = 0; SHA-256 legacy = 0; DES legacy = 0. InGraphView.tsxthedropAuxOnlyStateEdgescall site + theauxOnlyFilteredGraphandauxOnlyRootSinkIdsmemos are gone;auxOnlyRootIdssurvives — it still drives the layout-lift of aux-only roots off the spine row.opts.registryonderiveAuxGraphis now unread but retained for caller compatibility; ~10 doc-comments naming the deleted functions were truthed-up. One user-visible structural change (not a regression — a redundant duplicate removed): SHA-256'slength-append → msg-schedulewas, pre-deletion, drawn as BOTH a legacy spine arrow AND the (always-rendered) history-seed aux arrow; only the latter remains — browser-smoked to confirm msg-schedule reads cleanly connected, not stranded. Accepted editor-only regression: clearing all of a round's steps no longer keeps the empty round box connected to the chain (the empty-group-as-node spine lived ininferStateEdges; the port-flow spine has no analogue). Tests: deleteddrop-aux-only-state-edges-asymmetric.test.ts(its whole subject is the removed function) + thereplicate-fanout"dropAuxOnly composes with replication" describe block + the 2 empty-group and 2 non-ported-backward-compat cases (whose premise — a non-port-wired spec needing legacy inference — no longer exists). Six tests written against the pre-deletion duplicate spine were fixed: three whose premise was "AES key-expansion has a state-out" were deleted (key-expansion is aux-only; the invariant survives in synthetic-graph cases + SHA-256 split-wv S2(i)), three retargeted to port-flow reality (history-seed replica count 5→4; the twocollapseGraphround.5 cross-boundary endpoints from leaf-to-leafround.4.add-round-key → round.5to container-sourcedround.4 → round.5;graph-bundle's singleton-bundle invariant moved to SHA-256 split-wv). Gate green — biome clean, tsc 0 errors, 2242 tests / 191 files (−15 / −1 vs Batch 2), build 621.89 KB raw / 183.46 KB gz (≈1.6 KB lighter). No KAT orschemaVersionchange. Batch 4 follows (thestateBefore/stateAfterfield +State/StateShape/BytesStatecollapse). Seedocs/plans/phase-5-legacy-retirement.md"## Slice 5.3e". BytesViewretired (Slice 5.3e, Batch 2 of 4, 2026-05-31). With the Feistel toy gone (Batch 1),BytesView— the before/after byte-pair inspector that has been the test-only-toy fallback ever since Slice 5.1 routed every shipped frame toPortFlowView— had no remaining consumer. Deleted the component (src/ui/components/BytesView.tsx) + its test (tests/bytes-view.test.tsx); collapsedApp.tsx::FrameStateViewfrom theisPortNativeFrameShow/fallback to an unconditional<PortFlowView>(dropping thepreviousRunFrameprop, thebefore/after/prevAfteraccessors, and theBytesView/isPortNativeFrameimports). The BytesView-exclusive CSS goes (.bytes-view,.bytes-row*,.bytes-block-*, and the.bytes-cell.changed/.diff-vs-prev/.bytes-cell-missinghighlight modifiers); the bare.bytes-cellprimitive stays (shared byPortFlowView+StepStrip). Also removed: the inline "compare to previous run" checkbox in the linear frame header — its only renderer wasBytesView'spreviousAfterrow (PortFlowViewhas none), so it had been a no-op for every shipped cipher since 5.1; left visible it would toggle a signal nothing renders. Cross-run diffing still lives in the Run Explorer modal (unchanged). Two subsystems are left dormant, not deleted (matching the dormant-store treatment elsewhere in this batch + flagged for a follow-up): the cell-level provenance overlay (src/ui/provenance/*+provenance-hoverstore + RoundKeyPanel's aux-cell reader —lookupProvenance/setProvenanceHovernow have no caller, but thedes.ts/serpent.tsoutput-byte→input-byte maps are reusable for the enqueued port-aware inspector, Slice 2.9c–e) and thehistory.tsprevious-run toggle exports (useShowPreviousRun/setShowPreviousRun/findPreviousRunFrameByStepId— uncalled but reusable for a future port-level prev-run diff). Live-code comments that namedBytesViewas part of the dispatch were truthed-up. Gate green — biome clean, tsc 0 errors, 2257 tests / 192 files (−8 / −1 = the deleted BytesView test file), build 623.46 KB raw / 183.90 KB gz (≈4.7 KB raw lighter). No KAT orschemaVersionchange. Batches 3–4 follow (graph state-edge inference; then thestateBefore/stateAfter+State/StateShape/BytesStatecollapse). Seedocs/plans/phase-5-legacy-retirement.md"## Slice 5.3e".- Feistel branching scaffolding + the legacy-projection bridge retired (Slice 5.3e, Batch 1 of 4, 2026-05-31). The universal-port thesis (B4, 2026-05-30) made DES port-native — each round a plain
groupofsplit-bytes → …F… → xor → concat, the swap expressed as theconcatargument order — so thefeistel-roundbranching primitive was already unused by every shipped spec. This batch deletes it and everything that hung off it. Gone from the runtime/types/core:FeistelRoundGroup/BranchTrack/CombineKind(types.ts) +core/combine-kinds.ts;runFeistelRound, the synthetic:rejoinframe, and thebranchPathfield + its threading (runtime.ts); the:t…/:rejoin/:swapstepId suffixes (step-id.ts, regex narrowed to/(?::b\d+|:r\d+)+$/); the lifted-legacy projection bridgeliftLegacyExecutor/project/reconstruct/Projection/PortedFrame(port-projection.ts/types.ts) — vacuous once the toy was gone, since it was the only step that ran under both dispatch flags. Gone from the UI: the old toy-only linear components (RejoinFrameView/FeistelTrackContext/FeistelMiniDiagram) + their App dispatch + the StepListFeistelRow/FeistelTrackRow; allfeistel-roundgraph handling (kind:"feistel"containers, thesyntheticrejoin/passthrough nodes,processFeistelRound,feistelPassthroughId,rejoinSwapSourceXSign, the per-track drop gutters +into-track-start+prependChildToTrack+StepLocation.trackIdx); the dead.feistel-*/.rejoin-*/ track-badge CSS (≈440 lines). The toy itself (feistel.toy-add-k@1— the lastlegacy-bearing step — its fixturefeistel-toy.ts, its step file, and its narration registration) is deleted; theNARRATION_NO_OP_ALLOWLISTshrinks 8 → 7. Thefeistel-rounddocument schema was unreleased (DES went port-native before any cipher carried the node into a saved doc — last tag is v0.5.0, and DES +feistel-roundlived entirely under[Unreleased]), so its Zod node was clean-deleted: afeistel-rounddocument now rejects at parse rather than silently skipping a node kind the runtime no longer walks. Kept: the 5.3d port-native Feistel/swap visualization (feistel-shape.ts+ its three self-detecting components, keyed off wiring — untouched); thekind:"legacy"registration variant + the runtime's legacy-dispatch path (registry still normalizes bare-executor registrations);ProjectionMetadata/meta(hybrid-ported key-schedules / padding / aux still project state ↔ ports through it). Coercion + coerce-badge tests retargeted onto a hybrid-ported (meta, nolegacy) no-op so the live coercion mechanism keeps coverage; the container-output-port wiring test retargeted to anot@1leaf via groupseedInput/bodyOutput. ~16 pure-Feistel/toy test files plus the now-vacuous dual-flag frame-parity matrix were removed. Gate green — biome clean, tsc 0 errors, 2265 tests / 193 files (down from 2402 / 210), build 628.20 KB raw / 185.68 KB gz (≈28 KB raw lighter). No KAT orschemaVersionchange. Batches 2–4 follow (BytesView; the graph state-edge inference; then thestateBefore/stateAfter+State/StateShape/BytesStatecollapse). Seedocs/plans/phase-5-legacy-retirement.md"## Slice 5.3e".
- Rejoin chip + passthrough chip clicks no longer error "no frame found" (Phase 6e smoke, 2026-05-20). The asymmetric canonicalization in
findConsumerFramemade the rejoin frame unreachable when the inspector passed its literal synthetic id; the passthrough chip had no frame at all (empty-track passthroughs emit zero frames per runtime.ts:275). Both paths now route throughlookupPassthroughBytesand the canonicalize-both-sides comparison. - State edge between rejoin and next-round track entries now surfaces only the relevant half (Phase 6e smoke, 2026-05-20). The arrow from
round.N:rejointo the next round's L track showsnew_L(4 bytes); to the R track showsnew_R(4 bytes). Pre-fix both edges surfaced the full 8-byte combined state, hiding the swap. - L passthrough chip's outgoing arrow shows 4 bytes (Phase 6e smoke, 2026-05-20). The state edge
round.N:passthrough-0 → round.N:rejoinno longer falls back to the rejoin's combinedstateBefore(8B); it slicesL_infrom the rejoin frame's params using the sharedlookupPassthroughByteshelper that also resolves the chip-click path.
0.5.0 - 2026-05-19
The linear-mode pedagogy release. Every step in every cipher now narrates what it does, per conceptual sub-unit, with byte values inline (per-frame value-prose). The key schedule that used to be a single passthrough frame becomes a navigable per-word stage breakdown (AES RotWord → SubWord → Rcon → XOR chain; Serpent prekey recurrence + bitsliced S-boxes + IP). All round keys render side-by-side as labelled ribbons with the consumed K_i outlined. Hovering an after cell in MatrixView lights up the contributing before cell(s) and (for AddRoundKey) the same-position cell of the consumed round key. Plus: aux replica chips and block chips become draggable with per-node + hard-reset affordances, source-color coding surfaces dataflow at a glance, and a stack of graph-layout polish (state-spine honesty, port-spreading on left/right edges, structural feedback edge synthesis, replica scope-aware placement, arrow bundling) closes out the graph-narrative-and-zoom plan.
-
Aux replica chips and block chips become draggable, with per-node + hard-reset affordances (draggable-replicas plan, 2026-05-19). Pre-fix the only nodes a user could reposition were containers and root-level non-replica leaves; synthetic-id chips (aux replicas
${source}@->${consumer}, block chips${iterateId}@block${i}) were algorithmically placed and immovable. User asked to nudge them to taste — particularly when high-fanout sources crowded the canvas. The advisor flagged the risk that user-positioned replicas can look WORSE than algorithmic placement (memoryfeedback_same_scope_replica_merge_rejected.mdis concrete prior evidence); the user heard this and chose to ship anyway. Pin model is RELATIVE to the chip's natural anchor, not absolute. A newLayoutSpec.relativePositionssidecar map holds{ dx, dy }deltas keyed by synthetic id. The anchor stays implicit in the graph relationship — replicas anchor to their consumer, block chips anchor to their iterate — so when the user later drags the anchor, the chip rides along (auto position is re-derived each layout pass; the delta gets re-applied on top).layoutNodereturns the AUTO box for flow advancement while writing the rendered box atauto + delta, so dragging a chip leaves a hole at its old slot instead of sucking neighbours leftward.setRelativePosition/clearRelativePositionmirror the existingsetNodePosition/setReplicationModesetter pattern, including the byte-stability discipline that drops the spec's entry when the layout becomes entirely empty. Schema is additive (Zod schema updated; existing documents that pre-date the field validate identically).renameLayoutIdspasses relative positions through unchanged — synthetic ids embed real ids inside them and a rename remap would need to parse-and-rewrite the synthetic format; today's only rename caller (the duplicate-round mutator) shifts round numbers (not replica targets) so the orphan-on-rename policy doesn't bite.rescaleAllPositionsrescales relative deltas alongside absolute positions so density flips don't drift pinned chips. Drag wire-up:startNodeDraggains a{ mode: "absolute" | "relative" }opts parameter; the JSX gate selects"relative"for replica + chip nodes. Relative mode capturesrelativePinsMap().get(id)at drag start and accumulates the cursor delta onto it (so subsequent drags compose rather than overwriting). No(0, 0)clamp on the relative path — auto position is already deep inside the canvas, and dragging toward the upper-left is a valid gesture. Two reset surfaces: per-node ↺ glyph (top-left of the chip, hover-revealed via the same opacity cycle as DeleteGlyph/DuplicateGlyph, accent-tinted not danger red because reset is constructive, U+21BA arrow chosen over×to avoid confusion with the delete affordance on non-replica leaves) — click clears the chip's relativePin. Toolbar[reset layout]button next to the zoom-reset —window.confirmprompt; clears positions + relativePositions + collapsedGroups + replicationModes for the active spec; disabled whenhasUserLayout(activeLayout())returns false. Newdocs/plans/draggable-replicas.mdcaptures the design + the advisor-flagged risks + the deferred items (nested-leaf drag, endpoint-pill drag, renameLayoutIds remap for synthetic ids, right-click context menu). Suite at 1385 tests across 122 files (+14 since the prior commit) —tests/layout-store.test.ts(+14 cases for the setter pair, hasUserLayout + rescaleAllPositions + byte-stability),tests/document-roundtrip.test.ts(+2 cases: round-trip + malformed-rejection),tests/draggable-replicas-layout.test.ts(NEW, 6 tests pinning the zero-pin parity guarantee, replica + block-chip delta application, canvas-extent growth, anchor-follows composition with absolute pins, flow-isolation of pinned chips),tests/draggable-replicas-drag.test.tsx(NEW, 8 tests pinning the JSX-level drag wire-up: replica drag writes relativePositions not positions, sub-threshold drag doesn't pin, second drag accumulates, per-node × clears the pin, × is not rendered when no pin exists, toolbar button disabled on a clean spec, toolbar button + confirm clears all four layers, toolbar button + cancel no-ops). Bundle 132.05 KB gzipped (+0.36 KB). Manual browser smoke pending — tests cover the wire-up but the user-visible "dragging a chip feels right and the reset affordances are discoverable" judgment requires the eye; recommend a browser pass on AES-128 ECB +key-expansion: always(the canonical replica-stacked case) before declaring the feature complete. -
Per-frame value-prose for padding + boundary + aux primitives (Phase 3 — final phase — of the step-narration plan, 2026-05-18). Phase 1 shipped the surface and AES round-body narrators; Phase 2 covered Serpent + Speck byte-level; Phase 3 closes the rollout by narrating the 17 remaining cell-shape / aux-shape step types. Allowlist drops to its irreducible 6 entries (4 key-expansion — covered by
<KeyScheduleExplorer />— plus 2 bit-level Serpent linear transforms — byte-level prose would mislead). 6 padding narrators (pkcs7-pad,pkcs7-unpad,zero-pad,zero-unpad,iso7816-4-pad,iso7816-4-unpad) — each emits 1 unit summarizing the padding decision: input length, output length, pad-length N, and pad-byte value. ReadstateBefore.bytes.lengthANDstateAfter.bytes.lengthdirectly (the state-shape changes across the transition; lengths are NOT equal, unlike the AES round body where they always are). Zero-pad special-cases the N=0 no-op label ("no padding — already aligned") so users see the difference from PKCS#7's "always at least one byte" guarantee. Zero-unpad calls out its lossiness ("any 0x00 bytes in the original plaintext are indistinguishable from padding"). ISO 7816-4 unpad locates the 0x80 sentinel at offsetafter.lengthand reports the strip count. 5 boundary narrators (load-block,store-block,split-blocks,concat-blocks,compute-block-count) — each emits 1 conceptual unit.load-block/store-blockexplain the FIPS-197 §3.4 column-major packing as a structural relabel (same bytes, different shape label).split-blocksreads the producedMatrixState[]length fromframe.auxWritten[outBlocksAux]to report the block count concretely (falls back tobefore.length / blockSizewhen the aux write is absent, so a half-wired authoring state still narrates informatively).concat-blocksreads the array fromframe.auxRead[blocksAux]instead.compute-block-countreads the integer count fromframe.auxWritten[countAux]. 6 aux narrators —aux-load(1 unit naming published key + byte sequence, read fromframe.auxWrittenso the executor'sauxName === ""short-circuit path naturally returns null),aux-xor(1 unit showing both operands fromauxRead+ the XOR result fromauxWritten+ the self-inverse note),aux-copy(1 unit naming source/destination with fresh-allocation note when source is a Uint8Array),iv-load(1 unit showing the Uint8Array → MatrixState shape bridge),xor-aux-into-state(1 unit per plan, not 16 — CBC's chain XOR is one pedagogical beat; per-cell detail visible in the MatrixView the same frame renders alongside; samples cell 0 in the prose for a concrete value),state-to-aux(1 unit naming the snapshot key and read shape; explains the deep-clone discipline). All graceful — returnnullwhen required aux is missing or shape is wrong, matching the AESAddRoundKeynarrator's convention, so a half-wired CBC node renders nothing rather than throwing. Contract test updated with three new Phase 3 presence assertion blocks (padding × 6, boundary × 5, aux × 6) PLUS a final pinnedNARRATION_NO_OP_ALLOWLIST.size === 6assertion. The 5 aux narrators declareinput: "any"on their step contract so they would NOT trigger the cell-shape coverage walk — the explicithasNarrationFnassertions are the safety net catching a silent omission, and the size assertion catches a regression that re-adds something to the allowlist instead of registering a narrator. No new step types, no executor changes, no document-schema changes — entirely UI-additive (3 new files undersrc/ui/narration/, 3 new test files, 4 doc / wire-up touch-ups). Bundle should grow by ~3–4 KB gzipped for the 17 narrator bodies + their helpers. Suite gains 24 new tests acrosstests/narration-padding.test.ts(6 + 2 null/wrong-shape — one per padding narrator + 1 zero-pad N=0 case + 1 pkcs7-pad wrong-shape null),tests/narration-boundary.test.ts(7 — one per boundary narrator + load-block wrong-shape null + split-blocks aux-fallback path),tests/narration-aux.test.ts(10 — one per aux narrator + 4 null-return cases covering aux-load unwired / aux-xor missing operand / aux-copy missing source / xor-aux-into-state wrong state shape). Manual browser smoke pending (carry-through from Phases 1+2): AES-128 CBC 2-block scrub theiv-loadframe → expand "aux[chain] := MatrixState(aux[iv])" → see the 16-byte IV bytes inline; scrub thexor-aux-into-stateframe → expand "state ⊕= aux[chain]" → see the cell-0 sample XOR; scrub thestate-to-auxframe → see the snapshot key + shape; toggle byte format → disclosures stay open + byte text updates. The end-to-end allowlist shrinkage 28 → 23 → 6 closes the step-narration plan. -
Per-frame value-prose for Serpent + Speck round body (Phase 2 of the step-narration plan, 2026-05-18). Phase 1 shipped the surface and AES round-body narrators; Phase 2 extends the same per-conceptual-unit
<details>rhythm to all five remaining Serpent + Speck byte-level steps, shrinking the narration allowlist from 28 → 23 entries. Serpent SubBytes narrator → 16 byte units. Each unit's label names the round-specific S-box ("byte 5 (uses S_3)", falling back to"byte 5"whenparams.sboxIndexis missing); prose splits the byte into low + high nibble, shows each S-box lookup separately, and re-assembles the output byte with the format-aware byte rendering. Serpent AddRoundKey narrator → 16 cell units. Same byte-wise XOR shape as the AES counterpart but reading from a flatBytesState(no row/col annotation); prose carries the consumed aux name (e.g.roundKey.7) and the before / K / after byte values. Serpent bit-permutation (IP / FP) narrator → 1 structural-overview + 16 per-output-byte drill = 17 units (the most verbose narrator in the plan; user-chosen design captured inmemory/feedback_bit_level_narration_pattern.md). Structural overview unit's prose explains the operation as a fixed bit shuffle, names the LSB-first-within-byte numbering convention, names theparams.label(typically"IP"or"FP"), and renders input + output side by side as 8-bit binary strings (MSB-on-left, textbook convention —0xABreads as10101011). Per-output-byte drills enumerate the 8 source-bit lookups for that byte (out_bit p ← state_bit S = byte S[bit b] → 0/1), with the taken bit highlighted in the source byte's binary form via a new.bit-highlightedCSS class (accent-coloured background, bold). The assembled output byte renders at the bottom of each drill in binary + hex. This pattern (structural overview + per-output-byte drill with bit-highlighting) is documented as the reusable design for future bit-level steps (SHA-3 θ/ρ/π/χ, Keccak permutations); it's pedagogically honest because each output bit comes from a single input bit. The two remaining bit-level Serpent steps (linear-transform@1,inv-linear-transform@1) stay on the allowlist with an updated rationale: GF(2) XOR over 6–7 input bits per output bit makes byte-level prose misleading, and per-bit prose would explode to 128 disclosures. Speck round (forward) narrator → 3 ARX sub-op units ("1. x ← (ROR(x, α) + y) mod 2^n","2. x ← x ⊕ k_i","3. y ← ROL(y, β) ⊕ x"). Each unit's prose names the algebraic rationale ("the carry chain inside the addition is key-dependent and unpredictable") and shows the intermediate values: ROR result, modular sum, key-XOR result, ROL result, final XOR. Speck round-inverse narrator → 3 inverse sub-op units (the inverse cipher is 2 statements, but separated into 3 disclosures so the mod-2^n subtraction step has room for the wrap explanation). Both Speck narrators decode(x, y)andkviadecodeBlock/decodeWordfromspeck-word-codec.ts— the same codec the executor uses — so the narrator's intermediatex'agrees byte-for-byte with what the executor computed. Tests pin BOTHbyteOrdervalues (be-paperANDle-nsa) by encoding the same logical words through the codec; the prose contains identical word values regardless of byte-layout convention because the codec absorbs the difference at the boundary. Word values render inwordBits/4-digit hex with an0xprefix (Speck literature convention — Speck32/64 words display as0x6574); the byte-format toggle is honored for byte rows where they appear in prose (currently none — word arithmetic is paper-natural in hex regardless), reserved for a future structural overview if needed. Contract test (tests/narration-registry-contract.test.ts) updated to assert Phase 2 presence (Serpent byte-level + bit-permutation + Speck rounds) and the inverted allowlist contract (serpent.bit-permutation@1is now NOT allowlisted; the two remaining bit-level linear transforms still are). The registry's allowlist constant docstring updated to name the new irreducible set: 6 entries (4 key-expansion + 2 bit-level Serpent linear transforms), with the bit-permutation's relocation called out. Reactivity contract preserved: each newNarrationUnit.Proseis aComponent<{fmt: ByteFormat}>(or simply() => …for Speck where word values are hex regardless of toggle) so the existing<Index>+<Dynamic>machinery inStepNarration.tsxkeeps<details>openstate alive across the byte-format toggle and across the 200ms debounced re-run that any plaintext/key edit triggers. New CSS rules:.step-narration-bit-drill(monospace bulleted list for the per-output-byte drill),.step-narration-bit-overview(boxed monospace binary string for structural overview),.step-narration-binary-byte(inline binary digit span),.bit-highlighted(accent-coloured digit). 17 new tests across three files.tests/narration-serpent.test.ts(9, jsdom): SubBytes labels include sboxIndex when present + fall back gracefully when missing + null on wrong-length state; AddRoundKey 16 cell units + aux name in prose + null on missing aux; bit-permutation 17 units (1 overview + 16 drills) with overview prose containing the bit-numbering convention + per-output-byte drill referencing the correct source byte (verified via a synthetic(i + 32) mod 128table where output byte 0 pulls entirely from input byte 4) + null on malformed table.tests/narration-speck.test.ts(6, jsdom): 3-unit emission with labels naming ROR / k_i / ROL; BE-paper prose carries the right intermediate values for the canonical Speck32/64 round-1 vector (x0=0x6574, y0=0x694c, k=0x0100→ ROR=0xe8ca, sum=0x5216, key-XOR=0x5316, ROL=0xa531, final-y=0xf627); LE-NSA prose contains identical word values via the codec; null on size mismatch; inverse direction recovers(x, y)from(x', y')through the same chain in reverse with the mod-2^n subtraction shown explicitly.tests/narration-registry-contract.test.tsgains 2 new presence assertions (Serpent byte-level + bit-permutation; Speck rounds) and flips the bit-permutation allowlist check fromhastofalse. Suite at 1263 tests across 111 files (+17 since Phase 1); bundle 122.44 KB gzipped (+~3 KB for the two narrator modules + CSS). Manual browser smoke pending (Phase 1's pattern): Serpent SubBytes → expand byte 5's disclosure → see both nibble lookups; Serpent bit-permutation → expand "byte 0 of output" drill → see the 8 source-bit lines with one digit highlighted in each source byte's binary form; Speck round (BE-paper AND LE-NSA) → expand each ARX sub-op → see concrete word-hex values for ROR/sum/XOR; toggle byte format on the Serpent narrators → disclosures stay open + byte text updates. Phase 3 still queued (17 padding / boundary / aux primitive narrators), at which point the allowlist shrinks to its irreducible 6. -
Per-frame value-prose for AES round body (Phase 1 of the step-narration plan, 2026-05-18). User loved the per-stage value-prose KeyScheduleExplorer shipped on AES key-expansion ("Rotate left one byte: [0x09, …] → [0xcf, …]. The leading byte (0x09) moves to position 3…") and asked for the same verbosity on every other step in every cipher — and as a requirement going forward. Phase 1 ships the surface: a new
<StepNarration />panel mounts in linear mode between the FrameStateView/KeyScheduleExplorer block and the RoundKeyPanel, and renders one collapsible<details>per conceptual sub-unit of the current step's transformation. AES SubBytes → 16 byte units ("byte 5 (row 1, col 1)"→ expand for"S-box lookup at position 5: S[71] = a3. Substitution is byte-local — only this cell's value changes; (row, col) are preserved."). AES ShiftRows → 4 row units with the actual row's before/after byte sequence and rotation amount. AES MixColumns → 4 column units, each containing a bulleted GF(2^8) dot-product breakdown per output row with non-1 coefficients labelled ("row 0: 0x02·a4 ⊕ 0x03·6f ⊕ 3c ⊕ 8b = e7"). AES AddRoundKey → 16 cell units each naming the consumed aux ("a4 ⊕ roundKey.3[5] = 1c = b8. XOR is self-inverse — applying the same key bit twice cancels."). Parallel registry atsrc/ui/narration/(registry + index + per-cipher fns) — same architectural shape assrc/ui/provenance/: narration is a pedagogy concern, kept separate from runtime (StepDocumentation) and navigation (provenance). A runtime refactor doesn't reach into narration code, and vice versa. Param-driven: the genericshift-rows@1andmix-columns@1narrators handle forward AND inverse directions viaparams.shifts/params.matrix, so ONE registration per step type covers decrypt-mode without separate forward/inverse fns. Reactivity contract: eachNarrationUnit.Proseis aComponent<{fmt: ByteFormat}>, NOT bare JSX — this keeps the<details>element identity stable across the byte-format toggle, so expanded disclosures stay open while their byte text surgically updates. Returning bare JSX would rebuild the unit array on every format change and snap every open<details>shut (the trap behind Phase 1's design choice; documented in the registry's module header AND in the newStepNarration.tsx). Made a requirement for future ciphers: new contract testtests/narration-registry-contract.test.tsmirrorstests/provenance-registry-contract.test.tsliterally — walksbuildDefaultRegistry().types(), filters toshapeContract.input ∈ {matrix4x4-bytes, bytes}, and asserts every shipped step type is EITHER narration-registered OR sits onNARRATION_NO_OP_ALLOWLISTwith a rationale. Inverse error case (both registered AND allowlisted) also fails. New cipher additions cannot ship without explicitly choosing narration or allowlist.src/steps/CLAUDE.mdchecklist gained a new step 7a documenting the requirement. Allowlist (28 entries in Phase 1, shrinks to 7 by Phase 3): 4 key-expansion step types (already covered by<KeyScheduleExplorer />— richer surface), 3 bit-level Serpent transforms (byte-level prose would be misleading; same rationale asPROVENANCE_NO_OP_ALLOWLIST), 4 Serpent/Speck byte-level steps + 17 padding/boundary/aux steps marked "Phase 2 will move OFF" / "Phase 3 will move OFF" so the allowlist reads as a roadmap of pending work. Shared<ByteRow>+formatBytesextracted fromKeyScheduleExplorer.tsxtosrc/ui/components/byte-row.tsxso every per-frame prose surface in linear mode renders byte sequences with one visual rhythm. CSS class names parallel.key-schedule-aes-stage*for stylistic consistency. Mount discipline:<StepNarration>renders nothing when the step type is on the allowlist OR the narrator returns null (defensive — wrong-shape params, missing aux), so adding the component to App.tsx doesn't change the DOM for key-expansion / padding / aux frames. 30 new tests across three files.tests/narration-registry-contract.test.ts(5) — registry contract, AES round-body presence, key-expansion + bit-level Serpent allowlist checks.tests/narration-aes.test.ts(8, jsdom) — per-narrator unit/label assertions + Prose-rendered prose text assertions against FIPS-197-style frames for all 4 fns (forward + inverse direction for ShiftRows + MixColumns) + null-return cases (wrong state shape, missing aux).tests/step-narration.test.tsx(3, jsdom) — 16 disclosures render for SubBytes,<details>open-state survives byte-format toggle (the reactivity contract pinned end-to-end), allowlisted step types render nothing. Suite at 1245 tests across 109 files (+30 since Phase 3 provenance). Bundle 118.57 KB gzipped (+~3 KB for the narration module + Phase 1 narrators + component + CSS). Manual browser smoke pending: AES-128 round-1 SubBytes/ShiftRows/MixColumns/AddRoundKey → expand a disclosure → text reads correctly → toggle byte format → disclosure stays open + text updates → scrub to key-expansion frame → narration suppressed. Phases 2 (Serpent + Speck byte-level: 4 narrators) and 3 (padding + boundary + aux primitives: 17 narrators) queued — each shrinks the allowlist toward the irreducible 7-entry post-Phase-3 set. -
Cell-level provenance hover overlay (Phase 3 — final phase — of the linear-mode pedagogy plan, 2026-05-18). Hovering an
aftercell in MatrixView now lights up the contributing cell(s) inbefore(and in RoundKeyPanel for AddRoundKey frames). Surfaces what each step actually does in a way the existing "before grid, after grid" pair couldn't: SubBytes shows the same-position S-box swap, ShiftRows points back at the shifted-position source, MixColumns highlights all four same-column contributors with their GF(2^8) coefficient labels (× 0x02,× 0x03, …), and AddRoundKey lights up both the same-position before cell AND the same-position cell of the consumedK_iin the round-key panel — the user can finally see the XOR coupling. Parallel registry atsrc/ui/provenance/(registry + per-cipher fns + index-load side-effect), NOT a new field onStepDocumentation— provenance is a navigation concern, doc blocks are pedagogy prose, and conflating them would force runtime-only refactors into pedagogy code. Param-driven (advisor-flagged): the genericshift-rows@1andmix-columns@1step types handle forward AND inverse directions viaparams.shifts/params.matrix, so ONE provenance fn per step type covers decrypt-mode hover without separate forward/inverse registrations. The fn reads the param value at call time and applies the appropriate formula. Contract test (tests/provenance-registry-contract.test.ts) walks the core step registry and asserts every shipped matrix-shape OR bytes-shape step type EITHER has a provenance fn registered OR sits on the explicitPROVENANCE_NO_OP_ALLOWLIST— turns "did I cover all the cases" from a manual audit into a CI gate. A new cipher addition with matrix-shape steps will fail the test until either a fn is wired or an explicit "we considered this and it's not worth a fn" decision lands on the allowlist with a rationale. Coverage in v1: AES SubBytes / ShiftRows / MixColumns / AddRoundKey + Serpent AddRoundKey / SubBytes. Allowlist covers bit-level steps (serpent.linear-transform@1and inverse,serpent.bit-permutation@1— each output bit derives from 6–7 input bits; byte-approximation would highlight nearly every input byte and read as "everything contributes to everything"), aux primitives + boundary steps (load-block, store-block, split-blocks, concat-blocks, aux-load/xor/copy, iv-load, xor-aux-into-state, state-to-aux, compute-block-count), padding family (pkcs7/zero/iso7816-4 pad+unpad), key-expansion (state pass-through), and Speck rounds (ARX over a 4-byte word — byte-level provenance muddled by modular arithmetic; revisit when BytesView gets hover). Hover wiring: newuseProvenanceHover()signal store carries{ stepId, afterCellIndex, sources }precomputed at set-time so consumers don't all re-runlookupProvenance; MatrixView'saftergrid sets onmouseEnterand clears onmouseLeave; bothMatrixView(before grid) ANDRoundKeyPanel(TinyMatrix subview) subscribe and apply.provenance-sourceoutline to matching cells. stepId-gated so a stale hover from a prior frame can't paint cells after a scrub. Highlight precedence declared per the plan and enforced by single-class composition inclassList(no!important, no layered outlines):.provenance-source>.round-key-cell-current>.diff-vs-prev>.changed. CSS class uses--accent-success(green) — distinct from the warm-orange.changedand accent-blue.diff-vs-prevso the user can tell which signal lit a cell. Bug caught during development (CLAUDE.md gotcha verified the hard way): Solid<For>callbacks aren't reactive scopes — a local constconst isProvSource = !!props.x?.has(i)captures at first render and never updates. Three sites refactored to inline the prop read into JSX (MatrixView.Grid,TinyMatrix,RoundKeyPanel.SequenceRibbon). 27 new tests across four files:tests/provenance-registry-contract.test.ts(5 tests) — registry contract enforcement, AES + Serpent fn registration check, bit-level allowlist check, inverse-error case (a step type accidentally BOTH registered AND allowlisted fails loudly).tests/provenance-aes.test.ts(12 tests) — pure-function pins for all four AES fns including forward[0,1,2,3]AND inverse[0,3,2,1]ShiftRows decompositions, forward AES_MIX_MATRIX AND inverse AES_INV_MIX_MATRIX MixColumns coefficient labels, AddRoundKey's both-sources rule, defensive fallback when auxRead is empty or multi-aux.tests/provenance-serpent.test.ts(4 tests) — Serpent AddRoundKey + SubBytes same shape as AES counterparts.tests/provenance-hover-integration.test.tsx(5 tests) — end-to-end against a real AES-128 trace: SubBytes hover→same-index before highlight, ShiftRows hover→shifted-index before highlight, precedence (after-cell never gets.provenance-source; before-cell never gets.changed), AddRoundKey cross-component RoundKeyPanel highlight, stepId gate (hover for a different frame's stepId clears highlights). Suite at 1217 tests across 106 files (+27 since Phase 2). Bundle gzipped: ~115 KB (+~1 KB). BytesView hover wiring deferred to a follow-up — Phase 3 v1 ships MatrixView only; Serpent's per-step BytesView frames will get hover via a small extension that reuses the existing registry. Manual browser smoke still pending per the plan's verification: AES-128 round-1 SubBytes/ShiftRows/MixColumns/AddRoundKey hover paths + decrypt-mode reverse-direction ShiftRows hover. -
Key-schedule explorer panel for AES and Serpent key-expansion frames (Phase 2 of the linear-mode pedagogy plan, 2026-05-18). AES and Serpent key-expansion executors collapsed their entire internal machinery into one trace frame whose state passes through unchanged — the FrameStateView for those frames rendered the input matrix on both before/after sides (useless), while the
RotWord → SubWord → Rcon → XORchain (AES) and the 132-word ROL-XOR prekey recurrence + bitsliced S-box + IP pipeline (Serpent) lived entirely inside the executor. Phase 2 surfaces those internals via a new<KeyScheduleExplorer />component that takes the FrameStateView slot whenever the current frame's stepType is in the key-expansion family. Plan trade-off (decided in~/.claude/plans/immutable-doodling-quokka.md): re-simulate the algorithms in viz-only files (src/ui/key-schedule-sim/{aes,serpent}.ts) rather than refactor the executor contract to yield sub-frames — keeps the runtime(state, params, ctx) → stateshape intact at the cost of code duplication. The cost is mitigated by parity tests that pin the simulator byte-for-byte against the executor for every shipped key configuration. AES sim emits per-word stages with discriminated-union kinds (init/rotword/subword/rcon-xor/extra-subword/xor-prev); the AES-256Nk > 6 && i % Nk === 4extra-SubWord branch is structurally distinguished by a separate kind so the UI can label it as "the AES-256 wrinkle." Serpent sim emits flat stages with kindspad/prekey-init/prekey-recurrence/sbox-group/ip— 200 total stages for Serpent-128 (1 + 1 + 132 + 33 + 33). New step-type → simulator registry (src/ui/key-schedule-sim/registry.ts) routesaes.key-expansion@1,aes.key-expansion@2, andserpent.key-expansion@1to their per-cipher sims; the AES variants share one simulator since@2's algorithm body is identical (only the input-validation envelope differs, and the simulator accepts both). Inline docking (advisor-recommended; user-picked over modal): the explorer replaces the FrameStateView in place via a<Show when={isKeyExpansionStepType(frame().stepType)}>gate, so the slot is free (key-expansion's FrameStateView was rendering matrix-on-both-sides anyway). UI dispatch is component-internal:kind: "aes"branch renders a per-word swimlane (44/52/60 word rows for AES-128/192/256) with chain-start words emphasized via accent-bordered left edge + "chain" badge, AES-256 mid-Nk words emphasized via dashed-accent variant + "Nk>6 mid" badge.kind: "serpent"branch renders four<details>sections (pad, prekey-init, recurrence, sbox-groups+IP); the 132-row recurrence section is collapsed by default since it dominates the trace structurally but the more pedagogically valuable bits are the surrounding stages. Per-cipher CSS scoped under.key-schedule-explorerwith amax-height: 70vh; overflow-y: autocap so a fully-expanded explorer never pushes ParamEditor below the fold. Graceful failure modes: missing or wrong-shape master key inframe.auxRead(e.g. user pointedkeyAuxNameat a non-existent aux entry) returns null and the component renders an inline error stub rather than crashing; same for malformedframe.params(sbox not 256 entries, rounds < 1, etc.). 35 new tests across three files.tests/aes-key-schedule-sim-parity.test.ts(18 tests): parity vskeyExpansion(@1) andkeyExpansionV2(@2) for FIPS-197 Appendix A canonical keys at all three key sizes + all-zero keys (structured-vs-zero key regression guard for the SubBytes-on-zero case whereS[0x00] = 0x63and a buggy simulator would diverge invisibly) +@2relaxed-rounds (rounds = 11,rounds = 15with heavy xtime Rcon extension) + truncated-Rcon-seed parity (only indices 0+1 seeded, both sides must agree on extension) + structural invariants (correct word count, chain-start stage sequence is exactly[rotword, subword, rcon-xor, xor-prev], AES-256 mid-Nk stage sequence is[extra-subword, xor-prev], plain words have exactly onexor-prevstage, throws on invalid inputs).tests/serpent-key-schedule-sim-parity.test.ts(10 tests): parity vsserpentKeyExpansionfor Serpent-128/192/256 canonical keys + all-zero keys for each (regression guard for the 0x01 padding-marker byte — at all-zero input the marker is the only non-zero byte in the padded buffer, so a misplaced marker would cause prekey[N] to diverge into garbage) + structural invariants (200 stages in1 + 1 + 132 + 33 + 33order, pad-byte index 16/24/-1 for the three key sizes, the trap-prone(35 - i) mod 8S-box index walks down with wraparound correctly across all 33 groups, throws on invalid lengths).tests/key-schedule-explorer.test.tsx(7 tests, jsdom): pins UI dispatch — AES branch renders 44 words and 10 chain-start rows for AES-128 with the "chain" badge present on each, byte-format toggle re-renders cell text, malformed frame renders the inline error stub without crashing; Serpent branch renders 4<details>sections, 132 recurrence rows, 33 sbox-group rows. Suite at 1190 tests across 102 files (+35 since Phase 1's commit). Bundle 114.03 KB gzipped (+~3.7 KB: ~2 KB for the two simulator files, ~1 KB for the component, ~0.7 KB CSS). Manual browser smoke still pending per the plan's verification section; recommend a quick browser pass on AES-128 / AES-256 (Nk>6branch firing) / Serpent-128 key-expansion frames before declaring Phase 2 visually complete. -
Round-key side-by-side panel in linear mode (Phase 1 of the linear-mode pedagogy plan, 2026-05-18). Previously the UI showed only the single round key the current AddRoundKey frame was consuming — the other Nr keys of the schedule lived in
trace.finalAuxbut were invisible to the user. Structural patterns spanning the whole schedule (Rcon's column injection, the chain XORW[4i] = W[4i-4] ⊕ f(W[4i-1]), AES-256'sNk > 6mid-word SubWord branch, Serpent's S-box-walks-down-with-wraparound indexing) were therefore impossible to see at a glance. New<RoundKeyPanel />injects in linear mode between<StepDescription />and<ParamEditor />, scanstrace.finalAuxfor entries matchingprefix.Nwhose values areUint8Array, and renders each qualifying sequence as a labelled ribbon: AES → 11/13/15 16-byte cells via TinyMatrix; Serpent → 33 cells same path; Speck32/64 → 22 two-byte strips via abyteLength !== 16fallback to a 1-row layout (no bespoke per-cipher logic). TheK_iwhose canonical name (${prefix}.${index}) appears in the current frame'sauxReadmap gets a.round-key-cell-currentoutline — so during AddRoundKey at round 3, K_3 lights up; during key-expansion (write-only) nothing highlights. Cipher-agnostic by construction (noisAesCiphergate) — a future cipher that publishes its schedule under any prefix automatically renders. Hidden via<Show when={sequences().length > 0}>when no qualifying sequences exist (very-early boot, hypothetical no-schedule cipher) so no orphan empty container appears in the DOM. Highlight precedence pre-declared in CSS comments for future phases of the same plan:.provenance-source(phase 3 — hover) >.round-key-cell-current(this slice) >.round-key-cell-diff(future toggle) >.diff-vs-prev>.changed. NewuseTraceFinalAux()accessor insrc/ui/stores/trace.tsmirrors theuseFrameIndex/useHistoryconvention — closes overversion()for reactive invalidation across re-runs; phases 2 and 3 will consume it too. 11 new tests intests/round-key-panel.test.tsx(@vitest-environment jsdom): 6 cover the puredetectRoundKeySequencesaux-walk (empty, single-entry-rejected, multi-prefix-sorted, mixed-length-rejected, non-Uint8Array-filtered, multi-sequence-alpha-sorted); 5 cover the component against a real AES-128 trace (11 cells in one ribbon with "11 × 16B" shape label, current-outline lands on the correct K_i for aroundKey.3consumer, no outline for a frame with empty auxRead, byte-format toggle re-renders the master-key first cell00→0, panel hidden when no trace seeded). Bundle 110.32 KB gzipped (+~2.5 KB from the prior commit — component code + CSS). Suite at 1151 tests across 99 files. No runtime, executor, or document-schema changes — entirely UI-additive.
- Cross-iteration feedback edges route OVER THE TOP in horizontal regime — separating the feedback head from the forward spine departure (2026-05-18, cbc-xor crowding fix). Diagnosed during the manual smoke after the z-order fix (commit
80cf29a) shipped: in CBC baseline (replicates OFF) the feedback edgecbc-snapshot → cbc-xorplanted its arrowhead oncbc-xor's RIGHT edge at right-center — the SAME point where the forward state spinecbc-xor → add-round-keyDEPARTS. Mechanism: source and target are same-row siblings inside the iterate body,vertOverlap=trueforcesuseVertical=false, the horizontal regime exits source-left / enters target-right, andconsumerPortOffsetshort-circuits to 0 for cbc-xor's single-incoming bucket. Both head and tail at one tiny region of the box edge. Fix is structural, not a within-edge nudge (per the 2026-05-18 design decision captured inproject_cbc_xor_arrowhead_crowding): when an edge becomes crowded, SWITCH the incoming arrowhead to a different edge entirely. New third path branch inEdgePath'sgeom()memo — gated unconditionally onprops.isFeedback && !useVertical— exits source's TOP edge, arcs upward with a vertical-control-point cubic Bezier, enters target's TOP edge from above (so the marker tangent points DOWN into the box). Pull height =max(28, |tx - sx| * 0.35)so longer overhead loops scale visibly with horizontal span. Unconditional, not conditional (user picked viaAskUserQuestion): every shipped feedback edge today and every planned cross-iteration mode (OFB / CFB) has a forward spine sibling on its consumer, so "crowded" is always true; the unconditional rule generalizes without re-tuning AND reinforces the dashed "loops to next iteration" narrative the styling already conveys. Scope: replicates OFF. Always-replicate cases (cbc-snapshot / iv-load / split-blocks set to"always") introduce additional density from replica chip POSITIONING — separate replica-placement work, not addressed here. Bundle midpoint handled correctly: the×Npill anchor atLABEL_T_ANCHOR = 0.25uses the actual on-curve Bezier point at t=0.25 rather than the chord-line interpolationperpendicularLabelMidpointreturns — chord-line at t=0.25 sits BELOW the arc midpoint for an overhead loop, pill would float in dead space; on-curve point sits visually on the shaft. Today's CBC feedback edge is a singleton (N=1) so the label doesn't render, but future stream-cipher / two-aux feedback shapes will get correct placement. Forward spine is unaffected (asymmetric fix): theisFeedbackgate keeps non-feedback edges on the horizontal regime, so the state spinecbc-xor → add-round-keystill departs the right edge — verified by a dedicated test assertion. Vertical-regime feedback edges (none today, hypothetical future): covered by the existinguseVerticalearly-return — they already exit top/bottom and don't crowd the right edge. Newtests/graph-view-feedback-edge-overhead.test.tsx(3 tests): (1) encrypt CBC — feedback path's M start sits on cbc-snapshot's top edge AND its C-end sits just above cbc-xor's top edge, and explicitly NOT near the right edge; (2) the state-kind spine departing cbc-xor still starts at cbc-xor's right-edge midline, pinning the asymmetric-fix contract; (3) decrypt CBC — direction-agnostic re-assertion (feedback path's start lands on SOME leaf's top edge AND its end sits ~6 px above SOME leaf's top edge; endpoint ids looked up by box geometry so the test is robust to internalaes-cbc-builderrenames). Decrypt coverage added at advisor's flag — fix is direction-agnostic (gated purely onprops.isFeedback), but the contract is now pinned both ways. Suite at 1303 tests across 118 files; bundle ~109.62 KB gzipped. Manual browser smoke deferred (remote-session limitation): tests verify path coordinates (M start on source top edge, C end ~6 px above target top edge) but the user-visible "clean loop above the chip row, dashed styling intact, arrowhead orients correctly into the top edge" judgment requires the eye; recommend a quick browser pass on AES-128 CBC before declaring the visual fix complete. - Port-spreading reaches the consumer's LEFT / RIGHT edge in the horizontal regime (2026-05-18 follow-up to port-spreading-consumer-head). The top-edge spread the user already approved on AES-128 ECB with collapsed blocks was the vertical-regime branch in
EdgePath(source above/below consumer); the sametargetXOffsetvalue also already applied to the consumer's BOTTOM edge by virtue of being computed before thedownwardbranch pickstEdge. The horizontal regime (source to the left or right of the consumer) hard-codedty = toCy, so a fan-IN of N sources at the same consumer collapsed onto the vertical midpoint of the consumer's LEFT or RIGHT edge with no separation. NewEdgePath.targetYOffsetprop, applied only in the horizontal regime, clamped toto.h / 2 − 4(mirrors the existing x-clamp pattern). Call site computestargetYOffsetfrom the sameconsumerPortOffsethelper with a height-awareportGap = Math.max(4, Math.round(LEAF_H / 4)) ≈ 7 px— reusing the vertical regime'sLEAF_W / 10 ≈ 13 pxwould exceedLEAF_H / 2 = 14and pin against the clamp on leaf-shaped consumers. Fixed (not scaled to actualto.h) by user choice — predictable visual rule across consumers; tall chip-row containers still get more spread room implicitly through theto.h-derived clamp. Slot ordering inherits the existingConsumerPortAssignmentrow-first comparator, so slot 0 (lowest source row) lands at the TOP of the consumer's left edge — same comparator the user already approved for top-edge spread; if it produces tangles on side-entries in some future canvas, that's a separate y-ordering refinement. No new tests:consumerPortOffsetis already pinned by 16 tests intests/graph-view-port-spreading.test.ts; this change consumes the same value on the y-axis through a small, direct branch. Suite at 1140 tests across 98 files; bundle 109.58 KB gzipped (+0.07 KB). - Replica scope-aware layout (narrow) + consumer-head slot ordering by source x (2026-05-17 follow-ups to Slice 7b). Two visual fixes the user surfaced from a screenshot after Slice 7b shipped. (1) Spine replica flows at source's old spec slot, not stacked above the consumer. Pre-fix: in the canonical bad case (AES-128 ECB +
key-expansion: always+ collapsed iterate) ALL replicas of a single source — thekey-expansion@->split-blocksstate-spine replica AND everykey-expansion@->{chip-N}aux fan-out replica — lifted above their consumer at the same y, reading as one column at the canvas top even though spine and aux are different dataflows. Root cause:replicateHighFanoutSourcesassigned every replica'scontainerPathand insertion parent to the consumer's, so the spine replica got lifted above its consumer along with the aux ones, ANDbuildReplicaPlacementpulled all replicas intoisReplicafor the lift treatment. Fix: newisSpineReplica?: truefield onGraphNode;replicateHighFanoutSourcesflags the single(source, spineSuccessor)replica per fully-replicated source and uses SOURCE's containerPath + SOURCE's parent insertion key (rather than consumer's) for it. For shipped ciphers source's parent === spineSuccessor's parent, so the splice-before-spineSuccessor logic still lands the spine replica at source's old spec slot.buildReplicaPlacementthen excludes spine-replicas fromisReplica, so layout flows them as regular leaves at the source's old slot. Render-boundary checks (node.replicaOf !== undefined) unchanged — spine replica still gets chip styling and click-routes to the source's trace frame. Narrow interpretation picked viaAskUserQuestion(broad would relocate ALL replicas to source's parent — defer until a future cipher demands it). (2) Port-spread comparator sorts by source visual x when defined. Pre-fix: atecb-blocks's top-edge withsplit-blocksLEFT ofcompute-block-countin the spec, the aux arrow from split-blocks landed at a RIGHT-of-compute-block-count slot — both non-replicas hadrowOfSource = Infinityso the comparator fell through to alphabeticaledge.from('c' < 's' → compute-block-count slot 0), unrelated to canvas x. Fix:buildConsumerPortAssignmenttakes an optionalsourceXOf?: (canonicalSource: string) => number | undefinedcallback; comparator uses it as PRIMARY key when defined for both edges, falls through to today's row → from → auxKey → kind chain otherwise. Renderer wiressourceXOffrom a newbaseLayoutmemo (layoutRootwith no port-assignment input — non-recursive because the only port-assignment consumer in layout is the single-replica x-shift, which can't flip the relative left/right order of distinct sources). Advisor flagged a potential production-only regression — the spine-replica inherits the source'sstepType(which hasshapeContract.input === "any"for every shipped case where the fix matters), andauxOnlyRootIdsclassifies root leaves by that exact criterion. Verified empirically with a new production-path component test:auxOnlyRootIdsiteratesspec().steps(NOT the post-replication graph), so replica ids with the@->infix never match → spine-replica correctly NOT classified as aux-only. 8 new tests + 3 updated existing tests: 3 intests/graph-view-port-spreading.test.ts(sourceXOf overrides alphabetical, undefined-on-one-edge falls through, equal-x falls through); 3 intests/replicate-fanout.test.ts(one-spine-replica-per-fully-replicated-source flagged; spine-replica's containerPath matches source's; scope-aware synthetic where source's parent ≠ consumer's parent pins the structural contract); 1 intests/graph-view-replica-gutter.test.ts(aux-fan-out replica with iterate consumer still anchors above first body child — Slice-2 invariant intact for non-spine replicas); 1 intests/graph-view.test.tsx(production-path: spine-replica's renderedyequals consumer'sy, AND itsxis strictly less). The three updated tests intests/graph-view-replica-gutter.test.tsinvert from "spine replica is lifted above consumer" to "spine replica flows at spine row at source's old slot" — pre-fix invariants no longer hold by design. Suite at 1136 tests across 98 files; bundle 109.48 KB gzipped. Manual browser smoke deferred (remote-session limitation) — tests verify layout positions and component DOM, but the user-visible "spine and aux read as distinct dataflows" judgment requires the eye; recommend a quick browser pass on AES-128 ECB +key-expansion: always+ collapsed iterate before declaring the visual fix complete.
- High-fanout source replication now fans STATE edges too, and the original chip is removed entirely from the graph (Slice 7b of the graph-narrative-and-zoom plan, 2026-05-17). Pre-7b
replicateHighFanoutSourcesskipped state-kind edges (if (e.kind !== "aux") continue), leaving the original source on the spine while replicas duplicated only its aux fan-out — visually three copies of one chip when a user set a source to"always". Slice 7b drops the kind filter so every outgoing edge (state + aux + feedback) of a qualifying source gets replica routing, and the original source is filtered out ofnodes,rootIds, AND every container'schildIds(the splice-then-strip ordering means each replica still lands in the original consumer's pre-replication slot — the cascade case A and B bothalwaysproduces[..., A@->B, B@->C, C, ...]cleanly). Incoming edges whosetois a fully-replicated source are redirected to that source's "spine entry" replica, defined as the replica generated for the source's first outgoing state edge target. Fallback: when the only state-out was suppressed by the iterate-boundary rule ininferStateEdges(e.g.compute-block-count → ecb-blocksunder AES-128 ECB), the lookup falls back to the first outgoing aux edge target — covered by a synthetic-fixture test pinning the post-suppressioncompute-block-count → alwaysshape. GraphView'spinnedMapmemo split intorawPinnedMap(raw map for drag/persistence) and orphan-filteredpinnedMap(declared aftergraph()to avoid Solid's createMemo TDZ on the lazy-but-eagerly-first-evaluated body); the filter ignores pins whose stepId isn't in the post-replication graph, with a one-timeconsole.debugper orphan id in DEV builds (import.meta.env.DEV-gated). localStorage is preserved so flipping the override back to"auto"/"never"restores both the original chip AND its pinned position in one step. Linear-list sidebar click-to-scrub continues to work via the trace (not via this graph); click-to-scrub on any replica still reaches the source frame via the existingreplicaOffield. Test impact: two pre-7b assertions intests/graph-view-replica-gutter.test.ts(the "original key-expansion box exists alongside its replica" invariants) inverted — by design those invariants no longer hold post-7b. New Slice 7b coverage intests/replicate-fanout.test.ts(6 tests: state-out alongside aux replication, spine-successor fallback to first aux consumer, incoming state edge redirects to first-replica, source removed from rootIds + parent container childIds, spine reaches the last root step through the replica chain, below-threshold baseline still identity-short-circuits) +tests/graph-bundle.test.ts(1 test: replica-sourced state bundles remain singletons with no×Ndecoration — pins the invariant that unique syntheticfromids prevent bundle-key collision). Suite at 1127 tests across 98 files; bundle ~109.33 KB gzipped. Manual browser smoke deferred (remote-session limitation): the Slice 1endpointLabelsmemo and Slice 2 iterate first-non-replica-child anchor weren't audited under the new state-replica conditions; tests didn't flag shifts but visual drift may surface as a follow-up "Slice 7b polish" commit. - Iterate boundary no longer carries the state spine — the white spine arrow stops at the leaf BEFORE an aux-mediated iterate and resumes at the leaf AFTER it. Pedagogically the previous behavior was misleading: in AES-128 ECB the spine threaded
compute-block-count → ecb-blocks → concat-blocks, but the runtime overwritesstatefromaux[blocksFromAux][i]at iteration entry and publishes the per-iteration output intoaux[outBlocksAux]at exit — so the state value at the iterate's boundary is dead. Clicking the previously-renderedcompute-block-count → ecb-blocksstate edge surfaced the plaintext bytes (compute-block-count's passthrough stateAfter), confusing users: "the plaintext isn't passed forward as plaintext in the cipher; it is passed already split into blocks." Right.inferStateEdgesnow tracks every iterate id and suppresses any state edge whose endpoint is one — noprev→iter, noiter→next, no bridgingprev→next. The aux arrows (input-blockscoming in,output-blocksgoing out) ARE the honest depiction of the handoff; the spine has nothing to add. AES-128-ECB pre-collapse drops from 43 to 41 state edges (2 top-scope pairs + 39 body); post-collapse drops from 4 to 2. Singleton-block ciphers (vanilla AES-128, Speck, Serpent) have no iterate, so their spines are unchanged. The four existing aux-graph-derivation tests that pinned the oldprev → iter → nextshape now pin the suppression invariant; one new in-test assertion explicitly checks that no bridgingprev→nextedge appears either (i.e. we're not just relocating the arrow, we're admitting the spine doesn't reach across the boundary). Feistel future: a non-aux-mediated iterate would carry meaningful spine information across the boundary and need an opt-out flag; today's rule is unconditional because every shipped iterate is aux-mediated. - Canvas margin bumped 24 → 60, replica-stack inter-row gap bumped 24 → 48, single-replica chip shifts by its bundle's port-slot offset for vertical arrows, and the bundle
×Npill anchors near the arrow START instead of the midpoint (arrow-bundling visual polish, 2026-05-17). Manual smoke iterated through four crowding/legibility complaints. (1) "All items in the canvas should spawn initially a little bit lower" — bumpedCANVAS_MARGINfrom 24 → 44 in the bundling commit; second smoke clarified "more space to the top" → bumped 44 → 60. (2) "More space between replicates will be nice, because the arrow counter is almost the same size as the space between the replicates" — the bundle×Npill rendered at each arrow's midpoint was crowding adjacent rows when replicas stacked vertically (the inter-row pitch wasLEAF_H + FLOW_GAP = 28 + 24 = 52 px, with the visible gap of 24 px ≈ the pill's ~32 px width). New constantBASE_REPLICA_STACK_GAP = 48(and matchingLayoutConstants.REPLICA_STACK_GAPfield) replacesFLOW_GAPat the two replica-stack sites (replicaSlotPosition's y formula andreplicaLiftHeight's row contribution);FLOW_GAPkeeps its 24 px value at all other sites (sibling stacking inside groups, where no bundle pill sits in the gap). Tests ingraph-view-replica-placement.test.tsandgraph-view-replica-gutter.test.tsupdated to consumeconsts.REPLICA_STACK_GAPso future tuning rounds don't need test churn. (3) "Arrow in single replicate is still not near vertical" — when a sole replica chip feeds a consumer that ALSO has other (non-replica) bundles, the consumer-port-spread gives the chip's bundle a non-center slot, so the chip-at-anchorXproduces a diagonal arrow into the target-at-anchorX + slotOffset. Fix:layoutRootnow takes an optionalportAssignmentparameter and, when the consumer's chip-count is exactly 1, looks up the chip's bundle representative edge (first edge ingraph.edgesmatching the chip → consumer pair) and shifts the chip's x byconsumerPortOffset(repEdge, ports, portGap). Result: chip.center.x = arrow target x → vertical arrow. Multi-replica consumers (≥ 2 chips) deliberately skip the shift to preserve the column-stacked visual ("if we have 2 or 3 replicates it look good"). TheportAssignmentarg is optional — tests and pre-bundle callers can omit it and get byte-identical layout (layoutmemo had to be relocated belowportAssignmentinGraphView.tsxto avoid TDZ on the lazy memo body's create-time first-run).ConsumerPortAssignmentis now exported as a type so test files can pass real assignments tolayoutRoot. 3 new tests intests/graph-view-replica-placement.test.tspin: (a) the headline shift on a single-replica + two-plain-leaves consumer; (b) no shift when ≥ 2 chips share a consumer; (c) backward-compat whenportAssignmentis omitted. (4) "It would be better if the arrow counter is situated near arrow start, not in the middle, because now sometimes it can get obstructed by other replicates" — bundle pill anchor moves from t = 0.5 (geometric midpoint, which intersected upper-row replica boxes for row-1+ chips) to t = 0.25 (one quarter along the arrow from source), via a new module constantLABEL_T_ANCHOR = 0.25consumed insideperpendicularLabelMidpoint. Pill stays visually grouped with the chip it belongs to and out of the intervening replica columns.
- Bundled aux edges: N parallel arrows between the same endpoints collapse to one thicker arrow with a
×Nlabel (arrow-bundling plan, 2026-05-17). The motivating manual smoke from the prior port-spreading session: AES-128 ECB + iterateecb-blockscollapsed +key-expansionset toalwaysreplicate produced 11 individual aux arrows from the replica chip into the iterate, one per consumed round key. The visual is semantically correct (11 distinctauxKeys flowing) but the user's reaction — "no way we will go with this" — flagged it as unreadable:replicateHighFanoutSources's docstring promises "one local chip with N tiny edges to its consumer," and at N=11 the "tiny edges" framing breaks down. Bundling collapses same-(from, to, kind, isFeedback)aux edges into oneEdgeBundlerendered as a single path with a stroke thickened logarithmically in N (1.5 + min(1.5, log2(N) × 0.7)→ N=2: 2.2 px, N=11: 2.7 px, N=100: 3.0 px — chosen so the×Ntext label does most of the communicating; the arrow shouldn't dominate). The hit zone stays a single 12 px transparent companion path. Clicking the bundle opens a new value-inspector variant (kind: "bundle") showing the full auxKey list one-row-per-key in a scrollable<ul>; clicking a row sets an internalactiveBundleAuxKeysignal that drives the per-aux value lookup shown below the list. The canvas selection halo stays on the bundle (advisor's recommended "halo doesn't jump" semantic), so the user always knows which visual element they're inspecting. Singleton bundles (N=1) render byte-identically to the pre-bundle world — samedata-edge-keyformat, same CSS classes, same stroke widths — so every non-bundled spec/iterate state is unchanged. Multi bundles get abundle:prefix ondata-edge-key(formatbundle:${from}|${to}|${kind}|${isFeedback?1:0}) so the inspector store'stoggleSelectedEdgedispatches by prefix without callers needing to know whether they're toggling an edge or a bundle. Bundle key includesisFeedback(not justfrom/to/kind) so cross-iteration aux flows (CBC'scbc-snapshot → cbc-xor) can't accidentally merge with same-pair forward aux — dashed styling applies bundle-wide, mixing flags would force a half-dashed render. Render-time port-spreading helpers (buildConsumerPortAssignment,consumerPortOffset) now consume the bundle'srepresentativeEdge(one per bundle, identity-stable across renders) rather thangraph.edges— so an 11-auxKey bundle counts as one incoming slot at its consumer instead of 11, correctly centering the bundle's arrow at the consumer midpoint. The bundle-edge label renders as a small white pill at the path midpoint (computed inside the existinggeom()memo via((sx+tx)/2, (sy+ty)/2)— symmetric cubic Bezier midpoints sit at the geometric midpoint by construction). New filetests/graph-bundle.test.ts(7 tests) pins the pure derivation: singleton-bundle-per-unique-edge baseline, N=11 collapse case on AES-128 ECB + collapse + replicate-1, encounter-order preservation insideauxKeys, state-edges-pass-through, identity-preservation of source nodes/containers/edges/rootIds (additive transform contract), feedback-flag-difference split, empty-graph degenerate.tests/graph-view-bundle-render.test.tsx(3 tests) pins the render layer: ×11 label visible on the AES-128 ECB+collapsed+always fixture, singleton bundles have no bundle class or label, hit-path<title>lists auxKeys with truncation.tests/graph-view-bundle-inspector.test.tsx(6 tests) pins the inspector: bundle target selected (not per-edge), 11-row drill list rendered with first row default-active, row click moves active but halo stays on bundle, toggle-off clears, fresh select clears stale row state, spec swap clears bundle selection. The pre-existing 86-test graph-view + value-inspector suite still passes byte-identically — only NEW rendering paths take the bundle branch.
- Port-spreading: visual-target bucketing for retargeted replica edges (port-spreading-consumer-head followup, 2026-05-17). Manual browser smoke on AES-128 ECB + all-aux-always (expanded iterate) surfaced a collision at
initial.add-round-key: three replica edges visually converged on that step, butbuildConsumerPortAssignmentbucketed by rawedge.to. The key-expansion replica hadedge.to = "initial.add-round-key"(direct) while the compute-block-count and split-blocks replicas hadedge.to = "iterate"(retargeted at render time to the first body step viavisualEdgeTargetId's slice-2 anchor). Different raw buckets → all three in single-incoming or middle-of-small-bucket slots → two arrows landed at the same x at the visual target → user-visible "3 arrows from above overlap."buildConsumerPortAssignmentnow takes an optionalvisualTargetOfcallback so callers can bucket by the same key the renderer uses fortoBox. The render site passesvisualEdgeTargetId-bound; existing tests with synthetic graphs (no iterate retargeting) keep their behavior via the identity default.ConsumerPortAssignment.localCountOfre-keyed toMap<GraphEdge, number>soconsumerPortOffsetlooks up by edge reference rather thanedge.to; newbucketSizeByTarget: Map<string, number>exposes per-target counts for test sanity checks (single-incoming consumers are NOT included — slotOf'sundefinedshort-circuit handles them). 2 new regression tests intests/graph-view-port-spreading.test.ts: one exercises the visualTargetOf callback with a stubbed retargeting and asserts distinct offsets at the visual convergence point; the other pins backward-compat behavior (no callback → identity bucketing → both edges single-incoming → both offset 0, which is the BUG state). User-confirmed visually on AES-128 ECB expanded fixture (2026-05-17). Bundle 107.43 KB gzipped (+0.04 KB from the prior port-spreading commit).
- Port-spreading at the consumer head: per-consumer slot semantics replace the global-row formula (port-spreading-consumer-head plan). Slice 7c's
replicaTargetXOffsetdistributed by global row index — confirmed buggy at collapsed-iterate chip heads via twoit.failsdiagnostic tests in commit97e098f(mechanism 1: collision at offset 0 when a non-replica edge meets a middle-row replica; mechanism 2: skipped global rows inflate the spread when a consumer's local fan-in is sparse). New pure exportsbuildConsumerPortAssignment(graph, replicaPlacement)+consumerPortOffset(edge, ports, portGap)insrc/ui/components/GraphView.tsxreplace it. Per-consumer slot assignment inherits row ordering fromReplicaPlacementso source-side staggering (replicaSourceXOffset) stays aligned with the target-side spread — no arrow crossovers within a consumer. Non-replica edges sort last (Infinity row), so the state spine arrow lands rightmost at any multi-incoming consumer. Kind-agnostic comparator (row → from → auxKey → kind tiebreakers) keeps Slice 7b's "drop thekind === 'aux'filter and machinery picks up state replicas for free" promise valid. The two diagnosticit.failstests are now plainit(regression guards); a newper-consumer localitytest pins the tradeoff explicitly (the same source can land at different offsets at consumers with different fan-in, vs the old "source A always lands HERE" cross-canvas property). Test file's 8 existing tests migrated to the new API (buildConsumerPortAssignment+consumerPortOffset); 1 new test added; 2 previously-failing diagnostics now pass. Bundle change tiny: ~0.36 KB gzipped (107.03 KB → 107.39 KB). Mechanism 3 (off-chip clamp against chip-vs-leaf width) lives in the EdgePath render site; deferred per the plan unless visual smoke shows arrows still sliding off chip edges. Manual browser smoke on the canonical AES-128 ECB + multiplealways-overrides + collapsed iterate fixture is recommended before declaring the fix visually complete.
0.4.0 - 2026-05-16
Cross-mode mirror plan ships end-to-end: every step type whose param has a known encrypt↔decrypt relationship now carries an opt-in, labelled mirror button enforced by a registry-driven enumeration test. Plus the Option-C collapsed-multi-block-iterate visual fix, replica-stack visual polish (port-spreading, vertical stacking, start-dots, panel height cap), and the auto-rerun trigger fix for plaintext/key/IV edits.
-
MixColumns inverse-sync button + GF(2^8) 4×4 Gauss-Jordan inverter (Slice 5 — final slice — of the cross-mode mirror plan). The cross-mode mirror principle had four registered surfaces (AES SubBytes, AES key-expansion v1+v2, Serpent SubBytes); the MixColumns matrix was the missing fifth. Encrypt-side AES holds the canonical
AES_MIX_MATRIX(FIPS-197 §5.1.3); decrypt-side AES holds its GF(2^8) inverseAES_INV_MIX_MATRIX(FIPS-197 §5.3.3). Until this commit a user editing the encrypt-side matrix had no one-click affordance for "rewrite decrypt to match" — they had to either know the algebra by hand (computationally awkward) or recall and re-type the published inverse table. New filesrc/core/state/gf-matrix.ts(~180 lines including docstrings) provides two pure exports:gfInverse(a)— multiplicative inverse via a lazily-built 256-entry lookup (O(256²) on first call, O(1) afterwards; throws ona === 0);gfMatInverse4x4(M)— Gauss-Jordan elimination on the augmented[M | I]4×8 matrix usinggfMul/gfInversefor row scaling and XOR as field "addition" (throws on singular matrices). HelpergfMatMul4x4exported alongside so tests can verifyM · M⁻¹ = Iwithout re-implementing the multiplication. The arithmetic primitivesxtimeandgfMulare imported from the existingsrc/core/state/matrix.ts— not re-implemented (the irreducible polynomialx^8 + x^4 + x^3 + x + 1lives in exactly one place). New mutatorsyncMixColumnsInverseToCounterpart(stepType, invertedMatrix)insrc/ui/stores/spec.tsis a parallel sibling tosyncSboxInverseToCounterpart— same broadcast-to-every-matching-step-in-counterpart shape, same "active slot untouched" invariant, sameuseMode()-based direction inference; the only difference is the param key (matrixinstead ofsbox) and a deep-copy of the input matrix (row.map(r => [...r])) so a caller mutating the source after the call doesn't bleed into the spec. New component<SyncMixColumnsRow>inParamEditor.tsxis appended afterApplyAllRowinsideMixBlock, wrapped in<ActionButton>so the green flash + ✓ glyph + aria-live announcement come for free. Label:Sync inverse MixColumns to {counterpart}. Tooltip when enabled: cites FIPS-197 §5.3.3 and names the Gauss-Jordan algorithm. Gating via try/catch aroundgfMatInverse4x4inside acreateMemo(advisor pick over a separatemixColumnsIsInvertiblehelper): the inverter throws on singular matrices, so the catch path IS the disabled-state signal — no duplicated invertibility logic, and the memo caches the result so the onAction handler doesn't re-run the elimination. Disabled tooltip honest about the gating reason:"This matrix has no inverse over GF(2^8) (singular). Edit a cell to restore invertibility — unlike the S-box editor, there's no general 'Repair' recipe for a 4×4 mixing matrix"(NO "Repair first" copy because there's no Repair affordance for a 4×4 — the canonical AES matrix is one specific invertible table among many, and there's no general inverse-table recipe like there is for an arbitrary bijection on 0..255). New CSS class.sync-mix-columns-rowmirrors.sync-inverse-rowstyling 1:1. Registry update: new entry{ stepType: "generic.mix-columns@1", paramKey: "matrix", mirrorClass: "inverse" }incross-mode-mirror-registry.ts(the actual step type — the plan's prose saidaes.mix-columns@1but the codebase usesgeneric.mix-columns@1because the same executor handles forward and inverse multiplication; the registry pre-correction in the existing comment was right). Enumeration test (tests/cross-mode-mirror-coverage.test.tsx) extended with ageneric.mix-columns@1setup branch — same shape as the existinggeneric.byte-substitution@1branch, just returns"round.1.mix-columns". 18 new tests across three files.tests/gf-matrix.test.ts(16):gfMulsanity (gfMul(2, 0x8d) === 1— the textbook KAT that catches a wrong-polynomial bug in seconds),gfInversespot-checks + the field property∀a ∈ 1..255: gfMul(a, gfInverse(a)) === 1+ involution + zero-throws, the headline KATgfMatInverse4x4(AES_MIX_MATRIX) === AES_INV_MIX_MATRIXand its reverse direction,M · M⁻¹ = Iround-trip on five matrices including identity, AES_MIX, AES_INV_MIX, a diagonal with distinct pivots, and a lower-triangular case (forces row-swap-free reduction), three singular-throws cases (zero row, duplicate rows, zero matrix), and two input-shape validations (non-4-row, non-4-column).tests/sync-mix-columns-store.test.ts(5): direction-encrypt + direction-decrypt + active-untouched + KAT integration (gfMatInverse4x4(AES_MIX_MATRIX) → mutator → decrypt slot has AES_INV_MIX_MATRIX byte-for-byte— pins the full UI flow at the store boundary) + deep-copy semantics (mutating source after call doesn't reach into spec).tests/sync-mix-columns-row.test.tsx(5): button renders with the correct verb,data-mirror-class="inverse", FIPS-197 §5.3.3 tooltip citation, sabotage-to-singular disables-with-honest-tooltip + restore re-enables (reactive-disable regression guard, same shape as the inverse-S-box row's test), and a second singular pattern (two identical rows — covers rank-deficient-but-no-zero-row to belt-and-braces the first test). The plan is now fully shipped end-to-end — all five slices onmain, all surfaces enforced by the enumeration test. Bundle change small (~1 KB for the gf-matrix module + ~300 bytes for the row + mutator); the gate runtime gained ~150 ms from the 256-element inverse-table build the first timegfInverseis called (one-time cost amortized across all subsequent calls in a session). -
Cross-mode mirror registry + enumeration coverage test (Slice 4 of the cross-mode mirror plan). Slices 1–3 wired three mirror-button surfaces by hand (AES SubBytes
SyncInverseRow, Serpent SubBytesSerpentSyncInverseRow, AES key-expansionCopySboxRow). The pedagogical principle — "every step whose param has a known cross-mode relationship ships with an opt-in, labelled button" — was documented in commit messages but only socially enforced; a new cipher addition could silently drop a button and nothing would fail. Newsrc/ui/components/cross-mode-mirror-registry.tsexportsCROSS_MODE_MIRROR_ENTRIES, areadonly { stepType, paramKey, mirrorClass, groupBy?, counterpartStepType? }[]listing the four shipped entries (AES SubBytesinverse, AES key-expansion v1 + v2identity, Serpent SubBytesinverse+groupBy: "sboxIndex"+counterpartStepType: "serpent.inv-sub-bytes@1"). ThegroupByfield carries the Serpent per-S-box-index semantic so the registry shape stays honest about the cipher's 8 distinct tables — but the enumeration test only asserts a Sync button is present for the step type; the per-index propagation contract stays pinned by the existingtests/sync-serpent-sbox-inverse.test.ts. Newtests/cross-mode-mirror-coverage.test.tsx(jsdom) walks the registry and, for each entry, renders<App />, runs a per-entry setup (canonical AES-128 for SubBytes + key-expansion v1;duplicateRoundInSpec("round.1")to morph v1 → v2;setCipher("serpent-128")for Serpent), navigates to a leaf of the entry's stepType viasetSelectedStepId, and asserts a<button data-mirror-class="{class}">rendered inside.param-editor. The three existing Sync rows (SyncInverseRow,SerpentSyncInverseRow, and the newCopySboxRow) all gained adata-mirror-classattribute ("inverse"/"inverse"/"identity"respectively);ActionButton's existingsplitPropsextracts only the seven explicit named props, sodata-*lands inrestand spreads onto the underlying<button>for free — no primitive change needed. The test's failure message names the offending stepType AND its expected mirror class so a future "I forgot to wire the button" fix path is obvious. CLAUDE.md "Cross-mode mirror buttons" section under Conventions captures the principle in prose so a future Claude session reading the project guide knows the rule before touching new ciphers. A sanity assertion in the test confirms the registry has bothidentityandinverseentries (catches future entries that accidentally drop a class). 5 new tests intests/cross-mode-mirror-coverage.test.tsx(sanity + one per entry). Bundle change negligible (the registry is ~50 bytes; the threedata-mirror-classattributes add ~15 bytes each in the JSX output). -
AES key-expansion Copy-S-box-to-counterpart button (Slice 3 of the cross-mode mirror plan). AES SubBytes shipped its "Sync inverse S-box to decrypt" button in commit
d5c0949; AES key-expansion's S-box editor (collapsed inside the<details>summary "S-box (256 entries — click to expand)" withinKeyExpansionBlock) had no mirror affordance at all. The asymmetry mattered pedagogically: FIPS-197 §5.2 says key expansion uses the FORWARD S-box even when decrypting — the inverse cipher consumes the same round keys in reverse order without re-deriving them with the inverse S-box. So encrypt and decrypt key-expansion hold the SAME table, not algebraic inverses. New mutatorsyncSboxCopyToCounterpart(stepType, sboxValue)insrc/ui/stores/spec.tsis a parallel sibling tosyncSboxInverseToCounterpart— same broadcast-to-every-matching-step-in-counterpart shape, same "active slot untouched" invariant, same direction inference fromuseMode(). The only semantic difference issbox: [...sboxValue]instead ofsbox: [...invertedSbox](no algebraic inversion). Docstring explicitly warns against caller-sideinvertSboxcomposition — the whole point of the Copy verb is to mirror the forward S-box exactly. New component<CopySboxRow currentSbox stepType>inParamEditor.tsx, wrapped in<ActionButton>so the click flash + ✓ glyph + aria-live announcement come for free; mounted INSIDE the<details>right after<SboxEditor>so the affordance lives next to the table it acts on (outside-the-details would orphan the button when the section is collapsed, which is the default state). Button label says "Copy S-box to decrypt" (NOT "Sync inverse" — distinct verb names the algebraic relationship). Tooltip cites FIPS-197 §5.2 and warns the operation overwrites per-step customizations on the counterpart side. Gated on bijection for consistency withSyncInverseRow— a copy of a non-permutation is mathematically valid but if half the S-box rows lock when broken and the other half don't, users learn a noisier mental model. New CSS class.copy-sbox-rowmirrors.sync-inverse-row's styling 1:1 (same accent palette, same disabled greyed state, same flush-right justification) — the two classes diverge only so test selectors can target one without the other. 8 new tests: 4 intests/sync-sbox-copy.test.ts(node) covering active=encrypt → decrypt slot, active=decrypt → encrypt slot, active-slot-untouched, canonical round-trip (AES_SBOX → AES_SBOX is a byte-identical no-op); 4 intests/copy-sbox-row.test.tsx(jsdom) covering button renders + label,data-mirror-class="identity"present (Slice 4 enumeration-test hook), tooltip cites FIPS-197 §5.2, disabled-on-non-bijection + re-enabled-after-restore (reactive-disable regression guard, same shape as the inverse row's test). -
Serpent SubBytes editor — duplicate detection + Repair to permutation + per-S-box-index Sync inverse to counterpart (Slice 2 of the cross-mode mirror plan). The existing
SerpentSubBytesBlockrendered a 4×4 grid of editable cells with no validation — a user could create duplicate values and silently end up with a non-invertible S-box, with no warning and no Sync-inverse affordance below the editor. Slice 2 brings the same validation suite the AESSboxEditorhas had since89c7d17to Serpent at N=16: per-cell red highlighting on duplicates (driven byfindDuplicateIndices+collisionGroupsByIndexmemos — both size-parameterized byvalues.lengthand thus reusable at 16 without modification), a<Show when={redundantCount() > 0}>warning banner above the grid (new.serpent-sbox-warning-bannerCSS class — same red token as the AES banner but tighter padding/font for the smaller grid context), and aRepair to permutation<ActionButton>wired torepairToPermutation(same green flash + ✓ glyph + aria-live announcement the AES button got in Slice 1). A new<SerpentSyncInverseRow>lives below the grid, labelledSync inverse S_{n} to {counterpart}wherenis the leaf'ssboxIndex(0..7). Plan deviation (advisor-confirmed in-scope): the original plan listed Serpent's multi-table sync as "out of scope" — but Serpent cycles 8 distinct 4-bit S-boxes across its 32 rounds and the existingsyncSboxInverseToCounterpartmutator would broadcast one inverted table to everyserpent.sub-bytes@1leaf in the counterpart slot, overwriting 28 of 32 rounds with the wrong inverse. A new sibling mutatorsyncSboxInverseToCounterpartByIndex(stepType, sboxIndex, invertedSbox)filters byparams.sboxIndex === sboxIndexinside the update function (reference-equal return on non-matching index →updateAllStepsByType's tree walker short-circuits the rebuild for that subtree, preserving structural sharing across the unchanged 7/8ths of the rounds). The two mutators now read as parallel siblings: "AES has one S-box, Serpent has eight, and the Sync semantics differ accordingly." The Serpent Sync button's tooltip explicitly states the other 7 S-boxes are independent so users don't think the button is broken when their S_5 edit doesn't propagate after a sync of S_3. Within-encrypt sboxIndex consistency is intentionally not solved this slice — editinground.4.sub-bytes(which uses S_3) and clicking Sync only writes to decrypt-side leaves withsboxIndex === 3; encrypt'sround.12/round.20/round.28(also S_3) stay un-edited and diverge from their decrypt counterparts. Same "each leaf owns its params" semantic AddRoundKey has had since launch. No ApplyAllRow on Serpent SubBytes for the same reason — broadcasting one S-box across 32 rounds would destroy 28 sboxIndex assignments (project's "skip ApplyAllRow when copying across siblings is actively harmful" guidance fromsrc/steps/CLAUDE.md). 9 new tests: 4 intests/sync-serpent-sbox-inverse.test.ts(node) covering writes-to-matching-index, other-index leaves byte-for-byte unchanged after Sync (the non-regression assertion — without this, a silent regression to broadcast mode would corrupt 7/8ths of the spec and the matching-index check alone wouldn't catch it), bidirectional encrypt↔decrypt, canonical round-trip; 5 intests/serpent-sbox-validation.test.tsx(jsdom) covering button label includes sboxIndex, banner absence on canonical, banner appearance on user-induced duplicate, disabled-on-non-bijection + re-enabled-after-Repair (regression guard against captured-by-value bijection memos), tooltip names the per-index semantic. Both test files importsetCipherfrom@/ui/stores/spec(not@/ui/stores/cipher) — the former rebuilds the spec viabuildCanonicalPair, the latter only flips the signal. Note for Slice 4 (enumeration test): the cross-mode mirror registry shape will need agroupBy?: "sboxIndex"field to handle the Serpent case cleanly — a single entry{ stepType: "serpent.sub-bytes@1", paramKey: "sbox", mirrorClass: "inverse" }would assert "a Sync button exists" but couldn't assert the per-index semantic. Don't fix here; flagged in the mutator's docstring. Suite at 1052 tests across 89 files (was 1043 across 87); bundle 105.91 KB gzipped (+~440 bytes for the new mutator + Serpent block additions + per-leaf component + tighter banner CSS). -
ActionButton flash-on-click primitive + retrofit of existing action buttons (Slice 1 of the cross-mode mirror plan). Three action buttons in the UI (
Repair to permutationin the S-box editor warning banner,Sync inverse S-box to decryptbelow the S-box editor,Apply this S-box/matrix to all N matching steps) mutate state in panels the user isn't currently viewing — the counterpart-mode spec, the rest of the round list, the not-presently-focused step, etc. Without click-feedback, users had no signal that their click registered, leading to repeat-clicks and the question "did anything happen?" New primitivesrc/ui/components/ActionButton.tsxis a drop-in replacement for raw<button>that on click (a) runsonActionsynchronously, (b) adds a.flashingclass to the underlying<button>for 900ms, painting it green (--accent-success: #4ade80) with a "✓" glyph appended via CSS::after, and (c) populates a visually-hidden<output>element (implicitrole="status"+aria-live="polite") with afeedbackLabelso screen readers hear what the click did ("Synced inverse S-box to decrypt mode", "Applied S-box to all 10 matching steps", etc.). The flash duration is overridable viaflashMsfor future use cases.prefers-reduced-motionremoves the transition (color still flips, just instantaneously — the flash is informational, not decorative).:disabledkeeps its existing greyed style even mid-flash. CSS specificity:.action-button.flashingis two classes (0,2,0) which wins over per-call-site rules like.sync-inverse-row button(0,1,1), so no!importantis needed. Retrofit constraint (advisor-flagged blocker for the plan): all retrofitted in this same commit so the UI doesn't carry two visual languages. New.visually-hiddenrule (standard clip-path recipe) added since the codebase didn't have one. Scope cut after empirical finding: the plan included a parallel SVG-native flash on the round-duplicate+glyph in the graph view, but the click triggers a spec mutation that reconstructs the containers array, and Solid's<For>rebuilds child instances when the array reference changes — the newDuplicateGlyphstarts withflashing=falseregardless of how the flash is timed (reorderingtriggerFlash()beforeonDuplicate()did not help). The click's RESULT is intensely visible anyway (a brand-new round group appears mid-canvas, every subsequent round renumbers, the matching decrypt round auto-mirrors), so no supplementary "click registered" signal is needed for this affordance. Documented inline onDuplicateGlyphinGraphView.tsxand in the CSS file (no.graph-duplicate-button.flashingrule). 8 new tests intests/action-button.test.tsx(jsdom + fake timers) pin: onAction-fires-synchronously, flashing-class-add-and-remove, live-region populate-and-clear, label-fallback to button text, caller's existing class preserved alongsideaction-button+flashing,disabled/titleforwarding,typedefaulting to"button"(HTML default is"submit"— easy footgun), andflashMsoverride honored. Suite at 1043 tests across 87 files (was 1035 across 86); bundle 105.47 KB gzipped (+~80 bytes for the primitive — the bulk of the slice is CSS-only).
- Stacked replicas above a shared consumer read clearly now — vertical column + straight diagonal lines + visible start-dots. When a user forces multiple sources to
alwayson the replication panel (e.g.key-expansion,compute-block-count, ANDsplit-blocksall set toalwayson AES-128 ECB with the iterate collapsed), three replica chips stack above each block-chip and three aux arrows flow from them into the chip. Earlier visual encodings tried (horizontal-tile / diagonal-source-stagger / curved-edge prototype) each broke down at high fan-in: arrows overlapped, ran behind boxes, or sprawled into adjacent columns. Final shape: (1) replicas stack in a clean vertical column above the consumer (REPLICA_ROW_X_STEP = 0again); (2) each replica's outgoing arrow originates from an offset x along the replica's bottom edge — same MONOTONIC(row − (total−1)/2) × stepspread the consumer-head port-spreading uses, so source and target sweep in the same direction by row and arrows never cross; (3) a small visible<circle>start-dot is pinned at each arrow's tail so the eye can anchor "this arrow originates here" even when the straight diagonal line crosses an intervening replica's bounding box. NewBASE_REPLICA_SOURCE_X_STEP = 32(~¼ × LEAF_W). The vertical regime's path switches from cubic Bezier to a straightLline when the edge originates from a fan-out replica; non-replica edges keep the existing curve. CSS classes.graph-edge-start-dot-aux/-statematch the parent path opacities with feedback + selected variants. Same release widened the row-0 → consumer gap so the arrow shaft is visible: newBASE_REPLICA_LIFT_GAP = 20(replacesSTACK_GAP = 6at the replica-lift sites only). Previously the shaft length collapsed to ~0 afterARROW_INSET = 6subtraction — the user reported "arrowhead kissing the chip top with no detectable line between dot and target."REPLICA_LIFT_GAPis used byreplicaSlotPosition(row-0 baseY),replicaLiftHeight(row-0 height contribution), andauxOnlyLiftH(aux-only-root lift at canvas root);STACK_GAPkeeps its 6-px value at all other sites (sibling stacking inside groups, where no arrow crosses the gap). Test comment updates only — assertions still pass byte-identically against the new constant. - Replication panel can't obstruct unbounded canvas anymore + the canvas got more vertical room. Two CSS-only tweaks. (1) The open replication panel now wraps its rows in a
.graph-replication-panel-bodydiv withmax-height: 30vh; overflow-y: auto; min-height: 0. On viewports ≥800 px the panel never claims more than ~240 px of canvas vertical real estate regardless of source count; for the typical 3-source AES-128 case the cap never engages (rows fit well under 30vh, no scrollbar appears). For ciphers with many overridable sources the panel scrolls internally rather than pushing the rest of the sticky header (and the canvas behind it) down. (2) The.graph-viewcanvas wrapper's hardcodedmax-height: 600pxwas replaced withmax-height: calc(100vh − 220px); min-height: 560px. On a 1080-px display this raises the canvas cap to ~860 px (~43 % more vertical canvas); on shorter displays themin-height: 560pxfloor keeps roughly the previous behaviour. The 220-px subtrahend accounts for the app's fixed chrome (header, frame-header, inputs, byte-format toggle, ParamEditor, footer); deliberately over-estimated so the canvas never pushes that chrome out of view.
- Auto-rerun now triggers on plaintext / ciphertext / key / IV edits, not just spec edits. User-reported regression: with auto-rerun ON, changing the plaintext field (or the key, or randomizing the IV) silently left the trace stale — the cipher wasn't re-run AND the "edits pending" banner that manual mode shows never appeared, because manual mode wasn't flipping the dirty flag either. Root cause:
createEffect(on(spec, …))inApp.tsxtracked only the spec signal, so changes to the localinputText/keyTextsignals and the module-scopedivBytessignal never re-fired the effect body. Fix: extended the dep tuple to[spec, inputText, keyText, ivBytes]. Side effect — selector helpers (changeFormat/changeCipher/changePadding/applyDocument) callsetInputText/setKeyTextas part of their flow, so each now triggers one extra debounced auto-rerun on top of the spec-driven one. ThepushSnapshotdedup keeps history clean (same bytes + same spec → no new snapshot), so the worst case is a sub-millisecond duplicate runtime call. 4 new tests intests/app-auto-rerun-toggle.test.tsx(auto-mode plaintext + key produce a new snapshot after debounce; manual-mode plaintext + key light the pending banner without growing history). The manual-mode tests drain leaked debounced timers from earlier test renders before asserting (a known test-isolation quirk in this file — module-scopedspecsignal fires undisposed effects from priorrender()calls on each__resetSpecForTestsin beforeEach). - Collapsing a multi-block iterate (ECB / CBC) post-Run is recoverable again — iterate box + chevron stay around the chip row. Reported by a user on AES-128 ECB: collapsing
ecb-blocksAFTER running with multi-block plaintext left no UI affordance to re-expand. Root cause:expandCollapsedIteratesdropped the iterate container (and its chevron) from the graph whenblockSpan > 0, replacing it with bare block chips at the iterate's old slot. Pre-Run still worked because the transform short-circuits on undefinedblockSpan. Fix: keep the iterate incontainersand rewrite itschildIdsto the chip ids (with chips'containerPathextended to include the iterate id, so layout recurses into the body); edges to/from the iterate stay anchored on the iterate's box (no per-chip fanning). Two same-release follow-ups for visual polish:- Aux replicas read as "feeds the iterate," not "feeds block 1." The replica anchor in
layoutRootused the iterate's first non-replica child's x — under Option C that first child is alwaysiter@block0, so every replica's chip + arrow tip landed above block 1. When the first body child is a block chip (blockChipOf !== undefined), the anchor now snaps to the iterate's horizontal center ANDvisualEdgeTargetIdkeeps the arrow's target at the iterate (instead of retargeting it to block 1's chip). Expanded iterates keep the original "anchor over the first real body step" behavior — the override is chip-shape-discriminated, not iterate-discriminated. - No more iterate-as-replication-source duplicate. Toggling a collapsed iterate to
alwaysin the replication panel produced a chip nearconcat-blockslabelled "ECB blocks (per-block AES)" (the iterate's container label overflowed the chip border) plus a duplicate of the state-spine arrow already drawn from the iterate.replicateHighFanoutSourcesnow silently skips any source whose id is ingraph.containers; the user'salwayspanel toggle is preserved (so the rule could relax later) but no-ops for container ids. Rationale: replication is a decongestion device, and a visible iterate is already one — replicating it just stacks two pictures of the same dataflow. - Horizontal breathing room.
BASE_FLOW_GAPbumped 16 → 24 — applies to both root flow and iterate body flow, so the chip row + the rest of the canvas open up uniformly. - 8 new tests: 4 in
expand-collapsed-iterates.test.ts(iterate retention contract — iterate stays in containers + rootIds, childIds rewired to chip ids, edges to/from the iterate untouched, chip containerPath includes the iterate id); 2 ingraph-view-block-chips.test.tsx(chevron clickable post-collapse + header label visible while collapsed — the regression assertion); 1 inreplicate-fanout.test.ts(container source skipped even when set toalways); 1 ingraph-view-replica-placement.test.ts(replica x lands at iterate-center, not block 0's x); 1 ingraph-view-replica-gutter.test.ts(visualEdgeTargetIdreturns the iterate id, not the first chip, when the first body child is a block chip).tests/expand-collapsed-iterates.test.ts's edge-fanning describe block was rewritten as an "iterate retention" describe block — the old contract (drop iterate + fan edges per chip) is preserved in git history; the new contract is pinned at the same depth.docs/help/graph-view.md"Collapse a container" paragraph updated to describe the box-with-chevron-around-chip-row visual.
- Aux replicas read as "feeds the iterate," not "feeds block 1." The replica anchor in
- Empty round containers stayed connected to the chain + dropping into them actually works. Two interlocking symptoms a user hit while editing in graph mode: deleting every step inside an AES round (
round.5's sub-bytes / shift-rows / mix-columns / add-round-key) left the round box visually stranded with no incoming or outgoing spine edges, AND a subsequent palette drop on that empty box appeared to "do nothing" (the new step actually landed at the end of the top-level spec, far from where the user dropped). Three coordinated fixes: (1)inferStateEdgesincore/graph.tsnow treats structurally empty groups (no leaves, no iterates anywhere in the subtree) as spine participants — the group's own id joins the leaf chain so the spine routesround.4.add-round-key → round.5 → round.6.sub-bytesinstead of leapfrogging. Filled groups stay transparent so the pedagogical "continuous round-body chain" reading inside non-empty rounds is preserved. (2) NewprependChildToContainerprimitive incore/spec-mutations.tslands a step at position 0 of the targeted container's children array, whether the container is empty or not;into-startininsertStepIntoSpecnow routes through it (the old code path usedinsertStepBefore(firstChildId, ...)which had nothing to anchor on when children was empty and silently fell through to root-append). (3)dropGuttersno longer skips empty containers — it emits a single sentinelinto-start:${containerId}gutter covering the entire box so the previously-dead 6-pixel body strip below the 22 px header also resolves to into-start. Gutters win over anchors, so the whole empty box becomes one consistent "drop here to fill it" target with live highlight feedback; the header'sdata-drop-anchorremains as a redundant backup but is no longer load-bearing. 8 new tests: 6 intests/spec-mutations-structure.test.tsforprependChildToContainer(empty + populated group, iterate body, throws on missing/leaf id, ref equality, no source mutation) and 2 intests/aux-graph-derivation.test.tsfor the empty-spine variant (AES-128 with round.5 emptied: 37 spine edges with the two bridge edges asserted by name; synthetic nested-empty-group fixture proving inner empty groups participate inside filled outer groups).
- Value inspector covers leaves, endpoint pills, and block chips (follow-up to the edge → value rename). Clicking ANY of the four canvas elements now populates the panel:
- Edges (existing) — value flowing through the edge at the current scrubber position.
- Leaves — the leaf's
frame.stateAfter(state-after at the leaf's own frame). Inside an iterate, the scrubber'scurrentBlockIndexpicks which per-block frame; outside, the first matching frame wins. Clicking a leaf is now ADDITIVE: it still scrubs the trace (existing) AND populates the inspector (new). Re-clicking clears the inspector but keeps the scrub position — the two are independent. Halo class.graph-leaf-selectedon the wrapping<g>(drop-shadow on the whole composition + accent stroke on the rect). - Endpoint pills — descriptive
"cipher input (plaintext)"/"cipher output (ciphertext)"label; same endpoint branch the edges-touching-pills already used. Pills are NOT spec nodes, so the click has no scrub side-effect.tabindex=0was added so the click action is keyboard-reachable (Enter / Space mirror click); the pre-existing "no affordances" docstring was updated. Halo class.graph-endpoint-selected. - Block chips — outgoing value (=
outBlocks[i]= the iterate's last-body-frame stateAfter at blockIndex i). The chip'snode.stepIdis the synthetic chip id (ecb-blocks@block0); the inspector resolves it through the same chip-parsing logic the edge lookup uses. Click still routes scrub to the iterate id, but the inspector target is the chip id —inspectorTargetIdis computed alongsideclickTargetIdto keep the two semantics distinct. - Aux replicas — inspector target is the source's stepId (= existing
clickTargetId), so clicking a replica chip or its original shows the same source value. The replica's synthetic${src}@->${consumer}id has no trace frame. - The store moved from
selectedEdgeKey: stringto a discriminatedselectedTarget: { kind: "edge", key } | { kind: "node", id } | nullunion. Convenience helpersisEdgeSelected(key)/isNodeSelected(id)give renderers the boolean without unpacking the union. Spec-swap effect still clears the unified target. - New pure helper
lookupNodeValue(nodeId, spec, trace, currentBlockIndex)exported fromcore/edge-value-lookup.ts(file kept its historical name — adding both lookups under one roof beats splitting them since they shareparseChipId/findIterateById/findBodyFramesAt). Reuses the existingEdgeValueLookupdiscriminant so the renderer'skindBadgeText+valueRowTextswitch unchanged. - The kind-badge label generator stopped taking the GraphEdge — it now reads
auxKeyfrom the lookup result itself so the same function works for both node and edge selections (nodes never have a GraphEdge in scope). - 13 new tests in
tests/node-value-lookup.test.tscovering endpoint pills (input + output + trace-null endpoint-branch precedence), trace-null on regular leaves and chips, chip values + cross-block discrimination, ellipsis chip, missing iterate, out-of-range index, top-level and nested leaves, currentBlockIndex disambiguation, missing leaf id. Component test file grew from 7 to 11 tests with three new ones — leaf-click scrub-plus-inspect (against the nestedround.5.mix-columnsleaf, since root-level leaves go through the drag path thatfireEvent.clickdoesn't trigger), input/output pill clicks, kind-mixing replacement (leaf → edge → pill). The graph-view.test.tsx endpoint-pill assertion flipped fromtabindex absenttotabindex="0". Suite at 975 passed across 80 files (was 957 across 79).
- Edge inspector renamed to "value inspector" and is now click-only. Same panel, narrower interaction model: hover no longer populates the body — only a click selects an edge. Re-clicking the same edge clears the selection; clicking a different edge replaces it. The visible-path halo class follows:
.graph-edge-pinned→.graph-edge-selected. Internal store renamedview-edge-inspector.ts→view-value-inspector.ts; CSS class prefix renamedgraph-edge-inspector-*→graph-value-inspector-*; testid prefix renamededge-inspector-*→value-inspector-*. The pinned-pin-badge span is gone (no hover-vs-pin distinction to disambiguate). Hint copy below the header collapsed to "click an edge"; empty-state copy collapsed to "Click an edge to see the value flowing through it." The 84 pxmin-heightcycle-fix on the body survives — click-only doesn't cycle, but the same height-stability reasoning applies (selecting vs. clearing must not shift the canvas). Mechanical rename, no behavioral change to the lookup helper or its 15 unit tests (which calllookupEdgeValuedirectly). The 8 component tests intests/graph-view-value-inspector.test.tsx(renamed fromgraph-view-edge-inspector.test.tsx) lose their hover assertion and gain a sharper click-toggle assertion against the new.graph-edge-selectedhalo class. Renaming staged ahead of a follow-up commit that expands the inspector to leaves, endpoint pills, and block chips — at which point "edge inspector" would have been misleading. - State spine threads through the iterate as a node — chips carry the spine in parallel. Same-session follow-up to the Slice 6 block-chip work after a manual browser pass surfaced the visual issue: the white state spine (the "main data flow" arrow) used to FLUSH at every iterate boundary and resume after, leaving
compute-block-countwith no outgoing white arrow at all. Pre-Slice-6 the iterate's own chip filled the visual gap; post-Slice-6 the chips replaced the iterate and the spine visibly died at the last pre-iterate leaf. After a brief detour through a "bridge edge" approach (commit 3aa412d, reverted) that wrongly skipped over the chips entirely, the right fix landed:inferStateEdgesnow pushes the iterate's id onto the parent leaf chain (in addition to recursing into the body as its own scope), so the parent spine threadsprev → iter → next. Pre-Slice-6 the iterate container catches both arrows on its left/right edges (analogous to how the spine threads through round groups today); post-Slice-6expandCollapsedIterates's defensive "fan every edge touching the iterate, regardless of kind" rule fans both edges to one-per-chip — N parallel white spine paths through the chips. Pedagogical match for the user's mental model: "after split-blocks the data IS the per-block payload, and the chips ARE where the data lives during the iterate." Nochip_i → chip_{i+1}edges are produced (chips are parallel paths, not chained ones). One-line change inprocessScope's walk:processScope(node.children); leaves.push(node.id);. Test impact: AES-128-ECB spine count 41 → 43 (4 top-scope edges including prev→iter and iter→next instead of 2), collapsed-iterate state-spine survivors 2 → 4 (both new edges survive collapse since the iterate chip stays visible). Files:src/core/graph.ts(one-line code change + extensive docstring updates rewriting both file-header item 3 AND theinferStateEdgesfunction-level docstring to reflect the iterate-as-node semantic and document both prior detours),tests/aux-graph-derivation.test.ts(count updates, "spine breaks at iterate" test rewritten as "spine threads through the iterate as a node"),tests/expand-collapsed-iterates.test.ts(end-to-end test now asserts the fanned state edges to/from each of the 4 chips on AES-128 ECB; aux-edge assertions filtered bykind === "aux"so they don't accidentally include the new state edges).
-
Edge inspector panel (Slice 4 of the graph-narrative plan) — hovering an edge in the graph view now reveals what data flows through it at the current scrubber position; clicking pins the edge so the value stays visible while the user scrubs through the trace. Panel lives in the graph toolbar below the replication-overrides panel, mirrors its collapsible chevron-toggle pattern, defaults closed. Body has three rows: identity (
from → toplus an optional pin badge), kind badge (aux: roundKey.3/state (block 2)/block payload (block 0)/input pill/output pill), and value rendered in the active byte format. Pinned edges get a wide-stroke halo (.graph-edge-pinned) so the user can find them at any zoom level. The pin clears automatically when the spec id changes (cipher swap, mode flip) — acreateEffect(on(() => spec().id, ...))callsclearPinnedEdgeso a stale pin from a prior spec can't render "missing" against ids that no longer exist. Pin discipline: pin > hover (the panel renders the pinned value even while the cursor moves over other edges); clicking the same edge un-pins; clicking a different edge replaces the pin. The killer demo this enables is "pin an edge, drag the scrubber, watch the value change frame-to-frame" — without pin, moving the cursor toward the scrubber clears the hover.- Pure value-lookup helper
core/edge-value-lookup.ts::lookupEdgeValue(edge, spec, trace, currentBlockIndex). Branches: synthetic endpoint pills → literal label; trace null → "no-trace" hint; block-chip incoming (${iterateId}@block${i}parsed from edge endpoints) → iterate body's first-frame stateBefore at blockIndex=i =blocks[i]; block-chip outgoing → last-frame stateAfter at blockIndex=i =outBlocks[i]; aux into chip with auxKey === iterate.blocksFromAux → same slice; aux out with auxKey === iterate.outBlocksAux → same; generic aux into chip → any body frame's auxRead at the chip's blockIndex (round keys are identical across blocks); regular leaf edges → consumer.frame.auxRead at current blockIndex (aux) or producer.frame.stateAfter (state); ellipsis chip@blockMore→ "missing" with a "pick a numbered block-chip" reason. The lookup is pure — no Solid signals, no DOM — and 15 unit tests against the AES-128 ECB fixture lock down each branch (block-payload values pinned against the published plaintext blocks; cross-block sanity test asserts blocks 0 and 2 are DIFFERENT values). - Reactivity dependency list per the advisor's note for Slice 4: the value-lookup memo depends on
activeEdgeKey()(hover or pin),frameIndex()(block-aware lookup),useTraceVersion()(re-run swaps trace), anduseByteFormat()(format toggle re-renders). Missing any one produces a stale panel; the deps are listed in the EdgeInspectorBody docstring as the discipline check. - a11y: SVG
<path>elements aren't keyboard-focusable by default, so the inspector's hover/click affordance is mouse-only. A no-oponKeyDown(Enter/Space → toggle pin) is wired anyway so biome'suseKeyWithClickEventslint passes and the affordance is present if a future cipher addstabindexto edges. - Wide invisible hit-path companion — the visible edge strokes are 1.5-2 px thick (
.graph-edge-aux/.graph-edge-state) and would be finicky to hover. EachEdgePathnow renders TWO<path>siblings inside a wrapping<g>: the visible path on top withpointer-events: none(so it doesn't compete for hit-tests) plus a 12px wide transparent hit-path that carriesdata-edge-key+ the handlers. Standard SVG idiom. Drop-shadow halo for pinned edges stays on the visible path (drop-shadow on a transparent stroke renders nothing). - 23 new tests across
tests/edge-value-lookup.test.ts(15: endpoint pills, trace null, regular aux/state edges, block-chip incoming/outgoing across state + aux + blocksFromAux/outBlocksAux, ellipsis chip, chip pointing at non-existent iterate, cross-block sanity) andtests/graph-view-edge-inspector.test.tsx(8: collapsed-by-default header, open-on-click body, hover populates kind + value rows, click pins + auto-opens + halo class, pin toggle re-click un-pins, different-edge replaces pin, no-trace hint, cipher-swap clears pin). Suite at 958 tests across 79 files (was 935 across 77). Bundle 100.26 KB gzipped after Slice 4 + the prior Slice 6 / spine-threading work.
- Pure value-lookup helper
-
Collapsed-iterate block-chip expansion (Slice 6 of the graph-narrative plan) — collapsing the
ecb-blocksiterate (or any future iterate-based mode) used to swap the body for a single chip with a×Nbadge, collapsing the "N parallel AES copies" pedagogy down to a number. Now the chip splits into N synthetic block-chips (block 1,block 2, …) laid out at the iterate's old slot. Cap is 6 visible items: N ≤ 6 renders all chips; N > 6 renders the first 5 plus an ellipsis chip labelled+M more blocks. Edges that touched the iterate fan one-per-chip, preserving auxKey + kind. Pre-run state (noblockSpanyet) and non-collapsed iterates pass through by reference, so the transform is zero-cost on single-block ciphers and pre-Run UIs. Implementation: newexpandCollapsedIterates(graph, collapsedIds)pure transform incore/graph.ts, threaded intoGraphView's memo chain betweencollapseGraphandreplicateHighFanoutSourcesso chips become aux-edge consumers — akey-expansion@alwaysoverride whose outgoing-aux fanout rises past the threshold spawns one tiny replica per chip (the "schedule fans out per block" composition the plan describes). New optionalblockChipOf?: stringfield onGraphNode(sibling toreplicaOf, deliberately distinct so chips lay out normally instead of being pulled intobuildReplicaPlacement's "above the consumer" auto-positioning loop). Renderer applies replica-style no-drag/no-delete at the JSX boundary and reads display text fromnode.labelsoblock 1/+5 more blockswin over the iterate id. 20 new tests acrosstests/expand-collapsed-iterates.test.ts(18: cap math at N ∈ {1, 5, 6, 7, 10, 100}, edge fanning preserves auxKey + kind, identity short-circuits, real-spec end-to-end on AES-128 ECB, replication composition) andtests/graph-view-block-chips.test.tsx(2: 4 chips when collapsed, no chips when expanded). Help doc paragraph updated under "Collapse a container" so the in-app?modal stays in sync. Suite at 935 tests across 77 files (was 915 across 75). -
Drop gutters + body tiling + Slice 8 drop-on-container semantic rescope (Slice 5 of the graph-narrative plan) — palette drops can now target any position inside a container body, including the previously-impossible "before the first child" slot. While a palette drag is in flight, every non-collapsed container's body is fully tiled by thin tinted
<rect>strips: one at the start (above the first body child), one filling the gap between every pair of siblings, and one at the end (below the last child). Group bodies (vertical stacks) get horizontal strips spanning the full body width; iterate bodies (horizontal flows) get vertical strips spanning the full body height. The strip the cursor is over lights up in real time so users see exactly where the new step will land. Encodingdata-drop-gutter="${"before"|"after"}:${siblingId}"doubles as the per-slot React key, the live-highlight signal value, and the drop-handler dispatch tag — three uses, one stable string. Hit-test priority:closest("[data-drop-gutter]")runs BEFOREclosest("[data-drop-anchor]")in bothhandleDragOverandhandleDrop, and SVG paint order reinforces it (gutters render last). Replica chips insidechildIdsare filtered out — they're synthetic graph artifacts with no spec entry, soinsertStepBefore(replicaId)would throw. The boundary strip thickness clamps atCONTAINER_PAD(not the largerFLOW_GAPfor iterates) so strips don't bleed past the container's edges. The structural invariant ("a drop inside a container's body must never escape to the parent scope") is documented in a 30-line comment on thedropGuttersmemo with the explicit charge to future container kinds: "if your new container has a body, you tile it." Generalizes to upcoming Feistel / hash compression / AEAD cipher families without per-family special-cases. Slice 8 rescope (same release, follow-up after a user-reported regression): the original semantic — "drop on container = insert after the container in its parent" — was actively confusing because the dragged chip obscures the ~22 px header band, so users dropping "on round.2" landed at the chip's anchor point (the header) without realizing it; aux-shape steps then landed at root and got auxOnlyRoot-lifted, severing the state-spine arrows through round.3.data-drop-anchormoved from each container's outer<g>to the header<rect>only; header drops now route to a new{ kind: "into-start", containerId }anchor flavor that inserts as the first child of the container's body. Body whitespace drops resolve via gutters/leaves (the body has no anchor anymore). "Drop on me = enter me" matches every other DAG editor. Existingstep-palette.test.tsx:294-310assertion inverted from "insert after in parent" to "insert as first child of container."docs/help/graph-view.mdrewritten to enumerate the four drop targets distinctly (leaf / header / body gutter / canvas). 16 new tests intests/drop-gutters.test.tsxcovering all three gutter flavors across groups and iterates, body-tiling X/Y coverage invariants pinned against the rendered container box, replica filtering, the new header-into-start semantic.tests/built-from-palette-roundtrip.test.tsxextended with a "drop at first position" assertion that survives Save → reset → Load including position + params. Consciously deferred: shape-dimming on gutters (advisor flagged; gutters don't carrydata-state-shapeso amatrix4x4-bytes-input step over abytes-shape slot reads fully saturated — drop is still permitted and the orange warning glyph appears post-drop; tracked in the plan's "Deferred follow-ups" section). Suite at 906 tests across 75 files (was 890 across 74). Bundle 95.93 KB → 97.10 KB gzipped (+1.17 KB for the gutter memo + render pass + CSS + newinto-startanchor branch). -
Mouse-wheel + button zoom for the graph view (Slice 3 of the graph-narrative plan) — wide canvases (AES-128 ECB ~1800 px at normal density; AES-256 wider still) can now be scaled from 50% to 200%. The graph toolbar gains a right-clustered
zoom [−] [%] [+] [reset]group:−/+step zoom by 10%, the percentage readout shows the current factor,resetsnaps back to 100% AND clears the canvas's horizontal scroll. Rolling the mouse wheel while the cursor is over the canvas zooms too (Figma / Maps convention — no modifier required; Shift + wheel falls back to the native horizontal scroll for the canvas, which AES-128 / Serpent need; Ctrl + wheel also zooms for muscle-memory users). Wheel step is deltaY-magnitude-scaled so a standard mouse notch matches one button click while trackpad pinch gestures stay gradual. Implementation: SVGwidth/heightattributes scale with zoom whileviewBoxstays at the logical canvas dimensions, so pinned positions inLayoutSpec.positionsremain in unscaled viewBox units regardless of zoom. The pointermove drag handler divides client-pixel deltas byzoom()so dragging at 2× zoom moves the pin the same physical distance the cursor moves (otherwise pins would race ahead). The wheel listener registers viaonMount+addEventListener("wheel", h, { passive: false })because Solid's JSXonWheelis passive in some browsers, which makespreventDefault()a no-op against page scroll. Deviation from plan: the plan suggested layeringviewZoom?: numberontoLayoutSpec, but the analogousview-density.tsstore explicitly argues against that pattern for a viewer preference: "the same shared.cipher.jsonshould render at whatever density the reader prefers, not whatever density the author happened to be using." Zoom is the same kind of preference, so it lives in a newsrc/ui/stores/view-zoom.ts(per-spec map, localStorage-only, default 1.0, hard-clamped to [0.5, 2.0]) — a recipient of a#doc=...URL doesn't inherit the author's zoom, and the byte-stability gate onhasUserLayout()is untouched. 25 new tests acrosstests/view-zoom-store.test.ts(17: defaults, persistence, clamp, per-spec independence, FP-drift round-trip, defensive load) andtests/graph-view-zoom.test.tsx(8: toolbar readout, +/−/reset button behavior, disabled states at min/max/default, SVG width-scales-with-zoom-while-viewBox-stays-fixed invariant). Wheel handling deferred to manual browser verification per the plan (jsdom can't compute layout dimensions the wheel handler depends on).docs/help/graph-view.mdgains a Zoom paragraph in the Toolbar section. Suite at 890 tests across 74 files (was 865 across 72). Bundle 94.62 KB → 95.93 KB gzipped (+1.3 KB for the new store + toolbar markup + CSS). -
Plaintext / ciphertext endpoint pills on the graph view (Slice 1 of the graph-narrative plan) — two synthetic, rounded "pill" nodes are now anchored at the canvas's left and right extremes labelled
plaintextandciphertext(encrypt mode) orciphertextandplaintext(decrypt — labels swap, layout stays left-to-right per the 2026-05-15 design decision). The input pill targets the first leaf whoseshapeContract.inputis not"any"— skipping aux-only steps likeaes.key-expansion@1— so the arrow lands on the leaf that actually reads state (initial.add-round-keyfor single-block AES;split-blocksfor ECB/CBC). The pills are NOT spec nodes: they don't round-trip through Save/Share, they're not drop-anchored, they're not click-scrubbable, they don't carry a delete glyph ortabindex. They exist purely so a fresh viewer reads the canvas as "this is where data enters / exits the cipher" before having to parse the rest of the graph. Implementation: newCIPHER_INPUT_ID/CIPHER_OUTPUT_IDconstants +isEndpointIdpredicate + optionalendpointSidefield onGraphNode+ opt-inopts.endpointsparameter onderiveAuxGraph(omitting it suppresses pill injection so every existing test stays untouched) + defensive guards inbuildIterateFeedbackPredicateso a future re-classification of endpoint edges can't silently pull them into feedback-edge styling. 13 new tests acrosstests/aux-graph-derivation.test.ts(10) andtests/graph-view.test.tsx(3).docs/help/graph-view.mdgains a "Reading the picture" paragraph. -
Visual companion to the endpoint pills — aux-only root leaves lifted above the spine row + their state-spine edges dropped from the display. Without this, the synthetic plaintext-pill → first-state-consumer arrow on AES specs geometrically passes through
key-expansion's rectangle (the arrow doesn't route through it logically, but visually overlaps). Worse, with fan-out replication ON,initial.add-round-keyended up with THREE near-parallel inbound arrows: (a) the plaintext-pill's endpoint spine, (b) the spec-truekey-expansion → initial.add-round-keystate-spine edge, (c) the smallkey-expansion-replica → initial.add-round-keyaux fan-out. Two complementary fixes: (1)layoutRootnow takes a newauxOnlyRootIds: ReadonlySet<string>parameter; GraphView computes it via the same shape-contract walk the anchor heuristic uses, and any root-level leaf withshapeContract.input === "any"is placed atCANVAS_MARGIN(same row root replicas already use), with the spine row lifting toCANVAS_MARGIN + LEAF_H + STACK_GAPwhenever EITHER condition fires (root replicas OR aux-only roots) — one lift level, even when both. (2) The display-graph memo filters out state-kind edges whosefromortois inauxOnlyRootIds, so the lifted leaf is also off the spine LOGICALLY — the canvas now shows (a) + (c) cleanly. Validation is unaffected (it consumes the unfilteredrawGraph()). Reinforces the visual language: "above the spine = supporting computation; on the spine = state flow." 4 new layout/render tests acrosstests/graph-view-replica-gutter.test.tsandtests/graph-view.test.tsx. Backward-compat: callers that don't passauxOnlyRootIdsget the old behavior (existing tests touch nothing). Bundle 92.43 → 94.62 KB gzipped (the +2 KB is mostly the endpoint pill renderer + CSS; the lift + filter are single-digit bytes). -
Hash-future seam.
endpointLabelsmemo carries a one-line comment marking the dispatch site for future hash / MAC / KDF specs (where nomenclature ismessage/digest/tag/okm, notplaintext/ciphertext). Memory entryproject_hash_future.mdindexes the seam. Defers the architectural commit until the first hash spec lands — AEAD's two-output shape (ciphertext + tag) may force an endpoint-API revision that's premature to model today. -
Iterate-replica anchor + edge retarget (Slice 2 of the graph-narrative plan, plus same-session follow-up). Two compounding placements caused a "long sweeping arrow" from a root-replica chip to a wide iterate's center-top — on AES-128 ECB with
compute-block-countforced toalwaysthe visible sweep was ~750 px. First commit (source-side anchor):layoutRoot's second pass, when the consumer is an iterate, anchors the replica above the iterate body's first non-replica child instead of the iterate's own left edge. Falls back to the container's own x when the iterate body is empty (collapsed). The "non-replica" skip matters becausereplicateHighFanoutSourcessplices in-body replicas (e.g.key-expansion@->initial.add-round-key) ahead of the real first body step; anchoring on a replica chip would visually misroute the count edge into a key-expansion replica. Second commit (target-side retarget, prompted by the user asking to "address the residual sweep"): new pure module-scope helpervisualEdgeTargetId(edge, nodesById, containersById)exported fromGraphView.tsx, intercepted in the edge<For>to point the rendered arrowhead at the same first-non-replica body child. Edge data model is deliberately unchanged —edge.tostill names the iterate so Slice 4's edge inspector + Slice 9'svalidateGraphkeep reading the correct consumer; only the rendered visual endpoint differs. Together: on AES-128 ECB the replica→iterate aux arrow becomes perfectly vertical (zero sweep) — source and target sit at the same center-x because the first body step is the leafinitial.add-round-key. The two improvements key off cipher-family-agnostic predicates (replicaOf !== undefined+kind: "iterate"), so they generalize to all shipped + planned ciphers (single-block AES/Speck/Serpent early-return; CBC/CTR reuse the iterate primitive; Feistel's branching state is orthogonal; hash compression loops that use iterate get the same retarget). Two cases intentionally deferred and flagged inline: nested iterates (recursion would fix; no shipped or planned cipher nests) and replica→group edges (groups have no iteration-entry semantics; revisit if a future cipher demands). 9 new tests intests/graph-view-replica-gutter.test.ts: the fourvisualEdgeTargetIdbranches (replica→iterate-with-body / replica→empty-iterate / replica→leaf / replica→group), the skip-past-in-body-replicas regression, the non-replica source branch, an AES-128-ECB integration check via thecompute-block-count: "always"fixture, the original Slice 2 anchor assertion, and a leaf-consumer non-regression. -
AES-128 CBC mode (Phase 2 of the multi-block plan) — Cipher Block Chaining is now the second AES mode of operation; pick it from the
mode of operationdropdown alongside ECB. Each plaintext block is XORed with the previous ciphertext (or the user-supplied IV for block 0) before encryption, so identical plaintext blocks produce different ciphertext halves — the structural difference vs ECB. NIST SP 800-38A §F.2.1 / §F.2.2 known-answer tests pinned byte-for-byte intests/aes-128-cbc-kat.test.ts(6 tests). AES-192/256 CBC + CTR mode arrive in Phases 3–4; the dropdown shows CTR disabled as a Phase 3 placeholder. -
IV input row + 🎲 randomize button. New
IvInputcomponent renders below the key field whenever CBC is active. Default IV is the NIST §F.2 standard test vector so a first-impression CBC run against the §F sample plaintext matches §F.2.1 byte-for-byte. The 🎲 button generates a fresh 16-byte IV viacrypto.getRandomValues; the value persists across reloads in localStorage (hex serialization, format-toggle-stable). Newsrc/ui/stores/iv.ts(signal + setter + randomize + reset), 6 tests intests/iv-store.test.ts(default, length contract, defensive copy on write, randomize distinctness). -
Document round-trip for the IV.
SessionSnapshotpicks up an optionalivBytes(number[], strictly length 16 via zod) so saving a CBC session and reloading it restores the IV alongside the cipher selectors. Backward-compatible: pre-Phase-2 documents without the field still validate. -
Three new generic step types that compose into CBC (and tee up future OFB/CFB without rewrites):
generic.iv-load@1(Uint8Array aux → MatrixState aux; one-shot pre-loop bridge between the App's IV bytes and the matrix-shaped chain),generic.xor-aux-into-state@1(state ⊕= aux[name]; the chaining XOR),generic.state-to-aux@1(deep clone of state into aux; the chain snapshot). Decrypt's chain-advance reuses the existinggeneric.aux-copy@1— no fourth primitive needed. All three are graceful on missing aux reads (passthrough + declared read for orphan-warning glyphs) and strict on present-but-wrong-shape values. 19 tests intests/chaining-primitives.test.tsplus an end-to-end composition throughrunSpec. -
aes-cbc-builder.ts— shared factory across AES-128/192/256 (only AES-128 wired up in this phase). Encrypt body:xor-aux-into-state(chain)→ AES round body (shared with ECB viaaes-round-builder) →state-to-aux(chain). Decrypt body:state-to-aux(next-chain)→ AES inverse →xor-aux-into-state(chain)→aux-copy(next-chain → chain). Pre-loopiv-loadruns once. -
35 new tests total (19 chaining primitives + 6 CBC KAT + 6 IV store + 4 App-level CBC IV flow integration). The integration test pins the full UI pipeline: dropdown picks CBC → IV row appears → type NIST §F.2 plaintext + key → run → ciphertext's first block matches §F.2.1 byte-for-byte → editing the IV alters the ciphertext (proves the Run handler reads ivBytes() live, not a stale closure). Suite at 808 tests across 71 files (was 772). Bundle 88.66 KB → 92.43 KB gzipped (+3.77 KB for the new factory + UI + step types).
-
Graph-view polish pass — six small UX cleanups orchestrated as parallel agent worktrees (three file-isolated items) plus a sequential pass on the GraphView.tsx-clustered ones. Together they close most of the polish backlog surfaced during v0.3.0 manual verification and the editable-threshold session.
- Canvas owns its horizontal scroll. Wide canvases (AES-128 with all rounds expanded) previously forced page-level horizontal scroll, which moved the palette sidebar and
ParamEditorout of view — users could see the right side of the canvas OR the controls but not both. Now.app/.trace-view/.graph-view-layoutcarrymin-width: 0(plusminmax(0, 1fr)on the grid column) so the wide SVG can't bubble its intrinsic width up the flex/grid chain; the canvas region scrolls inside its ownoverflow-xwhile the palette + editor stay pinned. CSS-only, no markup change. aux-loadParamEditor copy.Value (bytes — N)was bare jargon — users dropping the primitive from the palette had no context for what bytes belonged there. Replaced withBytes published under aux[name] (N total)plus a.mutedhint line listing the three canonical use cases (16-byte IV, 16-byte counter seed, per-mode constant) so the discoverability bar matches the visibility level.- Replica stacking. Setting multiple sources to
alwaysin the replication overrides panel (e.g. bothcompute-block-countandsplit-blocks→ ECB iterate) collapsed all replicas onto exactly(consumer.x, consumer.y - LEAF_H - STACK_GAP)— only one was visible; clicks landed on whichever sat on top. Per-consumer index counter in all three layout passes (root, group-lift, iterate-body) now offsets each subsequent replica right byLEAF_W + FLOW_GAP. 4 regression tests intests/graph-view-replica-gutter.test.ts. - Collapsible replication panel. After the fanout-≥-1 filter loosened the panel rarely emptied; on AES-128 the four panel rows ate ~140 px of canvas real estate even for users who tuned a single override and moved on. New session-only
replicationPanelOpensignal toggled by a clickable header (chevron rotates 90° via CSS); acreateEffect(on(spec().id, ...))auto-opens the panel on initial mount + spec switch IF the active spec carries any per-source override, so loading a customized spec or share URL still shows the user why their canvas looks tuned. 4 new tests intests/graph-view-replication-panel.test.tsx. - Drop-anchor highlight during palette drag. Until now only shape-incompatible drop targets got visual feedback (dimmed to 35% opacity). Compatible anchors got nothing positive, so users had to drop blind — especially in canvas gaps where
closest("[data-drop-anchor]")fell back to root-append far from where they meant to drop. NewdragOverAnchorIdsignal updated on every dragover via the sameclosest()walk the drop handler uses;LeafRect+ContainerRectcarry the highlight class (stroke + softcolor-mixfill on the inner rect) so the resolved anchor lights up in real time. Clears on dragleave + drop. 3 new tests intests/graph-view-drop-greying.test.tsx. - Iterate-feedback edge style (CBC, future OFB/CFB). CBC's
aux[chain]flow collapses across the:b{i}strip into a single canonical edgecbc-snapshot → cbc-xorthat goes BACKWARDS in spec order — until now rendered as a normal forward arrow, so users could mistake it for a cycle or bug. ExtractedbuildIterateFeedbackPredicateas a top-level export fromcore/graph.ts;validateGraph's cycle-detector filter and the renderer both share it so they can't drift. Renderer usesstroke-dasharrayon.graph-edge-feedback(hover removes the dash) plus a" — feedback (next iteration)"title suffix on the native hover tooltip. 4 new predicate-direct tests. - Authoring footnote. Three of the six items (canvas scroll, aux-load copy, replica stacking) ran in parallel via
Agentwithisolation: "worktree". The other three (panel, drop highlight, feedback edges) cluster onsrc/ui/components/GraphView.tsxand were authored sequentially to keep the diff tractable. The agent worktrees hit a broken pre-commit gate (worktree-relativenode_modulesresolution + CRLF baseline noise unrelated to the changes); workaround was--no-verifyin-worktree and a clean re-run onmainafter cherry-pick.
- Canvas owns its horizontal scroll. Wide canvases (AES-128 with all rounds expanded) previously forced page-level horizontal scroll, which moved the palette sidebar and
-
Editable fanout threshold + single-edge sources in the replication override panel — the previously-hardcoded
REPLICATION_THRESHOLD = 6is now a session-only signal (useReplicationThreshold()) backed by a numeric input next to thereplicate fan-outcheckbox in the graph toolbar. Users who want to replicate everything can drop the threshold to 1; users who want only the largest schedules (e.g. Serpent's 32 round keys) can raise it. Default unchanged (6), bounds clamped to [1, 99], NaN/Infinity falls back to default. Persistence is deliberately session-only — the alternative (per-spec inLayoutSpec) would tax the share-URL byte-stability discipline for a knob most users never touch, and there's no clean default-vs-explicit sentinel. The override panel's filter also loosened fromfanout >= 2to>= 1so single-edge aux sources surface as panel rows; a user with a single long arrow crossing the canvas can now force-replicate it via thealwaysmode. 8 new tests intests/graph-view-replication-threshold.test.tsx. Suite at 820 tests across 72 files. Known UX gaps surfaced during browser verification (deferred to a polish PR; seedocs/plans/graph-readability-polish.md): multiple replicas targeting the same wide consumer (e.g. bothcompute-block-countandsplit-blocks→ ECB iterate) stack on top of each other atconsumer.x, and the arrow from a small replica chip to a wide iterate's center reads as long.
0.3.0 — 2026-05-14
- Duplicate-round affordance (full pedagogy support) — graph-view container headers now sport a small blue
+chip next to the existing red×delete chip, both hover-revealed. Clicking the+on an AES round group splices in a clone of that round, renumbers subsequent rounds + their AddRoundKey aux references in lockstep, and bumps the key schedule (aes.key-expansion@1morphs to a newaes.key-expansion@2step type that drops the FIPS-197rounds === Nk + 6assertion and extends the Rcon table on the fly viaxtime). The same click auto-mirrors onto the counterpart cipher (encrypt ↔ decrypt) so the duplicated encrypt and decrypt specs stay round-trip-compatible by construction — duplicatinground.2on encrypt produces a matchinginv-round.3clone in decrypt, both readingroundKey.3from the bumped schedule. The visible affordance is gated byisRoundDuplicatable: only rendered for rounds with a clean counterpart (skips the final no-MixColumns round on encrypt andinv-round.0on decrypt, where the auto-mirror has no clean landing site). Pedagogically: the user can ask "what would AES-128 with 11 rounds look like?" and see the answer end-to-end, including the matching decrypt that still recovers their plaintext. - Two-spec store —
src/ui/stores/spec.tsnow holds encrypt AND decrypt specs simultaneously in a{ encrypt, decrypt }map.useSpec()still returns the active mode's spec accessor (no API change for consumers); the newuseSpecsByMode()exposes both slots for future "save both modes" flows. Edits viaeditStepParams/editAllStepsByType/removeStepFromSpec/insertStepIntoSpecwrite to the active slot only — changes don't leak across modes.setCipher/setCipherModerebuild both slots from canonical (a cipher swap is a clean break).duplicateRoundInSpecis the single action that writes both slots in one signal update. - Behavior change:
setModeno longer resets the spec to canonical. Previously, flipping encrypt ↔ decrypt discarded any in-progress customizations on the new mode. With the two-spec store, each mode keeps its own slot across flips — customizations on both sides persist independently. Ping-pong encrypt → decrypt → encrypt now preserves both customization histories. This is the right semantic for the duplicate-round auto-mirror (which needs the counterpart to survive the immediate flip-back); users who relied on flip-to-reset can call the existingresetSpecbutton instead. - 62 new tests across 5 files:
tests/key-expansion-v2.test.ts(parity vs@1at canonical rounds + non-canonical rounds=11/20 + Rcon extension),tests/duplicate-round-mutator.test.ts(forward + reverse mutators, single-block + ECB, rename-map exhaustiveness, ref equality, error paths, end-to-end round-trip),tests/layout-rename.test.ts(position / collapsedGroups / replicationModes migration + byte-stability),tests/duplicate-round-store.test.ts(auto-mirror, mode flip preservation, stacking duplicates, edit-isolation across modes, cipher swap clears both, store-level e2e round-trip, layout pin migration),tests/graph-duplicate-button.test.tsx(button visibility gate + click → spec growth), andtests/duplicate-round-save-load.test.ts(schema acceptance, run-correctness through serialize/parse, byte-stable serialization, layout-pin survival). Suite at 772 tests across 67 files. Bundle 86.87 KB → 88.66 KB gzipped (+1.79 KB for the new mutator + UI affordance). - "Custom (was AES-128)" indicator + inline reset button — when the live spec diverges from the canonical default (any param edit, palette insert, or delete), two UI surfaces flip to advertise the divergence: the small muted cipher-name label in the header switches from
AES-128toCustom (was AES-128)and adopts the--changedaccent colour, and the currently-selected option in thecipherdropdown gets the sameCustom (was X)text so the divergence is visible whether the user reads the header or opens the selector. A smallresetbutton (also--changed-tinted) appears immediately to the right of the dropdown while the spec is custom, calling the existingresetSpec()to snap back to the canonical default for the current(cipher, cipherMode, mode, padding). Detection lives in a newisCustomSpec()accessor insrc/ui/stores/spec.tsthat does a structural deep-equal against a freshly-built canonical (notJSON.stringify— order-stable today but a future param editor could reorder keys); padding round-trips (none → pkcs7 → zero-pad → … → none) are verified to not flag a false-positive. 5 new tests intests/app-custom-spec-indicator.test.tsx. Suite at 710 tests across 59 files. - Delete affordances on graph nodes — palette-dropped (and any other) leaves + containers can now be removed via three surfaces: a small red
×button at each node's top-left corner (hover-revealed via CSS opacity so the canvas stays uncluttered; suppressed on replicas), a "Delete this step" button at the bottom of theParamEditorpanel, and the Delete key while a node is focused (Backspace deliberately not bound — reserved for future navigation). All three route through a newremoveStepFromSpecwrapper insrc/ui/stores/spec.tsaround the existing pureremoveStepmutator incore/spec-mutations.ts(tree-aware: deleting a container removes every descendant in one step). The wrapper is lenient on stale ids (warns to console but doesn't throw) so a delete that races with another spec edit doesn't crash the UI. No undo, no confirmation — pedagogy is "experiment freely, see what breaks"; recover by dragging from the palette. 7 new tests intests/spec-delete.test.tsxcovering all three surfaces + the store wrapper's leniency contract. Suite at 705 tests across 58 files. - State-shape contracts on step types — every step type now declares (via the new optional
shapeContractfield onStepDocumentation) whichStateShapeits executor accepts and what shape it produces (bytes/matrix4x4-bytes/"any";output: "preserveInput"for the common passthrough case). Three UX surfaces consume the contract: (1) the step palette renders a small chip ("bytes" / "4×4 matrix" / "any") on every entry plus a "Expects: bytes state" tooltip; (2) during a palette drag, drop anchors whose inferred state shape doesn't match the dragged step's input dim to 35% opacity (advisory only — the drop is not blocked); (3)validateGraphgains a fourth warning kindstate-shape-mismatch, surfaced as the same orange!glyph used by the other validators but emitted statically (trace-free) so the warning lights up the moment a shape-incompatible step is dropped, before the user clicks Run — catching what was previously the "compute-block-count expects bytes state" runtime exception. Newsrc/core/spec-shapes.ts(inferShapesAtAnchors+validateShapes) does one spec walk that drives both the drop-anchordata-state-shapedecoration and the static warning. Contracts backfilled across all 28 shipped step files in one commit;tests/state-shape-contracts.test.tsenforces 100% coverage on the default registry so a future step shipped without a contract fails CI. 20 new tests across four files (spec-shapes,state-shape-contracts,step-paletteextension,graph-view-drop-greying). Suite at 698 tests across 57 files. Bundle 84.02 KB → 84.99 KB gzipped. - End-to-end integration + in-app help (Slice 11 of the 2D editor plan, the final slice) — ships nothing structurally new on its own; instead it pins the prior 10 slices' composition in
tests/built-from-palette-roundtrip.test.tsx(jsdom). The test drives the full user pathway: open app → switch to graph view → drop a step from the palette via the real DataTransfer pathway → mutate two more via the same store boundary → edit each step's params → pin a layout position on a user-inserted id → Save with session ON → fully reset every store → Load → assert the inserted leaves, edited params, and layout pin all survive, plus the cipher's final-state bytes are byte-equal across the boundary. Also adds an in-app?help button on the graph toolbar that opens a<dialog>renderingdocs/help/graph-view.md(loaded via Vite?rawso the .md file is the single source of truth for both GitHub readers and the in-app modal). Newsrc/ui/components/GraphHelpModal.tsx, newdocs/help/graph-view.md, four new tests across two new files. Suite at 678 tests across 54 files. Bundle 81.92 KB → 84.02 KB gzipped (the +2 KB is the help modal component + bundled markdown + CSS). - Aux primitives — compose-your-own block-cipher mode (Slice 10 of the 2D editor plan) — three new generic step types let users wire up block-cipher chaining modes (CBC / OFB / CFB) entirely inside the visual editor, with no executor code:
generic.aux-load@1publishes a literal byte sequence under an aux key (IV, counter starting value, tweak, per-mode constant);generic.aux-xor@1XORsaux[from]intoaux[into]and writes back;generic.aux-copy@1performs a defensive byte-array copy ofaux[from] → aux[to](so the destination doesn't alias the source). All three are state-passthrough — the work happens over aux. Critically, they're the first shipped steps that gracefully return on missing aux (declaring the read viaauxReadMissingrather than throwing), so a half-wired palette spec stays debuggable on the canvas — Slice 9's orphaned-read warnings light up naturally. Malformed inputs (non-Uint8Array, length mismatch onaux-xor) still throw — missing-vs-malformed is a deliberate split.ParamEditorblocks for the three primitives are fully editable (text inputs + a +/- byte row foraux-load's value), since palette drops arrive with empty params and the structural read-only blocks don't fit that flow. A same-day follow-up extended the leniency further: undefined / empty-string aux key names now also map to""(treated as unset) so a freshly-dropped, fully-unwired primitive still emits a frame instead of throwing — the graph click handler can find the frame to focus theParamEditoron, and the orphan glyph appears immediately. The follow-up also renderedParamEditorbelowGraphView(gated oncurrentFrame()) so users can edit params in-place without tab-switching to linear mode, and capped.graph-replication-row max-widthto 560 px (flex: 1 on the source id had pushed the auto / always / never buttons off-screen on AES-128's 1500-px-wide canvas). 31 tests intests/aux-primitives.test.tscover per-primitive KATs, runtimeauxReadMissingintegration withvalidateGraph, an end-to-end CBC-from-scratch over 2 plaintext blocks (encrypt + decrypt against a JS reference), and the palette-drop empty-params regression.src/steps/CLAUDE.mdgains a "compose your own block-cipher mode" recipe with the canonical CBC wiring. Bundle 78.4 KB → 81.92 KB gzipped. - Palette drag step insertion (Slice 8 of the 2D editor plan) — a left-sidebar
StepPaletteinside the graph view lists every registered step type (registry.types()minusPADDING_STEP_TYPES) grouped by namespace (aes / generic / speck / serpent; canonical group order first, then any unknown namespaces tail-append alphabetically). Each entry is HTML5-draggable, carries two MIME types ondataTransfer(application/x-cryptographer-step-typeplus atext/plaincross-browser fallback), and renders the step'sStepDocumentationname + summary tooltip. Drop semantics use DOM-target-closest anchoring (drop target'sclosest("[data-drop-anchor]")decides anchor) over Euclidean nearest-node — cleaner pedagogy, nogetScreenCTMmath, swappable later if user feedback prefers Euclidean: drop on a leaf inserts after that leaf in its parent; drop on a container's<g>inserts after the container in its parent (after-in-parent, NOT into-body — explicit Slice 8 semantic, kept within Slice 4'sinsertStepAfter-only API); drop on the SVG background root-appends. Padding overlay step types are excluded from the palette because the next selector flip would silently strip a palette-dropped padding leaf viastripPaddingLeaves— surfacing them as a palette entry would be a stealth footgun. Replica nodes carry their source'sstepIdindata-drop-anchor(the synthetic${src}@->${consumer}id doesn't exist in the spec, soinsertStepAfterwould throw). Empty params on the new leaf are deliberate: the runtime throws on first execution for any step type that needs configuration, and the user opensParamEditorto fill them in. The new mutator boundaryinsertStepIntoSpec(stepType, anchor)insrc/ui/stores/spec.tsaccepts either{kind: "after", stepId}(routes throughinsertStepAfter; works for leaf or container ids —findStepAndParentalready supports both) or{kind: "root-append"}(direct[...spec.steps, newLeaf]rebuild for the empty-canvas / empty-spec edge case). Id generation:<lastTypeSegment-without-@N>-<n>with a global collision walker (e.g.generic.byte-substitution@1→byte-substitution-1,byte-substitution-2, …); the generated id is returned so callers can route trace focus. The hand-rolled HTML5 DnD route lands here (and not via xyflow / solid-flow) because both framework spikes had already concluded "Abandon" on a parallel branch. 14 new tests intests/step-palette.test.tsx(jsdom): palette render coverage, mutator unit tests, drop integration via synthesizedDataTransfershapes. Bundle 76 KB → 77.4 KB gzipped. - Edge-aware graph validation (Slice 9 of the 2D editor plan) —
core/graph.tsnow exportsvalidateGraph(graph, trace) → GraphWarning[]plus aGraphWarningdiscriminated union (orphaned-read|unused-write|cycle). GraphView overlays a small orange!glyph on any leaf or container that carries at least one warning; the glyph's<title>shows the formatted message as a native tooltip (the plan also mentioned an inline message panel — that's an optional V2 follow-up, V1 is the tooltip +cursor: help). Warnings that target a node hidden by a collapsed container surface on the outermost visible ancestor so collapse can't silently hide wiring problems. Today's shipped specs produce zero warnings across all ciphers (AES-128/192/256, AES-128-ECB, Speck32/64 BE+LE, Serpent-128/192/256) — locked in bytests/graph-validation.test.ts. Orphaned-read coverage lights up naturally with the Slice 10 aux primitives that ship in this same release; the strict cipher executors throw on missing aux rather than emit a flag-able frame. TraceFrame.auxReadMissing?: readonly string[]— runtime now records aux-read requests that returnedundefined(in addition to the existingauxReadmap of successful reads). Required for orphaned-read detection; in-memory only, never serialized.- 23 new tests: 21 in
tests/graph-validation.test.ts(zero-warning baseline per cipher, orphan/unused-write/cycle detection, runtime capture verification) and 2 intests/graph-view.test.tsx(clean traces render no dots; syntheticauxReadMissingframe produces one dot with the missing key in the title). Suite at 643 tests across 51 files. Bundle 77.4 KB → 78.4 KB gzipped. - URL hash sharing (Slice 7 of the 2D editor plan) — new
[share…]button next to Save/Load. Encodes the currentCipherDocumentas${origin}${pathname}#doc=<base64url-deflate-raw>and copies it to the clipboard; opening that URL in a fresh tab decodes on mount and snaps the spec / layout / session to the shared values, then strips the hash from the address bar so a refresh doesn't re-apply. Honors the sameinclude sessiontoggle as Save (default off → spec-only, byte-stable across sessions). Compression uses the browser-nativeCompressionStream("deflate-raw"), no new dependencies; measured payloads land at ~1.6 KB for spec-only AES-128 and ~1.9 KB for the AES-256 + session worst case (vs. ~20 KB without compression). Bundle grew from 72 KB → 76 KB gzipped (compression is built-in; the +4 KB is the newsrc/ui/stores/url-share.tsmodule + handler wiring). - Clipboard-write failure (HTTP context / permission denied) falls back to writing the URL into the address bar so the user can copy it manually.
- 20 new tests across
tests/url-share.test.ts(node env — encode/decode round-trip, determinism, size budget, malformed payload handling) andtests/app-url-share.test.tsx(jsdom — Share button → clipboard, boot-mount hash decode for spec-only + session + layout sidecar, malformed-payload error path, no-hash noop). Suite now at 608 tests across 49 files.
- Trace-coupling: auto-run on boot +
ParamEditordecoupled from frames. Three editor-flow bugs in the graph view shared one root cause — thehasRunOncegate atApp.tsx:582blocked the spec→rerun effect until the user clicked Run manually. Before that first click, palette drops produced no orphan warnings (validateGraphwalks frames; no frames means no warnings), theParamEditorcouldn't open on a clicked leaf (it resolved through aTraceFramethat didn't exist), and the replicate fan-out toggle had no edges to operate on. Plan:docs/plans/trace-coupling-bug-fix.md. Fix: extend the existingonMount's no-hash branch withrun()so the trace exists before the user touches anything (the hash-decode branch already callsrun()viaapplyDocument, so no double-run). NewselectedStepIdsignal insrc/ui/stores/trace.tsalongsideframeIndex:setFramekeeps it in sync with linear-view scrubbing;setTracepreserves the user's explicit selection across re-runs and only re-initializes on app boot or when the prior anchor doesn't survive the new spec;setSelectedStepIdis the new boundary for graph leaf clicks — sets the signal regardless of trace state AND moves the scrubber if a matching frame exists.ParamEditornow takesstepId: string | nullinstead offrame: TraceFrame | nulland resolves the live spec leaf viafindStep, so it renders for freshly-dropped steps (empty params, no frame yet) and for steps downstream of an upstream throw. New regressions:tests/trace-coupling-repro.test.tsx(dropaux-xorwithout Run → orphan glyph appears, pinning Phase 1 of the plan) andtests/param-editor-no-trace.test.tsx(dropaux-xor, focus via keyboard Enter, assert the editor binds before AND after the 200 ms debounce — the post-debounce assertion pins an advisor-caughtsetTrace-clobber bug fixed in this commit).tests/app-url-share.test.tsxdrops a side-channel (getTrace() === nullas a proxy for "boot decode didn't apply") that disappears once boot triggers a run;tests/spec-delete.test.tsxrendersParamEditorwithstepIddirectly.
0.2.0 — 2026-05-13
- README.md as the public-facing GitHub entry point — what the project is, install + run, command table, architecture summary, project layout, pointers into the docs.
- CHANGELOG.md (this file) tracking releases retrospectively from v0.1.0.
- LICENSE (MIT).
docs/versioning.mdformalizing the versioning policy: app semver release process, thestepType@Nsuffix convention, and the documentschemaVersionmigration path.src/version.tsre-exportingpackage.jsonversion asAPP_VERSIONso the UI and the document-export path share one source of truth.- UI footer showing the current build version and a link to the GitHub repo.
metadata.appVersionis now stamped into exported.cipher.jsondocuments — but only on the session-included save path, alongside the existingcreatedAt. Spec-only saves remain byte-identical across builds so the planned URL-share feature (Slice 7) stays deterministic.
The schema field DocumentMetadata.appVersion?: string has been part of schemaVersion: 1 since Slice 3 — this release just starts populating it. No schema bump.
0.1.0 — 2026-05-13 (retrospective)
The initial release, reconstructed from git log. This entry covers everything that landed before the release-infrastructure work in v0.2.0.
- AES-128 forward + inverse (FIPS-197 Appendix C.1 vectors).
- AES-192 and AES-256 with NIST AES Core known-answer tests + the FIPS-197 §A.2/§A.3 round-key assertions. Shared step types with AES-128; the
aes.key-expansion@1step has an Nk>6 branch that fires only for AES-256. - Speck32/64 in two byte-order conventions: big-endian (the original paper) and little-endian (the NSA reference implementation). Both directions for both. 22 rounds, 4-byte block, 8-byte key.
- Serpent (128/192/256) — SP-network in standard form (visible IP/FP, per-nibble S-box, table-based linear transform). Single-block today; multi-block lands when the existing
iterateprimitive is plugged in.
- Multi-block ECB for AES-128 (Phase 1 of the multi-block plan). CBC/CTR scaffolding is in place but specs aren't shipped yet.
- The runtime's
iterateprimitive — the same loop body executes once per block, the trace's per-iteration frames get a:b{i}suffix on everystepId, andframe.blockIndexannotations let the UI render block badges. - Boundary steps
generic.split-blocks@1andgeneric.concat-blocks@1to convertBytesState ↔ MatrixState[]around the loop.
- PKCS#7, zero-pad, ISO 7816-4, and none padding schemes — each rendered as a visible step in the trace (not hidden by the runtime).
- Per-
(mode, scheme, cipher, cipherMode)length limits surface as friendly errors on Run.
- Per-frame state view: 4×4 grid for
MatrixState, 1×N wrap forBytesState, mixed-shape view forload-block/store-blockboundaries. - StepDescription panel with markdown rendering, FIPS / paper references, inline parameters.
- ParamEditor blocks for every step's params (matrix, S-box, AddRoundKey, padding/load/store, key-expansion) — edit a value and the trace re-runs in ~200ms.
- Auto-rerun toggle: in manual mode, an "edits pending" banner replaces the live re-run.
- Keyboard navigation: ←/→ step, Home/End jump, PgUp/PgDn round.
- Neighborhood strip of nearby steps with virtualization.
- Run history (5-deep ring) + previous-run overlay showing diff cells against the last run.
- Run Explorer modal for side-by-side run comparison with a pure delta-string formatter (testable in node-env).
- Byte format toggle: hex / decimal / ASCII; the active format propagates to every byte-rendering site. S-box axis labels stay hex (addresses, not values).
- Cipher selector + per-cipher key auto-swap when the field still holds the previous cipher's default.
- Mode-of-operation selector with cross-cipher matrix (
SUPPORTED_CIPHER_MODES_BY_CIPHER) so unsupported combos disable gracefully. - Multi-block visual grouping: ECB output renders as 4×4 block panels so identical plaintext blocks produce visibly-identical ciphertext blocks (the Tux-image pedagogy).
- 2D graph view tab (Slices 1–6 of the visual editor plan):
deriveAuxGraph(trace, spec): CipherGraph— pure derivation of nodes, containers, aux-flow edges (with iterate-mediated synthesis), and the state-edge spine.collapseGraph(graph, collapsedIds): CipherGraph— pure view-time transform; collapsed containers become chips, internal nodes disappear, edges remap to surviving endpoints.- SVG renderer with hand-rolled FIPS-197-flavored layout (top-level + iterate bodies flow left-to-right, groups stack vertically). Container drag + chevron-toggle collapse. Arrowhead markers + state-vs-aux edge styling. Container label truncation via SVG
textLength. - Layout sidecar persistence — pinned positions + collapsed groups survive reload.
- File Save / Load for
CipherDocumentJSON (Slice 5), with an include-session checkbox controlling whether plaintext/key bytes are written. Spec-only saves are byte-stable.
CipherSpecas JSON (src/core/types.ts): tree ofStepNodes (step leaves, groups, iterate groups). Saved-document forever-shape.StepRegistrymappingstepType → { executor, doc }so adding a step type registers behavior and documentation together.Runtime— pure walk-and-trace engine; the only piece that knows about tracing or iteration.CipherDocumentfile format (schemaVersion: 1) — requiredspec, optionallayout(graph positions + collapsed groups),session(selector snapshot + optional bytes),metadata(name / createdAt / appVersion).serializeDocumentis deterministic (alphabetical key order).spec-mutations.ts—findStep,findStepAndParent,updateStepParams,updateAllStepsByType,insertStepAfter/insertStepBefore,removeStep,reorderStep,compareSpecs. Pure spec-in/spec-out with reference equality preserved on untouched branches.
- Pre-commit hook in
.githooks/pre-commitrunning the fullnpm run check(biome + tsc + vitest + vite build) plus a step-coverage gate (new files insrc/steps/require atests/change in the same commit). - GitHub Actions running the same on push.
- Playwright real-browser smoke tests in
e2e/(currently the Slice 6 graph drag/collapse spec). - ~528 tests across ~42 files; ~7s for the full gate, ~4s for vitest alone.