Skip to content

Latest commit

 

History

History
337 lines (271 loc) · 308 KB

File metadata and controls

337 lines (271 loc) · 308 KB

Changelog

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).

Added

  • 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.md Phase 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 hardcoded 16 anywhere 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's fetch-iv reads 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 of aes-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:crypto cannot 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 $input was 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. No schemaVersion change.
  • AES-192 and AES-256 gained ECB and CBC (docs/plans/foamy-prancing-wren.md Phase 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's BlockCipherCore rather 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:crypto on a message NIST never published (tests/aes-192-256-modes-kat.test.ts). The plan called this "a table-only change, correct by construction" — but aesCore at 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.

Changed

  • Block-cipher modes, padding, and the IV stopped assuming a 16-byte block (docs/plans/foamy-prancing-wren.md Phase 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's BlockCipherCore via 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), paddingLimits derives 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 a BlockCipherCore to 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 with MatrixState back 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 in tests/block-size-generic-modes.test.ts rather than by the app. No schemaVersion change.

Fixed

  • The IV no longer keeps AES's width after you switch to a cipher with a different block size (docs/plans/foamy-prancing-wren.md Phase 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 what tests/iv-width-reconcile.test.ts pins.

0.8.0 - 2026-07-17

Added

  • 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) and g(ROL(R1,8)) feeding the PHT, F0/F1 landing on the two mix rails, and then the swap. The swap is the payload. A Twofish round ends with concat(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, which F each consumes, and each rotation named the way the Twofish paper names it (ROL 8, not the builder's equivalent ROR 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; the g boxes 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 a right_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 appends right_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 same aux["key"] channel the block ciphers use — no new plumbing — and is read back with aux-load-bytes@1, so it is a runtime input, never baked into the spec. The customization string S is live-editable (the function name N is 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.ts drives 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, the aux-load-bytes@1 read width, and encode_string(K)'s left_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.ts pins all four variants at key lengths 1/8/16/31/40 — spanning both left_encode width 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 in tests/spec-store-hash-branch.test.ts. No schemaVersion change.
  • 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-name N (reserved for NIST-defined functions; empty for direct use) and a user customization string S — so any change to S domain-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-185 encode_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), and right-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 shared src/ciphers/sponge.ts, a provably-inert refactor — buildShakeSpec JSON byte-identical across 14 variant×length checks). Only the pad's domain byte differs (0x04, merge 0x84), so no new pad step. The empty-customization branch is exact: with N and S both empty, SP 800-185 defines cSHAKE to equal SHAKE, so the builder emits no prefix and uses domain 0x1F — byte-for-byte a SHAKE pipeline. Both N and S are 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 as constant-load@1 params 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:crypto has 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 against node: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. No schemaVersion change.
  • 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 to node:crypto's shake128/shake256 across message and output lengths. They reuse SHA3-256's Keccak-f[1600] permutation + sponge absorb unchanged (extracted into a shared src/ciphers/keccak-f.ts first, 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 an iterate (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 n byte-slice extractions and n−1 Keccak-f permutations, no trailing wasted permute. Zero new step types: padding reuses keccak.pad@1 with domainByte: 0x1F (the executor already merges 0x1F ^ 0x80 = 0x9F generically), the rounds reuse the shared Keccak-f machinery, and the squeeze is byte-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 the squeeze.truncate step's length; applyDocument syncs the control from a loaded spec). Verified: tests/shake-kat.test.ts (98 KATs — published vectors + node:crypto across both the message-length and output-length axes, the 0x9F pad-merge case, and squeeze-block-count pins); tests/shake-graph-resolution.test.ts guards that every squeeze edge resolves in the value inspector. Store integration widens the Hash union + isHash + label/description/history/default maps (cipher.ts), HASH_IDS (document-schema.ts, whose compile-time assert enforces the widening), and resolveHashDefault builds SHAKE on demand at the editable length (spec.ts); a real-Chromium smoke confirmed the stepper rebuilds live and the graph renders without crash. No schemaVersion change. 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-mode iterate fold 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 single byte-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 new keccak.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 generic rotate-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-endian rotate-bits-right@1 is deliberately NOT used); π = a single permute@1 lane transposition (a 200-byte gather); χ = decomposed into permute/not/and/xor because it is Keccak's only nonlinear step and worth keeping visible (A'[x] = A[x] ⊕ (¬A[x+1] ∧ A[x+2])); ι = a new hybrid-ported keccak.iota@1 that XORs RC[round] (sliced from a shared 192-byte aux["RC"] table via meta.auxReadPorts, preserving the RC → ι fan-out in the graph) into lane 0. Padding is a new keccak.pad@1 (pad10*1 + the 0x06 domain byte — no length suffix, unlike SHA-256; the one-byte-short-of-a-block case merges the domain and trailing pad bits into 0x86). The entire decomposition was validated against node:crypto before any app code was written (feedback_crypto_verification): tests/sha3-256-kat.test.ts pins the published empty/"abc"/quick-brown-fox vectors AND a node:crypto cross-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.ts KAT-checks each of the four primitives from the FIPS 202 formulas. Store integration adds sha3-256 to the Hash union + label/description/history/default-plaintext maps (cipher.ts), the hashDefaults table (spec.ts), and HASH_IDS (document-schema.ts, whose compile-time coverage assertion caught the omission); four read-only ParamEditor blocks; a rotate-lanes@1 provenance-allowlist entry (per-lane rotation → byte-approximate); and a ConstantsPanel collectConsumers case 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 θ→ρ→π→χ→ι. No schemaVersion change. 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-mode group per round wiring split → g(R0) / g(ROL(R1,8)) → pseudo-Hadamard combine (two subkeys) → 1-bit rotations → concat, the Feistel swap expressed as the concat argument order — where the g function is four aux-fed byte→byte twofish.sbox-lookup@1 leaves (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 new gf-matrix-multiply@2 (generalizes @1 with a fieldModulus param, backed by gfMulPoly; @1 untouched, 0x11B default = AES parity). Words travel the ports big-endian so the generic add-mod-32@1 / rotate-bits-right@1 primitives apply unchanged; Twofish's little-endian serialization is honored by visible permute@1 word-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 of add-mod-32 / rotate-bits-right frames combining the h-outputs into the 40 subkeys (gathered by a twofish.publish-subkeys@1 aux-publish tail, allowlisted like the other *-publish-round-keys@1 tails), 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 opaque twofish.h-expand@1 step (publishing the A/B intermediates + the four byte→byte S-boxes to aux). Unlike Blowfish's silent monolith, h-expand carries 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 in portOutputs without 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 vector 9f589f5cf6122c32b6bfec2f2ae8c35a. tests/twofish-vectors.test.ts pins 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.ts adds the decrypt-KAT + round-trip; tests/twofish-narration.test.tsx drives both narrators off real trace frames (doubling as the check that h-expand actually publishes A/B/S and exposes the S-vector on its display ports). Store integration adds a TwofishCipher family to cipher.ts (union, labels, structural + historical one-liners, default key/PT/CT tables — the default lands on the Ferguson-verified df8451d2…3203), a defaults entry, the SUPPORTED_CIPHER_MODES_BY_CIPHER row (single-block only; cipher-mode-fallback canary updated), the paddingLimits 16-byte case, CIPHER_IDS, and four ParamEditor blocks (the three Twofish steps + gf-matrix-multiply@2 with 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. No schemaVersion change. 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 match analyzeFeistelRound, 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-mode group per round wiring split-bytes → xor-with-aux(P[i]) → F → xor → concat, the Feistel swap expressed as the concat argument order (DES precedent), with the F function ((S0[a]+S1[b]) ⊕ S2[c]) + S3[d] decomposed into four aux-fed blowfish.sbox-lookup@1 leaves (a new primitive — the S-boxes are key-derived, so the 32-bit-word table comes from aux, not params) plus add-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-ported blowfish.key-schedule@1 monolith (same meta.auxWritePorts publish-to-aux posture as the four *-publish-round-keys@1 tails + rsa.publish-key-params@1; it doubles as the KAT oracle) — while the key ⊕ P mixing IS visible as 18 real xor@1 frames (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.json and #doc= share URL. Verified against the Eric-Young / pycryptodome Blowfish-ECB vector set (feedback_crypto_verification): tests/blowfish-vectors.test.ts checks both the pure math oracle AND the full port-native spec through the runtime on five published vectors (all-zero, all-one, 0123456789abcdef/1111111111111111, …), and tests/blowfish-roundtrip.test.ts pins the reversed-P decrypt + encrypt↔decrypt identity. Store integration adds a BlowfishCipher family to cipher.ts (union, labels, default key/PT/CT tables — the default lands on the published 61f9c3802281b096 vector), a defaults entry, the SUPPORTED_CIPHER_MODES_BY_CIPHER matrix row (single-block only; the cipher-mode-fallback canary updated), the paddingLimits 8-byte case, CIPHER_IDS, and two read-only ParamEditor blocks. No schemaVersion change. Deferred: variable-length keys (4–56 bytes), multi-block ECB/CBC, and the canonical two-column Feistel graph layout (Blowfish's pre-F L ⊕ P[i] doesn't match analyzeFeistelRound's split→F→xor→concat shape, 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 / redo buttons 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-scope createEffect(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 are batch()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 watches mode, so the stack survives). Two real-browser details the design gets right: Ctrl+Z inside 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+letter e.key arrives 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 in e2e/exploratory-hash-and-edges.spec.ts (real Ctrl+Z / Ctrl+Shift+Z, editable bail, cipher-switch boundary). No schemaVersion change (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. No schemaVersion change (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 + a describeAlgorithm router in stores/cipher.ts) drives the header, right after the cipher name next to "Cryptographer" (always visible, shown regardless of isCustom(), truncating with an ellipsis + full-text title on narrow viewports) and each dropdown <option> / <select> title tooltip so the learner can compare structures while the menu is open. (2) A historical line (CIPHER_HISTORY / HASH_HISTORY / ASYMMETRIC_HISTORY + a historyOfAlgorithm router — 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 of CIPHER_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). No schemaVersion change.
  • 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's portInputs + the P/S it published to auxWritten. (2) The blowfish.sbox-lookup@1 F-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 to PortFlowView + each leaf's narrationOverride detail — 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.tsx drives both narrators off real trace frames (doubling as the check that the monolith frame actually publishes blowfish.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). No schemaVersion change.

Changed

  • 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, no schemaVersion change.
  • "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 / defaultStrokeThresholdFor return 1 universally and defaultStrokeStylingFor returns true universally (the old sha-256/rsa prefix sets are gone; DEFAULT_COLOR_THRESHOLD / DEFAULT_STROKE_THRESHOLD are 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 — no schemaVersion change.

Fixed

  • A group-to-group seedInput carry 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-level group seeds from another group'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 DES initial-permutation → rounds redundancy, reached through a group "out" rather than a leaf. The "does the seed already reach a descendant?" skip-check compared the raw seedInput node (a group id) while the resolved leaf edge's from is the deep producing leaf, so the skip never fired. Resolving the seed through resolveSeedChain before the check drops the redundant edge; SHA-256's blocks loop-input edge is unaffected (its seed is a leaf "output", and the chase stops at the iterate boundary). Pinned by tests/shake-graph-resolution.test.ts; tests/graph-arrival-dot-coverage.test.ts stays green across every cipher. Core only, no schemaVersion change.
  • 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 rounds group, and round.1.seedInput = port("rounds","in") while rounds.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 frameless port("rounds","in") container pseudo-port and drew a phantom rounds → round.1.split edge (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 drew initial-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 whose from was a container). resolveSeedChain now chases a port(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-resolvable initial-permutation → round.1.split edge — while stopping at an iterate's "in" port (the loop boundary) so SHA-256's per-block length-append → blocks loop-input edge and every ECB/CBC per-block read are byte-identically unaffected. The now-redundant initial-permutation → rounds loop-input edge is suppressed (its bytes already reach a descendant leaf). Pinned by tests/des-graph.test.ts (the honest carry exists with real state/input ports, resolves to a value in the inspector, and NO edge anchors on the rounds boundary). Core only, no schemaVersion change.
  • 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 showed no frame found for step "key-schedule". AES doesn't hit this because its key-expansion aux source is a root-level leaf (its id resolves to a frame); a group-wrapped schedule broke the assumption. lookupNodeValue now 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 by tests/node-value-lookup.test.ts (the Blowfish key-schedule group id resolves to a value; an unknown id still returns missing). Core only, no schemaVersion change.
  • 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, no schemaVersion change.
  • 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

Licensing

  • 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. LICENSE carries the full terms, the new NOTICE file the short-form summary (both must be retained on redistribution), and package.json declares "license": "LicenseRef-BNCL-1.0". This is a deliberate move away from an OSI-approved open-source license — LicenseRef-BNCL-1.0 is a namespaced non-standard SPDX identifier that dependency tooling will not recognize as open source.

Added

  • 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 the kind selector (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 editable p, q, e constants on the spec), while the encrypt/decrypt mode toggle stays. Both halves of RSA are visible trace frames. Key generation derives n = p·q, φ(n) = (p-1)(q-1), and the private exponent d = e⁻¹ mod φ (extended Euclid). Exponentiation is an unrolled square-and-multiply ladder (c = mᵉ mod n encrypt, m = cᵈ mod n decrypt): one rung per modulus-width bit, each a mod-mul@1 square + a cond-mod-mul@1 conditional multiply that reads its exponent bit at run time — so editing e (or p, q → the derived d) 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 exchanges Uint8Array at 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 — plus src/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 fan n / d port-to-port to the ladder among same-scope siblings; p, q, e ride cipherConstants (editable, default 61 / 53 / 17n = 3233). The UI rejects a message m ≥ n with a value-based error (else the ladder would silently compute m mod n). Verified against a Python pow() oracle (feedback_crypto_verification): tests/rsa-vectors.test.ts pins pow(65,17,3233) = 2790, the derived n / φ / d, and an exhaustive 0..3232 round-trip compared by bigint value; tests/app-rsa.test.tsx drives the whole flow through real DOM events (select Public-key → encrypt 65 → 2790 → decrypt → 65; key field hidden; m ≥ n rejected). Store integration adds a third SpecsByMode kind (asymmetric) + an Asymmetric family / Category to cipher.ts (the isCipher predicate is now an explicit exclusion of every non-cipher family — !isHash && !isAsymmetric — so widening Algorithm can't silently route RSA down the cipher path), and "rsa" joins the document-schema ALGORITHM_IDS so saved RSA docs round-trip. No schemaVersion bump. Deferred to follow-ups: a collapsible "Key Generation" group + per-leaf narrationOverride (Phase 2 — now shipped, see the next entry), and decomposing the mod-inverse@1 oracle 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, hybrid rsa.publish-key-params@1 step mirrors the computed key material into aux (the one channel that crosses a group boundary), and the ladder reads it back via top-level aux-load-bytes@1 loaders. 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 → an unused-write warning glyph on the default spec; a manual browser pass caught exactly that, and tests/rsa-key-gen-group.test.ts now pins zero unused-write warnings in both directions via validateGraph). The private exponent d is 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-specific narrationOverride (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 generic mul@1 / mod-inverse@1 / cond-mod-mul@1 registry doc. The exhaustive 0..3232 KAT 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 + meta contract, and the per-leaf override names) + tests/graph-view-rsa.test.tsx updated for the grouped topology (45 leaves, 1 "Key Generation" container, ladder stays flat). No schemaVersion bump. Deferred: decomposing the mod-inverse@1 oracle 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 exponent d = e⁻¹ mod φ inside one opaque mod-inverse@1 executor — 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 traced eea-step@1 (one division-with-remainder + coefficient update, the Bézout recurrence advanced by one iteration), and a final eea-extract@1 reduces the surviving coefficient mod φ to the non-negative d. The rung count K = ⌈1.4404·8W⌉ + 2 is the Lamé/Fibonacci worst-case bound for a W-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-small K would truncate the algorithm to a wrong d with no error). The exhaustive 0..3232 KAT 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). No schemaVersion change.
  • High-fanout REFERENCE replication keeps a pure-aux iterate's box while decluttering its fan-out (2026-06-08). SHA-256's msg-schedule (a for-each-subgraph-with-history) publishes aux["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 from replicateHighFanoutSources by 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-chip W_0..W_63 would falsely imply 64 distinct values). Full replication (leaves, collapsed groups → DELETE + scatter) is untouched. The guard is verified to catch only msg-schedule among shipped specs: ECB/CBC *-blocks iterates have zero outgoing edges (never enter the fan-out map) and SHA-256's outer blocks iterate 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-1 ecb-blocks → concat-blocks iterate 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; short W labels; incoming edges preserved; no spine-replica promotion — the isSpine gate is load-bearing because samePath is genuinely true post-collapse, msg-schedule and round.N both in blocks scope) + real-Chromium e2e/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; no schemaVersion change.

Changed

  • 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 published n/e/d, so nothing the ladder needs disappears. tests/rsa-key-gen-group.test.ts (now asserts defaultCollapsed === true) + tests/graph-view-rsa.test.tsx (35 visible leaves; the "Key Generation" container still renders as a collapsed box) updated. No schemaVersion change.

0.6.0 - 2026-06-04

Added

  • 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 stored StepGroup template, 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 (its seedInput), one published output (bodyOutput/outputPorts), plus any number of aux reads (e.g. a round's xor-with-aux@1 reading aux["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's seedInput is 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.json stays self-contained and there is no schemaVersion bump. 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), and e2e/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.ts walk 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; portInputs is already in the document schema, so no schemaVersion bump 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-input xor@1 so per-port wiring is exercised), tests/graph-view-wiring.test.tsx (gesture logic), tests/port-wiring-roundtrip.test.ts (Save/Load + clear byte-stability), and e2e/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 constants alpha/beta (via the shared IntInput, clamped to [1, wordBits-1]) and the byteOrder convention (a be-paper/le-nsa <select>) are editable — the speck.round@1 / speck.round-inverse@1 executors 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) and wordBits (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 via editAllStepsByType by spreading each leaf's existing params — so every round keeps its own roundKeyAux, which is why it can NOT reuse the generic ApplyAllRow (that replaces the whole params object and would point every round at the same key). A .muted small note 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; byteOrder select commit; and the load-bearing assertion — apply-to-all broadcasts α/β/byteOrder to every round while preserving each round's distinct roundKeyAux). 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, no schemaVersion bump.

Fixed

  • 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 split node, or either of its outgoing wires that lands on a fan-in consumer (split → fxor, split → recombine), surfaced step "round.1.split" has no resolvable state at frame N / no frame found for either endpoint of state edge …. Root cause: split-bytes@1 has two outputs (output0 = L, output1 = R) and its consumers (xor@1 / concat@1) read two operands each, so the cipher-agnostic framePrimaryOut/InBytes helpers ("state" port → sole port → null) return null for both endpoints — and a GraphEdge carried only (from, to), discarding which port the edge represented, so the lookup had no way to pick output0 vs output1. Now inferPortEdges stamps each port-flow edge with the specific fromPort (producer output) / toPort (consumer input, the portInputs binding key), and lookupRegularState adds 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 clicking split shows the one 8-byte block it divides rather than an error; a genuinely multi-input leaf (a fan-in, or the 11-input publish-round-keys tail) still resolves "missing" and is inspected per-port in PortFlowView. Pinned by tests/des-multiport-value-lookup.test.ts against the published FIPS 46-3 / Grabbe worked example (split→fxor = CC00CCFF, split→recombine = F0AAF0AA, node = CC00CCFFF0AAF0AA); the new edge fields are optional so every GraphEdge test literal compiles unchanged, and encodeEdgeKey (from/to/auxKey/kind) is unaffected. Core only, no schemaVersion change.

  • 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-view scroll 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 own graph-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 a graph-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: a graph-edge-hit path 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 in tests/graph-view-canvas-pan.test.tsx (the existing four wrapper / SVG-root / leaf-rect / no-overflow cases are unchanged — a leaf rect is graph-leaf-rect, not graph-container-rect, so it's still rejected). UI only, no schemaVersion change. (Deferred, only if a "dead spots on the lines" follow-up lands: threshold-gated panning from edges/decorations too, mirroring startNodeDrag'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 inner key-schedule.publish leaf remaps to the container id). replicateHighFanoutSources used 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 collapsed group (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 of graph.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 ×N bundle the container→container relationship already renders cleanly. The override-panel "N edges" count mirrors the same metric (replicationSources in GraphView.tsx). Replica chips inheriting a long container label (vs the fixed LEAF_W) now truncate the rendered text with an ellipsis (full label kept in the <title>). Verified by tests/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 in tests/replicate-fanout.test.ts, and a throwaway Playwright pass eyeballing the off/on canvas (Playwright stays dormant — not committed). The graph-bundle / graph-view-bundle-render / graph-view-bundle-inspector tests 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, no schemaVersion change.

  • 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-button used 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 .inputs flex-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, no schemaVersion change; tests/app-custom-spec-indicator.test.tsx still 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-banner and each Run clears it — and because the banner was a full-width row mounted/unmounted directly on the dirty flag inside the .inputs grid 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 on dirty); toggling dirty only 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 .inputs height 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 in tests/app-auto-rerun-toggle.test.tsx (slot present in manual mode regardless of dirty; live banner only when dirty). JSX + CSS only, no schemaVersion change. (Separately diagnosed during this fix: the .reset-spec-button appearing 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 against DEFAULT_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 new DEFAULT_CT_BYTES_BY_CIPHER (src/ui/stores/cipher.ts) holding each cipher's canonical ciphertext (the output of encrypt(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 by tests/default-ciphertext-table.test.ts: every entry must equal encrypt(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 to tests/app-cipher-selector.test.tsx. No schemaVersion change. 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 exponent d = e⁻¹ mod φ(n) in a single mod-inverse@1 black-box frame; Phase 4 decomposes it into a visible loop — one trace frame per Euclidean division step — so the learner watches d derived the way a textbook derives it. Two new pure port-native primitives: eea-step@1 carries the algorithm's running 4-tuple (r, newR, t, newT) port-to-port and shifts it by q = ⌊r/newR⌋ per rung (r ← newR, newR ← r mod newR, t ← newT, newT ← (t − q·newT) mod φ); eea-extract@1 reads the settled (r = gcd, t = inverse) slot and emits d, throwing the same "not invertible" error the oracle did when gcd(e, φ) ≠ 1 (a non-coprime e). The spec (src/ciphers/rsa.ts) replaces the single d leaf with two coefficient seeds (t = 0, newT = 1) + an unrolled chain of eeaMaxIterations(W) rungs + the eea-extract tail (which keeps the id "d", so the publish tail's port("d","output") and the KAT's frameOut(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 φ, e vs one seedInput). 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 + the bigIntToBytes codec is unsigned big-endian and throws on a negative — a two's-complement value on a layout:"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 of t; t ≡ true coefficient (mod φ) throughout, so the final t is d directly with no terminal += m); the only visible cost is a textbook's −183 showing as φ − 183 = 2937, which the per-leaf narration names. The unroll count K = eeaMaxIterations(W) = ⌈1.4404·8W⌉ + 2 is the Lamé worst-case bound (Euclid's worst case is consecutive Fibonacci numbers) plus a +2 margin that also absorbs a user-entered e ≥ φ; 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-small K would be a silent wrong d for some user-entered key (every prior RSA test fixes e=17 → always 4 iterations), so the gate (tests/rsa-eea-decomposition.test.ts) drives the real eea-step executor with the largest consecutive Fibonacci pair at the working width (W=1/2/3) plus 200 random coprime pairs, asserting convergence strictly within K and byte-equality with the independent modInverseBigInt oracle — it is what pins K. The existing exhaustive 0..3232 encrypt→decrypt round-trip now flows through the decomposition and stays byte-identical. mod-inverse@1 stays 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@1 join the port-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 ×4 multi-output edge; clicking a rung node shows "no value" by design — a 5-input/4-output leaf has no single value, identical to concat/fan-in xor/the 11-input publish-round-keys tail, with the values inspectable per-edge — and validateGraph is warning-free in both directions. tests/graph-view-rsa.test.tsx updated for the new topology (45 → 73 leaves). No schemaVersion bump. Gate green: biome + tsc + 2427 vitest / 213 files + vite build clean; browser-smoked in real Chromium (correct live ciphertext 0ae6 = 2790, the loop renders + scrubs in graph view with zero console errors).

Removed

  • Key-schedule decomposition — K4b: the <KeyScheduleExplorer /> + the whole src/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's isKeyExpansionStepType-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 its key-schedule-byte-* CSS — still used by the port-native Feistel views + the narration components; the key-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 across default-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 in src/ciphers/default-registry.ts; the speck.key-schedule@1 entry in the narration allowlist (src/ui/narration/registry.ts). Migrated: tests/speck-32-64-key-schedule-decomposition.test.ts from 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") in tests/runtime-ported-dispatch-speck.test.ts now pre-seeds roundKey.0 in initialAux (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 a toEqual(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 across default-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) and RoundKeyPanel retarget (the aux-keyed isRelevantFrame classifier may quietly miss-classify under A). Gate green.

Changed

  • 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 schemaVersion change. The K2c gate had picked topology A (each round leaf wires portInputs.roundKey to a publish-tail output port) for Speck. When K2d opened, A proved unbuildable without new runtime machinery: a group walks its children in an isolated nodeOutputs scope, portInputs resolve 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 collapsed key-schedule group, so only the global aux map 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 its K/H/W across container boundaries with no complaint; and round keys are derived (view-only), so A unlocks no inspect/edit. The user chose B-minimal, which main already 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 in docs/plans/key-schedule-decomposition.md; the runtime fact behind the infeasibility is recorded in docs/gotchas.md (new "Port-flow scope" section) and memory project_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-expanded key-schedule group, then AskUserQuestion surfaced the A-vs-B topology pick + the SpeckKeyScheduleBlock fate, 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 (a86842f2 BE, f24268a8 LE), collapsed view shows a single Key Schedule chip with the 22-round aux fan-out tamed by replicateHighFanoutSources into 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-k per iteration) with the (m-1)=3-iteration l-chain lag rendered as in-place master-split replicas 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-only output-codec bulk-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 a speck.key-schedule@1 step now falls through to the raw-JSON editor). The legacy executor + speckKeyScheduleDoc stay registered in default-registry.ts as the KAT oracle for tests/speck-32-64-key-schedule-decomposition.test.ts. The block delete is a small targeted change in src/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 or schemaVersion change; 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 feat commit deferred. The aux-graph derivation tests had already been retargeted preemptively in K2a (speck.key-schedule leaf → key-schedule GROUP container), so K2b is purely the framing/allowlist work the advisor pass left for closure. Narration allowlist (src/ui/narration/registry.ts) gains a speck.publish-round-keys@1 entry — 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 own narrationOverride). Both publish tails are below the cell-shape contract gate (input: "any"), so the entries are for parity / documentation, not coverage enforcement. The stale speck.key-schedule@1 allowlist 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 decomposed key-schedule GROUP built by buildSpeck32_64KeyScheduleNative; the monolithic executor survives registered only as the KAT oracle for tests/speck-32-64-key-schedule-decomposition.test.ts and 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. SpeckKeyScheduleBlock in src/ui/components/ParamEditor.tsx has its header comment reframed as fallback-only: post-K2a no shipped Speck spec contains the speck.key-schedule@1 leaf; 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 in tests/narration-registry-contract.test.ts bumped 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 parallel has("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.

Added

  • 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.ts encrypt + 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. New src/ciphers/des-key-schedule-builder-native.ts (buildDesKeyScheduleNative(), no params — DES has no key-size variant): one default-collapsed key-schedule group holding load-key (aux-load-bytes@1, 8 bytes) → pc1 → 16 × (g{r}.rotateg{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 (verbatim fipsPermute lift, params {table, outBits}, input/output ports) 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 existing permute@1 gathers whole bytes and the round-body permutes (IP/FP/E/P) have hardcoded role-specific widths. Named bit-permute (not key-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-generated des.bit-permute@1 table: 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₀), and des.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.auxWritePorts maps key${r} → roundKey.${r}), a thin sibling of the AES/Speck/Serpent publish tails: 16 FIXED keys (count param, not AES's rounds+1), 6 bytes each. Reusing serpent.publish-round-keys@1 (byteLength-agnostic, would functionally work) was declined — it would weld a serpent.* 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: new tests/des-key-schedule-decomposition.test.ts runs the decomposed schedule and asserts every published roundKey.0..15 is byte-equal to the monolithic des.key-schedule@1 oracle (the FIPS 46-3 Appendix B key + one more — DES has no key-size variant, so no 3-size matrix); the shipped des-vectors / des-decrypt KATs (now routing end-to-end through the decomposition) stay byte-identical to the published FIPS 46-3 vectors. The des.key-schedule@1 executor STAYS registered (KAT oracle + back-compat for pre-K4 saved docs; do not global-rename key-schedule). Blast radius: both DES specs rewired; three new step types registered + given ParamEditor blocks (des.bit-permute@1 reuses the read-only DesPermutationBlock; new thin read-only DesRotateHalvesBlock + DesPublishRoundKeysBlock) + the publish tail added to the narration allowlist (parity with the AES/Speck/Serpent tails); the round-key-fan-out graph/UI tests retargeted key-schedule leaf → key-schedule.publish (the surviving meta-bearing writer) — des-graph.test.ts, round-key-panel.test.tsx's DES section, and runtime-ported-dispatch-des.test.ts (c). The KeyScheduleExplorer DES branch is now dormant (no shipped spec emits a des.key-schedule@1 frame); its des-key-schedule-explorer.test.tsx keeps 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; no schemaVersion change. 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; the key-schedule group default-collapses to a single chip (no ~50-chip wall) and expands into the clean load-key → pc1 → (rotate → pc2)×16 → publish staircase; clicking a rotate-halves chip 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@1 leaf 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. New src/ciphers/serpent-key-schedule-builder-native.ts (buildSerpentKeyScheduleNative(keyByteLength)): one default-collapsible key-schedule group holding load-key (aux-load-bytes@1) → size-specific pad (concat@1 + constant-load@1 0x01-tail; no-op for 256-bit keys) → input-codec (permute@1 byte-swap LE→BE per 32-bit word) → master-split (split-bytes@1 ×8) → 132 unrolled recurrence iterations (each xor@1 over the 4 taps + φ-const + per-iteration index-const, then rotate-bits-right@1 {wordBits:32, bits:21} = ROL 11) → 33 serpent.key-sbox@1 groups → 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), reusing sboxBitslice4/wordsToBytes4/applyBitPermutation(SERPENT_IP) VERBATIM so it is byte-identical to the monolith by construction; group i uses S_{(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 hazard serpent.sub-bytes@1 would have introduced. A throwaway probe confirmed the standard⇄bitslice identity IP(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.auxWritePorts maps key${i} → roundKey.${i}), a thin sibling of the AES/Speck publish tails: 33 FIXED keys (count param, not AES's rounds+1), 16 bytes each. Byte order (the K3 load-bearing detail, parallel to K2's codec): the prekeys are little-endian but rotate-bits-right@1 reads 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@1 decodes BE then serializes LE+IP verbatim. KAT byte-equality pinned: new tests/serpent-32-key-schedule-decomposition.test.ts runs the decomposed schedule for all three key sizes and asserts every published roundKey.0..32 is byte-equal to the monolithic serpent.key-expansion@1 oracle; the shipped serpent-vectors/serpent-roundtrip/serpent-key-schedule KATs (now routing end-to-end through the decomposition) stay byte-identical to the published Serpent vectors. The serpent.key-expansion@1 executor STAYS registered (KAT oracle + back-compat for pre-K3 saved docs; do not global-rename key-expansion). Blast radius: serpent-spec-builder.ts rewired; both new step types registered + given ParamEditor blocks (a thin SerpentPublishRoundKeysBlock + read-only SerpentKeySboxBlock) + narration allowlist / shape coverage; the round-key-fan-out graph/UI tests retargeted serpent.key-expansion leaf → 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 a serpent.key-expansion@1 frame — 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 via aux-load-bytes@1) rather than hard-rejecting. Gate green: biome + tsc + 2231 vitest / 193 files + vite build; no schemaVersion change. 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 the key-schedule group 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 a serpent.key-expansion@1 frame, so the branch was unreachable. Premise verified directly (not just inferred from the K3a commit message): src/ciphers/serpent-spec-builder.ts routes the key schedule through buildSerpentKeyScheduleNative, so the monolithic leaf no longer appears in any trace. Deleted: src/ui/key-schedule-sim/serpent.ts (the simulateSerpentKeySchedule simulator + its SerpentScheduleTrace/SerpentStage types); the serpent variant of the ScheduleSimulator discriminated union + its serpent.key-expansion@1 registry entry in src/ui/key-schedule-sim/registry.ts (the union is now a single DES member, and KeyScheduleExplorer's dispatch collapses from a Switch/Match to the lone DesExplorer); the SerpentExplorer/SerpentScheduleView/PadStageView render branch in src/ui/components/KeyScheduleExplorer.tsx; and the two Serpent-only tests (tests/serpent-key-schedule-sim-parity.test.ts and tests/key-schedule-explorer.test.tsx — the latter tested only the Serpent dispatch branch; DES dispatch through <KeyScheduleExplorer> stays independently covered by tests/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 in app.css. Kept: the serpent.key-expansion@1 executor (KAT oracle + back-compat — never global-rename key-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; no schemaVersion change. 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@1 leaf 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 (new src/ciphers/speck-32-64-key-schedule-builder-native.ts): one default-collapsed key-schedule group holding load-keyinput-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 wires new-k directly 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) — iteration g writes l_{g+m-1}, iteration g+(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 of aes.publish-round-keys@1 because AES emits rounds+1 keys (initial pre-round key + one per round) while Speck emits exactly rounds, and AES hardcodes byteLength: 16 in 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 of add-mod-32@1, fixed-width per the same precedent (carry semantics differs per width → folding into a parameterized add-mod@1 would 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: existing speck-32-64-vectors + speck-32-64-decrypt test files green end-to-end across all 4 specs; new tests/speck-32-64-key-schedule-decomposition.test.ts runs each shipped spec and compares every published roundKey.0..21 byte-for-byte against the still-registered legacy speck.key-schedule@1 executor's outputs under BOTH byte orders + explicit k_0 codec check (logical word 0x0100 → BE bytes [01,00] vs LE bytes [00,01]); the 22-round-frame golden stream stays byte-identical in runtime-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 via aux-load-bytes@1's declared byteLength: 8 port, 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_TYPES allowlist includes add-mod-16@1 (label "+ mod 2¹⁶"); speck.publish-round-keys@1 reuses the AES PublishRoundKeysBlock (identical { outputPrefix, rounds } params shape). The legacy speck.key-schedule@1 executor 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 block H ← H + compress(H, block); the digest is H after the last block (FIPS 180-4 §6.2.2) — and the port-mode iterate already folds N blocks while carrying a cross-iteration value (the CBC chain). So the running hash IS that chain: Slice 2.11a adds chainOutput to the iterate node (the honest dual of chainInput, harvesting the final carried value — the mechanism a fold-to-a-single-value needs; additive optional field, no schemaVersion bump), and Slice 2.11b wraps SHA-256's per-block body (message schedule + 64 rounds + per-block final-add) in a port-mode iterate "blocks" whose chain is H — chainInput bootstraps it from the initial-H constant, chainFeedback advances 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 constant aux["H"] (retiring the final.fetch-H leaf). For a single-block message the fold runs once and the §A.1 "abc" digest is byte-identical — only trace stepIds gain a :b0 suffix. Graph fix (core/graph.ts): the per-block iterate's seedInput (length-append) floated because its only consumer is the msg-schedule container's seedInput = port("blocks","in"), not a leaf portInputinferPortEdges now draws the loop-input edge length-append → blocks when 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 ships tests/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), a node:crypto block-boundary cross-check across lengths straddling every block transition up to 512 bytes, fold-engagement pins (55→1 block, 56→2, 120→3 via unique blockIndex — the 56/64-byte cases that mis-hashed before flip green here), and a bounded multi-block stress behind the SHA256_STRESS env 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:crypto across 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 (:b0 suffixes, descend into the iterate for round-group pins, history-seed edges re-anchored at blocks, fanout moved blocks=5/length-append=1). npm run check GREEN; no schemaVersion change. Phase 2 of the universal-port-dataflow plan is closed — SHA-256 ships port-native end-to-end, multi-block, KAT-equal. Next: meta retirement / 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 group of split-bytes → …F function… → xor → concat, the swap expressed as the concat argument order), the old feistel-round-keyed linear views went dark — they read the dead branchPath / synthetic :rejoin frame. 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 module src/core/feistel-shape.ts recognizes the split→F→xor→concat shape (analyzeFeistelRound), finds the active round from frame.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 in findActiveFeistelRound keeps the diagram off a frame that isn't actually a leaf of the detected round (every cipher names its rounds round.N, so a transient spec/trace mismatch during a cipher switch could otherwise draw DES structure on an AES round.N frame). 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 a K_i cross-reference), FeistelRoundBytes (round-level L / R / F / L⊕F / L' / R' byte rows), and FeistelRecombineView (an additive panel on the concat frame 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 old feistel-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.ts pins the analyzer's enc/dec swap pattern, its null cases for AES/SHA/the outer rounds group/a hand-broken round/the spec-trace mismatch guard, and resolveFeistelRoundBytes byte-equal to the FIPS 46-3 des-kat.json vector across all 16 rounds including the round-16 preFp byte-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 or schemaVersion change.
  • Universal port-based dataflow — Phase 2, Slice 2.0b-i GREEN (2026-05-24). Widens the for-each-subgraph spec 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 legacy IterateGroup'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-time params.rounds) does NOT transfer — picking per-block ports would force a PortShapeMap widening to take run-time data OR a magic-number params.maxBlocks cap 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 fieldsfor-each-subgraph carries inputArrayPort + outputsPort + blockByteLength + blockLayout directly; iterationCount auto-derives as state.bytes.length / blockByteLength. Mirrors legacy iterate's blocksFromAux/countFromAux/outBlocksAux self-contained pattern; no graph-introspection coupling. Type widening in src/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 in src/core/document-schema.ts: iterationCount becomes optional + four new optional fields. No schemaVersion bump (pre-Slice-2.0b documents validate unchanged). Static shape walk in src/core/spec-shapes.ts::walk: item-array mode treats the body's input shape as blockLayout and the node's after shape as "bytes"; state-thread mode stays shape-transparent. Runtime walker in src/core/runtime.ts::runForEachSubgraph: item-array mode reads parent-scope state.bytes (Phase 4+ port-edge wiring is the future story for inputArrayPort as an actual edge anchor), slices into blockByteLength chunks, decodes each via portBytesToState(slice, blockLayout) to seed body's state, walks children with :r{i} suffix, encodes body's exit state via stateToPortBytes, accumulates, and concatenates back to parent-scope state as a flat BytesState on node exit. Mirror semantics make legacy concat-blocks redundant in port-native specs (lifting split-blocks / concat-blocks is the next sub-slice 2.0b-ii). Toy fixture in tests/runtime-for-each-subgraph-outer.test.ts (8 cases): 3-block × 4-byte XOR-with-constant pins per-iteration stateBefore / stateAfter + concatenated final state + iterationCount auto-derivation; zero-blocks case emits zero frames; single-block (1 × 4 bytes) emits one :r0 frame; 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 check green 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 (lift split-blocks@1 + concat-blocks@1 with the new layout vocabulary for State[] 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-subgraph spec node kind ships as a new StepNode variant, validated on a synthetic toy fixture before any SHA-256 specifics land. The new kind threads state across iterations — iteration i+1's body input is iteration i's body output, no clone-from-aux per iteration like iterate — which is what SHA-256's 64-round compression body needs. The runtime walker (runSpec in src/core/runtime.ts) handles kind === "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.ts regex extended to strip :r\d+ (correctness gate for setTrace frame preservation). core/document-schema.ts Zod schema gains ForEachSubgraphSchema so saved documents using the new kind round-trip through Save / Share; no schemaVersion bump (the v2 union widens; pre-Slice-2.0a documents validate unchanged). core/spec-shapes.ts::walk treats the new kind as shape-transparent (unlike iterate which clobbers shape to matrix4x4-bytes). Graph data model gains a fourth container kind ("for-each-subgraph") mirroring iterate's container shape and spine-termination treatment for Slice 2.0a; richer rendering (round-count badge, collapse semantics) lands in Slice 2.10. iterationCount accepts either a literal number (the toy uses 5; SHA-256 compression will use 64) 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). findStepAndParent in core/spec-mutations.ts accepts ForEachSubgraphNode as a parent type; duplicateRoundGroup gains a defensive bail when called on a for-each-subgraph parent (per-iteration content shifts aren't a designed operation). Toy fixture in tests/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 stepIds xor: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: 0 emits zero frames. (4) iterationCount: { fromParam } throws with /Slice 2\.0a/ regex match. (5) nested toy — 2-block outer iterate × 3-iter inner for-each-subgraph, 6 body frames emit with composed inc: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 check green 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 + lifting split-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-trip deepEqual(reconstruct(project(legacyFrame), layoutTags), legacyFrame) could not be made lossless without layoutTags ballooning, Phases 1–5 would have re-opened for redesign. Three Phase-0 scenarios all green: pure state-only (generic.byte-substitution@1 in single-block AES-128), aux-reading (generic.add-round-key@1 with the round-key name preserved through the port-name ↔ aux-key binding), and iterate body (both step types when emitted inside aes-128-ecb's ECB iterate, preserving blockIndex: 0 and the :b0 stepId suffix). Type additions in src/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 shape StepShapeContract but that identifier was already taken at types.ts:319 for the single-thread state-shape contract used by the palette chip + drop-anchor greying + validateShapes; the new shape is renamed PortContract so both contracts coexist during the migration. Anti-trivial discipline: LayoutTags carries the layout enum + optional bit-length / bigint-encoding metadata + port-name ↔ aux-key bindings only — it does NOT carry the legacy State object 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, with bitvec + bigint State variants TODO-marked in the projection helpers and LayoutTags for 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 (the project / reconstruct pure helpers + per-step-type ProjectionMetadata interface), 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 a feistel-round container. 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 encoded into-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 new prependChildToTrack(spec, roundId, trackIdx, newStep) primitive sits alongside prependChildToContainer; the structural mutator chain (insertStepAfter/Before/removeStep/reorderStep) gained Feistel-track descent in transformParentArray, so any stepId living inside a track is now a valid anchor. StepLocation widens to include FeistelRoundGroup parents + an optional trackIdx; findStepAndParent descends 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 outer data-drop-anchor and route to {kind: "after", stepId: roundId} — inserting the new leaf immediately AFTER the round in its containing parent (the rounds group 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's track object 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 across tests/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), and tests/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 ciphertext 85e813540f0ab405. The cipher uses the feistel-round branching 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), with feistel-standard combine on rounds 1..15 and feistel-no-swap on 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's load-block step 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 single des.s-boxes@1 leaf). 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 for des.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 every feistel-round rejoin frame gets a single narrator that dispatches on params.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@1 stays on the narration allowlist — the multi-round PC-1 → 16 shifts → PC-2 walk is the wrong shape for per-frame narration; the future DesKeyScheduleExplorer (Phase 5e of the plan) replaces FrameStateView for that step, matching how AES / Serpent key expansions are handled.
  • CipherDocument.schemaVersion bumped 1 → 2 with backward-compat migration. Saved .cipher.json files and URL-share links from prior sessions still load: parseDocument now 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 contain feistel-round nodes). The Zod schema validates the bumped version after migration, and post-migration documents carry the current version literal in the in-memory CipherDocument. 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 the stateShape !== "matrix4x4-bytes" guard in applyPaddingScheme).

Changed

  • 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; BytesView is now reachable ONLY by the test-only lifted-legacy feistel.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 in tests/requires-ported-dispatch.test.ts (reusing the shippedSpecs enumeration) asserts every leaf of every shipped spec is port-native (kind:"ported", legacy === undefined) → no selectable cipher reaches BytesView, with a positive control that the toy IS lifted-legacy (the invariant has teeth). The stale S2(e) docstring in graph.ts (inferPortEdges) is truthed-up: AES/DES are now port-wired (spine from inferPortEdges) but Speck/Serpent are NOT (monolithic hybrid-ported, no spec portInputs), so inferStateEdges remains 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. See docs/plans/phase-5-legacy-retirement.md. No KAT or schemaVersion change.
  • 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 from BytesView (before/after byte rows) to PortFlowView (labelled input/output state port 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 by KeyScheduleExplorer by 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 projection meta.
  • DES rebuilt port-native — no longer uses the feistel-round primitive (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-mode group wiring split-bytes(widths=[4,4])des.expand-Rdes.xor-with-Kdes.s-boxesdes.p-permutationxor@1 (L⊕F) → concat@1, with $input → IP → rounds → FP → outputFrom. The Feistel swap is the concat argument order — rounds 1–15 concat(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 true PortedExecutors (IP/FP/E/S/P pure port-native with projection meta dropped; des.xor-with-K@1 keeps meta.auxReadPorts only); the key-schedule stays lifted (aux-only). KAT byte-equal to FIPS 46-3 (85e813540f0ab405), pinned by a per-leaf parity net against des-kat.json across 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 threaded state through the rounds (every DES frame's stateBefore/stateAfter is 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. The feistel-round-keyed Feistel visualizations go dark for DES — a port-native-aware Feistel/swap diagram is an obligatory follow-up. No schemaVersion bump (the schema still accepts feistel-round for old saved docs + the toy fixture). See docs/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=1 via 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 their beforeEach (graph-view-replica-gutter, graph-view-replica-placement, graph-view.test.tsx AES-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 to graph-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_ALLOWLIST and PROVENANCE_NO_OP_ALLOWLIST (6 each, added in Phase 3 of docs/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; only des.key-schedule@1 stays 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 .inputs so 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-result CSS rule handles the wrap; the old grid-area: result was 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-feistel CSS rule (neutral fill, 1px stroke that's a 60/40 mix of --border + --changed) so each round.N reads as a distinct sub-container inside its parent Rounds group 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 parent Rounds group AFTER its feistel-round children, so the parent's <rect> painted ON TOP of every child rect, obliterating any visual treatment. Fix: new containersInPaintOrder memo sorts by containerPath.length ascending 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). findConsumerFrame now 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"). New lookupPassthroughBytes helper resolves passthrough chip ids to their L_in / R_in from 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 from round.N:rejoin to the next round's L track now surfaces new_L (4 bytes), to the R track surfaces new_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-N suffix 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_L and new_R halves), 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: sourceFanoutMap now skips edges whose source is a synthetic === "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-schedule is at root, its 16 consumers are inside Rounds > round.N. Under the rule, round.1's xor-K got 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 requires sourcePath and consumerPath to 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).

Removed

  • 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-call portedDispatchEnabled dual-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, no schemaVersion bump (registration kind + the dispatch flag are never persisted). Batch 1 converted the last kind:"legacy" registrations — the test-only StepDefinition helper steps in the iterate / for-each-subgraph runtime tests — to hybrid-ported steps, and reframed the FES-with-history aux snapshot/restore tests onto trace.finalAux (the ported path's synthetic ctx.aux can'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), removed portedDispatchEnabled from RuntimeInput, deleted core/dispatch.ts (requiresPortedDispatch was vestigial — true for every shipped spec) + the App.tsx flag derivation, and collapsed StepRegistration to its single port-native shape — dropping the kind:"legacy" arm + the legacy? fallback field and deleting the orphaned StepExecutor / StepDefinition / StepResult types (StepContext survives; the ported contract reuses it). register() now takes a StepRegistration directly (no more bare-fn / StepDefinition normalization); get() and normalizeRegistration are gone. The kind:"ported" literal is kept as a single-member tag so every registration literal compiles unchanged (dropping the discriminator would have churned dozens of default-registry literals for a purely cosmetic gain). meta / ProjectionMetadata is 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. The GraphView replication-auto-on memo simplified — its requiresPortedDispatch(spec, registry) call became a literal true. This is behavior-identical for every shipped spec: requiresPortedDispatch was 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): removed portedDispatchEnabled: true from every runSpec call (tsc-verified complete) + the @/core/dispatch imports; deleted the per-primitive "off-flag dispatch throws" guard tests (the on-flag "input port not wired" siblings stay), the now-vacuous requires-ported-dispatch.test.ts and state-shape-contracts.test.ts (its "legacy-shaped steps declare a shapeContract" category is gone — vacuous since Slice 5.2; spec-shapes.test.ts keeps the contract↔topology coherence), and the byte-native-ports-contract legacy-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-3 85e813540f0ab405 — verifies the App.tsx wiring + the meta key-schedule projection) + AES-128 (FIPS-197 C.1, confirming the new universal replication-auto-on default renders clean), zero console/page errors. See docs/plans/phase-5-legacy-retirement.md "## Phase C".
  • TraceFrame.stateBefore / stateAfter retired — the trace is port-flow-only (Slice 5.3e, Batch 4 of 4, 2026-05-31; irreversible). Completes Slice 5.3e. Deleted the per-frame stateBefore / stateAfter State snapshots from TraceFrame (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's portInputs/portOutputs). The honest per-step bytes ride the captured port I/O; the cipher's seed + result live on Trace.initialState / Trace.finalState. frameStateInBytes / frameStateOutBytes (core/frame-state.ts) now return portInputs/portOutputs.get("state") ?? null — the field fallback is gone. State / StateShape / BytesState were ALREADY at the bytes floor (Slices 5.0/5.1), so the "collapse" was a no-op: removing the now-vestigial shape:"bytes" discriminant would churn ~566 sites across 132 files for zero gain, so it was deliberately NOT done — State survives verbatim as the runtime-internal thread type (cloneState). The runtime still threads a State internally; 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), RunExplorerModal tiles — now show "(no state)" for any leaf with no "state" port (every native-AES leaf, all SHA-256 primitives — their payloads ride output/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 migrated stateBefore/stateAfterportInputs/portOutputs keyed "state" (the narrators read the helpers); Speck + Serpent golden frame-streams retargeted to frameStateOutBytes (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 by portName; the for-each-subgraph toy fork reframed onto the surviving finalState except 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-serpent updated for the port-only reality; trace-initial-state's pre-deletion helper==field corpus 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. No schemaVersion bump (trace frames are not persisted). Browser-smoked (throwaway Playwright, 3/3): AES-128 FIPS-197 C.1, SHA-256 abc, DES FIPS 46-3 — KATs correct, PortFlowView renders full port bytes, "(no state)" thumbnails don't crash, all view tabs error-free. See docs/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) → state spine inference, plus its local helpers and the S2(f) skipStateEdgeTo gate) and dropAuxOnlyStateEdges (the aux-only-root spine-edge suppressor) from src/core/graph.ts — ~390 lines. The graph spine is now composed entirely by inferPortEdges from each leaf's declared portInputs. This is safe because every shipped spec wires its first consumer to the $input source (specReferencesInputSource is true for all), so the legacy CIPHER_INPUT_ID pill never injects and the only surviving kind:"state"+auxKey:"state" edge is the output pill (last step → output, never an aux-only root) — i.e. dropAuxOnlyStateEdges was 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. In GraphView.tsx the dropAuxOnlyStateEdges call site + the auxOnlyFilteredGraph and auxOnlyRootSinkIds memos are gone; auxOnlyRootIds survives — it still drives the layout-lift of aux-only roots off the spine row. opts.registry on deriveAuxGraph is 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's length-append → msg-schedule was, 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 in inferStateEdges; the port-flow spine has no analogue). Tests: deleted drop-aux-only-state-edges-asymmetric.test.ts (its whole subject is the removed function) + the replicate-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 two collapseGraph round.5 cross-boundary endpoints from leaf-to-leaf round.4.add-round-key → round.5 to container-sourced round.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 or schemaVersion change. Batch 4 follows (the stateBefore/stateAfter field + State/StateShape/BytesState collapse). See docs/plans/phase-5-legacy-retirement.md "## Slice 5.3e".
  • BytesView retired (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 to PortFlowView — had no remaining consumer. Deleted the component (src/ui/components/BytesView.tsx) + its test (tests/bytes-view.test.tsx); collapsed App.tsx::FrameStateView from the isPortNativeFrame Show/fallback to an unconditional <PortFlowView> (dropping the previousRunFrame prop, the before/after/prevAfter accessors, and the BytesView/isPortNativeFrame imports). The BytesView-exclusive CSS goes (.bytes-view, .bytes-row*, .bytes-block-*, and the .bytes-cell.changed/.diff-vs-prev/.bytes-cell-missing highlight modifiers); the bare .bytes-cell primitive stays (shared by PortFlowView + StepStrip). Also removed: the inline "compare to previous run" checkbox in the linear frame header — its only renderer was BytesView's previousAfter row (PortFlowView has 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-hover store + RoundKeyPanel's aux-cell reader — lookupProvenance/setProvenanceHover now have no caller, but the des.ts/serpent.ts output-byte→input-byte maps are reusable for the enqueued port-aware inspector, Slice 2.9c–e) and the history.ts previous-run toggle exports (useShowPreviousRun/setShowPreviousRun/findPreviousRunFrameByStepId — uncalled but reusable for a future port-level prev-run diff). Live-code comments that named BytesView as 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 or schemaVersion change. Batches 3–4 follow (graph state-edge inference; then the stateBefore/stateAfter + State/StateShape/BytesState collapse). See docs/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 group of split-bytes → …F… → xor → concat, the swap expressed as the concat argument order — so the feistel-round branching 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 :rejoin frame, and the branchPath field + its threading (runtime.ts); the :t… / :rejoin / :swap stepId suffixes (step-id.ts, regex narrowed to /(?::b\d+|:r\d+)+$/); the lifted-legacy projection bridge liftLegacyExecutor / 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 StepList FeistelRow / FeistelTrackRow; all feistel-round graph handling (kind:"feistel" containers, the synthetic rejoin/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 last legacy-bearing step — its fixture feistel-toy.ts, its step file, and its narration registration) is deleted; the NARRATION_NO_OP_ALLOWLIST shrinks 8 → 7. The feistel-round document 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-round lived entirely under [Unreleased]), so its Zod node was clean-deleted: a feistel-round document 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); the kind:"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, no legacy) no-op so the live coercion mechanism keeps coverage; the container-output-port wiring test retargeted to a not@1 leaf via group seedInput / 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 or schemaVersion change. Batches 2–4 follow (BytesView; the graph state-edge inference; then the stateBefore / stateAfter + State / StateShape / BytesState collapse). See docs/plans/phase-5-legacy-retirement.md "## Slice 5.3e".

Fixed

  • Rejoin chip + passthrough chip clicks no longer error "no frame found" (Phase 6e smoke, 2026-05-20). The asymmetric canonicalization in findConsumerFrame made 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 through lookupPassthroughBytes and 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:rejoin to the next round's L track shows new_L (4 bytes); to the R track shows new_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:rejoin no longer falls back to the rejoin's combined stateBefore (8B); it slices L_in from the rejoin frame's params using the shared lookupPassthroughBytes helper 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.

Added

  • 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 (memory feedback_same_scope_replica_merge_rejected.md is 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 new LayoutSpec.relativePositions sidecar 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). layoutNode returns the AUTO box for flow advancement while writing the rendered box at auto + delta, so dragging a chip leaves a hole at its old slot instead of sucking neighbours leftward. setRelativePosition/clearRelativePosition mirror the existing setNodePosition/setReplicationMode setter 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). renameLayoutIds passes 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. rescaleAllPositions rescales relative deltas alongside absolute positions so density flips don't drift pinned chips. Drag wire-up: startNodeDrag gains a { mode: "absolute" | "relative" } opts parameter; the JSX gate selects "relative" for replica + chip nodes. Relative mode captures relativePinsMap().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.confirm prompt; clears positions + relativePositions + collapsedGroups + replicationModes for the active spec; disabled when hasUserLayout(activeLayout()) returns false. New docs/plans/draggable-replicas.md captures 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. Read stateBefore.bytes.length AND stateAfter.bytes.length directly (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 offset after.length and 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-block explain the FIPS-197 §3.4 column-major packing as a structural relabel (same bytes, different shape label). split-blocks reads the produced MatrixState[] length from frame.auxWritten[outBlocksAux] to report the block count concretely (falls back to before.length / blockSize when the aux write is absent, so a half-wired authoring state still narrates informatively). concat-blocks reads the array from frame.auxRead[blocksAux] instead. compute-block-count reads the integer count from frame.auxWritten[countAux]. 6 aux narratorsaux-load (1 unit naming published key + byte sequence, read from frame.auxWritten so the executor's auxName === "" short-circuit path naturally returns null), aux-xor (1 unit showing both operands from auxRead + the XOR result from auxWritten + 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 — return null when required aux is missing or shape is wrong, matching the AES AddRoundKey narrator'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 pinned NARRATION_NO_OP_ALLOWLIST.size === 6 assertion. The 5 aux narrators declare input: "any" on their step contract so they would NOT trigger the cell-shape coverage walk — the explicit hasNarrationFn assertions 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 under src/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 across tests/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 the iv-load frame → expand "aux[chain] := MatrixState(aux[iv])" → see the 16-byte IV bytes inline; scrub the xor-aux-into-state frame → expand "state ⊕= aux[chain]" → see the cell-0 sample XOR; scrub the state-to-aux frame → 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" when params.sboxIndex is 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 flat BytesState (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 in memory/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 the params.label (typically "IP" or "FP"), and renders input + output side by side as 8-bit binary strings (MSB-on-left, textbook convention — 0xAB reads as 10101011). 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-highlighted CSS 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) and k via decodeBlock / decodeWord from speck-word-codec.ts — the same codec the executor uses — so the narrator's intermediate x' agrees byte-for-byte with what the executor computed. Tests pin BOTH byteOrder values (be-paper AND le-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 in wordBits/4-digit hex with an 0x prefix (Speck literature convention — Speck32/64 words display as 0x6574); 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@1 is 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 new NarrationUnit.Prose is a Component<{fmt: ByteFormat}> (or simply () => … for Speck where word values are hex regardless of toggle) so the existing <Index> + <Dynamic> machinery in StepNarration.tsx keeps <details> open state 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 128 table 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.ts gains 2 new presence assertions (Serpent byte-level + bit-permutation; Speck rounds) and flips the bit-permutation allowlist check from has to false. 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 at src/ui/narration/ (registry + index + per-cipher fns) — same architectural shape as src/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 generic shift-rows@1 and mix-columns@1 narrators handle forward AND inverse directions via params.shifts / params.matrix, so ONE registration per step type covers decrypt-mode without separate forward/inverse fns. Reactivity contract: each NarrationUnit.Prose is a Component<{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 new StepNarration.tsx). Made a requirement for future ciphers: new contract test tests/narration-registry-contract.test.ts mirrors tests/provenance-registry-contract.test.ts literally — walks buildDefaultRegistry().types(), filters to shapeContract.input ∈ {matrix4x4-bytes, bytes}, and asserts every shipped step type is EITHER narration-registered OR sits on NARRATION_NO_OP_ALLOWLIST with 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.md checklist 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 as PROVENANCE_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> + formatBytes extracted from KeyScheduleExplorer.tsx to src/ui/components/byte-row.tsx so 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 after cell in MatrixView now lights up the contributing cell(s) in before (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 consumed K_i in the round-key panel — the user can finally see the XOR coupling. Parallel registry at src/ui/provenance/ (registry + per-cipher fns + index-load side-effect), NOT a new field on StepDocumentation — 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 generic shift-rows@1 and mix-columns@1 step types handle forward AND inverse directions via params.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 explicit PROVENANCE_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@1 and 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: new useProvenanceHover() signal store carries { stepId, afterCellIndex, sources } precomputed at set-time so consumers don't all re-run lookupProvenance; MatrixView's after grid sets on mouseEnter and clears on mouseLeave; both MatrixView (before grid) AND RoundKeyPanel (TinyMatrix subview) subscribe and apply .provenance-source outline 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 in classList (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 .changed and accent-blue .diff-vs-prev so 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 const const 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 → XOR chain (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) → state shape 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-256 Nk > 6 && i % Nk === 4 extra-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 kinds pad / 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) routes aes.key-expansion@1, aes.key-expansion@2, and serpent.key-expansion@1 to 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-explorer with a max-height: 70vh; overflow-y: auto cap so a fully-expanded explorer never pushes ParamEditor below the fold. Graceful failure modes: missing or wrong-shape master key in frame.auxRead (e.g. user pointed keyAuxName at a non-existent aux entry) returns null and the component renders an inline error stub rather than crashing; same for malformed frame.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 vs keyExpansion (@1) and keyExpansionV2 (@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 where S[0x00] = 0x63 and a buggy simulator would diverge invisibly) + @2 relaxed-rounds (rounds = 11, rounds = 15 with 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 one xor-prev stage, throws on invalid inputs). tests/serpent-key-schedule-sim-parity.test.ts (10 tests): parity vs serpentKeyExpansion for 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 in 1 + 1 + 132 + 33 + 33 order, pad-byte index 16/24/-1 for the three key sizes, the trap-prone (35 - i) mod 8 S-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>6 branch 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.finalAux but were invisible to the user. Structural patterns spanning the whole schedule (Rcon's column injection, the chain XOR W[4i] = W[4i-4] ⊕ f(W[4i-1]), AES-256's Nk > 6 mid-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 />, scans trace.finalAux for entries matching prefix.N whose values are Uint8Array, 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 a byteLength !== 16 fallback to a 1-row layout (no bespoke per-cipher logic). The K_i whose canonical name (${prefix}.${index}) appears in the current frame's auxRead map gets a .round-key-cell-current outline — so during AddRoundKey at round 3, K_3 lights up; during key-expansion (write-only) nothing highlights. Cipher-agnostic by construction (no isAesCipher gate) — 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. New useTraceFinalAux() accessor in src/ui/stores/trace.ts mirrors the useFrameIndex / useHistory convention — closes over version() for reactive invalidation across re-runs; phases 2 and 3 will consume it too. 11 new tests in tests/round-key-panel.test.tsx (@vitest-environment jsdom): 6 cover the pure detectRoundKeySequences aux-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 a roundKey.3 consumer, no outline for a frame with empty auxRead, byte-format toggle re-renders the master-key first cell 000, 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.

Fixed

  • 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 edge cbc-snapshot → cbc-xor planted its arrowhead on cbc-xor's RIGHT edge at right-center — the SAME point where the forward state spine cbc-xor → add-round-key DEPARTS. Mechanism: source and target are same-row siblings inside the iterate body, vertOverlap=true forces useVertical=false, the horizontal regime exits source-left / enters target-right, and consumerPortOffset short-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 in project_cbc_xor_arrowhead_crowding): when an edge becomes crowded, SWITCH the incoming arrowhead to a different edge entirely. New third path branch in EdgePath's geom() memo — gated unconditionally on props.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 via AskUserQuestion): 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 ×N pill anchor at LABEL_T_ANCHOR = 0.25 uses the actual on-curve Bezier point at t=0.25 rather than the chord-line interpolation perpendicularLabelMidpoint returns — 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): the isFeedback gate keeps non-feedback edges on the horizontal regime, so the state spine cbc-xor → add-round-key still departs the right edge — verified by a dedicated test assertion. Vertical-regime feedback edges (none today, hypothetical future): covered by the existing useVertical early-return — they already exit top/bottom and don't crowd the right edge. New tests/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 internal aes-cbc-builder renames). Decrypt coverage added at advisor's flag — fix is direction-agnostic (gated purely on props.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 same targetXOffset value also already applied to the consumer's BOTTOM edge by virtue of being computed before the downward branch picks tEdge. The horizontal regime (source to the left or right of the consumer) hard-coded ty = 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. New EdgePath.targetYOffset prop, applied only in the horizontal regime, clamped to to.h / 2 − 4 (mirrors the existing x-clamp pattern). Call site computes targetYOffset from the same consumerPortOffset helper with a height-aware portGap = Math.max(4, Math.round(LEAF_H / 4)) ≈ 7 px — reusing the vertical regime's LEAF_W / 10 ≈ 13 px would exceed LEAF_H / 2 = 14 and pin against the clamp on leaf-shaped consumers. Fixed (not scaled to actual to.h) by user choice — predictable visual rule across consumers; tall chip-row containers still get more spread room implicitly through the to.h-derived clamp. Slot ordering inherits the existing ConsumerPortAssignment row-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: consumerPortOffset is already pinned by 16 tests in tests/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 — the key-expansion@->split-blocks state-spine replica AND every key-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: replicateHighFanoutSources assigned every replica's containerPath and insertion parent to the consumer's, so the spine replica got lifted above its consumer along with the aux ones, AND buildReplicaPlacement pulled all replicas into isReplica for the lift treatment. Fix: new isSpineReplica?: true field on GraphNode; replicateHighFanoutSources flags 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. buildReplicaPlacement then excludes spine-replicas from isReplica, 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 via AskUserQuestion (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: at ecb-blocks's top-edge with split-blocks LEFT of compute-block-count in the spec, the aux arrow from split-blocks landed at a RIGHT-of-compute-block-count slot — both non-replicas had rowOfSource = Infinity so the comparator fell through to alphabetical edge.from ('c' < 's' → compute-block-count slot 0), unrelated to canvas x. Fix: buildConsumerPortAssignment takes an optional sourceXOf?: (canonicalSource: string) => number | undefined callback; comparator uses it as PRIMARY key when defined for both edges, falls through to today's row → from → auxKey → kind chain otherwise. Renderer wires sourceXOf from a new baseLayout memo (layoutRoot with 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's stepType (which has shapeContract.input === "any" for every shipped case where the fix matters), and auxOnlyRootIds classifies root leaves by that exact criterion. Verified empirically with a new production-path component test: auxOnlyRootIds iterates spec().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 in tests/graph-view-port-spreading.test.ts (sourceXOf overrides alphabetical, undefined-on-one-edge falls through, equal-x falls through); 3 in tests/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 in tests/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 in tests/graph-view.test.tsx (production-path: spine-replica's rendered y equals consumer's y, AND its x is strictly less). The three updated tests in tests/graph-view-replica-gutter.test.ts invert 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.

Changed

  • 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 replicateHighFanoutSources skipped 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 of nodes, rootIds, AND every container's childIds (the splice-then-strip ordering means each replica still lands in the original consumer's pre-replication slot — the cascade case A and B both always produces [..., A@->B, B@->C, C, ...] cleanly). Incoming edges whose to is 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 in inferStateEdges (e.g. compute-block-count → ecb-blocks under AES-128 ECB), the lookup falls back to the first outgoing aux edge target — covered by a synthetic-fixture test pinning the post-suppression compute-block-count → always shape. GraphView's pinnedMap memo split into rawPinnedMap (raw map for drag/persistence) and orphan-filtered pinnedMap (declared after graph() 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-time console.debug per 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 existing replicaOf field. Test impact: two pre-7b assertions in tests/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 in tests/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 ×N decoration — pins the invariant that unique synthetic from ids 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 1 endpointLabels memo 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 overwrites state from aux[blocksFromAux][i] at iteration entry and publishes the per-iteration output into aux[outBlocksAux] at exit — so the state value at the iterate's boundary is dead. Clicking the previously-rendered compute-block-count → ecb-blocks state 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. inferStateEdges now tracks every iterate id and suppresses any state edge whose endpoint is one — no prev→iter, no iter→next, no bridging prev→next. The aux arrows (input-blocks coming in, output-blocks going 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 old prev → iter → next shape now pin the suppression invariant; one new in-test assertion explicitly checks that no bridging prev→next edge 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 ×N pill 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" — bumped CANVAS_MARGIN from 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 ×N pill rendered at each arrow's midpoint was crowding adjacent rows when replicas stacked vertically (the inter-row pitch was LEAF_H + FLOW_GAP = 28 + 24 = 52 px, with the visible gap of 24 px ≈ the pill's ~32 px width). New constant BASE_REPLICA_STACK_GAP = 48 (and matching LayoutConstants.REPLICA_STACK_GAP field) replaces FLOW_GAP at the two replica-stack sites (replicaSlotPosition's y formula and replicaLiftHeight's row contribution); FLOW_GAP keeps its 24 px value at all other sites (sibling stacking inside groups, where no bundle pill sits in the gap). Tests in graph-view-replica-placement.test.ts and graph-view-replica-gutter.test.ts updated to consume consts.REPLICA_STACK_GAP so 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-anchorX produces a diagonal arrow into the target-at-anchorX + slotOffset. Fix: layoutRoot now takes an optional portAssignment parameter and, when the consumer's chip-count is exactly 1, looks up the chip's bundle representative edge (first edge in graph.edges matching the chip → consumer pair) and shifts the chip's x by consumerPortOffset(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"). The portAssignment arg is optional — tests and pre-bundle callers can omit it and get byte-identical layout (layout memo had to be relocated below portAssignment in GraphView.tsx to avoid TDZ on the lazy memo body's create-time first-run). ConsumerPortAssignment is now exported as a type so test files can pass real assignments to layoutRoot. 3 new tests in tests/graph-view-replica-placement.test.ts pin: (a) the headline shift on a single-replica + two-plain-leaves consumer; (b) no shift when ≥ 2 chips share a consumer; (c) backward-compat when portAssignment is 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 constant LABEL_T_ANCHOR = 0.25 consumed inside perpendicularLabelMidpoint. Pill stays visually grouped with the chip it belongs to and out of the intervening replica columns.

Added

  • Bundled aux edges: N parallel arrows between the same endpoints collapse to one thicker arrow with a ×N label (arrow-bundling plan, 2026-05-17). The motivating manual smoke from the prior port-spreading session: AES-128 ECB + iterate ecb-blocks collapsed + key-expansion set to always replicate produced 11 individual aux arrows from the replica chip into the iterate, one per consumed round key. The visual is semantically correct (11 distinct auxKeys 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 one EdgeBundle rendered 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 ×N text 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 internal activeBundleAuxKey signal 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 — same data-edge-key format, same CSS classes, same stroke widths — so every non-bundled spec/iterate state is unchanged. Multi bundles get a bundle: prefix on data-edge-key (format bundle:${from}|${to}|${kind}|${isFeedback?1:0}) so the inspector store's toggleSelectedEdge dispatches by prefix without callers needing to know whether they're toggling an edge or a bundle. Bundle key includes isFeedback (not just from/to/kind) so cross-iteration aux flows (CBC's cbc-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's representativeEdge (one per bundle, identity-stable across renders) rather than graph.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 existing geom() memo via ((sx+tx)/2, (sy+ty)/2) — symmetric cubic Bezier midpoints sit at the geometric midpoint by construction). New file tests/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 inside auxKeys, 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.

Fixed

  • 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, but buildConsumerPortAssignment bucketed by raw edge.to. The key-expansion replica had edge.to = "initial.add-round-key" (direct) while the compute-block-count and split-blocks replicas had edge.to = "iterate" (retargeted at render time to the first body step via visualEdgeTargetId'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." buildConsumerPortAssignment now takes an optional visualTargetOf callback so callers can bucket by the same key the renderer uses for toBox. The render site passes visualEdgeTargetId-bound; existing tests with synthetic graphs (no iterate retargeting) keep their behavior via the identity default. ConsumerPortAssignment.localCountOf re-keyed to Map<GraphEdge, number> so consumerPortOffset looks up by edge reference rather than edge.to; new bucketSizeByTarget: Map<string, number> exposes per-target counts for test sanity checks (single-incoming consumers are NOT included — slotOf's undefined short-circuit handles them). 2 new regression tests in tests/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).

Changed

  • Port-spreading at the consumer head: per-consumer slot semantics replace the global-row formula (port-spreading-consumer-head plan). Slice 7c's replicaTargetXOffset distributed by global row index — confirmed buggy at collapsed-iterate chip heads via two it.fails diagnostic tests in commit 97e098f (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 exports buildConsumerPortAssignment(graph, replicaPlacement) + consumerPortOffset(edge, ports, portGap) in src/ui/components/GraphView.tsx replace it. Per-consumer slot assignment inherits row ordering from ReplicaPlacement so 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 the kind === 'aux' filter and machinery picks up state replicas for free" promise valid. The two diagnostic it.fails tests are now plain it (regression guards); a new per-consumer locality test 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 + multiple always-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.

Added

  • 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) inverse AES_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 file src/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 on a === 0); gfMatInverse4x4(M) — Gauss-Jordan elimination on the augmented [M | I] 4×8 matrix using gfMul/gfInverse for row scaling and XOR as field "addition" (throws on singular matrices). Helper gfMatMul4x4 exported alongside so tests can verify M · M⁻¹ = I without re-implementing the multiplication. The arithmetic primitives xtime and gfMul are imported from the existing src/core/state/matrix.ts — not re-implemented (the irreducible polynomial x^8 + x^4 + x^3 + x + 1 lives in exactly one place). New mutator syncMixColumnsInverseToCounterpart(stepType, invertedMatrix) in src/ui/stores/spec.ts is a parallel sibling to syncSboxInverseToCounterpart — same broadcast-to-every-matching-step-in-counterpart shape, same "active slot untouched" invariant, same useMode()-based direction inference; the only difference is the param key (matrix instead of sbox) 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> in ParamEditor.tsx is appended after ApplyAllRow inside MixBlock, 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 around gfMatInverse4x4 inside a createMemo (advisor pick over a separate mixColumnsIsInvertible helper): 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-row mirrors .sync-inverse-row styling 1:1. Registry update: new entry { stepType: "generic.mix-columns@1", paramKey: "matrix", mirrorClass: "inverse" } in cross-mode-mirror-registry.ts (the actual step type — the plan's prose said aes.mix-columns@1 but the codebase uses generic.mix-columns@1 because 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 a generic.mix-columns@1 setup branch — same shape as the existing generic.byte-substitution@1 branch, just returns "round.1.mix-columns". 18 new tests across three files. tests/gf-matrix.test.ts (16): gfMul sanity (gfMul(2, 0x8d) === 1 — the textbook KAT that catches a wrong-polynomial bug in seconds), gfInverse spot-checks + the field property ∀a ∈ 1..255: gfMul(a, gfInverse(a)) === 1 + involution + zero-throws, the headline KAT gfMatInverse4x4(AES_MIX_MATRIX) === AES_INV_MIX_MATRIX and its reverse direction, M · M⁻¹ = I round-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 on main, 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 time gfInverse is 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 SubBytes SerpentSyncInverseRow, AES key-expansion CopySboxRow). 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. New src/ui/components/cross-mode-mirror-registry.ts exports CROSS_MODE_MIRROR_ENTRIES, a readonly { stepType, paramKey, mirrorClass, groupBy?, counterpartStepType? }[] listing the four shipped entries (AES SubBytes inverse, AES key-expansion v1 + v2 identity, Serpent SubBytes inverse + groupBy: "sboxIndex" + counterpartStepType: "serpent.inv-sub-bytes@1"). The groupBy field 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 existing tests/sync-serpent-sbox-inverse.test.ts. New tests/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 via setSelectedStepId, and asserts a <button data-mirror-class="{class}"> rendered inside .param-editor. The three existing Sync rows (SyncInverseRow, SerpentSyncInverseRow, and the new CopySboxRow) all gained a data-mirror-class attribute ("inverse" / "inverse" / "identity" respectively); ActionButton's existing splitProps extracts only the seven explicit named props, so data-* lands in rest and 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 both identity and inverse entries (catches future entries that accidentally drop a class). 5 new tests in tests/cross-mode-mirror-coverage.test.tsx (sanity + one per entry). Bundle change negligible (the registry is ~50 bytes; the three data-mirror-class attributes 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)" within KeyExpansionBlock) 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 mutator syncSboxCopyToCounterpart(stepType, sboxValue) in src/ui/stores/spec.ts is a parallel sibling to syncSboxInverseToCounterpart — same broadcast-to-every-matching-step-in-counterpart shape, same "active slot untouched" invariant, same direction inference from useMode(). The only semantic difference is sbox: [...sboxValue] instead of sbox: [...invertedSbox] (no algebraic inversion). Docstring explicitly warns against caller-side invertSbox composition — the whole point of the Copy verb is to mirror the forward S-box exactly. New component <CopySboxRow currentSbox stepType> in ParamEditor.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 with SyncInverseRow — 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-row mirrors .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 in tests/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 in tests/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 SerpentSubBytesBlock rendered 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 AES SboxEditor has had since 89c7d17 to Serpent at N=16: per-cell red highlighting on duplicates (driven by findDuplicateIndices + collisionGroupsByIndex memos — both size-parameterized by values.length and thus reusable at 16 without modification), a <Show when={redundantCount() > 0}> warning banner above the grid (new .serpent-sbox-warning-banner CSS class — same red token as the AES banner but tighter padding/font for the smaller grid context), and a Repair to permutation <ActionButton> wired to repairToPermutation (same green flash + ✓ glyph + aria-live announcement the AES button got in Slice 1). A new <SerpentSyncInverseRow> lives below the grid, labelled Sync inverse S_{n} to {counterpart} where n is the leaf's sboxIndex (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 existing syncSboxInverseToCounterpart mutator would broadcast one inverted table to every serpent.sub-bytes@1 leaf in the counterpart slot, overwriting 28 of 32 rounds with the wrong inverse. A new sibling mutator syncSboxInverseToCounterpartByIndex(stepType, sboxIndex, invertedSbox) filters by params.sboxIndex === sboxIndex inside 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 — editing round.4.sub-bytes (which uses S_3) and clicking Sync only writes to decrypt-side leaves with sboxIndex === 3; encrypt's round.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 from src/steps/CLAUDE.md). 9 new tests: 4 in tests/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 in tests/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 import setCipher from @/ui/stores/spec (not @/ui/stores/cipher) — the former rebuilds the spec via buildCanonicalPair, the latter only flips the signal. Note for Slice 4 (enumeration test): the cross-mode mirror registry shape will need a groupBy?: "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 permutation in the S-box editor warning banner, Sync inverse S-box to decrypt below 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 primitive src/ui/components/ActionButton.tsx is a drop-in replacement for raw <button> that on click (a) runs onAction synchronously, (b) adds a .flashing class 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 (implicit role="status" + aria-live="polite") with a feedbackLabel so 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 via flashMs for future use cases. prefers-reduced-motion removes the transition (color still flips, just instantaneously — the flash is informational, not decorative). :disabled keeps its existing greyed style even mid-flash. CSS specificity: .action-button.flashing is two classes (0,2,0) which wins over per-call-site rules like .sync-inverse-row button (0,1,1), so no !important is 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-hidden rule (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 new DuplicateGlyph starts with flashing=false regardless of how the flash is timed (reordering triggerFlash() before onDuplicate() 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 on DuplicateGlyph in GraphView.tsx and in the CSS file (no .graph-duplicate-button.flashing rule). 8 new tests in tests/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 alongside action-button+flashing, disabled/title forwarding, type defaulting to "button" (HTML default is "submit" — easy footgun), and flashMs override 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).

Changed

  • Stacked replicas above a shared consumer read clearly now — vertical column + straight diagonal lines + visible start-dots. When a user forces multiple sources to always on the replication panel (e.g. key-expansion, compute-block-count, AND split-blocks all set to always on 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 = 0 again); (2) each replica's outgoing arrow originates from an offset x along the replica's bottom edge — same MONOTONIC (row − (total−1)/2) × step spread 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. New BASE_REPLICA_SOURCE_X_STEP = 32 (~¼ × LEAF_W). The vertical regime's path switches from cubic Bezier to a straight L line when the edge originates from a fan-out replica; non-replica edges keep the existing curve. CSS classes .graph-edge-start-dot-aux / -state match the parent path opacities with feedback + selected variants. Same release widened the row-0 → consumer gap so the arrow shaft is visible: new BASE_REPLICA_LIFT_GAP = 20 (replaces STACK_GAP = 6 at the replica-lift sites only). Previously the shaft length collapsed to ~0 after ARROW_INSET = 6 subtraction — the user reported "arrowhead kissing the chip top with no detectable line between dot and target." REPLICA_LIFT_GAP is used by replicaSlotPosition (row-0 baseY), replicaLiftHeight (row-0 height contribution), and auxOnlyLiftH (aux-only-root lift at canvas root); STACK_GAP keeps 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-body div with max-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-view canvas wrapper's hardcoded max-height: 600px was replaced with max-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 the min-height: 560px floor 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.

Fixed

  • 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, …)) in App.tsx tracked only the spec signal, so changes to the local inputText / keyText signals and the module-scoped ivBytes signal never re-fired the effect body. Fix: extended the dep tuple to [spec, inputText, keyText, ivBytes]. Side effect — selector helpers (changeFormat / changeCipher / changePadding / applyDocument) call setInputText / setKeyText as part of their flow, so each now triggers one extra debounced auto-rerun on top of the spec-driven one. The pushSnapshot dedup keeps history clean (same bytes + same spec → no new snapshot), so the worst case is a sub-millisecond duplicate runtime call. 4 new tests in tests/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-scoped spec signal fires undisposed effects from prior render() calls on each __resetSpecForTests in 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-blocks AFTER running with multi-block plaintext left no UI affordance to re-expand. Root cause: expandCollapsedIterates dropped the iterate container (and its chevron) from the graph when blockSpan > 0, replacing it with bare block chips at the iterate's old slot. Pre-Run still worked because the transform short-circuits on undefined blockSpan. Fix: keep the iterate in containers and rewrite its childIds to the chip ids (with chips' containerPath extended 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 layoutRoot used the iterate's first non-replica child's x — under Option C that first child is always iter@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 AND visualEdgeTargetId keeps 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 always in the replication panel produced a chip near concat-blocks labelled "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. replicateHighFanoutSources now silently skips any source whose id is in graph.containers; the user's always panel 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_GAP bumped 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 in graph-view-block-chips.test.tsx (chevron clickable post-collapse + header label visible while collapsed — the regression assertion); 1 in replicate-fanout.test.ts (container source skipped even when set to always); 1 in graph-view-replica-placement.test.ts (replica x lands at iterate-center, not block 0's x); 1 in graph-view-replica-gutter.test.ts (visualEdgeTargetId returns 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.
  • 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) inferStateEdges in core/graph.ts now 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 routes round.4.add-round-key → round.5 → round.6.sub-bytes instead of leapfrogging. Filled groups stay transparent so the pedagogical "continuous round-body chain" reading inside non-empty rounds is preserved. (2) New prependChildToContainer primitive in core/spec-mutations.ts lands a step at position 0 of the targeted container's children array, whether the container is empty or not; into-start in insertStepIntoSpec now routes through it (the old code path used insertStepBefore(firstChildId, ...) which had nothing to anchor on when children was empty and silently fell through to root-append). (3) dropGutters no longer skips empty containers — it emits a single sentinel into-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's data-drop-anchor remains as a redundant backup but is no longer load-bearing. 8 new tests: 6 in tests/spec-mutations-structure.test.ts for prependChildToContainer (empty + populated group, iterate body, throws on missing/leaf id, ref equality, no source mutation) and 2 in tests/aux-graph-derivation.test.ts for 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).

Added

  • 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's currentBlockIndex picks 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-selected on 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=0 was 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's node.stepId is 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 — inspectorTargetId is computed alongside clickTargetId to 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: string to a discriminated selectedTarget: { kind: "edge", key } | { kind: "node", id } | null union. Convenience helpers isEdgeSelected(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 from core/edge-value-lookup.ts (file kept its historical name — adding both lookups under one roof beats splitting them since they share parseChipId / findIterateById / findBodyFramesAt). Reuses the existing EdgeValueLookup discriminant so the renderer's kindBadgeText + valueRowText switch unchanged.
    • The kind-badge label generator stopped taking the GraphEdge — it now reads auxKey from 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.ts covering 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 nested round.5.mix-columns leaf, since root-level leaves go through the drag path that fireEvent.click doesn't trigger), input/output pill clicks, kind-mixing replacement (leaf → edge → pill). The graph-view.test.tsx endpoint-pill assertion flipped from tabindex absent to tabindex="0". Suite at 975 passed across 80 files (was 957 across 79).

Changed

  • 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 renamed view-edge-inspector.tsview-value-inspector.ts; CSS class prefix renamed graph-edge-inspector-*graph-value-inspector-*; testid prefix renamed edge-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 px min-height cycle-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 call lookupEdgeValue directly). The 8 component tests in tests/graph-view-value-inspector.test.tsx (renamed from graph-view-edge-inspector.test.tsx) lose their hover assertion and gain a sharper click-toggle assertion against the new .graph-edge-selected halo 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-count with 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: inferStateEdges now 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 threads prev → 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-6 expandCollapsedIterates'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." No chip_i → chip_{i+1} edges are produced (chips are parallel paths, not chained ones). One-line change in processScope'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 the inferStateEdges function-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 by kind === "aux" so they don't accidentally include the new state edges).

Added

  • 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 → to plus 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) — a createEffect(on(() => spec().id, ...)) calls clearPinnedEdge so 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), and useByteFormat() (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-op onKeyDown (Enter/Space → toggle pin) is wired anyway so biome's useKeyWithClickEvents lint passes and the affordance is present if a future cipher adds tabindex to 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. Each EdgePath now renders TWO <path> siblings inside a wrapping <g>: the visible path on top with pointer-events: none (so it doesn't compete for hit-tests) plus a 12px wide transparent hit-path that carries data-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) and tests/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.
  • Collapsed-iterate block-chip expansion (Slice 6 of the graph-narrative plan) — collapsing the ecb-blocks iterate (or any future iterate-based mode) used to swap the body for a single chip with a ×N badge, 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 (no blockSpan yet) and non-collapsed iterates pass through by reference, so the transform is zero-cost on single-block ciphers and pre-Run UIs. Implementation: new expandCollapsedIterates(graph, collapsedIds) pure transform in core/graph.ts, threaded into GraphView's memo chain between collapseGraph and replicateHighFanoutSources so chips become aux-edge consumers — a key-expansion@always override 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 optional blockChipOf?: string field on GraphNode (sibling to replicaOf, deliberately distinct so chips lay out normally instead of being pulled into buildReplicaPlacement's "above the consumer" auto-positioning loop). Renderer applies replica-style no-drag/no-delete at the JSX boundary and reads display text from node.label so block 1 / +5 more blocks win over the iterate id. 20 new tests across tests/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) and tests/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. Encoding data-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 BEFORE closest("[data-drop-anchor]") in both handleDragOver and handleDrop, and SVG paint order reinforces it (gutters render last). Replica chips inside childIds are filtered out — they're synthetic graph artifacts with no spec entry, so insertStepBefore(replicaId) would throw. The boundary strip thickness clamps at CONTAINER_PAD (not the larger FLOW_GAP for 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 the dropGutters memo 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-anchor moved 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. Existing step-palette.test.tsx:294-310 assertion inverted from "insert after in parent" to "insert as first child of container." docs/help/graph-view.md rewritten to enumerate the four drop targets distinctly (leaf / header / body gutter / canvas). 16 new tests in tests/drop-gutters.test.tsx covering 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.tsx extended 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 carry data-state-shape so a matrix4x4-bytes-input step over a bytes-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 + new into-start anchor 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, reset snaps 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: SVG width / height attributes scale with zoom while viewBox stays at the logical canvas dimensions, so pinned positions in LayoutSpec.positions remain in unscaled viewBox units regardless of zoom. The pointermove drag handler divides client-pixel deltas by zoom() so dragging at 2× zoom moves the pin the same physical distance the cursor moves (otherwise pins would race ahead). The wheel listener registers via onMount + addEventListener("wheel", h, { passive: false }) because Solid's JSX onWheel is passive in some browsers, which makes preventDefault() a no-op against page scroll. Deviation from plan: the plan suggested layering viewZoom?: number onto LayoutSpec, but the analogous view-density.ts store explicitly argues against that pattern for a viewer preference: "the same shared .cipher.json should 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 new src/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 on hasUserLayout() is untouched. 25 new tests across tests/view-zoom-store.test.ts (17: defaults, persistence, clamp, per-spec independence, FP-drift round-trip, defensive load) and tests/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.md gains 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 plaintext and ciphertext (encrypt mode) or ciphertext and plaintext (decrypt — labels swap, layout stays left-to-right per the 2026-05-15 design decision). The input pill targets the first leaf whose shapeContract.input is not "any" — skipping aux-only steps like aes.key-expansion@1 — so the arrow lands on the leaf that actually reads state (initial.add-round-key for single-block AES; split-blocks for 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 or tabindex. 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: new CIPHER_INPUT_ID / CIPHER_OUTPUT_ID constants + isEndpointId predicate + optional endpointSide field on GraphNode + opt-in opts.endpoints parameter on deriveAuxGraph (omitting it suppresses pill injection so every existing test stays untouched) + defensive guards in buildIterateFeedbackPredicate so a future re-classification of endpoint edges can't silently pull them into feedback-edge styling. 13 new tests across tests/aux-graph-derivation.test.ts (10) and tests/graph-view.test.tsx (3). docs/help/graph-view.md gains 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-key ended up with THREE near-parallel inbound arrows: (a) the plaintext-pill's endpoint spine, (b) the spec-true key-expansion → initial.add-round-key state-spine edge, (c) the small key-expansion-replica → initial.add-round-key aux fan-out. Two complementary fixes: (1) layoutRoot now takes a new auxOnlyRootIds: ReadonlySet<string> parameter; GraphView computes it via the same shape-contract walk the anchor heuristic uses, and any root-level leaf with shapeContract.input === "any" is placed at CANVAS_MARGIN (same row root replicas already use), with the spine row lifting to CANVAS_MARGIN + LEAF_H + STACK_GAP whenever 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 whose from or to is in auxOnlyRootIds, so the lifted leaf is also off the spine LOGICALLY — the canvas now shows (a) + (c) cleanly. Validation is unaffected (it consumes the unfiltered rawGraph()). Reinforces the visual language: "above the spine = supporting computation; on the spine = state flow." 4 new layout/render tests across tests/graph-view-replica-gutter.test.ts and tests/graph-view.test.tsx. Backward-compat: callers that don't pass auxOnlyRootIds get 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. endpointLabels memo carries a one-line comment marking the dispatch site for future hash / MAC / KDF specs (where nomenclature is message / digest / tag / okm, not plaintext / ciphertext). Memory entry project_hash_future.md indexes 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-count forced to always the 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 because replicateHighFanoutSources splices 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 helper visualEdgeTargetId(edge, nodesById, containersById) exported from GraphView.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.to still names the iterate so Slice 4's edge inspector + Slice 9's validateGraph keep 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 leaf initial.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 in tests/graph-view-replica-gutter.test.ts: the four visualEdgeTargetId branches (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 the compute-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 operation dropdown 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 in tests/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 IvInput component 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 via crypto.getRandomValues; the value persists across reloads in localStorage (hex serialization, format-toggle-stable). New src/ui/stores/iv.ts (signal + setter + randomize + reset), 6 tests in tests/iv-store.test.ts (default, length contract, defensive copy on write, randomize distinctness).

  • Document round-trip for the IV. SessionSnapshot picks up an optional ivBytes (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 existing generic.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 in tests/chaining-primitives.test.ts plus an end-to-end composition through runSpec.

  • 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 via aes-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-loop iv-load runs 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 ParamEditor out of view — users could see the right side of the canvas OR the controls but not both. Now .app / .trace-view / .graph-view-layout carry min-width: 0 (plus minmax(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 own overflow-x while the palette + editor stay pinned. CSS-only, no markup change.
    • aux-load ParamEditor copy. Value (bytes — N) was bare jargon — users dropping the primitive from the palette had no context for what bytes belonged there. Replaced with Bytes published under aux[name] (N total) plus a .muted hint 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 always in the replication overrides panel (e.g. both compute-block-count and split-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 by LEAF_W + FLOW_GAP. 4 regression tests in tests/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 replicationPanelOpen signal toggled by a clickable header (chevron rotates 90° via CSS); a createEffect(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 in tests/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. New dragOverAnchorId signal updated on every dragover via the same closest() walk the drop handler uses; LeafRect + ContainerRect carry the highlight class (stroke + soft color-mix fill on the inner rect) so the resolved anchor lights up in real time. Clears on dragleave + drop. 3 new tests in tests/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 edge cbc-snapshot → cbc-xor that goes BACKWARDS in spec order — until now rendered as a normal forward arrow, so users could mistake it for a cycle or bug. Extracted buildIterateFeedbackPredicate as a top-level export from core/graph.ts; validateGraph's cycle-detector filter and the renderer both share it so they can't drift. Renderer uses stroke-dasharray on .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 Agent with isolation: "worktree". The other three (panel, drop highlight, feedback edges) cluster on src/ui/components/GraphView.tsx and were authored sequentially to keep the diff tractable. The agent worktrees hit a broken pre-commit gate (worktree-relative node_modules resolution + CRLF baseline noise unrelated to the changes); workaround was --no-verify in-worktree and a clean re-run on main after cherry-pick.
  • Editable fanout threshold + single-edge sources in the replication override panel — the previously-hardcoded REPLICATION_THRESHOLD = 6 is now a session-only signal (useReplicationThreshold()) backed by a numeric input next to the replicate fan-out checkbox 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 in LayoutSpec) 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 from fanout >= 2 to >= 1 so single-edge aux sources surface as panel rows; a user with a single long arrow crossing the canvas can now force-replicate it via the always mode. 8 new tests in tests/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; see docs/plans/graph-readability-polish.md): multiple replicas targeting the same wide consumer (e.g. both compute-block-count and split-blocks → ECB iterate) stack on top of each other at consumer.x, and the arrow from a small replica chip to a wide iterate's center reads as long.

0.3.0 — 2026-05-14

Added

  • 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@1 morphs to a new aes.key-expansion@2 step type that drops the FIPS-197 rounds === Nk + 6 assertion and extends the Rcon table on the fly via xtime). The same click auto-mirrors onto the counterpart cipher (encrypt ↔ decrypt) so the duplicated encrypt and decrypt specs stay round-trip-compatible by construction — duplicating round.2 on encrypt produces a matching inv-round.3 clone in decrypt, both reading roundKey.3 from the bumped schedule. The visible affordance is gated by isRoundDuplicatable: only rendered for rounds with a clean counterpart (skips the final no-MixColumns round on encrypt and inv-round.0 on 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 storesrc/ui/stores/spec.ts now 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 new useSpecsByMode() exposes both slots for future "save both modes" flows. Edits via editStepParams / editAllStepsByType / removeStepFromSpec / insertStepIntoSpec write to the active slot only — changes don't leak across modes. setCipher / setCipherMode rebuild both slots from canonical (a cipher swap is a clean break). duplicateRoundInSpec is the single action that writes both slots in one signal update.
  • Behavior change: setMode no 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 existing resetSpec button instead.
  • 62 new tests across 5 files: tests/key-expansion-v2.test.ts (parity vs @1 at 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), and tests/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-128 to Custom (was AES-128) and adopts the --changed accent colour, and the currently-selected option in the cipher dropdown gets the same Custom (was X) text so the divergence is visible whether the user reads the header or opens the selector. A small reset button (also --changed-tinted) appears immediately to the right of the dropdown while the spec is custom, calling the existing resetSpec() to snap back to the canonical default for the current (cipher, cipherMode, mode, padding). Detection lives in a new isCustomSpec() accessor in src/ui/stores/spec.ts that does a structural deep-equal against a freshly-built canonical (not JSON.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 in tests/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 the ParamEditor panel, and the Delete key while a node is focused (Backspace deliberately not bound — reserved for future navigation). All three route through a new removeStepFromSpec wrapper in src/ui/stores/spec.ts around the existing pure removeStep mutator in core/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 in tests/spec-delete.test.tsx covering 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 shapeContract field on StepDocumentation) which StateShape its 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) validateGraph gains a fourth warning kind state-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. New src/core/spec-shapes.ts (inferShapesAtAnchors + validateShapes) does one spec walk that drives both the drop-anchor data-state-shape decoration and the static warning. Contracts backfilled across all 28 shipped step files in one commit; tests/state-shape-contracts.test.ts enforces 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-palette extension, 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> rendering docs/help/graph-view.md (loaded via Vite ?raw so the .md file is the single source of truth for both GitHub readers and the in-app modal). New src/ui/components/GraphHelpModal.tsx, new docs/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@1 publishes a literal byte sequence under an aux key (IV, counter starting value, tweak, per-mode constant); generic.aux-xor@1 XORs aux[from] into aux[into] and writes back; generic.aux-copy@1 performs a defensive byte-array copy of aux[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 via auxReadMissing rather 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 on aux-xor) still throw — missing-vs-malformed is a deliberate split. ParamEditor blocks for the three primitives are fully editable (text inputs + a +/- byte row for aux-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 the ParamEditor on, and the orphan glyph appears immediately. The follow-up also rendered ParamEditor below GraphView (gated on currentFrame()) so users can edit params in-place without tab-switching to linear mode, and capped .graph-replication-row max-width to 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 in tests/aux-primitives.test.ts cover per-primitive KATs, runtime auxReadMissing integration with validateGraph, 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.md gains 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 StepPalette inside the graph view lists every registered step type (registry.types() minus PADDING_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 on dataTransfer (application/x-cryptographer-step-type plus a text/plain cross-browser fallback), and renders the step's StepDocumentation name + summary tooltip. Drop semantics use DOM-target-closest anchoring (drop target's closest("[data-drop-anchor]") decides anchor) over Euclidean nearest-node — cleaner pedagogy, no getScreenCTM math, 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's insertStepAfter-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 via stripPaddingLeaves — surfacing them as a palette entry would be a stealth footgun. Replica nodes carry their source's stepId in data-drop-anchor (the synthetic ${src}@->${consumer} id doesn't exist in the spec, so insertStepAfter would 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 opens ParamEditor to fill them in. The new mutator boundary insertStepIntoSpec(stepType, anchor) in src/ui/stores/spec.ts accepts either {kind: "after", stepId} (routes through insertStepAfter; works for leaf or container ids — findStepAndParent already 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@1byte-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 in tests/step-palette.test.tsx (jsdom): palette render coverage, mutator unit tests, drop integration via synthesized DataTransfer shapes. Bundle 76 KB → 77.4 KB gzipped.
  • Edge-aware graph validation (Slice 9 of the 2D editor plan)core/graph.ts now exports validateGraph(graph, trace) → GraphWarning[] plus a GraphWarning discriminated 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 by tests/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 returned undefined (in addition to the existing auxRead map 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 in tests/graph-view.test.tsx (clean traces render no dots; synthetic auxReadMissing frame 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 current CipherDocument as ${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 same include session toggle as Save (default off → spec-only, byte-stable across sessions). Compression uses the browser-native CompressionStream("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 new src/ui/stores/url-share.ts module + 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) and tests/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.

Fixed

  • Trace-coupling: auto-run on boot + ParamEditor decoupled from frames. Three editor-flow bugs in the graph view shared one root cause — the hasRunOnce gate at App.tsx:582 blocked the spec→rerun effect until the user clicked Run manually. Before that first click, palette drops produced no orphan warnings (validateGraph walks frames; no frames means no warnings), the ParamEditor couldn't open on a clicked leaf (it resolved through a TraceFrame that 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 existing onMount's no-hash branch with run() so the trace exists before the user touches anything (the hash-decode branch already calls run() via applyDocument, so no double-run). New selectedStepId signal in src/ui/stores/trace.ts alongside frameIndex: setFrame keeps it in sync with linear-view scrubbing; setTrace preserves 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; setSelectedStepId is the new boundary for graph leaf clicks — sets the signal regardless of trace state AND moves the scrubber if a matching frame exists. ParamEditor now takes stepId: string | null instead of frame: TraceFrame | null and resolves the live spec leaf via findStep, 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 (drop aux-xor without Run → orphan glyph appears, pinning Phase 1 of the plan) and tests/param-editor-no-trace.test.tsx (drop aux-xor, focus via keyboard Enter, assert the editor binds before AND after the 200 ms debounce — the post-debounce assertion pins an advisor-caught setTrace-clobber bug fixed in this commit). tests/app-url-share.test.tsx drops a side-channel (getTrace() === null as a proxy for "boot decode didn't apply") that disappears once boot triggers a run; tests/spec-delete.test.tsx renders ParamEditor with stepId directly.

0.2.0 — 2026-05-13

Added — release infrastructure

  • 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.md formalizing the versioning policy: app semver release process, the stepType@N suffix convention, and the document schemaVersion migration path.
  • src/version.ts re-exporting package.json version as APP_VERSION so 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.appVersion is now stamped into exported .cipher.json documents — but only on the session-included save path, alongside the existing createdAt. Spec-only saves remain byte-identical across builds so the planned URL-share feature (Slice 7) stays deterministic.

Notes

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.

Added — ciphers

  • 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@1 step 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 iterate primitive is plugged in.

Added — modes of operation

  • 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 iterate primitive — the same loop body executes once per block, the trace's per-iteration frames get a :b{i} suffix on every stepId, and frame.blockIndex annotations let the UI render block badges.
  • Boundary steps generic.split-blocks@1 and generic.concat-blocks@1 to convert BytesState ↔ MatrixState[] around the loop.

Added — padding

  • 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.

Added — UI

  • Per-frame state view: 4×4 grid for MatrixState, 1×N wrap for BytesState, mixed-shape view for load-block/store-block boundaries.
  • 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 CipherDocument JSON (Slice 5), with an include-session checkbox controlling whether plaintext/key bytes are written. Spec-only saves are byte-stable.

Added — core

  • CipherSpec as JSON (src/core/types.ts): tree of StepNodes (step leaves, groups, iterate groups). Saved-document forever-shape.
  • StepRegistry mapping stepType → { 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.
  • CipherDocument file format (schemaVersion: 1) — required spec, optional layout (graph positions + collapsed groups), session (selector snapshot + optional bytes), metadata (name / createdAt / appVersion). serializeDocument is deterministic (alphabetical key order).
  • spec-mutations.tsfindStep, findStepAndParent, updateStepParams, updateAllStepsByType, insertStepAfter/insertStepBefore, removeStep, reorderStep, compareSpecs. Pure spec-in/spec-out with reference equality preserved on untouched branches.

Added — quality gate

  • Pre-commit hook in .githooks/pre-commit running the full npm run check (biome + tsc + vitest + vite build) plus a step-coverage gate (new files in src/steps/ require a tests/ 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.