From de52a1d5d0812f065f78f44ab560150353fddafa Mon Sep 17 00:00:00 2001 From: zendaya Date: Sun, 3 May 2026 21:26:22 +0200 Subject: [PATCH 1/5] feat: enhance web UI with settings page, collapsible sidebar, and theme management - Introduced a new **Settings** page (`/#/settings`) for users to select their preferred appearance (Light / Dark / System). - Implemented a collapsible sidebar to improve navigation and user experience. - Updated the favicon handling to use a bundled icon with a stable URL (`GET /flightdeck-icon.png`) for better accessibility. - Enhanced CSS for dark mode support and improved layout consistency across the application. - Added tests to ensure the new settings and icon functionalities are working as expected. This update aims to provide users with greater control over their UI experience and streamline navigation within the FlightDeck platform. --- CHANGELOG.md | 1 + README.md | 8 + ROADMAP.md | 2 +- docs/web-ui.md | 61 +++- src/flightdeck/server/app.py | 10 +- tests/test_server_health.py | 7 + web/README.md | 4 + web/e2e/smoke.spec.ts | 20 +- web/src/App.tsx | 27 +- web/src/components/AppShell.tsx | 95 ++++-- web/src/index.css | 568 +++++++++++++++++++++++++++++--- web/src/main.tsx | 20 ++ 12 files changed, 723 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed8770..b6204d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0** ### Added +- **Web UI (`flightdeck serve`):** **`/#/settings`** for appearance (Light / Dark / System, **`flightdeck-theme`**); collapsible sidebar (**`flightdeck-sidebar-collapsed`**); **offline system font stack** (no remote font CSS); sidebar + favicon use **bundled** **`/assets/flightdeck-icon-*.png`** with stable **`GET /flightdeck-icon.png`** fallback; **`html[data-theme="dark"]`** tokens and Playwright **`web/e2e/`** (`smoke` icon checks, `theme.spec.ts`, `sidebar.spec.ts`). - **`flightdeck pricing check`** — reports **`flightdeck-bundled-*`** snapshot age vs **`--max-age-days`** (default **90**); **`--fail`** for CI. **`release diff`** / **`POST /v1/diff`** append **`pricing.warnings`** when bundled snapshots exceed the same age threshold. - **`flightdeck.integrations.telemetry.configure_otel_tracing()`** — optional OTLP HTTP **`TracerProvider`** wiring when the **`telemetry`** extra is installed (see **`docs/sdk-integrations.md`**). - **SDK:** **`flightdeck.sdk.http_common`** shared serializers and retry policy; parity tests keep sync/async clients aligned. **`pytest-cov`** no longer omits **`sdk/client.py`**. diff --git a/README.md b/README.md index 49ca963..901887d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,14 @@ FlightDeck is **local-first** (CLI + SQLite + optional **`flightdeck serve`** UI): run evidence, pricing tables, and the ledger **stay on disk in your environment** by default—**no data leaves your infrastructure** for FlightDeck’s own product telemetry (there is no vendor backend that ingests your runs or tariffs). **No trace or billing payload is sent to FlightDeck as a vendor.** That posture matters for **regulated**, **air-gapped**, and **data-sovereignty** teams that cannot ship telemetry to a third-party SaaS observability backend. It is not an agent framework, prompt IDE, tracing dashboard, or gateway — it is where **what shipped**, **what ran**, **what it cost**, and **whether promote is allowed** are recorded and compared. +## Product overview + +![FlightDeck — AI release governance (illustrative)](docs/images/flightdeck-overview.png) + +*Illustrative composite, not a screenshot of the shipped UI.* The bundled **`flightdeck serve`** app is **light-themed** today ([docs/web-ui.md](docs/web-ui.md)). **`flightdeck release diff`** always needs **`--window`** (for example **`7d`**). Policy gates are **threshold-based** on rollups from ingested runs (cost per run, optional **average** latency, error rate, diff sample confidence) — not built-in PII or content-safety scanners. **Prompt drift** shows up indirectly via token usage and pricing; there is no separate “prompt diff +N tokens” line in **`release diff`** output. Releases are **checksum-addressed** bundles at registration (`release verify`); there is no separate cryptographic signing step. + +*Visual north star vs code:* we treat the poster and hex mark as **desired-state art**, then improve the real UI incrementally toward that palette (optional dark theme, accent tokens)—see **[Theming and brand alignment](docs/web-ui.md#theming-and-brand-alignment)**. + ## In ~20 seconds 1. **Register** immutable agent releases (`release.yaml` + bundle checksum). diff --git a/ROADMAP.md b/ROADMAP.md index 5615ae3..c8d995d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,7 +58,7 @@ These map to **What is next** items **1**, **2**, and **5**; ship notes stay in **Explicit UI deferrals** -Out of scope for the near-term web app: custom themes or theme marketplaces; embedded arbitrary log viewers; full observability or fleet consoles in the browser; multi-workspace UI (follows conditional **Fleet / cross-workspace** in **What is next**). +Out of scope for the near-term web app: arbitrary third-party themes or theme marketplaces; embedded arbitrary log viewers; full observability or fleet consoles in the browser; multi-workspace UI (follows conditional **Fleet / cross-workspace** in **What is next**). A **single built-in dark palette** (plus system preference) aligned with operator ergonomics and brand art is **not** a “custom theme product”—see **[docs/web-ui.md — Theming and brand alignment](docs/web-ui.md#theming-and-brand-alignment)** for the phased plan vs the marketing composite. --- diff --git a/docs/web-ui.md b/docs/web-ui.md index 6aaf080..7dff274 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -10,17 +10,54 @@ For setup, dev workflow, build commands, and Playwright E2E instructions see --- +## Theming and brand alignment + +### Desired state (disclaimer) + +The **[README product overview](../README.md#product-overview)** image is a **marketing composite**: dark chrome, dense “dashboard” cards, and narrative labels that **do not** map one-to-one to shipped pages. The bundled **hex mark** (`web/public/flightdeck-icon.png`) matches that art direction (cyan–purple accent, dark ground). **This document and the operator UI** stay grounded in real **`/v1/*`** data—visual work should **not** invent panels (for example a synthetic “release blocked” hero) until the APIs and product decisions exist. + +### What we can borrow from the art (incremental) + +| Art direction | Application in this repo | +|---------------|---------------------------| +| Dark navy / near-black shell | **`html[data-theme="dark"]`** in `web/src/index.css` mirrors semantic tokens; **Appearance** control in the sidebar defaults to **Light** (stored under **`localStorage`** key **`flightdeck-theme`**). | +| Cyan → purple gradient | CSS variables (for example `--fd-accent-gradient`) for **active nav**, **primary buttons**, and **focus-visible** accents—used sparingly so trust/safety UI stays calm. | +| High-contrast titles | Tune `--fd-type-*` and weights under dark mode; avoid shrinking body text for density. | +| “Neon” feel | Reserve for **interactive** states, not large background fills. | +| Geometric sans | **Shipped:** offline **system UI stack** in `index.css` (`--fd-font`). Optional: install **Inter** locally if you want that face without bundling remote CSS. | + +### Phased implementation plan + +1. **Token foundation** — Extend `:root` with any missing semantics (`--fd-surface-elevated`, gradient stops, optional `--fd-bg-subtle`). Replace scattered literals in `web/src/index.css` (for example warning callout backgrounds) with variables so dark mode does not require hunting hex values. +2. **`[data-theme="dark"]` block** — Mirror every semantic token used by `.fd-shell`, sidebar, cards, tables, `Badge`, drawers, and `JsonPanel`; set `color-scheme: dark` on `html` when active. Validate **WCAG AA** for body text and links. +3. **Preference UI** — **`/#/settings`** (and room for more prefs later): **Light** / **Dark** / **System**; listen to `prefers-color-scheme` when System is selected. Persist `localStorage` key **`flightdeck-theme`** (`light` \| `dark` \| `system`). +4. **Brand accents** — Apply the gradient token to **active** `.fd-nav__link--active` (left rail) and primary submit-style buttons; keep destructive actions on existing red semantics. +5. **Light theme polish** — Even before dark ships: align spacing rhythm and card shadows with the same tokens so both themes stay maintainable. +6. **Verification** — From `web/`: **`npm ci`**, **`npm run build`**, commit **`src/flightdeck/server/static/`**; **`npm run test:e2e`** (includes **`e2e/theme.spec.ts`**: default light, dark persistence, system / `prefers-color-scheme`, overview smoke in dark). Manually smoke **Diff** and **Actions** in both themes (policy panels, JSON drawer, rollback affordances). + +### Explicit deferrals (still) + +- **Multi-theme marketplaces**, per-user arbitrary color pickers, or third-party skin systems — off mission. +- **Infographic-only widgets** (staged DEV→STAGING→PROD pipeline strip, sparkline grids) — wait for real APIs and **[ROADMAP](ROADMAP.md)** operator outcomes, not decorative parity with the poster. + +--- + ## Routing The app uses **HashRouter** (`react-router-dom`) so all navigation stays within the single `index.html` that FastAPI's static file mount serves. URLs look like `http://127.0.0.1:8765/#/diff`. No server-side route matching is required. +**Static UI assets:** hashed bundles are mounted at **`/assets/`**. The sidebar mark and tab icons use the **bundled** URL from `web/src/assets/flightdeck-icon.png` (emitted as **`/assets/flightdeck-icon-.png`**; `main.tsx` sets `` at runtime). A **stable** duplicate remains at **`GET /flightdeck-icon.png`** (from `web/public/` at build time + FastAPI `FileResponse`) for bookmarks, probes, and **`web/e2e/smoke.spec.ts`**. + +**Typography:** the UI uses an **offline-first system font stack** (no Google Fonts or other remote CSS). Install **Inter** locally if you want that face in dev tools without changing the bundle. + | Hash path | Component | HTTP calls | Notes | |-----------|-----------|-----------|-------| | `#/` | `OverviewPage` | `GET /v1/releases`, `GET /v1/promoted`, `GET /v1/actions`, `GET /v1/metrics` (parallel where applicable) | Ledger metrics (read-only); short per-counter hints; skeleton on first load; **auto-refresh** every 30s when the tab is visible + on timeline **`generation`** bump; links to Diff/Runs | | `#/diff` | `DiffPage` | `POST /v1/diff` | Sections: policy gate (incl. `evaluated_at`), evidence window, pricing/catalog/hints (incl. provider/version skew callout when sides differ), per-1k prices when present, cost/quality rollups; raw JSON panel | | `#/runs` | `RunsPage` | `GET /v1/releases` (for datalist), `GET /v1/runs`, `GET /v1/runs/export` | Forensics: filters, table (trace/status, trace band rows or **Group by trace_id**), **View** drawer (focus trap, session/span ids), typed **run-query error** card with **Retry**, empty/offset/truncation hints, NDJSON download | +| `#/settings` | `SettingsPage` | *(none)* | **Color theme** (Light / Dark / System) via `ThemeToggle`; more preferences later. | | `#/actions` | `ActionsPage` | `GET /v1/workspace`, `GET /v1/promotion-requests` (when `promotion_requires_approval`), `POST /v1/promote` **or** `POST /v1/promote/request` + `POST /v1/promote/confirm`, `POST /v1/rollback` | Workspace skeleton then strip; approval path: numbered steps, pending **Refresh list** / **Use for confirm**; **Rollback** danger-styled; see **ActionsPage** below | | `#/*` (any other) | — | Redirects to `#/` | | @@ -36,25 +73,25 @@ promote/rollback capability should be unavailable regardless of network placemen ## Component tree ``` -App (HashRouter) -└── AppShell (layout: left sidebar + main column) - └── TimelineRefreshProvider (context) - └── div.fd-shell - ├── aside.fd-sidebar (brand + primary nav) - └── div.fd-shell__content - ├── SecurityStatusBar - └── main#main-content → OverviewPage | DiffPage | RunsPage | ActionsPage +ThemePreferenceProvider (`App.tsx`) +└── HashRouter + └── Routes / AppShell layout route + └── TimelineRefreshProvider + └── div.fd-shell + ├── aside.fd-sidebar (brand, collapse chevron, primary nav, footer nav → Settings) + └── div.fd-shell__content + ├── SecurityStatusBar + └── main#main-content → OverviewPage | DiffPage | RunsPage | ActionsPage | SettingsPage ``` --- ## `AppShell` (`web/src/components/AppShell.tsx`) -Renders a fixed-width **left sidebar** (`aside.fd-sidebar`) with brand and vertical primary -nav (Langfuse-style rail), then a **`fd-shell__content`** column with `SecurityStatusBar` and +Renders a fixed-width **left sidebar** (`aside.fd-sidebar`) with brand (gradient **FlightDeck** wordmark, mark in a **raised tile**), a **collapse** control (SVG chevrons, `localStorage` **`flightdeck-sidebar-collapsed`**), a **primary** nav (inline SVG icons + labels; icon-only when collapsed), and a **footer** nav pinned to the bottom of the rail with **Settings** → `#/settings`. Then a **`fd-shell__content`** column with `SecurityStatusBar` and `
` wrapping an `` for the active page. On narrow viewports the sidebar stacks -above the content with a horizontal nav row. Wraps the subtree in `TimelineRefreshProvider` -so any descendant can access the refresh context. +above the content with a horizontal nav row; a **collapsed** rail is expanded back to full labels in that breakpoint. Wraps the subtree in `TimelineRefreshProvider` +so any descendant can access the refresh context. `ThemePreferenceProvider` (from `App.tsx`) wraps the router so `ThemeToggle` on **Settings** can read and update **`flightdeck-theme`**; `main.tsx` applies the effective theme before the first paint to avoid a flash of the wrong scheme. A **Skip to main content** link (class `fd-skip-link`) appears first in the shell; it uses `preventDefault` + `focus()` on `#main-content` so **HashRouter** hash URLs (`#/…`) are not diff --git a/src/flightdeck/server/app.py b/src/flightdeck/server/app.py index 62eb7c6..04d8dfb 100644 --- a/src/flightdeck/server/app.py +++ b/src/flightdeck/server/app.py @@ -4,7 +4,7 @@ import os from pathlib import Path -from fastapi import FastAPI, Request +from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles @@ -52,4 +52,12 @@ def health(request: Request) -> dict[str, str]: def ui_index() -> FileResponse: return FileResponse(static_dir / "index.html") + @app.get("/flightdeck-icon.png") + def ui_app_icon() -> FileResponse: + """Shipped UI favicon / sidebar mark (copied from ``web/public`` at build time).""" + path = static_dir / "flightdeck-icon.png" + if not path.is_file(): + raise HTTPException(status_code=404, detail="UI icon not found (rebuild web bundle)") + return FileResponse(path, media_type="image/png") + return app diff --git a/tests/test_server_health.py b/tests/test_server_health.py index 56e3acd..c1891f3 100644 --- a/tests/test_server_health.py +++ b/tests/test_server_health.py @@ -22,6 +22,13 @@ def test_health_includes_mutation_auth_loopback_when_no_token(monkeypatch: pytes assert r.json() == {"status": "ok", "mutation_auth": "loopback", "read_auth": "open"} +def test_ui_icon_png_served() -> None: + with TestClient(create_app()) as client: + r = client.get("/flightdeck-icon.png") + assert r.status_code == 200 + assert r.headers.get("content-type", "").startswith("image/png") + + def test_health_includes_mutation_auth_bearer_when_token_set(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("FLIGHTDECK_LOCAL_API_TOKEN", "test-secret-token") with TestClient(create_app()) as client: diff --git a/web/README.md b/web/README.md index f4024dc..80f1eed 100644 --- a/web/README.md +++ b/web/README.md @@ -2,6 +2,10 @@ Source for the local UI served by **`flightdeck serve`** at **`/`**. Production bundles are emitted to **`../src/flightdeck/server/static/`** (FastAPI serves **`index.html`** and hashed files under **`/assets/`**). +**App mark:** keep **`web/public/flightdeck-icon.png`** in sync with **`web/src/assets/flightdeck-icon.png`** (same bytes). The bundled copy is what the React shell and favicon use in production; the public copy is copied to **`static/flightdeck-icon.png`** for the stable **`/flightdeck-icon.png`** URL. + +**Theming and brand:** the README overview image is **desired-state art**, not a UI spec. Incremental plan (tokens, optional dark mode, cyan–purple accents) lives under **[Theming and brand alignment](../docs/web-ui.md#theming-and-brand-alignment)** in **`docs/web-ui.md`**. + ## Commands ```bash diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts index f94941c..5e8b0d9 100644 --- a/web/e2e/smoke.spec.ts +++ b/web/e2e/smoke.spec.ts @@ -11,12 +11,14 @@ test("home loads FlightDeck shell and overview tables", async ({ page }) => { await expect(page.getByText("No releases yet.")).toBeVisible(); }); -test("hash routes reach diff, runs, and promote pages", async ({ page }) => { +test("hash routes reach diff, runs, settings, and promote pages", async ({ page }) => { await page.goto("/#/diff"); await expect(page.getByRole("heading", { name: "Run diff", level: 2 })).toBeVisible(); await expect(page.getByRole("region", { name: "Diff help" })).toBeVisible(); await page.goto("/#/runs"); await expect(page.getByRole("heading", { name: "Run events", level: 2 })).toBeVisible(); + await page.goto("/#/settings"); + await expect(page.getByRole("heading", { name: "Settings", level: 2 })).toBeVisible(); await page.goto("/#/actions"); await expect(page.getByRole("heading", { name: "Promote & rollback", level: 2 })).toBeVisible(); }); @@ -58,6 +60,22 @@ test("health endpoint", async ({ request }) => { }); }); +test("bundled app icon is reachable via hashed /assets URL", async ({ page, request }) => { + await page.goto("/"); + const href = await page.locator('link[rel="icon"]').getAttribute("href"); + expect(href).toBeTruthy(); + expect(href).toMatch(/^\/assets\/flightdeck-icon-[A-Za-z0-9_-]+\.png$/); + const res = await request.get(href!); + expect(res.ok()).toBeTruthy(); + expect((res.headers()["content-type"] ?? "").toLowerCase()).toContain("image/png"); +}); + +test("stable root icon URL for favicon crawlers", async ({ request }) => { + const res = await request.get("/flightdeck-icon.png"); + expect(res.ok()).toBeTruthy(); + expect((res.headers()["content-type"] ?? "").toLowerCase()).toContain("image/png"); +}); + test("security status reflects server loopback mode", async ({ page }) => { await page.goto("/"); await expect(page.getByRole("status")).toContainText("loopback"); diff --git a/web/src/App.tsx b/web/src/App.tsx index 4f18d6c..b1d713f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,23 +1,28 @@ import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; import { AppShell } from "./components/AppShell"; +import { ThemePreferenceProvider } from "./context/ThemePreferenceContext"; import { ActionsPage } from "./pages/ActionsPage"; import { DiffPage } from "./pages/DiffPage"; import { OverviewPage } from "./pages/OverviewPage"; import { RunsPage } from "./pages/RunsPage"; +import { SettingsPage } from "./pages/SettingsPage"; import { UI_READ_ONLY } from "./uiConfig"; export function App() { return ( - - - }> - } /> - } /> - } /> - : } /> - } /> - - - + + + + }> + } /> + } /> + } /> + } /> + : } /> + } /> + + + + ); } diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 16416d6..b88e1c7 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -1,43 +1,88 @@ -import { NavLink, Outlet } from "react-router-dom"; +import { useCallback, useState } from "react"; +import { Outlet } from "react-router-dom"; import { TimelineRefreshProvider } from "../context/TimelineRefreshContext"; +import { flightdeckMarkUrl } from "../branding"; +import { readSidebarCollapsed, writeSidebarCollapsed } from "../sidebarStorage"; import { SecurityStatusBar } from "./SecurityStatusBar"; +import { + IconChevronLeft, + IconChevronRight, + IconDiff, + IconOverview, + IconPromote, + IconRuns, + IconSettings, +} from "./sidebarIcons"; +import { SidebarNavLink } from "./SidebarNavLink"; import { UI_READ_ONLY } from "../uiConfig"; -const navCls = ({ isActive }: { isActive: boolean }) => - `fd-nav__link${isActive ? " fd-nav__link--active" : ""}`; - function skipToMain() { document.getElementById("main-content")?.focus({ preventScroll: false }); } export function AppShell() { + const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed); + + const toggleSidebar = useCallback(() => { + setSidebarCollapsed((prev) => { + const next = !prev; + writeSidebarCollapsed(next); + return next; + }); + }, []); + return (
-