Files
homepage_relaunch/src/components/Header.astro
T
2026-10-04 22:43:25 +02:00

279 lines
11 KiB
Plaintext

---
import Wordmark from './Wordmark.astro';
import { localePath, t, type Lang } from '../i18n/ui';
interface Props { lang: Lang; tone?: 'paper' | 'night' }
const { lang, tone = 'paper' } = Astro.props;
const ui = t(lang);
const home = localePath(lang, '/');
const items = [
{ href: `${home}#capabilities`, label: ui.nav.capabilities },
{ href: localePath(lang, '/cipherverifier'), label: ui.nav.product },
{ href: localePath(lang, '/insights'), label: ui.nav.insights },
{ href: localePath(lang, '/team'), label: ui.nav.team },
];
const contactHref = `${home}#contact`;
const path = Astro.url.pathname.replace(/\/$/, '') || '/';
const isCurrent = (href: string) => href.split('#')[0] === path && !href.includes('#');
---
<header class="header" data-header data-tone={tone} data-tone-init>
<div class="container header__shell">
<div class="header__bar hud-frame">
<a class="header__home" href={home} aria-label={ui.nav.homeLabel}>
<Wordmark inline class="header__logo header__logo--paper" />
<Wordmark inline light class="header__logo header__logo--night" />
</a>
<nav class="header__nav hud-frame" aria-label="Primary" id="primary-nav">
<ul class="header__list" role="list">
{items.map((item) => {
const current = isCurrent(item.href);
return (
<li><a class:list={[{ 'hud-corners': current }]} href={item.href} aria-current={current ? 'page' : undefined}>{item.label}</a></li>
);
})}
<li class="header__contact-mobile"><a href={contactHref}>{ui.nav.contact}</a></li>
</ul>
</nav>
<a class="header__contact" href={contactHref}>{ui.nav.contact}</a>
<button class="header__toggle" type="button" aria-expanded="false" aria-controls="primary-nav" data-nav-toggle>
<span data-label-open>{ui.nav.menu}</span>
<span data-label-close hidden>{ui.nav.close}</span>
</button>
</div>
</div>
</header>
<style>
/* The header is a transparent sticky strip; the bar floats inside it with room on every side
for the corner crosses. Only the bar takes pointer events, so the margin never blocks clicks. */
.header {
position: sticky;
top: 0;
z-index: 20;
pointer-events: none;
}
.header__shell {
padding-block: var(--space-3);
transition:
max-width var(--dur-slow) var(--ease),
padding var(--dur-slow) var(--ease);
}
/* Once the page scrolls, the bar docks: full width, flush to the top, and as tall as the header
so nothing below moves. Its outer crosses would sit off-screen (and the side ones would make
the page scroll sideways), so they go, with the top and side borders; the bottom line stays. */
.header[data-scrolled] .header__shell {
max-width: 100%;
padding: 0;
}
.header[data-scrolled] .header__bar {
min-height: var(--header-height);
border-width: 0 0 var(--line);
}
.header[data-scrolled] .header__bar::after { content: none; }
.header__bar {
pointer-events: auto;
isolation: isolate;
display: flex;
align-items: stretch;
min-height: calc(var(--header-height) - 2 * var(--space-3));
color: var(--ink-base);
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
line-height: 1;
letter-spacing: var(--track-caps);
text-transform: uppercase;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease),
min-height var(--dur-slow) var(--ease);
}
/* The glass sits on a pseudo-element rather than the bar itself: a backdrop-filter on the bar
would make it a backdrop root, and the mobile panel inside it could then blur nothing. */
.header__bar::before,
.header__nav[data-open]::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: var(--glass-solid);
transition: background-color var(--dur) var(--ease);
}
/* Until the script has settled the tone on load, it changes without a fade: the tone is a fact
about where the page is, not a change the visitor made. */
.header[data-tone-init],
.header[data-tone-init] *,
.header[data-tone-init] ::before,
.header[data-tone-init] ::after { transition: none !important; }
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.header__bar::before,
.header__nav[data-open]::before {
background: var(--glass);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
}
}
/* Over a night section the bar takes the night tone. */
.header[data-tone='night'] { --hud-line: var(--hud-line-on-night); }
.header[data-tone='night'] .header__bar { color: var(--ink-on-night); }
.header[data-tone='night'] .header__bar::before,
.header[data-tone='night'] .header__nav[data-open]::before { background: var(--glass-solid-on-night); }
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.header[data-tone='night'] .header__bar::before,
.header[data-tone='night'] .header__nav[data-open]::before { background: var(--glass-on-night); }
}
.header__home {
display: flex;
align-items: center;
padding-inline: var(--space-4);
text-decoration: none;
}
.header__home :global(.header__logo--night),
.header[data-tone='night'] .header__home :global(.header__logo--paper) { display: none; }
.header[data-tone='night'] .header__home :global(.header__logo--night) { display: block; }
/* Cells: every item is a full-height cell, divided from its neighbour by one frame line. */
.header__nav { margin-left: auto; display: flex; }
/* On desktop the nav is just a run of cells inside the bar: no frame of its own. */
.header__nav.hud-frame { border: 0; }
.header__nav.hud-frame::after { content: none; }
.header__list { display: flex; }
.header__list li { display: flex; border-left: var(--line) solid var(--frame-color); }
.header__list a,
.header__contact,
.header__toggle {
display: flex;
align-items: center;
padding-inline: var(--space-4);
text-decoration: none;
transition: background-color var(--dur-fast) var(--ease);
}
.header__list a:hover,
.header__toggle:hover { background: color-mix(in srgb, currentColor var(--tint-hover), transparent); }
.header__list a[aria-current='page'] { background: color-mix(in srgb, currentColor var(--tint-active), transparent); }
.header__contact {
border-left: var(--line) solid var(--frame-color);
background: var(--ink-base);
color: var(--paper);
}
.header__contact:hover { background: var(--ink-muted-base); }
.header[data-tone='night'] .header__contact { background: var(--paper); color: var(--night); }
.header[data-tone='night'] .header__contact:hover { background: var(--paper-deep); }
.header__list .header__contact-mobile { display: none; }
.header__toggle {
display: none;
border-left: var(--line) solid var(--frame-color);
text-transform: inherit;
letter-spacing: inherit;
}
/* Focus ring drawn inside the cell, so it never collides with the frame lines. */
.header__bar :focus-visible {
outline-offset: calc(-1 * var(--space-1));
border-radius: 0;
}
/* A panel left open across a resize to desktop must not paint a second glass layer. */
@media (min-width: 48rem) {
.header__nav[data-open]::before { content: none; }
}
@media (max-width: 47.99rem) {
.header__contact { display: none; }
.header__list .header__contact-mobile { display: flex; }
.header__toggle { display: flex; margin-left: auto; }
.header__nav {
position: absolute;
top: calc(100% + var(--space-3));
left: calc(-1 * var(--line));
right: calc(-1 * var(--line));
display: none;
}
/* As the mobile panel it is a full .hud-frame: border and corner crosses. */
.header__nav.hud-frame { border: var(--line) solid var(--frame-color); }
.header__nav.hud-frame::after { content: ''; }
.header__nav[data-open] { display: block; }
/* Docked: the panel hangs straight off the bar's bottom line, edge to edge. */
.header[data-scrolled] .header__nav {
top: calc(100% + var(--line));
border-width: 0 0 var(--line);
}
.header[data-scrolled] .header__nav.hud-frame::after { content: none; }
.header__list { flex-direction: column; }
.header__list li { border-left: 0; }
.header__list li + li { border-top: var(--line) solid var(--frame-color); }
.header__list a {
flex: 1;
padding-block: var(--space-4);
font-size: var(--step-small);
}
}
</style>
<script>
const header = document.querySelector<HTMLElement>('[data-header]');
const toggle = header?.querySelector<HTMLButtonElement>('[data-nav-toggle]');
const nav = header?.querySelector<HTMLElement>('#primary-nav');
if (header && toggle && nav) {
const open = toggle.querySelector<HTMLElement>('[data-label-open]')!;
const close = toggle.querySelector<HTMLElement>('[data-label-close]')!;
const set = (isOpen: boolean) => {
toggle.setAttribute('aria-expanded', String(isOpen));
if (isOpen) nav.setAttribute('data-open', ''); else nav.removeAttribute('data-open');
open.hidden = isOpen;
close.hidden = !isOpen;
};
toggle.addEventListener('click', () => set(toggle.getAttribute('aria-expanded') !== 'true'));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') set(false); });
nav.addEventListener('click', (e) => { if ((e.target as HTMLElement).closest('a')) set(false); });
}
/* Scrolled: a one-pixel marker at the very top of the document; once it leaves the viewport
the page has scrolled. An IntersectionObserver, so nothing runs on scroll. */
if (header && 'IntersectionObserver' in window) {
const marker = document.createElement('div');
marker.setAttribute('aria-hidden', 'true');
marker.style.cssText = 'position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none;';
document.body.prepend(marker);
new IntersectionObserver(([entry]) => {
header.toggleAttribute('data-scrolled', !entry.isIntersecting);
}).observe(marker);
}
/* Tone: watch a one-pixel line through the middle of the bar and switch to night while a
.night section crosses it. An IntersectionObserver, so nothing runs on scroll. */
const sections = document.querySelectorAll<HTMLElement>('.night');
// Transitions come back once the first tone is drawn (two frames, so it has been painted).
const settle = () => requestAnimationFrame(() => requestAnimationFrame(() => header?.removeAttribute('data-tone-init')));
if (header && sections.length && 'IntersectionObserver' in window) {
const under = new Set<Element>();
let observer: IntersectionObserver | undefined;
const watch = () => {
observer?.disconnect();
under.clear();
const mid = Math.round(header.offsetHeight / 2);
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) under.add(entry.target); else under.delete(entry.target);
}
header.dataset.tone = under.size ? 'night' : 'paper';
if (header.hasAttribute('data-tone-init')) settle();
}, { rootMargin: `-${mid}px 0px -${window.innerHeight - mid - 1}px 0px` });
sections.forEach((section) => observer!.observe(section));
};
watch();
let resize: number | undefined;
window.addEventListener('resize', () => {
window.clearTimeout(resize);
resize = window.setTimeout(watch, 150);
});
} else {
settle();
}
</script>