feat(frontend): design system foundation (M6.2) - #66
Merged
Conversation
*.tsbuildinfo is TypeScript's incremental build cache, machine-local and different per build. Was accidentally tracked in an initial commit. Untrack the two existing files and add the pattern to .gitignore so future builds stop polluting git status.
Establish the single design language for the control-room UI. Dark-only for v1. - design-system/tokens.css: CSS vars for surface, text, accent, equipment status, per-agent identity colors, radii, typography, motion timing, with prefers-reduced-motion override. - design-system/motion.ts: framer-motion variants — fadeInUp, streamToken, artifactReveal, anomalyPulse, handoffSweep, drawerSlide. - Primitives: Button (default/accent/ghost/danger × sm/md/lg), Card (+ CardHeader/Title/Description), Badge (incl. per-agent variant mapping to --ds-agent-*), Drawer (left/right/bottom with Escape + overlay), Tabs (controlled + uncontrolled), StatusDot (nominal/ warning/critical/unknown + optional pulse), KbdKey. - icons.tsx: curated lucide-react re-export surface. - styles/index.css: @theme inline bridge exposing tokens as Tailwind v4 color utilities (bg-ds-bg-surface, text-ds-fg-primary, …). - index.html: enable dark class on <html>, preload Inter + JetBrains Mono from Google Fonts. - Add /design debug route rendering every primitive in each state, token palettes, typography samples, and motion replays.
There was a problem hiding this comment.
Pull request overview
Establishes a foundational, dark-only design system for the frontend control-room UI (tokens, primitives, motion helpers), plus a /design debug route to preview components and styles without introducing Storybook.
Changes:
- Adds ARIA design-system CSS tokens and bridges them into Tailwind v4 utilities via
@theme inline. - Introduces initial primitive components (Button/Card/Badge/Drawer/Tabs/StatusDot/KbdKey) and shared Framer Motion variants + curated Lucide icon re-exports.
- Adds
/designroute and updatesindex.htmlfor dark mode defaults and Google Fonts loading.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/styles/index.css | Imports design tokens and maps them to Tailwind v4 theme variables. |
| frontend/src/pages/DesignPage.tsx | New debug page rendering tokens/components/motion demos. |
| frontend/src/design-system/tokens.css | Adds --ds-* token set (surface/text/accent/status/agents/fonts/motion). |
| frontend/src/design-system/motion.ts | Adds shared Framer Motion Variants primitives. |
| frontend/src/design-system/index.ts | Barrel exports for design-system primitives/types/icons/motion. |
| frontend/src/design-system/icons.tsx | Curated lucide-react re-export surface. |
| frontend/src/design-system/Tabs.tsx | Adds a custom tabs primitive with ARIA roles. |
| frontend/src/design-system/StatusDot.tsx | Adds status indicator dot with optional pulse animation. |
| frontend/src/design-system/KbdKey.tsx | Adds keyboard key indicator primitive. |
| frontend/src/design-system/Drawer.tsx | Adds sliding drawer primitive (overlay optional, Escape to close). |
| frontend/src/design-system/Card.tsx | Adds Card primitive + header/title/description subcomponents. |
| frontend/src/design-system/Button.tsx | Adds Button primitive with variants/sizes/focus/disabled styling. |
| frontend/src/design-system/Badge.tsx | Adds Badge primitive including per-agent styling. |
| frontend/src/App.tsx | Registers /design route. |
| frontend/index.html | Forces dark mode, sets color-scheme, and loads Inter + JetBrains Mono via Google Fonts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+11
to
12
| <Route path="/design" element={<DesignPage />} /> | ||
| <Route |
Comment on lines
+103
to
+108
| if (active !== value) return null; | ||
| return ( | ||
| <div | ||
| role="tabpanel" | ||
| id={`${idBase}-panel-${value}`} | ||
| aria-labelledby={`${idBase}-trigger-${value}`} |
Comment on lines
+3
to
+46
| const easeOut: Transition["ease"] = [0.16, 1, 0.3, 1]; | ||
|
|
||
| export const fadeInUp: Variants = { | ||
| hidden: { opacity: 0, y: 8 }, | ||
| visible: { opacity: 1, y: 0, transition: { duration: 0.22, ease: easeOut } }, | ||
| }; | ||
|
|
||
| export const streamToken: Variants = { | ||
| hidden: { opacity: 0 }, | ||
| visible: { opacity: 1, transition: { duration: 0.08, ease: "linear" } }, | ||
| }; | ||
|
|
||
| export const artifactReveal: Variants = { | ||
| hidden: { opacity: 0, scale: 0.96, y: 6 }, | ||
| visible: { | ||
| opacity: 1, | ||
| scale: 1, | ||
| y: 0, | ||
| transition: { duration: 0.28, ease: easeOut }, | ||
| }, | ||
| }; | ||
|
|
||
| export const anomalyPulse: Variants = { | ||
| idle: { boxShadow: "0 0 0 0 rgba(239, 68, 68, 0.0)" }, | ||
| pulse: { | ||
| boxShadow: ["0 0 0 0 rgba(239, 68, 68, 0.55)", "0 0 0 12px rgba(239, 68, 68, 0)"], | ||
| transition: { duration: 1.4, repeat: Infinity, ease: "easeOut" }, | ||
| }, | ||
| }; | ||
|
|
||
| export const handoffSweep: Variants = { | ||
| hidden: { opacity: 0, x: -16 }, | ||
| visible: { | ||
| opacity: 1, | ||
| x: 0, | ||
| transition: { duration: 0.32, ease: easeOut }, | ||
| }, | ||
| exit: { opacity: 0, x: 16, transition: { duration: 0.2 } }, | ||
| }; | ||
|
|
||
| export const drawerSlide: Variants = { | ||
| hidden: { x: "100%" }, | ||
| visible: { x: 0, transition: { duration: 0.24, ease: easeOut } }, | ||
| exit: { x: "100%", transition: { duration: 0.2, ease: easeOut } }, |
Comment on lines
+28
to
+37
| <span | ||
| className={`inline-block relative align-middle ${className}`} | ||
| style={{ | ||
| width: size, | ||
| height: size, | ||
| ...style, | ||
| }} | ||
| role="status" | ||
| aria-label={`status: ${status}`} | ||
| {...rest} |
Comment on lines
+8
to
+13
| <title>ARIA — Adaptive Runtime Intelligence</title> | ||
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | ||
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | ||
| <link | ||
| href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" | ||
| rel="stylesheet" /> |
Comment on lines
+14
to
+20
| const variants: Record<Variant, string> = { | ||
| default: | ||
| "bg-[var(--ds-bg-elevated)] text-[var(--ds-fg-primary)] border border-[var(--ds-border)] hover:bg-[color-mix(in_oklab,var(--ds-bg-elevated),white_6%)] hover:border-[var(--ds-border-strong)]", | ||
| accent: "bg-[var(--ds-accent)] text-[var(--ds-accent-fg)] hover:bg-[var(--ds-accent-hover)]", | ||
| ghost: "bg-transparent text-[var(--ds-fg-muted)] hover:text-[var(--ds-fg-primary)] hover:bg-[var(--ds-bg-elevated)]", | ||
| danger: "bg-[var(--ds-status-critical)] text-white hover:bg-[color-mix(in_oklab,var(--ds-status-critical),white_10%)]", | ||
| }; |
Comment on lines
+275
to
+276
| function Section({ title, children }: { title: string; children: React.ReactNode }) { | ||
| return ( |
Comment on lines
+63
to
+90
| export function TabsTrigger({ | ||
| value, | ||
| className = "", | ||
| children, | ||
| }: { | ||
| value: string; | ||
| className?: string; | ||
| children: ReactNode; | ||
| }) { | ||
| const { value: active, setValue, idBase } = useTabs(); | ||
| const selected = active === value; | ||
| return ( | ||
| <button | ||
| type="button" | ||
| role="tab" | ||
| aria-selected={selected} | ||
| aria-controls={`${idBase}-panel-${value}`} | ||
| id={`${idBase}-trigger-${value}`} | ||
| onClick={() => setValue(value)} | ||
| className={`h-7 px-3 text-xs font-medium rounded-[var(--ds-radius-sm)] transition-colors duration-[var(--ds-motion-fast)] ${ | ||
| selected | ||
| ? "bg-[var(--ds-bg-surface)] text-[var(--ds-fg-primary)]" | ||
| : "text-[var(--ds-fg-muted)] hover:text-[var(--ds-fg-primary)]" | ||
| } ${className}`} | ||
| > | ||
| {children} | ||
| </button> | ||
| ); |
Comment on lines
+49
to
+55
| <style>{` | ||
| @keyframes ds-status-pulse { | ||
| 0% { transform: scale(1); opacity: 0.6; } | ||
| 100% { transform: scale(2.4); opacity: 0; } | ||
| } | ||
| `}</style> | ||
| </span> |
Comment on lines
+5
to
+86
| export interface DrawerProps { | ||
| open: boolean; | ||
| onClose: () => void; | ||
| side?: "left" | "right" | "bottom"; | ||
| width?: number | string; | ||
| height?: number | string; | ||
| className?: string; | ||
| children: ReactNode; | ||
| /** | ||
| * If true, an overlay is rendered that closes the drawer on click. | ||
| * Default true for bottom/left, false for right (chat drawer stays docked). | ||
| */ | ||
| overlay?: boolean; | ||
| } | ||
|
|
||
| export function Drawer({ | ||
| open, | ||
| onClose, | ||
| side = "right", | ||
| width = 420, | ||
| height = "40vh", | ||
| className = "", | ||
| children, | ||
| overlay, | ||
| }: DrawerProps) { | ||
| const showOverlay = overlay ?? side !== "right"; | ||
|
|
||
| useEffect(() => { | ||
| if (!open) return; | ||
| function onKey(e: KeyboardEvent) { | ||
| if (e.key === "Escape") onClose(); | ||
| } | ||
| window.addEventListener("keydown", onKey); | ||
| return () => window.removeEventListener("keydown", onKey); | ||
| }, [open, onClose]); | ||
|
|
||
| const position = { | ||
| left: "top-0 left-0 h-full", | ||
| right: "top-0 right-0 h-full", | ||
| bottom: "left-0 right-0 bottom-0", | ||
| }[side]; | ||
|
|
||
| const slideDir = { | ||
| left: { hidden: { x: "-100%" }, visible: { x: 0 }, exit: { x: "-100%" } }, | ||
| right: drawerSlide, | ||
| bottom: { hidden: { y: "100%" }, visible: { y: 0 }, exit: { y: "100%" } }, | ||
| }[side]; | ||
|
|
||
| const sizeStyle = side === "bottom" ? { height } : { width }; | ||
|
|
||
| return ( | ||
| <AnimatePresence> | ||
| {open && ( | ||
| <> | ||
| {showOverlay && ( | ||
| <motion.div | ||
| className="fixed inset-0 bg-black/40 z-40" | ||
| initial={{ opacity: 0 }} | ||
| animate={{ opacity: 1 }} | ||
| exit={{ opacity: 0 }} | ||
| transition={{ duration: 0.2 }} | ||
| onClick={onClose} | ||
| aria-hidden | ||
| /> | ||
| )} | ||
| <motion.aside | ||
| className={`fixed ${position} z-50 bg-[var(--ds-bg-surface)] border-[var(--ds-border)] shadow-2xl ${ | ||
| side === "right" | ||
| ? "border-l" | ||
| : side === "left" | ||
| ? "border-r" | ||
| : "border-t" | ||
| } ${className}`} | ||
| style={sizeStyle} | ||
| variants={slideDir} | ||
| initial="hidden" | ||
| animate="visible" | ||
| exit="exit" | ||
| transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }} | ||
| role="dialog" | ||
| aria-modal={showOverlay ? true : undefined} | ||
| > |
…rain
Second-pass polish on the design system to shed the "first-draft AI" tells
before the app shell lands on top of it.
Tokens (tokens.css):
- Accent: #00d4ff (neon) → #3ab5c9 (desaturated cyan, SCADA feel).
Hover creuses toward #5fd0e3 so the interaction is actually visible.
- Critical: #ef4444 → #d84545 (brick red, less "demo alert").
- Nominal / warning also pulled toward deeper earthy tones.
- Agent identities washed down so the 5 colors whisper instead of shout
next to each other on a dense timeline.
- bg-surface / bg-elevated steps widened (#0d131d / #1a2435) so elevation
reads clearly.
- Radii tightened by 1-2px across the board for a more technical feel.
- Type scale added (xs → 2xl) so downstream components can stop picking
px literals.
Signature:
- Body fractalNoise overlay (~3.5% opacity, overlay blend-mode, pure SVG
data-uri, zero lib) — subtle film grain that makes dark surfaces feel
like matter, not flat CSS. Respects prefers-reduced-motion.
- New AriaMark component: minimal triangle "A" bisected by a telemetry
pulse. Replaces the generic Sparkles icon in the design page header
and becomes the reusable brand mark for the app shell topbar (M6.3).
Badge (Badge.tsx):
- rounded-full → rounded-[var(--ds-radius-xs)] (rectangles, not chat pills)
so tags read as industrial status labels instead of social-media bubbles.
- New `tag` prop enables uppercase + mono + letter-spacing for SCADA-style
status tags ("MONITORED", "ALARM", "RUNNING").
DesignPage:
- Header uses AriaMark (not Sparkles).
- Brand mark section shows the logo at 16/24/40/64.
- Badge section gains a row of tag-variant status labels.
Hybrid visual law for ARIA, synthesized from the modern-design skill
(editorial rigor) and the industrial-brutalist-ui skill (tactical
telemetry), adapted for a data-dense control-room app — not a landing.
12 sections:
1. Direction artistique (Editorial Industrial Telemetry)
2. Palette locked (dark substrate, steel cyan accent)
3. Typography (Inter + JetBrains Mono, scale capped, tracking rules)
4. Grid & spatial architecture (compartments, radius discipline)
5. Six micro-pattern signatures (brackets, registration marks,
hairlines, status rail, scoped scanlines, AriaMark)
6. Motion language (narrow variant vocabulary, banned patterns)
7. Iconography (stroke-width 1.5, no decorative sparkles)
8. Component translations per existing primitive
9. Anti-patterns list — armed for review (modern-design landing
patterns rejected, brutalist absolute-zero-radius softened,
global AI-slop guardrails)
10. Deps delta (nothing mandatory to install)
11. Rollout plan (what lands in this PR vs later milestones)
12. Validation checklist for Adam
Implements the "Immediately" rollout from docs/DESIGN_PLAN.md: the app now has its visual language, not just a clean dark theme. Icons (icons.tsx): - All lucide re-exports wrapped to default stroke-width 1.5 (matches the 1px hairline grid discipline). Callers can still override per-instance. 4 new primitives (DESIGN_PLAN §5, §8): - SectionHeader — mono uppercase +0.08em tracking micro-label with optional [ brackets ], optional marker prefix, optional right-aligned metadata slot. The single most recognizable visual pattern across the app, applied everywhere a section/panel needs a name. - MetaStrip — right-aligned registration-mark metadata strip rendering as `LABEL / value · LABEL / value`. Never legal text, always real data (site id, cell id, commit sha). Structural signal that "this is a real system". - Hairline — 1px (or 2px) decorative divider, optional inline label (`─── control room ───`). Editorial rule between sections. - StatusRail — 2px colored left-edge rail for cards / rows. Silent on nominal/idle, speaks on warning/critical (optional pulse). Vocabulary: status tones + agent tones + accent + idle. Card: new `rail` prop composes StatusRail on the left edge. Card title tightened to 16px (-0.01em tracking) to match the type scale in §3. Tabs: selected trigger visual swapped from filled surface pill to a 2px bottom rail in accent color. Triggers are now mono uppercase at +0.08em tracking for SCADA feel. The list itself is a 1px bottom border — Tabs *are* the compartment line. DesignPage: full rewrite as a live application of the plan, not a component dump. Uses real SectionHeaders with § markers, MetaStrip with plausible REV/UNIT/BUILD values, Hairlines between zones, Cards with nominal + critical status rails, Tabs in the new visual, typography samples at the real scale stops. Serves double duty: primitive showcase + reference implementation of the §5 signatures. Zero new deps. Inter + JetBrains Mono from Google Fonts suffice for v1.
Ensures any future Claude agent session reading this repo picks up the full project contract without re-deriving it from chat history: - Team lanes (vgtray frontend / zestones backend), deadline, stack. - Mandatory reading order before acting (ROADMAP → idea.md → relevant milestone issues → DESIGN_PLAN → live GitHub state → prior lesson on scope-creep). - Workflow rules (branch naming, conventional commits English, no Co-Authored-By: Claude, issue = branch = PR, user merges). - Docker volume quirk: deps changes require "docker compose exec frontend npm install" after pull. - Board automation IDs (project v2 #28 field + option IDs) so chef can move issues In progress / In review / Done via GraphQL. - Quality gates required before any PR. - Design discipline: DESIGN_PLAN.md §9 anti-patterns enforced. - Interface contract pointer (WS event schema frozen in M4.1). - Hard no-goes: never touch backend lane, never merge, never force-push, never invent tokens, never add deps without §10 justification. Drop-in for every agent that spawns in this repo.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Establishes the single design language for the control-room UI. Dark-only for v1 per M6.2 scope.
Stacked on top of #64 (M6.1 deps) — will retarget
mainautomatically once #64 merges.Tokens —
design-system/tokens.cssAll defined under
--ds-*namespace so they never collide with the existing shadcn-style--background/--foregroundfromstyles/dark.css:--ds-bg-base,bg-surface,bg-elevated,border,border-strong--ds-fg-primary,fg-muted,fg-subtle--ds-accent(#00d4ff),accent-hover,accent-glow--ds-status-{nominal,warning,critical}--ds-agent-{sentinel,investigator,kb-builder,work-order,qa}@media (prefers-reduced-motion: reduce)overrides motion durations to 0Motion primitives —
design-system/motion.tsfadeInUp,streamToken,artifactReveal,anomalyPulse,handoffSweep,drawerSlideas typed framer-motionVariants. Shared easingcubic-bezier(0.16, 1, 0.3, 1).Primitives (7)
ButtonCard(+ Header/Title/Description)Badge--ds-agent-*perAgentId)DrawerTabs(+ List/Trigger/Content)StatusDotKbdKey--ds-radius-xs)Icons —
design-system/icons.tsxCurated
lucide-reactre-export surface (Activity, AlertTriangle, Bot, Sparkles, Wrench, etc.).Tailwind v4 bridge —
styles/index.css@theme inlineblock exposes every token as a Tailwind color/font utility (bg-ds-bg-surface,text-ds-fg-primary,font-mono, …) so both CSS vars and utility classes work interchangeably.Dark mode + fonts —
index.html<html class="dark">+color-scheme: darkdisplay=swap)Debug route
/design—src/pages/DesignPage.tsxRenders every primitive, token palette, typography samples, and motion replays in one page. No Storybook, no lib dep.
Test plan
npm run typecheck— greennpm run build— green (CSS 29 kB / JS 418 kB gzip 133 kB)npm run check(Biome lint + format) — green/designroute renders every primitive in all states (manual smoke)prefers-reduced-motion(durations drop to 0)Closes #35