From 3a0157e279059b77110dc79b8f1cd7e84f613471 Mon Sep 17 00:00:00 2001 From: Omer Beck Date: Fri, 28 Aug 2026 12:35:55 +0300 Subject: [PATCH 1/4] Polish auth pages and turn the dashboard into a swipeable pet carousel Sign-in/sign-up now show a real brand moment on mobile (headline, subtext, growth-stage strip) instead of a bare logo, matching the new desktop split-panel layout. The dashboard's pet grid is replaced by a horizontally scroll-snapped carousel: whichever card is centered becomes selected, and its full detail renders inline below instead of navigating to a separate page. /dashboard/[repoId] still works standalone for direct links. Extracted along the way: PetDetailSection (shared by both the inline carousel view and the standalone detail page), useCenteredCard (the scroll-centering mechanism, decoupled from pet-specific rendering), fadeUp() (one entrance-animation helper instead of the same Tailwind arbitrary-value string copy-pasted across 7 files), and a named McpTokenStatus type. --- app/_components/AuthBrandPanel.tsx | 147 ++++++++++++++++++ app/dashboard/[repoId]/page.tsx | 36 +---- app/dashboard/_components/Bar.tsx | 4 +- app/dashboard/_components/CopyButton.tsx | 17 +- app/dashboard/_components/ExternalLink.tsx | 2 +- app/dashboard/_components/GrowthCard.tsx | 2 +- app/dashboard/_components/HealthCard.tsx | 4 +- app/dashboard/_components/Hero.tsx | 3 +- app/dashboard/_components/McpTokenCard.tsx | 6 +- app/dashboard/_components/Nav.tsx | 10 +- app/dashboard/_components/PetCard.tsx | 24 +-- .../_components/PetDetailSection.tsx | 57 +++++++ app/dashboard/_components/PetsCarousel.tsx | 64 ++++++++ app/dashboard/_components/useCenteredCard.ts | 69 ++++++++ app/dashboard/page.tsx | 37 +++-- app/globals.css | 14 ++ app/sign-in/[[...sign-in]]/page.tsx | 20 ++- app/sign-up/[[...sign-up]]/page.tsx | 20 ++- lib/mcp/tokens.ts | 4 +- lib/pets/repo-name.ts | 11 ++ lib/ui/motion.ts | 8 + 21 files changed, 477 insertions(+), 82 deletions(-) create mode 100644 app/_components/AuthBrandPanel.tsx create mode 100644 app/dashboard/_components/PetDetailSection.tsx create mode 100644 app/dashboard/_components/PetsCarousel.tsx create mode 100644 app/dashboard/_components/useCenteredCard.ts create mode 100644 lib/pets/repo-name.ts create mode 100644 lib/ui/motion.ts diff --git a/app/_components/AuthBrandPanel.tsx b/app/_components/AuthBrandPanel.tsx new file mode 100644 index 0000000..0d806ce --- /dev/null +++ b/app/_components/AuthBrandPanel.tsx @@ -0,0 +1,147 @@ +import { Fragment } from "react"; +import type { Stage } from "@/lib/pets/growth"; +import { PetArt } from "@/app/dashboard/_components/PetArt"; +import { fadeUp } from "@/lib/ui/motion"; +import { Logo } from "./Logo"; + +const STAGES: { stage: Stage; label: string }[] = [ + { stage: "egg", label: "Egg" }, + { stage: "hatchling", label: "Hatchling" }, + { stage: "juvenile", label: "Juvenile" }, + { stage: "adult", label: "Adult" }, +]; + +// The only two sizings StageStrip is ever asked for — desktop brand panel +// vs. the compact mobile hero. A closed set of variants instead of five +// independent style props means there's no way to call it with a mismatched +// combination (e.g. mobile-sized art with desktop-sized chevrons). +const STAGE_STRIP_VARIANTS = { + full: { + artClassName: "h-20 w-auto", + tileClassName: "px-3 py-5", + chevronSize: 16, + gapClassName: "gap-2.5", + labels: true, + }, + compact: { + artClassName: "h-9 w-auto", + tileClassName: "px-1.5 py-2.5", + chevronSize: 12, + gapClassName: "gap-1.5", + labels: false, + }, +} as const; + +// Shared growth-stage row, sized differently for the desktop brand panel vs. +// the compact mobile hero below. Labels are dropped on mobile — four +// two-word labels don't fit at that width without wrapping into the chevrons. +function StageStrip({ variant }: { variant: keyof typeof STAGE_STRIP_VARIANTS }) { + const { artClassName, tileClassName, chevronSize, gapClassName, labels } = + STAGE_STRIP_VARIANTS[variant]; + + return ( +
+ {STAGES.map(({ stage, label }, i) => ( + +
+ + {labels && ( + + {label} + + )} +
+ {i < STAGES.length - 1 && ( + + )} +
+ ))} +
+ ); +} + +// Shared left panel for sign-in/sign-up — hidden below lg, where +// MobileAuthHero (below) takes over. Doubles as the app's de facto landing +// page for signed-out visitors, since "/" redirects straight here instead of +// showing separate marketing content. +export function AuthBrandPanel() { + return ( +
+ + ); +} + +// Compact hero for the auth pages below `lg`, shown above the Clerk widget +// instead of just a bare logo. Same brand moment as AuthBrandPanel (headline, +// subtext, growth-stage strip) at mobile scale. +export function MobileAuthHero() { + return ( +
+ + ); +} diff --git a/app/dashboard/[repoId]/page.tsx b/app/dashboard/[repoId]/page.tsx index 96e038d..8558e05 100644 --- a/app/dashboard/[repoId]/page.tsx +++ b/app/dashboard/[repoId]/page.tsx @@ -3,14 +3,10 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { getAccessibleInstallationIds } from "@/lib/github/user-auth"; import { getDashboardPet } from "@/lib/pets/dashboard-data"; +import { repoShortName } from "@/lib/pets/repo-name"; import { getMcpTokenStatus } from "@/lib/mcp/tokens"; -import { Hero } from "../_components/Hero"; -import { GrowthCard } from "../_components/GrowthCard"; -import { OpenIssuesCard } from "../_components/OpenIssuesCard"; -import { HealthCard } from "../_components/HealthCard"; -import { BadgeCard } from "../_components/BadgeCard"; -import { RepoInfoCard } from "../_components/RepoInfoCard"; -import { McpTokenCard } from "../_components/McpTokenCard"; +import { fadeUp } from "@/lib/ui/motion"; +import { PetDetailSection } from "../_components/PetDetailSection"; export async function generateMetadata({ params, @@ -47,7 +43,7 @@ export default async function PetDetailPage({ return (
-
+
/ - {pet.fullName} + {repoShortName(pet.fullName)}
-
-
- - {pet.phase === "development" ? ( - - ) : ( - - )} -
- -
- - - - -
-
+
); } diff --git a/app/dashboard/_components/Bar.tsx b/app/dashboard/_components/Bar.tsx index 736fa61..86dfaee 100644 --- a/app/dashboard/_components/Bar.tsx +++ b/app/dashboard/_components/Bar.tsx @@ -8,9 +8,9 @@ export function Bar({ }) { const width = `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`; return ( -
+
diff --git a/app/dashboard/_components/CopyButton.tsx b/app/dashboard/_components/CopyButton.tsx index 5444e99..a587b98 100644 --- a/app/dashboard/_components/CopyButton.tsx +++ b/app/dashboard/_components/CopyButton.tsx @@ -37,7 +37,7 @@ export function CopyButton({ ); } diff --git a/app/dashboard/_components/ExternalLink.tsx b/app/dashboard/_components/ExternalLink.tsx index f9f2eb9..bec5c35 100644 --- a/app/dashboard/_components/ExternalLink.tsx +++ b/app/dashboard/_components/ExternalLink.tsx @@ -12,7 +12,7 @@ export function ExternalLink({ return ( {children}

Growth

- {label} + {label}

{pet.lastCommitRelative ? `Last commit ${pet.lastCommitRelative}` diff --git a/app/dashboard/_components/HealthCard.tsx b/app/dashboard/_components/HealthCard.tsx index dcca4cc..7512abe 100644 --- a/app/dashboard/_components/HealthCard.tsx +++ b/app/dashboard/_components/HealthCard.tsx @@ -21,7 +21,9 @@ export function HealthCard({ pet }: { pet: DashboardPet }) {

Health

- + {pet.health}% diff --git a/app/dashboard/_components/Hero.tsx b/app/dashboard/_components/Hero.tsx index 7c17887..704cb65 100644 --- a/app/dashboard/_components/Hero.tsx +++ b/app/dashboard/_components/Hero.tsx @@ -1,5 +1,6 @@ import { stageForXp } from "@/lib/pets/growth"; import { moodFor } from "@/lib/pets/mood"; +import { repoShortName } from "@/lib/pets/repo-name"; import type { DashboardPet } from "@/lib/pets/dashboard-data"; import { PetArt } from "./PetArt"; import { MoodPill, PhasePill, StagePill } from "./Pills"; @@ -23,7 +24,7 @@ export function Hero({ pet }: { pet: DashboardPet }) {

- {pet.fullName} + {repoShortName(pet.fullName)}

diff --git a/app/dashboard/_components/McpTokenCard.tsx b/app/dashboard/_components/McpTokenCard.tsx index a546dfe..9f7a675 100644 --- a/app/dashboard/_components/McpTokenCard.tsx +++ b/app/dashboard/_components/McpTokenCard.tsx @@ -38,7 +38,7 @@ export function McpTokenCard({

{token ? ( - <> +
{token} @@ -49,7 +49,7 @@ export function McpTokenCard({ MCP client, pointed at /api/mcp.

- +
) : ( <>

@@ -66,7 +66,7 @@ export function McpTokenCard({ type="button" onClick={handleGenerate} disabled={pending} - className="flex items-center justify-center gap-1.5 rounded-lg border border-dash-border p-2 text-sm font-semibold text-dash-heading hover:bg-dash-neutral-pill disabled:opacity-50" + className="flex items-center justify-center gap-1.5 rounded-lg border border-dash-border p-2 text-sm font-semibold text-dash-heading transition-[background-color,transform] duration-150 ease-out hover:bg-dash-neutral-pill focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-dash-accent/50 focus-visible:ring-offset-2 focus-visible:ring-offset-dash-card active:scale-[0.97] disabled:opacity-50 disabled:active:scale-100" > {pending ? "Generating…" diff --git a/app/dashboard/_components/Nav.tsx b/app/dashboard/_components/Nav.tsx index 3d0da05..09b0aae 100644 --- a/app/dashboard/_components/Nav.tsx +++ b/app/dashboard/_components/Nav.tsx @@ -1,11 +1,17 @@ import Link from "next/link"; import { UserButton } from "@clerk/nextjs"; import { Logo } from "@/app/_components/Logo"; +import { fadeUp } from "@/lib/ui/motion"; export function Nav() { return ( -

- +
+ Commit Pet diff --git a/app/dashboard/_components/PetCard.tsx b/app/dashboard/_components/PetCard.tsx index 08d5580..fb27f8b 100644 --- a/app/dashboard/_components/PetCard.tsx +++ b/app/dashboard/_components/PetCard.tsx @@ -1,6 +1,6 @@ -import Link from "next/link"; import { stageProgress } from "@/lib/pets/growth"; import { moodFor } from "@/lib/pets/mood"; +import { repoShortName } from "@/lib/pets/repo-name"; import type { DashboardPet } from "@/lib/pets/dashboard-data"; import { PetArt } from "./PetArt"; import { Bar } from "./Bar"; @@ -15,7 +15,10 @@ const XP_BAR_FILL = { sick: MOOD.sick.dot, } as const; -export function PetCard({ pet }: { pet: DashboardPet }) { +// Presentational only — no wrapping Link/button, so callers pick the +// interaction (PetsCarousel wraps this in a selectable + ))} +
+ + +
+ ); +} diff --git a/app/dashboard/_components/useCenteredCard.ts b/app/dashboard/_components/useCenteredCard.ts new file mode 100644 index 0000000..8afb286 --- /dev/null +++ b/app/dashboard/_components/useCenteredCard.ts @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from "react"; + +// Tracks which of a set of horizontally-scroll-snapped elements is centered +// in their shared scroll container, updating as the user scrolls. Register +// each element with the returned `register(id)` ref callback; `containerRef` +// goes on the scrolling element itself. +// +// Pure DOM-observation mechanism, no knowledge of what the cards contain — +// kept separate from PetsCarousel so that component can stay focused on +// rendering instead of also owning the IntersectionObserver bookkeeping. +export function useCenteredCard(initialId: string | undefined) { + const [centeredId, setCenteredId] = useState(initialId); + const containerRef = useRef(null); + const elements = useRef(new Map()); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // observe() itself fires an initial callback with every element at + // once — on a wide viewport where nothing needs to scroll, they all tie + // at ratio 1, and picking a "winner" from a tie is unstable. Skip that + // first batch entirely so the initial selection stands until the user + // actually scrolls the strip. + let isInitialBatch = true; + + const observer = new IntersectionObserver( + (entries) => { + if (isInitialBatch) { + isInitialBatch = false; + return; + } + + // Closest-to-center wins, not highest ratio — with snap-mandatory, + // ties at ratio 1 are common (peeking neighbor cards), and center + // distance is what "which one is centered" actually means here. + const containerRect = container.getBoundingClientRect(); + const containerCenter = containerRect.left + containerRect.width / 2; + let bestId: string | null = null; + let bestDistance = Infinity; + for (const entry of entries) { + if (entry.intersectionRatio < 0.6) continue; + const elementCenter = + entry.boundingClientRect.left + entry.boundingClientRect.width / 2; + const distance = Math.abs(elementCenter - containerCenter); + const id = entry.target.getAttribute("data-centered-card-id"); + if (id && distance < bestDistance) { + bestId = id; + bestDistance = distance; + } + } + if (bestId) setCenteredId(bestId); + }, + { root: container, threshold: [0.6, 0.75, 0.9, 1] }, + ); + + elements.current.forEach((el) => observer.observe(el)); + return () => observer.disconnect(); + }, []); + + function register(id: string) { + return (el: HTMLElement | null) => { + if (el) elements.current.set(id, el); + else elements.current.delete(id); + }; + } + + return { centeredId, containerRef, register }; +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 0c5f64e..4d15454 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -2,7 +2,9 @@ import type { Metadata } from "next"; import Link from "next/link"; import { getAccessibleInstallationIds } from "@/lib/github/user-auth"; import { getDashboardPets } from "@/lib/pets/dashboard-data"; -import { PetCard } from "./_components/PetCard"; +import { getMcpTokenStatus, type McpTokenStatus } from "@/lib/mcp/tokens"; +import { fadeUp } from "@/lib/ui/motion"; +import { PetsCarousel } from "./_components/PetsCarousel"; export const metadata: Metadata = { title: "Your pets", @@ -13,7 +15,7 @@ export default async function DashboardPage() { return (
-
+

Your pets

@@ -25,13 +27,17 @@ export default async function DashboardPage() { {installationIds === null ? ( ) : ( - + )}
); } -async function PetsGrid({ installationIds }: { installationIds: number[] }) { +async function PetsSection({ + installationIds, +}: { + installationIds: number[]; +}) { const pets = await getDashboardPets(installationIds); if (pets.length === 0) { @@ -40,7 +46,7 @@ async function PetsGrid({ installationIds }: { installationIds: number[] }) { No pets yet —{" "}
install Commit Pet {" "} @@ -49,13 +55,20 @@ async function PetsGrid({ installationIds }: { installationIds: number[] }) { ); } - return ( -
- {pets.map((pet) => ( - - ))} -
+ // One extra query per pet — fine at the handful-of-repos scale this + // dashboard runs at. Keyed by repoId so PetsCarousel can look up whichever + // pet is currently selected without re-fetching on every swipe. + const tokenStatusEntries = await Promise.all( + pets.map( + async (pet): Promise<[string, McpTokenStatus]> => [ + pet.repoId, + await getMcpTokenStatus(Number(pet.repoId)), + ], + ), ); + const tokenStatuses = Object.fromEntries(tokenStatusEntries); + + return ; } function ConnectGithubPrompt() { @@ -64,7 +77,7 @@ function ConnectGithubPrompt() { Connect your GitHub account to see your repos' pets —{" "} manage connected accounts diff --git a/app/globals.css b/app/globals.css index be8bbb4..1bd4d73 100644 --- a/app/globals.css +++ b/app/globals.css @@ -53,3 +53,17 @@ body { color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } + +/* Shared mount-in animation for Dashboard and auth pages — always paired + with the `motion-safe:` variant at the call site so it's skipped entirely + under prefers-reduced-motion instead of just running without movement. */ +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/app/sign-in/[[...sign-in]]/page.tsx b/app/sign-in/[[...sign-in]]/page.tsx index 34c9daf..ece0072 100644 --- a/app/sign-in/[[...sign-in]]/page.tsx +++ b/app/sign-in/[[...sign-in]]/page.tsx @@ -1,15 +1,23 @@ import { SignIn } from "@clerk/nextjs"; -import { Logo } from "@/app/_components/Logo"; +import { AuthBrandPanel, MobileAuthHero } from "@/app/_components/AuthBrandPanel"; import { clerkAppearance } from "@/app/_components/clerk-appearance"; +import { fadeUp } from "@/lib/ui/motion"; export default function SignInPage() { return ( -
-
- - Commit Pet +
+ +
+ +
+
+ +
+
-
); } diff --git a/app/sign-up/[[...sign-up]]/page.tsx b/app/sign-up/[[...sign-up]]/page.tsx index a764fa3..8af340d 100644 --- a/app/sign-up/[[...sign-up]]/page.tsx +++ b/app/sign-up/[[...sign-up]]/page.tsx @@ -1,15 +1,23 @@ import { SignUp } from "@clerk/nextjs"; -import { Logo } from "@/app/_components/Logo"; +import { AuthBrandPanel, MobileAuthHero } from "@/app/_components/AuthBrandPanel"; import { clerkAppearance } from "@/app/_components/clerk-appearance"; +import { fadeUp } from "@/lib/ui/motion"; export default function SignUpPage() { return ( -
-
- - Commit Pet +
+ +
+ +
+
+ +
+
-
); } diff --git a/lib/mcp/tokens.ts b/lib/mcp/tokens.ts index 0d3fbf3..e09cb7c 100644 --- a/lib/mcp/tokens.ts +++ b/lib/mcp/tokens.ts @@ -69,9 +69,11 @@ export async function touchTokenLastUsed(rawToken: string): Promise { } } +export type McpTokenStatus = { exists: boolean; lastUsedRelative: string | null }; + export async function getMcpTokenStatus( repoId: number, -): Promise<{ exists: boolean; lastUsedRelative: string | null }> { +): Promise { const [row] = await db .select({ lastUsedAt: mcpTokens.lastUsedAt }) .from(mcpTokens) diff --git a/lib/pets/repo-name.ts b/lib/pets/repo-name.ts new file mode 100644 index 0000000..08ea376 --- /dev/null +++ b/lib/pets/repo-name.ts @@ -0,0 +1,11 @@ +// Display-only trim of the "owner/repo" fullName down to just "repo" — used +// wherever the surrounding UI (a card, a page heading) already makes the +// context clear. GitHub links still need the owner, so they read fullName +// directly instead of this. +// +// Deliberately its own file with zero imports (not folded into +// dashboard-data.ts, which pulls in the db client) so any component — +// server or client — can use it without dragging in a database dependency. +export function repoShortName(fullName: string): string { + return fullName.split("/").pop() ?? fullName; +} diff --git a/lib/ui/motion.ts b/lib/ui/motion.ts new file mode 100644 index 0000000..44dbf56 --- /dev/null +++ b/lib/ui/motion.ts @@ -0,0 +1,8 @@ +// The one entrance animation used across the Dashboard and auth pages for +// content that mounts once per page load (cards, sections, page headers). +// Centralized so the 450ms/easing tuning lives in one place instead of being +// copy-pasted into every className string that wants it. +export function fadeUp(delayMs = 0): string { + const base = "motion-safe:animate-[fade-up_450ms_cubic-bezier(0.16,1,0.3,1)_both]"; + return delayMs > 0 ? `${base} [animation-delay:${delayMs}ms]` : base; +} From 1ea5818034003d8052c26e8252c7094d7c15a122 Mon Sep 17 00:00:00 2001 From: Omer Beck Date: Fri, 28 Aug 2026 12:39:40 +0300 Subject: [PATCH 2/4] Fix Prettier formatting flagged by CI --- app/_components/AuthBrandPanel.tsx | 10 ++++++++-- app/dashboard/_components/PetsCarousel.tsx | 6 +++++- app/dashboard/page.tsx | 16 +++++----------- app/sign-in/[[...sign-in]]/page.tsx | 5 ++++- app/sign-up/[[...sign-up]]/page.tsx | 5 ++++- lib/mcp/tokens.ts | 5 ++++- lib/ui/motion.ts | 3 ++- 7 files changed, 32 insertions(+), 18 deletions(-) diff --git a/app/_components/AuthBrandPanel.tsx b/app/_components/AuthBrandPanel.tsx index 0d806ce..71d48da 100644 --- a/app/_components/AuthBrandPanel.tsx +++ b/app/_components/AuthBrandPanel.tsx @@ -35,7 +35,11 @@ const STAGE_STRIP_VARIANTS = { // Shared growth-stage row, sized differently for the desktop brand panel vs. // the compact mobile hero below. Labels are dropped on mobile — four // two-word labels don't fit at that width without wrapping into the chevrons. -function StageStrip({ variant }: { variant: keyof typeof STAGE_STRIP_VARIANTS }) { +function StageStrip({ + variant, +}: { + variant: keyof typeof STAGE_STRIP_VARIANTS; +}) { const { artClassName, tileClassName, chevronSize, gapClassName, labels } = STAGE_STRIP_VARIANTS[variant]; @@ -129,7 +133,9 @@ export function MobileAuthHero() {
-
+

A pet that grows as you commit.

diff --git a/app/dashboard/_components/PetsCarousel.tsx b/app/dashboard/_components/PetsCarousel.tsx index 53c6727..4cd7a50 100644 --- a/app/dashboard/_components/PetsCarousel.tsx +++ b/app/dashboard/_components/PetsCarousel.tsx @@ -58,7 +58,11 @@ export function PetsCarousel({ ))}
- +
); } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 4d15454..8465864 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -33,11 +33,7 @@ export default async function DashboardPage() { ); } -async function PetsSection({ - installationIds, -}: { - installationIds: number[]; -}) { +async function PetsSection({ installationIds }: { installationIds: number[] }) { const pets = await getDashboardPets(installationIds); if (pets.length === 0) { @@ -59,12 +55,10 @@ async function PetsSection({ // dashboard runs at. Keyed by repoId so PetsCarousel can look up whichever // pet is currently selected without re-fetching on every swipe. const tokenStatusEntries = await Promise.all( - pets.map( - async (pet): Promise<[string, McpTokenStatus]> => [ - pet.repoId, - await getMcpTokenStatus(Number(pet.repoId)), - ], - ), + pets.map(async (pet): Promise<[string, McpTokenStatus]> => [ + pet.repoId, + await getMcpTokenStatus(Number(pet.repoId)), + ]), ); const tokenStatuses = Object.fromEntries(tokenStatusEntries); diff --git a/app/sign-in/[[...sign-in]]/page.tsx b/app/sign-in/[[...sign-in]]/page.tsx index ece0072..d86c469 100644 --- a/app/sign-in/[[...sign-in]]/page.tsx +++ b/app/sign-in/[[...sign-in]]/page.tsx @@ -1,5 +1,8 @@ import { SignIn } from "@clerk/nextjs"; -import { AuthBrandPanel, MobileAuthHero } from "@/app/_components/AuthBrandPanel"; +import { + AuthBrandPanel, + MobileAuthHero, +} from "@/app/_components/AuthBrandPanel"; import { clerkAppearance } from "@/app/_components/clerk-appearance"; import { fadeUp } from "@/lib/ui/motion"; diff --git a/app/sign-up/[[...sign-up]]/page.tsx b/app/sign-up/[[...sign-up]]/page.tsx index 8af340d..1289464 100644 --- a/app/sign-up/[[...sign-up]]/page.tsx +++ b/app/sign-up/[[...sign-up]]/page.tsx @@ -1,5 +1,8 @@ import { SignUp } from "@clerk/nextjs"; -import { AuthBrandPanel, MobileAuthHero } from "@/app/_components/AuthBrandPanel"; +import { + AuthBrandPanel, + MobileAuthHero, +} from "@/app/_components/AuthBrandPanel"; import { clerkAppearance } from "@/app/_components/clerk-appearance"; import { fadeUp } from "@/lib/ui/motion"; diff --git a/lib/mcp/tokens.ts b/lib/mcp/tokens.ts index e09cb7c..6162425 100644 --- a/lib/mcp/tokens.ts +++ b/lib/mcp/tokens.ts @@ -69,7 +69,10 @@ export async function touchTokenLastUsed(rawToken: string): Promise { } } -export type McpTokenStatus = { exists: boolean; lastUsedRelative: string | null }; +export type McpTokenStatus = { + exists: boolean; + lastUsedRelative: string | null; +}; export async function getMcpTokenStatus( repoId: number, diff --git a/lib/ui/motion.ts b/lib/ui/motion.ts index 44dbf56..ac136df 100644 --- a/lib/ui/motion.ts +++ b/lib/ui/motion.ts @@ -3,6 +3,7 @@ // Centralized so the 450ms/easing tuning lives in one place instead of being // copy-pasted into every className string that wants it. export function fadeUp(delayMs = 0): string { - const base = "motion-safe:animate-[fade-up_450ms_cubic-bezier(0.16,1,0.3,1)_both]"; + const base = + "motion-safe:animate-[fade-up_450ms_cubic-bezier(0.16,1,0.3,1)_both]"; return delayMs > 0 ? `${base} [animation-delay:${delayMs}ms]` : base; } From 3c80434e8777dde106ea63b96e324466a57c70da Mon Sep 17 00:00:00 2001 From: Omer Beck Date: Fri, 28 Aug 2026 13:03:39 +0300 Subject: [PATCH 3/4] Address CodeRabbit and Baz review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix a real regression from the fadeUp() refactor: Tailwind never generates CSS for arbitrary-value classes assembled at runtime via string interpolation, so every staggered entrance delay (auth pages, pet detail cards, carousel cards) was silently firing at 0ms instead of cascading. fadeUp() now returns {className, style} and applies the delay via a real inline style, which has no such restriction. Verified via computed styles in the browser. - Add aria-current to the selected carousel card so screen readers can tell which card controls the detail section below. - Extract AuthShell (desktop split panel + mobile hero + Clerk widget wrapper) instead of duplicating that structure across the sign-in and sign-up pages verbatim. - Fix grammar in a PetDetailSection comment. - Catch per-pet failures in the dashboard's token-status fetch so one failing lookup can't take down the whole "Your pets" page. - Make useCenteredCard recompute the centered card from every registered element's latest known ratio and live geometry, not just the entries included in a given IntersectionObserver callback (which only reports elements whose ratio crossed a threshold since last time) — a real, if narrow, correctness gap in the original version. - Tighten PetsCarousel's empty-pets handling: narrow `pets[0]` once via an early return instead of repeated optional chaining. --- app/_components/AuthBrandPanel.tsx | 47 +++++++++++--- app/dashboard/[repoId]/page.tsx | 4 +- app/dashboard/_components/Nav.tsx | 2 +- .../_components/PetDetailSection.tsx | 14 ++--- app/dashboard/_components/PetsCarousel.tsx | 62 ++++++++++++------- app/dashboard/_components/useCenteredCard.ts | 35 ++++++++--- app/dashboard/page.tsx | 21 +++++-- app/sign-in/[[...sign-in]]/page.tsx | 23 ++----- app/sign-up/[[...sign-up]]/page.tsx | 23 ++----- lib/ui/motion.ts | 27 ++++++-- 10 files changed, 160 insertions(+), 98 deletions(-) diff --git a/app/_components/AuthBrandPanel.tsx b/app/_components/AuthBrandPanel.tsx index 71d48da..05f5167 100644 --- a/app/_components/AuthBrandPanel.tsx +++ b/app/_components/AuthBrandPanel.tsx @@ -1,4 +1,4 @@ -import { Fragment } from "react"; +import { Fragment, type ReactNode } from "react"; import type { Stage } from "@/lib/pets/growth"; import { PetArt } from "@/app/dashboard/_components/PetArt"; import { fadeUp } from "@/lib/ui/motion"; @@ -82,6 +82,8 @@ function StageStrip({ // page for signed-out visitors, since "/" redirects straight here instead of // showing separate marketing content. export function AuthBrandPanel() { + const headline = fadeUp(80); + return (
-
+
Commit Pet
-
+

A pet that grows as you commit.

@@ -107,7 +112,7 @@ export function AuthBrandPanel() {

-
+
@@ -119,6 +124,9 @@ export function AuthBrandPanel() { // instead of just a bare logo. Same brand moment as AuthBrandPanel (headline, // subtext, growth-stage strip) at mobile scale. export function MobileAuthHero() { + const headline = fadeUp(80); + const stageStrip = fadeUp(160); + return (
-
+
Commit Pet @@ -134,7 +142,8 @@ export function MobileAuthHero() {

A pet that grows as you commit. @@ -145,9 +154,33 @@ export function MobileAuthHero() {

-
+
); } + +// Shared page shell for /sign-in and /sign-up — desktop split panel plus +// mobile hero, with the Clerk widget (SignIn or SignUp) as children. Was +// duplicated verbatim in both page files; this is the one copy. +export function AuthShell({ children }: { children: ReactNode }) { + const widget = fadeUp(200); + + return ( +
+ +
+ +
+
+ {children} +
+
+
+
+ ); +} diff --git a/app/dashboard/[repoId]/page.tsx b/app/dashboard/[repoId]/page.tsx index 8558e05..78fee09 100644 --- a/app/dashboard/[repoId]/page.tsx +++ b/app/dashboard/[repoId]/page.tsx @@ -43,7 +43,9 @@ export default async function PetDetailPage({ return (
-
+
-
+
-
+
{pet.phase === "development" ? ( ) : ( @@ -35,20 +35,20 @@ export function PetDetailSection({
-
+
-
+
-
+
-
+
diff --git a/app/dashboard/_components/PetsCarousel.tsx b/app/dashboard/_components/PetsCarousel.tsx index 4cd7a50..448d199 100644 --- a/app/dashboard/_components/PetsCarousel.tsx +++ b/app/dashboard/_components/PetsCarousel.tsx @@ -19,10 +19,18 @@ export function PetsCarousel({ pets: DashboardPet[]; tokenStatuses: Record; }) { + const [firstPet] = pets; const { centeredId, containerRef, register } = useCenteredCard( - pets[0]?.repoId, + firstPet?.repoId, ); - const selected = pets.find((p) => p.repoId === centeredId) ?? pets[0]; + + // The dashboard only renders this component once it already knows there's + // at least one pet (see PetsSection in app/dashboard/page.tsx) — this is + // just making that invariant explicit instead of letting `selected` below + // silently resolve to undefined for an empty list. + if (!firstPet) return null; + + const selected = pets.find((p) => p.repoId === centeredId) ?? firstPet; const tokenStatus = tokenStatuses[selected.repoId] ?? { exists: false, lastUsedRelative: null, @@ -34,28 +42,34 @@ export function PetsCarousel({ ref={containerRef} className="-mx-6 flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth px-6 pb-2 sm:-mx-12 sm:px-12" > - {pets.map((pet, i) => ( - - ))} + {pets.map((pet, i) => { + const isSelected = pet.repoId === selected.repoId; + const entrance = fadeUp(Math.min(i, 6) * 40); + return ( + + ); + })}
(null); const elements = useRef(new Map()); + // Latest known ratio per element, persisted across callbacks — a callback + // only reports entries whose ratio crossed a threshold since last time, so + // an element that's been sitting at ratio 1 throughout a scroll (nothing + // changed for it) wouldn't otherwise be considered at all. + const ratios = useRef(new Map()); useEffect(() => { const container = containerRef.current; @@ -26,6 +38,11 @@ export function useCenteredCard(initialId: string | undefined) { const observer = new IntersectionObserver( (entries) => { + for (const entry of entries) { + const id = entry.target.getAttribute("data-centered-card-id"); + if (id) ratios.current.set(id, entry.intersectionRatio); + } + if (isInitialBatch) { isInitialBatch = false; return; @@ -34,21 +51,23 @@ export function useCenteredCard(initialId: string | undefined) { // Closest-to-center wins, not highest ratio — with snap-mandatory, // ties at ratio 1 are common (peeking neighbor cards), and center // distance is what "which one is centered" actually means here. + // Recomputed over every registered element's latest known ratio and + // live geometry, not just this callback's entries. const containerRect = container.getBoundingClientRect(); const containerCenter = containerRect.left + containerRect.width / 2; let bestId: string | null = null; let bestDistance = Infinity; - for (const entry of entries) { - if (entry.intersectionRatio < 0.6) continue; - const elementCenter = - entry.boundingClientRect.left + entry.boundingClientRect.width / 2; - const distance = Math.abs(elementCenter - containerCenter); - const id = entry.target.getAttribute("data-centered-card-id"); - if (id && distance < bestDistance) { + elements.current.forEach((el, id) => { + if ((ratios.current.get(id) ?? 0) < 0.6) return; + const rect = el.getBoundingClientRect(); + const distance = Math.abs( + rect.left + rect.width / 2 - containerCenter, + ); + if (distance < bestDistance) { bestId = id; bestDistance = distance; } - } + }); if (bestId) setCenteredId(bestId); }, { root: container, threshold: [0.6, 0.75, 0.9, 1] }, diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 8465864..90849e7 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -15,7 +15,7 @@ export default async function DashboardPage() { return (
-
+

Your pets

@@ -53,12 +53,21 @@ async function PetsSection({ installationIds }: { installationIds: number[] }) { // One extra query per pet — fine at the handful-of-repos scale this // dashboard runs at. Keyed by repoId so PetsCarousel can look up whichever - // pet is currently selected without re-fetching on every swipe. + // pet is currently selected without re-fetching on every swipe. Failures + // are caught per-pet: a DB hiccup on one repo's token lookup shouldn't + // take down the "no token" default for every other pet on the page. const tokenStatusEntries = await Promise.all( - pets.map(async (pet): Promise<[string, McpTokenStatus]> => [ - pet.repoId, - await getMcpTokenStatus(Number(pet.repoId)), - ]), + pets.map(async (pet): Promise<[string, McpTokenStatus]> => { + try { + return [pet.repoId, await getMcpTokenStatus(Number(pet.repoId))]; + } catch (err) { + console.error( + `Failed to load MCP token status for repo ${pet.repoId}`, + err, + ); + return [pet.repoId, { exists: false, lastUsedRelative: null }]; + } + }), ); const tokenStatuses = Object.fromEntries(tokenStatusEntries); diff --git a/app/sign-in/[[...sign-in]]/page.tsx b/app/sign-in/[[...sign-in]]/page.tsx index d86c469..3fa5143 100644 --- a/app/sign-in/[[...sign-in]]/page.tsx +++ b/app/sign-in/[[...sign-in]]/page.tsx @@ -1,26 +1,11 @@ import { SignIn } from "@clerk/nextjs"; -import { - AuthBrandPanel, - MobileAuthHero, -} from "@/app/_components/AuthBrandPanel"; +import { AuthShell } from "@/app/_components/AuthBrandPanel"; import { clerkAppearance } from "@/app/_components/clerk-appearance"; -import { fadeUp } from "@/lib/ui/motion"; export default function SignInPage() { return ( -
- -
- -
-
- -
-
-
-
+ + + ); } diff --git a/app/sign-up/[[...sign-up]]/page.tsx b/app/sign-up/[[...sign-up]]/page.tsx index 1289464..41290fe 100644 --- a/app/sign-up/[[...sign-up]]/page.tsx +++ b/app/sign-up/[[...sign-up]]/page.tsx @@ -1,26 +1,11 @@ import { SignUp } from "@clerk/nextjs"; -import { - AuthBrandPanel, - MobileAuthHero, -} from "@/app/_components/AuthBrandPanel"; +import { AuthShell } from "@/app/_components/AuthBrandPanel"; import { clerkAppearance } from "@/app/_components/clerk-appearance"; -import { fadeUp } from "@/lib/ui/motion"; export default function SignUpPage() { return ( -
- -
- -
-
- -
-
-
-
+ + + ); } diff --git a/lib/ui/motion.ts b/lib/ui/motion.ts index ac136df..e965f25 100644 --- a/lib/ui/motion.ts +++ b/lib/ui/motion.ts @@ -1,9 +1,24 @@ +import type { CSSProperties } from "react"; + // The one entrance animation used across the Dashboard and auth pages for // content that mounts once per page load (cards, sections, page headers). -// Centralized so the 450ms/easing tuning lives in one place instead of being -// copy-pasted into every className string that wants it. -export function fadeUp(delayMs = 0): string { - const base = - "motion-safe:animate-[fade-up_450ms_cubic-bezier(0.16,1,0.3,1)_both]"; - return delayMs > 0 ? `${base} [animation-delay:${delayMs}ms]` : base; +// +// className is a fixed literal string, never interpolated, because Tailwind +// generates CSS by scanning source files for class text at build time — it +// does not execute this function. An arbitrary-value class built from +// `delayMs` (e.g. `` `[animation-delay:${delayMs}ms]` ``) would never appear +// as literal text anywhere Tailwind scans, so it would compile to nothing. +// The delay goes through a real inline style instead, which has no such +// restriction. +const FADE_UP_CLASS = + "motion-safe:animate-[fade-up_450ms_cubic-bezier(0.16,1,0.3,1)_both]"; + +export function fadeUp(delayMs = 0): { + className: string; + style?: CSSProperties; +} { + return { + className: FADE_UP_CLASS, + style: delayMs > 0 ? { animationDelay: `${delayMs}ms` } : undefined, + }; } From dd73173d0bfe639b92b4e5c679f86324bcfd947a Mon Sep 17 00:00:00 2001 From: Omer Beck Date: Fri, 28 Aug 2026 13:20:01 +0300 Subject: [PATCH 4/4] Fix two more review findings, decline one - getDashboardPets now orders by installation date. It had no ORDER BY before, so row order (and therefore which pet the carousel defaults to selecting) wasn't guaranteed stable across requests. Pre-existing gap, but this PR is what gives it a visible behavioral consequence. - McpTokenCard calls router.refresh() after generating a token, so the server-fetched hasToken snapshot doesn't go stale for the rest of the session. Without this, swiping away from a pet and back in the carousel (or navigating away and back on the standalone page) could show "No token generated yet" for a pet that already has one, inviting an accidental revoke-and-replace. - Declined CodeRabbit's suggestion to run the center-distance tie-break on the initial IntersectionObserver batch too: for an even number of simultaneously-visible cards there's no unique closest-to-center answer, which is exactly the instability the initial-batch skip was added to fix earlier in this PR. Documented the reasoning in place. --- app/dashboard/_components/McpTokenCard.tsx | 9 +++++++++ app/dashboard/_components/useCenteredCard.ts | 10 +++++++--- lib/pets/dashboard-data.ts | 9 +++++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/app/dashboard/_components/McpTokenCard.tsx b/app/dashboard/_components/McpTokenCard.tsx index 9f7a675..4cec464 100644 --- a/app/dashboard/_components/McpTokenCard.tsx +++ b/app/dashboard/_components/McpTokenCard.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useRouter } from "next/navigation"; import { regenerateMcpToken } from "../[repoId]/actions"; import { CopyButton } from "./CopyButton"; @@ -16,12 +17,20 @@ export function McpTokenCard({ const [token, setToken] = useState(null); const [pending, setPending] = useState(false); const [error, setError] = useState(false); + const router = useRouter(); async function handleGenerate() { setPending(true); setError(false); try { setToken(await regenerateMcpToken(Number(repoId))); + // hasToken/lastUsedRelative are a server-fetched snapshot (see + // PetsSection in app/dashboard/page.tsx) — without this, they'd stay + // stale for the rest of the session. Doesn't affect what's on screen + // right now (the reveal below reads local `token` state, not the + // prop), but it means swiping away and back in the carousel won't + // show "No token generated yet" for a pet that already has one. + router.refresh(); } catch { setError(true); } finally { diff --git a/app/dashboard/_components/useCenteredCard.ts b/app/dashboard/_components/useCenteredCard.ts index cc774c9..15f8495 100644 --- a/app/dashboard/_components/useCenteredCard.ts +++ b/app/dashboard/_components/useCenteredCard.ts @@ -31,9 +31,13 @@ export function useCenteredCard(initialId: string | undefined) { // observe() itself fires an initial callback with every element at // once — on a wide viewport where nothing needs to scroll, they all tie - // at ratio 1, and picking a "winner" from a tie is unstable. Skip that - // first batch entirely so the initial selection stands until the user - // actually scrolls the strip. + // at ratio 1. Deliberately not running the geometry-based tie-break on + // this batch: for an even number of fully-visible cards, "closest to + // container center" has no unique answer (two cards are equidistant), + // so it would pick one arbitrarily instead of honoring `initialId`. + // Skipping this batch keeps the initial selection stable until the user + // actually scrolls the strip, which is the one thing "closest to + // center" can't be ambiguous about. let isInitialBatch = true; const observer = new IntersectionObserver( diff --git a/lib/pets/dashboard-data.ts b/lib/pets/dashboard-data.ts index 2b6ff64..6003b55 100644 --- a/lib/pets/dashboard-data.ts +++ b/lib/pets/dashboard-data.ts @@ -1,5 +1,5 @@ import { cache } from "react"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, asc, eq, inArray } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { repos, pets } from "@/lib/db/schema"; import { currentHealth } from "./health"; @@ -61,11 +61,16 @@ export async function getDashboardPets( ): Promise { if (installationIds.length === 0) return []; + // Ordered explicitly (not left to whatever order Postgres happens to + // return): the Dashboard carousel treats the first pet as the default + // selection, so an unordered query would make that default nondeterministic + // across otherwise-identical requests. const rows = await db .select(PET_ROW_COLUMNS) .from(repos) .innerJoin(pets, eq(pets.repoId, repos.id)) - .where(inArray(repos.installationId, installationIds)); + .where(inArray(repos.installationId, installationIds)) + .orderBy(asc(repos.createdAt)); return rows.map(toDashboardPet); }