P0-4 fix: mobile header collapses to ONE line — logo + clock + sync dot

Phone audit: at 390px we still rendered the full desktop 3-row header (nav +
TOP MOVES ticker + SYNC line) eating ~20% of the viewport, and its height
clipped page titles under it (MY READS tabs, HEAD TO HEAD). Implemented Design's
mobile app bar <768px:
- New MobileSyncClock (extracted from HeartbeatBar) lives in the Nav's right
  cluster — wall clock rests, amber/STALE reacts off the shared freshness tier.
- <768px: the ticker row (.nav-ticker) AND the whole heartbeat bar are hidden;
  only the nav row shows (logo + clock + search). main padding-top → 62px and
  the Slate sticky tabs → top:60px, so nothing clips under the bar.
- Locked in vyndrParityQA (P0-4): ticker+heartbeat hidden, nav clock shown,
  paddings collapsed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-17 01:15:24 -04:00
parent db61876b2f
commit ff53f31bfc
5 changed files with 96 additions and 4 deletions
+8 -3
View File
@@ -4,7 +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 { HeartbeatBar, MobileSyncClock } 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,6 +190,9 @@ export default function Nav() {
the "Query" pill was a duplicate link to /scan — deleted. A real
⌘K palette is a later nicety (spec §4), not a nav link. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{/* P0-4 — the mobile app-bar clock. Shown ONLY <768px (nav-mobile-clock);
the ticker + heartbeat bar are hidden there, so the header is ONE line. */}
<MobileSyncClock className="nav-mobile-clock" />
{/* S6 (A1 board) — global search (players + teams). ⌘K opens the
same modal; this icon is the mobile/mouse path (window.__search
registered by GlobalHosts). */}
@@ -412,8 +415,10 @@ export default function Nav() {
</nav>
{/* Ticker + heartbeat under the bar (§8 living layer). No fallback
items — real snapshot exhaust or nothing. */}
<Ticker items={[]} height={32} />
items — real snapshot exhaust or nothing. P0-4: both hidden <768px
(mobile header is ONE line — the nav-mobile-clock in the bar carries
freshness; the ticker's own JS still runs but the row is display:none). */}
<div className="nav-ticker"><Ticker items={[]} height={32} /></div>
<HeartbeatBar />
<style jsx>{`
+5 -1
View File
@@ -918,10 +918,14 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
<div style={{ display: 'grid', gap: 24, paddingBottom: 24 }}>
{/* Sticky header — search + tabs */}
<div
className="slate-sticky-head"
style={{
position: 'sticky',
// Clears nav (60) + heartbeat (30) + the dynamic ticker (Session 57:
// --ticker-h is 32px only when the ticker has ≥4 real items).
// --ticker-h is 32px only when the ticker has ≥4 real items). P0-4:
// on mobile the header collapses to the 60px app bar (ticker +
// heartbeat hidden), so .slate-sticky-head is overridden to top:60px
// in globals.css — otherwise it stuck 62px below the bar.
top: 'calc(90px + var(--ticker-h, 0px))',
zIndex: 5,
background: 'var(--bg-0, #0A0A0F)',
+50
View File
@@ -188,3 +188,53 @@ export function HeartbeatBar() {
</div>
);
}
/**
* MobileSyncClock (P0-4) — the mobile APP-BAR clock. Rendered in the Nav's right
* cluster and shown ONLY <768px (where the ticker + the whole heartbeat bar are
* hidden), so the mobile header collapses to ONE line: logo + clock + sync dot.
* Wall clock rests (the stillness); amber → red STALE reacts off the shared
* freshness tier (refreshed_at vs expected_interval_s). Never silently stale.
*/
export function MobileSyncClock({ className = '' }: { className?: string }) {
const live = useLive();
const [summary, setSummary] = useState<SnapshotSummary | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
useEffect(() => {
let active = true;
const load = async () => {
try {
const r = await fetch('/api/snapshot/summary', { cache: 'no-store' });
if (!r.ok) return;
const d = await r.json();
if (active && typeof d?.graded === 'number') {
setSummary({
graded: d.graded,
updated_at: d.updated_at ?? null,
refreshed_at: d.refreshed_at ?? d.updated_at ?? null,
expected_interval_s: Number(d.expected_interval_s) > 0 ? Number(d.expected_interval_s) : undefined,
});
}
} catch { /* keep last known — never invent */ }
};
load();
const id = setInterval(load, SUMMARY_POLL_MS);
return () => { active = false; clearInterval(id); };
}, []);
void live.tick;
const freshAt = summary?.refreshed_at ?? summary?.updated_at ?? null;
const { tier, elapsedMs } = freshnessTier(freshAt ? Date.parse(freshAt) : NaN, summary?.expected_interval_s ?? 0, Date.now());
const wall = mounted ? new Date().toLocaleTimeString('en-US', { hour12: false }) : '--:--:--';
const view = tier === 'stale'
? { label: `STALE ${elapsedMs !== null ? syncLabel(elapsedMs) : ''}`.trim(), color: 'var(--miss)', weight: 700 }
: tier === 'amber'
? { label: `SYNC ${elapsedMs !== null ? syncLabel(elapsedMs) : '—'}`, color: 'var(--amber)', weight: 700 }
: { label: wall, color: 'var(--text-1)', weight: 400 };
return (
<span className={`mono ${className}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 10.5, color: view.color, fontWeight: view.weight, letterSpacing: '0.04em', flexShrink: 0 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: view.color, flexShrink: 0 }} />
{view.label}
</span>
);
}