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
+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,
};