Session 38: Design system Phase G — living layer, i18n/odds, a11y, paywall, parlay math (1890 tests)

VYNDR 2.0 conversion, Phase G (the systems that make the design alive). All 5
wired. Frontend-only; zero backend changes.

- lib/parlayMath.js: correlation model (0.62/0.34/0.06/0) + parlayGrade penalty
  + grade->odds + combined odds (frontend; backend parlayService unchanged).
- lib/oddsFormat.js: fmtOdds across american/decimal/fractional/implied with the
  totals-pass-through rule (safer than the prototype's parseAm, which would
  mis-convert 228.5) + region presets.
- lib/prefs.js: applyPrefs sets <html data-*> (the S33 a11y CSS layer) + load/save.
- lib/liveTick.js: single tick engine (SSR/test-safe, no auto-start, fresh state).
- lib/checkout.js: checkoutUrl(plan).
- LiveLayer (useLive/LiveNumber/HeartbeatBar) under the Nav ticker; GlobalHosts in
  layout applies prefs + registers __prefs/__goPaywall/__checkout + hosts the
  Preferences and Paywall modals. Nav read-meter is now a paywall trigger.

Gotchas: useEffect can't return a Set.delete unsub directly (boolean != cleanup);
header grew to 124px so layout paddingTop + Slate sticky-top updated to match.

18 new tests. Backend 1872 -> 1890, 146 suites, zero regressions. Web build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-16 10:37:31 -04:00
parent f88961885c
commit 956a7455eb
13 changed files with 949 additions and 8 deletions
+25 -3
View File
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { Wordmark, Ticker } from '@/components/vyndr';
import { HeartbeatBar } from '@/components/vyndr/LiveLayer';
import NotificationBell from '@/components/NotificationBell';
// Nav labels are English literals for now; nav-string i18n lands in Phase G
// (Session 38) once the locale dictionaries carry slate/terminal/etc. keys.
@@ -190,21 +191,41 @@ export default function Nav() {
<span>Query</span>
</a>
{/* Preferences (language / odds format / accessibility) — §9/§10 */}
<button
onClick={() => typeof window !== 'undefined' && window.__prefs && window.__prefs()}
aria-label="Language & accessibility preferences"
title="Language & accessibility"
style={{ width: 30, height: 30, borderRadius: 8, border: '1px solid var(--border-hi)', background: 'var(--bg-2)', color: 'var(--text-1)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
</svg>
</button>
{user && <NotificationBell />}
{user ? (
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{showReadCounter && scansRemaining != null && tier === 'free' && (
<span
// Read meter is a live paywall trigger (§12): tapping it (or
// hitting 0) opens the paywall.
<button
onClick={() => typeof window !== 'undefined' && window.__goPaywall && window.__goPaywall()}
className="mono"
aria-label={`${scansRemaining} of 5 reads remaining — upgrade`}
style={{
fontSize: 11,
fontWeight: 700,
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 0,
color: scansRemaining <= 1 ? 'var(--g-c)' : 'var(--text-1)',
}}
>
{scansRemaining}/5 · MO
</span>
</button>
)}
{tier !== 'free' && (
<span
@@ -376,8 +397,9 @@ export default function Nav() {
)}
</nav>
{/* Ticker under the bar — sample data; real feed wires in Session 38 */}
{/* Ticker + heartbeat under the bar (§8 living layer) */}
<Ticker items={TICKER_ITEMS} height={32} />
<HeartbeatBar />
<style jsx>{`
@media (min-width: 768px) {
+1 -1
View File
@@ -555,7 +555,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
<div
style={{
position: 'sticky',
top: 64, // matches Nav height
top: 122, // clears nav (60) + ticker (32) + heartbeat (30) — Session 38
zIndex: 5,
background: 'var(--bg-0, #0A0A0F)',
paddingTop: 12,
+208
View File
@@ -0,0 +1,208 @@
'use client';
import { useEffect, useState } from 'react';
import { applyPrefs, loadPrefs, savePrefs, DEFAULTS } from '@/lib/prefs';
import { REGIONS, ODDS_FORMATS, regionPreset } from '@/lib/oddsFormat';
import { checkoutUrl, PLAN_PRICES } from '@/lib/checkout';
import SectionHead from '@/components/vyndr/SectionHead';
import VBtn from '@/components/vyndr/VBtn';
// Augment the window with the design's global hosts (§12).
declare global {
interface Window {
__prefs?: () => void;
__goPaywall?: () => void;
__checkout?: (plan: string) => void;
}
}
type Prefs = typeof DEFAULTS;
/**
* GlobalHosts (§9/§10/§12) — mounted once in the layout. On mount it:
* - applies stored a11y/i18n prefs to <html> (the CSS layer keys off data-*),
* - registers window.__prefs / __goPaywall / __checkout,
* - hosts the Preferences modal and the Paywall modal.
*/
export default function GlobalHosts() {
const [prefs, setPrefs] = useState<Prefs>(DEFAULTS);
const [prefsOpen, setPrefsOpen] = useState(false);
const [paywallOpen, setPaywallOpen] = useState(false);
useEffect(() => {
const loaded = loadPrefs();
setPrefs(loaded);
applyPrefs(loaded);
window.__prefs = () => setPrefsOpen(true);
window.__goPaywall = () => setPaywallOpen(true);
window.__checkout = (plan: string) => {
window.location.href = checkoutUrl(plan);
};
return () => {
delete window.__prefs;
delete window.__goPaywall;
delete window.__checkout;
};
}, []);
const update = (patch: Partial<Prefs>) => {
setPrefs((prev) => {
let next = { ...prev, ...patch };
// Region preset cascades odds + currency.
if (patch.region) {
const r = regionPreset(patch.region);
next = { ...next, odds: r.odds, currency: r.currency };
}
savePrefs(next);
applyPrefs(next);
return next;
});
};
return (
<>
{prefsOpen && <PrefsModal prefs={prefs} update={update} onClose={() => setPrefsOpen(false)} />}
{paywallOpen && <PaywallModal onClose={() => setPaywallOpen(false)} />}
</>
);
}
const overlay: React.CSSProperties = {
position: 'fixed',
inset: 0,
zIndex: 80,
background: 'rgba(6,6,11,.7)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
};
const panel: React.CSSProperties = {
width: '100%',
maxWidth: 460,
maxHeight: '88vh',
overflowY: 'auto',
background: 'var(--bg-1)',
border: '1px solid var(--border-hi)',
borderRadius: 14,
position: 'relative',
};
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 18px', borderBottom: '1px solid var(--border)' }}>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-0)', letterSpacing: '0.04em' }}>{label}</span>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>{children}</div>
</div>
);
}
function Seg({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
aria-pressed={active}
className="mono"
style={{
minHeight: 30,
padding: '5px 10px',
fontSize: 11,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.04em',
borderRadius: 6,
cursor: 'pointer',
border: `1px solid ${active ? 'var(--g-a)' : 'var(--border-hi)'}`,
background: active ? 'color-mix(in srgb, var(--g-a) 16%, transparent)' : 'transparent',
color: active ? 'var(--g-a)' : 'var(--text-1)',
}}
>
{children}
</button>
);
}
function PrefsModal({ prefs, update, onClose }: { prefs: Prefs; update: (p: Partial<Prefs>) => void; onClose: () => void }) {
return (
<div className="fade-in" role="dialog" aria-modal="true" aria-label="Preferences" style={overlay} onClick={onClose}>
<div className="scanlines" style={panel} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', borderBottom: '1px solid var(--border)' }}>
<SectionHead accent="var(--g-a)">PREFERENCES</SectionHead>
<button onClick={onClose} aria-label="Close" style={{ background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 6, color: 'var(--text-1)', width: 30, height: 30, cursor: 'pointer' }}>×</button>
</div>
<Row label="Region">
{Object.keys(REGIONS).map((r) => (
<Seg key={r} active={prefs.region === r} onClick={() => update({ region: r })}>{r}</Seg>
))}
</Row>
<Row label="Odds format">
{ODDS_FORMATS.map((f) => (
<Seg key={f} active={prefs.odds === f} onClick={() => update({ odds: f })}>{f.slice(0, 4)}</Seg>
))}
</Row>
<Row label="Text size">
{(['sm', 'base', 'lg', 'xl'] as const).map((t) => (
<Seg key={t} active={prefs.text === t} onClick={() => update({ text: t })}>{t === 'base' ? 'M' : t.toUpperCase()}</Seg>
))}
</Row>
<Row label="Reduce motion">
<Seg active={prefs.motion === 'reduced'} onClick={() => update({ motion: prefs.motion === 'reduced' ? 'on' : 'reduced' })}>{prefs.motion === 'reduced' ? 'ON' : 'OFF'}</Seg>
</Row>
<Row label="High contrast">
<Seg active={prefs.contrast === 'high'} onClick={() => update({ contrast: prefs.contrast === 'high' ? 'off' : 'high' })}>{prefs.contrast === 'high' ? 'ON' : 'OFF'}</Seg>
</Row>
<Row label="Colorblind-safe">
<Seg active={prefs.cb === 'on'} onClick={() => update({ cb: prefs.cb === 'on' ? 'off' : 'on' })}>{prefs.cb === 'on' ? 'ON' : 'OFF'}</Seg>
</Row>
<Row label="Readable font">
<Seg active={prefs.font === 'readable'} onClick={() => update({ font: prefs.font === 'readable' ? 'default' : 'readable' })}>{prefs.font === 'readable' ? 'ON' : 'OFF'}</Seg>
</Row>
<div className="mono" style={{ padding: '12px 18px', fontSize: 10.5, color: 'var(--text-2)' }}>
Saved to this device. Totals &amp; stat lines never reformat only odds.
</div>
</div>
</div>
);
}
function PaywallModal({ onClose }: { onClose: () => void }) {
const tiers = [
{ id: 'analyst', name: 'Analyst', price: PLAN_PRICES.analyst, lines: ['Unlimited reads', 'Every signal', 'Kill conditions'] },
{ id: 'desk', name: 'Desk', price: PLAN_PRICES.desk, lines: ['Everything in Analyst', 'Alt-line ladder', 'Full intelligence layer'] },
];
return (
<div className="fade-in" role="dialog" aria-modal="true" aria-label="Upgrade" style={overlay} onClick={onClose}>
<div className="scanlines" style={panel} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', borderBottom: '1px solid var(--border)' }}>
<SectionHead accent="var(--amber)"> SIGNAL EXHAUSTED</SectionHead>
<button onClick={onClose} aria-label="Close" style={{ background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 6, color: 'var(--text-1)', width: 30, height: 30, cursor: 'pointer' }}>×</button>
</div>
<div style={{ padding: 18 }}>
<p className="mono" style={{ fontSize: 13, color: 'var(--text-1)', lineHeight: 1.6, marginBottom: 16 }}>
You&apos;ve used your free reads. Unlock the full intelligence layer founder pricing locks for life.
</p>
<div style={{ display: 'grid', gap: 12 }}>
{tiers.map((t) => (
<div key={t.id} style={{ border: '1px solid var(--border-hi)', borderRadius: 10, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 10 }}>
<span style={{ fontSize: 16, fontWeight: 800 }}>{t.name}</span>
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: 'var(--g-a)' }}>{t.price}<span style={{ fontSize: 11, color: 'var(--text-2)' }}>/mo</span></span>
</div>
{t.lines.map((l) => (
<div key={l} className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', marginBottom: 4 }}>· {l}</div>
))}
<VBtn variant={t.id === 'analyst' ? 'primary' : 'outline'} style={{ width: '100%', marginTop: 12 }} onClick={() => window.__checkout && window.__checkout(t.id)}>
Go {t.name}
</VBtn>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
'use client';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { liveTick } from '@/lib/liveTick';
/** Subscribe to the single living-layer tick (§8). Starts the shared interval
* on first mount; many components can call this — one interval, fan-out. */
export function useLive() {
const [s, setS] = useState(liveTick.state);
useEffect(() => {
liveTick.start();
const unsub = liveTick.subscribe(setS);
return () => {
unsub();
};
}, []);
return s;
}
/** A number that pops (count-tick) when its value changes. Data, not chrome —
* the pop is a one-shot transform, killed under reduced-motion by the CSS. */
export function LiveNumber({ value, className = '', style = {} }: { value: number | string; className?: string; style?: CSSProperties }) {
const [pop, setPop] = useState(false);
const prev = useRef(value);
useEffect(() => {
if (prev.current !== value) {
prev.current = value;
setPop(true);
const t = setTimeout(() => setPop(false), 500);
return () => clearTimeout(t);
}
}, [value]);
return (
<span className={`mono ${pop ? 'count-tick' : ''} ${className}`} style={{ display: 'inline-block', ...style }}>
{value}
</span>
);
}
const EKG = 'M0 12 H10 L13 4 L16 20 L19 12 H30 L33 9 L36 15 L39 12 H50';
/** The system heartbeat (§8): scrolling EKG + SIGNAL LIVE + graded count +
* breathing neural % + sync clock. Sits under the ticker. */
export function HeartbeatBar() {
const live = useLive();
const clock = `${String(Math.floor(live.tick / 60) % 60).padStart(2, '0')}:${String(live.tick % 60).padStart(2, '0')}`;
return (
<div
className="scanlines"
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
height: 30,
padding: '0 16px',
background: 'var(--bg-0)',
borderBottom: '1px solid var(--border)',
overflow: 'hidden',
fontSize: 10.5,
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<span className="live-dot" style={{ background: 'var(--g-a)' }} />
<span className="mono" style={{ color: 'var(--g-a)', fontWeight: 700, letterSpacing: '0.12em' }}>SIGNAL LIVE</span>
</span>
{/* EKG strip */}
<div style={{ flex: 1, overflow: 'hidden', height: 22, maskImage: 'linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent)' }}>
<div className="ekg-track" style={{ height: 22 }}>
{[0, 1].map((k) => (
<svg key={k} width="200" height="22" viewBox="0 0 200 22" style={{ display: 'block' }} aria-hidden>
{[0, 50, 100, 150].map((x) => (
<path key={x} d={EKG} transform={`translate(${x},0)`} fill="none" stroke="var(--g-a)" strokeWidth="1.2" opacity="0.55" />
))}
</svg>
))}
</div>
</div>
<span className="mono" style={{ color: 'var(--text-1)', flexShrink: 0 }}>
<LiveNumber value={live.graded} style={{ color: 'var(--text-0)', fontWeight: 700 }} /> graded
</span>
<span className="mono" style={{ color: 'var(--text-1)', flexShrink: 0 }}>
neural <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>{live.neural}%</span>
</span>
<span className="mono" style={{ color: 'var(--text-2)', flexShrink: 0 }}>SYNC {clock}</span>
</div>
);
}