refactor(web): align auth surfaces with the install wizard

Extract the wizard's INPUT_CLASS, PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS, and ErrorMessage into
apps/fabro-web/app/components/ui.tsx so auth-login, setup, and the
install wizard share one source of truth.

- auth-login: raise the heading to text-2xl, swap white-on-teal for
  navy-on-teal, replace the bordered dev-token input with the outline
  pattern, use the ErrorMessage pill for invalid tokens, associate the
  input with a label, and shrink the GitHub mark to size-4 per the
  icons guideline
- setup: replace the nested bg-overlay cards with a numbered <ol>
  matching the wizard's welcome layout, raise the heading, switch the
  primary button to navy-on-teal
- install-app: re-import the shared primitives instead of holding
  local duplicates

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-19 16:43:41 -04:00
parent 43b30f386a
commit 2ff71d74bb
No known key found for this signature in database
4 changed files with 158 additions and 98 deletions

View file

@ -0,0 +1,23 @@
// Shared UI primitives. The install wizard set the visual baseline; this file
// exposes the primary button, secondary button, input, and inline error
// treatments so the auth and in-app surfaces can match.
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-navy-950 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 (
<p
role="alert"
className="rounded-md bg-coral/10 px-3 py-2 text-sm/6 text-fg-2 outline-1 -outline-offset-1 outline-coral/40"
>
{message}
</p>
);
}

View file

@ -37,6 +37,12 @@ import {
consumeInstallTokenFromUrl,
shouldConsumeInstallGithubErrorForPath,
} from "./mode";
import {
ErrorMessage,
INPUT_CLASS,
PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS,
} from "./components/ui";
const INSTALL_STEPS = [
{ id: "welcome", label: "Welcome", href: "/install/welcome" },
@ -1416,17 +1422,6 @@ function Spinner({ className = "" }: { className?: string }) {
);
}
function ErrorMessage({ message }: { message: string }) {
return (
<p
role="alert"
className="rounded-md bg-coral/10 px-3 py-2 text-sm/6 text-fg-2 outline-1 -outline-offset-1 outline-coral/40"
>
{message}
</p>
);
}
function defaultProviderSelection(): ProviderSelection {
return Object.fromEntries(
INSTALL_PROVIDERS.map((provider) => [provider.id, { apiKey: "" }]),
@ -1509,11 +1504,3 @@ function submitGithubManifest(
form.submit();
}
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";
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-navy-950 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";
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";

View file

@ -1,6 +1,11 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { AuthLayout } from "../components/auth-layout";
import {
ErrorMessage,
INPUT_CLASS,
PRIMARY_BUTTON_CLASS,
} from "../components/ui";
import { getAuthConfig, loginDevToken } from "../api";
export async function loader() {
@ -23,16 +28,16 @@ export default function AuthLogin({ loaderData }: any) {
await loginDevToken(token);
navigate("/runs");
} catch {
setError("Invalid dev token");
setError("Invalid dev token.");
}
}
return (
<AuthLayout>
<h1 className="text-center text-lg font-semibold text-fg">
<h1 className="text-center text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
Sign in to Fabro
</h1>
<p className="mt-2 text-center text-sm text-fg-3">
<p className="mt-3 text-center text-sm/6 text-fg-3 text-pretty">
{hasGitHub
? "Authenticate with your GitHub account to continue."
: "Paste your dev token to continue."}
@ -41,7 +46,7 @@ export default function AuthLogin({ loaderData }: any) {
{hasGitHub ? (
<a
href="/auth/login/github"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
className={`${PRIMARY_BUTTON_CLASS} w-full`}
>
<GitHubMark />
Sign in with GitHub
@ -55,34 +60,65 @@ export default function AuthLogin({ loaderData }: any) {
onSubmit={handleSubmit}
/>
) : hasDevToken ? (
<form className="space-y-3" onSubmit={handleSubmit}>
<input
type="password"
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder="fabro_dev_..."
className="w-full rounded-lg border border-line-strong bg-panel px-4 py-2.5 text-sm text-fg outline-none focus:border-teal-500"
/>
<button
type="submit"
className="flex w-full items-center justify-center rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
>
Sign in with Dev Token
</button>
<p className="text-center text-xs text-fg-muted">
Paste the dev token from your terminal or{" "}
<code>cat ~/.fabro/dev-token</code>
</p>
{error ? (
<p className="text-center text-sm text-red-500">{error}</p>
) : null}
</form>
<DevTokenForm
token={token}
setToken={setToken}
error={error}
onSubmit={handleSubmit}
showLocation
/>
) : null}
</div>
</AuthLayout>
);
}
function DevTokenForm({
token,
setToken,
error,
onSubmit,
showLocation = false,
}: {
token: string;
setToken: (v: string) => void;
error: string | null;
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
showLocation?: boolean;
}) {
return (
<form className="space-y-4" onSubmit={onSubmit}>
<div>
<label htmlFor="dev-token" className="sr-only">
Dev token
</label>
<input
id="dev-token"
type="password"
name="dev_token"
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder="fabro_dev_…"
className={`${INPUT_CLASS} font-mono`}
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
/>
</div>
{error ? <ErrorMessage message={error} /> : null}
<button type="submit" className={`${PRIMARY_BUTTON_CLASS} w-full`}>
Sign in with dev token
</button>
{showLocation ? (
<p className="text-center text-xs text-fg-muted">
Paste the dev token from your terminal or{" "}
<code className="font-mono text-fg-3">cat ~/.fabro/dev-token</code>.
</p>
) : null}
</form>
);
}
function DevTokenCollapsible({
token,
setToken,
@ -101,7 +137,7 @@ function DevTokenCollapsible({
<button
type="button"
onClick={() => setOpen(!open)}
className="mx-auto flex items-center gap-1 text-xs text-fg-3 hover:text-fg-2 transition-colors"
className="mx-auto flex items-center gap-1 rounded text-xs text-fg-3 outline-teal-500 hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-2"
>
Use a dev token instead
<svg
@ -110,6 +146,7 @@ function DevTokenCollapsible({
viewBox="0 0 12 12"
fill="none"
className={`transition-transform ${open ? "rotate-180" : ""}`}
aria-hidden="true"
>
<path
d="M3 4.5L6 7.5L9 4.5"
@ -121,24 +158,14 @@ function DevTokenCollapsible({
</svg>
</button>
{open ? (
<form className="mt-3 space-y-3" onSubmit={onSubmit}>
<input
type="password"
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder="fabro_dev_..."
className="w-full rounded-lg border border-line-strong bg-panel px-4 py-2.5 text-sm text-fg outline-none focus:border-teal-500"
<div className="mt-4">
<DevTokenForm
token={token}
setToken={setToken}
error={error}
onSubmit={onSubmit}
/>
<button
type="submit"
className="flex w-full items-center justify-center rounded-lg border border-line-strong bg-panel px-4 py-2.5 text-sm font-medium text-fg transition-colors hover:bg-panel-2"
>
Sign in with Dev Token
</button>
{error ? (
<p className="text-center text-sm text-red-500">{error}</p>
) : null}
</form>
</div>
) : null}
</div>
);
@ -146,7 +173,7 @@ function DevTokenCollapsible({
function GitHubMark() {
return (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
<svg viewBox="0 0 16 16" fill="currentColor" className="size-4 shrink-0" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
);

View file

@ -1,45 +1,68 @@
import { AuthLayout } from "../components/auth-layout";
import { PRIMARY_BUTTON_CLASS } from "../components/ui";
const steps = [
{
title: "Open a terminal on the server host",
body: (
<p className="text-sm/6 text-fg-3">
Run{" "}
<code className="font-mono text-fg-2">fabro install</code> on the same
host that runs the Fabro server.
</p>
),
},
{
title: "Choose GitHub App setup",
body: (
<p className="text-sm/6 text-fg-3">
The CLI opens GitHub, exchanges the manifest code, and writes the
required settings and secrets locally.
</p>
),
},
{
title: "Restart the server, then return to sign in",
body: (
<p className="text-sm/6 text-fg-3">
Once the server comes back up, you can authenticate from the browser.
</p>
),
},
];
export default function Setup() {
return (
<AuthLayout footer="GitHub App setup is managed from the terminal, not the browser.">
<h1 className="text-center text-lg font-semibold text-fg">
<h1 className="text-center text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
Set up Fabro
</h1>
<p className="mt-2 text-center text-sm text-fg-3">
Run the installer on the same host that runs the Fabro server to
register a GitHub App and write local configuration.
<p className="mt-3 text-center text-sm/6 text-fg-3 text-pretty">
Run the installer on the server host to register a GitHub App and write
local configuration.
</p>
<div className="mt-6 space-y-4">
<div className="rounded-lg border border-line-strong bg-overlay px-4 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-fg-muted">
1. Open a terminal on the server host
</p>
<pre className="mt-2 overflow-x-auto text-sm text-fg-2">
<code>fabro install</code>
</pre>
</div>
<div className="rounded-lg border border-line-strong bg-overlay px-4 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-fg-muted">
2. Choose GitHub App setup
</p>
<p className="mt-2 text-sm text-fg-3">
The CLI opens GitHub, exchanges the manifest code, and writes the
required settings and secrets locally.
</p>
</div>
<div className="rounded-lg border border-line-strong bg-overlay px-4 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-fg-muted">
3. Restart the server, then return to sign in
</p>
<a
href="/login"
className="mt-3 flex w-full items-center justify-center rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
>
Continue to sign in
</a>
</div>
</div>
<ol
role="list"
className="mt-8 divide-y divide-line border-y border-line"
>
{steps.map((step, index) => (
<li key={step.title} className="flex items-start gap-4 py-4">
<span
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-overlay text-xs font-semibold tabular-nums text-fg-2 outline-1 -outline-offset-1 outline-white/10"
aria-hidden="true"
>
{index + 1}
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-fg">{step.title}</p>
<div className="mt-1">{step.body}</div>
</div>
</li>
))}
</ol>
<a href="/login" className={`${PRIMARY_BUTTON_CLASS} mt-8 w-full`}>
Continue to sign in
</a>
</AuthLayout>
);
}