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;
+ }
}