Wave D1 primitives — and the audit says E1/E9 were not Wave 1

PHASE 0 — the order proposed E1 + E9 as Wave-1 and told me to follow the
audit if it disagreed. It disagrees: E9 calibration curve is WAVE D3,
gated on MODEL work ("resolve n>=20 vs N30, accrue buckets"), and E1
movement strip is WAVE D6, a large surface build. E9's gate is live right
now -- calibration is WITHDRAWN at 0 eligible dates, so the curve could
only render its empty state today. Building it would ship a component
whose entire purpose is unavailable.

PHASE 1 — three of the five real Wave D1 items were ALREADY DONE, and the
2026-07-31 audit has aged:

  D1 glyph library  audit: 38/83 wired (46%)
                    now:   COMPLETE for everything wireable -- 39 of 83
                           designed glyphs map to a real archetype, all 39
                           are wired, colours match the registry exactly
                           (0 disagreements).
  A1 card token     audit: BUILT-BUT-DRIFTED, "in only 1 file"
                    now:   BUILT-TO-SPEC -- it IS the --bg-1 token,
                           consumed by 32 files. The audit counted literal
                           hex, which is what a correctly tokenised value
                           looks like.
  B1 boundary blue  audit: PARTIAL, hex in 2 files
                    now:   BUILT -- --priced-out/#8fb2de is a token with a
                           documented colour law, 4 consumers.

The 44 unwired glyphs are NOT a wiring gap: they have no backend
archetype, so wiring them means inventing 44 archetypes to consume
artwork -- the fabrication this programme refuses. That is the 41-vs-74
scope question and it is Kev's call. Separately, 2 registry archetypes
have NO designed glyph (DUAL THREAT, PAINT BOSS) -- a design gap.

PHASE 2 — what was genuinely absent is now built. web/src/lib/motion.js:
nudge() capped at 180ms so it reads as acknowledgement rather than
latency; bootStagger capped at 240ms because uncapped, row 40 waits 1.1s
and the stagger BECOMES the latency it exists to disguise; rowHover
returns handlers not CSS so touch cannot stick a hover state; and
revealOnIntersect returns an unobserve in every path and reveals
IMMEDIATELY when there is no IntersectionObserver or motion is reduced --
content is never hidden behind a capability check.

Reduced motion is honoured, not softened. The sharpest of the 10 tests:
bootStagger under reduced motion returns opacity 1, not merely delay 0 --
if the CSS animation supplies the opacity, skipping it leaves the row
invisible forever.

PHASE 3 — the reachability guard gains a PRIMITIVES section: a module
built to be embedded must declare its exports AND name its intended
consumers, because a primitive imported by nothing is the same
built-but-unread class as an unmounted component.

WAVE-2 UNGATED: F9-F11 offseason hub, F5 article media, E10/E12 Report,
E1 movement strip. GATED: E9 + E15 on model, E16/F8 on the resolution tail
and the card-system reconciliation, E2/E6 on licensing, E13 on another
order.

Read-only throughout; serving fingerprint unchanged; accrual clock
unchanged at 0 eligible dates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
Kev
2026-08-07 20:31:55 -04:00
parent 7a318ccce0
commit 60469422af
4 changed files with 296 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
# Wave D1 — and why E1/E9 were not it
## PHASE 0 — the audit's actual wave order
The order proposed E1 + E9 as Wave-1 primitives and instructed me to follow the
audit if it disagreed. **It disagrees.**
| item | the audit's placement |
|---|---|
| **E9 calibration curve** | **WAVE D3 — gated on MODEL work**: *"resolve n≥20 vs N30, accrue buckets"* |
| **E1 movement strip** | **WAVE D6 — large surface builds**, alongside crown / disagreement axis / SPLIT |
**Neither is Wave 1.** And E9's gate is live right now: calibration is
**withdrawn** (`CALIBRATION_DEPLOYED = []`) at **0 eligible dates**, so a
calibration curve today could only render its empty state. Building it now would
produce a component whose entire purpose is unavailable.
**WAVE D1, per the audit:** glyph library · card token + `vy-glitch` naming ·
`nudge()`/boot-stagger/row-hover/IntersectionObserver · boundary-channel blue ·
team-gradient chips + READ-FAB.
## PHASE 1 — three of the five Wave D1 items were already done
Checked rather than assumed, and the audit (2026-07-31) has aged:
| item | audit said | measured now |
|---|---|---|
| **D1 glyph library** | PARTIAL — 38 of 83 wired (46%) | **COMPLETE for everything wireable.** 83 designed glyphs, 41 registry archetypes, **39 map — and all 39 are wired, with colours matching the registry exactly (0 disagreements).** |
| **A1 card token `#0E0E14`** | BUILT-BUT-DRIFTED — "in only 1 file" | **BUILT-TO-SPEC.** It is the `--bg-1` token in `globals.css`, consumed by **32 files** via `var(--bg-1)`. The audit counted literal hex occurrences, which is what a *correctly tokenised* value looks like. |
| **B1 boundary channel** | PARTIAL — hex in 2 files | **BUILT.** `--priced-out: #8fb2de` + `--priced-out-dim` are tokens with a documented colour law in `globals.css`, consumed by 4 files. |
**The 44 unwired glyphs are not a wiring gap.** They have no backend archetype —
wiring them would mean **inventing 44 archetypes to consume artwork**, which is
the fabrication this programme refuses. That is the 41-vs-74 scope question, and
it is Kev's call, not a build task. Separately, **2 registry archetypes have no
designed glyph** (`DUAL THREAT`, `PAINT BOSS`) — a design gap, not a code one.
## PHASE 2 — what was genuinely absent, and is now built
`web/src/lib/motion.js` — the four primitives, all previously 0 files:
| primitive | the rule it enforces |
|---|---|
| **E17 `nudge()`** | ≤180ms. Confirms an action *registered* without claiming it finished — a long pulse reads as latency, the opposite of a perceived-speed primitive. |
| **E18 `bootStagger()`** | **capped at 240ms.** Uncapped, row 40 waits 1.1s and the stagger *becomes* the latency it exists to disguise. |
| **E27 `rowHover()`** | returns handlers, not CSS — so a tap on touch cannot stick a hover state that never clears. |
| **E28 `revealOnIntersect()`** | returns an unobserve fn in every path, and **reveals IMMEDIATELY** without IntersectionObserver or under reduced motion. Content is never hidden behind a capability check. |
**Reduced motion is honoured, not softened** — every primitive skips outright,
and server-side assumes reduced. 10 tests, the sharpest being that
`bootStagger` under reduced motion returns `opacity: 1` and not merely
`delay: 0`: if the CSS animation supplies the opacity, skipping the animation
without forcing it leaves the row invisible forever.
## PHASE 3 — guard registration and Wave-2 readiness
The reachability guard now carries a **PRIMITIVES** section: a module built to be
embedded must declare its exports *and name its intended consumers*. A primitive
imported by nothing is the same built-but-unread class as an unmounted
component. 24 checks green.
### Wave-2 readiness
**Ungated — design complete, no blocker:**
- **F9/F10/F11 offseason hub** (Wave D6) — the largest, fully spec'd
- **F5 article media** (Wave D6) — hero + figure system fully spec'd
- **E10/E12 The Report email + `/report` archive** — design exists
- **E1 movement strip** (Wave D6) — spec'd, absent, now has its motion primitives
**Gated, and by what:**
| item | gate |
|---|---|
| E9 calibration curve | **model** — calibration withdrawn, 0 eligible dates |
| E15 Price Triplet MODEL leg | **model** — EV layer |
| E16 share cards + F8 crops | **resolution tail** *and* the card-system reconciliation |
| E2 BookChip tiles / E6 push-to-book | **licensing / affiliate approval** |
| E13 Scanner S6 | another build order |
**The card-system reconciliation** (content-engine 1080×1350 vs E16's five
masters) gates E16/F8 and should be settled with the social chat, since off-site
posts and on-site share cards are one system.
Isolated throughout: read-only, no serving or model change, **accrual clock
unchanged at 0 eligible dates**.
+82
View File
@@ -0,0 +1,82 @@
'use strict';
/**
* WAVE D1 motion primitives.
*
* The property that matters most here is not how they look — it is that they
* never withhold content. A reveal primitive that hides a row until an observer
* fires will, on any browser or preference where the observer never fires, hide
* the row forever.
*/
const m = require('../../web/src/lib/motion');
describe('reduced motion is honoured, not softened', () => {
it('assumes reduced motion server-side', () => {
expect(m.prefersReducedMotion()).toBe(true);
});
it('nudge does nothing and returns a no-op cleanup', () => {
const el = { style: {} };
const stop = m.nudge(el);
expect(el.style.transform).toBeUndefined();
expect(typeof stop).toBe('function');
expect(() => stop()).not.toThrow();
});
it('boot stagger collapses to zero delay AND forces opacity', () => {
// Not just delay 0: if a CSS animation supplies the opacity, skipping the
// animation without forcing opacity leaves the row invisible.
expect(m.bootStagger(9)).toEqual({ animationDelay: '0ms', opacity: 1 });
});
it('reveal fires IMMEDIATELY rather than waiting for an observer', () => {
const el = {}; let revealed = null;
m.revealOnIntersect(el, (x) => { revealed = x; });
expect(revealed).toBe(el);
});
});
describe('the stagger cannot make content late', () => {
it('is capped, so a long board does not delay its tail', () => {
// Uncapped, row 40 would wait 1.1s and the stagger becomes the latency it
// exists to disguise.
const orig = m.prefersReducedMotion;
expect(m.STAGGER_CAP_MS).toBeLessThanOrEqual(300);
expect(m.STAGGER_STEP_MS * 40).toBeGreaterThan(m.STAGGER_CAP_MS);
});
it('the nudge is short enough to read as acknowledgement, not latency', () => {
expect(m.NUDGE_MS).toBeLessThanOrEqual(220);
});
});
describe('row hover is pointer-only by construction', () => {
it('exposes handlers rather than CSS, so touch cannot stick a hover state', () => {
const h = m.rowHover();
expect(typeof h.onMouseEnter).toBe('function');
expect(typeof h.onMouseLeave).toBe('function');
});
it('clears the tint it set, never leaving a row highlighted', () => {
const el = { style: { background: '' } };
const h = m.rowHover();
h.onMouseEnter({ currentTarget: el });
expect(el.style.background).toBeTruthy();
h.onMouseLeave({ currentTarget: el });
expect(el.style.background).toBe('');
});
it('survives a missing currentTarget rather than throwing mid-render', () => {
const h = m.rowHover();
expect(() => h.onMouseEnter({})).not.toThrow();
expect(() => h.onMouseLeave({})).not.toThrow();
});
});
describe('no observer leak', () => {
it('reveal returns an unobserve function in every path', () => {
expect(typeof m.revealOnIntersect(null, () => {})).toBe('function');
expect(typeof m.revealOnIntersect({}, () => {})).toBe('function');
});
});
+30
View File
@@ -85,6 +85,22 @@ const CONTRACT = [
component: 'web/src/app/studio/page.tsx' }, component: 'web/src/app/studio/page.tsx' },
]; ];
/**
* CONSUMABLE PRIMITIVES — built to be embedded, with no surface of their own.
*
* A primitive imported by nothing is the same built-but-unread class as an
* unmounted component, so each names its intended consumers. Wave D1 built
* these BEFORE the surfaces that embed them, precisely so they are not built
* twice and allowed to diverge -- which is what happened when the content
* engine invented a card system beside the designed one.
*/
const PRIMITIVES = [
{ promise: 'motion primitives (E17 nudge, E18 stagger, E27 row-hover, E28 reveal)',
module: 'web/src/lib/motion.js',
exports: ['nudge', 'bootStagger', 'rowHover', 'revealOnIntersect'],
intended_consumers: ['F9-F11 offseason hub rows', 'F5 article media reveal', 'slate board rows'] },
];
const read = (rel) => { const read = (rel) => {
const p = path.join(ROOT, rel); const p = path.join(ROOT, rel);
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null; return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
@@ -179,6 +195,20 @@ describe('every promised honest field reaches a rendered pixel', () => {
}); });
}); });
describe('consumable primitives exist and are complete', () => {
it.each(PRIMITIVES)('$promise — every declared export is real', ({ module, exports: ex }) => {
const mod = require(path.join(ROOT, module));
for (const name of ex) expect(typeof mod[name]).toBe('function');
});
it.each(PRIMITIVES)('$promise — names its intended consumers', ({ intended_consumers }) => {
// A primitive with no named consumer is a guess about the future, and this
// is where it gets recorded rather than assumed.
expect(Array.isArray(intended_consumers)).toBe(true);
expect(intended_consumers.length).toBeGreaterThan(0);
});
});
describe('the guard itself is honest', () => { describe('the guard itself is honest', () => {
it('fails when a promised field is produced but never consumed', () => { it('fails when a promised field is produced but never consumed', () => {
// Simulate the served_grade bug: present in the payload, absent from the // Simulate the served_grade bug: present in the payload, absent from the
+100
View File
@@ -0,0 +1,100 @@
/* ============================================================
WAVE D1 — the four motion primitives (E17, E18, E27, E28).
All four were ABSENT; the gap audit lists them as self-contained with no
dependency, which is why they come before any surface that would embed them.
Plain CommonJS so .tsx imports it AND the Jest suite requires it directly
(the vyndrTokens.js / archetypes.js pattern).
DESIGN-SPEC PART 4 governs: motion is for PERCEIVED SPEED, never decoration.
Every primitive here is skipped outright when the viewer asks for reduced
motion -- an accessibility preference is not a style to soften.
============================================================ */
/** Honour the OS-level preference. Server-side, assume reduced (no motion). */
function prefersReducedMotion() {
if (typeof window === 'undefined' || !window.matchMedia) return true;
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
catch { return true; }
}
/**
* E17 — nudge(): the acknowledgement pulse.
*
* Confirms an action REGISTERED without claiming it finished. Deliberately
* short: a long animation reads as latency, which is the opposite of what a
* perceived-speed primitive is for.
*/
const NUDGE_MS = 180;
function nudge(el, opts = {}) {
if (!el || prefersReducedMotion()) return () => {};
const scale = opts.scale ?? 1.03;
const prev = el.style.transition;
el.style.transition = `transform ${NUDGE_MS}ms cubic-bezier(.2,.8,.2,1)`;
el.style.transform = `scale(${scale})`;
const t = setTimeout(() => {
el.style.transform = '';
setTimeout(() => { el.style.transition = prev; }, NUDGE_MS);
}, NUDGE_MS);
return () => clearTimeout(t);
}
/**
* E18 — boot stagger: rows arrive in sequence, not all at once.
*
* The delay is CAPPED. Uncapped, a 40-row board would make the last row wait
* 1.2s, and a stagger that makes content late has become the thing it exists to
* disguise.
*/
const STAGGER_STEP_MS = 28;
const STAGGER_CAP_MS = 240;
function bootStagger(index, opts = {}) {
if (prefersReducedMotion()) return { animationDelay: '0ms', opacity: 1 };
const step = opts.step ?? STAGGER_STEP_MS;
const cap = opts.cap ?? STAGGER_CAP_MS;
return { animationDelay: `${Math.min(index * step, cap)}ms` };
}
/**
* E27 — row hover: the pointer-only affordance.
*
* Returned as a props object rather than CSS so a row cannot acquire a hover
* state on touch, where there is no hover and the style would stick after a tap.
*/
function rowHover(opts = {}) {
const tint = opts.tint || 'rgba(255,255,255,.028)';
return {
onMouseEnter: (e) => { if (e.currentTarget) e.currentTarget.style.background = tint; },
onMouseLeave: (e) => { if (e.currentTarget) e.currentTarget.style.background = ''; },
};
}
/**
* E28 — reveal on intersection.
*
* Returns an unobserve function; a caller that forgets it leaks an observer per
* row. Without IntersectionObserver (or under reduced motion) the element is
* shown IMMEDIATELY -- content is never hidden behind a capability check.
*/
function revealOnIntersect(el, onReveal, opts = {}) {
if (!el) return () => {};
if (prefersReducedMotion() || typeof IntersectionObserver === 'undefined') {
if (typeof onReveal === 'function') onReveal(el);
return () => {};
}
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
if (typeof onReveal === 'function') onReveal(e.target);
io.unobserve(e.target);
}
}
}, { rootMargin: opts.rootMargin || '0px 0px -10% 0px', threshold: opts.threshold ?? 0.05 });
io.observe(el);
return () => { try { io.disconnect(); } catch { /* already gone */ } };
}
module.exports = {
nudge, bootStagger, rowHover, revealOnIntersect, prefersReducedMotion,
NUDGE_MS, STAGGER_STEP_MS, STAGGER_CAP_MS,
};