D1-A: combat glyphs, boundary-channel blue, reaction primitives, READ-FAB
Additive frontend/visual. Backend untouched (git diff src/ = empty): no grade,
model, classifier or ledger change. The 41->74 registry expansion is HELD for
D1-B. Push scoring untouched.
REVIEW ZERO — classifier coverage bounded the glyph wiring. Three buckets, and
the computation was redone three times before it was right (the frontend keys
GLYPHS by ARCHETYPE NAME while the backend keys `glyph:` by SHAPE NAME, and most
registry keys are unquoted identifiers — the first two passes mis-parsed both):
(a) classifier-backed, already wired: 38
(b) classifier-backed, package SVG exists, NOT wired -> WIRED HERE: 6
striker, grappler, pressure, counter, grinder, finisher — all MMA/combat
archetypes in archetypeService.js that were rendering EMOJI fallbacks
('*', 'x', '>', '<>') where the package ships real 24-grid duotone marks.
(c) package SVG with no classifier -> HELD for D1-B: 39 (wiring them would
render nothing)
(d) classifier-backed but NO package SVG: 2 ('dual threat', 'paint boss') —
a DESIGN gap, not a build gap; flagged for D1-B.
GLYPHS map 38 -> 44 keys, deliberately far short of the package's 83.
BOUNDARY CHANNEL — the blue tokens already existed (--priced-out set) and were
applied on NoMarketState and the scan void box, but PriceTriplet's NO_MODEL
("line not priced") still rendered in neutral text, so the channel was applied
inconsistently. NO_MODEL now renders in the channel, completing "every boundary
state or none". Token-only (no hex fallback and no hex in prose — PriceTriplet's
own test forbids literal hex, and it caught both).
REACTION PRIMITIVES — new web/src/lib/reactions.js + globals.css keyframes at the
exact HANDOFF timings: flash .75s ease-out, boot stagger 60ms steps, reactions
gated 1.5s, WIRE hold 6s. nudge() REFUSES a no-op (null/absent direction -> no
flash) so the primitive cannot be attached to an idle loop — a flash without a
new datum is the UI lying about the feed. Reduced-motion honoured.
READ-FAB — aligned to the exact package geometry: 50px circle, translateY(-14px),
6px void ring (was 46px, marginTop -16, 3px ring).
CARD TOKEN — audit correction: #0E0E14 was already tokenised as --bg-1/--card;
the audit's "1 file" was counting the raw hex, not the token. No change needed.
Floor: 317 suites / 3946 tests green (16 new), web build exit 0.
NOT DONE THIS ORDER (reported, not silently dropped): row-hover rationale and
IntersectionObserver reveal (Phase 3 item 8) and team-gradient chips (Phase 4
item 10) are not implemented — they need the System artboard's row anatomy,
which is a larger port than the rest of D1-A.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* D1-A — design self-contained implementation (specs/design-vs-build-gap-audit.md).
|
||||||
|
* Built to the EXACT package spec in specs/design-reference/HANDOFF.md.
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const read = (p) => fs.readFileSync(path.join(__dirname, '../../', p), 'utf8');
|
||||||
|
const arche = require('../../web/src/lib/archetypes');
|
||||||
|
const rx = require('../../web/src/lib/reactions');
|
||||||
|
|
||||||
|
describe('glyphs — wired ONLY where a classifier can assign the archetype', () => {
|
||||||
|
const COMBAT = ['striker', 'grappler', 'pressure', 'counter', 'grinder', 'finisher'];
|
||||||
|
|
||||||
|
test('the six classifier-backed combat marks are wired from the package SVGs', () => {
|
||||||
|
const src = read('web/src/lib/archetypes.js');
|
||||||
|
const m = src.match(/const GLYPHS = \{([\s\S]*?)\n\};/);
|
||||||
|
const keys = [...m[1].matchAll(/^\s*'([a-z0-9 _-]+)':/gm)].map((x) => x[1]);
|
||||||
|
for (const c of COMBAT) expect(keys).toContain(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each wired mark is real SVG inner-markup on currentColor (not an emoji)', () => {
|
||||||
|
const src = read('web/src/lib/archetypes.js');
|
||||||
|
for (const c of COMBAT) {
|
||||||
|
const row = src.match(new RegExp(`'${c}': '([^']*)'`));
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
expect(row[1]).toMatch(/<(path|circle|rect|g|line|polygon)/);
|
||||||
|
expect(row[1]).toMatch(/currentColor/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('glyphs whose archetype has NO classifier are NOT wired dead (held for D1-B)', () => {
|
||||||
|
// The package ships 83 SVGs; the registry backs far fewer. Wiring a mark the
|
||||||
|
// classifier can never assign would render nothing, so the count must stay
|
||||||
|
// well under the package total.
|
||||||
|
const src = read('web/src/lib/archetypes.js');
|
||||||
|
const m = src.match(/const GLYPHS = \{([\s\S]*?)\n\};/);
|
||||||
|
const keys = [...m[1].matchAll(/^\s*'([a-z0-9 _-]+)':/gm)].map((x) => x[1]);
|
||||||
|
const pkg = fs.readdirSync(path.join(__dirname, '../../specs/design-reference/assets/glyphs'))
|
||||||
|
.filter((f) => f.endsWith('.svg'));
|
||||||
|
expect(pkg.length).toBe(83);
|
||||||
|
expect(keys.length).toBeLessThan(pkg.length); // deliberately not all 83
|
||||||
|
expect(keys.length).toBe(44);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('boundary channel — one meaning, EVERY boundary state', () => {
|
||||||
|
test('the blue tokens exist as a semantic set', () => {
|
||||||
|
const css = read('web/src/app/globals.css');
|
||||||
|
expect(css).toMatch(/--priced-out:\s*#8fb2de/i);
|
||||||
|
expect(css).toMatch(/--priced-out-tint-2:\s*rgba\(106, ?147, ?200, ?0?\.12\)/);
|
||||||
|
expect(css).toMatch(/--priced-out-border-hi:\s*rgba\(106, ?147, ?200, ?0?\.34\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NO_MODEL (line not priced) now renders in the boundary channel — the last uncovered state', () => {
|
||||||
|
const src = read('web/src/components/vyndr/PriceTriplet.tsx');
|
||||||
|
expect(src).toMatch(/function modelAbsentColor/);
|
||||||
|
expect(src).toMatch(/STATES\.NO_MODEL \? 'var\(--priced-out\)'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the other two boundary states already use it (all of them, or none)', () => {
|
||||||
|
expect(read('web/src/components/vyndr/NoMarketState.tsx')).toMatch(/priced-out-tint-2/);
|
||||||
|
expect(read('web/src/app/scan/page.tsx')).toMatch(/priced-out-border-hi/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('boundary blue is NOT the refusal red nor the quarantine amber', () => {
|
||||||
|
const css = read('web/src/app/globals.css');
|
||||||
|
expect(css).not.toMatch(/--priced-out:\s*#FF4757/i);
|
||||||
|
expect(css).not.toMatch(/--priced-out:\s*#FFB347/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reaction primitives — exact HANDOFF timings, data events only', () => {
|
||||||
|
test('timings match the handoff verbatim', () => {
|
||||||
|
expect(rx.FLASH_MS).toBe(750); // .75s ease-out
|
||||||
|
expect(rx.BOOT_STEP_MS).toBe(60); // 60ms steps
|
||||||
|
expect(rx.REACTION_GATE_MS).toBe(1500); // ~1.5s
|
||||||
|
expect(rx.WIRE_HOLD_MS).toBe(6000); // ~6s
|
||||||
|
});
|
||||||
|
|
||||||
|
test('boot stagger steps at 60ms and the hero (index 0) has no delay', () => {
|
||||||
|
expect(rx.bootDelayMs(0)).toBe(0);
|
||||||
|
expect(rx.bootDelayMs(1)).toBe(60);
|
||||||
|
expect(rx.bootDelayMs(5)).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reactions are GATED until boot settles — no flash during the entrance', () => {
|
||||||
|
expect(rx.reactionsReady(0)).toBe(false);
|
||||||
|
expect(rx.reactionsReady(1499)).toBe(false);
|
||||||
|
expect(rx.reactionsReady(1500)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nudge REFUSES a no-op — no direction means no flash (never an idle loop)', () => {
|
||||||
|
expect(rx.nudge({}, null)).toBeNull();
|
||||||
|
expect(rx.nudge({}, undefined)).toBeNull();
|
||||||
|
expect(rx.nudge(null, true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a real change flashes by SIGN', () => {
|
||||||
|
expect(rx.nudge({}, true)).toBe('rx-up');
|
||||||
|
expect(rx.nudge({}, false)).toBe('rx-down');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the keyframes exist and honour reduced motion', () => {
|
||||||
|
const css = read('web/src/app/globals.css');
|
||||||
|
expect(css).toMatch(/@keyframes rx-up/);
|
||||||
|
expect(css).toMatch(/@keyframes vy-rowin/);
|
||||||
|
expect(css).toMatch(/@keyframes vy-arrive/);
|
||||||
|
expect(css).toMatch(/animation: rx-up\s+\.75s ease-out/);
|
||||||
|
expect(css).toMatch(/prefers-reduced-motion: reduce/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('READ-FAB — exact package geometry', () => {
|
||||||
|
const src = read('web/src/components/BottomTabBar.tsx');
|
||||||
|
test('50px circle, translateY(-14px), 6px void ring', () => {
|
||||||
|
expect(src).toMatch(/width: 50/);
|
||||||
|
expect(src).toMatch(/height: 50/);
|
||||||
|
expect(src).toMatch(/translateY\(-14px\)/);
|
||||||
|
expect(src).toMatch(/border: '6px solid var\(--bg-0\)'/);
|
||||||
|
});
|
||||||
|
test('the pre-spec geometry is gone', () => {
|
||||||
|
expect(src).not.toMatch(/width: 46,\s*\n\s*height: 46/);
|
||||||
|
expect(src).not.toMatch(/marginTop: -16/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('card token', () => {
|
||||||
|
test('#0E0E14 is the tokenised card surface (void -> card -> elevated)', () => {
|
||||||
|
const css = read('web/src/app/globals.css');
|
||||||
|
expect(css).toMatch(/--bg-1:\s*#0E0E14/i);
|
||||||
|
expect(css).toMatch(/--card:\s*var\(--bg-1\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1532,3 +1532,28 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.stamp-in, .grade-reveal > div > * { animation: none !important; }
|
.stamp-in, .grade-reveal > div > * { animation: none !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
D1-A REACTION PRIMITIVES (2026-07-31) — exact HANDOFF timings.
|
||||||
|
LAW: "Alive = punctuated stillness (react to truth, then rest)." These fire on
|
||||||
|
DATA EVENTS ONLY. Never attach them to an idle loop — a value that flashes
|
||||||
|
without new data is the animation lying about the feed.
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* nudge(cell, up) — value update flash, .75s ease-out, edge recolours by sign. */
|
||||||
|
@keyframes rx-up { 0% { background: rgba(0,212,160,.22); } 100% { background: transparent; } }
|
||||||
|
@keyframes rx-down { 0% { background: rgba(255,71,87,.22); } 100% { background: transparent; } }
|
||||||
|
.rx-up { animation: rx-up .75s ease-out 1; }
|
||||||
|
.rx-down { animation: rx-down .75s ease-out 1; }
|
||||||
|
|
||||||
|
/* Boot: rows stagger in at 60ms steps, hero arrives last. */
|
||||||
|
@keyframes vy-rowin { from { opacity: .6; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes vy-arrive { from { opacity: .6; transform: scale(.985); } to { opacity: 1; transform: none; } }
|
||||||
|
.vy-rowin { animation: vy-rowin .28s ease-out both; }
|
||||||
|
.vy-arrive { animation: vy-arrive .42s ease-out both; }
|
||||||
|
|
||||||
|
/* Reduced motion: the entrance floors at the VISIBLE state, never invisible. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.rx-up, .rx-down, .vy-rowin, .vy-arrive { animation: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -188,14 +188,17 @@ export default function BottomTabBar() {
|
|||||||
const body = t.primary ? (
|
const body = t.primary ? (
|
||||||
// Scan — prominent raised action
|
// Scan — prominent raised action
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-start', gap: 3, paddingTop: 4 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-start', gap: 3, paddingTop: 4 }}>
|
||||||
|
{/* D1-A READ-FAB — exact package spec: 50px green circle, void-coloured V,
|
||||||
|
translateY(-14px), 6px void ring. Was 46px / -16px / 3px ring. */}
|
||||||
<span
|
<span
|
||||||
|
className="read-fab"
|
||||||
style={{
|
style={{
|
||||||
width: 46,
|
width: 50,
|
||||||
height: 46,
|
height: 50,
|
||||||
marginTop: -16,
|
transform: 'translateY(-14px)',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: 'var(--g-a)',
|
background: 'var(--g-a)',
|
||||||
border: '3px solid var(--bg-0)',
|
border: '6px solid var(--bg-0)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -177,6 +177,13 @@ function Leg({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The boundary channel — one meaning: we cannot hand you this number. */
|
||||||
|
function modelAbsentColor(state: string): string | null {
|
||||||
|
// Token-only: PriceTriplet forbids literal hex (its own test enforces it), and
|
||||||
|
// --priced-out is defined in globals.css, so no fallback is needed here.
|
||||||
|
return state === STATES.NO_MODEL ? 'var(--priced-out)' : null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PriceTriplet({
|
export default function PriceTriplet({
|
||||||
data,
|
data,
|
||||||
compact = false,
|
compact = false,
|
||||||
@@ -233,7 +240,14 @@ export default function PriceTriplet({
|
|||||||
// (verdictFor returns null for this state), never QUARANTINE's "suppressed" copy.
|
// (verdictFor returns null for this state), never QUARANTINE's "suppressed" copy.
|
||||||
const modelAbsent = state === STATES.NO_MODEL;
|
const modelAbsent = state === STATES.NO_MODEL;
|
||||||
// GREEN = TAKEABLE EDGE ONLY. The model leg is green on exactly one state.
|
// GREEN = TAKEABLE EDGE ONLY. The model leg is green on exactly one state.
|
||||||
const modelColor = state === STATES.VALUE ? 'var(--g-a)' : 'var(--text-0)';
|
// D1-A boundary channel (2026-07-31): the blue channel is the system being HONEST about what it
|
||||||
|
// cannot hand you — edge priced out / no market / LINE NOT PRICED. NO_MODEL is the third
|
||||||
|
// such state and was the last one still rendering in neutral text, so the channel was
|
||||||
|
// applied inconsistently. It is NOT red (that is a model REFUSAL — a decision) and NOT
|
||||||
|
// amber (that is QUARANTINE — a leg suppressed). Never decorative: this is the only
|
||||||
|
// reason the colour appears here.
|
||||||
|
const modelColor = state === STATES.VALUE ? 'var(--g-a)'
|
||||||
|
: modelAbsentColor(state) || 'var(--text-0)';
|
||||||
const verdict = verdictFor(state, vsFair, Number.isFinite(ev as number) ? (ev as number) : null);
|
const verdict = verdictFor(state, vsFair, Number.isFinite(ev as number) ? (ev as number) : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,6 +8,19 @@
|
|||||||
|
|
||||||
/* Glyph SVG inner-markup keyed by name (drawn in a 0 0 16 16 viewBox). */
|
/* Glyph SVG inner-markup keyed by name (drawn in a 0 0 16 16 viewBox). */
|
||||||
const GLYPHS = {
|
const GLYPHS = {
|
||||||
|
/* D1-A (2026-07-31) — the six COMBAT marks lifted from the package SVGs
|
||||||
|
(specs/design-reference/assets/glyphs/, currentColor, 24-grid duotone).
|
||||||
|
These archetypes are classifier-backed in archetypeService.js (sport: 'mma')
|
||||||
|
and were rendering EMOJI fallbacks ('✦' '⊗' '➤' '◊'); the designed marks
|
||||||
|
replace them. Glyphs whose archetype the classifier cannot assign are NOT
|
||||||
|
wired here — they are held for D1-B (registry expansion), because a glyph
|
||||||
|
with no classifier renders nothing. */
|
||||||
|
'striker': '<path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" d="M3 12h8"></path><path fill="currentColor" fill-opacity=".2" d="M12 8h7v8h-7z"></path><path fill="none" stroke="currentColor" stroke-width="2.1" stroke-linejoin="round" d="M12 8h7v8h-7z"></path><path stroke="currentColor" stroke-width="1.8" stroke-linecap="round" d="M20 6l2-1M20.5 12H22M20 18l2 1"></path>',
|
||||||
|
'grappler': '<path fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" d="M6 4v9M6 13l-3-3M6 13l3-3M18 20v-9M18 11l-3 3M18 11l3 3"></path><path stroke="currentColor" stroke-width="2.2" stroke-linecap="round" d="M9 12h6"></path>',
|
||||||
|
'pressure': '<path fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" d="M3 8h9M3 12h9M3 16h9M9 6l4 6-4 6"></path><path stroke="currentColor" stroke-width="2.6" stroke-linecap="round" d="M18 4v16"></path>',
|
||||||
|
'counter': '<path fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" d="M4 9h11M11 5l4 4-4 4M20 15H9M13 11l-4 4 4 4"></path>',
|
||||||
|
'grinder': '<path fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" d="M4 15c3-4 5-4 8 0s5 4 8 0"></path><path stroke="currentColor" stroke-width="2.3" stroke-linecap="round" d="M4 20h16"></path><circle cx="12" cy="9" r="1.8" fill="currentColor"></circle>',
|
||||||
|
'finisher': '<path fill="none" stroke="currentColor" stroke-width="1.9" d="M4 6h16v12H4z"></path><path stroke="currentColor" stroke-width="1.3" d="M4 10h16M4 14h16M9 6v12M15 6v12"></path><circle cx="17" cy="9" r="2.1" fill="currentColor"></circle>',
|
||||||
/* Rev 3 — 9 classifier-legacy marks (fallback renders, never user-facing) */
|
/* Rev 3 — 9 classifier-legacy marks (fallback renders, never user-facing) */
|
||||||
'brush': '<path fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" d="M5 19 15 9"/><path fill="currentColor" fill-opacity=".2" d="M15 9l4-4 1.6 1.6-4 4z"/><path fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" d="M15 9l4-4M5 19l-1 1M7 20l-2 1"/>',
|
'brush': '<path fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" d="M5 19 15 9"/><path fill="currentColor" fill-opacity=".2" d="M15 9l4-4 1.6 1.6-4 4z"/><path fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" d="M15 9l4-4M5 19l-1 1M7 20l-2 1"/>',
|
||||||
'whiff': '<path fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" d="M4 16c5 3 11 3 16-2"/><path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-dasharray="2.5 3" d="M4 11c5 3 10 3 15-1.5"/><circle cx="18" cy="6" r="2.2" fill="currentColor"/>',
|
'whiff': '<path fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" d="M4 16c5 3 11 3 16-2"/><path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-dasharray="2.5 3" d="M4 11c5 3 10 3 15-1.5"/><circle cx="18" cy="6" r="2.2" fill="currentColor"/>',
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* D1-A reaction primitive (2026-07-31) — exact HANDOFF spec.
|
||||||
|
*
|
||||||
|
* nudge(cell, up) — value updates, `rx-up`/`rx-down` background flash
|
||||||
|
* (.75s ease-out), edge recolours by sign.
|
||||||
|
*
|
||||||
|
* 🔴 THE LAW: "Fire on data events only, never idle loops." Alive is punctuated
|
||||||
|
* stillness — react to truth, then rest. A flash with no new datum is the UI
|
||||||
|
* lying about the feed, so `nudge` REFUSES a no-op: same value in, no flash.
|
||||||
|
*
|
||||||
|
* Timings, all from HANDOFF.md: flash .75s · boot stagger 60ms steps ·
|
||||||
|
* reactions gated ~1.5s after boot · WIRE hold ~6s.
|
||||||
|
*/
|
||||||
|
const FLASH_MS = 750;
|
||||||
|
const BOOT_STEP_MS = 60;
|
||||||
|
const REACTION_GATE_MS = 1500;
|
||||||
|
const WIRE_HOLD_MS = 6000;
|
||||||
|
|
||||||
|
/** Flash a cell for a REAL change. Returns the class applied, or null if nothing changed. */
|
||||||
|
function nudge(cell, up) {
|
||||||
|
if (!cell || typeof cell !== 'object') return null;
|
||||||
|
if (up === null || up === undefined) return null; // absent direction => no reaction
|
||||||
|
const cls = up ? 'rx-up' : 'rx-down';
|
||||||
|
if (cell.classList && typeof cell.classList.add === 'function') {
|
||||||
|
cell.classList.remove('rx-up', 'rx-down');
|
||||||
|
void (cell.offsetWidth); // restart the animation
|
||||||
|
cell.classList.add(cls);
|
||||||
|
if (typeof setTimeout === 'function') {
|
||||||
|
setTimeout(() => cell.classList && cell.classList.remove(cls), FLASH_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Boot delay for row `i` — 60ms steps; the hero arrives LAST. */
|
||||||
|
function bootDelayMs(i) {
|
||||||
|
const n = Number(i);
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.round(n) * BOOT_STEP_MS : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reactions stay silent until the boot sequence has settled. */
|
||||||
|
function reactionsReady(msSinceBoot) {
|
||||||
|
const n = Number(msSinceBoot);
|
||||||
|
return Number.isFinite(n) && n >= REACTION_GATE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { nudge, bootDelayMs, reactionsReady, FLASH_MS, BOOT_STEP_MS, REACTION_GATE_MS, WIRE_HOLD_MS };
|
||||||
Reference in New Issue
Block a user