Files
vyndr/web/src/app/desk/page.tsx
T
builtbykev 1c681df5d3 DS1 (design): speed + trust bugs
Fixes the three DESIGN-SPEC Part 4 + #17 audit findings.

1. React #418 hydration mismatch (landing → dashboard entry). The
   `maybeSignedIn` value was computed in a useState INITIALIZER that reads
   localStorage during render: server (no window) → false → emits the
   marketing tree; a signed-in visitor's first CLIENT render → true → emits
   the loading placeholder. Whole-subtree server/client mismatch → React
   discarded and re-rendered the page. Deferred behind a mounted flag so the
   first client render matches the server; the stored-session check flips
   post-mount. SSR HTML is no longer discarded.

2. Loading walls → skeletons. New tokenized Skeleton primitive
   (.vyndr-skeleton, reduced-motion-safe via the global rule). Swapped into
   every text-wall loader: dashboard slate load ("Loading the slate…"), /desk
   ("Assembling the pack…"), /ledger ("Loading…"), scan ("Loading the model…"),
   and the landing redirect placeholder. No bare text loader remains.

3. scan→ledger persistence. Root cause: the scan page read its bearer token
   from localStorage['sb-token'] — a key written ONLY by the OAuth callback —
   so email/password users posted /api/scan anonymously and the ledger write
   (gated on an authed user) was silently skipped. Now uses the authoritative
   session.access_token (matching the ledger read path). Extracted the row
   builder to web/src/lib/ledgerRow.js (shared, testable).

Tests: +17 (scanLedgerPersistence write→mine round-trip + scope + idempotency;
ds1SpeedTrust hydration/skeleton/persistence source invariants). Full suite
233 suites / 2793 green; web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:25:32 -04:00

161 lines
7.3 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { Skeleton, SkeletonList } from '@/components/vyndr';
/**
* /desk (Session 63 / A1-S4c) — the founder's daily copy-paste arsenal.
* Auth: gated route + backend email allowlist (DESK_OWNERS). Everything on
* this page is assembled from the pipeline by the VOICE v1.1 template
* engine; per-tweet copy buttons + character counts; the DATA BRIEF block
* pastes into claude.ai for freeform writing. Nothing auto-posts.
*/
interface Variant { label: string; text: string; tweets: string[] }
interface DeskPack {
generated_at: string | null;
formats: {
morning_wire?: Variant[];
signals?: Variant[][];
streak_watch?: Variant[];
settle?: Variant[];
receipts?: Variant[][];
archetype_watch?: Variant[];
line_dispatches?: Variant[][];
};
data_brief?: unknown;
error?: string;
}
function CopyBtn({ text, label = 'COPY' }: { text: string; label?: string }) {
const [done, setDone] = useState(false);
return (
<button
onClick={() => {
void navigator.clipboard.writeText(text).then(() => {
setDone(true);
setTimeout(() => setDone(false), 1200);
});
}}
className="mono"
style={{ cursor: 'pointer', background: done ? 'var(--g-a)' : 'transparent', color: done ? '#06060B' : 'var(--text-1)', border: '1px solid var(--border-hi)', borderRadius: 6, padding: '4px 10px', fontSize: 10, fontWeight: 700, letterSpacing: '0.06em' }}
>
{done ? 'COPIED' : label}
</button>
);
}
function VariantBlock({ v }: { v: Variant }) {
return (
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span className="lbl">{v.label.toUpperCase()}</span>
<CopyBtn text={v.text} label="COPY ALL" />
</div>
{v.tweets.map((t, i) => (
<div key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '8px 10px', background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 8, marginBottom: 6 }}>
<pre className="mono" style={{ margin: 0, flex: 1, whiteSpace: 'pre-wrap', fontSize: 12.5, lineHeight: 1.55, color: 'var(--text-0)' }}>{t}</pre>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end', flexShrink: 0 }}>
<CopyBtn text={t} />
<span className="mono" style={{ fontSize: 9.5, color: t.length > 280 ? 'var(--miss)' : 'var(--text-2)' }}>{t.length}/280</span>
</div>
</div>
))}
</div>
);
}
function FormatCard({ title, variants }: { title: string; variants?: Variant[] }) {
if (!variants || variants.length === 0) return null;
return (
<section style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16, marginBottom: 16 }}>
<div className="mono" style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.14em', color: 'var(--g-a)', marginBottom: 12 }}>{title}</div>
{variants.map((v, i) => <VariantBlock key={i} v={v} />)}
</section>
);
}
export default function DeskPage() {
const { session } = useAuth();
const [pack, setPack] = useState<DeskPack | null>(null);
const [status, setStatus] = useState<'loading' | 'denied' | 'ready' | 'error'>('loading');
useEffect(() => {
const token = session?.access_token;
if (!token) return;
let active = true;
fetch('/api/desk/pack', { headers: { Authorization: `Bearer ${token}` } })
.then(async (r) => {
if (!active) return;
if (r.status === 403 || r.status === 401) { setStatus('denied'); return; }
const data = (await r.json()) as DeskPack;
setPack(data);
setStatus(data && data.formats ? 'ready' : 'error');
})
.catch(() => { if (active) setStatus('error'); });
return () => { active = false; };
}, [session]);
if (status === 'denied') {
return (
<section style={{ maxWidth: 640, margin: '0 auto', padding: '48px 16px' }}>
<p className="mono" style={{ color: 'var(--text-1)', fontSize: 13 }}>The desk is the publisher&rsquo;s surface. DESK_OWNERS grants access.</p>
</section>
);
}
if (status === 'loading') {
// DS1 (§4) — the pack is assembling; layout-matched skeleton, not a text
// wall. Mirrors the header + stack of FormatCards below.
return (
<section style={{ maxWidth: 860, margin: '0 auto', padding: '28px 16px 120px' }} aria-busy="true">
<Skeleton height={14} width={90} radius={4} style={{ marginBottom: 12 }} />
<Skeleton height={30} width={260} radius={8} style={{ marginBottom: 24 }} />
<SkeletonList count={4} height={120} />
</section>
);
}
if (status !== 'ready' || !pack) {
return (
<section style={{ maxWidth: 640, margin: '0 auto', padding: '48px 16px' }}>
<p className="mono" style={{ color: 'var(--text-2)', fontSize: 13 }}>Pack unavailable. Check back after the first pipeline run.</p>
</section>
);
}
const f = pack.formats;
return (
<section style={{ maxWidth: 860, margin: '0 auto', padding: '28px 16px 120px' }}>
<header style={{ marginBottom: 20 }}>
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em', marginBottom: 8 }}>THE DESK</div>
<h1 className="mono" style={{ margin: 0, fontSize: 26, fontWeight: 800 }}>Today&rsquo;s arsenal</h1>
{pack.generated_at && (
<p className="mono" style={{ fontSize: 11, color: 'var(--text-2)', marginTop: 6 }}>
Assembled {new Date(pack.generated_at).toLocaleString('en-US', { timeZone: 'America/New_York' })} ET · nothing auto-posts
</p>
)}
</header>
<FormatCard title="THE MORNING WIRE" variants={f.morning_wire} />
{(f.signals || []).map((v, i) => <FormatCard key={`sig-${i}`} title={`SIGNAL ${i + 1}`} variants={v} />)}
<FormatCard title="STREAK WATCH" variants={f.streak_watch} />
<FormatCard title="ARCHETYPE WATCH" variants={f.archetype_watch} />
{(f.line_dispatches || []).map((v, i) => <FormatCard key={`ld-${i}`} title={`LINE DISPATCH ${i + 1}`} variants={v} />)}
<FormatCard title="THE SETTLE" variants={f.settle} />
{(f.receipts || []).map((v, i) => <FormatCard key={`rc-${i}`} title={`RECEIPT ${i + 1}`} variants={v} />)}
{/* DATA BRIEF — paste into claude.ai for freeform Read Room threads. */}
{pack.data_brief != null && (
<section style={{ background: 'var(--bg-1)', border: '1px solid var(--border-hi)', borderRadius: 10, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<span className="mono" style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.14em', color: 'var(--amber)' }}>DATA BRIEF</span>
<CopyBtn text={JSON.stringify(pack.data_brief, null, 2)} label="COPY BRIEF" />
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>paste into claude.ai for Read Room + freeform</span>
</div>
<pre className="mono" style={{ margin: 0, maxHeight: 320, overflow: 'auto', fontSize: 11, lineHeight: 1.5, color: 'var(--text-1)', whiteSpace: 'pre-wrap' }}>{JSON.stringify(pack.data_brief, null, 2)}</pre>
</section>
)}
</section>
);
}