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:
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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>{`
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user