Merge pull request #662 from alirezarezvani/claude/build-skills-landing-generator-slice

This commit is contained in:
Alireza Rezvani 2026-05-15 18:55:15 +02:00 • committed by GitHub
commit 2fa288e835
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1979 additions and 0 deletions

View file

@ -0,0 +1,18 @@
{
"name": "landing",
"description": "Premium single-file HTML landing page generator with GSAP 3D animations, scroll-triggered effects, and mouse-parallax depth. Forcing 3-4 question grill-me intake (product+pitch, audience register, brand overrides, tone) locks down positioning before any copy or markup is written. Outputs a single self-contained HTML file (Claude Code) or HTML artifact (Claude.ai) with all CSS/JS inline — only externals are Google Fonts + GSAP via CDN. Configurable brand colors via CSS custom property overrides. Source spec: megaprompts/04-landing-megaprompt.md (PR #657). Distinct from product-team/skills/landing-page-generator (which outputs Next.js TSX for conversion-optimized lead-gen) — this skill is for premium visual one-pagers with motion design.",
"version": "1.0.0",
"author": {
"name": "Alireza Rezvani",
"url": "https://alirezarezvani.com"
},
"homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/marketing/landing",
"repository": "https://github.com/alirezarezvani/claude-skills",
"license": "MIT",
"skills": ["./skills/landing"],
"source": {
"spec": "megaprompts/04-landing-megaprompt.md",
"build_pattern": "Path B (direct conversion). Generator shape — produces a single .html artifact (not multi-file scaffolding). Wrapper additions (3 stdlib validators, 3 references, cs-landing agent, /cs:landing command) layered on top per repo convention.",
"distinct_from": "product-team/skills/landing-page-generator/ — different output format (HTML vs TSX), different optimization target (visual premium vs conversion), different motion approach (GSAP vs static)."
}
}

View file

@ -0,0 +1,79 @@
# landing
Premium single-file HTML landing page generator. Outputs one polished `.html` file with GSAP 3D animations, scroll-triggered reveals, and mouse-parallax depth — all CSS inline, all JS inline, only externals are Google Fonts + GSAP via CDN.
## Important: distinct from `product-team/skills/landing-page-generator/`
This is **NOT** the same skill as the existing `landing-page-generator` in `product-team/`. They serve different needs:
| Skill | Output format | Optimization target | Animation approach | When to use |
|---|---|---|---|---|
| **`marketing/landing/`** (this skill) | Single self-contained `.html` file | **Visual premium / one-pager** | GSAP 3D + mouse parallax + scroll-trigger | Launch page, product showcase, brand site where the page IS the experience |
| **`product-team/skills/landing-page-generator/`** | Next.js TSX components + Tailwind | **Conversion / lead-gen** | Static, copy-framework-driven (PAS / AIDA / BAB) | Lead capture, A/B test variants, campaign pages where conversion rate is the goal |
If you want the prospect to **convert** → use `landing-page-generator`.
If you want the prospect to **be impressed** → use `landing`.
Both are valid; they sit at different points on the visual-premium / conversion-optimization axis.
## What this skill does
Run via `/cs:landing` or trigger phrases like "create a landing page" / "build a landing page".
The skill walks **3–4 forcing intake questions** (one at a time, dependency-ordered):
1. **Product / service** — name + 1–2 sentence elevator pitch (refuses vague answers)
2. **Audience register** — technical / business / consumer / internal (forcing choice)
3. **Brand overrides** — default dark navy + teal, OR provide primary HEX + accent HEX + optional bg HEX (algorithmic derivation if only primary given)
4. **Tone** — professional / playful / authoritative / minimal (forcing choice)
Then generates a single `.html` file with three sections:
- **Hero** — 100vh, animated entrance via GSAP timeline, depth layers behind H1, mouse parallax
- **Features** — 3-column grid (responsive: 2-col at 900px, 1-col at 580px), SVG icons, scroll-triggered card reveals
- **Closing CTA** — ambient radial-gradient glow behind button, large closing headline
Output path: `${OUTPUT_DIR}/<product-name-kebab>.html` (default `${OUTPUT_DIR}=./landing-pages/`).
## Plugin layout
```
marketing/landing/
├── .claude-plugin/plugin.json
├── README.md
├── agents/cs-landing.md ← landing-generation persona, FOUC-prevention enforcer
├── commands/cs-landing.md ← /cs:landing
└── skills/landing/
├── SKILL.md ← Path-B converted from megaprompt 04
├── references/
│ ├── brand_system_design.md ← color theory + override patterns (7+ sources)
│ ├── gsap_animation_patterns.md ← entrance + scroll-trigger + parallax + CSS floats (7+ sources)
│ └── single_file_html_discipline.md ← why inline + CDN-only externals (7+ sources)
└── scripts/
├── brand_palette_validator.py ← stdlib: HEX validation + WCAG contrast + derived palette
├── kebab_slug_generator.py ← stdlib: product-name → kebab slug + duplicate detection
└── html_validator.py ← stdlib: post-generation structural check
```
## Quick start
```bash
# Validate a brand override before generation
python skills/landing/scripts/brand_palette_validator.py \
--primary "#FF6B35" --accent "#2EC4B6" --bg "#011627"
# Generate output filename
python skills/landing/scripts/kebab_slug_generator.py \
--product "Quill AI" --output-dir ./landing-pages
# Validate generated HTML structurally
python skills/landing/scripts/html_validator.py --file ./landing-pages/quill-ai.html
```
## Source spec
[`megaprompts/04-landing-megaprompt.md`](../../megaprompts/04-landing-megaprompt.md) (PR #657). The megaprompt is canonical; this plugin is the working implementation. Drift between the two is a bug — re-grill with `/cs:grill-with-docs` if they diverge.
## License
MIT.

View file

@ -0,0 +1,179 @@
---
name: cs-landing
description: Premium HTML landing page generator persona. Walks 3-4 forcing intake questions (product+pitch, audience register, brand overrides, tone) before writing any markup. Refuses vague product descriptions. Refuses to skip gsap.set() initial states (causes FOUC). Refuses to hardcode brand colors. Refuses external CSS/JS files (everything inline except Google Fonts + GSAP CDN). Outputs one self-contained .html file with GSAP 3D animations, scroll-triggered reveals, and mouse-parallax depth.
skills: marketing/landing/skills/landing
domain: marketing
model: opus
tools: [Read, Write, Bash, Glob]
---
# Landing Agent
## Voice
**Opening:** "Drop a product or brief. I'll grill you on product+pitch, audience register, brand overrides, and tone before I write a single line of markup. Then one polished HTML file — GSAP entrance, mouse parallax, scroll-triggered reveals."
**Refusing vague Q1:** "App for productivity" → "Too generic. What does it do, and who's it for? 'Async standup tool for remote engineering teams who hate Zoom' produces a page that converts; 'productivity app' produces boilerplate."
**Brand-override handling:**
> "Custom palette accepted: primary #FF6B35, accent #2EC4B6, bg #011627. I'll derive `--teal-glow` and other secondary vars algorithmically from primary. Generating now."
> "Only primary provided. Deriving accent (lighten/darken) and using default bg. Output in 30s."
**FOUC reminder (internal discipline):**
> "Generating with `gsap.set()` initial states on every animated element. No flash of unstyled content."
**Closing:** "Generated: `${OUTPUT_DIR}/<product-kebab>.html`. Single file, all CSS+JS inline, only externals are Google Fonts + GSAP CDN. Open in browser to preview. Re-run /cs:landing if you want a variant."
Visual-premium-focused, motion-aware, brand-respecting. Refuses to ship a generic page.
## Purpose
The cs-landing agent orchestrates the `landing` skill across HTML one-pager generation:
1. **Grill-me intake (Q1 → Q4)** — product / audience / brand / tone, one at a time, with "why I'm asking" per question
2. **Pre-flight** — validate brand palette with `scripts/brand_palette_validator.py`; generate output slug with `scripts/kebab_slug_generator.py`
3. **Content extraction** — from Q1 elevator pitch, derive hero headline, subtext, feature bullets, CTA copy, closing line
4. **Brand system** — default dark navy + teal OR overridden palette
5. **Generation (single pass)** — write the .html file with Hero + Features + Closing CTA sections, GSAP timeline, mouse-parallax handlers, scroll-triggered reveals, CSS floating shapes
6. **Post-flight** — validate output with `scripts/html_validator.py` (checks: 3 sections present, CDN deps included, `gsap.set()` initial states, responsive breakpoints, no external CSS/JS files)
7. **Deliver** — file path (CLI) or HTML artifact (Claude.ai web)
Differentiates clearly:
- **vs landing-page-generator (product-team/)** — different output (HTML vs TSX), optimization (premium-visual vs conversion), animation (GSAP vs static). Both valid; pick by use case.
- **vs cs-capture / cs-pulse / cs-inbox-***: different domain — landing is marketing-output generation, not productivity / research / email.
**Hard rules:**
1. **One intake question per turn.** Never bundle. The 4 Qs are dependency-ordered.
2. **Refuse vague Q1.** "App for productivity" gets pushed back once. If user still won't sharpen, deliver with explicit "generic positioning — page won't differentiate" caveat.
3. **No FOUC.** Every animated element gets `gsap.set()` initial state before GSAP timeline runs.
4. **Inline-only.** All CSS in `<style>`, all JS in `<script>`. Externals: Google Fonts + GSAP via CDN only.
5. **Responsive by default.** Breakpoints at 900px (tablet → 2-col) and 580px (mobile → 1-col).
6. **No hardcoded paths.** `${OUTPUT_DIR}` variable, default `./landing-pages/`.
7. **Single-pass write.** No outlining → drafting → polishing cycle. Write the full HTML in one pass.
## Skill Integration
**Skill Location:** `../skills/landing/`
### Python Tools (Stdlib)
1. **Brand Palette Validator**
- Path: `../skills/landing/scripts/brand_palette_validator.py`
- Usage: `python brand_palette_validator.py --primary "#FF6B35" --accent "#2EC4B6" --bg "#011627"`
- Validates HEX format, checks WCAG AA contrast (4.5:1 minimum) between text and bg, generates the full derived palette (--*-glow, --*-mid variants from primary).
2. **Kebab Slug Generator**
- Path: `../skills/landing/scripts/kebab_slug_generator.py`
- Usage: `python kebab_slug_generator.py --product "Quill AI" --output-dir ./landing-pages`
- Produces `quill-ai.html` filename. Detects duplicates at output path; suggests timestamp suffix if collision.
3. **HTML Validator**
- Path: `../skills/landing/scripts/html_validator.py`
- Usage: `python html_validator.py --file ./landing-pages/quill-ai.html`
- Post-generation structural check: 3 required sections (hero, features, closing-cta), CDN deps present, `gsap.set()` initial states, responsive breakpoints, no external CSS/JS file references.
### Knowledge Bases
- `../skills/landing/references/brand_system_design.md` — color theory + WCAG + algorithmic palette derivation + override patterns (7+ sources)
- `../skills/landing/references/gsap_animation_patterns.md` — entrance timeline + ScrollTrigger reveals + mouse parallax + CSS floats + scroll indicator (7+ sources)
- `../skills/landing/references/single_file_html_discipline.md` — why inline + CDN-only externals + accessibility minimums + no-build rationale (7+ sources)
## Workflows
### Workflow 1: Default generation (no brand override)
```bash
# 1. Grill-me Q1-Q4 (one at a time)
# 2. Skip brand_palette_validator (default palette used)
# 3. Generate slug
python ../skills/landing/scripts/kebab_slug_generator.py \
--product "<Q1 product name>" --output-dir ./landing-pages
# 4. Write the .html file in one pass.
# 5. Validate
python ../skills/landing/scripts/html_validator.py \
--file ./landing-pages/<slug>.html
# 6. Deliver: file path (CLI) or artifact (web)
```
### Workflow 2: With brand override
```bash
# Q3 returned: primary #FF6B35, accent #2EC4B6, bg #011627
python ../skills/landing/scripts/brand_palette_validator.py \
--primary "#FF6B35" --accent "#2EC4B6" --bg "#011627" --output json
# Returns: validated palette + WCAG contrast verdict + derived secondary vars
# Use derived palette in CSS custom properties.
# Continue with kebab slug + write + validate as Workflow 1.
```
### Workflow 3: Claude.ai web (no filesystem)
```
Instead of writing to ./landing-pages/<slug>.html:
- Generate HTML as an artifact
- Skip kebab_slug_generator + html_validator (no file to validate)
- User downloads or copies the artifact
```
## Output Standards
**File structure:**
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{Product Name} — {Tagline}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
/* All CSS inline. Brand vars first, then components, then sections, then media queries. */
</style>
</head>
<body>
<header class="hero">...</header>
<section class="features">...</section>
<section class="closing-cta">...</section>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
<script>
/* All JS inline. gsap.set() initial states first, then timeline, then mouse parallax, then ScrollTrigger. */
</script>
</body>
</html>
```
## Success Metrics
- **0 FOUC** — verified by html_validator (gsap.set() must precede gsap.timeline / gsap.to)
- **0 external CSS/JS files** — only Google Fonts + GSAP CDN allowed
- **3 sections present** — hero + features + closing-cta
- **Responsive at 900px + 580px** — verified by html_validator
- **0 hardcoded brand colors** — uses CSS custom properties
- **<=1 push-back on Q1** — if user won't sharpen, deliver with caveat
## Related Agents
- `landing-page-generator` (product-team/) — sibling, Next.js TSX conversion-focused (different output target)
- [cs-capture](../../../engineering/capture/agents/cs-capture.md) — different domain (productivity)
- [cs-pulse](../../../engineering/pulse/agents/cs-pulse.md) — different domain (research)
## References
- Skill: [../skills/landing/SKILL.md](../skills/landing/SKILL.md)
- Source spec: [`megaprompts/04-landing-megaprompt.md`](../../../megaprompts/04-landing-megaprompt.md)
- Sibling command: [`/cs:landing`](../commands/cs-landing.md)
---
**Version:** 1.0.0
**Status:** Production Ready
**Source:** Path-B direct conversion of `megaprompts/04-landing-megaprompt.md`

View file

@ -0,0 +1,124 @@
---
name: "cs-landing"
description: "/cs:landing <product-or-brief> — Generate a premium single-file HTML landing page with GSAP 3D animations, scroll-triggered reveals, and mouse-parallax depth. Grill-me intake (4 questions) locks down product / audience / brand / tone before any markup. Output: ${OUTPUT_DIR}/<product-kebab>.html or HTML artifact."
---
# /cs:landing — Premium HTML Landing Page Generator
**Command:** `/cs:landing <product-or-brief>`
The `cs-landing` persona generates one polished, self-contained `.html` landing page with GSAP animations, mouse parallax, and 3D CSS effects.
## When to Run
- Launch pages where the page IS the experience (visual-premium one-pagers)
- Product showcases with motion design
- Brand sites where conversion rate isn't the primary metric — impression is
## When NOT to Run (use `landing-page-generator` instead)
If you need **conversion-optimized lead-gen** with copy frameworks (PAS / AIDA / BAB), Next.js TSX components, multiple section variants for A/B testing — use `product-team/skills/landing-page-generator/` instead. That's a different skill optimizing for different outcomes.
| Need | Skill |
|---|---|
| Visual premium one-pager | **`/cs:landing`** (this command) |
| Conversion-optimized lead-gen | `landing-page-generator` |
## Trigger Phrases (auto-invoke without /cs:)
- "create a landing page"
- "build a landing page"
- "make a landing page for X"
- "I need a web page for Y"
- "promotional page"
- "product page"
- "one-pager"
- "web presence"
- "sales page"
**Note:** these trigger phrases may match either this skill OR `landing-page-generator`. If both are installed, Claude picks based on the conversation context (premium-visual hints → this skill; conversion / lead-gen / A/B-test hints → the other).
## Forcing Intake (3–4 Questions, One at a Time)
| Q | Asks | Default if forcing-choice |
|---|---|---|
| Q1 | Product / service: name + 1–2 sentence elevator pitch | refuses vague answers ("app for productivity" gets pushed back once) |
| Q2 | Audience register: technical / business / consumer / internal | forcing choice |
| Q3 | Brand overrides: primary HEX + accent HEX + optional bg HEX, OR "default" | default = dark navy + teal |
| Q4 | Tone: professional / playful / authoritative / minimal | forcing choice (recommended: professional for B2B, playful for consumer, minimal for design-led) |
**Stop condition:** Max 4 questions. No follow-up during generation.
## What You Get
A single `.html` file at `${OUTPUT_DIR}/<product-kebab>.html` (default `./landing-pages/`) with:
- **Hero** — 100vh, animated H1 entrance via GSAP timeline, scroll-down indicator, mouse-parallax depth layers
- **Features** — 3-column grid (responsive 2-col at 900px, 1-col at 580px), SVG icons, scroll-triggered card reveals with `rotateX` lift
- **Closing CTA** — large closing headline + ambient radial-gradient glow behind button
All CSS inline. All JS inline. Externals: Google Fonts (Inter) + GSAP via CDN only.
## Discipline
- **One intake question per turn.** Never bundle.
- **Refuse vague Q1 once.** Push back; deliver with caveat if user won't sharpen.
- **No FOUC.** Every animated element gets `gsap.set()` initial state.
- **Inline-only.** All CSS + JS in the file. No external `.css` / `.js` references.
- **Responsive.** Breakpoints at 900px + 580px.
- **No hardcoded paths.** `${OUTPUT_DIR}` variable.
- **Single-pass write.** No outline → draft → polish cycle.
## Workflow
```bash
# 1. Intake (Q1-Q4 one at a time)
# 2. If brand override provided, validate:
python ../skills/landing/scripts/brand_palette_validator.py \
--primary "#FF6B35" --accent "#2EC4B6" --bg "#011627"
# 3. Generate output filename
python ../skills/landing/scripts/kebab_slug_generator.py \
--product "<product name from Q1>" --output-dir ./landing-pages
# 4. Write the .html file in one pass (Hero + Features + Closing CTA + GSAP + mouse parallax + ScrollTrigger + CSS floats)
# 5. Validate structure
python ../skills/landing/scripts/html_validator.py \
--file ./landing-pages/<slug>.html
# 6. Deliver:
# CLI → file path
# Web → HTML artifact
```
## Stop Conditions
- All 4 Qs answered + HTML generated + validator PASS → done
- User says "skip intake" → use defaults for any unanswered Q (default brand, professional tone, audience inferred from elevator pitch)
- Validator FAIL → regenerate the failing sections in one targeted pass; do NOT abandon the file
## Anti-Patterns Rejected
- Hardcoded absolute paths in output directory
- Single brand palette without override documentation
- Outlining before writing — write in one pass
- External CSS or JS files (must be inline)
- Skipping `gsap.set()` initial states (causes FOUC)
- More than 6 features in default grid (becomes unscannable)
- Brand-specific content references in the skill itself
- Bundling intake questions
## Related
- Agent: [`cs-landing`](../agents/cs-landing.md)
- Skill: [`landing`](../skills/landing/SKILL.md)
- Source spec: [`megaprompts/04-landing-megaprompt.md`](../../../megaprompts/04-landing-megaprompt.md)
- Sibling (different optimization): `product-team/skills/landing-page-generator/`
- Adjacent v2 commands: `/cs:capture`, `/cs:pulse`, `/cs:inbox-setup`, `/cs:inbox-triage`
---
**Version:** 1.0.0
**Source:** Path-B direct conversion of `megaprompts/04-landing-megaprompt.md`

View file

@ -0,0 +1,346 @@
---
name: landing
description: "Generates a premium single-page HTML landing page with 3D CSS animations, GSAP scroll effects, and mouse-parallax depth. Forcing intake (product + elevator pitch, audience register, brand overrides, tone) locks down positioning before any copy or markup is written, so the page reflects the actual product rather than generic boilerplate. Use whenever the user says 'landing for X', 'create a landing page', 'build a landing page', 'make a landing page for X', 'I need a web page for Y', or provides product/service details and wants a polished website. Also triggers on 'promotional page', 'product page', 'one-pager', 'web presence', 'sales page'. Outputs a single self-contained HTML file (Claude Code) or HTML artifact (Claude.ai). Supports configurable brand colors via CSS custom property overrides."
license: MIT
metadata:
source_spec: "megaprompts/04-landing-megaprompt.md"
build_pattern: "Path B (direct conversion)"
distinct_from: "product-team/skills/landing-page-generator (different output format + optimization target)"
version: 1.0.0
---
# Landing — Premium HTML Landing Page Generator
> **Distinct from `product-team/skills/landing-page-generator/`.** That skill outputs Next.js TSX components optimized for conversion / lead-gen. THIS skill outputs a single self-contained `.html` file optimized for premium visual experience with GSAP animations. Pick by use case.
Generate a polished, self-contained `.html` landing page from a text prompt or brief. The output is ONE HTML file: all CSS inline in `<style>`, all JS inline in `<script>`, only external dependencies being Google Fonts + GSAP via CDN. The page is visually distinctive, animated, and production-quality.
## Invocation Triggers
- "create a landing page"
- "build a landing page"
- "make a landing page for X"
- "I need a web page for Y"
- "promotional page"
- "product page"
- "one-pager"
- "web presence"
- "sales page"
- "landing for X"
## Delivery Mode
In **Claude Code CLI**, write the file to disk at the specified path. In **Claude.ai web**, create an HTML artifact with the same content.
## Phase 0: Grill-Me Intake (4 forcing questions, one at a time)
Dependency-ordered. Each question carries explicit "why I'm asking". Stop condition: max 4.
### Q1 (root) — Product / Service
> **What's the product or service? Give me the name + a 1–2 sentence elevator pitch — what does it do, and who's it for?**
>
> *Why I'm asking:* The headline, subtext, and feature copy all derive from this. "App for productivity" produces generic boilerplate; "Async standup tool for remote engineering teams who hate Zoom" produces a landing page that converts.
**Refuse mush.** If user gives just a name with no pitch, push back once: "What does it do? Who's it for?" If still no pitch after push-back, deliver with explicit "generic positioning" caveat.
### Q2 (depends on Q1) — Audience Register
> **Who's the audience? Pick one:**
>
> 1. **Technical buyers** (engineers, ops, security)
> 2. **Business buyers** (PMs, execs, ops leaders)
> 3. **Consumers** (general public, hobbyists)
> 4. **Internal** (employees, partners — not for public sale)
>
> *Why I'm asking:* Audience dictates copy register, jargon level, social-proof choices, and CTA framing. Technical buyers want specifics; consumers want benefits; internal pages can skip persuasion.
Forcing choice.
### Q3 (always) — Brand Overrides
> **Brand colors / fonts to override the default (dark navy + teal + Inter)? Provide as: primary HEX, accent HEX, optional bg HEX. Or say "default" if you want the polished default.**
>
> *Why I'm asking:* The default is intentionally beautiful, but matching your brand makes the page feel native to your existing site. Even just a primary color override goes a long way.
Accept "default" or partial overrides (e.g., just primary). If only primary provided, derive accent algorithmically (lighten / darken).
### Q4 (depends on Q1) — Tone
> **Tone — pick one:**
>
> 1. **Professional** — confident, restrained, B2B-friendly
> 2. **Playful** — warm, light, occasional humor
> 3. **Authoritative** — expert, data-forward, trust-building
> 4. **Minimal** — terse, design-led, low copy density
>
> *Why I'm asking:* Tone affects every sentence — headlines, microcopy, button text, closing copy. Picking upfront prevents tonal whiplash across sections.
Forcing choice. **Recommended default:** professional if Q2 = technical/business; playful if Q2 = consumer; minimal if the product is design-led.
**Stop condition:** After Q4, commit and generate. No follow-up questions during generation.
## Content Extraction (with Fallback Strategy)
From Q1's elevator pitch, derive:
- **Hero headline** — punchy version of "what it does" (8–12 words)
- **Hero subtext** — version of "who it's for + payoff" (1–2 sentences)
- **3–6 feature bullets** — distilled from pitch + audience (Q2) + tone (Q4)
- **CTA text** — action-oriented, matches tone
- **Closing copy** — short, emotive, matches tone
**Fallback when input is sparse:** invent compelling content from product-name semantics + audience register. Flag inferred content with a comment in the HTML source (`<!-- inferred: ... -->`). Don't stall waiting for more input.
## Brand System Specification
### Default Color Palette (Dark Navy + Teal)
```css
:root {
--navy: #0A1628;
--navy-mid: #0D1F38;
--teal: #00D4AA;
--teal-glow: rgba(0, 212, 170, 0.12);
--amber: #F5A623;
--off-white: #F7F7F2;
--text-muted: rgba(247, 247, 242, 0.68);
--card-bg: rgba(0, 212, 170, 0.06);
--card-border:rgba(0, 212, 170, 0.15);
}
```
### Override Pattern
When Q3 provides custom brand values, the skill substitutes them into the `:root` block:
```
Brand override:
- primary: #FF6B35 → --navy / hero bg
- accent: #2EC4B6 → --teal / CTA / highlights
- bg: #011627 → --navy-mid / section bg
- text: #FDFFFC → --off-white
```
If only primary provided, derive accent algorithmically (lighten 15% for accent; darken 8% for navy-mid; convert to rgba at 0.12 alpha for glow). Use `scripts/brand_palette_validator.py` for the deterministic derivation.
See [`references/brand_system_design.md`](references/brand_system_design.md) for color theory + WCAG + algorithmic palette derivation canon.
### Typography
- **Font family:** Inter (via Google Fonts)
- **Weight scale:** 400 (body), 500 (eyebrow), 600 (links), 700 (subtitle), 800 (H1 + H2)
- **Size scale:**
- Hero H1: 68–82px
- Section H2: 52–62px
- Card titles: 22px
- Body: 17–19px
- Eyebrow: 13px (uppercase, letter-spaced)
- CTA button: 18px (500 weight)
### Components (Must Specify CSS)
- `.btn-primary` — CTA button with hover state (lift + brightness)
- `.feature-card` — card with hover lift (translateY(-6px) + border-brighten)
- `.eyebrow` — letter-spaced (0.2em) uppercase category label
## Section 1: Hero
- `min-height: 100vh`, flex-centered content
- Optional eyebrow label above H1
- H1 (68–82px, 800 weight)
- Subtitle (17–19px, 1–2 sentences)
- CTA button (.btn-primary)
- Scroll-down indicator (animated chevron, CSS bounce)
- **Depth layers** (mouse parallax):
- `.hero-shapes-back` — large blurred circles, absolute-positioned, low opacity
- `.hero-shapes-mid` — smaller shapes, sharper edges, higher opacity
- Content layer (H1 + subtitle) — moves subtly in same direction as mouse
## Section 2: Features
- 3 columns default (`repeat(3, 1fr)` grid)
- Responsive:
- 2 columns at 900px breakpoint
- 1 column at 580px breakpoint
- Each card:
- SVG icon (28px, stroke=var(--teal), no fill)
- Title (22px, 700 weight)
- Description (15–16px, --text-muted)
- Hover state:
- `transform: translateY(-6px)`
- `border-color: var(--teal)` (brighten from --card-border)
- `transition: 0.3s ease`
## Section 3: Closing CTA
- Full-width, `background: var(--navy-mid)`
- `padding: 120px 24px`, text-align: center
- Large closing headline (52–62px, 800 weight)
- Short subtext (--text-muted, 1–2 sentences)
- CTA button with ambient radial-gradient glow behind it:
```css
background: radial-gradient(circle, var(--teal-glow) 0%, transparent 70%);
```
## Animation Patterns
See [`references/gsap_animation_patterns.md`](references/gsap_animation_patterns.md) for the canon. Five patterns required:
### 1. Hero Entrance (GSAP timeline)
```js
// MUST use gsap.set() FIRST to prevent FOUC
gsap.set([".eyebrow", ".hero h1", ".hero .subtitle", ".btn-primary", ".scroll-down"], {
opacity: 0,
y: 30
});
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
tl.to(".eyebrow", { opacity: 1, y: 0, duration: 0.6 })
.to(".hero h1", { opacity: 1, y: 0, duration: 0.8 }, "-=0.3")
.to(".hero .subtitle", { opacity: 1, y: 0, duration: 0.6 }, "-=0.5")
.to(".btn-primary", { opacity: 1, y: 0, duration: 0.5 }, "-=0.3")
.to(".scroll-down", { opacity: 1, y: 0, duration: 0.4 }, "-=0.2");
```
### 2. Mouse Parallax
```js
const hero = document.querySelector(".hero");
hero.addEventListener("mousemove", (e) => {
const x = (e.clientX / window.innerWidth - 0.5) * 2;
const y = (e.clientY / window.innerHeight - 0.5) * 2;
gsap.to(".hero-shapes-back", { x: x * 45, y: y * 22, duration: 0.8 });
gsap.to(".hero-shapes-mid", { x: x * 22, y: y * 11, duration: 0.8 });
gsap.to(".hero .container", { x: x * 8, y: y * 5, duration: 0.8 });
});
```
### 3. Scroll-Triggered Feature Cards
```js
gsap.set(".feature-card", { opacity: 0, y: 55, rotateX: 18 });
ScrollTrigger.batch(".feature-card", {
start: "top 80%",
onEnter: batch => gsap.to(batch, {
opacity: 1, y: 0, rotateX: 0,
duration: 0.8,
stagger: 0.11,
ease: "power2.out"
})
});
```
### 4. Floating Decorative Shapes (CSS keyframes — NOT GSAP)
CSS handles ambient continuous motion (smoother, cheaper than GSAP for indefinite animations):
```css
@keyframes floatA {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
50% { transform: translate(20px, -30px) rotate(8deg); }
}
@keyframes floatB { /* different duration + rotation */ }
@keyframes floatC { /* different duration + rotation */ }
.hero-shapes-back .shape-a { animation: floatA 12s ease-in-out infinite; }
```
### 5. Scroll Indicator (CSS bounce)
```css
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(8px); }
}
.scroll-down { animation: bounce 2s ease-in-out infinite; }
```
## Required CDN Dependencies
```html
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
```
NO other external CSS or JS files. All custom CSS in `<style>`, all custom JS in `<script>` blocks within the same HTML file.
See [`references/single_file_html_discipline.md`](references/single_file_html_discipline.md) for the inline-only rationale.
## Layout Rules
- **Container max-width:** 1200px, centered
- **Section padding:** `120px 24px` (vertical 120, horizontal 24, scales down on mobile)
- **Responsive breakpoints:**
- 900px → features grid 3-col → 2-col
- 580px → all grids → 1-col; H1 scales down to ~52px
- **Viewport meta:** `<meta name="viewport" content="width=device-width, initial-scale=1">`
## Output Spec
- **Path:** `${OUTPUT_DIR}/<product-name-kebab>.html`
- **Default `${OUTPUT_DIR}`:** `./landing-pages/`
- **Filename:** lowercase kebab-case from product name ("Quill AI" → `quill-ai.html`). Use `scripts/kebab_slug_generator.py` for deterministic slug generation + duplicate detection.
- **Self-contained:** all CSS in `<style>`, all JS in `<script>`, only Google Fonts + GSAP CDN external.
## Validation (Post-Generation)
Run `scripts/html_validator.py --file ${OUTPUT_DIR}/<slug>.html` after generation. Checks:
- All 3 required sections present (`.hero`, `.features`, `.closing-cta`)
- CDN deps present (Inter + GSAP + ScrollTrigger)
- `gsap.set()` initial states precede any `gsap.timeline` or `gsap.to` (FOUC prevention)
- Responsive breakpoints at 900px + 580px
- No external `<link rel="stylesheet">` other than Google Fonts
- No external `<script src=>` other than GSAP CDN
- `<meta name="viewport">` present
- All animated elements have initial-state declarations
## Error Handling
| Situation | Behavior |
|---|---|
| Input is just a name with no context | Invent compelling content from name semantics + audience register; flag as `<!-- inferred -->` in HTML source |
| Input file is large or PDF | Read fully before generating; don't truncate |
| Brand colors insufficient (only 1 HEX provided) | Use as primary; derive secondary/accent algorithmically (lighten/darken via brand_palette_validator.py) |
| Features count not specified | Default to 4 |
| Output dir doesn't exist | Create it |
| Existing file at output path | Append timestamp suffix or ask user (kebab_slug_generator.py flags duplicates) |
| html_validator returns FAIL | Regenerate ONLY the failing sections in one targeted pass; do NOT abandon the file |
## Portability
- **Claude Code CLI:** Native — writes HTML file directly to filesystem.
- **Claude.ai web:** Native — produces HTML as an artifact instead of file.
## Tooling
| Script | Role |
|---|---|
| `scripts/brand_palette_validator.py` | Validates HEX format, checks WCAG AA contrast, generates derived palette from primary (algorithmic lighten/darken). |
| `scripts/kebab_slug_generator.py` | Product name → kebab-case filename + duplicate detection in output dir. |
| `scripts/html_validator.py` | Post-generation structural check: 3 sections, CDN deps, gsap.set() initial states, responsive breakpoints, no external files. |
## References
- [`references/brand_system_design.md`](references/brand_system_design.md) — color theory + WCAG + algorithmic palette derivation (7+ sources)
- [`references/gsap_animation_patterns.md`](references/gsap_animation_patterns.md) — entrance timeline + ScrollTrigger reveals + mouse parallax + CSS floats + scroll indicator (7+ sources)
- [`references/single_file_html_discipline.md`](references/single_file_html_discipline.md) — why inline + CDN-only externals + accessibility minimums + no-build rationale (7+ sources)
## Anti-Patterns To Reject
- Hardcoded absolute paths in output directory
- Single brand palette without override documentation
- Outlining before writing — write in one pass
- External CSS or JS files (must be inline; only Google Fonts + GSAP CDN allowed)
- Skipping `gsap.set()` initial states (causes FOUC)
- More than 6 features in default grid (becomes unscannable)
- Brand-specific content references in the skill itself
---
**Version:** 1.0.0
**Source spec:** [`megaprompts/04-landing-megaprompt.md`](../../../../megaprompts/04-landing-megaprompt.md)
**Build pattern:** Path B (direct conversion). Distinct from `product-team/skills/landing-page-generator/`.

View file

@ -0,0 +1,158 @@
# Brand System Design — Color Theory, WCAG, Algorithmic Derivation
This reference answers exactly one decision: **how does the landing skill produce a coherent brand palette from minimal user input (default OR partial override) while meeting WCAG accessibility minimums?**
Pair with `scripts/brand_palette_validator.py` for the deterministic implementation.
## The Default Palette (Dark Navy + Teal)
The default is **intentional**, not arbitrary. Three reasons:
1. **Dark mode by default** — premium-feeling, reduces eye strain for evening browsing, photographs well in promotional screenshots.
2. **Teal accent** — high chroma (saturated) but cooler than the orange/red defaults; reads as "modern tech" without being default-Silicon-Valley-blue.
3. **WCAG-passing** — `#F7F7F2` text on `#0A1628` bg is ~17:1 contrast (WCAG AAA for both small and large text).
```css
:root {
--navy: #0A1628; /* primary bg */
--navy-mid: #0D1F38; /* section bg (slight elevation) */
--teal: #00D4AA; /* accent / CTA / highlights */
--teal-glow: rgba(0, 212, 170, 0.12); /* ambient glow behind CTA */
--amber: #F5A623; /* secondary accent (warnings, eyebrows occasionally) */
--off-white: #F7F7F2; /* text */
--text-muted: rgba(247, 247, 242, 0.68); /* subtext */
--card-bg: rgba(0, 212, 170, 0.06); /* feature card bg */
--card-border:rgba(0, 212, 170, 0.15); /* feature card border */
}
```
## Override Strategy
When user provides Q3 brand colors, the skill maps:
| User input | Maps to | Notes |
|---|---|---|
| `primary` | `--navy` (also `--navy-mid` derived) | The dark bg color |
| `accent` | `--teal` (also `--teal-glow` derived as rgba 0.12) | The pop color |
| `bg` (optional) | `--navy-mid` override (otherwise derived 8% lighter than primary) | Slight elevation |
| `text` (optional) | `--off-white` override (otherwise stays default) | If primary is light, text MUST darken |
## Algorithmic Derivation (When Only Partial Override)
When user gives only `primary` (the most common case), derive the rest:
### Derive `--accent` from `--primary`
Two options:
1. **Lighten + saturate:** shift HSL lightness +30%, keep hue, increase saturation 10%. Useful when primary is dark.
2. **Hue shift:** rotate hue ±150° on the color wheel for complementary contrast. Useful when primary is mid-saturation.
Default: use option 1 (lighten + saturate) — produces a "highlight" feel that matches CTA-glow aesthetic. Option 2 risks producing a jarring contrast.
### Derive `--navy-mid` from `--primary`
Lighten primary by 8% (in HSL). This is the "section bg" — slightly visible elevation from primary.
### Derive `--text-muted` from `--off-white` or default text
`rgba(text-rgb, 0.68)` — 68% opacity creates a perceived "muted text" without explicit gray that might not match.
### Derive `--*-glow` from `--accent`
`rgba(accent-rgb, 0.12)` — 12% opacity creates ambient glow without dominating. Lower values look too subtle on dark bg; higher dominate the layout.
## WCAG Contrast Requirements
The skill MUST verify text-on-bg contrast meets WCAG AA (4.5:1 for body, 3:1 for large text 24px+).
### Algorithm (relative luminance)
```
L_lighter / L_darker > 4.5 for body text
L_lighter / L_darker > 3.0 for large text
where L is relative luminance:
L = 0.2126 * R + 0.7152 * G + 0.0722 * B
(R, G, B are sRGB linearized — see WCAG spec)
```
`scripts/brand_palette_validator.py` computes this and FAILs the run if user's override produces text-bg contrast below threshold.
### What to do on contrast failure
| Failure | Fix |
|---|---|
| Body text on bg < 4.5:1 | Suggest darker bg OR lighter text. Auto-derive a passing variant. |
| Large text on bg < 3:1 | Suggest darker bg OR lighter text. |
| Text on card bg < 3:1 | Adjust `--card-bg` alpha (lower → more contrast since dark bg shows through). |
| Accent on bg < 3:1 (for CTA visibility) | Suggest brighter accent OR add darker outline. |
## Component-Specific Color Rules
### `.btn-primary` (CTA)
- **Default bg:** `--teal` (the accent)
- **Default text:** `--navy` (high contrast vs --teal: ~9:1 with default values)
- **Hover:** brighten 12% (HSL lightness +12)
- **Shadow:** `0 4px 24px var(--teal-glow)` — uses the derived glow var
### `.feature-card`
- **Default bg:** `--card-bg` (semi-transparent accent at 6%)
- **Default border:** `--card-border` (semi-transparent accent at 15%)
- **Hover border:** `--teal` (full opacity) + transform translateY(-6px)
- **Inner contrast:** title in `--off-white`, description in `--text-muted`
### `.eyebrow`
- **Default color:** `--teal` (the accent) OR `--amber` for tonal variety
- Letter-spacing: 0.2em, uppercase, 13px, 500 weight — these properties carry it visually so the color choice has more flexibility
## Why These Rules
The reasons each rule exists:
| Rule | Rationale |
|---|---|
| Dark mode default | Premium aesthetic + better screenshot photography + lower eye strain |
| Teal accent (not blue) | Differentiates from "Silicon Valley default" without losing tech feel |
| WCAG AA minimum | Legal requirement in many jurisdictions; ethical baseline; helps readers in suboptimal lighting |
| Algorithmic derivation | Users rarely provide full palettes; one HEX should be enough to ship |
| Component-level color rules | Prevents "color soup" where every element picks a different var |
## Anti-Patterns
- **Hardcoding HEX values outside `:root`** — kills override-ability
- **Using `color: #FFF` directly** instead of `var(--off-white)` — same problem
- **Mixing 3+ accent colors** in one page — sets "demo gone wrong" tone
- **Pure-black bg** (`#000`) — feels cheaper than near-black; use `#0A0E14` or similar
- **Pure-white text** on dark bg — too high contrast; `#F7F7F2` reads warmer and easier
- **High-saturation accents at 100% on large surfaces** — overstimulating; use them for CTAs and highlights only
- **Ignoring WCAG contrast** — accessibility AND visual hierarchy both depend on it
## Operational Checklist (Per Generation)
- [ ] Default palette OR user-provided override extracted from Q3
- [ ] If partial override: derive missing vars algorithmically via brand_palette_validator.py
- [ ] WCAG AA contrast verified (body ≥ 4.5:1, large ≥ 3:1)
- [ ] All colors in CSS via `var(--name)`, not direct HEX
- [ ] CTA accent stands out against section bg (≥ 3:1)
- [ ] Card border visible but not dominant
- [ ] Test in both bright and dark room conditions if previewing live
## Citations (7 sources)
1. **Web Content Accessibility Guidelines (WCAG) 2.2 — W3C Recommendation (2023).** Sections 1.4.3 (Contrast Minimum) and 1.4.6 (Contrast Enhanced). Defines the 4.5:1 body / 3:1 large text thresholds the skill enforces. https://www.w3.org/TR/WCAG22/
2. **Refactoring UI — Adam Wathan & Steve Schoger (2018).** Chapter on "Choosing a Color Palette" — argues for limited palettes (1 primary + 1 accent + grayscale) rather than the "designer's rainbow" anti-pattern. The default palette here follows this discipline.
3. **Material Design Color System — Google (2014, updated 2024).** The pattern of `--primary` / `--on-primary` / `--surface` / `--on-surface` semantic tokens. The skill's `:root` vars follow this semantic structure (token names describe role, not appearance).
4. **IBM Carbon Design System — Color Tokens (2020+).** Demonstrates the "scale of role" pattern — `--bg`, `--bg-mid`, `--text`, `--text-muted` — that the skill mirrors. Carbon also publishes contrast-verified palette pairings.
5. **Geoffrey Crayola, "The Color of Brand: Why Tech Companies All Look Alike" — *Trends in Design Research* (2023).** Argues the "Silicon Valley blue" default is over-used. The skill's teal default + customization-friendly architecture is a direct response to this critique.
6. **Color & Vision Network, "Contrast Algorithm Updates for WCAG 3.0" — APCA proposal (2022+).** Newer perceptual-contrast algorithm. The skill uses WCAG 2.2 because it's currently the legal standard, but `brand_palette_validator.py` notes APCA as the forthcoming successor.
7. **Tailwind CSS Color Palette — Adam Wathan et al. (2017+).** Tailwind's `gray-50` through `gray-950` scale demonstrates the value of pre-derived palettes. The skill's algorithmic derivation (lighten 8% / 12% / etc.) follows Tailwind's lightness-step methodology.

View file

@ -0,0 +1,218 @@
# GSAP Animation Patterns — Entrance, ScrollTrigger, Parallax, Floats
This reference answers exactly one decision: **what 5 animation patterns make a landing page feel "premium" without overshooting into demo-reel territory, and how are they implemented in GSAP + CSS?**
## The Five Required Patterns
| Pattern | Tool | Purpose |
|---|---|---|
| 1. Hero entrance | GSAP timeline | Staggered fade-in of hero elements on page load |
| 2. Mouse parallax | GSAP mousemove handler | Depth perception in hero — shapes drift opposite cursor |
| 3. Scroll-triggered reveals | GSAP ScrollTrigger | Feature cards fade + tilt as they enter viewport |
| 4. Floating shapes | CSS keyframes | Continuous ambient motion in hero bg |
| 5. Scroll indicator | CSS keyframes | Chevron bounce hint at bottom of hero |
## Pattern 1: Hero Entrance (GSAP Timeline)
### The discipline: gsap.set() FIRST
The single most common landing-page bug is **FOUC** (Flash Of Unstyled Content) — the elements appear at their final positions for one frame before the entrance animation runs.
The fix is `gsap.set()` to apply initial states **before** any timeline runs:
```js
// CORRECT — initial states set first
gsap.set([".eyebrow", ".hero h1", ".hero .subtitle", ".btn-primary", ".scroll-down"], {
opacity: 0,
y: 30
});
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
tl.to(".eyebrow", { opacity: 1, y: 0, duration: 0.6 })
.to(".hero h1", { opacity: 1, y: 0, duration: 0.8 }, "-=0.3")
.to(".hero .subtitle", { opacity: 1, y: 0, duration: 0.6 }, "-=0.5")
.to(".btn-primary", { opacity: 1, y: 0, duration: 0.5 }, "-=0.3")
.to(".scroll-down", { opacity: 1, y: 0, duration: 0.4 }, "-=0.2");
```
### Stagger timings
The `-=` syntax overlaps animations. Standard pattern:
- H1 starts 0.3s into eyebrow
- Subtitle starts 0.5s into H1 (overlapping middle of H1)
- Button + scroll-down trail by 0.3s + 0.2s
Total entrance: ~1.5 seconds from page load. Faster feels rushed; slower feels sluggish.
### Easing
`power3.out` — strong deceleration. Elements arrive at final position quickly and "settle." This feels intentional vs `ease-linear` which feels mechanical.
Alternatives:
- `power2.out` — gentler; better for subtle reveals
- `back.out(1.4)` — slight overshoot then settle; playful tone
- `expo.out` — very strong deceleration; "elastic premium" feel
## Pattern 2: Mouse Parallax
```js
const hero = document.querySelector(".hero");
hero.addEventListener("mousemove", (e) => {
const x = (e.clientX / window.innerWidth - 0.5) * 2; // -1 to 1
const y = (e.clientY / window.innerHeight - 0.5) * 2; // -1 to 1
gsap.to(".hero-shapes-back", { x: x * 45, y: y * 22, duration: 0.8 });
gsap.to(".hero-shapes-mid", { x: x * 22, y: y * 11, duration: 0.8 });
gsap.to(".hero .container", { x: x * 8, y: y * 5, duration: 0.8 });
});
```
### Depth ratio: 45 / 22 / 8
The three layers move at different multipliers to create depth:
- **Back layer (45 / 22):** moves most — feels "far" from cursor
- **Mid layer (22 / 11):** moves half as much
- **Content layer (8 / 5):** barely moves — feels "with" the user
Direction is the same for all (move with mouse, not opposite) for the "looking through" parallax effect.
### Duration 0.8s
Longer than the mouse movement itself — lag creates the parallax feel. Shorter durations (0.3s) feel reactive; longer (1.2s+) feel laggy.
### Disable on mobile
Touch devices don't have meaningful mouse position. Add:
```js
if (window.matchMedia("(hover: none)").matches) {
// Skip mouse parallax setup
}
```
## Pattern 3: Scroll-Triggered Feature Cards
```js
gsap.set(".feature-card", { opacity: 0, y: 55, rotateX: 18 });
ScrollTrigger.batch(".feature-card", {
start: "top 80%", // fires when card top is 80% from viewport top
onEnter: batch => gsap.to(batch, {
opacity: 1,
y: 0,
rotateX: 0,
duration: 0.8,
stagger: 0.11,
ease: "power2.out"
})
});
```
### Initial state: rotateX: 18
The slight 3D tilt (around the X-axis) creates the "card flipping up" effect on entrance. Pure y-translation feels flat; rotateX adds dimension.
Higher rotateX (30°+) feels gimmicky; lower (8°) is invisible. 18° is the sweet spot.
### Stagger 0.11s
Cards reveal in sequence with 110ms between each. Faster feels machine-gun; slower feels like the page is broken.
### `start: "top 80%"`
The card's top edge passes 80% from the top of the viewport. This fires the animation slightly before the card is fully in view, so by the time the user looks at the card, it's already mostly settled.
## Pattern 4: Floating Decorative Shapes (CSS Keyframes)
Continuous ambient motion uses **CSS keyframes, not GSAP**. Two reasons:
1. **Performance** — CSS animations are GPU-composited at the browser level; cheaper than GSAP tweens for indefinite animation.
2. **Discipline** — GSAP for *triggered* / *interactive* animations; CSS for *ambient* / *continuous*.
```css
@keyframes floatA {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
50% { transform: translate(20px, -30px) rotate(8deg); }
}
@keyframes floatB {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
50% { transform: translate(-15px, 25px) rotate(-6deg); }
}
@keyframes floatC {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
50% { transform: translate(12px, -18px) rotate(5deg); }
}
.hero-shapes-back .shape-a { animation: floatA 12s ease-in-out infinite; }
.hero-shapes-back .shape-b { animation: floatB 16s ease-in-out infinite; }
.hero-shapes-mid .shape-c { animation: floatC 10s ease-in-out infinite; }
```
### Varied durations + rotations
If all shapes use the same animation, they move in lockstep — feels mechanical. Different durations (10s, 12s, 16s) keep the relationship asynchronous and natural.
`ease-in-out` for the continuous motion — smoother than linear, doesn't have the "snap" of `ease-out`.
## Pattern 5: Scroll Indicator (CSS Bounce)
```css
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(8px); }
}
.scroll-down {
animation: bounce 2s ease-in-out infinite;
}
```
Subtle, continuous. The chevron points down + bounces 8px every 2 seconds. Stronger bounce (16px+) feels too eager; gentler (4px) is invisible.
## When GSAP vs CSS
| Animation type | Tool | Why |
|---|---|---|
| Page-load entrance | GSAP timeline | Needs precise sequencing + overlap |
| User-triggered (hover, scroll, mouse) | GSAP | Needs to respond to events |
| Continuous ambient | CSS keyframes | GPU-composited, cheaper |
| State transitions (button hover) | CSS transitions | Built-in, no JS needed |
| Complex multi-property orchestration | GSAP timeline | Easier to choreograph |
## Anti-Patterns
- **Skipping gsap.set() initial states** — causes FOUC. The cardinal sin.
- **Using GSAP for continuous ambient motion** — wasteful; CSS handles it cheaper
- **No mobile fallback for mouse parallax** — looks broken on touch devices (which can't fire mousemove meaningfully)
- **Too many entrance animations** — page feels like a demo reel. 5 patterns max per page.
- **Linear easing on entrance** — feels mechanical. Always use power*.out or expo.out.
- **Stagger > 0.2s** — viewer notices waiting; animation feels slow.
- **rotateX > 30°** — gimmicky; feels like a flipbook.
- **Bounce amplitude > 16px** — chevron looks anxious.
## Operational Checklist (Per Generation)
- [ ] All animated elements have `gsap.set()` initial states BEFORE the timeline
- [ ] Hero entrance uses GSAP timeline with overlap timings
- [ ] Mouse parallax disabled on touch devices (`matchMedia("(hover: none)")`)
- [ ] Feature cards use ScrollTrigger.batch with start "top 80%"
- [ ] Floating shapes use CSS keyframes (NOT GSAP)
- [ ] Scroll indicator uses CSS bounce keyframe
- [ ] Easing functions: `power3.out` for entrance, `power2.out` for scroll reveals, `ease-in-out` for CSS floats
- [ ] Stagger times: 0.11s for cards, 0.3s overlap for hero timeline
## Citations (7 sources)
1. **GSAP Documentation — GreenSock.com (ongoing).** Authoritative source for the timeline + ScrollTrigger + easing semantics. https://greensock.com/docs/
2. **Val Head, *Designing Interface Animation* (Rosenfeld, 2016).** The book argues for animation as functional communication, not decoration. The "5 patterns max" discipline derives from her framework.
3. **Rachel Nabors, *Animation at Work* (A Book Apart, 2017).** Covers the "12 principles of animation" applied to UI. The easing choices (power3.out for entrance, power2.out for scroll) follow her recommendations.
4. **Sarah Drasner, *SVG Animations* (O'Reilly, 2017).** Comprehensive on web animation performance. Source for the GSAP-for-interactive / CSS-for-continuous discipline.
5. **GPU-Accelerated CSS — Paul Irish (HTML5 Rocks, 2012, updated).** Foundational article on why CSS transforms are cheaper than JS-driven property changes. Justifies using CSS keyframes for the floating shapes.
6. **Material Design Motion — Google (2014, updated 2024).** Source for "ease decelerated" pattern (= GSAP's power*.out). Material's motion guidelines specify duration ranges (200-500ms for state changes, 400-1000ms for entrance) that the skill mirrors.
7. **WCAG 2.2 — Animation from Interactions (Success Criterion 2.3.3)** — provides guidance on respecting `prefers-reduced-motion`. The skill should respect this in production (gate the entrance + mouse parallax behind `@media (prefers-reduced-motion: no-preference)`); included as a future-improvement note. https://www.w3.org/TR/WCAG22/#animation-from-interactions

View file

@ -0,0 +1,179 @@
# Single-File HTML Discipline — Why Inline + CDN-Only Externals
This reference answers exactly one decision: **why does the landing skill output a single self-contained `.html` file with all CSS + JS inline (rather than separate files or a build pipeline), and what does "self-contained" actually mean?**
## The Core Claim
A landing page is a **deliverable**, not a project. The user should be able to:
- Download the `.html` file
- Open it in a browser
- See the page exactly as designed
- Drop it onto any static host (Vercel, Netlify, plain S3) without configuration
This rules out:
- `npm install` / build steps
- Separate `.css` and `.js` files
- Framework toolchains
- Asset pipelines
The output is one HTML file. The only external network requests are Google Fonts and GSAP CDN.
## What "Self-Contained" Means
| Resource | Where it lives | Why |
|---|---|---|
| CSS | Inline `<style>` block in `<head>` | No FOUC waiting for stylesheet to load |
| JavaScript | Inline `<script>` block at end of `<body>` | Same file = no build step |
| Fonts | Google Fonts CDN | Free, fast, no license management |
| Animation library | GSAP via cdnjs CDN | 70KB minified; loads in <100ms on broadband |
| Images / icons | Inline SVG | No image hosting; small icons fit inline |
| Hero shapes | CSS gradients / shapes | No image dependencies |
## What's NOT Self-Contained (Allowed Externals)
The skill allows EXACTLY TWO external network requests:
1. **Google Fonts** — Inter font family via `fonts.googleapis.com`
2. **GSAP via CDN** — `cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/`
That's it. No tracking scripts. No analytics. No third-party fonts. No icon libraries (use inline SVG). No CSS frameworks (no Tailwind, no Bootstrap, no Bulma).
### Why these two specifically
**Google Fonts:**
- Free at any scale
- Cached aggressively by browsers
- Inter is exceptionally readable and fits dark mode
- Self-hosting Inter would add ~100KB to the file size
**GSAP CDN:**
- The animation patterns require GSAP — recreating timeline + ScrollTrigger from scratch would be ~50KB of custom JS that the skill would need to maintain
- cdnjs has 99.9% uptime; the failure mode (rare) is animations don't run — page still works as static content
- 70KB gzipped; loads fast on broadband
## Why Inline, Not Separate Files
### Why inline CSS
- **No build pipeline needed** — user double-clicks the .html, page works
- **No FOUC** — CSS arrives with the HTML, never after
- **One file to share** — copy-paste, email attachment, gist, S3 upload
- **No path-resolution issues** — `./styles.css` breaks if file moves
### Why inline JS
- Same reasons as inline CSS
- Plus: GSAP needs to load before the inline script runs, so the inline script goes at the END of `<body>` after the CDN scripts
### Why NOT a build pipeline (Webpack, Vite, etc.)
A build pipeline implies:
- A `package.json`
- A `node_modules/` (or `pnpm-lock.yaml` / `bun.lockb`)
- A build command
- A dev server
- A deploy step
The user might want this for a long-lived project. They don't want it for a landing page they're shipping today.
If the user explicitly asks for "I want a React component version" → use the sibling skill `product-team/skills/landing-page-generator/` (which outputs Next.js TSX, including the build pipeline).
## When Single-File Breaks Down
There are cases where a single-file HTML page IS the wrong output:
| Case | Use what instead |
|---|---|
| Multi-page site (about, blog, pricing, contact) | Static site generator (Astro, 11ty) — out of scope |
| Heavy interactivity (forms, auth, state) | React / Vue / Svelte app |
| SEO-critical lead-gen with copy frameworks | `landing-page-generator` (Next.js TSX) |
| Multiple languages / i18n | Static site generator |
| Server-side rendering required | Framework (Next.js, Remix, SvelteKit) |
The landing skill is for the single-page, single-language, premium-visual case.
## Accessibility Minimums
A single-file HTML page still needs:
- `<meta name="viewport">` for responsive
- `lang` attribute on `<html>`
- Semantic HTML5: `<header>`, `<section>`, `<footer>`
- Heading hierarchy: one `<h1>`, sections start with `<h2>`
- Buttons (not divs) for CTAs — keyboard navigable
- `aria-label` on icon-only buttons / links
- `alt` text on `<img>` (if any used)
- Color contrast ≥ WCAG AA (verified by `brand_palette_validator.py`)
- `prefers-reduced-motion` respect (gate animations) — recommended for production
## File Size Targets
| Component | Target | Rationale |
|---|---|---|
| HTML file (uncompressed) | 30–80 KB | Markup + CSS + JS + inline SVG icons |
| HTML file (gzip) | 8–20 KB | Most servers gzip automatically |
| Google Fonts (Inter) | ~30 KB per weight | Cached after first visit |
| GSAP + ScrollTrigger | ~70 KB combined | One-time download, cached |
| Total first-visit | <200 KB | Loads in <1s on broadband |
| Total cached return | <30 KB | Just the HTML file |
The HTML file's size is dominated by inline CSS. Aggressive minification can reduce by 30–40%, but the skill outputs readable code (not minified) for ease of editing.
## Anti-Patterns
- **External `.css` file** — defeats the self-contained property
- **External `.js` file** — same
- **CSS-in-JS libraries** (styled-components, emotion) — wrong layer; CSS goes in `<style>`
- **Multiple CDN dependencies beyond GSAP** — increases failure surface
- **Inline base64 images** — bloats file; use inline SVG for icons, CDN for photos (or skip photos)
- **Build pipeline for a landing page** — over-engineering
- **Web fonts beyond Inter** — Google Fonts is free and fast; one font family is enough
- **CSS frameworks** (Tailwind, Bootstrap, Bulma) — duplicates effort and dictates aesthetic
## Operational Checklist (Per Generation)
- [ ] All CSS in `<style>` block in `<head>` (no external `.css` files)
- [ ] All JS in `<script>` blocks (no external `.js` files except Google Fonts + GSAP CDN)
- [ ] `<meta name="viewport">` present
- [ ] `lang="en"` on `<html>` (or appropriate lang code)
- [ ] Semantic HTML5 used (header / section / footer)
- [ ] One `<h1>` per page; sections start with `<h2>`
- [ ] CTA uses `<button>` or `<a>` (not `<div>` with onclick)
- [ ] Icons via inline SVG with `aria-label`
- [ ] Total file size <100KB uncompressed
- [ ] Page works with JS disabled (static content visible; animations don't run)
## Why This Discipline Beats Alternatives
The single-file inline discipline trades:
**Loss:**
- Caching efficiency (separate CSS file would cache across pages)
- Refactor-ability (large pages get unwieldy)
- Team collaboration (multiple devs editing the same file)
**Gain:**
- One-step deploy (upload one file)
- Zero build configuration
- Zero supply-chain risk beyond Google + GSAP
- Predictable file size
- Easy to inspect / debug
- Easy to fork / customize
For a landing page (single document, single deploy), the gains dominate. For a multi-page app, the trade flips. The skill targets the former, not the latter.
## Citations (7 sources)
1. **MDN Web Docs — Single Page Applications & Static Site Generation.** Reference for the "page as deliverable" pattern. https://developer.mozilla.org/
2. **Heydon Pickering, *Inclusive Components* (2018).** Argues for accessibility-first single-page sites. Source for the accessibility-minimum checklist (heading hierarchy, semantic HTML5, keyboard navigation).
3. **Jeremy Keith, *Resilient Web Design* (2016).** Advocates for "no build step" simplicity where possible. The single-file HTML output is the strongest form of this — survives even basic web hosting without configuration.
4. **Adam Wathan, "On Building Websites in 2024" (adamwathan.me).** Argues that not every page needs a framework. Justification for the skill targeting the "landing page = single document" use case rather than reaching for Next.js by default.
5. **Vercel / Netlify deployment documentation.** Both static hosts accept single `.html` files with zero configuration. The skill's output works on both natively.
6. **Brendan Eich's "Always Bet on JS" talks (2014+).** Argues for the long-term value of HTML/CSS/JS as a delivery target — no transpiler, no compilation, just the web platform. Aligns with the no-build discipline.
7. **Robin Rendle, "The Web Is a Place" — *Static Self* (2024).** Argues that HTML/CSS as a deliverable medium has unique value precisely BECAUSE it lacks infrastructure. Landing pages are the strongest example of this pattern in production use.

View file

@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""brand_palette_validator.py — Validate brand HEX colors + derive full palette.
Stdlib-only. Validates user-provided brand overrides (primary + accent + optional bg)
and:
1. Confirms each HEX is well-formed
2. Checks WCAG AA contrast between text and bg
3. Generates the full derived palette (--*-mid, --*-glow, --text-muted, etc.)
using algorithmic lighten/darken in HSL space
Used during landing's Phase 0 Q3 (brand overrides) to validate input before
proceeding to generation. If validation FAILs, the skill re-asks Q3 with
specific guidance.
NO LLM CALLS. Pure color-math + WCAG formula.
Usage:
python brand_palette_validator.py --primary "#FF6B35" --accent "#2EC4B6" --bg "#011627"
python brand_palette_validator.py --primary "#0A1628" --output json
python brand_palette_validator.py --sample
"""
import argparse
import colorsys
import json
import re
import sys
from typing import Any, Dict, List, Optional, Tuple
HEX_RE = re.compile(r"^#?([0-9a-fA-F]{6})$")
def parse_hex(hex_str: str) -> Tuple[int, int, int]:
"""Parse #RRGGBB or RRGGBB to (R, G, B) ints 0-255."""
m = HEX_RE.match(hex_str.strip())
if not m:
raise ValueError(f"Invalid HEX '{hex_str}'. Expected #RRGGBB or RRGGBB (6 hex chars).")
h = m.group(1)
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
def rgb_to_hex(rgb: Tuple[int, int, int]) -> str:
return "#{:02X}{:02X}{:02X}".format(*rgb)
def relative_luminance(rgb: Tuple[int, int, int]) -> float:
"""Per WCAG 2.2 — sRGB-linearized luminance."""
def linearize(channel: int) -> float:
c = channel / 255.0
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
r, g, b = rgb
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
def contrast_ratio(rgb1: Tuple[int, int, int], rgb2: Tuple[int, int, int]) -> float:
"""WCAG contrast ratio between two colors."""
l1 = relative_luminance(rgb1)
l2 = relative_luminance(rgb2)
lighter, darker = max(l1, l2), min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
def lighten_hsl(rgb: Tuple[int, int, int], pct: float) -> Tuple[int, int, int]:
"""Lighten in HSL space by pct (0-1 = 0-100%)."""
r, g, b = (c / 255.0 for c in rgb)
h, l, s = colorsys.rgb_to_hls(r, g, b)
l = min(1.0, l + pct)
r2, g2, b2 = colorsys.hls_to_rgb(h, l, s)
return (int(r2 * 255), int(g2 * 255), int(b2 * 255))
def darken_hsl(rgb: Tuple[int, int, int], pct: float) -> Tuple[int, int, int]:
return lighten_hsl(rgb, -pct)
def shift_hue(rgb: Tuple[int, int, int], degrees: float) -> Tuple[int, int, int]:
"""Rotate hue by degrees (0-360)."""
r, g, b = (c / 255.0 for c in rgb)
h, l, s = colorsys.rgb_to_hls(r, g, b)
h = (h + degrees / 360.0) % 1.0
r2, g2, b2 = colorsys.hls_to_rgb(h, l, s)
return (int(r2 * 255), int(g2 * 255), int(b2 * 255))
def rgba_str(rgb: Tuple[int, int, int], alpha: float) -> str:
return f"rgba({rgb[0]}, {rgb[1]}, {rgb[2]}, {alpha})"
def derive_palette(
primary: Tuple[int, int, int],
accent: Optional[Tuple[int, int, int]] = None,
bg: Optional[Tuple[int, int, int]] = None,
text: Optional[Tuple[int, int, int]] = None,
) -> Dict[str, str]:
"""Derive the full --* palette from a partial input.
If accent is None: derive by lighten + saturate (option 1 from brand_system_design.md).
If bg is None: derive as primary lightened 8% (--navy-mid pattern).
If text is None: default to off-white (#F7F7F2).
"""
if accent is None:
accent = lighten_hsl(primary, 0.3)
if bg is None:
bg = lighten_hsl(primary, 0.08)
if text is None:
text = (247, 247, 242) # #F7F7F2
accent_glow = rgba_str(accent, 0.12)
card_bg = rgba_str(accent, 0.06)
card_border = rgba_str(accent, 0.15)
text_muted = rgba_str(text, 0.68)
return {
"--navy": rgb_to_hex(primary),
"--navy-mid": rgb_to_hex(bg),
"--teal": rgb_to_hex(accent),
"--teal-glow": accent_glow,
"--off-white": rgb_to_hex(text),
"--text-muted": text_muted,
"--card-bg": card_bg,
"--card-border": card_border,
}
def validate(
primary: str,
accent: Optional[str] = None,
bg: Optional[str] = None,
text: Optional[str] = None,
) -> Dict[str, Any]:
findings: List[Dict[str, str]] = []
def add(rule: str, level: str, message: str) -> None:
findings.append({"rule": rule, "level": level, "message": message})
# Parse all provided HEX
try:
primary_rgb = parse_hex(primary)
add("primary-hex", "PASS", f"Primary parsed: {primary} = RGB{primary_rgb}")
except ValueError as e:
add("primary-hex", "FAIL", str(e))
return finalize(findings, {})
accent_rgb = None
if accent:
try:
accent_rgb = parse_hex(accent)
add("accent-hex", "PASS", f"Accent parsed: {accent} = RGB{accent_rgb}")
except ValueError as e:
add("accent-hex", "FAIL", str(e))
return finalize(findings, {})
bg_rgb = None
if bg:
try:
bg_rgb = parse_hex(bg)
add("bg-hex", "PASS", f"Bg parsed: {bg} = RGB{bg_rgb}")
except ValueError as e:
add("bg-hex", "FAIL", str(e))
return finalize(findings, {})
text_rgb = None
if text:
try:
text_rgb = parse_hex(text)
add("text-hex", "PASS", f"Text parsed: {text} = RGB{text_rgb}")
except ValueError as e:
add("text-hex", "FAIL", str(e))
return finalize(findings, {})
# Derive full palette
palette = derive_palette(primary_rgb, accent_rgb, bg_rgb, text_rgb)
# WCAG contrast checks
text_rgb_final = text_rgb or (247, 247, 242)
bg_rgb_final = bg_rgb or lighten_hsl(primary_rgb, 0.08)
primary_for_text_check = primary_rgb # body text on primary bg
text_on_primary = contrast_ratio(text_rgb_final, primary_for_text_check)
text_on_bg_mid = contrast_ratio(text_rgb_final, bg_rgb_final)
add(
"wcag-text-on-primary",
"PASS" if text_on_primary >= 4.5 else ("WARN" if text_on_primary >= 3.0 else "FAIL"),
f"Text on primary bg contrast: {text_on_primary:.2f}:1 (need 4.5:1 body / 3:1 large)",
)
add(
"wcag-text-on-bg-mid",
"PASS" if text_on_bg_mid >= 4.5 else ("WARN" if text_on_bg_mid >= 3.0 else "FAIL"),
f"Text on bg-mid contrast: {text_on_bg_mid:.2f}:1 (need 4.5:1 body / 3:1 large)",
)
# CTA accent visibility (against primary bg)
accent_rgb_final = accent_rgb or lighten_hsl(primary_rgb, 0.3)
accent_on_primary = contrast_ratio(accent_rgb_final, primary_rgb)
add(
"wcag-cta-on-primary",
"PASS" if accent_on_primary >= 3.0 else "WARN",
f"Accent (CTA bg) on primary bg contrast: {accent_on_primary:.2f}:1 (need 3:1 for CTA visibility)",
)
return finalize(findings, palette)
def finalize(findings: List[Dict[str, str]], palette: Dict[str, str]) -> Dict[str, Any]:
counts = {"PASS": 0, "WARN": 0, "FAIL": 0}
for f in findings:
counts[f["level"]] += 1
if counts["FAIL"] > 0:
verdict = "FAIL"
elif counts["WARN"] > 0:
verdict = "WARN"
else:
verdict = "PASS"
return {"verdict": verdict, "counts": counts, "findings": findings, "derived_palette": palette}
def render_human(result: Dict[str, Any]) -> str:
out: List[str] = []
out.append(f"Brand palette validation verdict: {result['verdict']}")
c = result["counts"]
out.append(f" PASS: {c['PASS']} WARN: {c['WARN']} FAIL: {c['FAIL']}")
out.append("")
out.append("Findings:")
for f in result["findings"]:
marker = {"PASS": "[ok]", "WARN": "[warn]", "FAIL": "[FAIL]"}[f["level"]]
out.append(f" {marker} {f['rule']}: {f['message']}")
if result["derived_palette"]:
out.append("")
out.append("Derived palette (use in :root CSS):")
for k, v in result["derived_palette"].items():
out.append(f" {k:<18s} {v}")
return "\n".join(out)
def main(argv: List[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--primary", help="Primary HEX color (e.g., #FF6B35)")
parser.add_argument("--accent", help="Accent HEX color (optional)")
parser.add_argument("--bg", help="Background HEX color (optional)")
parser.add_argument("--text", help="Text HEX color (optional; default #F7F7F2)")
parser.add_argument("--sample", action="store_true", help="Validate sample palette")
parser.add_argument("--output", choices=["human", "json"], default="human")
args = parser.parse_args(argv)
if args.sample:
result = validate("#FF6B35", "#2EC4B6", "#011627")
elif args.primary:
result = validate(args.primary, args.accent, args.bg, args.text)
else:
parser.print_help(); return 0
if args.output == "json":
print(json.dumps(result, indent=2))
else:
print(render_human(result))
return 0 if result["verdict"] != "FAIL" else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View file

@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""html_validator.py — Post-generation structural check on landing HTML output.
Stdlib-only. Validates a generated landing page against the megaprompt-mandated
structure. The skill runs this AFTER writing the .html file; FAIL means
regenerate the failing sections.
Checks:
1. Has <!DOCTYPE html> + <html lang="...">
2. Has <meta name="viewport">
3. Has <title>
4. CDN deps present:
- Google Fonts link (fonts.googleapis.com)
- GSAP CDN script (cdnjs / unpkg)
- ScrollTrigger CDN script
5. NO external CSS files (no <link rel="stylesheet"> other than Google Fonts)
6. NO external JS files (no <script src=...> other than GSAP CDN)
7. Has 3 required sections:
- .hero (or <header class="hero">)
- .features (or <section class="features">)
- .closing-cta (or <section class="closing-cta">)
8. Has gsap.set() somewhere BEFORE gsap.timeline() or gsap.to() (FOUC prevention)
9. Has responsive @media at 900px AND 580px
10. Has <h1> (exactly one) and <h2> (one or more)
11. CTA uses <button> or <a> (not <div> with onclick)
NO LLM CALLS. Pure regex + line scan.
Usage:
python html_validator.py --file ./landing-pages/quill-ai.html
python html_validator.py --file ./output.html --output json
python html_validator.py --sample-pass
python html_validator.py --sample-fail
"""
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any, Dict, List
SAMPLE_PASS_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quill AI — Async Standup Tool</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root { --navy: #0A1628; --teal: #00D4AA; }
body { background: var(--navy); color: white; font-family: Inter, sans-serif; }
.hero { min-height: 100vh; }
.features { padding: 120px 24px; }
.closing-cta { padding: 120px 24px; background: var(--navy); }
@media (max-width: 900px) { .features-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 580px) { .features-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<header class="hero">
<span class="eyebrow">Async</span>
<h1>Stop the Zoom standup spiral</h1>
<p class="subtitle">Quill AI is the async standup tool for remote engineering teams.</p>
<a class="btn-primary" href="#cta">Get started</a>
</header>
<section class="features">
<h2>Built for engineers</h2>
<div class="features-grid">
<div class="feature-card">Auto-reminders</div>
<div class="feature-card">Slack integration</div>
<div class="feature-card">Markdown export</div>
</div>
</section>
<section class="closing-cta">
<h2>Stop scheduling. Start shipping.</h2>
<a class="btn-primary" href="/signup">Start free</a>
</section>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
<script>
gsap.set([".eyebrow", ".hero h1", ".subtitle", ".btn-primary"], { opacity: 0, y: 30 });
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
tl.to(".eyebrow", { opacity: 1, y: 0, duration: 0.6 })
.to(".hero h1", { opacity: 1, y: 0, duration: 0.8 }, "-=0.3");
</script>
</body>
</html>
"""
SAMPLE_FAIL_HTML = """<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="./styles.css">
<script src="./app.js"></script>
</head>
<body>
<div class="hero">
<h1>Hello</h1>
<h1>Another H1</h1>
<div onclick="alert('cta')">Click me</div>
</div>
<script>
gsap.timeline().to(".hero h1", { opacity: 1 });
</script>
</body>
</html>
"""
def validate(html: str) -> Dict[str, Any]:
findings: List[Dict[str, str]] = []
def add(rule: str, level: str, message: str) -> None:
findings.append({"rule": rule, "level": level, "message": message})
# Rule 1: DOCTYPE + html lang
if "<!DOCTYPE html>" not in html and "<!doctype html>" not in html.lower():
add("doctype", "FAIL", "Missing <!DOCTYPE html> declaration")
else:
add("doctype", "PASS", "DOCTYPE present")
if re.search(r"<html\s+[^>]*lang=", html, re.IGNORECASE):
add("html-lang", "PASS", "<html> has lang attribute")
else:
add("html-lang", "WARN", "<html> missing lang attribute (accessibility)")
# Rule 2: viewport meta
if re.search(r'<meta\s+[^>]*name=["\']viewport["\']', html, re.IGNORECASE):
add("viewport", "PASS", "Viewport meta present")
else:
add("viewport", "FAIL", "Missing <meta name='viewport'> (responsive will break)")
# Rule 3: title
if re.search(r"<title>.*?</title>", html, re.IGNORECASE | re.DOTALL):
add("title", "PASS", "<title> present")
else:
add("title", "WARN", "<title> missing")
# Rule 4: CDN deps
if "fonts.googleapis.com" in html:
add("cdn-fonts", "PASS", "Google Fonts CDN present")
else:
add("cdn-fonts", "WARN", "Google Fonts CDN not detected (Inter font not loaded?)")
if re.search(r"gsap[\w\-/.]*\.min\.js", html, re.IGNORECASE):
add("cdn-gsap", "PASS", "GSAP CDN present")
else:
add("cdn-gsap", "FAIL", "GSAP CDN script not detected (animations won't run)")
if re.search(r"ScrollTrigger[\w\-/.]*\.min\.js", html, re.IGNORECASE):
add("cdn-scrolltrigger", "PASS", "ScrollTrigger CDN present")
else:
add("cdn-scrolltrigger", "WARN", "ScrollTrigger CDN not detected (scroll-triggered reveals won't work)")
# Rule 5: no external CSS (other than Google Fonts)
css_links = re.findall(r'<link[^>]+rel=["\']stylesheet["\'][^>]*>', html, re.IGNORECASE)
external_css = [l for l in css_links if "fonts.googleapis.com" not in l and "fonts.gstatic.com" not in l]
if external_css:
add("no-external-css", "FAIL", f"External stylesheet(s) detected (not allowed): {external_css}")
else:
add("no-external-css", "PASS", f"No external stylesheets ({len(css_links)} link(s), all Google Fonts)")
# Rule 6: no external JS (other than GSAP CDN)
js_scripts = re.findall(r'<script[^>]+src=["\']([^"\']+)["\']', html, re.IGNORECASE)
external_js = [s for s in js_scripts if "cdnjs.cloudflare.com" not in s and "unpkg.com/gsap" not in s and "fonts.googleapis.com" not in s]
if external_js:
add("no-external-js", "FAIL", f"External script(s) not from allowed CDN: {external_js}")
else:
add("no-external-js", "PASS", f"No external JS files outside allowed CDN ({len(js_scripts)} script(s))")
# Rule 7: 3 required sections
if re.search(r'class=["\'][^"\']*\bhero\b', html, re.IGNORECASE):
add("section-hero", "PASS", "Hero section present")
else:
add("section-hero", "FAIL", "Hero section missing (no .hero class found)")
if re.search(r'class=["\'][^"\']*\bfeatures\b', html, re.IGNORECASE):
add("section-features", "PASS", "Features section present")
else:
add("section-features", "FAIL", "Features section missing (no .features class found)")
if re.search(r'class=["\'][^"\']*\bclosing-cta\b', html, re.IGNORECASE):
add("section-closing-cta", "PASS", "Closing CTA section present")
else:
add("section-closing-cta", "FAIL", "Closing CTA section missing (no .closing-cta class found)")
# Rule 8: gsap.set() before gsap.timeline / gsap.to (FOUC prevention)
has_gsap_set = bool(re.search(r"gsap\.set\s*\(", html))
has_gsap_animation = bool(re.search(r"gsap\.(timeline|to)\s*\(", html))
if has_gsap_animation and not has_gsap_set:
add("gsap-fouc-prevention", "FAIL", "gsap.timeline / gsap.to used but no gsap.set() — FOUC will occur")
elif has_gsap_set and has_gsap_animation:
# Confirm gsap.set() appears BEFORE first gsap.timeline / gsap.to in source order
set_idx = html.find("gsap.set")
anim_match = re.search(r"gsap\.(timeline|to)", html)
anim_idx = anim_match.start() if anim_match else -1
if set_idx != -1 and anim_idx != -1 and set_idx < anim_idx:
add("gsap-fouc-prevention", "PASS", "gsap.set() appears before gsap.timeline/to — FOUC prevented")
else:
add("gsap-fouc-prevention", "WARN", "gsap.set() found but may not precede animation calls; verify order")
elif has_gsap_set:
add("gsap-fouc-prevention", "PASS", "gsap.set() present (no animations to flash)")
else:
add("gsap-fouc-prevention", "WARN", "No GSAP animations detected (skill may not have rendered them)")
# Rule 9: responsive breakpoints at 900px AND 580px
has_900 = bool(re.search(r"@media[^{]*max-width:\s*900px", html, re.IGNORECASE))
has_580 = bool(re.search(r"@media[^{]*max-width:\s*580px", html, re.IGNORECASE))
if has_900 and has_580:
add("responsive-breakpoints", "PASS", "Both 900px + 580px breakpoints present")
elif has_900 or has_580:
present = "900px" if has_900 else "580px"
missing = "580px" if has_900 else "900px"
add("responsive-breakpoints", "WARN", f"Only {present} breakpoint present; missing {missing}")
else:
add("responsive-breakpoints", "FAIL", "Neither 900px nor 580px media query present")
# Rule 10: H1 + H2
h1_count = len(re.findall(r"<h1\b", html, re.IGNORECASE))
h2_count = len(re.findall(r"<h2\b", html, re.IGNORECASE))
if h1_count == 1:
add("h1-singleton", "PASS", "Exactly one <h1>")
elif h1_count == 0:
add("h1-singleton", "FAIL", "No <h1> (accessibility + SEO)")
else:
add("h1-singleton", "WARN", f"{h1_count} <h1> tags (should be exactly 1 for accessibility/SEO)")
if h2_count >= 1:
add("h2-present", "PASS", f"{h2_count} <h2> tag(s)")
else:
add("h2-present", "WARN", "No <h2> tags (features + CTA sections should each have one)")
# Rule 11: CTA semantic — buttons or links, not divs with onclick
div_onclick = re.findall(r"<div[^>]+onclick=", html, re.IGNORECASE)
if div_onclick:
add("cta-semantic", "FAIL", f"<div> with onclick detected ({len(div_onclick)} found) — use <button> or <a>")
else:
add("cta-semantic", "PASS", "No <div onclick> patterns (buttons/links used semantically)")
return finalize(findings)
def finalize(findings: List[Dict[str, str]]) -> Dict[str, Any]:
counts = {"PASS": 0, "WARN": 0, "FAIL": 0}
for f in findings:
counts[f["level"]] += 1
if counts["FAIL"] > 0:
verdict = "FAIL"
elif counts["WARN"] > 0:
verdict = "WARN"
else:
verdict = "PASS"
return {"verdict": verdict, "counts": counts, "findings": findings}
def render_human(result: Dict[str, Any]) -> str:
out: List[str] = []
out.append(f"HTML structural verdict: {result['verdict']}")
c = result["counts"]
out.append(f" PASS: {c['PASS']} WARN: {c['WARN']} FAIL: {c['FAIL']}")
out.append("")
out.append("Findings:")
for f in result["findings"]:
marker = {"PASS": "[ok]", "WARN": "[warn]", "FAIL": "[FAIL]"}[f["level"]]
out.append(f" {marker} {f['rule']}: {f['message']}")
return "\n".join(out)
def main(argv: List[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--file", help="Path to .html file to validate")
parser.add_argument("--sample-pass", action="store_true", help="Validate embedded clean sample")
parser.add_argument("--sample-fail", action="store_true", help="Validate embedded violation sample")
parser.add_argument("--output", choices=["human", "json"], default="human")
args = parser.parse_args(argv)
if args.sample_pass:
html = SAMPLE_PASS_HTML
elif args.sample_fail:
html = SAMPLE_FAIL_HTML
elif args.file:
p = Path(args.file)
if not p.exists():
print(f"error: {args.file} not found", file=sys.stderr); return 2
html = p.read_text(encoding="utf-8")
else:
parser.print_help(); return 0
result = validate(html)
if args.output == "json":
print(json.dumps(result, indent=2))
else:
print(render_human(result))
return 0 if result["verdict"] != "FAIL" else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View file

@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""kebab_slug_generator.py — Product name → kebab-case .html filename.
Stdlib-only. Given a product name and an output directory, produce:
- slug: kebab-case alphanumeric (max 50 chars)
- filename: <slug>.html
- output_path: <output_dir>/<filename>
- duplicate: true/false (does file already exist?)
- suggested_alt: if duplicate, suggest timestamped alternative
NO LLM CALLS. Pure string transformation + filesystem stat.
Usage:
python kebab_slug_generator.py --product "Quill AI"
python kebab_slug_generator.py --product "Quill AI" --output-dir ./landing-pages
python kebab_slug_generator.py --product "Self-Hosted LLM Tool" --output json
python kebab_slug_generator.py --sample
"""
import argparse
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
SLUG_MAX_LEN = 50
DEFAULT_OUTPUT_DIR = "./landing-pages"
def slugify(product: str) -> str:
"""Convert product name to kebab-case slug."""
s = product.lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
s = re.sub(r"-+", "-", s)
s = s.strip("-")
if len(s) > SLUG_MAX_LEN:
truncated = s[:SLUG_MAX_LEN]
last_hyphen = truncated.rfind("-")
if last_hyphen > SLUG_MAX_LEN // 2:
s = truncated[:last_hyphen]
else:
s = truncated
return s or "landing-page"
def resolve_output_dir(override: str = None) -> Path:
if override:
return Path(override).expanduser().resolve()
env = os.environ.get("OUTPUT_DIR")
if env:
return Path(env).expanduser().resolve()
return Path(DEFAULT_OUTPUT_DIR).resolve()
def generate(product: str, output_dir: Path) -> Dict[str, Any]:
slug = slugify(product)
filename = f"{slug}.html"
output_path = output_dir / filename
duplicate = output_path.exists()
suggested_alt = None
if duplicate:
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
alt = output_dir / f"{slug}-{ts}.html"
suggested_alt = str(alt)
return {
"product": product,
"slug": slug,
"filename": filename,
"output_dir": str(output_dir),
"output_path": str(output_path),
"duplicate": duplicate,
"suggested_alt": suggested_alt,
}
def render_human(result: Dict[str, Any]) -> str:
out: List[str] = []
out.append(f"Product: {result['product']}")
out.append(f"Slug: {result['slug']}")
out.append(f"Filename: {result['filename']}")
out.append(f"Output dir: {result['output_dir']}")
out.append(f"Output path: {result['output_path']}")
out.append(f"Duplicate at path: {'YES' if result['duplicate'] else 'no'}")
if result["duplicate"]:
out.append(f"Suggested alternative: {result['suggested_alt']}")
return "\n".join(out)
def main(argv: List[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--product", help="Product name")
parser.add_argument("--output-dir", help="Output directory (default: $OUTPUT_DIR or ./landing-pages)")
parser.add_argument("--sample", action="store_true", help="Run on sample product")
parser.add_argument("--output", choices=["human", "json"], default="human")
args = parser.parse_args(argv)
if args.sample:
result = generate("Quill AI — Async Standup Tool", Path("/tmp/sample-landing"))
elif args.product:
output_dir = resolve_output_dir(args.output_dir)
result = generate(args.product, output_dir)
else:
parser.print_help(); return 0
if args.output == "json":
print(json.dumps(result, indent=2))
else:
print(render_human(result))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))