From 64535ee9dce5f1a7b03a30a6f58282ce24b0cda7 Mon Sep 17 00:00:00 2001
From: Fuwn <contact@fuwn.net>
Date: Tue, 19 May 2026 00:54:11 +0000
Subject: feat(nav): float header as a corner hamburger under 800px

Below 800px the inline header overflows the viewport. Strips card chrome
from .header itself and floats it position:fixed at top:1.25rem/right
:1.25rem so it does not consume a horizontal band. The 44x44 toggle
button carries the desktop banner's exact card recipe (--base0011 glass,
shadow-card-emphasized + 5px --base02 ring, blur, 8px radius), and the
open panel mirrors it as a separately-positioned card below.

Menu closes on route navigation and Escape. Header stays visible while
the menu is open so a scroll-driven hide does not chop the open sheet
mid-interaction. Profile-avatar anchor and the bullet separator are
hidden in the mobile menu (avatar is redundant alongside the Profile
dropdown; separator reads as line noise vertically).
---
 src/routes/+layout.svelte | 132 +++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 130 insertions(+), 2 deletions(-)

(limited to 'src/routes/+layout.svelte')

diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 0cfde36d..a7a03066 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -54,6 +54,7 @@ let previousScrollPosition = 0;
 let notificationInterval: ReturnType<typeof setInterval> | undefined =
 	undefined;
 let lenis: Lenis | undefined = undefined;
+let isMenuOpen = false;
 
 addMessages("en", english as unknown as LocaleDictionary);
 addMessages("ja", japanese as unknown as LocaleDictionary);
@@ -87,6 +88,8 @@ $: way = data.url.includes("/user")
 			? 200
 			: -200;
 
+$: if ($navigating) isMenuOpen = false;
+
 const handleScroll = () => {
 	const currentScrollPosition = window.scrollY;
 
@@ -214,6 +217,8 @@ $: {
 
 <HeadTitle />
 
+<svelte:window on:keydown={(e) => { if (e.key === 'Escape' && isMenuOpen) isMenuOpen = false; }} />
+
 <CommandPalette
   items={[
     ...defaultActions,
@@ -239,8 +244,25 @@ $: {
   <Announcement />
 
   <div class="container">
-    <div class="card card-centered card-glass header" class:header-hidden={!isHeaderVisible}>
-      <div>
+    <div
+      class="card card-centered card-glass header"
+      class:header-hidden={!isHeaderVisible && !isMenuOpen}
+      class:menu-open={isMenuOpen}
+    >
+      <button
+        type="button"
+        class="menu-toggle"
+        aria-label="Menu"
+        aria-expanded={isMenuOpen}
+        aria-controls="primary-nav"
+        onclick={() => (isMenuOpen = !isMenuOpen)}
+      >
+        <span class="menu-bar"></span>
+        <span class="menu-bar"></span>
+        <span class="menu-bar"></span>
+      </button>
+
+      <div class="nav-items" id="primary-nav">
         <a href={root('/')} class="header-item">{$locale().navigation.home}</a><a
           href={root('/completed')}
           class="header-item"
@@ -425,4 +447,110 @@ $: {
   .separator {
     color: var(--base04);
   }
+
+  .menu-toggle {
+    display: none;
+  }
+
+  @media (max-width: 800px) {
+    .header {
+      position: fixed;
+      top: 1.25rem;
+      right: 1.25rem;
+      left: auto;
+      width: auto;
+      margin: 0;
+      padding: 0;
+      background: transparent;
+      box-shadow: none;
+      backdrop-filter: none;
+      -webkit-backdrop-filter: none;
+      z-index: 10;
+    }
+
+    .header.header-hidden {
+      transform: none;
+    }
+
+    .menu-toggle {
+      box-sizing: border-box;
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+      align-items: center;
+      gap: 5px;
+      width: 44px;
+      height: 44px;
+      padding: 0;
+      border: none;
+      border-radius: 8px;
+      background: var(--base0011);
+      box-shadow:
+        var(--shadow-card-emphasized),
+        0 0 0 5px var(--base02);
+      backdrop-filter: blur(4px);
+      -webkit-backdrop-filter: blur(4px);
+      cursor: pointer;
+    }
+
+    .menu-toggle:hover {
+      background: var(--base0011);
+    }
+
+    .menu-bar {
+      display: block;
+      width: 20px;
+      height: 2px;
+      background: var(--base06);
+      border-radius: 2px;
+      transition:
+        transform var(--duration-fast) var(--ease-in-out-quart),
+        opacity var(--duration-fast) var(--ease-in-out-quart);
+    }
+
+    .menu-toggle[aria-expanded='true'] .menu-bar:nth-child(1) {
+      transform: translateY(7px) rotate(45deg);
+    }
+
+    .menu-toggle[aria-expanded='true'] .menu-bar:nth-child(2) {
+      opacity: 0;
+    }
+
+    .menu-toggle[aria-expanded='true'] .menu-bar:nth-child(3) {
+      transform: translateY(-7px) rotate(-45deg);
+    }
+
+    .nav-items {
+      display: none;
+    }
+
+    .header.menu-open .nav-items {
+      display: flex;
+      flex-direction: column;
+      align-items: stretch;
+      position: absolute;
+      top: calc(100% + 0.75rem);
+      right: 0;
+      min-width: 200px;
+      padding: 0.5rem;
+      border-radius: 8px;
+      background: var(--base0011);
+      box-shadow:
+        var(--shadow-card-emphasized),
+        0 0 0 5px var(--base02);
+      backdrop-filter: blur(4px);
+      -webkit-backdrop-filter: blur(4px);
+    }
+
+    .header.menu-open .nav-items :global(.header-item) {
+      display: block;
+      margin: 0;
+      padding: 0.6rem 0.75rem;
+    }
+
+    .header.menu-open .nav-items :global(.separator),
+    .header.menu-open .nav-items :global(.header-item:has(.avatar)) {
+      display: none;
+    }
+  }
 </style>
-- 
cgit v1.2.3

