| name | 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.
Warm-start handoff: when the client has an existing blog, Phase 1c.1 produces research/client-blog-inventory.json, a seed list of unbranded category queries derived from the client's existing posts. Phase 9 upserts it into seo_keywords with source = 'client_blog' before the cron activates, so the first 20 to 50 guide pages target topics the client has already invested editorial work into rather than cold DataForSEO discovery. The inventory captures topics and traffic signals only, never body copy: every /t/<slug> page is still a substantially longer, unbranded rewrite written by gsc-seo-page.
Business model: we own the funnel end-to-end
We build a separate SEO site alongside the client's brand site because we own this new domain, the traffic it earns, and the conversion path that ends at our booking link (or our tracked get-started URL). The client keeps their brand site; we route organic visitors through a tracked CTA on a domain we control until the booking fires. That is the whole business model: we are paid for bookings, not for traffic, so every visitor who leaves to the client's brand or product domain before the CTA fires is revenue handed back for free.
Hard rule: no marketing CTA ever points at the client's brand or product domain. Not <brand_domain>, not hire.<brand_domain>, not app.<brand_domain>, nowhere in src/app/(main)/. Privacy and terms should be written as self-contained pages on the new domain, not as stub pages that link out to the brand site's legal copy. Every tier button, hero CTA, pricing card, inline CTA, modal CTA, and footer CTA terminates on our site: /precall, BookCallLink, GetStartedLink, a modal that captures email then routes to a tracked booking, or a tracked trackAs="..." CTA. A raw <a href="https://<anything-the-client-owns>"> on a marketing page is a bug, same severity as a raw <a href="https://cal.com/..."> that bypasses withBookingAttribution. Phase 8 must grep src/app/(main)/ for anchors pointing at brand_domain (or any of its subdomains) and fail the audit on any hit.
Self-serve CTAs on a book-a-call-only site. When the research brief surfaces a self-serve product motion (pricing page, "Start free" tier, download button) but only book-a-call scope is enabled, do NOT link the self-serve CTA at the client's product app. Build an email-capture modal on our domain that collects the email, sends a welcome transactional via Resend containing the Book-a-Call link, and in the modal itself routes the user to /precall or BookCallLink. The client still gets the signup, we keep the funnel.
Hard rule, copy edition (gated-redirect on): the destination URL never appears in user-visible text either. The anchor rule above closes the link surface (no <a href> to the brand). When gated-redirect is on, the destination URL must also be absent from: body copy in any .tsx under src/, FAQ Q&A objects, footer paragraphs, modal subtitles, metadata.description, OpenGraph descriptions, JSON-LD sameAs arrays in layout.tsx, alt text, and the welcome-email HTML template inside src/app/api/signup/route.ts. The bare brand name (Jungle, Piastech) may stay where it's needed for legal/entity disclosure (privacy and terms keep <Brand> Inc. as the operating entity), but the literal URL string (app.<brand>.com, <brand>.com, even fragments like <brand>.com/terms-of-service) does not. The welcome email body sent by Resend carries the access link in an <a href={redirect}>, which is unavoidable and fine; that email is post-gate, not pre-gate. Phase 8 audits this by grepping the whole src/ tree (not just src/app/(main)/) for the brand and app URL stems and failing on any match outside the welcome-email payload. A user-visible "Where am I being redirected?" FAQ Q&A that names the destination is the most common source of leak; do not generate one.
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", "with get-started", "add a contact form"). If the scope flag is not mentioned, skip every phase marked [opt-in: book-a-call] / [opt-in: get-started] — do not half-scaffold placeholders.
| Flag | Default | Triggers phases |
|---|
book-a-call | off | 3.5d Book-a-Call helpers, 3.5k.1 /r/[code] short-link redirect, 3.5l Cal.com event type creation, 6g Cal.com webhook wiring, Phase 8 booking-verification row, Phase 10a booking_link field |
get-started | off | 3.5d′ Get-Started helpers, Phase 8 get-started-verification row, Phase 10a get_started_link field |
gated-redirect | off | 3.5d″ EmailGateModal + /api/signup scaffold (with mandatory Resend audience upsert + newsletter_subscribed event), server-only GET_STARTED_URL, runtime envs RESEND_API_KEY+RESEND_AUDIENCE_ID on the deploy target (NOT just .env.local), Phase 8 destination-leak audit + audience-population audit + Email-Signups dashboard audit. Requires get-started to also be on. |
Get-Started covers every self-serve primary CTA: downloads (Mac .dmg, App Store, /download), installs (/install, Chrome Web Store, browser extension listings), and signups (SaaS app.<domain> entry, waitlist, trial start). They all fire the same get_started_click PostHog event and roll into one "Get Started" column in the dashboard. Aliases like download-link or signup-link map to this flag.
Always-on (NOT behind a flag): inbound email. Every client site sends from matt@<domain> (per defaults.sender_local_part in ~/social-autoposter/config.json). Because that's a real human-named address, recipients WILL hit Reply. Every site MUST therefore wire Resend Inbound (Phase 3.5k) so replies route to defaults.inbound_forward_email (i@m13v.com by default). This is not optional regardless of which feature flags are on. Studyly 2026-05-05 shipped without inbound and we lost replies from 6 captured signups before noticing.
When a flag is off: do not add the corresponding CTA components, do not add the corresponding field to config.json, and skip the related Phase 8 checklist rows.
- Book-a-call off → no
<BookCallLink>/<BookCallTracker>, no Cal.com event type, no booking_link in config.json.
- Get-started off → no
<GetStartedLink>/<GetStartedCTA>, no get_started_link in config.json. The stats pipeline counts get_started_click per-host, so a site that does not fire this event will simply show 0 — harmless.
Both flags can be on for the same site. Fazm is the canonical example: it has a booking_link (Cal.com team URL for pilot calls) and a get_started_link (Mac app download). Assrt is another: booking_link for the pilot call plus get_started_link pointing at app.assrt.ai for self-serve signup.
Gated-redirect: when to turn it on. The default get-started scaffold renders <GetStartedLink href={GET_STARTED_URL} target="_blank">, which puts the destination URL in the DOM (href attribute, hover status bar, "copy link"). That is fine when the destination IS our domain (Fazm: fazm.cc/download) or when we don't mind users bypassing email capture (Assrt: app.assrt.ai with no separate brand domain). Turn gated-redirect ON whenever the get-started destination is on a different domain we want to keep hidden until the visitor submits an email, typically when brand_domain ≠ website AND the brand_domain is the self-serve product app (e.g. studyly.io routes to app.jungleai.com). With this flag on, every Get-Started CTA opens an email-gate modal; /api/signup collects the email, sends a Resend welcome with the access link, and returns the redirect URL via JSON for window.location.href. The destination URL never appears in HTML, body copy, FAQ, JSON-LD, or metadata.description. Studyly is the canonical example.
Rationale: free OSS tools and install-driven products (ClaudeMeter, appmaker-style utilities) have no "book a call" conversion — they need get-started instead. Enterprise pilot sites with no self-serve path need only book-a-call. Product-led sites with both a pilot offer and a self-serve entry point need both. Forcing either wiring on a site that does not match the conversion shape produces dead links and skewed funnel stats.
Prerequisites
- Google Cloud project under the org specified in
~/social-autoposter/config.json > defaults.gcp_organization_name (see 1.5e — per-client projects are created as <slug>-prod)
- GitHub org or personal account
- PostHog account (org specified in
config.json > defaults.posthog_org) 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.
1c.1. Client Blog Inventory (warm-start seed for gsc-seo-page)
Skip this sub-phase only if the client has no blog, resources, guides, learn area, or article archive. When a post corpus exists, the upfront cost is one pass now versus a cold start for the SEO pipeline.
Goal: turn the client's existing blog into a seed list that Phase 9 upserts into the seo_keywords table on day one. The first 20 to 50 guide pages on our domain then target topics the client has already invested editorial work into, instead of cold DataForSEO discovery.
Hard boundary: the inventory captures topics, queries, and traffic signals, never body copy. Our /t/<slug> pages must be substantially longer, unbranded-intent rewrites of the same topic. Pasting their post body would trigger duplicate-content filtering and push our pages behind theirs in the SERP, defeating the entire reason we run a separate SEO domain (re-read line 15 of this file).
1c.1.a. Discover blog URLs
Extend the 1c crawl to follow paths matching /blog, /resources, /guides, /learn, /posts, /articles, /insights, /academy, /journal, plus any /[category]/[post-slug] pattern observed in the navigation. If the client has a sitemap, parse sitemap.xml and any post-sitemap.xml first; that is usually the complete list. Walk category and tag indexes to catch posts not in the sitemap.
1c.1.b. For each post, capture
| Field | Source |
|---|
url | canonical URL from <link rel="canonical"> or final URL after redirects |
title | page <title> (often "Post Title | Brand"); strip the brand suffix |
h1 | first <h1> text |
h2s | ordered list of <h2> strings (reveals sub-topics worth splitting into separate /t/<slug> pages) |
publish_date | <meta property="article:published_time">, visible byline date, or sitemap lastmod |
word_count | rough body word count (strip nav, sidebar, footer) |
primary_query | inferred unbranded category query, 2 to 5 words (see 1c.1.c) |
secondary_queries | up to 3 adjacent queries each large enough to be its own /t/<slug> page |
suggested_slug | kebab-case of primary_query |
gsc_impressions | trailing 90-day impressions if we have GSC access (1c.1.d), else null |
gsc_position | trailing 90-day average position if available, else null |
dedupe_note | "", or "near-dup of <other url>" when the client has two posts on the same query |
skip | false by default; "brand-only" if the post cannot be rewritten for unbranded intent (see 1c.1.c step 4) |
1c.1.c. Deriving primary_query
The client's title is almost never the right query for our domain. It is usually brand-forward ("How Cyrano Helps Apartment Owners Deter Package Theft") and we need unbranded category intent ("how to prevent apartment package theft"). For each post:
- Strip the brand name and product name from the title.
- Rewrite as the phrase a buyer who has never heard of the client would Google.
- Validate informally with Google Autocomplete; an autocompletion suggests the query has demand.
- If the rewrite forces you into a branded or tooling-specific phrase ("Cyrano integrations", "Fazm pricing"), mark the row
skip: brand-only and exclude it from the seed list. Brand queries belong on the brand site, not on our SEO domain.
1c.1.d. GSC join (optional but high-value)
If the client will grant Search Console access, have them add the email defined in ~/social-autoposter/.env as GSC_ADMIN_EMAIL (the address that owns the GCP project running this pipeline) as a Restricted user on their GSC property before this sub-phase runs. With access, fetch trailing 90-day query data per post URL through the GSC API (searchanalytics.query with dimensionFilterGroups on page) and join it onto the inventory. Priority tiers (used as a soft sort in the output .md, no special handling in the seed insert):
- Tier 1 (highest priority): posts ranking positions 5 to 25 on a non-branded query. Proven demand, good on-domain authority, reachable with a better page on our domain.
- Tier 2: posts ranking positions 26 to 100. Topical demand exists but the post under-delivers; our rewrite has plenty of room above it.
- Tier 3 (deprioritize): posts ranking positions 1 to 4 on their own domain. The client already owns that query; our version would cannibalize before it overtakes. Seed only if the secondary queries on that post are unowned.
When GSC access is not granted, all rows go in as Tier 2 by default; volume scoring happens later through DataForSEO via seo_keywords.volume.
1c.1.e. Output two artifacts
Write a human-readable Markdown table at research/client-blog-inventory.md (the user skims and vetoes rows by setting skip: <reason>) and a machine-readable sidecar at research/client-blog-inventory.json (array of row objects in the schema above). The Phase 9 seed step reads the JSON file directly. Suggested .md columns: url, primary_query, tier, gsc_impressions, suggested_slug, skip.
1c.1.f. Seeding happens in Phase 9, not now
Seeding into seo_keywords cannot run until the product row exists in ~/social-autoposter/config.json (Phase 10a) and the GSC pipeline credentials resolve (Phase 9 prerequisites). Phase 9 step 6 runs the upsert and uses:
INSERT INTO seo_keywords (product, keyword, slug, source, status)
VALUES (%s, %s, %s, 'client_blog', 'unscored')
ON CONFLICT (product, keyword) DO NOTHING
source = 'client_blog' gives traceability in the dashboard (warm-start versus cold-discovery) without special-casing the scoring path. status = 'unscored' lets the existing db_helpers.pick_next_keyword flow score, rank, and promote the seeds the same way it handles every other inbound keyword.
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.
**Direct competitor root domains (5 to 7):** plain bullet list of bare domains, e.g. `scribemedia.com`, `forbesbooks.com`. These are lifted verbatim into the `competitor_domains` field of `config.json` in Phase 10a and drive DataForSEO `keywords_for_site` discovery (`seo/generate_keywords.py`). Drop marketplaces (reedsy, upwork, fiverr) and personal-brand pages; keep direct category competitors only.
## 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, 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:
.com (strongly preferred, always default), .io, .co, short-country TLDs like .bz, .me, .to. Do NOT suggest .ai domains. .ai is not supported by Cloud Domains (our only registrar), carries a mandatory 2-year registration via third-party registrars, and runs ~$75-$100/yr. Skip it entirely unless the user explicitly asks for .ai.
- 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 (strongly preferred, always default), .io, plus any niche Cloud Domains-supported TLD that fits the category (e.g. .cameras, .tech, .studio). Do NOT include .ai (see Track A TLD rule).
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, drop the candidate and suggest an alternative TLD. Cloud Domains is the only registrar; we do not maintain a fallback path.
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, track (A=short, B=keyword-rich), rationale. All candidates must be Cloud Domains-supported TLDs.
Ranking rules:
- Track A candidates under 8 characters rank above Track B.
- Within Track A, ties broken by:
.com > .io > .co > others. .ai is excluded.
- 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
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: create one per client site. Naming convention: from config.json > defaults.gcp_project_naming_convention (currently <slug>-prod, e.g. fde10x-prod, cyrano-prod). Every field below is read from config.json > defaults; the skill never hardcodes these values.
CFG=~/social-autoposter/config.json
ORG_ID=$(jq -r '.defaults.gcp_organization_id' "$CFG")
BILLING=$(jq -r '.defaults.gcp_billing_account' "$CFG")
AUTH_ACCOUNT=$(jq -r '.defaults.gcp_auth_account' "$CFG")
PROJECT="<slug>-prod"
gcloud config set account "$AUTH_ACCOUNT"
gcloud projects create "$PROJECT" --name="$PROJECT" --organization="$ORG_ID"
gcloud billing projects link "$PROJECT" --billing-account="$BILLING"
gcloud config set project "$PROJECT"
gcloud config get-value project
Prefer Workload Identity Federation for CI/CD over service account keys on any new project; some org policies block SA key creation outright.
-
Cloud DNS zone created inside the same per-client project. Zone name follows the domain stem:
gcloud dns managed-zones create <STEM>-zone --dns-name="<domain>." --project=<slug>-prod
(Example: fde10x.com → fde10x-zone inside project fde10x-prod.)
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=<slug>-prod \
--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: <slug>-prod. 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=<slug>-prod 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. Record in config.json
Add the project entry to ~/social-autoposter/config.json. The full shape is owned by the downstream writer (with weight, description, platform, topics, twitter_topics, linkedin_topics, landing_pages, voice, icp, features, differentiator, etc.). The fields Phase 1.5 is responsible for:
{
"name": "<stem>",
"website": "https://<generic-domain>",
"brand_domain": "https://<brand-domain>",
"weight": 10,
...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).
weight default: 10 (matches existing top-tier projects: fazm, mediar, assrt). Lower it only if the user explicitly asks the new site to be deprioritized vs existing projects. weight controls how often pickers (pick_project.py, select_product.py, pick_thread_target.py, pick_top_pages.py) sample this project for engagement and content runs; setting it lower than peers silently buries the new site.
Add one field: "gcp_project": "<slug>-prod" so downstream scripts know where the Cloud Run service and DNS zone live. registrar and dns_zone are derivable (always Cloud Domains, always <STEM>-zone).
1.5g. 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.5h. 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 pinned to whatever npm @latest resolves to (≥0.22.1 at the time of writing). Example:
"@m13v/seo-components": "^0.22.1",
"@seo/components": "npm:@m13v/seo-components@^0.22.1"
Why the floor of 0.20.1: the /api/guide-chat route handler gained a healthCheck branch in v0.20.1. Bundles older than that respond to the GuideChatPanel's boot-time health probe with 400 no_messages, which the client reads as res.ok === false and hides the panel silently. Sites stuck on a pre-0.20.1 bundle look "fine" but never render the page assistant.
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";
@source "../../node_modules/@seo/components/src";
: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);
}
@layer base {
body {
background: var(--background);
color: var(--foreground);
}
html {
scroll-behavior: smooth;
}
a {
color: inherit;
text-decoration: none;
}
}
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.
CSS layering rule (single source of truth):
Only :root { --var: ... } custom-property definitions, @theme inline { ... }, @layer ...; declarations, @import ...;, and @source ...; may sit outside a @layer block. Every other rule (element selectors, class selectors, pseudo-elements, body, html, ::selection, a, .prose-link, etc.) must be wrapped in @layer base { ... } (or @layer components { ... } for reusable component classes). @keyframes at the top level is allowed since it doesn't match selectors.
A Phase-8 automated check enforces this; see "Phase 8 contrast + layer audit" below.
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, HeadingAnchors, and the three-column flex row that holds SitemapSidebar, {children}, and GuideChatPanel side-by-side. Header/Footer do NOT live here — they go in the (main) layout below.
import { Inter, Oswald } from "next/font/google";
import {
HeadingAnchors,
SitemapSidebar,
GuideChatPanel,
} from "@seo/components";
import { walkPages } from "@seo/components/server";
const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
const oswald = Oswald({ variable: "--font-oswald", subsets: ["latin"] });
export default function RootLayout({ children }: { children: React.ReactNode }) {
const pages = walkPages();
return (
<html lang="en" className={`${inter.variable} ${oswald.variable} h-full antialiased`}>
{/* Body is min-h-full only. DO NOT add `flex flex-col` here — the flex row below is the layout. */}
<body className="min-h-full font-sans">
<HeadingAnchors />
<div className="flex min-h-screen">
<SitemapSidebar pages={pages} brandName="CLIENT_NAME" hideOnPaths={["/"]} />
<div className="flex-1 min-w-0 flex flex-col">
{children}
</div>
<GuideChatPanel app="CLIENT_SLUG" hideOnPaths={["/"]} />
</div>
{/* Organization JSON-LD here */}
</body>
</html>
);
}
HeadingAnchors auto-injects id attributes on H2 elements for sidebar linking and anchor navigation.
- The three-column flex row is mandatory.
SitemapSidebar and GuideChatPanel are both sticky top-0 h-screen shrink-0 asides. They MUST be DOM siblings of {children} inside a <div className="flex min-h-screen"> so the browser lays them out side-by-side. Anti-pattern that renders them invisible: <body className="... flex flex-col"> + <HeadingAnchors /> {children} <SitemapSidebar/> <GuideChatPanel/> as plain body siblings. In a flex-col body the asides stack below the full-height article (rectTop ≈ 8000–10000px) and sticky has nothing to anchor against. Both components still render with display:flex and the API health probe still returns 200, so no error surfaces; the site just silently ships with no sidebar and no page assistant. This is the 10xats.com 2026-04-24 failure mode.
- Do not render
<SeoComponentsStyles /> in the layout. That component injects a prebuilt Tailwind bundle wrapped in @layer seo-components, which collides with the consumer's own Tailwind classes sitting in @layer utilities (higher priority per the layer order in globals.css). The collision means library components with hidden xl:flex wrappers (GuideChatPanel, SitemapSidebar) are permanently display:none: the consumer's .hidden wins over the library's .xl:flex at every breakpoint. Phase 2c's @source "../../node_modules/@seo/components/src"; pragma already scans the library with the consumer's own Tailwind and is the only stylesheet you need.
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.
Layout smoke test (BEFORE moving on). Do not leave Phase 2d without visually confirming the three-column layout. Waiting until Phase 8 to catch a broken wiring costs hours of wasted section work:
- Place any real
/t/<slug>/page.tsx under src/app/(main)/t/ (one placeholder guide with a few H2s is enough). You need a non-homepage route because hideOnPaths={["/"]} suppresses both rails on /.
npm run dev and open http://localhost:3000/t/<slug> at viewport width ≥1280px.
- In the browser console, run:
Array.from(document.querySelectorAll('aside')).map(a => ({
top: Math.round(a.getBoundingClientRect().top),
left: Math.round(a.getBoundingClientRect().left),
w: Math.round(a.getBoundingClientRect().width),
}))
Expect exactly two asides, both with top: 0. One is the left sidemap (left: 0, w: 288), the other is the right page assistant (left ≈ viewport width − 384, w: 384). Any top in the thousands means the aside is stranded below the article — stop and re-check the root layout.tsx for the <div className="flex min-h-screen"> wrapper BEFORE continuing to Phase 3.
- Kill the dev server only after this check passes.
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">