From bca39dcca75ecdd454ebb03a19fe18028b1dcf10 Mon Sep 17 00:00:00 2001 From: Knacky Date: Sun, 21 Jun 2026 22:10:59 +0200 Subject: [PATCH] feat(frontend): add Tabs primitive + useHashTab hook - useHashTab: pure-TS hook reading window.location.hash, falls back to defaultId, hashchange listener cleaned up on unmount - Tabs: with tab-underline / tab-underline-active recipes and count-pill (rounded-pill exception per DESIGN.md) - index.css: tab-underline*, tab-count-pill*, alert-*, table-compact recipes Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/Tabs.tsx | 43 +++++++++++++++++++++++++++++++ frontend/src/hooks/useHashTab.ts | 25 ++++++++++++++++++ frontend/src/styles/index.css | 44 ++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 frontend/src/components/Tabs.tsx create mode 100644 frontend/src/hooks/useHashTab.ts diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx new file mode 100644 index 0000000..5f7a8be --- /dev/null +++ b/frontend/src/components/Tabs.tsx @@ -0,0 +1,43 @@ +interface TabItem { + id: string; + label: string; + count?: number; +} + +interface TabsProps { + items: TabItem[]; + activeId: string; + onChange: (id: string) => void; +} + +export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element { + return ( +
+ {items.map((item) => { + const isActive = item.id === activeId; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/hooks/useHashTab.ts b/frontend/src/hooks/useHashTab.ts new file mode 100644 index 0000000..e7330bd --- /dev/null +++ b/frontend/src/hooks/useHashTab.ts @@ -0,0 +1,25 @@ +import { useCallback, useEffect, useState } from 'react'; + +function readHash(defaultId: string): string { + const hash = window.location.hash.slice(1); // strip leading '#' + return hash || defaultId; +} + +export function useHashTab(defaultId: string): [string, (id: string) => void] { + const [activeId, setActiveId] = useState(() => readHash(defaultId)); + + useEffect(() => { + function onHashChange() { + setActiveId(readHash(defaultId)); + } + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, [defaultId]); + + const navigate = useCallback((id: string) => { + window.location.hash = id; + setActiveId(id); + }, []); + + return [activeId, navigate]; +} diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 14e3350..31d34d3 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -147,4 +147,48 @@ .tag-mitre { @apply inline-flex items-center bg-cloud text-ink border border-hairline rounded-none px-2 py-[2px] font-mono text-[12px] leading-[1.33]; } + + /* ─── Sub-page tabs (L1) ─────────────────────────────────────────────── */ + .tab-underline { + @apply text-graphite caption-bold cursor-pointer border-b-2 border-transparent hover:text-ink px-xs; + } + .tab-underline-active { + @apply text-primary border-primary; + } + /* Count pill — rounded-pill is the allowed exception per DESIGN.md */ + .tab-count-pill { + @apply inline-flex items-center rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono; + } + .tab-count-pill-active { + @apply bg-primary-soft text-primary; + } + + /* ─── Inline alert banners (L2) ─────────────────────────────────────── */ + .alert-error { + @apply bg-paper border border-hairline border-l-4 border-l-bloom-deep rounded-none px-md py-sm flex items-start gap-sm; + } + .alert-warn { + @apply bg-paper border border-hairline border-l-4 border-l-warn rounded-none px-md py-sm flex items-start gap-sm; + } + .alert-success { + @apply bg-paper border border-hairline border-l-4 border-l-success rounded-none px-md py-sm flex items-start gap-sm; + } + .alert-info { + @apply bg-paper border border-hairline border-l-4 border-l-primary rounded-none px-md py-sm flex items-start gap-sm; + } + + /* ─── Compact table density (L4) — 32px row height, global ──────────── */ + .table-compact thead tr { + @apply text-[11px] uppercase tracking-[0.5px] text-graphite; + } + .table-compact th { + @apply px-md py-xxs; + } + .table-compact td { + @apply px-md py-xxs caption-md; + } + .table-compact tbody tr { + @apply border-b border-hairline last:border-0; + min-height: 32px; + } }