diff --git a/.fallowrc.json b/.fallowrc.json index eeb4c009..b0353cdc 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -38,6 +38,7 @@ } ], "ignoreDependencies": [ + "@fontsource-variable/inter", "@ai-sdk/anthropic", "@ai-sdk/google", "@ai-sdk/openai", diff --git a/apps/agent-forge/e2e/docShots.spec.ts b/apps/agent-forge/e2e/docShots.spec.ts index f4d6b657..a643ddde 100644 --- a/apps/agent-forge/e2e/docShots.spec.ts +++ b/apps/agent-forge/e2e/docShots.spec.ts @@ -13,14 +13,31 @@ import { SEED_ERROR, SEED_NEWEST_FAILURE, seedObserve } from './fixtures/seedObs * LOUSHO_DOC_SHOTS=1 npx playwright test -c apps/agent-forge/playwright.config.ts docShots * * writes `docs/images/studio-.png` at the repo root (1280x800, light). + * Two optional overrides, for before/after reviews of a restyle: + * + * LOUSHO_DOC_SHOTS_OUT= write the PNGs there instead (absolute, + * or relative to the process cwd) + * LOUSHO_DOC_SHOTS_SCHEME=light|dark the colour scheme to shoot (default light) + * * The small fixtures below fill the screens the seed leaves thin: a fleet with * mixed health, a day of chart buckets for it, and a few threads. */ test.skip(!process.env.LOUSHO_DOC_SHOTS, 'set LOUSHO_DOC_SHOTS=1 to regenerate the docs screenshots'); -test.use({ viewport: { width: 1280, height: 800 }, colorScheme: 'light' }); + +/** `LOUSHO_DOC_SHOTS_SCHEME`, failing fast on anything but light or dark. */ +function shotScheme(): 'light' | 'dark' { + const raw = process.env.LOUSHO_DOC_SHOTS_SCHEME ?? 'light'; + if (raw === 'light' || raw === 'dark') return raw; + throw new Error(`LOUSHO_DOC_SHOTS_SCHEME must be "light" or "dark", got "${raw}"`); +} + +// Validated only when the shots run, so a stray value never breaks collection of a normal e2e run. +test.use({ viewport: { width: 1280, height: 800 }, colorScheme: process.env.LOUSHO_DOC_SHOTS ? shotScheme() : 'light' }); const APP = '/?token=e2e-studio-token'; -const OUT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../docs/images'); +const OUT = process.env.LOUSHO_DOC_SHOTS_OUT + ? path.resolve(process.cwd(), process.env.LOUSHO_DOC_SHOTS_OUT) + : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../docs/images'); const NOW = Date.now(); const MINUTE = 60_000; diff --git a/apps/agent-forge/package.json b/apps/agent-forge/package.json index 07df56e2..a5b41a4a 100644 --- a/apps/agent-forge/package.json +++ b/apps/agent-forge/package.json @@ -36,6 +36,7 @@ "@ai-sdk/anthropic": "^0.0.42", "@ai-sdk/openai": "^0.0.42", "@axe-core/playwright": "4.13.0", + "@fontsource-variable/inter": "^5.3.0", "@playwright/test": "^1.63.0", "@types/dagre": "^0.7.52", "@types/express": "^5.0.5", @@ -45,6 +46,7 @@ "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.4", "jsdom": "^25.0.1", + "lucide-react": "^1.63.0", "ollama-ai-provider": "^1.2.0", "supertest": "^7.3.0", "tsup": "^8.0.0", diff --git a/apps/agent-forge/src/__tests__/contrast.test.ts b/apps/agent-forge/src/__tests__/contrast.test.ts index 61e4aaf4..fa6dd19f 100644 --- a/apps/agent-forge/src/__tests__/contrast.test.ts +++ b/apps/agent-forge/src/__tests__/contrast.test.ts @@ -54,6 +54,16 @@ const PAIRS: [text: string, background: string][] = [ // Prompt version links (runs, Playground) draw --accent on the version card's --surface. The diff's removed/added // lines reuse danger/danger-soft and success/success-soft, and its line numbers text-faint/surface (all listed above). ['accent', 'surface'], + // Running status pills draw info on its own tint; violet (LLM/agent) pills likewise. + ['info', 'info-soft'], + ['violet', 'violet-soft'], + // Pressed / selected neutrals and code blocks sit on --surface-3. + ['text-muted', 'surface-3'], + ['text', 'surface-3'], + // A hovered or pressed primary button keeps white text on its deeper fill. + ['on-accent', 'accent-solid-hover'], + // A switched-on chip (Requires approval) draws --accent on the accent tint. + ['accent', 'accent-soft'], ]; describe('theme contrast', () => { diff --git a/apps/agent-forge/src/canvas/AgentNode.tsx b/apps/agent-forge/src/canvas/AgentNode.tsx index ea53d439..2173dbaa 100644 --- a/apps/agent-forge/src/canvas/AgentNode.tsx +++ b/apps/agent-forge/src/canvas/AgentNode.tsx @@ -4,8 +4,7 @@ import type { AgentGraphNode, AgentGraphNodeType } from '../graph/types'; import { StatusPill } from '../ui/StatusPill'; /** - * Custom ReactFlow node renderer for every `AgentGraphNodeType`, styled to - * match `.design-ref/agent-forge-mockup.html`'s `.node` chrome: a colored + * Custom ReactFlow node renderer for every `AgentGraphNodeType`: a colored * type-swatch header with the node's name, a meta line (an LLM node's * `provider/model` chip and tool count, and the node's run status pill), a * body of key/value config rows read straight from the node's @@ -26,8 +25,7 @@ const NODE_META: Record renders the LLM card markup 1`] = `"
🧠support-bot
openai/gpt-4o-mini1 toolawaiting approval
"`; +exports[`AgentNode card > renders the LLM card markup 1`] = `"
🧠support-bot
openai/gpt-4o-mini1 toolawaiting approval
"`; diff --git a/apps/agent-forge/src/components/ChatPanel.tsx b/apps/agent-forge/src/components/ChatPanel.tsx index 01737dda..17332002 100644 --- a/apps/agent-forge/src/components/ChatPanel.tsx +++ b/apps/agent-forge/src/components/ChatPanel.tsx @@ -2,9 +2,8 @@ * Chat with a running agent - the Chat tab's real content, replacing * BottomDrawer's former placeholder. Renders `AppState.chat` (the server's * real `Message[]` history, reconciled and streamed over the existing - * `WS /agents/:id/stream` channel - see runRegistry.ts/chatReconcile.ts), - * ported from `.design-ref/agent-forge-mockup.html`'s bubble/avatar/ - * timestamp layout. + * `WS /agents/:id/stream` channel - see runRegistry.ts/chatReconcile.ts) + * as bubbles with avatars and timestamps. * * Streaming granularity: MESSAGE-level, not token-level. The * server's `AgentExecutor.execute({ onAgentEvent })` streams each model diff --git a/apps/agent-forge/src/components/Inspector.tsx b/apps/agent-forge/src/components/Inspector.tsx index 1c6e7010..6ac3305d 100644 --- a/apps/agent-forge/src/components/Inspector.tsx +++ b/apps/agent-forge/src/components/Inspector.tsx @@ -144,8 +144,7 @@ function useHookSelection(hooks: AgentNodeHookInstance[]) { * `AgentNodeHookInstance`), lets you toggle each on/off, add one from a * starter template (the redact-pii/rate-limit/audit-log/ * inject-context), and edit its code body in a real CodeMirror editor - * (replacing the mockup's static `.hook-code` preview - see - * `.design-ref/agent-forge-mockup.html`). Only rendered for `llm`/`tool` + * (not a static preview). Only rendered for `llm`/`tool` * nodes, the two node types a `toolCall`/`generate` hook can meaningfully * attach to. * diff --git a/apps/agent-forge/src/components/SettingsPanel.tsx b/apps/agent-forge/src/components/SettingsPanel.tsx index 7f67d8c2..3a1cfac8 100644 --- a/apps/agent-forge/src/components/SettingsPanel.tsx +++ b/apps/agent-forge/src/components/SettingsPanel.tsx @@ -380,8 +380,7 @@ function DeploySection({ deployAdapter, onAdapterChange }: DeploySectionProps) { * Real Settings tab content, replacing the static placeholder * ("Provider keys and deploy target settings are wired up later."). * - * Three sections, matching `.design-ref/agent-forge-mockup.html`'s - * `#drawerSettings` layout: + * Three sections: * - Provider keys: add/replace/remove a stored key per provider, with * the input always rendered blank (never pre-filled with a real key - * the server only ever returns a masked status, see runtimeClient.ts's diff --git a/apps/agent-forge/src/components/drawer.css b/apps/agent-forge/src/components/drawer.css index da2efe5f..08f4b863 100644 --- a/apps/agent-forge/src/components/drawer.css +++ b/apps/agent-forge/src/components/drawer.css @@ -231,8 +231,7 @@ } /* ---------- Chat ---------- - Ported from .design-ref/agent-forge-mockup.html's `.chat-*` classes - - user/agent bubbles, avatars, timestamps, a typing indicator, and inline + The `.chat-*` classes - user/agent bubbles, avatars, timestamps, a typing indicator, and inline tool-call/approval cards under an agent bubble. See ChatPanel.tsx. */ .chat-panel { display: flex; diff --git a/apps/agent-forge/src/components/topbarNarrow.css b/apps/agent-forge/src/components/topbarNarrow.css index 9910d87e..3bb1bb31 100644 --- a/apps/agent-forge/src/components/topbarNarrow.css +++ b/apps/agent-forge/src/components/topbarNarrow.css @@ -59,6 +59,8 @@ .topbar-narrow { gap: 6px; padding: 0 8px; + /* never widen the page: the crumbs ellipsize instead */ + min-width: 0; } .topbar-narrow .crumbs { flex: 1; diff --git a/apps/agent-forge/src/fonts.css b/apps/agent-forge/src/fonts.css new file mode 100644 index 00000000..0b2e62bd --- /dev/null +++ b/apps/agent-forge/src/fonts.css @@ -0,0 +1,16 @@ +/* + * Inter Variable, latin only. The package's own index.css / wght.css declare + * every subset (cyrillic, greek, vietnamese, latin-ext ...) and Vite would ship + * a woff2 for each, so this is the single latin wght rule copied from + * @fontsource-variable/inter/wght.css, pointing at the package's file (Vite + * resolves and fingerprints it). Imported once, from main.tsx. + */ +@font-face { + font-family: 'Inter Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-latin-wght-normal.woff2') format('woff2-variations'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/apps/agent-forge/src/hooks/hookTemplates.ts b/apps/agent-forge/src/hooks/hookTemplates.ts index 702191d1..0d72cf75 100644 --- a/apps/agent-forge/src/hooks/hookTemplates.ts +++ b/apps/agent-forge/src/hooks/hookTemplates.ts @@ -1,8 +1,7 @@ import type { AgentNodeHookPhase, AgentNodeHookPoint } from '../graph/types'; /** - * Starter hook templates, matching `.design-ref/agent-forge-mockup.html`'s - * Inspector "Hooks" section examples (`redact-pii`, `rate-limit`, + * Starter hook templates for the Inspector "Hooks" section (`redact-pii`, `rate-limit`, * `inject-context`, `audit-log`). Each is an editable starting point - the * Inspector's CodeMirror editor lets a user change `code` freely after * dragging/adding one onto a node; `templateId` is kept on the resulting diff --git a/apps/agent-forge/src/main.tsx b/apps/agent-forge/src/main.tsx index 3033c5e2..afc61453 100644 --- a/apps/agent-forge/src/main.tsx +++ b/apps/agent-forge/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import './fonts.css'; import './theme.css'; import { App } from './App'; import { applyStoredTheme } from './shell/theme'; diff --git a/apps/agent-forge/src/screens/playground/playground.css b/apps/agent-forge/src/screens/playground/playground.css index 52691974..3dd102ad 100644 --- a/apps/agent-forge/src/screens/playground/playground.css +++ b/apps/agent-forge/src/screens/playground/playground.css @@ -146,7 +146,8 @@ } .pg-model { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + /* the provider select's longest option ("custom (provider/model)") needs the wider column */ + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 8px; } diff --git a/apps/agent-forge/src/theme.css b/apps/agent-forge/src/theme.css index 1a386e83..78f8cb65 100644 --- a/apps/agent-forge/src/theme.css +++ b/apps/agent-forge/src/theme.css @@ -1,41 +1,135 @@ /* - * Design tokens ported from .design-ref/agent-forge-mockup.html. - * Keep these in lockstep with the mockup's :root / dark-mode custom - * properties - this file is the single source of truth for color/typography - * tokens consumed by every component's CSS module. - */ -/* - * Each color token is a light-dark(, ) pair, resolved by + * Studio design tokens. The source of truth is the Studio redesign Design + * Brief (audit/forge-redesign/PLAN.md, copied into the D1 PR description); + * this file implements it, and every component stylesheet reads these + * custom properties instead of raw colours. + * + * Each colour token is a light-dark(, ) pair, resolved by * `color-scheme`: the OS preference by default, or forced via - * `data-theme='light' | 'dark'` on . + * `data-theme='light' | 'dark'` on . Shadows are built from + * light-dark() layer colours; the one value light-dark() cannot carry (the + * select chevron image) is swapped in by the two dark blocks below. + * + * Every text/background pair a component draws meets WCAG AA (4.5:1) in + * both schemes - enforced by src/__tests__/contrast.test.ts. */ :root { color-scheme: light dark; - --bg: light-dark(#f4f5f8, #0b0d12); - --surface: light-dark(#ffffff, #12151c); - --surface-2: light-dark(#eceef3, #171b24); - --border: light-dark(#d7dbe4, #262b39); - --text: light-dark(#171a21, #e7e9f2); - /* every text token meets WCAG AA (4.5:1) on the surfaces it is - drawn on, in both schemes - enforced by src/__tests__/contrast.test.ts. */ - --text-muted: light-dark(#4f5466, #9096ac); - --text-faint: light-dark(#5e6376, #8a90a6); - --accent: light-dark(#4c4ee8, #7678f0); - --accent-soft: light-dark(#e7e7fc, #23234a); - /* Filled accent buttons (Save, primary actions): --on-accent text on --accent-solid is >= 4.5:1 in both schemes. - The dark --accent is too light for white text (3.67:1), so the fill is a deeper shade of it. */ - --accent-solid: light-dark(#4c4ee8, #5d5fe8); + + /* ---------- Neutrals (zinc) ---------- */ + /* app canvas behind panels */ + --bg: light-dark(#f6f6f7, #0a0a0c); + /* cards, panels, tables */ + --surface: light-dark(#ffffff, #111114); + /* inputs' hover, table header, row hover */ + --surface-2: light-dark(#f2f2f4, #18181c); + /* pressed / selected neutral, code blocks */ + --surface-3: light-dark(#e9e9ec, #202026); + --border: light-dark(#e4e4e8, #26262c); + /* input borders, focused cards, secondary buttons */ + --border-strong: light-dark(#d2d2d8, #34343c); + --text: light-dark(#18181b, #f4f4f5); + --text-muted: light-dark(#52525b, #a1a1aa); + --text-faint: light-dark(#5f5f68, #8a8a94); + + /* ---------- Accent (indigo) ---------- */ + --accent: light-dark(#4f46e5, #8b8cf8); + --accent-soft: light-dark(#eef0ff, #1e1d3f); + /* Filled accent buttons: --on-accent on --accent-solid (and its hover) is >= 4.5:1 in both schemes. + The dark --accent is too light for white text, so the fill is a deeper shade of it. */ + --accent-solid: light-dark(#4f46e5, #5b54e8); + --accent-solid-hover: light-dark(#4338ca, #4f48dc); --on-accent: light-dark(#ffffff, #ffffff); - --success: light-dark(#137333, #34d399); - --success-soft: light-dark(#dcf3e4, #113325); - --warning: light-dark(#8a5a12, #fbbf24); - --warning-soft: light-dark(#fbedd3, #3a2c0d); - --danger: light-dark(#b42318, #f87171); - --danger-soft: light-dark(#fbe2df, #3a1615); + + /* ---------- Status (only for status) ---------- */ + /* light --success is one step darker than the brief's #15803d so it reads 4.5:1 on --success-soft */ + --success: light-dark(#157e3c, #4ade80); + --success-soft: light-dark(#e3f6e9, #0f2a1b); + --warning: light-dark(#a15c07, #fbbf24); + --warning-soft: light-dark(#fdf1d8, #2f2408); + --danger: light-dark(#c0262d, #f87171); + --danger-soft: light-dark(#fde8e8, #3a1517); --info: light-dark(#0369a1, #38bdf8); - --shadow: 0 1px 2px rgba(20, 22, 30, 0.06), 0 8px 24px -12px rgba(20, 22, 30, 0.18); - --font-ui: 'IBM Plex Sans', system-ui, sans-serif; - --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace; + --info-soft: light-dark(#e3f2fb, #0b2635); + /* LLM / agent accent */ + --violet: light-dark(#6d28d9, #a78bfa); + --violet-soft: light-dark(#f1eafe, #241a3d); + + /* ---------- Chart series ---------- */ + --chart-1: light-dark(#4f46e5, #8b8cf8); + --chart-2: light-dark(#0891b2, #22d3ee); + --chart-3: light-dark(#d97706, #fbbf24); + --chart-4: light-dark(#db2777, #f472b6); + --chart-5: light-dark(#059669, #34d399); + --chart-6: light-dark(#7c3aed, #c4b5fd); + + /* ---------- Radius ---------- */ + /* small buttons, kbd, inner pills */ + --radius-sm: 6px; + /* buttons, inputs, menus */ + --radius: 8px; + /* cards, panels, dialogs, canvas nodes */ + --radius-lg: 12px; + /* pills, avatars */ + --radius-full: 999px; + + /* ---------- Space ---------- */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + /* page gutter: 24px, 16px at narrow widths (below) */ + --gutter: var(--space-6); + + /* ---------- Elevation ---------- + A shadow is not a colour, so it cannot be one light-dark() pair; instead every layer's colour is, + and a layer that only one scheme draws is transparent in the other. */ + /* hairline lift for cards */ + --shadow-sm: 0 1px 2px light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.5)); + /* menus, popovers, toasts */ + --shadow: + 0 4px 12px -2px light-dark(rgb(16 16 24 / 0.08), transparent), + 0 2px 4px -2px light-dark(rgb(16 16 24 / 0.06), transparent), + 0 8px 24px -6px light-dark(transparent, rgb(0 0 0 / 0.6)), + 0 0 0 1px light-dark(transparent, rgb(255 255 255 / 0.04)); + /* dialogs, command palette */ + --shadow-lg: + 0 24px 48px -12px light-dark(rgb(16 16 24 / 0.18), transparent), + 0 24px 64px -12px light-dark(transparent, rgb(0 0 0 / 0.75)); + /* the subtle top highlight on filled (primary) buttons */ + --highlight-inset: inset 0 1px 0 light-dark(rgb(255 255 255 / 0.14), rgb(255 255 255 / 0.12)); + /* focus ring for inputs (paired with border-color: var(--accent)) */ + --ring: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent); + /* light-dark() cannot hold an image, so the select chevron (stroke = --text-muted) has a light and a + dark data URI; the dark blocks below swap it in. */ + --select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2352525b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + --select-chevron-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23a1a1aa' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + + /* ---------- Motion (disabled under prefers-reduced-motion) ---------- */ + --ease: cubic-bezier(0.2, 0, 0, 1); + --dur-fast: 120ms; + --dur: 180ms; + + /* ---------- Type ---------- */ + --font-ui: 'Inter Variable', 'Inter', 'Geist', ui-sans-serif, system-ui, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', 'Geist Mono', ui-monospace, 'Cascadia Code', 'SF Mono', Menlo, monospace; + /* label / overline (11px 600 uppercase, tracking 0.04em, --text-faint) */ + --fs-overline: 11px; + /* small / meta */ + --fs-meta: 12px; + /* table cells */ + --fs-table: 13px; + /* body, card titles */ + --fs-body: 13.5px; + --lh-body: 1.5; + /* page title (600, tracking -0.01em) */ + --fs-title: 18px; + /* metric value (600, tabular) */ + --fs-metric: 24px; } :root[data-theme='light'] { @@ -44,12 +138,18 @@ :root[data-theme='dark'] { color-scheme: dark; - --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 32px -14px rgba(0, 0, 0, 0.6); + --select-chevron: var(--select-chevron-dark); } @media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { - --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 32px -14px rgba(0, 0, 0, 0.6); + --select-chevron: var(--select-chevron-dark); + } +} + +@media (max-width: 860px) { + :root { + --gutter: var(--space-4); } } @@ -68,23 +168,38 @@ body { background: var(--bg); color: var(--text); font-family: var(--font-ui); - font-size: 13.5px; - line-height: 1.45; + font-size: var(--fs-body); + line-height: var(--lh-body); + font-feature-settings: 'cv11', 'ss01'; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; overflow: hidden; } -button { +button, +input, +select, +textarea { font-family: inherit; + font-feature-settings: inherit; } +/* Thin scrollbars: transparent track, --border-strong thumb. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} ::-webkit-scrollbar { - width: 9px; - height: 9px; + width: 8px; + height: 8px; } ::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 20px; + background: var(--border-strong); + border-radius: var(--radius-full); + border: 2px solid transparent; + background-clip: padding-box; } -::-webkit-scrollbar-track { +::-webkit-scrollbar-track, +::-webkit-scrollbar-corner { background: transparent; } diff --git a/apps/agent-forge/src/ui/StatusPill.tsx b/apps/agent-forge/src/ui/StatusPill.tsx index 9571187a..c508c3f2 100644 --- a/apps/agent-forge/src/ui/StatusPill.tsx +++ b/apps/agent-forge/src/ui/StatusPill.tsx @@ -3,9 +3,9 @@ import './primitives.css'; /** * The closed set of colours a status pill can take. Each one is a * `pill-` class in primitives.css: `ok` success, `error` danger, - * `warning` warning, `running` info, `neutral` muted. + * `warning` warning, `running` info, `neutral` muted, `violet` the LLM/agent accent. */ -export type StatusTone = 'ok' | 'error' | 'warning' | 'running' | 'neutral'; +export type StatusTone = 'ok' | 'error' | 'warning' | 'running' | 'neutral' | 'violet'; const TONES = new Map(Object.entries({ // run @@ -55,7 +55,7 @@ export function StatusPill({ status, live = false }: { status: string | null | u > ); } diff --git a/apps/agent-forge/src/ui/Toast.tsx b/apps/agent-forge/src/ui/Toast.tsx index f6074705..60681b57 100644 --- a/apps/agent-forge/src/ui/Toast.tsx +++ b/apps/agent-forge/src/ui/Toast.tsx @@ -1,4 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { X } from 'lucide-react'; import './primitives.css'; /** What {@link useToast}'s `show` takes: a message, a tone (default `info`) and an optional action button. */ @@ -40,7 +41,7 @@ function ToastView({ toast, onDismiss }: { toast: ToastItem; onDismiss: (id: str )} ); diff --git a/apps/agent-forge/src/ui/base.css b/apps/agent-forge/src/ui/base.css index 6b851059..ab89f38e 100644 --- a/apps/agent-forge/src/ui/base.css +++ b/apps/agent-forge/src/ui/base.css @@ -1,7 +1,16 @@ /* - * Shared primitives used by more than one screen (buttons, status pills, form - * fields, chips, switches). Moved out of layout.css unchanged; imported before + * Shared primitives used by more than one screen: buttons, status pills, form + * fields, chips, switches, kbd and a few type utilities. Built only from the + * tokens in theme.css (the Studio redesign Design Brief); imported before * layout.css so the chrome, Agents and drawer rules keep their cascade position. + * + * Classes later screens can use: + * .btn (secondary) .btn-secondary .btn-primary .btn-ghost .btn-danger + * .btn-success .btn-danger-text, sizes .btn-sm, icon-only .btn-icon + * (icon-only buttons always carry aria-label); + * .status-pill + .pill-ok|error|warning|running|neutral|violet (ui/primitives.css); + * .field / .input / .select / .textarea; .chip (.on); .switch (.on); .toggle-row; + * ; .overline; .tabular-nums. */ /* read by screen readers, not drawn (the page h1, status prefixes). */ @@ -17,84 +26,223 @@ border: 0; } +/* ---------- Type utilities ---------- */ +.overline { + font-size: var(--fs-overline); + font-weight: 600; + line-height: 1.4; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-faint); +} +.tabular-nums { + font-variant-numeric: tabular-nums; +} + +kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + line-height: 1; + color: var(--text-muted); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); + flex: none; } -.btn { - border: 1px solid var(--border); - background: var(--surface-2); - color: var(--text); - border-radius: 7px; - padding: 6px 12px; - font-size: 12.5px; - font-weight: 600; - cursor: pointer; +/* ---------- Buttons ---------- */ +/* The base .btn is the secondary button: surface fill, strong hairline, a hairline lift. */ +.btn, +.btn-secondary { display: inline-flex; align-items: center; gap: 6px; - transition: filter 0.12s, transform 0.04s; + min-height: 32px; + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow-sm); + font-size: 13px; + font-weight: 500; + line-height: 1.25; + cursor: pointer; + transition: + background-color var(--dur-fast) var(--ease), + border-color var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease); } -.btn:hover { - filter: brightness(1.05); +.btn:hover:not(:disabled) { + background: var(--surface-2); } -.btn:active { - transform: translateY(1px); +.btn:active:not(:disabled) { + background: var(--surface-3); } .btn:focus-visible { outline: 2px solid var(--accent); - outline-offset: 1px; + outline-offset: 2px; +} +.btn svg { + width: 16px; + height: 16px; + stroke-width: 1.75; + flex: none; +} +.btn-sm { + min-height: 28px; + padding: 0 10px; + gap: 4px; + font-size: 12px; + border-radius: var(--radius-sm); +} +.btn-icon { + justify-content: center; + width: 32px; + padding: 0; +} +.btn-icon.btn-sm { + width: 28px; } + .btn-primary { background: var(--accent-solid); border-color: var(--accent-solid); color: var(--on-accent); + box-shadow: var(--highlight-inset), var(--shadow-sm); } -.btn-success { - background: var(--success-soft); - border-color: var(--success); - color: var(--success); +.btn-primary:hover:not(:disabled), +.btn-primary:active:not(:disabled) { + background: var(--accent-solid-hover); + border-color: var(--accent-solid-hover); +} +.btn-primary:active:not(:disabled) { + box-shadow: none; +} + +/* Ghost: no fill and no lift, only the quiet --border hairline; icon-only ghosts drop the hairline too. */ +.btn-ghost { + background: transparent; + border-color: var(--border); + box-shadow: none; +} +.btn-ghost.btn-icon { + border-color: transparent; + color: var(--text-muted); +} +.btn-ghost:hover:not(:disabled) { + background: var(--surface-2); + color: var(--text); } +.btn-ghost:active:not(:disabled) { + background: var(--surface-3); + color: var(--text); +} + +/* Stop, Reject: danger text on the neutral button, the danger tint on hover. */ .btn-danger { + color: var(--danger); +} +.btn-danger:hover:not(:disabled), +.btn-danger:active:not(:disabled) { background: var(--danger-soft); - border-color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 40%, transparent); +} +/* a ghost button whose action is destructive (Delete agent). */ +.btn-danger-text { color: var(--danger); } -.btn-ghost { - background: transparent; +.btn-danger-text:hover:not(:disabled), +.btn-danger-text:active:not(:disabled) { + background: var(--danger-soft); + color: var(--danger); } + +/* Run, Approve, Continue, Deploy. */ +.btn-success { + background: var(--success-soft); + border-color: color-mix(in srgb, var(--success) 35%, transparent); + color: var(--success); + box-shadow: none; +} +.btn-success:hover:not(:disabled), +.btn-success:active:not(:disabled) { + /* toward --surface: lighter in light mode, darker in dark - the text only gains contrast. */ + background: color-mix(in srgb, var(--success-soft) 60%, var(--surface)); + border-color: var(--success); +} + +.btn:disabled, .btn[disabled] { - opacity: 0.4; + opacity: 0.45; cursor: not-allowed; } +/* ---------- Status pill (tones in ui/primitives.css) ---------- */ .status-pill { - font-size: 10px; - font-weight: 700; - letter-spacing: 0.3px; - text-transform: uppercase; - padding: 2px 6px; - border-radius: 20px; display: inline-flex; align-items: center; - gap: 4px; + gap: 6px; + height: 20px; + padding: 0 8px; + border-radius: var(--radius-full); + box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 14%, transparent); + font-size: 12px; + font-weight: 500; + line-height: 1; + letter-spacing: 0; + white-space: nowrap; + font-variant-numeric: tabular-nums; flex: none; } +/* sentence case: the label keeps its text ("awaiting approval"), only its first letter is drawn upper case. */ +.status-pill-label::first-letter { + text-transform: uppercase; +} .status-pill .dot { - width: 5px; - height: 5px; + width: 6px; + height: 6px; box-shadow: none; /* match each status pill's own text color (set by the .pill- classes in ui/primitives.css) instead of always rendering the base `.dot` rule's green - a status pill's dot should reflect that status. */ background: currentColor; } -.btn-danger-text { - color: var(--danger); +.status-pill svg { + width: 14px; + height: 14px; + stroke-width: 1.75; + flex: none; +} +.status-pill.pill-running .dot { + animation: status-pulse 1.4s var(--ease) infinite; +} +@keyframes status-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.35; + transform: scale(0.75); + } } .node-swatch { @@ -104,20 +252,21 @@ flex: none; } +/* ---------- Form fields ---------- */ .field { - margin-bottom: 13px; + margin-bottom: 14px; } .field label { display: block; - font-size: 11px; - font-weight: 600; - color: var(--text-muted); - margin-bottom: 5px; + font-size: 12px; + font-weight: 500; + color: var(--text); + margin-bottom: 6px; } .field .hint { - font-size: 10.5px; + font-size: 12px; color: var(--text-faint); - margin-top: 4px; + margin-top: 5px; } .field .hint.field-error { color: var(--danger); @@ -126,79 +275,159 @@ .select, .textarea { width: 100%; - background: var(--surface-2); - border: 1px solid var(--border); + min-height: 32px; + padding: 0 10px; + background-color: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius); color: var(--text); - border-radius: 7px; - padding: 7px 9px; - font-size: 12px; + font-size: 13px; font-family: var(--font-ui); + line-height: 1.4; + transition: + border-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease); +} +.input:hover:not(:disabled):not(:focus), +.select:hover:not(:disabled):not(:focus), +.textarea:hover:not(:disabled):not(:focus) { + border-color: color-mix(in srgb, var(--border-strong) 55%, var(--text-faint)); +} +.input::placeholder, +.textarea::placeholder { + color: var(--text-faint); + opacity: 1; +} +.select { + appearance: none; + padding-right: 30px; + background-image: var(--select-chevron); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 16px 16px; + cursor: pointer; } .textarea { - font-family: var(--font-mono); - font-size: 11.5px; min-height: 64px; + padding: 7px 10px; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; resize: vertical; } +/* the accent border + ring; the transparent outline keeps a focus indicator in forced-colors mode. */ .input:focus, .select:focus, .textarea:focus { - outline: 2px solid var(--accent); + outline: 2px solid transparent; outline-offset: -1px; + border-color: var(--accent); + box-shadow: var(--ring); +} +.input:disabled, +.select:disabled, +.textarea:disabled { + background-color: var(--surface-2); + color: var(--text-muted); + cursor: not-allowed; } .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } + +/* ---------- Chip ---------- */ .chip { display: inline-flex; align-items: center; - gap: 5px; + gap: 6px; + min-height: 24px; + padding: 0 10px; border: 1px solid var(--border); + border-radius: var(--radius-full); background: var(--surface-2); - border-radius: 20px; - padding: 4px 9px 4px 8px; - font-size: 11px; + color: var(--text); + font-size: 12px; font-family: var(--font-mono); + line-height: 1.2; + transition: + background-color var(--dur-fast) var(--ease), + border-color var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} +.chip svg { + width: 14px; + height: 14px; + stroke-width: 1.75; + flex: none; } .chip.on { - border-color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 45%, transparent); color: var(--accent); background: var(--accent-soft); } +/* ---------- Switch ---------- */ .switch { - width: 30px; - height: 17px; - border-radius: 20px; - background: var(--surface-2); - border: 1px solid var(--border); + width: 32px; + height: 18px; + border-radius: var(--radius-full); + background: var(--border-strong); + border: 1px solid transparent; position: relative; cursor: pointer; flex: none; + transition: + background-color var(--dur) var(--ease), + border-color var(--dur) var(--ease); } .switch.on { - background: var(--accent); - border-color: var(--accent); + background: var(--accent-solid); + border-color: var(--accent-solid); } .switch::after { content: ''; position: absolute; - width: 12px; - height: 12px; + width: 14px; + height: 14px; border-radius: 50%; background: #fff; - top: 1.5px; - left: 2px; - transition: left 0.12s; + box-shadow: var(--shadow-sm); + top: 1px; + left: 1px; + transition: transform var(--dur) var(--ease); } .switch.on::after { - left: 15px; + transform: translateX(14px); +} +.switch:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .toggle-row { display: flex; align-items: center; justify-content: space-between; + gap: 12px; + min-height: 32px; padding: 2px 0; + font-size: 13px; +} + +/* Every transition and animation above is off for reduced motion. */ +@media (prefers-reduced-motion: reduce) { + .btn, + .btn-secondary, + .input, + .select, + .textarea, + .chip, + .switch, + .switch::after { + transition: none; + } + .status-pill.pill-running .dot { + animation: none; + } } diff --git a/apps/agent-forge/src/ui/primitives.css b/apps/agent-forge/src/ui/primitives.css index 08aca2ef..8430b816 100644 --- a/apps/agent-forge/src/ui/primitives.css +++ b/apps/agent-forge/src/ui/primitives.css @@ -18,13 +18,18 @@ color: var(--warning); } .status-pill.pill-running { - background: var(--surface-2); + background: var(--info-soft); color: var(--info); } .status-pill.pill-neutral { background: var(--surface-2); color: var(--text-muted); } +/* LLM / agent-flavoured statuses. */ +.status-pill.pill-violet { + background: var(--violet-soft); + color: var(--violet); +} /* ---------- EmptyState ---------- */ .empty-state { @@ -105,12 +110,15 @@ } .toast-close { flex: none; + display: inline-flex; + align-items: center; + justify-content: center; background: none; border: none; - padding: 0 4px; + border-radius: var(--radius-sm); + /* 16px icon + 4px each side: a 24x24 hit target */ + padding: 4px; color: var(--text-muted); - font: inherit; - font-size: 16px; line-height: 1; cursor: pointer; } diff --git a/docs/agent-forge.md b/docs/agent-forge.md index 573223ab..480499dd 100644 --- a/docs/agent-forge.md +++ b/docs/agent-forge.md @@ -826,3 +826,12 @@ LOUSHO_DOC_SHOTS=1 npx playwright test -c apps/agent-forge/playwright.config.ts It writes `docs/images/studio-.png` (1280x800, light theme) from the same fake daemon. The images are not part of the npm package. +Two optional variables help review a restyle before and after: +`LOUSHO_DOC_SHOTS_OUT=` writes the PNGs to another directory (absolute, +or relative to where you run the command), and `LOUSHO_DOC_SHOTS_SCHEME=dark` +shoots the dark theme (`light` is the default; any other value fails the run). + +```bash +LOUSHO_DOC_SHOTS=1 LOUSHO_DOC_SHOTS_OUT=/tmp/shots-dark LOUSHO_DOC_SHOTS_SCHEME=dark npx playwright test -c apps/agent-forge/playwright.config.ts docShots +``` + diff --git a/llms-full.txt b/llms-full.txt index 24063646..2bbd5c11 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2707,6 +2707,15 @@ LOUSHO_DOC_SHOTS=1 npx playwright test -c apps/agent-forge/playwright.config.ts It writes `docs/images/studio-.png` (1280x800, light theme) from the same fake daemon. The images are not part of the npm package. +Two optional variables help review a restyle before and after: +`LOUSHO_DOC_SHOTS_OUT=` writes the PNGs to another directory (absolute, +or relative to where you run the command), and `LOUSHO_DOC_SHOTS_SCHEME=dark` +shoots the dark theme (`light` is the default; any other value fails the run). + +```bash +LOUSHO_DOC_SHOTS=1 LOUSHO_DOC_SHOTS_OUT=/tmp/shots-dark LOUSHO_DOC_SHOTS_SCHEME=dark npx playwright test -c apps/agent-forge/playwright.config.ts docShots +``` + # AI SDK UI (`useChat`) Source: docs/ai-sdk-ui.md diff --git a/package-lock.json b/package-lock.json index cb252e42..1c2619b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -150,6 +150,7 @@ "@ai-sdk/anthropic": "^0.0.42", "@ai-sdk/openai": "^0.0.42", "@axe-core/playwright": "4.13.0", + "@fontsource-variable/inter": "^5.3.0", "@playwright/test": "^1.63.0", "@types/dagre": "^0.7.52", "@types/express": "^5.0.5", @@ -159,6 +160,7 @@ "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.4", "jsdom": "^25.0.1", + "lucide-react": "^1.63.0", "ollama-ai-provider": "^1.2.0", "supertest": "^7.3.0", "tsup": "^8.0.0", @@ -3068,6 +3070,16 @@ "win32" ] }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@gerrit0/mini-shiki": { "version": "3.23.0", "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", @@ -10320,6 +10332,22 @@ "dev": true, "license": "ISC" }, + "node_modules/lucide-react": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.63.0.tgz", + "integrity": "sha512-GkBfQG+FUJ41x6y3/lNXiOziVU0Rcqqq0TCzhHS8avsFXHZmOdmfBqsVTvHSaVmbI+wbdGkxHRmhYWhD3N4/yA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/lunr": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",