From ba9f59471ac0c654db5b09bb50825774bc7f29e1 Mon Sep 17 00:00:00 2001 From: Pycomet Date: Thu, 6 Aug 2026 12:13:29 +0100 Subject: [PATCH 1/7] chore(qa): add env-gated fixture harness for dashboard design review /dashboard sits behind auth against a hosted Supabase project, so a visual design review cannot reach it without creating an account or minting a session against production data. This renders the real dashboard against in-memory fixtures instead. Everything is gated behind NEXT_PUBLIC_QA_MOCK=1 and inert by default: - src/lib/trpc/qa-mock.ts: terminating tRPC link serving fixtures; ?qa=full|empty selects the scenario - src/lib/trpc/client.ts: swaps in the mock link when the flag is set - src/contexts/AuthContext.tsx: mounts a deterministic user when set - src/app/design-qa: renders DashboardShell against those fixtures - next.config.ts: X-Frame-Options SAMEORIGIN when the flag is set, so breakpoints can be measured in fixed-width iframes Note there is no auth bypass here. The proxy matcher only guards /dashboard/:path* and /auth/:path*, so a route outside those prefixes needs no change to auth at all; src/proxy.ts is untouched. With the flag off the route redirects to /, verified in a browser. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015jSBatKis96LuECidvPYX3 --- next.config.ts | 4 +- src/app/design-qa/layout.tsx | 26 +++ src/app/design-qa/page.tsx | 11 ++ src/contexts/AuthContext.tsx | 15 ++ src/lib/trpc/client.ts | 17 +- src/lib/trpc/qa-mock.ts | 320 +++++++++++++++++++++++++++++++++++ 6 files changed, 386 insertions(+), 7 deletions(-) create mode 100644 src/app/design-qa/layout.tsx create mode 100644 src/app/design-qa/page.tsx create mode 100644 src/lib/trpc/qa-mock.ts diff --git a/next.config.ts b/next.config.ts index 7f88605..90184e1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -21,7 +21,9 @@ const nextConfig: NextConfig = { }, { key: "X-Frame-Options", - value: "DENY", + // QA design-review harness renders the app in same-origin iframes + // to audit fixed breakpoints. Production keeps DENY. + value: process.env.NEXT_PUBLIC_QA_MOCK === "1" ? "SAMEORIGIN" : "DENY", }, ], }, diff --git a/src/app/design-qa/layout.tsx b/src/app/design-qa/layout.tsx new file mode 100644 index 0000000..9ce0adb --- /dev/null +++ b/src/app/design-qa/layout.tsx @@ -0,0 +1,26 @@ +import DashboardLayout from "@/app/dashboard/layout"; + +/** + * Design-QA harness route. + * + * Renders the real dashboard against in-memory fixtures so light mode, empty + * states and breakpoints can be reviewed without a Supabase session. It lives + * outside `/dashboard` on purpose: the proxy matcher only guards + * `/dashboard/:path*` and `/auth/:path*` (src/proxy.ts), so this route needs + * no auth bypass of any kind. + * + * Redirects to the landing page unless NEXT_PUBLIC_QA_MOCK=1, so the harness + * is inert in a normal build. `redirect` rather than `notFound` because a + * build-time-prerendered `notFound()` was still being served with a 200. + */ +// Evaluated per request rather than at build time, so the guard returns a real +// 404 status instead of a statically prerendered not-found page served as 200. +export const dynamic = "force-dynamic"; + +export default function DesignQALayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/design-qa/page.tsx b/src/app/design-qa/page.tsx new file mode 100644 index 0000000..3b00e87 --- /dev/null +++ b/src/app/design-qa/page.tsx @@ -0,0 +1,11 @@ +import { redirect } from "next/navigation"; +import { DashboardShell } from "@/components/dashboard/DashboardShell"; + +// Evaluated per request so the guard cannot be baked out by prerendering. +export const dynamic = "force-dynamic"; + +export default function DesignQAPage() { + if (process.env.NEXT_PUBLIC_QA_MOCK !== "1") redirect("/"); + + return ; +} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index bc66673..2f388d9 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -25,6 +25,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [isLoading, setIsLoading] = useState(true); useEffect(() => { + // QA design-review harness: skip the network round-trip and mount a + // deterministic user so dashboard surfaces render for visual inspection. + if (process.env.NEXT_PUBLIC_QA_MOCK === "1") { + setUser({ + id: "u1", + email: "you@example.com", + app_metadata: {}, + user_metadata: {}, + aud: "authenticated", + created_at: new Date().toISOString(), + } as User); + setIsLoading(false); + return; + } + supabase.auth.getUser().then(({ data: { user } }) => { setUser(user); setIsLoading(false); diff --git a/src/lib/trpc/client.ts b/src/lib/trpc/client.ts index 3c03cc7..4e0415e 100644 --- a/src/lib/trpc/client.ts +++ b/src/lib/trpc/client.ts @@ -5,12 +5,17 @@ import type { AppRouter } from "@/server/trpc/routers/_app"; export const trpc = createTRPCReact(); +// QA design-review harness: swap the network link for in-memory fixtures. +const QA_MOCK = process.env.NEXT_PUBLIC_QA_MOCK === "1"; + export const trpcClient = trpc.createClient({ - links: [ - httpBatchLink({ - url: "/api", - transformer: superjson, - }), - ], + links: QA_MOCK + ? [require("./qa-mock").qaMockLink] + : [ + httpBatchLink({ + url: "/api", + transformer: superjson, + }), + ], }); diff --git a/src/lib/trpc/qa-mock.ts b/src/lib/trpc/qa-mock.ts new file mode 100644 index 0000000..216c005 --- /dev/null +++ b/src/lib/trpc/qa-mock.ts @@ -0,0 +1,320 @@ +/** + * QA-ONLY tRPC mock link. + * + * Renders the real dashboard components with deterministic fixture data so a + * design review can inspect actual pixels without authenticating against the + * hosted Supabase project. Activated only when NEXT_PUBLIC_QA_MOCK === "1". + * + * Scenario is chosen with the `qa` query param: ?qa=full (default) | empty. + */ + +import { observable } from "@trpc/server/observable"; +import type { TRPCLink } from "@trpc/client"; + +function scenario(): string { + if (typeof window === "undefined") return "full"; + return new URLSearchParams(window.location.search).get("qa") || "full"; +} + +// Anchored to the current day so "today's tasks" fixtures never go stale. +const now = (() => { + const d = new Date(); + d.setHours(9, 15, 0, 0); + return d; +})(); + +function at(h: number, m = 0) { + const d = new Date(now); + d.setHours(h, m, 0, 0); + return d; +} + +function daysAgo(n: number) { + const d = new Date(now); + d.setDate(d.getDate() - n); + return d; +} + +const GOALS = [ + { + id: "g1", + userId: "u1", + title: "Ship GrindProof v1 to 100 paying users", + description: "Public launch, billing live, first cohort onboarded.", + status: "active" as const, + priority: "high" as const, + taskTotal: 42, + taskCompleted: 27, + createdAt: daysAgo(60), + updatedAt: daysAgo(1), + }, + { + id: "g2", + userId: "u1", + title: "Run a sub-40 10K", + description: "Base building block, then speed work.", + status: "active" as const, + priority: "medium" as const, + taskTotal: 18, + taskCompleted: 11, + createdAt: daysAgo(45), + updatedAt: daysAgo(2), + }, + { + id: "g3", + userId: "u1", + title: "Read 24 books this year", + description: null, + status: "active" as const, + priority: "low" as const, + taskTotal: 24, + taskCompleted: 9, + createdAt: daysAgo(120), + updatedAt: daysAgo(5), + }, +]; + +const TASKS = [ + { + id: "t1", + userId: "u1", + goalId: "g1", + title: "Fix the goal progress counter on the dashboard", + description: "Counting off a capped page undercounts goals.", + dueDate: at(9), + startTime: at(9), + endTime: at(10, 30), + priority: "high" as const, + status: "completed" as const, + tags: ["eng"], + reflection: null, + recurrencePattern: null, + createdAt: daysAgo(1), + updatedAt: now, + }, + { + id: "t2", + userId: "u1", + goalId: "g1", + title: "Write launch email to the waitlist", + description: null, + dueDate: at(11), + startTime: at(11), + endTime: at(12), + priority: "high" as const, + status: "pending" as const, + tags: ["growth"], + reflection: null, + recurrencePattern: null, + createdAt: daysAgo(1), + updatedAt: daysAgo(1), + }, + { + id: "t3", + userId: "u1", + goalId: "g2", + title: "Easy 8K recovery run", + description: null, + dueDate: at(18), + startTime: at(18), + endTime: at(19), + priority: "medium" as const, + status: "pending" as const, + tags: ["health"], + reflection: null, + recurrencePattern: null, + createdAt: daysAgo(1), + updatedAt: daysAgo(1), + }, + { + id: "t4", + userId: "u1", + goalId: null, + title: "Review the Q3 accountability metrics doc that finance sent over", + description: null, + dueDate: at(15), + startTime: null, + endTime: null, + priority: "low" as const, + status: "pending" as const, + tags: null, + reflection: null, + recurrencePattern: null, + createdAt: daysAgo(2), + updatedAt: daysAgo(2), + }, + { + id: "t5", + userId: "u1", + goalId: "g3", + title: "Read 30 pages of Meditations", + description: null, + dueDate: at(21), + startTime: null, + endTime: null, + priority: "low" as const, + status: "skipped" as const, + tags: null, + reflection: "Fell asleep.", + recurrencePattern: null, + createdAt: daysAgo(1), + updatedAt: daysAgo(1), + }, +]; + +const TREND = Array.from({ length: 14 }, (_, i) => { + const d = daysAgo(13 - i); + const iso = d.toISOString().slice(0, 10); + const scores = [41, 44, 40, 52, 58, 55, 61, 64, 60, 66, 71, 68, 72, 68]; + return { + date: iso, + score: scores[i], + completed: i === 13 ? 1 : 2, + total: i === 13 ? 3 : 3, + active: i !== 2 && i !== 8, + }; +}); + +function fullFixtures(): Record { + return { + "profile.getSetupState": { setupState: "completed" }, + "profile.getCurrent": { + id: "u1", + email: "you@example.com", + timezone: "Africa/Lagos", + displayName: "Alfred", + createdAt: daysAgo(60), + }, + "retention.getReentryState": { + shouldShowReentry: false, + isReturningFromBadWeek: false, + daysSinceLastActive: 0, + }, + "accountabilityScore.getScore": { + score: 68, + tier: { name: "Grinding", color: "amber" }, + currentStreak: 6, + streak: 6, + delta: -4, + today: { completed: 1, total: 3 }, + drivers: { + top: "6-day streak and a 74% completion rate on high-priority tasks", + drag: "three evening tasks skipped in the last week", + }, + localDate: now.toISOString().slice(0, 10), + active: true, + timezone: "Africa/Lagos", + }, + "accountabilityScore.getScoreTrend": { trend: TREND, currentStreak: 6 }, + "accountabilityScore.getActivityHeatmap": { + heatmap: TREND.map((t) => ({ date: t.date, value: t.active ? 0.7 : 0 })), + }, + "accountabilityScore.getRecentEvents": { events: [] }, + "weeklyRoast.getLatest": { + id: "r1", + createdAt: daysAgo(2), + roastData: { + weekSummary: + "You planned fourteen things and finished eight. Every single one you dropped was scheduled after 6pm, which means your evenings are where your plans go to die.", + insights: [ + { + text: "High-priority work shipped at 86% — your mornings are working.", + severity: "positive", + }, + { + text: "Every skipped task this week was scheduled after 6pm.", + severity: "high", + }, + { + text: "You rescheduled 'Read 30 pages' four times.", + severity: "medium", + }, + ], + recommendations: [ + "Move the reading block to 7am, before the day gets a vote.", + "Cap evening commitments at one task until you close two full weeks.", + ], + }, + taskStats: { completionRate: 57, completed: 8, total: 14, skipped: 3 }, + }, + "dailyCheck.getMorningSchedule": { + alreadySubmitted: false, + yesterdayIncomplete: [ + { id: "y1", title: "Draft the pricing page copy", priority: "high" }, + { id: "y2", title: "Read 30 pages of Meditations", priority: "low" }, + ], + todayTasks: TASKS.filter((t) => t.status !== "skipped").map((t) => ({ + id: t.id, + title: t.title, + startTime: t.startTime, + priority: t.priority, + })), + }, + "dailyCheck.getEveningSchedule": { + alreadySubmitted: false, + todayTasks: TASKS.map((t) => ({ + id: t.id, + title: t.title, + status: t.status, + priority: t.priority, + })), + }, + "task.getAll": TASKS, + "goal.getAll": GOALS, + "notification.getSettings": { + pushEnabled: true, + morningTime: "07:00", + eveningTime: "21:00", + }, + "mcpToken.list": [], + }; +} + +function emptyFixtures(): Record { + const full = fullFixtures(); + return { + ...full, + "accountabilityScore.getScore": { + score: 0, + tier: { name: "Slacking", color: "red" }, + currentStreak: 0, + streak: 0, + delta: 0, + today: { completed: 0, total: 0 }, + drivers: { top: "Nothing yet", drag: null }, + localDate: now.toISOString().slice(0, 10), + active: false, + timezone: "Africa/Lagos", + }, + "accountabilityScore.getScoreTrend": { + trend: TREND.map((t) => ({ ...t, score: 0, active: false })), + currentStreak: 0, + }, + "weeklyRoast.getLatest": null, + "task.getAll": [], + "goal.getAll": [], + "dailyCheck.getMorningSchedule": { + alreadySubmitted: false, + yesterdayIncomplete: [], + todayTasks: [], + }, + }; +} + +function resolve(path: string): unknown { + const table = scenario() === "empty" ? emptyFixtures() : fullFixtures(); + if (path in table) return table[path]; + // Unknown query: return null so components fall through to empty states + // instead of hanging in a permanent skeleton. + return null; +} + +export const qaMockLink: TRPCLink = + () => + ({ op }) => + observable((observer) => { + // Mutations resolve to null; this harness is for visual review only. + const data = op.type === "query" ? resolve(op.path) : null; + observer.next({ result: { type: "data", data } }); + observer.complete(); + }); From 25db41851b6785a3d4faa308e4f6ccc0f7491915 Mon Sep 17 00:00:00 2001 From: Pycomet Date: Thu, 6 Aug 2026 12:13:37 +0100 Subject: [PATCH 2/7] fix(dashboard): align header and content to a shared page width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header wrapped its contents in max-w-xl (576px) while the body below used max-w-7xl (1280px). The two centred independently, so nothing lined up. Measured in the DOM at a 1440px viewport, before: header content 425 -> 1001 main column 89 -> 665 chat sidebar 689 -> 1069 page container 73 -> 1353 The logo sat 352px right of the first card, and 284px of container was dead space on the right — the header drifting one way while the content drifted the other. After: both offsets measure 0. The surface moves into DashboardShell so /dashboard and the design-QA route render one definition, and the width is declared once as a constant used by both the header and the content wrapper. The bug class here was two places independently declaring the page width, so the fix is to have only one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015jSBatKis96LuECidvPYX3 --- src/app/dashboard/page.tsx | 82 +---------------- src/components/dashboard/DashboardShell.tsx | 97 +++++++++++++++++++++ 2 files changed, 100 insertions(+), 79 deletions(-) create mode 100644 src/components/dashboard/DashboardShell.tsx diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 01e5354..602fd56 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,65 +1,9 @@ "use client"; -import Link from "next/link"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { Settings } from "lucide-react"; -import { Logo } from "@/components/Logo"; import { useAuth } from "@/contexts/AuthContext"; -import { TaskList } from "@/components/TaskList"; -import { GoalList } from "@/components/GoalList"; - -import { MorningCheckIn } from "@/components/MorningCheckIn"; -import { EveningCheckIn } from "@/components/EveningCheckIn"; -import { WeeklyRoastCard } from "@/components/WeeklyRoastCard"; -import { StoicQuote } from "@/components/StoicQuote"; -import { ChatPanel } from "@/components/ChatPanel"; -import { AccountabilityWidget } from "@/components/AccountabilityWidget"; -import { Day1Orientation } from "@/components/Day1Orientation"; -import { ReentryBanner } from "@/components/ReentryBanner"; -import { SetupChecklistCard } from "@/components/setup/SetupChecklistCard"; -import { useEffect } from "react"; - -function getGreeting(): string { - const hour = new Date().getHours(); - if (hour < 12) return "Good morning"; - if (hour < 17) return "Good afternoon"; - return "Good evening"; -} - -function shouldShowMorning(): boolean { - const hour = new Date().getHours(); - return hour < 11; -} - -function shouldShowEvening(): boolean { - const hour = new Date().getHours(); - return hour >= 17; -} - -function DashboardContent() { - return ( -
-
- - - - - - - - {shouldShowMorning() && } - {shouldShowEvening() && } - - - - -
- -
- ); -} +import { DashboardShell } from "@/components/dashboard/DashboardShell"; export default function Dashboard() { const router = useRouter(); @@ -81,25 +25,5 @@ export default function Dashboard() { if (!user) return null; - return ( -
-
-
- -
- {getGreeting()} - - - -
-
-
- - -
- ); + return ; } diff --git a/src/components/dashboard/DashboardShell.tsx b/src/components/dashboard/DashboardShell.tsx new file mode 100644 index 0000000..c72f894 --- /dev/null +++ b/src/components/dashboard/DashboardShell.tsx @@ -0,0 +1,97 @@ +"use client"; + +import Link from "next/link"; +import { Settings } from "lucide-react"; +import { Logo } from "@/components/Logo"; +import { TaskList } from "@/components/TaskList"; +import { GoalList } from "@/components/GoalList"; +import { MorningCheckIn } from "@/components/MorningCheckIn"; +import { EveningCheckIn } from "@/components/EveningCheckIn"; +import { WeeklyRoastCard } from "@/components/WeeklyRoastCard"; +import { StoicQuote } from "@/components/StoicQuote"; +import { ChatPanel } from "@/components/ChatPanel"; +import { AccountabilityWidget } from "@/components/AccountabilityWidget"; +import { Day1Orientation } from "@/components/Day1Orientation"; +import { ReentryBanner } from "@/components/ReentryBanner"; +import { SetupChecklistCard } from "@/components/setup/SetupChecklistCard"; + +/** + * One page width, declared once. + * + * The header and the content column previously set their own max-widths + * (`max-w-xl` vs `max-w-7xl`) and centred independently, which put the logo + * 352px right of the first card and stranded 284px of empty container on the + * right. Both boxes share this constant so they cannot drift apart again. + * + * 576 (main) + 24 (lg:gap-6) + 380 (aside) + 32 (px-4 both sides) = 1012 + */ +const SHELL = "mx-auto w-full max-w-[1012px] px-4"; + +function getGreeting(): string { + const hour = new Date().getHours(); + if (hour < 12) return "Good morning"; + if (hour < 17) return "Good afternoon"; + return "Good evening"; +} + +function shouldShowMorning(): boolean { + return new Date().getHours() < 11; +} + +function shouldShowEvening(): boolean { + return new Date().getHours() >= 17; +} + +function DashboardContent() { + return ( +
+
+ + + + + + + + {shouldShowMorning() && } + {shouldShowEvening() && } + + + + +
+ +
+ ); +} + +/** + * The dashboard surface itself, with no auth gate. `/dashboard` wraps this in + * its session check; the design-QA harness renders it against fixtures. Both + * share this one definition so the surface can't drift between them. + */ +export function DashboardShell() { + return ( +
+
+
+ +
+ {getGreeting()} + + + +
+
+
+ + +
+ ); +} From aa5354ca50b4676013668d66c633ef96ab8ff397 Mon Sep 17 00:00:00 2001 From: Pycomet Date: Thu, 6 Aug 2026 12:13:51 +0100 Subject: [PATCH 3/7] fix(theme): restore light-mode page/card elevation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --gp-bg and --gp-surface were both #ffffff, so the page and every card rendered the same colour and surfaces were distinguishable only by a 1px zinc-200 hairline. The light theme read as one flat sheet. design-system-foundation §1.4 specifies page = zinc-50, card = white. The card token was already correct; only the page token was wrong. This is user-reachable, not theoretical — settings ships a theme toggle. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015jSBatKis96LuECidvPYX3 --- src/app/globals.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/globals.css b/src/app/globals.css index a31118d..8a18abd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -204,7 +204,10 @@ :root[data-theme="light"] { color-scheme: light; - --gp-bg: #ffffff; + /* Page sits one step below cards so surfaces read as raised. When both were + #ffffff the whole theme was one flat sheet separated only by hairlines. + Per design-system-foundation §1.4: page = zinc-50, card = white. */ + --gp-bg: var(--gp-zinc-50); --gp-surface: #ffffff; --gp-surface-2: var(--gp-zinc-100); --gp-surface-mute: var(--gp-zinc-100); From 38863c0ff9fcc02a8e36d15c7faf52274ae02013 Mon Sep 17 00:00:00 2001 From: Pycomet Date: Thu, 6 Aug 2026 12:13:51 +0100 Subject: [PATCH 4/7] fix(accountability): colour progress by state instead of always green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today's progress hardcoded text-green-400 regardless of value, so a brand new user saw "0/0 done" in success green sitting beside a red score ring and a red SLACKING label — three contradictory signals in one card. A user at 1/3 got the same green. For a product built on adversarial honesty, green has to be earned. Implements the rules already specified in design-system-phase-3: - §2.4 progress colour: zero is neutral, partial is warming, only a full sweep is locked-green. Same rule applied to the weekly roast's completion rate, which had the identical problem. - §2.2 bad-week copy: a drop of 20+ now reads "Last week was a write-off. New week starts now." instead of quoting a bare red number back at someone. A small dip is still a number. - §2.3 zero streak: the numeral drops to muted rather than tier-red, and the caption becomes "Start one today". A zero streak is the absence of a result, not a result worth colouring. Also moves the delta and severity colours onto the success/error/tier tokens per §1, and pads the widget to p-5 as a featured card per foundation §3. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015jSBatKis96LuECidvPYX3 --- src/components/AccountabilityWidget.tsx | 82 ++++++++++++++++--------- src/components/StreakFlame.tsx | 7 ++- src/components/WeeklyRoastCard.tsx | 20 ++++-- 3 files changed, 76 insertions(+), 33 deletions(-) diff --git a/src/components/AccountabilityWidget.tsx b/src/components/AccountabilityWidget.tsx index 4646c24..c8cdf55 100644 --- a/src/components/AccountabilityWidget.tsx +++ b/src/components/AccountabilityWidget.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { Share2 } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import { trpc } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; import { AccountabilityScoreRing } from "./AccountabilityScoreRing"; import { StreakBreakBanner } from "./StreakBreakBanner"; import { StreakFlame } from "./StreakFlame"; @@ -118,8 +119,8 @@ export function AccountabilityWidget() { /> )} -
-
+
+
@@ -130,45 +131,70 @@ export function AccountabilityWidget() {
{delta > 0 && ( - + +{delta} from last week )} - {delta < 0 && ( - + {/* A small dip is a number. A collapse is a sentence — quoting + "-38 from last week" back at someone is punishment, not + accountability. Per design-system-phase-3 §2.2. */} + {delta < 0 && delta > -20 && ( + {delta} from last week )} + {delta <= -20 && ( + Last week was a write-off. New week starts now. + )} {delta === 0 && No change from last week}
-
- -
day streak
-
- -
-
Today
-
- {today.completed}/{today.total} done + {/* Below `sm` this wrapper is a single flex item, so the streak and + today blocks drop to a second line together instead of each + squeezing to two-line wraps. At `sm` and up `contents` dissolves + it and the original three-column row returns unchanged. */} +
+
+ +
+ {currentStreak === 0 ? "Start one today" : "day streak"} +
- - View stats → - - {currentStreak > 0 && ( - - )} + {today.completed}/{today.total} done +
+ + View stats → + + {currentStreak > 0 && ( + + )} +
diff --git a/src/components/StreakFlame.tsx b/src/components/StreakFlame.tsx index f11587c..8c18beb 100644 --- a/src/components/StreakFlame.tsx +++ b/src/components/StreakFlame.tsx @@ -282,7 +282,12 @@ const TIER_TEXT_COLORS: Record = { export function StreakFlame({ streak, color, size = 40 }: Props) { const FlameComponent = FLAME_COMPONENTS[color] ?? NuclearFlame; - const textColor = TIER_TEXT_COLORS[color] ?? "text-tier-proven"; + // A zero streak is the absence of a result, not a result worth colouring. + // Tier colour is earned from the first day on. Per phase-3 §2.3. + const textColor = + streak === 0 + ? "text-muted-foreground" + : (TIER_TEXT_COLORS[color] ?? "text-tier-proven"); return (
diff --git a/src/components/WeeklyRoastCard.tsx b/src/components/WeeklyRoastCard.tsx index ba3c296..4c0b491 100644 --- a/src/components/WeeklyRoastCard.tsx +++ b/src/components/WeeklyRoastCard.tsx @@ -3,11 +3,12 @@ import { useState } from "react"; import { useAuth } from "@/contexts/AuthContext"; import { trpc } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; import { Check, AlertTriangle, Info, type LucideIcon } from "lucide-react"; const SEVERITY_ICON: Record = { - positive: { Icon: Check, color: "text-green-400" }, - high: { Icon: AlertTriangle, color: "text-red-400" }, + positive: { Icon: Check, color: "text-success" }, + high: { Icon: AlertTriangle, color: "text-error" }, medium: { Icon: Info, color: "text-muted-foreground" }, }; @@ -21,7 +22,7 @@ export function WeeklyRoastCard() { if (isLoading) { return ( -
+
); } if (!user || dismissed) return null; @@ -49,7 +50,18 @@ export function WeeklyRoastCard() { {/* Stats bar */} {taskStats && (
- + {/* Same rule as the accountability widget: green is earned, not the + default. A 57% week is not a success colour. */} + {taskStats.completionRate}% done From e9517809d5d40b7dcfa4c110eaa44032cb259770 Mon Sep 17 00:00:00 2001 From: Pycomet Date: Thu, 6 Aug 2026 12:14:06 +0100 Subject: [PATCH 5/7] feat(chat): context-aware empty state with suggestion chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coach panel greeted an empty conversation with one generic sentence, which on desktop left the largest uninterrupted region of the dashboard (~700px) doing nothing. On a product whose differentiator is the AI coach, the coach's own panel was the emptiest thing on screen. Implements design-system-phase-3 §6.1: prompts are built from the user's real score, tier, streak, today's progress and goal count, then rendered as tappable chips. The branch set is deliberately exhaustive rather than edge-case only — a steady mid-week user gets "1/3 done today — what do I take next?" rather than falling through to the generic prompts. Fallbacks remain so the panel always has something to press, including before data loads. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015jSBatKis96LuECidvPYX3 --- src/components/ChatPanel.tsx | 88 ++++++++++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 8 deletions(-) diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 6f84edc..9694b1b 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -3,9 +3,88 @@ import { useRef, useEffect } from "react"; import { useAuth } from "@/contexts/AuthContext"; import { useChatContext } from "@/contexts/ChatContext"; +import { useTaskContext } from "@/contexts/TaskContext"; +import { trpc } from "@/lib/trpc/client"; import { AnimatePresence, motion } from "framer-motion"; import { MessageCircle, X, Info } from "lucide-react"; +/** + * Opening move for the coach panel. + * + * The panel used to greet an empty conversation with one generic sentence, + * which on desktop left the largest region of the dashboard doing nothing. + * These prompts are built from the user's actual score, streak, today's + * progress and goal count, so the first thing the coach says already knows + * what kind of week this is. Per design-system-phase-3 §6.1. + */ +function ChatEmptyState({ + onPick, +}: { + onPick: (prompt: string) => void; +}) { + const { data } = trpc.accountabilityScore.getScore.useQuery(); + const { goals } = useTaskContext(); + + const prompts: string[] = []; + + if (data) { + const { score, tier, currentStreak, delta, today } = data; + const openGoals = goals.filter((g) => g.status === "active"); + const { completed, total } = today; + + // Most specific signal first, so an unusual week leads the conversation. + // Every branch below is reachable — a steady mid-week user still gets a + // prompt built from their own numbers rather than the generic fallbacks. + if (delta <= -20) { + prompts.push("What happened last week?"); + } + if (tier?.name === "Slacking") { + prompts.push(`Score ${score} — what's actually getting in the way?`); + } + if (total === 0) { + prompts.push("Nothing planned today. What's worth committing to?"); + } else if (completed === 0) { + prompts.push(`0/${total} done today. What should I ship first?`); + } else if (completed === total) { + prompts.push("Everything's done. What should tomorrow look like?"); + } else { + prompts.push(`${completed}/${total} done today — what do I take next?`); + } + if (openGoals.length >= 5) { + prompts.push(`Help me cut a goal — ${openGoals.length} is too many.`); + } + if (currentStreak >= 3) { + prompts.push(`${currentStreak} days in. How do I not break it?`); + } + } + + // Always leave the user something to press, including on first load. + for (const fallback of [ + "What should I focus on today?", + "Review my plan with me.", + ]) { + if (prompts.length >= 3) break; + prompts.push(fallback); + } + + return ( +
+

+ {data ? "I've seen your week. Pick one:" : "Pick one to start:"} +

+ {prompts.slice(0, 3).map((prompt) => ( + + ))} +
+ ); +} + export function ChatPanel({ docked = false }: { docked?: boolean }) { const { user } = useAuth(); const { messages, sendMessage, status, isOpen, setIsOpen, input, setInput } = @@ -22,14 +101,7 @@ export function ChatPanel({ docked = false }: { docked?: boolean }) { const renderMessages = () => ( <> - {messages.length === 0 && ( -
-

- I'm your accountability coach. Ask me anything, or tell me - what you're working on. -

-
- )} + {messages.length === 0 && } {messages.map((message) => { const text = message.parts?.find((p) => p.type === "text")?.text ?? ""; From 88e826ecefb9961f8981021ee12a2ed29353b25b Mon Sep 17 00:00:00 2001 From: Pycomet Date: Thu, 6 Aug 2026 12:14:06 +0100 Subject: [PATCH 6/7] fix(dashboard): focus rings, priority encoding, and quote chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focus states: ui/button and ui/input both carry focus-visible treatments, but hand-rolled buttons bypassed them — including the task complete/skip checkbox, which is the app's core action. A keyboard user could not see what was focused. The shared ring pattern is now applied to the checkbox, the per-task menu trigger, and the evening check-in's Done/Skipped, and the menu trigger gains an aria-label. Done vs Skipped were pixel-identical until clicked. Each now previews its own outcome on hover and focus, so the choice reads before it is made, without weighting one answer above the other. Task rows stated priority twice — a colour strip and a colour-coded badge. The badge is gone; the strip carries it, with an sr-only label so priority is not conveyed by colour alone. The due date is gone too: the Today view is by definition today, and the Week view already groups rows under day headers, so it was five identical stamps carrying no information. StoicQuote picks up the rest of phase-3 §3.2 — border-l-2 and the "Today's Reminder" wording. Its amber gradient wash is removed: it was decorative depth the surface language (foundation §5) has no slot for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015jSBatKis96LuECidvPYX3 --- src/components/EveningCheckIn.tsx | 16 +++++++----- src/components/StoicQuote.tsx | 25 +++++++++---------- src/components/TaskItem.tsx | 41 ++++++++++++------------------- 3 files changed, 37 insertions(+), 45 deletions(-) diff --git a/src/components/EveningCheckIn.tsx b/src/components/EveningCheckIn.tsx index 9d9ddbc..89824b0 100644 --- a/src/components/EveningCheckIn.tsx +++ b/src/components/EveningCheckIn.tsx @@ -186,15 +186,19 @@ export function EveningCheckIn() {
+ {/* Two opposite answers used to be pixel-identical until + clicked. Each now previews its own outcome on hover + and focus, so the choice reads before it is made — + without weighting one above the other. */} From eda60eea561ba5df1c3e3d65d165da089bbcbbef Mon Sep 17 00:00:00 2001 From: Pycomet Date: Fri, 7 Aug 2026 08:16:41 +0100 Subject: [PATCH 7/7] fix(theme): make the dark variant track the tokens, not the OS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The semantic tokens are dark-first at :root, but Tailwind's dark variant was gated on prefers-color-scheme. Those two disagreed for anyone on an OS set to light who had not picked a theme: the page rendered dark from the tokens while every `dark:` utility stayed switched off. The wordmark resolved to zinc-900 on a zinc-950 background and disappeared. Verified with the OS in light mode and no stored theme: before page rgb(9,9,11) logo oklch(0.21…) invisible after page rgb(9,9,11) logo oklch(0.985…) visible The variant now keys off "not explicitly light" rather than "OS is dark", which is what dark-first means. Two follow-ons: - The light opt-in has three spellings (.light, .gp-allow-light, [data-theme="light"]) and only .light was excluded before, so the other two would have rendered light tokens with dark utilities on an OS-dark machine. All three now disable the variant. - :root gains color-scheme: dark so scrollbars, form controls and autofill match the dark surface by default. .light still overrides it. Marketing and auth are unaffected — they force .marketing-dark, which the first branch of the variant already matched. Checked across all five theme states (unclassed, .dark, and each of the three light spellings) plus a marketing route: light mode is unchanged and the dashboard alignment and progress-colour fixes still measure as before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015jSBatKis96LuECidvPYX3 --- src/app/globals.css | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 8a18abd..c9b353f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -43,6 +43,11 @@ --gp-success: #22c55e; /* Semantic — DARK-FIRST (canonical experience) */ + /* Declared here too, so scrollbars, form controls and autofill match the + dark surface by default. Without it the unclassed default rendered dark + tokens with light native chrome. `.light` overrides this below. */ + color-scheme: dark; + --gp-bg: var(--gp-zinc-950); --gp-surface: var(--gp-zinc-900); --gp-surface-2: var(--gp-zinc-800); @@ -362,16 +367,22 @@ --duration-slow: var(--motion-slow); } -/* Override Tailwind's dark variant: respond to .dark / .marketing-dark - classes (forced) AND OS preference when no explicit class is set. */ +/* Override Tailwind's dark variant so it tracks the tokens rather than the OS. + The semantic tokens above are dark-first: absent an explicit light opt-in the + page IS dark, whatever the OS says. Gating `dark:` on + prefers-color-scheme meant that a visitor on an OS set to light, who had + chosen no theme, got a dark page rendered with every `dark:` utility switched + off — the wordmark resolved to zinc-900 on a zinc-950 background and + disappeared. So the condition is "not explicitly light", not "OS is dark". + + The light opt-in has three spellings (see the light block above); all three + have to turn the variant off, not just `.light`. */ @variant dark { &:where(.dark, .dark *, .marketing-dark, .marketing-dark *) { @slot; } - @media (prefers-color-scheme: dark) { - html:not(.light):not(.dark) & { - @slot; - } + html:not(.dark):not(.light):not(.gp-allow-light):not([data-theme="light"]) & { + @slot; } }