Files
qumo-website/openspec/changes/step-003-navigation-bar/design.md

6.4 KiB
Raw Blame History

Context

Step 002 produced a BaseLayout.astro that imports Nav.astro and Footer.astro stubs — both render nothing. The navigation bar is the first visible UI element needed on every page. It must:

  • Render correctly on a Midnight (#102022) dark background
  • Work at build time (Astro SSG — no server runtime)
  • Derive the current locale from the URL path (no runtime props)
  • Use zero client-side framework code

Available assets confirmed:

  • src/assets/logos/logo-wide.svg — full "qumo" wordmark, paths filled with #161616 (near-black — needs override)
  • src/assets/icons/wum-arrow.svg — chevron arrow, already stroke="#f3f3f3" (Snow — correct for dark nav)

Goals / Non-Goals

Goals:

  • Fully functional Nav.astro replacing the empty stub
  • Logo visible on Midnight background
  • Three nav links + language switcher + Connect CTA
  • Scroll-triggered background transition (transparent → Midnight)
  • Mobile hamburger menu with open/close behavior
  • All text strings from en.json / nl.json

Non-Goals:

  • Animated underline or hover effects beyond Tailwind utilities
  • Active page highlighting (deferred — pages don't exist yet to test against)
  • Dropdown sub-menus (not on staging site)
  • Separate LanguageSwitcher.astro component (simple enough to inline)

Decisions

1. Logo: inline SVG with fill override

Decision: Inline the logo-wide.svg SVG directly in Nav.astro. Change all fill="#161616" attributes to fill="currentColor". Set class="text-snow" on the <svg> element so currentColor resolves to Snow (#F3F3F3).

Rationale: The logo is a small, purely decorative SVG — inlining it avoids an extra HTTP request and allows CSS-controlled coloring without filters. fill="currentColor" + Tailwind text color is the idiomatic approach. An <img> tag cannot be recolored without CSS filters, which look worse.

Alternative considered: <Image> component with a separate white-fill SVG asset. Rejected: requires maintaining two copies of the same logo.

2. Locale detection: Astro.url at build time

Decision: In Nav.astro, derive locale from Astro.url.pathname:

const isNl = Astro.url.pathname.startsWith('/nl');
const locale = isNl ? 'nl' : 'en';
const alternatePath = isNl
  ? Astro.url.pathname.replace(/^\/nl/, '') || '/'
  : '/nl' + Astro.url.pathname;

Pass locale to getContent() for nav strings. Use alternatePath for the language switcher link.

Rationale: Astro SSG renders each page at build time with its own Astro.url. This approach is zero-JS, works for every route without configuration, and the prefix rule (/nl/.../...) holds for all routes on this site. Confirmed with user during explore session (Option B).

Alternative considered: Prop threading from BaseLayout (Option A). Rejected by user — more verbose, non-standard for nav components.

3. Scroll behavior: window scroll event + CSS class toggle

Decision: A <script> tag in Nav adds/removes a scrolled class on the <nav> element when window.scrollY > 20. Tailwind's data-scrolled attribute or a direct class toggle drives the style change.

Implementation approach — use a data-scrolled attribute to avoid class name conflicts:

const nav = document.getElementById('main-nav');
window.addEventListener('scroll', () => {
  nav.dataset.scrolled = window.scrollY > 20 ? 'true' : 'false';
}, { passive: true });

CSS: nav starts bg-transparent, transitions to bg-midnight when data-scrolled="true". Use Tailwind's arbitrary variant: data-[scrolled=true]:bg-midnight.

Rationale: Simple, no dependencies, no layout shift. passive: true avoids scroll jank. The data-* attribute approach keeps the JSCSS contract explicit and avoids collisions with Tailwind's JIT-generated class names.

Alternative considered: IntersectionObserver on a sentinel element. Unnecessary complexity for this use case — scroll position threshold is simpler and more predictable.

4. Mobile menu: vanilla JS toggle with CSS transition

Decision: The hamburger button toggles aria-expanded on itself and a corresponding data-open attribute on the mobile menu panel. CSS handles the visual transition (translate or max-height). A <script> tag wires up the toggle.

<button id="menu-toggle" aria-expanded="false" aria-controls="mobile-menu">...</button>
<div id="mobile-menu" class="... -translate-y-full data-[open]:translate-y-0 transition-transform">
  ...
</div>

Rationale: aria-expanded is the correct ARIA pattern for disclosure widgets. The data-open attribute drives CSS state without needing to manage class lists. translate transitions are GPU-accelerated and smooth.

5. Nav content: nav key in en.json / nl.json

Decision: Add a top-level "nav" key to both content files:

"nav": {
  "links": [
    { "label": "Services", "href": "/#services" },
    { "label": "AI Launchpad", "href": "/ai-launchpad" },
    { "label": "About", "href": "/about" }
  ],
  "cta": "Connect",
  "langSwitch": { "en": "EN", "nl": "NL" }
}

NL links have the same hrefs with /nl prefix where needed; CTA becomes "Verbind".

Rationale: Consistent with the meta key established in step 002. Keeps all user-visible text out of component markup.

Note on NL link hrefs: The /#services anchor on the EN homepage becomes /nl#services in NL (no slash before #). The href values in nl.json must reflect this.

Risks / Trade-offs

  • Logo SVG inlining grows component size → The logo SVG is ~1.5KB. Acceptable for a component rendered on every page; Astro will inline it into each page's HTML at build time. If the logo is ever animated or reused frequently, extract to a dedicated Logo.astro component.
  • Scroll behavior flickers on page load → On slow connections the nav may briefly show transparent before JS runs. Mitigation: set data-scrolled="false" as the default HTML attribute so no flash occurs on initial render.
  • Mobile menu accessibilityaria-expanded and aria-controls are set, but focus trapping inside the open menu is not implemented in this step. Acceptable for now — full accessibility audit is a later concern.
  • NL href construction → The alternatePath logic strips the /nl prefix. If a page path ever starts with /nl for a reason other than locale (e.g. /nl-tech), it would be incorrectly treated as Dutch. This site has no such routes, so it's safe — but document the assumption.