CI (SIAX Cloud) / integration (push) Skipped
masterplan-lock / check (push) Successful in 7s
Security Scan (SIAX Cloud) / security (push) Successful in 20s
CI (SIAX Cloud) / security (push) Successful in 47s
masterplan-lock / check (pull_request) Successful in 10s
CI (SIAX Cloud) / quality (push) Successful in 1m11s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 32s
CI (SIAX Cloud) / security (pull_request) Successful in 42s
CI (SIAX Cloud) / integration (pull_request) Successful in 56s
CI (SIAX Cloud) / quality (pull_request) Successful in 59s
axe-core 4.10.2 mot 0pt.app 2026-09-26: en enda regel, color-contrast, impact=serious, 82 noder. Estatets största enskilda a11y-defekt. Alla 82 har samma form: en Tailwind-alfamodifierare på en textfärg. Uppmätt WCAG 2.2-kontrast mot bägge temabakgrunderna i styles/globals.css: text-muted-foreground 4.73:1 ljust 7.63:1 mörkt GODKÄNT text-muted-foreground/70 2.71:1 4.20:1 faller text-muted-foreground/60 2.29:1 3.37:1 faller text-muted-foreground/50 1.96:1 2.67:1 faller text-muted-foreground/40 1.69:1 2.11:1 faller text-foreground/20 1.57:1 1.74:1 faller Den omodifierade token klarar AA i bägge teman, så åtgärden är helt enkelt att aldrig tona den. Elva textställen bytte till text-muted-foreground. Kopieringsknappen i app/portal går från 1.57:1 till 4.73:1 i viloläge och 19.79:1 vid hover — den föll även 1.4.11 för icke-textkomponenter. Toastens stängknapp klarade redan 1.4.11 (3.71:1) men blir 4.73:1. Kvar med alfa är endast den dekorativa masthead-siffran i app/bestall, som redan bär aria-hidden + select-none + pointer-events-none. Den är undantagen både av WCAG och av axe, och guarden nedan tillåter den på just den grunden. scripts/ci/check-text-contrast.mjs är en blockerande källkodsgrind i quality-jobbet. Den är avsiktligt inte en axe-körning: den fullständiga axe-jobb som UI-09 också begär kräver build + serve i CI och spåras separat. Tills det finns stoppar den här grinden regressionen vid den form som orsakade defekten. Refs: UI-09 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
5.9 KiB
TypeScript
144 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useEffect, useState } from "react";
|
|
import { useTheme } from "next-themes";
|
|
import { usePathname } from "next/navigation";
|
|
import { useT } from "@/components/locale-provider";
|
|
import { LocaleSwitcher } from "@/components/locale-switcher";
|
|
|
|
const NAV: { label: { en: string; sv: string }; href: string }[] = [
|
|
{ label: { en: "Services", sv: "Tjänster" }, href: "/tjanster" },
|
|
{ label: { en: "Catalog", sv: "Katalog" }, href: "/katalog" },
|
|
{ label: { en: "How it works", sv: "Så funkar det" }, href: "/#motorn" },
|
|
{ label: { en: "Sources", sv: "Källor" }, href: "/kallor" },
|
|
{ label: { en: "Pricing", sv: "Priser" }, href: "/#priser" },
|
|
];
|
|
|
|
function Wordmark() {
|
|
return (
|
|
<Link href="/" className="group flex items-baseline gap-2">
|
|
<span className="text-xl font-semibold tracking-[-0.04em]">0pt</span>
|
|
<span
|
|
className="inline-block h-[5px] w-[5px] translate-y-[-2px] transition-transform duration-300 group-hover:translate-y-[-4px]"
|
|
style={{ backgroundColor: "var(--accent-red)" }}
|
|
/>
|
|
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-muted-foreground">your data</span>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function ThemeToggle() {
|
|
const { resolvedTheme, setTheme } = useTheme();
|
|
const [mounted, setMounted] = useState(false);
|
|
useEffect(() => setMounted(true), []);
|
|
if (!mounted) return <span className="h-9 w-9" />;
|
|
const dark = resolvedTheme === "dark";
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
|
|
onClick={() => setTheme(dark ? "light" : "dark")}
|
|
className="flex h-9 w-9 items-center justify-center border border-border font-mono text-[11px] text-muted-foreground transition-colors hover:border-foreground hover:text-foreground"
|
|
>
|
|
{dark ? "○" : "●"}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function Header() {
|
|
const t = useT();
|
|
const pathname = usePathname();
|
|
const [scrolled, setScrolled] = useState(false);
|
|
const [open, setOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const onScroll = () => setScrolled(window.scrollY > 40);
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
onScroll();
|
|
return () => window.removeEventListener("scroll", onScroll);
|
|
}, []);
|
|
|
|
return (
|
|
<header
|
|
className="sticky top-0 z-50 border-b border-border transition-colors"
|
|
style={{
|
|
backgroundColor: scrolled ? "color-mix(in srgb, var(--background) 82%, transparent)" : "var(--background)",
|
|
backdropFilter: scrolled ? "blur(8px)" : "none",
|
|
}}
|
|
>
|
|
<div className="mx-auto flex h-16 max-w-[80rem] items-center justify-between px-6">
|
|
<Wordmark />
|
|
|
|
<nav className="hidden items-center md:flex">
|
|
{NAV.map((n) => {
|
|
const active = !n.href.includes("#") && pathname === n.href;
|
|
return (
|
|
<Link
|
|
key={n.href}
|
|
href={n.href}
|
|
aria-current={active ? "page" : undefined}
|
|
className="group relative px-4 py-2 font-mono text-[12px] uppercase tracking-[0.12em] text-muted-foreground transition-colors hover:text-foreground"
|
|
style={{ color: active ? "var(--foreground)" : undefined }}
|
|
>
|
|
{t(n.label)}
|
|
<span aria-hidden className="pointer-events-none absolute bottom-1 left-4 right-4 h-px origin-left bg-foreground transition-transform duration-300 group-hover:scale-x-100" style={{ transform: active ? "scaleX(1)" : "scaleX(0)", transformOrigin: "left" }} />
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
<div className="hidden items-center gap-4 md:flex">
|
|
<ThemeToggle />
|
|
<LocaleSwitcher />
|
|
<Link href="/portal" className="px-3 py-2 font-mono text-[12px] uppercase tracking-[0.12em] text-muted-foreground transition-colors hover:text-foreground">
|
|
{t({ en: "Sign in", sv: "Logga in" })}
|
|
</Link>
|
|
<Link
|
|
href="/bestall"
|
|
className="inline-flex items-center gap-2 bg-foreground px-5 py-2.5 font-mono text-[12px] uppercase tracking-[0.12em] text-background transition-colors hover:bg-[var(--accent-red)]"
|
|
>
|
|
{t({ en: "Order", sv: "Beställ" })}
|
|
<span aria-hidden>→</span>
|
|
</Link>
|
|
</div>
|
|
|
|
<button
|
|
aria-label="Menu"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="flex h-9 w-9 items-center justify-center border border-border transition-colors hover:border-foreground md:hidden"
|
|
>
|
|
<span className="font-mono text-sm">{open ? "✕" : "≡"}</span>
|
|
</button>
|
|
</div>
|
|
|
|
{open ? (
|
|
<div className="border-t border-border bg-background md:hidden">
|
|
<div className="flex flex-col px-6 py-2">
|
|
{NAV.map((n, i) => (
|
|
<Link
|
|
key={n.href}
|
|
href={n.href}
|
|
onClick={() => setOpen(false)}
|
|
className="flex items-baseline justify-between border-b border-border py-4 font-mono text-[12px] uppercase tracking-[0.12em] text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<span>{t(n.label)}</span>
|
|
<span className="text-[10px] tabular-nums text-muted-foreground">{String(i + 1).padStart(2, "0")}</span>
|
|
</Link>
|
|
))}
|
|
<div className="flex items-center justify-between py-4">
|
|
<div className="flex items-center gap-3">
|
|
<ThemeToggle />
|
|
<LocaleSwitcher />
|
|
</div>
|
|
<Link href="/bestall" onClick={() => setOpen(false)} className="bg-foreground px-5 py-3 font-mono text-[12px] uppercase tracking-[0.12em] text-background">
|
|
{t({ en: "Order", sv: "Beställ" })} →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</header>
|
|
);
|
|
}
|