diff --git a/docs/demos/demos.css b/docs/demos/demos.css
index 27365c1..38e6b46 100644
--- a/docs/demos/demos.css
+++ b/docs/demos/demos.css
@@ -96,6 +96,7 @@
.hw-demo .score .k { color: var(--hw-muted); }
.hw-demo .stat { padding: 2px 6px; border-radius: 6px; }
.hw-demo .inflight-big { font-size: 1.15rem; font-weight: 700; transition: color .3s; }
+.hw-demo .fail-big { font-size: 1.15rem; font-weight: 700; transition: color .3s; }
.hw-demo .big { font-weight: 700; }
.hw-demo .note { color: var(--hw-muted); font-size: .76rem; font-style: italic; margin-top: 6px; }
diff --git a/docs/demos/engine.js b/docs/demos/engine.js
index d5a764d..d5dbc21 100644
--- a/docs/demos/engine.js
+++ b/docs/demos/engine.js
@@ -213,7 +213,7 @@ window.HttpwareDemo = (function () {
in-flight 0
✓ 0
- ✗ 0
+ ✗ failed 0
p99 40ms
@@ -229,7 +229,7 @@ window.HttpwareDemo = (function () {
in-flight 0
✓ 0
- ✗ 0
+ ✗ failed 0
⏻ fast-failed 0
pool —
elapsed —
@@ -262,16 +262,16 @@ window.HttpwareDemo = (function () {
scenarios: $('scenarios'), play: $('play'), replay: $('replay'), scenLabel: $('scenLabel'),
timeline: $('timeline'), outage: $('outage'), outageLabel: $('outageLabel'), playhead: $('playhead'),
laneA: $('laneA'), badgeA: $('badgeA'), srvA: $('srvA'), trackA: $('trackA'),
- ifA: $('ifA'), okA: $('okA'), badA: $('badA'), latA: $('latA'),
+ ifA: $('ifA'), okA: $('okA'), badA: $('badA'), badWrapA: $('badWrapA'), latA: $('latA'),
laneB: $('laneB'), badgeB: $('badgeB'), srvB: $('srvB'), trackB: $('trackB'), brkB: $('brkB'),
- ifB: $('ifB'), okB: $('okB'), badB: $('badB'), rejB: $('rejB'), latB: $('latB'),
+ ifB: $('ifB'), okB: $('okB'), badB: $('badB'), badWrapB: $('badWrapB'), rejB: $('rejB'), latB: $('latB'),
poolWrap: $('poolWrap'), poolB: $('poolB'),
elapsedWrap: $('elapsedWrap'), elapsedB: $('elapsedB'),
note: $('note'),
dimT: $('dimT'), dimB: $('dimB'), dimL: $('dimL'), dimR: $('dimR'), ring: $('ring'),
coach: $('coach'), cArrow: $('cArrow'), cStep: $('cStep'), cTitle: $('cTitle'), cBody: $('cBody'), cGo: $('cGo'),
};
- const ELS = { ifA: els.ifA, latA: els.latA, ifB: els.ifB, latB: els.latB, brkB: els.brkB, poolB: els.poolB, elapsedB: els.elapsedB };
+ const ELS = { ifA: els.ifA, latA: els.latA, badWrapA: els.badWrapA, ifB: els.ifB, latB: els.latB, badWrapB: els.badWrapB, brkB: els.brkB, poolB: els.poolB, elapsedB: els.elapsedB };
// Built fresh inside run() from the selected scenario (STOPS is never read before a
// scenario is played, so it's safe to leave empty until then) — pages with one
@@ -401,7 +401,7 @@ window.HttpwareDemo = (function () {
els.ifA.textContent = '0'; els.okA.textContent = '0'; els.badA.textContent = '0';
els.ifB.textContent = '0'; els.okB.textContent = '0'; els.badB.textContent = '0'; els.rejB.textContent = '0';
els.latA.textContent = '40ms'; els.latB.textContent = '40ms';
- [els.latA, els.latB, els.ifA, els.ifB].forEach((n) => { n.style.color = ''; });
+ [els.latA, els.latB, els.ifA, els.ifB, els.badA, els.badB].forEach((n) => { n.style.color = ''; });
// Flow-diagram boxes reflect the selected scenario's CHAIN config, not the runtime
// middleware objects (which aren't constructed until run()). Otherwise the breaker
// box reads "no breaker" on a circuit-breaker page until the first Play.
@@ -425,6 +425,8 @@ window.HttpwareDemo = (function () {
els.ifB.textContent = B.if; els.okB.textContent = B.ok; els.badB.textContent = B.bad; els.rejB.textContent = B.rej;
els.ifA.style.color = A.if > 10 ? 'var(--hw-bad)' : '';
els.ifB.style.color = B.if <= 6 ? 'var(--hw-ok)' : '';
+ els.badA.style.color = A.bad > 0 ? 'var(--hw-bad)' : '';
+ els.badB.style.color = B.bad > 0 ? 'var(--hw-bad)' : '';
if (bulk) els.poolB.textContent = bulk.inUse + '/' + bulk.max;
let maxElapsed = 0;
if (tmoCfg) {
diff --git a/docs/demos/retry.md b/docs/demos/retry.md
index c58eb30..c7cbb99 100644
--- a/docs/demos/retry.md
+++ b/docs/demos/retry.md
@@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', function () {
HttpwareDemo.mount('#retry-demo', {
scenarios: [
{ id: 'blip', label: 'Brief blip (recovers)', dur: 12.5,
- fault: (now, rnd) => (now >= 2.0 && now < 3.2)
+ fault: (now, rnd) => (now >= 2.0 && now < 2.4)
? { ok: false, ms: 0.05, label: 'blip' } : { ok: true, ms: 0.05 },
chainB: { retry: { maxAttempts: 3, baseDelay: 0.1, maxDelay: 5.0 },
budget: { ttl: 10.0, minRetriesPerSec: 10.0, percentCanRetry: 0.2 } } },
@@ -32,12 +32,12 @@ document.addEventListener('DOMContentLoaded', function () {
{ when: (s) => s.now >= 10.0, spot: ['ifA', 'ifB'], title: 'Blip: recovered. Outage: contained',
body: 'Retry rescues transient errors without turning a real outage into a storm. That cap is the whole reason the budget exists.' },
] : [
- { when: (s) => s.now >= 1.2, spot: ['ifA', 'ifB'], title: 'A backend blip appears',
- body: 'Both clients hit the same transient errors. Watch how each responds.' },
- { when: (s) => s.now >= 2.4, spot: ['ifB'], title: 'httpware retries the blip',
- body: 'The plain client surfaces the error immediately. httpware retries with backoff — most of these recover on attempt 2 or 3, invisibly to the caller.' },
- { when: (s) => s.now >= 10.0, spot: ['ifA', 'ifB'], title: 'Blip: recovered',
- body: 'The backend healed and so did both clients — but compare the ✗ counts: httpware surfaced far fewer failures to the caller. That is what retry buys you on a transient blip.' },
+ { when: (s) => s.now >= 1.2, spot: ['badWrapA', 'badWrapB'], title: 'A backend blip appears',
+ body: 'Both clients are about to hit the same transient errors. Keep your eye on the ✗ failed counts — they start equal at zero.' },
+ { when: (s) => s.now >= 2.4, spot: ['badWrapA', 'badWrapB'], title: 'Plain surfaces every error; httpware retries',
+ body: 'The plain client surfaces each error straight to the caller — its ✗ is climbing. httpware retries with backoff, so most of these recover on attempt 2 or 3 and its ✗ barely moves.' },
+ { when: (s) => s.now >= 10.0, spot: ['badWrapA', 'badWrapB'], title: 'Blip over — mind the ✗ gap',
+ body: 'The backend healed. Compare the ✗ failed counts: the plain client surfaced far more failures than httpware. That gap is exactly what retry buys you on a transient blip.' },
],
});
});
diff --git a/planning/changes/2026-07-19.01-retry-demo-failures-visibility.md b/planning/changes/2026-07-19.01-retry-demo-failures-visibility.md
new file mode 100644
index 0000000..3a2c70e
--- /dev/null
+++ b/planning/changes/2026-07-19.01-retry-demo-failures-visibility.md
@@ -0,0 +1,41 @@
+---
+summary: Make the retry demo's failure count the visible payoff — prominent ✗ stat, spotlighted at the blip stops, with a punchier recover-vs-surface contrast.
+---
+
+# Change: Make retry demo failures count obvious
+
+**Lane:** lightweight — docs demo only, no capability contract moves.
+
+## Goal
+
+On the retry demo the whole point is that httpware surfaces *fewer failures* to
+the caller than a plain client. But the ✗ failure count is rendered in the same
+muted style as every label, the blip stops spotlight the in-flight counters
+(while the copy says "compare the ✗ counts"), and the 1.2 s blip lets httpware
+recover only ~5 of ~14 failures — a weak contrast. Result: the failure count,
+the metric that matters, is not obvious.
+
+## Approach
+
+- Make the ✗ failure count a prominent stat (larger number, red when > 0),
+ wrapped in a spotlightable element, on all pages.
+- Register the failure-stat wrappers in the engine's spotlight lookup so stops
+ can point at them.
+- Retry blip stops spotlight the ✗ counts (plain rising vs httpware staying
+ low) instead of in-flight.
+- Tighten the blip window so httpware recovers nearly all of it, making the
+ recover-vs-surface contrast dramatic (verified by trace).
+
+## Files
+
+- `docs/demos/engine.js` — failure-stat wrappers in the template + ELS lookup;
+ colour ✗ count red when > 0.
+- `docs/demos/demos.css` — prominent failure-number style.
+- `docs/demos/retry.md` — blip stops spotlight the ✗ counts; tighten the blip.
+
+## Verification
+
+- [ ] `node --check docs/demos/engine.js`.
+- [ ] `just docs-build` clean; scratchpad `verify-real-page.js` + `verify-demos.js` green.
+- [ ] Trace the blip: plain ✗ high, httpware ✗ low (dramatic gap); sustained still
+ hits `budgetExhausted`; every stop reachable.