diff --git a/app/_components/AuthBrandPanel.tsx b/app/_components/AuthBrandPanel.tsx new file mode 100644 index 0000000..05f5167 --- /dev/null +++ b/app/_components/AuthBrandPanel.tsx @@ -0,0 +1,186 @@ +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"; +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() { + const headline = fadeUp(80); + + 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() { + const headline = fadeUp(80); + const stageStrip = fadeUp(160); + + return ( +
+ + ); +} + +// 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 96e038d..78fee09 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,9 @@ 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..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 { @@ -38,7 +47,7 @@ export function McpTokenCard({

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

- +
) : ( <>

@@ -66,7 +75,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..7a4e881 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..15f8495 --- /dev/null +++ b/app/dashboard/_components/useCenteredCard.ts @@ -0,0 +1,92 @@ +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. +// +// Assumes the registered set of elements is fixed for the component's +// lifetime (observe() is only called once, for whatever's registered by the +// time the effect runs) — true for PetsCarousel's pets, which are fetched +// once server-side and never added to or removed from after mount. A caller +// with a genuinely dynamic list would need this to observe/unobserve as +// elements are registered, not just once. +export function useCenteredCard(initialId: string | undefined) { + const [centeredId, setCenteredId] = useState(initialId); + const containerRef = useRef(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; + 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. 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( + (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; + } + + // 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; + 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] }, + ); + + 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..90849e7 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,13 @@ 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 +42,7 @@ async function PetsGrid({ installationIds }: { installationIds: number[] }) { No pets yet —{" "}
install Commit Pet {" "} @@ -49,13 +51,27 @@ 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. 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]> => { + 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); + + return ; } function ConnectGithubPrompt() { @@ -64,7 +80,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..3fa5143 100644 --- a/app/sign-in/[[...sign-in]]/page.tsx +++ b/app/sign-in/[[...sign-in]]/page.tsx @@ -1,15 +1,11 @@ import { SignIn } from "@clerk/nextjs"; -import { Logo } from "@/app/_components/Logo"; +import { AuthShell } from "@/app/_components/AuthBrandPanel"; import { clerkAppearance } from "@/app/_components/clerk-appearance"; 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..41290fe 100644 --- a/app/sign-up/[[...sign-up]]/page.tsx +++ b/app/sign-up/[[...sign-up]]/page.tsx @@ -1,15 +1,11 @@ import { SignUp } from "@clerk/nextjs"; -import { Logo } from "@/app/_components/Logo"; +import { AuthShell } from "@/app/_components/AuthBrandPanel"; import { clerkAppearance } from "@/app/_components/clerk-appearance"; export default function SignUpPage() { return ( -
-
- - Commit Pet -
+ -
+ ); } diff --git a/lib/mcp/tokens.ts b/lib/mcp/tokens.ts index 0d3fbf3..6162425 100644 --- a/lib/mcp/tokens.ts +++ b/lib/mcp/tokens.ts @@ -69,9 +69,14 @@ 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/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); } 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..e965f25 --- /dev/null +++ b/lib/ui/motion.ts @@ -0,0 +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). +// +// 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, + }; +}