From 4908bc86f79542762aa69cf57bc8db18b24b24c3 Mon Sep 17 00:00:00 2001 From: Victor Zertuche Date: Sat, 18 Jul 2026 13:36:46 -0600 Subject: [PATCH] Add mobile navigation (burger + sidebar drawer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Below 880px there was no way to reach the nav: the topbar links are hidden and the docs sidebar was display:none. The API reference already shipped the drawer CSS and the body.docnav-open listener, but nothing ever toggled it — there was no button. Add a burger button to SiteNav that: - toggles the shared body.docnav-open contract on pages that have a sidebar (docs shell, API reference), sliding it in as a drawer with a scrim; and - falls back to a dropdown panel with the nav links on pages without one (home, 404). The docs sidebar gets the same slide-in drawer treatment the API reference already had, plus a row of tab links at the top of the drawer so the top-level sections stay reachable on mobile. Desktop is unchanged. --- app/docs.css | 17 ++++++++++--- app/markline-tokens.css | 32 +++++++++++++++++++++++ components/docs/nav.tsx | 20 ++++++++++++++- components/site-nav.tsx | 56 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/app/docs.css b/app/docs.css index 46da8dc..13eefbd 100644 --- a/app/docs.css +++ b/app/docs.css @@ -20,10 +20,21 @@ .docs-shell { grid-template-columns: 240px minmax(0, 1fr) !important; } .docs-shell > .docs-toc { display: none !important; } } -@media (max-width: 720px) { +/* ≤880px the sidebar becomes a slide-in drawer (body.docnav-open contract, + opened from the SiteNav burger) — same pattern as the API reference. */ +@media (max-width: 880px) { .docs-shell { grid-template-columns: 1fr !important; } - .docs-shell > .docs-side { display: none !important; } -} + /* aside.docs-side out-specifies the base `.docs-shell .docs-side` sticky + rule below (the grid rules above use !important for the same reason). */ + .docs-shell > aside.docs-side { + position: fixed; top: 0; left: 0; bottom: 0; width: 296px; z-index: 60; + height: 100vh; background: var(--bg); border-right: 1px solid var(--line); + transform: translateX(-100%); transition: transform .25s; padding-top: 70px; + } + body.docnav-open .docs-shell > aside.docs-side { transform: none; } + body.docnav-open .docs-shell .docnav-scrim { display: block; position: fixed; inset: 0; z-index: 55; background: rgba(0,0,0,.5); } +} +.docs-shell .docnav-scrim { display: none; } .docs-shell { /* Design var aliases → framework tokens. Local to the docs shell so they diff --git a/app/markline-tokens.css b/app/markline-tokens.css index 6cdd4ca..f9105c2 100644 --- a/app/markline-tokens.css +++ b/app/markline-tokens.css @@ -213,7 +213,39 @@ body { /* nav wrap (constrains content width, matches the design) */ .nav .wrap { width: 100%; max-width: 1240px; margin-inline: auto; padding-inline: 32px; } +/* mobile menu button — hidden on desktop, replaces the nav links ≤880px */ +.nav-burger { display: none; width: 36px; height: 36px; place-items: center; border-radius: 999px; border: 1px solid var(--line); background: transparent; color: var(--ink-2); transition: border-color .15s, color .15s; } +.nav-burger:hover { color: var(--ink); border-color: var(--ink-4); } +.nav-burger svg { width: 18px; height: 18px; } + +/* fallback dropdown for pages without a sidebar drawer (home, 404) */ +.nav-mobile-panel { display: none; } + @media (max-width: 880px) { .nav .wrap { padding-inline: 20px; } .nav-links, .ghbadge .lbl { display: none; } + .nav-burger { display: grid; flex: none; } + /* Fit brand + actions on narrow screens: tighter gaps/padding, smaller logo, + and a shrinkable brand so nothing (the burger) gets pushed off-viewport. */ + .nav-in { gap: 14px; } + .nav-in--full, .nav-in--contained { padding-inline: 16px; } + .nav-in .brand { min-width: 0; overflow: hidden; } + .brand .brand-logo { height: 22px; max-width: 100%; object-position: left center; } + .nav-right { gap: 8px; flex: none; } + .nav-right .theme-btn, .nav-right .ghbadge { flex: none; } + .nav-mobile-panel { display: flex; flex-direction: column; gap: 2px; padding: 10px 14px 14px; border-top: 1px solid var(--line); background: var(--bg); } + .nav-mobile-panel a { font-size: 15px; color: var(--ink-2); padding: 11px 10px; border-radius: 9px; text-decoration: none; } + .nav-mobile-panel a:hover { color: var(--ink); background: var(--panel-hi); } + .nav-mobile-panel a.active { color: var(--ink); font-weight: 500; } + .nav-mobile-panel .btn { justify-content: center; margin-top: 8px; } +} + +/* tab links pinned at the top of the mobile sidebar drawer (docs shell) so the + top-level sections stay reachable on mobile */ +.drawer-tabs { display: none; } +@media (max-width: 880px) { + .drawer-tabs { display: flex; flex-direction: column; gap: 2px; margin: 0 0 14px; padding-bottom: 12px; border-bottom: 1px solid var(--line); } + .drawer-tabs a { display: block; font-size: 13.5px; font-weight: 500; color: var(--ink-2); padding: 8px 10px; border-radius: 8px; text-decoration: none; } + .drawer-tabs a:hover { color: var(--ink); background: var(--panel-hi); } + .drawer-tabs a.active { color: var(--accent-ink, rgb(var(--c-brand))); background: var(--accent-dim, rgb(var(--c-brand) / 0.10)); } } diff --git a/components/docs/nav.tsx b/components/docs/nav.tsx index d6e383f..9adb688 100644 --- a/components/docs/nav.tsx +++ b/components/docs/nav.tsx @@ -143,14 +143,32 @@ export function DocsSidebar({ return () => cancelAnimationFrame(id); }, [pathname]); + const closeDrawer = () => document.body.classList.remove("docnav-open"); + return ( <> + {/* Mobile drawer scrim (body.docnav-open contract — see app/docs.css). */} +
{/* Page-level AI affordances (the doc-ai row + View-as-Markdown modal) live in the docs shell so they're available on every docs page. */} diff --git a/components/site-nav.tsx b/components/site-nav.tsx index 9a5305b..3f57152 100644 --- a/components/site-nav.tsx +++ b/components/site-nav.tsx @@ -52,6 +52,26 @@ export function SiteNav({ const pathname = usePathname(); // The homepage ("/") uses its own width knob; every other route uses `width`. const layout = pathname === "/" ? homeWidth : width; + const [menuOpen, setMenuOpen] = useState(false); + + // Close the mobile menu / page drawer whenever the route changes. + useEffect(() => { + setMenuOpen(false); + document.body.classList.remove("docnav-open"); + }, [pathname]); + + // Mobile menu button. Pages that ship a sidebar drawer (docs shell, API + // reference) use the shared body.docnav-open contract; pages without one + // (home, 404) fall back to the SiteNav's own dropdown panel with the links. + const onBurger = () => { + if (document.querySelector(".docs-side, .api-side")) { + setMenuOpen(false); + document.body.classList.toggle("docnav-open"); + return; + } + document.body.classList.remove("docnav-open"); + setMenuOpen((v) => !v); + }; const isActive = (href: string) => { const path = href.split(/[#?]/)[0] || "/"; @@ -105,8 +125,44 @@ export function SiteNav({ {cta.label} )} +
+ {menuOpen && ( +
+ {links.map((l) => ( + setMenuOpen(false)} + > + {l.label} + + ))} + {cta && ( + setMenuOpen(false)}> + {cta.label} + + )} +
+ )} ); }