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:
@@ -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**.
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user