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
+19
View File
@@ -246,3 +246,22 @@ describe('P0-1 — grade badges are token-derived; blue-B / amber-C banished', (
expect(css).toMatch(/\.grade-c-bg\s*\{[^}]*color-mix\(in srgb, var\(--grade-c\)/); expect(css).toMatch(/\.grade-c-bg\s*\{[^}]*color-mix\(in srgb, var\(--grade-c\)/);
}); });
}); });
// ── P0-4 lock — mobile header collapses to ONE line ──────────────────────
describe('P0-4 — mobile app bar is one line (ticker + heartbeat hidden)', () => {
const css = read('app/globals.css');
it('<768px hides the ticker row + the whole heartbeat bar; nav clock shows', () => {
expect(css).toMatch(/@media\s*\(max-width:\s*767px\)\s*\{[^]*?\.nav-ticker\s*\{[^}]*display:\s*none/);
expect(css).toMatch(/@media\s*\(max-width:\s*767px\)\s*\{[^]*?\.heartbeat-bar\s*\{[^}]*display:\s*none/);
expect(css).toMatch(/\.nav-mobile-clock\s*\{\s*display:\s*none/); // desktop hides it
expect(css).toMatch(/@media[^]*?\.nav-mobile-clock\s*\{[^}]*display:\s*inline-flex/);
// the clock is wired into the nav, freshness-driven (never silently stale)
const nav = read('components/Nav.tsx');
expect(nav).toMatch(/MobileSyncClock/);
expect(nav).toMatch(/nav-mobile-clock/);
});
it('main top padding + slate sticky drop to the 60px bar on mobile (no clipping)', () => {
expect(css).toMatch(/@media[^]*?main\s*\{[^}]*padding-top:\s*62px/);
expect(css).toMatch(/\.slate-sticky-head\s*\{\s*top:\s*60px/);
});
});
+14
View File
@@ -1371,6 +1371,20 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
.gl-full.gl-expanded { margin-top: 10px; } .gl-full.gl-expanded { margin-top: 10px; }
} }
/* P0-4 — MOBILE APP BAR: collapse the 3-row header (nav + ticker + heartbeat)
to ONE line <768px. The ticker row + the whole heartbeat bar are hidden; the
nav's right cluster shows the app-bar clock (nav-mobile-clock). main's top
padding drops to the nav height alone so page titles stop clipping under it. */
.nav-mobile-clock { display: none; }
@media (max-width: 767px) {
.nav-ticker { display: none !important; }
.heartbeat-bar { display: none !important; }
.nav-mobile-clock { display: inline-flex !important; }
main { padding-top: 62px !important; }
.slate-sticky-head { top: 60px !important; } /* sticky tabs clear the 60px app bar, not 122 */
}
/* ============================================================ /* ============================================================
DESIGN COMPLETION TRAIN — M1 MOBILE FOUNDATION (2026-07-16) DESIGN COMPLETION TRAIN — M1 MOBILE FOUNDATION (2026-07-16)
Now RECONCILED against Design's drawn mobile (specs/design-reference/ Now RECONCILED against Design's drawn mobile (specs/design-reference/
+8 -3
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Wordmark, Ticker } from '@/components/vyndr'; import { Wordmark, Ticker } from '@/components/vyndr';
import { HeartbeatBar } from '@/components/vyndr/LiveLayer'; import { HeartbeatBar, MobileSyncClock } from '@/components/vyndr/LiveLayer';
import NotificationBell from '@/components/NotificationBell'; import NotificationBell from '@/components/NotificationBell';
// Nav labels are English literals for now; nav-string i18n lands in Phase G // 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. // (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 the "Query" pill was a duplicate link to /scan — deleted. A real
⌘K palette is a later nicety (spec §4), not a nav link. */} ⌘K palette is a later nicety (spec §4), not a nav link. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <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 {/* S6 (A1 board) — global search (players + teams). ⌘K opens the
same modal; this icon is the mobile/mouse path (window.__search same modal; this icon is the mobile/mouse path (window.__search
registered by GlobalHosts). */} registered by GlobalHosts). */}
@@ -412,8 +415,10 @@ export default function Nav() {
</nav> </nav>
{/* Ticker + heartbeat under the bar (§8 living layer). No fallback {/* Ticker + heartbeat under the bar (§8 living layer). No fallback
items — real snapshot exhaust or nothing. */} items — real snapshot exhaust or nothing. P0-4: both hidden <768px
<Ticker items={[]} height={32} /> (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 /> <HeartbeatBar />
<style jsx>{` <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 }}> <div style={{ display: 'grid', gap: 24, paddingBottom: 24 }}>
{/* Sticky header — search + tabs */} {/* Sticky header — search + tabs */}
<div <div
className="slate-sticky-head"
style={{ style={{
position: 'sticky', position: 'sticky',
// Clears nav (60) + heartbeat (30) + the dynamic ticker (Session 57: // 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))', top: 'calc(90px + var(--ticker-h, 0px))',
zIndex: 5, zIndex: 5,
background: 'var(--bg-0, #0A0A0F)', background: 'var(--bg-0, #0A0A0F)',
+50
View File
@@ -188,3 +188,53 @@ export function HeartbeatBar() {
</div> </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>
);
}