Merge DS5 (design): pricing Desk-as-hero, ticker stillness, empty/error unify, archetype glyphs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
// DS5 (Design v2) — Pricing (Desk-as-hero) + Motion (ticker → punctuated
|
||||
// stillness) + Empty/Error unification (the 404 bar) + archetype propagation.
|
||||
// Source-grep locks (plain-JS Jest, no TS transform) — same pattern as
|
||||
// vyndrParityQA / ds4Billboards. Every assertion targets a DS5 artifact that
|
||||
// did NOT exist on the base commit, so the suite fails before and passes after.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const WEB = path.join(ROOT, 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
const readCss = () => fs.readFileSync(path.join(WEB, 'app', 'globals.css'), 'utf8');
|
||||
|
||||
// Extract a single TIERS object block by its id.
|
||||
function tierBlock(src, id) {
|
||||
const m = src.match(new RegExp(`id: '${id}'[\\s\\S]*?highlight: (true|false)`));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// ── 1. Desk $44.99 is the HERO tier (#8, Part 6) ──────────────────────────
|
||||
describe('Pricing — Desk is the hero, real prices, single primary CTA', () => {
|
||||
const pricing = read('components/Pricing.tsx');
|
||||
const showcase = read('app/pricing/DeskShowcase.tsx');
|
||||
const page = read('app/pricing/page.tsx');
|
||||
|
||||
test('Desk is highlighted (the hero); Analyst is NOT (no two competing green CTAs, #9)', () => {
|
||||
expect(tierBlock(pricing, 'desk')).toBe('true');
|
||||
expect(tierBlock(pricing, 'analyst')).toBe('false');
|
||||
});
|
||||
|
||||
test('Desk CTA is at least as strong as Analyst — Desk drives the primary button', () => {
|
||||
// highlight:true → btn-primary (the strong CTA); highlight:false → btn-ghost.
|
||||
expect(pricing).toContain("className={tier.highlight ? 'btn-primary' : 'btn-ghost'}");
|
||||
// Desk is the highlighted tier, so it owns the primary; Analyst is secondary.
|
||||
expect(tierBlock(pricing, 'desk')).toBe('true');
|
||||
});
|
||||
|
||||
test('real prices only — Desk $34.99 founder / $44.99 regular, Analyst $14.99/$19.99, Free 5 scans', () => {
|
||||
expect(pricing).toMatch(/id: 'desk'[\s\S]*?price: '\$34\.99'/);
|
||||
expect(pricing).toMatch(/id: 'desk'[\s\S]*?originalPrice: '\$44\.99'/);
|
||||
expect(pricing).toMatch(/id: 'analyst'[\s\S]*?price: '\$14\.99'/);
|
||||
expect(pricing).toMatch(/id: 'analyst'[\s\S]*?originalPrice: '\$19\.99'/);
|
||||
expect(pricing).toContain('5 scans to try the model');
|
||||
// no fabricated legacy price
|
||||
expect(pricing).not.toContain("originalPrice: '$24.99'");
|
||||
expect(pricing).not.toContain("originalPrice: '$49.99'");
|
||||
});
|
||||
|
||||
test('the "$1M terminal · $44.99" story leads, above the grid, with a real feature ladder', () => {
|
||||
expect(page).toContain('import DeskShowcase');
|
||||
expect(page).toContain('<DeskShowcase');
|
||||
expect(showcase).toContain('$1M terminal');
|
||||
expect(showcase).toContain('$44.99'); // the conversion hook figure
|
||||
expect(showcase).toContain('$34.99'); // the founder price
|
||||
// real feature visuals fill the right half (kills the dead half, #8)
|
||||
expect(showcase).toContain('ALT LINE LADDER');
|
||||
expect(showcase).toContain('QUARTER-KELLY');
|
||||
// the primary CTA scrolls to the grid where the real checkout lives
|
||||
expect(showcase).toContain('href="#pricing"');
|
||||
expect(showcase).toContain('btn-primary');
|
||||
});
|
||||
|
||||
test('Stripe checkout wiring is untouched (checkoutUrl + POST /api/checkout)', () => {
|
||||
expect(require('../../web/src/lib/checkout').checkoutUrl('desk')).toBe('/api/checkout?tier=desk');
|
||||
expect(pricing).toContain("fetch('/api/checkout'");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. Ticker → punctuated stillness (#7, Part 4) ─────────────────────────
|
||||
describe('Ticker + header motion — mostly still, meaningful pulses', () => {
|
||||
const ticker = read('components/vyndr/Ticker.tsx');
|
||||
const css = readCss();
|
||||
|
||||
test('motion is tokenized — no one-off durations (--ticker-hold + --motion-*)', () => {
|
||||
expect(css).toMatch(/--ticker-hold:\s*\d+ms/);
|
||||
expect(css).toContain('--motion-transition:');
|
||||
expect(css).toContain('--motion-data:');
|
||||
expect(css).toContain('--motion-idle:');
|
||||
});
|
||||
|
||||
test('the ticker RESTS ≥4s on each item (hold token + JS constant both ≥4000ms, in sync)', () => {
|
||||
const holdMs = Number((css.match(/--ticker-hold:\s*(\d+)ms/) || [])[1]);
|
||||
const jsMs = Number((ticker.match(/TICKER_HOLD_MS\s*=\s*(\d+)/) || [])[1]);
|
||||
expect(holdMs).toBeGreaterThanOrEqual(4000);
|
||||
expect(jsMs).toBeGreaterThanOrEqual(4000);
|
||||
expect(jsMs).toBe(holdMs);
|
||||
});
|
||||
|
||||
test('the continuous marquee is retired — the ticker no longer scrolls forever', () => {
|
||||
// The resting strip is static; the old infinite .ticker-track marquee is gone
|
||||
// from the component (constant motion reads cheap — Part 0 law #1).
|
||||
expect(ticker).not.toContain('ticker-track');
|
||||
expect(ticker).toContain('ticker-rest');
|
||||
});
|
||||
|
||||
test('ONE animated element in the header zone — the ticker no longer renders a competing live-dot', () => {
|
||||
// The single idle proof-of-life is the heartbeat live-dot; the ticker drops
|
||||
// its own pulsing dot so the header is not ticker + heartbeat + counter.
|
||||
expect(ticker).not.toMatch(/className="[^"]*\blive-dot\b/);
|
||||
const live = read('components/vyndr/LiveLayer.tsx');
|
||||
expect((live.match(/live-dot/g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
test('the EKG heartbeat is a STATIC readout (no idle scroll animation)', () => {
|
||||
// .ekg-track keeps its class (vyndrSystems locks the string) but no longer
|
||||
// carries an infinite scroll — proof-of-life is the one live-dot.
|
||||
expect(/\.ekg-track\s*\{[^}]*animation/.test(css)).toBe(false);
|
||||
expect(read('components/vyndr/LiveLayer.tsx')).toContain('ekg-track');
|
||||
});
|
||||
|
||||
test('prefers-reduced-motion kills the motion entirely', () => {
|
||||
expect(ticker).toContain('prefers-reduced-motion');
|
||||
// new ticker motion classes are in the global reduced-motion kill list
|
||||
expect(css).toMatch(/prefers-reduced-motion[\s\S]*?\.ticker-item-enter/);
|
||||
expect(css).toMatch(/prefers-reduced-motion[\s\S]*?\.ticker-pulse/);
|
||||
});
|
||||
|
||||
test('the polling contract is preserved (tickerLive lock stays green)', () => {
|
||||
expect(ticker).toContain("fetch('/api/ticker'");
|
||||
expect(ticker).toContain('pollMs = 30_000');
|
||||
expect(ticker).toContain('feed && feed.length > 0 ? feed : items');
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. Unify empty/error to the 404 bar (#20, Part 8) ─────────────────────
|
||||
describe('EmptyState — one designed empty/error system, modeled on the 404', () => {
|
||||
const es = read('components/vyndr/EmptyState.tsx');
|
||||
|
||||
test('carries the 404 north-star grammar (scanlines + glitch wordmark + amber system voice)', () => {
|
||||
expect(es).toContain('scanlines');
|
||||
expect(es).toContain('crt-sweep');
|
||||
expect(es).toContain('amber-glow');
|
||||
expect(es).toContain('Wordmark');
|
||||
});
|
||||
|
||||
test('CTA hierarchy — at most one primary (color contract #9)', () => {
|
||||
expect(es).toContain('primary');
|
||||
expect(es).toContain("a.primary ? 'btn-primary' : 'btn-ghost'");
|
||||
});
|
||||
|
||||
test('reused at the "Team not found" offender — no bare-red line', () => {
|
||||
const hub = read('app/team/[abbr]/TeamHub.tsx');
|
||||
expect(hub).toContain('<EmptyState');
|
||||
expect(hub).toContain('code="TEAM NOT FOUND"');
|
||||
// the bare-red string is gone
|
||||
expect(hub).not.toContain('Team not found.');
|
||||
});
|
||||
|
||||
test('reused at the "Game not found" offender', () => {
|
||||
const game = read('app/game/[id]/page.tsx');
|
||||
expect(game).toContain('<EmptyState');
|
||||
expect(game).toContain('code="GAME NOT FOUND"');
|
||||
});
|
||||
|
||||
test('reused for the ledger empty states (one voice, not a bespoke box)', () => {
|
||||
const ledger = read('app/ledger/page.tsx');
|
||||
expect(ledger).toContain('<EmptyState');
|
||||
// the old bespoke "surface diagonal-cut tex-scan" empty box is replaced
|
||||
expect(ledger).not.toContain('LEDGER EMPTY</p>');
|
||||
});
|
||||
|
||||
test('EmptyState is exported from the vyndr barrel (one component, everywhere)', () => {
|
||||
expect(read('components/vyndr/index.ts')).toContain("export { default as EmptyState }");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 4. Propagate archetype glyphs everywhere (Part 5) ─────────────────────
|
||||
describe('Archetype glyph+chip — the ONE component, propagated', () => {
|
||||
test('the grade reveal renders the archetype (glyph+chip via ArchetypeBlend)', () => {
|
||||
const card = read('components/vyndr/GradeResultCard.tsx');
|
||||
expect(card).toContain('ArchetypeBlend');
|
||||
expect(card).toContain('<ArchetypeBlend');
|
||||
});
|
||||
|
||||
test('STREAKS rows render the archetype badge (was plain text) — self-hiding', () => {
|
||||
const panel = read('components/StreaksPanel.tsx');
|
||||
expect(panel).toContain("import ArchetypeBadge");
|
||||
expect(panel).toContain('<ArchetypeBadge');
|
||||
expect(panel).toContain('s.archetype &&'); // optional + self-hiding
|
||||
});
|
||||
|
||||
test('the ledger rows render the archetype badge (was plain text) — self-hiding', () => {
|
||||
const ledger = read('app/ledger/page.tsx');
|
||||
expect(ledger).toContain('ArchetypeBadge');
|
||||
expect(ledger).toContain('<ArchetypeBadge');
|
||||
expect(ledger).toContain('row.archetype &&');
|
||||
});
|
||||
|
||||
test('the badge shows its one-line meaning where it leads (showDesc)', () => {
|
||||
expect(read('components/StreaksPanel.tsx')).toContain('showDesc');
|
||||
expect(read('app/ledger/page.tsx')).toContain('showDesc');
|
||||
});
|
||||
});
|
||||
@@ -29,13 +29,16 @@ describe('Team Hub page', () => {
|
||||
expect(src).toContain('opacity: noProps ? 0.6 : 1');
|
||||
});
|
||||
it('has back-to-slate navigation', () => {
|
||||
expect(src).toContain('← Back to Slate');
|
||||
expect(src).toContain('href="/dashboard"');
|
||||
// DS5 (#20) — the error surface is the unified EmptyState; its action links
|
||||
// back to the Slate.
|
||||
expect(src).toContain('← Back to the Slate');
|
||||
expect(src).toContain("href: '/dashboard'");
|
||||
});
|
||||
it('handles loading + error states', () => {
|
||||
it('handles loading + error states via the unified EmptyState (no bare-red line)', () => {
|
||||
expect(src).toContain("'loading'");
|
||||
expect(src).toContain("'error'");
|
||||
expect(src).toContain('Team not found');
|
||||
expect(src).toContain('<EmptyState');
|
||||
expect(src).toContain('code="TEAM NOT FOUND"');
|
||||
});
|
||||
it('wires the parlay "+" on graded props', () => {
|
||||
expect(src).toContain('useParlay');
|
||||
|
||||
@@ -182,9 +182,12 @@ describe('Phase B — shared component contracts (§5)', () => {
|
||||
expect(src).toContain('<path');
|
||||
});
|
||||
|
||||
it('Ticker uses the ticker-track marquee and duplicates content for a seamless loop', () => {
|
||||
it('Ticker RESTS on each item (DS5 punctuated stillness) — no infinite marquee', () => {
|
||||
// DS5 (Part 4, #7) retired the continuous .ticker-track marquee: the ticker
|
||||
// now holds each ranked item ≥4s and pulses only on change. See
|
||||
// ds5PricingStates.test.js for the full motion lock.
|
||||
const src = comp('Ticker.tsx');
|
||||
expect(src).toContain('ticker-track');
|
||||
expect(src).toMatch(/\{content\}\s*\{content\}/);
|
||||
expect(src).not.toContain('ticker-track');
|
||||
expect(src).toContain('ticker-rest');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user