diff --git a/.agents/rules/m3-expressive-design.md b/.agents/rules/m3-expressive-design.md new file mode 100644 index 00000000..6a9ca0f6 --- /dev/null +++ b/.agents/rules/m3-expressive-design.md @@ -0,0 +1,194 @@ +# Google Material 3 (M3) Expressive Design Rules + +> **MANDATE:** All UI components, pages, views, modals, cards, widgets, and styles in `web/` must strictly adhere to Google Material 3 (M3) Expressive Design principles. Arbitrary hex colors, harsh drop shadows, sharp rectangular interactive elements, and un-scaled typography are strictly prohibited. + +--- + +## 1. Core Design Philosophy + +Google Material 3 Expressive evolves Material Design into an emotional, tactile, and highly structured visual language: +1. **Expressive Tone & Emotion**: UI is not merely functional; it conveys intent, hierarchy, and delight through deliberate color harmonies, organic shapes, and responsive physics. +2. **Tonal Elevation over Drop Shadows**: Surfaces communicate depth through layered **Surface Containers** and tonal shifts rather than heavy, blurry drop shadows. +3. **Shape as Hierarchy**: Curvature signals interactivity and role. High-curvature shapes (pills, squircles, `rounded-full`, `rounded-2xl`) create clear visual distinction between containers and action triggers. +4. **Purposeful Motion**: Every transition is tactile, using spring-like physics and emphasized decelerations. Actions provide instant micro-feedback (`scale-[0.98]` press state). +5. **Accessibility by Default**: High contrast ratios (WCAG AA/AAA), 48x48dp minimum touch targets, visible 2px offset focus rings, and full `prefers-reduced-motion` compliance. + +--- + +## 2. Color System & Dynamic Tonal Palette + +Never use arbitrary hex values or untyped colors. Always use M3 role-based semantic tokens: + +### Primary, Secondary, and Tertiary Roles + +| Token | CSS Variable | Purpose | +|---|---|---| +| **Primary** | `--md-sys-color-primary` | Key interactive actions, primary buttons, active indicator pills | +| **On-Primary** | `--md-sys-color-on-primary` | Text and icons placed on top of Primary | +| **Primary Container** | `--md-sys-color-primary-container` | High-prominence container surfaces needing emphasis without solid fill | +| **On-Primary Container** | `--md-sys-color-on-primary-container` | Text and icons on Primary Container | +| **Secondary** | `--md-sys-color-secondary` | Less prominent components, filter chips, secondary toggles | +| **On-Secondary** | `--md-sys-color-on-secondary` | Text/icons on Secondary | +| **Secondary Container** | `--md-sys-color-secondary-container` | Medium-emphasis containers, tonal buttons, active nav indicators | +| **On-Secondary Container** | `--md-sys-color-on-secondary-container` | Text/icons on Secondary Container | +| **Tertiary** | `--md-sys-color-tertiary` | **Expressive accent**: contrasting warm/vibrant hue for highlights, badges, AI insights | +| **On-Tertiary** | `--md-sys-color-on-tertiary` | Text/icons on Tertiary | +| **Tertiary Container** | `--md-sys-color-tertiary-container` | Expressive cards, special callouts, AI insight banners | +| **On-Tertiary Container** | `--md-sys-color-on-tertiary-container` | Text/icons on Tertiary Container | +| **Error** | `--md-sys-color-error` | Critical errors, destructive actions | +| **Error Container** | `--md-sys-color-error-container` | Warning/error cards, danger banners | + +### Tonal Surface Hierarchy + +Do **not** use flat, monochromatic cards or arbitrary background shades. Layer depth using the 5-tier Surface Container system: + +``` +┌────────────────────────────────────────────────────────┐ +│ Surface Container Highest (Dialogs, Menus, Modals) │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Surface Container High (Floating Bars, Popovers) │ │ +│ │ ┌────────────────────────────────────────────┐ │ │ +│ │ │ Surface Container (Standard Cards, Sheets) │ │ │ +│ │ │ ┌──────────────────────────────────────┐ │ │ │ +│ │ │ │ Surface Container Low (Subtle Panels)│ │ │ │ +│ │ │ │ ┌────────────────────────────────┐ │ │ │ │ +│ │ │ │ │ Surface / Lowest (Canvas/Page) │ │ │ │ │ +``` + +- **`surface-container-lowest`**: Recessed areas, code editors, sunken background tracks. +- **`surface-container-low`**: Structural sidebars, secondary grouping panels. +- **`surface-container`**: Default standard cards, list rows, content panels. +- **`surface-container-high`**: Elevated search bars, active tabs, floating headers. +- **`surface-container-highest`**: Modals, dropdown menus, context popovers. + +### State Layers + +Interactive states must apply semi-transparent state overlays (rather than abruptly jumping color): +- **Hover**: 8% opacity tint of the element's on-color +- **Focus**: 12% opacity tint + 2px offset outline +- **Press / Active**: 12% opacity tint + `scale-[0.98]` tactile dip +- **Drag**: 16% opacity tint + Level 3 elevation + +--- + +## 3. Expressive Geometry & Shape Scale + +Material 3 Expressive pairs structured layouts with organic, rounded geometry: + +| Shape Token | Radius | Allowed Components | +|---|---|---| +| **None** | `0px` (`rounded-none`) | Full-bleed screen dividers, edge-to-edge layout borders | +| **Extra Small** | `4px` (`rounded-xs`) | Micro tags, dense table inline badges | +| **Small** | `8px` (`rounded-sm`) | Code snippets, compact data chips, inner item blocks | +| **Medium** | `12px` (`rounded-md`) | Form inputs, select dropdowns, segmented button items | +| **Large** | `16px` (`rounded-lg` / `rounded-xl`) | Standard content cards, snackbars, sub-panels | +| **Extra Large** | `24px`–`28px` (`rounded-2xl` / `rounded-3xl`) | Modal dialogs, floating action sheets, hero cards | +| **Full (Pill)** | `9999px` (`rounded-full`) | **Buttons**, **Filter chips**, **Status badges**, **Search bars**, **Floating pills** | + +### Shape Rules: +- **Buttons must be Pill (`rounded-full`)**: Primary, secondary, tonal, and outline action buttons must use full pill curvature. +- **Cards must be `rounded-2xl` (16px) or `rounded-3xl` (24px)**: Never use sharp, boxy 4px/8px corners for main UI cards. +- **Badges & Chips must be Pill (`rounded-full`)**: Filter chips, status pills, and counter tags must be fully rounded. + +--- + +## 4. Typography Scale & Hierarchy + +Use the M3 15-step typography scale. All text must strictly map to one of these roles: + +1. **Display (Large 57px, Medium 45px, Small 36px)**: + - For hero KPI numbers, executive summary scores, and landing headlines. + - Tracking: `-0.25px` to `-0.5px` (tight, expressive). +2. **Headline (Large 32px, Medium 28px, Small 24px)**: + - For page titles and primary view section headers. + - Font weight: `font-semibold` or `font-bold`. +3. **Title (Large 22px, Medium 16px, Small 14px)**: + - For card headers, list section dividers, and modal titles. + - Font weight: `font-semibold`. +4. **Body (Large 16px, Medium 14px, Small 12px)**: + - For descriptive copy, analysis paragraphs, and data cell content. + - Line height: Generous (`leading-relaxed`), font weight: `font-normal`. +5. **Label (Large 14px, Medium 12px, Small 11px)**: + - For buttons, chip labels, table column headers, and form input labels. + - Font weight: `font-medium`, tracking: `tracking-wide`. + +--- + +## 5. Tonal Elevation & Surface Depth + +- **Level 0 (0dp)**: Flat surface, no elevation (`shadow-none`). +- **Level 1 (1dp)**: Subtle separation, Surface Container Low (`--elevation-1`). +- **Level 2 (3dp)**: Standard elevated cards, Surface Container (`--elevation-2`). +- **Level 3 (6dp)**: Floating bars, filter pills, dropdowns. +- **Level 4 (8dp)**: Modal dialogs, navigation drawers (`--elevation-3`). +- **Level 5 (12dp)**: Floating Action Buttons (FAB), drag previews. + +*Rule:* Never apply dark, heavy CSS box shadows. Depth is communicated 90% by container tone and 10% by soft, ambient occlusion. + +--- + +## 6. Motion & Spring Physics + +Material 3 Expressive motion feels spring-like, physical, and alive: + +### Easing Tokens +- **Emphasized Decelerate**: `cubic-bezier(0.05, 0.7, 0.1, 1.0)` (elements entering screen or expanding) +- **Emphasized Accelerate**: `cubic-bezier(0.3, 0, 0.8, 0.15)` (elements exiting screen) +- **Standard Spring**: `cubic-bezier(0.2, 0, 0, 1)` (hover, toggle, state transitions) + +### Durations +- Micro-interactions (hover, focus, chip toggle): `150ms`–`200ms` +- Medium transitions (sheet open, expand card): `250ms`–`350ms` +- Large transitions (dialog open, page transition): `400ms`–`500ms` + +### Interactive Tactile Feedback: +- All interactive elements must include active press scaling: `active:scale-[0.98]` or `.press`. +- Card hover states must lift smoothly with `transition-all duration-200 ease-out hover:-translate-y-0.5`. +- Grids and lists must feature staggered reveal transitions (30ms–50ms stagger per child). +- **Reduced Motion**: Always include `motion-reduce:transition-none motion-reduce:transform-none`. + +--- + +## 7. Component Standards & Recipes + +### Buttons +- **Filled Button (Primary)**: + `rounded-full bg-md-sys-primary text-md-sys-on-primary font-medium px-5 py-2.5 transition-all duration-200 hover:shadow-md active:scale-[0.98]` +- **Filled Tonal Button (Medium emphasis)**: + `rounded-full bg-md-sys-secondary-container text-md-sys-on-secondary-container font-medium px-5 py-2.5 transition-all hover:bg-opacity-90 active:scale-[0.98]` +- **Outlined Button**: + `rounded-full border border-md-sys-outline text-md-sys-primary font-medium px-5 py-2.5 transition-all hover:bg-md-sys-primary/8 active:scale-[0.98]` +- **Text Button**: + `rounded-full text-md-sys-primary font-medium px-3 py-2 transition-all hover:bg-md-sys-primary/8 active:scale-[0.98]` +- **Floating Action Button (FAB)**: + `rounded-2xl bg-md-sys-primary-container text-md-sys-on-primary-container p-4 shadow-lg hover:shadow-xl active:scale-[0.96]` + +### Cards +- Container: `bg-md-sys-surface-container rounded-2xl p-6 border border-md-sys-outline-variant/50 transition-all` +- Clickable/Interactive Card: Add `hover:shadow-md hover:-translate-y-0.5 cursor-pointer active:scale-[0.99]` +- **Mandatory Repository Rule**: Every report card and dashboard widget MUST wire `devData` on `` (see `AGENT.md` § Dev widget JSON copy). + +### Chips & Badges +- Filter Chip: `rounded-full px-4 py-1.5 text-xs font-medium border border-md-sys-outline transition-all` +- Selected Chip: `bg-md-sys-secondary-container text-md-sys-on-secondary-container border-transparent` +- Status Badge: `rounded-full px-3 py-1 text-xs font-semibold inline-flex items-center gap-1.5` + +### Form Controls & Inputs +- Container: `bg-md-sys-surface-container-high rounded-xl border border-md-sys-outline-variant px-4 py-3 text-md-sys-on-surface` +- Focus State: `focus:outline-none focus:ring-2 focus:ring-md-sys-primary focus:border-transparent` +- Helper Text: `text-xs text-md-sys-on-surface-variant mt-1.5` + +--- + +## 8. Developer & Agent Strict Checklist + +Before committing any UI change in `web/`, verify each item: + +- [ ] **Color Roles**: Are colors sourced from M3 role tokens (`md-sys-*`) instead of hardcoded hex colors? +- [ ] **Surface Containers**: Is depth layered using `surface-container-*` rather than arbitrary background grays? +- [ ] **Shape Curvature**: Are action buttons and chips pill-shaped (`rounded-full`)? Are cards `rounded-2xl` or `rounded-3xl`? +- [ ] **Typography Scale**: Do text elements strictly follow Display / Headline / Title / Body / Label hierarchy? +- [ ] **Tactile Press**: Do buttons and clickable cards have active press feedback (`active:scale-[0.98]` / `.press`)? +- [ ] **Dev Widget Copy**: Does every card/widget pass `devData` to `` or render ``? +- [ ] **Accessibility**: Does the touch target meet 48x48dp minimum? Does text contrast exceed 4.5:1? Is there a visible focus ring? +- [ ] **Dark Mode**: Do all components render cleanly in both Light mode and `html.dark` mode? diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..f78178f0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto + +# Enforce LF for shell scripts and Linux container files +*.sh text eol=lf +*.bash text eol=lf +*.conf text eol=lf +Dockerfile* text eol=lf + +# Windows PowerShell scripts +*.ps1 text eol=crlf +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore index 4412e786..2a613a8d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,8 @@ input.txt pipeline-config.txt *__pycache__* .coverage -.agents/ +.agents/* +!.agents/rules/ skills-lock.json crawl_results.csv commit.* diff --git a/AGENT.md b/AGENT.md index c92a4986..18a2ce79 100644 --- a/AGENT.md +++ b/AGENT.md @@ -62,6 +62,20 @@ Developer reference for agents and contributors. User-facing overview: [README.m Schema changes: add an EF Core migration (`dotnet ef migrations add ` in `services/Schema/src/Schema.Model/`). +**UI Design — Google Material 3 (M3) Expressive Design (Strict Rules)** + +All frontend UI code in `web/` must strictly comply with **Google Material 3 Expressive Design**. Canonical rules: [.agents/rules/m3-expressive-design.md](.agents/rules/m3-expressive-design.md). + +| Element | M3 Expressive Rule | Allowed Tailwind / CSS | +|---|---|---| +| **Buttons** | **Must be pill-shaped (`rounded-full`)**. High emphasis uses Primary; medium emphasis uses Filled Tonal (`bg-secondary-container`). | `rounded-full px-5 py-2.5 font-medium transition-all active:scale-[0.98]` | +| **Cards** | **Tonal Surface Container (`rounded-2xl` or `rounded-3xl`)**. Layer depth using `surface-container-*` hierarchy instead of arbitrary dark drop shadows. Mandatory `devData` wiring. | `bg-md-sys-surface-container rounded-2xl p-6 border border-md-sys-outline-variant/50` | +| **Chips & Badges** | **Must be pill-shaped (`rounded-full`)**. Height 32dp (or compact 24dp), tonal background. | `rounded-full px-3.5 py-1 text-xs font-medium` | +| **Typography** | Strictly follow M3 15-scale (Display, Headline, Title, Body, Label). Tighter tracking on display/headline; generous line-height on body. | M3 type scale roles (`Display`, `Headline`, `Title`, `Body`, `Label`) | +| **Motion** | Responsive spring curves (`cubic-bezier(0.2, 0, 0, 1)`), tactile press downscale (`active:scale-[0.98]` / `.press`), staggered entrance lists. | `transition-all duration-200 ease-out active:scale-[0.98]` | +| **Elevation** | Surface depth communicated through **tonal color shifts** (Surface Container Lowest to Highest), not heavy drop shadows. | `--elevation-1` to `--elevation-3` | +| **Accessibility** | Minimum 48x48dp touch targets, ≥4.5:1 text contrast, visible 2px offset focus rings, full `prefers-reduced-motion` support. | `focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2` | + **Charts — Chart.js + D3 (hybrid)** The web UI uses **both** Chart.js and D3.js. Pick the library that fits each chart; do not migrate everything to one stack. diff --git a/AGENTS.md b/AGENTS.md index 9a4bb239..04349f4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,8 +50,15 @@ python -m src # Run audit pipeline | Report/card widgets (dev JSON copy) | `Card` `devData` prop — see [AGENT.md](AGENT.md) § Dev widget JSON copy; reference: `web/src/components/overview/OverviewExecutiveSummary.tsx` | | Charts | D3: `web/src/components/charts/d3/`, `web/src/lib/viz/` · Chart.js: GSC/GA4/Links etc. — see [AGENT.md](AGENT.md) § Charts | +**Google Material 3 (M3) Expressive Design (Strict UI Rules):** All UI components, pages, views, cards, modals, and styling in `web/` **must strictly follow Google Material 3 Expressive Design** — full specification in [.agents/rules/m3-expressive-design.md](.agents/rules/m3-expressive-design.md). +- **Colors & Surfaces:** Use M3 semantic tokens (`md-sys-color-*`) and the 5-tier Surface Container system (`surface-container-lowest` through `highest`) for tonal elevation. No arbitrary hex values or harsh drop shadows. +- **Shapes:** Action buttons, filter chips, and status badges **must be pill-shaped** (`rounded-full`); cards must use expressive curvature (`rounded-2xl` or `rounded-3xl`). +- **Typography:** Strictly adhere to the 15-tier M3 typography scale (Display, Headline, Title, Body, Label). +- **Motion & Feedback:** Include tactile press scaling (`active:scale-[0.98]` / `.press`), spring physics, and full `prefers-reduced-motion` compliance. + **Charts:** Use **both** Chart.js and D3 — choose per chart (Overview/Compare → D3; standard GSC/GA4 bars → Chart.js). Full rules in [AGENT.md](AGENT.md). **Dev widget JSON copy:** In local dev, each report card/panel should pass `devData` on `Card` so agents/devs can copy the widget’s JSON from the top-right `{ }` button. Wire on every widget you add or touch; full conventions in [AGENT.md](AGENT.md) § Dev widget JSON copy. **Common pitfalls:** See [AGENT.md](AGENT.md) for the full footguns checklist (React context, Python local imports, psycopg dict rows, coverage gates). + diff --git a/Dockerfile b/Dockerfile index 4df117a9..80fb2d7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,7 +66,9 @@ COPY docker-entrypoint.sh /app/docker-entrypoint.sh ENV NODE_ENV=production # Persisted data directory (secrets + shadow config) -RUN mkdir -p /data && chmod +x /app/docker-entrypoint.sh +RUN mkdir -p /data \ + && sed -i 's/\r$//' /app/docker-entrypoint.sh \ + && chmod +x /app/docker-entrypoint.sh EXPOSE 8096 diff --git a/local-prod.ps1 b/local-prod.ps1 new file mode 100644 index 00000000..b8f3216e --- /dev/null +++ b/local-prod.ps1 @@ -0,0 +1,2 @@ +# Wrapper — run from repo root: .\local-prod.ps1 [command] +& "$PSScriptRoot\scripts\local-prod.ps1" @args diff --git a/scripts/ensure-deps.ps1 b/scripts/ensure-deps.ps1 index 7b682909..d749c3fe 100644 --- a/scripts/ensure-deps.ps1 +++ b/scripts/ensure-deps.ps1 @@ -89,7 +89,29 @@ function Invoke-EnsurePythonLauncher { Assert-EnsureExitCode "Python command failed: $($Launcher -join ' ') $($PythonArgs -join ' ')" } +function Update-SessionPath { + try { + $machinePath = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + $userPath = [System.Environment]::GetEnvironmentVariable("Path", "User") + $current = @($env:PATH -split ';') + $regPaths = @($machinePath -split ';' + $userPath -split ';') + $standardPaths = @( + "C:\Program Files\dotnet", + "C:\Program Files\nodejs", + "C:\Program Files\Docker\Docker\resources\bin", + "$HOME\AppData\Local\Programs\Python\Python312", + "$HOME\AppData\Local\Programs\Python\Python312\Scripts", + "$HOME\.dotnet\tools" + ) + $all = @($standardPaths + $regPaths + $current) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique + $env:PATH = ($all -join ';') + } catch { + # ignore if registry read is constrained + } +} + function Test-DotnetVersionOk { + Update-SessionPath if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { return $false } $version = (& dotnet --version 2>$null) if (-not $version) { return $false } @@ -103,6 +125,7 @@ function Install-WingetPackage { [string]$PackageId ) + Update-SessionPath if (Get-Command $CommandName -ErrorAction SilentlyContinue) { return } @@ -114,14 +137,15 @@ function Install-WingetPackage { } Write-EnsureLog "Installing $PackageId via winget" & winget install --id $PackageId -e --accept-source-agreements --accept-package-agreements - Assert-EnsureExitCode "winget install failed for $PackageId" + Update-SessionPath if (-not (Get-Command $CommandName -ErrorAction SilentlyContinue)) { - Write-EnsureWarn "$CommandName not on PATH yet — open a new terminal after winget install" - Write-EnsureDie "Still missing required command: $CommandName" + Write-EnsureWarn "$CommandName not on PATH yet in current session" + Write-EnsureDie "Installed $PackageId, but '$CommandName' is not found in PATH yet. Please restart your PowerShell terminal to reload system environment variables, or run the full stack via Docker: .\local-run.ps1 docker" } } function Ensure-SystemTools { + Update-SessionPath Install-WingetPackage -CommandName "docker" -PackageId "Docker.DockerDesktop" Install-WingetPackage -CommandName "python" -PackageId "Python.Python.3.12" if (-not (Get-EnsurePythonLauncher)) { @@ -132,7 +156,7 @@ function Ensure-SystemTools { Install-WingetPackage -CommandName "dotnet" -PackageId "Microsoft.DotNet.SDK.10" } if (-not (Test-DotnetVersionOk)) { - Write-EnsureDie ".NET SDK 10+ required (see README.md prerequisites)" + Write-EnsureDie ".NET SDK 10+ required for local host execution (services/CoreService, AiService, Bff).`nInstall via: winget install Microsoft.DotNet.SDK.10`nor download from: https://dotnet.microsoft.com/download/dotnet/10.0`n(Note: You can also run the full stack without installing .NET locally using: .\local-run.ps1 docker)" } } diff --git a/scripts/local-prod.ps1 b/scripts/local-prod.ps1 new file mode 100644 index 00000000..2029fdb9 --- /dev/null +++ b/scripts/local-prod.ps1 @@ -0,0 +1,317 @@ +# Local prod: same Postgres as .\local-run.ps1, full stack + Vite build + preview (NODE_ENV=production). +# Usage: .\local-prod.ps1 [command] +# (default) start — DB, migrations, .NET stack, FastAPI, vite preview +# build — npm run build only +# help — show commands + +$ErrorActionPreference = "Stop" + +$ROOT = Split-Path -Parent $PSScriptRoot +Set-Location $ROOT + +$PG_CONTAINER = if ($env:WP_PG_CONTAINER) { $env:WP_PG_CONTAINER } else { "wp-pg" } +$PG_PORT = if ($env:WP_PG_PORT) { $env:WP_PG_PORT } else { "5432" } +$PG_USER = if ($env:WP_PG_USER) { $env:WP_PG_USER } else { "postgres" } +$PG_PASSWORD = if ($env:WP_PG_PASSWORD) { $env:WP_PG_PASSWORD } else { "dev" } +$PG_DB = if ($env:WP_PG_DB) { $env:WP_PG_DB } else { "website_profiling" } + +if (-not $env:DATABASE_URL) { + $env:DATABASE_URL = "postgres://${PG_USER}:${PG_PASSWORD}@127.0.0.1:${PG_PORT}/${PG_DB}" +} +if (-not $env:DATA_DIR) { + $env:DATA_DIR = Join-Path $ROOT "data" +} + +$VENV = Join-Path $ROOT ".venv" +$VENV_PYTHON = Join-Path $VENV "Scripts\python.exe" +$WEB = Join-Path $ROOT "web" +$LOCAL_RUN = Join-Path $PSScriptRoot "local-run.ps1" + +$env:WEBSITE_PROFILING_ROOT = $ROOT +if ($env:PYTHONPATH) { + $env:PYTHONPATH = "$($env:PYTHONPATH);$(Join-Path $ROOT 'src')" +} else { + $env:PYTHONPATH = Join-Path $ROOT "src" +} +$env:NODE_ENV = "production" +if (-not $env:VITE_BFF_BASE_URL) { + $env:VITE_BFF_BASE_URL = "http://localhost:8090" +} +$env:DEPRECATE_PYTHON_INTEGRATIONS = "1" +$env:USE_FASTAPI_PYTHON_BRIDGE = "1" + +. (Join-Path $PSScriptRoot "ensure-deps.ps1") + +$global:ManagedProcesses = [System.Collections.Generic.List[System.Diagnostics.Process]]::new() + +function Write-Log([string]$Message) { + Write-Host "-> $Message" -ForegroundColor Cyan +} + +function Write-Warn([string]$Message) { + Write-Host "! $Message" -ForegroundColor Yellow +} + +function Write-Die([string]$Message) { + Write-Host "X $Message" -ForegroundColor Red + exit 1 +} + +function Stop-PortListener([int]$Port) { + try { + $connections = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue + if ($connections) { + foreach ($conn in $connections) { + $pId = $conn.OwningProcess + if ($pId -and $pId -gt 0 -and $pId -ne $PID) { + Write-Warn "Stopping stale listener on port $Port (PID: $pId)" + cmd /c "taskkill /PID $pId /T /F >nul 2>&1" + } + } + Start-Sleep -Milliseconds 300 + } + } catch { + $lines = netstat -ano | Select-String ":$Port\s+.*LISTENING\s+(\d+)" + foreach ($line in $lines) { + if ($line.Matches[0].Groups[1].Value -match '^\d+$') { + $pidToKill = [int]$line.Matches[0].Groups[1].Value + if ($pidToKill -gt 0 -and $pidToKill -ne $PID) { + cmd /c "taskkill /PID $pidToKill /T /F >nul 2>&1" + } + } + } + } +} + +function Wait-ForHttp([string]$Url, [string]$Name, [int]$TimeoutSeconds = 90) { + Write-Log "Waiting for $Name ($Url)" + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + while ($stopwatch.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + try { + $resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop + if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 400) { + Write-Host "-> $Name ready" -ForegroundColor Green + return $true + } + } catch { + Start-Sleep -Seconds 1 + } + } + Write-Die "$Name did not become ready in ${TimeoutSeconds}s ($Url)" +} + +function Start-ManagedProcess { + param( + [string]$Name, + [string]$FilePath, + [string[]]$ArgumentList, + [string]$WorkingDirectory, + [hashtable]$EnvironmentVariables = @{} + ) + + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $FilePath + foreach ($arg in $ArgumentList) { + $psi.ArgumentList.Add($arg) + } + $psi.WorkingDirectory = $WorkingDirectory + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + + foreach ($key in $EnvironmentVariables.Keys) { + $psi.EnvironmentVariables[$key] = [string]$EnvironmentVariables[$key] + } + + $proc = [System.Diagnostics.Process]::Start($psi) + if ($proc) { + $global:ManagedProcesses.Add($proc) + } + return $proc +} + +function Stop-AllManagedProcesses { + Write-Log "Shutting down local prod stack..." + foreach ($proc in $global:ManagedProcesses) { + if ($proc -and -not $proc.HasExited) { + try { + cmd /c "taskkill /PID $($proc.Id) /T /F >nul 2>&1" + } catch { + try { $proc.Kill() } catch {} + } + } + } + $global:ManagedProcesses.Clear() +} + +function Stop-Postgres { + if (Get-Command docker -ErrorAction SilentlyContinue) { + $running = & docker ps --format "{{.Names}}" 2>$null + if ($running -contains $PG_CONTAINER) { + Write-Log "Stopping $PG_CONTAINER" + & docker stop $PG_CONTAINER *> $null + Write-Log "Postgres stopped." + } + } +} + +function Invoke-Build { + Ensure-SystemTools + Ensure-WebDeps + Write-Log "Building Vite SPA (production, VITE_BFF_BASE_URL=$($env:VITE_BFF_BASE_URL))" + Push-Location $WEB + try { + & npm run build + } finally { + Pop-Location + } +} + +function Invoke-Start { + param([switch]$SkipBuild) + + Ensure-SystemTools + Ensure-AllProjectDeps + + New-Item -ItemType Directory -Force -Path $env:DATA_DIR | Out-Null + Write-Log "Ensuring Postgres and migrations (via .\local-run.ps1 migrate)" + & $LOCAL_RUN migrate + + if (-not $SkipBuild) { + Invoke-Build + } else { + Ensure-WebDeps + Write-Log "Skipping build (--SkipBuild)" + } + + $coreUrl = if ($env:CORE_SERVICE_URL) { $env:CORE_SERVICE_URL } else { "http://127.0.0.1:8094" } + $aiUrl = if ($env:AI_SERVICE_URL) { $env:AI_SERVICE_URL } else { "http://127.0.0.1:8092" } + $fastApiUrl = if ($env:FASTAPI_URL) { $env:FASTAPI_URL } else { "http://127.0.0.1:8096" } + + try { + Stop-PortListener 8094 + Write-Log "Starting CoreService on port 8094 (Production)" + Start-ManagedProcess -Name "CoreService" ` + -FilePath "dotnet" ` + -ArgumentList @("run", "--project", "src/CoreService.Api", "--no-launch-profile") ` + -WorkingDirectory (Join-Path $ROOT "services/CoreService") ` + -EnvironmentVariables @{ + "DATABASE_URL" = $env:DATABASE_URL + "FASTAPI_URL" = $fastApiUrl + "AI_SERVICE_URL" = $aiUrl + "WEBSITE_PROFILING_ROOT" = $ROOT + "DATA_DIR" = $env:DATA_DIR + "PYTHON" = $VENV_PYTHON + "REPORT_SERVICE_USE_PYTHON_BRIDGE" = "0" + "REPORT_SERVICE_VALIDATE_NATIVE" = "1" + "REPORT_SERVICE_WORKER_ENABLED" = "1" + "USE_FASTAPI_PYTHON_BRIDGE" = "1" + "ASPNETCORE_URLS" = "http://127.0.0.1:8094" + "ASPNETCORE_ENVIRONMENT" = "Production" + } | Out-Null + + Stop-PortListener 8092 + Write-Log "Starting AiService on port 8092 (Production)" + Start-ManagedProcess -Name "AiService" ` + -FilePath "dotnet" ` + -ArgumentList @("run", "--project", "src/AiService.Api", "--no-launch-profile") ` + -WorkingDirectory (Join-Path $ROOT "services/AiService") ` + -EnvironmentVariables @{ + "DATABASE_URL" = $env:DATABASE_URL + "FASTAPI_URL" = $fastApiUrl + "ASPNETCORE_URLS" = "http://127.0.0.1:8092" + "ASPNETCORE_ENVIRONMENT" = "Production" + "WP_MCP_HTTP" = "1" + } | Out-Null + + Wait-ForHttp "http://127.0.0.1:8094/health" "CoreService" + Wait-ForHttp "http://127.0.0.1:8092/health" "AiService" + + Stop-PortListener 8096 + Write-Log "Starting Python bridge (FastAPI) on port 8096" + Start-ManagedProcess -Name "FastAPI" ` + -FilePath $VENV_PYTHON ` + -ArgumentList @("-m", "uvicorn", "website_profiling.api.main:app", "--host", "0.0.0.0", "--port", "8096", "--workers", "1") ` + -WorkingDirectory $ROOT ` + -EnvironmentVariables @{ + "DATABASE_URL" = $env:DATABASE_URL + "DATA_DIR" = $env:DATA_DIR + "PYTHON" = $VENV_PYTHON + "WEBSITE_PROFILING_ROOT" = $ROOT + "PYTHONPATH" = $env:PYTHONPATH + "FASTAPI_URL" = $fastApiUrl + "FASTAPI_ALLOWED_ORIGINS" = "http://localhost:8090" + "DEPRECATE_PYTHON_INTEGRATIONS" = "1" + "DEPRECATE_PYTHON_REPORT_ROUTES" = "1" + } | Out-Null + + Wait-ForHttp "http://127.0.0.1:8096/api/health" "FastAPI" + + Stop-PortListener 8090 + Write-Log "Starting BFF on port 8090 (Production)" + Start-ManagedProcess -Name "BFF" ` + -FilePath "dotnet" ` + -ArgumentList @("run", "--project", "src/Bff.Api", "--no-launch-profile") ` + -WorkingDirectory (Join-Path $ROOT "services/Bff") ` + -EnvironmentVariables @{ + "FASTAPI_URL" = $fastApiUrl + "CORE_SERVICE_URL" = $coreUrl + "DATA_SERVICE_URL" = $coreUrl + "AI_SERVICE_URL" = $aiUrl + "INTEGRATIONS_SERVICE_URL" = $coreUrl + "REPORT_SERVICE_URL" = $coreUrl + "REPORT_ROUTES" = "/api/compare,/api/dashboards,/api/run,/api/jobs,/api/schedule,/api/crawl,/api/pipeline-preview" + "DATA_ROUTES" = "/api/report/meta,/api/report/payload,/api/report/history,/api/report/crawl-payload,/api/report/mobile-delta,/api/report/portfolio,/api/portfolio,/api/issues/status,/api/filters,/api/properties,/api/content-drafts,/api/content/score,/api/keywords,/api/page-markdown,/api/alerts,/api/logs,/api/backlinks,/api/pipeline-settings,/api/ui-preferences,/api/client-preferences" + "AI_ROUTES" = "/api/chat,/api/links/page-coach,/api/issues/fix-suggestion,/api/issues/action-plan,/api/ai/fix-suggestion,/api/dashboards/ai-generate,/api/content/analyze,/api/content/wizard,/api/llm-settings,/api/secrets,/api/ollama/status,/api/report/audit-tool,/api/mcp-tools" + "INTEGRATIONS_ROUTES" = "/api/integrations/google,/api/integrations/bing" + "BFF_ALLOWED_ORIGINS" = "http://localhost:3000" + "AUTH_SECRET" = "" + "SESSION_SECRET" = "" + "AUTH_PASSWORD" = "" + "AUTH_USER" = "" + "ASPNETCORE_URLS" = "http://127.0.0.1:8090" + "ASPNETCORE_ENVIRONMENT" = "Production" + } | Out-Null + + Wait-ForHttp "http://127.0.0.1:8090/health" "BFF" + + Write-Log "Starting Vite preview on port 3000 (Ctrl+C stops all services including Postgres)" + Write-Log "Open http://localhost:3000/home in your browser" + + Push-Location $WEB + try { + & npm run preview -- --host 0.0.0.0 --port 3000 + } finally { + Pop-Location + } + } finally { + Stop-AllManagedProcesses + Stop-Postgres + Write-Log "All services stopped." + } +} + +function Show-Help { + Write-Host @" +Local prod runner — same Postgres as .\local-run.ps1, production Vite build + full .NET stack. + + .\local-prod.ps1 Same as: start + .\local-prod.ps1 start DB + migrations + build + full stack + vite preview + .\local-prod.ps1 start --skip-build Start without rebuilding (reuse dist/) + .\local-prod.ps1 build npm run build only + .\local-prod.ps1 help Show this help + +After start, open: http://localhost:3000/home +"@ +} + +$cmd = if ($args.Count -gt 0) { $args[0] } else { "start" } +$skipBuild = ($args -contains "--skip-build") + +switch ($cmd) { + "start" { Invoke-Start -SkipBuild:$skipBuild } + "build" { Invoke-Build } + "help" { Show-Help } + "-h" { Show-Help } + "--help" { Show-Help } + default { Write-Die "Unknown command: $cmd (try: .\local-prod.ps1 help)" } +} diff --git a/scripts/local-run.ps1 b/scripts/local-run.ps1 index 5b3bddf0..983ea8b6 100644 --- a/scripts/local-run.ps1 +++ b/scripts/local-run.ps1 @@ -1,10 +1,13 @@ -# Local dev: PostgreSQL in Docker (wp-pg), Python venv + Vite/React SPA + BFF on the host. +# Local dev: PostgreSQL in Docker (wp-pg), Python venv + Vite/React SPA + .NET backend on host. # Usage: .\local-run.ps1 [command] -# (default) start — ensure DB, migrations, npm run dev -# setup — DB + venv + deps + migrations (no web server) +# (default) start — ensure DB, migrations, full .NET stack, FastAPI bridge, npm run dev +# setup — DB + venv + deps + migrations (no dev server) # db — start Postgres container only # migrate — EF Core: dotnet run --project services\Schema\src\Schema.Migrator # stop — stop wp-pg container +# docker — run entire full stack in Docker (docker compose up --build) +# docker:prod — run production stack in Docker (docker compose -f docker-compose.prod.yml up --build) +# docker:down — stop all Docker compose services # help — show commands # Requires: PowerShell 5.1+ (PowerShell 7+ recommended for reliable exit codes) @@ -45,6 +48,8 @@ if (-not $env:PYTHON) { . (Join-Path $PSScriptRoot "ensure-deps.ps1") +$global:ManagedProcesses = [System.Collections.Generic.List[System.Diagnostics.Process]]::new() + function Write-Log([string]$Message) { Write-Host "-> $Message" -ForegroundColor Cyan } @@ -76,50 +81,106 @@ function Test-Command([string]$Name) { } } -function Get-PythonLauncher { - foreach ($cmd in @("python", "python3", "py")) { - if (Get-Command $cmd -ErrorAction SilentlyContinue) { - if ($cmd -eq "py") { - return ,@("py", "-3") +function Stop-PortListener([int]$Port) { + try { + $connections = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue + if ($connections) { + foreach ($conn in $connections) { + $pId = $conn.OwningProcess + if ($pId -and $pId -gt 0 -and $pId -ne $PID) { + Write-Warn "Stopping stale listener on port $Port (PID: $pId)" + cmd /c "taskkill /PID $pId /T /F >nul 2>&1" + } + } + Start-Sleep -Milliseconds 300 + } + } catch { + $lines = netstat -ano | Select-String ":$Port\s+.*LISTENING\s+(\d+)" + foreach ($line in $lines) { + if ($line.Matches[0].Groups[1].Value -match '^\d+$') { + $pidToKill = [int]$line.Matches[0].Groups[1].Value + if ($pidToKill -gt 0 -and $pidToKill -ne $PID) { + Write-Warn "Stopping stale listener on port $Port (PID: $pidToKill)" + cmd /c "taskkill /PID $pidToKill /T /F >nul 2>&1" + } } - return ,@($cmd) } } - Write-Die "Missing required command: python (install Python 3.11+ and ensure it is on PATH)" } -function Invoke-PythonLauncher { +function Wait-ForHttp([string]$Url, [string]$Name, [int]$TimeoutSeconds = 60) { + Write-Log "Waiting for $Name ($Url)" + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + while ($stopwatch.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + try { + $resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop + if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 400) { + Write-Host "-> $Name ready" -ForegroundColor Green + return $true + } + } catch { + Start-Sleep -Seconds 1 + } + } + Write-Die "$Name did not become ready in ${TimeoutSeconds}s ($Url)" +} + +function Start-ManagedProcess { param( - [Parameter(Mandatory = $true)] - [string[]]$Launcher, - [Parameter(ValueFromRemainingArguments = $true)] - [string[]]$PythonArgs + [string]$Name, + [string]$FilePath, + [string[]]$ArgumentList, + [string]$WorkingDirectory, + [hashtable]$EnvironmentVariables = @{} ) - if ($Launcher.Count -gt 1) { - & $Launcher[0] $Launcher[1] @PythonArgs - } else { - & $Launcher[0] @PythonArgs + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $FilePath + foreach ($arg in $ArgumentList) { + $psi.ArgumentList.Add($arg) + } + $psi.WorkingDirectory = $WorkingDirectory + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + + foreach ($key in $EnvironmentVariables.Keys) { + $psi.EnvironmentVariables[$key] = [string]$EnvironmentVariables[$key] + } + + $proc = [System.Diagnostics.Process]::Start($psi) + if ($proc) { + $global:ManagedProcesses.Add($proc) } - Assert-LastExitCode "Python command failed: $($Launcher -join ' ') $($PythonArgs -join ' ')" + return $proc +} + +function Stop-AllManagedProcesses { + Write-Log "Shutting down local background services..." + foreach ($proc in $global:ManagedProcesses) { + if ($proc -and -not $proc.HasExited) { + try { + cmd /c "taskkill /PID $($proc.Id) /T /F >nul 2>&1" + } catch { + try { $proc.Kill() } catch {} + } + } + } + $global:ManagedProcesses.Clear() } function Get-DockerContainerNames { param([switch]$All) - $dockerArgs = if ($All) { @("ps", "-a") } else { @("ps") } - $output = & docker @dockerArgs --format "{{.Names}}" 2>$null + $dockerArgs = if ($All) { @("ps", "-a", "--format", "{{.Names}}") } else { @("ps", "--format", "{{.Names}}") } + $output = & docker @dockerArgs 2>$null if (-not $output) { return @() } - # Force array: a single container name returns a scalar string; -contains on a - # string checks characters, not whole names. return @($output | ForEach-Object { "$_".Trim() } | Where-Object { $_ }) } function Test-DockerRunning { Test-Command docker - # Docker Desktop writes capability warnings to stderr; avoid treating them as terminating errors. $prevErrorAction = $ErrorActionPreference $ErrorActionPreference = "Continue" try { @@ -174,10 +235,6 @@ function Invoke-Db { Write-Log "DATABASE_URL=$($env:DATABASE_URL)" } -function Invoke-Venv { - Ensure-PythonDeps -} - function Invoke-Migrate { Ensure-SystemTools Ensure-DotnetDeps @@ -187,14 +244,6 @@ function Invoke-Migrate { Assert-LastExitCode "Database migration failed (Schema.Migrator)" } -function Invoke-WebDeps { - Ensure-WebDeps -} - -function Invoke-BrowserDeps { - Ensure-BrowserDeps -} - function Invoke-Setup { New-Item -ItemType Directory -Force -Path $env:DATA_DIR | Out-Null Ensure-SystemTools @@ -212,58 +261,118 @@ function Invoke-Start { Invoke-Migrate $bffBase = if ($env:VITE_BFF_BASE_URL) { $env:VITE_BFF_BASE_URL } else { "http://localhost:8090" } - $fileServiceUrl = if ($env:FILE_SERVICE_URL) { $env:FILE_SERVICE_URL } else { "http://127.0.0.1:8097" } - - if (Get-Command dotnet -ErrorAction SilentlyContinue) { - Write-Log "Starting FileService on port 8097" - $env:REPORT_API_URL = "http://127.0.0.1:8096" - Start-Process -FilePath "dotnet" ` - -ArgumentList @("run", "--project", "src/FileService.Api", "--no-launch-profile") ` - -WorkingDirectory (Join-Path $ROOT "services/FileService") ` - -WindowStyle Minimized | Out-Null - } else { - Write-Warn "dotnet not found — PDF export requires FileService (see services/FileService/README.md)" - } - - Write-Log "Pipeline jobs run in ReportService C# worker" - Write-Log "Starting Python bridge on port 8096" - $env:FASTAPI_URL = "http://127.0.0.1:8096" - $env:FASTAPI_ALLOWED_ORIGINS = "http://localhost:8090" - Start-Process -FilePath $VENV_PYTHON ` - -ArgumentList @("-m", "uvicorn", "website_profiling.api.main:app", "--host", "0.0.0.0", "--port", "8096", "--workers", "1") ` - -WorkingDirectory $ROOT ` - -WindowStyle Minimized | Out-Null + $coreUrl = if ($env:CORE_SERVICE_URL) { $env:CORE_SERVICE_URL } else { "http://127.0.0.1:8094" } + $aiUrl = if ($env:AI_SERVICE_URL) { $env:AI_SERVICE_URL } else { "http://127.0.0.1:8092" } + $fastApiUrl = if ($env:FASTAPI_URL) { $env:FASTAPI_URL } else { "http://127.0.0.1:8096" } - if (Get-Command dotnet -ErrorAction SilentlyContinue) { + try { + Stop-PortListener 8094 + Write-Log "Starting CoreService on port 8094" + Start-ManagedProcess -Name "CoreService" ` + -FilePath "dotnet" ` + -ArgumentList @("run", "--project", "src/CoreService.Api", "--no-launch-profile") ` + -WorkingDirectory (Join-Path $ROOT "services/CoreService") ` + -EnvironmentVariables @{ + "DATABASE_URL" = $env:DATABASE_URL + "FASTAPI_URL" = $fastApiUrl + "AI_SERVICE_URL" = $aiUrl + "WEBSITE_PROFILING_ROOT" = $ROOT + "DATA_DIR" = $env:DATA_DIR + "PYTHON" = $VENV_PYTHON + "REPORT_SERVICE_USE_PYTHON_BRIDGE" = "0" + "REPORT_SERVICE_VALIDATE_NATIVE" = "1" + "REPORT_SERVICE_WORKER_ENABLED" = "1" + "USE_FASTAPI_PYTHON_BRIDGE" = "1" + "ASPNETCORE_URLS" = "http://127.0.0.1:8094" + "ASPNETCORE_ENVIRONMENT" = "Development" + } | Out-Null + + Stop-PortListener 8092 + Write-Log "Starting AiService on port 8092" + Start-ManagedProcess -Name "AiService" ` + -FilePath "dotnet" ` + -ArgumentList @("run", "--project", "src/AiService.Api", "--no-launch-profile") ` + -WorkingDirectory (Join-Path $ROOT "services/AiService") ` + -EnvironmentVariables @{ + "DATABASE_URL" = $env:DATABASE_URL + "FASTAPI_URL" = $fastApiUrl + "ASPNETCORE_URLS" = "http://127.0.0.1:8092" + "ASPNETCORE_ENVIRONMENT" = "Development" + "WP_MCP_HTTP" = "1" + } | Out-Null + + Wait-ForHttp "http://127.0.0.1:8094/health" "CoreService" + Wait-ForHttp "http://127.0.0.1:8092/health" "AiService" + + Stop-PortListener 8096 + Write-Log "Starting Python bridge (FastAPI) on port 8096" + Start-ManagedProcess -Name "FastAPI" ` + -FilePath $VENV_PYTHON ` + -ArgumentList @("-m", "uvicorn", "website_profiling.api.main:app", "--host", "0.0.0.0", "--port", "8096", "--workers", "1") ` + -WorkingDirectory $ROOT ` + -EnvironmentVariables @{ + "DATABASE_URL" = $env:DATABASE_URL + "DATA_DIR" = $env:DATA_DIR + "PYTHON" = $VENV_PYTHON + "WEBSITE_PROFILING_ROOT" = $ROOT + "PYTHONPATH" = $env:PYTHONPATH + "FASTAPI_URL" = $fastApiUrl + "FASTAPI_ALLOWED_ORIGINS" = "http://localhost:8090" + "DEPRECATE_PYTHON_INTEGRATIONS" = "1" + "DEPRECATE_PYTHON_REPORT_ROUTES" = "1" + } | Out-Null + + Wait-ForHttp "http://127.0.0.1:8096/api/health" "FastAPI" + + Stop-PortListener 8090 Write-Log "Starting BFF on port 8090" - $bffDir = Join-Path $ROOT "services/Bff" - $env:FASTAPI_URL = "http://127.0.0.1:8096" - $env:FILE_SERVICE_URL = $fileServiceUrl - $env:BFF_ALLOWED_ORIGINS = "http://localhost:3000" - $env:ASPNETCORE_URLS = "http://127.0.0.1:8090" - $env:ASPNETCORE_ENVIRONMENT = "Development" - Start-Process -FilePath "dotnet" ` + Start-ManagedProcess -Name "BFF" ` + -FilePath "dotnet" ` -ArgumentList @("run", "--project", "src/Bff.Api", "--no-launch-profile") ` - -WorkingDirectory $bffDir ` - -WindowStyle Minimized | Out-Null - } else { - Write-Warn "dotnet not found — browser API calls need the BFF (see services/Bff/)" - } - - $env:FILE_SERVICE_URL = $fileServiceUrl - $env:VITE_BFF_BASE_URL = $bffBase - - Write-Log "Starting Vite dev server (Ctrl+C to stop)" - Write-Log "DATABASE_URL=$($env:DATABASE_URL)" - Write-Log "DATA_DIR=$($env:DATA_DIR)" - Write-Log "PYTHON=$($env:PYTHON)" - Write-Log "VITE_BFF_BASE_URL=$bffBase" - Write-Log "FILE_SERVICE_URL=$fileServiceUrl" - Push-Location $WEB - try { - & npm run dev + -WorkingDirectory (Join-Path $ROOT "services/Bff") ` + -EnvironmentVariables @{ + "FASTAPI_URL" = $fastApiUrl + "CORE_SERVICE_URL" = $coreUrl + "DATA_SERVICE_URL" = $coreUrl + "AI_SERVICE_URL" = $aiUrl + "INTEGRATIONS_SERVICE_URL" = $coreUrl + "REPORT_SERVICE_URL" = $coreUrl + "REPORT_ROUTES" = "/api/compare,/api/dashboards,/api/run,/api/jobs,/api/schedule,/api/crawl,/api/pipeline-preview" + "DATA_ROUTES" = "/api/report/meta,/api/report/payload,/api/report/history,/api/report/crawl-payload,/api/report/mobile-delta,/api/report/portfolio,/api/portfolio,/api/issues/status,/api/filters,/api/properties,/api/content-drafts,/api/content/score,/api/keywords,/api/page-markdown,/api/alerts,/api/logs,/api/backlinks,/api/pipeline-settings,/api/ui-preferences,/api/client-preferences" + "AI_ROUTES" = "/api/chat,/api/links/page-coach,/api/issues/fix-suggestion,/api/issues/action-plan,/api/ai/fix-suggestion,/api/dashboards/ai-generate,/api/content/analyze,/api/content/wizard,/api/llm-settings,/api/secrets,/api/ollama/status,/api/report/audit-tool,/api/mcp-tools" + "INTEGRATIONS_ROUTES" = "/api/integrations/google,/api/integrations/bing" + "BFF_ALLOWED_ORIGINS" = "http://localhost:3000" + "AUTH_SECRET" = "" + "SESSION_SECRET" = "" + "AUTH_PASSWORD" = "" + "AUTH_USER" = "" + "ASPNETCORE_URLS" = "http://127.0.0.1:8090" + "ASPNETCORE_ENVIRONMENT" = "Development" + } | Out-Null + + Wait-ForHttp "http://127.0.0.1:8090/health" "BFF" + + $env:VITE_BFF_BASE_URL = $bffBase + + Write-Log "Starting Vite dev server (Ctrl+C stops all services including Postgres)" + Write-Log "DATABASE_URL=$($env:DATABASE_URL)" + Write-Log "DATA_DIR=$($env:DATA_DIR)" + Write-Log "VITE_BFF_BASE_URL=$bffBase" + Write-Log "CORE_SERVICE_URL=$coreUrl" + Write-Log "AI_SERVICE_URL=$aiUrl" + Write-Log "FASTAPI_URL=$fastApiUrl" + Write-Log "Open http://localhost:3000/home in your browser" + + Push-Location $WEB + try { + & npm run dev + } finally { + Pop-Location + } } finally { - Pop-Location + Stop-AllManagedProcesses + Invoke-Stop + Write-Log "All services stopped." } } @@ -273,22 +382,46 @@ function Invoke-Stop { Write-Log "Stopping $PG_CONTAINER" & docker stop $PG_CONTAINER *> $null Assert-LastExitCode "Failed to stop container $PG_CONTAINER" + Write-Log "Postgres stopped." } else { Write-Warn "Container $PG_CONTAINER is not running" } } +function Invoke-DockerCompose([string]$Mode = "dev") { + Test-DockerRunning + switch ($Mode) { + "dev" { + Write-Log "Starting full stack via Docker Compose (docker compose up --build)..." + & docker compose up --build + } + "prod" { + Write-Log "Starting production layout via Docker Compose..." + & docker compose -f docker-compose.prod.yml up --build + } + "down" { + Write-Log "Stopping Docker Compose containers..." + & docker compose down + } + } +} + function Show-Help { Write-Host @" Local dev runner - Postgres in Docker, app on your machine .\local-run.ps1 Same as: start - .\local-run.ps1 start DB + migrations + npm run dev + .\local-run.ps1 start DB + migrations + full stack + npm run dev (Ctrl+C stops all) .\local-run.ps1 setup One-time setup (no dev server) .\local-run.ps1 db Start Postgres only .\local-run.ps1 migrate Apply EF Core migrations (Schema.Migrator) .\local-run.ps1 stop Stop Postgres container +Docker Compose commands: + .\local-run.ps1 docker Build & run full stack in Docker (docker compose up --build) + .\local-run.ps1 docker:prod Run production stack in Docker (docker compose -f docker-compose.prod.yml) + .\local-run.ps1 docker:down Tear down Docker containers (docker compose down) + Environment overrides (optional): DATABASE_URL (default: postgres://postgres:dev@127.0.0.1:5432/website_profiling) DATA_DIR (default: /data) @@ -311,6 +444,9 @@ switch ($cmd) { "db" { Invoke-Db } "migrate" { Invoke-Migrate } "stop" { Invoke-Stop } + "docker" { Invoke-DockerCompose "dev" } + "docker:prod" { Invoke-DockerCompose "prod" } + "docker:down" { Invoke-DockerCompose "down" } "help" { Show-Help } "-h" { Show-Help } "--help" { Show-Help } diff --git a/scripts/local-test.ps1 b/scripts/local-test.ps1 index d0a40d19..c31f4e5b 100644 --- a/scripts/local-test.ps1 +++ b/scripts/local-test.ps1 @@ -112,8 +112,8 @@ function Invoke-PythonLauncher { function Get-DockerContainerNames { param([switch]$All) - $dockerArgs = if ($All) { @("ps", "-a") } else { @("ps") } - $output = & docker @dockerArgs --format "{{.Names}}" 2>$null + $dockerArgs = if ($All) { @("ps", "-a", "--format", "{{.Names}}") } else { @("ps", "--format", "{{.Names}}") } + $output = & docker @dockerArgs 2>$null if (-not $output) { return @() } @@ -264,6 +264,20 @@ function Invoke-WebChecks { Write-Ok "Web checks passed" } +function Invoke-DotnetChecks { + Ensure-SystemTools + Ensure-DotnetDeps + Write-Log "dotnet test (services/WebsiteProfiling.slnx)" + Push-Location (Join-Path $ROOT "services") + try { + & dotnet test WebsiteProfiling.slnx -m:1 + Assert-LastExitCode ".NET tests failed (WebsiteProfiling.slnx)" + } finally { + Pop-Location + } + Write-Ok ".NET tests passed" +} + function Invoke-Quick { if (-not $env:DATABASE_URL) { Write-Die "DATABASE_URL is not set. Export it or run .\scripts\local-test.ps1 all" @@ -277,20 +291,22 @@ function Invoke-Quick { & $VENV_PYTHON -m src --help *> $null Assert-LastExitCode "CLI smoke failed" Invoke-WebChecks + Invoke-DotnetChecks Write-Ok "Quick test run passed" } function Show-Help { Write-Host @" -Local test runner — mirrors CI (python + web jobs) +Local test runner — mirrors CI (python + web + .NET jobs) .\scripts\local-test.ps1 Same as: all - .\scripts\local-test.ps1 all Postgres + migrations + full pytest + web + .\scripts\local-test.ps1 all Postgres + migrations + full pytest + web + .NET .\scripts\local-test.ps1 python DB + pytest (core + reporting + tools) + CLI + .\scripts\local-test.ps1 dotnet dotnet test services/WebsiteProfiling.slnx .\scripts\local-test.ps1 reporting Reporting module 100% coverage gate only .\scripts\local-test.ps1 tools Tools module coverage gate only .\scripts\local-test.ps1 web typecheck, lint, vitest (no Docker) - .\scripts\local-test.ps1 quick pytest -NoCov + web (DB must be ready) + .\scripts\local-test.ps1 quick pytest -NoCov + web + dotnet (DB must be ready) .\scripts\local-test.ps1 all -NoCov skip pytest coverage gates (faster) @@ -322,9 +338,11 @@ switch ($cmd) { "all" { Invoke-PythonChecks Invoke-WebChecks - Write-Ok "All local tests passed (CI python + web jobs)" + Invoke-DotnetChecks + Write-Ok "All local tests passed (CI python + web + .NET jobs)" } "python" { Invoke-PythonChecks } + "dotnet" { Invoke-DotnetChecks } "reporting" { Invoke-Venv Invoke-PytestReporting diff --git a/services/Schema/Dockerfile b/services/Schema/Dockerfile index 6407f9f2..8efad075 100644 --- a/services/Schema/Dockerfile +++ b/services/Schema/Dockerfile @@ -8,7 +8,7 @@ COPY Shared/WebsiteProfiling.Shared/ Shared/WebsiteProfiling.Shared/ COPY Schema/src/ Schema/src/ RUN dotnet publish Schema/src/Schema.Migrator/Schema.Migrator.csproj -c Release -o /app/publish --no-restore -FROM mcr.microsoft.com/dotnet/runtime:10.0 AS runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app # Npgsql probes for libgssapi_krb5 (GSSAPI auth support) even when unused; the slim runtime image # doesn't ship it, which logs a scary-looking (but harmless) load failure on every connection attempt. diff --git a/web/src/AppRoutes.tsx b/web/src/AppRoutes.tsx index 815d99d0..46c69f3e 100644 --- a/web/src/AppRoutes.tsx +++ b/web/src/AppRoutes.tsx @@ -25,6 +25,7 @@ function LandingRoute() { } function ChatRoute() { + usePageTitle('AI Chat · Site Audit'); return ( @@ -32,6 +33,41 @@ function ChatRoute() { ); } +function WriteRoute() { + usePageTitle('Write Studio · Site Audit'); + return ; +} + +function PipelineRoute() { + usePageTitle('Run Audit · Site Audit'); + return ; +} + +function SettingsRoute() { + usePageTitle('Settings · Site Audit'); + return ; +} + +function SecretsRoute() { + usePageTitle('API Keys & Secrets · Site Audit'); + return ; +} + +function McpRoute() { + usePageTitle('MCP Tools · Site Audit'); + return ; +} + +function PagesMarkdownRoute() { + usePageTitle('Pages Markdown · Site Audit'); + return ; +} + +function ContentPipelineRoute() { + usePageTitle('Content Pipeline · Site Audit'); + return ; +} + function RiskSettingsRoute() { usePageTitle('Risk Settings · Site Audit'); return ; @@ -47,13 +83,13 @@ export default function AppRoutes() { } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/web/src/ReportShell.tsx b/web/src/ReportShell.tsx index 2bf6c2f3..14910bc4 100644 --- a/web/src/ReportShell.tsx +++ b/web/src/ReportShell.tsx @@ -47,8 +47,8 @@ function viewLoading(label = 'Loading view…') { return (
{label} -
-
+
+
); } @@ -246,16 +246,16 @@ function AppContent({ slug }: SlugProps): ReactNode {
-

+

{isDomainError ? strings.app.noReportForDomainTitle : strings.app.failedTitle}

-

{error}

+

{error}

{!isDomainError ? ( -

{strings.app.failedHint}

+

{strings.app.failedHint}

) : null} {strings.app.openRunAudit} diff --git a/web/src/_lighthouse_extract.json b/web/src/_lighthouse_extract.json index d5edb196..2eb514af 100644 --- a/web/src/_lighthouse_extract.json +++ b/web/src/_lighthouse_extract.json @@ -64,38 +64,38 @@ { "id": "LCP", "label": "LCP", - "color": "text-link", - "border": "border-blue-700/40" + "color": "text-md-sys-primary", + "border": "border-md-sys-primary/40" }, { "id": "CLS", "label": "CLS", - "color": "text-purple-400", - "border": "border-purple-700/40" + "color": "text-md-sys-tertiary", + "border": "border-md-sys-tertiary/40" }, { "id": "TBT", "label": "TBT / FID", - "color": "text-yellow-400", - "border": "border-yellow-700/40" + "color": "text-md-sys-warning", + "border": "border-md-sys-warning/40" }, { "id": "Accessibility", "label": "Accessibility", - "color": "text-teal-400", - "border": "border-teal-700/40" + "color": "text-md-sys-secondary", + "border": "border-md-sys-secondary/40" }, { "id": "SEO", "label": "SEO", - "color": "text-green-400", - "border": "border-green-700/40" + "color": "text-md-sys-success", + "border": "border-md-sys-success/40" }, { "id": "UX", "label": "Best Practices / UX", - "color": "text-orange-400", - "border": "border-orange-700/40" + "color": "text-md-sys-warning", + "border": "border-md-sys-warning/40" } ], "quickWins": [ diff --git a/web/src/components/AlertBanner.tsx b/web/src/components/AlertBanner.tsx index 54e8b81c..f726cacd 100644 --- a/web/src/components/AlertBanner.tsx +++ b/web/src/components/AlertBanner.tsx @@ -9,23 +9,23 @@ const VARIANT_STYLES: Record< { container: string; text: string; defaultIcon: typeof AlertTriangle } > = { warning: { - container: 'border-amber-500/30 bg-amber-500/10', - text: 'text-amber-950 dark:text-amber-100', + container: 'border-md-sys-tertiary/40 bg-md-sys-tertiary-container/30', + text: 'text-md-sys-on-tertiary-container', defaultIcon: AlertTriangle, }, info: { - container: 'border-blue-500/30 bg-blue-500/5', - text: 'text-foreground', + container: 'border-md-sys-primary/30 bg-md-sys-primary-container/20', + text: 'text-md-sys-on-surface', defaultIcon: Info, }, error: { - container: 'border-red-500/30 bg-red-500/10', - text: 'text-red-950 dark:text-red-100', + container: 'border-md-sys-error/40 bg-md-sys-error-container/40', + text: 'text-md-sys-on-error-container', defaultIcon: XCircle, }, success: { - container: 'border-emerald-500/30 bg-emerald-500/10', - text: 'text-emerald-950 dark:text-emerald-100', + container: 'border-md-sys-success/40 bg-md-sys-success-container/30', + text: 'text-md-sys-on-success-container', defaultIcon: CheckCircle, }, }; @@ -51,21 +51,22 @@ export default function AlertBanner({ children, onDismiss, className = '', - role = 'status', + role, collapsible = false, defaultOpen = false, }: AlertBannerProps) { const [open, setOpen] = useState(defaultOpen); + const resolvedRole = role ?? (variant === 'error' ? 'alert' : 'status'); const styles = VARIANT_STYLES[variant]; const DefaultIcon = styles.defaultIcon; const iconNode = icon ?? ; - const containerClass = `rounded-xl border text-sm ${styles.container} ${styles.text} ${className}`.trim(); + const containerClass = `rounded-2xl border text-sm ${styles.container} ${styles.text} ${className}`.trim(); if (collapsible && title) { const Chevron = open ? ChevronDown : ChevronRight; return ( -
+
@@ -312,14 +307,14 @@ export default function AppShell({ className={`flex items-center ${sidebarCollapsed ? 'gap-3 md:justify-center md:gap-0' : 'gap-3'}`} >
{auditedInitials}
-
{auditedHost}
-
{lastCrawlText}
+
{auditedHost}
+
{lastCrawlText}
{auditedStartUrl ? ( @@ -330,7 +325,7 @@ export default function AppShell({ rel="noreferrer" title={strings.app.viewSiteLabel} aria-label={strings.app.viewSiteLabel} - className="mt-3 hidden md:flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors" + className="mt-3 hidden md:flex items-center justify-center text-md-sys-on-surface-variant hover:text-md-sys-on-surface transition-colors" > @@ -339,7 +334,7 @@ export default function AppShell({ href={auditedStartUrl} target="_blank" rel="noreferrer" - className="mt-3 flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors" + className="mt-3 flex items-center gap-2 text-xs text-md-sys-on-surface-variant hover:text-md-sys-on-surface transition-colors" > {strings.app.viewSiteLabel} @@ -350,22 +345,22 @@ export default function AppShell({ ) : null} -
+
{sessionReadonly ? (
{strings.app.readonlyBanner}
) : null} {showSidebar ? ( -
+
{showSearch && onSearchChange ? (
- + onSearchChange(e.target.value)} placeholder={strings.app.searchPlaceholder} - className="w-full bg-brand-900 border border-default focus:border-[var(--accent)] rounded-lg pl-10 pr-4 py-2 text-sm outline-none text-foreground transition-all" + className="w-full bg-md-sys-surface-container-high border border-md-sys-outline-variant/40 focus:ring-2 focus:ring-md-sys-primary rounded-full pl-10 pr-9 py-2 text-sm outline-none text-md-sys-on-surface transition-all duration-200 placeholder:text-md-sys-on-surface-variant/60" /> + {searchQuery ? ( + + ) : null}
) : (
)}
{headerExtra} +
diff --git a/web/src/components/Badge.tsx b/web/src/components/Badge.tsx index beaa8c42..b8edc8f8 100644 --- a/web/src/components/Badge.tsx +++ b/web/src/components/Badge.tsx @@ -6,12 +6,12 @@ import { getBadgeVariant } from '../lib/badges'; * Single size: text-xs, py-1, px-2. Normalize display value via optional `label` prop. */ const VARIANT_CLASSES: Record = { - critical: 'bg-red-500 text-white', - high: 'bg-red-500/20 text-red-700 dark:text-red-400 border border-red-500/30', - medium: 'bg-yellow-500/20 text-yellow-800 dark:text-yellow-400 border border-yellow-500/30', - low: 'bg-brand-700/20 text-muted-foreground border border-brand-700/30', - info: 'bg-brand-700/20 text-muted-foreground border border-brand-700/30', - success: 'bg-green-500/20 text-green-700 dark:text-green-400 border border-green-500/30', + critical: 'bg-md-sys-error text-md-sys-on-error shadow-xs', + high: 'bg-md-sys-error-container text-md-sys-on-error-container border border-md-sys-error/30', + medium: 'bg-md-sys-tertiary-container text-md-sys-on-tertiary-container border border-md-sys-tertiary/30', + low: 'bg-md-sys-secondary-container text-md-sys-on-secondary-container border border-md-sys-outline-variant/40', + info: 'bg-md-sys-primary-container text-md-sys-on-primary-container border border-md-sys-primary/30', + success: 'bg-md-sys-success-container text-md-sys-on-success-container border border-md-sys-success/30', }; export default function Badge({ @@ -32,7 +32,7 @@ export default function Badge({ const classes = VARIANT_CLASSES[v] || VARIANT_CLASSES.info; return ( {display} diff --git a/web/src/components/Button.tsx b/web/src/components/Button.tsx index af953350..e4b8cbb8 100644 --- a/web/src/components/Button.tsx +++ b/web/src/components/Button.tsx @@ -1,7 +1,7 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react'; import { Loader2 } from 'lucide-react'; -type ButtonVariant = 'primary' | 'secondary' | 'ghost'; +type ButtonVariant = 'primary' | 'secondary' | 'tonal' | 'outline' | 'ghost' | 'danger' | 'danger-outline' | 'success'; type ButtonProps = { children?: ReactNode; @@ -12,9 +12,9 @@ type ButtonProps = { } & ButtonHTMLAttributes; /** - * Shared button: primary (Export style), secondary (border), ghost. - * Same size: px-4 py-2 rounded-lg text-sm font-medium/bold for primary. - * Includes tactile press feedback (.press) and, for primary, hover elevation. + * Shared button adhering strictly to Google Material 3 (M3) Expressive Design: + * Pill shape (rounded-full), tactile press feedback (active:scale-[0.98]), M3 color roles, + * spring transitions, and accessible focus rings. */ export default function Button({ children, @@ -27,11 +27,24 @@ export default function Button({ ...rest }: ButtonProps) { const base = - 'press inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all disabled:opacity-50 disabled:pointer-events-none'; + 'press inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-full text-sm font-medium transition-all duration-200 ease-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-md-sys-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none'; const variants: Record = { - primary: 'bg-blue-600 hover:bg-blue-500 text-white font-bold hover:shadow-[var(--elevation-2)]', - secondary: 'border border-default text-foreground hover:bg-brand-700/80', - ghost: 'text-muted-foreground hover:text-foreground hover:bg-brand-800/80', + primary: + 'bg-md-sys-primary text-md-sys-on-primary font-semibold hover:brightness-105 hover:shadow-[var(--elevation-2)]', + secondary: + 'bg-md-sys-secondary-container text-md-sys-on-secondary-container hover:brightness-95 border border-transparent', + tonal: + 'bg-md-sys-secondary-container text-md-sys-on-secondary-container hover:brightness-95 border border-transparent', + outline: + 'border border-md-sys-outline text-md-sys-primary hover:bg-md-sys-primary/10 font-medium', + ghost: + 'text-md-sys-primary hover:bg-md-sys-primary/10', + danger: + 'bg-md-sys-error text-md-sys-on-error font-semibold hover:brightness-105 hover:shadow-[var(--elevation-2)]', + 'danger-outline': + 'border border-md-sys-error text-md-sys-error hover:bg-md-sys-error/10 font-medium', + success: + 'bg-md-sys-success text-md-sys-on-success font-semibold hover:brightness-105 hover:shadow-[var(--elevation-2)]', }; const combined = `${base} ${variants[variant] || variants.primary} ${className}`.trim(); return ( diff --git a/web/src/components/Card.tsx b/web/src/components/Card.tsx index 2f53a2e9..9931494e 100644 --- a/web/src/components/Card.tsx +++ b/web/src/components/Card.tsx @@ -15,9 +15,9 @@ type CardProps = { }; /** - * Standard card container: bg-brand-800, border, rounded-xl, padding. - * Use shadow for stat cards, overflowHidden for table wrappers, - * interactive for clickable cards (hover lift). + * Standard card container adhering to Google Material 3 (M3) Expressive Design: + * bg-md-sys-surface-container, rounded-2xl (16px), subtle tonal border, and tactile spring motion. + * Interactive cards feature hover lift and active:scale-[0.99] press feedback. */ export default function Card({ children, @@ -30,15 +30,17 @@ export default function Card({ devData, }: CardProps) { const showDevCopy = import.meta.env.DEV && devData != null; - const paddingClass = padding === 'none' ? '' : padding === 'tight' ? 'p-4' : 'p-5'; - const shadowClass = shadow ? 'shadow-sm' : ''; + const paddingClass = padding === 'none' ? '' : padding === 'tight' ? 'p-4' : 'p-6'; + const shadowClass = shadow ? 'shadow-[var(--elevation-1)]' : ''; const overflowClass = overflowHidden ? 'overflow-hidden' : ''; - const interactiveClass = interactive ? 'hover-lift cursor-pointer' : ''; + const interactiveClass = interactive + ? 'cursor-pointer transition-all duration-200 ease-out hover:-translate-y-0.5 hover:shadow-[var(--elevation-2)] active:scale-[0.99]' + : 'transition-all duration-200'; const devClass = showDevCopy ? 'relative group/dev-card' : ''; return (
{showDevCopy ? : null} {children} diff --git a/web/src/components/ChartCard.tsx b/web/src/components/ChartCard.tsx index 6a15ba23..d2b7031e 100644 --- a/web/src/components/ChartCard.tsx +++ b/web/src/components/ChartCard.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import HelpHint, { normalizeHintContent, type HelpHintContent } from './HelpHint'; -import DevCopyJsonButton from './DevCopyJsonButton'; +import Card from './Card'; export interface ChartCardProps { title: string; @@ -23,13 +23,11 @@ export default function ChartCard({ devData, }: ChartCardProps) { const hintContent = normalizeHintContent(hint); - const showDevCopy = import.meta.env.DEV && devData != null; return ( -
- {showDevCopy ? : null} -
-

{title}

+ +
+

{title}

{hintContent ? ( {hintContent.body} @@ -39,6 +37,6 @@ export default function ChartCard({
{children}
-
+
); } diff --git a/web/src/components/CrawlScopeBanner.tsx b/web/src/components/CrawlScopeBanner.tsx index 4f5a683c..a868f532 100644 --- a/web/src/components/CrawlScopeBanner.tsx +++ b/web/src/components/CrawlScopeBanner.tsx @@ -50,13 +50,13 @@ export default function CrawlScopeBanner({ data }: { data: ReportPayload | null } - icon={} + icon={} > {limited ?

{cs.limitedNote}

: null} {blocked > 0 ? (

{format(cs.robotsLine, { count: blocked.toLocaleString() })}

) : null} -

+

{renderMode === 'javascript' ? cs.javascriptNote : renderMode === 'auto' @@ -64,7 +64,7 @@ export default function CrawlScopeBanner({ data }: { data: ReportPayload | null : cs.staticHtmlNote}

{renderMode !== 'static' && jsConcurrency != null && jsConcurrency > 0 ? ( -

+

{format(cs.jsConcurrencyLine, { count: jsConcurrency.toLocaleString() })}

) : null} @@ -72,7 +72,7 @@ export default function CrawlScopeBanner({ data }: { data: ReportPayload | null pagesStatic != null && pagesRendered != null && (pagesStatic > 0 || pagesRendered > 0) ? ( -

+

{format(cs.fetchMethodMixLine, { staticCount: pagesStatic.toLocaleString(), renderedCount: pagesRendered.toLocaleString(), @@ -80,7 +80,7 @@ export default function CrawlScopeBanner({ data }: { data: ReportPayload | null

) : null} {pagesWithConsoleErrors > 0 ? ( -

+

{format(cs.browserConsoleErrorsLine, { pages: pagesWithConsoleErrors.toLocaleString(), errors: totalConsoleErrors.toLocaleString(), @@ -88,7 +88,7 @@ export default function CrawlScopeBanner({ data }: { data: ReportPayload | null

) : null} {pagesWithPageErrors > 0 ? ( -

+

{format(cs.browserPageErrorsLine, { pages: pagesWithPageErrors.toLocaleString(), })} @@ -98,7 +98,7 @@ export default function CrawlScopeBanner({ data }: { data: ReportPayload | null

{cs.viewJavaScriptErrors} diff --git a/web/src/components/DataViewLayout.tsx b/web/src/components/DataViewLayout.tsx index a7126652..02bd617d 100644 --- a/web/src/components/DataViewLayout.tsx +++ b/web/src/components/DataViewLayout.tsx @@ -37,7 +37,7 @@ export default function DataViewLayout({

{header ?
{header}
: null} {toolbar ? ( -
+
{toolbar}
) : null} diff --git a/web/src/components/DevCopyJsonButton.tsx b/web/src/components/DevCopyJsonButton.tsx index ad1fc772..c42caef1 100644 --- a/web/src/components/DevCopyJsonButton.tsx +++ b/web/src/components/DevCopyJsonButton.tsx @@ -36,10 +36,10 @@ export default function DevCopyJsonButton({ data, className = '' }: DevCopyJsonB onClick={copy} title={strings.components.devCopyJson.title} aria-label={strings.components.devCopyJson.title} - className={`absolute top-2 right-2 z-10 rounded border border-default/60 bg-brand-900/90 p-1 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:border-blue-500/40 hover:text-bright focus:opacity-100 group-hover/dev-card:opacity-100 ${className}`.trim()} + className={`press absolute top-2 right-2 z-10 rounded-full border border-md-sys-outline-variant/40 bg-md-sys-surface-container-low/90 p-1.5 text-md-sys-on-surface-variant opacity-0 shadow-xs backdrop-blur transition-all duration-150 hover:border-md-sys-primary/40 hover:text-md-sys-on-surface active:scale-90 focus:opacity-100 group-hover/dev-card:opacity-100 ${className}`.trim()} > {copied ? ( - + ) : ( )} diff --git a/web/src/components/EmptyState.tsx b/web/src/components/EmptyState.tsx index 3744cfbd..90deb8e6 100644 --- a/web/src/components/EmptyState.tsx +++ b/web/src/components/EmptyState.tsx @@ -65,18 +65,18 @@ export default function EmptyState({ }: EmptyStateProps) { return (
{aurora ?
: null}
{Icon ? ( - + ) : null} -

{title}

+

{title}

{description ? ( -

{description}

+

{description}

) : null} {primaryAction || secondaryAction ? (
@@ -89,9 +89,9 @@ export default function EmptyState({ {highlights.map(({ icon: Hi, label }) => (
  • - + {label} diff --git a/web/src/components/GoogleIntegrationsPanel.tsx b/web/src/components/GoogleIntegrationsPanel.tsx index b95c0703..967cccc5 100644 --- a/web/src/components/GoogleIntegrationsPanel.tsx +++ b/web/src/components/GoogleIntegrationsPanel.tsx @@ -39,14 +39,14 @@ const s = strings.pipelineRunner; function StatusPill({ connected }: { connected?: boolean }) { if (connected) { return ( - - + + Connected ); } return ( - + Not connected @@ -81,25 +81,25 @@ function SetupStep({ }) { return (
    -
    +
    {done ? : step}
    -

    {title}

    - {description ?

    {description}

    : null} +

    {title}

    + {description ?

    {description}

    : null}
    - +
    {children}
    @@ -107,7 +107,7 @@ function SetupStep({ } function selectClassName() { - return 'w-full rounded-lg border border-default bg-brand-900 px-3 py-2.5 text-sm text-foreground focus:border-blue-500/50 focus:outline-none focus:ring-2 focus:ring-blue-500/20'; + return 'w-full rounded-lg border border-md-sys-outline-variant/40 bg-md-sys-surface-container-low px-3 py-2.5 text-sm text-md-sys-on-surface focus:border-md-sys-primary/50 focus:outline-none focus:ring-2 focus:ring-md-sys-primary/20'; } type PropertiesSaveState = @@ -126,7 +126,7 @@ function PropertiesSaveFeedback({ if (state.phase === 'saving') { return (

    @@ -138,7 +138,7 @@ function PropertiesSaveFeedback({ if (state.phase === 'error') { return (

    @@ -155,13 +155,13 @@ function PropertiesSaveFeedback({ }); return (

    {label} - + {format(s.googlePropertiesSavedAt, { time })}

    @@ -170,7 +170,7 @@ function PropertiesSaveFeedback({ if (dirty) { return (

    @@ -203,7 +203,7 @@ function InputField({ }) { return (

    -
    ); } @@ -790,7 +790,7 @@ export default function GoogleIntegrationsPanel({ const isTabbed = layout === 'tabbed'; const readOnlyBanner = readOnly ? ( -

    +

    {strings.app.readonlyBanner}

    ) : null; @@ -799,27 +799,27 @@ export default function GoogleIntegrationsPanel({ 'Google Client ID/Secret and service account JSON are stored app-wide in the database. Upload a service account for API access without per-site OAuth, or connect each site with OAuth for user-delegated access. Search Console and Analytics property IDs remain per site.'; const infoBanner = ( -

    +

    {infoBannerText}

    ); const propertySelector = ( -
    -
  • diff --git a/web/src/components/SectionHeader.tsx b/web/src/components/SectionHeader.tsx index 0776640d..46b7a07b 100644 --- a/web/src/components/SectionHeader.tsx +++ b/web/src/components/SectionHeader.tsx @@ -30,8 +30,8 @@ export default function SectionHeader({ return (
    - -

    {title}

    + +

    {title}

    {hintContent ? ( {hintContent.body} @@ -39,7 +39,7 @@ export default function SectionHeader({ ) : null}
    {description ? ( -

    {description}

    +

    {description}

    ) : null}
    ); diff --git a/web/src/components/SectionWidgetSkeleton.tsx b/web/src/components/SectionWidgetSkeleton.tsx index a890faa5..a90a88d7 100644 --- a/web/src/components/SectionWidgetSkeleton.tsx +++ b/web/src/components/SectionWidgetSkeleton.tsx @@ -4,9 +4,9 @@ export function StatRowSkeleton({ count = 4 }: { count?: number }) { return (
    {Array.from({ length: count }, (_, i) => ( -
    - - +
    + +
    ))}
    @@ -15,19 +15,19 @@ export function StatRowSkeleton({ count = 4 }: { count?: number }) { export function ChartBlockSkeleton() { return ( -
    - - +
    + +
    ); } export function CardBlockSkeleton({ lines = 4 }: { lines?: number }) { return ( -
    - +
    + {Array.from({ length: lines }, (_, i) => ( - + ))}
    ); @@ -35,10 +35,10 @@ export function CardBlockSkeleton({ lines = 4 }: { lines?: number }) { export function TableBlockSkeleton({ rows = 6 }: { rows?: number }) { return ( -
    - +
    + {Array.from({ length: rows }, (_, i) => ( - + ))}
    ); @@ -49,43 +49,43 @@ export function ViewPageSkeleton() { return (
    - - + +
    - - + +
    {Array.from({ length: 5 }, (_, i) => ( - + ))}
    -
    -
    - - - - - +
    +
    + + + + +
    -
    +
    {Array.from({ length: 10 }, (_, i) => (
    - - - - + + + +
    ))}
    -
    - - - +
    + + +
    diff --git a/web/src/components/Select.tsx b/web/src/components/Select.tsx index b6f393e5..a7abd162 100644 --- a/web/src/components/Select.tsx +++ b/web/src/components/Select.tsx @@ -1,7 +1,7 @@ import type { SelectHTMLAttributes, ReactNode } from 'react'; export const SELECT_CLASS = - 'bg-brand-800 border border-brand-700 text-sm rounded-lg px-3 py-2 text-foreground outline-none focus:border-blue-500 transition-colors'; + 'bg-md-sys-surface-container-high border border-md-sys-outline-variant text-sm rounded-xl px-3.5 py-2 text-md-sys-on-surface outline-none focus:ring-2 focus:ring-md-sys-primary transition-all duration-200'; export interface SelectProps extends SelectHTMLAttributes { children: ReactNode; diff --git a/web/src/components/Skeleton.tsx b/web/src/components/Skeleton.tsx index ab4b80e7..b8c05abf 100644 --- a/web/src/components/Skeleton.tsx +++ b/web/src/components/Skeleton.tsx @@ -4,7 +4,7 @@ export function Skeleton({ className = '' }: { className?: string }) { return (
    ); @@ -13,26 +13,26 @@ export function Skeleton({ className = '' }: { className?: string }) { /** Rounded rectangle mimicking a portfolio / domain card on Home. */ export function SkeletonDomainCard() { return ( -
    +
    - - + +
    - - + +
    - - - - -
    - - - - + + + + +
    + + + +
    ); diff --git a/web/src/components/StatCard.tsx b/web/src/components/StatCard.tsx index 7033d7c2..6929a88a 100644 --- a/web/src/components/StatCard.tsx +++ b/web/src/components/StatCard.tsx @@ -24,7 +24,7 @@ export default function StatCard({ value, sub, band, - bandClassName = 'text-muted-foreground', + bandClassName = 'text-md-sys-on-surface-variant', icon, hint, size = 'md', @@ -32,7 +32,7 @@ export default function StatCard({ shadow = false, href, fillHeight = false, - valueClassName = 'text-bright', + valueClassName = 'text-md-sys-on-surface', }: StatCardProps) { const valueClass = size === 'lg' ? `text-3xl font-bold tabular-nums ${valueClassName}` : `text-2xl font-bold tabular-nums ${valueClassName}`; @@ -56,14 +56,14 @@ export default function StatCard({ > {band ?? '—'}

    -

    +

    {sub ?? '—'}

    ) : ( <> {band ?

    {band}

    : null} - {sub ?

    {sub}

    : null} + {sub ?

    {sub}

    : null} ); @@ -71,9 +71,10 @@ export default function StatCard({ -

    +

    {icon} {label} {hintNode} @@ -87,7 +88,7 @@ export default function StatCard({ return ( {card} diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx index ac2170c0..25a29dd9 100644 --- a/web/src/components/Table.tsx +++ b/web/src/components/Table.tsx @@ -8,7 +8,7 @@ interface TableProps { } /** - * Wrapper for consistent table styling: thead bg-brand-900, uppercase text-xs font-semibold text-muted-foreground. + * Wrapper for consistent table styling: thead bg-md-sys-surface-container-low, uppercase text-xs font-semibold text-md-sys-on-surface-variant. * Use `striped` on TableBody for alternating row backgrounds. */ export default function Table({ children, className = '', wrapperClassName = '' }: TableProps) { @@ -27,7 +27,7 @@ interface TableHeadProps { } export const TableHead = ({ children, sticky = false }: TableHeadProps) => ( - + {children} ); @@ -66,15 +66,15 @@ interface TableBodyProps { } export const TableBody = ({ children, striped = false, className = '' }: TableBodyProps) => ( - tr:nth-child(even)]:bg-brand-900/30' : ''} ${className}`.trim()}> + tr:nth-child(even)]:bg-md-sys-surface-container-low/40' : ''} ${className}`.trim()}> {children} ); export const TableRow = ({ children, className = '' }: { children?: ReactNode; className?: string }) => ( - {children} + {children} ); export const TableCell = ({ children, className = '', title }: { children?: ReactNode; className?: string; title?: string }) => ( - {children} + {children} ); diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx index 8f5c5110..ce11a884 100644 --- a/web/src/components/ThemeToggle.tsx +++ b/web/src/components/ThemeToggle.tsx @@ -14,7 +14,7 @@ export default function ThemeToggle() { return (

    @@ -30,10 +30,10 @@ export default function ThemeToggle() { title={label()} aria-label={label()} aria-pressed={active} - className={`press p-2 rounded-md transition-all focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 ${ + className={`press p-1.5 rounded-full transition-all duration-200 ease-out active:scale-[0.95] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-md-sys-primary ${ active - ? 'bg-brand-700 text-bright shadow-sm' - : 'text-muted-foreground hover:text-foreground' + ? 'bg-md-sys-secondary-container text-md-sys-on-secondary-container shadow-xs font-semibold' + : 'text-md-sys-on-surface-variant hover:text-md-sys-on-surface hover:bg-md-sys-surface-container-highest/60' }`} > {text} diff --git a/web/src/components/UrlInspectorDrawer.tsx b/web/src/components/UrlInspectorDrawer.tsx index 09ebc121..ec36cc37 100644 --- a/web/src/components/UrlInspectorDrawer.tsx +++ b/web/src/components/UrlInspectorDrawer.tsx @@ -4,6 +4,7 @@ import { ChevronLeft, ChevronRight, X } from 'lucide-react'; import { useReport } from '@/context/useReport'; import { useUrlInspector } from '@/context/UrlInspectorContext'; import { useSectionData } from '@/hooks/useSectionData'; +import { useModalDismiss } from '@/hooks/useModalDismiss'; import InspectorTabs from '@/components/links/InspectorTabs'; import { shortPath } from '@/lib/linkGraph'; import { strings } from '@/lib/strings'; @@ -82,21 +83,32 @@ export default function UrlInspectorDrawer({ url, onClose }: UrlInspectorDrawerP return buildInspectorDetails(data, url, links); }, [url, data, links]); + useModalDismiss({ + onDismiss: onClose, + enabled: Boolean(url && link), + lockScroll: true, + }); + if (!url || !link) return null; return (
    - @@ -106,7 +118,7 @@ export default function UrlInspectorDrawer({ url, onClose }: UrlInspectorDrawerP disabled={!canGoForward} title={ui.forward} aria-label={ui.forward} - className="p-1.5 rounded-lg text-muted-foreground enabled:hover:bg-brand-700 enabled:hover:text-bright disabled:opacity-30 disabled:cursor-not-allowed press" + className="p-1.5 rounded-full text-md-sys-on-surface-variant enabled:hover:bg-md-sys-surface-container-highest enabled:hover:text-md-sys-on-surface disabled:opacity-30 disabled:cursor-not-allowed press transition-colors" > @@ -118,7 +130,7 @@ export default function UrlInspectorDrawer({ url, onClose }: UrlInspectorDrawerP return ( {i > 0 && ( - + )}
  • @@ -233,7 +233,7 @@ export default function AxePagesTable({ variant="secondary" onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))} disabled={pagination.page >= pagination.totalPages} - className="px-3 py-1 text-foreground touch-manipulation min-h-11 sm:min-h-0" + className="px-3 py-1 text-md-sys-on-surface touch-manipulation min-h-11 sm:min-h-0" > {pl.next} diff --git a/web/src/components/accessibility/AxeTopRulesChart.tsx b/web/src/components/accessibility/AxeTopRulesChart.tsx index 389b21b9..228430ba 100644 --- a/web/src/components/accessibility/AxeTopRulesChart.tsx +++ b/web/src/components/accessibility/AxeTopRulesChart.tsx @@ -60,7 +60,7 @@ export default function AxeTopRulesChart({ rules, devData, onRuleClick }: AxeTop if (!chart) { return ( -
    +
    {va.noViolations}
    diff --git a/web/src/components/ai/AiSuggestionButton.tsx b/web/src/components/ai/AiSuggestionButton.tsx index 549ccbce..5a615634 100644 --- a/web/src/components/ai/AiSuggestionButton.tsx +++ b/web/src/components/ai/AiSuggestionButton.tsx @@ -56,7 +56,7 @@ export default function AiSuggestionButton({ request, initialText = null, classN type="button" onClick={() => void handleClick()} disabled={loading} - className="inline-flex items-center gap-1.5 rounded-md border border-fuchsia-500/30 bg-fuchsia-500/10 px-2.5 py-1 text-[10px] font-semibold text-fuchsia-800 dark:text-fuchsia-300 hover:bg-fuchsia-500/20 transition-colors disabled:opacity-60" + className="inline-flex items-center gap-1.5 rounded-full border border-md-sys-tertiary/30 bg-md-sys-tertiary-container px-3 py-1 text-xs font-medium text-md-sys-on-tertiary-container shadow-elevation-1 hover:shadow-elevation-2 active:scale-[0.98] transition-all disabled:opacity-60" > {loading ? ( @@ -66,11 +66,11 @@ export default function AiSuggestionButton({ request, initialText = null, classN {loading ? s.loading : text ? s.regenerate : s.button} ) : null} - {error ?

    {error}

    : null} + {error ?

    {error}

    : null} {text ? (
    -

    - {s.label}: +

    + {s.label}: {text}

    diff --git a/web/src/components/backlinks/CompetitorGapImport.tsx b/web/src/components/backlinks/CompetitorGapImport.tsx index aca29673..c8561306 100644 --- a/web/src/components/backlinks/CompetitorGapImport.tsx +++ b/web/src/components/backlinks/CompetitorGapImport.tsx @@ -67,16 +67,16 @@ export default function CompetitorGapImport({ gscLinks }: CompetitorGapImportPro ); return ( -
    -

    {s.title}

    -

    {s.hint}

    +
    +

    {s.title}

    +

    {s.hint}

    setCompetitor(e.target.value)} placeholder={s.competitorPlaceholder} disabled={readOnly} - className="w-full max-w-md rounded-lg border border-default bg-brand-900 px-3 py-2 text-sm text-foreground disabled:opacity-60" + className="w-full max-w-md rounded-lg border border-md-sys-outline-variant/40 bg-md-sys-surface-container-low px-3 py-2 text-sm text-md-sys-on-surface disabled:opacity-60" /> : } {loading ? s.uploading : s.uploadLabel} - {error ?

    {error}

    : null} + {error ?

    {error}

    : null} {gap?.gap_count != null && gap.gap_count > 0 ? (
    -

    +

    {format(s.gapSummary, { count: gap.gap_count, competitor: gap.competitor || competitor, })}

    -
      +
        {(gap.gap_domains || []).slice(0, 20).map((d) => (
      • {d}
      • ))}
    ) : gap ? ( -

    {s.noGap}

    +

    {s.noGap}

    ) : null}
    ); diff --git a/web/src/components/backlinks/ThirdPartyLinksImport.tsx b/web/src/components/backlinks/ThirdPartyLinksImport.tsx index 50b9c72f..e01931b4 100644 --- a/web/src/components/backlinks/ThirdPartyLinksImport.tsx +++ b/web/src/components/backlinks/ThirdPartyLinksImport.tsx @@ -75,7 +75,7 @@ export default function ThirdPartyLinksImport({ gscLinks, onImported }: ThirdPar if (!propertyId) { return ( -

    {s.noProperty}

    +

    {s.noProperty}

    ); } @@ -84,9 +84,9 @@ export default function ThirdPartyLinksImport({ gscLinks, onImported }: ThirdPar : savedOverlays; return ( -
    -

    {s.title}

    -

    {s.hint}

    +
    +

    {s.title}

    +

    {s.hint}

    {(['moz', 'majestic'] as const).map((id) => ( - {error ?

    {error}

    : null} + {error ?

    {error}

    : null} {displayOverlays.length > 0 ? ( -
    +
    {displayOverlays.map((overlay) => (
    -

    +

    {(overlay.provider || 'unknown').toUpperCase()} — {overlay.provenance || s.estimated}

    -

    +

    {format(s.summary, { count: overlay.referring_domain_count ?? 0, gaps: overlay.domains_not_in_gsc_count ?? 0, diff --git a/web/src/components/browser/BrowserDiagnosticsPanel.tsx b/web/src/components/browser/BrowserDiagnosticsPanel.tsx index 2653da8d..17b00b3b 100644 --- a/web/src/components/browser/BrowserDiagnosticsPanel.tsx +++ b/web/src/components/browser/BrowserDiagnosticsPanel.tsx @@ -30,22 +30,22 @@ export default function BrowserDiagnosticsPanel({ return (

    {showTitle ? ( -

    +

    {title ?? p.browserConsoleTitle}

    ) : null} {!hasAny ? ( -

    {cleanMessage ?? p.browserConsoleClean}

    +

    {cleanMessage ?? p.browserConsoleClean}

    ) : (
    {consoleMsgs.length > 0 ? ( -
    -
    +
    +
    {p.browserConsoleMessages}
    - + @@ -53,10 +53,10 @@ export default function BrowserDiagnosticsPanel({ {consoleMsgs.map((msg, i) => ( - - - - + + +
    {p.browserThLevel} {p.browserThMessage} {p.browserThLocation}
    {msg.level || '—'}{msg.text || '—'} +
    {msg.level || '—'}{msg.text || '—'} {msg.source_url ? `${msg.source_url}${msg.line != null ? `:${msg.line}` : ''}` : '—'} @@ -69,25 +69,25 @@ export default function BrowserDiagnosticsPanel({ ) : null} {pageErrors.length > 0 ? ( -
    -
    +
    +
    {p.browserUncaughtExceptions}
    -
      +
        {pageErrors.map((err, i) => (
      • -
        {err.message || '—'}
        +
        {err.message || '—'}
        {err.stack ? ( ) : null} {err.stack && expandedStacks[i] ? ( -
        +                      
                                 {err.stack}
                               
        ) : null} @@ -98,13 +98,13 @@ export default function BrowserDiagnosticsPanel({ ) : null} {failedRequests.length > 0 ? ( -
        -
        +
        +
        {p.browserFailedRequests}
        - + @@ -112,10 +112,10 @@ export default function BrowserDiagnosticsPanel({ {failedRequests.map((req, i) => ( - - - - + + + + ))} diff --git a/web/src/components/charts/CategoryScoreGauge.tsx b/web/src/components/charts/CategoryScoreGauge.tsx index 452a0a1e..65d50f83 100644 --- a/web/src/components/charts/CategoryScoreGauge.tsx +++ b/web/src/components/charts/CategoryScoreGauge.tsx @@ -23,12 +23,12 @@ export function CategoryScoreGauge({ name, score, size = 'md', onClick }: Catego score == null ? sj.na : score >= 80 ? vo.scoreGood : score >= 50 ? vo.scoreNeeds : vo.scoreCritical; const labelCls = score == null - ? 'text-muted-foreground' + ? 'text-md-sys-on-surface-variant' : score >= 80 - ? 'text-green-700 dark:text-green-400' + ? 'text-md-sys-success' : score >= 50 - ? 'text-yellow-700 dark:text-yellow-400' - : 'text-red-600 dark:text-red-500'; + ? 'text-md-sys-warning' + : 'text-md-sys-error'; const color = scoreBandColor(score); const isCritical = score != null && score < 50; const dim = @@ -50,7 +50,7 @@ export function CategoryScoreGauge({ name, score, size = 'md', onClick }: Catego > -
        +
        {score != null ? score : sj.na}
        -

        {name}

        +

        {name}

        {label}

        @@ -80,7 +80,7 @@ export function CategoryScoreGauge({ name, score, size = 'md', onClick }: Catego diff --git a/web/src/components/charts/RatioBar.tsx b/web/src/components/charts/RatioBar.tsx index 66baf0ef..f92666f1 100644 --- a/web/src/components/charts/RatioBar.tsx +++ b/web/src/components/charts/RatioBar.tsx @@ -18,8 +18,8 @@ export function RatioBar({ secondaryLabel, primaryPct, ariaSummary, - primaryClassName = 'bg-green-500', - secondaryClassName = 'bg-slate-600', + primaryClassName = 'bg-md-sys-success', + secondaryClassName = 'bg-md-sys-surface-container-highest', }: RatioBarProps) { const safePrimary = Math.min(100, Math.max(0, primaryPct)); const safeSecondary = 100 - safePrimary; @@ -29,15 +29,15 @@ export function RatioBar({ return (
        -
        - {label} - {safePrimary.toFixed(1)}% {primaryLabel} +
        + {label} + {safePrimary.toFixed(1)}% {primaryLabel}
        -
        +
        -
        +
        {primaryLabel} {secondaryLabel}
        @@ -53,17 +53,17 @@ export interface CoverageBarProps { } /** Single-metric coverage progress bar (replaces 2-slice doughnuts). */ -export function CoverageBar({ label, pct, color = 'text-link-soft' }: CoverageBarProps) { +export function CoverageBar({ label, pct, color = 'text-md-sys-primary-soft' }: CoverageBarProps) { const safeP = Math.min(100, Math.max(0, pct ?? 0)); return (
        -
        +
        {label} {safeP}%
        -
        +
        = 80 ? 'bg-green-500' : safeP >= 50 ? 'bg-amber-500' : 'bg-red-500'}`} + className={`h-full rounded-full transition-all duration-500 ${safeP >= 80 ? 'bg-md-sys-success' : safeP >= 50 ? 'bg-md-sys-warning' : 'bg-md-sys-error'}`} style={{ width: `${safeP}%` }} />
        diff --git a/web/src/components/charts/ScoreDelta.tsx b/web/src/components/charts/ScoreDelta.tsx index 20db7222..24e1699d 100644 --- a/web/src/components/charts/ScoreDelta.tsx +++ b/web/src/components/charts/ScoreDelta.tsx @@ -24,7 +24,7 @@ export function isImprovedScoreDelta(delta: number, higherIsBetter = true): bool export function ScoreDelta({ delta, higherIsBetter = true }: ScoreDeltaProps) { if (!isDisplayableScoreDelta(delta)) { return ( - + 0 ); @@ -33,8 +33,8 @@ export function ScoreDelta({ delta, higherIsBetter = true }: ScoreDeltaProps) { const improved = isImprovedScoreDelta(value, higherIsBetter); const Icon = value > 0 ? TrendingUp : TrendingDown; const color = improved - ? 'text-emerald-700 dark:text-emerald-400' - : 'text-rose-700 dark:text-rose-400'; + ? 'text-md-sys-success' + : 'text-md-sys-error'; return ( diff --git a/web/src/components/charts/compact/CompactAreaSparkline.tsx b/web/src/components/charts/compact/CompactAreaSparkline.tsx index dafc9e62..6b8530a1 100644 --- a/web/src/components/charts/compact/CompactAreaSparkline.tsx +++ b/web/src/components/charts/compact/CompactAreaSparkline.tsx @@ -15,7 +15,7 @@ export function CompactAreaSparkline({ points, className = '', heightClass = 'h-8', - strokeClassName = 'text-link/70', + strokeClassName = 'text-md-sys-primary/70', }: CompactAreaSparklineProps) { const fillId = useId(); if (points.length < 2) return null; diff --git a/web/src/components/charts/compact/CompactBarChart.tsx b/web/src/components/charts/compact/CompactBarChart.tsx index 3d54de84..a1bafbad 100644 --- a/web/src/components/charts/compact/CompactBarChart.tsx +++ b/web/src/components/charts/compact/CompactBarChart.tsx @@ -55,8 +55,8 @@ export function CompactBarChart({ .range([innerHeight, 0]); const wrapperClass = isChubby - ? `rounded-xl border border-default/50 bg-brand-950/35 px-3 py-3 ${className}`.trim() - : `rounded-md bg-brand-950/30 px-1 pb-1 pt-2 ${heightClass} ${className}`.trim(); + ? `rounded-xl border border-md-sys-outline-variant/30 bg-md-sys-surface-container-lowest/35 px-3 py-3 ${className}`.trim() + : `rounded-md bg-md-sys-surface-container-lowest/30 px-1 pb-1 pt-2 ${heightClass} ${className}`.trim(); return ( -
          +
            {segments.map((s) => (
          • diff --git a/web/src/components/charts/compact/CompactHorizontalBars.tsx b/web/src/components/charts/compact/CompactHorizontalBars.tsx index d1566fd5..d3783ea8 100644 --- a/web/src/components/charts/compact/CompactHorizontalBars.tsx +++ b/web/src/components/charts/compact/CompactHorizontalBars.tsx @@ -17,10 +17,10 @@ export function CompactHorizontalBars({ items }: CompactHorizontalBarsProps) { {items.map((item) => (
          • - {item.label} - {item.value.toLocaleString()} + {item.label} + {item.value.toLocaleString()}
            -
            +
            -

            +

            +

            {label}

            -

            {value}

            +

            {value}

            {delta ? {delta} : null}
            diff --git a/web/src/components/charts/compact/CompactStackedBar.tsx b/web/src/components/charts/compact/CompactStackedBar.tsx index e10172c6..ab963ddc 100644 --- a/web/src/components/charts/compact/CompactStackedBar.tsx +++ b/web/src/components/charts/compact/CompactStackedBar.tsx @@ -21,7 +21,7 @@ export function CompactStackedBar({ segments }: CompactStackedBarProps) {
            {segments.map((s) => ( - + {s.label} ({s.value}) diff --git a/web/src/components/charts/compact/CompactWidget.tsx b/web/src/components/charts/compact/CompactWidget.tsx index 37cecf3e..6280906d 100644 --- a/web/src/components/charts/compact/CompactWidget.tsx +++ b/web/src/components/charts/compact/CompactWidget.tsx @@ -8,8 +8,8 @@ export interface CompactWidgetProps { export function CompactWidget({ title, children, className = '' }: CompactWidgetProps) { return ( -
            -

            +

            +

            {title}

            {children} diff --git a/web/src/components/charts/d3/D3DonutChart.tsx b/web/src/components/charts/d3/D3DonutChart.tsx index e2468cc2..3b1bdded 100644 --- a/web/src/components/charts/d3/D3DonutChart.tsx +++ b/web/src/components/charts/d3/D3DonutChart.tsx @@ -109,7 +109,7 @@ export function D3DonutChart({ key={filteredLabels[i]} d={pathD} fill={color} - stroke="var(--background, #0f172a)" + stroke="var(--md-sys-color-surface, #0b0f19)" strokeWidth={2} style={{ cursor: 'default', transition: 'all 0.1s' }} onMouseEnter={(e) => { @@ -157,10 +157,10 @@ export function D3DonutChart({ width={Math.max(tooltip.label.length * 6 + 60, 100)} height={24} rx={4} - fill="var(--popover, #1e293b)" + fill="var(--md-sys-color-surface-container-highest, #1f2937)" opacity={0.92} /> - + {tooltip.label}: {tooltip.value.toLocaleString()} ({tooltip.pct}%) diff --git a/web/src/components/charts/d3/D3DualLineChart.tsx b/web/src/components/charts/d3/D3DualLineChart.tsx index d66989b2..a01299fc 100644 --- a/web/src/components/charts/d3/D3DualLineChart.tsx +++ b/web/src/components/charts/d3/D3DualLineChart.tsx @@ -182,7 +182,7 @@ export function D3DualLineChart({ cy={d.y} r={3} fill={baseColor} - stroke="var(--background, #fff)" + stroke="var(--md-sys-color-surface, #0b0f19)" strokeWidth={1.5} style={{ cursor: 'default' }} onMouseEnter={(e) => { @@ -210,7 +210,7 @@ export function D3DualLineChart({ cy={d.y} r={3} fill={curColor} - stroke="var(--background, #fff)" + stroke="var(--md-sys-color-surface, #0b0f19)" strokeWidth={1.5} style={{ cursor: 'default' }} onMouseEnter={(e) => { @@ -255,10 +255,10 @@ export function D3DualLineChart({ width={160} height={48} rx={4} - fill="var(--popover, #1e293b)" + fill="var(--md-sys-color-surface-container-highest, #1f2937)" opacity={0.92} /> - + {tooltip.label} diff --git a/web/src/components/charts/d3/D3GroupedBarChart.tsx b/web/src/components/charts/d3/D3GroupedBarChart.tsx index 6352c055..7591d2d4 100644 --- a/web/src/components/charts/d3/D3GroupedBarChart.tsx +++ b/web/src/components/charts/d3/D3GroupedBarChart.tsx @@ -250,10 +250,10 @@ export function D3GroupedBarChart({ width={Math.max((tooltip.seriesLabel.length + tooltip.barLabel.length) * 6 + 30, 100)} height={24} rx={4} - fill="var(--popover, #1e293b)" + fill="var(--md-sys-color-surface-container-highest, #1f2937)" opacity={0.92} /> - + {tooltip.seriesLabel}: {tooltip.barLabel} — {tooltip.value.toLocaleString()} diff --git a/web/src/components/charts/d3/D3HorizontalBarChart.tsx b/web/src/components/charts/d3/D3HorizontalBarChart.tsx index 353f4c20..6500901e 100644 --- a/web/src/components/charts/d3/D3HorizontalBarChart.tsx +++ b/web/src/components/charts/d3/D3HorizontalBarChart.tsx @@ -189,10 +189,10 @@ export function D3HorizontalBarChart({ width={Math.max(tooltip.label.length * 6 + tooltip.value.toLocaleString().length * 7 + 20, 80)} height={24} rx={4} - fill="var(--popover, #1e293b)" + fill="var(--md-sys-color-surface-container-highest, #1f2937)" opacity={0.92} /> - + {truncateChartLabel(tooltip.label, 20)}: {tooltip.value.toLocaleString()} diff --git a/web/src/components/charts/d3/D3StackedHorizontalBarChart.tsx b/web/src/components/charts/d3/D3StackedHorizontalBarChart.tsx index 10704b34..f734322e 100644 --- a/web/src/components/charts/d3/D3StackedHorizontalBarChart.tsx +++ b/web/src/components/charts/d3/D3StackedHorizontalBarChart.tsx @@ -174,8 +174,8 @@ export function D3StackedHorizontalBarChart({ {tooltip && ( - - + + {tooltip.seriesLabel}: {tooltip.value.toFixed(1)}% diff --git a/web/src/components/charts/d3/D3StackedVerticalBarChart.tsx b/web/src/components/charts/d3/D3StackedVerticalBarChart.tsx index ca0ccd1e..fafab6c7 100644 --- a/web/src/components/charts/d3/D3StackedVerticalBarChart.tsx +++ b/web/src/components/charts/d3/D3StackedVerticalBarChart.tsx @@ -200,8 +200,8 @@ export function D3StackedVerticalBarChart({ transform={`translate(${tooltip.x},${tooltip.y})`} style={{ pointerEvents: 'none' }} > - - + + {tooltip.seriesLabel}: {tooltip.value.toFixed(1)}% diff --git a/web/src/components/charts/d3/D3VerticalBarChart.tsx b/web/src/components/charts/d3/D3VerticalBarChart.tsx index c58da239..0bc147e2 100644 --- a/web/src/components/charts/d3/D3VerticalBarChart.tsx +++ b/web/src/components/charts/d3/D3VerticalBarChart.tsx @@ -181,10 +181,10 @@ export function D3VerticalBarChart({ width={Math.max(tooltip.label.length * 6 + tooltip.value.toLocaleString().length * 7 + 20, 80)} height={24} rx={4} - fill="var(--popover, #1e293b)" + fill="var(--md-sys-color-surface-container-highest, #1f2937)" opacity={0.92} /> - + {tooltip.label}: {tooltip.value.toLocaleString()} diff --git a/web/src/components/chat/ChatActivityBar.tsx b/web/src/components/chat/ChatActivityBar.tsx index e6a0712a..fcc15860 100644 --- a/web/src/components/chat/ChatActivityBar.tsx +++ b/web/src/components/chat/ChatActivityBar.tsx @@ -15,7 +15,7 @@ export default function ChatActivityBar({ busy, statusText, elapsedSec }: ChatAc return (
            diff --git a/web/src/components/chat/ChatApiKeyBanner.tsx b/web/src/components/chat/ChatApiKeyBanner.tsx index 1f00b42d..84715f4c 100644 --- a/web/src/components/chat/ChatApiKeyBanner.tsx +++ b/web/src/components/chat/ChatApiKeyBanner.tsx @@ -18,18 +18,18 @@ export default function ChatApiKeyBanner({ provider, compact }: ChatApiKeyBanner return (
            - +
            -

            +

            {c.apiKeyMissingTitle}

            -

            {format(c.apiKeyMissingHint, { provider: label })}

            - +

            {format(c.apiKeyMissingHint, { provider: label })}

            + {c.openSecrets}
            diff --git a/web/src/components/chat/ChatAssistantAvatar.tsx b/web/src/components/chat/ChatAssistantAvatar.tsx index 61d267be..d5160a4e 100644 --- a/web/src/components/chat/ChatAssistantAvatar.tsx +++ b/web/src/components/chat/ChatAssistantAvatar.tsx @@ -44,7 +44,7 @@ export default function ChatAssistantAvatar({ return (
            {failed ? ( diff --git a/web/src/components/chat/ChatAssistantMessage.tsx b/web/src/components/chat/ChatAssistantMessage.tsx index 8685bcac..f56a23df 100644 --- a/web/src/components/chat/ChatAssistantMessage.tsx +++ b/web/src/components/chat/ChatAssistantMessage.tsx @@ -63,7 +63,7 @@ export default function ChatAssistantMessage({ ); const cardClass = fatalError - ? 'chat-assistant-card border-red-500/30 bg-red-500/10' + ? 'chat-assistant-card border-md-sys-error/30 bg-md-sys-error-container/20' : showPartialNote ? 'chat-assistant-card chat-assistant-card-partial' : 'chat-assistant-card'; @@ -78,7 +78,7 @@ export default function ChatAssistantMessage({ if (!hasBody && fatalError) { return ( -
            +
            {content || agentError || c.responseFailed}
            ); @@ -93,12 +93,12 @@ export default function ChatAssistantMessage({ Boolean(statusText || (toolActivity?.length ?? 0) > 0); return ( -
            +
            {showStreamingPanel ? ( ) : (streaming || (!content && !blocks.length && !showNarrative)) && !fatalError ? ( ) : null} @@ -110,11 +110,11 @@ export default function ChatAssistantMessage({ {blocks.length > 0 ? : null} {processed.proseHidden && blocks.length > 0 ? ( -

            {c.proseStrippedNote}

            +

            {c.proseStrippedNote}

            ) : null} {showPartialNote ? ( -

            {c.partialResponseNote}

            +

            {c.partialResponseNote}

            ) : null} {showNarrative && narrative ? ( @@ -123,7 +123,7 @@ export default function ChatAssistantMessage({ {showProse ? ( streaming && !prose.includes('###') ? ( -

            {prose}

            +

            {prose}

            ) : ( ) diff --git a/web/src/components/chat/ChatComposer.tsx b/web/src/components/chat/ChatComposer.tsx index e31bd80d..de65ef5c 100644 --- a/web/src/components/chat/ChatComposer.tsx +++ b/web/src/components/chat/ChatComposer.tsx @@ -72,7 +72,7 @@ export default function ChatComposer({ className={`mx-auto w-full ${isHero || isCompact ? '' : 'max-w-3xl px-4 pb-4 pt-2'} ${isCompact ? 'px-3 pb-3 pt-2' : ''}`} >
            { @@ -121,7 +121,7 @@ export default function ChatComposer({
            {/* ── Body ───────────────────────────────────────────── */} -
            +
            {/* Empty state */} {isEmpty && (
            {needsApiKey ? : null} -

            +

            {domain ? `Ask anything about ${domain}` : c.emptyHint}

            @@ -171,7 +169,7 @@ export default function ChatFabDrawer({ open, domain, onClose }: ChatFabDrawerPr type="button" onClick={() => sendMessage(prompt)} disabled={busy || needsApiKey} - className="rounded-xl border border-default/60 bg-[var(--chat-surface)]/30 px-3 py-2.5 text-left text-[12px] text-muted-foreground transition-all hover:border-[var(--accent-border)] hover:bg-[var(--chat-surface)]/70 hover:text-foreground disabled:opacity-40" + className="rounded-2xl border border-md-sys-outline-variant/40 bg-md-sys-surface-container px-3.5 py-2.5 text-left text-[12px] text-md-sys-on-surface-variant transition-all duration-200 hover:border-md-sys-primary hover:bg-md-sys-surface-container-high hover:text-md-sys-on-surface active:scale-[0.98] disabled:opacity-40" > {prompt} @@ -197,11 +195,11 @@ export default function ChatFabDrawer({ open, domain, onClose }: ChatFabDrawerPr className={`max-w-[82%] rounded-2xl rounded-bl-none px-4 py-3 shadow-sm ${ msg.error ? 'border border-[var(--color-danger-border)] bg-[var(--color-danger-bg)] text-[13px] text-[var(--color-danger)]' - : 'bg-[var(--chat-assistant-bubble)] text-foreground' + : 'bg-[var(--chat-assistant-bubble)] text-md-sys-on-surface' }`} > {msg.toolStatus && ( -
            +
            {msg.toolStatus}
            @@ -210,13 +208,13 @@ export default function ChatFabDrawer({ open, domain, onClose }: ChatFabDrawerPr {msg.narrative && (
            {msg.narrative.power_insights.map((insight, i) => ( -

            {insight}

            +

            {insight}

            ))} {msg.narrative.recommended_actions.length > 0 && (
              {msg.narrative.recommended_actions.map((action, i) => ( -
            • - +
            • + {action}
            • ))} @@ -239,7 +237,7 @@ export default function ChatFabDrawer({ open, domain, onClose }: ChatFabDrawerPr See full results in chat @@ -254,7 +252,7 @@ export default function ChatFabDrawer({ open, domain, onClose }: ChatFabDrawerPr
            {/* ── Composer ─────────────────────────────────────── */} -
            +
            {needsApiKey && !isEmpty ? (
            diff --git a/web/src/components/chat/ChatInsightSections.tsx b/web/src/components/chat/ChatInsightSections.tsx index 667e9356..61192708 100644 --- a/web/src/components/chat/ChatInsightSections.tsx +++ b/web/src/components/chat/ChatInsightSections.tsx @@ -68,7 +68,7 @@ function SectionBody({ body, streaming }: { body: string; streaming?: boolean }) @@ -103,24 +103,24 @@ export default function ChatInsightSections({ content, streaming }: ChatInsightS return (
            {expanded ? ( -
            +
            ) : null} diff --git a/web/src/components/chat/ChatMarkdown.tsx b/web/src/components/chat/ChatMarkdown.tsx index b28381a6..45452fb7 100644 --- a/web/src/components/chat/ChatMarkdown.tsx +++ b/web/src/components/chat/ChatMarkdown.tsx @@ -50,7 +50,7 @@ export default function ChatMarkdown({ content, streaming, nested }: ChatMarkdow {children} ), - strong: ({ children }) => {children}, + strong: ({ children }) => {children}, em: ({ children }) => {children}, blockquote: ({ children }) => { const text = String(children ?? ''); diff --git a/web/src/components/chat/ChatMessageList.tsx b/web/src/components/chat/ChatMessageList.tsx index 99c771e6..24eea68f 100644 --- a/web/src/components/chat/ChatMessageList.tsx +++ b/web/src/components/chat/ChatMessageList.tsx @@ -55,7 +55,7 @@ export default function ChatMessageList({ messages, empty }: ChatMessageListProp className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`} > {msg.role === 'user' ? ( -
            +

            {msg.content}

            ) : ( diff --git a/web/src/components/chat/ChatModelPicker.tsx b/web/src/components/chat/ChatModelPicker.tsx index 215adb6e..5e691a9b 100644 --- a/web/src/components/chat/ChatModelPicker.tsx +++ b/web/src/components/chat/ChatModelPicker.tsx @@ -53,17 +53,17 @@ function ModelRow({ @@ -142,7 +142,7 @@ export default function ChatModelPicker({ onClick={() => setOpen((v) => !v)} className={ triggerClassName ?? - 'flex max-w-[7rem] items-center gap-1 rounded-full px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-[var(--chat-surface-hover)] hover:text-foreground disabled:opacity-50 sm:max-w-[9rem]' + 'flex max-w-[7rem] items-center gap-1 rounded-full px-2 py-1.5 text-xs text-md-sys-on-surface-variant transition-colors hover:bg-md-sys-surface-container-high hover:text-md-sys-on-surface disabled:opacity-50 sm:max-w-[9rem]' } aria-expanded={open} aria-haspopup="listbox" @@ -155,7 +155,7 @@ export default function ChatModelPicker({ /> ) : null} {triggerLabel} @@ -164,14 +164,14 @@ export default function ChatModelPicker({ {open ? (
            {isOllama && catalogUsable && models.length ? ( <> -
            +
            setQuery(e.target.value)} placeholder={s.searchPlaceholder} aria-label={c.findModel} - className="w-full rounded-xl border border-default bg-[var(--chat-bg)] px-3 py-2 text-xs text-foreground placeholder:text-muted-foreground focus:border-violet-500/50 focus:outline-none" + className="w-full rounded-full border border-md-sys-outline-variant/40 bg-md-sys-surface px-3 py-2 text-xs text-md-sys-on-surface placeholder:text-md-sys-on-surface-variant/60 focus:border-md-sys-primary focus:outline-none" />
            {installed.length ? (
            -

            +

            {s.groupInstalled}

            {installed.map((m) => ( @@ -201,7 +201,7 @@ export default function ChatModelPicker({ ) : null} {cloud.length ? (
            -

            +

            {s.groupCloud}

            {cloud.map((m) => ( @@ -217,7 +217,7 @@ export default function ChatModelPicker({ ) : null} {local.length ? (
            -

            +

            {s.groupLocal}

            {local.map((m) => ( @@ -245,12 +245,12 @@ export default function ChatModelPicker({ ))}
            ) : ( -
            +
            {isOllama ? ( catalogUsable ? (

            {model || c.ollamaNoModel}

            ) : ( -

            {c.ollamaUnreachable}

            +

            {c.ollamaUnreachable}

            ) ) : (

            {effectiveModel || c.cloudNoModel}

            @@ -258,16 +258,16 @@ export default function ChatModelPicker({
            )} -
            +
            {isOllama && catalogUsable ? ( -
            +
            {health === 'degraded' ? ( - {c.ollamaDegraded} + {c.ollamaDegraded} ) : null} {status?.supportsTools ? ( - {c.ollamaToolsMode} + {c.ollamaToolsMode} ) : ( - {c.ollamaReactMode} + {c.ollamaReactMode} )} {status?.cloudCatalogOk ? ( {format(c.ollamaCloudCount, { count: status.cloudModelCount ?? 0 })} @@ -279,14 +279,14 @@ export default function ChatModelPicker({ ) : null}
            ) : null} - {saveError ?

            {saveError}

            : null} + {saveError ?

            {saveError}

            : null}
            {isOllama ? ( @@ -84,24 +84,24 @@ export default function ChatNarrativeSections({ return (
            {expanded ? ( -
            +
            setOpen((v) => !v)} className={ triggerClassName ?? - 'flex max-w-[6.5rem] items-center gap-1 rounded-full px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-[var(--chat-surface-hover)] hover:text-foreground disabled:opacity-50 sm:max-w-[8.5rem]' + 'flex max-w-[6.5rem] items-center gap-1 rounded-full px-2 py-1.5 text-xs text-md-sys-on-surface-variant transition-colors hover:bg-md-sys-surface-container-high hover:text-md-sys-on-surface disabled:opacity-50 sm:max-w-[8.5rem]' } aria-expanded={open} aria-haspopup="listbox" aria-label={c.chooseProvider} > {providerLabel(provider)} @@ -93,7 +93,7 @@ export default function ChatProviderPicker({ {open ? (
            void handleProviderChange(opt.value)} - className={`flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--chat-surface-hover)] ${ - active ? 'bg-[var(--chat-surface-hover)]' : '' + className={`press flex w-full items-center gap-2 rounded-full px-3 py-2 text-left text-sm transition-colors hover:bg-md-sys-surface-container-high active:scale-[0.98] ${ + active ? 'bg-md-sys-surface-container-high' : '' }`} > - {active ? : null} + {active ? : null} - {opt.label} + {opt.label}
          • ); })}
          -
          - {saveError ?

          {saveError}

          : null} +
          + {saveError ?

          {saveError}

          : null}
          setOpen(false)} > {c.aiSettingsLink} diff --git a/web/src/components/chat/ChatShell.tsx b/web/src/components/chat/ChatShell.tsx index 515603f1..1a15bfa9 100644 --- a/web/src/components/chat/ChatShell.tsx +++ b/web/src/components/chat/ChatShell.tsx @@ -26,7 +26,7 @@ export default function ChatShell({ sidebar, children }: ChatShellProps) { }; return ( -
          +
          {sidebar(layout)}
          diff --git a/web/src/components/chat/ChatSidebar.tsx b/web/src/components/chat/ChatSidebar.tsx index 832da83b..706f4a77 100644 --- a/web/src/components/chat/ChatSidebar.tsx +++ b/web/src/components/chat/ChatSidebar.tsx @@ -61,10 +61,10 @@ function RailButton({ onClick={onClick} title={label} aria-label={label} - className={`flex h-10 w-10 items-center justify-center rounded-xl transition-colors ${ + className={`press flex h-10 w-10 items-center justify-center rounded-full transition-colors active:scale-95 ${ active - ? 'bg-brand-700/80 text-foreground' - : 'text-muted-foreground hover:bg-[var(--chat-surface-hover)] hover:text-foreground' + ? 'bg-md-sys-surface-container-high/80 text-md-sys-on-surface' + : 'text-md-sys-on-surface-variant hover:bg-md-sys-surface-container-high hover:text-md-sys-on-surface' }`} > {children} @@ -74,29 +74,29 @@ function RailButton({ function SettingsMenu({ onClose }: { onClose: () => void }) { return ( -
          -

          {c.settingsTitle}

          +
          +

          {c.settingsTitle}

          - Theme + Theme
          {strings.settings.settingsLink} {c.aiSettingsLink} {c.assistantAppearanceLink} @@ -141,9 +141,9 @@ export default function ChatSidebar({ const sessionList = ( <> {loading ? ( -

          {c.loadingSessions}

          +

          {c.loadingSessions}

          ) : sessions.length === 0 ? ( -

          {c.noSessions}

          +

          {c.noSessions}

          ) : (
            {sessions.map((s) => ( @@ -151,10 +151,10 @@ export default function ChatSidebar({ @@ -225,30 +225,30 @@ export default function ChatSidebar({
            - {c.pageTitle} + {c.pageTitle}
            -
            +
            -
        {p.browserThMethod} {p.browserThUrl} {p.browserThFailure}
        {req.method || '—'}{req.url || '—'}{req.failure || '—'}
        {req.method || '—'}{req.url || '—'}{req.failure || '—'}
        - + @@ -32,12 +32,12 @@ export default function ChatCompareCategoryBlock({ block }: { block: Block }) { return ( -
        Category Current Baseline
        {row.current ?? '—'} + {row.baseline ?? '—'} diff --git a/web/src/components/chat/blocks/ChatFileDownloadBlock.tsx b/web/src/components/chat/blocks/ChatFileDownloadBlock.tsx index 0abe02aa..579cc6fd 100644 --- a/web/src/components/chat/blocks/ChatFileDownloadBlock.tsx +++ b/web/src/components/chat/blocks/ChatFileDownloadBlock.tsx @@ -24,8 +24,8 @@ function formatLabel(filename: string, mimeType?: string): string { export default function ChatFileDownloadBlock({ block }: { block: FileDownloadBlock }) { return ( -
        -

        Export ready

        +
        +

        Export ready

        {block.files.map((file) => { const href = resolveHref(file.url); @@ -37,8 +37,8 @@ export default function ChatFileDownloadBlock({ block }: { block: FileDownloadBl download={file.filename} className={ isPdf - ? 'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold bg-blue-600 hover:bg-blue-500 text-white transition-colors' - : 'inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border border-default text-foreground hover:bg-brand-700/80 transition-colors' + ? 'press inline-flex items-center gap-2 px-5 py-2.5 rounded-full text-sm font-semibold bg-md-sys-primary hover:brightness-105 active:scale-[0.98] text-md-sys-on-primary transition-all duration-200' + : 'press inline-flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium border border-md-sys-outline-variant/40 text-md-sys-on-surface hover:bg-md-sys-surface-container-high/80 active:scale-[0.98] transition-all duration-200' } > {isPdf ? : } diff --git a/web/src/components/chat/blocks/ChatGenericTableBlock.tsx b/web/src/components/chat/blocks/ChatGenericTableBlock.tsx index d7263d08..a136fd0f 100644 --- a/web/src/components/chat/blocks/ChatGenericTableBlock.tsx +++ b/web/src/components/chat/blocks/ChatGenericTableBlock.tsx @@ -21,12 +21,12 @@ export default function ChatGenericTableBlock({ block }: { block: Block }) { const remaining = (block.total ?? block.rows.length) - shown.length; return ( -
        -

        {block.title}

        +
        +

        {block.title}

        - + {block.columns.map((col) => ( {shown.map((row, i) => ( - + {block.columns.map((col) => ( - ))} @@ -48,11 +48,11 @@ export default function ChatGenericTableBlock({ block }: { block: Block }) {
        {col.replace(/_/g, ' ')} @@ -36,9 +36,9 @@ export default function ChatGenericTableBlock({ block }: { block: Block }) {
        + {formatCell(row[col])}
        {remaining > 0 || block.truncated ? ( -
        +
        {remaining > 0 ? `${remaining} more rows not shown` : 'Results truncated'} - + {q.clicks ?? 0} clicks @@ -74,14 +74,14 @@ export default function ChatGoogleSummaryBlock({ block }: { block: Block }) { {block.pages.length > 0 ? (
        -

        {cb.topPages}

        +

        {cb.topPages}

          {block.pages.map((p) => (
        • - + {p.page} - + {p.clicks ?? 0}
        • diff --git a/web/src/components/chat/blocks/ChatHealthTrendBlock.tsx b/web/src/components/chat/blocks/ChatHealthTrendBlock.tsx index 376dd1c8..d347e0e1 100644 --- a/web/src/components/chat/blocks/ChatHealthTrendBlock.tsx +++ b/web/src/components/chat/blocks/ChatHealthTrendBlock.tsx @@ -57,8 +57,8 @@ export default function ChatHealthTrendBlock({ block }: { block: Block }) { ); return ( -
          -

          {block.title}

          +
          +

          {block.title}

          diff --git a/web/src/components/chat/blocks/ChatImageAttentionTableBlock.tsx b/web/src/components/chat/blocks/ChatImageAttentionTableBlock.tsx index b4d2d8fc..1b56e86f 100644 --- a/web/src/components/chat/blocks/ChatImageAttentionTableBlock.tsx +++ b/web/src/components/chat/blocks/ChatImageAttentionTableBlock.tsx @@ -21,11 +21,11 @@ export default function ChatImageAttentionTableBlock({ block }: { block: Block } const remaining = (block.total ?? block.items.length) - shown.length; return ( -
          -

          +

          +

          {block.title}

          -
            +
              {shown.map((item, i) => { const href = item.url || item.pageUrl; const label = href ? formatChatUrlDisplay(href) : 'Page-level issue'; @@ -37,16 +37,16 @@ export default function ChatImageAttentionTableBlock({ block }: { block: Block } href={href} target="_blank" rel="noreferrer" - className="min-w-0 flex-1 font-mono text-link hover:underline break-all" + className="min-w-0 flex-1 font-mono text-md-sys-primary hover:underline break-all" title={href} > {label} ) : ( - {label} + {label} )} {item.sizeBytes != null && Number.isFinite(item.sizeBytes) ? ( - + {formatBytes(item.sizeBytes)} ) : null} @@ -56,7 +56,7 @@ export default function ChatImageAttentionTableBlock({ block }: { block: Block } {item.reasons.map((r) => ( {formatReason(r)} @@ -68,7 +68,7 @@ export default function ChatImageAttentionTableBlock({ block }: { block: Block } })}
            {remaining > 0 || block.truncated ? ( -

            +

            {remaining > 0 ? `${remaining} more not shown` : 'Results truncated'}

            ) : null} diff --git a/web/src/components/chat/blocks/ChatImageAuditBlock.tsx b/web/src/components/chat/blocks/ChatImageAuditBlock.tsx index cdb524bf..228e654d 100644 --- a/web/src/components/chat/blocks/ChatImageAuditBlock.tsx +++ b/web/src/components/chat/blocks/ChatImageAuditBlock.tsx @@ -7,7 +7,7 @@ type Block = Extract; export default function ChatImageAuditBlock({ block }: { block: Block }) { return ( -

            +

            +

            {ib.lighthouseTitle}

            -
              + {block.total > block.items.length ? ( -

              +

              {block.total - block.items.length} more Lighthouse image findings

              ) : null} diff --git a/web/src/components/chat/blocks/ChatImagePagesTableBlock.tsx b/web/src/components/chat/blocks/ChatImagePagesTableBlock.tsx index 6529ed61..11a751f6 100644 --- a/web/src/components/chat/blocks/ChatImagePagesTableBlock.tsx +++ b/web/src/components/chat/blocks/ChatImagePagesTableBlock.tsx @@ -16,38 +16,38 @@ export default function ChatImagePagesTableBlock({ block }: { block: Block }) { const remaining = (block.total ?? block.pages.length) - shown.length; return ( -
              -

              +

              +

              {block.title} {block.total != null ? ( - ({block.total}) + ({block.total}) ) : null}

              -
                + {remaining > 0 || block.truncated ? ( -
                +
                {remaining > 0 ? format(ib.morePages, { count: remaining }) : cb.showAll}