From 60469422af4424211f157a4a0c3108e39390eee3 Mon Sep 17 00:00:00 2001 From: Kev Date: Fri, 7 Aug 2026 20:31:55 -0400 Subject: [PATCH] =?UTF-8?q?Wave=20D1=20primitives=20=E2=80=94=20and=20the?= =?UTF-8?q?=20audit=20says=20E1/E9=20were=20not=20Wave=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9 --- specs/wave-d1-primitives.md | 84 ++++++++++++++++++++++ tests/unit/motionPrimitives.test.js | 82 +++++++++++++++++++++ tests/unit/renderReachability.test.js | 30 ++++++++ web/src/lib/motion.js | 100 ++++++++++++++++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 specs/wave-d1-primitives.md create mode 100644 tests/unit/motionPrimitives.test.js create mode 100644 web/src/lib/motion.js diff --git a/specs/wave-d1-primitives.md b/specs/wave-d1-primitives.md new file mode 100644 index 0000000..db9bdbf --- /dev/null +++ b/specs/wave-d1-primitives.md @@ -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**. diff --git a/tests/unit/motionPrimitives.test.js b/tests/unit/motionPrimitives.test.js new file mode 100644 index 0000000..91bf737 --- /dev/null +++ b/tests/unit/motionPrimitives.test.js @@ -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'); + }); +}); diff --git a/tests/unit/renderReachability.test.js b/tests/unit/renderReachability.test.js index 8c3e6fd..0f48b3b 100644 --- a/tests/unit/renderReachability.test.js +++ b/tests/unit/renderReachability.test.js @@ -85,6 +85,22 @@ const CONTRACT = [ 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 p = path.join(ROOT, rel); 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', () => { it('fails when a promised field is produced but never consumed', () => { // Simulate the served_grade bug: present in the payload, absent from the diff --git a/web/src/lib/motion.js b/web/src/lib/motion.js new file mode 100644 index 0000000..c7fc34a --- /dev/null +++ b/web/src/lib/motion.js @@ -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, +};