Skip to content

Commit 935145a

Browse files
committed
Put a screen in front of it: projects, branches, a review you can watch
The app had an engine and no way to use it. This is the flow a person follows: add a project, watch it clone, pick the branch to review and what to compare it against, choose the rules and the model, describe what the change was for, and watch the review run. Five screens and a left rail. The rail carries a chip while a review is running, because a review takes minutes and someone who navigates away needs a way back to it. The review page opens on a snapshot read from the database and then follows the event stream, so reloading mid-review shows the same run rather than starting the story again. Design decisions rather than defaults. Severity is the only place strong colour appears, so a tool that coloured its own chrome would have nothing left to mean "this one is critical". Numbers are tabular, because a review screen is mostly numbers in columns. A running review pulses once for the whole page instead of scattering spinners per widget, all of them out of step, and the pulse stops entirely under prefers-reduced-motion. Empty states teach the next step rather than apologising. Errors show what actually failed: a clone that fails prints git's stderr verbatim. The lint rules caught five effects calling setState synchronously. Fixing them properly, with a cancellation guard per effect, also fixed a real bug: navigating away during the first fetch would have set state on a component that was already gone. Driven end to end against a running production server before committing: added a project from a local repository, watched the clone finish, listed its branches with ahead and behind counts, imported the twelve-rule example protocol, created a review pinned to the branch tips, and started it.
1 parent 3cd8ebe commit 935145a

12 files changed

Lines changed: 1437 additions & 15 deletions

File tree

src/app/globals.css

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,100 @@
11
@import "tailwindcss";
22

33
/*
4-
* Both themes are first-class (docs/04-UI-DESIGN.md). Tokens live here so
5-
* components never hardcode a colour; the full design system lands at WP-K.
4+
* Both themes are first-class (docs/04-UI-DESIGN.md), so every colour is a
5+
* token and no component hardcodes one.
6+
*
7+
* Severity is the only place strong colour appears. Everything else is a
8+
* near-neutral surface with one restrained accent, because a reviewer's tool
9+
* that colours its chrome leaves nothing left to mean "this one is critical".
610
*/
711
@theme {
812
--color-surface: oklch(99% 0.002 250);
913
--color-surface-raised: oklch(97% 0.003 250);
14+
--color-surface-sunken: oklch(95.5% 0.004 250);
1015
--color-border: oklch(90% 0.005 250);
16+
--color-border-strong: oklch(82% 0.008 250);
1117
--color-ink: oklch(25% 0.01 250);
1218
--color-ink-muted: oklch(50% 0.01 250);
19+
--color-ink-faint: oklch(64% 0.008 250);
20+
21+
--color-accent: oklch(52% 0.13 250);
22+
--color-accent-ink: oklch(99% 0.002 250);
23+
--color-accent-soft: oklch(94% 0.03 250);
24+
25+
--color-critical: oklch(52% 0.19 25);
26+
--color-critical-soft: oklch(95% 0.04 25);
27+
--color-warning: oklch(58% 0.13 75);
28+
--color-warning-soft: oklch(95% 0.04 75);
29+
--color-question: oklch(52% 0.11 250);
30+
--color-question-soft: oklch(95% 0.03 250);
31+
--color-good: oklch(52% 0.12 155);
32+
--color-good-soft: oklch(95% 0.04 155);
33+
34+
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
1335
}
1436

1537
@media (prefers-color-scheme: dark) {
1638
@theme {
1739
--color-surface: oklch(19% 0.008 250);
1840
--color-surface-raised: oklch(23% 0.009 250);
41+
--color-surface-sunken: oklch(16% 0.007 250);
1942
--color-border: oklch(32% 0.01 250);
43+
--color-border-strong: oklch(42% 0.012 250);
2044
--color-ink: oklch(94% 0.004 250);
2145
--color-ink-muted: oklch(70% 0.008 250);
46+
--color-ink-faint: oklch(58% 0.008 250);
47+
48+
--color-accent: oklch(70% 0.12 250);
49+
--color-accent-ink: oklch(17% 0.01 250);
50+
--color-accent-soft: oklch(30% 0.05 250);
51+
52+
--color-critical: oklch(70% 0.16 25);
53+
--color-critical-soft: oklch(28% 0.06 25);
54+
--color-warning: oklch(76% 0.12 75);
55+
--color-warning-soft: oklch(28% 0.05 75);
56+
--color-question: oklch(72% 0.1 250);
57+
--color-question-soft: oklch(27% 0.05 250);
58+
--color-good: oklch(72% 0.11 155);
59+
--color-good-soft: oklch(26% 0.05 155);
2260
}
2361
}
2462

2563
body {
2664
background: var(--color-surface);
2765
color: var(--color-ink);
66+
/* Counts, token totals and line numbers line up column-wise when they are
67+
tabular, and a review screen is mostly numbers in columns. */
68+
font-variant-numeric: tabular-nums;
69+
}
70+
71+
/* A running review pulses rather than spinning. One steady signal for the
72+
whole page beats a spinner per widget, all of them out of step. */
73+
@keyframes trysquare-pulse {
74+
0%,
75+
100% {
76+
opacity: 1;
77+
}
78+
50% {
79+
opacity: 0.45;
80+
}
81+
}
82+
83+
.pulse {
84+
animation: trysquare-pulse 1.8s ease-in-out infinite;
85+
}
86+
87+
@media (prefers-reduced-motion: reduce) {
88+
.pulse {
89+
animation: none;
90+
}
91+
}
92+
93+
::selection {
94+
background: var(--color-accent-soft);
95+
}
96+
97+
:focus-visible {
98+
outline: 2px solid var(--color-accent);
99+
outline-offset: 2px;
28100
}

src/app/layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Metadata } from "next";
22
import type { ReactNode } from "react";
3+
import { Rail } from "@/components/rail";
34
import "./globals.css";
45

56
export const metadata: Metadata = {
@@ -10,7 +11,12 @@ export const metadata: Metadata = {
1011
export default function RootLayout({ children }: { children: ReactNode }) {
1112
return (
1213
<html lang="en">
13-
<body className="min-h-screen antialiased">{children}</body>
14+
<body className="min-h-screen antialiased">
15+
<div className="flex min-h-screen">
16+
<Rail />
17+
<main className="min-w-0 flex-1">{children}</main>
18+
</div>
19+
</body>
1420
</html>
1521
);
1622
}

src/app/page.tsx

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,6 @@
1+
import { redirect } from "next/navigation";
2+
13
export default function HomePage() {
2-
return (
3-
<main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
4-
<h1 className="text-2xl font-medium tracking-tight">Trysquare</h1>
5-
<p className="text-[var(--color-ink-muted)]">
6-
Scaffold only. No review functionality is implemented yet: the projects, ruleset, and review
7-
surfaces arrive with WP-H and WP-I of the build plan.
8-
</p>
9-
<p className="text-sm text-[var(--color-ink-muted)]">
10-
Progress is tracked in <code className="font-mono">docs/PROJECT-STATE.md</code>.
11-
</p>
12-
</main>
13-
);
4+
// Projects is where every flow starts, so the root is not a screen of its own.
5+
redirect("/projects");
146
}

src/app/projects/page.tsx

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"use client";
2+
3+
/**
4+
* Projects: what this app has cloned, and how to add another.
5+
*
6+
* A clone runs in the background, so the list polls while any project is still
7+
* cloning and stops when none is. A failed clone shows git's own message,
8+
* because "clone failed" tells nobody whether it was the address, the network
9+
* or their SSH key.
10+
*/
11+
12+
import Link from "next/link";
13+
import { useCallback, useEffect, useState } from "react";
14+
import { PageBody, PageHeader } from "@/components/page";
15+
import { Badge, Button, Card, Empty, Field, Input, Mono, Problem } from "@/components/ui";
16+
17+
interface Project {
18+
id: string;
19+
name: string;
20+
gitUrl: string;
21+
defaultBranch: string;
22+
cloneStatus: string;
23+
cloneError: string | null;
24+
lastFetchedAt: string | null;
25+
}
26+
27+
export default function ProjectsPage() {
28+
const [projects, setProjects] = useState<Project[] | null>(null);
29+
const [gitUrl, setGitUrl] = useState("");
30+
const [adding, setAdding] = useState(false);
31+
const [error, setError] = useState("");
32+
33+
const load = useCallback(async () => {
34+
const response = await fetch("/api/projects");
35+
const body = (await response.json()) as { projects: Project[] };
36+
setProjects(body.projects);
37+
return body.projects;
38+
}, []);
39+
40+
// Guarded against unmount rather than fired and forgotten: navigating away
41+
// during the first fetch would otherwise set state on a gone component.
42+
useEffect(() => {
43+
let cancelled = false;
44+
void (async () => {
45+
const response = await fetch("/api/projects");
46+
const body = (await response.json()) as { projects: Project[] };
47+
if (!cancelled) setProjects(body.projects);
48+
})();
49+
return () => {
50+
cancelled = true;
51+
};
52+
}, []);
53+
54+
// Polls only while something is actually cloning, so an idle screen is idle.
55+
const cloning = projects?.some((project) => project.cloneStatus === "pending") ?? false;
56+
useEffect(() => {
57+
if (!cloning) return;
58+
const timer = setInterval(() => void load(), 1000);
59+
return () => clearInterval(timer);
60+
}, [cloning, load]);
61+
62+
async function add(event: React.FormEvent) {
63+
event.preventDefault();
64+
setError("");
65+
setAdding(true);
66+
try {
67+
const response = await fetch("/api/projects", {
68+
method: "POST",
69+
body: JSON.stringify({ gitUrl }),
70+
});
71+
const body = (await response.json()) as { error?: string };
72+
if (!response.ok) {
73+
setError(body.error ?? "The project could not be added.");
74+
return;
75+
}
76+
setGitUrl("");
77+
await load();
78+
} finally {
79+
setAdding(false);
80+
}
81+
}
82+
83+
return (
84+
<>
85+
<PageHeader title="Projects" subtitle="Cloned read-only. Nothing here is ever written to." />
86+
<PageBody>
87+
<Card className="mb-6 p-4">
88+
<form onSubmit={add} className="flex flex-wrap items-end gap-3">
89+
<div className="min-w-64 flex-1">
90+
<Field
91+
label="Add a project"
92+
hint="Uses your machine's git credentials. This app stores no secrets."
93+
>
94+
<Input
95+
value={gitUrl}
96+
onChange={(event) => setGitUrl(event.target.value)}
97+
placeholder="git@github.com:you/your-app.git"
98+
spellCheck={false}
99+
autoComplete="off"
100+
/>
101+
</Field>
102+
</div>
103+
<Button type="submit" variant="primary" disabled={adding || gitUrl.trim() === ""}>
104+
{adding ? "Cloning..." : "Add project"}
105+
</Button>
106+
</form>
107+
{error ? <div className="mt-3">{error ? <Problem>{error}</Problem> : null}</div> : null}
108+
</Card>
109+
110+
{projects === null ? (
111+
<p className="text-sm text-[var(--color-ink-muted)]">Loading...</p>
112+
) : projects.length === 0 ? (
113+
<Empty title="No projects yet">
114+
Add a repository above. Trysquare clones it read-only, then you pick two branches and
115+
the rules to review them against.
116+
</Empty>
117+
) : (
118+
<ul className="grid gap-3">
119+
{projects.map((project) => (
120+
<li key={project.id}>
121+
<Card className="p-4">
122+
<div className="flex flex-wrap items-start justify-between gap-3">
123+
<div className="min-w-0">
124+
<div className="flex items-center gap-2">
125+
{project.cloneStatus === "ready" ? (
126+
<Link
127+
href={`/projects/${project.id}`}
128+
className="font-medium hover:underline"
129+
>
130+
{project.name}
131+
</Link>
132+
) : (
133+
<span className="font-medium">{project.name}</span>
134+
)}
135+
{project.cloneStatus === "pending" ? (
136+
<Badge tone="accent">cloning</Badge>
137+
) : project.cloneStatus === "failed" ? (
138+
<Badge tone="critical">clone failed</Badge>
139+
) : (
140+
<Badge tone="neutral">{project.defaultBranch}</Badge>
141+
)}
142+
</div>
143+
<Mono className="mt-1 block truncate text-xs text-[var(--color-ink-muted)]">
144+
{project.gitUrl}
145+
</Mono>
146+
</div>
147+
{project.cloneStatus === "ready" ? (
148+
<Link href={`/reviews/new?projectId=${project.id}`}>
149+
<Button variant="secondary">Review a branch</Button>
150+
</Link>
151+
) : null}
152+
</div>
153+
{project.cloneError ? (
154+
<pre className="mt-3 overflow-x-auto rounded border border-[var(--color-critical)] bg-[var(--color-critical-soft)] p-3 text-xs text-[var(--color-critical)]">
155+
{project.cloneError}
156+
</pre>
157+
) : null}
158+
</Card>
159+
</li>
160+
))}
161+
</ul>
162+
)}
163+
</PageBody>
164+
</>
165+
);
166+
}

0 commit comments

Comments
 (0)