diff --git a/apps/fabro-web/app/app.css b/apps/fabro-web/app/app.css index de1de0d9b..f0c5bc131 100644 --- a/apps/fabro-web/app/app.css +++ b/apps/fabro-web/app/app.css @@ -53,24 +53,30 @@ --color-line: rgba(255, 255, 255, 0.06); --color-line-strong: rgba(255, 255, 255, 0.12); --color-focus: rgba(103, 178, 215, 0.4); + /* Text color that sits on --color-teal-500 primary fills. + Dark mode: teal-500 is light (#67B2D7) so dark text reads best. + Light mode: teal-500 is dark (#0e7490) so white reads best. */ + --color-on-primary: #0F1729; } /* ── Light theme overrides ── */ .light { - /* Semantic tokens */ - --color-page: #ffffff; - --color-panel: #f8fafc; - --color-panel-alt: #f1f5f9; - --color-overlay: rgba(0, 0, 0, 0.04); - --color-overlay-strong: rgba(0, 0, 0, 0.10); + /* Semantic tokens — page is softly tinted so panels/cards (white) + read as the lighter surface, mirroring the dark-theme hierarchy. */ + --color-page: #eef2f7; + --color-panel: #ffffff; + --color-panel-alt: #e4eaf1; + --color-overlay: rgba(15, 23, 41, 0.05); + --color-overlay-strong: rgba(15, 23, 41, 0.10); --color-fg: #0f172a; --color-fg-2: #1e293b; --color-fg-3: #475569; - --color-fg-muted: #94a3b8; - --color-line: rgba(0, 0, 0, 0.08); - --color-line-strong: rgba(0, 0, 0, 0.15); + --color-fg-muted: #64748b; + --color-line: rgba(15, 23, 41, 0.10); + --color-line-strong: rgba(15, 23, 41, 0.18); --color-focus: rgba(14, 116, 144, 0.5); + --color-on-primary: #ffffff; /* Accent — darkened for WCAG AA on white */ --color-mint: #0d9488; diff --git a/apps/fabro-web/app/components/graph-toolbar.tsx b/apps/fabro-web/app/components/graph-toolbar.tsx new file mode 100644 index 000000000..93707c89c --- /dev/null +++ b/apps/fabro-web/app/components/graph-toolbar.tsx @@ -0,0 +1,84 @@ +import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid"; + +type Direction = "LR" | "TB"; + +export const GRAPH_ZOOM_STEPS = [25, 50, 75, 100, 150, 200]; +export const GRAPH_DEFAULT_ZOOM_INDEX = 2; + +export function GraphToolbar({ + direction, + setDirection, + fitToWindow, + zoomIndex, + setZoomIndex, +}: { + direction: Direction; + setDirection: (d: Direction) => void; + fitToWindow: () => void; + zoomIndex: number; + setZoomIndex: (updater: (i: number) => number) => void; +}) { + const group = + "flex items-center gap-0.5 px-0.5 [&:not(:first-child)]:border-l [&:not(:first-child)]:border-line-strong [&:not(:first-child)]:pl-1 [&:not(:first-child)]:ml-1"; + const btn = + "flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"; + const btnActive = "bg-overlay-strong text-fg-3"; + const btnDisabled = + "disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"; + + return ( +
+
+ + +
+
+ +
+
+ + +
+
+ ); +} diff --git a/apps/fabro-web/app/components/stage-sidebar.tsx b/apps/fabro-web/app/components/stage-sidebar.tsx index 46c801c76..7bbfe1fe0 100644 --- a/apps/fabro-web/app/components/stage-sidebar.tsx +++ b/apps/fabro-web/app/components/stage-sidebar.tsx @@ -1,6 +1,12 @@ import { useState, useEffect, useRef } from "react"; import { Link, useRevalidator } from "react-router"; -import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@heroicons/react/24/solid"; +import { + ArrowPathIcon, + CheckCircleIcon, + NoSymbolIcon, + PauseCircleIcon, + XCircleIcon, +} from "@heroicons/react/24/solid"; import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline"; import { formatDurationSecs } from "../lib/format"; @@ -19,7 +25,7 @@ export const statusConfig: Record>; + +// Shared chrome for non-critical "the content isn't there" states. Full-page +// crashes are handled by the root ErrorBoundary; these render inside the +// current content column. +function StatePanel({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function EmptyState({ + icon: Icon, + title, + description, + action, +}: { + icon?: IconComponent; + title: string; + description?: string; + action?: ReactNode; +}) { + return ( + + {Icon ? ( + + ); +} + +export function ErrorState({ + title = "Something went wrong", + description, + onRetry, +}: { + title?: string; + description?: string; + onRetry?: () => void; +}) { + return ( + + + ); +} + +export function LoadingState({ label }: { label?: string }) { + return ( + + +

{label ?? "Loading…"}

+
+ ); +} + +function Spinner({ className = "" }: { className?: string }) { + return ( + + ); +} diff --git a/apps/fabro-web/app/components/tool-use.tsx b/apps/fabro-web/app/components/tool-use.tsx index 7534bfb54..a22280406 100644 --- a/apps/fabro-web/app/components/tool-use.tsx +++ b/apps/fabro-web/app/components/tool-use.tsx @@ -30,11 +30,11 @@ export function ToolRow({ tool }: { tool: ToolUse }) { {open && (
-
Input
+
Input
{tool.input}
-
Result
+
Result
{tool.result}
diff --git a/apps/fabro-web/app/components/ui.tsx b/apps/fabro-web/app/components/ui.tsx new file mode 100644 index 000000000..06565cf76 --- /dev/null +++ b/apps/fabro-web/app/components/ui.tsx @@ -0,0 +1,64 @@ +// Shared UI primitives. The install wizard set the visual baseline; this file +// exposes the primary button, secondary button, input, error message, and +// copy button so the auth and in-app surfaces can match. + +import { useState } from "react"; +import { + ClipboardDocumentCheckIcon, + ClipboardIcon, +} from "@heroicons/react/16/solid"; + +export const INPUT_CLASS = + "block w-full rounded-lg bg-panel-alt px-3.5 py-2.5 text-base text-fg outline-1 -outline-offset-1 outline-white/10 placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 sm:text-sm"; + +export const PRIMARY_BUTTON_CLASS = + "inline-flex items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500"; + +export const SECONDARY_BUTTON_CLASS = + "inline-flex items-center justify-center gap-2 rounded-lg bg-transparent px-3.5 py-2 text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-white/10 hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"; + +export function ErrorMessage({ message }: { message: string }) { + return ( +

+ {message} +

+ ); +} + +export function CopyButton({ + value, + label, + className = "", +}: { + value: string; + label: string; + className?: string; +}) { + const [copied, setCopied] = useState(false); + return ( + + ); +} diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index 2acf3c0e5..a5ad3955d 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -126,6 +126,11 @@ describe("mapRunSummaryToRunItem", () => { expect(runStatusDisplay).toHaveProperty("blocked"); }); + test("recognizes archived as a terminal run status", () => { + expect(isRunStatus("archived")).toBe(true); + expect(runStatusDisplay).toHaveProperty("archived"); + }); + test("uses blocked board column instead of waiting", () => { expect(columnStatusDisplay).toHaveProperty("blocked"); expect(columnStatusDisplay).not.toHaveProperty("waiting"); diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index 711cca52a..5dfb3f4e0 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -132,7 +132,8 @@ export type RunStatus = | "removing" | "succeeded" | "failed" - | "dead"; + | "dead" + | "archived"; export const runStatusDisplay: Record = { submitted: { label: "Submitted", dot: "bg-fg-muted", text: "text-fg-muted" }, @@ -145,6 +146,7 @@ export const runStatusDisplay: Record(Object.keys(runStatusDisplay)); diff --git a/apps/fabro-web/app/install-app.tsx b/apps/fabro-web/app/install-app.tsx index 6c390ede2..a6f86c0b9 100644 --- a/apps/fabro-web/app/install-app.tsx +++ b/apps/fabro-web/app/install-app.tsx @@ -1,6 +1,18 @@ import { startTransition, useEffect, useMemo, useState } from "react"; -import type { ReactNode } from "react"; +import type { FormEvent, ReactNode } from "react"; import { Link, Navigate, useLocation, useNavigate } from "react-router"; +import { + ArrowLeftIcon, + ArrowRightIcon, + ArrowTopRightOnSquareIcon, + CheckCircleIcon, + CheckIcon, + ChevronDownIcon, + ClipboardDocumentCheckIcon, + ClipboardIcon, + EyeIcon, + EyeSlashIcon, +} from "@heroicons/react/16/solid"; import { type InstallFinishResponse, @@ -18,7 +30,6 @@ import { testInstallGithubToken, testInstallLlm, } from "./install-api"; -import { AuthLayout } from "./components/auth-layout"; import { INSTALL_PROVIDERS } from "./install-config"; import { shouldRedirectAfterHealthPoll } from "./install-flow"; import { @@ -26,15 +37,25 @@ import { consumeInstallTokenFromUrl, shouldConsumeInstallGithubErrorForPath, } from "./mode"; +import { + CopyButton, + ErrorMessage, + INPUT_CLASS, + PRIMARY_BUTTON_CLASS, + SECONDARY_BUTTON_CLASS, +} from "./components/ui"; +import { LoadingState } from "./components/state"; const INSTALL_STEPS = [ { id: "welcome", label: "Welcome", href: "/install/welcome" }, - { id: "llm", label: "LLM", href: "/install/llm" }, { id: "server", label: "Server", href: "/install/server" }, + { id: "llm", label: "LLMs", href: "/install/llm" }, { id: "github", label: "GitHub", href: "/install/github" }, { id: "review", label: "Review", href: "/install/review" }, ] as const; +const STEPPER_STEPS = INSTALL_STEPS.slice(1); + type StepId = (typeof INSTALL_STEPS)[number]["id"]; type FinishState = InstallFinishResponse | null; type GithubStrategy = "token" | "app"; @@ -54,12 +75,7 @@ type AppForm = { allowedUsername: string; }; -type ProviderSelection = Record< - string, - { - apiKey: string; - } ->; +type ProviderSelection = Record; export default function InstallApp() { const navigate = useNavigate(); @@ -246,9 +262,7 @@ export default function InstallApp() { if (sessionState.status === "loading") { return ( - - Reading the current install state from the server. - + ); } @@ -278,11 +292,11 @@ export default function InstallApp() { ) : location.pathname === "/install/llm" ? ( { const providers = INSTALL_PROVIDERS.map(({ id }) => { const current = llmSelection[id] ?? { apiKey: "" }; @@ -306,7 +320,7 @@ export default function InstallApp() { await putInstallLlm(installToken, providers); const nextSession = await getInstallSession(installToken); setSessionState({ status: "ready", data: nextSession }); - navigate("/install/server"); + navigate("/install/github"); } catch (error) { setSaveError( error instanceof Error ? error.message : "Failed to save LLM settings.", @@ -320,11 +334,11 @@ export default function InstallApp() { ) : location.pathname === "/install/server" ? ( { if (!canonicalUrl.trim()) { setSaveError("Enter the canonical server URL before continuing."); @@ -336,7 +350,7 @@ export default function InstallApp() { await putInstallServer(installToken, canonicalUrl.trim()); const nextSession = await getInstallSession(installToken); setSessionState({ status: "ready", data: nextSession }); - navigate("/install/github"); + navigate("/install/llm"); } catch (error) { setSaveError( error instanceof Error ? error.message : "Failed to save server settings.", @@ -348,13 +362,17 @@ export default function InstallApp() { > setCanonicalUrl(event.target.value)} className={INPUT_CLASS} placeholder="https://fabro.example.com" + autoComplete="url" + spellCheck={false} /> @@ -362,12 +380,12 @@ export default function InstallApp() { ) : location.pathname === "/install/github" ? ( { setSubmitting(true); setSaveError(null); @@ -421,29 +439,43 @@ export default function InstallApp() { > {githubStrategy === "token" ? ( - <> - -