| name | m13v-setup-client-website |
| description | One-time onboarding for a new consumer site: SEO audit of the existing site, Next.js 16 scaffold, @m13v/seo-components install, route-group architecture, real images/video/structured data, Cloud Run deploy, dashboard registration, and SEO infrastructure wiring (withSeoContent, guide index, optional sidebar and AI chat). This skill stops at infrastructure; day-to-day SEO guide page generation is handled by the gsc-seo-page skill and seo/generate_page.py. Use when: 'set up client website', 'onboard new client site', 'new consumer site', 'recreate website', 'rebuild website', or when spinning up a fresh site that will later receive programmatic SEO pages. |
| user_invocable | true |
Setup Client Website
One-time onboarding flow for a new (or rebuilt) client/consumer site. Produces a modern, SEO-optimized Next.js site with real content, images, video embeds, structured data, and the infrastructure needed for programmatic SEO guide pages.
Scope boundary: this skill leaves the repo in a state where /t/<slug> guide pages can be produced. It does NOT write guide pages. All guide-page content generation is handled by the gsc-seo-page skill, which drives ~/social-autoposter/seo/generate_page.py. After running this skill, register the site in ~/social-autoposter/config.json and hand off to gsc-seo-page for every page.
Arguments
Provide the client name, domain (if any), and existing site URL (if any). Example: "Paperback Expert at paperbackexpert.com"
Optional scope flags
These are OFF by default. Only enable them if the invoker mentions the feature explicitly (e.g. "with book-a-call", "add a contact form"). If the scope flag is not mentioned, skip every phase marked [opt-in: book-a-call] etc. — do not half-scaffold placeholders.
| Flag | Default | Triggers phases |
|---|
book-a-call | off | 3.5d Book-a-Call helpers, 3.5l Cal.com event type creation, 6g Cal.com webhook wiring, Phase 8 booking-verification row, Phase 10a booking_link field |
When a flag is off: do not add <BookCallLink>/<BookCallTracker>, do not create a Cal.com event type, do not add booking_link to config.json, and skip the booking-related Phase 8 checklist rows. The DM / dashboard pipeline must fall back to links.install (or whatever primary CTA the site has) when booking_link is absent.
Rationale: free OSS tools, install-driven products, and open-source landing pages (ClaudeMeter, appmaker-style utilities) have no "book a call" conversion. Forcing Cal.com wiring on them created dead links + placeholder config that broke the stats pipeline.
Prerequisites
- Google Cloud project under the m13v.com org (or create a new one)
- GitHub org or personal account
- PostHog account (org: m13v) for analytics
- Resend account (
you@example.com) for transactional email
- Neon account for Postgres (one project per client, pooled connection)
- Google Search Console access
- Isolated browser MCP for visual comparison
Stack
- Next.js 16 (App Router) + React 19 + TypeScript
- Tailwind CSS 4 (inline theme via
@theme)
- next/image for optimized images
- Google Cloud Run for hosting (with HTTPS Load Balancer + Certificate Manager)
- PostHog for analytics (pageviews, CTA clicks, newsletter subscribes)
- Resend for transactional email (newsletter welcome, contact form, optional inbound webhook)
- Neon (
@neondatabase/serverless) for email + contact logs
Phase 1: Audit and Research
Phase 1 runs two tracks concurrently:
- Outward track (1a): understand the market the client sells into (competitors, search demand, industry developments, ICP).
- Inward track (1b-1e): audit what the client already has (SEO baseline, content crawl, assets, screenshots).
Both tracks feed 1f, which produces a single research-brief.md that every downstream phase (copy, hero, CTAs, FAQ, case studies) is required to consume. Research that does not make it into the brief is decoration.
1a. Market Research Fan-Out
Launch these agents in parallel. They have no dependencies on each other or on the inward track, so batch them in a single message.
Launch 4 agents in parallel:
- competitor-analysis: identify top 3-5 rivals by SERP + brand search.
Per rival, capture: positioning one-liner, pricing, primary CTAs,
hero copy, testimonial themes, messaging pillars, obvious gaps or
weaknesses.
- keyword-research + serp-analysis (single agent, both skills): head
terms, long-tail clusters, search intent (informational /
commercial / transactional), SERP feature mix (AI Overviews, PAA,
video, local pack), difficulty, monthly volume.
- deep-research-pro: industry developments in the last 90 days,
regulation, notable launches, funding, M&A, price moves, new
entrants, platform / distribution shifts. Cite sources.
- general-purpose (ICP pass, with WebFetch): 1-2 primary personas with
jobs-to-be-done, top 3 pains, top 3 gains, objections, triggers,
and the language they actually use (verbatim pulls from Reddit
threads, review sites, forum posts, NOT marketing copy).
Output: four raw reports in research/raw/ (competitors.md, keywords.md, industry.md, icp.md). Do not edit them down here, 1f does the compression.
Budget guardrail: if any single agent returns more than ~15k tokens, ask it to re-emit a tighter version capped at ~8k before moving on. Raw-output bloat is the main failure mode of this step.
1b. SEO Audit (if existing site)
Run parallel SEO agents to baseline the current site. This runs concurrently with 1a.
Launch 5 agents in parallel:
- seo-technical: crawlability, indexability, Core Web Vitals, mobile
- seo-content: E-E-A-T signals, readability, content depth
- seo-schema: existing structured data (JSON-LD, Microdata, RDFa)
- seo-performance: Lighthouse scores, LCP, CLS, TBT (desktop + mobile)
- seo-geo: AI crawler accessibility, llms.txt, citation readiness
Record all scores. These become the "before" baseline and the fix list for the new site.
1c. Crawl All Pages
Use an agent with WebFetch to discover and extract content from every page on the site:
- Fetch the homepage, extract all navigation and footer links
- Try common paths: /about, /services, /contact, /faq, /blog, /pricing, /testimonials, /privacy
- For each discovered page, extract:
- URL and page title
- All headings (h1 through h6) with hierarchy
- Full body text (quotes, testimonials, stats, descriptions)
- CTA text and link targets
- Form fields (if any)
- Navigation links (to discover more pages)
Output: Complete content inventory organized by page.
1d. Extract Visual Assets (and Brand Identity)
Use the isolated browser to catalog images, videos, and embeds on the original brand domain (e.g. piastech.com, cyrano.ai), not on the new generic SEO domain. The brand identity is the property of the brand domain.
() => {
const imgs = Array.from(document.querySelectorAll('img')).map((img, i) => {
const rect = img.getBoundingClientRect();
return { idx: i, src: img.src, y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) };
}).filter(x => x.w > 50);
const iframes = Array.from(document.querySelectorAll('iframe')).map(f => f.src);
return { imgs, iframes };
}
Key assets to identify and download:
- Brand wordmark / logo (header top-left; may be SVG, PNG, or inline text — capture exact spelling, casing, and any logomark character like
π(a|s))
- Favicon (
/favicon.ico, /favicon.png, or any <link rel="icon"> href)
- Hero images or background photos
- Client/team headshot photos (circular, 100-200px)
- Product images (book covers, screenshots, etc.)
- Social proof imagery (awards, certifications, partner logos)
- Video embeds (Vimeo, YouTube URLs)
- Scheduling widgets (Calendly, Cal.com URLs)
- Book cover strips / product galleries
Brand colors and fonts. Dump the brand site's CSS custom properties and fonts so the new site can match the palette:
() => {
const r = getComputedStyle(document.documentElement);
const b = getComputedStyle(document.body);
const varNames = ['--background','--foreground','--primary','--primary-foreground','--accent','--card','--secondary','--muted','--border'];
const vars = Object.fromEntries(varNames.map(n => [n, r.getPropertyValue(n).trim()]));
return {
bodyBg: b.backgroundColor,
bodyFg: b.color,
bodyFont: b.fontFamily,
vars,
};
}
Also pull the brand site's CSS file and grep for hex/hsl color values if CSS vars aren't exposed:
curl -sL <brand-url>/assets/index.css | grep -oE '\-\-[a-z-]+:[^;]+;' | sort -u
Save the palette to research/brand-identity.md with: brand name exact spelling, logomark character (if any), primary/accent hex codes, body/heading fonts, favicon URL, and the logo image URL.
Download all identified images to public/images/ with descriptive filenames. Copy the favicon to public/favicon.png and convert to src/app/favicon.ico (multi-size .ico) with:
magick <favicon>.png -define icon:auto-resize=16,32,48 src/app/favicon.ico
1e. Take Full Page Screenshots
Capture full-page screenshots of every key page on the original site for visual reference:
For each page:
1. browser_navigate to URL
2. browser_take_screenshot with fullPage: true
3. Save as original-{pagename}-full.png
1f. Synthesize Research Brief
Once 1a-1e are all complete, run a single synthesizer pass (general-purpose agent, no fan-out) that reads every file in research/raw/ plus the crawl inventory and produces research/research-brief.md using exactly this schema:
# Research Brief: <client>
## Positioning angle (one sentence)
<single crisp sentence — what the client uniquely is, for whom, against whom>
## 3 differentiators
1. <differentiator> — proof: <citation / source / datapoint>
2. ...
3. ...
## 5 messaging pillars
1. <pillar headline> — supporting evidence: <...>
2. ...
...
## ICP (1-2 personas)
For each persona:
- Name + one-line description
- Top 3 jobs-to-be-done
- Top 3 pains (verbatim language from research)
- Top 3 gains
- Primary objection + the counter
- Trigger event that starts the buying journey
## Proof points
Verifiable stats, awards, case-study numbers, named clients, certifications, press mentions. Anything that can appear on the site as evidence — with source.
## Competitor landscape (one paragraph)
Who the client is up against, how they're positioned, and the gap the client is walking into.
## Banned clichés
Phrases pulled from competitor copy that the new site must NOT reuse (e.g. "cutting-edge", "world-class", "one-stop-shop", plus any industry-specific filler found during 1a).
## Industry signals (last 90 days)
3-5 bullets. Only items that should change hero copy, FAQ, or CTAs. Everything else is cut.
Hard rules for the synthesizer:
- Every claim must trace to a file in
research/raw/ or the crawl inventory. No invented stats.
- Hero copy, CTAs, FAQ answers, and case-study selection in Phases 3+ must cite lines from this brief. If a later phase wants to say something not in the brief, it returns to 1a instead of making it up.
- The brief is the artifact. The raw reports in
research/raw/ are scratch and can be deleted after 1f lands.
1g. Visual Reference Brief (required for Phase 3)
Phase 3 no longer prescribes how each section looks. It prescribes structure and tokens; visual treatment comes from this brief. Without research/visual-references.md, Phase 3 cannot start.
Pick 3-5 reference sites:
- 2 direct category competitors (the best-designed ones from Phase 1a, not the most prominent).
- 2-3 design benchmarks from adjacent categories. Rotate so no two client sites share the same benchmark set. Pool: Linear, Raycast, Vercel, Anthropic, PostHog, Stripe, Resend, Rauno, Framer, Arc, Readwise, Granola, NYT Cooking, magazine editorial layouts (Apple newsroom, Stripe Press), brutalist/Swiss sites.
For each reference, capture in research/visual-references.md:
- Screenshot at
research/visual-references/<slug>.png (full page via isolated browser).
- Palette (3-5 hex codes; note which is background, ink, accent, signal).
- Type stack (display, body, mono families and weights).
- 2-3 distinctive motifs (e.g. section numbering with mono chip, live animated product mock in hero, paper grain overlay, hairline grid instead of cards, pull quotes with giant serif marks, floating persistent chip, scroll-reveal stagger, italic serif for emphasis, tilt-on-hover, copy-to-clipboard CTA, kinetic type).
- Animation vocabulary (sheen, pulse, parallax, marquee, scroll-driven, spring).
- One-line observation: what this reference does well that this client can borrow.
Close with a design thesis (one paragraph): the visual identity for this client site. Must be specific enough that two agents reading it would produce the same palette family and the same 2-3 signature motifs. Example: "Editorial, Swiss, on paper-cream #F4EEE4 with ink #121110 and signal orange #E8471C. Serif display (Instrument Serif, italic for emphasis), sans body (Geist), mono for meta and numbers (Geist Mono). Signature motifs: numbered section eyebrows with hairline rules, live-ticking product mock as the hero anchor, copy-to-clipboard install chip as primary CTA, pull quotes with 90px orange serif mark, floating persistent product chip that follows scroll."
Exemplar to read first: ~/.claude/skills/setup-client-website/exemplars/claude-meter-editorial.html. This is ONE treatment (editorial, Swiss, paper-and-ink, product-first). Do not copy it. Read it as proof that Phase 3 output can be distinctive, product-first, and tactile, rather than the corporate baseline in Appendix A. Add a second and third exemplar to exemplars/ over time so this skill accumulates visual range.
Phase 1.5: Generic Domain Discovery and Purchase
Goal: secure a generic, keyword-rich domain that describes the product category rather than the brand (pattern: Cyrano → apartment-security-cameras.com). The brand domain (e.g. cyrano.ai) still exists; the generic domain is what the new Next.js site ships under for SEO.
Skip this phase only if the user explicitly opts out or the brand domain IS already the generic descriptive domain.
1.5a. Brainstorm candidates
Launch one subagent to generate two tracks of candidates, 15-25 per track. Both tracks describe the product category and never include the brand name.
Track A: Short brandable names (priority). These are the preferred output.
- Length cap: under 8 characters for the stem (TLD excluded). Total domain with TLD should stay ≤ 11 chars where possible. Examples:
s4l.ai (stem 3, total 6), fazm.ai (stem 4, total 7), mk0r.com (stem 4, total 8), fde10x.com (stem 6, total 10). Shorter stems are better.
- Derive from the product category keyword by vowel drops, consonant clusters, or playful contractions (e.g.
camera → cam, cmra, kmr; security → sec, scrty).
- Digit substitution for availability. Swap letters for visually or phonetically similar digits to find available domains:
o ↔ 0, i/l ↔ 1, e ↔ 3, a ↔ 4, s ↔ 5, b ↔ 6 or 8, t ↔ 7, g/q ↔ 9. Pattern matches the user's own brands: s4l.ai, mk0r.com, cl0ne.ai, t8r.tech, fde10x.com.
- TLDs ordered by preference:
.ai (short, premium feel), .io, .com, .co, short-country TLDs like .bz, .me, .to.
- Must still phonetically suggest the category (not random letter soup).
Track B: Keyword-rich descriptive names (fallback). Use when Track A returns nothing available under the price cap.
- 3-6 words max; hyphens allowed.
- Keyword-front-loaded (e.g.
apartment-security-cameras.com, not best.cameras.for.apartments.com).
- Mix TLDs:
.com (preferred), .io, .ai, plus any niche TLD that fits the category (e.g. .cameras, .tech, .studio).
Seed the subagent with the Phase 1 research brief (research/brief.md) and the product's top 3 SEO keywords.
Output: research/domain-candidates.md with Track A on top, Track B below. One domain per line, grouped by track.
1.5b. Bulk discovery via Cloud Domains search
Run Google Cloud Domains' keyword search against the top 3 SEO keywords from the research brief. This adds Google's own suggestions on top of the subagent list:
gcloud domains registrations search-domains "apartment security camera" --format=json
The search-domains response is cached and fast; use it for breadth. Append any attractive suggestions to research/domain-candidates.md.
1.5c. Check availability and up-to-date price
For every candidate, run:
gcloud domains registrations get-register-parameters <candidate> --format=json 2>&1
This returns live availability and price (unlike search-domains which is cached). Parse availability (must be AVAILABLE) and yearlyPrice.units + yearlyPrice.currencyCode.
Run candidates in parallel with xargs -P 5 or a bash loop. Build a table of available domains and first-year price. Filter:
- Drop any domain where
availability != AVAILABLE.
- Default price cap: $50/yr. Anything above goes in a separate "premium" list.
- If
get-register-parameters returns TLD_NOT_SUPPORTED for a candidate, move it to the Vercel fallback list (see 1.5e).
1.5d. Present top picks to the user
Show the user a ranked short-list (5-10 entries) with columns: domain, length (chars incl. TLD), price/yr, tld, registrar (Cloud Domains vs Vercel fallback), track (A=short, B=keyword-rich), rationale.
Ranking rules:
- Track A candidates under 8 characters rank above Track B.
- Within Track A, ties broken by:
.ai > .io > .com > others.
- Within Track B,
.com beats everything else; ties broken by shorter length.
- Always include at least one
.com if any .com is available across either track.
Wait for the user to pick one. Never pre-select. Never auto-buy.
1.5e. Purchase via Cloud Domains (primary path)
Preconditions:
-
Contact YAML (one-time setup per user, reusable across purchases). If ~/.config/gcloud/domain-contacts.yaml does not exist, create it with the user's WHOIS contact info and chmod 600 it. Template:
allContacts:
email: you@example.com
phoneNumber: '+1.XXXXXXXXXX'
postalAddress:
regionCode: US
postalCode: 'XXXXX'
administrativeArea: CA
locality: San Francisco
addressLines: ['123 Example St']
recipients: ['Your Name']
Ask the user for the phone/address the first time; reuse thereafter.
-
GCP project: REUSE the shared social-autoposter project. Do NOT create a new project per client. The default shared project is piastest (confirmed via the fde10x.com purchase, 2026-04-17). All client sites, domains, DNS zones, and Cloud Run services live here. Only create a new project if the user explicitly overrides.
Before running any gcloud command in this phase, verify the active project:
gcloud config set project piastest
gcloud config get-value project
Known org-policy constraint on piastest: iam.disableServiceAccountKeyCreation is enforced, which blocks gcloud iam service-accounts keys create. Phase 6 CI/CD must use Workload Identity Federation, not SA keys. See Phase 6 for the WIF binding pattern.
-
Cloud DNS zone created in the shared project. Name the zone after the domain stem so zones from different clients don't collide:
gcloud dns managed-zones create <STEM>-zone --dns-name="<domain>." --project=piastest
(Example: fde10x.com → fde10x-zone.)
Purchase flow:
-
Pick the right --contact-privacy value per TLD. Do NOT hardcode private-contact-data.
.com (and other Squarespace-reseller TLDs): use redacted-contact-data. Since Google Domains was spun off to Squarespace, new .com registrations reject PRIVATE_CONTACT_DATA with "does not support contact privacy type PRIVATE_CONTACT_DATA."
.ai, .io, and many others: use private-contact-data.
- When in doubt, run
--validate-only first with private-contact-data. If it errors, parse the allowed values from contactSettings.privacy in the error output and retry with the correct flag.
-
Dry-run / preview first with --validate-only:
gcloud domains registrations register <domain> \
--project=piastest \
--contact-data-from-file=~/.config/gcloud/domain-contacts.yaml \
--contact-privacy=<redacted-contact-data OR private-contact-data per rule above> \
--yearly-price="<price from 1.5c>" \
--cloud-dns-zone=<STEM>-zone \
--validate-only
This returns the exact price + any required --notices (some TLDs require HSTS preload or similar).
-
Pause and ask the user to confirm. Print literally: About to register <domain> for $<price>/yr (renews at same). GCP project: piastest. Confirm? Wait for an explicit yes.
-
On yes, re-run the same command WITHOUT --validate-only and with --quiet (since contact/price/DNS are already supplied). Add --notices=<notices> if step 2 flagged any.
-
Verify: gcloud domains registrations list --project=piastest should show the new registration in state ACTIVE (may take up to 5 minutes; REGISTRATION_PENDING is normal during that window).
Domain purchases are irreversible. Per the global Ethics Check rule, step 3 is mandatory.
1.5f. Purchase via Vercel (fallback path only)
Use this path ONLY when Cloud Domains returned TLD_NOT_SUPPORTED for the user's pick (e.g. niche TLDs like .cameras, .studio). In that case:
- Use
playwright-extension (real Chrome, attached to the user's logged-in session).
- Navigate to
https://vercel.com/matt-mediarais-projects/~/domains.
- Enter the chosen domain. Wait for the dashboard's price + renewal preview.
- Pause and ask the user to confirm. Print:
About to buy <domain> for $<price> via Vercel (Cloud Domains doesn't support this TLD). Confirm? and wait for an explicit yes.
- On yes, click "Buy" and wait for the success state.
- Verify:
vercel domains ls | grep <domain>.
- DNS wire-up: in this fallback path, DNS lives in Vercel (not Cloud DNS). Phase 6 must add an A record in Vercel pointing to the Cloud Run Load Balancer's static IP instead of using
gcloud dns record-sets.
Past incident (skl.bz, 2026-03-01): the card on file was declined and the CLI would have given no preview. Dashboard is mandatory here.
1.5g. Record in config.json
Add the project entry to ~/social-autoposter/config.json. The full shape is owned by the downstream writer (with weight, topics, twitter_topics, linkedin_topics, landing_pages, voice, icp, features, differentiator, etc.). The two fields Phase 1.5 is responsible for:
{
"name": "<stem>",
"website": "https://<generic-domain>",
"brand_domain": "https://<brand-domain>",
...rest owned by downstream
}
website is the generic domain (the one the new site deploys to). brand_domain is the original brand URL (kept for reference, may redirect later).
Do NOT add registrar, gcp_project, or dns_zone fields. They are derivable: gcp_project is always piastest, DNS backend is Cloud DNS when the domain was bought via Cloud Domains and Vercel DNS only in the Vercel fallback path (rare). Downstream scripts don't read these fields.
If the fallback Vercel path was used, note it in the session summary; Phase 6 will branch on the registrar at deploy time by probing (gcloud domains registrations describe succeeds = Cloud Domains, else Vercel).
1.5h. Hand off to Phase 2
Phase 2 scaffolds under ~/<stem>-website/ (derive the slug from the purchased domain's stem, e.g. fde10x.com → ~/fde10x-website/). Phase 5 deploy and Phase 6 DNS wire-up use the generic domain as the primary target.
1.5i. Brand identity persists (the generic domain is SEO-only)
Hard rule: the generic domain is a URL, not a rebrand. When Phase 1.5 buys a keyword-rich domain (e.g. fde10x.com, apartment-security-cameras.com), that domain exists purely to rank on SEO keywords. The client's original brand is untouched and must be carried into the new site.
| Carries over from the brand domain | Stays as the generic domain |
|---|
| Brand name (exact spelling, casing, any logomark character) | metadataBase, OpenGraph url, canonical URLs |
| Brand logo + favicon | Support email addresses (hello@<generic>.com) |
| Primary + accent colors | DB table names, internal slugs, PostHog site ID, env var names |
| Heading + body font pairing | GSC property, robots.txt, sitemap URL |
| Tagline / positioning (from the brand site hero) | Cloud Run service name, GCP project slug |
Phase 3 (copy + design system), Phase 3a (Header), Phase 3b (Footer), Phase 3c (metadata + JSON-LD), and Phase 3.5 (email copy, PostHog name) must render the brand name from research/brand-identity.md, not the generic-domain stem. Email and URL strings keep the generic domain.
Example: for Pias Tech on fde10x.com:
- Header wordmark:
π(a|s) PIAS AI (brand)
- Footer copyright:
© 2026 PIAS AI. All rights reserved. (brand)
- Organization JSON-LD:
{ "name": "PIAS AI", "legalName": "PIAS - Policy-Informed Agentic Systems" } (brand)
- Support email:
hello@fde10x.com (domain)
- Metadata base:
https://fde10x.com (domain)
- Resend from:
"PIAS AI <you@your-brand.com>" (both: brand label + domain address)
If the brand name and generic-domain stem happen to be the same (e.g. a client whose brand is literally FDE10x), this rule is a no-op. In every other case, use the brand name from research/brand-identity.md for every user-facing string and reserve the generic domain for technical identifiers.
Phase 2: Scaffold Project
2a. Create Next.js App
cd ~
npx create-next-app@latest CLIENT-website --typescript --tailwind --eslint --app --src-dir --no-turbopack --import-alias "@/*"
cd ~/CLIENT-website
2b. Install @seo/components
npm install @m13v/seo-components@latest
npm install @seo/components@npm:@m13v/seo-components@latest
Both entries will appear in package.json:
"@m13v/seo-components": "^0.8.15",
"@seo/components": "npm:@m13v/seo-components@^0.8.15"
2c. Configure Theme (globals.css)
The theme uses CSS custom properties in :root mapped into Tailwind 4 via @theme inline. Always define both the CSS variable AND the Tailwind mapping. Every client gets a primary color, a dark variant, and an accent color at minimum.
Source the values from research/brand-identity.md (written in Phase 1d). Do not pick arbitrary colors. If the brand site uses hsl(217 91% 50%) as its primary, use #3b82f6 here. If the brand site has no extractable palette, default to a neutral navy-and-white scheme and flag it for the user to confirm.
@layer seo-components, theme, base, components, utilities;
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #1a1a1a;
--primary: #073c61;
--primary-dark: #052d49;
--cta: #e11010;
--cta-dark: #ae0c0c;
--accent: #d4a843;
--accent-light: #f0d88a;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-dark: var(--primary-dark);
--color-cta: var(--cta);
--color-cta-dark: var(--cta-dark);
--color-accent: var(--accent);
--color-accent-light: var(--accent-light);
--font-sans: var(--font-inter);
--font-heading: var(--font-oswald);
}
body {
background: var(--background);
color: var(--foreground);
}
html {
scroll-behavior: smooth;
}
Color naming convention: Use semantic names (primary, cta, accent) in the skill templates below. When building for a specific client, you can also add client-specific names (e.g., --color-navy, --color-red, --color-gold) for readability.
2d. Configure Fonts and @seo/components (layout.tsx)
Use next/font/google with CSS variable mode. The body font goes on --font-sans, the heading font on --font-heading. Apply both variables to the <html> tag.
IMPORTANT: Route group architecture. The root layout must NOT include Header/Footer directly. Instead, use a (main) route group with its own layout for pages that need the site Header/Footer. SEO guide pages under /t/ also go inside (main) so they share the same Header/Footer as the rest of the site.
Root layout (src/app/layout.tsx): fonts, metadata, Organization JSON-LD, @seo/components styles, and {children} only.
import { Inter, Oswald } from "next/font/google";
import { HeadingAnchors } from "@seo/components";
import { SeoComponentsStyles } from "@seo/components/server";
const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
const oswald = Oswald({ variable: "--font-oswald", subsets: ["latin"] });
<html lang="en" className={`${inter.variable} ${oswald.variable} h-full antialiased`}>
<head>
<SeoComponentsStyles />
</head>
<body className="min-h-full flex flex-col font-sans">
<HeadingAnchors />
{children}
{/* Organization JSON-LD here */}
</body>
</html>
SeoComponentsStyles injects the package's prebuilt Tailwind CSS for all @seo/components
HeadingAnchors auto-injects id attributes on H2 elements for sidebar linking and anchor navigation
Main layout (src/app/(main)/layout.tsx): wraps all pages with Header/Footer.
import { Header } from "@/components/Header";
import { Footer } from "@/components/Footer";
export default function MainLayout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
<main className="flex-1">{children}</main>
<Footer />
</>
);
}
All page routes (homepage, about, wins, faq, precall, AND /t/ guide pages) go inside src/app/(main)/. The (main) directory is a Next.js route group: it does not affect URLs.
Font pairing guide:
- Professional services: Inter + Oswald (clean, authoritative)
- Creative/lifestyle: Lato + Playfair Display
- Tech/SaaS: DM Sans + Space Grotesk
- Legal/finance: Source Sans 3 + Merriweather
Phase 3: Build All Pages (Structure fixed, Visual design bespoke)
What this phase is now: the section stack order, component scaffolding (Header, Footer, section wrapper, FAQ, inner page set), and SEO infra are fixed across every client site. The visual treatment of each section is not prescribed and must be designed fresh from research/brand-identity.md, research/visual-references.md, and the design brief you produce in Phase 3.0 below.
Client sites must look distinct from one another. If your markup resembles another client site's markup, or reads as a generic corporate-agency template, you have failed Phase 3. Agents: read exemplars/claude-meter-editorial.html before writing markup, to calibrate what "distinctive, contemporary, product-first" looks like.
Appendix A (at the end of this file, formerly Phase 3d/g/h/i class dumps) contains one reference treatment: "Corporate B2B Safe Baseline". It is ONE example, not the mandate. Do not copy its Tailwind classes verbatim. Use it only if the design brief concludes that corporate-safe is actually the right answer for this client (rare; usually only for legal, finance, healthcare).
Brand identity still persists (re-read Phase 1.5i). Every user-facing string (Header wordmark, Footer copyright, metadata title/siteName/template, OpenGraph siteName, Organization JSON-LD name, WebSite JSON-LD name, email from labels, email subjects, page titles, HtmlSitemap brandName prop, page narrative copy) uses the brand name from research/brand-identity.md. The generic-domain stem only appears in URLs, email addresses, env vars, DB tables, and internal slugs. If you find yourself typing the stem into a title or JSON-LD name, you are making the rename mistake Phase 1.5i exists to prevent.
3.0. Design Brief (gate, no markup before this lands)
Before writing a single line of JSX for this site, produce research/design-brief.md answering all six items. If you can't fill an item, go back to Phase 1g and add another reference.
- Design thesis: one sentence that names the visual identity (e.g. "Editorial Swiss on paper with live product mock", "Terminal-green brutalist with ASCII dividers", "Soft pastel Memphis with kinetic marquee"). Must be specific enough that two agents would produce converging palettes and motifs.
- Palette: 4-6 hex codes with named roles:
background, ink, accent, signal, optional muted and rule. Grounded in research/brand-identity.md. No arbitrary picks.
- Type stack: at least two families (display + body), usually three (display + body + mono). All-sans is a red flag unless the brief explicitly argues for it. Justify each family against the thesis.
- Signature motifs (at least 2): pick from the Phase 1g observations or invent new ones. Examples: numbered section eyebrows, hairline grid (not cards), pull quotes with oversized serif marks, paper grain overlay, italic serif for emphasis, live animated product mock, floating persistent chip, tilt parallax, copy-to-clipboard chips, kinetic type, ASCII dividers, marquee ribbons, asymmetric layouts.
- Interactive moment (at least 1): hero animation tied to product state, scroll-reveal stagger, tilt-on-hover, scroll-driven sheen, copy-to-clipboard feedback, live-ticking numbers. Static pages age badly; give the site one thing that moves and rewards attention.
- What this site will NOT do: list 3-5 patterns it deliberately rejects. Usually the list includes: "rounded-xl shadow-sm card grid", "centered-title + subtitle section headers", "gradient-text headlines", "generic hero with 'Primary Action' + 'Secondary Action' buttons". The point is to foreclose defaults.
Exit criteria for 3.0: research/design-brief.md exists, every item has a concrete answer, the thesis is different from every prior client's thesis (spot-check by reading 2-3 older briefs in ~/.claude/skills/setup-client-website/exemplars/briefs/ if that directory has accumulated entries).
3.1. Design DNA → Tailwind 4 theme tokens
The design brief's palette and type stack feed directly into globals.css :root + @theme inline (set up in Phase 2c). Every section after this point consumes only theme tokens: no hex codes inline, no ad-hoc font-families inline. This is the one rigid rule: if you find yourself writing style={{ color: "#E8471C" }} in a section component, stop and add it to :root as a named variable first.
Semantic token names should follow the brief's role names (e.g. --paper, --ink, --accent, --signal, --rule, --muted), not generic Tailwind defaults (--primary-50, --gray-900). Role-named tokens make the design brief and the CSS mutually readable.
3a. Header Component Blueprint
Sticky nav with logo, dropdown menus, mobile hamburger, and CTA button.
"use client";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
const navLinks = [
{ href: "/", label: "Home" },
{ href: "/how-it-works", label: "How It Works" },
{ href: "/wins", label: "Client Results" },
];
const dropdowns = [
{
label: "About",
items: [
{ href: "/about", label: "Our Story" },
{ href: "/faq", label: "FAQ" },
{ href: "/blog", label: "Blog" },
{ href: "/contact", label: "Contact" },
],
},
];
export function Header() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<header className="sticky top-0 z-50 bg-white border-b border-gray-100 shadow-sm">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
{/* Logo: always use next/image with priority */}
<Link href="/" className="flex items-center">
<Image src="/images/logo.png" alt="CLIENT_NAME" width={200} height={66} priority />
</Link>
{/* Desktop Nav */}
<nav className="hidden lg:flex items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="font-heading text-sm font-semibold uppercase tracking-wider text-gray-700 hover:text-primary transition-colors"
/>
))}
{/* DROPDOWN PATTERN: group + group-hover for CSS-only dropdowns */}
{dropdowns.map((dropdown) => (
<div key={dropdown.label} className="relative group">
<button className="font-heading text-sm font-semibold uppercase tracking-wider text-gray-700 hover:text-primary transition-colors flex items-center gap-1">
{dropdown.label}
<svg className="h-4 w-4 transition-transform group-hover:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="invisible opacity-0 group-hover:visible group-hover:opacity-100 transition-all duration-200 absolute top-full left-0 mt-1 w-56 rounded-md bg-white shadow-lg ring-1 ring-black/5 py-2 z-50">
{dropdown.items.map((item) => (
<Link key={item.href} href={item.href} className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 hover:text-primary transition-colors" />
))}
</div>
</div>
))}
{/* CTA BUTTON in nav */}
<Link
href="/precall"
className="rounded-md bg-cta px-5 py-2.5 font-heading text-sm font-semibold uppercase tracking-wider text-white hover:bg-cta-dark transition-colors"
/>
</nav>
{/* MOBILE HAMBURGER */}
<button className="lg:hidden p-2 text-gray-700" onClick={() => setMobileOpen(!mobileOpen)}>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{mobileOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
</div>
{/* MOBILE NAV: flat list with section headers */}
{mobileOpen && (
<nav className="lg:hidden pb-4 space-y-1">
{navLinks.map((link) => (
<Link key={link.href} href={link.href} className="block py-2 font-heading text-sm font-semibold uppercase tracking-wider text-gray-700 hover:text-primary" onClick={() => setMobileOpen(false)} />
))}
{dropdowns.map((dropdown) => (
<div key={dropdown.label}>
<span className="block py-2 font-heading text-xs font-bold uppercase tracking-wider text-gray-400 mt-2">{dropdown.label}</span>
{dropdown.items.map((item) => (
<Link key={item.href} href={item.href} className="block py-2 pl-4 font-heading text-sm font-semibold uppercase tracking-wider text-gray-700 hover:text-primary" onClick={() => setMobileOpen(false)} />
))}
</div>
))}
<Link href="/precall" className="block rounded-md bg-cta px-5 py-2.5 text-center font-heading text-sm font-semibold uppercase tracking-wider text-white hover:bg-cta-dark mt-3" onClick={() => setMobileOpen(false)} />
</nav>
)}
</div>
</header>
);
}
Key classes: sticky top-0 z-50, font-heading text-sm font-semibold uppercase tracking-wider, invisible opacity-0 group-hover:visible group-hover:opacity-100
3b. Footer Component Blueprint
4-column layout: Brand + description, Company links, Resource links, CTA + contact info. Social icons row at bottom.
import Image from "next/image";
import Link from "next/link";
const companyLinks = [
{ href: "/about", label: "About" },
{ href: "/how-it-works", label: "How It Works" },
{ href: "/faq", label: "FAQ" },
{ href: "/contact", label: "Contact" },
{ href: "/blog", label: "Blog" },
{ href: "/privacy-policy", label: "Privacy Policy" },
{ href: "/sitemap", label: "Sitemap" },
];
const resourceLinks = [
{ href: "/wins", label: "Client Results" },
{ href: "/testimonials", label: "Testimonials" },
];
export function Footer() {
return (
<footer className="bg-primary-dark text-white">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{/* Column 1: Brand */}
<div>
<div className="mb-4">
<Image src="/images/logo.png" alt="CLIENT_NAME" width={200} height={66} />
</div>
<p className="text-gray-300 text-sm leading-relaxed">
Short brand description here.
</p>
</div>
{/* Column 2: Company links */}
<div>
<h3 className="font-heading text-sm font-semibold uppercase tracking-wider text-accent mb-4">Company</h3>
<ul className="space-y-2">
{companyLinks.map((link) => (
<li key={link.href}>
<Link href={link.href} className="text-gray-300 hover:text-white text-sm transition-colors">{link.label}</Link>
</li>
))}
</ul>
</div>
{/* Column 3: Resource links */}
<div>
<h3 className="font-heading text-sm font-semibold uppercase tracking-wider text-accent mb-4">Resources</h3>
<ul className="space-y-2">
{resourceLinks.map((link) => (
<li key={link.href}>
<Link href={link.href} className="text-gray-300 hover:text-white text-sm transition-colors">{link.label}</Link>
</li>
))}
</ul>
</div>
{/* Column 4: CTA + Contact */}
<div>
<h3 className="font-heading text-sm font-semibold uppercase tracking-wider text-accent mb-4">Ready to Get Started?</h3>
<p className="text-gray-300 text-sm mb-4">Short CTA teaser text.</p>
<Link href="/precall" className="inline-block rounded-md bg-cta px-6 py-3 font-heading text-sm font-semibold uppercase tracking-wider text-white hover:bg-cta-dark transition-colors mb-6" />
<div className="space-y-1 text-sm text-gray-300">
<p><a href="mailto:EMAIL" className="hover:text-white transition-colors">EMAIL</a></p>
<p><a href="tel:+1PHONE" className="hover:text-white transition-colors">PHONE</a></p>
<p>CITY, STATE</p>
</div>
</div>
</div>
{/* SOCIAL ICONS ROW */}
<div className="mt-8 flex justify-center gap-4">
{/* Each social: circular button with SVG icon */}
{socialLinks.map((social) => (
<a key={social.label} href={social.href} aria-label={social.label}
className="flex h-10 w-10 items-center justify-center rounded-full bg-gray-700 text-white transition-colors hover:bg-accent hover:text-primary">
{social.icon}
</a>
))}
</div>
{/* COPYRIGHT */}
<div className="mt-8 border-t border-gray-600 pt-8 text-center">
<p className="text-gray-400 text-sm">© {new Date().getFullYear()} CLIENT_NAME. All rights reserved.</p>
</div>
</div>
</footer>
);
}
Key classes: bg-primary-dark text-white, text-accent for section headers, h-10 w-10 rounded-full bg-gray-700 hover:bg-accent hover:text-primary for social icons
3c. Section Layout Patterns
These are the exact section patterns used throughout the site. Each section follows a consistent structure.
Universal Section Wrapper
<section className="bg-{COLOR} py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* content */}
</div>
</section>
- Light sections:
bg-white or bg-gray-50
- Dark sections:
bg-primary with white text
- Section padding:
py-20 sm:py-28 (standard), py-16 sm:py-24 (compact), py-12 (stats bar)
- Content max width:
max-w-7xl for grids, max-w-3xl for prose, max-w-4xl for CTAs
Section Header Pattern
<div className="text-center max-w-2xl mx-auto mb-16">
<h2 className="font-heading text-3xl sm:text-4xl font-bold text-primary mb-4">
Section Title
</h2>
<p className="text-gray-600 text-lg">
Section subtitle/description text.
</p>
</div>
On dark backgrounds, use text-white for h2 and text-gray-300 for description.
3d. Homepage Section Blueprints (REFERENCE EXAMPLE; structure fixed, classes not)
Reading order: the section stack order below is fixed for every client. The Tailwind classes inside each section are ONE reference treatment, not the mandate. After Phase 3.0 lands the design brief, reinterpret each of these seven sections in the visual identity the brief defines. Do not paste the classes verbatim. If your markup reads as a copy of this reference, it fails Phase 3.
Treat this section the way a musician reads sheet music for a jazz standard: the chord changes are fixed (hero → strip → stats → benefits → process → testimonials → CTA), the voicing and phrasing are yours.
The homepage follows this exact section order:
1. Hero Section (White/Light Background)
<section className="relative bg-white overflow-hidden">
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-24 sm:py-32 lg:py-40">
<div className="max-w-3xl">
<p className="font-heading text-primary text-sm sm:text-base font-semibold uppercase tracking-[0.2em] mb-4">
TAGLINE / CATEGORY
</p>
<h1 className="font-heading text-4xl sm:text-5xl lg:text-6xl font-bold text-primary leading-tight mb-6">
Main Headline <span className="text-accent">(Accent Phrase)</span>
</h1>
<p className="text-lg sm:text-xl text-gray-700 leading-relaxed mb-4">
Primary subheadline.
</p>
<p className="text-base sm:text-lg text-gray-500 leading-relaxed mb-10">
Secondary description with more detail.
</p>
<div className="flex flex-col sm:flex-row gap-4">
{/* PRIMARY CTA */}
<Link href="/wins" className="inline-flex items-center justify-center rounded-md bg-cta px-8 py-4 font-heading text-base font-semibold uppercase tracking-wider text-white hover:bg-cta-dark transition-colors">
Primary Action
</Link>
{/* SECONDARY CTA (outline) */}
<Link href="/about#contact" className="inline-flex items-center justify-center rounded-md border-2 border-primary px-8 py-4 font-heading text-base font-semibold uppercase tracking-wider text-primary hover:bg-primary hover:text-white transition-colors">
Secondary Action
</Link>
</div>
</div>
</div>
</section>
Key details:
- Two CTA buttons side by side: solid bg + outline border
- CTA text:
font-heading text-base font-semibold uppercase tracking-wider
- CTA padding:
px-8 py-4
- Hero padding is larger than other sections:
py-24 sm:py-32 lg:py-40
- Accent color used on a key phrase in the headline via
<span className="text-accent">
2. Product/Image Strip
<section className="bg-white py-8 overflow-hidden">
<div className="mx-auto max-w-7xl px-4">
<Image src="/images/product-strip.png" alt="Description" width={1116} height={134} className="w-full h-auto" />
</div>
</section>
3. Stats Bar
<section className="bg-white border-b border-gray-100">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{stats.map((stat) => (
<div key={stat.label} className="text-center">
<p className="font-heading text-4xl sm:text-5xl font-bold text-primary">{stat.value}</p>
<p className="mt-2 text-sm font-semibold uppercase tracking-wider text-gray-500">{stat.label}</p>
</div>
))}
</div>
</div>
</section>
Data shape: { value: "275+", label: "Books Published" }
4. Benefits Card Grid (3 columns)
<section className="bg-gray-50 py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* Section header (centered) */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{benefits.map((benefit) => (
<div key={benefit.title} className="bg-white rounded-xl p-8 shadow-sm border border-gray-100 hover:shadow-md transition-shadow">
{/* Icon in colored circle */}
<div className="inline-flex items-center justify-center w-14 h-14 rounded-lg bg-primary/10 text-primary mb-6">
{benefit.icon}
</div>
<h3 className="font-heading text-xl font-bold text-primary mb-3">{benefit.title}</h3>
<p className="text-gray-600 leading-relaxed">{benefit.description}</p>
</div>
))}
</div>
</div>
</section>
Card pattern: bg-white rounded-xl p-8 shadow-sm border border-gray-100 hover:shadow-md transition-shadow
Icon container: w-14 h-14 rounded-lg bg-primary/10 text-primary
5. Process/How It Works (4 columns, numbered)
<section className="bg-white py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* Section header (centered) */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{steps.map((step) => (
<div key={step.step} className="relative">
<div className="text-6xl font-heading font-bold text-primary/10 mb-2">{step.step}</div>
<h3 className="font-heading text-xl font-bold text-primary mb-3">{step.title}</h3>
<p className="text-gray-600 leading-relaxed">{step.description}</p>
</div>
))}
</div>
{/* Optional team/product photo below */}
<div className="mt-12">
<Image src="/images/team-photo.png" alt="Team" width={1024} height={768} className="w-full h-auto rounded-xl shadow-lg" />
</div>
</div>
</section>
Step number: text-6xl font-heading font-bold text-primary/10 (large, faded background number)
6. Testimonials Section (Dark Background, Glass Cards)
<section className="bg-primary py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center max-w-2xl mx-auto mb-16">
<h2 className="font-heading text-3xl sm:text-4xl font-bold text-white mb-4">Real Results from Real Clients</h2>
<p className="text-gray-300 text-lg">Description text.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{testimonials.map((t) => (
<div key={t.name} className="bg-white/5 border border-white/10 rounded-xl p-8 backdrop-blur-sm">
{/* Star rating */}
<div className="flex items-center gap-1 mb-4">
{[...Array(5)].map((_, i) => (
<svg key={i} className="h-5 w-5 text-accent" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
{/* Quote */}
<blockquote className="text-white/90 leading-relaxed mb-6 italic">
“{t.quote}”
</blockquote>
{/* Author with headshot */}
<div className="border-t border-white/10 pt-4 flex items-start gap-4">
<Image src={t.image} alt={t.name} width={64} height={64} className="w-16 h-16 rounded-full object-cover flex-shrink-0" />
<div>
<p className="font-heading font-bold text-white">{t.name}</p>
<p className="text-gray-400 text-sm">{t.title}</p>
<p className="mt-2 text-accent font-heading font-semibold text-sm uppercase tracking-wider">{t.result}</p>
</div>
</div>
</div>
))}
</div>
{/* CTA button centered below */}
<div className="text-center mt-12">
<Link href="/wins" className="inline-flex items-center justify-center rounded-md bg-cta px-8 py-4 font-heading text-base font-semibold uppercase tracking-wider text-white hover:bg-cta-dark transition-colors">
See All Client Results
</Link>
</div>
</div>
</section>
Glass card: bg-white/5 border border-white/10 rounded-xl p-8 backdrop-blur-sm
Star SVG path: M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z
7. Final CTA Section (White Background)
<section className="bg-white py-20 sm:py-28">
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 text-center">
<h2 className="font-heading text-3xl sm:text-4xl font-bold text-primary mb-6">
Ready to Get Started?
</h2>
<p className="text-gray-600 text-lg mb-10 max-w-2xl mx-auto">
Join our clients who have used [PRODUCT] to grow their business.
</p>
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Link href="/precall" className="inline-flex items-center justify-center rounded-md bg-cta px-8 py-4 font-heading text-base font-semibold uppercase tracking-wider text-white hover:bg-cta-dark transition-colors">
Primary CTA
</Link>
<Link href="/wins" className="inline-flex items-center justify-center rounded-md border-2 border-primary px-8 py-4 font-heading text-base font-semibold uppercase tracking-wider text-primary hover:bg-primary hover:text-white transition-colors">
Secondary CTA
</Link>
</div>
</div>
</section>
3e. Inner Page Hero Pattern (Dark Background)
Used on About, Wins, FAQ, and all non-homepage pages:
<section className="bg-primary py-16 sm:py-24">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 text-center">
<h1 className="font-heading text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6">
Page Title
</h1>
<p className="text-gray-300 text-lg sm:text-xl max-w-2xl mx-auto">
Page subtitle.
</p>
</div>
</section>
Variant with gradient overlay (precall, FAQ):
<section className="relative bg-primary overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary-dark to-primary opacity-90" />
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-24 sm:py-32">
{/* content with z-index above gradient */}
</div>
</section>
Variant with accent highlight (FAQ):
<h1 className="font-heading text-4xl sm:text-5xl lg:text-6xl font-bold text-white leading-tight">
Frequently Asked <span className="text-accent">Questions</span>
</h1>
3f. FAQ Accordion Component Blueprint
Client component with useState for expand/collapse:
"use client";
import { useState } from "react";
export function FAQItem({ question, answer }: { question: string; answer: string }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="border border-gray-100 rounded-xl overflow-hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-6 text-left bg-white hover:bg-gray-50 transition-colors"
>
<span className="font-heading text-lg font-bold text-primary pr-4">{question}</span>
<svg
className={`h-6 w-6 text-primary shrink-0 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="px-6 pb-6 bg-white">
<p className="text-gray-600 leading-relaxed">{answer}</p>
</div>
)}
</div>
);
}
Usage: Render in a <div className="space-y-4"> container within a bg-gray-50 section. Always include JSON-LD FAQPage schema alongside.
3g. Case Study / Wins Page Blueprint (REFERENCE EXAMPLE)
Same rule as 3d: structure fixed, visual treatment bespoke. Featured tier + additional tier is the pattern; the cards below are one rendering. If the design brief names "pull quotes with serif marks" as a motif, render case studies as pull quotes, not shadow cards. Always skip this page entirely if the client has no case studies to tell yet; a page with three fabricated wins is worse than no page.
Two tiers: featured case studies (detailed cards) and additional testimonials (grid).
Featured Case Study Card
interface CaseStudy {
name: string;
location: string;
company: string;
industry: string;
books: string[];
quote: string;
results: string[];
highlight: string;
headshot: string;
bookCover?: string;
}
{}
<article className={`rounded-2xl border border-gray-100 overflow-hidden shadow-sm ${idx % 2 === 0 ? "bg-gray-50" : "bg-white"}`}>
<div className="p-8 sm:p-12">
<div className="flex flex-col lg:flex-row lg:items-start gap-8">
{/* LEFT COLUMN (2/5): Info */}
<div className="lg:w-2/5">
{/* Industry badge */}
<span className="inline-block bg-primary text-white text-xs font-heading font-semibold uppercase tracking-wider px-3 py-1 rounded-full">
{cs.industry}
</span>
{/* Headshot + name */}
<div className="flex items-center gap-4 mb-4">
<Image src={cs.headshot} alt={cs.name} width={80} height={80} className="rounded-full object-cover w-20 h-20" />
<div>
<h3 className="font-heading text-2xl sm:text-3xl font-bold text-primary mb-1">{cs.name}</h3>
<p className="text-gray-500 mb-0.5">{cs.company}</p>
<p className="text-gray-400 text-sm">{cs.location}</p>
</div>
</div>
{/* Book/product tags */}
<div className="flex flex-wrap gap-2">
{cs.books.map((book) => (
<span key={book} className="inline-block bg-accent/20 text-primary text-sm font-medium px-3 py-1 rounded-md italic">{book}</span>
))}
</div>
{/* Highlight metric box */}
<div className="bg-primary rounded-lg p-4 text-center">
<p className="text-accent font-heading font-bold text-xl">{cs.highlight}</p>
</div>
</div>
{/* RIGHT COLUMN (3/5): Quote + Results */}
<div className="lg:w-3/5">
<blockquote className="text-lg sm:text-xl text-gray-700 italic leading-relaxed mb-8 border-l-4 border-accent pl-6">
“{cs.quote}”
</blockquote>
<p className="text-sm font-semibold uppercase tracking-wider text-gray-500 mb-3">Key Results</p>
<ul className="space-y-2">
{cs.results.map((result) => (
<li key={result} className="flex items-start gap-3">
<svg className="h-5 w-5 text-green-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-700">{result}</span>
</li>
))}
</ul>
</div>
</div>
</div>
</article>
Additional Testimonials Grid
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{testimonials.map((t) => (
<div key={t.name} className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 hover:shadow-md transition-shadow flex flex-col">
{/* Star rating */}
<div className="flex items-center gap-1 mb-3">
{[...Array(5)].map((_, i) => (
<svg key={i} className="h-4 w-4 text-accent" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l..." />
</svg>
))}
</div>
<blockquote className="text-gray-700 italic leading-relaxed mb-4 flex-1">
“{t.quote}”
</blockquote>
<div className="border-t border-gray-100 pt-3">
<p className="font-heading font-bold text-primary text-sm">{t.name}</p>
<p className="text-gray-500 text-xs">{t.company}</p>
{t.result && (
<p className="mt-1 text-accent font-heading font-semibold text-xs uppercase tracking-wider">{t.result}</p>
)}
</div>
</div>
))}
</div>
3h. Book a Call / Precall Page Blueprint (REFERENCE EXAMPLE)
Structure fixed, visual treatment bespoke. The two-column scheduling + proof pattern is the pattern; classes below are one rendering. For product-led sites (Claude Meter, developer tools, self-serve SaaS), this page is often replaced by /install or /signup with a copy-to-clipboard chip; see the Claude Meter exemplar. Use your judgment from the design brief on whether book-a-call is actually the right CTA for this client.
Two-column layout: left (2/3) has video + scheduling widget, right (1/3) has testimonials sidebar.
{}
<section className="relative bg-primary overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary-dark to-primary opacity-90" />
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-24 sm:py-32">
<div className="max-w-3xl mx-auto text-center">
<Image src="/images/founder.png" alt="Founder Name" width={200} height={200} className="rounded-full mx-auto mb-6" />
<h1 className="font-heading text-4xl sm:text-5xl lg:text-6xl font-bold text-white leading-tight mb-6">
Ready to Get Started? <span className="text-accent">Book a Call Today!</span>
</h1>
</div>
</div>
</section>
{}
<section className="bg-white py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* LEFT (2/3): Video + Calendly */}
<div className="lg:col-span-2 space-y-16">
{/* Video */}
<div>
<h2 className="font-heading text-2xl sm:text-3xl font-bold text-primary mb-6">Watch This Video</h2>
<div className="aspect-video rounded-xl overflow-hidden shadow-lg">
<iframe src="https://player.vimeo.com/video/VIDEO_ID?badge=0&autopause=0" width="100%" height="100%" frameBorder="0" allow="autoplay; fullscreen; picture-in-picture" allowFullScreen className="w-full h-full" />
</div>
</div>
{/* Scheduling */}
<div>
<h2 className="font-heading text-2xl sm:text-3xl font-bold text-primary mb-6">Select a Time Below</h2>
<div className="bg-white rounded-xl shadow-lg overflow-hidden" style={{ minHeight: '700px' }}>
<iframe src="https://calendly.com/USER/MEETING?embed_type=Inline&hide_event_type_details=1" width="100%" height="700" frameBorder="0" className="w-full" />
</div>
</div>
</div>
{/* RIGHT (1/3): Testimonial Sidebar */}
<div className="space-y-6">
<h3 className="font-heading text-xl font-bold text-primary mb-4">What Our Clients Say</h3>
{testimonials.map((t) => (
<div key={t.name} className="bg-gray-50 rounded-xl p-6 border border-gray-100">
{/* Stars */}
<blockquote className="text-gray-700 leading-relaxed mb-3 italic text-sm">“{t.quote}”</blockquote>
<div className="flex items-center gap-3">
{t.headshot && <Image src={t.headshot} alt={t.name} width={48} height={48} className="w-12 h-12 rounded-full object-cover" />}
<p className="font-heading font-bold text-primary text-sm">{t.name}</p>
</div>
</div>
))}
{/* Contact Info Card */}
<div className="bg-primary rounded-xl p-6 mt-8">
<h3 className="font-heading text-lg font-bold text-white mb-4">Get in Touch</h3>
<div className="space-y-3">
<a href="mailto:EMAIL" className="flex items-center gap-3 text-gray-300 hover:text-white transition-colors text-sm">
{/* Email SVG icon in text-accent */} EMAIL
</a>
<a href="tel:+1PHONE" className="flex items-center gap-3 text-gray-300 hover:text-white transition-colors text-sm">
{/* Phone SVG icon in text-accent */} PHONE
</a>
</div>
</div>
</div>
</div>
</div>
</section>
3i. About Page Blueprint (REFERENCE EXAMPLE)
Structure fixed, visual treatment bespoke. The section order below is the pattern. "Dark Hero + Glass Card" is one rendering; reinterpret in the brief's identity. Product-led / open-source sites may replace About entirely with a single "Why / Who built this" editorial page.
Section order: Dark Hero, Stats Bar, Founder Story (prose with photo), Team Photo, Values Grid (2-col), "Who We Serve" Checklist Grid (3-col), Contact CTA (dark bg with glass card).
Values card grid:
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{values.map((v) => (
<div key={v.title} className="bg-white rounded-xl p-8 shadow-sm border border-gray-100">
<h3 className="font-heading text-xl font-bold text-primary mb-3">{v.title}</h3>
<p className="text-gray-600 leading-relaxed">{v.description}</p>
</div>
))}
</div>
"Who We Serve" checklist grid:
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-4xl mx-auto">
{professions.map((profession) => (
<div key={profession} className="flex items-center gap-3 bg-gray-50 rounded-lg p-4">
<svg className="h-5 w-5 text-accent shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-primary font-medium">{profession}</span>
</div>
))}
</div>
Contact CTA glass card (dark section):
<div className="bg-white/10 border border-white/20 rounded-2xl p-8 sm:p-12 max-w-xl mx-auto">
<h3 className="font-heading text-2xl font-bold text-white mb-6">Card Title</h3>
<div className="space-y-4 text-left mb-8">
{items.map((item) => (
<div key={item} className="flex items-start gap-3">
<svg className="h-5 w-5 text-accent mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-300">{item}</span>
</div>
))}
</div>
<Link href="/precall" className="inline-flex items-center justify-center w-full rounded-md bg-cta px-8 py-4 font-heading text-base font-semibold uppercase tracking-wider text-white hover:bg-cta-dark transition-colors">
CTA Text
</Link>
</div>
3j. Reusable SVG Icons
Checkmark (for results, features, checklists):
<svg className="h-5 w-5 text-green-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Chevron down (for dropdowns, accordions):
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
Star (for ratings):
<svg className="h-5 w-5 text-accent" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
Hamburger / Close (for mobile nav):
{}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
{}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
3k. Typography Quick Reference
| Element | Classes |
|---|
| Page h1 | font-heading text-4xl sm:text-5xl lg:text-6xl font-bold text-primary leading-tight |
| Section h2 | font-heading text-3xl sm:text-4xl font-bold text-primary mb-4 |
| Card h3 | font-heading text-xl font-bold text-primary mb-3 |
| Body text | text-gray-600 leading-relaxed |
| Tagline/label | font-heading text-sm font-semibold uppercase tracking-wider text-gray-500 |
| Accent label | font-heading text-sm font-semibold uppercase tracking-[0.2em] |
| Stat number | font-heading text-4xl sm:text-5xl font-bold text-primary |
| Stat label | text-sm font-semibold uppercase tracking-wider text-gray-500 |
| Quote text | text-gray-700 italic leading-relaxed |
| Dark bg h2 | font-heading text-3xl sm:text-4xl font-bold text-white mb-4 |
| Dark bg body | text-gray-300 text-lg |
| CTA button | font-heading text-base font-semibold uppercase tracking-wider |
| Nav link | font-heading text-sm font-semibold uppercase tracking-wider text-gray-700 |
3l. Color Usage Quick Reference
| Context | Light section | Dark section |
|---|
| Background | bg-white or bg-gray-50 | bg-primary or bg-primary-dark |
| Heading | text-primary | text-white |
| Body text | text-gray-600 | text-gray-300 |
| Subtle text | text-gray-500 | text-gray-400 |
| Card | bg-white rounded-xl p-8 shadow-sm border border-gray-100 | bg-white/5 border border-white/10 rounded-xl p-8 |
| CTA button | bg-cta text-white hover:bg-cta-dark | Same |
| Outline button | border-2 border-primary text-primary hover:bg-primary hover:text-white | border-2 border-white text-white hover:bg-white hover:text-primary |
| Accent highlight | text-accent | text-accent |
| Badge | bg-primary text-white text-xs px-3 py-1 rounded-full | N/A |
| Divider | border-gray-100 | border-white/10 |
3m. Image Integration Checklist
For every page, verify these images are included:
3n. Video and Widget Embeds
{}
<div className="aspect-video rounded-xl overflow-hidden shadow-lg">
<iframe
src="https://player.vimeo.com/video/VIDEO_ID?badge=0&autopause=0"
width="100%" height="100%" frameBorder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen title="Video Title" className="w-full h-full"
/>
</div>
{}
<div className="bg-white rounded-xl shadow-lg overflow-hidden" style={{ minHeight: '700px' }}>
<iframe
src="https://calendly.com/USERNAME/MEETING_TYPE?embed_type=Inline&hide_event_type_details=1"
width="100%" height="700" frameBorder="0"
title="Schedule a Call" className="w-full"
/>
</div>
3o. Structured Data
Every page gets JSON-LD. Minimum set:
{ "@type": "Organization", "name": "...", "url": "...", "foundingDate": "...", "numberOfEmployees": { "@type": "QuantitativeValue", "value": N }, "sameAs": [...] }
{ "@type": "BreadcrumbList", "itemListElement": [{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://domain.com" }, ...] }
{ "@type": "Review", "author": { "@type": "Person", "name": "..." }, "reviewBody": "...", "itemReviewed": { "@type": "Service", "name": "...", "provider": { "@type": "Organization", "name": "..." } }, "reviewRating": { "@type": "Rating", "ratingValue": 5, "bestRating": 5 } }
{ "@type": "FAQPage", "mainEntity": [{ "@type": "Question", "name": "...", "acceptedAnswer": { "@type": "Answer", "text": "..." } }] }
{ "@type": "WebPage", "name": "...", "url": "...", "description": "..." }
3p. SEO Infrastructure
Use the generateSitemap helper from @m13v/seo-components (v0.12.0+). It walks src/app for page.tsx files, skips route groups/dynamic segments/api, and applies the default priority tiers. Do NOT hand-roll a sitemap or hardcode URLs.
src/app/sitemap.ts:
import type { MetadataRoute } from "next";
import { generateSitemap } from "@seo/components/server";
export default function sitemap(): MetadataRoute.Sitemap {
return generateSitemap({ baseUrl: "https://DOMAIN" });
}
Default priority tiers (overridable via the priorities option):
| Priority | Pages |
|---|
| 1.0 | Homepage |
| 0.9 | Core conversion pages (how it works, wins, book, contact, pricing) |
| 0.8 | Secondary pages (about, faq, blog, podcast, testimonials, case-studies) |
| 0.6 | Resource pages (guides, trainings, tools, resources, /t/) |
| 0.3 | Legal pages (privacy, terms, legal, cookies) |
| 0.7 | Fallback for unmatched routes |
Also create src/app/sitemap/page.tsx as a human-readable HTML sitemap using the HtmlSitemap component (served alongside /sitemap.xml):
import type { Metadata } from "next";
import { HtmlSitemap } from "@seo/components";
import { walkPages } from "@seo/components/server";
export const metadata: Metadata = {
title: "Sitemap — BRAND",
description: "Every page on BRAND, grouped by section.",
};
export default function SitemapPage() {
const pages = walkPages({ includeHome: false });
return <HtmlSitemap pages={pages} brandName="BRAND" />;
}
Create src/app/robots.ts with sitemap reference.
Phase 3.5: Integrations (PostHog + Resend + Neon)
Every client site gets the same three integrations: PostHog for analytics, Resend for transactional email, and Neon for a lightweight relational store. The contract matches a prior site (use your reference repo). The pattern is fixed: port it verbatim, change only the brand strings and the from address.
Why these three:
- PostHog: required for
@seo/components NewsletterSignup + TrackedCta. The NewsletterSignup component calls window.posthog?.capture("newsletter_subscribed", ...) on success, so PostHog must be globally attached before the component mounts. Default path: hand-rolled PostHogProvider in 3.5b (shared-project sites). Only use <FullSiteAnalytics> if the site has its own dedicated PostHog project (3.5b-alt, rare).
- Resend:
/api/newsletter adds the subscriber to an audience and fires a welcome email. /api/contact replaces mailto: with a server-validated submission that also logs to Neon. Inbound webhook stores replies and forwards them to you@example.com.
- Neon:
@neondatabase/serverless for subscriber/email logs. No pool, no lifecycle. One DATABASE_URL, tagged-template SQL.
Scope note: Phase 3.5d's Book-a-Call helpers and Phase 3.5l's Cal.com event type creation are [opt-in: book-a-call]. Skip both unless the invoker requested Book-a-Call — see "Optional scope flags" above.
Hard rule for all external IDs/keys: every phc_..., audience UUID, Neon URL, Cal.com slug is pulled from keychain or live API response and substituted into the real file. Never commit a placeholder string like phc_REPLACE_ME_... or REPLACE_WITH_..._PROJECT_ID into .env.production or config.json. If the real value isn't available yet, stop Phase 3.5 here — do not proceed to Phase 6 with placeholders. Placeholders have shipped to production twice (fde10x 2026-04-19, claude-meter 2026-04-20) and silently dark-launched PostHog for the site.
3.5a. Install deps
npm install posthog-js posthog-node @neondatabase/serverless framer-motion
framer-motion is already required by NewsletterSignup; install it even if nothing else needs it yet.
3.5b. PostHog analytics wiring: hand-rolled provider with site group (default for client sites)
Default path for every new client site (matches ~/fde10x-website/src/components/posthog-provider.tsx): a tiny hand-rolled client component that (a) initialises posthog-js, (b) sets window.posthog so legacy helpers find it, (c) calls ph.group("site", SITE_ID) + ph.register({ site: SITE_ID }) so every event is tagged with the client slug, and (d) wraps children in PHProvider + SeoAnalyticsProvider.
Why this is the default, not <FullSiteAnalytics>: every client site piggybacks on a shared PostHog project (the m13v org has an 8-project hard cap, and creating a new project per client blows past it). To filter cleanly across shared projects, every event must carry a site group tag. <FullSiteAnalytics> (shipped by @m13v/seo-components) does not accept siteId / loaded / group props — it only wires the key + host + provider context. So any site sharing a PostHog project MUST use the hand-rolled provider below. Save <FullSiteAnalytics> for standalone projects that have their own dedicated PostHog project (rare — see 3.5l for when that applies).
Why bother attaching to window.posthog: three of four client sites (Fazm, Cyrano, Mediar) silently dropped @m13v/seo-components analytics events because posthog-js was loaded via ESM but never attached to window.posthog. Library components call window.posthog?.capture(...), which is a silent no-op when window is empty. The (window as ...).posthog = posthog line below is load-bearing.
"use client";
import posthog from "posthog-js";
import { PostHogProvider as PHProvider } from "posthog-js/react";
import { SeoAnalyticsProvider } from "@m13v/seo-components";
import { useEffect } from "react";
const POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const POSTHOG_HOST =
process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
const SITE_ID = process.env.NEXT_PUBLIC_POSTHOG_SITE_ID || "BRAND_SLUG";
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (POSTHOG_KEY && typeof window !== "undefined") {
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
person_profiles: "identified_only",
capture_pageview: true,
capture_pageleave: true,
disable_session_recording: false,