Merge branch 'feat/frontend-v040' — 4 stacks + 3 Rust + 5 shell + 17 skills + /site-create (partial)

Cargo.lock regenerated after 8-crate workspace merge.
This commit is contained in:
Parfii-bot 2026-04-21 21:17:19 +08:00
commit c89352c87c
46 changed files with 6907 additions and 7 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
_primitives/_rust/target/
**/target/
.DS_Store

36
_blocks/stack-astro.md Normal file
View file

@ -0,0 +1,36 @@
# STACK — Astro 6 (Content + Marketing + Islands)
Use for marketing sites, content-heavy sites, docs, and landing pages. Zero-JS by default; interactivity is opt-in per component via islands.
**When to pick:** the page is >70% static content (marketing, blog, docs, portfolio). For app-like surfaces (dashboards, editors, long session state) prefer `stack-nextjs` or `stack-react-vite`.
**Routing:** file-based (`src/pages/`). `.astro` components render to HTML at build time. Dynamic routes via `[slug].astro` + `getStaticPaths`.
**Islands:** any framework component (React / Svelte / Vue / Solid) renders via an integration and takes a `client:*` directive:
- `client:load` — hydrate immediately (interactive from first paint)
- `client:idle` — hydrate when main thread idle
- `client:visible` — hydrate when visible (default for below-fold widgets)
- `client:media="(max-width: 768px)"` — hydrate only on matching viewport
- `client:only="react"` — skip SSR entirely (client-only components)
No directive = zero JS shipped. Never add one "just in case".
**React integration:** `npx astro add react` → installs `@astrojs/react`. Then import and use `.tsx` components inside `.astro` with a `client:*` directive where interactivity is needed.
**Deploy adapter (Cloudflare default):** `npx astro add cloudflare` → installs `@astrojs/cloudflare`. In `astro.config.mjs` set `output: "server"` (for per-request SSR) or `"hybrid"` (pre-render by default, SSR where opted in). Static-only builds need no adapter — `astro build` emits `dist/`.
**Content collections:** `src/content/<collection>/*.md(x)` + `src/content/config.ts` (Zod schema). Type-safe queries via `getCollection(name)`. Use for blog posts, case studies, docs.
**View Transitions:** `import { ViewTransitions } from "astro:transitions"` — 2 lines in the base layout, zero JS overhead. Pairs well with the `motion-design` skill.
**Env vars:**
- Build-time: `import.meta.env.FOO` (inlined). `PUBLIC_*` prefix is client-visible; everything else is build-host only.
- Runtime (server/SSR): via adapter runtime (`context.locals.runtime.env` on Cloudflare Workers).
- Secrets go in platform env (Cloudflare dashboard / `.dev.vars` locally). NEVER in `astro.config.mjs`.
**Images:** `<Image src={...} />` from `astro:assets` — automatic `srcset`, `sizes`, `width`/`height`, AVIF/WebP fallback. Pair with `web-assets` skill for pipeline details.
**Typical stack:** Astro 6 + TypeScript + Tailwind 4 (via `stack-tailwind`) + `@astrojs/react` for islands + `adapter-cloudflare` + Content Collections. Files > 200 LOC get split (Constructor Pattern).
**Forbidden:** `client:load` on static content, importing React at the top of `.astro` pages that don't render interactive components, secrets in `PUBLIC_*` vars, mixing two UI frameworks without a concrete reason (ships multiple hydration runtimes).

View file

@ -0,0 +1,26 @@
# STACK — Vite + React 19 + TypeScript (SPA)
Use for single-page applications, internal dashboards, editors, design tools — surfaces where the page IS the app and SEO / zero-JS don't matter. For marketing use `stack-astro`. For full-stack React with Server Components use `stack-nextjs`.
**Scaffold:** `npm create vite@latest <app> -- --template react-ts`. Dev server via `vite`; production via `vite build``dist/` (static files).
**Routing:** `react-router-dom` v7 (data routers, `createBrowserRouter`). One file per route under `src/routes/`. For file-based routing prefer TanStack Router (first-class TS inference).
**Data:**
- Server state → TanStack Query v5 (`useQuery` / `useMutation`). Never `useEffect + fetch`.
- Client state → Zustand or `useState` — pick one per feature, don't layer Redux unless the team already uses it.
- Form state → React Hook Form v7 + Zod resolver (single schema client + server).
**Rendering:** React 19's `useActionState`, `useOptimistic`, and the `use()` hook for promise unwrapping. `Suspense` + `ErrorBoundary` on every route boundary. No conditional rendering that hides suspense errors.
**Types first:** Props/interfaces declared BEFORE the component. Discriminated unions for variant props. `as const` for finite-set string unions. No `any` in new code — use `unknown` + type-guards.
**Env vars:** `import.meta.env.VITE_*` — anything NOT prefixed with `VITE_` is stripped at build time. Secrets → backend, never in `VITE_*` (ships to browser).
**Styling:** Tailwind 4 (via `stack-tailwind`) OR CSS Modules — never both in the same project. `className` + token classes; no inline `style={{}}` except for dynamic CSS custom properties.
**Testing:** Vitest + React Testing Library + Playwright for E2E. Tests co-located next to source (`Component.test.tsx`).
**Deploy target:** the SPA is a static bundle — Cloudflare Pages, Vercel, S3+CloudFront, or any static host. No adapter needed.
**Forbidden:** `create-react-app` (deprecated), `fetch` inside `useEffect` (use TanStack Query), `any` in new code, secret env vars without the `VITE_` prefix (shipped to client), mixing Redux + Zustand in the same feature, CSS-in-JS runtimes (ships extra KB — use Tailwind or CSS Modules).

View file

@ -0,0 +1,34 @@
# STACK — SvelteKit (Svelte 5 Runes + TS)
Use for animation-heavy sites, mobile-first interactive surfaces, and apps where smallest-possible runtime matters. Svelte 5 compiles to minimal JS; Runes replace the legacy reactive-label syntax.
**Scaffold:** `npm create svelte@latest <app>` → choose "SvelteKit", TypeScript strict, ESLint + Prettier.
**Routing:** file-based (`src/routes/`). Each route is a folder containing `+page.svelte` (UI) + optionally `+page.server.ts` (server load / actions) / `+page.ts` (universal load) / `+layout.svelte`. Dynamic routes via `[slug]/+page.svelte`.
**Runes (Svelte 5):**
- `$state(x)` — reactive value (replaces `let x = ...` + `$:` label)
- `$derived(expr)` — computed (replaces `$:` derivations)
- `$effect(() => {...})` — side effect (replaces `$:` statements with side effects)
- `$props()` — component props (replaces `export let`)
- `$bindable()` — two-way binding opt-in
No more legacy `export let` / `$: foo = bar` in new code. Runes are the canonical API from Svelte 5 onwards.
**Data flow:**
- `+page.server.ts` `load({ fetch, params })` — runs on server only, DB/secrets OK.
- `+page.ts` `load(...)` — runs both server (SSR) + client (navigation) — no secrets.
- Form actions: `export const actions = { default: async ({ request }) => {...} }` in `+page.server.ts`. Use `<form method="POST">` + progressive enhancement — works without JS.
**Env vars:**
- `$env/static/private` + `$env/dynamic/private` — server-only, secrets OK.
- `$env/static/public` + `$env/dynamic/public` — must be prefixed `PUBLIC_`, ships to client.
- SvelteKit refuses to build if a private env is imported into a client module — enforcement built in.
**Deploy adapter (Cloudflare default):** `npm i -D @sveltejs/adapter-cloudflare` and set it in `svelte.config.js`. Alternatives: `adapter-node`, `adapter-vercel`, `adapter-static`. Cloudflare adapter supports KV / R2 / D1 via `platform.env.*` inside load functions.
**Stores (legacy, still supported):** `writable`, `readable`, `derived` from `svelte/store`. Prefer `$state` in components; use stores only for cross-component shared state that truly needs it.
**Testing:** Vitest for unit + `@testing-library/svelte` for components + Playwright for E2E.
**Forbidden:** legacy `export let` + `$:` label syntax for NEW code (use Runes), `$env/static/private` imported into a client-reachable module, mixing runes + legacy reactivity in the same component, hardcoded secrets in `svelte.config.js` (ships to client bundle), adding React/Vue into a SvelteKit app without a very specific reason.

32
_blocks/stack-tailwind.md Normal file
View file

@ -0,0 +1,32 @@
# STACK — Tailwind CSS 4 (compositional add-on)
This is a **compositional** block — it does NOT stand alone. Layer on top of `stack-nextjs`, `stack-react-vite`, `stack-astro`, or `stack-sveltekit`. Any of those + this = the canonical 2026 "Tailwind project" shape.
**Version:** Tailwind 4.x — ships as a Vite / PostCSS / CLI plugin, NOT as a dependency you import into your framework code. Config lives in CSS via `@theme` (not `tailwind.config.ts` — that is v3).
**Minimal setup (v4):**
```css
/* src/styles/app.css */
@import "tailwindcss";
@theme {
--color-brand: oklch(0.6 0.2 250);
--font-display: "Fraunces Variable", serif;
--font-body: "Inter Variable", sans-serif;
--radius-card: 0.75rem;
}
```
Any `--color-*`, `--font-*`, `--radius-*`, `--spacing-*`, `--breakpoint-*` declared in `@theme` auto-generates utilities (`bg-brand`, `font-display`, `rounded-card`, etc.).
**Design tokens are CSS custom properties**, not JS config. Same tokens reachable from runtime (`var(--color-brand)`) + Tailwind classes (`text-brand`). Single source of truth; no duplication.
**Dark mode:** `@custom-variant dark (&:where(.dark, .dark *))` (or `@media (prefers-color-scheme: dark)` for system-driven). Then `dark:bg-neutral-900` works as expected.
**Utilities forbidden in new code:** `@apply` in component CSS (makes purge harder, obscures which utilities render). Use the class attribute directly, or extract to a real component.
**Class composition:** use `clsx` or `tailwind-merge` (`cn()` helper pattern) for conditional classes. Never `className={"bg-red " + (active ? "opacity-100" : "opacity-0")}` — use `cn()`.
**Component libraries:** shadcn/ui (copy-paste, source-owned), Radix primitives, Headless UI — all compatible. Avoid UI kits that ship their own runtime CSS (MUI, Chakra) on top of Tailwind — the two design systems will fight.
**Forbidden:** `tailwind.config.js` for NEW v4 projects (use `@theme` in CSS), `@apply` beyond tiny one-offs, mixing Tailwind with MUI / Chakra / Bootstrap, hardcoded hex colors in `className` (`bg-[#ff0000]`) outside prototyping — those bypass the token system and drift.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
[package]
name = "mock-render"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
[[bin]]
name = "mock-render"
path = "src/main.rs"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,48 @@
//! SHA-256 helpers. Used for WYSIWYD invariant checks
//! (source file must not mutate between lock and verify).
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;
pub fn hash_file(path: &Path) -> Result<String, String> {
let bytes = fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Ok(format!("{:x}", hasher.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn hashes_empty_file() {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(b"").unwrap();
let h = hash_file(f.path()).unwrap();
// sha256("") — well-known constant
assert_eq!(
h,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn hashes_hello_world() {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(b"hello world").unwrap();
let h = hash_file(f.path()).unwrap();
assert_eq!(
h,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[test]
fn returns_err_on_missing_file() {
let result = hash_file(Path::new("/nonexistent/path/xyz"));
assert!(result.is_err());
}
}

View file

@ -0,0 +1,226 @@
//! mock-render — enforces the WYSIWYD invariant (What You See Is What's Deployed)
//! for block-based site-builder. Every section = one source file; screenshot is
//! a render of that file; lock freezes the hash; verify fails if source mutated.
//!
//! USAGE
//! mock-render screenshot <url> --out <png> [--viewport WxH]
//! mock-render lock --project <dir> --section <src> [--screenshot <png>]
//! mock-render verify --project <dir> --section <src>
//! mock-render status --project <dir>
mod hash;
mod render;
mod state;
use state::{Section, SiteState};
use std::env;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
fn main() -> ExitCode {
let args: Vec<String> = env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("screenshot") => cmd_screenshot(&args[1..]),
Some("lock") => cmd_lock(&args[1..]),
Some("verify") => cmd_verify(&args[1..]),
Some("status") => cmd_status(&args[1..]),
Some("--help") | Some("-h") | None => {
print_help();
ExitCode::SUCCESS
}
Some(cmd) => {
eprintln!("mock-render: unknown command '{cmd}'. Run with --help.");
ExitCode::from(1)
}
}
}
fn print_help() {
println!(
"mock-render — WYSIWYD invariant enforcer for site-builder
USAGE
mock-render screenshot <url> --out <png> [--viewport WxH]
mock-render lock --project <dir> --section <src> [--screenshot <png>]
mock-render verify --project <dir> --section <src>
mock-render status --project <dir>
EXIT
0 ok
1 usage / missing args
2 WYSIWYD invariant violated (file drift / hash mismatch)"
);
}
fn cmd_screenshot(args: &[String]) -> ExitCode {
let Some(url) = args.first().cloned() else {
eprintln!("screenshot: <url> required");
return ExitCode::from(1);
};
let out = match flag(args, "--out") {
Some(p) => PathBuf::from(p),
None => {
eprintln!("screenshot: --out <png> required");
return ExitCode::from(1);
}
};
let viewport = flag(args, "--viewport").and_then(parse_viewport);
match render::screenshot(&url, &out, viewport) {
Ok(()) => {
println!("{}", out.display());
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("mock-render: {e}");
ExitCode::from(1)
}
}
}
fn cmd_lock(args: &[String]) -> ExitCode {
let (project, section) = match require_project_section(args) {
Ok(v) => v,
Err(e) => {
eprintln!("lock: {e}");
return ExitCode::from(1);
}
};
let screenshot = flag(args, "--screenshot");
let Ok(hash_now) = hash::hash_file(&section) else {
eprintln!("lock: cannot hash {}", section.display());
return ExitCode::from(2);
};
let mut st = match SiteState::load(&project) {
Ok(s) => s,
Err(e) => {
eprintln!("lock: {e}");
return ExitCode::from(2);
}
};
let key = SiteState::key_for(&section);
st.sections.insert(
key.clone(),
Section {
path: section.display().to_string(),
sha256: hash_now.clone(),
locked: true,
screenshot: screenshot.map(String::from),
},
);
if let Err(e) = st.save(&project) {
eprintln!("lock: {e}");
return ExitCode::from(2);
}
println!("locked {key} ({})", &hash_now[..12]);
ExitCode::SUCCESS
}
fn cmd_verify(args: &[String]) -> ExitCode {
let (project, section) = match require_project_section(args) {
Ok(v) => v,
Err(e) => {
eprintln!("verify: {e}");
return ExitCode::from(1);
}
};
let st = match SiteState::load(&project) {
Ok(s) => s,
Err(e) => {
eprintln!("verify: {e}");
return ExitCode::from(2);
}
};
let key = SiteState::key_for(&section);
let Some(entry) = st.sections.get(&key) else {
eprintln!("verify: section '{key}' not in site-state.json (not locked yet)");
return ExitCode::SUCCESS;
};
if !entry.locked {
return ExitCode::SUCCESS;
}
let Ok(hash_now) = hash::hash_file(&section) else {
eprintln!("verify: cannot hash {}", section.display());
return ExitCode::from(2);
};
if hash_now != entry.sha256 {
eprintln!(
"WYSIWYD VIOLATION: {key} drifted\n locked : {}\n current: {}\nThe screenshot user approved no longer matches the source.\nRerun render + user-approval before deploy.",
&entry.sha256[..12],
&hash_now[..12]
);
return ExitCode::from(2);
}
println!("ok {key} ({})", &hash_now[..12]);
ExitCode::SUCCESS
}
fn cmd_status(args: &[String]) -> ExitCode {
let project = flag(args, "--project")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let st = match SiteState::load(&project) {
Ok(s) => s,
Err(e) => {
eprintln!("status: {e}");
return ExitCode::from(2);
}
};
if st.sections.is_empty() {
println!("(no sections tracked)");
return ExitCode::SUCCESS;
}
for (name, sec) in &st.sections {
let lock = if sec.locked { "LOCKED" } else { "open" };
let drift = match hash::hash_file(Path::new(&sec.path)) {
Ok(h) if h == sec.sha256 => "clean",
Ok(_) => "DRIFT",
Err(_) => "missing",
};
println!(
"{:<20} {:>6} {:<7} {} ({})",
name,
lock,
drift,
sec.path,
&sec.sha256[..12]
);
}
ExitCode::SUCCESS
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.windows(2)
.find(|w| w[0] == name)
.map(|w| w[1].as_str())
}
fn parse_viewport(s: &str) -> Option<(u32, u32)> {
let (w, h) = s.split_once('x')?;
Some((w.parse().ok()?, h.parse().ok()?))
}
fn require_project_section(args: &[String]) -> Result<(PathBuf, PathBuf), String> {
let project = flag(args, "--project")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let section = flag(args, "--section")
.map(PathBuf::from)
.ok_or_else(|| "--section <file> required".to_string())?;
if !section.exists() {
return Err(format!("section file not found: {}", section.display()));
}
Ok((project, section))
}

View file

@ -0,0 +1,49 @@
//! Playwright subprocess wrapper (RULE 0.2 exception 6 — JS-only binding).
//! Calls `npx playwright screenshot` with clear error messages.
//!
//! Requires Node + `npx`. Playwright browsers installable via:
//! npx playwright install chromium
use std::path::Path;
use std::process::Command;
/// Render a URL (typically http://localhost:<port>/<page>) or a file:// URL
/// to a PNG via Playwright's CLI.
pub fn screenshot(url: &str, out: &Path, viewport: Option<(u32, u32)>) -> Result<(), String> {
let mut args = vec![
"--yes".to_string(),
"playwright".to_string(),
"screenshot".to_string(),
"--full-page".to_string(),
];
if let Some((w, h)) = viewport {
args.push("--viewport-size".to_string());
args.push(format!("{w},{h}"));
}
args.push(url.to_string());
args.push(out.display().to_string());
let output = Command::new("npx")
.args(&args)
.output()
.map_err(|e| format!("npx spawn: {e} — is Node installed?"))?;
if !output.status.success() {
return Err(format!(
"playwright screenshot failed (exit {}):\n{}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr)
));
}
if !out.exists() {
return Err(format!(
"playwright claimed success but {} was not written",
out.display()
));
}
Ok(())
}

View file

@ -0,0 +1,88 @@
//! site-state.json — single file that tracks which sections are locked
//! and what their approved SHA-256 is. One row per section.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_STATE: &str = "site-state.json";
#[derive(Serialize, Deserialize, Default)]
pub struct SiteState {
pub sections: BTreeMap<String, Section>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Section {
pub path: String,
pub sha256: String,
pub locked: bool,
pub screenshot: Option<String>,
}
impl SiteState {
pub fn load(project: &Path) -> Result<Self, String> {
let p = Self::path(project);
if !p.exists() {
return Ok(Self::default());
}
let text = fs::read_to_string(&p).map_err(|e| format!("read state: {e}"))?;
serde_json::from_str(&text).map_err(|e| format!("parse state: {e}"))
}
pub fn save(&self, project: &Path) -> Result<(), String> {
let p = Self::path(project);
let text = serde_json::to_string_pretty(self).map_err(|e| format!("serialize: {e}"))?;
fs::write(&p, text).map_err(|e| format!("write state: {e}"))
}
fn path(project: &Path) -> PathBuf {
project.join(DEFAULT_STATE)
}
pub fn key_for(section_path: &Path) -> String {
section_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_uses_file_stem() {
let k = SiteState::key_for(Path::new("src/sections/Hero.astro"));
assert_eq!(k, "Hero");
}
#[test]
fn load_missing_returns_default() {
let tmp = tempfile::tempdir().unwrap();
let st = SiteState::load(tmp.path()).unwrap();
assert!(st.sections.is_empty());
}
#[test]
fn save_and_reload_roundtrips() {
let tmp = tempfile::tempdir().unwrap();
let mut st = SiteState::default();
st.sections.insert(
"Hero".into(),
Section {
path: "src/sections/Hero.astro".into(),
sha256: "abcdef".repeat(10),
locked: true,
screenshot: Some("mocks/Hero.png".into()),
},
);
st.save(tmp.path()).unwrap();
let reloaded = SiteState::load(tmp.path()).unwrap();
assert_eq!(reloaded.sections.len(), 1);
assert!(reloaded.sections.get("Hero").unwrap().locked);
}
}

View file

@ -0,0 +1,16 @@
[package]
name = "tokens-sync"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
[[bin]]
name = "tokens-sync"
path = "src/main.rs"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,127 @@
//! Tokens → Tailwind config (TypeScript) + CSS custom-property emission.
//! Emits stable, deterministically-ordered output (BTreeMap input guarantees).
use crate::parse::Tokens;
use std::fmt::Write as _;
use std::fs;
use std::path::Path;
pub fn tailwind_config(t: &Tokens, out: &Path) -> Result<(), String> {
let mut s = String::new();
s.push_str("// GENERATED by tokens-sync — do not edit by hand.\n");
s.push_str("import type { Config } from 'tailwindcss';\n\n");
s.push_str("const config: Config = {\n");
s.push_str(" content: [],\n");
s.push_str(" theme: {\n extend: {\n");
emit_category(&mut s, "colors", &t.colors);
emit_category(&mut s, "fontFamily", &t.fonts);
emit_category(&mut s, "spacing", &t.spacing);
emit_category(&mut s, "borderRadius", &t.radius);
s.push_str(" },\n },\n plugins: [],\n};\n\nexport default config;\n");
fs::write(out, s).map_err(|e| format!("write {}: {e}", out.display()))
}
pub fn css_vars(t: &Tokens, out: &Path) -> Result<(), String> {
let mut s = String::new();
s.push_str("/* GENERATED by tokens-sync — do not edit by hand. */\n:root {\n");
for (k, v) in &t.colors {
let _ = writeln!(s, " --color-{}: {};", css_ident(k), v);
}
for (k, v) in &t.fonts {
let _ = writeln!(s, " --font-{}: {};", css_ident(k), v);
}
for (k, v) in &t.spacing {
let _ = writeln!(s, " --space-{}: {};", css_ident(k), v);
}
for (k, v) in &t.radius {
let _ = writeln!(s, " --radius-{}: {};", css_ident(k), v);
}
s.push_str("}\n");
fs::write(out, s).map_err(|e| format!("write {}: {e}", out.display()))
}
fn emit_category(
s: &mut String,
key: &str,
map: &std::collections::BTreeMap<String, String>,
) {
if map.is_empty() {
return;
}
let _ = writeln!(s, " {key}: {{");
for (k, v) in map {
// Escape JS single quotes inside the value string.
let escaped = v.replace('\'', "\\'");
let _ = writeln!(s, " '{k}': '{escaped}',");
}
s.push_str(" },\n");
}
fn css_ident(raw: &str) -> String {
raw.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn sample_tokens() -> Tokens {
let mut colors = BTreeMap::new();
colors.insert("primary".into(), "oklch(0.6 0.2 250)".into());
colors.insert("surface".into(), "oklch(0.99 0 0)".into());
let mut fonts = BTreeMap::new();
fonts.insert("body".into(), "Inter, sans-serif".into());
let mut spacing = BTreeMap::new();
spacing.insert("md".into(), "1rem".into());
let mut radius = BTreeMap::new();
radius.insert("card".into(), "0.75rem".into());
Tokens { colors, fonts, spacing, radius }
}
#[test]
fn tailwind_emits_extend_categories() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("tw.ts");
tailwind_config(&sample_tokens(), &out).unwrap();
let text = fs::read_to_string(&out).unwrap();
assert!(text.contains("colors: {"));
assert!(text.contains("'primary': 'oklch(0.6 0.2 250)'"));
assert!(text.contains("fontFamily: {"));
assert!(text.contains("borderRadius: {"));
}
#[test]
fn css_emits_vars_under_root() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("tokens.css");
css_vars(&sample_tokens(), &out).unwrap();
let text = fs::read_to_string(&out).unwrap();
assert!(text.contains(":root {"));
assert!(text.contains("--color-primary: oklch(0.6 0.2 250);"));
assert!(text.contains("--font-body: Inter, sans-serif;"));
assert!(text.contains("--space-md: 1rem;"));
assert!(text.contains("--radius-card: 0.75rem;"));
}
#[test]
fn empty_tokens_still_emit_valid_shells() {
let dir = tempfile::tempdir().unwrap();
let t = Tokens::default();
let tw = dir.path().join("tw.ts");
let css = dir.path().join("c.css");
tailwind_config(&t, &tw).unwrap();
css_vars(&t, &css).unwrap();
let css_text = fs::read_to_string(&css).unwrap();
assert!(css_text.contains(":root {"));
assert!(css_text.trim_end().ends_with('}'));
}
}

View file

@ -0,0 +1,85 @@
//! tokens-sync — emit Tailwind config + CSS custom properties from a single
//! design-tokens JSON file. One SSoT; no drift between CSS/JS sides.
//!
//! USAGE
//! tokens-sync <tokens.json> --out-tailwind <path> --out-css <path>
//!
//! Input JSON shape (minimum):
//! {
//! "colors": { "primary": "oklch(0.6 0.2 250)", ... },
//! "fonts": { "display": "Fraunces Variable, serif", ... },
//! "spacing": { "sm": "0.5rem", ... },
//! "radius": { "card": "0.75rem", ... }
//! }
//!
//! At least one of --out-tailwind or --out-css must be supplied.
mod emit;
mod parse;
use std::env;
use std::path::PathBuf;
use std::process::ExitCode;
fn main() -> ExitCode {
let args: Vec<String> = env::args().skip(1).collect();
if args.iter().any(|a| a == "-h" || a == "--help") {
print_help();
return ExitCode::SUCCESS;
}
let Some(input) = args.iter().find(|a| !a.starts_with("--")).cloned() else {
eprintln!("tokens-sync: <tokens.json> required");
print_help();
return ExitCode::from(1);
};
let tailwind = flag(&args, "--out-tailwind").map(PathBuf::from);
let css = flag(&args, "--out-css").map(PathBuf::from);
if tailwind.is_none() && css.is_none() {
eprintln!("tokens-sync: need at least one of --out-tailwind or --out-css");
return ExitCode::from(1);
}
let tokens = match parse::load(&PathBuf::from(&input)) {
Ok(t) => t,
Err(e) => {
eprintln!("tokens-sync: {e}");
return ExitCode::from(1);
}
};
if let Some(path) = tailwind {
if let Err(e) = emit::tailwind_config(&tokens, &path) {
eprintln!("tokens-sync: emit tailwind: {e}");
return ExitCode::from(1);
}
println!("tailwind -> {}", path.display());
}
if let Some(path) = css {
if let Err(e) = emit::css_vars(&tokens, &path) {
eprintln!("tokens-sync: emit css: {e}");
return ExitCode::from(1);
}
println!("css -> {}", path.display());
}
ExitCode::SUCCESS
}
fn print_help() {
println!(
"tokens-sync — design tokens JSON → Tailwind config + CSS vars
USAGE
tokens-sync <tokens.json> --out-tailwind <path> --out-css <path>
Write at least one of --out-tailwind or --out-css (both allowed).
JSON schema: see source for minimum shape."
);
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.windows(2).find(|w| w[0] == name).map(|w| w[1].as_str())
}

View file

@ -0,0 +1,71 @@
//! JSON → Tokens model. Flat string-keyed maps per category (colors, fonts,
//! spacing, radius). Unknown categories are ignored; missing categories
//! default to empty.
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
#[derive(Deserialize, Default)]
pub struct Tokens {
#[serde(default)]
pub colors: BTreeMap<String, String>,
#[serde(default)]
pub fonts: BTreeMap<String, String>,
#[serde(default)]
pub spacing: BTreeMap<String, String>,
#[serde(default)]
pub radius: BTreeMap<String, String>,
}
pub fn load(path: &Path) -> Result<Tokens, String> {
let text = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn parses_full_shape() {
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(
f,
r#"{{
"colors": {{ "primary": "oklch(0.6 0.2 250)", "surface": "oklch(0.995 0 0)" }},
"fonts": {{ "display": "Fraunces, serif", "body": "Inter, sans-serif" }},
"spacing": {{ "sm": "0.5rem", "md": "1rem" }},
"radius": {{ "card": "0.75rem" }}
}}"#
)
.unwrap();
let tokens = load(f.path()).unwrap();
assert_eq!(tokens.colors.len(), 2);
assert_eq!(tokens.colors.get("primary").unwrap(), "oklch(0.6 0.2 250)");
assert_eq!(tokens.fonts.get("body").unwrap(), "Inter, sans-serif");
assert_eq!(tokens.spacing.len(), 2);
assert_eq!(tokens.radius.len(), 1);
}
#[test]
fn missing_categories_default_empty() {
let mut f = tempfile::NamedTempFile::new().unwrap();
let payload = r##"{ "colors": { "primary": "#000" } }"##;
f.write_all(payload.as_bytes()).unwrap();
let tokens = load(f.path()).unwrap();
assert_eq!(tokens.colors.len(), 1);
assert!(tokens.fonts.is_empty());
assert!(tokens.spacing.is_empty());
assert!(tokens.radius.is_empty());
}
#[test]
fn invalid_json_errors() {
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(f, "not json").unwrap();
assert!(load(f.path()).is_err());
}
}

View file

@ -0,0 +1,15 @@
[package]
name = "visual-diff"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
[[bin]]
name = "visual-diff"
path = "src/main.rs"
[dependencies]
image = { workspace = true }
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,136 @@
//! PNG diff routine. Decodes both images, compares pixels with a per-channel
//! tolerance, writes a red-tinted overlay where pixels differ, and reports
//! mismatch percentage.
use image::{ImageBuffer, Rgba, RgbaImage};
use std::path::Path;
pub struct Report {
pub pct: f64,
pub diff_px: u64,
pub total_px: u64,
pub diff_png_written: bool,
}
const CHANNEL_TOLERANCE: u8 = 4;
pub fn compare(a: &Path, b: &Path, out: &Path) -> Result<Report, String> {
let img_a = image::open(a).map_err(|e| format!("open {}: {e}", a.display()))?.to_rgba8();
let img_b = image::open(b).map_err(|e| format!("open {}: {e}", b.display()))?.to_rgba8();
let (wa, ha) = img_a.dimensions();
let (wb, hb) = img_b.dimensions();
if (wa, ha) != (wb, hb) {
return Err(format!(
"dimension mismatch: a={wa}x{ha} b={wb}x{hb} (resize before comparing)"
));
}
let total_px = u64::from(wa) * u64::from(ha);
let mut diff_px: u64 = 0;
let mut overlay: RgbaImage = ImageBuffer::new(wa, ha);
for y in 0..ha {
for x in 0..wa {
let pa = img_a.get_pixel(x, y);
let pb = img_b.get_pixel(x, y);
if pixel_differs(pa, pb, CHANNEL_TOLERANCE) {
diff_px += 1;
overlay.put_pixel(x, y, Rgba([255, 0, 64, 200])); // red flag
} else {
// faded original to give context
let faded = Rgba([pa[0] / 3, pa[1] / 3, pa[2] / 3, 255]);
overlay.put_pixel(x, y, faded);
}
}
}
let pct = if total_px == 0 {
0.0
} else {
(diff_px as f64 / total_px as f64) * 100.0
};
let mut diff_png_written = false;
if diff_px > 0 {
overlay
.save(out)
.map_err(|e| format!("write {}: {e}", out.display()))?;
diff_png_written = true;
}
Ok(Report {
pct,
diff_px,
total_px,
diff_png_written,
})
}
fn pixel_differs(a: &Rgba<u8>, b: &Rgba<u8>, tol: u8) -> bool {
(0..4).any(|i| a[i].abs_diff(b[i]) > tol)
}
#[cfg(test)]
mod tests {
use super::*;
use image::ImageBuffer;
fn write_solid(path: &Path, w: u32, h: u32, color: [u8; 4]) {
let img: RgbaImage = ImageBuffer::from_pixel(w, h, Rgba(color));
img.save(path).unwrap();
}
#[test]
fn identical_images_match() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
let out = dir.path().join("diff.png");
write_solid(&a, 16, 16, [255, 255, 255, 255]);
write_solid(&b, 16, 16, [255, 255, 255, 255]);
let r = compare(&a, &b, &out).unwrap();
assert_eq!(r.diff_px, 0);
assert_eq!(r.total_px, 256);
assert!(!r.diff_png_written);
}
#[test]
fn fully_different_images() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
let out = dir.path().join("diff.png");
write_solid(&a, 8, 8, [0, 0, 0, 255]);
write_solid(&b, 8, 8, [255, 255, 255, 255]);
let r = compare(&a, &b, &out).unwrap();
assert_eq!(r.diff_px, 64);
assert_eq!(r.total_px, 64);
assert!((r.pct - 100.0).abs() < 1e-6);
assert!(r.diff_png_written);
}
#[test]
fn dimension_mismatch_errors() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
let out = dir.path().join("diff.png");
write_solid(&a, 8, 8, [0, 0, 0, 255]);
write_solid(&b, 16, 16, [0, 0, 0, 255]);
assert!(compare(&a, &b, &out).is_err());
}
#[test]
fn tolerance_absorbs_tiny_delta() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
let out = dir.path().join("diff.png");
write_solid(&a, 8, 8, [100, 100, 100, 255]);
write_solid(&b, 8, 8, [103, 103, 103, 255]); // within CHANNEL_TOLERANCE=4
let r = compare(&a, &b, &out).unwrap();
assert_eq!(r.diff_px, 0);
}
}

View file

@ -0,0 +1,83 @@
//! visual-diff — pixel-level PNG comparator for WYSIWYD drift detection.
//!
//! USAGE
//! visual-diff <a.png> <b.png> [--out diff.png] [--threshold 5]
//!
//! Exit codes:
//! 0 images equal (within threshold)
//! 1 usage error
//! 2 images differ beyond threshold
//!
//! Prints percentage of mismatched pixels to stdout. Writes a red-overlay
//! diff PNG to <out> (default: ./diff.png) when images differ.
mod diff;
use std::env;
use std::path::PathBuf;
use std::process::ExitCode;
fn main() -> ExitCode {
let args: Vec<String> = env::args().skip(1).collect();
if args.iter().any(|a| a == "-h" || a == "--help") {
print_help();
return ExitCode::SUCCESS;
}
let positional: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
if positional.len() < 2 {
eprintln!("visual-diff: need <a.png> <b.png>");
print_help();
return ExitCode::from(1);
}
let a = PathBuf::from(positional[0]);
let b = PathBuf::from(positional[1]);
let out = flag(&args, "--out")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("diff.png"));
let threshold: f64 = flag(&args, "--threshold")
.and_then(|s| s.parse().ok())
.unwrap_or(1.0);
match diff::compare(&a, &b, &out) {
Ok(report) => {
println!("{:.4}% differ ({} px of {})", report.pct, report.diff_px, report.total_px);
if report.diff_png_written {
eprintln!("wrote diff: {}", out.display());
}
if report.pct > threshold {
ExitCode::from(2)
} else {
ExitCode::SUCCESS
}
}
Err(e) => {
eprintln!("visual-diff: {e}");
ExitCode::from(1)
}
}
}
fn print_help() {
println!(
"visual-diff — pixel-level PNG comparator
USAGE
visual-diff <a.png> <b.png> [--out diff.png] [--threshold 5]
OPTIONS
--out FILE write red-overlay diff PNG (default: diff.png)
--threshold PCT fail (exit 2) if mismatch exceeds PCT%% (default: 1.0)
EXIT
0 equal (within threshold)
1 usage / IO error
2 differ beyond threshold"
);
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.windows(2).find(|w| w[0] == name).map(|w| w[1].as_str())
}

111
_primitives/design-scrape.sh Executable file
View file

@ -0,0 +1,111 @@
#!/usr/bin/env sh
# design-scrape — Playwright-based scrape of a live website into tokens,
# section map, and full-page screenshots. Output: one directory per URL.
#
# USAGE
# design-scrape <url> [--out <dir>]
#
# OUTPUT
# <out>/desktop.png
# <out>/mobile.png
# <out>/tokens.json
# <out>/structure.json
#
# Requires: npx (Node), Playwright (`npx playwright install chromium` once).
set -eu
URL="${1:-}"
OUT="${OUT:-./design-scrape}"
usage() {
cat <<'EOF'
Usage: design-scrape <url> [--out <dir>]
Captures two full-page screenshots (desktop 1280x900, mobile 375x812),
extracts computed tokens + DOM structure via Playwright. Writes all
artefacts under <dir> (default: ./design-scrape).
EOF
}
[ -z "$URL" ] || [ "$URL" = "-h" ] || [ "$URL" = "--help" ] && { usage; [ -z "$URL" ] && exit 1; exit 0; }
# --out arg parsing (positional URL first)
shift
while [ $# -gt 0 ]; do
case "$1" in
--out) OUT="$2"; shift 2 ;;
*) echo "design-scrape: unknown arg: $1" >&2; exit 1 ;;
esac
done
if ! command -v npx >/dev/null 2>&1; then
echo "design-scrape: npx not found. Install Node 20+." >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "design-scrape: jq not found. brew install jq / apt install jq" >&2
exit 1
fi
mkdir -p "$OUT"
# Inline Playwright script piped on stdin. We generate a self-contained .mjs
# so npx resolves `playwright` from the nearest node_modules OR installs ephemeral.
SCRIPT="$OUT/.scrape.mjs"
cat > "$SCRIPT" <<'MJS'
import { chromium } from "playwright";
import { writeFileSync } from "node:fs";
import { argv } from "node:process";
const url = argv[2];
const outDir = argv[3];
const browser = await chromium.launch();
async function shot(viewport, name) {
const ctx = await browser.newContext({ viewport });
const page = await ctx.newPage();
await page.goto(url, { waitUntil: "networkidle", timeout: 45000 });
await page.screenshot({ path: `${outDir}/${name}.png`, fullPage: true });
await ctx.close();
}
await shot({ width: 1280, height: 900 }, "desktop");
await shot({ width: 375, height: 812 }, "mobile");
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
await page.goto(url, { waitUntil: "networkidle", timeout: 45000 });
const tokens = await page.evaluate(() => {
const g = (sel) => { const el = document.querySelector(sel); return el ? getComputedStyle(el) : null; };
const body = g("body"); const h1 = g("h1");
return {
colors: { background: body?.backgroundColor, text: body?.color, heading: h1?.color },
typography: { bodyFont: body?.fontFamily, bodySize: body?.fontSize, h1Font: h1?.fontFamily, h1Size: h1?.fontSize },
};
});
const structure = await page.evaluate(() => ({
title: document.title,
sections: document.querySelectorAll("section, [class*='section']").length,
headings: Array.from(document.querySelectorAll("h1,h2,h3")).map(h => ({ t: h.tagName, x: h.textContent.trim().slice(0,80) })),
}));
writeFileSync(`${outDir}/tokens.json`, JSON.stringify(tokens, null, 2));
writeFileSync(`${outDir}/structure.json`, JSON.stringify(structure, null, 2));
await browser.close();
MJS
echo "[design-scrape] capturing $URL -> $OUT" >&2
if ! npx --yes playwright --version >/dev/null 2>&1; then
echo "[design-scrape] note: Playwright not yet installed — first run will download Chromium" >&2
fi
node "$SCRIPT" "$URL" "$OUT"
rm -f "$SCRIPT"
echo "[design-scrape] done:"
ls -1 "$OUT"

73
_primitives/figma-tokens.sh Executable file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env sh
# figma-tokens — fetch a Figma file's design tokens (Variables + Styles) via
# the REST API and emit a tokens.json usable by tokens-sync.
#
# USAGE
# FIGMA_TOKEN=figd_xxx figma-tokens <file-key> [--out tokens.json]
#
# The Figma personal-access-token (legacy) OR OAuth bearer token lives in
# $FIGMA_TOKEN. Never hardcode into this file — per RULE 0.8.
set -eu
FILE_KEY="${1:-}"
OUT="tokens.json"
usage() {
cat <<'EOF'
Usage: FIGMA_TOKEN=<token> figma-tokens <file-key> [--out <path>]
file-key: the part after /design/ or /file/ in the Figma URL
e.g. https://www.figma.com/design/ABC123xyz/Design-System
^^^^^^^^^^
Output JSON shape: { "colors": {...}, "fonts": {...}, "spacing": {...}, "radius": {...} }
Pipe into tokens-sync to generate Tailwind config + CSS vars.
EOF
}
[ -z "$FILE_KEY" ] || [ "$FILE_KEY" = "-h" ] || [ "$FILE_KEY" = "--help" ] && {
usage
[ -z "$FILE_KEY" ] && exit 1 || exit 0
}
shift
while [ $# -gt 0 ]; do
case "$1" in
--out) OUT="$2"; shift 2 ;;
*) echo "figma-tokens: unknown arg: $1" >&2; exit 1 ;;
esac
done
if [ -z "${FIGMA_TOKEN:-}" ]; then
echo "figma-tokens: \$FIGMA_TOKEN not set. Export via shell or \`source ~/.claude/secrets/.env\`." >&2
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
echo "figma-tokens: curl not found" >&2; exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "figma-tokens: jq not found (brew install jq)" >&2; exit 1
fi
API="https://api.figma.com/v1"
# Variables + local styles (styles gives colors/fonts for files that predate Variables)
VARS=$(curl -fsSL -H "X-Figma-Token: ${FIGMA_TOKEN}" "${API}/files/${FILE_KEY}/variables/local" 2>/dev/null || echo '{}')
STYLES=$(curl -fsSL -H "X-Figma-Token: ${FIGMA_TOKEN}" "${API}/files/${FILE_KEY}/styles" 2>/dev/null || echo '{}')
# Minimal extractor — colors from Variables local collection (modern files).
# Falls back to an empty colors map if the file uses Styles only.
jq -n --argjson vars "$VARS" --argjson styles "$STYLES" '
{
colors: ($vars.meta.variables // {}
| to_entries
| map(select(.value.resolvedType == "COLOR"))
| map({key: .value.name, value: (.value.valuesByMode | (to_entries|first.value) | tostring)})
| from_entries),
fonts: {},
spacing: {},
radius: {}
}
' > "$OUT"
echo "[figma-tokens] wrote $OUT"
jq '{colors: (.colors | length), fonts: (.fonts | length), spacing: (.spacing | length), radius: (.radius | length)}' "$OUT"

109
_primitives/frontend-inspect.sh Executable file
View file

@ -0,0 +1,109 @@
#!/usr/bin/env sh
# frontend-inspect — scan a project directory and report what it is:
# framework (Astro/Next/SvelteKit/Vite-React), styling (Tailwind/CSS-Modules/
# styled-components), UI-component count, and package-manager lockfile.
#
# USAGE
# frontend-inspect [<dir>] # default: current directory
# frontend-inspect <dir> --json # machine-readable JSON output
set -eu
DIR="${1:-.}"
JSON=0
[ "${2:-}" = "--json" ] && JSON=1
usage() {
cat <<'EOF'
Usage: frontend-inspect [<dir>] [--json]
Reports:
- Framework (astro / next / sveltekit / vite-react / static / unknown)
- Styling (tailwind4 / tailwind3 / css-modules / plain)
- Package manager (npm / pnpm / yarn / bun)
- Component file count (.tsx / .vue / .svelte / .astro)
- Contains tests? (yes/no)
EOF
}
[ "$DIR" = "-h" ] || [ "$DIR" = "--help" ] && { usage; exit 0; }
[ -d "$DIR" ] || { echo "frontend-inspect: $DIR not a directory" >&2; exit 1; }
PKG="$DIR/package.json"
has_dep() {
# $1 = dep name
[ -f "$PKG" ] || return 1
if command -v jq >/dev/null 2>&1; then
jq -e --arg d "$1" '(.dependencies[$d] // .devDependencies[$d] // null) != null' "$PKG" >/dev/null 2>&1
else
grep -q "\"$1\"" "$PKG" 2>/dev/null
fi
}
detect_framework() {
if has_dep astro; then echo astro; return; fi
if has_dep next; then echo next; return; fi
if has_dep "@sveltejs/kit"; then echo sveltekit; return; fi
if has_dep vite && has_dep react; then echo vite-react; return; fi
if has_dep vite && has_dep vue; then echo vite-vue; return; fi
if has_dep vite; then echo vite; return; fi
[ -f "$DIR/index.html" ] && echo static && return
echo unknown
}
detect_styling() {
if has_dep tailwindcss; then
# Tailwind 4 has `@theme` in CSS and no tailwind.config.js, usually; rough heuristic:
if [ -f "$DIR/tailwind.config.ts" ] || [ -f "$DIR/tailwind.config.js" ] || [ -f "$DIR/tailwind.config.mjs" ]; then
echo tailwind3
else
echo tailwind4
fi
return
fi
if has_dep "styled-components"; then echo styled-components; return; fi
if find "$DIR/src" -maxdepth 3 -name '*.module.css' -print -quit 2>/dev/null | grep -q .; then
echo css-modules
return
fi
echo plain
}
detect_pm() {
[ -f "$DIR/pnpm-lock.yaml" ] && echo pnpm && return
[ -f "$DIR/yarn.lock" ] && echo yarn && return
[ -f "$DIR/bun.lockb" ] && echo bun && return
[ -f "$DIR/package-lock.json" ] && echo npm && return
echo none
}
count_components() {
find "$DIR/src" -type f \( -name '*.tsx' -o -name '*.vue' -o -name '*.svelte' -o -name '*.astro' \) 2>/dev/null | wc -l | tr -d ' '
}
has_tests() {
if [ -f "$PKG" ] && (has_dep vitest || has_dep jest || has_dep "@playwright/test"); then
echo yes
else
echo no
fi
}
FW="$(detect_framework)"
ST="$(detect_styling)"
PM="$(detect_pm)"
CC="$(count_components)"
TS="$(has_tests)"
if [ "$JSON" = "1" ]; then
printf '{"dir":"%s","framework":"%s","styling":"%s","pm":"%s","components":%s,"tests":"%s"}\n' \
"$DIR" "$FW" "$ST" "$PM" "$CC" "$TS"
else
printf "dir: %s\n" "$DIR"
printf "framework: %s\n" "$FW"
printf "styling: %s\n" "$ST"
printf "pm: %s\n" "$PM"
printf "components: %s\n" "$CC"
printf "tests: %s\n" "$TS"
fi

102
_primitives/live-preview.sh Executable file
View file

@ -0,0 +1,102 @@
#!/usr/bin/env sh
# live-preview — start / stop / status for a project's dev server.
# Detects framework from package.json; stores PID in .keisei/dev-server.pid.
#
# USAGE
# live-preview start <dir>
# live-preview stop [pid] # default: reads .keisei/dev-server.pid
# live-preview status
set -eu
CMD="${1:-}"
usage() {
cat <<'EOF'
Usage: live-preview start <dir> — start `npm run dev` in <dir>, record PID
live-preview stop [pid] — stop running server (default: recorded PID)
live-preview status — show whether a server is running
EOF
}
PID_FILE() {
dir="${1:-.}"
mkdir -p "$dir/.keisei"
printf '%s/.keisei/dev-server.pid\n' "$dir"
}
detect_script() {
pkg="$1/package.json"
[ -f "$pkg" ] || { echo "dev"; return; }
if command -v jq >/dev/null 2>&1; then
jq -r '.scripts.dev // .scripts.start // "dev"' "$pkg"
else
echo "dev"
fi
}
case "$CMD" in
start)
DIR="${2:-}"
[ -z "$DIR" ] && { usage; exit 1; }
[ -d "$DIR" ] || { echo "live-preview: $DIR not a directory" >&2; exit 1; }
PID_F="$(PID_FILE "$DIR")"
if [ -f "$PID_F" ]; then
OLD="$(cat "$PID_F" 2>/dev/null || true)"
if [ -n "$OLD" ] && kill -0 "$OLD" 2>/dev/null; then
echo "live-preview: server already running pid=$OLD (pidfile $PID_F)" >&2
exit 1
fi
fi
SCRIPT="$(detect_script "$DIR")"
echo "[live-preview] starting 'npm run $SCRIPT' in $DIR" >&2
(
cd "$DIR"
nohup npm run "$SCRIPT" >.keisei/dev-server.log 2>&1 &
echo $! > ".keisei/dev-server.pid"
)
NEW="$(cat "$PID_F")"
echo "live-preview: started pid=$NEW log=$DIR/.keisei/dev-server.log"
;;
stop)
TARGET="${2:-}"
if [ -z "$TARGET" ]; then
PID_F="$(PID_FILE ".")"
[ -f "$PID_F" ] || { echo "live-preview: no pidfile at $PID_F" >&2; exit 1; }
TARGET="$(cat "$PID_F")"
fi
if kill -0 "$TARGET" 2>/dev/null; then
kill "$TARGET"
echo "live-preview: stopped pid=$TARGET"
[ -f ".keisei/dev-server.pid" ] && rm -f ".keisei/dev-server.pid"
else
echo "live-preview: pid=$TARGET not running (cleaning pidfile)" >&2
[ -f ".keisei/dev-server.pid" ] && rm -f ".keisei/dev-server.pid"
exit 1
fi
;;
status)
PID_F="$(PID_FILE ".")"
if [ ! -f "$PID_F" ]; then
echo "live-preview: no pidfile (not running from $(pwd))"
exit 0
fi
PID="$(cat "$PID_F")"
if kill -0 "$PID" 2>/dev/null; then
echo "live-preview: running pid=$PID"
else
echo "live-preview: stale pidfile (pid=$PID exited)"
rm -f "$PID_F"
fi
;;
-h|--help|help|"")
usage
;;
*)
echo "live-preview: unknown command '$CMD'" >&2
usage
exit 1
;;
esac

View file

@ -0,0 +1,63 @@
#!/usr/bin/env sh
# screenshot-decode — send a screenshot to Claude's vision API and return
# a structured description (tokens / layout / sections). For use in teardown
# and audit pipelines.
#
# USAGE
# ANTHROPIC_API_KEY=sk-ant-xxx screenshot-decode <png> [--prompt <text>]
#
# Reads $ANTHROPIC_API_KEY from env (RULE 0.8: never hardcoded).
# Requires: curl, jq, base64.
set -eu
IMG="${1:-}"
PROMPT="Describe this UI. Extract design tokens (colors, fonts), section layout, and key components. Output as JSON."
usage() {
cat <<'EOF'
Usage: ANTHROPIC_API_KEY=<key> screenshot-decode <png> [--prompt <text>]
Posts <png> + prompt to Anthropic Messages API (claude-sonnet-4) and prints
the text response. Default prompt asks for token + layout extraction.
EOF
}
[ -z "$IMG" ] || [ "$IMG" = "-h" ] || [ "$IMG" = "--help" ] && {
usage
[ -z "$IMG" ] && exit 1 || exit 0
}
[ -f "$IMG" ] || { echo "screenshot-decode: file not found: $IMG" >&2; exit 1; }
shift
while [ $# -gt 0 ]; do
case "$1" in
--prompt) PROMPT="$2"; shift 2 ;;
*) echo "screenshot-decode: unknown arg: $1" >&2; exit 1 ;;
esac
done
[ -n "${ANTHROPIC_API_KEY:-}" ] || { echo "screenshot-decode: \$ANTHROPIC_API_KEY not set" >&2; exit 1; }
command -v curl >/dev/null 2>&1 || { echo "screenshot-decode: curl not found" >&2; exit 1; }
command -v jq >/dev/null 2>&1 || { echo "screenshot-decode: jq not found" >&2; exit 1; }
B64=$(base64 < "$IMG" | tr -d '\n')
PAYLOAD=$(jq -n --arg img "$B64" --arg prompt "$PROMPT" '{
model: "claude-sonnet-4-5",
max_tokens: 2048,
messages: [{
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: "image/png", data: $img } },
{ type: "text", text: $prompt }
]
}]
}')
curl -fsSL https://api.anthropic.com/v1/messages \
-H "x-api-key: ${ANTHROPIC_API_KEY}" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d "$PAYLOAD" \
| jq -r '.content[0].text // .error.message // "(no response)"'

102
skills/a11y-audit/SKILL.md Normal file
View file

@ -0,0 +1,102 @@
---
name: a11y-audit
description: Use when auditing accessibility — WCAG 2.2 AA compliance, contrast checks, keyboard navigation, screen reader support, prefers-reduced-motion. Triggers on "accessibility", "a11y", "wcag", "screen reader", "contrast check".
arguments:
- name: command
description: "Command: scan, fix, contrast, checklist, report"
required: false
- name: target
description: URL or file path to audit
required: false
---
# Accessibility Audit — WCAG 2.2 AA
## Legal Context
- **EAA (EU):** In force since June 2025. Penalties: up to 100K EUR / 4% revenue
- **ADA (US):** References WCAG 2.2 AA
- **Standard:** WCAG 2.2 AA is minimum for any commercial site targeting US/EU
## Top 10 Violations
1. Missing alt text on images
2. Low contrast text (4.5:1 normal, 3:1 large text)
3. Keyboard traps in menus
4. Missing form labels
5. Skipped heading levels
6. No skip links
7. Non-semantic HTML (`<div>` instead of `<nav>`, `<main>`)
8. Missing video captions
9. Invisible focus styles
10. Touch targets <24x24px (WCAG 2.2 new)
**Automated tools catch only 30-40%.** Manual audit required.
## Automated Testing
```bash
# Lighthouse CLI
npx lighthouse <url> --output=json --only-categories=accessibility
# axe-core
npx @axe-core/cli <url> --tags wcag2a,wcag2aa,wcag22aa
```
Playwright integration:
```javascript
import AxeBuilder from '@axe-core/playwright';
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag22aa']).analyze();
expect(results.violations).toEqual([]);
```
## CSS Media Queries
### prefers-reduced-motion
```css
@media (prefers-reduced-motion: reduce) {
.animated { animation: fade-in 0.2s ease; transition: opacity 0.2s ease; }
.parallax { transform: none !important; }
.scroll-animation { animation: none; }
}
```
Replace motion (slide, scale) with non-motion (fade, opacity). Keep transitions <200ms.
### prefers-color-scheme / prefers-contrast / forced-colors
Always support dark mode, high contrast, and Windows forced colors.
## WCAG 2.2 New Criteria
- **2.5.8:** Touch targets min 24x24 CSS px
- **2.4.11/12:** Focus not obscured by sticky elements
- **3.3.7:** No redundant entry (don't re-ask info)
- **3.3.8:** No cognitive tests for auth (allow password managers)
- **2.5.7:** Dragging has non-drag alternative
## Semantic HTML Reference
```html
<a href="#main" class="skip-link">Skip to content</a>
<header><nav aria-label="Main">...</nav></header>
<main id="main">
<section aria-labelledby="heading"><h2 id="heading">...</h2></section>
</main>
<footer>...</footer>
```
## Manual Checklist
- [ ] Keyboard-only: Tab through entire page, verify focus order
- [ ] Skip link visible on focus
- [ ] All interactive elements: visible focus indicator
- [ ] Heading hierarchy: one h1, no skipped levels
- [ ] All images: meaningful alt OR aria-hidden="true" (decorative)
- [ ] Color contrast: 4.5:1 normal, 3:1 large (18px+ bold or 24px+)
- [ ] Forms: visible labels, errors linked with aria-describedby
- [ ] ARIA landmarks: header, nav, main, footer
- [ ] Touch targets: 24x24px minimum
- [ ] Animations: respect prefers-reduced-motion
- [ ] Dark mode: all elements visible and contrasted
- [ ] Video: captions present, controls accessible
- [ ] `lang` attribute on `<html>`
- [ ] Link text descriptive (not "click here")
- [ ] Errors announced to screen readers (aria-live)

View file

@ -0,0 +1,61 @@
---
name: design-system
description: Use when building a design system — tokens, base components, Tailwind config, dark mode, docs
arguments:
- name: name
description: Design system / project name
required: true
- name: style
description: "Visual direction: minimal, playful, corporate, editorial"
required: false
---
# Design System Workflow
## Step 1: Define Tokens
Design tokens (adapt to existing project if any):
### Colors
- Primary: main brand color + 50-950 scale
- Secondary: accent color + scale
- Neutral: gray scale for text, borders, backgrounds
- Semantic: success, warning, error, info
- Surface: background levels (0, 1, 2, 3)
### Typography
- Font families: heading, body, mono
- Size scale: xs, sm, base, lg, xl, 2xl, 3xl, 4xl
- Weight: normal, medium, semibold, bold
- Line heights: tight, normal, relaxed
### Spacing
- Scale: 0, 1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24
### Other
- Border radius: none, sm, md, lg, full
- Shadows: sm, md, lg, xl
- Transitions: fast (150ms), normal (300ms), slow (500ms)
## Step 2: Tailwind Config
- For Tailwind 4: declare tokens in CSS via `@theme { ... }` (no `tailwind.config.ts`)
- For Tailwind 3: generate `tailwind.config.ts` with all tokens
- CSS custom properties for runtime theming
- Dark mode: `class` strategy with token overrides
## Step 3: Base Components
Core primitives (adjust to framework):
- Button (primary, secondary, ghost, destructive + sizes)
- Input (text, textarea, select + states)
- Card (surface levels, padding variants)
- Badge (status colors + sizes)
- Typography components (Heading, Text, Label)
## Step 4: Dark Mode
- Map all color tokens to dark equivalents
- Test contrast ratios (WCAG AA minimum: 4.5:1 text, 3:1 large text)
- Surface hierarchy inverts (dark bg, lighter cards)
## Step 5: Documentation
- Token reference table
- Component API (props, variants, examples)
- Usage guidelines (do's and don'ts)

View file

@ -0,0 +1,55 @@
---
name: figma-to-code
description: Use when converting Figma designs to code — screenshot, context, tokens, responsive implementation
arguments:
- name: url
description: Figma URL (figma.com/design/... or figma.com/make/...)
required: true
---
# Figma to Code Workflow
## Step 1: Extract from Figma
- Parse URL to get fileKey and nodeId
- Call `get_design_context` with fileKey and nodeId (Figma MCP / REST)
- Call `get_screenshot` for visual reference
- Review returned code, tokens, and component mappings
## Step 2: Analyze Design
From the Figma output, identify:
- **Layout:** flex/grid structure, spacing, alignment
- **Typography:** font family, size, weight, line-height, color
- **Colors:** map to project's design tokens or CSS variables
- **Components:** map to existing project components
- **Responsive hints:** auto-layout direction, min/max widths
- **Interactions:** hover states, transitions, animations
## Step 3: Map to Project
- Match Figma colors → project design tokens
- Match Figma fonts → project typography scale
- Match Figma components → existing project components
- Identify gaps: new components or tokens needed
- If Code Connect mappings exist, use them directly
## Step 4: Implement
- Use project's stack (React, Next.js, Astro, SvelteKit, etc.)
- Mobile-first responsive implementation
- Match pixel-perfect on design breakpoint
- Adapt gracefully to other breakpoints
- Use design system components where they match
### Responsive Breakpoints
- Mobile: 375px (default)
- Tablet: 768px
- Desktop: 1024px
- Wide: 1280px
## Step 5: Verify
- Compare screenshot with implementation side-by-side
- Check all breakpoints
- Verify interactive states (hover, focus, active)
- Accessibility check (contrast, keyboard nav, ARIA)
- Cross-browser check (Chrome, Safari, Firefox)
## Step 6: Commit
- `feat: implement <component/page> from Figma design`

View file

@ -0,0 +1,111 @@
---
name: form-builder
description: Use when building forms — multi-step wizards, Zod validation, anti-spam (Turnstile), serverless backends, file upload, progressive enhancement. Triggers on "form", "contact form", "wizard", "form validation", "turnstile".
arguments:
- name: command
description: "Command: create, validate, backend, spam, analytics, audit"
required: false
- name: type
description: "Form type: contact, multi-step, file-upload, survey"
required: false
---
# Form Construction & Submission
Progressive enhancement by default. Forms MUST work without JavaScript.
## Architecture
```
User → Client Validation (Zod) → Submit
↓ (JS disabled: standard POST)
Server Action/Worker → Server Validation (same Zod schema)
Anti-spam (Turnstile) → Process → Email (Resend) / Webhook / D1
```
## Validation: Zod v4 + react-hook-form v7
**Single schema shared between client and server (SSoT):**
```typescript
// schemas/contact.ts
import { z } from 'zod';
export const contactSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
company: z.string().optional(),
message: z.string().min(10).max(5000),
budget: z.enum(['<5k', '5k-15k', '15k-50k', '50k+']),
});
export type ContactFormData = z.infer<typeof contactSchema>;
```
**Client form:**
```tsx
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
});
// method="POST" action="/api/contact" — works without JS
// noValidate — use Zod, not browser
// aria-describedby + aria-invalid + role="alert" for a11y
```
**WARNING:** react-hook-form v8 in beta with breaking changes. Stick to v7.
## Multi-Step Wizard
- Schema per step, merged for final validation
- `sessionStorage` for persistence across refreshes
- Progress indicator, back navigation, summary before submit
- Validate current step before "Next"
## Anti-Spam
### Cloudflare Turnstile (DEFAULT — free, unlimited, privacy-friendly)
```html
<div class="cf-turnstile" data-sitekey="YOUR_KEY"></div>
```
Server: verify via `challenges.cloudflare.com/turnstile/v0/siteverify`
### Honeypot (always layer with Turnstile)
```html
<div style="position:absolute;left:-9999px" aria-hidden="true">
<input type="text" name="website" tabindex="-1" autocomplete="off" />
</div>
```
### Rate Limiting
5 submissions/IP/hour via Cloudflare KV.
## Backends
| Backend | Best For |
|---------|----------|
| CF Worker + Resend | Email notifications (DEFAULT) |
| Webhook | Slack/Discord/Zapier/n8n |
| D1 | Persistent storage + analytics |
| R2 presigned URL | File uploads (>5MB use multipart) |
## Form Types
| Type | Fields | Anti-Spam | Backend |
|------|--------|-----------|---------|
| Contact | name, email, message, budget? | Turnstile + honeypot | Resend + webhook |
| Multi-step | per-step schemas | Turnstile on final | D1 + Resend |
| File upload | name, email, file(s) | Turnstile + rate limit | R2 presigned |
| Survey | rating, category, text | honeypot + rate limit | D1 |
## Audit Checklist
- [ ] All fields: visible `<label>`, aria-describedby for errors
- [ ] Works without JS (method + action set)
- [ ] Server validation matches client (same Zod schema)
- [ ] Anti-spam: honeypot minimum, Turnstile preferred
- [ ] Rate limiting on endpoint
- [ ] File uploads: presigned URLs (not Worker proxy)
- [ ] Input types match data (email, tel, url)
- [ ] Autocomplete attributes set
- [ ] Submit disabled during submission
- [ ] Success/error announced to screen readers
- [ ] Mobile: 44x44px touch targets, appropriate keyboards

View file

@ -0,0 +1,144 @@
---
name: frontend-design
description: Use when designing web UI before coding — anti-AI-slop aesthetic philosophy, typography pairing, color theory, spatial composition, motion guidelines, design archetypes. Triggers on "design", "UI design", "frontend design", "anti-slop", "make it look premium", "design thinking".
arguments:
- name: archetype
description: "Archetype: editorial, swiss, brutalist, minimal, maximalist, retro-futuristic, organic, industrial, art-deco, lo-fi (auto-suggest if omitted)"
required: false
- name: differentiator
description: "The ONE thing someone will remember about this design"
required: false
---
# Frontend Design — Think Before You Code
> Design-first, code-second. Every implementation starts with a design decision, not a div.
## Phase Gate (MANDATORY before writing any UI code)
1. **Purpose** — What problem? Who uses it? (1 sentence)
2. **Archetype** — Pick from 10 below (sets the aesthetic DNA)
3. **Differentiator** — "The one thing someone remembers" (1 sentence)
4. **Anti-references** — Name 3 sites/patterns this is NOT
5. **Tokens** — Define palette + fonts + spacing in CSS variables
Skip this gate = skip the skill. Code without design intent = AI slop.
## Hard Bans (Anti-AI-Slop)
**Typography:**
- Inter, Roboto, Arial, system font stacks
- Space Grotesk (overused in AI-generated sites)
- Same font for heading and body
**Color:**
- Purple gradients on white backgrounds
- Evenly distributed palettes (everything gets equal weight)
- Pure #000 or #fff without tinting
**Layout:**
- Centered card grids as default composition
- Hero → Cards → Testimonials → Footer (the template trap)
- Even spacing everywhere (no rhythm)
**Motion:**
- `linear` easing on UI transitions
- `scale(0)` animation origins
- Default `ease` without custom cubic-bezier
## 10 Archetypes
| # | Name | Typography | Color | Layout | Motion |
|---|------|-----------|-------|--------|--------|
| 1 | **Editorial** | Serif display + sans body | Warm neutrals + 1 accent | Asymmetric columns, pull quotes | Subtle parallax, text reveals |
| 2 | **Swiss** | Geometric sans (Helvetica Now, Neue Haas) | Black/white + 1 primary | Strict grid, mathematical spacing | Minimal, precision timing |
| 3 | **Brutalist** | Monospace or system | Raw, high contrast | Exposed structure, raw HTML aesthetic | Glitch, intentional jank |
| 4 | **Minimal** | 1 refined sans, extreme weight contrast | 2 colors max + neutral | Massive whitespace, single column | Fade only, ultra-slow |
| 5 | **Maximalist** | Mixed display fonts, decorative | Saturated, 4+ colors | Layered, overlapping, collage | Everything moves, scroll-driven |
| 6 | **Retro-Futuristic** | Futuristic display + mono | Neon on dark, CRT glow | Scanlines, terminal aesthetic | Typing effects, flicker |
| 7 | **Organic** | Rounded sans + handwritten accent | Earth tones, muted | Curved containers, blob shapes | Fluid, spring physics |
| 8 | **Industrial** | Condensed bold sans | Dark grays + safety yellow/orange | Dense info, data-heavy | Mechanical, step-based |
| 9 | **Art Deco** | Geometric display, high contrast weight | Gold/brass + deep navy/black | Symmetrical, ornamental borders | Elegant reveals, fade + scale |
| 10 | **Lo-Fi** | Hand-drawn or pixel font | Paper/notebook palette | Sketch-like borders, tape/sticker elements | Wobbly, imperfect |
## Typography Rules
- Max 2 fonts: 1 display (headings) + 1 body (text)
- Use `clamp()` for fluid scaling: `font-size: clamp(1rem, 2.5vw, 1.5rem)`
- Body `line-height`: 1.4-1.6 | Display `line-height`: 1.0-1.2
- 3-5 clear hierarchy levels with dramatic size contrast (4:1 heading-to-body)
- Tune `letter-spacing` per size: tighter for large, looser for small caps
- `font-feature-settings` for ligatures, tabular numbers where needed
## Color System (OKLCH)
```css
@theme {
--brand-hue: 250;
--color-primary: oklch(0.6 0.2 var(--brand-hue));
--color-surface: oklch(0.995 0.005 var(--brand-hue));
--color-text: oklch(0.15 0.02 var(--brand-hue));
--color-muted: oklch(0.55 0.01 var(--brand-hue));
--color-accent: oklch(0.7 0.25 calc(var(--brand-hue) + 30));
--color-border: oklch(0.9 0.01 var(--brand-hue));
}
```
**60-30-10 rule:** 60% dominant (surface/bg), 30% secondary (text/containers), 10% accent (CTAs, highlights).
OKLCH = perceptually uniform. One `--brand-hue` controls entire palette.
## Spatial Composition
- Consistent scale: `--space-xs: 0.25rem` through `--space-3xl: 4rem`
- Whitespace is structural, not leftover
- At least ONE grid-breaking moment per page (full-bleed, overlap, offset)
- 8px base grid for alignment
- Dramatic rhythm changes between sections (dense → spacious → dense)
## Visual Depth & Texture
- Noise/grain via SVG `<feTurbulence>` filter or CSS pseudo-element
- Multi-value `box-shadow` for realistic depth
- `backdrop-filter: blur()` for glass effects
- `clip-path` for non-rectangular shapes
- Background: gradients, patterns, grain — never flat solid white
## Motion Guidelines
- Custom `cubic-bezier()` per element — never default `ease`
- Staggered page-load: 50-100ms increments between elements
- Duration: productivity UI <300ms, creative 200-500ms
- Spring physics for interactive elements (bounce: 0, no jello)
- Exit animations subtler than enter
- `prefers-reduced-motion`: replace motion with fade, keep <200ms
- Keyboard-initiated actions: NO animation
### Enter Animation Recipe (Motion/Framer Motion)
```jsx
initial={{ opacity: 0, y: 8, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ type: "spring", duration: 0.45, bounce: 0 }}
```
## Output Contract
Every frontend-design invocation MUST produce:
1. **Stated direction** — archetype + differentiator + anti-references
2. **Design tokens** — CSS custom properties (colors, type, spacing)
3. **Typography selection** — 2 fonts with Google Fonts / Fontsource links
4. **Working code** — implementation matching the stated direction
5. **Responsiveness** — mobile-first, tested at 375px and 1280px
## The Blur Test
At 20% visibility, the layout silhouette should be distinguishable from anti-references. If blurred Stripe and blurred Your-Page look the same → composition is not distinctive.
## Diverge-Kill-Mutate
If output feels generic:
1. **Diverge** — generate 3 structurally different variants (different spatial logic, not color swaps)
2. **Kill** — binary: alive or dead. NO blending (blending = averaging = AI slop)
3. **Mutate** — within survivor, introduce named "breaks" (violations of convention)
4. **Repeat** — each cycle moves further from center

View file

@ -0,0 +1,188 @@
---
name: landing-page
description: Use when creating a landing page — orchestrates design, copy, assets, animations, SEO. Supports recipe system for specific page types (apple-product, saas, portfolio, ecommerce). Triggers on "landing page", "create page", "website".
arguments:
- name: product
description: Product/service name and brief description
required: true
- name: recipe
description: "Recipe: apple-product, saas, portfolio, ecommerce, agency, startup (auto-suggest if omitted)"
required: false
- name: goal
description: "Page goal: signups, downloads, waitlist, sales, portfolio showcase"
required: false
---
# Landing Page Orchestrator
Creates premium landing pages by composing specialized skills.
## Step 1: Design Direction
Invoke `/frontend-design` with product context:
- Suggest archetype based on recipe (see matrix below)
- Define differentiator, anti-references, tokens
- Output: design direction + CSS custom properties
## Step 2: Research & Copy
- Understand product: features, audience, value proposition
- WebSearch 3-5 competitors for positioning
- Write copy: headline (<10 words, benefit-driven), subheadline, CTAs, feature descriptions
- Tone matches archetype from Step 1
## Step 3: Page Structure
Adapt structure to recipe (see below). Core sections:
1. **Hero** — headline, subheadline, CTA, visual
2. **Problem** — pain point (empathy)
3. **Solution** — how product solves it (3 features max)
4. **Social proof** — testimonials, metrics, logos
5. **How it works** — 3-step process
6. **Pricing** (if applicable)
7. **FAQ** (3-5 questions)
8. **Final CTA** — repeat conversion action
## Step 4: Implementation
- Framework: Astro 6 (default for marketing) or project's stack
- Invoke skills per recipe (see matrix)
- Mobile-first responsive design
- Performance: lazy load below-fold, optimize all assets
## Step 5: Quality Pipeline
Sequential audit chain:
1. `/web-assets audit` — image formats, sizes, fonts
2. `/a11y-audit scan` — WCAG 2.2 AA compliance
3. `/seo-audit` — meta, headings, schema, OG tags
4. `/responsive-audit` — 6 breakpoints
5. `/perf-audit` — Lighthouse >90
## Step 6: Deploy
`/web-deploy deploy` — Cloudflare Pages (default)
---
## Recipe System
### `apple-product` — Premium Product Reveal
**Archetype:** Minimal or Swiss
**Skills invoked:** ai-animation, scroll-animation, video-gen, 3d-scene, web-assets, motion-design
**Structure:**
1. Hero: product floating in space, minimal text
2. Video scrub section: product rotation/reveal on scroll (frame sequence or 3D)
3. Feature deep-dives: pin + scrub with parallax text
4. Specs grid: bento layout with micro-animations
5. CTA: clean, single action
**Key techniques:**
- Frame sequence (120-180 WebP frames) or Three.js model with ScrollControls
- GSAP ScrollTrigger pin + scrub
- Lenis smooth scroll
- Staggered text reveals with blur-in animation
- Dark background, dramatic lighting
### `saas` — SaaS Product Landing
**Archetype:** Minimal or Editorial
**Skills invoked:** motion-design, ui-component, web-assets, form-builder
**Structure:**
1. Hero: headline + product screenshot/video + CTA
2. Logo bar: client/integration logos
3. Features: bento grid (3-6 cards) with hover micro-interactions
4. Demo: embedded video or interactive preview
5. Testimonials: carousel or grid with photos
6. Pricing: 2-3 tier comparison table
7. FAQ: accordion
8. CTA: signup form (Turnstile + Zod)
**Key techniques:**
- Bento grid layout with staggered entrance
- View Transitions for page navigation
- Dark/light mode toggle
- Micro-interactions on every card (hover scale, shadow elevation)
- Auto-animate for list/grid transitions
### `portfolio` — Creative Portfolio
**Archetype:** Editorial or Maximalist
**Skills invoked:** scroll-animation, web-effects, motion-design, 3d-scene
**Structure:**
1. Hero: kinetic typography (name/title animates on load)
2. Project showcase: horizontal scroll or masonry grid
3. Project detail: image distortion on hover (WebGL)
4. About: asymmetric editorial layout
5. Contact: minimal form
**Key techniques:**
- Custom cursor that reacts to content
- Image distortion on hover (curtains.js displacement)
- GSAP horizontal scroll for project gallery
- SVG line drawing for decorative elements
- Kinetic typography with SplitText
### `ecommerce` — Product E-Commerce
**Archetype:** Minimal or Organic
**Skills invoked:** ui-component, web-assets, form-builder, motion-design
**Structure:**
1. Hero: product lifestyle image + CTA
2. Product grid: filterable with auto-animate transitions
3. Product detail: gallery + variant selector + add-to-cart
4. Reviews: social proof grid
5. Related products: horizontal scroll
6. Trust: shipping, returns, secure payment badges
**Key techniques:**
- Image zoom on hover
- Variant selector with instant preview update
- Add-to-cart animation (fly to cart icon)
- Skeleton loading states
- Optimistic UI updates
### `agency` — Creative Agency
**Archetype:** Brutalist or Swiss
**Skills invoked:** scroll-animation, web-effects, 3d-scene, motion-design
**Structure:**
1. Hero: bold statement + reel/showreel video
2. Services: icon grid with hover reveals
3. Case studies: full-bleed image + overlay text
4. Team: grid with playful hover effects
5. Process: timeline with scroll-linked progress
6. Contact: multi-step form
**Key techniques:**
- Full-screen video hero (AV1 + H.264 fallback)
- Noise/grain texture overlay
- Scroll-driven timeline with pin sections
- Magnetic cursor on interactive elements
- Page transitions with View Transitions API
### `startup` — Early-Stage Startup
**Archetype:** Minimal or Retro-Futuristic
**Skills invoked:** motion-design, form-builder, web-assets
**Structure:**
1. Hero: problem statement + waitlist CTA
2. Pain points: 3 illustrated scenarios
3. Solution: how it works (3 steps)
4. Early metrics/traction (if available)
5. Founder story (optional)
6. Waitlist form with social proof counter
**Key techniques:**
- Simple fade-in animations (AutoAnimate)
- Email capture with Turnstile
- Social proof: "Join 1,234 others" counter
- Minimal JavaScript, maximum speed
- Ship fast: Astro + Tailwind + Cloudflare Pages

View file

@ -0,0 +1,347 @@
---
name: motion-design
description: Use when implementing motion design — page transitions, element animations, micro-interactions, layout animations. Covers Motion (ex Framer Motion), View Transitions API, auto-animate, SVG animation (Rive, Lottie), and accessibility.
arguments:
- name: type
description: "Type: page-transition, micro-interaction, layout-animation, svg-animation, loading, hover (auto-detect if omitted)"
required: false
- name: framework
description: "Framework: react, next, astro, vue, svelte, vanilla (auto-detect if omitted)"
required: false
---
# Motion Design Skill
## Decision Matrix — Pick Library
| Need | Library | Bundle | Why |
|------|---------|--------|-----|
| React component animations | Motion 12 | ~32KB gzip | Best React DX, layout animations |
| Page transitions (MPA) | View Transitions API | 0KB | Native browser API |
| Page transitions (Astro) | Astro View Transitions | 0KB | Built-in, zero JS |
| Zero-config list animations | AutoAnimate | ~2KB gzip | One line, FLIP-based |
| Interactive vector graphics | Rive | ~78KB WASM | State machines, 60fps |
| After Effects exports | Lottie/dotLottie | ~50KB runtime | Huge asset library |
| SVG path morphing | GSAP MorphSVG | included in gsap | Now free, best morph engine |
| Line drawing | CSS stroke-dasharray | 0KB | Pure CSS, no library |
---
## 1. Motion (ex Framer Motion)
**Install:** `npm i motion`
**Bundle:** ~32KB min+gzip
### Core API
```jsx
import { motion, AnimatePresence } from "motion/react";
// Basic animation
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
Content
</motion.div>
// Layout animation (FLIP under the hood)
<motion.div layout layoutId="card-expand">
{isExpanded ? <ExpandedCard /> : <CompactCard />}
</motion.div>
// AnimatePresence — exit animations
<AnimatePresence mode="wait">
{items.map(item => (
<motion.div
key={item.id}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
/>
))}
</AnimatePresence>
```
### Gestures
```jsx
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
Click me
</motion.button>
// Drag
<motion.div
drag="x"
dragConstraints={{ left: -200, right: 200 }}
dragElastic={0.1}
/>
```
### Scroll-Linked
```jsx
import { useScroll, useTransform, motion } from "motion/react";
function ParallaxHero() {
const { scrollYProgress } = useScroll();
const y = useTransform(scrollYProgress, [0, 1], [0, -300]);
const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0]);
return (
<motion.div style={{ y, opacity }}>
Hero Content
</motion.div>
);
}
```
### AnimateView (View Transitions integration)
```jsx
import { AnimateView } from "motion/react";
<AnimateView>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</AnimateView>
```
---
## 2. View Transitions API
### Vanilla Implementation
```js
// Single-document transition
document.startViewTransition(() => {
container.innerHTML = newContent;
});
```
```css
::view-transition-old(root) { animation: fade-out 0.2s ease-out; }
::view-transition-new(root) { animation: fade-in 0.3s ease-in; }
.hero-image { view-transition-name: hero; }
```
### Astro Integration (Built-in)
```astro
---
import { ViewTransitions } from "astro:transitions";
---
<html>
<head><ViewTransitions /></head>
<body><slot /></body>
</html>
<img transition:name="hero" src="/hero.jpg" />
<h1 transition:animate="slide">Page Title</h1>
```
Built-in animation presets: `fade`, `slide`, `morph`, `none`.
---
## 3. AutoAnimate
**Install:** `npm i @formkit/auto-animate`
**Zero config.** Uses FLIP technique internally.
```jsx
import { useAutoAnimate } from "@formkit/auto-animate/react";
function TodoList({ items }) {
const [parent] = useAutoAnimate();
return (
<ul ref={parent}>
{items.map(item => <li key={item.id}>{item.text}</li>)}
</ul>
);
}
// Vanilla JS
import autoAnimate from "@formkit/auto-animate";
autoAnimate(document.getElementById("list"));
```
**Best for:** List reordering, add/remove items, accordion expand/collapse.
---
## 4. SVG Animation
### Rive
```jsx
import Rive from "@rive-app/react-canvas";
<Rive
src="/animations/hero.riv"
stateMachines="MainState"
style={{ width: 400, height: 400 }}
/>
```
**Key features:** State Machines, layout engine, scroll-linked via data inputs.
**When to use:** Interactive illustrations, mascots, loading states, onboarding flows.
### Lottie / dotLottie
```jsx
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
<DotLottieReact
src="/animations/hero.lottie"
loop
autoplay
style={{ width: 300, height: 300 }}
/>
```
**Rive vs Lottie:**
| Factor | Rive | Lottie |
|--------|------|--------|
| Interactivity | Built-in state machine | Manual JS coding |
| Design tool | Rive editor | After Effects + plugin |
| File size | Smaller (binary) | Larger (JSON) |
| Asset ecosystem | Growing | Massive marketplace |
### SVG Morphing
**GSAP MorphSVG** (now free with gsap):
```js
gsap.to("#star", { morphSVG: "#circle", duration: 1, ease: "power2.inOut" });
```
**SVG points limit:** Keep under 200 points for smooth 60fps morphing.
### Line Drawing (Pure CSS)
```css
.svg-line {
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
animation: draw 2s ease forwards;
}
@keyframes draw { to { stroke-dashoffset: 0; } }
```
Get path length: `document.querySelector("path").getTotalLength()`.
---
## 5. Micro-Interaction Patterns
### Button Hover/Tap
```jsx
<motion.button
whileHover={{ scale: 1.03, boxShadow: "0 4px 20px rgba(0,0,0,0.15)" }}
whileTap={{ scale: 0.97 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
/>
```
### Toast/Notification Enter
```jsx
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: 50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ type: "spring", damping: 20 }}
/>
)}
</AnimatePresence>
```
### Staggered List
```jsx
const container = { animate: { transition: { staggerChildren: 0.06 } } };
const item = { initial: { opacity: 0, y: 15 }, animate: { opacity: 1, y: 0 } };
<motion.ul variants={container} initial="initial" animate="animate">
{items.map(i => <motion.li key={i} variants={item} />)}
</motion.ul>
```
---
## 6. Timing & Easing Reference
### Duration Guidelines
| Element | Duration | Easing |
|---------|----------|--------|
| Button hover | 150-200ms | ease-out |
| Tooltip appear | 100-150ms | ease-out |
| Modal enter | 200-300ms | ease-out / spring |
| Modal exit | 150-200ms | ease-in |
| Page transition | 200-400ms | ease-in-out |
| Layout shift | 200-350ms | ease-out / spring |
| Scroll reveal | 400-600ms | ease-out |
### Spring Presets (Motion)
```js
// Snappy UI feedback
{ type: "spring", stiffness: 500, damping: 25 }
// Smooth layout
{ type: "spring", stiffness: 300, damping: 30 }
// Bouncy/playful
{ type: "spring", stiffness: 400, damping: 10 }
```
---
## 7. Accessibility
### prefers-reduced-motion
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
```jsx
// Motion respects prefers-reduced-motion by default
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
```
### Guidelines
- Never rely on animation alone to convey information
- Ensure all animated content is accessible via keyboard
- Provide static fallback for critical content
- Test with reduced motion enabled in OS settings
---
## Workflow
1. **Identify animation type** — page transition, reveal, micro-interaction, SVG
2. **Pick library** — use Decision Matrix above
3. **Define timing** — use Duration Guidelines, spring presets
4. **Implement** — start with `initial` + `animate` states
5. **Add exit** — wrap in AnimatePresence for unmount animations
6. **Add a11y** — prefers-reduced-motion, keyboard testing
7. **Performance audit** — Chrome DevTools, check for layout thrashing

View file

@ -0,0 +1,51 @@
---
name: perf-audit
description: Use when auditing performance — baseline, profile, identify top 3 bottlenecks, fix, remeasure
arguments:
- name: target
description: "What to audit: endpoint, page, function, or 'full'"
required: true
---
# Performance Audit Workflow
## Step 1: Establish Baseline
- Measure current performance:
- API: response time (p50, p95, p99), throughput
- Frontend: LCP, FID, CLS, bundle size
- Function: execution time, memory usage
- Record numbers BEFORE any changes
- Use project's existing tools or:
- Python: `time`, `cProfile`, `memory_profiler`
- JS/TS: `performance.now()`, Lighthouse, `webpack-bundle-analyzer`
- API: `curl -w @-` timing, `ab`, `wrk`
## Step 2: Profile
- Identify WHERE time is spent:
- Database queries (N+1, missing indexes, full scans)
- Network calls (sequential vs parallel, caching)
- CPU (algorithmic complexity, unnecessary computation)
- Memory (leaks, large allocations, unnecessary copies)
- I/O (file reads, disk writes)
## Step 3: Identify Top 3 Bottlenecks
- Rank by impact (% of total time)
- Focus on top 3 — don't optimize everything
- For each: document what, why slow, potential fix
## Step 4: Checkpoint
- `checkpoint: before perf-audit $target`
## Step 5: Fix (One at a Time)
- Fix #1 bottleneck → measure → confirm improvement
- Fix #2 bottleneck → measure → confirm improvement
- Fix #3 bottleneck → measure → confirm improvement
- After each fix: run tests — no regressions
## Step 6: Final Measurement
- Re-run baseline measurements
- Compare before/after
- Report: metric, before, after, improvement %
## Step 7: Commit
- `perf: optimize $target — <summary of improvements>`

View file

@ -0,0 +1,65 @@
---
name: responsive-audit
description: Use when auditing responsive design — 6 breakpoints, layout, touch targets, overflow, images
arguments:
- name: target
description: Page or component path to audit
required: true
---
# Responsive Audit Workflow
## Step 1: Identify Target
- Read the target file(s)
- Understand the layout structure (flex, grid, absolute, etc.)
- List all breakpoint-dependent styles
## Step 2: Audit Each Breakpoint
### Mobile (375px)
- [ ] Single column layout where appropriate
- [ ] Touch targets min 44x44px
- [ ] No horizontal scroll
- [ ] Font size min 16px for body text
- [ ] Adequate spacing between interactive elements
### Small Mobile (320px)
- [ ] No content overflow or truncation breaking layout
- [ ] Navigation still usable
- [ ] Forms still fillable
### Tablet (768px)
- [ ] Layout adapts (2-column where appropriate)
- [ ] Images scale properly
- [ ] Navigation adapts (hamburger → tabs or vice versa)
### Desktop (1024px)
- [ ] Full layout utilizes space
- [ ] Max content width set (not stretching to infinity)
- [ ] Sidebar/aside content visible if applicable
### Wide (1280px)
- [ ] Content centered or max-width contained
- [ ] No excessive whitespace
- [ ] Images don't pixelate
### Ultra-wide (1920px+)
- [ ] Layout doesn't break
- [ ] Content doesn't stretch uncomfortably
## Step 3: Common Issues Check
- [ ] Images: `srcset` / responsive sizing, proper aspect ratios
- [ ] Typography: readable at all sizes, proper line lengths (45-75 chars)
- [ ] Spacing: consistent with design system tokens
- [ ] Overflow: no `overflow: hidden` hiding important content
- [ ] Z-index: modals/dropdowns work on all sizes
- [ ] Inputs: don't zoom on iOS (font-size >= 16px)
## Step 4: Issues Report
For each issue:
- Breakpoint where it occurs
- File and line number
- Screenshot description or CSS selector
- Suggested fix
Prioritize: broken layout > usability > polish

View file

@ -0,0 +1,304 @@
---
name: scroll-animation
description: Use when building scroll-driven animations — GSAP ScrollTrigger, CSS scroll-timeline, frame sequences, parallax, pin/scrub effects. Covers Apple-style scroll playback, progress-linked animations, and smooth scroll integration.
arguments:
- name: technique
description: "Technique: gsap, css-native, frame-sequence, parallax, hybrid (auto-detect if omitted)"
required: false
- name: framework
description: "Framework: react, next, astro, vue, svelte, vanilla (auto-detect if omitted)"
required: false
---
# Scroll Animation Skill
## Decision Matrix — Pick Technique
| Need | Technique | Why |
|------|-----------|-----|
| Pin + scrub + snap | GSAP ScrollTrigger | Most mature, free since Webflow acquisition |
| Simple fade/slide on scroll | CSS `animation-timeline` | Zero JS, native performance |
| Apple-style frame playback | Canvas frame sequence | Smoothest result for product reveals |
| Parallax layers | CSS or GSAP | CSS for simple, GSAP for complex |
| Smooth scroll feel | Lenis + GSAP | Industry standard combo |
---
## 1. GSAP ScrollTrigger
**License:** 100% FREE including all plugins
**Install:** `npm i gsap`
### Core API
```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
// Pin + Scrub
gsap.to(".hero-content", {
y: -100,
opacity: 0,
scrollTrigger: {
trigger: ".hero",
start: "top top",
end: "bottom top",
pin: true,
scrub: 1,
snap: { snapTo: 1 / 4, duration: 0.3, ease: "power1.inOut" }
}
});
// Batch — stagger elements entering viewport
ScrollTrigger.batch(".card", {
onEnter: (elements) => {
gsap.to(elements, { opacity: 1, y: 0, stagger: 0.1 });
},
start: "top 85%"
});
```
### React Integration (useGSAP hook)
```jsx
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
function Section({ children }) {
const container = useRef(null);
useGSAP(() => {
gsap.from(".animate-in", {
y: 50,
opacity: 0,
stagger: 0.2,
scrollTrigger: { trigger: container.current, start: "top 80%" }
});
}, { scope: container });
return <section ref={container}>{children}</section>;
}
```
**Key:** `useGSAP` = drop-in for `useEffect`, auto-cleanup via `gsap.context()`.
### Astro Integration
```astro
<section id="scroll-section">
<div class="pin-target">Content</div>
</section>
<script>
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
gsap.to(".pin-target", {
x: 500,
scrollTrigger: { trigger: "#scroll-section", pin: true, scrub: true }
});
</script>
```
### Performance Best Practices
- Use `will-change: transform` on pinned elements
- Prefer `transform` and `opacity` — GPU-composited, no layout recalc
- `scrub: 1` (or higher) smooths jank vs `scrub: true` (instant)
- `invalidateOnRefresh: true` for responsive layouts
- Call `ScrollTrigger.refresh()` after dynamic content loads
- Avoid animating `width`, `height`, `top`, `left` — triggers reflow
---
## 2. CSS Scroll-Driven Animations (Native)
### Scroll Progress Timeline
```css
@keyframes fade-in {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-on-scroll {
animation: fade-in linear both;
animation-timeline: scroll();
animation-range: entry 0% entry 100%;
}
```
### View Progress Timeline
```css
.reveal {
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 25% cover 50%;
}
```
### Progressive Enhancement
```css
@supports (animation-timeline: scroll()) {
.animate { animation-timeline: scroll(); }
}
/* Fallback: use IntersectionObserver + classList toggle */
```
### What CSS Can Replace from GSAP
| Feature | CSS Native | Still Need GSAP |
|---------|-----------|-----------------|
| Fade/slide on scroll | Yes | No |
| Progress-linked animation | Yes | No |
| View-enter/exit triggers | Yes | No |
| Pin element | No | Yes |
| Snap to sections | No (scroll-snap is separate) | Yes (integrated) |
| Batch stagger | No | Yes |
| Timeline sequencing | Limited | Yes |
| Complex easing curves | Limited | Yes |
| JS callbacks on progress | No | Yes |
**Rule of thumb:** CSS for simple reveal animations. GSAP for anything with pin, snap, stagger, or JS logic.
---
## 3. Lenis Smooth Scroll
**Install:** `npm i lenis`
**Bundle:** ~14KB min+gzip (no dependencies)
```js
import Lenis from "lenis";
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
orientation: "vertical",
smoothWheel: true,
});
// Connect to GSAP ticker for sync
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
// Connect to ScrollTrigger
lenis.on("scroll", ScrollTrigger.update);
```
**When to use:** Agency-style smooth scroll feel. Pairs with GSAP ScrollTrigger.
**When NOT to use:** Content-heavy sites, accessibility-first projects.
---
## 4. Frame Sequence on Scroll (Apple-Style)
### Pipeline
```
Video (MP4/MOV)
→ FFmpeg frame extraction (PNG)
→ Convert to WebP (90% size reduction vs PNG)
→ Canvas playback synced to scroll
```
### FFmpeg Extraction
```bash
ffmpeg -i source.mp4 -vf "fps=30,scale=1280:720" frames/frame_%04d.png
for f in frames/*.png; do cwebp -q 80 "$f" -o "${f%.png}.webp"; done
```
### Optimal Parameters
| Parameter | Desktop | Mobile |
|-----------|---------|--------|
| Frame count | 120-180 | 60-90 |
| Resolution | 1920x1080 | 960x540 |
| Format | WebP q80 | WebP q75 |
| Total budget | 2-4 MB | 1-2 MB |
### Canvas Implementation
```js
const canvas = document.getElementById("sequence-canvas");
const ctx = canvas.getContext("2d");
const frameCount = 150;
const frames = [];
function preloadFrames() {
for (let i = 1; i <= frameCount; i++) {
const img = new Image();
img.src = `/frames/frame_${String(i).padStart(4, "0")}.webp`;
frames.push(img);
}
}
gsap.to({ frame: 0 }, {
frame: frameCount - 1,
snap: "frame",
ease: "none",
scrollTrigger: {
trigger: "#sequence-section", start: "top top", end: "+=3000", pin: true, scrub: 0.5,
},
onUpdate: function() {
const index = Math.round(this.targets()[0].frame);
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (frames[index]?.complete) {
ctx.drawImage(frames[index], 0, 0, canvas.width, canvas.height);
}
}
});
```
### Alternative: Video Scrub
```js
const video = document.getElementById("scrub-video");
gsap.to(video, {
currentTime: video.duration,
ease: "none",
scrollTrigger: { trigger: "#video-section", start: "top top", end: "+=4000", pin: true, scrub: true }
});
```
**Tradeoff:** Video scrub = smaller payload, less smooth on mobile. Frame sequence = more bytes, smoother everywhere.
---
## Accessibility
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
```js
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (prefersReduced) { ScrollTrigger.getAll().forEach(st => st.kill()); }
```
---
## Workflow
1. **Define scroll sections** — wireframe which content pins, reveals, or plays
2. **Pick technique** — use Decision Matrix above
3. **Implement with GSAP** — pin/scrub/snap for complex, CSS for simple reveals
4. **Add Lenis** — only if smooth scroll feel is required
5. **Test performance** — Chrome DevTools Performance panel, aim for <16.6ms/frame
6. **Add a11y**`prefers-reduced-motion`, keyboard nav still works
7. **Test mobile** — reduce frame counts, disable heavy effects on low-end

49
skills/seo-audit/SKILL.md Normal file
View file

@ -0,0 +1,49 @@
---
name: seo-audit
description: Use when auditing SEO — technical + content analysis via WebFetch and code inspection
arguments:
- name: url
description: URL or project path to audit
required: true
---
# SEO Audit Workflow
## Step 1: Technical SEO
Fetch and analyze the page:
- **Meta tags:** title (<60 chars), description (<155 chars), viewport, robots
- **Headings:** proper H1-H6 hierarchy, single H1
- **URLs:** clean, descriptive, no query params for content pages
- **Canonical:** present and correct
- **Sitemap:** exists at /sitemap.xml
- **Robots.txt:** exists, not blocking important pages
- **HTTPS:** enforced, no mixed content
- **Mobile:** responsive meta tag, no horizontal scroll
## Step 2: Performance Impact
- Image optimization: format (WebP/AVIF), size, lazy loading, alt text
- Core Web Vitals indicators:
- LCP: largest element load time
- CLS: layout shift from images/fonts without dimensions
- FID/INP: heavy JS blocking interaction
- Bundle size check if applicable
## Step 3: Content SEO
- Keyword presence in: title, H1, first paragraph, URL
- Content length (>300 words for ranking)
- Internal links (to other pages on same domain)
- External links (to authoritative sources)
- Structured data (JSON-LD): Article, Product, FAQ, etc.
- Open Graph + Twitter Card meta tags
## Step 4: Issues Report
Format as prioritized list:
- **Critical:** blocks indexing or ranking (missing title, noindex, broken canonical)
- **Important:** significant ranking impact (no meta description, missing alt text, slow LCP)
- **Nice-to-have:** minor improvements (schema markup, additional links)
Each issue: what's wrong, where, how to fix, impact level.
## Step 5: Action Items
- Generate fix list ordered by impact
- For code changes: specific file + line + suggested edit

View file

@ -0,0 +1,330 @@
---
name: site-builder
description: Build a website from block recipes via interactive wizard. Asks stack/type/style/sections via AskUserQuestion, generates one section at a time, enforces WYSIWYD (what you see in the mock is byte-identical to what gets deployed) via the mock-render primitive.
---
# /site-builder — WYSIWYD website builder
> **Core promise:** every section you approve in the preview IS the file that gets deployed. No "approximately like this". Byte-for-byte.
## When to use
Triggers: `/site-builder`, "create website", "build a site", "landing page", "portfolio site", "SaaS site", "docs site".
## Output contract
Produces a complete website project as local code:
```
<project-root>/
├── src/
│ ├── pages/index.astro (or app/page.tsx for Next)
│ ├── sections/
│ │ ├── Nav.astro — one file per section (Constructor Pattern)
│ │ ├── Hero.astro
│ │ ├── Features.astro
│ │ ├── Pricing.astro
│ │ └── ...
│ ├── layouts/Base.astro
│ └── tokens.css — CSS custom properties
├── public/
│ └── <brand assets>
├── astro.config.mjs (or next.config.js)
├── package.json
├── site-state.json — mock-render lock file (WYSIWYD)
└── mocks/
├── Hero.png — locked screenshots per section
├── Features.png
└── ...
```
Every `sections/*.astro` is independently regeneratable — editing one never touches the others.
## Prerequisites
- Node 20+ with `npx` available
- `mock-render` Rust primitive installed (built by `install.sh` into `$HOME/.claude/agents/_primitives/_rust/target/release/mock-render`)
- Playwright installed (`npx playwright install chromium` — the skill will prompt if missing)
## Phase 0 — Intake via AskUserQuestion
Send questions in AskUserQuestion calls (max 4 per call; use 2 calls if more).
### Call 1 — 4 questions
- **Site archetype?** SaaS landing / Multi-page marketing / Portfolio / Docs site
- **Framework?** Astro 6 (marketing default) / Next.js 16 (SaaS/app) / Static HTML
- **Visual archetype?** Premium minimalist / Dark moody tech / Editorial long-form / Brutalist anti-design
- **Motion tier?** None / Subtle (Motion LazyMotion) / Rich (GSAP ScrollTrigger + Motion) / Experimental (3D + shaders)
### Call 2 — 3 follow-up questions
- **Deploy target?** Cloudflare Pages (recommended) / Vercel / Local only
- **Brand assets?** User provides / Generate with AI / Minimal (text logo + neutral palette)
- **Include a contact form?** Yes (wire via /form-builder) / No
## Phase 1 — Section selection
After Phase 0, pick SECTIONS based on site type.
Defaults per archetype:
| Archetype | Default sections |
|---|---|
| SaaS landing | Nav, Hero, LogoBar, Features, Testimonials, Pricing, FAQ, CTA, Footer |
| Multi-page marketing | Nav, Hero, Features, CTA, Footer — plus routes `/about`, `/pricing`, `/contact` |
| Portfolio | Nav, Hero, Features (case grid), Testimonials, Contact, Footer |
| Docs site | NavSidebar, content layout, Footer-minimal |
Ask via AskUserQuestion: "Pick sections to include" with multi-select — show the defaults checked, user can add/remove.
For each section selected, ask: "Variant?" (A/B/C).
## Phase 2 — Foundation
Create project scaffold (Astro example):
```bash
npm create astro@latest <project-root> -- --template minimal --typescript strict --no-install --no-git
cd <project-root>
npm install
npm install motion @radix-ui/react-tabs lucide-astro # per block deps
```
Write `src/tokens.css` using answers from Phase 0:
```css
:root {
--color-bg: ...;
--color-fg: ...;
--color-accent: ...;
--color-border: ...;
--radius-card: 0.75rem;
--space-section: clamp(4rem, 8vw, 8rem);
--font-display: ...;
--font-body: ...;
}
@media (prefers-color-scheme: dark) { :root { ... } }
```
If user chose "brand assets: I'll provide", ask free-text for logo path + 2-3 hex colors.
Commit checkpoint: `checkpoint: scaffold + tokens`.
## Phase 3 — WYSIWYD block-by-block build (THE CORE LOOP)
For EACH section in the approved list:
### 3.1 Generate the section file
Write `<project-root>/src/sections/<Name>.astro`:
- Copy from Phase 0 brand answers (or placeholder with user's domain words)
- Tokens from `src/tokens.css` (no hardcoded colors)
- Motion hooks matching Phase 0 motion tier
- Brand assets if provided
**Anti-patterns to enforce:**
- No "AI-powered X" headlines
- No centered gradient + default Inter + purple/blue palette combo
- No 5+ CTAs / tiers / features on one block
### 3.2 Render mock
Start dev server (once per session):
```bash
npm run dev & # Astro on :4321 or Next on :3000
```
Wait for port to respond:
```bash
for i in {1..30}; do
curl -s http://localhost:4321 > /dev/null && break
sleep 1
done
```
Screenshot via the `mock-render` primitive:
```bash
$HOME/.claude/agents/_primitives/_rust/target/release/mock-render screenshot \
"http://localhost:4321/_block-preview?block=<Name>" \
--out "<project-root>/mocks/<Name>.png" \
--viewport 1440x900
```
Create a simple `_block-preview.astro` route that imports and renders one section by query param — include this in the scaffold.
### 3.3 Show user + get approval
Display the screenshot. Ask via AskUserQuestion:
- Approve — lock and move on
- Iterate — tell me what to change
- Switch variant (A/B/C)
- Swap block entirely
### 3.4 Act on approval
**Approve:**
```bash
$HOME/.claude/agents/_primitives/_rust/target/release/mock-render lock \
--project <project-root> \
--section src/sections/<Name>.astro \
--screenshot mocks/<Name>.png
```
Commit: `feat: lock <Name> section`. Move to next section.
**Iterate:** Ask free-text "What to change?", apply surgical edit to `<Name>.astro` only. Re-render. Loop.
**Switch variant:** Re-run 3.1 with different variant flag.
**Swap block:** Go back to Phase 1 for this section slot.
### 3.5 WYSIWYD invariant check before any later write
Before writing to ANY already-locked section:
```bash
$HOME/.claude/agents/_primitives/_rust/target/release/mock-render verify \
--project <project-root> \
--section src/sections/<Name>.astro
# Exit 0: file unchanged since lock, OK to proceed
# Exit 2: DRIFT — re-render + re-approve before continuing
```
This invariant means the final deploy is guaranteed to look like the last screenshot the user approved.
## Phase 4 — Audit (parallel)
After all sections locked, run 4 audits in parallel:
```
/a11y-audit scan src/
/seo-audit <project-root>
/responsive-audit src/pages/index.astro
/perf-audit src/
```
Report findings grouped by severity. For each fix proposed:
1. Run `mock-render verify` on the affected section
2. If verify passes AND fix is minor (e.g., add `alt=""`, tweak meta tag) — apply
3. If fix alters layout — ask user to re-approve (back to 3.2 for that section)
4. If fix spans multiple sections — STOP, report, let user decide
## Phase 5 — Preview
Spin up a preview URL before production deploy:
- Cloudflare Pages: `npx wrangler pages deploy <build-dir> --project-name=<slug>-preview`
- Vercel: `npx vercel --preview` (returns preview URL)
- Local: `npm run preview` on :4321
Send URL to user.
## Phase 6 — Deploy
Only after user explicitly confirms preview:
```
/web-deploy deploy --target=<chosen-in-Phase-0> --project=<project-root>
```
Final output:
- Production URL
- Git commit SHA
- Screenshot grid of all locked sections (from `mocks/*.png`)
## State file — site-state.json
Maintained by `mock-render lock/verify`. Shape:
```json
{
"sections": {
"Hero": {"path": "src/sections/Hero.astro", "sha256": "6a48...", "locked": true, "screenshot": "mocks/Hero.png"},
"Features": {"path": "src/sections/Features.astro", "sha256": "...", "locked": true, "screenshot": "mocks/Features.png"},
"Pricing": {"path": "src/sections/Pricing.astro", "sha256": "...", "locked": false, "screenshot": null}
}
}
```
Commands:
- `mock-render lock` — freeze current hash after user approves mock
- `mock-render verify` — assert source unchanged before any new write
- `mock-render status` — list sections, lock state, drift check
- `mock-render screenshot` — Playwright wrapper
## Handoffs (sub-skills called)
| Sub-skill | When |
|---|---|
| `/frontend-design` | Phase 2 — if archetype picked but user wants custom tokens |
| `/design-inspiration` | Phase 0 alt — if user wants to see refs before picking style |
| `/site-teardown` | Phase 0 alt — if user provides a ref site URL to clone-style |
| `/ai-animation` | Phase 3 — video-bg hero or scroll loop |
| `/scroll-animation` | Phase 3 — rich/experimental motion tier with pin/scrub |
| `/3d-scene` | Phase 3 — experimental motion tier with R3F hero |
| `/web-effects` | Phase 3 — shader bg, particles |
| `/motion-design` | Phase 3 — subtle motion tier (Motion library) |
| `/form-builder` | Phase 3 — if contact form yes (Phase 0 Call 2 Q3) |
| `/ui-component` | Phase 3 — novel primitive not in blocks |
| `/web-assets` | Phase 4 — image/font/video optimization |
| `/a11y-audit` | Phase 4 — parallel |
| `/seo-audit` | Phase 4 — parallel |
| `/responsive-audit` | Phase 4 — parallel |
| `/perf-audit` | Phase 4 — parallel |
| `/web-deploy` | Phase 6 — production |
## Forbidden
- Generating a section file without immediately rendering a screenshot of it
- Approving a section without calling `mock-render lock`
- Editing a locked section file without first running `mock-render verify`
- Cascading edits that touch multiple sections at once (violates Constructor Pattern for UI)
- Deploying before user approves the preview URL (Phase 5)
- AI-slop anti-patterns
- Hardcoded colors / fonts / spacing outside `tokens.css`
- Breaking the WYSIWYD invariant — the last screenshot the user approved MUST match what's deployed
## Anti-patterns (AI slop guards)
Enforced at generation time — block the section and regenerate if detected:
1. Generic centered hero + gradient + "AI-powered X" subhead
2. Stock 3D isometric illustrations
3. Lorem-ipsum-tier feature copy
4. Every section animated (motion fatigue)
5. 5+ pricing tiers
6. No specific outcome claim (numbers like "47 seconds", "12x faster")
7. Default stack tell: Inter + Slate/Zinc + rounded-lg + Lucide — pick one deviation
## Output report format
```
=== /SITE-BUILDER REPORT ===
Project: <project-root>
Stack: <Astro 6 / Next 16 / static>
Archetype: <SaaS / multi-page / portfolio / docs>
Style: <premium / dark-tech / editorial / brutalist>
Motion tier: <none / subtle / rich / experimental>
Sections built: <N>
- Nav locked, 23 KB screenshot
- Hero locked, 87 KB screenshot
- ...
WYSIWYD check: all locked, 0 drift
Audits: a11y=pass seo=2-minor perf=LCP 1.2s responsive=pass
Preview: <url>
Deploy: <url or "pending user confirm">
```
## References
- `$HOME/.claude/agents/_primitives/_rust/mock-render/` — WYSIWYD enforcer (Rust)
- `skills/landing-page/SKILL.md` — predecessor (single-page only)
- `skills/frontend-design/SKILL.md` — archetype philosophy
- `skills/motion-design/SKILL.md` — motion library choices
- `skills/scroll-animation/SKILL.md` — GSAP / scroll-timeline patterns
- `skills/web-deploy/SKILL.md` — CF Pages / Vercel deploy

131
skills/site-create/SKILL.md Normal file
View file

@ -0,0 +1,131 @@
---
name: site-create
description: End-to-end site pipeline — intake → design → sections → WYSIWYD mock-render loop → parallel audits → preview → deploy. Pure-click (≥8 AskUserQuestion blocks). The mock-render verify gate HARD-BLOCKS deploy of unlocked sections.
argument-hint: <optional one-line project intent>
---
# /site-create — 7-Phase Website Pipeline (index)
You convert a free-text product description into a deployed website through
seven strictly-ordered phases. Every decision is a click; only the intake
description (Phase 0) and per-section iteration edits (Phase 3) are typed.
This `SKILL.md` is the INDEX. Each phase lives in its own file and is
executed in order. Never skip a phase. Never re-order phases.
---
## Pipeline overview
| Phase | File | Purpose | AskUserQuestion |
|---|---|---|---|
| 0 | [phase-0-intake.md](phase-0-intake.md) | 7-question intake batch | 2× AskUserQuestion (4+3) |
| 1 | [phase-1-design.md](phase-1-design.md) | Invoke `/frontend-design`, emit `tokens.css` | 1× AskUserQuestion |
| 2 | [phase-2-sections.md](phase-2-sections.md) | Multi-select sections; variant per section | 2× AskUserQuestion |
| 3 | [phase-3-wysiwyd.md](phase-3-wysiwyd.md) | Per-section generate → mock-render → approve loop | N× (1 per section) |
| 4 | [phase-4-audit.md](phase-4-audit.md) | Parallel a11y / seo / responsive / perf | 1× (apply fixes?) |
| 5 | [phase-5-preview.md](phase-5-preview.md) | Preview deploy URL | 1× (proceed?) |
| 6 | [phase-6-deploy.md](phase-6-deploy.md) | Production deploy via `/web-deploy` | 1× (confirm) |
**Minimum AskUserQuestion count across a complete pipeline: 8+** — pure-click
contract. Only Phase 0 description and per-section iteration prompts are
free-text.
---
## WYSIWYD invariant (LOAD-BEARING)
> **Every section the user approved in the screenshot IS the file that gets
> deployed. Byte-for-byte. No "approximately like this".**
Enforced by the `mock-render` Rust primitive (`_primitives/_rust/mock-render/`):
- `mock-render lock` — freezes source SHA-256 after user-approved screenshot.
- `mock-render verify` — asserts source unchanged before any later write.
- `mock-render status` — lists sections, lock state, drift check.
**Hard block:** Phase 6 (deploy) refuses to run if any locked section shows
drift in `mock-render status`. The pipeline stops and loops the user back
to Phase 3 for that section.
The companion `hooks/site-wysiwyd-check.sh` (PostToolUse Edit|Write) gives a
stderr advisory whenever an edit touches a section file while a
`.keisei/dev-server.pid` exists — catches drift in the moment, not at
deploy time.
---
## Variables the pipeline produces
| Name | Set in | Meaning |
|---|---|---|
| `DESC` | Phase 0 | User's product/project intent (1-3 sentences) |
| `STACK` | Phase 0 | Astro 6 / Next 16 / SvelteKit / static |
| `STYLE` | Phase 0 | Premium / dark-tech / editorial / brutalist archetype |
| `MOTION` | Phase 0 | none / subtle / rich / experimental |
| `DEPLOY` | Phase 0 | Cloudflare Pages / Vercel / local |
| `TOKENS` | Phase 1 | CSS custom properties file written to `src/tokens.css` |
| `SECTIONS` | Phase 2 | Ordered list `[{name, variant}]` |
| `LOCKED` | Phase 3 | Set of sections that passed user approval |
| `AUDIT` | Phase 4 | `{a11y, seo, responsive, perf}` findings |
| `PREVIEW_URL` | Phase 5 | Short-lived preview URL |
| `PROD_URL` | Phase 6 | Final deploy URL |
---
## Final report (emit after Phase 6)
```
=== /SITE-CREATE REPORT ===
Intake: <first 80 chars of DESC>...
Stack: <STACK>
Style: <STYLE> / motion: <MOTION>
Sections: <N locked / M total>
- Nav locked sha256:6a48ca7...
- Hero locked sha256:b37e2d1...
- ...
WYSIWYD: <clean | drifted:X sections BLOCKED>
Audits: a11y=<pass/N-findings> seo=<..> resp=<..> perf=<LCP Xs>
Preview: <PREVIEW_URL>
Prod: <PROD_URL or "pending user confirm">
Next action: <verify on mobile / share URL / iterate section X>
```
---
## Rules (enforced at every phase)
- **Pure-click contract.** Only `DESC` (Phase 0) and per-section iteration
prompts (Phase 3) are typed. Every other decision is an `AskUserQuestion`.
Count them in the final report.
- **WYSIWYD hard block.** Phase 6 refuses to run if `mock-render status`
shows any drift. See Phase 3.5 for the invariant algorithm.
- **NO DOWNGRADE (RULE -1).** Any phase that fails returns 2-3 constructive
paths, never "can't be done".
- **NO HALLUCINATION (RULE 0.4).** Every section name / variant / hook
referenced must exist on disk or in the block recipe. Phase 3 verifies
before any lock.
- **Plan Mode First (RULE 0.5).** This skill IS the plan; each phase file
has its own verify-criterion. No Edit/Write to project source before the
corresponding phase's confirm click.
- **Constructor Pattern (RULE ZERO).** One file per section (Phase 3).
Generated `sections/*.astro` (or `.tsx`) never exceeds 200 LOC — split
into sub-sections on the fly.
- **Surgical Changes.** Never edit adjacent sections when iterating one.
Orphan imports in the edited section are cleaned; neighbours are not
touched.
---
## References
- [phase-0-intake.md](phase-0-intake.md) · [phase-1-design.md](phase-1-design.md) · [phase-2-sections.md](phase-2-sections.md) · [phase-3-wysiwyd.md](phase-3-wysiwyd.md) · [phase-4-audit.md](phase-4-audit.md) · [phase-5-preview.md](phase-5-preview.md) · [phase-6-deploy.md](phase-6-deploy.md)
- `skills/frontend-design/SKILL.md` — archetype philosophy (Phase 1)
- `skills/site-builder/SKILL.md` — hub-level WYSIWYD reference
- `skills/site-teardown/SKILL.md` — optional Phase 0 alt (clone a reference site)
- `skills/a11y-audit`, `skills/seo-audit`, `skills/responsive-audit`, `skills/perf-audit` — Phase 4 parallel fan-out
- `skills/web-deploy/SKILL.md` — Phase 6 deploy
- `_primitives/_rust/mock-render/` — WYSIWYD enforcer
- `_primitives/live-preview.sh` — dev-server lifecycle
- `_primitives/design-scrape.sh` — optional reference-site scrape (Phase 0 alt)
- `hooks/site-wysiwyd-check.sh` — PostToolUse drift advisory

View file

@ -0,0 +1,84 @@
# Phase 0 — Intake
> Goal: convert free-text product intent into 6 locked decisions that drive
> the rest of the pipeline. 2 AskUserQuestion calls (4 + 3 questions).
> **Verify criterion:** `DESC`, `STACK`, `STYLE`, `MOTION`, `DEPLOY`, `BRAND`,
> `FORM` all set.
---
## 0.a — Description (only free-text in the skill aside from Phase-3 iteration)
Prompt the user for ONE paragraph describing the product/project.
Capture 1-3 sentences into `DESC`. If the invocation already came with an
argument (`/site-create <text>`), skip this and use it.
---
## 0.b — First AskUserQuestion (4 questions)
Send exactly 4 questions in one `AskUserQuestion` call (the UI cap is 4):
1. **Site archetype?** (single-select, stored as `TYPE`)
- SaaS landing (one page)
- Multi-page marketing (/ + /about + /pricing + /contact + /blog)
- Portfolio / personal
- Docs site
2. **Framework?** (single-select, stored as `STACK`)
- Astro 6 (recommended for content/marketing)
- Next.js 16 (recommended for SaaS / app-like)
- SvelteKit (Runes, compiles small)
- Static HTML (single index.html)
3. **Visual archetype?** (single-select, stored as `STYLE`)
- Premium minimalist (Apple / Linear / Anthropic)
- Dark / moody tech (Vercel / Raycast)
- Editorial / long-form
- Brutalist / anti-design
4. **Motion tier?** (single-select, stored as `MOTION`)
- None (instant, print-like)
- Subtle (fade-up, stagger — 2026 conversion default)
- Rich (scroll-linked reveals, micro-interactions)
- Experimental (3D / shaders / pin-scrub; Awwwards-tier only)
---
## 0.c — Second AskUserQuestion (3 questions)
Send exactly 3 questions in a second `AskUserQuestion` call:
1. **Deploy target?** (stored as `DEPLOY`)
- Cloudflare Pages (recommended)
- Vercel (best Next integration)
- Local only (skip deploy for now)
2. **Brand assets?** (stored as `BRAND`)
- I'll provide (logo path + colors next)
- Generate with AI (skill will fan out to an external image generator)
- Minimal (text logo + neutral palette)
3. **Include a contact form?** (stored as `FORM`)
- Yes (wire via `/form-builder`)
- No
---
## 0.d — Branch: reference-site clone?
If `STYLE` needs guidance, offer an OPTIONAL detour (1 extra
AskUserQuestion, skip if the user answered "I know the style already"):
- Clone a reference — invoke `/site-teardown <url>` first, feed extracted
tokens into Phase 1.
- Start fresh — proceed directly to Phase 1.
---
## 0.e — Verify criterion
All of `DESC`, `TYPE`, `STACK`, `STYLE`, `MOTION`, `DEPLOY`, `BRAND`, `FORM`
must be populated. If any is missing, loop back to the relevant question.
Emit a single-line confirmation: `Intake locked: <TYPE> / <STACK> / <STYLE> / <MOTION> / deploy:<DEPLOY>`. Proceed to Phase 1.

View file

@ -0,0 +1,94 @@
# Phase 1 — Design (tokens + typography)
> Goal: produce `src/tokens.css` and a typography choice aligned with the
> Phase-0 `STYLE` archetype. 1 AskUserQuestion.
> **Verify criterion:** `src/tokens.css` written, passes `cssparser` (or a
> quick `curl`+ file inspection), fonts declared.
---
## 1.a — Invoke /frontend-design
Delegate to the `frontend-design` skill with the Phase-0 archetype:
```
/frontend-design archetype=<STYLE-derived> differentiator=<one-line from DESC>
```
Map `STYLE` → archetype:
| Phase-0 STYLE | frontend-design archetype |
|---|---|
| Premium minimalist | minimal |
| Dark / moody tech | retro-futuristic OR swiss (dark skin) |
| Editorial / long-form | editorial |
| Brutalist / anti-design | brutalist |
The sub-skill produces design tokens (color + type + spacing) in OKLCH form.
---
## 1.b — Brand asset wiring
Depending on `BRAND` from Phase 0:
- **I'll provide** — ask free-text once for the logo path + 2-3 hex colors.
Convert hex to OKLCH before writing into tokens.
- **Generate with AI** — fan out to an external image-gen service via
`keiagent`/`fal.ai` (skill-agnostic; the generator is not part of this
pipeline's required deps). Save to `public/brand/logo.svg` (or .png).
- **Minimal** — emit a text-only logo placeholder; no image asset.
---
## 1.c — Write `src/tokens.css`
Shape:
```css
:root {
/* Color (OKLCH — one --brand-hue controls the whole palette) */
--brand-hue: <from frontend-design>;
--color-bg: oklch(<L> <C> var(--brand-hue));
--color-fg: oklch(<L> <C> var(--brand-hue));
--color-accent: oklch(<L> <C> calc(var(--brand-hue) + 30));
--color-muted: oklch(<L> <C> var(--brand-hue));
--color-border: oklch(<L> <C> var(--brand-hue));
/* Type */
--font-display: "<display>", serif;
--font-body: "<body>", sans-serif;
/* Space + radius */
--space-section: clamp(4rem, 8vw, 8rem);
--radius-card: 0.75rem;
}
@media (prefers-color-scheme: dark) {
:root { /* dark-mode overrides */ }
}
```
If `STACK = Astro 6` or `Next.js 16`, also emit `src/styles/tokens.css` and
import it from the root layout.
---
## 1.d — One AskUserQuestion: confirm direction
Send a single `AskUserQuestion` with the rendered token preview
(swatch block + font-pair line) and 3 options:
- Looks good — proceed to Phase 2
- Adjust palette — loop back to 1.a with a "more muted" / "more saturated" hint
- Swap typography — loop back to 1.a with a "different fonts" hint
---
## 1.e — Checkpoint commit
```
checkpoint: phase-1 design tokens + typography
```
Proceed to Phase 2.

View file

@ -0,0 +1,68 @@
# Phase 2 — Section Selection
> Goal: user picks which sections the site contains, in what order, and which
> variant per section. 2 AskUserQuestion calls.
> **Verify criterion:** `SECTIONS = [{name, variant}, ...]` populated and
> non-empty.
---
## 2.a — Default section list per archetype
From Phase-0 `TYPE`:
| TYPE | Default sections (in order) |
|---|---|
| SaaS landing | Nav, Hero, LogoBar, Features, Testimonials, Pricing, FAQ, CTA, Footer |
| Multi-page marketing | Nav, Hero, Features, CTA, Footer + routes /about, /pricing, /contact |
| Portfolio | Nav, Hero, CaseGrid, About, Contact, Footer |
| Docs site | NavSidebar, Content, TOC, Footer-minimal |
---
## 2.b — First AskUserQuestion: pick sections (multi-select)
Send an `AskUserQuestion` with `multiSelect: true`. Pre-check the defaults
from 2.a; user can add or remove freely. The label for each option carries
a one-line description.
Include under "available" the full set from the block library (approx):
```
Nav, NavSidebar, Hero, Hero-split, Hero-centered, LogoBar, Features,
Features-bento, Features-alternating, Pricing, Pricing-simple, Pricing-tiered,
Testimonials, Testimonials-grid, Testimonials-carousel, CTA, CTA-split,
FAQ, FAQ-accordion, CaseGrid, Contact, Contact-form, Footer, Footer-minimal
```
Store the user's selection (ordered) as `SECTIONS` (names only for now).
---
## 2.c — Second AskUserQuestion: variant per section
For sections that have multiple variants (e.g. Hero-split vs Hero-centered),
send a SECOND `AskUserQuestion` with 3-5 questions (batched — UI max is 4;
use two calls if >4 sections have variants).
Each question: "Variant for <section>?" with A / B / C options. Default
pre-selected is usually the most conservative variant.
Store into `SECTIONS` as `[{name, variant}, ...]`.
---
## 2.d — Verify criterion
- `SECTIONS` is a non-empty ordered list
- Every `{name}` maps to a known block recipe (if a block library is
installed) OR is one of the default archetype sections
- Every variant is `A`, `B`, or `C`
If any section lacks a known recipe, fall back to a plain skeleton (tokens
only, no fancy variant).
Emit a confirmation line:
`Sections locked: N × {name/variant} — starting WYSIWYD loop`.
Proceed to Phase 3.

View file

@ -0,0 +1,133 @@
# Phase 3 — WYSIWYD Block-by-Block Build (CORE LOOP)
> Goal: for each section in `SECTIONS`, generate the source file, render a
> mock, get user approval, then **lock** via `mock-render` so the
> source-SHA is frozen.
>
> This phase enforces the LOAD-BEARING invariant: the screenshot the user
> approves IS the file that gets deployed. Byte-for-byte.
> **Verify criterion per section:** `site-state.json` has `locked: true` +
> matching `sha256` for that section.
---
## 3.0 — One-time setup (first section only)
Start the dev server via the `live-preview.sh` primitive:
```bash
$HOME/.claude/agents/_primitives/live-preview.sh start <project-root>
```
This writes `.keisei/dev-server.pid` — the WYSIWYD PostToolUse hook
(`hooks/site-wysiwyd-check.sh`) uses this file to decide whether to run
drift checks on subsequent Edit/Write operations.
Wait for the port to respond (max 30s poll). If it never comes up, fall
back to printing the dev-server log tail and ask the user whether to
abort or retry.
Create a helper preview route `<project-root>/src/pages/_block-preview.astro`
that takes `?block=<Name>` and renders only that section. This isolates
sections for per-section screenshots without bleed-through.
---
## 3.1 — For each section in SECTIONS: generate
Write `<project-root>/src/sections/<Name>.astro` (or `<Name>.tsx` for Next /
`<Name>.svelte` for SvelteKit):
- Props: none (sections are concrete, not generic)
- Tokens: only CSS custom properties from `src/tokens.css`
- No hardcoded hex / pixel / font values
- Copy: use Phase-0 `DESC`-derived placeholders; first section includes the
product name from `DESC`
- Motion: match `MOTION` tier from Phase 0 (none / subtle / rich / experimental)
- File stays < 200 LOC (Constructor Pattern) split into sub-components if
it grows
---
## 3.2 — Render mock
```bash
$HOME/.claude/agents/_primitives/_rust/target/release/mock-render screenshot \
"http://localhost:4321/_block-preview?block=<Name>" \
--out "<project-root>/mocks/<Name>.png" \
--viewport 1440x900
```
If Playwright is not installed, the primitive fails with a clear error —
prompt user to `npx playwright install chromium` and retry.
---
## 3.3 — Show + approve (1 AskUserQuestion per section)
Display `mocks/<Name>.png` inline. Ask:
- **Approve** — lock and move on
- **Iterate** — free-text what to change (single free-text moment inside
the skill, allowed per Phase-3 exception)
- **Switch variant** (A / B / C) — regenerate 3.1 with different variant
- **Swap block** — pick a different block for this slot, re-loop 3.1
---
## 3.4 — Act on approval
### Approve
```bash
$HOME/.claude/agents/_primitives/_rust/target/release/mock-render lock \
--project <project-root> \
--section src/sections/<Name>.astro \
--screenshot mocks/<Name>.png
```
This writes into `<project-root>/site-state.json`:
```json
{
"sections": {
"<Name>": { "path": "src/sections/<Name>.astro", "sha256": "...", "locked": true, "screenshot": "mocks/<Name>.png" }
}
}
```
Commit: `feat(site): lock <Name> section`. Move to next section in `SECTIONS`.
### Iterate / Switch variant / Swap block
Loop back to 3.1 (with the free-text change, the new variant, or the new
block). Do NOT touch any other section's file.
---
## 3.5 — WYSIWYD verify before any cross-section edit
If a later phase (e.g. audit) would edit a locked section, you MUST first:
```bash
$HOME/.claude/agents/_primitives/_rust/target/release/mock-render verify \
--project <project-root> \
--section src/sections/<Name>.astro
```
- Exit 0: unchanged since lock — proceed.
- Exit 2: **DRIFT** — stop. Re-render, re-approve via 3.3 loop, re-lock.
This is the hard block that guarantees Phase-6 deploy never ships a section
the user did not approve.
---
## 3.6 — Verify criterion (completion of Phase 3)
All `SECTIONS[i]` must have `locked: true` in `site-state.json`.
Run `mock-render status` and confirm 0 `DRIFT` rows. Emit:
`Phase 3 done: N sections locked, 0 drift. Proceeding to audits.`
Proceed to Phase 4.

View file

@ -0,0 +1,84 @@
# Phase 4 — Parallel Audit (a11y / seo / responsive / perf)
> Goal: run 4 audit skills in parallel against the locked site; collect
> findings grouped by severity; get user approval per fix.
> **Verify criterion:** all 4 audits ran; findings surfaced or confirmed
> zero; any applied fix passed `mock-render verify` first.
---
## 4.a — Fan-out parallel
Run the 4 audit skills concurrently (tool fan-out is allowed for audits —
they are read-only and independent):
```
/a11y-audit scan src/
/seo-audit <project-root>
/responsive-audit src/pages/index.astro
/perf-audit src/
```
Each returns a findings list with severity (`critical / important / nice`)
and, where possible, a file+line + suggested patch.
Merge the 4 result streams into a single aggregated list.
---
## 4.b — Group + present findings
Group findings by severity. For each, show:
- Severity (critical / important / nice)
- Category (a11y / seo / responsive / perf)
- File + line
- Description (1 sentence)
- Proposed fix (1 sentence)
- Affected section(s) (map file path → section name)
---
## 4.c — One AskUserQuestion: apply fixes?
Three options:
- **Apply all non-layout fixes automatically** — tweak meta tags, alt
attributes, `fetchpriority`, preload hints, other non-visual edits.
Per-fix algorithm in 4.d below.
- **Review each fix individually** — loop per finding, ask approve/skip.
- **Skip audit fixes** — proceed to Phase 5 with findings in the report.
---
## 4.d — Per-fix algorithm (applies to any chosen fix)
For EVERY fix the skill is about to write:
1. Determine the affected section file (if any).
2. Run `mock-render verify --section <file>` first.
- Exit 0 → proceed to step 3.
- Exit 2 → STOP. Report drift to user; loop back to Phase 3.3 for that
section (re-render, re-approve, re-lock) before attempting the fix.
3. If the fix is non-layout (meta tag, alt, preload, `loading="lazy"`,
`decoding="async"`, `aria-*`) → apply directly.
4. If the fix alters layout (CSS classes that move content, new DOM nodes,
removed sections) → do NOT apply silently. Flag it back to the user:
> "Fix #N changes layout. Re-approval via Phase 3.3 needed. Proceed?"
5. After EVERY applied fix, re-run `mock-render lock` on the affected
section so the frozen hash matches the new source.
6. Commit: `fix(site): <audit-category> — <short description>`.
---
## 4.e — Verify criterion
- All 4 audit skills completed.
- Findings list fully walked (either applied, individually approved/skipped,
or deferred per 4.c choice).
- `mock-render status` shows 0 drift rows.
Emit:
`Phase 4 done: <a11y-findings> a11y / <seo> seo / <resp> responsive / <perf> perf. Proceeding to preview.`
Proceed to Phase 5.

View file

@ -0,0 +1,230 @@
---
name: site-teardown
description: "Deconstruct any live website into reusable recipe — extract HTML, CSS, JS, design tokens, animations. Use when user says: teardown, deconstruct, clone site, reverse engineer, how is this site built."
arguments:
- name: url
description: URL of the website to deconstruct
required: true
- name: depth
description: "quick = tokens + screenshots only, full = complete teardown (default: full)"
required: false
---
# Site Teardown — Deconstruct Any Website into a Reusable Recipe
Extracts design tokens, layout structure, animation techniques, and library stack from a live website.
Output: structured recipe that can be fed into `/frontend-design`, `/landing-page`, `/design-system`.
## Phase 1 — Navigate & Screenshot
```
1. browser_navigate → {url}
2. browser_resize → width: 1280, height: 900
3. browser_take_screenshot → fullPage: true, filename: "teardown-desktop.png"
4. browser_resize → width: 375, height: 812
5. browser_take_screenshot → fullPage: true, filename: "teardown-mobile.png"
6. browser_resize → width: 1280, height: 900 (restore)
```
Save screenshots to `teardown/{domain}/` in the project directory (relative to `$PWD`).
## Phase 2 — Extract HTML Structure
Run `browser_evaluate` with:
```javascript
() => {
const sections = Array.from(document.querySelectorAll('section, [class*="section"], main > div'));
const nav = document.querySelector('nav, header');
const footer = document.querySelector('footer');
const headings = Array.from(document.querySelectorAll('h1, h2, h3')).map(h => ({
tag: h.tagName, text: h.textContent.trim().slice(0, 80)
}));
return {
title: document.title,
sectionCount: sections.length,
hasNav: !!nav,
navType: nav?.classList?.toString() || 'unknown',
hasFooter: !!footer,
headings,
bodyClasses: document.body.classList.toString(),
htmlLang: document.documentElement.lang
};
}
```
Also extract full HTML for deep analysis:
```javascript
() => document.documentElement.outerHTML
```
Save to `teardown/{domain}/index.html`.
## Phase 3 — Extract Design Tokens
Run `browser_evaluate` to extract computed styles from key elements:
```javascript
() => {
const get = (sel) => {
const el = document.querySelector(sel);
return el ? getComputedStyle(el) : null;
};
const body = get('body');
const h1 = get('h1');
const btn = get('a[class*="btn"], button[class*="btn"], .cta, a[class*="cta"]');
const card = get('[class*="card"], [class*="Card"]');
const props = {};
const root = getComputedStyle(document.documentElement);
for (const name of ['--primary', '--secondary', '--accent', '--background', '--foreground',
'--radius', '--font-sans', '--font-mono', '--brand']) {
const val = root.getPropertyValue(name).trim();
if (val) props[name] = val;
}
return {
colors: {
background: body?.backgroundColor,
text: body?.color,
heading: h1?.color,
button: btn ? { bg: btn.backgroundColor, text: btn.color, radius: btn.borderRadius } : null,
card: card ? { bg: card.backgroundColor, border: card.borderColor, radius: card.borderRadius, shadow: card.boxShadow } : null
},
typography: {
bodyFont: body?.fontFamily,
bodySize: body?.fontSize,
h1Font: h1?.fontFamily,
h1Size: h1?.fontSize,
h1Weight: h1?.fontWeight,
lineHeight: body?.lineHeight
},
spacing: {
bodyPadding: body?.padding,
sectionPadding: get('section')?.padding
},
customProperties: props
};
}
```
**Output:** Save as `teardown/{domain}/tokens.json`.
## Phase 4 — Fetch CSS & JS Sources
### 4a. Collect resource URLs
```javascript
() => {
const css = Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map(l => l.href);
const js = Array.from(document.querySelectorAll('script[src]')).map(s => s.src);
const inlineStyles = document.querySelectorAll('style').length;
return { css, js, inlineStyleBlocks: inlineStyles };
}
```
### 4b. Fetch each CSS file via WebFetch
For each CSS URL: `WebFetch` with prompt:
> "Extract ALL design-relevant CSS from this stylesheet: custom properties (--vars), @keyframes, @font-face, color values, gradient definitions, backdrop-filter, box-shadow patterns, border-radius values, transition/animation properties. Return as structured list."
### 4c. Detect JS libraries
```javascript
() => ({
gsap: typeof gsap !== 'undefined',
ScrollTrigger: typeof ScrollTrigger !== 'undefined',
lenis: !!document.querySelector('[data-lenis-prevent]') || typeof Lenis !== 'undefined',
framerMotion: !!document.querySelector('[data-framer-component-type]'),
three: typeof THREE !== 'undefined',
curtains: typeof Curtains !== 'undefined',
particles: typeof tsParticles !== 'undefined',
aos: typeof AOS !== 'undefined',
locomotive: !!document.querySelector('[data-scroll-container]'),
swiper: typeof Swiper !== 'undefined',
tailwind: !!document.querySelector('[class*="bg-"], [class*="text-"], [class*="flex"]'),
react: typeof __NEXT_DATA__ !== 'undefined' || !!document.getElementById('__next'),
astro: !!document.querySelector('[data-astro-source-file]'),
vue: !!document.getElementById('__nuxt') || !!document.querySelector('[data-v-]')
})
```
### 4d. Network analysis (supplementary)
`browser_network_requests` with `filter: "\\.css$|\\.js$"`, `static: false` — cross-reference with DOM-extracted URLs.
## Phase 5 — Animation Catalog
```javascript
() => {
const anims = [];
const allEls = document.querySelectorAll('*');
const seen = new Set();
allEls.forEach(el => {
const s = getComputedStyle(el);
if (s.animationName && s.animationName !== 'none' && !seen.has(s.animationName)) {
seen.add(s.animationName);
anims.push({ type: 'css-animation', name: s.animationName, duration: s.animationDuration });
}
if (s.transition && s.transition !== 'all 0s ease 0s' && s.transition !== 'none') {
const key = s.transition.slice(0, 60);
if (!seen.has(key)) { seen.add(key); anims.push({ type: 'transition', value: s.transition.slice(0, 120) }); }
}
});
const canvases = document.querySelectorAll('canvas').length;
const videos = document.querySelectorAll('video').length;
const svgAnims = document.querySelectorAll('animate, animateTransform').length;
return { animations: anims, canvasCount: canvases, videoCount: videos, svgAnimations: svgAnims };
}
```
**Output:** Save analysis as `teardown/{domain}/animations.md`.
If `depth=quick` → STOP here with tokens + screenshots only.
## Phase 6 — Compile Recipe
Assemble `teardown/{domain}/recipe.md`:
```markdown
# Site Teardown: {domain}
Date: {date}
## Layout Structure
{section map from Phase 2}
## Design Tokens
{from Phase 3 — colors, typography, spacing}
## Tech Stack
- Framework: {React/Next/Astro/Vue from Phase 4c}
- CSS: {Tailwind/custom/styled-components}
- Animation: {GSAP/Framer Motion/CSS/AOS from Phase 4c}
- Scroll: {Lenis/Locomotive/native from Phase 4c}
- 3D/WebGL: {Three.js/curtains.js/none from Phase 4c}
## Animation Techniques
{catalog from Phase 5}
## Reproduction Steps
1. Set up {framework} project with {css approach}
2. Apply design tokens: {token summary}
3. Implement layout: {section sequence}
4. Add animations: {technique list with skill references}
5. Optimize: /web-assets → /a11y-audit → /perf-audit
## Recommended Skills
- /frontend-design archetype={suggested}
- /scroll-animation technique={if GSAP detected}
- /web-effects effect={if WebGL detected}
- /motion-design {if Framer Motion detected}
```
## Chaining
| Direction | Skill | How |
|-----------|-------|-----|
| FROM | `/design-inspiration` | User picks best reference → teardown |
| FROM | `/competitor-analysis` | Deep-dive competitor's site |
| TO | `/frontend-design` | Feed tokens → suggest archetype |
| TO | `/landing-page` | Use recipe as template |
| TO | `/design-system` | Generate token system from extracted tokens |
| TO | `/scroll-animation` | Reproduce detected scroll effects |
| TO | `/web-effects` | Reproduce detected WebGL/particle effects |

View file

@ -0,0 +1,66 @@
---
name: ui-component
description: Use when building a UI component — API design, variants, accessibility, animations, tests
arguments:
- name: component
description: Component name and description
required: true
- name: framework
description: "Framework: react, next, astro, svelte, vue (auto-detect if omitted)"
required: false
---
# UI Component Workflow
## Step 1: Research
- Check if component exists in project already (Glob/Grep)
- Check existing component library for similar components
- Review design system tokens if available
- Identify the component's role and variations needed
## Step 2: API Design (Props First)
Define before implementing:
```
interface ComponentProps {
// Required props
// Optional props with defaults
// Event handlers
// Composition slots (children, render props)
// Style overrides (className, style)
}
```
- Keep API minimal — only props that are actually needed
- Use discriminated unions for variant props
- Sensible defaults for all optional props
## Step 3: Implementation
- Follow project's component patterns exactly
- Compose from existing primitives when possible
- Variants via props, not separate components
### Accessibility
- Semantic HTML elements
- ARIA attributes where needed
- Keyboard navigation (Tab, Enter, Escape, Arrow keys)
- Focus management and visible focus styles
- Screen reader announcements for dynamic content
- Color contrast WCAG AA (4.5:1 text, 3:1 large/UI)
### Animations
- Use CSS transitions/animations over JS when possible
- Respect `prefers-reduced-motion`
- Consistent timing from design system tokens
- Enter/exit animations for conditional rendering
## Step 4: Tests
- Render test (mounts without error)
- Props test (each variant renders correctly)
- Interaction test (click, hover, keyboard)
- Accessibility test (axe-core or similar)
## Step 5: Examples
- Default usage
- All variants
- With different content lengths
- Responsive behavior
- Dark mode

110
skills/web-assets/SKILL.md Normal file
View file

@ -0,0 +1,110 @@
---
name: web-assets
description: Use when optimizing images, fonts, and video for web — AVIF pipeline, responsive srcset, font subsetting, video codec selection, Sharp.js processing. Triggers on "optimize images", "web assets", "image pipeline", "font optimization".
arguments:
- name: command
description: "Command: optimize, picture, fonts, video, audit, pipeline"
required: false
- name: target
description: Directory or file path to process
required: false
---
# Image & Asset Optimization Pipeline
Optimize images, fonts, and video for premium web performance.
## Decision Matrix
| Asset | Tool | Format | Quality |
|-------|------|--------|---------|
| Photos | Sharp.js | AVIF primary, WebP fallback | avif:50, webp:75, jpg:80 |
| Icons | SVG sprite | `<symbol>` + `<use>` | N/A |
| Fonts | glyphhanger | WOFF2 only, subset | variable font preferred |
| Video | FFmpeg | AV1 > H.265 > H.264 | CRF 28-32 |
| AI-generated images | External generator (e.g. fal.ai) + Sharp | Process through Sharp | per above |
## Image Pipeline (Sharp.js)
```bash
npm ls sharp 2>/dev/null || npm install sharp
```
Breakpoints: 400, 640, 768, 1024, 1280, 1920px. Max 2560px for Retina.
```javascript
const sharp = require('sharp');
const WIDTHS = [400, 640, 768, 1024, 1280, 1920];
const FORMATS = ['avif', 'webp', 'jpg'];
const QUALITY = { avif: 50, webp: 75, jpg: 80 };
async function processImage(inputPath, outputDir) {
const name = path.parse(inputPath).name;
fs.mkdirSync(outputDir, { recursive: true });
for (const width of WIDTHS) {
for (const format of FORMATS) {
await sharp(inputPath)
.resize(width, null, { withoutEnlargement: true })
.toFormat(format, { quality: QUALITY[format] })
.toFile(path.join(outputDir, `${name}-${width}.${format}`));
}
}
}
```
### Picture Element
```html
<picture>
<source type="image/avif"
srcset="img/hero-400.avif 400w, img/hero-768.avif 768w, img/hero-1280.avif 1280w, img/hero-1920.avif 1920w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 60vw" />
<source type="image/webp"
srcset="img/hero-400.webp 400w, img/hero-768.webp 768w, img/hero-1280.webp 1280w, img/hero-1920.webp 1920w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 60vw" />
<img src="img/hero-1280.jpg" alt="Descriptive alt text"
width="1280" height="720" loading="lazy" decoding="async" />
</picture>
```
Hero/LCP image: `fetchpriority="high"`, NO `loading="lazy"`.
## Font Optimization
- Variable fonts = industry standard. WOFF2 only (97%+ support)
- Subset with glyphhanger: `glyphhanger --US_ASCII --subset=font.woff2 --formats=woff2` (60%+ reduction)
- `font-display: swap` + preload critical: `<link rel="preload" href="/fonts/heading.woff2" as="font" type="font/woff2" crossorigin />`
## SVG Sprites
```html
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-arrow" viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></symbol>
</svg>
<svg class="icon" aria-hidden="true"><use href="/sprites.svg#icon-arrow"/></svg>
```
## Video
AV1 primary (30-50% better than H.264), H.265 fallback, H.264 universal. Always set poster, width/height.
```html
<video autoplay muted loop playsinline poster="hero-poster.avif" preload="none" width="1920" height="1080">
<source src="hero.av1.mp4" type='video/mp4; codecs="av01.0.08M.08"' />
<source src="hero.h265.mp4" type='video/mp4; codecs="hvc1"' />
<source src="hero.h264.mp4" type="video/mp4" />
</video>
```
Lazy load via IntersectionObserver (no native `loading="lazy"` for `<video>`).
## Audit Checklist
- [ ] All images: AVIF + WebP + fallback, responsive srcset
- [ ] All `<img>`: explicit width/height (prevents CLS)
- [ ] Hero/LCP: `fetchpriority="high"`, no lazy loading
- [ ] Below-fold: `loading="lazy" decoding="async"`
- [ ] Fonts: WOFF2, subsetted, font-display: swap, critical preloaded
- [ ] Icons: SVG sprites (not individual files or icon fonts)
- [ ] Video: AV1 > H.265 > H.264 cascade, poster image
- [ ] No images >500KB, total page <1.5MB ideal

101
skills/web-deploy/SKILL.md Normal file
View file

@ -0,0 +1,101 @@
---
name: web-deploy
description: Use when deploying websites — Cloudflare Pages, Vercel, edge functions, caching strategy, Core Web Vitals, CI/CD pipeline, DNS setup. Triggers on "deploy", "hosting", "cloudflare pages", "web vitals", "caching strategy".
arguments:
- name: command
description: "Command: init, deploy, perf, cache, dns, ci, compare"
required: false
- name: framework
description: "Framework: astro, next, sveltekit, react-router (auto-detect if omitted)"
required: false
---
# Web Deployment & Performance
Default target: Cloudflare Pages. Default framework: Astro 6.
## Platform Decision
| Platform | Free Tier | Pro Price | Best For |
|----------|-----------|-----------|----------|
| **Cloudflare Pages** | Unlimited BW, 500 builds/mo | $5/mo | Content sites, marketing (DEFAULT) |
| Vercel | 100GB BW, 100 deploys/day | $20/user/mo | Next.js full-stack apps |
| Netlify | 100GB BW, 300 build min | $19/user/mo | Static + built-in forms |
Cloudflare ecosystem: Workers, D1, R2, KV, Turnstile, Analytics — all free tier.
## Framework Decision
| Framework | Zero JS | Islands | Best For |
|-----------|---------|---------|----------|
| **Astro 6** | Yes | Yes | Content/marketing (DEFAULT) |
| Next.js 16 | No | No | Full-stack React apps |
| SvelteKit | Compiles | No | Animation-heavy, mobile-first |
Astro 6 static output: typical LCP <500ms on CF Pages.
## CDN Caching Strategy
| Asset Type | Cache-Control |
|-----------|---------------|
| Hashed JS/CSS/fonts | `public, max-age=31536000, immutable` |
| HTML pages | `public, max-age=0, s-maxage=3600, stale-while-revalidate=86400` |
| API/dynamic | `public, s-maxage=60, stale-while-revalidate=300` |
| Images | `public, max-age=86400, s-maxage=604800` |
## Core Web Vitals
| Metric | Good | Key Fix |
|--------|------|---------|
| LCP | <2.5s | Preload hero: `fetchpriority="high"`, inline critical CSS, preload fonts |
| INP | <200ms | Break tasks >50ms, `requestIdleCallback`, defer 3rd-party |
| CLS | <0.1 | Width/height on all images/video, `aspect-ratio`, font size-adjust |
## GitHub Actions CI/CD
```yaml
name: Deploy
on: { push: { branches: [main] } }
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci && npm run build && npm test
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=my-site
```
Secrets: `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`.
## Cloudflare DNS + SSL
1. Add domain, change nameservers
2. `A @ <ip> Proxied` + `CNAME www @ Proxied`
3. SSL: Full (Strict), Always HTTPS, HSTS
4. www→apex redirect rule (301)
## Edge Functions
| Feature | CF Workers | Vercel Edge |
|---------|-----------|-------------|
| Locations | 330+ | 30+ |
| Cold start | <1ms | <50ms |
| Free | 100K req/day | 1M/month |
## Deploy Checklist
- [ ] Build succeeds, tests pass
- [ ] Lighthouse Performance >90
- [ ] Core Web Vitals green
- [ ] Caching headers per asset type
- [ ] SSL/HTTPS enforced
- [ ] www/apex redirect
- [ ] Error pages (404, 500) configured
- [ ] Security headers: CSP, X-Frame-Options, Referrer-Policy
- [ ] Environment variables in dashboard

315
skills/web-effects/SKILL.md Normal file
View file

@ -0,0 +1,315 @@
---
name: web-effects
description: Use when building visual web effects — WebGL shaders, image distortion, particles, noise/grain, hover effects, displacement maps. Covers curtains.js, OGL, tsParticles, custom WebGL, and CSS-only effects.
arguments:
- name: effect
description: "Effect: distortion, particles, noise, hover, displacement, gradient, blur (auto-detect if omitted)"
required: false
- name: approach
description: "Approach: css-only, webgl, canvas, library (auto-detect by complexity)"
required: false
---
# Web Effects Skill
## Decision Matrix — Pick Approach
| Effect | CSS Only | Canvas 2D | WebGL (library) | Custom WebGL |
|--------|----------|-----------|-----------------|--------------|
| Image hover distortion | No | No | curtains.js | Possible |
| Particles (decorative) | Limited | Possible | tsParticles | Best perf |
| Noise/grain overlay | Yes | Yes | Shader | Overkill |
| Gradient animation | Yes | Possible | Unnecessary | No |
| Blur/glassmorphism | Yes | No | No | No |
| Displacement on scroll | No | No | curtains.js/OGL | Possible |
| Liquid/fluid effects | No | No | OGL | Yes |
| Image reveal/transition | CSS clip-path | Canvas | curtains.js | Possible |
**Rule:** Start with CSS. Escalate to Canvas/WebGL only when CSS cannot achieve the effect.
---
## 1. Curtains.js — DOM-Driven WebGL
**Bundle:** ~30KB min+gzip
**What it does:** Converts HTML images/videos/canvases into WebGL textured planes that stay positioned with DOM layout.
**Best for:** Image hover distortion, displacement effects, WebGL transitions between slides.
```js
import { Curtains, Plane } from "curtainsjs";
const curtains = new Curtains({ container: "#canvas" });
const plane = new Plane(curtains, document.querySelector(".image-wrapper"), {
vertexShader: vertexShaderSource,
fragmentShader: fragmentShaderSource,
uniforms: {
uMouse: { name: "uMouse", type: "2f", value: [0, 0] },
uTime: { name: "uTime", type: "1f", value: 0 },
}
});
plane.onRender(() => { plane.uniforms.uTime.value++; });
document.querySelector(".image-wrapper").addEventListener("mousemove", (e) => {
const rect = e.target.getBoundingClientRect();
plane.uniforms.uMouse.value = [
(e.clientX - rect.left) / rect.width,
1 - (e.clientY - rect.top) / rect.height
];
});
```
### Displacement Shader (Hover Distortion)
```glsl
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D uSampler0;
uniform sampler2D uDisplacement;
uniform vec2 uMouse;
void main() {
vec2 uv = vTextureCoord;
vec4 disp = texture2D(uDisplacement, uv);
float dist = distance(uv, uMouse);
float strength = smoothstep(0.3, 0.0, dist) * 0.05;
uv += disp.rg * strength;
gl_FragColor = texture2D(uSampler0, uv);
}
```
**Note:** `gpu-curtains` is a WebGPU successor worth watching.
---
## 2. OGL — Minimal WebGL
**Bundle:** ~8KB gzip, zero dependencies
**What it does:** Thin WebGL abstraction, you write your own shaders.
**Best for:** Custom shader effects, fullscreen post-processing, when curtains.js is too opinionated.
```js
import { Renderer, Camera, Program, Mesh, Plane } from "ogl";
const renderer = new Renderer();
const gl = renderer.gl;
document.body.appendChild(gl.canvas);
const camera = new Camera(gl);
camera.position.z = 1;
const geometry = new Plane(gl);
const program = new Program(gl, {
vertex: `
attribute vec3 position;
attribute vec2 uv;
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position, 1.0); }
`,
fragment: `
precision highp float;
varying vec2 vUv;
uniform float uTime;
void main() {
gl_FragColor = vec4(vec3(sin(uTime + vUv.x * 6.28) * 0.5 + 0.5), 1.0);
}
`,
uniforms: { uTime: { value: 0 } }
});
const mesh = new Mesh(gl, { geometry, program });
function update(t) {
requestAnimationFrame(update);
program.uniforms.uTime.value = t * 0.001;
renderer.render({ scene: mesh, camera });
}
requestAnimationFrame(update);
```
**OGL vs Three.js:** OGL is 8KB vs Three.js ~150KB. Use OGL for shader effects where you do not need a scene graph, models, or physics.
---
## 3. Particles
### tsParticles (Library)
**Install:** `npm i tsparticles`
**Bundle:** ~40KB min+gzip (core), modular
**Frameworks:** React, Vue, Svelte, Angular, Solid, vanilla
```jsx
import Particles from "@tsparticles/react";
import { loadSlim } from "@tsparticles/slim";
function Background() {
const init = useCallback(async (engine) => { await loadSlim(engine); }, []);
return (
<Particles
init={init}
options={{
particles: {
number: { value: 50 },
size: { value: { min: 1, max: 3 } },
move: { enable: true, speed: 0.5 },
opacity: { value: { min: 0.1, max: 0.5 } },
links: { enable: true, distance: 150, opacity: 0.2 },
},
detectRetina: true,
}}
/>
);
}
```
### Custom WebGL Particles (Performance-Critical)
When you need 10K+ particles at 60fps, do everything in shaders:
```glsl
attribute vec3 position;
attribute vec2 velocity;
attribute float life;
uniform float uTime;
uniform float uDelta;
void main() {
vec3 pos = position + vec3(velocity * uDelta, 0.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = mix(3.0, 0.0, life);
}
```
**Decision:** tsParticles for <1000 particles with config flexibility. Custom WebGL for >1000 particles or specific visual needs.
---
## 4. CSS-Only Effects
### Animated Gradient
```css
.gradient-bg {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
background-size: 400% 400%;
animation: gradient-shift 15s ease infinite;
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
```
### Noise/Grain Overlay (CSS)
```css
.grain::after {
content: "";
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,...");
opacity: 0.05;
pointer-events: none;
z-index: 9999;
mix-blend-mode: overlay;
}
```
### Glassmorphism
```css
.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px) saturate(150%);
-webkit-backdrop-filter: blur(12px) saturate(150%);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
}
```
### Image Reveal (Clip-Path)
```css
.reveal {
clip-path: inset(0 100% 0 0);
transition: clip-path 0.8s cubic-bezier(0.77, 0, 0.175, 1);
}
.reveal.visible { clip-path: inset(0 0 0 0); }
```
### Hover Magnetic Effect (JS Required)
```js
const btn = document.querySelector(".magnetic-btn");
btn.addEventListener("mousemove", (e) => {
const rect = btn.getBoundingClientRect();
const x = e.clientX - rect.left - rect.width / 2;
const y = e.clientY - rect.top - rect.height / 2;
btn.style.transform = `translate(${x * 0.3}px, ${y * 0.3}px)`;
});
btn.addEventListener("mouseleave", () => {
btn.style.transform = "translate(0, 0)";
btn.style.transition = "transform 0.5s ease";
});
```
---
## 5. Performance Rules
### GPU-Composited Properties (animate these)
```
transform — translate, rotate, scale
opacity — fade in/out
filter — blur, brightness
clip-path — reveal/hide
```
### Layout-Triggering Properties (avoid animating)
```
width, height, top, left, right, bottom
margin, padding, border-width
font-size, line-height
```
### will-change
```css
.about-to-animate { will-change: transform, opacity; }
/* Do NOT: * { will-change: transform; } */
```
### Frame Budget
- **60fps target:** 16.66ms per frame
- **Pause offscreen:** IntersectionObserver to stop animations outside viewport
```js
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) startRenderLoop();
else stopRenderLoop();
});
observer.observe(canvasElement);
```
---
## Workflow
1. **Define the effect** — what visual result is needed?
2. **Try CSS first** — gradient, blur, clip-path, mix-blend-mode
3. **Escalate to Canvas/WebGL** — only if CSS cannot achieve it
4. **Pick library** — curtains.js for DOM-synced, OGL for custom shaders
5. **Write shader** — keep fragment shaders simple, profile on mobile
6. **Add IntersectionObserver** — pause offscreen effects
7. **Test performance** — Chrome DevTools Performance, GPU memory
8. **Add prefers-reduced-motion** — disable or simplify effects