Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
},
Expand Down
82 changes: 3 additions & 79 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-7xl lg:flex lg:gap-6 px-4 py-6 pb-20 lg:pb-6">
<main className="flex-1 lg:max-w-xl space-y-3">
<SetupChecklistCard />
<Day1Orientation />
<ReentryBanner />
<AccountabilityWidget />
<StoicQuote />
<WeeklyRoastCard />

{shouldShowMorning() && <MorningCheckIn />}
{shouldShowEvening() && <EveningCheckIn />}

<TaskList />

<GoalList />
</main>
<aside className="hidden lg:block lg:w-[380px] lg:sticky lg:top-6 lg:h-[calc(100vh-3rem)]">
<ChatPanel docked />
</aside>
</div>
);
}
import { DashboardShell } from "@/components/dashboard/DashboardShell";

export default function Dashboard() {
const router = useRouter();
Expand All @@ -81,25 +25,5 @@ export default function Dashboard() {

if (!user) return null;

return (
<div className="min-h-screen bg-background">
<header className="border-b border-border bg-card">
<div className="mx-auto flex max-w-xl items-center justify-between px-4 py-4">
<Logo size="md" href="/" />
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">{getGreeting()}</span>
<Link
href="/dashboard/settings"
aria-label="Settings"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<Settings className="h-5 w-5" />
</Link>
</div>
</div>
</header>

<DashboardContent />
</div>
);
return <DashboardShell />;
}
26 changes: 26 additions & 0 deletions src/app/design-qa/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 <DashboardLayout>{children}</DashboardLayout>;
}
11 changes: 11 additions & 0 deletions src/app/design-qa/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <DashboardShell />;
}
28 changes: 21 additions & 7 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -204,7 +209,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);
Expand Down Expand Up @@ -359,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;
}
}

Expand Down
82 changes: 54 additions & 28 deletions src/components/AccountabilityWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -118,8 +119,8 @@ export function AccountabilityWidget() {
/>
)}

<div className="rounded-md border border-border bg-card p-4 text-foreground">
<div className="flex items-center justify-between gap-3">
<div className="rounded-md border border-border bg-card p-5 text-foreground">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<AccountabilityScoreRing score={score} color={tier.color} />
<div className="min-w-0">
Expand All @@ -130,45 +131,70 @@ export function AccountabilityWidget() {
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{delta > 0 && (
<span className="gp-num text-green-400">
<span className="gp-num text-success">
+{delta} from last week
</span>
)}
{delta < 0 && (
<span className="gp-num text-red-400">
{/* 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 && (
<span className="gp-num text-tier-warming">
{delta} from last week
</span>
)}
{delta <= -20 && (
<span>Last week was a write-off. New week starts now.</span>
)}
{delta === 0 && <span>No change from last week</span>}
</div>
</div>
</div>

<div className="flex flex-col items-center">
<StreakFlame streak={currentStreak} color={tier.color} size={44} />
<div className="mt-1 text-xs text-muted-foreground">day streak</div>
</div>

<div className="text-right">
<div className="text-xs text-muted-foreground">Today</div>
<div className="gp-num text-base font-semibold text-green-400">
{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. */}
<div className="flex items-center gap-6 sm:contents">
<div className="flex flex-col items-center">
<StreakFlame streak={currentStreak} color={tier.color} size={44} />
<div className="mt-1 text-xs text-muted-foreground">
{currentStreak === 0 ? "Start one today" : "day streak"}
</div>
</div>
<Link
href="/dashboard/stats"
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
View stats →
</Link>
{currentStreak > 0 && (
<button
onClick={handleShare}
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"

<div className="text-right">
<div className="text-xs text-muted-foreground">Today</div>
{/* Never green unless it was earned: zero done is neutral, partial
is warming, only a full sweep reads as success. §2.4 */}
<div
className={cn(
"gp-num text-base font-semibold",
today.total === 0 || today.completed === 0
? "text-muted-foreground"
: today.completed === today.total
? "text-tier-locked"
: "text-tier-warming"
)}
>
<Share2 size={12} />
{copied ? "Copied!" : "Share streak"}
</button>
)}
{today.completed}/{today.total} done
</div>
<Link
href="/dashboard/stats"
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
View stats →
</Link>
{currentStreak > 0 && (
<button
onClick={handleShare}
className="ml-auto flex items-center gap-1 rounded-sm text-xs text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Share2 size={12} />
{copied ? "Copied!" : "Share"}
</button>
)}
</div>
</div>
</div>

Expand Down
Loading