Distills programming knowledge, patterns, anti-patterns, pitfalls, and best practices from 21 source skills (Next.js 16 stack + frontend design + TDD + code quality + security/hardening + accessibility + CI/CD) into a single source of truth for any AI agent working on the Stillwater codebase.
Stillwater — Project Skill File
How to use this document: Read §1 (Project Identity) and §2 (Tech Stack) before touching any file. Read §9 (Anti-Patterns) and §13 (Pitfalls) before writing any new code. Read §11 (Pre-Ship Checklist) before claiming any work is done. Every claim in this document traces to a file path, a test scenario ID, or an executable command.
Status: v1.4.0 — Phase 0 (scaffold) ✅ COMPLETE (2026-07-06); Phases 1–12 pending per MASTER_EXECUTION_PLAN.md. All version pins, tsconfig flags, and env vars in this document are aligned with the source skills in skills/ and verified against current ecosystem state via web research (July 2026). The package.json files in the repo match §2.1. 45 discrepancies (D1–D45) reconciled; all 10 Open Questions resolved. pnpm install / pnpm check-types / pnpm lint all green.
Stillwater is an enterprise-grade yoga studio management platform — a Turborepo monorepo combining a public marketing surface (Next.js 16 + Sanity CMS, ISR), a member booking application with real-time seat availability via SSE, an RBAC-gated admin surface, Stripe subscription billing, and Trigger.dev v4 background jobs, serving a boutique yoga studio in Southeast Portland.
1.2 The Design Thesis
"Editorial Calm" — the intersection of high-end wellness editorial (Kinfolk magazine, Monocle) and Japanese spatial design philosophy (ma / 間 — negative space as active presence). Every pixel must earn its place. Whitespace is structural, not empty. Typography is the primary structural tool. Color temperature changes how the user feels, not just what they see.
1.3 Non-Negotiable Design Rules
The following are lint- and review-enforceable. A PR violating any rule auto-fails Axis 6 (Aesthetic) of the code review checklist.
--radius: 0 propagates through all shadcn components
✅ Color contrast 7:1 (WCAG 2.2 Level AAA)
scripts/contrast-check.ts runs in CI
1.4 The Anti-Generic Mandate
"Build the platform a boutique wellness studio deserves — not the one a SaaS template provides."
— MASTER_EXECUTION_PLAN.md §1.1
Every UI element must pass the Anti-Generic Litmus Test:
Why? — Tie the element to a user need or psychological purpose.
Only? — Challenge defaults. Is this the only way? Were alternatives considered?
Without? — Enforce minimalism. Does removal diminish the core?
A "no" or "unsure" answer to any of the three auto-fails the PR. See MASTER_EXECUTION_PLAN.md §3.2 for the full banned/required contract.
1.4.1 The 10-Point Anti-Generic Checklist
Source: avant-garde-design-v4/references/12-anti-generic-checklist.md §2.0. Before marking any UI work complete, it must pass these 10 points (the Litmus Test above covers point 1):
1. Intentionality: Every element earns its place (Why? Only? Without?).
2. Distinctive Hierarchy: Large typography is used for more than just size (e.g., as a structural element — Cormorant Garamond display sizes serve as architectural columns, not just bigger text).
3. Whitespace as Voice: Whitespace communicates drama or calm, not just empty space (--space-13: 256px for major section breaks = luxury signal, not emptiness).
4. Human Imperfection: Intentional roughness or asymmetry that signals authorship (62/38 grid breaks, not 50/50; sharp --radius: 0 edges, not pillowy rounded corners).
5. Tactile Interaction: Elements feel physically reactive (hover state shifts surface color, not shadow; click has transition-colors not scale-105).
6. Radical Color: Palette deviates from the standard "SaaS Blue/Indigo" (Warm Mineral palette: sand, stone, clay, water — no Tailwind defaults).
7. Narrative Flow: The page tells a story, rather than just listing features (editorial layout: hero → single instructor profile → schedule, not a 3-column feature grid).
8. Typography Soul: Fonts are selected for character, not just legibility (Cormorant Garamond for editorial gravitas; DM Sans for neutral body; JetBrains Mono for data precision).
9. Invisible UX: Micro-interactions serve the user, not just the eyes (reduced-motion respected globally; focus rings only on :focus-visible, not :focus).
10. Strategic Alignment: The aesthetic directly supports the "Compass" position (Editorial Calm = Kinfolk × 間; every choice traces back to this thesis).
1.4.2 Scoring System (The Quality Gate)
Source: avant-garde-design-v4/references/12-anti-generic-checklist.md §3.0. Evaluate the design from 1–10 on these three metrics:
Metric
Score (1–10)
Question
Memorability
Will the user remember this site in 24 hours?
Integrity
Does the code and UI feel like a cohesive whole?
Craftsmanship
Are the details (spacing, timing, contrast) flawless?
Pass threshold: Minimum 24/30 total score. Below 24 = redesign. Axis 6 (Aesthetic/UX Rigor) in §11.1.1 enforces this gate at PR review.
1.5 CTA Hierarchy (Editorial Restraint)
The Editorial Calm aesthetic rations the accent. Do NOT make every CTA solid clay.
Tier
Visual
When to use
1. Text link
Underline-offset-4, hover underline
Footer links, secondary nav
2. Outline button
1px stone border, transparent bg, hover muted-sand
"View Full Schedule", "Browse other classes"
3. Filled button
Clay-500 bg, sand-100 text
"Start Your Practice", "Begin Free Trial" (max 1 per page section)
4. Editorial link
Cormorant italic + clay-400 + arrow
"Full profile →", "View all 8 instructors →"
The page-level rule: at most one filled (Tier 3) CTA per visible section. A section with two filled CTAs is a design failure even if it passes lint.
§2. Tech Stack & Environment
2.1 Locked Versions
Layer
Technology
Version
Critical Note
Framework
Next.js (App Router, Turbopack)
^16.2.0
proxy.ts replaces middleware.ts (ADR-009); top-level serverExternalPackages (moved from experimental in Next.js 15, not 16); top-level cacheComponents: true (moved out of experimental in Next.js 16); React Compiler NOT default — requires explicit reactCompiler: true in next.config.ts
UI Runtime
React
^19.2.3
⚠️ CVE-2025-55182 floor ("React2Shell" RCE, CVSS 10.0) — never downgrade below 19.2.3; No forwardRef (ref as regular prop in React 19); React Compiler requires explicit opt-in via reactCompiler: true in next.config.ts (NOT default)
Language
TypeScript
^5.9.0
strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, useUnknownInCatchVariables, verbatimModuleSyntax: true (added TS 5.0; requires import type for type-only imports), erasableSyntaxOnly: true (added TS 5.8; FORBIDS enum, namespace, and parameter properties — use string unions or Drizzle pgEnum()) — see §13 for pitfalls
Styling
Tailwind CSS
^4.3.0
CSS-first @theme in globals.css; NO tailwind.config.js required; @source directives required in monorepo (see §13.6); outline-hidden replaces v3 outline-none (v4 outline-none now sets outline-style: none — different semantics)
Component Lib
Radix UI + shadcn/ui
latest
Initialize with style: "default", baseColor: "stone"; --radius: 0 overrides all defaults
API Layer
tRPC
^11.0.0
10 routers, 4 procedure tiers (public/protected/staff/owner); server caller for RSC, React Query for client
ORM
Drizzle ORM
^0.45.0
Schema in TypeScript, no codegen; neon-http driver for serverless; db.$count requires ≥0.34; relational query API v1 (db.query.*) since 0.28; v2 (defineRelations()) requires ≥1.0.0-beta
Per-procedure rate limiting on bookings.book (10/min) and auth mutations
Auth
Better Auth
^1.6.23
Replaces Auth.js v5 (ADR-008); stable v1.x line (Auth.js v5 still beta at 5.0.0-beta.31 as of July 2026); Drizzle adapter; Google + Magic Link; session enriched with memberId + roles
Background Jobs
Trigger.dev
v4
11 durable tasks with retries + cron schedules. ⚠️ v3 is deprecated — new v3 deploys stop working April 1, 2026; v4 reached GA August 2025. maxDuration in trigger.config.ts measures CPU time (not wall-clock); set explicitly. See §17 of PAD.md. ⚠️ SDK import (validated July 2026): Per official Trigger.dev v4 docs: "ALWAYS import from @trigger.dev/sdk. NEVER import from @trigger.dev/sdk/v3." The /v3 subpath is the deprecated v3-era pattern (both resolve to the same file in @trigger.dev/sdk@4.5.0, but root import is the official v4 path and future-proofs against /v3 removal). See §9.9 Gotcha 1 + §12 Lesson 16.
custom-conditions=@stillwater/source in .npmrc; pnpm-workspace.yaml with packages: ['.']; pnpm 9.x is EOL — use 11.x+
CMS
Sanity
v3
Marketing content only; operational data stays in PostgreSQL (ADR-005). Studio hosted at stillwater.sanity.studio (Sanity Cloud managed — MEP §9 Q4 resolved); config in apps/studio/sanity.config.ts
Payments
Stripe
^22.3.0
"Dahlia" API (2026-06-24) pinned by SDK v22; current_period_end moved to items.data[0].current_period_end (introduced in Basil 2025-03-31, carried forward); SDK exposes snake_case to match API wire format (NOT camelCase); Subscriptions + credit packs + customer portal; idempotent webhooks (UNIQUE INDEX + pg_advisory_xact_lock). v1 refund scope: Stripe Dashboard only — in-app refund UI deferred to v2 (MEP §9 Q5+Q8 resolved; D12 updated).
Email Templates
React Email
^6.6.6
13 templates, single-column 600px, CAN-SPAM compliant. ⚠️ v6.0.0 paradigm shift (April 16, 2026): all imports from react-email root (NOT @react-email/components or @react-email/render which are deprecated). v6 bundle is 1.8MB (514KB gzipped) — see react_email_suggestion.md Alternative A (Resend Native Templates) for Trigger.dev workers. Pending ADR-010 will formalize the Resend Native Templates decision. See §9.9 Gotcha 3 + §12 Lesson 18.
Email Delivery
Resend
^6.17.1
2,400 emails/day free tier. Supports Resend Native Templates (template ID + variables API) as zero-runtime-rendering alternative to local JSX rendering. Recommended for Trigger.dev workers to avoid 1.8MB React Email v6 bundle bloat (pending ADR-010).
Linting
ESLint
^9.39.4
v9 flat config (tooling/eslint/index.js). ⚠️ Do NOT upgrade to v10 — eslint-plugin-react@7.37.5 and eslint-plugin-import@2.32.0 have no v10-compatible versions (latest npm versions support ^9 only). v9.39.4 is the maintenance dist-tag (actively receiving security/bug fixes). See §9.9 Gotcha 2 + §12 Lesson 17 + MEP D45.
All env vars validated via t3-env Zod schema in packages/config/src/env.ts. Server vs client prefix enforced (NEXT_PUBLIC_* for client). Direct process.env.* reads bypass validation — typos like GOOGLE_CLIENTID (missing underscore) silently return undefined.
Critical env vars (full list in .env.example):
Variable
Purpose
Validation
DATABASE_URL
Pooled PG connection (app queries)
z.string().url() + custom refine for postgres scheme
Infrastructure clients (Stripe, Resend, Trigger.dev, Sanity, Upstash) MUST use process.env directly with null fallback, NOT the Zod env module — env validation throws in browser. See §15 for the graceful degradation pattern.
3.5 Common Commands
# Dev
pnpm dev # All apps (Next.js + Trigger.dev worker)
pnpm dev --filter=@stillwater/web # Just web
pnpm jobs:dev # Trigger.dev local worker# Quality
pnpm check-types # tsc --noEmit across all packages
pnpm lint # ESLint across all packages
pnpm lint:fix # Auto-fix
pnpm format # Prettier write
pnpm format:check # Prettier verify# Testing
pnpm test# Vitest (unit + integration)
pnpm test --filter=@stillwater/api # Single package
pnpm test:watch # Watch mode
pnpm test:coverage # With V8 coverage
pnpm test:e2e # Playwright (chromium + firefox + webkit)
pnpm test:e2e --ui # Interactive Playwright UI
pnpm test:e2e -- --grep "booking"# Filter by scenario# Database
pnpm db:generate # Drizzle Kit: generate migration SQL
pnpm db:migrate # Apply migrations (DATABASE_URL_UNPOOLED)
pnpm db:push # Push schema directly (dev only)
pnpm db:seed # Load development data (idempotent)
pnpm db:studio # Open Drizzle Studio GUI
pnpm db:reset # LOCAL ONLY — refuses in production# Build & deploy
pnpm build # Build all packages
pnpm build --filter=@stillwater/web # Build only web
pnpm jobs:deploy # Deploy Trigger.dev tasks to cloud# Vercel deploy handled by CI on main merge# Stripe webhook local testing
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger invoice.paid
stripe trigger invoice.payment_failed
§4. The Design System (Code-First)
4.1 The @theme Block (Tailwind v4 CSS-first)
All design tokens live in apps/web/src/app/globals.css via the @theme directive. NO tailwind.config.js. The @theme block maps every Stillwater token to Tailwind's theme variable namespace so utility classes (bg-stone-50, font-display, duration-quick) generate correctly.
Critical: Apply text-wrap: balance to every Cormorant Garamond heading (prevents ugly orphaned words). Apply text-wrap: pretty to every DM Sans paragraph. Apply font-variant-numeric: tabular-nums lining-nums to JetBrains Mono in any data table.
4.3 Color Usage Hierarchy (60-30-10)
Role
Color
Coverage
Background (60%)
--color-sand (#F5F0E8)
Page background
Surface + Text (30%)
--color-sand-warm (#EDE5D8) + --color-stone-900 (#1C1915) text
Forbidden colors (enforced by scripts/brand-tokens.test.ts):
#7c3aed, #a855f7, #8b5cf6 (purple family)
#3b82f6, #6366f1 (Tailwind default blue)
#fde68a, #fcd34d (Tailwind default amber)
Any raw #rrggbb not in the Warm Mineral palette
Color format note: Stillwater uses hex colors for simplicity with the Warm Mineral palette. Tailwind v4 and source skills (nextjs16-tailwind4 §2.4, tailwind-patterns §7) recommend OKLCH for wider gamut and perceptually uniform gradients. If gradient support is added in the future, consider migrating brand tokens to OKLCH format:
Font-mono choice: Stillwater uses JetBrains Mono (Apache 2.0, open-source) for the mono font family — matching PAD.md §11.2 and packages/ui/src/tokens/typography.css. The original design.md Phase 1 proposal suggested Berkeley Mono (paid commercial font), but the license was not acquired, so JetBrains Mono was chosen as the open-source fallback. This also aligns with packages/ui/src/fonts/jetbrains-mono/ which contains the self-hosted woff2 files.
4.5 Keyframes & Custom Utilities
All keyframes live INSIDE the @theme block so Tailwind picks them up. Use @utility (NOT @layer utilities) for custom utilities.
Banned: Do NOT define a glass utility (glassmorphism). Do NOT define aurora-gradient or mesh-bg utilities.
4.5.1 Animation Performance Guardrails
Source: avant-garde-design-v4/references/14-animation-standards.md §6.0. These rules are non-negotiable for maintaining 60fps on average mobile hardware.
Rule
Why
Enforcement
Compositor Only — only animate transform and opacity
Other properties (width, height, top, left, margin, padding) trigger layout reflow, which is 10–100× slower than compositor-only animations
The keyframes defined above (marquee, fade-in, reveal) all comply: they animate only transform and opacity. Any new keyframe MUST follow the same constraint. If you need to animate a non-compositor property (e.g., width for an accordion), use a ResizeObserver + transform: scaleX() workaround instead.
⚠️ Use 0.01ms, NOT 0ms — some browsers treat 0ms as "use default". 0.01ms is effectively instant but unambiguous.
Component-level pattern: Default to transition-none and only add transitions with motion-safe:transition-*. This inverts the safety default — animations only apply when motion is allowed.
<div className="transition-colors motion-reduce:transition-none">
Respects user preference
</div>
§5. Component Architecture & Patterns
5.1 The 5-Layer Architecture (Golden Rule)
Layer
Location
Allowed Imports
Forbidden Imports
0. Proxy (Edge or Node.js)
apps/web/proxy.ts
auth (cookie check only)
DB, Drizzle, Node APIs
1. App Router
apps/web/src/app/
Layouts, metadata, Suspense, PPR
DB queries (use tRPC server caller)
2. Feature modules
apps/web/src/components/, packages/ui/
UI composition, data binding, mutations
Raw Drizzle calls
3. Domain (pure)
packages/api/src/domain/
import type only from schema
ANY runtime import (Drizzle, Next.js, Stripe, tRPC)
If a UI library provides a primitive, USE IT. Do not rebuild.
Need
Use
Don't rebuild
Dialog / Modal
Radix Dialog
Custom overlay
Tabs
Radix Tabs
Custom tab logic
Select dropdown
Radix Select
Custom dropdown
Toast notifications
sonner
Custom toast
Date picker
react-day-picker
Custom calendar
Data tables
@tanstack/react-table
Custom table
Forms
react-hook-form + Zod resolver
Custom form state
Server state
@tanstack/react-query (via tRPC)
Custom fetch hooks
URL state
nuqs
Custom URL parsing
Animations
framer-motion (sparingly — CSS for most)
Custom CSS keyframes (mostly)
Exception: You may wrap or style library components to achieve the "Editorial Calm" look, but the underlying primitive must come from the library.
5.5 shadcn/ui Component Customization
After installing ANY shadcn component, run a grep for shadow-, rounded- (excluding rounded-full), and bg-gradient- — strip or replace every match. Add this as a pre-commit hook.
Full session validation via auth.api.getSession({ headers: await headers() }). RBAC role checks via requireRole().
Node.js
Why: proxy.ts runs on every request (Edge or Node.js runtime — Next.js 16 docs are inconsistent on the default). Full session validation (DB lookup, JWT verification) is too expensive and breaks Next.js 16's caching model regardless of runtime. Cookie-existence is a fast optimistic check; the actual security boundary is the Server Component layer.
Reference:Auth0 Next.js 16 guidance — "proxy.ts is not intended for full session management or complex authorization. Keep it light."
Anti-pattern (BANNED): Calling auth.api.getSession() inside proxy.ts. This was the original scaffolded pattern; it is wrong per the guide and must be replaced.
Server Components / Layouts use requireAuth() / requireRole() from apps/web/src/lib/auth.ts:
Source: nextjs16-react19-next-auth5-drizzle-orm/SKILL.md lessons 42–43 (lines 1442–1444) — P0 production outage lesson. Verified against Better Auth docs (July 2026): trustHost is a NextAuth.js/Auth.js v5 concept, NOT a Better Auth option. Better Auth trusts baseURL by default and uses trustedOrigins for additional allowed origins.
The Auth.js v5 problem (and Better Auth equivalent): In Auth.js v5, without trustHost: true, the library falls back to AUTH_URL for callback URLs. Better Auth does NOT have a trustHost option — instead, it uses baseURL (configured explicitly) for all callback URL construction. If BETTER_AUTH_URL=http://localhost:3000 leaks to production (e.g., via a copied .env.local), auth redirects resolve to localhost → ERR_CONNECTION_REFUSED → users can't log in. This was a P0 production outage in the source skill.
Better Auth configuration:
// packages/auth/src/index.tsimport { betterAuth } from'better-auth';
import { drizzleAdapter } from'better-auth/adapters/drizzle';
exportconst auth = betterAuth({
// Better Auth does NOT have a `trustHost` option (that's NextAuth/Auth.js v5).// Instead, it trusts `baseURL` by default and uses `trustedOrigins` for// additional allowed origins (e.g., for CORS on the auth API).// See: https://better-auth.com/docs/reference/optionsbaseURL: process.env.BETTER_AUTH_URL ?? 'http://localhost:3000',
// Used for ALL callback URLs (magic link, password reset, OAuth redirects).// MUST match the production URL. In production: https://stillwater.studio// In dev: http://localhost:3000trustedOrigins: [
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
],
// Additional origins allowed to call the auth API (CORS).// ... rest of config
});
Env module host-mismatch warning (T2 from source skill): Add a runtime check in packages/config/src/env.ts that warns when BETTER_AUTH_URL and NEXT_PUBLIC_APP_URL resolve to different hosts:
// packages/config/src/env.tsif (process.env.NODE_ENV === 'production') {
const authHost = newURL(env.BETTER_AUTH_URL).host;
const appHost = newURL(env.NEXT_PUBLIC_APP_URL).host;
if (authHost !== appHost) {
console.warn(`[auth] Host mismatch: BETTER_AUTH_URL host (${authHost}) != NEXT_PUBLIC_APP_URL host (${appHost}). Auth callbacks may redirect to the wrong host.`);
}
}
Reverse-proxy note: If deploying behind Cloudflare Tunnel, a load balancer, or any reverse proxy, ensure baseURL is set to the user-facing URL (NOT the internal proxy URL). Better Auth uses baseURL for all callback URL construction — it does NOT read the Host header (unlike Auth.js v5's trustHost: true). Verify with a curl to the auth callback in staging.
Better Auth default; verify in auth.tssession.cookie config
Session timeout
Idle: 24h; absolute: 7 days
session.expiresIn: 60 * 60 * 24 (24h idle); rotate refresh token on use
Logout invalidation
Session row deleted from DB (not just cookie cleared)
authClient.signOut() calls auth.api.signOut() which deletes the session row
Brute-force note: Return HTTP 429 (rate limited) — NOT 401 (unauthorized) — when the lockout triggers. Returning 401 reveals that the account exists, enabling username enumeration. The 429 response should be identical whether the lockout is active OR the rate limit is hit, to avoid a timing side-channel.
Magic Link security (Stillwater-specific): Magic Link emails expire in 10 min (Better Auth default). The email link contains a single-use token. Verify magicLink.tokenExpiresIn: 10 * 60 in auth.ts. Rotate the token on each resend (Better Auth default).
5.7 Layout-Level Auth Guards (Not Per-Page)
// ✅ CORRECT — auth at layout boundary// app/(studio)/layout.tsximport { requireAuth } from'@/lib/auth';
exportdefaultasyncfunctionStudioLayout({ children }: { children: React.ReactNode }) {
awaitrequireAuth(); // throws NEXT_REDIRECT if unauthenticatedreturn<StudioShell>{children}</StudioShell>;
}
// ❌ WRONG — per-page guard (any new page that forgets is publicly accessible)// app/(studio)/dashboard/page.tsxexportdefaultasyncfunctionDashboardPage() {
awaitrequireAuth(); // ⚠️ easy to forget on new pages// ...
}
Critical:requireAuth() throws NEXT_REDIRECT — never wrap in try/catch (it would catch the redirect and silently swallow it).
5.8 Server Actions / tRPC Mutations
tRPC mutations are preferred for all member-facing writes (booking, cancellation, profile updates)
Server Actions used only for form submissions that don't fit tRPC's procedure model (e.g., Stripe Checkout redirect)
All mutations start with auth verification, then Zod validation, then business logic, then side-effect triggering
Rule: Every Server Action (or tRPC procedure) accepting an id: string parameter MUST validate it with z.string().uuid() BEFORE any database call. Invalid UUIDs return a VALIDATION_ERROR (HTTP 422) immediately — never reach the DB.
// ✅ CORRECT: UUID validation before any DB callconstIdSchema = z.string().uuid('Invalid ID format');
exportasyncfunctioncancelBooking(id: string) {
const parsed = IdSchema.safeParse(id);
if (!parsed.success) {
return { success: falseasconst, error: 'VALIDATION_ERROR'asconst };
}
// Only now is it safe to query the DBconst booking = await db.query.bookings.findFirst({
where: eq(bookings.id, parsed.data),
});
// ...
}
// ❌ WRONG: id hits the DB unvalidated — non-UUID input can cause// Drizzle/Postgres errors or expose timing side-channelsexportasyncfunctioncancelBooking(id: string) {
const booking = await db.query.bookings.findFirst({
where: eq(bookings.id, id), // 💥 unvalidated
});
}
Why this matters: Source skill added 18 regression tests for this (5 invalid ID formats × 3 actions + 3 valid UUID tests). Without validation, a malicious input like '; DROP TABLE bookings; -- or 00000000-0000-0000-0000-000000000000 causes DB-level errors that may leak schema info or cause connection-pool exhaustion.
Zod v4 UUID note:z.string().uuid() in Zod v4 requires proper v4 format (version digit 4, variant 8/9/a/b). IDs like 00000000-0000-0000-0000-000000000001 will FAIL validation. Use valid v4 format for seed data.
React 19 added native use() for unwrapping promises in Client Components. This replaces the useEffect + useState anti-pattern for data passed from Server Components as props.
import { use } from'react';
import { Suspense } from'react';
// Server Component — fetches and passes the promise (NOT the resolved value)// app/schedule/[sessionId]/page.tsxexportdefaultasyncfunctionSchedulePage({ params }: { params: Promise<{ sessionId: string }> }) {
const { sessionId } = await params;
const sessionPromise = getSession(sessionId); // Do NOT await — pass the promisereturn (
<Suspensefallback={<ScheduleSkeleton />}>
<SessionDetailsessionPromise={sessionPromise} /><SessionCapacitysessionPromise={sessionPromise} /></Suspense>
);
}
// Client Component — unwraps the promise with use()functionSessionDetail({ sessionPromise }: { sessionPromise: Promise<Session> }) {
const session = use(sessionPromise); // Unwraps the promisereturn<div>{session.title}</div>;
}
// Both components share the SAME promise → only one fetch occursfunctionSessionCapacity({ sessionPromise }: { sessionPromise: Promise<Session> }) {
const session = use(sessionPromise); // Reuses the same promisereturn<div>{session.capacity} spots</div>;
}
Why each detail matters:
Pass the promise (not awaited value) from Server Component — this enables PPR streaming
use() must be called inside a <Suspense> boundary or it throws
Both clients share the same promise → single fetch, no waterfall
Total propagation: < 5 minutes (Goal G3 from PAD §2.3).
7.5.1 Public Query published: true Filter (Critical)
Source: nextjs16-react19-tailwind4-full-stack/SKILL.md H2 fix lines 348–378 (Critical-class audit finding: "Public Queries Did Not Filter by published: true").
Rule: Every public-facing query (tRPC public procedures, Sanity GROQ queries, any route handler serving unauthenticated requests) MUST filter by published: true (or equivalent). Unpublished content must NEVER reach the public API.
// ✅ CORRECT: Public tRPC procedure filters by publishedexportconst getInstructors = publicProcedure
.query(async ({ ctx }) => {
const result = await ctx.db
.select()
.from(instructors)
.where(eq(instructors.published, true)) // REQUIRED for public queries
.orderBy(instructors.order);
returnInstructorArraySchema.parse(result); // Zod defense-in-depth
});
// ❌ WRONG: No published filter — unpublished instructors leak to publicexportconst getInstructors = publicProcedure
.query(async ({ ctx }) => {
return ctx.db.select().from(instructors).orderBy(instructors.order);
});
Static fallback: If using a static fallback array (for build-time ISR), the fallback MUST also filter by published: true — don't assume the static array is pre-filtered.
Defense-in-depth: Even with the published: true filter, validate DB results with Zod (InstructorArraySchema.safeParse(result)) before returning. This catches varchar→enum narrowing issues and corrupted data.
7.6 Cloudflare Images Integration
All instructor photos, class thumbnails, and blog hero images served via Cloudflare Images CDN. Originals stored in Cloudflare R2 (zero egress cost).
Source: avant-garde-design-v4/references/04-accessibility-checklist.md §Level AAA Requirements. Stillwater targets WCAG 2.2 Level AAA (not just AA). The table below covers all 9 criteria applicable to web apps.
#
WCAG 2.2 AAA Criterion
Requirement
Stillwater Value
Verified via
1.4.6
Contrast (Enhanced) — normal text
7:1 minimum
All --color-stone-* on --color-sand verified
scripts/contrast-check.ts in CI
1.4.6
Contrast (Enhanced) — large text (≥ 18pt)
4.5:1 minimum
All Cormorant display sizes
scripts/contrast-check.ts
1.4.8
Visual Presentation
(a) Width ≤ 80 chars; (b) no justified text; (c) line spacing ≥ 1.5; (d) no background color override by user stylesheet blocking
--leading-body: 1.65; max-width: 70ch on prose; text-align: left (never justify)
Code review + axe-core
1.4.9
Images of Text (No Exception)
No images of text (logos exempt)
All text is real HTML text; instructor names/schedules are NOT in images
Code review
2.2.4
Interruptions
User can postpone or suppress interruptions
No auto-redirects; toast notifications dismissible; no auto-playing video with audio
Code review
2.3.2
Three Flashes
No more than 3 flashes per second
No flashing animations; @media (prefers-reduced-motion) reduces all motion to 0.01ms (see §4.6)
Code review
2.5.5
Target Size (Enhanced)
44×44 CSS pixels minimum
min-h-[44px] min-w-[44px] on all interactive elements
ESLint rule + E2E assertion
2.5.7
Dragging Movements (WCAG 2.2 NEW)
Functionality requiring dragging MUST have a single-click/tap alternative
Booking calendar has click-to-select alternative to drag-to-range; kanban admin has click-move buttons
Code review
3.1.5
Reading Level
Lower secondary education (≈ Grade 8)
Instructional copy only; legal/medical copy reviewed for reading level
Content review
3.1.6
Pronunciation
Pronunciation available for words where meaning depends on it
Japanese term 間 (ma) includes <ruby> annotation; Sanskrit yoga terms include IAST transliteration
Global :focus-visible rule (see §8.3); --color-clay-300 on dark backgrounds
scripts/contrast-check.ts
—
Keyboard navigation
Full tab order, no traps
Radix primitives + custom testing
axe-core + Lighthouse A11y = 100
—
Screen reader
Semantic HTML, ARIA labels
axe-core + Lighthouse A11y = 100
Lighthouse CI Gate 6
—
Reduced motion
0.01ms durations globally
@media (prefers-reduced-motion: reduce) block (see §4.6)
Code review
—
Time limits
None without warning + extension
No auto-logout, no countdown timers
Code review
ADA Title II compliance: As of April 24, 2026, ADA Title II requires WCAG 2.1 AA conformance for state and local government websites. Stillwater targets AAA (stricter), so AA compliance is implicit. Source: avant-garde-design-v4/references/04-accessibility-checklist.md lines 270–285. Non-compliance risk: legal action, financial penalties, reputation damage, loss of federal contracts.
8.2 Color Contrast Verification
Run scripts/contrast-check.ts in CI against every --color-* pairing:
With --radius: 0 (sharp edges), focus rings are the only interactive affordance — they must be prominent.
/* Global focus-visible rule (in @layer base) */:focus-visible {
outline: 3px solid var(--color-water-500);
outline-offset: 2px;
border-radius: 0; /* Maintain sharp edges even on focus */
}
/* For elements on dark backgrounds (CTA band, admin dark sections) */.cta-band:focus-visible {
outline-color: var(--color-clay-300);
outline-offset: 2px;
}
Never use focus:outline-none without a replacement. With sharp edges, removing focus visibility is a Critical-block review issue.
8.4 Skip-to-Content Link
// Place as FIRST element in <body>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-clay-500 focus:text-sand-100"
>
Skip to main content
</a>
<mainid="main-content">{children}</main>
Symptom: Edge middleware never runs; routes aren't protected.
Root cause: Next.js 16 renamed middleware.ts to proxy.ts. The exported function must be named proxy, not middleware.
Fix: Rename apps/web/middleware.ts → apps/web/proxy.ts. Change export function middleware(...) → export async function proxy(...).
Lesson: ADR-009. The MASTER_EXECUTION_PLAN.md Phase 0 patch D2 enforces this.
Symptom:@neondatabase/serverless, drizzle-orm, better-auth bundled into server bundle, causing build failures or runtime errors.
Root cause: Next.js 16 renamed this to top-level serverExternalPackages (no experimental. prefix).
Fix: Move from experimental.serverComponentsExternalPackages: [...] to top-level serverExternalPackages: [...] in next.config.ts.
Lesson: MASTER_EXECUTION_PLAN.md Phase 0 patch D21.
Symptom: Every "use cache" directive is silently dead; cacheLife() throws TypeError: cacheLife is not a function.
Root cause:cacheComponents: true and cacheLife profiles MUST be top-level in Next.js 16, not inside experimental.
Fix: Move to top-level. Verify with a smoke test that cacheLife("feed") doesn't throw.
Lesson: Skill nextjs16-react19-postgres17 §Anti-Patterns.
Symptom: Runtime 500 in production; works in dev (silent).
Root cause: All three are Promise<T> in Next.js 16. Synchronous access throws.
Fix: Always await them: