diff --git a/config.toml b/config.toml
index a62b873..5f0770c 100644
--- a/config.toml
+++ b/config.toml
@@ -19,6 +19,7 @@ skip_prefixes = [
[markdown]
smart_punctuation = true
+bottom_footnotes = true
[markdown.highlighting]
enabled = true
diff --git a/content/blog/2026-04-23-spec-driven-development-is-half-the-loop.md b/content/blog/2026-04-23-spec-driven-development-is-half-the-loop.md
new file mode 100644
index 0000000..11aab38
--- /dev/null
+++ b/content/blog/2026-04-23-spec-driven-development-is-half-the-loop.md
@@ -0,0 +1,238 @@
++++
+title = "Spec-driven development is half the loop"
+description = "Spec-driven development compresses the software lifecycle to spec → plan → implement, with a QA-lens agent at the end. That agent is a soft oracle. We replaced it twice — once for finding bugs in code, once for closing gaps in the audit graph — using the same oracle-gated parallel-agent scaffold that Anthropic's red team used for their Claude Mythos preview. Here is the pattern, our two applications, and why it is the half SDD does not ship."
+date = 2026-04-23
+[taxonomies]
+tags = ["verification", "process", "deep-dive"]
+authors = ["Ralf Anton Beier"]
++++
+
+{% insight() %}
+Spec-driven development sells the intent half of the loop — agents against structured specs. The verification half it ships with is an agent reading the spec back. That is a soft oracle; it cannot find what the spec did not mention, and it cannot resist the politeness drift of long LLM conversations. We replaced it twice in sigil this month, both times with the same pattern: minimal prompt + strong mechanical oracle + parallel agents + fresh-session validator. Anthropic's red team uses the same shape as the scaffold for their vulnerability research — most recently showcased with Claude Mythos, a new model, but the scaffold predates it. We apply it to bug hunting and to V-model traceability gaps. SDD plus an oracle-gated verification phase downstream is a loop. SDD alone is half of one.
+{% end %}
+
+## The pattern
+
+The shape does not have a clean industry name. Descriptively: *oracle-gated parallel agents*. Anthropic's red team uses it as the scaffold for their vulnerability-finding research[^mythos-preview]. Their own phrasing is a *"simple agentic scaffold"* that runs across multiple of their publications — the model changes, the scaffold holds. The four ingredients:
+
+1. **Minimal prompt.** The agent gets a narrow task and the artifact under investigation. Not instructions.
+2. **Strong mechanical oracle.** A check the agent cannot hallucinate its way around — a failing PoC test, a fuzzer crash, a type error, a proof obligation, a `rivet validate` diagnostic. The oracle either fires or it does not.
+3. **Parallel agents.** Many in parallel, each narrow, each independent. Diversity of candidate resolutions matters more than depth per agent.
+4. **Fresh-session validator.** A separate agent with no stake in the proposal re-runs the oracle before any change lands.
+
+The April 2026 Claude Mythos Preview used this scaffold to produce working exploits for a 27-year-old OpenBSD SACK vulnerability, a 16-year-old FFmpeg H.264 codec bug, CVE-2026-4747 in FreeBSD NFS, multiple Linux kernel privilege-escalation chains, and several browser-JIT heap-spray techniques — thousands of further findings still under responsible disclosure[^mythos-preview]. The model matters; independent reproductions with smaller open-weights models have landed similar findings on the same scaffold[^mythos-decoder], which is how you know the scaffold is doing much of the work.
+
+The shape of one pipeline pass:
+
+{% mermaid() %}
+flowchart LR
+ subgraph rank["rank.md"]
+ r["score and
prioritize candidates"]
+ end
+ subgraph discover["discover.md · parallel"]
+ direction TB
+ a1["agent 1"]
+ a2["agent 2"]
+ aN["agent N"]
+ end
+ oracle["mechanical oracle
must fire
(PoC · Kani · rivet validate
fuzzer · sanitizer · CodeQL)"]
+ subgraph validate["validate.md · fresh session"]
+ v["re-run oracle
independently"]
+ end
+ subgraph emit["emit.md"]
+ e["draft artifact or
link command"]
+ end
+
+ rank --> discover --> oracle --> validate --> emit
+
+ classDef phase fill:#13161f,stroke:#3d4258,color:#8b90a0;
+ classDef node fill:#1a1d27,stroke:#6c8cff,color:#e1e4ed;
+ classDef gate fill:#1a1d27,stroke:#fbbf24,color:#e1e4ed;
+ classDef good fill:#1a1d27,stroke:#4ade80,color:#e1e4ed;
+
+ class rank,discover,validate,emit phase;
+ class r,a1,a2,aN,v node;
+ class oracle gate;
+ class e good;
+{% end %}
+
+The amber box is the hard part. Everything before it is hypothesis generation; the oracle is the first thing that has to say yes. SDD's QA-lens agent sits in the same position — but it is a second LLM with no mechanical check behind it, so the gate is soft. A soft oracle cannot find what the specification did not think to say, and bug classes are almost by definition what the specification did not think to say.
+
+## Our two applications
+
+We run two oracle-gated pipelines in [sigil](https://github.com/pulseengine/sigil), against two different oracle types. Both live in the repo as four-file prompt pipelines: `rank.md` → `discover.md` → `validate.md` → `emit.md`.
+
+| Pipeline | Oracle | Finds | First real output |
+|---|---|---|---|
+| [`scripts/mythos/`](https://github.com/pulseengine/sigil/tree/main/scripts/mythos) | failing PoC test + Kani harness | bug classes in code | [PR #87](https://github.com/pulseengine/sigil/pull/87) — silently-swallowed `cert_count` parse error |
+| [`scripts/vmodel/`](https://github.com/pulseengine/sigil/tree/main/scripts/vmodel) | `rivet validate` diagnostics | gaps in the audit graph | [PR #90](https://github.com/pulseengine/sigil/pull/90) — 9 of 12 sigil-local errors closed in one cycle |
+
+**[`scripts/mythos/`](https://github.com/pulseengine/sigil/tree/main/scripts/mythos) — bug hunting against code.** The oracle is a failing PoC test plus, where tractable, a failing Kani harness. `rank.md` scores hypotheses across the codebase; `discover.md` runs one agent per hypothesis in parallel with a minimal prompt; `validate.md` re-runs the oracle in a fresh session with no context from the proposal; `emit.md` produces the draft artifact only if the oracle fires and the validator agrees. First real output: [PR #87](https://github.com/pulseengine/sigil/pull/87) — a malformed `cert_count` in a signature section's chain block was being silently swallowed into `None` by `if let Ok(...)`, masking bitstream corruption. The regression test is the oracle; it would now fail without the fix.
+
+**[`scripts/vmodel/`](https://github.com/pulseengine/sigil/tree/main/scripts/vmodel) — traceability gap hunting against the audit graph.** The oracle is `rivet validate` — our own traceability validator that knows requirement ↔ design ↔ code ↔ test ↔ proof links by schema. Same four-file shape; different oracle. An agent cannot hallucinate a closure, because the validator either still reports the gap or it does not. First real output: [PR #90](https://github.com/pulseengine/sigil/pull/90) closed 9 of 12 sigil-local `rivet validate` errors — 75% in one cycle. The remaining three are a schema fix, not a gap.
+
+The point of showing both is that the oracle is interchangeable. Any mechanical check your domain produces — tests, fuzzers, sanitizers, Semgrep, CodeQL, a model checker, `rivet validate`, a differential run — can be the oracle. The discipline is the same.
+
+## Why this is the half SDD does not ship
+
+SDD owns the intent axis: *what should be built.* Oracle-gated agent work owns the verification axis: *did it work, is it safe, is the audit trail complete.* They are orthogonal. You can run Spec Kit or Kiro on the front end — their structured intent document is a useful front-end — and put oracle-gated pipelines downstream of `/implement`. What you cannot do is let the QA-lens agent be the only verification and pretend you have the loop.
+
+Clay Nelson put the compliance-grade version of this argument into one sentence at GitHub Shift: Automotive[^nelson-medium]:
+
+> *"You cannot attest to what you did not observe."*
+
+The mechanical oracle is the instrument that produces the observation. A QA-lens agent reading a spec is a second opinion, not an instrument. Auditors and attackers do not accept second opinions — and the security ecosystem has built most of the instruments you need already (OSS-Fuzz, cargo-fuzz, ASAN/TSAN/Miri, Semgrep, CodeQL, OSV, GHSA, Sigstore, SLSA[^sigstore-slsa]). What is missing is the *plumbing*: running them in parallel, under the oracle-gated discipline, with a fresh-session validator before anything lands.
+
+There is a third half worth naming but not dwelling on — the iteration phase where a ticket turns into a real specification. Kent Beck keeps pointing at this[^beck-pragmatic]: *"you learn things during implementation that change what the spec should say."* SDD tools present that as done; it rarely is. Oracle-gated agent pipelines do not address this gap — they address the verification gap. Worth being explicit about what each pattern solves.
+
+## The tools around it
+
+**[rivet](https://github.com/pulseengine/rivet)** — our traceability graph validator. Ships with schemas for STPA-Sec, cybersecurity, IEC 61508, IEC 62304, DO-178C, EN 50128, ASPICE, EU AI Act. `rivet validate` is the oracle for the V-model pipeline. The `rivet query --sexpr` DSL lets agents enumerate gap candidates without LLM hallucination. Both exposed to agents through an MCP server so the trail stays current as they work. More in [rivet: because AI agents don't remember why](/blog/rivet-v0-1-0/). *(Adjacent prior art worth acknowledging on the requirements side: useblocks' [Sphinx-Needs](https://useblocks.com/products/sphinx-needs) and [ubCode](https://useblocks.com/products/ubcode) — battle-tested MCP-exposed structured requirements in automotive.)*
+
+**[spar](https://github.com/pulseengine/spar)** — our AADL v2.3 architecture toolchain, shipping at v0.6.0. 27+ analysis passes (scheduling, latency, ARINC 653 partitioning, EMV2 fault trees, memory budgets), a deployment solver with ASIL decomposition and SIL/DAL integrity constraints, a declarative assertion engine, LSP support, and a WASM component build. In the pipe: `spar-codegen` emits Cargo.toml and BUILD.bazel directly from the AADL model (the moment the architecture stops being a parallel drawing and starts being what the build reads), a SysML v2 parser for the requirements side of the roundtrip, a JSON CLI adapter so rivet can consume spar analysis results, and an MCP server so agents call spar for architecture review the same way they call rivet for traceability.
+
+**[sigil](https://github.com/pulseengine/sigil)** — in-module cryptographic signing. Sigstore keyless, SLSA L4 predicates, per-transformation attestations. This is what operationalizes Clay Nelson's *cannot attest to what you did not observe* end-to-end: each build transformation produces signed evidence or it does not.
+
+**Spec Kit / Kiro / Tessl** — fine front-ends for `/specify`. The minimum we expect them to emit into our loop is a draft rivet requirement: title, description, acceptance criteria structured enough that rivet's schema can validate them, and a parent architecture or requirement link. Anything less is a prose ticket in a different file.
+
+None of these alone closes the loop. Together — the MBSE layer (spar for architecture, rivet for requirements and traceability), oracle-gated agents against a mechanical check, sigil-signed build output — they are the shape of an AI-assisted loop that produces an audit trail a regulator or an attacker cannot dismiss.
+
+## How do you make an AI follow the V-model?
+
+Someone asked me this the first time I showed them rivet, and it is the right question. The answer is that the question misstates the problem. You do not make the agent follow the V-model through instructions. You make the tools *require* V-model shape, and the agent responds to the errors the tools produce. It is the difference between *"please follow the rules"* and *"the door is locked until you follow the rules."* Only the second works on LLMs.
+
+The shape of the flow, assuming a GitHub issue or Jira ticket as the starting point — the same structure holds for either:
+
+{% mermaid() %}
+flowchart LR
+ issue([issue · ticket])
+ req["requirement
rivet validate"]
+ design["design
rivet validate"]
+ impl["implement
scripts/mythos
failing-test oracle"]
+ unit["unit tests
test pass"]
+ integ["integration
rivet validate
verified-by links"]
+ signed([sigil-signed
attestation bundle])
+
+ issue --> req --> design --> impl --> unit --> integ --> signed
+
+ req -.verified-by.-> integ
+ design -.verified-by.-> unit
+
+ classDef start fill:#13161f,stroke:#4a5068,color:#e1e4ed;
+ classDef node fill:#1a1d27,stroke:#6c8cff,color:#e1e4ed;
+ classDef gate fill:#1a1d27,stroke:#fbbf24,color:#e1e4ed;
+ classDef good fill:#1a1d27,stroke:#4ade80,color:#e1e4ed;
+
+ class issue start;
+ class req,design,unit,integ node;
+ class impl gate;
+ class signed good;
+{% end %}
+
+The dotted lines are the V-model's symmetry made concrete: the requirement is *verified-by* the integration result; the design is *verified-by* the unit tests. `rivet validate` checks both directions. The six-step walk-through:
+
+1. **The issue is already the spec.** Description, acceptance criteria, linked design doc if you have one. You do not re-type this into a Spec Kit markdown form; you use the issue as the input to step 2.
+
+2. **Iterate into a rivet requirement.** An agent drafts the structured artifact from the issue text (status, description, safety goal, verification-method, parent architecture element). `rivet validate` runs and complains about missing fields. Each complaint is the next prompt. This is the iteration phase — not a separate ceremony, just a tight loop between the agent, the human reviewing the draft, and the schema. Beck's *"you learn things during implementation that change what the spec should say"* happens here, with the schema as the teacher.
+
+3. **Descend the left side of the V.** `rivet validate` now reports: this requirement has no linked design, no linked test plan, no risk analysis, no allocated component. Each report is an ERROR. The [`scripts/vmodel/`](https://github.com/pulseengine/sigil/tree/main/scripts/vmodel) pipeline picks those up: `discover.md` proposes closures in parallel (one agent per gap), `validate.md` re-runs `rivet validate` in a fresh session, `emit.md` produces either a `rivet link` command (if the target artifact exists) or a draft artifact for human review (if it does not). No LLM narrative in the loop — just the validator's diagnostic and the agent's proposed closure.
+
+4. **Implement with the test as oracle.** The tests that fell out of step 3 are now the mechanical oracle. [`scripts/mythos/`](https://github.com/pulseengine/sigil/tree/main/scripts/mythos) pattern: minimal prompt, the failing test, parallel agents for different sub-tasks, fresh-session validator that re-runs the test before anything merges. If you want stronger evidence, a Kani harness or a Verus contract on top of the test.
+
+5. **Ascend the right side.** `rivet validate` gates again. Every requirement now needs a `verified-by` link to a passing test; every code change needs an `implements` link to the design; every test needs a `verifies` link back to a requirement. The agent either adds the link (because the artifact exists) or flags the gap (because it does not). The ascent is not a ritual; it is a graph-completion task with the validator as oracle.
+
+6. **Sign and merge.** `sigil` attaches signed evidence to each transformation — source commit, Bazel build, test pass, `rivet validate` zero-errors, Kani harness result if present. The PR carries the attestation bundle. Clay Nelson's *"cannot attest to what you did not observe"* becomes "observed and signed at each step." Audit trail complete.
+
+At no point does anyone tell the agent *"follow the V-model."* The agent responds to `rivet validate` errors. That is what *"the tools require V-model shape"* means in practice — and it is why the V-model survives AI velocity instead of being eroded by it.
+
+If you prefer Spec Kit or Kiro as the front-end for step 1 or 2, that slots in cleanly: use `/specify` to produce the draft rivet requirement rather than writing one directly. The oracle-gated steps 3–6 do not change. Front-end choice is a matter of taste; the mechanical floor is what makes the agent's output attestable.
+
+## MBSE, mandatory now
+
+Model-Based Systems Engineering has a reputation. For two decades it was sold as the future — SysML, AADL, Capella, Papyrus, executable architecture, traceable models from requirements to deployment — and most of us said no. The tooling was heavy. The models drifted from code within weeks. The cost-benefit did not pencil for anything short of aerospace.
+
+Two things shifted, both for the same reason: AI agents.
+
+First, on the cost side, authoring the model stopped being the bottleneck. A structured requirement block that used to take half a day of an engineer's time to write, review, and link now takes a few agent-minutes plus a human review of the draft. The drift problem shrinks for the same reason — maintaining the model as a first-class artifact stops being free labor nobody signed up for and starts being another loop the agent closes against the schema oracle. This is the same economic shift I argued for formal verification in [Formal verification just became practical](/blog/formal-verification-ai-agents/); it lands on MBSE for the same reasons.
+
+Second, and more load-bearing, in a world where the agent produces most of the code, *how do you prove what was produced*? The answer cannot be *"we trust the agent"* and cannot be *"our QA-lens agent reviewed the output."* Both are soft oracles. The answer has to be a model — a structured, machine-readable description of the intended system — against which the built system is mechanically checked. That is MBSE renamed for AI velocity.
+
+And the model has to *drive* the build, not sit alongside it. Audit-only models drift and get skipped under pressure. Models that actually select cargo features, emit build files, or configure hardware cannot be skipped, because the build depends on them:
+
+{% mermaid() %}
+flowchart LR
+ subgraph mbse["MBSE layer"]
+ direction TB
+ spar["spar
AADL architecture"]
+ rivet["rivet
requirements · variants · links"]
+ end
+
+ codegen["spar-codegen
Cargo.toml · BUILD.bazel
#[aadl] attributes"]
+ build[compiled binary]
+ validate[rivet validate]
+ attest([sigil attestation bundle])
+
+ mbse -->|drives| codegen
+ mbse -->|gates| validate
+ codegen --> build
+ build --> attest
+ validate --> attest
+
+ classDef phase fill:#13161f,stroke:#3d4258,color:#8b90a0;
+ classDef model fill:#1a1d27,stroke:#fbbf24,color:#e1e4ed;
+ classDef step fill:#1a1d27,stroke:#6c8cff,color:#e1e4ed;
+ classDef good fill:#1a1d27,stroke:#4ade80,color:#e1e4ed;
+
+ class mbse phase;
+ class spar,rivet model;
+ class codegen,build,validate step;
+ class attest good;
+{% end %}
+
+Two concrete beats from our own stack: [spar](https://github.com/pulseengine/spar) already ships deployment allocation with ASIL decomposition and ARINC 653 partitioning — the integrity-level constraints are first-class in the solver, not bolted-on checks. Next on the roadmap, `spar-codegen` emits Cargo.toml and BUILD.bazel directly from the AADL model, with `#[aadl(period = ...)]` attributes tying Rust functions to the architecture elements they implement. The moment that lands, you cannot produce a binary without the model, because the build files are the model's output. rivet's variant artifacts do the symmetric thing on the requirements side — selecting which requirement set applies at which integrity level, gated by `rivet validate`. `sigil` signs the bundle only when all three layers — architecture, requirements, build — agree. The model is no longer a parallel document. It is the source of truth the build depends on, and that dependency is what makes skipping it impossible rather than merely discouraged.
+
+Where *"mandatory"* cuts: not every internal CRUD app needs MBSE. The line is systems with safety regulation, systems facing external auditors, and high-blast-radius infrastructure — cryptographic components, OS kernels, signing tools, language-model infrastructure with reach into hundreds of downstream systems. The common denominator is *"if this fails, the failure is not locally contained."* For those systems, the "too heavy" argument against MBSE stops holding; the alternative is shipping AI-authored code with no answer to how-do-you-prove.
+
+In the past the argument was *"we can't do MBSE, it's too heavy for our pace."* For any system that needs to be proven, that argument is over. The new argument is the inverse: if you want an attestable trail from the agent's input to the agent's output, a model is how you get it. Otherwise — how do you prove?
+
+## Limits, migration, and where to start
+
+The post argues for a pattern; here is what would stop me from over-pitching it.
+
+**The oracle has to exist.** The pattern works where the check is mechanical — tests, fuzzers, proof obligations, `rivet validate`, schema diagnostics, `sigil` verify. On domains with blurry correctness signals — performance regressions, UX quality, business-logic smells — there is no crisp oracle and this pattern does not close the loop. Performance has proxies (criterion benchmarks as the oracle for no-regression); UX and product sense do not. Admit it before anyone has to point it out.
+
+**The oracle can be wrong.** If a Verus contract encodes an incorrect postcondition, or a rivet schema rule fires for the wrong reason, the pipeline confidently enforces the wrong behaviour. Three countermeasures, all already in our stack: (1) multiple independent oracles on the same property — Verus + Kani + property tests discharge the same claim by different techniques, and a bug in any one is revealed by disagreement with the other two; (2) mutation testing (cargo-mutants) catches tautological oracles — if mutating the code does not fail the test, the test was not a real oracle; (3) counter-examples force re-examination of the oracle, not just the code. When Kani reports a failing execution, the right move is to read the trace and ask *"is my property wrong?"* before asking *"is my code wrong?"*
+
+**Signed is not the same as safe.** This is the attestation-illiteracy risk — management or auditors reading a `sigil` bundle and hearing *"safe."* The bundle claims only what was observed: *this commit's Verus proof discharged, these Kani harnesses ran to completion, `rivet validate` reported zero errors against these schema versions*. It does not claim the deployed binary is correct, that the proof obligations were the right ones, or that the schemas captured the right properties. The way to avoid the confusion is to name what the attestation *does not* cover inside the attestation itself — versions of each oracle, scope of the proof, schema revisions, explicitly excluded failure modes.
+
+**Brownfield does not mean stop-the-world.** If you already have Jira or DOORS requirements, rivet reads those (or you script the import once) and starts producing diagnostics the next day — no new authoring required beyond filling the schema gaps rivet surfaces. spar is a bigger ask, so earn its place: introduce it where an AADL or SysML model already exists, or where the architecture analysis pays for itself (scheduling feasibility, ARINC 653 isolation, deployment allocation). sigil is additive — it layers onto whatever CI you have.
+
+**If you can only start with one, start with rivet.** It is the oracle, it works standalone, and it produces diagnostics on day one against whatever requirement set you already have. spar earns its place when an architecture model exists or is worth building. sigil is the easiest to add late, because its output format is orthogonal to the rest of the pipeline.
+
+## Take-away
+
+- SDD is useful at the front. Put oracle-gated parallel agents downstream. The QA-lens agent is not the verification.
+- The oracle is the part that matters. Any mechanical check your domain produces works — tests, fuzzers, proofs, `rivet validate`, Semgrep, CodeQL. Pick one, gate on it, fresh-session-validate before merging.
+- Parallel is the trick. Diversity of candidate resolutions beats depth per agent.
+- Make the model essential to the build, not parallel to it. If spar-codegen emits your Cargo.toml and rivet variants select your cargo features, nobody can skip the MBSE layer.
+- The audit trail is the product. Ticket → iteration → model → oracle-gated build → signed artifact. MBSE is mandatory now for anything that has to be proven — not because the process team asked for it, but because that is the only way to answer "how do you prove?"
+
+We published the four-prompt pipeline skeletons in `scripts/mythos/` and `scripts/vmodel/`. The `mythos` directory is a deliberate homage to the Anthropic preview that put this scaffold in the public eye — the pattern is what we ran, the name of the model is what made it newsworthy. Copy, adapt the oracle for your domain, run. The discipline transfers; the tooling transfers through open Skills primitives that Claude Code, Cursor, Codex CLI, and Copilot all consume.
+
+---
+
+## Sources
+
+[^mythos-preview]: Anthropic — *Claude Mythos Preview.* Red-team research publication, April 2026. [red.anthropic.com/2026/mythos-preview](https://red.anthropic.com/2026/mythos-preview/). Primary (Anthropic publication). *Mythos* is a new general-purpose Claude model, not a methodology; the preview describes the "simple agentic scaffold" used across Anthropic's prior vulnerability-finding work and the specific bugs this model produced exploits for (OpenBSD SACK, FFmpeg H.264, FreeBSD NFS, Linux kernel privesc chains, browser JITs). The scaffold is what this post calls the pattern; the model is what made the 2026 results newsworthy.
+
+[^mythos-decoder]: *The myth of Claude Mythos crumbles as small open models hunt the same cybersecurity bugs Anthropic showcased*. The Decoder, 2026. [the-decoder.com](https://the-decoder.com/the-myth-of-claude-mythos-crumbles-as-small-open-models-hunt-the-same-cybersecurity-bugs-anthropic-showcased/). Secondary (journalism; reports reproduction of Anthropic's scaffold results with smaller open-weights models).
+
+[^nelson-medium]: Clay Nelson — *Automotive's AI problem isn't speed, it's proof.* Medium, April 2026. [medium.com/@claynelson](https://medium.com/@claynelson/automotives-ai-problem-isn-t-speed-it-s-proof-15a1d3cc9cee). Primary (author's own publication). Source of the *"you cannot attest to what you did not observe"* line delivered at GitHub Shift: Automotive in Frankfurt.
+
+[^sigstore-slsa]: [Sigstore](https://www.sigstore.dev/) — keyless signing, Fulcio CA, Rekor transparency log. [SLSA](https://slsa.dev/) — Supply-chain Levels for Software Artifacts. Primary (specification project home pages). Both are the build-attestation and provenance machinery that makes Nelson's argument operationally concrete.
+
+[^beck-pragmatic]: Kent Beck — *TDD, AI agents, and coding with Kent Beck.* The Pragmatic Engineer newsletter interview. [newsletter.pragmaticengineer.com](https://newsletter.pragmaticengineer.com/p/tdd-ai-agents-and-coding-with-kent). Secondary (interview).
+
+---
+
+*This post is part of [PulseEngine](/) — a formally verified WebAssembly Component Model engine for safety-critical systems. Prior posts in the arc: [Formal verification just became practical](/blog/formal-verification-ai-agents/), [What comes after test suites](/blog/what-comes-after-test-suites/), [rivet v0.1.0](/blog/rivet-v0-1-0/).*
diff --git a/sass/_base.scss b/sass/_base.scss
index e3537ec..445fbc2 100644
--- a/sass/_base.scss
+++ b/sass/_base.scss
@@ -19,6 +19,55 @@ body {
min-height: 100vh;
}
+// Focus indicators for keyboard navigation
+:focus-visible {
+ outline: 2px solid $accent;
+ outline-offset: 2px;
+}
+
+:focus:not(:focus-visible) {
+ outline: none;
+}
+
+// Skip to content link
+.skip-link {
+ position: absolute;
+ top: -100%;
+ left: 1rem;
+ z-index: 1000;
+ padding: 0.5rem 1rem;
+ background: $accent;
+ color: $bg;
+ border-radius: 0 0 8px 8px;
+ font-weight: 600;
+ text-decoration: none;
+
+ &:focus {
+ top: 0;
+ }
+}
+
+// Screen-reader only (visually hidden but accessible)
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+// Reduced motion
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ }
+}
+
a {
color: $accent;
text-decoration: none;
@@ -80,13 +129,16 @@ pre {
border-radius: 6px;
color: $text-faint;
font-family: $font-mono;
- font-size: 0.7rem;
+ font-size: 0.75rem;
padding: 0.25em 0.5em;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, border-color 0.2s, color 0.2s;
- pre:hover & {
+ pre:hover &,
+ pre:focus-within &,
+ &:focus,
+ &:focus-visible {
opacity: 1;
}
diff --git a/sass/_blog.scss b/sass/_blog.scss
index 3804f01..ac23401 100644
--- a/sass/_blog.scss
+++ b/sass/_blog.scss
@@ -308,3 +308,264 @@
.toc ul ul {
padding-left: 1rem;
}
+
+// Footnotes — rendered as
also catches clicks on the
+// padding around the SVG; extend the zoom cursor there.
+.blog-post__content pre.mermaid {
+ cursor: zoom-in;
+}
+
+body.diagram-modal-open {
+ overflow: hidden;
+}
+
+.diagram-modal {
+ display: none;
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ background: rgba(15, 17, 23, 0.92);
+ backdrop-filter: blur(6px);
+ align-items: center;
+ justify-content: center;
+ padding: 3.5rem 1.5rem 2rem;
+ touch-action: none;
+
+ &.is-open {
+ display: flex;
+ }
+}
+
+.diagram-modal__stage {
+ width: 100%;
+ max-width: 1600px;
+ max-height: 90vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: grab;
+ user-select: none;
+ touch-action: none;
+
+ > svg {
+ width: 100%;
+ height: auto;
+ max-height: 90vh;
+ }
+}
+
+.diagram-modal__close {
+ position: absolute;
+ top: 1rem;
+ right: 1.25rem;
+ background: transparent;
+ border: 1px solid $border-subtle;
+ color: $text;
+ font-size: 1.6rem;
+ line-height: 1;
+ cursor: pointer;
+ padding: 0.15rem 0.6rem 0.3rem;
+ border-radius: 6px;
+
+ &:hover {
+ background: $surface-raised;
+ border-color: $accent;
+ }
+}
+
+.diagram-modal__hint {
+ position: absolute;
+ bottom: 1rem;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 0.8rem;
+ color: $text-faint;
+ letter-spacing: 0.05em;
+ pointer-events: none;
+}
+
+// ── Credit-matrix heatmap (domain × technique) ────────────────────
+.credit-matrix-wrap {
+ overflow-x: auto;
+ margin: 1.5rem 0 0.5rem;
+ border: 1px solid $border-subtle;
+ border-radius: $glass-radius;
+}
+
+.credit-matrix {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+ margin: 0;
+
+ th,
+ td {
+ padding: 0.6rem 0.9rem;
+ text-align: center;
+ border-bottom: 1px solid $border-subtle;
+ white-space: nowrap;
+ }
+
+ thead th {
+ font-weight: 600;
+ color: $text-faint;
+ text-transform: uppercase;
+ font-size: 0.68rem;
+ letter-spacing: 0.06em;
+ border-bottom: 1px solid $border;
+ background: $surface;
+ vertical-align: bottom;
+ }
+
+ tbody th {
+ text-align: left;
+ font-weight: 600;
+ color: $text;
+ background: $surface;
+ position: sticky;
+ left: 0;
+ }
+
+ tbody tr:last-child th,
+ tbody tr:last-child td {
+ border-bottom: none;
+ }
+
+ td {
+ font-size: 1.15rem;
+ line-height: 1;
+ font-weight: 600;
+
+ &.fit-strong {
+ background: rgba(74, 222, 128, 0.16);
+ color: $green;
+ }
+
+ &.fit-partial {
+ background: rgba(251, 191, 36, 0.16);
+ color: $amber;
+ }
+
+ &.fit-gap {
+ background: rgba(248, 113, 113, 0.16);
+ color: $red;
+ }
+
+ &.fit-na {
+ color: $text-faint;
+ }
+ }
+}
+
+.credit-matrix-legend {
+ display: flex;
+ gap: 1.25rem;
+ flex-wrap: wrap;
+ font-size: 0.8rem;
+ color: $text-dim;
+ margin: 0.5rem 0 1.5rem;
+
+ span {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+
+ &::before {
+ content: '';
+ display: inline-block;
+ width: 0.8rem;
+ height: 0.8rem;
+ border-radius: 3px;
+ }
+
+ &.fit-strong::before { background: rgba(74, 222, 128, 0.5); }
+ &.fit-partial::before { background: rgba(251, 191, 36, 0.5); }
+ &.fit-gap::before { background: rgba(248, 113, 113, 0.5); }
+ &.fit-na::before { background: rgba(128, 134, 152, 0.35); }
+ }
+}
diff --git a/sass/_glass.scss b/sass/_glass.scss
index ab3de5c..a93f4dc 100644
--- a/sass/_glass.scss
+++ b/sass/_glass.scss
@@ -19,6 +19,12 @@
&:hover {
color: inherit;
}
+
+ &:focus-visible {
+ border-color: $accent;
+ box-shadow: 0 0 0 2px rgba(108, 140, 255, 0.4);
+ color: inherit;
+ }
}
.glass-card__title {
@@ -71,27 +77,42 @@
background: $accent;
color: $bg;
}
+
+ &:focus-visible {
+ background: $accent;
+ color: $bg;
+ box-shadow: 0 0 0 2px rgba(108, 140, 255, 0.4);
+ }
}
// Hero
.hero {
text-align: center;
- padding: 5rem 0 3rem;
+ padding: 5rem 0 2rem;
}
.hero__title {
font-size: 2.75rem;
font-weight: 700;
letter-spacing: -0.03em;
- margin-bottom: 1rem;
+ margin-bottom: 0.5rem;
}
.hero__subtitle {
- font-size: 1.125rem;
- color: $text-dim;
+ font-size: 1.35rem;
+ color: $text;
+ font-weight: 600;
max-width: 600px;
+ margin: 0 auto 1rem;
+ line-height: 1.4;
+}
+
+.hero__lead {
+ font-size: 0.95rem;
+ color: $text-dim;
+ max-width: 640px;
margin: 0 auto 2rem;
- line-height: 1.6;
+ line-height: 1.7;
}
.hero__actions {
@@ -227,6 +248,85 @@
padding: 3rem 0;
}
+// Flip cards
+.flip-card {
+ perspective: 600px;
+ cursor: pointer;
+ outline: none;
+
+ // Kill all glass-card hover/transition inside flip cards — they fight with 3D
+ .glass-card {
+ transition: border-color 0.2s;
+ &:hover {
+ transform: none;
+ border-color: inherit;
+ }
+ }
+
+ &:hover .flip-card__front.glass-card {
+ border-color: $accent;
+ }
+
+ &:focus-visible .flip-card__inner {
+ box-shadow: 0 0 0 2px rgba(108, 140, 255, 0.4);
+ border-radius: $glass-radius;
+ }
+}
+
+.flip-card__inner {
+ position: relative;
+ transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
+ transform-style: preserve-3d;
+}
+
+.flip-card.flipped .flip-card__inner {
+ transform: rotateY(180deg);
+}
+
+.flip-card__front,
+.flip-card__back {
+ backface-visibility: hidden;
+ min-height: 180px;
+}
+
+.flip-card__front {
+ position: relative;
+}
+
+.flip-card__back {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ transform: rotateY(180deg);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ overflow: auto;
+}
+
+.flip-card__detail {
+ font-size: 0.85rem;
+ color: $text-dim;
+ line-height: 1.7;
+ margin-bottom: 0.75rem;
+}
+
+.flip-card__links {
+ display: flex;
+ gap: 1rem;
+ margin-top: auto;
+ font-size: 0.85rem;
+ font-weight: 500;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .flip-card__inner {
+ transition: none;
+ }
+}
+
// Project card specifics
.project-card__icon {
font-size: 1.5rem;
@@ -248,6 +348,134 @@
padding: 2rem 0;
}
+// Section intro text
+.section-intro {
+ font-size: 0.95rem;
+ color: $text-dim;
+ max-width: $content-width;
+ line-height: 1.7;
+ margin-bottom: 1.5rem;
+}
+
+// Transformation grid — before/after cards
+.transform-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: $gap;
+}
+
+.transform-item {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+
+ &__before {
+ font-size: 0.85rem;
+ color: $text-faint;
+ line-height: 1.6;
+ padding-left: 1rem;
+ border-left: 2px solid $border;
+ }
+
+ &__after {
+ font-size: 0.9rem;
+ color: $text;
+ line-height: 1.7;
+ padding-left: 1rem;
+ border-left: 2px solid $accent;
+ }
+}
+
+.section-more {
+ margin-top: 1.25rem;
+ font-size: 0.875rem;
+}
+
+// Ecosystem map
+.ecosystem-map {
+ margin: 0 auto 2rem;
+ max-width: 900px;
+
+ svg {
+ width: 100%;
+ height: auto;
+ }
+}
+
+.ecosystem-note {
+ text-align: center;
+ font-size: 0.85rem;
+ color: $text-faint;
+ margin-bottom: 2rem;
+}
+
+// Proof paths — three-column verification cards
+.proof-paths {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: $gap;
+}
+
+.proof-path {
+ text-align: center;
+
+ &__name {
+ font-size: 1.2rem;
+ font-weight: 700;
+ color: $text;
+ margin-bottom: 0.25rem;
+ }
+
+ &__method {
+ font-size: 0.75rem;
+ color: $accent;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ margin-bottom: 0.75rem;
+ }
+
+ &__desc {
+ font-size: 0.85rem;
+ color: $text-dim;
+ line-height: 1.6;
+ }
+}
+
+// Builder section
+.builder-section {
+ max-width: 820px;
+}
+
+.builder-intro {
+ font-size: 0.95rem;
+ line-height: 1.75;
+ color: $text-dim;
+}
+
+.builder-attrib {
+ font-size: 0.8rem;
+ color: $text-faint;
+ margin-bottom: 0;
+ margin-top: 1.25rem;
+ padding-top: 1rem;
+ border-top: 1px solid $border-subtle;
+}
+
+// Status note at bottom
+.status-section {
+ padding-top: 1rem;
+}
+
+.status-note {
+ font-size: 0.8rem;
+ color: $text-faint;
+ text-align: center;
+ max-width: $content-width;
+ margin: 0 auto;
+ line-height: 1.6;
+}
+
// Footer
.site-footer {
border-top: 1px solid $border-subtle;
@@ -259,7 +487,7 @@
.build-ref {
font-family: $font-mono;
- font-size: 0.7rem;
+ font-size: 0.75rem;
a {
color: $text-faint;
diff --git a/sass/_nav.scss b/sass/_nav.scss
index 53bb1a0..628bf36 100644
--- a/sass/_nav.scss
+++ b/sass/_nav.scss
@@ -33,7 +33,7 @@
color: $text-dim;
font-size: 0.875rem;
font-weight: 500;
- padding: 0.25rem 0;
+ padding: 0.5rem 0.25rem;
border-bottom: 2px solid transparent;
transition: color 0.2s, border-color 0.2s;
@@ -42,4 +42,8 @@
color: $text;
border-bottom-color: $accent;
}
+
+ &:focus-visible {
+ border-bottom-color: $accent;
+ }
}
diff --git a/sass/_responsive.scss b/sass/_responsive.scss
index 652b4d2..5fde8eb 100644
--- a/sass/_responsive.scss
+++ b/sass/_responsive.scss
@@ -13,7 +13,9 @@
.grid,
.blog-list,
- .intro-columns {
+ .intro-columns,
+ .proof-paths,
+ .transform-grid {
grid-template-columns: 1fr;
}
diff --git a/sass/_variables.scss b/sass/_variables.scss
index 2177e8b..542fefb 100644
--- a/sass/_variables.scss
+++ b/sass/_variables.scss
@@ -4,11 +4,11 @@ $bg-subtle: #13161f;
$surface: #1a1d27;
$surface-raised: #242836;
$surface-glass: rgba(26, 29, 39, 0.72);
-$border: #2e3345;
-$border-subtle: #252836;
+$border: #4a5068;
+$border-subtle: #3d4258;
$text: #e1e4ed;
$text-dim: #8b90a0;
-$text-faint: #5c6070;
+$text-faint: #808698;
$accent: #6c8cff;
$accent-glow: rgba(108, 140, 255, 0.12);
$green: #4ade80;
diff --git a/static/bg-julia.js b/static/bg-julia.js
index 566157b..54f92fe 100644
--- a/static/bg-julia.js
+++ b/static/bg-julia.js
@@ -103,9 +103,9 @@
} else {
const log2 = Math.log(2);
const nu = Math.log(Math.log(zr * zr + zi * zi) / log2) / log2;
- const smooth = (iter + 1 - nu) / maxIter;
- const ci2 = ((smooth * 255 * 3) | 0) % 256;
- const col = palette[ci2];
+ const smooth = Math.max(0, Math.min(1, (iter + 1 - (isFinite(nu) ? nu : 0)) / maxIter));
+ const ci2 = (smooth * 255) | 0;
+ const col = palette[Math.min(255, Math.max(0, ci2))];
buf[idx] = col[0]; buf[idx + 1] = col[1]; buf[idx + 2] = col[2]; buf[idx + 3] = 255;
}
}
diff --git a/static/diagram-zoom.js b/static/diagram-zoom.js
new file mode 100644
index 0000000..d073a32
--- /dev/null
+++ b/static/diagram-zoom.js
@@ -0,0 +1,142 @@
+// Click-to-zoom for diagrams in blog posts.
+// Uses event delegation on .blog-post__content so Mermaid's async-rendered
+// SVGs work without needing to re-attach handlers.
+// Modal supports wheel zoom, drag pan, ESC/backdrop close.
+(function () {
+ 'use strict';
+
+ var postContent = document.querySelector('.blog-post__content');
+ if (!postContent) return;
+
+ var MIN_SCALE = 0.25;
+ var MAX_SCALE = 10;
+
+ var modal = null;
+ var stage = null;
+ var state = { scale: 1, x: 0, y: 0 };
+ var drag = null;
+
+ function apply() {
+ if (!stage || !stage.firstElementChild) return;
+ stage.firstElementChild.style.transform =
+ 'translate(' + state.x + 'px, ' + state.y + 'px) scale(' + state.scale + ')';
+ }
+
+ function makeEl(tag, cls, attrs) {
+ var el = document.createElement(tag);
+ if (cls) el.className = cls;
+ if (attrs) {
+ for (var k in attrs) {
+ if (Object.prototype.hasOwnProperty.call(attrs, k)) el.setAttribute(k, attrs[k]);
+ }
+ }
+ return el;
+ }
+
+ function createModal() {
+ modal = makeEl('div', 'diagram-modal', {
+ role: 'dialog',
+ 'aria-modal': 'true',
+ 'aria-label': 'Zoomed diagram',
+ });
+
+ var closeBtn = makeEl('button', 'diagram-modal__close', {
+ type: 'button',
+ 'aria-label': 'Close zoomed diagram',
+ });
+ closeBtn.textContent = '×';
+
+ var hint = makeEl('div', 'diagram-modal__hint');
+ hint.textContent = 'scroll to zoom · drag to pan · ESC to close';
+
+ stage = makeEl('div', 'diagram-modal__stage', { role: 'presentation' });
+
+ modal.appendChild(closeBtn);
+ modal.appendChild(hint);
+ modal.appendChild(stage);
+
+ modal.addEventListener('click', function (e) {
+ if (e.target === modal) close();
+ });
+ closeBtn.addEventListener('click', close);
+
+ modal.addEventListener('wheel', function (e) {
+ e.preventDefault();
+ var factor = e.deltaY < 0 ? 1.18 : 1 / 1.18;
+ var next = state.scale * factor;
+ if (next < MIN_SCALE) next = MIN_SCALE;
+ if (next > MAX_SCALE) next = MAX_SCALE;
+ state.scale = next;
+ apply();
+ }, { passive: false });
+
+ stage.addEventListener('pointerdown', function (e) {
+ drag = { x: e.clientX, y: e.clientY, ox: state.x, oy: state.y };
+ try { stage.setPointerCapture(e.pointerId); } catch (_) {}
+ stage.style.cursor = 'grabbing';
+ });
+ stage.addEventListener('pointermove', function (e) {
+ if (!drag) return;
+ state.x = drag.ox + (e.clientX - drag.x);
+ state.y = drag.oy + (e.clientY - drag.y);
+ apply();
+ });
+ var endDrag = function () {
+ drag = null;
+ if (stage) stage.style.cursor = 'grab';
+ };
+ stage.addEventListener('pointerup', endDrag);
+ stage.addEventListener('pointercancel', endDrag);
+
+ document.body.appendChild(modal);
+ }
+
+ function open(svg) {
+ if (!modal) createModal();
+ var clone = svg.cloneNode(true);
+ clone.removeAttribute('width');
+ clone.removeAttribute('height');
+ clone.style.transformOrigin = 'center center';
+ clone.style.transition = 'none';
+ clone.style.maxWidth = '100%';
+ clone.style.maxHeight = '100%';
+ while (stage.firstChild) stage.removeChild(stage.firstChild);
+ stage.appendChild(clone);
+ state = { scale: 1, x: 0, y: 0 };
+ apply();
+ modal.classList.add('is-open');
+ document.body.classList.add('diagram-modal-open');
+ }
+
+ function close() {
+ if (!modal) return;
+ modal.classList.remove('is-open');
+ document.body.classList.remove('diagram-modal-open');
+ while (stage.firstChild) stage.removeChild(stage.firstChild);
+ }
+
+ // Event delegation: one click handler on the post content,
+ // catches any SVG (including Mermaid's async renders) and any
+ // .diagram-zoomable element (e.g. table wrappers).
+ postContent.addEventListener('click', function (e) {
+ // Respect nested anchors (bespoke SVGs like pipeline.html)
+ if (e.target.closest && e.target.closest('a[href]')) return;
+
+ var target = null;
+ if (e.target.closest) {
+ target = e.target.closest('svg');
+ if (!target) target = e.target.closest('.diagram-zoomable');
+ }
+ if (!target) return;
+
+ // If the zoomable element is a container (e.g. table wrapper), find its
+ // SVG child if any; otherwise zoom the container itself.
+ var inner = target.tagName === 'svg' ? target : (target.querySelector('svg') || target);
+ e.preventDefault();
+ open(inner);
+ });
+
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') close();
+ });
+})();
diff --git a/static/flip.js b/static/flip.js
new file mode 100644
index 0000000..fb77fb2
--- /dev/null
+++ b/static/flip.js
@@ -0,0 +1,17 @@
+// Flip cards — click to toggle
+(function () {
+ 'use strict';
+ document.querySelectorAll('.flip-card').forEach(function (card) {
+ card.addEventListener('click', function (e) {
+ // Don't flip if clicking a link on the back
+ if (e.target.tagName === 'A') return;
+ card.classList.toggle('flipped');
+ });
+ card.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ card.classList.toggle('flipped');
+ }
+ });
+ });
+})();
diff --git a/static/journey.js b/static/journey.js
new file mode 100644
index 0000000..784d938
--- /dev/null
+++ b/static/journey.js
@@ -0,0 +1,110 @@
+// Journey — path selection + fuse animation + scroll reveals
+(function () {
+ 'use strict';
+
+ var journey = document.getElementById('journey');
+ if (!journey) return;
+
+ var fuseFill = journey.querySelector('.fuse__fill');
+ var steps = journey.querySelectorAll('.journey__step');
+ var nodes = journey.querySelectorAll('.fuse__node');
+ var pathBtns = document.querySelectorAll('.paths__btn');
+ var activePath = null;
+ var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ // -- Path selection --
+ pathBtns.forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var path = btn.getAttribute('data-path');
+
+ // Toggle
+ if (activePath === path) {
+ activePath = null;
+ pathBtns.forEach(function (b) { b.classList.remove('paths__btn--active'); });
+ } else {
+ activePath = path;
+ pathBtns.forEach(function (b) {
+ b.classList.toggle('paths__btn--active', b.getAttribute('data-path') === path);
+ });
+ }
+
+ updateVisibility();
+
+ // Scroll to journey
+ journey.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth' });
+ });
+ });
+
+ function updateVisibility() {
+ steps.forEach(function (step) {
+ var paths = step.getAttribute('data-paths');
+ var isRelevant = !activePath || paths === 'all' || paths.indexOf(activePath) !== -1;
+
+ step.classList.toggle('journey__step--dim', !isRelevant && !!activePath);
+
+ // Show/hide expanded content
+ var expands = step.querySelectorAll('.journey__expand');
+ expands.forEach(function (ex) {
+ var showFor = ex.getAttribute('data-show');
+ ex.style.display = (activePath === showFor) ? 'block' : 'none';
+ });
+ });
+ }
+
+ // Initial state — hide all expands
+ updateVisibility();
+
+ // -- Fuse fill on scroll --
+ function updateFuse() {
+ var rect = journey.getBoundingClientRect();
+ var journeyTop = rect.top;
+ var journeyHeight = rect.height;
+ var viewportHeight = window.innerHeight;
+
+ // How far through the journey are we?
+ var progress = Math.max(0, Math.min(1,
+ (viewportHeight - journeyTop) / (journeyHeight + viewportHeight * 0.5)
+ ));
+
+ if (fuseFill) {
+ fuseFill.style.transform = 'scaleY(' + progress + ')';
+ }
+
+ // Light up nodes
+ nodes.forEach(function (node) {
+ var nodeRect = node.getBoundingClientRect();
+ var nodeCenter = nodeRect.top + nodeRect.height / 2;
+ node.classList.toggle('fuse__node--lit', nodeCenter < viewportHeight * 0.6);
+ });
+ }
+
+ // -- Reveal on scroll --
+ var reveals = document.querySelectorAll('.reveal');
+
+ if (reduceMotion) {
+ reveals.forEach(function (el) { el.classList.add('visible'); });
+ }
+
+ var revealObserver = new IntersectionObserver(function (entries) {
+ entries.forEach(function (entry) {
+ if (entry.isIntersecting) {
+ entry.target.classList.add('visible');
+ }
+ });
+ }, { threshold: 0.15, rootMargin: '0px 0px -50px 0px' });
+
+ reveals.forEach(function (el) { revealObserver.observe(el); });
+
+ // Scroll handler (throttled)
+ var ticking = false;
+ window.addEventListener('scroll', function () {
+ if (!ticking) {
+ requestAnimationFrame(function () {
+ updateFuse();
+ ticking = false;
+ });
+ ticking = true;
+ }
+ });
+ updateFuse();
+})();
diff --git a/templates/base.html b/templates/base.html
index b56211b..7760351 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -71,21 +71,24 @@
{% block head_extra %}{% endblock %}
+ Skip to content
-
+
{% block content %}{% endblock %}
@@ -96,5 +99,7 @@
+
+