feat(design): terminal-SOC aesthetic refresh (sprint 7) #10

Merged
knacky merged 12 commits from sprint/7-design into main 2026-06-10 16:40:20 +00:00
34 changed files with 639 additions and 773 deletions

View File

@@ -11,10 +11,22 @@ You are the **Frontend Builder** for the Mimic project (BAS WebUI based on MITRE
Read these files first, in order: Read these files first, in order:
1. `SPEC.md` — global spec and technical decisions. 1. `SPEC.md` — global spec and technical decisions.
2. `DESIGN.md` — UI design system. **Mandatory** — every component you build must follow it. 2. `DESIGN.md` — UI design system. **Mandatory** — every component you build must follow it (tokens, slab, btn-outline, etc.).
3. The **backend-builder's summary** for the current sprint (in `tasks/todo.md` or the latest team-lead dispatch). This is your API contract. 3. The **backend-builder's summary** for the current sprint (in `tasks/todo.md` or the latest team-lead dispatch). This is your API contract.
4. `tasks/lessons.md` — past mistakes to avoid. 4. `tasks/lessons.md` — past mistakes to avoid.
## Mandatory skill — `frontend-design`
Before creating or modifying **any visible UI component** (new page, new component, layout change, state additions like loading/error/empty), you MUST invoke the `frontend-design` skill once at the start of the sprint via:
```
Skill({ skill: "frontend-design" })
```
`DESIGN.md` rules the **project-specific** tokens and motifs (slab, btn-outline, palette, BAS layout patterns). `frontend-design` adds the **universal** principles `DESIGN.md` doesn't restate: typographic hierarchy, alignment grid, contrast ratios, focus states, density rhythm, motion restraint. The two are complementary — `DESIGN.md` wins on tokens/component shape, `frontend-design` wins on visual craft.
Exception: pure logic/data-layer work with no visible UI change (hook refactor, query key rename, internal type tightening) — skip the skill, note it in your summary.
## What you build ## What you build
- React components under `frontend/src/components/` - React components under `frontend/src/components/`

View File

@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased] ## [Unreleased]
### Changed — Sprint 7 (Terminal-SOC design refresh)
**Frontend** (136 vitest passing — unchanged count, 3 assertions updated for new token names)
- `DESIGN.md` — complete rewrite from HP-catalog (346 lines) to terminal-SOC brutalist spec. Covers palette (success/warn tokens added), typography (Inter + JetBrains Mono), layout, shapes (border-radius 0 rule), component patterns, and Do/Don't list.
- `frontend/tailwind.config.ts` — added `success` / `warn` semantic color tokens (WCAG AA in both light+dark); added `fontFamily.mono` (JetBrains Mono Variable); reduced all `display-*` scale (xxl 72→40, xl 56→32, lg 44→28, md 32→24, sm 24→20, xs 20→16); `borderRadius` reduced to `none: '0px'` + `pill: '9999px'` only; `section` spacing 40px→48px.
- `frontend/src/styles/index.css` — CSS vars for `--color-success[-soft]` / `--color-warn[-soft]` (light + dark variants); all `.btn-*` classes: `rounded-none`, no `transition-colors`; `.text-input`: `rounded-none`, no transition; `.card-product`: `rounded-none`, `border border-hairline`, no shadow; `.badge-pill-*` kept `rounded-pill`; added `.tag-mitre` (angular MITRE tags, `font-mono`).
- `frontend/src/styles/fonts.css` — added `@import '@fontsource-variable/jetbrains-mono/index.css'` (local, no CDN).
- `frontend/src/components/StatusBadge.tsx``rounded-lg``rounded-pill`; new semantic colors: `planned → warn-soft`, `active → primary-soft`, `closed → cloud/graphite`.
- `frontend/src/components/SimulationStatusBadge.tsx``rounded-lg``rounded-pill`; semantic mapping: `pending → cloud`, `in_progress → primary-soft`, `review_required → warn-soft`, `done → success-soft`.
- `frontend/src/components/Toast.tsx` — removed `rounded-xl` and shadow; left border strips: `error → border-l-bloom-deep`, `success → border-l-success`, default → `border-l-primary`.
- `frontend/src/components/MitreTechniqueTag.tsx``rounded-full``rounded-none` on both technique and tactic tags; added `font-mono` (MITRE IDs are data).
- `frontend/src/components/ExportEngagementButton.tsx` — removed `rounded-r-none` / `rounded-l-none` from split-button; dropdown: `rounded-md shadow-floating``rounded-none`.
- `frontend/src/components/SimulationList.tsx` — dropdown: `rounded-md shadow-floating``rounded-none`; MITRE column + `executed_at`: added `font-mono`; split-button: removed radius classes.
- `frontend/src/pages/SimulationFormPage.tsx` — h1: `text-[44px]``text-[32px]`; Done/SOC banners: `rounded-xl``rounded-none`.
- `frontend/src/pages/UsersAdminPage.tsx` — h1: `text-[44px]``text-[32px]`; username column + created_at column: added `font-mono`.
- `frontend/tests/SimulationStatusBadge.test.tsx` — updated 3 assertions for renamed semantic tokens (`bg-fog``bg-cloud`, `bg-bloom-coral``bg-warn-soft`, `bg-storm-deep``bg-success-soft`).
**No backend changes.** No DB schema change. No migration.
### Added — Sprint 6 (Engagement export) ### Added — Sprint 6 (Engagement export)
**Backend** (253 pytest passing — 226 sprint-1-to-4 + 28 sprint 5 + 5 sprint 5 post-code-review + 23 sprint 6 + 1 CSV-injection defense-in-depth test) **Backend** (253 pytest passing — 226 sprint-1-to-4 + 28 sprint 5 + 5 sprint 5 post-code-review + 23 sprint 6 + 1 CSV-injection defense-in-depth test)

438
DESIGN.md
View File

@@ -1,345 +1,263 @@
## Overview ## Overview
HP reads like a long-running consumer-electronics catalog crossed with an enterprise-software product page. The whole system sits on **pure white** (`{colors.canvas}``#ffffff`) with thin gray panels (`{colors.cloud}` / `{colors.fog}`) for alternating section bands. There is one chromatic action color — **HP Electric Blue** (`{colors.primary}``#024ad8`) — and one ink color (`{colors.ink}``#1a1a1a`); together they do ninety percent of the work. Type is a single family across every surface: **Forma DJR Micro**, HP's bespoke geometric grotesque, set at weight 500 for headlines and 400 for body — clean, neutral, slightly mechanical. Mimic is a **BAS (Breach and Attack Simulation) purple-team console** — not a product catalog, not a marketing page. The aesthetic is **Bloomberg Terminal / SOC dashboard**: dense, angular, semantic-color-driven, zero ornament. Every surface decision reinforces operational trust: data is primary, chrome is invisible.
The signature gesture is **angular blue chevrons** — sharp 0-radius slashes derived from the HP wordmark's pair of parallel slashes — that anchor the homepage hero, the laptop-page hero, and the printer pricing page. They appear on the left and right edges of the primary banner card, layered behind product photography. Outside those decorative slashes, every other surface is rectilinear with **soft 816px corners** on cards and a 4px corner on buttons. The system sits on a **pure-white canvas** (light) / **dark slab** (dark) with one chromatic action color — **Electric Blue** (`{colors.primary}``#024ad8`) — and semantic status signals (`success`, `warn`, `bloom-deep`). Inter handles body/headers/labels. JetBrains Mono carries data: IDs, ISO dates, commands, execution output, MITRE technique codes, metrics — anything that must be read at a glance without typographic distraction.
The system breaks into three voice modes: a **white commercial body** for product browsing (cards, category icons, pricing tiers); a **dark navy slab** (`{colors.ink}` near-black) for testimonial bands, the closing "How can we help?" footer-prelude, and the page footer; and a **light fog band** (`{colors.cloud}` / `{colors.fog}`) for utility sections like comparison strips and FAQ accordions. The blue accent appears only on filled CTAs, link text, the chevron decorations, and the active price-stamp on a featured tier — never as a section background. **No rounded corners on containers.** No shadows on interactive surfaces. No transitions. Hover is instantaneous. Focus rings are sharp. This is a tool, not a storefront.
**Key Characteristics:** **Key Characteristics:**
- Pure white canvas (`{colors.canvas}`) with deep ink (`{colors.ink}`) running every body surface; light fog bands (`{colors.cloud}`, `{colors.fog}`) alternate for section rhythm - Angular surfaces everywhere (`border-radius: 0`) — exception: status pills (`rounded-pill`) and avatars
- HP Electric Blue (`{colors.primary}`) is the lone CTA fill and link color; it appears at most twice per viewport - Zero transitions / zero animations — state changes are immediate
- Bespoke Forma DJR Micro across every surface — display, body, button, caption — at weights 400 / 500 / 600 / 700 - Semantic color signals: primary blue = action, success green = confirmed, warn amber = pending/caution, bloom-deep = destructive/error
- Cards round at `{rounded.xl}` (16px) for product/pricing tiles; buttons sit at `{rounded.md}` (4px) with capitalize labels - Monospace data discipline: `font-mono` ONLY for IDs, dates, codes, output, metrics — never for headers, labels, prose
- Geometric blue chevrons (`{colors.primary}` rectangles cut at 45°) frame hero photography and reinforce the wordmark - Dense spacing: section gap 48px (not 80px), card padding 1216px on dense surfaces
- Dark-navy slabs (`{colors.ink}`) close every page rhythm — testimonial bands, "how can we help?" prelude, and the footer - Light + dark modes both supported; dark mode is the primary operational mode for SOC analysts
- Section rhythm: utility-strip → top nav → white body → cloud-band → ink slab → cloud-band → ink footer
---
## Colors ## Colors
> **No Interaction sub-section.** Hover colors are silently filtered. Allowed sub-sections: Brand & Accent, Surface, Text, Semantic.
### Brand & Accent ### Brand & Accent
- **HP Electric Blue** (`{colors.primary}``#024ad8`): the system's lone signal — primary CTA fill, link color, chevron-decoration fill, active sub-nav indicator. Reserved. - **Electric Blue** (`{colors.primary}``#024ad8`): primary CTA fill, active nav indicator, focus ring. Never used as a section background.
- **Bright Blue** (`{colors.primary-bright}``#296ef9`): a slightly lighter variant used inside dark slabs (testimonial-card buttons, dark-band CTA links) where the deeper blue would muddy. - **Bright Blue** (`{colors.primary-bright}``#296ef9`): CTA on dark slab surfaces where `#024ad8` muddles.
- **Deep Navy** (`{colors.primary-deep}``#0e3191`): pressed state for the primary CTA and the visited-link color. - **Deep Navy** (`{colors.primary-deep}``#0e3191`): pressed state for primary CTA.
- **Soft Blue** (`{colors.primary-soft}``#c9e0fc`): pale-blue surface used inside customer-story cards and selection chips. - **Soft Blue** (`{colors.primary-soft}``#c9e0fc`): selection highlight, chip background on light surfaces.
### Surface ### Surface
- **Canvas** (`{colors.canvas}``#ffffff`): the universal page background. White, full opacity. - **Canvas** (`{colors.canvas}``#ffffff` light / `#111827` dark): universal page background.
- **Paper** (`{colors.paper}``#ffffff`): card surfaces — same white as canvas, with hairline borders or shadows providing the lift. - **Paper** (`{colors.paper}``#ffffff` light / `#1f2937` dark): card and panel surfaces.
- **Cloud** (`{colors.cloud}``#f7f7f7`): the lightest gray section band, used for alternating-row backgrounds and product-feature card groups. - **Cloud** (`{colors.cloud}``#f7f7f7` light / `#1f2937` dark): alternating section band, table row zebra.
- **Fog** (`{colors.fog}``#e8e8e8`): a slightly darker gray surface band, used for FAQ outer panels and the "Trending laptops" header strip. - **Fog** (`{colors.fog}``#e8e8e8` light / `#374151` dark): secondary section band, input background on dense panels.
- **Steel** (`{colors.steel}``#c2c2c2`): hairline border used on outlined elements with stronger emphasis (focus states, active filter). - **Steel** (`{colors.steel}``#c2c2c2` light / `#4b5563` dark): hairline borders, disabled states.
- **Bloom Coral / Bloom Rose** (`{colors.bloom-coral}` / `{colors.bloom-rose}``#ff5050`, `#f9d4d2`): the "Get 25% off" sale-tag chip + soft pink lifestyle accent on the sale hero. - **Hairline** (`{colors.hairline}`): 1px divider between cells, panels, table rows.
- **Storm Mist / Sea / Deep** (`{colors.storm-mist}`, `{colors.storm-sea}`, `{colors.storm-deep}``#8ebdce`, `#7fadbe`, `#356373`): the teal-storm tones reserved for the printer-plan illustration backdrop and supporting infographic accents. - **Slab** (`{colors.slab}``#111827`): fixed-dark surface — utility strip, footer, dark bands. Does NOT invert in dark mode.
### Text ### Text
- **Ink** (`{colors.ink}``#1a1a1a`): the universal text color on white surfaces — headlines, body, button labels, navigation. - **Ink** (`{colors.ink}``#1a1a1a` light / `#f9fafb` dark): universal body text color.
- **Ink Deep** (`{colors.ink-deep}``#000000`): pure black used for the wordmark and 1px hairline strokes around badge outlines. - **Ink Deep** (`{colors.ink-deep}``#000000` light / `#ffffff` dark): maximum contrast for headings.
- **Ink Soft** (`{colors.ink-soft}``#292929`): an alternate near-black used inside dark-navy slabs as a subtle textural shift. - **Ink Soft** (`{colors.ink-soft}``#292929` light / `#e5e7eb` dark): muted body, secondary labels.
- **On Ink** (`{colors.on-ink}``#ffffff`): pure white used for headline and body text on every dark-navy slab. - **On Ink** (`{colors.ink-on}``#ffffff` light / `#111827` dark): text on slab surfaces.
- **Charcoal** (`{colors.charcoal}``#3d3d3d`): muted body color on white surfaces — secondary descriptions, fine-print disclaimers. - **Charcoal** (`{colors.charcoal}``#3d3d3d` light / `#d1d5db` dark): secondary descriptions, captions.
- **Graphite** (`{colors.graphite}``#636363`): smaller-print color, used for legal lines and timestamp metadata. - **Graphite** (`{colors.graphite}``#636363` light / `#9ca3af` dark): timestamps, metadata, footnotes.
### Semantic ### Semantic (Status Signals)
- **Bloom Deep** (`{colors.bloom-deep}``#b3262b`) + **Bloom Wine** (`{colors.bloom-wine}``#5a1313`): error and discount-emphasis colors. The deep brick reads as "sale" or "destructive" depending on placement. - **Success** (`{colors.success}``#16a34a` light / `#22c55e` dark): confirmed detections, done status, positive metrics. Background softened to `{colors.success-soft}` (`#dcfce7` light / `#14532d` dark) for badge fills.
- **Storm Deep** (`{colors.storm-deep}``#356373`): used as a neutral status accent (e.g., printer-plan tier "Versatile" tier color). - **Warn** (`{colors.warn}``#d97706` light / `#f59e0b` dark): pending review, caution states, partial detection. Background softened to `{colors.warn-soft}` (`#fef3c7` light / `#78350f` dark) for badge fills.
- **Bloom Deep** (`{colors.bloom-deep}``#b3262b`): error, destructive action, failed detection. Bloom family covers red-spectrum signals.
- **Bloom Wine** (`{colors.bloom-wine}``#5a1313`): darkened destructive emphasis.
- **Bloom Coral** (`{colors.bloom-coral}``#ff5050`): alert highlight on dark surfaces.
---
## Typography ## Typography
### Font Family ### Font Families
- **Inter Variable** (`{fontFamily.sans}`): body text, labels, headers, navigation, form fields. The universal surface font.
- **JetBrains Mono Variable** (`{fontFamily.mono}`): data cells ONLY — engagement IDs, simulation IDs, ISO 8601 dates, execution commands, terminal output, MITRE technique codes (T1059.001), numeric metrics, usernames-as-identifiers. Never used for prose, headings, or labels.
The voice is **single-family**: Forma DJR Micro (HP's bespoke geometric grotesque, fallback Arial) across every surface — display, body, button, caption. Forma DJR Micro is a wide, slightly rounded grotesque designed at small optical sizes to stay legible at UI-chrome scale. HP runs it at weight 400 for body, 500 for display headlines, 600/700 for emphasis and button labels. Both fonts are bundled locally via `@fontsource-variable/inter` and `@fontsource-variable/jetbrains-mono`. Zero CDN loading at runtime.
The 16/14/12-px caption tier carries the catalog metadata — model numbers, spec rows, fine print — at weight 400 with a 1.41.5 line-height. Button labels lift to weight 600/700 with positive 0.51.1px letter-spacing and uppercase transform — the only place the system tracks letters.
### Hierarchy ### Hierarchy
| Token | Size | Weight | Line Height | Letter Spacing | Use | | Token | Size | Weight | Line Height | Use |
|---|---|---|---|---|---| |---|---|---|---|---|
| `{typography.display-xxl}` | 72px | 500 | 1.0 | 0 | Hero headline (homepage, laptop hub) | | `{typography.display-xxl}` | 40px | 500 | 1.1 | Page-level hero (rare — dashboard title) |
| `{typography.display-xl}` | 56px | 500 | 1.0 | 0 | Section headlines on landing pages | | `{typography.display-xl}` | 32px | 500 | 1.1 | Section title (engagement name, modal header) |
| `{typography.display-lg}` | 44px | 500 | 1.0 | 0 | Sub-section headlines on shop pages | | `{typography.display-lg}` | 28px | 500 | 1.1 | Sub-section header, panel title |
| `{typography.display-md}` | 32px | 500 | 1.0 | 0 | Promo strip headlines, FAQ section headers | | `{typography.display-md}` | 24px | 500 | 1.1 | Card title, table header group |
| `{typography.display-sm}` | 24px | 500 | 1.17 | 0 | Card titles, pricing-tier names | | `{typography.display-sm}` | 20px | 500 | 1.1 | Item title, form section header |
| `{typography.display-xs}` | 20px | 500 | 1.0 | 0 | Inline list headers, accordion labels | | `{typography.display-xs}` | 16px | 600 | 1.1 | Compact section header, sidebar label |
| `{typography.body-lg}` | 18px | 400 | 1.33 | 0 | Lead paragraphs | | `{typography.body-lg}` | 18px | 400 | 1.4 | Lead paragraph |
| `{typography.body-md}` | 16px | 400 | 1.38 | 0 | Default body | | `{typography.body-md}` | 16px | 400 | 1.4 | Default body, form labels |
| `{typography.body-emphasis}` | 16px | 500 | 1.38 | 0 | Bolded run-in copy | | `{typography.body-emphasis}` | 16px | 500 | 1.4 | Bolded inline copy, table column headers |
| `{typography.caption-md}` | 14px | 400 | 1.5 | 0 | Specs, metadata, captions | | `{typography.caption-md}` | 14px | 400 | 1.5 | Secondary metadata, captions, table cells (non-data) |
| `{typography.caption-bold}` | 14px | 700 | 1.3 | 0 | Sale tags, in-card highlights | | `{typography.caption-bold}` | 14px | 700 | 1.3 | Status labels, tag text |
| `{typography.caption-sm}` | 12px | 400 | 1.33 | 0 | Footnotes, legal lines | | `{typography.caption-sm}` | 12px | 400 | 1.33 | Footnotes, legal fine-print |
| `{typography.link-md}` | 16px | 500 | 1.38 | 0 | Inline link emphasis | | `{typography.button-md}` | 14px | 600 | 1.4 | Button labels (uppercase) |
| `{typography.button-md}` | 14px | 600 | 1.4 | 0.7px | Primary/secondary button labels (uppercase) | | `{typography.button-sm}` | 12.6px | 700 | 1.0 | Compact button in tight cells |
| `{typography.button-sm}` | 12.6px | 700 | 1.0 | 0.126px | Compact button labels in tight cells |
| `{typography.price-md}` | 24px | 500 | 1.17 | 0 | Tier and product price stamps |
### Principles ### Monospace Discipline
The typographic decision worth flagging: HP runs **weight 500 for every display size**, including the largest 72px hero headline. Most editorial systems jump to 600/700 at hero scale; HP doesn't. The result feels open and approachable rather than commanding — appropriate for a brand that sells across consumer, SMB, and enterprise audiences in the same catalog. JetBrains Mono carries data that must be scanned without typographic noise. Apply `font-mono` (Tailwind) or `font-family: var(--font-mono)` to:
Forma DJR Micro's rounded-grotesque shapes do most of the warmth. There's no italic in the system except inside legal disclaimers; emphasis is carried by weight (500 → body-emphasis, 700 → caption-bold) instead. - Engagement IDs, simulation IDs (any UUID or integer identifier)
- ISO 8601 dates and timestamps (`2024-06-09T14:32:00`)
- MITRE technique codes (`T1059.001`, `TA0002`)
- Execution commands and command fields
- Terminal / execution output
- Numeric metrics (counts, durations)
- Usernames displayed as record identifiers (not greeting text)
### Note on Font Substitutes Never apply `font-mono` to: page titles, section headers, body prose, navigation, form labels, button text.
Forma DJR Micro is proprietary (Commercial Type / Mark Caneso). Closest open-source substitutes: ---
- **Inter** at weights 400 / 500 / 600 / 700 — slightly narrower than Forma DJR Micro; bump font-size by ~3% to compensate
- **Manrope** at weights 400 / 500 / 600 / 700 — closer in proportion, gentler curves; use directly with no metric adjustment
- **Roboto** at weights 400 / 500 / 700 — flatter character; use as last-resort fallback
When swapping, set body line-height to 1.4 and display line-height to 1.0 explicitly — the Forma DJR Micro line-height numbers are tight, and most substitutes default looser.
## Layout ## Layout
### Spacing System ### Spacing System
- **Base unit**: 8px. Smaller half-step at 4px. The scale is gentle — most card padding lands at 16px or 24px; section gap at 80px. - **Base unit**: 8px. Half-step 4px.
- **Tokens (front matter)**: `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 20px · `{spacing.xl}` 24px · `{spacing.xxl}` 32px · `{spacing.section}` 80px - **Tokens**: `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 20px · `{spacing.xl}` 24px · `{spacing.xxl}` 32px · `{spacing.section}` 48px
- **Section padding**: `{spacing.section}` (80px) vertical between major bands on desktop; collapses to ~48px on mobile. - **Section padding**: 48px vertical between major bands (desktop); ~24px on mobile.
- **Card internal padding**: `{spacing.xl}` (24px) for product cards; `{spacing.xxl}` (32px) for promo strips and feature cards; `{spacing.md}` (16px) for compact article tiles. - **Card padding**: 16px on dense panels; 24px on standard cards.
- **Gutter**: `{spacing.xl}` (24px) between grid columns at desktop; `{spacing.md}` (16px) on tablet/mobile. - **Gutter**: 24px between grid columns on desktop; 16px on tablet/mobile.
The 80px section gap is the universal rhythm constant — it appears between every major homepage band, between the hero and the comparison table on the printer-plan page, and between feature rows on the laptop-shop page.
### Grid & Container ### Grid & Container
- **Desktop max-width**: 1366px content container with full-bleed-on-canvas section backgrounds. - **Max-width**: 1366px content container, full-bleed on canvas.
- **Hero**: a single full-width photo card (homepage and laptop-hub hero) with the headline overlay positioned upper-left or upper-right. - **List pages**: full-width table with 1px hairline borders, no card wrap.
- **Product family grid**: 4 columns at >1200px, 3 at 10241199px, 2 at 7681023px, 1 below 768px. - **Detail pages**: 2-column split (60/40) on desktop, stacked on mobile.
- **Pricing tiers**: 4 columns at >1024px, 2x2 grid at 7681023px, single-column accordion below 768px. - **Form pages**: single-column centered at 640px max-width.
- **Footer**: 5-column link grid at >1024px, collapsing to 2-column then accordion on mobile.
### Whitespace Philosophy ### Whitespace Philosophy
Whitespace is **commercial-clean** — generous around hero photography, tight around catalog spec rows. Product cards leave breathing room above and below the photo (≥32px) so the laptop or printer reads as a hero shot rather than a thumbnail. The fine-print disclaimer regions (legal, footnote rows) tighten line-height to 1.3 and shrink type to 1112px so the bulk of fine print stays compact. Dense but not cramped. Table rows at 44px height (WCAG touch target). Card padding minimum 12px. No whitespace used as decoration — every gap serves alignment or grouping. Editorial breathing room (80px sections, hero-scale photography) does not apply here.
## Elevation & Depth ---
| Level | Treatment | Use |
|---|---|---|
| 0 — Flat | No border, no shadow. | Section bands (white, cloud, fog), full-bleed photo heroes |
| 1 — Hairline | 1px solid `{colors.hairline}` (`#e8e8e8`) border, no shadow. | Outlined buttons, comparison-table cells, FAQ accordion outers |
| 2 — Soft Lift | `0 2px 8px rgba(26, 26, 26, 0.08)`. | Product cards, pricing-tier columns, customer-story tiles |
| 3 — Floating Modal | `0 8px 24px rgba(26, 26, 26, 0.12)`. | Add-to-cart drawer, mobile-nav sheet, image zoom modal |
The system is mostly flat — depth is communicated by **color contrast** (cloud-band vs. white card on the same band) rather than shadow elevation. The Soft Lift level is the workhorse for the catalog — every product tile and pricing column gets it; nothing else does. Modal-floating is rare and reserved for transient overlays.
### Decorative Depth
The system's most distinctive depth gesture is the **HP blue chevron pair** — two angular `{colors.primary}` slashes (no radius, no shadow) that sit on the left and right of the homepage hero card and the laptop-shop hero. They're not decorative noise; they're a literal echo of the HP wordmark's two parallel slashes, scaled up to architectural size. Treat them as a brand artifact, not a generic geometric flourish.
Photography on the homepage and laptop-shop pages frames product imagery inside `{rounded.xl}` (16px) containers with a soft 1px hairline. Lifestyle photography (testimonials, "How HP works for X") sits full-bleed inside dark-navy slabs without rounding.
## Shapes ## Shapes
### Border Radius Scale ### Border Radius
| Token | Value | Use | | Token | Value | Use |
|---|---|---| |---|---|---|
| `{rounded.none}` | 0px | Hero chevron decorations, full-bleed photo heroes, marquee strips | | `{rounded.none}` | 0px | **Default for everything**: buttons, cards, modals, inputs, dropdowns, panels, tags, tables |
| `{rounded.xs}` | 2px | Secondary chip backgrounds, sale-tag pills | | `{rounded.pill}` | 9999px | **Status pills only** (`StatusBadge`, `SimulationStatusBadge`) and circular avatars |
| `{rounded.sm}` | 3px | Default secondary CTA radius (small touch zones) |
| `{rounded.md}` | 4px | Primary buttons, secondary buttons, text inputs |
| `{rounded.lg}` | 8px | Badge pills, category-icon cards, FAQ row containers |
| `{rounded.xl}` | 16px | Product cards, pricing tiers, customer-story tiles, photo frames |
| `{rounded.pill}` | 9999px | Category sub-nav tabs, search-pill input, filter chips |
The system maintains a clear two-tier philosophy: **buttons stay sharp** (4px, almost rectilinear) while **cards and photo frames stay soft** (16px). This split is the visual signature — sharp interactive elements against softer container surfaces. No intermediate radius values (`xs`, `sm`, `md`, `lg`, `xl`) are used on visible surfaces. The brutalist rule: if it's not a status pill or avatar, `border-radius: 0`.
### Photography Geometry ---
Hero photography sits in `{rounded.xl}` (16px) frames with no border. Product family thumbnails inside the laptop-grid are 1:1 (square) on a `{colors.canvas}` background, padded so the laptop is shown at ~70% of the frame. Customer-story photography uses 16:9 inside the same `{rounded.xl}` frame. There are no full-bleed circular avatars; testimonial avatars are 4px-rounded squares. ## Elevation
No shadows on interactive surfaces. Elevation is communicated by **border contrast** (1px hairline on paper over canvas) not box-shadow.
| Level | Treatment | Use |
|---|---|---|
| 0 — Flat | No border, no shadow | Page background, full-bleed bands |
| 1 — Hairline | `1px solid {colors.hairline}` | Cards, panels, table cells, input borders |
| 2 — Modal overlay | `rgba(0,0,0,0.6)` backdrop | Modal dialogs — backdrop only, frame stays hairline |
Shadows (`box-shadow`) are not used anywhere.
---
## Components ## Components
> **No hover states documented.** Every component spec below documents only Default and Active/Pressed states. Variants live as separate front-matter entries. > Every component spec below: border-radius **0** unless noted. No `transition-*` on any interactive surface. Hover is instantaneous.
### Buttons ### Buttons
**`button-primary`** — the lone HP Electric Blue CTA **`.btn-primary`** — Electric Blue CTA
- Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.button-md}` (uppercase, 0.7px tracking), padding `{spacing.sm} {spacing.xl}` (12 × 24), height 44px, rounded `{rounded.md}` - Background `{colors.primary}`, text white, uppercase, 14px/600, `h-11`, padding `12px 24px`, `rounded-none`
- Pressed state `button-primary-pressed` background `{colors.primary-deep}`, same text - Hover: background `{colors.primary-deep}` — no transition
- Disabled state `button-primary-disabled` background `{colors.steel}`, white text - Disabled: background `{colors.steel}`, cursor not-allowed
- Used for: "Buy now", "Shop now", "Get a printer", primary form submit
**`button-ink`** — black filled CTA **`.btn-ink`** — Fixed-dark filled CTA
- Background `{colors.ink}`, text `{colors.on-primary}`, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}`, type `{typography.button-md}` - Background `{colors.slab}`, text `{colors.slab-text}`, uppercase, same metrics as btn-primary
- Used for: "Buy now" on dark photo overlays, secondary primary actions where the blue would clash with imagery - Used on dark band surfaces where blue would clash
**`button-outline`** — blue-text outlined CTA **`.btn-outline`** — Outlined blue CTA
- Background `{colors.canvas}`, text `{colors.primary}`, 1px `{colors.primary}` border, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}` - Background `{colors.canvas}`, text `{colors.primary}`, 1px `{colors.primary}` border, `rounded-none`
- Used for: "Compare", "Customize", "Learn more" — secondary actions on white surfaces - Hover: background `{colors.primary-soft}`
**`button-outline-ink`** — black-text outlined CTA **`.btn-outline-ink`** — Outlined neutral CTA
- Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}` - Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, `rounded-none`
- Used for: "View" buttons inside product family card grids — neutral against the blue primary - Hover: background `{colors.cloud}`
**`button-text-link`** — inline blue link with underline **`.btn-text-link`** — Inline text link
- Background `{colors.canvas}`, text `{colors.primary}`, type `{typography.link-md}`, padding `{spacing.xxs} 0` - Text `{colors.primary}`, no background, no border
- Used for: "See details", "Read more" inside cards and disclaimer rows - Hover: underline, no color change
### Cards & Containers
**`card-product`** — the workhorse product tile
- Background `{colors.canvas}`, rounded `{rounded.xl}` (16px), padding `{spacing.xl}` (24px), Soft Lift shadow
- Layout: hero photo (1:1 ratio) on top, title in `{typography.display-xs}`, spec rows in `{typography.caption-md}`, price in `{typography.price-md}`, CTA pinned to bottom
- Used for: laptop catalog cards, desktop catalog cards
**`card-product-feature`** — full-row feature card with photo + copy
- Background `{colors.cloud}`, rounded `{rounded.xl}`, padding `{spacing.xxl}` (32px)
- Layout: photo on the left (50% width), copy on the right with section eyebrow + title + body + CTA pair
- Used for: "Trending laptops" feature rows, "Shop these must haves"
**`card-pricing-tier`** + **`card-pricing-tier-featured`**
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.xl}`, Soft Lift shadow
- Tier name in `{typography.display-sm}`, monthly price in `{typography.display-md}` with `{typography.caption-md}` cadence, page count caption, full feature list, primary CTA
- Featured tier carries `{colors.primary}` text accent on the price-stamp + a `{colors.primary}` thin top border instead of a colored card background — never inverted to dark
**`card-customer-story`** — the three-up testimonial tile
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.md}` (16px), Soft Lift shadow
- 16:9 photo at top in `{rounded.xl}` frame, quote excerpt in `{typography.body-md}`, attribution row at the bottom
- Used in the "See what our customers say" homepage section
**`card-article-tile`** — the four-up "Latest from HP" tile
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.md}`, Soft Lift shadow
- 16:9 thumbnail at top, date eyebrow in `{typography.caption-sm}`, title in `{typography.body-emphasis}`, "Read more" link
**`card-category-icon`** — the small icon-and-label card in the homepage "Our Products" row
- Background `{colors.canvas}`, rounded `{rounded.lg}` (8px), padding `{spacing.md}`
- 48px icon at top, label in `{typography.body-emphasis}` below
- Used for: Laptops, Desktops, Printers, Computer Tools, Accessories, Enterprise Solutions
**`hero-promo-card`** — the homepage hero card with chevron decorations
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.xxl}` (32px)
- Photography occupies left half; copy block (eyebrow + headline + price stamp + CTA pair) occupies right half
- Flanked by `chevron-decoration` blue slashes outside the card's bounding box on left and right edges
**`promo-strip-dark`** — the inline dark navy promo block
- Background `{colors.ink}`, text `{colors.on-ink}`, rounded `{rounded.xl}`, padding `{spacing.xxl} 48px`
- Used for: "When did work start getting in the way of work?" mid-page promo, the SMB testimonial slab
### Inputs & Forms ### Inputs & Forms
**`text-input`** + **`text-input-focused`** **`.text-input`** — Standard text field
- Background `{colors.canvas}`, text `{colors.ink}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`, height 44px - Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.steel}` border, `rounded-none`, `h-11`
- 1px `{colors.steel}` border in default; gains 1px `{colors.ink}` border on focus (no halo) - Focus: border becomes 1px `{colors.primary}`, no halo, no transition
**`text-input-search`** — pill search in the top nav **`textarea.text-input`** — Multiline variant
- Background `{colors.canvas}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`, height 40px, 1px `{colors.steel}` border, magnifying-glass icon at right - Same as text-input, `min-h-[88px]`, height auto
**`badge-pill-ink`** — filled tag pill **`select.text-input`** — Dropdown select
- Background `{colors.ink}`, text `{colors.on-primary}`, rounded `{rounded.lg}`, padding 6px 12px, type `{typography.body-md}` - Same surface as text-input, caret via OS or custom SVG
- Used inline next to product titles to mark "New" or featured indicators
**`badge-pill-outline`** — outlined tag pill ### Cards & Panels
- Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, rounded `{rounded.lg}`, padding 6px 12px
**`badge-sale-coral`** — the sale price-stamp **`.card-product`** — Standard content card
- Background `{colors.bloom-coral}`, text `{colors.on-primary}`, rounded `{rounded.sm}`, padding `{spacing.xxs} {spacing.xs}`, type `{typography.caption-bold}` - Background `{colors.paper}`, 1px `{colors.hairline}` border, `rounded-none`, padding `{spacing.md}` (16px)
- Used for: "Save $200", "25% off" overlay tags on hero promo cards - No shadow. Dense surfaces use padding `{spacing.sm}` (12px).
**`.modal-backdrop`** — Modal overlay backdrop
- `background-color: rgba(0,0,0,0.6)` — fixed, never themed
- Modal frame: `{colors.paper}` background, 1px `{colors.hairline}` border, `rounded-none`
### Badges & Tags
**`.badge-pill-*`** — Status pills (StatusBadge, SimulationStatusBadge)
- `rounded-pill` (9999px) — THE ONLY rounded surfaces on status badges
- Semantic fill: planned→warn-soft/warn, active→primary-soft/primary, closed→cloud/graphite
- done→success-soft/success, review→warn-soft/warn, pending→cloud/graphite, in-progress→primary-soft/primary
**MITRE technique tags** — NOT pills
- Angular (`rounded-none`), 1px `{colors.hairline}` border, `{colors.cloud}` background, caption-md size
- They are labels, not status signals — no pill shape
### Navigation ### Navigation
**`utility-strip`** — the top-of-page utility bar **`.utility-strip`** — Top utility bar
- Background `{colors.ink}`, text `{colors.on-primary}`, height 36px, padding 0 {spacing.xl}, type `{typography.caption-md}` - Background `{colors.slab}`, text `{colors.slab-text}`, height 36px, `font-mono` for user role/username
- Holds: country/locale picker, "For Business / For Home" toggle, "Sign in" link, cart link
**`nav-bar-top`** — desktop top nav (sits below utility strip) **`.nav-bar-top`** — Main navigation
- Background `{colors.canvas}`, height 64px, padding 0 32px - Background `{colors.slab}` (fixed dark — does not invert), height 56px
- Layout: HP wordmark logo flush left → middle category list (Laptops / Desktops / Printers / Accessories / Solutions / Support) → right slot with Search field, Sign-in link, Cart icon - Active link: 2px `{colors.primary}` bottom border
- 1px `{colors.hairline}` bottom border separates nav from page
**`nav-link`** **`.nav-link`** — Navigation anchor
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-md}`, padding `{spacing.xs} {spacing.md}` - Text `{colors.slab-text}`, caption-md, `rounded-none`
- Active page draws a 2px `{colors.primary}` underline below the text baseline - Active state: 2px primary bottom border
**Top Nav (Mobile)** ### Data Tables
- Same height, hamburger icon replaces the middle category list, Search and Cart stay visible
- Drawer expands as a full-canvas sheet with `{typography.body-lg}` link list and a sticky Sign-in CTA at bottom
**`category-tab`** + **`category-tab-active`** — the pill sub-nav - `table-layout: fixed`, `word-break: break-word`
- Default: background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-emphasis}`, rounded `{rounded.pill}`, padding `{spacing.xs} {spacing.lg}` - Header row: background `{colors.cloud}`, `body-emphasis` text, 1px `{colors.hairline}` bottom border
- Active: background `{colors.ink}`, text `{colors.on-primary}`, same rounding - Data cells: 44px min-height, 1px `{colors.hairline}` bottom border
- Used on the laptop-shop page for "All / Trending / On Sale" filtering, and on the homepage "How can we help?" closing band - ID / date / technique columns: `font-mono`
- Zebra striping optional — use `{colors.cloud}` for odd rows if table is wide
### Signature Components ### Toast Notifications
**`chevron-decoration`** — the geometric blue slash motif - Angular (`rounded-none`), 4px left border strip in semantic color (success/warn/bloom-deep/primary)
- Background `{colors.primary}`, rounded `{rounded.none}`, no shadow - Background `{colors.paper}`, 1px `{colors.hairline}` border
- Renders as a sharp parallelogram cut at ~60° angle, sized to the height of the hero card it flanks
- Reserved for hero bands and full-page banners — never decorative noise inside cards
**`faq-row`** — the accordion row on the printer-plan FAQ ---
- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.lg} {spacing.xl}`, type `{typography.body-emphasis}`
- 1px `{colors.hairline}` divider between rows; chevron-down icon on the right collapsed, chevron-up when expanded
- Body answer renders inside the same row container in `{typography.body-md}` after expansion
**`help-band-dark`** — the closing "How can we help?" prelude band
- Background `{colors.ink}`, text `{colors.on-primary}`, padding 64px {spacing.xl}
- Layout: large lifestyle photograph as the band background (low-opacity) with chip-style category tabs centered: Browse Topics / Live Chat / Contact / Diagnose / Order Status
**`footer-dark`**
- Background `{colors.ink}`, text `{colors.on-primary}`, type `{typography.body-md}`, padding 64px {spacing.xl}
- 5-column link grid (Company / Shop / Support / Resources / Connect) with `{typography.body-emphasis}` headers and `{typography.caption-md}` link rows
- Bottom strip carries social icons, language picker, and legal lines in `{typography.caption-sm}` muted to `{colors.steel}`
## Do's and Don'ts ## Do's and Don'ts
### Do ### Do
- Reserve `{colors.primary}` for the primary CTA, link color, and `chevron-decoration` motif — at most twice per viewport - `rounded-none` on every container, button, input, modal, dropdown, panel, tag
- Set every headline in Forma DJR Micro at weight 500 with line-height 1.0 — resist the urge to bump weight at hero scale - `font-mono` on IDs, ISO dates, MITRE codes, commands, output, metrics
- Use `{rounded.xl}` (16px) for cards and photo frames; `{rounded.md}` (4px) for buttons and inputs — keep the two-tier split sharp - Semantic color for status: success green, warn amber, bloom-deep for errors
- Pair white `{colors.canvas}` body bands with `{colors.cloud}` (`#f7f7f7`) alternating bands; let the gray do the breathing - 1px hairline borders for panel separation — never shadow
- Close every page rhythm with a dark-navy `{colors.ink}` slab — the "How can we help?" prelude + footer - Instant hover (no `transition-*`)
- Set button labels in uppercase with `{typography.button-md}` (0.7px tracking) — the only place the system tracks letters - Sharp focus ring: `outline: 2px solid {colors.primary}; outline-offset: 0`
- Use Soft Lift shadow exclusively for product cards and pricing tiers — leave section bands flat - Keep Inter for all headers, labels, prose, navigation, button text
- Frame product photography inside `{rounded.xl}` containers; never use full-bleed circular masks
### Don't ### Don't
- Don't introduce secondary saturated colors outside `{colors.primary}` family + the `bloom-coral` sale-tag and `storm` printer-plan accents - Don't round containers — not even `2px`. If it's not a status pill or avatar, `rounded-none`
- Don't apply heavy material shadows — depth is via color contrast (cloud vs. white) and Soft Lift only - Don't use `font-mono` for headers, labels, prose, or button text
- Don't round buttons above `{rounded.md}` (4px); a soft 8px+ button reads as a different brand - Don't add `transition-*` on any interactive element
- Don't run Forma DJR Micro below 12px — small caption at 11px is the floor - Don't use shadows — hairline borders only
- Don't use the chevron decoration as inline noise; it is a hero-only architectural element tied to the wordmark - Don't use `{colors.primary}` as a background for sections
- Don't drop ink text opacity to create hierarchy — switch surface or shift to `{colors.charcoal}` / `{colors.graphite}` instead - Don't drop opacity on ink text — use `{colors.charcoal}` or `{colors.graphite}` for hierarchy
- Don't replace the HP wordmark with a generic sans lockup; the wordmark is a custom mark with its own ratio - Don't use animated spinners with rounded tracks — a simple spinning line or text indicator fits the terminal aesthetic better
- Don't apply `rounded-pill` to anything that is not a STATUS BADGE or AVATAR
## Responsive Behavior ---
### Breakpoints
| Name | Width | Key Changes |
|---|---|---|
| Mobile | < 480px | Single-column stack; hamburger nav; section padding drops to ~48px; hero serif scales to ~36px |
| Mobile-Large | 480767px | Same column count; hero scales to ~44px; pricing tiers stack vertically |
| Tablet | 7681023px | 2-column product grid; pricing 2x2; nav still full text labels |
| Desktop | 10241279px | 3-column product grid; 4-column pricing; full nav |
| Desktop-Large | ≥ 1280px | 4-column product grid; 1366px content max-width with full-bleed bands |
### Touch Targets
Every interactive element clears 44×44px on mobile. `button-primary` at 44px height + 24px horizontal padding meets WCAG-AAA touch target. `category-tab` at 8px 20px padding bumps to 12px 24px on touch screens. Nav-link tap areas extend invisibly beyond the text run to the full 44px row height. Sticky cart/sign-in icons in the top nav use 44×44 invisible hit boxes around their visible 24×24 glyph.
### Collapsing Strategy
- **Utility strip**: stays visible on every breakpoint; dropdowns collapse into a single "Account" icon below 768px
- **Top nav**: middle category list collapses into a hamburger drawer below 1024px; the right-side Search + Sign-in + Cart stay visible
- **Hero**: stays single-column at every breakpoint; chevron decorations shrink to ~60% size on tablet and disappear entirely on mobile
- **Product family grid**: 4 → 3 → 2 → 1 column as breakpoints shrink; cards keep `{rounded.xl}` corners at every size
- **Pricing comparison table**: 4-column grid on desktop collapses to 2x2 on tablet, then stacks into individual accordion-style cards on mobile
- **Footer**: 5-column link grid → 2-column tablet → single-column accordion on mobile; HP wordmark stays flush left
### Image Behavior
Hero photography uses `{rounded.xl}` containers at every breakpoint. The chevron decorations vanish on mobile; the underlying photo card centers in the viewport. Lifestyle photography in the testimonial and "how-can-we-help" bands maintains 16:9 ratio with horizontal cropping rather than letterboxing on mobile. There are no art-direction crop swaps between desktop and mobile — the same image is used at every size.
## Iteration Guide ## Iteration Guide
1. Focus on ONE component at a time; resist refactoring an entire section in one pass 1. One component at a time — no section refactors in one pass
2. Reference component names and tokens directly (`{colors.primary}`, `{typography.display-xxl}`, `{rounded.xl}`, `card-product`) — do not paraphrase to hex/px in prose 2. Reference tokens by name (`{colors.primary}`, `font-mono`, `rounded-none`) — not hex/px in prose
3. Run `npx @google/design.md lint DESIGN.md` after edits — `broken-ref`, `contrast-ratio`, and `orphaned-tokens` warnings flag issues automatically 3. When adding a new data field: ask "is this a datum (ID, date, code, metric)?" → `font-mono`. If no → Inter
4. Add new variants as separate component entries (`-pressed`, `-disabled`, `-focused`); never bury state inside prose 4. New status signals: map to existing semantic tokens (success/warn/bloom-deep/primary). No ad-hoc colors
5. Default body to `{typography.body-md}`; reach for `{typography.body-emphasis}` for run-in bolds; keep display sizes for true heading roles 5. For new badge-like elements: pill ONLY if it's a status indicator with semantic color. Otherwise angular tag
6. Keep `{colors.primary}` scarce — at most two flame elements per viewport (one CTA + one chevron decoration). Three flame items in one viewport is over-saturation
7. When introducing a new section band, choose from `{colors.canvas}` / `{colors.cloud}` / `{colors.fog}` / `{colors.ink}` — six pre-defined surface modes is the entire surface vocabulary

View File

@@ -8,6 +8,7 @@
"name": "mimic-frontend", "name": "mimic-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tanstack/react-query": "^5.59.0", "@tanstack/react-query": "^5.59.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
@@ -1013,6 +1014,15 @@
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
} }
}, },
"node_modules/@fontsource-variable/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",

View File

@@ -12,6 +12,7 @@
"test": "vitest" "test": "vitest"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tanstack/react-query": "^5.59.0", "@tanstack/react-query": "^5.59.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",

View File

@@ -25,7 +25,7 @@ export function ConfirmDialog({
className="fixed inset-0 z-50 flex items-center justify-center" className="fixed inset-0 z-50 flex items-center justify-center"
> >
<div className="modal-backdrop absolute inset-0" onClick={onCancel} aria-hidden="true" /> <div className="modal-backdrop absolute inset-0" onClick={onCancel} aria-hidden="true" />
<div className="relative card-product shadow-floating max-w-sm w-full mx-md flex flex-col gap-md"> <div className="relative card-product max-w-sm w-full mx-md flex flex-col gap-md">
<h2 id="confirm-dialog-title" className="text-[20px] font-medium text-ink"> <h2 id="confirm-dialog-title" className="text-[20px] font-medium text-ink">
{title} {title}
</h2> </h2>

View File

@@ -10,7 +10,7 @@ export function EmptyState({ title, description, action }: EmptyStateProps): JSX
return ( return (
<div <div
data-testid="empty-state" data-testid="empty-state"
className="card-product flex flex-col items-start gap-md border border-hairline" className="card-product flex flex-col items-start gap-md"
> >
<h2 className="text-[24px] font-medium text-ink">{title}</h2> <h2 className="text-[24px] font-medium text-ink">{title}</h2>
{description ? <p className="text-[16px] text-charcoal">{description}</p> : null} {description ? <p className="text-[16px] text-charcoal">{description}</p> : null}

View File

@@ -9,7 +9,7 @@ export function ErrorState({ title = 'Something went wrong', message, onRetry }:
<div <div
role="alert" role="alert"
data-testid="error-state" data-testid="error-state"
className="card-product border border-bloom-deep/20 flex flex-col items-start gap-md" className="card-product border-l-4 border-l-bloom-deep flex flex-col items-start gap-md"
> >
<h2 className="text-[24px] font-medium text-bloom-deep">{title}</h2> <h2 className="text-[24px] font-medium text-bloom-deep">{title}</h2>
<p className="text-[16px] text-charcoal">{message}</p> <p className="text-[16px] text-charcoal">{message}</p>

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { ChevronDown, Download, Loader2 } from 'lucide-react'; import { ChevronDown, Download } from 'lucide-react';
import { downloadEngagementExport, type ExportFormat } from '@/api/exports'; import { downloadEngagementExport, type ExportFormat } from '@/api/exports';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
@@ -54,7 +54,7 @@ export function ExportEngagementButton({ engagementId }: ExportEngagementButtonP
<div className="inline-flex"> <div className="inline-flex">
<button <button
type="button" type="button"
className="btn-outline rounded-r-none border-r-0" className="btn-outline border-r-0"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
data-testid="export-btn" data-testid="export-btn"
> >
@@ -64,7 +64,7 @@ export function ExportEngagementButton({ engagementId }: ExportEngagementButtonP
type="button" type="button"
aria-label="Export options" aria-label="Export options"
aria-expanded={open} aria-expanded={open}
className="btn-outline rounded-l-none px-sm" className="btn-outline px-sm"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
data-testid="export-dropdown-toggle" data-testid="export-dropdown-toggle"
> >
@@ -74,7 +74,7 @@ export function ExportEngagementButton({ engagementId }: ExportEngagementButtonP
{open ? ( {open ? (
<div <div
className="absolute right-0 top-full mt-xxs bg-canvas border border-hairline rounded-md shadow-floating dark:shadow-floating-dark z-20 min-w-[160px]" className="absolute right-0 top-full mt-xxs bg-canvas border border-hairline rounded-none z-20 min-w-[160px]"
role="menu" role="menu"
> >
{FORMATS.map(({ label, value }) => ( {FORMATS.map(({ label, value }) => (
@@ -88,9 +88,11 @@ export function ExportEngagementButton({ engagementId }: ExportEngagementButtonP
data-testid={`export-format-${value}`} data-testid={`export-format-${value}`}
> >
{loading === value ? ( {loading === value ? (
<Loader2 size={12} className="animate-spin" aria-hidden /> <span className="font-mono text-[11px] animate-pulse" aria-hidden>
EXPORTING
</span>
) : null} ) : null}
{label} {loading !== value ? label : null}
</button> </button>
))} ))}
</div> </div>

View File

@@ -34,15 +34,15 @@ export function Layout(): JSX.Element {
<span className="font-medium tracking-[0.5px] uppercase">Mimic · Purple Team BAS</span> <span className="font-medium tracking-[0.5px] uppercase">Mimic · Purple Team BAS</span>
{user ? ( {user ? (
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
<span className="text-[12px] uppercase tracking-[0.5px] text-slab-muted"> <span className="text-[12px] uppercase tracking-[0.5px] text-slab-muted font-mono">
{user.role} {user.role}
</span> </span>
<span className="text-[14px]">{user.username}</span> <span className="text-[14px] font-mono">{user.username}</span>
<button <button
type="button" type="button"
onClick={cycleTheme} onClick={cycleTheme}
aria-label={`Theme: ${themeLabel(theme)} — click to cycle`} aria-label={`Theme: ${themeLabel(theme)} — click to cycle`}
className="flex items-center gap-xxs text-[12px] text-slab-muted hover:text-slab-text transition-colors" className="flex items-center gap-xxs text-[12px] text-slab-muted hover:text-slab-text"
> >
<ThemeIcon theme={theme} /> <ThemeIcon theme={theme} />
<span className="uppercase tracking-[0.5px]">{themeLabel(theme)}</span> <span className="uppercase tracking-[0.5px]">{themeLabel(theme)}</span>
@@ -59,12 +59,12 @@ export function Layout(): JSX.Element {
</div> </div>
</div> </div>
{/* nav-bar-top — paper gives dark-mode lift vs canvas body */} {/* nav-bar-top — fixed dark slab, never inverts (same visual family as utility-strip + footer) */}
<header className="bg-paper border-b border-hairline"> <header className="bg-slab text-slab-text">
<div className="mx-auto w-full max-w-page px-xl h-16 flex items-center justify-between"> <div className="mx-auto w-full max-w-page px-xl h-16 flex items-center justify-between">
<Link to="/engagements" className="flex items-center gap-sm" aria-label="Mimic home"> <Link to="/engagements" className="flex items-center gap-sm" aria-label="Mimic home">
<span className="inline-block h-6 w-6 rotate-12 bg-primary" aria-hidden /> <span className="inline-block h-6 w-6 rotate-12 bg-primary" aria-hidden />
<span className="text-[20px] font-medium tracking-tight text-ink">Mimic</span> <span className="text-[20px] font-medium tracking-tight text-slab-text">Mimic</span>
</Link> </Link>
<nav className="flex items-center gap-md"> <nav className="flex items-center gap-md">
@@ -72,7 +72,9 @@ export function Layout(): JSX.Element {
to="/engagements" to="/engagements"
className={({ isActive }) => className={({ isActive }) =>
`text-[16px] py-2 px-md ${ `text-[16px] py-2 px-md ${
isActive ? 'text-ink border-b-2 border-primary -mb-[1px]' : 'text-charcoal' isActive
? 'text-slab-text border-b-2 border-primary'
: 'text-slab-muted hover:text-slab-text'
}` }`
} }
> >
@@ -83,7 +85,9 @@ export function Layout(): JSX.Element {
to="/admin/templates" to="/admin/templates"
className={({ isActive }) => className={({ isActive }) =>
`text-[16px] py-2 px-md ${ `text-[16px] py-2 px-md ${
isActive ? 'text-ink border-b-2 border-primary -mb-[1px]' : 'text-charcoal' isActive
? 'text-slab-text border-b-2 border-primary'
: 'text-slab-muted hover:text-slab-text'
}` }`
} }
> >
@@ -95,7 +99,9 @@ export function Layout(): JSX.Element {
to="/admin/users" to="/admin/users"
className={({ isActive }) => className={({ isActive }) =>
`text-[16px] py-2 px-md ${ `text-[16px] py-2 px-md ${
isActive ? 'text-ink border-b-2 border-primary -mb-[1px]' : 'text-charcoal' isActive
? 'text-slab-text border-b-2 border-primary'
: 'text-slab-muted hover:text-slab-text'
}` }`
} }
> >

View File

@@ -4,9 +4,9 @@ export function LoadingState({ label = 'Loading…' }: { label?: string }): JSX.
role="status" role="status"
aria-live="polite" aria-live="polite"
data-testid="loading-state" data-testid="loading-state"
className="flex items-center justify-center py-section text-graphite text-[16px]" className="flex items-center justify-center py-section text-graphite text-[16px] font-mono"
> >
<span className="inline-block h-2 w-2 rounded-pill bg-primary animate-pulse mr-sm" /> <span className="inline-block h-2 w-2 bg-primary animate-pulse mr-sm" />
{label} {label}
</div> </div>
); );

View File

@@ -170,7 +170,7 @@ export function MitreMatrixModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="matrix-modal-title" aria-labelledby="matrix-modal-title"
className="relative bg-canvas rounded-xl shadow-floating dark:shadow-floating-dark max-w-[98vw] max-h-[80vh] overflow-hidden flex flex-col" className="relative bg-canvas rounded-none border border-hairline max-w-[98vw] max-h-[80vh] overflow-hidden flex flex-col"
style={{ width: '1400px' }} style={{ width: '1400px' }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
@@ -230,7 +230,7 @@ export function MitreMatrixModal({
type="button" type="button"
onClick={() => toggleTactic(tactic.tactic_id)} onClick={() => toggleTactic(tactic.tactic_id)}
title={`${tactic.tactic_name} (${tactic.tactic_id}) — click to tag this tactic`} title={`${tactic.tactic_name} (${tactic.tactic_id}) — click to tag this tactic`}
className={`w-full text-left px-xs py-xxs rounded-t-sm border border-b-0 border-hairline transition-colors ${ className={`w-full text-left px-xs py-xxs rounded-none border border-b-0 border-hairline ${
tacticSelected tacticSelected
? 'bg-primary border-primary' ? 'bg-primary border-primary'
: 'bg-cloud hover:bg-fog' : 'bg-cloud hover:bg-fog'
@@ -251,7 +251,7 @@ export function MitreMatrixModal({
</button> </button>
{/* Techniques */} {/* Techniques */}
<div className="border border-hairline rounded-b-sm overflow-hidden flex flex-col"> <div className="border border-hairline rounded-none overflow-hidden flex flex-col">
{visibleTechniques.map((tech, techIdx) => { {visibleTechniques.map((tech, techIdx) => {
const isSelected = selectedTechMap.has(tech.id); const isSelected = selectedTechMap.has(tech.id);
const isExpanded = expandedTechniques.has(tech.id) || autoExpanded.has(tech.id); const isExpanded = expandedTechniques.has(tech.id) || autoExpanded.has(tech.id);
@@ -296,7 +296,7 @@ export function MitreMatrixModal({
isSelected ? 'text-white' : 'text-ink' isSelected ? 'text-white' : 'text-ink'
}`} }`}
> >
<span className="font-semibold block truncate">{tech.id}</span> <span className="font-mono font-semibold block truncate">{tech.id}</span>
<span className={`block truncate text-[10px] ${isSelected ? 'text-white/80' : 'text-charcoal'}`}> <span className={`block truncate text-[10px] ${isSelected ? 'text-white/80' : 'text-charcoal'}`}>
{tech.name} {tech.name}
</span> </span>
@@ -318,7 +318,7 @@ export function MitreMatrixModal({
: 'bg-cloud text-charcoal hover:bg-fog' : 'bg-cloud text-charcoal hover:bg-fog'
}`} }`}
> >
<span className="font-semibold block truncate">{sub.id}</span> <span className="font-mono font-semibold block truncate">{sub.id}</span>
<span className="block truncate">{sub.name}</span> <span className="block truncate">{sub.name}</span>
</button> </button>
); );

View File

@@ -107,7 +107,7 @@ export function MitreTechniquePicker({
/> />
{open && ( {open && (
<div className="absolute z-20 w-full mt-xxs bg-canvas border border-steel rounded-md shadow-floating overflow-hidden"> <div className="absolute z-20 w-full mt-xxs bg-canvas border border-steel rounded-none overflow-hidden">
{isFetching && ( {isFetching && (
<div className="px-md py-sm text-[14px] text-graphite">Searching</div> <div className="px-md py-sm text-[14px] text-graphite">Searching</div>
)} )}
@@ -144,10 +144,10 @@ export function MitreTechniquePicker({
selectItem(item); selectItem(item);
}} }}
> >
<span className="font-medium">{item.id}</span> <span className="font-mono font-medium">{item.id}</span>
<span className="text-charcoal"> {item.name}</span> <span className="text-charcoal"> {item.name}</span>
{item.tactics.length > 0 && ( {item.tactics.length > 0 && (
<span className="text-graphite"> ({item.tactics[0]})</span> <span className="font-mono text-graphite"> ({item.tactics[0]})</span>
)} )}
</li> </li>
))} ))}

View File

@@ -12,7 +12,7 @@ interface TacticTagProps {
disabled?: boolean; disabled?: boolean;
} }
// Technique chip — soft blue, id only, name in title // Technique tag — angular, soft blue, monospace ID
export function MitreTechniqueTag({ export function MitreTechniqueTag({
technique, technique,
onRemove, onRemove,
@@ -22,7 +22,7 @@ export function MitreTechniqueTag({
<span <span
data-testid="mitre-technique-tag" data-testid="mitre-technique-tag"
title={`${technique.id}${technique.name}`} title={`${technique.id}${technique.name}`}
className="inline-flex items-center gap-xxs bg-primary-soft text-primary-deep rounded-full px-sm py-xxs text-[13px] font-medium" className="tag-mitre gap-xxs"
> >
{technique.id} {technique.id}
{!disabled && ( {!disabled && (
@@ -39,7 +39,7 @@ export function MitreTechniqueTag({
); );
} }
// Tactic chip — primary blue filled, id only, name in title // Tactic tag — angular, primary blue filled, monospace ID
export function MitreTacticTag({ export function MitreTacticTag({
tactic, tactic,
onRemove, onRemove,
@@ -49,7 +49,7 @@ export function MitreTacticTag({
<span <span
data-testid="mitre-tactic-tag" data-testid="mitre-tactic-tag"
title={`${tactic.id}${tactic.name}`} title={`${tactic.id}${tactic.name}`}
className="inline-flex items-center gap-xxs bg-primary text-white rounded-full px-sm py-xxs text-[13px] font-medium" className="inline-flex items-center gap-xxs bg-primary text-white rounded-none px-sm py-xxs text-[13px] font-mono"
> >
{tactic.id} {tactic.id}
{!disabled && ( {!disabled && (

View File

@@ -116,7 +116,7 @@ export function MitreTechniquesField({
aria-label="Open MITRE matrix" aria-label="Open MITRE matrix"
onClick={() => { setShowPicker(false); setShowMatrix(true); }} onClick={() => { setShowPicker(false); setShowMatrix(true); }}
disabled={isPending} disabled={isPending}
className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-md border border-steel text-graphite hover:text-ink hover:border-ink transition-colors" className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-none border border-steel text-graphite hover:text-ink hover:border-ink"
> >
<Grid2x2 size={16} /> <Grid2x2 size={16} />
</button> </button>

View File

@@ -73,7 +73,7 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
<div className="inline-flex"> <div className="inline-flex">
<button <button
type="button" type="button"
className="btn-primary rounded-r-none border-r border-primary-deep" className="btn-primary border-r border-primary-deep"
onClick={handleBlank} onClick={handleBlank}
data-testid="new-simulation-btn" data-testid="new-simulation-btn"
> >
@@ -83,7 +83,7 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
type="button" type="button"
aria-label="More options" aria-label="More options"
aria-expanded={open} aria-expanded={open}
className="btn-primary rounded-l-none px-sm" className="btn-primary px-sm"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
data-testid="new-simulation-dropdown-toggle" data-testid="new-simulation-dropdown-toggle"
> >
@@ -93,7 +93,7 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
{open ? ( {open ? (
<div <div
className="absolute right-0 top-full mt-xxs bg-canvas border border-hairline rounded-md shadow-floating dark:shadow-floating-dark z-20 min-w-[180px]" className="absolute right-0 top-full mt-xxs bg-canvas border border-hairline rounded-none z-20 min-w-[180px]"
role="menu" role="menu"
> >
<button <button
@@ -199,7 +199,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
{sim.name} {sim.name}
</Link> </Link>
</td> </td>
<td className="px-xl py-md text-charcoal text-[14px]"> <td className="px-xl py-md text-charcoal text-[14px] font-mono">
{(() => { {(() => {
const items = [ const items = [
...(sim.tactics ?? []).map((t) => t.id), ...(sim.tactics ?? []).map((t) => t.id),
@@ -213,7 +213,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
<td className="px-xl py-md"> <td className="px-xl py-md">
<SimulationStatusBadge status={sim.status} /> <SimulationStatusBadge status={sim.status} />
</td> </td>
<td className="px-xl py-md text-charcoal text-[14px]"> <td className="px-xl py-md text-charcoal text-[14px] font-mono">
{formatDate(sim.executed_at)} {formatDate(sim.executed_at)}
</td> </td>
</tr> </tr>

View File

@@ -7,19 +7,17 @@ const LABELS: Record<SimulationStatus, string> = {
done: 'Done', done: 'Done',
}; };
// Fixed colors — badge backgrounds are decorative/semantic, not themeable.
// text-white is hardcoded (not text-canvas) so dark mode doesn't invert it to near-black.
const STYLES: Record<SimulationStatus, string> = { const STYLES: Record<SimulationStatus, string> = {
pending: 'bg-fog text-charcoal border border-hairline', pending: 'bg-cloud text-graphite border border-hairline',
in_progress: 'bg-primary-soft text-primary-deep', in_progress: 'bg-primary-soft text-primary-deep',
review_required: 'bg-bloom-coral text-white', review_required: 'bg-warn-soft text-warn',
done: 'bg-storm-deep text-white', done: 'bg-success-soft text-success',
}; };
export function SimulationStatusBadge({ status }: { status: SimulationStatus }): JSX.Element { export function SimulationStatusBadge({ status }: { status: SimulationStatus }): JSX.Element {
return ( return (
<span <span
className={`inline-flex items-center rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`} className={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`}
data-testid="simulation-status-badge" data-testid="simulation-status-badge"
data-status={status} data-status={status}
> >

View File

@@ -7,17 +7,15 @@ const LABELS: Record<EngagementStatus, string> = {
}; };
const STYLES: Record<EngagementStatus, string> = { const STYLES: Record<EngagementStatus, string> = {
// Outlined ink for planned (neutral), filled primary for active (engagement live), planned: 'bg-warn-soft text-warn border border-warn',
// outlined steel for closed (muted). Stays within DESIGN.md palette. active: 'bg-primary-soft text-primary-deep',
planned: 'bg-canvas text-ink border border-ink',
active: 'bg-primary text-white',
closed: 'bg-cloud text-graphite border border-hairline', closed: 'bg-cloud text-graphite border border-hairline',
}; };
export function StatusBadge({ status }: { status: EngagementStatus }): JSX.Element { export function StatusBadge({ status }: { status: EngagementStatus }): JSX.Element {
return ( return (
<span <span
className={`inline-flex items-center rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`} className={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`}
data-testid="status-badge" data-testid="status-badge"
data-status={status} data-status={status}
> >

View File

@@ -34,7 +34,7 @@ export function TemplatePickerModal({
> >
<div className="modal-backdrop absolute inset-0" onClick={onClose} aria-hidden="true" /> <div className="modal-backdrop absolute inset-0" onClick={onClose} aria-hidden="true" />
<div className="relative card-product shadow-floating dark:shadow-floating-dark max-w-xl w-full mx-md flex flex-col gap-md max-h-[80vh] overflow-hidden"> <div className="relative card-product max-w-xl w-full mx-md flex flex-col gap-md max-h-[80vh] overflow-hidden">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 id="tpl-picker-title" className="text-[20px] font-medium text-ink"> <h2 id="tpl-picker-title" className="text-[20px] font-medium text-ink">
From template From template
@@ -43,7 +43,7 @@ export function TemplatePickerModal({
type="button" type="button"
aria-label="Close" aria-label="Close"
onClick={onClose} onClick={onClose}
className="text-graphite hover:text-ink transition-colors text-[20px] leading-none" className="text-graphite hover:text-ink text-[20px] leading-none"
> >
× ×
</button> </button>

View File

@@ -1,9 +1,5 @@
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
/**
* Stack of toast notifications anchored bottom-right.
* Pure DESIGN.md surfaces: rounded-xl, soft-lift, ink slab for errors.
*/
export function ToastViewport(): JSX.Element { export function ToastViewport(): JSX.Element {
const { toasts, dismiss } = useToast(); const { toasts, dismiss } = useToast();
return ( return (
@@ -15,19 +11,18 @@ export function ToastViewport(): JSX.Element {
{toasts.map((t) => { {toasts.map((t) => {
const isError = t.kind === 'error'; const isError = t.kind === 'error';
const isSuccess = t.kind === 'success'; const isSuccess = t.kind === 'success';
// Fixed colors: toasts don't theme (error=dark slab, success=primary blue)
const surface = isError const surface = isError
? 'bg-slab text-slab-text' ? 'bg-paper text-ink border border-hairline border-l-4 border-l-bloom-deep'
: isSuccess : isSuccess
? 'bg-primary text-white' ? 'bg-paper text-ink border border-hairline border-l-4 border-l-success'
: 'bg-canvas text-ink border border-hairline'; : 'bg-paper text-ink border border-hairline border-l-4 border-l-primary';
return ( return (
<div <div
key={t.id} key={t.id}
role="status" role="status"
data-testid="toast" data-testid="toast"
data-kind={t.kind} data-kind={t.kind}
className={`pointer-events-auto rounded-xl px-md py-sm shadow-soft-lift text-[14px] leading-[1.4] ${surface}`} className={`pointer-events-auto rounded-none px-md py-sm text-[14px] leading-[1.4] ${surface}`}
> >
<div className="flex items-start justify-between gap-sm"> <div className="flex items-start justify-between gap-sm">
<span className="flex-1">{t.message}</span> <span className="flex-1">{t.message}</span>

View File

@@ -35,7 +35,7 @@ export function EngagementDetailPage(): JSX.Element {
<Link to="/engagements" className="btn-text-link text-[14px]"> <Link to="/engagements" className="btn-text-link text-[14px]">
Back to engagements Back to engagements
</Link> </Link>
<h1 className="text-[44px] font-medium leading-none">{eng.name}</h1> <h1 className="text-[32px] font-medium leading-none">{eng.name}</h1>
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
<StatusBadge status={eng.status} /> <StatusBadge status={eng.status} />
<span className="text-[14px] text-graphite"> <span className="text-[14px] text-graphite">
@@ -58,13 +58,13 @@ export function EngagementDetailPage(): JSX.Element {
<h2 className="text-[20px] font-medium mb-md">Schedule</h2> <h2 className="text-[20px] font-medium mb-md">Schedule</h2>
<dl className="grid grid-cols-2 gap-md text-[14px]"> <dl className="grid grid-cols-2 gap-md text-[14px]">
<dt className="text-graphite">Start date</dt> <dt className="text-graphite">Start date</dt>
<dd className="text-ink">{eng.start_date}</dd> <dd className="text-ink font-mono">{eng.start_date}</dd>
<dt className="text-graphite">End date</dt> <dt className="text-graphite">End date</dt>
<dd className="text-ink">{eng.end_date ?? '—'}</dd> <dd className="text-ink font-mono">{eng.end_date ?? '—'}</dd>
<dt className="text-graphite">Status</dt> <dt className="text-graphite">Status</dt>
<dd className="text-ink capitalize">{eng.status}</dd> <dd className="text-ink capitalize">{eng.status}</dd>
<dt className="text-graphite">Created at</dt> <dt className="text-graphite">Created at</dt>
<dd className="text-ink">{eng.created_at}</dd> <dd className="text-ink font-mono">{eng.created_at}</dd>
</dl> </dl>
</div> </div>

View File

@@ -123,7 +123,7 @@ export function EngagementFormPage(): JSX.Element {
return ( return (
<div className="flex flex-col gap-xl max-w-2xl"> <div className="flex flex-col gap-xl max-w-2xl">
<header> <header>
<h1 className="text-[44px] font-medium leading-none"> <h1 className="text-[32px] font-medium leading-none">
{editing ? 'Edit engagement' : 'New engagement'} {editing ? 'Edit engagement' : 'New engagement'}
</h1> </h1>
<p className="text-charcoal text-[16px] mt-sm"> <p className="text-charcoal text-[16px] mt-sm">

View File

@@ -35,7 +35,7 @@ export function EngagementsListPage(): JSX.Element {
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header className="flex items-end justify-between gap-md"> <header className="flex items-end justify-between gap-md">
<div> <div>
<h1 className="text-[44px] font-medium leading-none">Engagements</h1> <h1 className="text-[32px] font-medium leading-none">Engagements</h1>
<p className="text-charcoal text-[16px] mt-sm"> <p className="text-charcoal text-[16px] mt-sm">
Red team missions and their lifecycle status. Red team missions and their lifecycle status.
</p> </p>
@@ -91,8 +91,8 @@ export function EngagementsListPage(): JSX.Element {
<td className="px-xl py-md"> <td className="px-xl py-md">
<StatusBadge status={eng.status} /> <StatusBadge status={eng.status} />
</td> </td>
<td className="px-xl py-md text-charcoal">{formatDate(eng.start_date)}</td> <td className="px-xl py-md text-charcoal font-mono">{formatDate(eng.start_date)}</td>
<td className="px-xl py-md text-charcoal">{formatDate(eng.end_date)}</td> <td className="px-xl py-md text-charcoal font-mono">{formatDate(eng.end_date)}</td>
<td className="px-xl py-md text-charcoal">{eng.created_by.username}</td> <td className="px-xl py-md text-charcoal">{eng.created_by.username}</td>
<td className="px-xl py-md text-right"> <td className="px-xl py-md text-right">
<div className="inline-flex gap-sm"> <div className="inline-flex gap-sm">

View File

@@ -46,7 +46,7 @@ export function LoginPage(): JSX.Element {
{/* Chevron echo of the brand mark */} {/* Chevron echo of the brand mark */}
<div className="flex items-center gap-sm"> <div className="flex items-center gap-sm">
<span className="inline-block h-8 w-8 rotate-12 bg-primary" aria-hidden /> <span className="inline-block h-8 w-8 rotate-12 bg-primary" aria-hidden />
<h1 className="text-[32px] font-medium leading-none">Mimic</h1> <h1 className="text-[28px] font-medium leading-none">Mimic</h1>
</div> </div>
<p className="text-[16px] text-charcoal">Sign in to access your engagements.</p> <p className="text-[16px] text-charcoal">Sign in to access your engagements.</p>

View File

@@ -222,7 +222,7 @@ export function SimulationFormPage(): JSX.Element {
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]"> <Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
Back to engagement Back to engagement
</Link> </Link>
<h1 className="text-[44px] font-medium leading-none mt-sm">New simulation</h1> <h1 className="text-[32px] font-medium leading-none mt-sm">New simulation</h1>
</header> </header>
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md"> <form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
@@ -257,13 +257,13 @@ export function SimulationFormPage(): JSX.Element {
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending; updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
return ( return (
<div className="flex flex-col gap-xl max-w-3xl"> <div className="flex flex-col gap-xl">
<header className="flex items-start justify-between gap-md"> <header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm"> <div className="flex flex-col gap-sm">
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]"> <Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
Back to engagement Back to engagement
</Link> </Link>
<h1 className="text-[44px] font-medium leading-none">{rt.name || simulation?.name}</h1> <h1 className="text-[32px] font-medium leading-none">{rt.name || simulation?.name}</h1>
{status ? ( {status ? (
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
<SimulationStatusBadge status={status} /> <SimulationStatusBadge status={status} />
@@ -281,7 +281,7 @@ export function SimulationFormPage(): JSX.Element {
{isDone && ( {isDone && (
<div <div
role="status" role="status"
className="rounded-xl px-xl py-md bg-cloud border border-hairline text-[14px] text-charcoal" className="rounded-none px-xl py-md bg-cloud border border-hairline text-[14px] text-charcoal"
> >
This simulation is <strong>done</strong> and read-only. Use Reopen to make changes. This simulation is <strong>done</strong> and read-only. Use Reopen to make changes.
</div> </div>
@@ -292,12 +292,14 @@ export function SimulationFormPage(): JSX.Element {
<div <div
role="alert" role="alert"
data-testid="soc-blocked-banner" data-testid="soc-blocked-banner"
className="rounded-xl px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal" className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
> >
Simulation not yet ready for review the red team must mark it as &quot;Review required&quot; before you can fill in the SOC section. Simulation not yet ready for review the red team must mark it as &quot;Review required&quot; before you can fill in the SOC section.
</div> </div>
)} )}
{/* 2-column grid: RT left, SOC right. Stacks vertically below lg. */}
<div className="grid gap-xl lg:grid-cols-2 items-start">
{/* Red Team card */} {/* Red Team card */}
<form <form
id="rt-form" id="rt-form"
@@ -432,6 +434,7 @@ export function SimulationFormPage(): JSX.Element {
/> />
</FormField> </FormField>
</form> </form>
</div>
{submitError ? ( {submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div> <div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>

View File

@@ -130,7 +130,7 @@ export function TemplateFormPage(): JSX.Element {
<Link to="/admin/templates" className="btn-text-link text-[14px]"> <Link to="/admin/templates" className="btn-text-link text-[14px]">
Back to templates Back to templates
</Link> </Link>
<h1 className="text-[44px] font-medium leading-none"> <h1 className="text-[32px] font-medium leading-none">
{isNew ? 'New template' : (existing.data?.name ?? 'Edit template')} {isNew ? 'New template' : (existing.data?.name ?? 'Edit template')}
</h1> </h1>
</div> </div>
@@ -173,6 +173,7 @@ export function TemplateFormPage(): JSX.Element {
onChange={(e) => setForm({ ...form, commands: e.target.value })} onChange={(e) => setForm({ ...form, commands: e.target.value })}
disabled={isPending} disabled={isPending}
placeholder="e.g. mimikatz.exe&#10;sekurlsa::logonpasswords" placeholder="e.g. mimikatz.exe&#10;sekurlsa::logonpasswords"
className="font-mono text-[14px]"
/> />
</FormField> </FormField>
@@ -228,7 +229,7 @@ export function TemplateFormPage(): JSX.Element {
type="button" type="button"
aria-label="Open MITRE matrix" aria-label="Open MITRE matrix"
onClick={() => { setShowPicker(false); setShowMatrix(true); }} onClick={() => { setShowPicker(false); setShowMatrix(true); }}
className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-md border border-steel text-graphite hover:text-ink hover:border-ink transition-colors" className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-none border border-steel text-graphite hover:text-ink hover:border-ink"
> >
<Grid2x2 size={16} /> <Grid2x2 size={16} />
</button> </button>

View File

@@ -36,7 +36,7 @@ export function TemplatesListPage(): JSX.Element {
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header className="flex items-end justify-between gap-md"> <header className="flex items-end justify-between gap-md">
<div> <div>
<h1 className="text-[44px] font-medium leading-none">Templates</h1> <h1 className="text-[32px] font-medium leading-none">Templates</h1>
<p className="text-charcoal text-[16px] mt-sm"> <p className="text-charcoal text-[16px] mt-sm">
Reusable simulation blueprints for red team operations. Reusable simulation blueprints for red team operations.
</p> </p>
@@ -94,7 +94,7 @@ export function TemplatesListPage(): JSX.Element {
{mitreCount(t) === 0 ? '—' : mitreCount(t)} {mitreCount(t) === 0 ? '—' : mitreCount(t)}
</td> </td>
<td className="px-xl py-md text-charcoal">{t.created_by.username}</td> <td className="px-xl py-md text-charcoal">{t.created_by.username}</td>
<td className="px-xl py-md text-charcoal">{formatDate(t.updated_at)}</td> <td className="px-xl py-md text-charcoal font-mono">{formatDate(t.updated_at)}</td>
<td className="px-xl py-md text-right"> <td className="px-xl py-md text-right">
<div className="inline-flex gap-sm"> <div className="inline-flex gap-sm">
<Link to={`/admin/templates/${t.id}/edit`} className="btn-text-link"> <Link to={`/admin/templates/${t.id}/edit`} className="btn-text-link">

View File

@@ -102,7 +102,7 @@ export function UsersAdminPage(): JSX.Element {
return ( return (
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header> <header>
<h1 className="text-[44px] font-medium leading-none">User accounts</h1> <h1 className="text-[32px] font-medium leading-none">User accounts</h1>
<p className="text-charcoal text-[16px] mt-sm"> <p className="text-charcoal text-[16px] mt-sm">
Manage local accounts. Admins can create new red team or SOC analysts. Manage local accounts. Admins can create new red team or SOC analysts.
</p> </p>
@@ -205,10 +205,10 @@ export function UsersAdminPage(): JSX.Element {
// per-row reconciliation (reset-password state leaked across rows). // per-row reconciliation (reset-password state leaked across rows).
<Fragment key={u.id}> <Fragment key={u.id}>
<tr className="border-b border-hairline last:border-0"> <tr className="border-b border-hairline last:border-0">
<td className="px-xl py-md font-medium text-ink"> <td className="px-xl py-md text-ink">
{u.username} <span className="font-mono font-medium">{u.username}</span>
{isSelf ? ( {isSelf ? (
<span className="ml-sm text-[12px] text-graphite uppercase tracking-[0.5px]"> <span className="ml-sm font-sans text-[12px] text-graphite">
(you) (you)
</span> </span>
) : null} ) : null}
@@ -222,7 +222,7 @@ export function UsersAdminPage(): JSX.Element {
disabled={patchMutation.isPending} disabled={patchMutation.isPending}
/> />
</td> </td>
<td className="px-xl py-md text-charcoal">{u.created_at}</td> <td className="px-xl py-md text-charcoal font-mono">{u.created_at}</td>
<td className="px-xl py-md text-right"> <td className="px-xl py-md text-right">
<div className="inline-flex gap-sm"> <div className="inline-flex gap-sm">
<button <button

View File

@@ -1,6 +1,7 @@
/* /*
* Inter Variable — bundled locally via @fontsource-variable/inter. * Inter Variable — bundled locally via @fontsource-variable/inter.
* JetBrains Mono Variable — bundled locally via @fontsource-variable/jetbrains-mono.
* NO remote CDN / Google Fonts loading at runtime. * NO remote CDN / Google Fonts loading at runtime.
* Forma DJR Micro substitute per DESIGN.md §Note on Font Substitutes.
*/ */
@import '@fontsource-variable/inter/index.css'; @import '@fontsource-variable/inter/index.css';
@import '@fontsource-variable/jetbrains-mono/index.css';

View File

@@ -19,9 +19,11 @@
--color-ink-on: #ffffff; --color-ink-on: #ffffff;
--color-charcoal: #3d3d3d; --color-charcoal: #3d3d3d;
--color-graphite: #636363; --color-graphite: #636363;
/* Semantic status tokens — WCAG AA on canvas and slab surfaces */
/* DESIGN.md: body line-height 1.4 when substituting Inter */ --color-success: #16a34a;
font-size: 16.5px; --color-success-soft: #dcfce7;
--color-warn: #d97706;
--color-warn-soft: #fef3c7;
} }
/* Dark mode overrides */ /* Dark mode overrides */
@@ -38,6 +40,11 @@
--color-ink-on: #111827; --color-ink-on: #111827;
--color-charcoal: #d1d5db; --color-charcoal: #d1d5db;
--color-graphite: #9ca3af; --color-graphite: #9ca3af;
/* Semantic status tokens — dark variants, WCAG AA on #111827 slab */
--color-success: #22c55e;
--color-success-soft: #14532d;
--color-warn: #f59e0b;
--color-warn-soft: #78350f;
} }
html, html,
@@ -48,6 +55,8 @@
body { body {
@apply bg-canvas text-ink font-sans antialiased; @apply bg-canvas text-ink font-sans antialiased;
font-size: 16px;
line-height: 1.4;
font-feature-settings: 'cv11', 'ss01'; font-feature-settings: 'cv11', 'ss01';
} }
@@ -57,19 +66,20 @@
h4, h4,
h5, h5,
h6 { h6 {
line-height: 1; line-height: 1.1;
font-weight: 500; font-weight: 500;
} }
} }
@layer components { @layer components {
/* /*
* DESIGN.md component recipes. * Terminal-SOC brutalist component recipes.
* Buttons stay sharp (rounded-md = 4px); cards stay soft (rounded-xl = 16px). * border-radius: 0 everywhere except .badge-pill-* (status pills only).
* No transition-* on any interactive surface.
*/ */
.btn-primary { .btn-primary {
@apply inline-flex items-center justify-center gap-xs bg-primary text-white uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors; @apply inline-flex items-center justify-center gap-xs bg-primary text-white uppercase font-semibold text-[14px] leading-[1.4] rounded-none px-xl py-sm h-11;
} }
.btn-primary:hover { .btn-primary:hover {
@apply bg-primary-deep; @apply bg-primary-deep;
@@ -78,19 +88,19 @@
@apply bg-steel cursor-not-allowed; @apply bg-steel cursor-not-allowed;
} }
/* btn-ink uses fixed dark slab so it doesn't invert in dark mode */ /* btn-ink: fixed dark slab does not invert in dark mode */
.btn-ink { .btn-ink {
@apply inline-flex items-center justify-center gap-xs bg-slab text-white uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors; @apply inline-flex items-center justify-center gap-xs bg-slab text-slab-text uppercase font-semibold text-[14px] leading-[1.4] rounded-none px-xl py-sm h-11;
} }
.btn-ink:hover { .btn-ink:hover {
@apply bg-paper; @apply opacity-80;
} }
.btn-ink:disabled { .btn-ink:disabled {
@apply bg-steel cursor-not-allowed; @apply bg-steel cursor-not-allowed;
} }
.btn-outline { .btn-outline {
@apply inline-flex items-center justify-center gap-xs bg-canvas text-primary border border-primary uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors; @apply inline-flex items-center justify-center gap-xs bg-canvas text-primary border border-primary uppercase font-semibold text-[14px] leading-[1.4] rounded-none px-xl py-sm h-11;
} }
.btn-outline:hover { .btn-outline:hover {
@apply bg-primary-soft; @apply bg-primary-soft;
@@ -100,37 +110,41 @@
} }
.btn-outline-ink { .btn-outline-ink {
@apply inline-flex items-center justify-center gap-xs bg-canvas text-ink border border-ink uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors; @apply inline-flex items-center justify-center gap-xs bg-canvas text-ink border border-ink uppercase font-semibold text-[14px] leading-[1.4] rounded-none px-xl py-sm h-11;
} }
.btn-outline-ink:hover { .btn-outline-ink:hover {
@apply bg-cloud; @apply bg-cloud;
} }
.btn-text-link { .btn-text-link {
@apply inline-flex items-center gap-xxs text-primary font-medium text-[16px] leading-[1.38] underline-offset-2 hover:underline; @apply inline-flex items-center gap-xxs text-primary font-medium text-[16px] leading-[1.4] underline-offset-2 hover:underline;
} }
.text-input { .text-input {
@apply block w-full bg-canvas text-ink rounded-md border border-steel px-md py-sm h-11 text-[16px] leading-[1.38] focus:outline-none focus:border-ink; @apply block w-full bg-canvas text-ink rounded-none border border-steel px-md py-sm h-11 text-[16px] leading-[1.4] focus:outline-none focus:border-primary;
} }
/* Panel / card — hairline border, no shadow, no radius */
.card-product { .card-product {
@apply bg-canvas rounded-xl p-xl shadow-soft-lift; @apply bg-paper border border-hairline rounded-none p-md;
}
.dark .card-product {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.32);
} }
/* Fixed-color modal backdrop — must not use themed ink (inverts in dark mode) */ /* Fixed modal backdrop — must not use themed ink (inverts in dark mode) */
.modal-backdrop { .modal-backdrop {
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.6);
} }
/* Status pill badges — ONLY surfaces with rounded-pill */
.badge-pill-ink { .badge-pill-ink {
@apply inline-flex items-center bg-ink text-white rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium; @apply inline-flex items-center bg-ink text-white rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
} }
.badge-pill-outline { .badge-pill-outline {
@apply inline-flex items-center bg-canvas text-ink border border-ink rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium; @apply inline-flex items-center bg-canvas text-ink border border-ink rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
}
/* MITRE technique tags — angular, not pills */
.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];
} }
} }

View File

@@ -1,8 +1,8 @@
import type { Config } from 'tailwindcss'; import type { Config } from 'tailwindcss';
/** /**
* Tokens mirror DESIGN.md. * Tokens mirror DESIGN.md (terminal-SOC aesthetic, sprint 7).
* Forma DJR Micro substitut: Inter (bundled locally via @fontsource-variable/inter). * Inter Variable: body/headers/labels. JetBrains Mono Variable: data cells only.
* Dark mode: class-based, toggled by adding 'dark' to <html>. * Dark mode: class-based, toggled by adding 'dark' to <html>.
*/ */
const config: Config = { const config: Config = {
@@ -11,7 +11,7 @@ const config: Config = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
// Brand & Accent — primary stays fixed (HP Electric Blue never inverts) // Brand & Accent — primary stays fixed (never inverts)
primary: { primary: {
DEFAULT: '#024ad8', DEFAULT: '#024ad8',
bright: '#296ef9', bright: '#296ef9',
@@ -34,10 +34,19 @@ const config: Config = {
}, },
charcoal: 'var(--color-charcoal)', charcoal: 'var(--color-charcoal)',
graphite: 'var(--color-graphite)', graphite: 'var(--color-graphite)',
// Fixed dark slab — never inverts in dark mode (utility strip, footer, dark bands) // Fixed dark slab — never inverts in dark mode
slab: '#111827', slab: '#111827',
'slab-text': '#f9fafb', 'slab-text': '#f9fafb',
'slab-muted': '#6b7280', 'slab-muted': '#6b7280',
// Semantic status tokens — light+dark variants via CSS vars
success: {
DEFAULT: 'var(--color-success)',
soft: 'var(--color-success-soft)',
},
warn: {
DEFAULT: 'var(--color-warn)',
soft: 'var(--color-warn-soft)',
},
// Semantic / decorative — fixed (not themeable) // Semantic / decorative — fixed (not themeable)
bloom: { bloom: {
coral: '#ff5050', coral: '#ff5050',
@@ -53,26 +62,28 @@ const config: Config = {
}, },
fontFamily: { fontFamily: {
sans: ['"Inter Variable"', 'Inter', 'Arial', 'sans-serif'], sans: ['"Inter Variable"', 'Inter', 'Arial', 'sans-serif'],
mono: ['"JetBrains Mono Variable"', '"JetBrains Mono"', 'ui-monospace', 'monospace'],
}, },
fontSize: { fontSize: {
// DESIGN.md typography scale // Terminal-SOC display scale — reduced per §0 D9
'display-xxl': ['72px', { lineHeight: '1.0', fontWeight: '500' }], 'display-xxl': ['40px', { lineHeight: '1.1', fontWeight: '500' }],
'display-xl': ['56px', { lineHeight: '1.0', fontWeight: '500' }], 'display-xl': ['32px', { lineHeight: '1.1', fontWeight: '500' }],
'display-lg': ['44px', { lineHeight: '1.0', fontWeight: '500' }], 'display-lg': ['28px', { lineHeight: '1.1', fontWeight: '500' }],
'display-md': ['32px', { lineHeight: '1.0', fontWeight: '500' }], 'display-md': ['24px', { lineHeight: '1.1', fontWeight: '500' }],
'display-sm': ['24px', { lineHeight: '1.17', fontWeight: '500' }], 'display-sm': ['20px', { lineHeight: '1.1', fontWeight: '500' }],
'display-xs': ['20px', { lineHeight: '1.0', fontWeight: '500' }], 'display-xs': ['16px', { lineHeight: '1.1', fontWeight: '600' }],
'body-lg': ['18px', { lineHeight: '1.33', fontWeight: '400' }], 'body-lg': ['18px', { lineHeight: '1.4', fontWeight: '400' }],
'body-md': ['16px', { lineHeight: '1.38', fontWeight: '400' }], 'body-md': ['16px', { lineHeight: '1.4', fontWeight: '400' }],
'body-emphasis': ['16px', { lineHeight: '1.38', fontWeight: '500' }], 'body-emphasis': ['16px', { lineHeight: '1.4', fontWeight: '500' }],
'caption-md': ['14px', { lineHeight: '1.5', fontWeight: '400' }], 'caption-md': ['14px', { lineHeight: '1.5', fontWeight: '400' }],
'caption-bold': ['14px', { lineHeight: '1.3', fontWeight: '700' }], 'caption-bold': ['14px', { lineHeight: '1.3', fontWeight: '700' }],
'caption-sm': ['12px', { lineHeight: '1.33', fontWeight: '400' }], 'caption-sm': ['12px', { lineHeight: '1.33', fontWeight: '400' }],
'link-md': ['16px', { lineHeight: '1.38', fontWeight: '500' }], 'link-md': ['16px', { lineHeight: '1.4', fontWeight: '500' }],
'button-md': ['14px', { lineHeight: '1.4', fontWeight: '600', letterSpacing: '0.7px' }], 'button-md': ['14px', { lineHeight: '1.4', fontWeight: '600' }],
'button-sm': ['12.6px', { lineHeight: '1.0', fontWeight: '700' }],
}, },
spacing: { spacing: {
// DESIGN.md spacing tokens (named, complement Tailwind defaults) // Named tokens complement Tailwind defaults
xxs: '4px', xxs: '4px',
xs: '8px', xs: '8px',
sm: '12px', sm: '12px',
@@ -80,24 +91,13 @@ const config: Config = {
lg: '20px', lg: '20px',
xl: '24px', xl: '24px',
xxl: '32px', xxl: '32px',
section: '80px', section: '48px',
}, },
borderRadius: { borderRadius: {
// DESIGN.md radius tokens // Brutalist: 0 everywhere except status pills and avatars
none: '0px', none: '0px',
xs: '2px',
sm: '3px',
md: '4px',
lg: '8px',
xl: '16px',
pill: '9999px', pill: '9999px',
}, },
boxShadow: {
'soft-lift': '0 2px 8px rgba(26, 26, 26, 0.08)',
floating: '0 8px 24px rgba(26, 26, 26, 0.12)',
'soft-lift-dark': '0 2px 8px rgba(0, 0, 0, 0.32)',
'floating-dark': '0 8px 24px rgba(0, 0, 0, 0.48)',
},
maxWidth: { maxWidth: {
page: '1366px', page: '1366px',
}, },

View File

@@ -18,10 +18,10 @@ describe('SimulationStatusBadge', () => {
expect(badge.textContent).toBe(label); expect(badge.textContent).toBe(label);
}); });
it('applies fog surface for pending', () => { it('applies cloud surface for pending (terminal-SOC semantic tokens)', () => {
render(<SimulationStatusBadge status="pending" />); render(<SimulationStatusBadge status="pending" />);
const badge = screen.getByTestId('simulation-status-badge'); const badge = screen.getByTestId('simulation-status-badge');
expect(badge.className).toContain('bg-fog'); expect(badge.className).toContain('bg-cloud');
}); });
it('applies primary-soft surface for in_progress', () => { it('applies primary-soft surface for in_progress', () => {
@@ -30,15 +30,15 @@ describe('SimulationStatusBadge', () => {
expect(badge.className).toContain('bg-primary-soft'); expect(badge.className).toContain('bg-primary-soft');
}); });
it('applies bloom-coral surface for review_required', () => { it('applies warn-soft surface for review_required (terminal-SOC semantic tokens)', () => {
render(<SimulationStatusBadge status="review_required" />); render(<SimulationStatusBadge status="review_required" />);
const badge = screen.getByTestId('simulation-status-badge'); const badge = screen.getByTestId('simulation-status-badge');
expect(badge.className).toContain('bg-bloom-coral'); expect(badge.className).toContain('bg-warn-soft');
}); });
it('applies storm-deep surface for done', () => { it('applies success-soft surface for done (terminal-SOC semantic tokens)', () => {
render(<SimulationStatusBadge status="done" />); render(<SimulationStatusBadge status="done" />);
const badge = screen.getByTestId('simulation-status-badge'); const badge = screen.getByTestId('simulation-status-badge');
expect(badge.className).toContain('bg-storm-deep'); expect(badge.className).toContain('bg-success-soft');
}); });
}); });

View File

@@ -9,4 +9,19 @@ describe('StatusBadge', () => {
expect(badge).toHaveAttribute('data-status', status); expect(badge).toHaveAttribute('data-status', status);
expect(badge.textContent?.toLowerCase()).toBe(status); expect(badge.textContent?.toLowerCase()).toBe(status);
}); });
it('applies warn-soft surface for planned', () => {
render(<StatusBadge status="planned" />);
expect(screen.getByTestId('status-badge').className).toContain('bg-warn-soft');
});
it('applies primary-soft surface for active', () => {
render(<StatusBadge status="active" />);
expect(screen.getByTestId('status-badge').className).toContain('bg-primary-soft');
});
it('applies cloud surface for closed', () => {
render(<StatusBadge status="closed" />);
expect(screen.getByTestId('status-badge').className).toContain('bg-cloud');
});
}); });

View File

@@ -1,281 +1,153 @@
# Sprint 6Engagement export (Markdown + CSV + PDF) # Sprint 7Design Refresh: Terminal-SOC Aesthetic
> Branch : `sprint/6-export` · Worktree : `.claude/worktrees/sprint-6-export` · Base : `main` @ `678ee8f` > Branch : `sprint/7-design` · Worktree : `.claude/worktrees/sprint-7-design` · Base : `main` @ `e27babe`
## §0 — Binding decisions (locked with the user 2026-06-07) ## §0 — Binding decisions (locked with user, 2026-06-09)
1. **Scope du sprint** : export d'un engagement (header + toutes ses simulations RT + SOC) vers Markdown, CSV et PDF — clôt la boucle « remplace l'utilisation d'un fichier excel plat partagé entre la redteam et les analystes SOC en fin de mission ». 1. **Visual direction**: Bloomberg / Terminal-SOC — dense, brutalist, semantic colors strong, no ornament.
2. **Formats livrés** : Markdown, CSV, PDF (3 formats). JSON exclu (redondant avec l'API existante). 2. **Border-radius**: **0 everywhere** except status pills (`rounded-pill`) and avatars (round). All buttons, cards, modals, inputs, dropdowns, tables, tags → angular.
3. **RBAC** : `admin` + `redteam` peuvent exporter. **SOC = pas d'accès** (pas de bouton dans l'UI, endpoint `/api/engagements/<eid>/export` → 403). Cohérent avec le pattern templates sprint 5 (livrable RedTeam). 3. **Palette**: KEEP current (`#024ad8` primary blue, `slab #111827`, `canvas/paper/cloud/fog/ink` light+dark vars). ADD semantic tokens `success-green` + `warn-amber` (confirmed scope add — needed for SOC-grade status legibility on dashboards and badges).
4. **Contenu de l'export** : Engagement header (name, description, dates, status, created_by, created_at) + **toutes** les simulations de l'engagement, avec leurs champs RT (name, techniques, tactics, description, commands, prerequisites, executed_at, execution_result, status) ET SOC (log_source, logs, soc_comment, incident_number). Ordre des simulations : `id ASC` (ordre de création). 4. **Scope**: Refonte globale en 1 sprint (all 8 pages + 17 components + tokens + DESIGN.md).
5. **Déclenchement UI** : un bouton **split-button dropdown** sur `EngagementDetailPage` libellé `[Export ▼]`, qui ouvre un menu `Markdown / CSV / PDF`. Click → download direct (Blob + `URL.createObjectURL`). Pas de modal de configuration. Pattern réutilisé du dropdown sprint 5 (`SimulationList`). 5. **Monospace**: data-only — JetBrains Mono for IDs, dates ISO, commands, execution output, MITRE techniques, metrics. Inter stays for body/labels/headers.
6. **Mono font**: JetBrains Mono, bundled locally via `@fontsource-variable/jetbrains-mono` (consistent with existing Inter bundle).
7. **Modes**: KEEP light + dark both. Toggle stays.
8. **Animations**: **Brutalist — zero transition**. Remove all `transition-*` utilities, focus rings sharp, hover instantaneous.
9. **Display scale reduction**: locked. `display-xxl 72→40`, `display-xl 56→32`, `display-lg 44→28`, `display-md 32→24`, `display-sm 24→20`, `display-xs 20→16`. Headers stay modest in terminal aesthetic — no editorial flourish at hero scale.
### Décisions techniques arrêtées par le team-lead (à challenger par spec-reviewer) ## §1 — Pre-work checks (team-lead, before dispatch)
6. **Endpoint backend** : **un seul** endpoint `GET /api/engagements/<eid>/export?format=md|csv|pdf` plutôt que 3 endpoints distincts. Une seule route à protéger (RBAC), un seul test d'intégration RBAC, switch sur `format` en interne. Format inconnu → **400** `{error: "format must be one of: md, csv, pdf"}`. Format manquant → **400** (pas de défaut implicite — évite l'ambiguïté). - [ ] Confirm `tasks/lessons.md` has nothing contradicting this brief
7. **Markdown** : généré via string templating Python (pas de lib externe). Simple, déterministe, testable par assertion de sous-chaînes. - [ ] Verify uncommitted `.claude/agents/frontend-builder.md` patch (Skill mandatory) is restored in worktree — sprint hygiene
8. **CSV** : généré via `csv.writer` (stdlib). Une ligne d'en-tête + N lignes simulations. Colonnes : `id, name, status, techniques (joined "|"), tactics (joined "|"), description, commands, prerequisites, executed_at, execution_result, log_source, logs, soc_comment, incident_number, created_at, updated_at`. **Pas de header engagement dans le CSV** (format machine-readable strict) ; l'engagement context sort dans le filename. - [ ] Send plan to **spec-reviewer** for 2-pass approval (vs SPEC.md, vs §0 binding decisions). MUST be APPROVED before any code touches `frontend/`.
9. **PDF** : généré via **WeasyPrint** (Python HTML→PDF, lib mature, qualité de rendu pro, dépendances système cairo/pango/gdk-pixbuf à ajouter au `python:3.12-slim` du Dockerfile). Pipeline : on génère **le même HTML** que pour le Markdown (mais wrappé en `<html>...<style>...</html>`), puis WeasyPrint le rend en PDF. Le styling CSS est inline (≤ 30 lignes : hierarchy h1/h2/h3, code-block monospace, alternance fond pour les simulations). Pas de logo / page de garde — keep it simple. - [ ] After approval: dispatch frontend-builder with this todo as brief.
10. **Filename convention** : `engagement-<id>-<slugified-name>-YYYYMMDD.{ext}`. Slugification :
```python
import unicodedata, re
normalized = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode()
slug = re.sub(r'[^a-z0-9]+', '-', normalized.lower()).strip('-')[:60] or "unnamed"
```
Le NFKD-strip enlève les accents proprement (`Opération` → `Operation`), le fallback `"unnamed"` couvre le edge case d'un nom 100 % non-alphanum (`"---!!!"` → `""` → `"unnamed"`). `YYYYMMDD` = `date.today().strftime('%Y%m%d')` côté serveur. Le frontend lit `Content-Disposition` pour le nom du fichier.
11. **Content-Type** : `text/markdown; charset=utf-8`, `text/csv; charset=utf-8`, `application/pdf`.
12. **Génération synchrone** : Flask renvoie le fichier dans la même requête (un engagement = quelques dizaines de simulations max, génération < 500 ms même PDF). Pas de job async.
13. **Pas de cache** : chaque export régénère depuis la DB (état toujours frais).
14. **Frontend client** : pour télécharger, on utilise un fetch avec `responseType: 'blob'`, on lit `Content-Disposition` pour le filename, puis `URL.createObjectURL` + `<a>` invisible + `click()`. Pas de navigation. Le bouton `[Export ▼]` utilise la classe **`btn-outline`** (la même que le bouton `Edit` du header existant — cohérence visuelle directe). Le dropdown wrapper réutilise le même token set que le sprint 5 `NewSimulationDropdown` (`shadow-floating` + `dark:shadow-floating-dark`, `bg-canvas` + `dark:bg-fog`).
### Points OUVERTS pour le spec-reviewer (à valider Pass 1) ## §2 — Sprint hygiene (commit #1)
- **WeasyPrint vs alternatives** : retenu pour rendu pro + pipeline HTML mutualisable. Alternatives écartées : `reportlab` (layout programmatique = beaucoup plus de code), `xhtml2pdf` (rendu inférieur), `pdfkit + wkhtmltopdf` (binaire externe en archive partielle). Le coût Dockerfile (≈ 50 MB de libs cairo/pango) est accepté. - [ ] `chore(agents): frontend-builder must invoke Skill frontend-design before UI work` — lands BEFORE design work so the agent itself triggers the Skill on first call this sprint.
- **CSV sans header engagement** : choix de pureté tabulaire (Excel-friendly direct). Le team-lead a tranché. Spec-reviewer doit confirmer ou proposer la variante "1 ligne commentaire `# Engagement: <name>`".
- **Pas de JSON export** : redondant avec l'API. À confirmer.
- **Statut `done` inclus comme tous les autres** : pas de filtre par défaut. L'utilisateur exporte toujours TOUT.
--- ## §3 — Foundation: DESIGN.md + tokens (commits #2#4)
## §1 — Backend (Sonnet · backend-builder) ### §3.1 DESIGN.md rewrite (commit #2)
### Modèle de données - [ ] Replace current HP-catalog doc (346 lines, off-brand) with terminal-SOC spec covering:
**Aucun changement** de modèle. Pas de migration. L'export est en lecture seule sur les modèles existants `Engagement` + `Simulation`. - **Overview**: brutalist BAS Purple Team console, angular surfaces, semantic color signals, data-monospace hybrid
- **Colors**: keep all existing tokens with **role redefinition** for terminal-SOC context. Primary = neutral action. Bloom-deep/coral = destructive/alert. ADD `success` (green) + `warn` (amber) — locked §0 D3 — with light + dark variants and WCAG AA contrast on slab and canvas surfaces
- **Typography**: Inter (body/headers/labels) + JetBrains Mono (data). Concrete tier table with size/weight/line-height
- **Layout**: tighter spacing (replace `section 80px``section 48px`; halve card padding on dense surfaces)
- **Shapes**: ALL radii = 0 except `rounded-pill` reserved for status badges and avatars
- **Components**: re-document `btn-*`, `text-input`, `card-*`, `badge-*`, `nav-*`, `modal-*` with brutalist specs (no shadow, hairline borders, zero transition)
- **Do's/Don'ts**: zero rounded on conteneurs; zero transitions; semantic colors only on status surfaces; mono ONLY for data, never headers
- **Iteration guide**
- [ ] Doc lives in English (in-repo).
### Services / serializers ### §3.2 Tailwind token refresh (commit #3)
- Nouveau module **`backend/app/services/export.py`** avec 3 fonctions pures testables unitairement :
- `render_engagement_markdown(engagement: Engagement, simulations: list[Simulation]) -> str`
- `render_engagement_csv(engagement: Engagement, simulations: list[Simulation]) -> str`
- `render_engagement_pdf(engagement: Engagement, simulations: list[Simulation]) -> bytes`
- Le rendu Markdown réutilise `_enrich_techniques` + `_enrich_tactics` de `serializers.py` pour avoir `[{id, name}]` au lieu de juste les IDs.
- Le rendu PDF construit l'HTML à partir d'un template Python `_render_engagement_html(engagement, simulations) -> str` (string templating, pas Jinja — KISS) **et le passe à `weasyprint.HTML(string=html).write_pdf()`. Important : le PDF est généré à partir des MÊMES DONNÉES (engagement + simulations) que le Markdown, PAS à partir du string Markdown — `_render_engagement_html` est un rendu distinct.**
- **Rendu de `created_by`** : pour Markdown et CSV, on rend la `username` seule (`engagement.created_by.username`), pas la dict `{id, username}`. Pour la cohérence du livrable handoff. Idem pour `simulation.created_by`.
- **MITRE non chargé** : si le bundle n'est pas chargé, `_enrich_techniques` retourne `tactics: []` silencieusement (cohérent avec `serialize_simulation` existant — pas de 503 dans l'export). Le render doit continuer sans crash. Test dédié exigé (cf. § Tests).
### Endpoint - [ ] `frontend/tailwind.config.ts`:
- Extension du blueprint `engagements_bp` existant. Path : `GET /api/engagements/<int:eid>/export?format=md|csv|pdf`. - `borderRadius`: keep `none: 0`, keep `pill: 9999px`. Drop or stop using `xs/sm/md/lg/xl` for surfaces — keep only if a badge variant needs `2px`
- Décorateur : `@role_required("admin", "redteam")`. - ADD `fontFamily.mono`: `['JetBrains Mono Variable', 'JetBrains Mono', 'ui-monospace', 'monospace']`
- Logique : - ADD semantic colors (locked §0): `success: { DEFAULT, soft }` (green) + `warn: { DEFAULT, soft }` (amber). Pull dark-mode variants from CSS vars too. Suggested anchors — `success #16a34a` (dark `#22c55e`), `warn #d97706` (dark `#f59e0b`); design-reviewer audits WCAG AA at both modes.
1. Charger l'engagement (404 si absent). - Reduce `display-*` scale (locked §0): `display-xxl 72px → 40px`, `display-xl 56→32`, `display-lg 44→28`, `display-md 32→24`, `display-sm 24→20`, `display-xs 20→16` — terminal headers are modest
2. Parse `format` query param. Format manquant ou inconnu → 400 `{error: "format must be one of: md, csv, pdf"}`. - Drop `tracking[0.7px]` and uppercase from `button-md` (still ALLCAPS via class but no letter-spacing)
3. Charger les simulations triées par `id ASC`. - Drop shadow tokens or keep but ensure no component class applies them
4. Appeler la fonction `render_engagement_<fmt>(engagement, simulations)`. - [ ] `frontend/src/styles/index.css`:
5. Construire la `Response` avec `Content-Type`, `Content-Disposition: attachment; filename="<slug>.<ext>"`, et le body. - Drop `font-size: 16.5px` root bump (back to `16px` standard)
- Filename helper : `_export_filename(engagement, ext) -> str` (slugifier + date). - Set body `line-height: 1.4`, tighten headings to 1.1
- Rewrite `.btn-primary/ink/outline/outline-ink`: `rounded-none`, NO `transition-colors`, keep `uppercase`, drop `tracking-[0.7px]`, keep `h-11` (touch target)
- Rewrite `.text-input`: `rounded-none`, focus border-primary sharp (no halo), no transition
- Rewrite `.card-product` and any `.card-*`: `rounded-none`, no shadow, 1px hairline border for separation
- Rewrite `.badge-pill-*`: keep `rounded-pill` ONLY here (status badges); strip uppercase if applied
- Rewrite `.modal-backdrop`: same dark backdrop, no rounded for the modal frame itself
- ADD `.mono` utility or rely on Tailwind's `font-mono` (preferred) for data cells
### Tests ### §3.3 JetBrains Mono bundle (commit #4)
**Cible : 226 → 245+ pytest passing.**
Fichiers nouveaux : - [ ] `cd frontend && npm i @fontsource-variable/jetbrains-mono`
- `backend/tests/test_export_engagement.py` — couvre l'endpoint + RBAC + format inconnu. - [ ] `frontend/src/styles/fonts.css`: add `@import '@fontsource-variable/jetbrains-mono'`
- `test_export_markdown_admin_returns_md_with_engagement_header_and_all_simulations` - [ ] No CDN. Confirms via `npm ls @fontsource-variable/jetbrains-mono`.
- `test_export_markdown_redteam_ok`
- `test_export_markdown_soc_403`
- `test_export_csv_returns_csv_with_one_row_per_simulation`
- `test_export_csv_columns_match_contract` (assert exact header row)
- `test_export_csv_escapes_special_characters` (commands avec virgule, guillemet, newline)
- `test_export_pdf_returns_pdf_magic_bytes_and_non_empty`
- `test_export_unknown_format_400`
- `test_export_missing_format_400`
- `test_export_unknown_engagement_404`
- `test_export_engagement_with_zero_simulations_renders_header_only`
- `test_export_unauthenticated_401`
- `test_export_filename_slugifies_name_and_carries_date`
- `backend/tests/test_export_render.py` — tests unitaires sur les 3 fonctions pures.
- `test_render_engagement_markdown_includes_header_fields`
- `test_render_engagement_markdown_lists_all_simulations_in_order`
- `test_render_engagement_markdown_includes_techniques_with_id_and_name`
- `test_render_engagement_markdown_includes_tactics`
- `test_render_engagement_markdown_includes_soc_fields_even_when_blank` (cohérence handoff)
- `test_render_engagement_markdown_escapes_backticks_in_commands` (fenced code block safety)
- `test_render_engagement_csv_has_header_row`
- `test_render_engagement_csv_joins_multi_techniques_with_pipe`
- `test_render_engagement_pdf_starts_with_pdf_magic` (assert `output[:4] == b'%PDF'`)
- `test_render_engagement_markdown_with_mitre_bundle_not_loaded_does_not_crash` (assert render OK et contient les technique IDs même quand le bundle MITRE est absent — sécurise les Docker cold-starts)
### Dépendances ## §4 — Component sweep (commit #5)
- `weasyprint>=60.0` ajouté à `backend/requirements.txt`.
- `docker/Dockerfile` stage Python : ajouter les libs minimales WeasyPrint pour Debian slim. **Set minimal pour text-only PDF** :
```
apt-get install -y libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 shared-mime-info
```
**Note** : `libgdk-pixbuf-2.0-0` n'est requis QUE si on intègre des images dans le PDF. Notre rendu est text-only → on peut s'en passer. Le builder confirme via `weasyprint --info` dans le container après build. Documenter dans le PR.
### Livrable backend-builder (summary attendu) Rule: `rounded-*``rounded-none` unless explicitly an avatar or status pill; remove `transition-*`; data text → `font-mono`.
- **PREMIÈRE LIGNE OBLIGATOIRE** du summary (lesson sprint 5 — URL drift silencieuse interdite) :
```
endpoint final = GET /api/engagements/<int:eid>/export?format=md|csv|pdf
```
Texte EXACT, pas paraphrasé. Si le builder a choisi un autre path, il le déclare ici en deviation.
- Tous les fichiers créés/modifiés
- Contrat API précis (statuts, query params, headers de réponse) en table
- Liste des helpers réutilisés (`_enrich_techniques`, `_enrich_tactics`, `serialize_user_brief`)
- **Section "Déviations vs plan"** explicite (cf. lesson sprint 5)
- Résultats pytest + ruff + mypy
--- - [ ] `Layout.tsx`: nav-bar/utility-strip already angular — confirm. Remove `transition-colors` on theme button and hover-underline transitions. Mono font for any data label exposed (e.g. user.role pill).
- [ ] `StatusBadge.tsx`: KEEP rounded → switch to `rounded-pill` (it's a status pill, locked exception). Audit semantic mapping (planned/active/closed → semantic tokens once added).
- [ ] `SimulationStatusBadge.tsx`: same — `rounded-pill`, semantic colors aligned with new tokens.
- [ ] `FormField.tsx`: angular inputs (already via `.text-input` recipe — confirm).
- [ ] `EmptyState.tsx`: angular wrapper. No rounded illustration container.
- [ ] `ErrorState.tsx`: angular. Bloom-deep border-left if signalling.
- [ ] `LoadingState.tsx`: drop any rounded spinner background. Spinner shape ok.
- [ ] `ConfirmDialog.tsx`: angular modal. Buttons via new `.btn-*` recipes.
- [ ] `Toast.tsx`: angular. Semantic color border-left strip.
- [ ] `ExportEngagementButton.tsx` (sprint 6): angular dropdown menu. Audit `rounded-*` in the menu/item classes.
- [ ] `MitreMatrixModal.tsx`: angular modal. Cells already grid — confirm no rounded.
- [ ] `MitreTechniquePicker.tsx`: angular dropdown.
- [ ] `MitreTechniquesField.tsx`: angular chips.
- [ ] `MitreTechniqueTag.tsx`: angular tag (NOT pill — terminal tag, not a status). Decide once and apply consistently across MITRE surfaces.
- [ ] `TemplatePickerModal.tsx`: angular modal.
- [ ] `SimulationList.tsx`: angular table. Data cells (commands, executed_at, MITRE techniques) → `font-mono`.
- [ ] `ProtectedRoute.tsx`: no visual surface, skip.
## §2Frontend (Sonnet · frontend-builder) ## §5Page sweep (commit #6)
### Composants For each page: header/body/footer review, replace rounded card containers with angular hairline-bordered containers, ensure data cells use mono.
- **`ExportEngagementButton.tsx`** (nouveau) : split-button dropdown style sprint 5.
- Bouton principal `Export` (icône `Download` lucide-react) + chevron à droite (icône `ChevronDown`).
- **IMPORTANT — différence sémantique vs `NewSimulationDropdown` sprint 5** : les DEUX moitiés (label `Export` + chevron) ouvrent le dropdown. Il n'y a PAS d'action par défaut sur le click gauche (parce qu'il n'y a pas de format "défaut" évident parmi Markdown/CSV/PDF). Ce n'est PAS le même pattern que `[+ New]` (où la gauche navigue vers `/.../new` blank).
- Dropdown : 3 items "Markdown" / "CSV" / "PDF". Click → mutation download.
- Fermeture : click outside + Escape (réutiliser le hook/effet du dropdown sprint 5 dans `SimulationList`).
- Loading state : pendant la mutation, le composant affiche un spinner inline sur l'item cliqué, le dropdown reste ouvert. Désactive les 3 items pendant l'in-flight.
- Toast erreur sur 4xx/5xx.
- **`data-testid="export-dropdown"`** sur le wrapper du composant pour permettre au test-verifier d'asserter la présence/absence DOM (AC-30.1).
- Style : utiliser la classe utilitaire **`btn-outline`** (la même que le bouton `Edit` du header existant) — cohérence visuelle directe avec le header.
- **`EngagementDetailPage.tsx`** : intégrer `<ExportEngagementButton engagementId={engagement.id} />` dans le header de la page, à côté du bouton `Edit` existant. **Visible uniquement si `currentUser.role in ['admin', 'redteam']`** (gate côté UI + RBAC backend de toute façon en force) — réutiliser le helper `canEditEngagements` de `useAuth` (le même rôle set).
### API client - [ ] `LoginPage.tsx`: angular form card. Drop ornament.
- **`frontend/src/api/exports.ts`** (nouveau) : - [ ] `EngagementsListPage.tsx`: angular table container (currently `.card-product` with rounded-xl). Data cells (dates) → mono.
- `downloadEngagementExport(engagementId: number, format: 'md' | 'csv' | 'pdf'): Promise<void>` — fait un GET `/api/engagements/<id>/export?format=<fmt>` avec `responseType: 'blob'`, lit `Content-Disposition` pour le filename, crée un `Blob` + `URL.createObjectURL` + `<a>.click()`, puis `URL.revokeObjectURL`. **Contrat d'erreur** : sur réponse non-2xx, parse le JSON `{error: "..."}` du body (ou défaut "Export failed") et **throw un `Error`** avec le message — laisse le caller catcher pour le toast. - [ ] `EngagementDetailPage.tsx`: angular header section. Engagement metadata (start/end dates, IDs, created_at) in mono. Simulations table covered via SimulationList.
- Helper `parseContentDispositionFilename(header: string | undefined): string | null` (regex `filename="..."`, fallback null). - [ ] `EngagementFormPage.tsx`: angular form. Date inputs ok.
- [ ] `SimulationFormPage.tsx`: angular form. Commands textarea → mono.
- [ ] `TemplatesListPage.tsx`: angular list.
- [ ] `TemplateFormPage.tsx`: angular form. Commands field → mono.
- [ ] `UsersAdminPage.tsx`: angular table. Username column → mono (it's an ID).
### Types ## §6 — Test refresh (commit #7)
- Aucun nouveau type API (l'export retourne un Blob).
### Tests - [ ] `cd frontend && npm run test -- --run` — identify failing assertions on class names (`rounded-xl`, `card-product`, etc.). Update tests to use semantic queries (role, name, data-testid) where possible; if test asserts on visual class, update assertion to the new class.
**Cible : 121 → 130+ vitest passing.** - [ ] No new vitest tests added (visual sprint, behavior unchanged).
- [ ] Playwright e2e: should be `data-testid`-driven — run full suite to confirm no regression. If breakage, fix the testid not the test logic.
Fichiers nouveaux : ## §7 — Reviews
- `frontend/tests/ExportEngagementButton.test.tsx`
- `renders Export button with chevron`
- `clicking primary opens dropdown with three formats`
- `clicking outside closes dropdown`
- `Escape closes dropdown`
- `clicking Markdown triggers download with format=md`
- `clicking CSV triggers download with format=csv`
- `clicking PDF triggers download with format=pdf`
- `loading state disables items during in-flight`
- `error response shows toast`
- `frontend/tests/EngagementDetailPage.test.tsx` (**nouveau** — il n'existe pas encore, le builder le crée from scratch) :
- `admin sees Export button`
- `redteam sees Export button`
- `soc does NOT see Export button`
### Screenshots OBLIGATOIRES (lesson sprint 4) - [ ] **spec-reviewer** (pre-dispatch, §1): plan validated vs SPEC.md and §0 binding decisions
- `EngagementDetailPage` light + dark, dropdown fermé. - [ ] **frontend-builder** (§2-§6): implements, runs typecheck/lint/test, delivers screenshots for design-reviewer (every page + key states, light+dark)
- `EngagementDetailPage` light + dark, dropdown ouvert (3 items visibles). - [ ] **design-reviewer** (post-frontend): reviews screenshots + diff vs new DESIGN.md. Brutalist consistency, mono-discipline (only data), zero-rounded discipline.
- `EngagementDetailPage` SOC view — bouton Export ABSENT. - [ ] **code-reviewer** (post-design): reviews frontend diff for duplication, lost reuse, dead code.
- Le builder doit fournir un script Playwright authenti (réutiliser le pattern sprint 5 — `page.goto('/login') → fill → wait nav`). - [ ] **test-verifier**: skipped this sprint (no new US, no behavior change).
- [ ] **backend-builder**: idle, no work this sprint.
### Livrable frontend-builder (summary attendu) ## §8 — Git workflow
- Tous les fichiers créés/modifiés
- API contracts consommés exactement comme livrés par backend (cf. lesson sprint 5 — path drift à éviter, grep `Content-Disposition` dans la PR)
- Helpers réutilisés (`useToast`, etc.)
- Résultats vitest + typecheck + lint
- Liste des écrans capturés (light + dark, role-by-role)
--- - [ ] Branch: `sprint/7-design` (already created from origin/main)
- [ ] Commits: conventional, one per logical group (§2 to §7)
- [ ] PR via `make open-pr` (Gitea pattern, per memory)
- [ ] PR body in `tasks/pr-body-sprint-7.md`
- [ ] CHANGELOG.md sprint 7 section
## §3Acceptance tests (Sonnet · test-verifier) ## §9Risks & mitigations
**Cible : 201 → 215+ Playwright passing.** - **R1 — Tests break en masse**: many vitest specs may assert on class strings (e.g., `rounded-xl` on cards). Mitigation: update assertions to semantic queries; budget half a phase to test repair.
- **R2 — Dark mode contrast lost**: angular + new semantic colors may break WCAG AA contrast on dark slab. Mitigation: design-reviewer audits both modes; adjust the dark variant hex to meet WCAG AA. Rollback the success/warn family only if no accessible green/amber is achievable on the dark slab.
- **R3 — Mono overflow**: JetBrains Mono is wider than Inter at same px. Cell widths in tables may overflow. Mitigation: keep `table-layout: fixed` and `word-break: break-word` (pattern reused from PDF export CSS sprint 6).
- **R4 — DESIGN.md rewrite churn**: replacing 346 lines is a big diff. Mitigation: rewrite atomically in commit #2, keep token names consistent so downstream commits don't drift.
- **R5 — User taste mismatch**: "Bloomberg/SOC" may not match user's mental image. Mitigation: design-reviewer screenshots → user check-in BEFORE merge.
3 user stories à couvrir : ## §10 — Definition of Done
### US-29 — Admin/redteam exporte l'engagement en Markdown/CSV/PDF - [ ] All §0 decisions reflected in DESIGN.md + tokens + components + pages
- **AC-29.1** : login admin → engagement avec ≥ 2 simulations → click "Export" → dropdown s'ouvre. - [ ] `npm run typecheck` clean
- **AC-29.2** : click "Markdown" → download d'un `.md` avec `Content-Type: text/markdown`. Le fichier contient le nom de l'engagement, la date de début, et le nom de chaque simulation. - [ ] `npm run lint` clean
- **AC-29.3** : click "CSV" → download d'un `.csv` avec exactement N+1 **rows CSV** (1 header + N simulations). La colonne `name` contient les noms des simulations. **Note implémentation test** : compter les rows via `csv.reader` (ou équivalent JS), PAS via `file.split('\n')` — les commands multilines produisent des cells avec newlines embedded entre quotes, le line-count du fichier > row-count CSV. - [ ] `npm run test -- --run` all green
- **AC-29.4** : click "PDF" → download avec `Content-Type: application/pdf`, taille > 1 KB, magic bytes `%PDF`. - [ ] Backend untouched — `git diff origin/main -- backend/` empty
- **AC-29.5** : login redteam → mêmes 3 formats fonctionnent. - [ ] Playwright e2e green (223 baseline preserved)
- **AC-29.6** : filename respecte `engagement-<id>-<slug>-YYYYMMDD.{ext}` (assert via Content-Disposition). - [ ] Screenshots delivered (light + dark) for every page + key states
- [ ] DESIGN.md rewritten, no HP/Forma/wordmark/chevron references
- [ ] CHANGELOG.md sprint 7 section
- [ ] PR opened on Gitea
- [ ] User merges PR → sprint closed → team idle ready for sprint 8
### US-30 — SOC pas d'accès à l'export ## §11 — Lessons being applied from prior sprints
- **AC-30.1** : login SOC → engagement page → bouton "Export" **ABSENT** du DOM (pas seulement `display: none`). Assert via `expect(page.locator('[data-testid="export-dropdown"]')).not.toBeAttached()`.
- **AC-30.2** : appel direct API `GET /api/engagements/<id>/export?format=md` (Bearer SOC) → 403.
- **AC-30.3** : (sanity) appel API sans token → 401.
### US-31 — Robustesse format / engagement - **SPEC/DESIGN commit-first**: DESIGN.md rewrite is commit #2 (after sprint hygiene). No design churn mid-sprint.
- **AC-31.1** : `GET /api/engagements/<id>/export` sans `format` → 400 message friendly. - **spec-reviewer 2-pass**: APPROVED before dispatch, not after.
- **AC-31.2** : `GET /api/engagements/<id>/export?format=xml` → 400 friendly. - **Team idle policy**: 6 agents already mounted, no shutdown until PR merged.
- **AC-31.3** : `GET /api/engagements/99999/export?format=md` → 404. - **frontend-builder MUST invoke `Skill frontend-design`** before UI work (the patch commits as #1, takes effect immediately for the same sprint).
- **AC-31.4** : engagement avec 0 simulations → export OK (header seul, le CSV n'a qu'une ligne d'en-tête, le MD n'a pas de section simulation).
### Bouncing
- Si un AC échoue → bounce au builder responsable (backend ou frontend), pas de patch test-side.
---
## §4 — Reviews
### Spec-reviewer Pass 1 (avant dispatch)
- Lit ce `tasks/todo.md` § 0 + § 1 + § 2 + § 3.
- Verdict attendu : APPROVED / NEEDS-CHANGES par section.
- Points particuliers à challenger : WeasyPrint vs reportlab, CSV sans header engagement, URL drift (un seul endpoint avec query param vs 3 endpoints distincts).
### Spec-reviewer Pass 2 (après mes éventuels édits du plan)
- Re-validation des changements apportés.
- **TEAM-LEAD : ne PAS dispatcher backend tant que Pass 2 n'a pas répondu APPROVED.** Lesson sprint 5 — la patience sur le 2-pass a éliminé les addenda mid-implementation.
### Code-reviewer (après backend + frontend)
- LSP first (`goToDefinition`, `findReferences`).
- Focalise sur : pureté des render functions (testables), gestion des deps WeasyPrint dans Dockerfile, échappement CSV, filename slug, dropdown close-on-outside réutilisation.
### Design-reviewer (après screenshots frontend)
- Light + dark cohérence du dropdown Export.
- Vérifie que le bouton respecte la convention "icône + label court ≤ 8 chars" (`Export`).
- Audit alignement vs le header existant de la page.
### Test-verifier (après code-reviewer APPROVED)
- Écrit 1 spec file par US (`us29-export-formats`, `us30-export-rbac`, `us31-export-robustness`).
- Rapport pass/fail par AC.
---
## §5 — SPEC.md update (au tout début du sprint — lesson sprint 3/4/5)
Ajouter une section **§ Export d'engagement** entre § Templates de simulations et § Authentification & rôles :
> ## Export d'engagement
> Un engagement peut être exporté à tout moment dans 3 formats au choix : **Markdown** (handoff narratif), **CSV** (machine-readable, intégration tableurs), **PDF** (livrable client). L'export contient l'en-tête de l'engagement et toutes ses simulations avec les champs Red Team **et** SOC. Endpoint unique : `GET /api/engagements/<id>/export?format=md|csv|pdf`. Réservé aux rôles admin et redteam (livrable RedTeam, cohérent avec le RBAC Templates). Filename normalisé : `engagement-<id>-<slug>-YYYYMMDD.<ext>`.
**Le commit qui crée cette section doit être le PREMIER commit du sprint** (pas le dernier — sinon on rate le bug récurrent identifié dans les lessons). Le commit suivant peut être le plan lui-même (`tasks/todo.md` + `tasks/lessons.md`).
---
## §6 — Workflow git du sprint
- Branch : `sprint/6-export` (créée @ `678ee8f`).
- Commit séquence :
1. `docs(spec): add § Export d'engagement section` (le `M SPEC.md` ne doit JAMAIS rester unstaged)
2. `docs(plan): sprint 6 plan + sprint-5 lessons folded` (tasks/)
3. Commits backend (un ou deux, signés par backend-builder)
4. Commits frontend (un ou deux, signés par frontend-builder)
5. Commit post-code-review fixes (si nécessaire)
6. Commit screenshots design + e2e tests
7. Wrap-up commit team-lead : CHANGELOG + README + lessons.md sprint-6 + plan final
- PR via `make open-pr SPRINT=6 TITLE="feat: sprint 6 — engagement export (md/csv/pdf)" BODY=tasks/pr-body-sprint-6.md` (3e dogfood du wrapper sprint 4).
---
## §7 — Risk / hazard list
| # | Risk | Mitigation |
|---|---|---|
| 1 | WeasyPrint deps gonflent l'image Docker | Liste minimale documentée + WeasyPrint déjà packagé sur Debian slim ; mesurer Δ MB image build après vs avant |
| 2 | CSV mal-échappé avec commands multilines / quotes | Utiliser `csv.writer` stdlib (handles tout automatiquement), pas de string concat manuel |
| 3 | Markdown casse sur backticks dans commands | Fenced code blocks `~~~bash` (tildes au lieu de backticks pour les blocks contenant des backticks), OU escape via `markdown.escape` |
| 4 | Test PDF fragile sur le contenu | Asserter UNIQUEMENT : Content-Type, magic bytes `%PDF`, taille > 1 KB. Pas de regex sur le texte rendu (binary). |
| 5 | URL drift backend (`/export` vs `/engagements/<id>/export`) | Lesson sprint 5 — la 1re ligne du backend summary doit confirmer le path exact |
| 6 | Frontend oublie `URL.revokeObjectURL` → fuite mémoire | Test unitaire explicite : assert `revokeObjectURL` appelé après le click téléchargement |
| 7 | SPEC.md uncommitted à la fin du sprint (3 sprints en série !) | Commit SPEC.md en commit #1 du sprint, pas en wrap-up. Étape « cendrillon » du plan ci-dessus. |
---
## §8 — Definition of Done (sprint-level)
- [ ] §5 SPEC.md committed AS THE FIRST COMMIT of the sprint.
- [ ] Backend : 245+ pytest, ruff clean, mypy clean.
- [ ] Frontend : 130+ vitest, typecheck clean, lint clean.
- [ ] E2e : 215+ Playwright, 0 régression vs main.
- [ ] Screenshots fournies : EngagementDetailPage light + dark, dropdown fermé + ouvert, vue SOC sans bouton.
- [ ] Dockerfile mis à jour avec deps WeasyPrint + `make build` réussit.
- [ ] CHANGELOG.md `[Unreleased] → Sprint 6` rédigée.
- [ ] README.md « Status » bumped + section dans le tableau des features si pertinent.
- [ ] PR ouverte via `make open-pr` (pas via UI manuelle).
- [ ] `git status` au sprint-close affiche **uniquement** des fichiers ignorés (lesson récurrente).