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:
commit
c89352c87c
46 changed files with 6907 additions and 7 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
_primitives/_rust/target/
|
||||
**/target/
|
||||
.DS_Store
|
||||
36
_blocks/stack-astro.md
Normal file
36
_blocks/stack-astro.md
Normal 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).
|
||||
26
_blocks/stack-react-vite.md
Normal file
26
_blocks/stack-react-vite.md
Normal 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).
|
||||
34
_blocks/stack-sveltekit.md
Normal file
34
_blocks/stack-sveltekit.md
Normal 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
32
_blocks/stack-tailwind.md
Normal 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.
|
||||
2141
_primitives/_rust/Cargo.lock
generated
2141
_primitives/_rust/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
17
_primitives/_rust/mock-render/Cargo.toml
Normal file
17
_primitives/_rust/mock-render/Cargo.toml
Normal 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"
|
||||
48
_primitives/_rust/mock-render/src/hash.rs
Normal file
48
_primitives/_rust/mock-render/src/hash.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
226
_primitives/_rust/mock-render/src/main.rs
Normal file
226
_primitives/_rust/mock-render/src/main.rs
Normal 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(§ion) 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(§ion);
|
||||
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(§ion);
|
||||
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(§ion) 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))
|
||||
}
|
||||
49
_primitives/_rust/mock-render/src/render.rs
Normal file
49
_primitives/_rust/mock-render/src/render.rs
Normal 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(())
|
||||
}
|
||||
88
_primitives/_rust/mock-render/src/state.rs
Normal file
88
_primitives/_rust/mock-render/src/state.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
16
_primitives/_rust/tokens-sync/Cargo.toml
Normal file
16
_primitives/_rust/tokens-sync/Cargo.toml
Normal 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"
|
||||
127
_primitives/_rust/tokens-sync/src/emit.rs
Normal file
127
_primitives/_rust/tokens-sync/src/emit.rs
Normal 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('}'));
|
||||
}
|
||||
}
|
||||
85
_primitives/_rust/tokens-sync/src/main.rs
Normal file
85
_primitives/_rust/tokens-sync/src/main.rs
Normal 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())
|
||||
}
|
||||
71
_primitives/_rust/tokens-sync/src/parse.rs
Normal file
71
_primitives/_rust/tokens-sync/src/parse.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
15
_primitives/_rust/visual-diff/Cargo.toml
Normal file
15
_primitives/_rust/visual-diff/Cargo.toml
Normal 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"
|
||||
136
_primitives/_rust/visual-diff/src/diff.rs
Normal file
136
_primitives/_rust/visual-diff/src/diff.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
83
_primitives/_rust/visual-diff/src/main.rs
Normal file
83
_primitives/_rust/visual-diff/src/main.rs
Normal 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
111
_primitives/design-scrape.sh
Executable 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
73
_primitives/figma-tokens.sh
Executable 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
109
_primitives/frontend-inspect.sh
Executable 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
102
_primitives/live-preview.sh
Executable 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
|
||||
63
_primitives/screenshot-decode.sh
Executable file
63
_primitives/screenshot-decode.sh
Executable 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
102
skills/a11y-audit/SKILL.md
Normal 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)
|
||||
61
skills/design-system/SKILL.md
Normal file
61
skills/design-system/SKILL.md
Normal 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)
|
||||
55
skills/figma-to-code/SKILL.md
Normal file
55
skills/figma-to-code/SKILL.md
Normal 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`
|
||||
111
skills/form-builder/SKILL.md
Normal file
111
skills/form-builder/SKILL.md
Normal 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
|
||||
144
skills/frontend-design/SKILL.md
Normal file
144
skills/frontend-design/SKILL.md
Normal 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
|
||||
188
skills/landing-page/SKILL.md
Normal file
188
skills/landing-page/SKILL.md
Normal 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
|
||||
347
skills/motion-design/SKILL.md
Normal file
347
skills/motion-design/SKILL.md
Normal 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
|
||||
51
skills/perf-audit/SKILL.md
Normal file
51
skills/perf-audit/SKILL.md
Normal 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>`
|
||||
65
skills/responsive-audit/SKILL.md
Normal file
65
skills/responsive-audit/SKILL.md
Normal 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
|
||||
304
skills/scroll-animation/SKILL.md
Normal file
304
skills/scroll-animation/SKILL.md
Normal 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
49
skills/seo-audit/SKILL.md
Normal 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
|
||||
330
skills/site-builder/SKILL.md
Normal file
330
skills/site-builder/SKILL.md
Normal 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
131
skills/site-create/SKILL.md
Normal 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
|
||||
84
skills/site-create/phase-0-intake.md
Normal file
84
skills/site-create/phase-0-intake.md
Normal 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.
|
||||
94
skills/site-create/phase-1-design.md
Normal file
94
skills/site-create/phase-1-design.md
Normal 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.
|
||||
68
skills/site-create/phase-2-sections.md
Normal file
68
skills/site-create/phase-2-sections.md
Normal 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.
|
||||
133
skills/site-create/phase-3-wysiwyd.md
Normal file
133
skills/site-create/phase-3-wysiwyd.md
Normal 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.
|
||||
84
skills/site-create/phase-4-audit.md
Normal file
84
skills/site-create/phase-4-audit.md
Normal 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.
|
||||
230
skills/site-teardown/SKILL.md
Normal file
230
skills/site-teardown/SKILL.md
Normal 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 |
|
||||
66
skills/ui-component/SKILL.md
Normal file
66
skills/ui-component/SKILL.md
Normal 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
110
skills/web-assets/SKILL.md
Normal 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
101
skills/web-deploy/SKILL.md
Normal 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
315
skills/web-effects/SKILL.md
Normal 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
|
||||
Loading…
Reference in a new issue