Phase 0: scaffold Next.js, Tailwind tokens, Supabase clients, env validation

Next.js 16 App Router with strict TypeScript, Tailwind 4 theme tokens from
docs/UI.md, Vitest, and the three Supabase clients. Env vars are validated
with zod in next.config.ts so dev and build fail fast with a readable error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 12:27:11 +02:00
co-authored by Claude Opus 5.5
commit c89a82295b
29 changed files with 9107 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
@import "tailwindcss";
/* Design tokens from docs/UI.md. */
:root {
--stage: #1b1640;
--floodlight: #ffd23f;
--chalk: #f5f7fa;
--ink: #14213d;
--signal: #2ec4b6;
--alert: #e63946;
}
/* Expose the tokens as Tailwind utilities: bg-stage, text-ink, font-display, ... */
@theme inline {
--color-stage: var(--stage);
--color-floodlight: var(--floodlight);
--color-chalk: var(--chalk);
--color-ink: var(--ink);
--color-signal: var(--signal);
--color-alert: var(--alert);
--font-display: var(--font-big-shoulders), ui-sans-serif, system-ui, sans-serif;
--font-sans: var(--font-atkinson), ui-sans-serif, system-ui, sans-serif;
}
body {
background: var(--chalk);
color: var(--ink);
}
:focus-visible {
outline: 3px solid var(--ink);
outline-offset: 2px;
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Atkinson_Hyperlegible, Big_Shoulders } from "next/font/google";
import { publicEnv } from "@/lib/env.public";
import "./globals.css";
// Google Fonts renamed "Big Shoulders Display" to "Big Shoulders"; same typeface.
const bigShoulders = Big_Shoulders({
subsets: ["latin"],
variable: "--font-big-shoulders",
display: "swap",
});
const atkinson = Atkinson_Hyperlegible({
subsets: ["latin"],
weight: ["400", "700"],
variable: "--font-atkinson",
display: "swap",
});
export const metadata: Metadata = {
title: publicEnv.NEXT_PUBLIC_APP_NAME,
description: "Live scoring for game nights: control room, projector scoreboard and judge links.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en" className={`${bigShoulders.variable} ${atkinson.variable}`}>
<body className="min-h-dvh font-sans antialiased">{children}</body>
</html>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { publicEnv } from "@/lib/env.public";
const tokens = [
{ name: "Stage", className: "bg-stage" },
{ name: "Floodlight", className: "bg-floodlight" },
{ name: "Chalk", className: "bg-chalk" },
{ name: "Ink", className: "bg-ink" },
{ name: "Signal", className: "bg-signal" },
{ name: "Alert", className: "bg-alert" },
];
// Placeholder home for Phase 0: proves fonts, tokens and env loading work.
export default function Home() {
return (
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col justify-center gap-8 p-6">
<div>
<h1 className="font-display text-6xl font-bold tabular-nums">
{publicEnv.NEXT_PUBLIC_APP_NAME}
</h1>
<p className="mt-2 text-lg">Scaffold is running. Admin, scoreboard and judge pages come next.</p>
</div>
<ul className="grid grid-cols-3 gap-3 sm:grid-cols-6">
{tokens.map((token) => (
<li key={token.name} className="text-sm">
<div className={`h-12 rounded border border-ink/20 ${token.className}`} />
{token.name}
</li>
))}
</ul>
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { parseEnv, publicEnvSchema } from "./env";
// Each var is referenced literally so Next.js can inline it into the client bundle.
export const publicEnv = parseEnv(publicEnvSchema, {
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
});
+5
View File
@@ -0,0 +1,5 @@
import "server-only";
import { parseEnv, serverEnvSchema } from "./env";
export const serverEnv = parseEnv(serverEnvSchema, process.env);
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { parseEnv, serverEnvSchema } from "./env";
const valid = {
NEXT_PUBLIC_SUPABASE_URL: "https://example.supabase.co",
NEXT_PUBLIC_SUPABASE_ANON_KEY: "anon",
NEXT_PUBLIC_SITE_URL: "http://localhost:3000",
SUPABASE_SERVICE_ROLE_KEY: "service",
ADMIN_EMAILS: "Host@Example.com, second@example.com",
};
describe("parseEnv", () => {
it("parses a valid env and normalises admin emails", () => {
const env = parseEnv(serverEnvSchema, valid);
expect(env.ADMIN_EMAILS).toEqual(["host@example.com", "second@example.com"]);
expect(env.NEXT_PUBLIC_APP_NAME).toBe("Game Night Live");
});
it("names every missing or blank variable in one error", () => {
const broken = { ...valid, SUPABASE_SERVICE_ROLE_KEY: undefined, ADMIN_EMAILS: " " };
expect(() => parseEnv(serverEnvSchema, broken)).toThrowError(
/SUPABASE_SERVICE_ROLE_KEY[\s\S]*ADMIN_EMAILS[\s\S]*\.env\.local/,
);
});
it("rejects a site URL with a trailing slash", () => {
const broken = { ...valid, NEXT_PUBLIC_SITE_URL: "http://localhost:3000/" };
expect(() => parseEnv(serverEnvSchema, broken)).toThrowError(/NEXT_PUBLIC_SITE_URL/);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { z } from "zod";
// Schemas only — no values are read here, so this file is safe to import from
// next.config.ts, tests, client and server code alike.
export const publicEnvSchema = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
NEXT_PUBLIC_APP_NAME: z.string().min(1).default("Game Night Live"),
NEXT_PUBLIC_SITE_URL: z
.url()
.refine((url) => !url.endsWith("/"), "must not end with a slash"),
});
export const serverEnvSchema = publicEnvSchema.extend({
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
ADMIN_EMAILS: z
.string()
.transform((value) =>
value
.split(",")
.map((email) => email.trim().toLowerCase())
.filter(Boolean),
)
.pipe(z.array(z.email()).min(1, "needs at least one email")),
});
export type PublicEnv = z.infer<typeof publicEnvSchema>;
export type ServerEnv = z.infer<typeof serverEnvSchema>;
type EnvSource = Record<string, string | undefined>;
/** Parses env vars, throwing one readable error that lists every problem. */
export function parseEnv<T extends z.ZodType>(schema: T, source: EnvSource): z.infer<T> {
// Treat blank values ("FOO=") the same as missing ones.
const cleaned = Object.fromEntries(
Object.entries(source).filter(([, value]) => value !== undefined && value.trim() !== ""),
);
const result = schema.safeParse(cleaned);
if (!result.success) {
const problems = result.error.issues
.map((issue) => {
const name = issue.path.join(".") || "(env)";
return ` - ${name}: ${name in cleaned ? issue.message : "is missing"}`;
})
.join("\n");
throw new Error(
`Invalid or missing environment variables:\n${problems}\n` +
"Copy .env.example to .env.local and fill in the values.",
);
}
return result.data;
}
+15
View File
@@ -0,0 +1,15 @@
import "server-only";
import { createClient } from "@supabase/supabase-js";
import { serverEnv } from "@/lib/env.server";
/**
* Service-role client. Bypasses RLS, so only call it from route handlers
* AFTER the scoreboard/judge token has been validated, and scope every query by that token.
*/
export function createAdminClient() {
return createClient(serverEnv.NEXT_PUBLIC_SUPABASE_URL, serverEnv.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false },
});
}
+11
View File
@@ -0,0 +1,11 @@
import { createBrowserClient } from "@supabase/ssr";
import { publicEnv } from "@/lib/env.public";
/** Anon-key client for the browser: Realtime subscriptions + the admin auth session. */
export function createClient() {
return createBrowserClient(
publicEnv.NEXT_PUBLIC_SUPABASE_URL,
publicEnv.NEXT_PUBLIC_SUPABASE_ANON_KEY,
);
}
+33
View File
@@ -0,0 +1,33 @@
import "server-only";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { publicEnv } from "@/lib/env.public";
/** Cookie-based client for admin server components/actions. RLS applies as the signed-in admin. */
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
publicEnv.NEXT_PUBLIC_SUPABASE_URL,
publicEnv.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// Server Components can't write cookies. Safe to ignore: the proxy
// refreshes the session cookie on every request.
}
},
},
},
);
}